Skip to main content

mars_schema/
diagnostic.rs

1//! Stable diagnostics returned by schema operations.
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7pub enum Severity {
8    Error,
9    Warning,
10    Info,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(untagged)]
15pub enum PathSegment {
16    Key(String),
17    Index(usize),
18}
19
20/// Byte offsets into the file text. Only fence and YAML-syntax errors may carry one
21/// (`serde_yaml` has no spans; divergence V3), and no reader sets it yet. Every other
22/// diagnostic is located by `path`, which consumers map to a range with their own parser.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ByteRange {
25    pub start: usize,
26    pub end: usize,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct SchemaDiagnostic {
31    pub code: String,
32    pub severity: Severity,
33    pub path: Vec<PathSegment>,
34    pub message: String,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub range: Option<ByteRange>,
37}
38
39impl SchemaDiagnostic {
40    pub fn new(
41        code: impl Into<String>,
42        severity: Severity,
43        path: Vec<PathSegment>,
44        message: impl Into<String>,
45    ) -> Self {
46        Self {
47            code: code.into(),
48            severity,
49            path,
50            message: message.into(),
51            range: None,
52        }
53    }
54
55    pub fn is_error(&self) -> bool {
56        self.severity == Severity::Error
57    }
58
59    pub fn message(&self) -> &str {
60        &self.message
61    }
62}
63
64/// A frontmatter key a schema version removed, as the reader and the legacy upgrade share it.
65/// `advice` is what the reader tells the author. A `discarded` key has no replacement, so the
66/// upgrade and the foreign-dialect import drop it with a warning; every other removed key is
67/// renamed or moved by the upgrade, or left for the author. Every discarded Agent key is one
68/// only pre-v1 Mars defined, so `lift_mars_only_keys` drops it from a definition read as any
69/// dialect.
70pub(crate) struct RemovedField {
71    pub key: &'static str,
72    pub advice: &'static str,
73    pub discarded: bool,
74}
75
76impl RemovedField {
77    pub(crate) const fn replaced(key: &'static str, advice: &'static str) -> Self {
78        Self {
79            key,
80            advice,
81            discarded: false,
82        }
83    }
84
85    pub(crate) const fn discarded(key: &'static str, advice: &'static str) -> Self {
86        Self {
87            key,
88            advice,
89            discarded: true,
90        }
91    }
92}
93
94/// A value a schema version removed from a field that remains. The reader reports it, and
95/// `lift_mars_only_keys` rewrites it to `replacement` in a definition read as any dialect.
96pub(crate) struct RemovedValue {
97    pub key: &'static str,
98    pub value: &'static str,
99    pub replacement: &'static str,
100}
101
102impl RemovedValue {
103    pub(crate) const fn new(
104        key: &'static str,
105        value: &'static str,
106        replacement: &'static str,
107    ) -> Self {
108        Self {
109            key,
110            value,
111            replacement,
112        }
113    }
114
115    /// The entry for `value` in `key`, when `removed` lists that pair.
116    pub(crate) fn find<'a>(removed: &'a [Self], key: &str, value: &str) -> Option<&'a Self> {
117        removed
118            .iter()
119            .find(|entry| entry.key == key && entry.value == value)
120    }
121
122    pub(crate) fn diagnostic(&self) -> SchemaDiagnostic {
123        SchemaDiagnostic::new(
124            "field.removed",
125            Severity::Error,
126            key_path(self.key),
127            format!(
128                "{} value `{}` was removed; use `{}`",
129                self.key, self.value, self.replacement
130            ),
131        )
132    }
133}
134
135/// What an Agent or skill reader reports for a key it has no field for: `field.removed` (an
136/// error) when `removed` lists it, else `field.unknown` (a warning). An unknown key stays in the
137/// authored file and reaches no native file.
138pub(crate) fn unrecognized_field(removed: &[RemovedField], key: &str) -> SchemaDiagnostic {
139    match removed.iter().find(|field| field.key == key) {
140        Some(field) => SchemaDiagnostic::new(
141            "field.removed",
142            Severity::Error,
143            key_path(key),
144            format!("`{key}` was removed; {}", field.advice),
145        ),
146        None => SchemaDiagnostic::new(
147            "field.unknown",
148            Severity::Warning,
149            key_path(key),
150            format!(
151                "unknown field `{key}` is kept in the file but written to no native file; put harness-specific keys under `harness-overrides.<harness>`"
152            ),
153        ),
154    }
155}
156
157/// A bad value for a field the item kind defines (`kind` is the noun in the message).
158pub(crate) fn push_invalid(
159    kind: &str,
160    diags: &mut Vec<SchemaDiagnostic>,
161    path: Vec<PathSegment>,
162    value: impl Into<String>,
163    allowed: &'static str,
164) {
165    let value = value.into();
166    let severity = if matches!(path.first(), Some(PathSegment::Key(key)) if key == "harness-overrides")
167    {
168        Severity::Warning
169    } else {
170        Severity::Error
171    };
172    let display_field = if matches!(path.first(), Some(PathSegment::Key(key)) if key == "model-policies")
173    {
174        path.iter()
175            .map(|segment| match segment {
176                PathSegment::Key(key) => key.clone(),
177                PathSegment::Index(index) => format!("[{}]", index),
178            })
179            .fold(String::new(), |mut display, segment| {
180                if !display.is_empty() && !segment.starts_with('[') {
181                    display.push('.');
182                }
183                display.push_str(&segment);
184                display
185            })
186    } else {
187        display_path(&path)
188    };
189    diags.push(SchemaDiagnostic::new(
190        "field.value",
191        severity,
192        path,
193        format!("{kind} field `{display_field}` has invalid value `{value}`; allowed: {allowed}"),
194    ));
195}
196
197pub(crate) fn key_path(field: impl Into<String>) -> Vec<PathSegment> {
198    vec![PathSegment::Key(field.into())]
199}
200
201pub(crate) fn child_path(parent: impl Into<String>, child: impl Into<String>) -> Vec<PathSegment> {
202    vec![
203        PathSegment::Key(parent.into()),
204        PathSegment::Key(child.into()),
205    ]
206}
207
208pub fn display_path(path: &[PathSegment]) -> String {
209    let mut display = String::new();
210    for segment in path {
211        match segment {
212            PathSegment::Key(key) => {
213                if !display.is_empty() {
214                    display.push('.');
215                }
216                display.push_str(key);
217            }
218            PathSegment::Index(index) => {
219                use std::fmt::Write;
220                let _ = write!(display, "[{index}]");
221            }
222        }
223    }
224    display
225}
226
227pub(crate) fn frontmatter_error(error: &crate::frontmatter::FrontmatterError) -> SchemaDiagnostic {
228    let code = match error {
229        crate::frontmatter::FrontmatterError::TooLarge(_) => "frontmatter.too-large",
230        crate::frontmatter::FrontmatterError::TooDeep => "frontmatter.too-deep",
231        crate::frontmatter::FrontmatterError::Unclosed => "frontmatter.unclosed",
232        crate::frontmatter::FrontmatterError::Anchor => "yaml.anchor",
233        crate::frontmatter::FrontmatterError::DocumentEnd => "yaml.document-end",
234        crate::frontmatter::FrontmatterError::UnsupportedSyntax(_) => "yaml.unsupported-syntax",
235        crate::frontmatter::FrontmatterError::NotAMapping => "frontmatter.not-mapping",
236        crate::frontmatter::FrontmatterError::MalformedYaml(inner) => {
237            let message = inner.to_string();
238            if message.contains("duplicate entry") || message.contains("duplicate key") {
239                "yaml.duplicate-key"
240            } else {
241                "yaml.invalid"
242            }
243        }
244    };
245    SchemaDiagnostic::new(code, Severity::Error, Vec::new(), error.to_string())
246}