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?, lines?, _graphs, message}`: `subject`
6/// is the implicated path (the source node for edge-level findings), `target` is
7/// the edge's destination on edge-level findings (absent on node-level ones),
8/// `lines` is the source line(s) where an edge finding's link appears (absent
9/// otherwise), and `_graphs` carries the same provenance key the node or edge
10/// does, so a consumer never has to parse anything.
11#[derive(Debug, Clone, Serialize)]
12pub struct Finding {
13    pub name: String,
14    pub severity: RuleSeverity,
15    pub subject: String,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub target: Option<String>,
18    #[serde(skip_serializing_if = "Vec::is_empty")]
19    pub lines: Vec<usize>,
20    #[serde(rename = "_graphs")]
21    pub graphs: Vec<String>,
22    pub message: String,
23    /// An optional second line naming the likely cause. Reserved for cases where
24    /// the finding is correct but reads as the wrong problem — a path that fails
25    /// to resolve looks like a typo when the base is what's wrong.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub hint: Option<String>,
28}
29
30impl Finding {
31    /// A finding at the default `warn` severity. The check orchestrator applies
32    /// the configured severity afterward.
33    pub fn warn(
34        name: impl Into<String>,
35        subject: impl Into<String>,
36        graphs: Vec<String>,
37        message: impl Into<String>,
38    ) -> Self {
39        Finding {
40            name: name.into(),
41            severity: RuleSeverity::Warn,
42            subject: subject.into(),
43            target: None,
44            lines: Vec::new(),
45            graphs,
46            message: message.into(),
47            hint: None,
48        }
49    }
50
51    /// Attach a cause line rendered beneath the finding.
52    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
53        self.hint = Some(hint.into());
54        self
55    }
56
57    /// Attach the destination path for an edge-level finding; renders as
58    /// `subject → target` and serializes as a `target` field.
59    pub fn with_target(mut self, target: impl Into<String>) -> Self {
60        self.target = Some(target.into());
61        self
62    }
63
64    /// Attach the source line(s) where an edge finding's link appears. They
65    /// annotate the subject (`subject:line → target`) and serialize as `lines`.
66    /// The `subject` path itself is unchanged, so `ignore` globs still match it.
67    pub fn with_lines(mut self, lines: Vec<usize>) -> Self {
68        self.lines = lines;
69        self
70    }
71
72    /// The locus as rendered in text: `subject:lines → target` for an edge
73    /// finding with known lines, `subject → target` for one without, and just
74    /// `subject` for a node finding.
75    fn subject_display(&self) -> String {
76        let subject = if self.lines.is_empty() {
77            self.subject.clone()
78        } else {
79            let joined = self
80                .lines
81                .iter()
82                .map(usize::to_string)
83                .collect::<Vec<_>>()
84                .join(",");
85            format!("{}:{joined}", self.subject)
86        };
87        match &self.target {
88            Some(target) => format!("{subject} → {target}"),
89            None => subject,
90        }
91    }
92
93    fn severity_label(&self) -> &'static str {
94        match self.severity {
95            RuleSeverity::Error => "error",
96            RuleSeverity::Warn => "warn",
97            RuleSeverity::Off => "off",
98        }
99    }
100
101    pub fn format_text(&self) -> String {
102        let head = format!(
103            "{}[{}]: {} ({})",
104            self.severity_label(),
105            self.name,
106            self.subject_display(),
107            self.message
108        );
109        match &self.hint {
110            Some(hint) => format!("{head}\n  hint: {hint}"),
111            None => head,
112        }
113    }
114
115    pub fn format_text_color(&self) -> String {
116        let color = match self.severity {
117            RuleSeverity::Error => "\x1b[1;31m",
118            RuleSeverity::Warn => "\x1b[1;33m",
119            RuleSeverity::Off => "\x1b[0m",
120        };
121        let reset = "\x1b[0m";
122        let bold = "\x1b[1m";
123        let cyan = "\x1b[36m";
124        let head = format!(
125            "{color}{}{reset}[{bold}{}{reset}]: {cyan}{}{reset} ({})",
126            self.severity_label(),
127            self.name,
128            self.subject_display(),
129            self.message
130        );
131        match &self.hint {
132            Some(hint) => format!("{head}\n  {bold}hint{reset}: {hint}"),
133            None => head,
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn serializes_to_diagnostic_shape() {
144        let f = Finding::warn(
145            "stale-node",
146            "src/graph.rs",
147            vec!["@fs".to_string()],
148            "hash b3:444 ≠ locked b3:222",
149        );
150        let json = serde_json::to_value(&f).unwrap();
151        assert_eq!(json["name"], "stale-node");
152        assert_eq!(json["severity"], "warn");
153        assert_eq!(json["subject"], "src/graph.rs");
154        assert_eq!(json["_graphs"], serde_json::json!(["@fs"]));
155        assert!(json.get("graphs").is_none(), "field renamed to _graphs");
156        assert!(json.get("target").is_none(), "node finding has no target");
157    }
158
159    #[test]
160    fn node_finding_has_no_arrow() {
161        let f = Finding::warn("detached-node", "orphan.md", vec![], "no connections");
162        assert_eq!(
163            f.format_text(),
164            "warn[detached-node]: orphan.md (no connections)"
165        );
166    }
167
168    #[test]
169    fn edge_finding_with_lines_annotates_subject() {
170        let f = Finding::warn("stale-edge", "README.md", vec![], "hash a ≠ locked b")
171            .with_target("src/lib.rs")
172            .with_lines(vec![12, 49]);
173        assert_eq!(
174            f.format_text(),
175            "warn[stale-edge]: README.md:12,49 → src/lib.rs (hash a ≠ locked b)"
176        );
177        let json = serde_json::to_value(&f).unwrap();
178        // The subject path stays bare so `ignore` globs still match it; lines are
179        // a separate field.
180        assert_eq!(json["subject"], "README.md");
181        assert_eq!(json["lines"], serde_json::json!([12, 49]));
182    }
183
184    #[test]
185    fn edge_finding_renders_arrow_and_target() {
186        let f = Finding::warn("unresolved-edge", "index.md", vec![], "no defining node")
187            .with_target("gone.md");
188        assert_eq!(
189            f.format_text(),
190            "warn[unresolved-edge]: index.md → gone.md (no defining node)"
191        );
192        let json = serde_json::to_value(&f).unwrap();
193        assert_eq!(json["subject"], "index.md");
194        assert_eq!(json["target"], "gone.md");
195    }
196}