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}
24
25impl Finding {
26    /// A finding at the default `warn` severity. The check orchestrator applies
27    /// the configured severity afterward.
28    pub fn warn(
29        name: impl Into<String>,
30        subject: impl Into<String>,
31        graphs: Vec<String>,
32        message: impl Into<String>,
33    ) -> Self {
34        Finding {
35            name: name.into(),
36            severity: RuleSeverity::Warn,
37            subject: subject.into(),
38            target: None,
39            lines: Vec::new(),
40            graphs,
41            message: message.into(),
42        }
43    }
44
45    /// Attach the destination path for an edge-level finding; renders as
46    /// `subject → target` and serializes as a `target` field.
47    pub fn with_target(mut self, target: impl Into<String>) -> Self {
48        self.target = Some(target.into());
49        self
50    }
51
52    /// Attach the source line(s) where an edge finding's link appears. They
53    /// annotate the subject (`subject:line → target`) and serialize as `lines`.
54    /// The `subject` path itself is unchanged, so `ignore` globs still match it.
55    pub fn with_lines(mut self, lines: Vec<usize>) -> Self {
56        self.lines = lines;
57        self
58    }
59
60    /// The locus as rendered in text: `subject:lines → target` for an edge
61    /// finding with known lines, `subject → target` for one without, and just
62    /// `subject` for a node finding.
63    fn subject_display(&self) -> String {
64        let subject = if self.lines.is_empty() {
65            self.subject.clone()
66        } else {
67            let joined = self
68                .lines
69                .iter()
70                .map(usize::to_string)
71                .collect::<Vec<_>>()
72                .join(",");
73            format!("{}:{joined}", self.subject)
74        };
75        match &self.target {
76            Some(target) => format!("{subject} → {target}"),
77            None => subject,
78        }
79    }
80
81    fn severity_label(&self) -> &'static str {
82        match self.severity {
83            RuleSeverity::Error => "error",
84            RuleSeverity::Warn => "warn",
85            RuleSeverity::Off => "off",
86        }
87    }
88
89    pub fn format_text(&self) -> String {
90        format!(
91            "{}[{}]: {} ({})",
92            self.severity_label(),
93            self.name,
94            self.subject_display(),
95            self.message
96        )
97    }
98
99    pub fn format_text_color(&self) -> String {
100        let color = match self.severity {
101            RuleSeverity::Error => "\x1b[1;31m",
102            RuleSeverity::Warn => "\x1b[1;33m",
103            RuleSeverity::Off => "\x1b[0m",
104        };
105        let reset = "\x1b[0m";
106        let bold = "\x1b[1m";
107        let cyan = "\x1b[36m";
108        format!(
109            "{color}{}{reset}[{bold}{}{reset}]: {cyan}{}{reset} ({})",
110            self.severity_label(),
111            self.name,
112            self.subject_display(),
113            self.message
114        )
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn serializes_to_diagnostic_shape() {
124        let f = Finding::warn(
125            "stale-node",
126            "src/graph.rs",
127            vec!["@fs".to_string()],
128            "hash b3:444 ≠ locked b3:222",
129        );
130        let json = serde_json::to_value(&f).unwrap();
131        assert_eq!(json["name"], "stale-node");
132        assert_eq!(json["severity"], "warn");
133        assert_eq!(json["subject"], "src/graph.rs");
134        assert_eq!(json["_graphs"], serde_json::json!(["@fs"]));
135        assert!(json.get("graphs").is_none(), "field renamed to _graphs");
136        assert!(json.get("target").is_none(), "node finding has no target");
137    }
138
139    #[test]
140    fn node_finding_has_no_arrow() {
141        let f = Finding::warn("detached-node", "orphan.md", vec![], "no connections");
142        assert_eq!(
143            f.format_text(),
144            "warn[detached-node]: orphan.md (no connections)"
145        );
146    }
147
148    #[test]
149    fn edge_finding_with_lines_annotates_subject() {
150        let f = Finding::warn("stale-edge", "README.md", vec![], "hash a ≠ locked b")
151            .with_target("src/lib.rs")
152            .with_lines(vec![12, 49]);
153        assert_eq!(
154            f.format_text(),
155            "warn[stale-edge]: README.md:12,49 → src/lib.rs (hash a ≠ locked b)"
156        );
157        let json = serde_json::to_value(&f).unwrap();
158        // The subject path stays bare so `ignore` globs still match it; lines are
159        // a separate field.
160        assert_eq!(json["subject"], "README.md");
161        assert_eq!(json["lines"], serde_json::json!([12, 49]));
162    }
163
164    #[test]
165    fn edge_finding_renders_arrow_and_target() {
166        let f = Finding::warn("unresolved-edge", "index.md", vec![], "no defining node")
167            .with_target("gone.md");
168        assert_eq!(
169            f.format_text(),
170            "warn[unresolved-edge]: index.md → gone.md (no defining node)"
171        );
172        let json = serde_json::to_value(&f).unwrap();
173        assert_eq!(json["subject"], "index.md");
174        assert_eq!(json["target"], "gone.md");
175    }
176}