use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Position {
pub line: u32,
pub character: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Location {
#[serde(rename = "startPosition")]
pub start: Position,
#[serde(rename = "endPosition")]
pub end: Position,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalLink {
pub url: String,
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Incident {
#[serde(rename = "fileURI", alias = "uri")]
pub file_uri: String,
#[serde(
rename = "lineNumber",
default,
skip_serializing_if = "Option::is_none"
)]
pub line_number: Option<u32>,
#[serde(
rename = "codeLocation",
default,
skip_serializing_if = "Option::is_none"
)]
pub code_location: Option<Location>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub message: String,
#[serde(rename = "codeSnip", skip_serializing_if = "Option::is_none")]
pub code_snip: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub variables: BTreeMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effort: Option<i64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<ExternalLink>,
#[serde(rename = "isDependencyIncident", default)]
pub is_dependency_incident: bool,
}
impl Incident {
pub fn new(file_uri: String, line_number: u32, code_location: Location) -> Self {
Self {
file_uri,
line_number: Some(line_number),
code_location: Some(code_location),
message: String::new(),
code_snip: None,
variables: BTreeMap::new(),
effort: None,
links: Vec::new(),
is_dependency_incident: false,
}
}
pub fn with_code_snip(mut self, snip: String) -> Self {
self.code_snip = Some(snip);
self
}
pub fn with_variable(
mut self,
key: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
self.variables.insert(key.into(), value.into());
self
}
}
pub fn extract_code_snip(source: &str, line_number: u32, context_lines: u32) -> String {
let lines: Vec<&str> = source.lines().collect();
let total = lines.len() as u32;
let start = line_number.saturating_sub(context_lines + 1);
let end = (line_number + context_lines).min(total);
let width = format!("{}", end).len();
let mut snip = String::new();
for i in start..end {
let line_num = i + 1;
let line_content = lines.get(i as usize).unwrap_or(&"");
snip.push_str(&format!(
"{:>width$} {}\n",
line_num,
line_content,
width = width
));
}
snip
}
#[cfg(test)]
mod tests {
use super::*;
fn make_location(line: u32, start_col: u32, end_col: u32) -> Location {
Location {
start: Position {
line,
character: start_col,
},
end: Position {
line,
character: end_col,
},
}
}
#[test]
fn test_incident_new_defaults() {
let incident = Incident::new("file:///test.tsx".to_string(), 10, make_location(9, 0, 20));
assert_eq!(incident.file_uri, "file:///test.tsx");
assert_eq!(incident.line_number, Some(10));
assert!(incident.code_snip.is_none());
assert!(incident.variables.is_empty());
assert!(incident.effort.is_none());
assert!(incident.links.is_empty());
assert!(!incident.is_dependency_incident);
assert!(incident.message.is_empty());
}
#[test]
fn test_incident_with_code_snip() {
let incident = Incident::new("file:///test.tsx".to_string(), 5, make_location(4, 0, 10))
.with_code_snip("const x = 1;".to_string());
assert_eq!(incident.code_snip, Some("const x = 1;".to_string()));
}
#[test]
fn test_incident_with_variable() {
let incident = Incident::new("file:///test.tsx".to_string(), 1, make_location(0, 0, 5))
.with_variable("propName", "isActive")
.with_variable("componentName", "Button");
assert_eq!(incident.variables.len(), 2);
assert_eq!(
incident.variables.get("propName"),
Some(&serde_json::Value::String("isActive".to_string()))
);
assert_eq!(
incident.variables.get("componentName"),
Some(&serde_json::Value::String("Button".to_string()))
);
}
#[test]
fn test_incident_builder_chain() {
let incident = Incident::new("file:///app.tsx".to_string(), 42, make_location(41, 4, 30))
.with_code_snip(" <Button isActive />".to_string())
.with_variable("propName", "isActive")
.with_variable("componentName", "Button");
assert_eq!(incident.file_uri, "file:///app.tsx");
assert_eq!(incident.line_number, Some(42));
assert_eq!(
incident.code_snip,
Some(" <Button isActive />".to_string())
);
assert_eq!(incident.variables.len(), 2);
}
#[test]
fn test_extract_code_snip_middle_of_file() {
let source = "line1\nline2\nline3\nline4\nline5\nline6\nline7";
let snip = extract_code_snip(source, 4, 2);
assert!(snip.contains("line2"));
assert!(snip.contains("line3"));
assert!(snip.contains("line4"));
assert!(snip.contains("line5"));
assert!(snip.contains("line6"));
}
#[test]
fn test_extract_code_snip_start_of_file() {
let source = "first\nsecond\nthird\nfourth\nfifth";
let snip = extract_code_snip(source, 1, 2);
assert!(snip.contains("first"));
assert!(snip.contains("second"));
assert!(snip.contains("third"));
}
#[test]
fn test_extract_code_snip_end_of_file() {
let source = "a\nb\nc\nd\ne";
let snip = extract_code_snip(source, 5, 2);
assert!(snip.contains("c"));
assert!(snip.contains("d"));
assert!(snip.contains("e"));
}
#[test]
fn test_incident_serde_roundtrip() {
let incident = Incident::new("file:///test.tsx".to_string(), 10, make_location(9, 5, 15))
.with_code_snip("test snip".to_string())
.with_variable("key", "value");
let json = serde_json::to_string(&incident).unwrap();
let back: Incident = serde_json::from_str(&json).unwrap();
assert_eq!(back.file_uri, "file:///test.tsx");
assert_eq!(back.line_number, Some(10));
assert_eq!(back.code_snip, Some("test snip".to_string()));
assert_eq!(
back.variables.get("key"),
Some(&serde_json::Value::String("value".to_string()))
);
}
#[test]
fn test_incident_deserialize_kantra_format() {
let json = r#"{
"uri": "file:///src/App.tsx",
"message": "Rename Chip to Label",
"lineNumber": 10,
"codeSnip": "import { Chip } from '@patternfly/react-core';"
}"#;
let incident: Incident = serde_json::from_str(json).unwrap();
assert_eq!(incident.file_uri, "file:///src/App.tsx");
assert_eq!(incident.message, "Rename Chip to Label");
assert_eq!(incident.line_number, Some(10));
assert!(incident.code_location.is_none());
}
#[test]
fn test_incident_deserialize_provider_format() {
let json = r#"{
"fileURI": "file:///src/App.tsx",
"lineNumber": 10,
"codeLocation": {
"startPosition": {"line": 9, "character": 0},
"endPosition": {"line": 9, "character": 20}
}
}"#;
let incident: Incident = serde_json::from_str(json).unwrap();
assert_eq!(incident.file_uri, "file:///src/App.tsx");
assert_eq!(incident.line_number, Some(10));
assert!(incident.code_location.is_some());
}
}