Skip to main content

konveyor_core/
incident.rs

1//! Incident types representing matched violations in source code.
2//!
3//! These mirror the Konveyor analyzer-lsp output format and the
4//! gRPC IncidentContext message.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// A position in a source file.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Position {
12    pub line: u32,
13    pub character: u32,
14}
15
16/// A range in a source file.
17#[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/// A hyperlink for additional context.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExternalLink {
28    pub url: String,
29    pub title: String,
30}
31
32/// A single match/incident found by the provider.
33///
34/// This is the canonical Konveyor incident type that maps to both:
35/// - gRPC `IncidentContext` message (for Konveyor provider mode)
36/// - Konveyor output incident (for analysis output consumed by fix tooling)
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Incident {
39    /// File URI where the incident was found (e.g. `file:///path/to/File.tsx`).
40    ///
41    /// Accepts both `fileURI` (gRPC/provider format) and `uri` (kantra output format).
42    #[serde(rename = "fileURI", alias = "uri")]
43    pub file_uri: String,
44
45    /// Line number of the match (1-indexed).
46    #[serde(
47        rename = "lineNumber",
48        default,
49        skip_serializing_if = "Option::is_none"
50    )]
51    pub line_number: Option<u32>,
52
53    /// Source code location (start/end positions).
54    /// Present when produced by scanners, absent in kantra serialized output.
55    #[serde(
56        rename = "codeLocation",
57        default,
58        skip_serializing_if = "Option::is_none"
59    )]
60    pub code_location: Option<Location>,
61
62    /// Human-readable message about the incident.
63    #[serde(default, skip_serializing_if = "String::is_empty")]
64    pub message: String,
65
66    /// Surrounding source code snippet for context.
67    #[serde(rename = "codeSnip", skip_serializing_if = "Option::is_none")]
68    pub code_snip: Option<String>,
69
70    /// Provider-specific variables (e.g., matched text, symbol name).
71    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
72    pub variables: BTreeMap<String, serde_json::Value>,
73
74    /// Optional effort override for this specific incident.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub effort: Option<i64>,
77
78    /// Associated hyperlinks.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub links: Vec<ExternalLink>,
81
82    /// Whether this incident is from a dependency (vs. source code).
83    #[serde(rename = "isDependencyIncident", default)]
84    pub is_dependency_incident: bool,
85}
86
87impl Incident {
88    /// Create a new incident with the minimum required fields.
89    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    /// Add a code snippet to the incident.
104    pub fn with_code_snip(mut self, snip: String) -> Self {
105        self.code_snip = Some(snip);
106        self
107    }
108
109    /// Add a variable to the incident.
110    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
120/// Extract a code snippet from source text centered around a line number.
121///
122/// Returns a string with line-number-prefixed source lines, matching
123/// the Konveyor output format.
124pub 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        // kantra output uses "uri" not "fileURI", and may omit codeLocation
267        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        // Provider format uses "fileURI" and has codeLocation
283        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}