Skip to main content

tailtriage_cli/
artifact.rs

1use std::path::{Path, PathBuf};
2
3use serde_json::Value;
4use tailtriage_core::{Run, SCHEMA_VERSION};
5
6const SUPPORTED_SCHEMA_VERSION: u64 = SCHEMA_VERSION;
7
8/// A validated run artifact plus non-fatal loader warnings.
9#[derive(Debug)]
10pub struct LoadedArtifact {
11    /// Parsed run artifact data used by analyzer and renderer flows.
12    pub run: Run,
13    /// Non-fatal loader findings that did not block loading.
14    pub warnings: Vec<String>,
15}
16
17/// Errors returned when loading and validating run artifacts from disk.
18#[derive(Debug)]
19pub enum ArtifactLoadError {
20    /// The file could not be read from disk.
21    Read {
22        /// Path that failed to read.
23        path: PathBuf,
24        /// Underlying I/O failure.
25        source: std::io::Error,
26    },
27    /// JSON parsing or schema-shape decoding failed.
28    Parse {
29        /// Path that failed to parse.
30        path: PathBuf,
31        /// Human-readable parse or decoding error detail.
32        message: String,
33    },
34    /// `schema_version` did not match this binary's supported version.
35    UnsupportedSchemaVersion {
36        /// Artifact path that contained the unsupported version.
37        path: PathBuf,
38        /// Found schema version in the artifact.
39        found: u64,
40        /// Supported schema version expected by this binary.
41        supported: u64,
42    },
43    /// Required top-level `schema_version` key was missing.
44    MissingSchemaVersion {
45        /// Artifact path missing `schema_version`.
46        path: PathBuf,
47    },
48    /// Top-level `schema_version` existed but was not an integer.
49    InvalidSchemaVersionType {
50        /// Artifact path with invalid `schema_version` type.
51        path: PathBuf,
52    },
53    /// Additional validation rejected the artifact contents.
54    Validation {
55        /// Artifact path that failed validation.
56        path: PathBuf,
57        /// Validation failure detail.
58        message: String,
59    },
60}
61
62impl std::fmt::Display for ArtifactLoadError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::Read { path, source } => {
66                write!(f, "failed to read run artifact '{}': {source}", path.display())
67            }
68            Self::Parse { path, message } => {
69                write!(f, "failed to parse run artifact '{}': {message}", path.display())
70            }
71            Self::UnsupportedSchemaVersion {
72                path,
73                found,
74                supported,
75            } => write!(
76                f,
77                "unsupported run artifact schema_version={found} in '{}'; supported schema_version is {supported}. Re-generate the artifact with a compatible tailtriage version.",
78                path.display()
79            ),
80            Self::MissingSchemaVersion { path } => write!(
81                f,
82                "invalid run artifact in '{}': missing required top-level schema_version.",
83                path.display()
84            ),
85            Self::InvalidSchemaVersionType { path } => write!(
86                f,
87                "invalid run artifact in '{}': schema_version must be an integer.",
88                path.display()
89            ),
90            Self::Validation { path, message } => write!(
91                f,
92                "invalid run artifact '{}': {message}",
93                path.display()
94            ),
95        }
96    }
97}
98
99impl std::error::Error for ArtifactLoadError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        if let Self::Read { source, .. } = self {
102            Some(source)
103        } else {
104            None
105        }
106    }
107}
108
109/// Loads and validates a tailtriage run artifact from disk.
110///
111/// Validation is strict:
112/// - top-level `schema_version` must exist and match this binary exactly
113/// - the decoded artifact must include at least one request event
114///
115/// Loader warnings are non-fatal findings and are returned in
116/// [`LoadedArtifact::warnings`].
117///
118/// # Errors
119/// Returns [`ArtifactLoadError`] when the file cannot be read, the JSON is malformed,
120/// the schema is unsupported, or required sections are missing.
121pub fn load_run_artifact(path: &Path) -> Result<LoadedArtifact, ArtifactLoadError> {
122    let input = std::fs::read_to_string(path).map_err(|source| ArtifactLoadError::Read {
123        path: path.to_path_buf(),
124        source,
125    })?;
126
127    let raw: Value = serde_json::from_str(&input).map_err(|err| ArtifactLoadError::Parse {
128        path: path.to_path_buf(),
129        message: parse_error_message(&err),
130    })?;
131
132    validate_schema_version(&raw, path)?;
133
134    let run: Run = serde_json::from_value(raw).map_err(|err| ArtifactLoadError::Parse {
135        path: path.to_path_buf(),
136        message: format!(
137            "JSON shape does not match the tailtriage run schema ({err}). Check for missing required fields such as metadata.run_id and requests[]."
138        ),
139    })?;
140
141    validate_required_sections(&run, path)?;
142
143    let mut warnings = run.metadata.lifecycle_warnings.clone();
144    if run.metadata.unfinished_requests.count > 0 {
145        warnings.push(format!(
146            "artifact recorded {} unfinished request(s) at shutdown",
147            run.metadata.unfinished_requests.count
148        ));
149    }
150
151    Ok(LoadedArtifact { run, warnings })
152}
153
154fn validate_schema_version(raw: &Value, path: &Path) -> Result<(), ArtifactLoadError> {
155    let Some(version) = raw.get("schema_version") else {
156        return Err(ArtifactLoadError::MissingSchemaVersion {
157            path: path.to_path_buf(),
158        });
159    };
160
161    let Some(found) = version.as_u64() else {
162        return Err(ArtifactLoadError::InvalidSchemaVersionType {
163            path: path.to_path_buf(),
164        });
165    };
166
167    if found != SUPPORTED_SCHEMA_VERSION {
168        return Err(ArtifactLoadError::UnsupportedSchemaVersion {
169            path: path.to_path_buf(),
170            found,
171            supported: SUPPORTED_SCHEMA_VERSION,
172        });
173    }
174
175    Ok(())
176}
177
178fn validate_required_sections(run: &Run, path: &Path) -> Result<(), ArtifactLoadError> {
179    if run.requests.is_empty() {
180        return Err(ArtifactLoadError::Validation {
181            path: path.to_path_buf(),
182            message: "requests section is empty. Capture at least one request event before running triage.".to_string(),
183        });
184    }
185
186    Ok(())
187}
188
189fn parse_error_message(error: &serde_json::Error) -> String {
190    match error.classify() {
191        serde_json::error::Category::Eof => {
192            format!("JSON ended unexpectedly ({error}). The artifact may be truncated; re-run capture and ensure the file was fully written.")
193        }
194        serde_json::error::Category::Syntax => {
195            format!("malformed JSON ({error}).")
196        }
197        serde_json::error::Category::Data => {
198            format!("JSON data is incompatible with the expected run schema ({error}).")
199        }
200        serde_json::error::Category::Io => {
201            format!("I/O error while parsing JSON ({error}).")
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::load_run_artifact;
209
210    #[test]
211    fn rejects_malformed_json() {
212        let dir = tempfile::tempdir().expect("tempdir should build");
213        let path = dir.path().join("bad.json");
214        std::fs::write(&path, "{ not json").expect("fixture should write");
215
216        let error = load_run_artifact(&path).expect_err("expected parse failure");
217        let message = error.to_string();
218
219        assert!(message.contains("failed to parse run artifact"));
220        assert!(message.contains("malformed JSON"));
221    }
222
223    #[test]
224    fn rejects_missing_required_fields() {
225        let dir = tempfile::tempdir().expect("tempdir should build");
226        let path = dir.path().join("missing-fields.json");
227        std::fs::write(&path, r#"{"schema_version":1,"metadata":{},"requests":[],"stages":[],"queues":[],"inflight":[],"runtime_snapshots":[]}"#)
228            .expect("fixture should write");
229
230        let error = load_run_artifact(&path).expect_err("expected schema failure");
231        let message = error.to_string();
232
233        assert!(message.contains("JSON shape does not match"));
234        assert!(message.contains("missing required fields"));
235    }
236
237    #[test]
238    fn rejects_empty_requests_section() {
239        let dir = tempfile::tempdir().expect("tempdir should build");
240        let path = dir.path().join("empty-requests.json");
241        std::fs::write(&path, valid_run_json_with_requests("[]")).expect("fixture should write");
242
243        let error = load_run_artifact(&path).expect_err("expected validation failure");
244        let message = error.to_string();
245
246        assert!(message.contains("requests section is empty"));
247    }
248
249    #[test]
250    fn rejects_missing_schema_version() {
251        let dir = tempfile::tempdir().expect("tempdir should build");
252        let path = dir.path().join("missing-version.json");
253        std::fs::write(&path, valid_run_json_with_prefix("")).expect("fixture should write");
254
255        let error = load_run_artifact(&path).expect_err("expected missing version failure");
256        let message = error.to_string();
257
258        assert!(message.contains("missing required top-level schema_version"));
259    }
260
261    #[test]
262    fn rejects_non_integer_schema_versions() {
263        let dir = tempfile::tempdir().expect("tempdir should build");
264        let path = dir.path().join("string-version.json");
265        std::fs::write(
266            &path,
267            valid_run_json_with_prefix("\"schema_version\": \"1\","),
268        )
269        .expect("fixture should write");
270
271        let error = load_run_artifact(&path).expect_err("expected schema type failure");
272        let message = error.to_string();
273
274        assert!(message.contains("schema_version must be an integer"));
275    }
276
277    #[test]
278    fn rejects_unsupported_schema_versions() {
279        let dir = tempfile::tempdir().expect("tempdir should build");
280        let path = dir.path().join("unsupported-version.json");
281        std::fs::write(&path, valid_run_json_with_prefix("\"schema_version\": 99,"))
282            .expect("fixture should write");
283
284        let error = load_run_artifact(&path).expect_err("expected version incompatibility");
285        let message = error.to_string();
286
287        assert!(message.contains("unsupported run artifact"));
288        assert!(message.contains("schema_version=99"));
289    }
290
291    #[test]
292    fn flags_truncation_like_parse_errors() {
293        let dir = tempfile::tempdir().expect("tempdir should build");
294        let path = dir.path().join("truncated.json");
295        std::fs::write(&path, "{\"metadata\": {\"run_id\": \"x\"").expect("fixture should write");
296
297        let error = load_run_artifact(&path).expect_err("expected parse failure");
298        let message = error.to_string();
299
300        assert!(message.contains("may be truncated"));
301    }
302
303    #[test]
304    fn surfaces_unfinished_request_warnings() {
305        let dir = tempfile::tempdir().expect("tempdir should build");
306        let path = dir.path().join("with-warning.json");
307        std::fs::write(
308            &path,
309            r#"{"schema_version":1,"metadata":{"run_id":"r1","service_name":"svc","service_version":null,"started_at_unix_ms":1,"finished_at_unix_ms":2,"mode":"light","host":null,"pid":null,"lifecycle_warnings":["x"],"unfinished_requests":{"count":1,"sample":[{"request_id":"req1","route":"/"}]}},"requests":[{"request_id":"req1","route":"/","kind":null,"started_at_unix_ms":1,"finished_at_unix_ms":2,"latency_us":10,"outcome":"ok"}],"stages":[],"queues":[],"inflight":[],"runtime_snapshots":[]}"#,
310        )
311        .expect("fixture should write");
312
313        let artifact = load_run_artifact(&path).expect("load should succeed");
314        assert!(artifact
315            .warnings
316            .iter()
317            .any(|warning| warning.contains("unfinished request")));
318    }
319
320    fn valid_run_json_with_requests(requests_json: &str) -> String {
321        format!(
322            "{{\"schema_version\":1,\"metadata\":{{\"run_id\":\"r1\",\"service_name\":\"svc\",\"service_version\":null,\"started_at_unix_ms\":1,\"finished_at_unix_ms\":2,\"mode\":\"light\",\"host\":null,\"pid\":null,\"lifecycle_warnings\":[],\"unfinished_requests\":{{\"count\":0,\"sample\":[]}}}},\"requests\":{requests_json},\"stages\":[],\"queues\":[],\"inflight\":[],\"runtime_snapshots\":[]}}"
323        )
324    }
325
326    fn valid_run_json_with_prefix(prefix: &str) -> String {
327        format!(
328            "{{{prefix}\"metadata\":{{\"run_id\":\"r1\",\"service_name\":\"svc\",\"service_version\":null,\"started_at_unix_ms\":1,\"finished_at_unix_ms\":2,\"mode\":\"light\",\"host\":null,\"pid\":null,\"lifecycle_warnings\":[],\"unfinished_requests\":{{\"count\":0,\"sample\":[]}}}},\"requests\":[{{\"request_id\":\"req1\",\"route\":\"/\",\"kind\":null,\"started_at_unix_ms\":1,\"finished_at_unix_ms\":2,\"latency_us\":10,\"outcome\":\"ok\"}}],\"stages\":[],\"queues\":[],\"inflight\":[],\"runtime_snapshots\":[]}}"
329        )
330    }
331}