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 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
387fn 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#[allow(dead_code)] fn 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 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 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 assert_eq!(a.split('-').count(), 2);
612 assert_eq!(b.split('-').count(), 2);
613 }
614}