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 #[serde(skip_serializing_if = "Option::is_none")]
27 pub hint: Option<String>,
28}
29
30impl Finding {
31 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 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
53 self.hint = Some(hint.into());
54 self
55 }
56
57 pub fn with_target(mut self, target: impl Into<String>) -> Self {
60 self.target = Some(target.into());
61 self
62 }
63
64 pub fn with_lines(mut self, lines: Vec<usize>) -> Self {
68 self.lines = lines;
69 self
70 }
71
72 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 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}