1use std::ffi::OsStr;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use serde::Serialize;
9use serde_json::Value as JsonValue;
10use thiserror::Error;
11
12use super::{
13 build_run_view_with_options, build_session_view_from_run_views, RunRecord, RunView,
14 RunViewOptions, SessionView, SessionViewOptions, ViewProducer, RUN_VIEW_SCHEMA,
15 SESSION_VIEW_SCHEMA,
16};
17
18const FIXTURE_ROOT: &str = "spec/run-view-fixtures";
19const FIXTURE_PRODUCER_VERSION: &str = "fixture";
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum RunViewFixtureMode {
23 Check,
24 Write,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub struct RunViewFixtureSummary {
29 pub case_count: usize,
30 pub run_view_count: usize,
31 pub snapshot_count: usize,
32}
33
34#[derive(Debug, Error)]
35pub enum RunViewFixtureError {
36 #[error("failed to {operation} {path}: {source}")]
37 Io {
38 operation: &'static str,
39 path: PathBuf,
40 #[source]
41 source: io::Error,
42 },
43 #[error("failed to decode {path} as a run record: {source}")]
44 Decode {
45 path: PathBuf,
46 #[source]
47 source: serde_json::Error,
48 },
49 #[error("invalid run-view fixture `{case}`: {detail}")]
50 Invalid { case: String, detail: String },
51 #[error("{path} is stale; run `make gen-run-view-fixtures` to refresh")]
52 Stale { path: PathBuf },
53}
54
55pub fn sync_run_view_fixtures(
61 repository_root: &Path,
62 mode: RunViewFixtureMode,
63) -> Result<RunViewFixtureSummary, RunViewFixtureError> {
64 let fixture_root = repository_root.join(FIXTURE_ROOT);
65 let cases_root = fixture_root.join("cases");
66 let cases = read_sorted_dirs(&cases_root)?;
67 if cases.is_empty() {
68 return Err(invalid(
69 FIXTURE_ROOT,
70 format!("expected at least one case under {}", cases_root.display()),
71 ));
72 }
73
74 let mut run_view_count = 0;
75 let mut snapshot_count = 0;
76 let mut snapshots = Vec::new();
77 for case_dir in &cases {
78 let case_name = utf8_file_name(case_dir)?;
79 let records_root = case_dir.join("records");
80 let record_paths = read_sorted_json_files(&records_root)?;
81 if record_paths.is_empty() {
82 return Err(invalid(
83 &case_name,
84 format!(
85 "expected at least one JSON record under {}",
86 records_root.display()
87 ),
88 ));
89 }
90
91 let mut run_views = Vec::with_capacity(record_paths.len());
92 for record_path in record_paths {
93 let record_name = utf8_file_stem(&record_path)?;
94 let raw = read_text(&record_path)?;
95 let run: RunRecord =
96 serde_json::from_str(&raw).map_err(|source| RunViewFixtureError::Decode {
97 path: record_path.clone(),
98 source,
99 })?;
100 let run_path = repo_relative_path(repository_root, &record_path, &case_name)?;
101 let run_view = build_run_view_with_options(
102 &run,
103 RunViewOptions {
104 producer: fixture_producer(),
105 run_path: Some(run_path),
106 ..RunViewOptions::default()
107 },
108 );
109 let snapshot_path = case_dir
110 .join("expected")
111 .join("runs")
112 .join(format!("{record_name}.run_view.json"));
113 snapshots.push((
114 snapshot_path,
115 render_snapshot(&run_view, RUN_VIEW_SCHEMA, &case_name)?,
116 ));
117 run_views.push(run_view);
118 run_view_count += 1;
119 snapshot_count += 1;
120 }
121
122 let session_view = build_session_view_from_run_views(
123 run_views.clone(),
124 SessionViewOptions {
125 producer: fixture_producer(),
126 ..SessionViewOptions::default()
127 },
128 );
129 snapshots.push((
130 case_dir.join("expected").join("session_view.json"),
131 render_snapshot(&session_view, SESSION_VIEW_SCHEMA, &case_name)?,
132 ));
133 assert_case_coverage(&case_name, &run_views, &session_view)?;
134 snapshot_count += 1;
135 }
136
137 for (path, rendered) in snapshots {
138 match mode {
139 RunViewFixtureMode::Check => check_snapshot(&path, &rendered)?,
140 RunViewFixtureMode::Write => write_snapshot(&path, rendered)?,
141 }
142 }
143
144 Ok(RunViewFixtureSummary {
145 case_count: cases.len(),
146 run_view_count,
147 snapshot_count,
148 })
149}
150
151fn fixture_producer() -> ViewProducer {
152 ViewProducer {
153 name: "harn".to_string(),
154 version: FIXTURE_PRODUCER_VERSION.to_string(),
155 }
156}
157
158fn read_sorted_dirs(path: &Path) -> Result<Vec<PathBuf>, RunViewFixtureError> {
159 let entries = fs::read_dir(path).map_err(|source| RunViewFixtureError::Io {
160 operation: "read directory",
161 path: path.to_path_buf(),
162 source,
163 })?;
164 let mut dirs = Vec::new();
165 for entry in entries {
166 let entry = entry.map_err(|source| RunViewFixtureError::Io {
167 operation: "read directory entry in",
168 path: path.to_path_buf(),
169 source,
170 })?;
171 if entry.path().is_dir() {
172 dirs.push(entry.path());
173 }
174 }
175 dirs.sort();
176 Ok(dirs)
177}
178
179fn read_sorted_json_files(path: &Path) -> Result<Vec<PathBuf>, RunViewFixtureError> {
180 let entries = fs::read_dir(path).map_err(|source| RunViewFixtureError::Io {
181 operation: "read directory",
182 path: path.to_path_buf(),
183 source,
184 })?;
185 let mut files = Vec::new();
186 for entry in entries {
187 let entry = entry.map_err(|source| RunViewFixtureError::Io {
188 operation: "read directory entry in",
189 path: path.to_path_buf(),
190 source,
191 })?;
192 if entry.path().is_file() && entry.path().extension() == Some(OsStr::new("json")) {
193 files.push(entry.path());
194 }
195 }
196 files.sort();
197 Ok(files)
198}
199
200fn read_text(path: &Path) -> Result<String, RunViewFixtureError> {
201 fs::read_to_string(path).map_err(|source| RunViewFixtureError::Io {
202 operation: "read",
203 path: path.to_path_buf(),
204 source,
205 })
206}
207
208fn utf8_file_name(path: &Path) -> Result<String, RunViewFixtureError> {
209 path.file_name()
210 .and_then(OsStr::to_str)
211 .map(str::to_owned)
212 .ok_or_else(|| invalid(path.display().to_string(), "path has no UTF-8 file name"))
213}
214
215fn utf8_file_stem(path: &Path) -> Result<String, RunViewFixtureError> {
216 path.file_stem()
217 .and_then(OsStr::to_str)
218 .map(str::to_owned)
219 .ok_or_else(|| invalid(path.display().to_string(), "path has no UTF-8 file stem"))
220}
221
222fn repo_relative_path(
223 repository_root: &Path,
224 path: &Path,
225 case_name: &str,
226) -> Result<String, RunViewFixtureError> {
227 let relative = path.strip_prefix(repository_root).map_err(|error| {
228 invalid(
229 case_name,
230 format!(
231 "{} is not under repository root {}: {error}",
232 path.display(),
233 repository_root.display()
234 ),
235 )
236 })?;
237 Ok(relative
238 .components()
239 .map(|component| component.as_os_str().to_string_lossy())
240 .collect::<Vec<_>>()
241 .join("/"))
242}
243
244fn check_snapshot(path: &Path, rendered: &str) -> Result<(), RunViewFixtureError> {
245 let expected = read_text(path)?;
246 if expected.replace("\r\n", "\n") != rendered {
247 return Err(RunViewFixtureError::Stale {
248 path: path.to_path_buf(),
249 });
250 }
251 Ok(())
252}
253
254fn write_snapshot(path: &Path, rendered: String) -> Result<(), RunViewFixtureError> {
255 let parent = path.parent().ok_or_else(|| {
256 invalid(
257 path.display().to_string(),
258 format!("{} has no parent", path.display()),
259 )
260 })?;
261 fs::create_dir_all(parent).map_err(|source| RunViewFixtureError::Io {
262 operation: "create directory",
263 path: parent.to_path_buf(),
264 source,
265 })?;
266 fs::write(path, rendered).map_err(|source| RunViewFixtureError::Io {
267 operation: "write",
268 path: path.to_path_buf(),
269 source,
270 })
271}
272
273fn render_snapshot<T: Serialize>(
274 value: &T,
275 schema: &str,
276 case_name: &str,
277) -> Result<String, RunViewFixtureError> {
278 let value = serde_json::to_value(value).map_err(|error| {
279 invalid(
280 case_name,
281 format!("failed to serialize projection: {error}"),
282 )
283 })?;
284 assert_projection_metadata(&value, schema, case_name)?;
285 serde_json::to_string_pretty(&value)
286 .map(|json| format!("{json}\n"))
287 .map_err(|error| invalid(case_name, format!("failed to render projection: {error}")))
288}
289
290fn assert_projection_metadata(
291 value: &JsonValue,
292 schema: &str,
293 case_name: &str,
294) -> Result<(), RunViewFixtureError> {
295 ensure_case(case_name, value["schema"] == schema, "schema drifted")?;
296 ensure_case(
297 case_name,
298 value["schema_version"] == 1,
299 "schema_version drifted",
300 )?;
301 ensure_case(
302 case_name,
303 value["producer"]["name"] == "harn",
304 "producer.name drifted",
305 )?;
306 ensure_case(
307 case_name,
308 value["producer"]["version"] == FIXTURE_PRODUCER_VERSION,
309 "producer.version drifted",
310 )
311}
312
313fn assert_case_coverage(
314 case_name: &str,
315 runs: &[RunView],
316 session: &SessionView,
317) -> Result<(), RunViewFixtureError> {
318 match case_name {
319 "legacy-sparse" => {
320 let run = only_run(case_name, runs)?;
321 ensure_case(case_name, run.run.run_id == "run_legacy_sparse", "run id")?;
322 ensure_case(case_name, run.run.session_id.is_none(), "session id")?;
323 ensure_case(case_name, run.run.workflow_id.is_empty(), "workflow id")?;
324 ensure_case(
325 case_name,
326 run.failure.as_ref().map(|failure| failure.status.as_str()) == Some("failed"),
327 "failure status",
328 )
329 }
330 "root-transcript" => {
331 let run = only_run(case_name, runs)?;
332 ensure_case(case_name, run.transcript.present, "transcript presence")?;
333 ensure_case(
334 case_name,
335 run.transcript.source.as_deref() == Some("run_root"),
336 "transcript source",
337 )?;
338 ensure_case(
339 case_name,
340 run.transcript.message_count == 2,
341 "message count",
342 )?;
343 ensure_case(case_name, run.providers.len() == 1, "provider count")
344 }
345 "session-lineage-failure-approval" => {
346 ensure_case(
347 case_name,
348 session.session.session_id.as_deref() == Some("session_lineage"),
349 "session id",
350 )?;
351 ensure_case(
352 case_name,
353 session.session.status == "failed",
354 "session status",
355 )?;
356 ensure_case(case_name, session.session.run_count == 2, "run count")?;
357 ensure_case(
358 case_name,
359 session.pending.approvals.len() == 1,
360 "pending approval count",
361 )?;
362 ensure_case(
363 case_name,
364 runs[0].run.child_runs.len() == 1,
365 "child run count",
366 )?;
367 ensure_case(
368 case_name,
369 runs.iter().any(|run| run.failure.is_some()),
370 "failure coverage",
371 )
372 }
373 "stage-transcript-active-auth" => {
374 let run = only_run(case_name, runs)?;
375 ensure_case(case_name, run.run.status == "running", "run status")?;
376 ensure_case(
377 case_name,
378 run.transcript.source.as_deref() == Some("stages"),
379 "transcript source",
380 )?;
381 ensure_case(
382 case_name,
383 run.transcript.message_count == 2,
384 "message count",
385 )?;
386 ensure_case(case_name, run.pending.auth.len() == 2, "pending auth count")?;
387 ensure_case(
388 case_name,
389 session.session.status == "active",
390 "session status",
391 )
392 }
393 other => Err(invalid(other, "unrecognized fixture case")),
394 }
395}
396
397fn only_run<'a>(case_name: &str, runs: &'a [RunView]) -> Result<&'a RunView, RunViewFixtureError> {
398 ensure_case(case_name, runs.len() == 1, "expected exactly one run")?;
399 Ok(&runs[0])
400}
401
402fn ensure_case(
403 case_name: &str,
404 condition: bool,
405 detail: impl Into<String>,
406) -> Result<(), RunViewFixtureError> {
407 if condition {
408 Ok(())
409 } else {
410 Err(invalid(case_name, detail))
411 }
412}
413
414fn invalid(case: impl Into<String>, detail: impl Into<String>) -> RunViewFixtureError {
415 RunViewFixtureError::Invalid {
416 case: case.into(),
417 detail: detail.into(),
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn stale_snapshot_fails_closed() {
427 let temp = tempfile::tempdir().expect("temporary fixture root");
428 let path = temp.path().join("stale.json");
429 fs::write(&path, "{}\n").expect("seed stale snapshot");
430 let error = check_snapshot(&path, "{\"current\":true}\n")
431 .expect_err("stale fixture must fail closed");
432 assert!(matches!(error, RunViewFixtureError::Stale { path: stale } if stale == path));
433 }
434}