1use crate::config::RuleSeverity;
2use serde::Serialize;
3
4#[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 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 pub fn with_target(mut self, target: impl Into<String>) -> Self {
44 self.target = Some(target.into());
45 self
46 }
47
48 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}