Skip to main content

keel_cli/
record.rs

1//! `keel record` — capture effects during a run, then turn the capture into
2//! an offline replay fixture + generated test glue (dx-spec §6 standing
3//! structure item 5; `docs/recording-format.md`).
4//!
5//! Subcommands:
6//!
7//! - `keel record run <script> [args…]` — exactly `keel run <script>
8//!   [args…]` (same dispatch via [`run::plan`]/[`run::exec_with`], same
9//!   exit-code passthrough), plus `KEEL_RECORD=<fresh path>` in the child's
10//!   environment. The front end (not this binary) does the actual capture —
11//!   see `python/keel/src/keel/_record.py`, `node/keel/src/record.mjs`.
12//! - `keel record list` — recordings under `.keel/recordings/`, newest first.
13//! - `keel record test <recording> [--out DIR]` — generate a pytest fixture
14//!   (Python recording) or `node:test` file (Node recording) from a
15//!   completed recording.
16//!
17//! Non-contract end to end: the `.ndjson` line format lives entirely in the
18//! front ends and is versioned but outside `contracts/` — this module only
19//! reads/writes plain files under `.keel/recordings/`, never `contracts/`.
20
21use std::path::{Path, PathBuf};
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use serde::Serialize;
25use serde_json::Value;
26
27use crate::render::to_json;
28use crate::{EXIT_FAILURE, EXIT_USAGE, Rendered, run};
29
30/// The subdirectory of `.keel/` holding recordings (mirrors
31/// `keel-core::events::EVENTS_SUBDIR`'s convention for non-contract, per-run
32/// tooling data).
33pub const RECORDINGS_SUBDIR: &str = "recordings";
34/// File extension of a recording (newline-delimited JSON).
35pub const RECORDING_EXT: &str = "ndjson";
36
37/// A fresh recording id: zero-padded hex epoch-milliseconds plus the process
38/// id (lexically sortable — newest last — and collision-free across
39/// concurrent `keel record run` invocations without a extra dependency).
40fn new_id() -> String {
41    let ms = SystemTime::now()
42        .duration_since(UNIX_EPOCH)
43        .map_or(0, |d| d.as_millis());
44    format!("{ms:011x}-{:04x}", std::process::id() & 0xffff)
45}
46
47/// `.keel/recordings/` under `project`.
48fn recordings_dir(project: &Path) -> PathBuf {
49    project.join(".keel").join(RECORDINGS_SUBDIR)
50}
51
52// ---------------------------------------------------------------------------
53// `keel record run`
54// ---------------------------------------------------------------------------
55
56/// `keel record run <target> [args…]`: plan the dispatch exactly like `keel
57/// run`, then exec with `KEEL_RECORD` set to a fresh path under
58/// `.keel/recordings/` so the front end tees every intercepted call into it.
59/// Otherwise byte-identical to `keel run` — recording is a pure observer
60/// (`docs/recording-format.md`).
61pub fn run(project: &Path, target: &str, args: &[String]) -> (Option<Rendered>, i32) {
62    let plan = match run::plan(target, args, false) {
63        Ok(p) => p,
64        Err(e) => {
65            let r = e.render();
66            let code = r.exit;
67            return (Some(r), code);
68        }
69    };
70    let dir = recordings_dir(project);
71    if let Err(err) = std::fs::create_dir_all(&dir) {
72        return (
73            Some(soft_error(&format!(
74                "could not create {}: {err}.",
75                dir.display()
76            ))),
77            EXIT_FAILURE,
78        );
79    }
80    let path = dir.join(format!("{}.{RECORDING_EXT}", new_id()));
81    let path_str = path.to_string_lossy().into_owned();
82    match run::exec_with(&plan, |cmd| {
83        cmd.env("KEEL_RECORD", &path_str);
84    }) {
85        Ok(code) => (None, code),
86        Err(r) => {
87            let code = r.exit;
88            (Some(r), code)
89        }
90    }
91}
92
93// ---------------------------------------------------------------------------
94// `keel record list`
95// ---------------------------------------------------------------------------
96
97/// One recording row for `keel record list`.
98#[derive(Debug, Serialize)]
99struct RecordingRow {
100    args: Vec<String>,
101    body_captured: usize,
102    calls: usize,
103    errors: usize,
104    id: String,
105    language: String,
106    started_at_ms: i64,
107    target: String,
108}
109
110/// The `keel record list` report.
111#[derive(Debug, Serialize)]
112struct RecordingsReport {
113    count: usize,
114    recordings: Vec<RecordingRow>,
115}
116
117/// `keel record list` for `project`: one row per `.keel/recordings/*.ndjson`
118/// file, newest first. A file that isn't a readable Keel recording (no `meta`
119/// header) is skipped rather than failing the whole listing — best-effort,
120/// like `keel tail`'s NDJSON line hygiene.
121pub fn list(project: &Path) -> Rendered {
122    let dir = recordings_dir(project);
123    let mut rows: Vec<RecordingRow> = Vec::new();
124    if let Ok(entries) = std::fs::read_dir(&dir) {
125        for entry in entries.flatten() {
126            let path = entry.path();
127            if path.extension().and_then(|e| e.to_str()) != Some(RECORDING_EXT) {
128                continue;
129            }
130            if let Some(row) = read_recording_row(&path) {
131                rows.push(row);
132            }
133        }
134    }
135    rows.sort_by(|a, b| b.id.cmp(&a.id)); // newest first
136    let report = RecordingsReport {
137        count: rows.len(),
138        recordings: rows,
139    };
140    let human = list_human(&report);
141    Rendered::ok(human, to_json(&report))
142}
143
144/// Parse one recording file into its list row: the `meta` header plus a scan
145/// of every `call` line for counts. Returns `None` for anything that is not a
146/// readable Keel recording (missing/foreign header).
147fn read_recording_row(path: &Path) -> Option<RecordingRow> {
148    let text = std::fs::read_to_string(path).ok()?;
149    let mut lines = text.lines();
150    let meta: Value = serde_json::from_str(lines.next()?.trim()).ok()?;
151    if meta.get("type").and_then(Value::as_str) != Some("meta") {
152        return None;
153    }
154    let str_field = |key: &str| {
155        meta.get(key)
156            .and_then(Value::as_str)
157            .unwrap_or_default()
158            .to_owned()
159    };
160    let args = meta
161        .get("args")
162        .and_then(Value::as_array)
163        .map(|a| {
164            a.iter()
165                .filter_map(|v| v.as_str().map(str::to_owned))
166                .collect()
167        })
168        .unwrap_or_default();
169
170    let mut calls = 0usize;
171    let mut body_captured = 0usize;
172    let mut errors = 0usize;
173    for line in lines {
174        let line = line.trim();
175        if line.is_empty() {
176            continue;
177        }
178        let Ok(v) = serde_json::from_str::<Value>(line) else {
179            continue;
180        };
181        if v.get("type").and_then(Value::as_str) != Some("call") {
182            continue;
183        }
184        calls += 1;
185        if v.get("body_captured").and_then(Value::as_bool) == Some(true) {
186            body_captured += 1;
187        }
188        if v.get("outcome")
189            .and_then(|o| o.get("result"))
190            .and_then(Value::as_str)
191            == Some("error")
192        {
193            errors += 1;
194        }
195    }
196
197    Some(RecordingRow {
198        args,
199        body_captured,
200        calls,
201        errors,
202        id: str_field("id"),
203        language: if meta.get("language").and_then(Value::as_str).is_some() {
204            str_field("language")
205        } else {
206            "?".to_owned()
207        },
208        started_at_ms: meta
209            .get("started_at_ms")
210            .and_then(Value::as_i64)
211            .unwrap_or(0),
212        target: str_field("target"),
213    })
214}
215
216/// The human `keel record list` table, derived entirely from
217/// [`RecordingsReport`].
218fn list_human(report: &RecordingsReport) -> String {
219    if report.recordings.is_empty() {
220        return "keel \u{25b8} no recordings yet.\n  `keel record run <script>` to make one."
221            .to_owned();
222    }
223    let mut lines = vec![format!(
224        "keel \u{25b8} recordings: {} total\n",
225        report.count
226    )];
227    for r in &report.recordings {
228        lines.push(format!(
229            "  {}  {:<7} {}  {} calls ({} with body, {} errors)\n",
230            r.id, r.language, r.target, r.calls, r.body_captured, r.errors
231        ));
232    }
233    lines.concat()
234}
235
236// ---------------------------------------------------------------------------
237// `keel record test`
238// ---------------------------------------------------------------------------
239
240/// Resolve `recording` (an exact id, a filesystem path, or an unambiguous id
241/// substring) to one recording file under `.keel/recordings/`.
242fn resolve_recording(project: &Path, recording: &str) -> Result<PathBuf, Rendered> {
243    let as_path = Path::new(recording);
244    if as_path.is_file() {
245        return Ok(as_path.to_path_buf());
246    }
247    let dir = recordings_dir(project);
248    let exact = dir.join(format!("{recording}.{RECORDING_EXT}"));
249    if exact.is_file() {
250        return Ok(exact);
251    }
252    let mut ids: Vec<String> = std::fs::read_dir(&dir)
253        .into_iter()
254        .flatten()
255        .flatten()
256        .filter_map(|e| {
257            let p = e.path();
258            if p.extension().and_then(|e| e.to_str()) == Some(RECORDING_EXT) {
259                p.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
260            } else {
261                None
262            }
263        })
264        .filter(|id| id.contains(recording))
265        .collect();
266    ids.sort();
267    match ids.len() {
268        0 => Err(soft_error(&format!(
269            "no recording matches {recording:?} under {}. `keel record list` to see what exists.",
270            dir.display()
271        ))),
272        1 => Ok(dir.join(format!("{}.{RECORDING_EXT}", ids[0]))),
273        n => Err(soft_error(&format!(
274            "{recording:?} matches {n} recordings ({}); use a full id or path.",
275            ids.join(", ")
276        ))),
277    }
278}
279
280/// `keel record test <recording> [--out DIR]`: read the recording's `meta`
281/// header to learn its language, then write one ready-to-run generated test
282/// file next to it (or under `out_dir`).
283pub fn test_gen(project: &Path, recording: &str, out_dir: Option<&Path>) -> Rendered {
284    let path = match resolve_recording(project, recording) {
285        Ok(p) => p,
286        Err(r) => return r,
287    };
288    let text = match std::fs::read_to_string(&path) {
289        Ok(t) => t,
290        Err(err) => return soft_error(&format!("could not read {}: {err}.", path.display())),
291    };
292    let Some(first_line) = text.lines().next() else {
293        return soft_error(&format!(
294            "{} is empty \u{2014} nothing was recorded.",
295            path.display()
296        ));
297    };
298    let meta: Value = match serde_json::from_str(first_line.trim()) {
299        Ok(v) => v,
300        Err(err) => {
301            return soft_error(&format!(
302                "{} has no readable meta header: {err}.",
303                path.display()
304            ));
305        }
306    };
307    let id = meta.get("id").and_then(Value::as_str).map_or_else(
308        || {
309            path.file_stem()
310                .and_then(|s| s.to_str())
311                .unwrap_or("recording")
312                .to_owned()
313        },
314        str::to_owned,
315    );
316    let language = meta.get("language").and_then(Value::as_str).unwrap_or("");
317    let target = meta.get("target").and_then(Value::as_str).unwrap_or("");
318    let abs_path = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
319    let dest_dir = out_dir.map_or_else(
320        || path.parent().map(Path::to_path_buf).unwrap_or_default(),
321        Path::to_path_buf,
322    );
323
324    let (file_name, contents) = match language {
325        "python" => (
326            format!("test_{id}_replay.py"),
327            python_fixture(&id, &abs_path, target),
328        ),
329        "node" => (
330            format!("{id}.replay.test.mjs"),
331            node_fixture(&id, &abs_path, target),
332        ),
333        other => {
334            return soft_error(&format!(
335                "{} was recorded by an unknown language {other:?} \u{2014} cannot generate test glue for it.",
336                path.display()
337            ));
338        }
339    };
340    if let Err(err) = std::fs::create_dir_all(&dest_dir) {
341        return soft_error(&format!("could not create {}: {err}.", dest_dir.display()));
342    }
343    let dest = dest_dir.join(&file_name);
344    if let Err(err) = std::fs::write(&dest, contents) {
345        return soft_error(&format!("could not write {}: {err}.", dest.display()));
346    }
347
348    let report = TestGenReport {
349        generated: &dest.to_string_lossy(),
350        language,
351        recording: abs_path.to_string_lossy().into_owned(),
352    };
353    Rendered::ok(
354        format!(
355            "keel \u{25b8} generated {} from {}.\n  Fill in the call under test, then run it with your test runner.",
356            dest.display(),
357            abs_path.display()
358        ),
359        to_json(&report),
360    )
361}
362
363/// The machine twin of `keel record test`'s human confirmation.
364#[derive(Serialize)]
365struct TestGenReport<'a> {
366    generated: &'a str,
367    language: &'a str,
368    recording: String,
369}
370
371fn python_fixture(id: &str, recording_path: &Path, target: &str) -> String {
372    let path_str = recording_path.to_string_lossy();
373    // `id` (e.g. `19f7756bb6b-065f`) is hyphenated — fine as a file-name
374    // component and in prose (the docstring's `keel record test {id}` hint,
375    // which must stay the real id so it's copy-pasteable), but NOT a legal
376    // Python identifier. Only the `def test_..._replay` name needs the
377    // sanitized form.
378    let py_ident = id.replace('-', "_");
379    format!(
380        "\"\"\"Auto-generated by `keel record test` from the recording at\n{path_str}. Keel serves the recorded call outcomes for calls matching\nthe request-matching rule in docs/recording-format.md, and raises\n`keel.testing.UnmatchedEffect` on any call the recording does not cover —\nnever a silent live pass-through. Fill in the test body below; regenerate\nthis file (`keel record test {id}`) if you re-record.\n\"\"\"\n\nfrom keel.testing import replay_fixture\n\nkeel_replay = replay_fixture({path_str:?})\n\n\ndef test_{py_ident}_replay(keel_replay):\n    # TODO: call the code you recorded ({target:?}) here. Every intercepted\n    # effect it makes is served from the recording above.\n    raise NotImplementedError(\"fill in the call under test\")\n"
381    )
382}
383
384fn node_fixture(id: &str, recording_path: &Path, target: &str) -> String {
385    let path_json = to_json(&recording_path.to_string_lossy().into_owned());
386    let target_json = to_json(&target.to_owned());
387    format!(
388        "// Auto-generated by `keel record test` from the recording at\n// {}. Keel serves the recorded call outcomes for calls matching the\n// request-matching rule in docs/recording-format.md, and throws\n// UnmatchedEffectError on any call the recording does not cover — never a\n// silent live pass-through. Fill in the test body below; regenerate this\n// file (`keel record test {id}`) if you re-record.\nimport test from \"node:test\";\nimport {{ withReplay }} from \"keel/testing\";\n\ntest(\"{id} replay\", async () => {{\n  await withReplay({path_json}, async () => {{\n    // TODO: call the code you recorded ({target_json}) here. Every\n    // intercepted effect it makes is served from the recording above.\n    throw new Error(\"fill in the call under test\");\n  }});\n}});\n",
389        recording_path.display(),
390    )
391}
392
393/// A precise, non-fatal-to-the-process guidance error (exit 1, stderr) —
394/// mirrors `crate::flows::soft_error`.
395fn soft_error(message: &str) -> Rendered {
396    #[derive(Serialize)]
397    struct ErrReport<'a> {
398        error: &'a str,
399    }
400    Rendered {
401        human: format!("keel \u{25b8} {message}"),
402        json: to_json(&ErrReport { error: message }),
403        exit: EXIT_FAILURE,
404        to_stderr: true,
405    }
406}
407
408/// A usage error for `keel record` itself (an unresolvable `--out`, etc.).
409#[allow(dead_code)] // reserved for a future `--out` validation path
410fn usage_error(message: &str) -> Rendered {
411    #[derive(Serialize)]
412    struct ErrReport<'a> {
413        error: &'a str,
414    }
415    Rendered {
416        human: format!("keel \u{25b8} {message}"),
417        json: to_json(&ErrReport { error: message }),
418        exit: EXIT_USAGE,
419        to_stderr: true,
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use std::fs;
427    use tempfile::TempDir;
428
429    fn project() -> TempDir {
430        TempDir::new().unwrap()
431    }
432
433    fn write_recording(
434        dir: &Path,
435        id: &str,
436        language: &str,
437        target: &str,
438        calls: &[&str],
439    ) -> PathBuf {
440        let recordings = dir.join(".keel").join(RECORDINGS_SUBDIR);
441        fs::create_dir_all(&recordings).unwrap();
442        let path = recordings.join(format!("{id}.{RECORDING_EXT}"));
443        let mut body = format!(
444            "{{\"v\":1,\"type\":\"meta\",\"id\":{id:?},\"language\":{language:?},\"target\":{target:?},\"args\":[],\"started_at_ms\":1000,\"redacted_headers\":[]}}\n"
445        );
446        for call in calls {
447            body.push_str(call);
448            body.push('\n');
449        }
450        fs::write(&path, body).unwrap();
451        path
452    }
453
454    /// Actually parse `source` as Python (`compile(..., 'exec')`), so a
455    /// generated fixture's syntax is verified for real rather than trusted
456    /// from a string-contains assertion (issue #31 shipped undetected
457    /// specifically because no test ever did this). Skips quietly if
458    /// python3 isn't on PATH, matching this crate's other python3-gated
459    /// tests (e.g. `mcp.rs`'s doctor-report tests).
460    fn py_compile(source: &str) {
461        use std::io::Write as _;
462        let Ok(mut child) = std::process::Command::new("python3")
463            .arg("-c")
464            .arg("import sys; compile(sys.stdin.read(), '<generated>', 'exec')")
465            .stdin(std::process::Stdio::piped())
466            .stdout(std::process::Stdio::null())
467            .stderr(std::process::Stdio::piped())
468            .spawn()
469        else {
470            eprintln!("skip: python3 not available");
471            return;
472        };
473        child
474            .stdin
475            .take()
476            .expect("child stdin")
477            .write_all(source.as_bytes())
478            .expect("write source");
479        let out = child.wait_with_output().expect("wait for python3");
480        assert!(
481            out.status.success(),
482            "generated fixture is not valid Python: {}",
483            String::from_utf8_lossy(&out.stderr)
484        );
485    }
486
487    const OK_CALL: &str = r#"{"v":1,"type":"call","seq":1,"target":"api.example.com","op":"GET api.example.com/x","idempotent":true,"args_hash":"abc","attempts":1,"latency_ms":5,"body_captured":true,"outcome":{"v":1,"result":"ok","payload":{"__keel_http__":1,"status":200,"headers":[],"body_b64":"eA=="},"attempts":1,"from_cache":false,"waits_ms":[],"throttled":false,"throttle_wait_ms":0,"breaker":"closed","trace_id":"t-1"}}"#;
488    const ERROR_CALL: &str = r#"{"v":1,"type":"call","seq":2,"target":"api.example.com","op":"POST api.example.com/y","idempotent":false,"args_hash":null,"attempts":3,"latency_ms":9,"body_captured":false,"outcome":{"v":1,"result":"error","error":{"code":"KEEL-E010","class":"http","message":"HTTP 503"},"attempts":3,"from_cache":false,"waits_ms":[10,20],"throttled":false,"throttle_wait_ms":0,"breaker":"closed","trace_id":"t-2"}}"#;
489
490    #[test]
491    fn list_reports_calls_body_captured_and_errors() {
492        let dir = project();
493        write_recording(
494            dir.path(),
495            "000000001-0000",
496            "python",
497            "app.py",
498            &[OK_CALL, ERROR_CALL],
499        );
500        let r = list(dir.path());
501        assert_eq!(r.exit, crate::EXIT_OK);
502        assert_eq!(r.json["count"], 1);
503        let row = &r.json["recordings"][0];
504        assert_eq!(row["id"], "000000001-0000");
505        assert_eq!(row["language"], "python");
506        assert_eq!(row["target"], "app.py");
507        assert_eq!(row["calls"], 2);
508        assert_eq!(row["body_captured"], 1);
509        assert_eq!(row["errors"], 1);
510        assert!(r.human.contains("2 calls"));
511    }
512
513    #[test]
514    fn list_newest_first() {
515        let dir = project();
516        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[]);
517        write_recording(dir.path(), "000000002-0000", "node", "b.mjs", &[]);
518        let r = list(dir.path());
519        let ids: Vec<&str> = r.json["recordings"]
520            .as_array()
521            .unwrap()
522            .iter()
523            .map(|v| v["id"].as_str().unwrap())
524            .collect();
525        assert_eq!(ids, vec!["000000002-0000", "000000001-0000"]);
526    }
527
528    #[test]
529    fn list_empty_directory_is_a_friendly_zero() {
530        let dir = project();
531        let r = list(dir.path());
532        assert_eq!(r.exit, crate::EXIT_OK);
533        assert_eq!(r.json["count"], 0);
534        assert!(r.human.contains("no recordings yet"));
535    }
536
537    #[test]
538    fn list_skips_files_without_a_meta_header() {
539        let dir = project();
540        let recordings = dir.path().join(".keel").join(RECORDINGS_SUBDIR);
541        fs::create_dir_all(&recordings).unwrap();
542        fs::write(recordings.join("garbage.ndjson"), "not json\n").unwrap();
543        let r = list(dir.path());
544        assert_eq!(r.json["count"], 0);
545    }
546
547    #[test]
548    fn resolve_by_exact_id_and_unique_substring() {
549        let dir = project();
550        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[]);
551        assert!(resolve_recording(dir.path(), "000000001-0000").is_ok());
552        assert!(resolve_recording(dir.path(), "0001").is_ok());
553        assert!(resolve_recording(dir.path(), "does-not-exist").is_err());
554    }
555
556    #[test]
557    fn resolve_ambiguous_substring_is_a_precise_error() {
558        let dir = project();
559        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[]);
560        write_recording(dir.path(), "000000001-0001", "python", "a.py", &[]);
561        let err = resolve_recording(dir.path(), "000000001").unwrap_err();
562        assert!(err.human.contains("matches 2 recordings"));
563    }
564
565    #[test]
566    fn test_gen_writes_a_pytest_fixture_for_a_python_recording() {
567        let dir = project();
568        write_recording(
569            dir.path(),
570            "000000001-0000",
571            "python",
572            "py:app.pipeline:main",
573            &[OK_CALL],
574        );
575        let r = test_gen(dir.path(), "000000001-0000", None);
576        assert_eq!(r.exit, crate::EXIT_OK, "{r:?}");
577        let generated = r.json["generated"].as_str().unwrap();
578        assert!(generated.ends_with("test_000000001-0000_replay.py"));
579        let contents = fs::read_to_string(generated).unwrap();
580        assert!(contents.contains("from keel.testing import replay_fixture"));
581        assert!(contents.contains("def test_000000001_0000_replay(keel_replay):"));
582        assert!(contents.contains("keel record test 000000001-0000"));
583        assert!(contents.contains("py:app.pipeline:main"));
584        py_compile(&contents);
585    }
586
587    #[test]
588    fn test_gen_writes_a_node_test_file_for_a_node_recording() {
589        let dir = project();
590        write_recording(dir.path(), "000000002-0000", "node", "app.mjs", &[OK_CALL]);
591        let r = test_gen(dir.path(), "000000002-0000", None);
592        assert_eq!(r.exit, crate::EXIT_OK, "{r:?}");
593        let generated = r.json["generated"].as_str().unwrap();
594        assert!(generated.ends_with("000000002-0000.replay.test.mjs"));
595        let contents = fs::read_to_string(generated).unwrap();
596        assert!(contents.contains("import { withReplay } from \"keel/testing\";"));
597        assert!(contents.contains("test(\"000000002-0000 replay\""));
598    }
599
600    #[test]
601    fn test_gen_out_dir_is_honored() {
602        let dir = project();
603        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[OK_CALL]);
604        let out = dir.path().join("tests");
605        let r = test_gen(dir.path(), "000000001-0000", Some(&out));
606        assert_eq!(r.exit, crate::EXIT_OK);
607        assert!(
608            r.json["generated"]
609                .as_str()
610                .unwrap()
611                .contains(&out.to_string_lossy().into_owned())
612        );
613    }
614
615    #[test]
616    fn test_gen_unknown_language_is_a_precise_error() {
617        let dir = project();
618        let recordings = dir.path().join(".keel").join(RECORDINGS_SUBDIR);
619        fs::create_dir_all(&recordings).unwrap();
620        fs::write(
621            recordings.join("x.ndjson"),
622            "{\"v\":1,\"type\":\"meta\",\"id\":\"x\",\"language\":\"ruby\",\"target\":\"a.rb\",\"args\":[],\"started_at_ms\":0,\"redacted_headers\":[]}\n",
623        )
624        .unwrap();
625        let r = test_gen(dir.path(), "x", None);
626        assert_eq!(r.exit, EXIT_FAILURE);
627        assert!(r.human.contains("unknown language"));
628    }
629
630    #[test]
631    fn run_reports_dispatch_errors_exactly_like_keel_run() {
632        // `record::run` reuses `run::plan`, so an undispatchable target renders
633        // the identical what/why/next error `keel run` would — no subprocess
634        // ever spawns, so this needs no python3/node on the test machine.
635        let dir = project();
636        let (rendered, code) = run(dir.path(), "does-not-exist.py", &[]);
637        let r = rendered.expect("a dispatch error renders");
638        assert_eq!(code, EXIT_USAGE);
639        assert!(r.human.contains("no such file or directory"));
640        // No recordings directory was created for a target that never dispatched.
641        assert!(!dir.path().join(".keel").join(RECORDINGS_SUBDIR).exists());
642    }
643
644    #[test]
645    fn new_id_is_lowercase_hex_and_monotonic_enough_to_sort() {
646        let a = new_id();
647        let b = new_id();
648        assert!(a.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
649        assert!(a.contains('-'));
650        // Not asserting a < b (millisecond clocks can tie within a test), only
651        // that the shape is stable and reused by `run`'s path construction.
652        assert_eq!(a.split('-').count(), 2);
653        assert_eq!(b.split('-').count(), 2);
654    }
655}