use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FindingLevel {
Error,
Warn,
Report,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourcePos {
pub line: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub col: Option<u32>,
}
impl SourcePos {
pub fn line(line: u32) -> Self {
Self { line, col: None }
}
pub fn at(line: u32, col: u32) -> Self {
Self {
line,
col: Some(col),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct EditRange {
pub start: SourcePos,
pub end: SourcePos,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextEdit {
pub range: EditRange,
#[serde(rename = "newText")]
pub new_text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fix {
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit: Option<TextEdit>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Finding {
pub id: String,
pub level: FindingLevel,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pos: Option<SourcePos>,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fix: Option<Fix>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn positioned_finding_serializes_to_the_interchange_shape() {
let f = Finding {
id: "structure/section-missing".into(),
level: FindingLevel::Error,
path: "notes/rollout.md".into(),
pos: Some(SourcePos::at(1, 1)),
message: "required section ‘Overview’ is missing".into(),
hint: None,
fix: None,
};
assert_eq!(
serde_json::to_string(&f).unwrap(),
r#"{"id":"structure/section-missing","level":"error","path":"notes/rollout.md","pos":{"line":1,"col":1},"message":"required section ‘Overview’ is missing"}"#
);
}
#[test]
fn absence_finding_omits_pos_entirely() {
let f = Finding {
id: "structure/section-missing".into(),
level: FindingLevel::Error,
path: "doc.md".into(),
pos: None,
message: "m".into(),
hint: None,
fix: None,
};
let json = serde_json::to_string(&f).unwrap();
assert!(!json.contains("pos"));
assert!(!json.contains("hint"));
assert!(!json.contains("fix"));
}
#[test]
fn hint_serializes_when_present() {
let f = Finding {
id: "structure/section-missing".into(),
level: FindingLevel::Error,
path: "doc.md".into(),
pos: None,
message: "m".into(),
hint: Some("One paragraph naming the outcome.".into()),
fix: None,
};
assert_eq!(
serde_json::to_string(&f).unwrap(),
r#"{"id":"structure/section-missing","level":"error","path":"doc.md","message":"m","hint":"One paragraph naming the outcome."}"#
);
}
#[test]
fn line_only_pos_omits_col() {
let json = serde_json::to_string(&SourcePos::line(7)).unwrap();
assert_eq!(json, r#"{"line":7}"#);
}
#[test]
fn levels_serialize_lowercase() {
assert_eq!(
serde_json::to_string(&FindingLevel::Error).unwrap(),
"\"error\""
);
assert_eq!(
serde_json::to_string(&FindingLevel::Warn).unwrap(),
"\"warn\""
);
assert_eq!(
serde_json::to_string(&FindingLevel::Report).unwrap(),
"\"report\""
);
}
#[test]
fn deserializes_ts_engine_output() {
let f: Finding = serde_json::from_str(
r#"{"id":"structure/heading-depth-jump","level":"warn","path":"a.md","pos":{"line":5},"message":"skip"}"#,
)
.unwrap();
assert_eq!(f.level, FindingLevel::Warn);
assert_eq!(f.pos, Some(SourcePos::line(5)));
assert_eq!(f.fix, None);
}
#[test]
fn fix_round_trips_with_camel_case_new_text() {
let f = Fix {
description: "add the heading".into(),
edit: Some(TextEdit {
range: EditRange {
start: SourcePos::at(1, 1),
end: SourcePos::at(1, 1),
},
new_text: "## Overview\n".into(),
}),
};
let json = serde_json::to_string(&f).unwrap();
assert!(json.contains("\"newText\":\"## Overview\\n\""));
assert_eq!(serde_json::from_str::<Fix>(&json).unwrap(), f);
}
}