Skip to main content

drft/
diagnostic.rs

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"), // bold red
83            RuleSeverity::Warn => ("warn", "\x1b[1;33m"),   // bold yellow
84            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        // Scope is handled by the output loop, not format_text
184        assert_eq!(
185            d.format_text(),
186            "warn[orphan-node]: orphan.md (no inbound links)"
187        );
188    }
189}