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::new(
59            json!({ "type": "file", "hash": "b3:x" })
60                .as_object()
61                .unwrap()
62                .clone(),
63        )
64    }
65
66    fn composed_unresolved() -> Graph {
67        let mut fs = Graph::labeled("fs");
68        fs.set_node("index.md", fs_node());
69        fs.add_edge(Edge::new("index.md", "gone.md"));
70        compose(&GraphSet::new(vec![fs]))
71    }
72
73    #[test]
74    fn applies_default_warn_severity() {
75        let graph = composed_unresolved();
76        let findings = run(&graph, None, &Config::defaults());
77        let unresolved = findings
78            .iter()
79            .find(|f| f.name == "unresolved-edge")
80            .unwrap();
81        assert_eq!(unresolved.severity, RuleSeverity::Warn);
82    }
83
84    #[test]
85    fn config_promotes_to_error() {
86        let dir = tempfile::TempDir::new().unwrap();
87        std::fs::write(
88            dir.path().join("drft.toml"),
89            "[rules]\nunresolved-edge = \"error\"\n",
90        )
91        .unwrap();
92        let config = Config::load(dir.path()).unwrap();
93        let findings = run(&composed_unresolved(), None, &config);
94        let unresolved = findings
95            .iter()
96            .find(|f| f.name == "unresolved-edge")
97            .unwrap();
98        assert_eq!(unresolved.severity, RuleSeverity::Error);
99    }
100
101    #[test]
102    fn config_off_silences_rule() {
103        let dir = tempfile::TempDir::new().unwrap();
104        std::fs::write(
105            dir.path().join("drft.toml"),
106            "[rules]\nunresolved-edge = \"off\"\n",
107        )
108        .unwrap();
109        let config = Config::load(dir.path()).unwrap();
110        let findings = run(&composed_unresolved(), None, &config);
111        assert!(!findings.iter().any(|f| f.name == "unresolved-edge"));
112    }
113
114    #[test]
115    fn ignore_glob_drops_subject() {
116        let dir = tempfile::TempDir::new().unwrap();
117        std::fs::write(
118            dir.path().join("drft.toml"),
119            "[rules.unresolved-edge]\nignore = [\"index.md\"]\n",
120        )
121        .unwrap();
122        let config = Config::load(dir.path()).unwrap();
123        let findings = run(&composed_unresolved(), None, &config);
124        assert!(!findings.iter().any(|f| f.name == "unresolved-edge"));
125    }
126}