Skip to main content

drft/
diagnostic.rs

1use crate::config::RuleSeverity;
2use serde::Serialize;
3
4/// A v0.8 check finding over the composed graph. Serializes to the diagnostic
5/// shape `{name, severity, subject, target?, _graphs, message}`: `subject` is the
6/// implicated path (the source node for edge-level findings), `target` is the
7/// edge's destination on edge-level findings (absent on node-level ones), and
8/// `_graphs` carries the same provenance key the node or edge does, so a consumer
9/// never has to parse anything.
10#[derive(Debug, Clone, Serialize)]
11pub struct Finding {
12    pub name: String,
13    pub severity: RuleSeverity,
14    pub subject: String,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub target: Option<String>,
17    #[serde(rename = "_graphs")]
18    pub graphs: Vec<String>,
19    pub message: String,
20}
21
22impl Finding {
23    /// A finding at the default `warn` severity. The check orchestrator applies
24    /// the configured severity afterward.
25    pub fn warn(
26        name: impl Into<String>,
27        subject: impl Into<String>,
28        graphs: Vec<String>,
29        message: impl Into<String>,
30    ) -> Self {
31        Finding {
32            name: name.into(),
33            severity: RuleSeverity::Warn,
34            subject: subject.into(),
35            target: None,
36            graphs,
37            message: message.into(),
38        }
39    }
40
41    /// Attach the destination path for an edge-level finding; renders as
42    /// `subject → target` and serializes as a `target` field.
43    pub fn with_target(mut self, target: impl Into<String>) -> Self {
44        self.target = Some(target.into());
45        self
46    }
47
48    /// The locus as rendered in text: `subject → target` for edge findings,
49    /// just `subject` otherwise.
50    fn subject_display(&self) -> String {
51        match &self.target {
52            Some(target) => format!("{} → {target}", self.subject),
53            None => self.subject.clone(),
54        }
55    }
56
57    fn severity_label(&self) -> &'static str {
58        match self.severity {
59            RuleSeverity::Error => "error",
60            RuleSeverity::Warn => "warn",
61            RuleSeverity::Off => "off",
62        }
63    }
64
65    pub fn format_text(&self) -> String {
66        format!(
67            "{}[{}]: {} ({})",
68            self.severity_label(),
69            self.name,
70            self.subject_display(),
71            self.message
72        )
73    }
74
75    pub fn format_text_color(&self) -> String {
76        let color = match self.severity {
77            RuleSeverity::Error => "\x1b[1;31m",
78            RuleSeverity::Warn => "\x1b[1;33m",
79            RuleSeverity::Off => "\x1b[0m",
80        };
81        let reset = "\x1b[0m";
82        let bold = "\x1b[1m";
83        let cyan = "\x1b[36m";
84        format!(
85            "{color}{}{reset}[{bold}{}{reset}]: {cyan}{}{reset} ({})",
86            self.severity_label(),
87            self.name,
88            self.subject_display(),
89            self.message
90        )
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn serializes_to_diagnostic_shape() {
100        let f = Finding::warn(
101            "stale-node",
102            "src/graph.rs",
103            vec!["@fs".to_string()],
104            "hash b3:444 ≠ locked b3:222",
105        );
106        let json = serde_json::to_value(&f).unwrap();
107        assert_eq!(json["name"], "stale-node");
108        assert_eq!(json["severity"], "warn");
109        assert_eq!(json["subject"], "src/graph.rs");
110        assert_eq!(json["_graphs"], serde_json::json!(["@fs"]));
111        assert!(json.get("graphs").is_none(), "field renamed to _graphs");
112        assert!(json.get("target").is_none(), "node finding has no target");
113    }
114
115    #[test]
116    fn node_finding_has_no_arrow() {
117        let f = Finding::warn("detached-node", "orphan.md", vec![], "no connections");
118        assert_eq!(
119            f.format_text(),
120            "warn[detached-node]: orphan.md (no connections)"
121        );
122    }
123
124    #[test]
125    fn edge_finding_renders_arrow_and_target() {
126        let f = Finding::warn("unresolved-edge", "index.md", vec![], "no defining node")
127            .with_target("gone.md");
128        assert_eq!(
129            f.format_text(),
130            "warn[unresolved-edge]: index.md → gone.md (no defining node)"
131        );
132        let json = serde_json::to_value(&f).unwrap();
133        assert_eq!(json["subject"], "index.md");
134        assert_eq!(json["target"], "gone.md");
135    }
136}