json_archive/
diagnostics.rs

1// json-archive is a tool for tracking JSON file changes over time
2// Copyright (C) 2025  Peoples Grocers LLC
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published
6// by the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16//
17// To purchase a license under different terms contact admin@peoplesgrocers.com
18// To request changes, report bugs, or give user feedback contact
19// marxism@peoplesgrocers.com
20//
21
22use std::fmt;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum DiagnosticLevel {
26    Fatal,
27    Warning,
28    Info,
29}
30
31impl fmt::Display for DiagnosticLevel {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            DiagnosticLevel::Fatal => write!(f, "error"),
35            DiagnosticLevel::Warning => write!(f, "warning"),
36            DiagnosticLevel::Info => write!(f, "info"),
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum DiagnosticCode {
43    EmptyFile,
44    MissingHeader,
45    InvalidUtf8,
46    TruncatedJson,
47
48    MissingHeaderField,
49    UnsupportedVersion,
50    InvalidTimestamp,
51    InvalidInitialState,
52
53    InvalidEventJson,
54    UnknownEventType,
55    WrongFieldCount,
56    WrongFieldType,
57
58    NonExistentObservationId,
59    DuplicateObservationId,
60
61    ChangeCountMismatch,
62    InvalidChangeCount,
63
64    InvalidPointerSyntax,
65    PathNotFound,
66    InvalidArrayIndex,
67    ArrayIndexOutOfBounds,
68    ParentPathNotFound,
69
70    TypeMismatch,
71    OldValueMismatch,
72
73    MoveOnNonArray,
74    MoveIndexOutOfBounds,
75    InvalidMoveIndex,
76
77    SnapshotStateMismatch,
78    SnapshotTimestampOrder,
79}
80
81impl DiagnosticCode {
82    pub fn as_str(&self) -> &'static str {
83        match self {
84            DiagnosticCode::EmptyFile => "E001",
85            DiagnosticCode::MissingHeader => "E002",
86            DiagnosticCode::InvalidUtf8 => "E003",
87            DiagnosticCode::TruncatedJson => "E004",
88
89            DiagnosticCode::MissingHeaderField => "E010",
90            DiagnosticCode::UnsupportedVersion => "E011",
91            DiagnosticCode::InvalidTimestamp => "W012",
92            DiagnosticCode::InvalidInitialState => "E013",
93
94            DiagnosticCode::InvalidEventJson => "E020",
95            DiagnosticCode::UnknownEventType => "W021",
96            DiagnosticCode::WrongFieldCount => "E022",
97            DiagnosticCode::WrongFieldType => "E023",
98
99            DiagnosticCode::NonExistentObservationId => "E030",
100            DiagnosticCode::DuplicateObservationId => "W031",
101
102            DiagnosticCode::ChangeCountMismatch => "W040",
103            DiagnosticCode::InvalidChangeCount => "E041",
104
105            DiagnosticCode::InvalidPointerSyntax => "E050",
106            DiagnosticCode::PathNotFound => "E051",
107            DiagnosticCode::InvalidArrayIndex => "E052",
108            DiagnosticCode::ArrayIndexOutOfBounds => "E053",
109            DiagnosticCode::ParentPathNotFound => "E054",
110
111            DiagnosticCode::TypeMismatch => "E060",
112            DiagnosticCode::OldValueMismatch => "W061",
113
114            DiagnosticCode::MoveOnNonArray => "E070",
115            DiagnosticCode::MoveIndexOutOfBounds => "E071",
116            DiagnosticCode::InvalidMoveIndex => "E072",
117
118            DiagnosticCode::SnapshotStateMismatch => "W080",
119            DiagnosticCode::SnapshotTimestampOrder => "W081",
120        }
121    }
122
123    pub fn title(&self) -> &'static str {
124        match self {
125            DiagnosticCode::EmptyFile => "Empty file",
126            DiagnosticCode::MissingHeader => "Missing header",
127            DiagnosticCode::InvalidUtf8 => "Invalid UTF-8 encoding",
128            DiagnosticCode::TruncatedJson => "Truncated JSON",
129
130            DiagnosticCode::MissingHeaderField => "Missing required header field",
131            DiagnosticCode::UnsupportedVersion => "Unsupported version",
132            DiagnosticCode::InvalidTimestamp => "Invalid timestamp",
133            DiagnosticCode::InvalidInitialState => "Invalid initial state",
134
135            DiagnosticCode::InvalidEventJson => "Invalid event JSON",
136            DiagnosticCode::UnknownEventType => "Unknown event type",
137            DiagnosticCode::WrongFieldCount => "Wrong field count",
138            DiagnosticCode::WrongFieldType => "Wrong field type",
139
140            DiagnosticCode::NonExistentObservationId => "Non-existent observation ID",
141            DiagnosticCode::DuplicateObservationId => "Duplicate observation ID",
142
143            DiagnosticCode::ChangeCountMismatch => "Change count mismatch",
144            DiagnosticCode::InvalidChangeCount => "Invalid change count",
145
146            DiagnosticCode::InvalidPointerSyntax => "Invalid JSON Pointer syntax",
147            DiagnosticCode::PathNotFound => "Path not found",
148            DiagnosticCode::InvalidArrayIndex => "Invalid array index",
149            DiagnosticCode::ArrayIndexOutOfBounds => "Array index out of bounds",
150            DiagnosticCode::ParentPathNotFound => "Parent path not found",
151
152            DiagnosticCode::TypeMismatch => "Type mismatch",
153            DiagnosticCode::OldValueMismatch => "Old value mismatch",
154
155            DiagnosticCode::MoveOnNonArray => "Move operation on non-array",
156            DiagnosticCode::MoveIndexOutOfBounds => "Move index out of bounds",
157            DiagnosticCode::InvalidMoveIndex => "Invalid move index",
158
159            DiagnosticCode::SnapshotStateMismatch => "Snapshot state mismatch",
160            DiagnosticCode::SnapshotTimestampOrder => "Snapshot timestamp out of order",
161        }
162    }
163}
164
165#[derive(Debug, Clone)]
166pub struct Diagnostic {
167    pub filename: Option<String>,
168    pub line_number: Option<usize>,
169    pub column: Option<usize>,
170    pub level: DiagnosticLevel,
171    pub code: DiagnosticCode,
172    pub description: String,
173    pub code_snippet: Option<String>,
174    pub advice: Option<String>,
175}
176
177impl Diagnostic {
178    pub fn new(level: DiagnosticLevel, code: DiagnosticCode, description: String) -> Self {
179        Self {
180            filename: None,
181            line_number: None,
182            column: None,
183            level,
184            code,
185            description,
186            code_snippet: None,
187            advice: None,
188        }
189    }
190
191    #[inline]
192    pub fn fatal(code: DiagnosticCode, description: String) -> Self {
193        Self::new(DiagnosticLevel::Fatal, code, description)
194    }
195
196    pub fn with_location(mut self, filename: String, line_number: usize) -> Self {
197        self.filename = Some(filename);
198        self.line_number = Some(line_number);
199        self
200    }
201
202    pub fn with_column(mut self, column: usize) -> Self {
203        self.column = Some(column);
204        self
205    }
206
207    pub fn with_snippet(mut self, snippet: String) -> Self {
208        self.code_snippet = Some(snippet);
209        self
210    }
211
212    pub fn with_advice(mut self, advice: String) -> Self {
213        self.advice = Some(advice);
214        self
215    }
216
217    pub fn is_fatal(&self) -> bool {
218        self.level == DiagnosticLevel::Fatal
219    }
220}
221
222impl From<Diagnostic> for Vec<Diagnostic> {
223    fn from(diagnostic: Diagnostic) -> Self {
224        vec![diagnostic]
225    }
226}
227
228impl fmt::Display for Diagnostic {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        if let (Some(filename), Some(line)) = (&self.filename, self.line_number) {
231            if let Some(col) = self.column {
232                write!(f, "{}:{}:{} - ", filename, line, col)?;
233            } else {
234                write!(f, "{}:{} - ", filename, line)?;
235            }
236        }
237
238        writeln!(
239            f,
240            "{} {}: {}",
241            self.level,
242            self.code.as_str(),
243            self.code.title()
244        )?;
245        writeln!(f)?;
246        writeln!(f, "{}", self.description)?;
247
248        if let Some(snippet) = &self.code_snippet {
249            writeln!(f)?;
250            writeln!(f, "{}", snippet)?;
251        }
252
253        if let Some(advice) = &self.advice {
254            writeln!(f)?;
255            writeln!(f, "{}", advice)?;
256        }
257
258        Ok(())
259    }
260}
261
262#[derive(Debug, Default)]
263pub struct DiagnosticCollector {
264    diagnostics: Vec<Diagnostic>,
265}
266
267impl DiagnosticCollector {
268    pub fn new() -> Self {
269        Self {
270            diagnostics: Vec::new(),
271        }
272    }
273
274    pub fn add(&mut self, diagnostic: Diagnostic) {
275        self.diagnostics.push(diagnostic);
276    }
277
278    pub fn has_fatal(&self) -> bool {
279        self.diagnostics.iter().any(|d| d.is_fatal())
280    }
281
282    pub fn diagnostics(&self) -> &[Diagnostic] {
283        &self.diagnostics
284    }
285
286    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
287        self.diagnostics
288    }
289
290    pub fn is_empty(&self) -> bool {
291        self.diagnostics.is_empty()
292    }
293
294    pub fn len(&self) -> usize {
295        self.diagnostics.len()
296    }
297}