1use crate::config::RuleSeverity;
2use serde::Serialize;
3
4#[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 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 pub fn with_target(mut self, target: impl Into<String>) -> Self {
48 self.target = Some(target.into());
49 self
50 }
51
52 pub fn with_lines(mut self, lines: Vec<usize>) -> Self {
56 self.lines = lines;
57 self
58 }
59
60 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 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}