mod infer;
mod rules;
pub mod types;
pub use crate::spec::DesignMeta;
pub use types::{DesignRule, Finding, Severity};
use crate::spec::Spec;
use types::Severity::{Info, Warning};
pub const KNOWN_INTENTS: &[&str] = &[
"browse",
"focus",
"collect",
"process",
"summarize",
"analyze",
"track",
];
pub fn rules() -> &'static [DesignRule] {
rules::RULE_REGISTRY
}
pub fn lint(spec: &Spec) -> Vec<Finding> {
let mut findings: Vec<Finding> = Vec::new();
let design = spec.design.as_ref();
let allow: &[String] = design.map(|d| d.allow.as_slice()).unwrap_or(&[]);
let resolved: Option<&str> = match design.and_then(|d| d.intent.as_deref()) {
Some(s) if KNOWN_INTENTS.contains(&s) => {
Some(s)
}
Some(s) => {
findings.push(Finding {
rule: "declare-intent",
element_id: None,
severity: Warning,
message: format!(
"Unknown design.intent `{s}`; expected one of the seven projection intents."
),
suggestion: "Use one of: browse, focus, collect, process, summarize, analyze, track.".into(),
});
infer::infer_intent(spec)
}
None => {
let inferred = infer::infer_intent(spec);
let message = match inferred {
Some(i) => format!("No design.intent declared; inferred `{i}` from spec content."),
None => {
"No design.intent declared and none could be inferred from spec content.".into()
}
};
findings.push(Finding {
rule: "declare-intent",
element_id: None,
severity: Info,
message,
suggestion: "Add a `design.intent` field to declare the page archetype.".into(),
});
inferred
}
};
for id in allow {
let known =
rules::RULE_REGISTRY.iter().any(|r| r.id == id.as_str()) || id == "declare-intent";
if !known {
findings.push(Finding {
rule: "allow",
element_id: None,
severity: Warning,
message: format!("Unknown allow id `{id}`."),
suggestion: "Remove it or fix the typo; allow ids must match a rule id.".into(),
});
}
}
for rule in rules::RULE_REGISTRY {
if !rule.intents.is_empty() {
match resolved {
Some(i) if rule.intents.contains(&i) => {}
_ => continue,
}
}
findings.extend((rule.check)(spec, resolved));
}
findings.retain(|f| !allow.iter().any(|a| a == f.rule));
findings
}
#[cfg(test)]
mod engine_tests {
use super::*;
use crate::spec::Spec;
fn spec_with(element_type: &str) -> Spec {
if element_type == "DataTable" {
Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"r","elements":{"r":{"type":"DataTable","props":{"empty_message":"No items"}}}}"#,
)
.unwrap()
} else {
Spec::from_json(&format!(
r#"{{"$schema":"ferro-json-ui/v2","root":"r","elements":{{"r":{{"type":"{element_type}"}}}}}}"#
))
.unwrap()
}
}
fn spec_with_design(design_json: &str) -> Spec {
Spec::from_json(&format!(
r#"{{"$schema":"ferro-json-ui/v2","root":"r","elements":{{"r":{{"type":"DataTable","props":{{"empty_message":"No items"}}}}}},"design":{design_json}}}"#
))
.unwrap()
}
fn spec_with_type_and_design(element_type: &str, design_json: &str) -> Spec {
Spec::from_json(&format!(
r#"{{"$schema":"ferro-json-ui/v2","root":"r","elements":{{"r":{{"type":"{element_type}"}}}},"design":{design_json}}}"#
))
.unwrap()
}
#[test]
fn undeclared_intent_with_data_table_emits_info_browse() {
let spec = spec_with("DataTable");
let findings = lint(&spec);
assert_eq!(findings.len(), 1, "expected exactly one finding");
assert_eq!(findings[0].rule, "declare-intent");
assert_eq!(findings[0].severity, Severity::Info);
assert!(
findings[0].message.contains("browse"),
"message should mention inferred intent 'browse', got: {}",
findings[0].message
);
}
#[test]
fn undeclared_intent_no_signal_emits_info_none_inferred() {
let spec = spec_with("Text");
let findings = lint(&spec);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule, "declare-intent");
assert_eq!(findings[0].severity, Severity::Info);
assert!(
findings[0].message.contains("none could be inferred"),
"message should mention no inference, got: {}",
findings[0].message
);
}
#[test]
fn unknown_declared_intent_emits_warning() {
let spec = spec_with_design(r#"{"intent":"totally-made-up"}"#);
let findings = lint(&spec);
assert_eq!(findings.len(), 1, "expected exactly one finding");
assert_eq!(findings[0].rule, "declare-intent");
assert_eq!(findings[0].severity, Severity::Warning);
assert!(
findings[0].message.contains("totally-made-up"),
"message should include the bad intent string, got: {}",
findings[0].message
);
}
#[test]
fn unknown_allow_id_emits_warning() {
let spec = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"r",
"elements":{"r":{"type":"Form"}},
"design":{"intent":"focus","allow":["no-such-rule"]}}"#,
)
.unwrap();
let findings = lint(&spec);
assert_eq!(
findings.len(),
1,
"expected one finding for unknown allow id"
);
assert_eq!(findings[0].rule, "allow");
assert_eq!(findings[0].severity, Severity::Warning);
assert!(
findings[0].message.contains("no-such-rule"),
"message should include the bad allow id, got: {}",
findings[0].message
);
}
#[test]
fn allow_declare_intent_suppresses_info_finding() {
let spec = spec_with_type_and_design("Text", r#"{"allow":["declare-intent"]}"#);
let findings = lint(&spec);
assert!(
findings.is_empty(),
"allow:declare-intent should suppress the info finding, got: {findings:#?}"
);
}
#[test]
fn valid_declared_intent_with_data_table_zero_findings() {
let spec = Spec::from_json(
r#"{"$schema":"ferro-json-ui/v2","root":"r",
"elements":{"r":{"type":"DataTable","props":{"empty_message":"No items"}}},
"design":{"intent":"browse"}}"#,
)
.unwrap();
let findings = lint(&spec);
assert!(
findings.is_empty(),
"valid declared intent + conforming spec should produce zero findings, got: {findings:#?}"
);
}
}
#[cfg(all(test, feature = "projections"))]
mod drift_tests {
use super::KNOWN_INTENTS;
use ferro_projections::Intent;
#[test]
fn design_intents_match_projection_intent_labels() {
let projection_labels: Vec<&str> = [
Intent::Browse,
Intent::Focus,
Intent::Collect,
Intent::Process,
Intent::Summarize,
Intent::Analyze,
Intent::Track,
]
.iter()
.map(|i| i.label())
.collect();
let mut design = KNOWN_INTENTS.to_vec();
design.sort_unstable();
let mut proj = projection_labels.clone();
proj.sort_unstable();
assert_eq!(
design, proj,
"KNOWN_INTENTS in design module drifted from ferro_projections::Intent labels"
);
}
}
#[cfg(test)]
mod docs_drift_tests {
use super::rules;
#[test]
fn patterns_md_matches_rule_registry() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let path =
std::path::Path::new(&manifest_dir).join("../docs/src/design-system/patterns.md");
let content = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"patterns.md not found at {}: {e} (D-09 drift guard)",
path.display()
)
});
for rule in rules() {
assert!(
content.contains(rule.id),
"patterns.md is missing rule id `{}` — add a section for it (D-09)",
rule.id
);
}
let known: std::collections::HashSet<&str> = rules().iter().map(|r| r.id).collect();
for line in content.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("## `") {
if let Some(id) = rest.strip_suffix('`') {
assert!(
known.contains(id),
"patterns.md documents unknown rule id `{id}` — not in design::rules() (D-09)"
);
}
}
}
}
}