1use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Position {
12 pub line: u32,
13 pub character: u32,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Location {
19 #[serde(rename = "startPosition")]
20 pub start: Position,
21 #[serde(rename = "endPosition")]
22 pub end: Position,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExternalLink {
28 pub url: String,
29 pub title: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Incident {
39 #[serde(rename = "fileURI", alias = "uri")]
43 pub file_uri: String,
44
45 #[serde(
47 rename = "lineNumber",
48 default,
49 skip_serializing_if = "Option::is_none"
50 )]
51 pub line_number: Option<u32>,
52
53 #[serde(
56 rename = "codeLocation",
57 default,
58 skip_serializing_if = "Option::is_none"
59 )]
60 pub code_location: Option<Location>,
61
62 #[serde(default, skip_serializing_if = "String::is_empty")]
64 pub message: String,
65
66 #[serde(rename = "codeSnip", skip_serializing_if = "Option::is_none")]
68 pub code_snip: Option<String>,
69
70 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72 pub variables: BTreeMap<String, serde_json::Value>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub effort: Option<i64>,
77
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub links: Vec<ExternalLink>,
81
82 #[serde(rename = "isDependencyIncident", default)]
84 pub is_dependency_incident: bool,
85}
86
87impl Incident {
88 pub fn new(file_uri: String, line_number: u32, code_location: Location) -> Self {
90 Self {
91 file_uri,
92 line_number: Some(line_number),
93 code_location: Some(code_location),
94 message: String::new(),
95 code_snip: None,
96 variables: BTreeMap::new(),
97 effort: None,
98 links: Vec::new(),
99 is_dependency_incident: false,
100 }
101 }
102
103 pub fn with_code_snip(mut self, snip: String) -> Self {
105 self.code_snip = Some(snip);
106 self
107 }
108
109 pub fn with_variable(
111 mut self,
112 key: impl Into<String>,
113 value: impl Into<serde_json::Value>,
114 ) -> Self {
115 self.variables.insert(key.into(), value.into());
116 self
117 }
118}
119
120pub fn extract_code_snip(source: &str, line_number: u32, context_lines: u32) -> String {
125 let lines: Vec<&str> = source.lines().collect();
126 let total = lines.len() as u32;
127
128 let start = line_number.saturating_sub(context_lines + 1);
129 let end = (line_number + context_lines).min(total);
130
131 let width = format!("{}", end).len();
132
133 let mut snip = String::new();
134 for i in start..end {
135 let line_num = i + 1;
136 let line_content = lines.get(i as usize).unwrap_or(&"");
137 snip.push_str(&format!(
138 "{:>width$} {}\n",
139 line_num,
140 line_content,
141 width = width
142 ));
143 }
144 snip
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn make_location(line: u32, start_col: u32, end_col: u32) -> Location {
152 Location {
153 start: Position {
154 line,
155 character: start_col,
156 },
157 end: Position {
158 line,
159 character: end_col,
160 },
161 }
162 }
163
164 #[test]
165 fn test_incident_new_defaults() {
166 let incident = Incident::new("file:///test.tsx".to_string(), 10, make_location(9, 0, 20));
167 assert_eq!(incident.file_uri, "file:///test.tsx");
168 assert_eq!(incident.line_number, Some(10));
169 assert!(incident.code_snip.is_none());
170 assert!(incident.variables.is_empty());
171 assert!(incident.effort.is_none());
172 assert!(incident.links.is_empty());
173 assert!(!incident.is_dependency_incident);
174 assert!(incident.message.is_empty());
175 }
176
177 #[test]
178 fn test_incident_with_code_snip() {
179 let incident = Incident::new("file:///test.tsx".to_string(), 5, make_location(4, 0, 10))
180 .with_code_snip("const x = 1;".to_string());
181
182 assert_eq!(incident.code_snip, Some("const x = 1;".to_string()));
183 }
184
185 #[test]
186 fn test_incident_with_variable() {
187 let incident = Incident::new("file:///test.tsx".to_string(), 1, make_location(0, 0, 5))
188 .with_variable("propName", "isActive")
189 .with_variable("componentName", "Button");
190
191 assert_eq!(incident.variables.len(), 2);
192 assert_eq!(
193 incident.variables.get("propName"),
194 Some(&serde_json::Value::String("isActive".to_string()))
195 );
196 assert_eq!(
197 incident.variables.get("componentName"),
198 Some(&serde_json::Value::String("Button".to_string()))
199 );
200 }
201
202 #[test]
203 fn test_incident_builder_chain() {
204 let incident = Incident::new("file:///app.tsx".to_string(), 42, make_location(41, 4, 30))
205 .with_code_snip(" <Button isActive />".to_string())
206 .with_variable("propName", "isActive")
207 .with_variable("componentName", "Button");
208
209 assert_eq!(incident.file_uri, "file:///app.tsx");
210 assert_eq!(incident.line_number, Some(42));
211 assert_eq!(
212 incident.code_snip,
213 Some(" <Button isActive />".to_string())
214 );
215 assert_eq!(incident.variables.len(), 2);
216 }
217
218 #[test]
219 fn test_extract_code_snip_middle_of_file() {
220 let source = "line1\nline2\nline3\nline4\nline5\nline6\nline7";
221 let snip = extract_code_snip(source, 4, 2);
222 assert!(snip.contains("line2"));
223 assert!(snip.contains("line3"));
224 assert!(snip.contains("line4"));
225 assert!(snip.contains("line5"));
226 assert!(snip.contains("line6"));
227 }
228
229 #[test]
230 fn test_extract_code_snip_start_of_file() {
231 let source = "first\nsecond\nthird\nfourth\nfifth";
232 let snip = extract_code_snip(source, 1, 2);
233 assert!(snip.contains("first"));
234 assert!(snip.contains("second"));
235 assert!(snip.contains("third"));
236 }
237
238 #[test]
239 fn test_extract_code_snip_end_of_file() {
240 let source = "a\nb\nc\nd\ne";
241 let snip = extract_code_snip(source, 5, 2);
242 assert!(snip.contains("c"));
243 assert!(snip.contains("d"));
244 assert!(snip.contains("e"));
245 }
246
247 #[test]
248 fn test_incident_serde_roundtrip() {
249 let incident = Incident::new("file:///test.tsx".to_string(), 10, make_location(9, 5, 15))
250 .with_code_snip("test snip".to_string())
251 .with_variable("key", "value");
252
253 let json = serde_json::to_string(&incident).unwrap();
254 let back: Incident = serde_json::from_str(&json).unwrap();
255 assert_eq!(back.file_uri, "file:///test.tsx");
256 assert_eq!(back.line_number, Some(10));
257 assert_eq!(back.code_snip, Some("test snip".to_string()));
258 assert_eq!(
259 back.variables.get("key"),
260 Some(&serde_json::Value::String("value".to_string()))
261 );
262 }
263
264 #[test]
265 fn test_incident_deserialize_kantra_format() {
266 let json = r#"{
268 "uri": "file:///src/App.tsx",
269 "message": "Rename Chip to Label",
270 "lineNumber": 10,
271 "codeSnip": "import { Chip } from '@patternfly/react-core';"
272 }"#;
273 let incident: Incident = serde_json::from_str(json).unwrap();
274 assert_eq!(incident.file_uri, "file:///src/App.tsx");
275 assert_eq!(incident.message, "Rename Chip to Label");
276 assert_eq!(incident.line_number, Some(10));
277 assert!(incident.code_location.is_none());
278 }
279
280 #[test]
281 fn test_incident_deserialize_provider_format() {
282 let json = r#"{
284 "fileURI": "file:///src/App.tsx",
285 "lineNumber": 10,
286 "codeLocation": {
287 "startPosition": {"line": 9, "character": 0},
288 "endPosition": {"line": 9, "character": 20}
289 }
290 }"#;
291 let incident: Incident = serde_json::from_str(json).unwrap();
292 assert_eq!(incident.file_uri, "file:///src/App.tsx");
293 assert_eq!(incident.line_number, Some(10));
294 assert!(incident.code_location.is_some());
295 }
296}