1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum FindingLevel {
14 Error,
16 Warn,
18 Report,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub struct SourcePos {
26 pub line: u32,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub col: Option<u32>,
31}
32
33impl SourcePos {
34 pub fn line(line: u32) -> Self {
36 Self { line, col: None }
37 }
38
39 pub fn at(line: u32, col: u32) -> Self {
41 Self {
42 line,
43 col: Some(col),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub struct EditRange {
51 pub start: SourcePos,
53 pub end: SourcePos,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct TextEdit {
61 pub range: EditRange,
63 #[serde(rename = "newText")]
65 pub new_text: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Fix {
71 pub description: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub edit: Option<TextEdit>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct Finding {
81 pub id: String,
83 pub level: FindingLevel,
86 pub path: String,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub pos: Option<SourcePos>,
91 pub message: String,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub hint: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub fix: Option<Fix>,
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
108 fn positioned_finding_serializes_to_the_interchange_shape() {
109 let f = Finding {
110 id: "structure/section-missing".into(),
111 level: FindingLevel::Error,
112 path: "notes/rollout.md".into(),
113 pos: Some(SourcePos::at(1, 1)),
114 message: "required section ‘Overview’ is missing".into(),
115 hint: None,
116 fix: None,
117 };
118 assert_eq!(
119 serde_json::to_string(&f).unwrap(),
120 r#"{"id":"structure/section-missing","level":"error","path":"notes/rollout.md","pos":{"line":1,"col":1},"message":"required section ‘Overview’ is missing"}"#
121 );
122 }
123
124 #[test]
125 fn absence_finding_omits_pos_entirely() {
126 let f = Finding {
127 id: "structure/section-missing".into(),
128 level: FindingLevel::Error,
129 path: "doc.md".into(),
130 pos: None,
131 message: "m".into(),
132 hint: None,
133 fix: None,
134 };
135 let json = serde_json::to_string(&f).unwrap();
136 assert!(!json.contains("pos"));
137 assert!(!json.contains("hint"));
138 assert!(!json.contains("fix"));
139 }
140
141 #[test]
143 fn hint_serializes_when_present() {
144 let f = Finding {
145 id: "structure/section-missing".into(),
146 level: FindingLevel::Error,
147 path: "doc.md".into(),
148 pos: None,
149 message: "m".into(),
150 hint: Some("One paragraph naming the outcome.".into()),
151 fix: None,
152 };
153 assert_eq!(
154 serde_json::to_string(&f).unwrap(),
155 r#"{"id":"structure/section-missing","level":"error","path":"doc.md","message":"m","hint":"One paragraph naming the outcome."}"#
156 );
157 }
158
159 #[test]
160 fn line_only_pos_omits_col() {
161 let json = serde_json::to_string(&SourcePos::line(7)).unwrap();
162 assert_eq!(json, r#"{"line":7}"#);
163 }
164
165 #[test]
166 fn levels_serialize_lowercase() {
167 assert_eq!(
168 serde_json::to_string(&FindingLevel::Error).unwrap(),
169 "\"error\""
170 );
171 assert_eq!(
172 serde_json::to_string(&FindingLevel::Warn).unwrap(),
173 "\"warn\""
174 );
175 assert_eq!(
176 serde_json::to_string(&FindingLevel::Report).unwrap(),
177 "\"report\""
178 );
179 }
180
181 #[test]
182 fn deserializes_ts_engine_output() {
183 let f: Finding = serde_json::from_str(
184 r#"{"id":"structure/heading-depth-jump","level":"warn","path":"a.md","pos":{"line":5},"message":"skip"}"#,
185 )
186 .unwrap();
187 assert_eq!(f.level, FindingLevel::Warn);
188 assert_eq!(f.pos, Some(SourcePos::line(5)));
189 assert_eq!(f.fix, None);
190 }
191
192 #[test]
193 fn fix_round_trips_with_camel_case_new_text() {
194 let f = Fix {
195 description: "add the heading".into(),
196 edit: Some(TextEdit {
197 range: EditRange {
198 start: SourcePos::at(1, 1),
199 end: SourcePos::at(1, 1),
200 },
201 new_text: "## Overview\n".into(),
202 }),
203 };
204 let json = serde_json::to_string(&f).unwrap();
205 assert!(json.contains("\"newText\":\"## Overview\\n\""));
206 assert_eq!(serde_json::from_str::<Fix>(&json).unwrap(), f);
207 }
208}