mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
Fabro's own DOT parser was left with one job after create-time compile moved to Petri: walking a workflow's file references for the bundler and the workflow-version store, and reading a name, a goal and two counts. Petri's frontend parses the same language, so the parser goes and a small crate reads the graph through Petri's. `fabro-dot` is that crate: `WorkflowGraph::parse` over `petri_frontend_attractor::dot` and its semantic model (defaults applied, subgraphs flattened, chains expanded), `references(position)` as the one walker over the static-reference vocabulary (each reference with its node and position, file references checked to be template-free), and `normalize_for_graphviz`, the re-emit of Fabro DOT with dotted attribute keys quoted, which the SVG render needs. It sits beside `fabro-petri` rather than inside it because `fabro-petri` depends on `fabro-workflow`, which depends on `fabro-workflow-version`: the version store cannot reach `fabro-petri` without a cycle, and the bundler should not pull the engine in to read a graph. Deleted: `fabro-graphviz`'s lexer, grammar, AST, semantic pass and `parse_ast` (1,829 lines, plus the `nom` dependency); the DOT model in `fabro-types::graph` (`Graph`, `Node`, `Edge`, `AttrValue`, `shape_to_handler_type`), with only `ReferenceKind` kept, moved to `fabro_types::reference`; `fabro-template`'s `visit_graph_references` and the `GraphReference`/`GraphPosition` types, with the template-syntax rule (`validate_static_reference`) kept there; the pull-request body's DOT fallback summary, which was unreachable because the DOT source only travels with the run spec whose display graph the summary already reads. `fabro-graphviz` is now the render alone, over `fabro-dot`. Parity: the old and new walkers were run over every `.fabro` and `.dot` file in the repository (118) before the deletion. Every reference set is identical. Five files differ in what Petri reads more correctly: a backslash before a newline inside a quoted string is a line continuation (four files, inline prompt text only), and a node named only by an edge counts as a node (`test/edge_only_node.fabro`, 3 nodes rather than 2, so the `fabro validate` snapshot moves). The checked-in bundles' shapes and references are pinned by a snapshot in `fabro-dot`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
231 lines
6.5 KiB
Rust
231 lines
6.5 KiB
Rust
use fabro_types::diagnostic::Diagnostic;
|
|
use fabro_util::error::{SharedError, collect_chain, render_with_causes};
|
|
use thiserror::Error as ThisError;
|
|
|
|
#[derive(ThisError, Debug, Clone)]
|
|
pub enum Error {
|
|
#[error("Parse error: {0}")]
|
|
Parse(String),
|
|
|
|
#[error("Validation error: {0}")]
|
|
Validation(String),
|
|
|
|
#[error("Validation failed")]
|
|
ValidationFailed { diagnostics: Vec<Diagnostic> },
|
|
|
|
/// Fabro's own platform work around a run failed: a store call, a
|
|
/// serialization, a spawned task, a Git command.
|
|
#[error("Engine error: {message}")]
|
|
Engine {
|
|
message: String,
|
|
#[source]
|
|
source: Option<SharedError>,
|
|
},
|
|
|
|
#[error("I/O error: {0}")]
|
|
Io(String),
|
|
|
|
#[error("Precondition failed: {0}")]
|
|
Precondition(String),
|
|
|
|
#[error("Run not found: {0}")]
|
|
RunNotFound(String),
|
|
|
|
#[error("Pipeline cancelled")]
|
|
Cancelled,
|
|
}
|
|
|
|
impl Error {
|
|
pub fn engine(message: impl Into<String>) -> Self {
|
|
Self::Engine {
|
|
message: message.into(),
|
|
source: None,
|
|
}
|
|
}
|
|
|
|
pub fn engine_with_source(
|
|
message: impl Into<String>,
|
|
source: impl Into<anyhow::Error>,
|
|
) -> Self {
|
|
Self::Engine {
|
|
message: message.into(),
|
|
source: Some(SharedError::new(source.into())),
|
|
}
|
|
}
|
|
|
|
pub fn engine_with_anyhow(message: impl Into<String>, source: anyhow::Error) -> Self {
|
|
Self::engine_with_source(message, source)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn causes(&self) -> Vec<String> {
|
|
match self {
|
|
Self::Engine { source, .. } => source
|
|
.as_ref()
|
|
.map_or_else(Vec::new, |source| collect_chain(source)),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn display_with_causes(&self) -> String {
|
|
render_with_causes(&self.to_string(), &self.causes())
|
|
}
|
|
}
|
|
|
|
impl From<std::io::Error> for Error {
|
|
fn from(err: std::io::Error) -> Self {
|
|
Self::Io(err.to_string())
|
|
}
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[derive(Debug)]
|
|
struct TestCause(&'static str);
|
|
|
|
impl std::fmt::Display for TestCause {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.0)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TestCause {}
|
|
|
|
#[derive(Debug)]
|
|
struct TestOuterError {
|
|
message: &'static str,
|
|
source: TestCause,
|
|
}
|
|
|
|
impl std::fmt::Display for TestOuterError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.message)
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TestOuterError {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
Some(&self.source)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_error_display() {
|
|
let err = Error::Parse("unexpected token".to_string());
|
|
assert_eq!(err.to_string(), "Parse error: unexpected token");
|
|
}
|
|
|
|
#[test]
|
|
fn validation_error_display() {
|
|
let err = Error::Validation("missing start node".to_string());
|
|
assert_eq!(err.to_string(), "Validation error: missing start node");
|
|
}
|
|
|
|
#[test]
|
|
fn validation_failed_display() {
|
|
let err = Error::ValidationFailed {
|
|
diagnostics: vec![Diagnostic {
|
|
rule: "test".to_string(),
|
|
severity: fabro_types::diagnostic::Severity::Error,
|
|
message: "missing start node".to_string(),
|
|
node_id: None,
|
|
edge: None,
|
|
fix: None,
|
|
|
|
..Diagnostic::default()
|
|
}],
|
|
};
|
|
assert_eq!(err.to_string(), "Validation failed");
|
|
}
|
|
|
|
#[test]
|
|
fn engine_error_display() {
|
|
let err = Error::engine("no outgoing edge");
|
|
assert_eq!(err.to_string(), "Engine error: no outgoing edge");
|
|
}
|
|
|
|
#[test]
|
|
fn engine_error_with_source_preserves_cause_chain() {
|
|
let source = TestOuterError {
|
|
message: "Failed to pull Docker image buildpack-deps:noble",
|
|
source: TestCause("connection refused"),
|
|
};
|
|
let err = Error::engine_with_source("Failed to initialize sandbox", source);
|
|
|
|
assert_eq!(
|
|
err.to_string(),
|
|
"Engine error: Failed to initialize sandbox"
|
|
);
|
|
assert_eq!(err.causes(), vec![
|
|
"Failed to pull Docker image buildpack-deps:noble".to_string(),
|
|
"connection refused".to_string(),
|
|
]);
|
|
assert_eq!(
|
|
err.display_with_causes(),
|
|
"Engine error: Failed to initialize sandbox\n caused by: Failed to pull Docker image buildpack-deps:noble\n caused by: connection refused"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn io_error_display() {
|
|
let err = Error::Io("permission denied".to_string());
|
|
assert_eq!(err.to_string(), "I/O error: permission denied");
|
|
}
|
|
|
|
#[test]
|
|
fn io_error_from_std() {
|
|
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
|
|
let err = Error::from(io_err);
|
|
assert!(matches!(err, Error::Io(_)));
|
|
assert!(err.to_string().contains("not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn result_type_alias_works() {
|
|
let ok: Result<i32> = Ok(42);
|
|
assert!(ok.is_ok());
|
|
|
|
let err: Result<i32> = Err(Error::Parse("bad".to_string()));
|
|
assert!(err.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_error_display() {
|
|
let err = Error::Cancelled;
|
|
assert_eq!(err.to_string(), "Pipeline cancelled");
|
|
}
|
|
|
|
#[test]
|
|
fn error_clone_preserves_display_for_all_variants() {
|
|
let errors: Vec<Error> = vec![
|
|
Error::Parse("bad".into()),
|
|
Error::Validation("bad".into()),
|
|
Error::ValidationFailed {
|
|
diagnostics: vec![Diagnostic {
|
|
rule: "test".into(),
|
|
severity: fabro_types::diagnostic::Severity::Error,
|
|
message: "bad".into(),
|
|
node_id: None,
|
|
edge: None,
|
|
fix: None,
|
|
|
|
..Diagnostic::default()
|
|
}],
|
|
},
|
|
Error::engine("engine err"),
|
|
Error::engine_with_source("engine err", TestCause("cause")),
|
|
Error::Io("io err".into()),
|
|
Error::Precondition("precondition".into()),
|
|
Error::RunNotFound("run".into()),
|
|
Error::Cancelled,
|
|
];
|
|
for err in errors {
|
|
assert_eq!(err.to_string(), err.clone().to_string());
|
|
}
|
|
}
|
|
}
|