fabro/lib/components/fabro-validate/src/lib.rs

467 lines
14 KiB
Rust

pub mod rules;
use fabro_graphviz::graph::Graph;
use fabro_model::Catalog;
use serde::{Deserialize, Serialize};
/// Severity level for validation diagnostics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Error,
Warning,
Info,
}
/// A validation diagnostic produced by a lint rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub rule: String,
pub severity: Severity,
pub message: String,
pub node_id: Option<String>,
pub edge: Option<(String, String)>,
pub fix: Option<String>,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
pub span_start: Option<usize>,
pub span_len: Option<usize>,
#[serde(default)]
pub related: Vec<RelatedDiagnostic>,
}
impl Default for Diagnostic {
fn default() -> Self {
Self {
rule: String::new(),
severity: Severity::Info,
message: String::new(),
node_id: None,
edge: None,
fix: None,
source_path: None,
line: None,
column: None,
span_start: None,
span_len: None,
related: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelatedDiagnostic {
pub message: String,
pub source_path: Option<String>,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// A lint rule that validates a graph.
pub trait LintRule {
fn name(&self) -> &'static str;
fn apply(&self, graph: &Graph) -> Vec<Diagnostic>;
}
/// Validation error returned when error-severity diagnostics are present.
#[derive(Debug, thiserror::Error)]
#[error("Validation error: {0}")]
pub struct ValidationError(pub String);
/// Run all built-in lint rules (and any extra rules) against the graph.
#[must_use]
pub fn validate(graph: &Graph, extra_rules: &[&dyn LintRule]) -> Vec<Diagnostic> {
let built_in = rules::built_in_rules();
let mut diagnostics = Vec::new();
for rule in &built_in {
diagnostics.extend(rule.apply(graph));
}
for rule in extra_rules {
diagnostics.extend(rule.apply(graph));
}
diagnostics
}
/// Run all built-in catalog-free lint rules, caller-supplied model catalog
/// rules, and any extra rules against the graph.
#[must_use]
pub fn validate_with_catalog(
graph: &Graph,
catalog: &Catalog,
extra_rules: &[&dyn LintRule],
) -> Vec<Diagnostic> {
let mut diagnostics = validate(graph, &[]);
let catalog_rules = rules::catalog_rules(catalog);
for rule in &catalog_rules {
diagnostics.extend(rule.apply(graph));
}
for rule in extra_rules {
diagnostics.extend(rule.apply(graph));
}
diagnostics
}
/// If any Error-severity diagnostics are present, return `ValidationError`.
///
/// # Errors
/// Returns `ValidationError` with joined error messages.
pub fn raise_on_errors(diagnostics: &[Diagnostic]) -> Result<(), ValidationError> {
let mut errors = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.peekable();
if errors.peek().is_some() {
let message = errors
.map(|d| d.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(ValidationError(message));
}
Ok(())
}
/// Run all built-in lint rules (and any extra rules). Returns Err if any
/// Error-severity diagnostics are found.
///
/// # Errors
/// Returns `ValidationError` if any Error-severity diagnostics are found.
pub fn validate_or_raise(
graph: &Graph,
extra_rules: &[&dyn LintRule],
) -> Result<Vec<Diagnostic>, ValidationError> {
let diagnostics = validate(graph, extra_rules);
raise_on_errors(&diagnostics)?;
Ok(diagnostics)
}
/// Run catalog-aware validation and return `ValidationError` if any
/// Error-severity diagnostics are found.
///
/// # Errors
/// Returns `ValidationError` if any Error-severity diagnostics are found.
pub fn validate_with_catalog_or_raise(
graph: &Graph,
catalog: &Catalog,
extra_rules: &[&dyn LintRule],
) -> Result<Vec<Diagnostic>, ValidationError> {
let diagnostics = validate_with_catalog(graph, catalog, extra_rules);
raise_on_errors(&diagnostics)?;
Ok(diagnostics)
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_graphviz::parser;
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ProviderId};
use super::*;
fn minimal_valid_graph() -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
g.edges.push(Edge::new("start", "exit"));
g
}
fn graph_with_model_and_provider(model: &str, provider: &str) -> Graph {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let mut work = Node::new("work");
work.attrs
.insert("model".to_string(), AttrValue::String(model.to_string()));
work.attrs.insert(
"provider".to_string(),
AttrValue::String(provider.to_string()),
);
g.nodes.insert("work".to_string(), work);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
g.edges.push(Edge::new("start", "work"));
g.edges.push(Edge::new("work", "exit"));
g
}
fn custom_catalog() -> Catalog {
let settings: LlmCatalogSettings = toml::from_str(
r#"
[providers.venice]
display_name = "Venice"
adapter = "openai_compatible"
agent_profile = "openai"
base_url = "https://api.venice.ai/api/v1"
[providers.venice.auth]
credentials = ["env:VENICE_API_KEY"]
[models."venice-large"]
provider = "venice"
display_name = "Venice Large"
family = "venice"
default = true
[models."venice-large".limits]
context_window = 128000
[models."venice-large".features]
tools = true
vision = false
reasoning = false
"#,
)
.unwrap();
Catalog::from_settings(&settings).unwrap()
}
#[test]
fn validate_minimal_valid_graph_has_no_errors() {
let g = minimal_valid_graph();
let diagnostics = validate(&g, &[]);
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "Expected no errors, got: {errors:?}");
}
#[test]
fn validate_or_raise_passes_for_valid_graph() {
let g = minimal_valid_graph();
let result = validate_or_raise(&g, &[]);
assert!(result.is_ok());
}
#[test]
fn validate_rejects_join_policy_on_any_node() {
let mut g = minimal_valid_graph();
let mut fork = Node::new("fork");
fork.attrs.insert(
"shape".to_string(),
AttrValue::String("component".to_string()),
);
fork.attrs.insert(
"join_policy".to_string(),
AttrValue::String("wait_all".to_string()),
);
g.nodes.insert("fork".to_string(), fork);
let mut custom = Node::new("custom");
custom.attrs.insert(
"type".to_string(),
AttrValue::String("custom.handler".to_string()),
);
custom.attrs.insert(
"join_policy".to_string(),
AttrValue::String("first_success".to_string()),
);
g.nodes.insert("custom".to_string(), custom);
let diagnostics = validate(&g, &[]);
let removed = diagnostics
.iter()
.filter(|d| d.rule == "join_policy_removed")
.collect::<Vec<_>>();
assert_eq!(removed.len(), 2, "diagnostics: {diagnostics:?}");
assert!(removed.iter().all(|d| d.severity == Severity::Error));
assert!(
removed
.iter()
.all(|d| d.message.contains("Remove 'join_policy'"))
);
assert_eq!(
removed
.iter()
.filter_map(|d| d.node_id.as_deref())
.collect::<std::collections::BTreeSet<_>>(),
std::collections::BTreeSet::from(["custom", "fork"]),
);
}
#[test]
fn validate_or_raise_fails_for_missing_start() {
let mut g = Graph::new("test");
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
g.nodes.insert("exit".to_string(), exit);
let result = validate_or_raise(&g, &[]);
assert!(result.is_err());
}
#[test]
fn validate_or_raise_fails_for_missing_exit() {
let mut g = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
g.nodes.insert("start".to_string(), start);
let result = validate_or_raise(&g, &[]);
assert!(result.is_err());
}
#[test]
fn validate_runs_extra_rules() {
struct AlwaysWarnRule;
impl LintRule for AlwaysWarnRule {
fn name(&self) -> &'static str {
"always_warn"
}
fn apply(&self, _graph: &Graph) -> Vec<Diagnostic> {
vec![Diagnostic {
rule: "always_warn".to_string(),
severity: Severity::Warning,
message: "custom warning".to_string(),
node_id: None,
edge: None,
fix: None,
..Diagnostic::default()
}]
}
}
let g = minimal_valid_graph();
let extra = AlwaysWarnRule;
let diagnostics = validate(&g, &[&extra]);
let custom: Vec<_> = diagnostics
.iter()
.filter(|d| d.rule == "always_warn")
.collect();
assert_eq!(custom.len(), 1);
}
#[test]
fn validate_does_not_run_catalog_aware_model_rules() {
let g = graph_with_model_and_provider("not-in-any-catalog", "not-a-provider");
let diagnostics = validate(&g, &[]);
assert!(
diagnostics
.iter()
.all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"),
"catalog-free validation should not emit model/provider diagnostics: {diagnostics:?}"
);
}
#[test]
fn validate_with_catalog_accepts_custom_catalog_entries() {
let g = graph_with_model_and_provider("venice-large", "venice");
let catalog = custom_catalog();
let diagnostics = validate_with_catalog(&g, &catalog, &[]);
assert!(
diagnostics
.iter()
.all(|d| d.rule != "node_model_known" && d.rule != "stylesheet_model_known"),
"custom catalog entries should validate cleanly: {diagnostics:?}"
);
}
#[test]
fn validate_with_catalog_warns_for_unknown_model_and_provider() {
let g = graph_with_model_and_provider("missing-model", "missing-provider");
let catalog = custom_catalog();
let diagnostics = validate_with_catalog(&g, &catalog, &[]);
assert!(
diagnostics
.iter()
.any(|d| d.rule == "node_model_known" && d.message.contains("missing-model")),
"missing model diagnostic not found: {diagnostics:?}"
);
assert!(
diagnostics.iter().any(|d| d.rule == "node_model_known"
&& d.message.contains("missing-provider")
&& d.message.contains(ProviderId::new("venice").as_str())),
"missing provider diagnostic not found: {diagnostics:?}"
);
}
#[test]
fn diagnostic_severity_eq() {
assert_eq!(Severity::Error, Severity::Error);
assert_ne!(Severity::Error, Severity::Warning);
assert_ne!(Severity::Warning, Severity::Info);
}
#[expect(
clippy::disallowed_methods,
reason = "unit test reads checked-in DOT compatibility fixtures synchronously"
)]
#[test]
fn dot_compatibility_corpus_validates() {
let fixtures = dot_compatibility_fixtures();
assert_eq!(
fixtures.len(),
3,
"dot compatibility corpus should stay intentionally small"
);
for fixture in fixtures {
let source = std::fs::read_to_string(&fixture)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture.display()));
let graph = parser::parse(&source)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", fixture.display()));
validate_or_raise(&graph, &[])
.unwrap_or_else(|err| panic!("failed to validate {}: {err}", fixture.display()));
}
}
#[expect(
clippy::disallowed_methods,
reason = "unit test helper enumerates checked-in DOT compatibility fixtures synchronously"
)]
fn dot_compatibility_fixtures() -> Vec<std::path::PathBuf> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../../test/dot-compatibility");
let mut fixtures = std::fs::read_dir(&dir)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display()))
.map(|entry| {
entry
.unwrap_or_else(|err| {
panic!("failed to read entry in {}: {err}", dir.display())
})
.path()
})
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("fabro"))
.collect::<Vec<_>>();
fixtures.sort();
fixtures
}
}