1use 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#[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
64pub(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
94pub(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 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
135pub(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
157pub(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}