Skip to main content

markdown_contract/
finding.rs

1//! The `Finding` model: the JSON shape both engines emit,
2//! `{"id","level","path","pos":{"line","col"?}?,"message","hint"?,"fix"?}`. Optional
3//! fields are omitted, never `null`; `pos.line` and `pos.col` are 1-based; `level` is
4//! `"error" | "warn" | "report"`. A `Finding` serialized here reads back from the TS
5//! engine's `--format json` output and the other way round.
6
7use serde::{Deserialize, Serialize};
8
9/// A finding's severity. Each id has a default level in the registry; a rule or a text
10/// constraint's `level:` can set another.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum FindingLevel {
14    /// Fails the document.
15    Error,
16    /// Reported, and does not fail the document.
17    Warn,
18    /// Informational, and does not fail the document.
19    Report,
20}
21
22/// A single source point. 1-based `line`; `col` (1-based) rides along when known and is
23/// omitted from JSON when absent — matching the TS `SourcePos`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub struct SourcePos {
26    /// 1-based line
27    pub line: u32,
28    /// 1-based column, when known
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub col: Option<u32>,
31}
32
33impl SourcePos {
34    /// A line-only position (no column) — the shape absence-adjacent findings pin.
35    pub fn line(line: u32) -> Self {
36        Self { line, col: None }
37    }
38
39    /// A full line + column position.
40    pub fn at(line: u32, col: u32) -> Self {
41        Self {
42            line,
43            col: Some(col),
44        }
45    }
46}
47
48/// A start–end source range for a [`TextEdit`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub struct EditRange {
51    /// where the edit starts
52    pub start: SourcePos,
53    /// where the edit ends
54    pub end: SourcePos,
55}
56
57/// A machine-applicable repair an external tool could apply. **Provisional** — a
58/// `Finding.fix` only *describes* a remedy; this engine never edits documents.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct TextEdit {
61    /// the range to replace
62    pub range: EditRange,
63    /// the text that replaces the range
64    #[serde(rename = "newText")]
65    pub new_text: String,
66}
67
68/// The `fix` slot on a finding: a description, optionally with a concrete edit.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Fix {
71    /// what the repair does
72    pub description: String,
73    /// the concrete edit, when there is one
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub edit: Option<TextEdit>,
76}
77
78/// One validation finding — the unit of the interchange format.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct Finding {
81    /// namespaced `area/.../name`, e.g. `"structure/section-missing"`
82    pub id: String,
83    /// the id's default level from the registry, unless the rule or text constraint
84    /// sets one
85    pub level: FindingLevel,
86    /// the source document's file path (`ctx.path`), for `<path>:<line>` — not a structural path
87    pub path: String,
88    /// omitted for whole-document absence findings
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub pos: Option<SourcePos>,
91    /// what is wrong, in a sentence
92    pub message: String,
93    /// the nearest enclosing `description:` in the contract; omitted when none is in
94    /// scope
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub hint: Option<String>,
97    /// describes only; applying is a separate repair pass
98    #[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    // The exact JSON a positioned finding serializes to: the shape the TS engine emits.
107    #[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    // `hint` serializes under the TS field name when present, and is omitted when absent.
142    #[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}