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    format!(
374        "\"\"\"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_{id}_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"
375    )
376}
377
378fn node_fixture(id: &str, recording_path: &Path, target: &str) -> String {
379    let path_json = to_json(&recording_path.to_string_lossy().into_owned());
380    let target_json = to_json(&target.to_owned());
381    format!(
382        "// 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",
383        recording_path.display(),
384    )
385}
386
387/// A precise, non-fatal-to-the-process guidance error (exit 1, stderr) —
388/// mirrors `crate::flows::soft_error`.
389fn soft_error(message: &str) -> Rendered {
390    #[derive(Serialize)]
391    struct ErrReport<'a> {
392        error: &'a str,
393    }
394    Rendered {
395        human: format!("keel \u{25b8} {message}"),
396        json: to_json(&ErrReport { error: message }),
397        exit: EXIT_FAILURE,
398        to_stderr: true,
399    }
400}
401
402/// A usage error for `keel record` itself (an unresolvable `--out`, etc.).
403#[allow(dead_code)] // reserved for a future `--out` validation path
404fn usage_error(message: &str) -> Rendered {
405    #[derive(Serialize)]
406    struct ErrReport<'a> {
407        error: &'a str,
408    }
409    Rendered {
410        human: format!("keel \u{25b8} {message}"),
411        json: to_json(&ErrReport { error: message }),
412        exit: EXIT_USAGE,
413        to_stderr: true,
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use std::fs;
421    use tempfile::TempDir;
422
423    fn project() -> TempDir {
424        TempDir::new().unwrap()
425    }
426
427    fn write_recording(
428        dir: &Path,
429        id: &str,
430        language: &str,
431        target: &str,
432        calls: &[&str],
433    ) -> PathBuf {
434        let recordings = dir.join(".keel").join(RECORDINGS_SUBDIR);
435        fs::create_dir_all(&recordings).unwrap();
436        let path = recordings.join(format!("{id}.{RECORDING_EXT}"));
437        let mut body = format!(
438            "{{\"v\":1,\"type\":\"meta\",\"id\":{id:?},\"language\":{language:?},\"target\":{target:?},\"args\":[],\"started_at_ms\":1000,\"redacted_headers\":[]}}\n"
439        );
440        for call in calls {
441            body.push_str(call);
442            body.push('\n');
443        }
444        fs::write(&path, body).unwrap();
445        path
446    }
447
448    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"}}"#;
449    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"}}"#;
450
451    #[test]
452    fn list_reports_calls_body_captured_and_errors() {
453        let dir = project();
454        write_recording(
455            dir.path(),
456            "000000001-0000",
457            "python",
458            "app.py",
459            &[OK_CALL, ERROR_CALL],
460        );
461        let r = list(dir.path());
462        assert_eq!(r.exit, crate::EXIT_OK);
463        assert_eq!(r.json["count"], 1);
464        let row = &r.json["recordings"][0];
465        assert_eq!(row["id"], "000000001-0000");
466        assert_eq!(row["language"], "python");
467        assert_eq!(row["target"], "app.py");
468        assert_eq!(row["calls"], 2);
469        assert_eq!(row["body_captured"], 1);
470        assert_eq!(row["errors"], 1);
471        assert!(r.human.contains("2 calls"));
472    }
473
474    #[test]
475    fn list_newest_first() {
476        let dir = project();
477        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[]);
478        write_recording(dir.path(), "000000002-0000", "node", "b.mjs", &[]);
479        let r = list(dir.path());
480        let ids: Vec<&str> = r.json["recordings"]
481            .as_array()
482            .unwrap()
483            .iter()
484            .map(|v| v["id"].as_str().unwrap())
485            .collect();
486        assert_eq!(ids, vec!["000000002-0000", "000000001-0000"]);
487    }
488
489    #[test]
490    fn list_empty_directory_is_a_friendly_zero() {
491        let dir = project();
492        let r = list(dir.path());
493        assert_eq!(r.exit, crate::EXIT_OK);
494        assert_eq!(r.json["count"], 0);
495        assert!(r.human.contains("no recordings yet"));
496    }
497
498    #[test]
499    fn list_skips_files_without_a_meta_header() {
500        let dir = project();
501        let recordings = dir.path().join(".keel").join(RECORDINGS_SUBDIR);
502        fs::create_dir_all(&recordings).unwrap();
503        fs::write(recordings.join("garbage.ndjson"), "not json\n").unwrap();
504        let r = list(dir.path());
505        assert_eq!(r.json["count"], 0);
506    }
507
508    #[test]
509    fn resolve_by_exact_id_and_unique_substring() {
510        let dir = project();
511        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[]);
512        assert!(resolve_recording(dir.path(), "000000001-0000").is_ok());
513        assert!(resolve_recording(dir.path(), "0001").is_ok());
514        assert!(resolve_recording(dir.path(), "does-not-exist").is_err());
515    }
516
517    #[test]
518    fn resolve_ambiguous_substring_is_a_precise_error() {
519        let dir = project();
520        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[]);
521        write_recording(dir.path(), "000000001-0001", "python", "a.py", &[]);
522        let err = resolve_recording(dir.path(), "000000001").unwrap_err();
523        assert!(err.human.contains("matches 2 recordings"));
524    }
525
526    #[test]
527    fn test_gen_writes_a_pytest_fixture_for_a_python_recording() {
528        let dir = project();
529        write_recording(
530            dir.path(),
531            "000000001-0000",
532            "python",
533            "py:app.pipeline:main",
534            &[OK_CALL],
535        );
536        let r = test_gen(dir.path(), "000000001-0000", None);
537        assert_eq!(r.exit, crate::EXIT_OK, "{r:?}");
538        let generated = r.json["generated"].as_str().unwrap();
539        assert!(generated.ends_with("test_000000001-0000_replay.py"));
540        let contents = fs::read_to_string(generated).unwrap();
541        assert!(contents.contains("from keel.testing import replay_fixture"));
542        assert!(contents.contains("def test_000000001-0000_replay(keel_replay):"));
543        assert!(contents.contains("py:app.pipeline:main"));
544    }
545
546    #[test]
547    fn test_gen_writes_a_node_test_file_for_a_node_recording() {
548        let dir = project();
549        write_recording(dir.path(), "000000002-0000", "node", "app.mjs", &[OK_CALL]);
550        let r = test_gen(dir.path(), "000000002-0000", None);
551        assert_eq!(r.exit, crate::EXIT_OK, "{r:?}");
552        let generated = r.json["generated"].as_str().unwrap();
553        assert!(generated.ends_with("000000002-0000.replay.test.mjs"));
554        let contents = fs::read_to_string(generated).unwrap();
555        assert!(contents.contains("import { withReplay } from \"keel/testing\";"));
556        assert!(contents.contains("test(\"000000002-0000 replay\""));
557    }
558
559    #[test]
560    fn test_gen_out_dir_is_honored() {
561        let dir = project();
562        write_recording(dir.path(), "000000001-0000", "python", "a.py", &[OK_CALL]);
563        let out = dir.path().join("tests");
564        let r = test_gen(dir.path(), "000000001-0000", Some(&out));
565        assert_eq!(r.exit, crate::EXIT_OK);
566        assert!(
567            r.json["generated"]
568                .as_str()
569                .unwrap()
570                .contains(&out.to_string_lossy().into_owned())
571        );
572    }
573
574    #[test]
575    fn test_gen_unknown_language_is_a_precise_error() {
576        let dir = project();
577        let recordings = dir.path().join(".keel").join(RECORDINGS_SUBDIR);
578        fs::create_dir_all(&recordings).unwrap();
579        fs::write(
580            recordings.join("x.ndjson"),
581            "{\"v\":1,\"type\":\"meta\",\"id\":\"x\",\"language\":\"ruby\",\"target\":\"a.rb\",\"args\":[],\"started_at_ms\":0,\"redacted_headers\":[]}\n",
582        )
583        .unwrap();
584        let r = test_gen(dir.path(), "x", None);
585        assert_eq!(r.exit, EXIT_FAILURE);
586        assert!(r.human.contains("unknown language"));
587    }
588
589    #[test]
590    fn run_reports_dispatch_errors_exactly_like_keel_run() {
591        // `record::run` reuses `run::plan`, so an undispatchable target renders
592        // the identical what/why/next error `keel run` would — no subprocess
593        // ever spawns, so this needs no python3/node on the test machine.
594        let dir = project();
595        let (rendered, code) = run(dir.path(), "does-not-exist.py", &[]);
596        let r = rendered.expect("a dispatch error renders");
597        assert_eq!(code, EXIT_USAGE);
598        assert!(r.human.contains("no such file or directory"));
599        // No recordings directory was created for a target that never dispatched.
600        assert!(!dir.path().join(".keel").join(RECORDINGS_SUBDIR).exists());
601    }
602
603    #[test]
604    fn new_id_is_lowercase_hex_and_monotonic_enough_to_sort() {
605        let a = new_id();
606        let b = new_id();
607        assert!(a.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
608        assert!(a.contains('-'));
609        // Not asserting a < b (millisecond clocks can tie within a test), only
610        // that the shape is stable and reused by `run`'s path construction.
611        assert_eq!(a.split('-').count(), 2);
612        assert_eq!(b.split('-').count(), 2);
613    }
614}