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#[derive(Debug)]
10pub struct LoadedArtifact {
11 pub run: Run,
13 pub warnings: Vec<String>,
15}
16
17#[derive(Debug)]
19pub enum ArtifactLoadError {
20 Read {
22 path: PathBuf,
24 source: std::io::Error,
26 },
27 Parse {
29 path: PathBuf,
31 message: String,
33 },
34 UnsupportedSchemaVersion {
36 path: PathBuf,
38 found: u64,
40 supported: u64,
42 },
43 MissingSchemaVersion {
45 path: PathBuf,
47 },
48 InvalidSchemaVersionType {
50 path: PathBuf,
52 },
53 Validation {
55 path: PathBuf,
57 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
109pub 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}