Skip to main content

drft/rules/
check.rs

1//! The check orchestrator: run the v0.8 rules over the composed graph, apply
2//! configured severity and per-rule ignore globs, and return the sorted
3//! findings.
4//!
5//! Built-in rules are always on at their default `warn` severity; config can
6//! promote a rule to `error`, silence it with `off`, or `ignore` subjects by
7//! glob. Staleness rules run only when a lockfile is present.
8
9use crate::config::{Config, RuleSeverity};
10use crate::diagnostic::Finding;
11use crate::lock::Lock;
12use crate::model::Graph;
13use crate::rules::{staleness, structural};
14
15/// Evaluate all v0.8 rules and apply config. Staleness rules run only with a
16/// lockfile; structural rules always run.
17pub fn run(graph: &Graph, lock: Option<&Lock>, config: &Config) -> Vec<Finding> {
18    let mut findings = Vec::new();
19    if let Some(lock) = lock {
20        findings.extend(staleness::evaluate(graph, lock));
21    }
22    findings.extend(structural::evaluate(graph));
23
24    findings.retain_mut(|finding| {
25        // Default severity is warn unless config overrides the rule.
26        let severity = config
27            .rules
28            .get(&finding.name)
29            .map(|r| r.severity)
30            .unwrap_or(RuleSeverity::Warn);
31        if severity == RuleSeverity::Off {
32            return false;
33        }
34        if config.is_rule_ignored(&finding.name, &finding.subject) {
35            return false;
36        }
37        finding.severity = severity;
38        true
39    });
40
41    findings.sort_by(|a, b| {
42        a.name
43            .cmp(&b.name)
44            .then_with(|| a.subject.cmp(&b.subject))
45            .then_with(|| a.message.cmp(&b.message))
46    });
47    findings
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::compose::compose;
54    use crate::model::{Edge, GraphSet, Node};
55    use serde_json::json;
56
57    fn fs_node() -> Node {
58        node("b3:x")
59    }
60
61    fn node(hash: &str) -> Node {
62        Node::new(
63            json!({ "type": "file", "hash": hash })
64                .as_object()
65                .unwrap()
66                .clone(),
67        )
68    }
69
70    fn composed_unresolved() -> Graph {
71        let mut fs = Graph::labeled("fs");
72        fs.set_node("index.md", fs_node());
73        fs.add_edge(Edge::new("index.md", "gone.md"));
74        compose(&GraphSet::new(vec![fs]))
75    }
76
77    #[test]
78    fn applies_default_warn_severity() {
79        let graph = composed_unresolved();
80        let findings = run(&graph, None, &Config::defaults());
81        let unresolved = findings
82            .iter()
83            .find(|f| f.name == "unresolved-edge")
84            .unwrap();
85        assert_eq!(unresolved.severity, RuleSeverity::Warn);
86    }
87
88    #[test]
89    fn config_promotes_to_error() {
90        let dir = tempfile::TempDir::new().unwrap();
91        std::fs::write(
92            dir.path().join("drft.toml"),
93            "[rules]\nunresolved-edge = \"error\"\n",
94        )
95        .unwrap();
96        let config = Config::load(dir.path()).unwrap();
97        let findings = run(&composed_unresolved(), None, &config);
98        let unresolved = findings
99            .iter()
100            .find(|f| f.name == "unresolved-edge")
101            .unwrap();
102        assert_eq!(unresolved.severity, RuleSeverity::Error);
103    }
104
105    #[test]
106    fn config_off_silences_rule() {
107        let dir = tempfile::TempDir::new().unwrap();
108        std::fs::write(
109            dir.path().join("drft.toml"),
110            "[rules]\nunresolved-edge = \"off\"\n",
111        )
112        .unwrap();
113        let config = Config::load(dir.path()).unwrap();
114        let findings = run(&composed_unresolved(), None, &config);
115        assert!(!findings.iter().any(|f| f.name == "unresolved-edge"));
116    }
117
118    #[test]
119    fn global_rule_ignore_suppresses_group_but_keeps_transitive_signal() {
120        use crate::lock::Lock;
121        // Locked state: yours.md links vendor/x.md, whose hash is then changed.
122        let mut locked = Graph::labeled("fs");
123        locked.set_node("yours.md", node("b3:yours"));
124        locked.set_node("vendor/x.md", node("b3:old"));
125        locked.add_edge(Edge::new("yours.md", "vendor/x.md"));
126        let lock = Lock::from_composed(&compose(&GraphSet::new(vec![locked])));
127
128        // Current state: vendor/x.md changed; yours.md unchanged.
129        let mut current = Graph::labeled("fs");
130        current.set_node("yours.md", node("b3:yours"));
131        current.set_node("vendor/x.md", node("b3:new"));
132        current.add_edge(Edge::new("yours.md", "vendor/x.md"));
133        let composed = compose(&GraphSet::new(vec![current]));
134
135        let dir = tempfile::TempDir::new().unwrap();
136        std::fs::write(
137            dir.path().join("drft.toml"),
138            "[rules]\nignore = [\"vendor/**\"]\n",
139        )
140        .unwrap();
141        let config = Config::load(dir.path()).unwrap();
142
143        let findings = run(&composed, Some(&lock), &config);
144        // The group file's own staleness is suppressed (subject is in the group).
145        assert!(
146            !findings
147                .iter()
148                .any(|f| f.name == "stale-node" && f.subject == "vendor/x.md"),
149            "vendor stale-node should be suppressed, got: {findings:?}"
150        );
151        // But your file's transitive staleness survives — its subject is yours.md,
152        // which the group ignore does not match.
153        assert!(
154            findings
155                .iter()
156                .any(|f| f.name == "stale-edge" && f.subject == "yours.md"),
157            "your transitive stale-edge should remain, got: {findings:?}"
158        );
159    }
160
161    #[test]
162    fn ignore_glob_drops_subject() {
163        let dir = tempfile::TempDir::new().unwrap();
164        std::fs::write(
165            dir.path().join("drft.toml"),
166            "[rules.unresolved-edge]\nignore = [\"index.md\"]\n",
167        )
168        .unwrap();
169        let config = Config::load(dir.path()).unwrap();
170        let findings = run(&composed_unresolved(), None, &config);
171        assert!(!findings.iter().any(|f| f.name == "unresolved-edge"));
172    }
173}