1use crate::config::RuleSeverity;
2use serde::Serialize;
3
4#[derive(Debug, Clone, Serialize)]
5pub struct Diagnostic {
6 pub rule: String,
7 pub severity: RuleSeverity,
8 pub message: String,
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub source: Option<String>,
11 #[serde(skip_serializing_if = "Option::is_none")]
12 pub target: Option<String>,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub node: Option<String>,
15 #[serde(skip_serializing_if = "Option::is_none")]
16 pub via: Option<String>,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 pub path: Option<Vec<String>>,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub graph: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub fix: Option<String>,
23}
24
25impl Default for Diagnostic {
26 fn default() -> Self {
27 Diagnostic {
28 rule: String::new(),
29 severity: RuleSeverity::Warn,
30 message: String::new(),
31 source: None,
32 target: None,
33 node: None,
34 via: None,
35 path: None,
36 graph: None,
37 fix: None,
38 }
39 }
40}
41
42impl Diagnostic {
43 pub fn format_text(&self) -> String {
44 let severity = match self.severity {
45 RuleSeverity::Error => "error",
46 RuleSeverity::Warn => "warn",
47 RuleSeverity::Off => "off",
48 };
49
50 match (&self.source, &self.target) {
51 (Some(source), Some(target)) => {
52 let via_suffix = self
53 .via
54 .as_ref()
55 .map(|v| format!(" via {v}"))
56 .unwrap_or_default();
57 format!(
58 "{severity}[{}]: {source} \u{2192} {target} ({}{})",
59 self.rule, self.message, via_suffix
60 )
61 }
62 _ if self.path.is_some() => {
63 let path = self.path.as_ref().unwrap();
64 let cycle = path.join(" \u{2192} ");
65 format!("{severity}[{}]: {}: {cycle}", self.rule, self.message)
66 }
67 _ if self.node.is_some() => {
68 let node = self.node.as_ref().unwrap();
69 match &self.via {
70 Some(via) => {
71 format!("{severity}[{}]: {node} ({} {via})", self.rule, self.message)
72 }
73 None => format!("{severity}[{}]: {node} ({})", self.rule, self.message),
74 }
75 }
76 _ => format!("{severity}[{}]: {}", self.rule, self.message),
77 }
78 }
79
80 pub fn format_text_color(&self) -> String {
81 let (severity_str, color) = match self.severity {
82 RuleSeverity::Error => ("error", "\x1b[1;31m"), RuleSeverity::Warn => ("warn", "\x1b[1;33m"), RuleSeverity::Off => ("off", "\x1b[0m"),
85 };
86 let reset = "\x1b[0m";
87 let bold = "\x1b[1m";
88 let cyan = "\x1b[36m";
89
90 match (&self.source, &self.target) {
91 (Some(source), Some(target)) => {
92 let via_suffix = self
93 .via
94 .as_ref()
95 .map(|v| format!(" via {cyan}{v}{reset}"))
96 .unwrap_or_default();
97 format!(
98 "{color}{severity_str}{reset}[{bold}{}{reset}]: {cyan}{source}{reset} \u{2192} {cyan}{target}{reset} ({}{})",
99 self.rule, self.message, via_suffix
100 )
101 }
102 _ if self.path.is_some() => {
103 let path = self.path.as_ref().unwrap();
104 let cycle = path
105 .iter()
106 .map(|p| format!("{cyan}{p}{reset}"))
107 .collect::<Vec<_>>()
108 .join(" \u{2192} ");
109 format!(
110 "{color}{severity_str}{reset}[{bold}{}{reset}]: {}: {cycle}",
111 self.rule, self.message
112 )
113 }
114 _ if self.node.is_some() => {
115 let node = self.node.as_ref().unwrap();
116 match &self.via {
117 Some(via) => format!(
118 "{color}{severity_str}{reset}[{bold}{}{reset}]: {cyan}{node}{reset} ({} {cyan}{via}{reset})",
119 self.rule, self.message
120 ),
121 None => format!(
122 "{color}{severity_str}{reset}[{bold}{}{reset}]: {cyan}{node}{reset} ({})",
123 self.rule, self.message
124 ),
125 }
126 }
127 _ => format!(
128 "{color}{severity_str}{reset}[{bold}{}{reset}]: {}",
129 self.rule, self.message
130 ),
131 }
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn text_format_edge_rule() {
141 let d = Diagnostic {
142 rule: "dangling-edge".into(),
143 severity: RuleSeverity::Warn,
144 message: "file not found".into(),
145 source: Some("index.md".into()),
146 target: Some("gone.md".into()),
147 ..Default::default()
148 };
149 assert_eq!(
150 d.format_text(),
151 "warn[dangling-edge]: index.md \u{2192} gone.md (file not found)"
152 );
153 }
154
155 #[test]
156 fn json_serialization() {
157 let d = Diagnostic {
158 rule: "dangling-edge".into(),
159 severity: RuleSeverity::Warn,
160 message: "file not found".into(),
161 source: Some("index.md".into()),
162 target: Some("gone.md".into()),
163 ..Default::default()
164 };
165 let json = serde_json::to_string(&d).unwrap();
166 assert!(json.contains("\"rule\":\"dangling-edge\""));
167 assert!(json.contains("\"severity\":\"warn\""));
168 assert!(json.contains("\"source\":\"index.md\""));
169 assert!(json.contains("\"target\":\"gone.md\""));
170 assert!(!json.contains("\"node\""));
171 }
172
173 #[test]
174 fn text_format_with_graph_prefix() {
175 let d = Diagnostic {
176 rule: "orphan-node".into(),
177 severity: RuleSeverity::Warn,
178 message: "no inbound links".into(),
179 node: Some("orphan.md".into()),
180 graph: Some("beta".into()),
181 ..Default::default()
182 };
183 assert_eq!(
185 d.format_text(),
186 "warn[orphan-node]: orphan.md (no inbound links)"
187 );
188 }
189}