1use 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
30pub const RECORDINGS_SUBDIR: &str = "recordings";
34pub const RECORDING_EXT: &str = "ndjson";
36
37fn 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
47fn recordings_dir(project: &Path) -> PathBuf {
49 project.join(".keel").join(RECORDINGS_SUBDIR)
50}
51
52pub 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#[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#[derive(Debug, Serialize)]
112struct RecordingsReport {
113 count: usize,
114 recordings: Vec<RecordingRow>,
115}
116
117pub 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)); 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
144fn 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
216fn 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
236fn 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
280pub 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#[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 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
393fn 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#[allow(dead_code)] fn 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 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 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 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 assert_eq!(a.split('-').count(), 2);
653 assert_eq!(b.split('-').count(), 2);
654 }
655}