1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5const HOST_AUTHORITY: &str = "host";
6
7#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ConfigurationValidationCode {
11 WorkingDirectoryUnavailable,
12 HostFileUnreadable,
13 HostDocumentTooLarge,
14 HostTextInvalidUtf8,
15 HostTomlInvalid,
16 AdminBindInvalid,
17 MetricsBindInvalid,
18 AdminOriginInvalid,
19 SourceModeConflict,
20 PathInvalid,
21 SecretReferenceInvalid,
22 LimitOutOfRange,
23 DurationInvalid,
24 PageSizeInvalid,
25 ContentLimitRelationshipInvalid,
26}
27
28impl ConfigurationValidationCode {
29 pub const fn as_str(self) -> &'static str {
30 match self {
31 Self::WorkingDirectoryUnavailable => "working_directory_unavailable",
32 Self::HostFileUnreadable => "host_file_unreadable",
33 Self::HostDocumentTooLarge => "host_document_too_large",
34 Self::HostTextInvalidUtf8 => "host_text_invalid_utf8",
35 Self::HostTomlInvalid => "host_toml_invalid",
36 Self::AdminBindInvalid => "admin_bind_invalid",
37 Self::MetricsBindInvalid => "metrics_bind_invalid",
38 Self::AdminOriginInvalid => "admin_origin_invalid",
39 Self::SourceModeConflict => "source_mode_conflict",
40 Self::PathInvalid => "path_invalid",
41 Self::SecretReferenceInvalid => "secret_reference_invalid",
42 Self::LimitOutOfRange => "limit_out_of_range",
43 Self::DurationInvalid => "duration_invalid",
44 Self::PageSizeInvalid => "page_size_invalid",
45 Self::ContentLimitRelationshipInvalid => "content_limit_relationship_invalid",
46 }
47 }
48}
49
50impl fmt::Display for ConfigurationValidationCode {
51 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52 formatter.write_str(self.as_str())
53 }
54}
55
56#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
58#[non_exhaustive]
59pub struct ConfigurationDiagnostic {
60 pub authority: &'static str,
61 pub field: Box<str>,
62 pub code: ConfigurationValidationCode,
63 pub message: &'static str,
64 pub line: Option<usize>,
65 pub column: Option<usize>,
66}
67
68impl ConfigurationDiagnostic {
69 pub(crate) fn new(
70 field: impl Into<Box<str>>,
71 code: ConfigurationValidationCode,
72 message: &'static str,
73 ) -> Self {
74 Self {
75 authority: HOST_AUTHORITY,
76 field: field.into(),
77 code,
78 message,
79 line: None,
80 column: None,
81 }
82 }
83
84 pub(crate) const fn at(mut self, line: usize, column: usize) -> Self {
85 self.line = Some(line);
86 self.column = Some(column);
87 self
88 }
89}
90
91impl fmt::Display for ConfigurationDiagnostic {
92 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93 write!(formatter, "{}:{}:{}", self.authority, self.field, self.code)?;
94 if let (Some(line), Some(column)) = (self.line, self.column) {
95 write!(formatter, " at {line}:{column}")?;
96 }
97 write!(formatter, ": {}", self.message)
98 }
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
103pub struct ConfigurationErrors {
104 diagnostics: Vec<ConfigurationDiagnostic>,
105}
106
107impl ConfigurationErrors {
108 pub(crate) fn from_diagnostics(mut diagnostics: Vec<ConfigurationDiagnostic>) -> Self {
109 debug_assert!(!diagnostics.is_empty());
110 diagnostics.sort();
111 diagnostics.dedup();
112 Self { diagnostics }
113 }
114
115 pub fn diagnostics(&self) -> &[ConfigurationDiagnostic] {
116 &self.diagnostics
117 }
118}
119
120impl fmt::Display for ConfigurationErrors {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 formatter.write_str("configuration validation failed")?;
123 for diagnostic in &self.diagnostics {
124 write!(formatter, "; {diagnostic}")?;
125 }
126 Ok(())
127 }
128}
129
130impl std::error::Error for ConfigurationErrors {}
131
132#[derive(Default)]
133pub(crate) struct DiagnosticCollector {
134 diagnostics: Vec<ConfigurationDiagnostic>,
135}
136
137impl DiagnosticCollector {
138 pub(crate) fn push(&mut self, diagnostic: ConfigurationDiagnostic) {
139 self.diagnostics.push(diagnostic);
140 }
141
142 pub(crate) fn into_result(self) -> Result<(), ConfigurationErrors> {
143 if self.diagnostics.is_empty() {
144 Ok(())
145 } else {
146 Err(ConfigurationErrors::from_diagnostics(self.diagnostics))
147 }
148 }
149}
150
151pub(crate) fn single_error(diagnostic: ConfigurationDiagnostic) -> ConfigurationErrors {
152 ConfigurationErrors::from_diagnostics(vec![diagnostic])
153}
154
155pub(crate) fn toml_location(source: &str, error: &toml::de::Error) -> Option<(usize, usize)> {
156 let offset = error.span()?.start.min(source.len());
157 let prefix = source.get(..offset)?;
158 let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1;
159 let column = prefix
160 .rsplit_once('\n')
161 .map_or(prefix, |(_, suffix)| suffix)
162 .chars()
163 .count()
164 + 1;
165 Some((line, column))
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn stable_codes_match_the_serde_contract() {
174 let cases = [
175 (
176 ConfigurationValidationCode::WorkingDirectoryUnavailable,
177 "working_directory_unavailable",
178 ),
179 (
180 ConfigurationValidationCode::HostFileUnreadable,
181 "host_file_unreadable",
182 ),
183 (
184 ConfigurationValidationCode::HostDocumentTooLarge,
185 "host_document_too_large",
186 ),
187 (
188 ConfigurationValidationCode::HostTextInvalidUtf8,
189 "host_text_invalid_utf8",
190 ),
191 (
192 ConfigurationValidationCode::HostTomlInvalid,
193 "host_toml_invalid",
194 ),
195 (
196 ConfigurationValidationCode::AdminBindInvalid,
197 "admin_bind_invalid",
198 ),
199 (
200 ConfigurationValidationCode::AdminOriginInvalid,
201 "admin_origin_invalid",
202 ),
203 (
204 ConfigurationValidationCode::SourceModeConflict,
205 "source_mode_conflict",
206 ),
207 (ConfigurationValidationCode::PathInvalid, "path_invalid"),
208 (
209 ConfigurationValidationCode::SecretReferenceInvalid,
210 "secret_reference_invalid",
211 ),
212 (
213 ConfigurationValidationCode::LimitOutOfRange,
214 "limit_out_of_range",
215 ),
216 (
217 ConfigurationValidationCode::DurationInvalid,
218 "duration_invalid",
219 ),
220 (
221 ConfigurationValidationCode::PageSizeInvalid,
222 "page_size_invalid",
223 ),
224 (
225 ConfigurationValidationCode::ContentLimitRelationshipInvalid,
226 "content_limit_relationship_invalid",
227 ),
228 ];
229
230 for (code, expected) in cases {
231 assert_eq!(code.as_str(), expected);
232 assert_eq!(serde_json::to_value(code).unwrap(), expected);
233 }
234 }
235
236 #[test]
237 fn diagnostics_are_sorted_and_do_not_contain_source_text() {
238 let errors = ConfigurationErrors::from_diagnostics(vec![
239 ConfigurationDiagnostic::new(
240 "paths.content_root",
241 ConfigurationValidationCode::PathInvalid,
242 "configured path must not be empty",
243 ),
244 ConfigurationDiagnostic::new(
245 "$document",
246 ConfigurationValidationCode::HostTomlInvalid,
247 "host TOML does not match the schema",
248 )
249 .at(2, 3),
250 ]);
251
252 assert_eq!(errors.diagnostics()[0].field.as_ref(), "$document");
253 let rendered = format!("{errors:?}");
254 assert!(!rendered.contains("credential-value"));
255 assert!(!rendered.contains("source ="));
256 }
257
258 #[test]
259 fn diagnostic_wire_contract_keeps_the_host_authority() {
260 let diagnostic = ConfigurationDiagnostic::new(
261 "$document",
262 ConfigurationValidationCode::HostTomlInvalid,
263 "host TOML does not match the schema",
264 )
265 .at(2, 3);
266
267 assert_eq!(
268 serde_json::to_value(diagnostic).unwrap(),
269 serde_json::from_str::<serde_json::Value>(
270 r#"{"authority":"host","field":"$document","code":"host_toml_invalid","message":"host TOML does not match the schema","line":2,"column":3}"#,
271 )
272 .unwrap()
273 );
274 }
275
276 #[test]
277 fn toml_locations_use_one_based_unicode_scalar_columns() {
278 #[derive(Debug, Deserialize)]
279 struct Document {
280 #[serde(rename = "root")]
281 _root: Root,
282 }
283
284 #[derive(Debug, Deserialize)]
285 struct Root {
286 #[serde(rename = "label")]
287 _label: String,
288 #[serde(rename = "count")]
289 _count: u64,
290 }
291
292 let source = "root = { label = \"é\", count = \"bad\" }\n";
293 let error = toml::from_str::<Document>(source).unwrap_err();
294
295 assert_eq!(toml_location(source, &error), Some((1, 31)));
296 }
297}