1use std::fs::File;
35use std::io::{self, IsTerminal, Read, Write};
36use std::path::{Path, PathBuf};
37use std::time::Duration;
38
39use serde::Serialize;
40use serde_json::Value;
41
42use crate::render::to_json;
43use crate::{EXIT_FAILURE, Rendered};
44
45const EVENTS_EXT: &str = "ndjson";
47
48const POLL_INTERVAL: Duration = Duration::from_millis(150);
50
51#[derive(Debug, Clone)]
54pub struct TailOptions {
55 pub color: bool,
57 pub follow: bool,
59 pub json: bool,
61 pub run: Option<String>,
63}
64
65pub trait Ticker {
68 fn tick(&mut self) -> bool;
71}
72
73#[derive(Debug)]
76pub struct SleepTicker {
77 interval: Duration,
78}
79
80impl Default for SleepTicker {
81 fn default() -> Self {
82 Self {
83 interval: POLL_INTERVAL,
84 }
85 }
86}
87
88impl Ticker for SleepTicker {
89 fn tick(&mut self) -> bool {
90 std::thread::sleep(self.interval);
91 true
92 }
93}
94
95#[must_use]
98pub fn color_enabled() -> bool {
99 let no_color = std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty());
100 !no_color && io::stdout().is_terminal()
101}
102
103pub fn run(
109 project: &Path,
110 opts: &TailOptions,
111 out: &mut dyn Write,
112 ticker: &mut dyn Ticker,
113) -> Result<(), Rendered> {
114 let keel = project.join(".keel");
115 if !keel.is_dir() {
116 return Err(guidance(
117 "nothing to tail \u{2014} this directory has no .keel/.",
118 "Keel projects keep live event feeds in .keel/events/<run>.ndjson; \
119 the sink turns on when .keel/ exists (or KEEL_EVENTS=1 is set).",
120 "run `keel init` here, start your program with `keel run <script>`, \
121 then `keel tail`.",
122 ));
123 }
124 let dir = keel.join("events");
125
126 let mut cursor: Option<Cursor> = None;
127 let mut announced_wait = false;
128 loop {
129 if cursor.is_none() {
130 match select_run(&dir, opts.run.as_deref())? {
131 Some(path) => {
132 cursor = Some(Cursor::open(&path).map_err(|e| unreadable(&path, &e))?);
133 }
134 None if !opts.follow => return Err(no_runs()),
135 None => {
136 if !opts.json && !announced_wait {
137 let _ = writeln!(
138 out,
139 "waiting for a run\u{2026} (.keel/events/ is empty; start a program under `keel run`)"
140 );
141 announced_wait = true;
142 }
143 }
144 }
145 }
146 if let Some(c) = cursor.as_mut()
147 && c.drain(opts, out).is_err()
148 {
149 return Ok(());
151 }
152 if opts.follow
156 && opts.run.is_none()
157 && let Some(current) = cursor.as_ref()
158 && let Some((stem, path)) = newest_run(&dir)
159 && stem > current.stem
160 {
161 cursor = Some(Cursor::open(&path).map_err(|e| unreadable(&path, &e))?);
162 continue; }
164 let _ = out.flush();
165 if !opts.follow || !ticker.tick() {
166 break;
167 }
168 }
169 let _ = out.flush();
170 Ok(())
171}
172
173fn select_run(dir: &Path, pinned: Option<&str>) -> Result<Option<PathBuf>, Rendered> {
178 let runs = runs_in(dir);
179 match pinned {
180 Some(run) if runs.iter().any(|r| r == run) => {
181 Ok(Some(dir.join(format!("{run}.{EVENTS_EXT}"))))
182 }
183 Some(run) => Err(unknown_run(run, &runs)),
184 None => Ok(runs.last().map(|r| dir.join(format!("{r}.{EVENTS_EXT}")))),
185 }
186}
187
188fn runs_in(dir: &Path) -> Vec<String> {
190 let Ok(entries) = std::fs::read_dir(dir) else {
191 return Vec::new();
192 };
193 let mut runs: Vec<String> = entries
194 .flatten()
195 .filter_map(|entry| {
196 let path = entry.path();
197 if path.extension().and_then(|e| e.to_str()) != Some(EVENTS_EXT) {
198 return None;
199 }
200 path.file_stem().and_then(|s| s.to_str()).map(str::to_owned)
201 })
202 .collect();
203 runs.sort_unstable();
204 runs
205}
206
207fn newest_run(dir: &Path) -> Option<(String, PathBuf)> {
209 let stem = runs_in(dir).pop()?;
210 let path = dir.join(format!("{stem}.{EVENTS_EXT}"));
211 Some((stem, path))
212}
213
214struct Cursor {
219 stem: String,
220 file: File,
221 carry: Vec<u8>,
222}
223
224impl Cursor {
225 fn open(path: &Path) -> io::Result<Self> {
226 Ok(Self {
227 stem: path
228 .file_stem()
229 .and_then(|s| s.to_str())
230 .unwrap_or_default()
231 .to_owned(),
232 file: File::open(path)?,
233 carry: Vec::new(),
234 })
235 }
236
237 fn drain(&mut self, opts: &TailOptions, out: &mut dyn Write) -> io::Result<()> {
241 let mut buf = Vec::new();
242 if self.file.read_to_end(&mut buf).is_ok() {
243 self.carry.extend_from_slice(&buf);
244 }
245 while let Some(pos) = self.carry.iter().position(|&b| b == b'\n') {
246 let line: Vec<u8> = self.carry.drain(..=pos).collect();
247 let text = String::from_utf8_lossy(&line[..pos]);
248 render_line(text.trim_end_matches('\r'), opts, out)?;
249 }
250 Ok(())
251 }
252}
253
254fn render_line(line: &str, opts: &TailOptions, out: &mut dyn Write) -> io::Result<()> {
259 if line.trim().is_empty() {
260 return Ok(());
261 }
262 let Ok(value) = serde_json::from_str::<Value>(line) else {
263 return Ok(());
264 };
265 if !value.is_object() {
266 return Ok(());
267 }
268 if opts.json {
269 return writeln!(out, "{value}");
270 }
271 if let Some(text) = human_line(&value, opts.color) {
272 writeln!(out, "{text}")?;
273 }
274 Ok(())
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279enum Tone {
280 Cyan,
281 Dim,
282 Green,
283 Plain,
284 Red,
285 Yellow,
286}
287
288fn paint(text: &str, tone: Tone, color: bool) -> String {
291 let code = match tone {
292 _ if !color => return text.to_owned(),
293 Tone::Plain => return text.to_owned(),
294 Tone::Cyan => "36",
295 Tone::Dim => "2",
296 Tone::Green => "32",
297 Tone::Red => "31",
298 Tone::Yellow => "33",
299 };
300 format!("\u{1b}[{code}m{text}\u{1b}[0m")
301}
302
303fn fmt_clock(ms: u64) -> String {
306 let minutes = ms / 60_000;
307 let seconds = (ms % 60_000) / 1000;
308 let millis = ms % 1000;
309 format!("{minutes:02}:{seconds:02}.{millis:03}")
310}
311
312fn fmt_wait(ms: u64) -> String {
315 if ms < 1000 {
316 format!("{ms}ms")
317 } else if ms.is_multiple_of(1000) {
318 format!("{}s", ms / 1000)
319 } else {
320 format!("{}.{}s", ms / 1000, (ms % 1000) / 100)
321 }
322}
323
324fn fmt_attempts(n: u64) -> String {
326 if n == 1 {
327 "1 attempt".to_owned()
328 } else {
329 format!("{n} attempts")
330 }
331}
332
333fn human_line(event: &Value, color: bool) -> Option<String> {
339 let ms = event.get("ms").and_then(Value::as_u64)?;
340 let kind = event.get("event").and_then(Value::as_str)?;
341 let time = paint(&fmt_clock(ms), Tone::Dim, color);
342
343 if kind == "run_start" {
344 let run = event.get("run").and_then(Value::as_str).unwrap_or("?");
345 let pid = event
346 .get("pid")
347 .and_then(Value::as_u64)
348 .map(|p| format!(" (pid {p})"))
349 .unwrap_or_default();
350 return Some(format!(
351 "{time} {} {run}{pid}",
352 paint("run", Tone::Dim, color)
353 ));
354 }
355
356 let call = event.get("call").and_then(Value::as_str).unwrap_or("-");
357 let target = event.get("target").and_then(Value::as_str).unwrap_or("-");
358 let attempt = event.get("attempt").and_then(Value::as_u64).unwrap_or(0);
359 let wait_ms = event.get("wait_ms").and_then(Value::as_u64).unwrap_or(0);
360 let scope = event.get("scope").and_then(Value::as_str).unwrap_or("?");
361
362 let (verb, tone, detail) = match kind {
363 "call_start" => (
364 "call",
365 Tone::Cyan,
366 event
367 .get("op")
368 .and_then(Value::as_str)
369 .unwrap_or("")
370 .to_owned(),
371 ),
372 "cache_hit" => ("cache", Tone::Green, format!("hit ({scope})")),
373 "cache_miss" => ("cache", Tone::Dim, format!("miss ({scope})")),
374 "throttle" => (
375 "rate",
376 Tone::Yellow,
377 format!("queued {}", fmt_wait(wait_ms)),
378 ),
379 "breaker_reject" => (
380 "breaker",
381 Tone::Red,
382 "rejected \u{2014} open, failing fast".to_owned(),
383 ),
384 "breaker_half_open" => (
385 "breaker",
386 Tone::Yellow,
387 "half-open \u{2014} probing".to_owned(),
388 ),
389 "breaker_open" => {
390 let cooldown = event
391 .get("cooldown_ms")
392 .and_then(Value::as_u64)
393 .unwrap_or(0);
394 (
395 "breaker",
396 Tone::Red,
397 format!("opened (cooldown {})", fmt_wait(cooldown)),
398 )
399 }
400 "breaker_close" => ("breaker", Tone::Green, "closed".to_owned()),
401 "attempt_start" => ("attempt", Tone::Plain, format!("#{attempt}")),
402 "attempt_error" => {
403 let class = event.get("class").and_then(Value::as_str).unwrap_or("?");
404 let status = event
405 .get("http_status")
406 .and_then(Value::as_u64)
407 .map(|s| format!(" {s}"))
408 .unwrap_or_default();
409 ("fail", Tone::Yellow, format!("#{attempt} {class}{status}"))
410 }
411 "backoff" => (
412 "backoff",
413 Tone::Yellow,
414 format!("{} \u{2192} #{}", fmt_wait(wait_ms), attempt + 1),
415 ),
416 "call_end" => {
417 let attempts = event.get("attempts").and_then(Value::as_u64).unwrap_or(0);
418 if event.get("result").and_then(Value::as_str) == Some("ok") {
419 ("ok", Tone::Green, fmt_attempts(attempts))
420 } else {
421 let code = event.get("code").and_then(Value::as_str).unwrap_or("error");
422 (
423 "error",
424 Tone::Red,
425 format!("{code} after {}", fmt_attempts(attempts)),
426 )
427 }
428 }
429 other => (other, Tone::Dim, String::new()),
430 };
431
432 let verb_padded = format!("{verb:<8}");
433 let line = format!(
434 "{time} {call:<9} {target:<24} {} {detail}",
435 paint(&verb_padded, tone, color)
436 );
437 Some(line.trim_end().to_owned())
438}
439
440fn guidance(what: &str, why: &str, next: &str) -> Rendered {
443 #[derive(Serialize)]
444 struct Guidance<'a> {
445 error: &'a str,
446 next: &'a str,
447 why: &'a str,
448 }
449 Rendered {
450 human: format!("keel \u{25b8} {what}\n why: {why}\n next: {next}"),
451 json: to_json(&Guidance {
452 error: what,
453 next,
454 why,
455 }),
456 exit: EXIT_FAILURE,
457 to_stderr: true,
458 }
459}
460
461fn no_runs() -> Rendered {
463 guidance(
464 "no runs recorded yet \u{2014} .keel/events/ is empty.",
465 "each program run under Keel appends its live events to \
466 .keel/events/<run>.ndjson (on by default in a Keel project; \
467 KEEL_EVENTS=1 forces it).",
468 "start your program with `keel run <script>`, then re-run `keel tail` \
469 \u{2014} without --no-follow it waits for the run to appear.",
470 )
471}
472
473fn unknown_run(run: &str, available: &[String]) -> Rendered {
475 let why = if available.is_empty() {
476 "no runs are recorded under .keel/events/ yet.".to_owned()
477 } else {
478 let newest_first: Vec<&str> = available.iter().rev().take(5).map(String::as_str).collect();
479 format!(
480 "available runs (newest first): {}.",
481 newest_first.join(", ")
482 )
483 };
484 guidance(
485 &format!("run {run:?} not found under .keel/events/."),
486 &why,
487 "`keel tail --run <id>` with a recorded id, or plain `keel tail` for \
488 the newest run.",
489 )
490}
491
492fn unreadable(path: &Path, error: &io::Error) -> Rendered {
494 guidance(
495 &format!("cannot read {}.", path.display()),
496 &format!("{error}."),
497 "check the file's permissions, or remove it and re-run the program.",
498 )
499}
500
501#[cfg(test)]
502mod tests {
503 use super::{
504 Cursor, TailOptions, Ticker, fmt_clock, fmt_wait, human_line, paint, run, runs_in,
505 select_run,
506 };
507 use serde_json::{Value, json};
508 use std::io::Write as _;
509 use std::path::{Path, PathBuf};
510
511 fn opts(follow: bool, json: bool) -> TailOptions {
512 TailOptions {
513 color: false,
514 follow,
515 json,
516 run: None,
517 }
518 }
519
520 struct ScriptTicker<F: FnMut(usize) -> bool> {
523 n: usize,
524 f: F,
525 }
526
527 impl<F: FnMut(usize) -> bool> ScriptTicker<F> {
528 fn new(f: F) -> Self {
529 Self { n: 0, f }
530 }
531 }
532
533 impl<F: FnMut(usize) -> bool> Ticker for ScriptTicker<F> {
534 fn tick(&mut self) -> bool {
535 let go = (self.f)(self.n);
536 self.n += 1;
537 go
538 }
539 }
540
541 fn no_tick() -> ScriptTicker<impl FnMut(usize) -> bool> {
543 ScriptTicker::new(|_| false)
544 }
545
546 fn project_with_events(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
547 let dir = tempfile::TempDir::new().unwrap();
548 let events = dir.path().join(".keel").join("events");
549 std::fs::create_dir_all(&events).unwrap();
550 for (run, body) in files {
551 std::fs::write(events.join(format!("{run}.ndjson")), body).unwrap();
552 }
553 let project = dir.path().to_path_buf();
554 (dir, project)
555 }
556
557 fn tail_to_string(project: &Path, o: &TailOptions, ticker: &mut dyn Ticker) -> String {
558 let mut out = Vec::new();
559 run(project, o, &mut out, ticker).expect("tail succeeds");
560 String::from_utf8(out).unwrap()
561 }
562
563 fn line(event: &Value) -> Option<String> {
564 human_line(event, false)
565 }
566
567 #[test]
570 fn renders_every_event_kind() {
571 let base = json!({"call": "t-000001", "target": "api.example.com"});
572 let ev = |ms: u64, tag: &str, extra: Value| {
573 let mut v = base.clone();
574 let obj = v.as_object_mut().unwrap();
575 obj.insert("ms".into(), json!(ms));
576 obj.insert("event".into(), json!(tag));
577 for (k, val) in extra.as_object().unwrap() {
578 obj.insert(k.clone(), val.clone());
579 }
580 v
581 };
582
583 assert_eq!(
584 line(&json!({"ms": 0, "event": "run_start", "run": "r-1", "pid": 42})).unwrap(),
585 "00:00.000 run r-1 (pid 42)"
586 );
587 assert_eq!(
588 line(&json!({"ms": 0, "event": "run_start", "run": "r-1"})).unwrap(),
589 "00:00.000 run r-1"
590 );
591 assert_eq!(
592 line(&ev(5, "call_start", json!({"op": "GET api.example.com"}))).unwrap(),
593 "00:00.005 t-000001 api.example.com call GET api.example.com"
594 );
595 assert_eq!(
596 line(&ev(5, "cache_hit", json!({"scope": "memory"}))).unwrap(),
597 "00:00.005 t-000001 api.example.com cache hit (memory)"
598 );
599 assert_eq!(
600 line(&ev(5, "cache_miss", json!({"scope": "persistent"}))).unwrap(),
601 "00:00.005 t-000001 api.example.com cache miss (persistent)"
602 );
603 assert_eq!(
604 line(&ev(5, "throttle", json!({"wait_ms": 150}))).unwrap(),
605 "00:00.005 t-000001 api.example.com rate queued 150ms"
606 );
607 assert_eq!(
608 line(&ev(5, "breaker_reject", json!({}))).unwrap(),
609 "00:00.005 t-000001 api.example.com breaker rejected \u{2014} open, failing fast"
610 );
611 assert_eq!(
612 line(&ev(5, "breaker_half_open", json!({}))).unwrap(),
613 "00:00.005 t-000001 api.example.com breaker half-open \u{2014} probing"
614 );
615 assert_eq!(
616 line(&ev(5, "breaker_open", json!({"cooldown_ms": 30000}))).unwrap(),
617 "00:00.005 t-000001 api.example.com breaker opened (cooldown 30s)"
618 );
619 assert_eq!(
620 line(&ev(5, "breaker_close", json!({}))).unwrap(),
621 "00:00.005 t-000001 api.example.com breaker closed"
622 );
623 assert_eq!(
624 line(&ev(5, "attempt_start", json!({"attempt": 2}))).unwrap(),
625 "00:00.005 t-000001 api.example.com attempt #2"
626 );
627 assert_eq!(
628 line(&ev(
629 5,
630 "attempt_error",
631 json!({"attempt": 1, "class": "http", "http_status": 503})
632 ))
633 .unwrap(),
634 "00:00.005 t-000001 api.example.com fail #1 http 503"
635 );
636 assert_eq!(
637 line(&ev(
638 5,
639 "attempt_error",
640 json!({"attempt": 2, "class": "timeout"})
641 ))
642 .unwrap(),
643 "00:00.005 t-000001 api.example.com fail #2 timeout"
644 );
645 assert_eq!(
646 line(&ev(5, "backoff", json!({"attempt": 1, "wait_ms": 200}))).unwrap(),
647 "00:00.005 t-000001 api.example.com backoff 200ms \u{2192} #2"
648 );
649 assert_eq!(
650 line(&ev(5, "call_end", json!({"result": "ok", "attempts": 1}))).unwrap(),
651 "00:00.005 t-000001 api.example.com ok 1 attempt"
652 );
653 assert_eq!(
654 line(&ev(
655 5,
656 "call_end",
657 json!({"result": "error", "code": "KEEL-E010", "attempts": 3})
658 ))
659 .unwrap(),
660 "00:00.005 t-000001 api.example.com error KEEL-E010 after 3 attempts"
661 );
662 assert_eq!(
664 line(&ev(5, "budget_exceeded", json!({}))).unwrap(),
665 "00:00.005 t-000001 api.example.com budget_exceeded"
666 );
667 assert_eq!(line(&json!({"note": "not an event"})), None);
669 }
670
671 #[test]
672 fn color_paints_the_verb_without_breaking_alignment() {
673 let ev = json!({
674 "ms": 5, "event": "call_start", "call": "t-000001",
675 "target": "api.example.com", "op": "GET api.example.com"
676 });
677 let colored = human_line(&ev, true).unwrap();
678 assert!(colored.contains("\u{1b}[36mcall \u{1b}[0m"));
680 assert!(colored.starts_with("\u{1b}[2m00:00.005\u{1b}[0m"));
682 assert_eq!(paint("attempt ", super::Tone::Plain, true), "attempt ");
684 }
685
686 #[test]
687 fn clock_and_wait_formats() {
688 assert_eq!(fmt_clock(0), "00:00.000");
689 assert_eq!(fmt_clock(31_080), "00:31.080");
690 assert_eq!(fmt_clock(61_005), "01:01.005");
691 assert_eq!(fmt_clock(6_000_000), "100:00.000");
692 assert_eq!(fmt_wait(450), "450ms");
693 assert_eq!(fmt_wait(1000), "1s");
694 assert_eq!(fmt_wait(1550), "1.5s");
695 assert_eq!(fmt_wait(30_000), "30s");
696 }
697
698 #[test]
701 fn newest_run_wins_by_name_sort_and_pin_overrides() {
702 let (_d, project) = project_with_events(&[
703 (
704 "0000000f00d-0001",
705 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"0000000f00d-0001\"}\n",
706 ),
707 (
708 "0000000f00e-0002",
709 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"0000000f00e-0002\"}\n",
710 ),
711 ]);
712 let dir = project.join(".keel").join("events");
713 assert_eq!(runs_in(&dir), vec!["0000000f00d-0001", "0000000f00e-0002"]);
714
715 let newest = tail_to_string(&project, &opts(false, false), &mut no_tick());
716 assert!(newest.contains("run 0000000f00e-0002"));
717 assert!(!newest.contains("run 0000000f00d-0001"));
718
719 let mut pinned = opts(false, false);
720 pinned.run = Some("0000000f00d-0001".to_owned());
721 let old = tail_to_string(&project, &pinned, &mut no_tick());
722 assert!(old.contains("run 0000000f00d-0001"));
723 }
724
725 #[test]
726 fn unknown_pinned_run_lists_whats_available() {
727 let (_d, project) = project_with_events(&[("0000000f00d-0001", "")]);
728 let dir = project.join(".keel").join("events");
729 let err = select_run(&dir, Some("zzz")).unwrap_err();
730 assert_eq!(err.exit, crate::EXIT_FAILURE);
731 assert!(err.to_stderr);
732 assert!(err.human.contains("run \"zzz\" not found"));
733 assert!(err.human.contains("0000000f00d-0001"));
734 assert_eq!(
735 err.json["error"],
736 "run \"zzz\" not found under .keel/events/."
737 );
738 }
739
740 #[test]
741 fn missing_keel_dir_explains_what_why_next() {
742 let dir = tempfile::TempDir::new().unwrap();
743 let mut out = Vec::new();
744 let err = run(dir.path(), &opts(false, false), &mut out, &mut no_tick()).unwrap_err();
745 assert_eq!(err.exit, crate::EXIT_FAILURE);
746 assert!(err.to_stderr);
747 assert!(err.human.contains("nothing to tail"));
748 assert!(err.human.contains("why:"));
749 assert!(err.human.contains("next:"));
750 assert!(err.json["next"].as_str().unwrap().contains("keel init"));
751 }
752
753 #[test]
754 fn empty_events_dir_without_follow_explains() {
755 let (_d, project) = project_with_events(&[]);
756 let mut out = Vec::new();
757 let err = run(&project, &opts(false, false), &mut out, &mut no_tick()).unwrap_err();
758 assert!(err.human.contains("no runs recorded yet"));
759 }
760
761 #[test]
764 fn follow_picks_up_lines_appended_mid_run() {
765 let (_d, project) = project_with_events(&[(
766 "run-a",
767 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"run-a\"}\n",
768 )]);
769 let feed = project.join(".keel").join("events").join("run-a.ndjson");
770 let appended = "{\"v\":1,\"seq\":1,\"ms\":7,\"event\":\"call_start\",\"call\":\"t-000001\",\"target\":\"api.example.com\",\"op\":\"GET api.example.com\"}";
771 let mut ticker = ScriptTicker::new(move |n| {
772 if n == 0 {
773 let mut f = std::fs::OpenOptions::new()
774 .append(true)
775 .open(&feed)
776 .unwrap();
777 writeln!(f, "{appended}").unwrap();
778 true
779 } else {
780 false
781 }
782 });
783 let text = tail_to_string(&project, &opts(true, false), &mut ticker);
784 assert!(text.contains("run run-a"));
785 assert!(text.contains("call GET api.example.com"));
786 }
787
788 #[test]
789 fn follow_buffers_a_partial_line_until_its_newline_arrives() {
790 let full = "{\"v\":1,\"seq\":1,\"ms\":7,\"event\":\"call_start\",\"call\":\"t-000001\",\"target\":\"api.example.com\",\"op\":\"GET api.example.com\"}";
791 let (head, rest) = full.split_at(40);
792 let (_d, project) = project_with_events(&[("run-a", head)]);
793 let feed = project.join(".keel").join("events").join("run-a.ndjson");
794
795 let rest_owned = rest.to_owned();
797 let empty = tail_to_string(&project, &opts(false, true), &mut no_tick());
798 assert_eq!(empty, "");
799
800 let mut ticker = ScriptTicker::new(move |n| {
801 if n == 0 {
802 let mut f = std::fs::OpenOptions::new()
803 .append(true)
804 .open(&feed)
805 .unwrap();
806 writeln!(f, "{rest_owned}").unwrap();
807 true
808 } else {
809 false
810 }
811 });
812 let text = tail_to_string(&project, &opts(true, true), &mut ticker);
813 assert_eq!(text.lines().count(), 1);
815 assert!(text.starts_with("{\"call\":\"t-000001\""));
816 }
817
818 #[test]
819 fn follow_rotates_to_a_newer_run() {
820 let (_d, project) = project_with_events(&[(
821 "0000000f00d-0001",
822 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"0000000f00d-0001\"}\n",
823 )]);
824 let events = project.join(".keel").join("events");
825 let mut ticker = ScriptTicker::new(move |n| {
826 if n == 0 {
827 std::fs::write(
828 events.join("0000000f00e-0002.ndjson"),
829 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"0000000f00e-0002\"}\n",
830 )
831 .unwrap();
832 true
833 } else {
834 false
835 }
836 });
837 let text = tail_to_string(&project, &opts(true, false), &mut ticker);
838 let lines: Vec<&str> = text.lines().collect();
839 assert_eq!(lines.len(), 2, "old header then new header: {text}");
840 assert!(lines[0].contains("run 0000000f00d-0001"));
841 assert!(lines[1].contains("run 0000000f00e-0002"));
842 }
843
844 #[test]
845 fn follow_waits_for_the_first_run_and_says_so() {
846 let (_d, project) = project_with_events(&[]);
847 let events = project.join(".keel").join("events");
848 let mut ticker = ScriptTicker::new(move |n| {
849 if n == 0 {
850 std::fs::write(
851 events.join("run-late.ndjson"),
852 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"run-late\"}\n",
853 )
854 .unwrap();
855 true
856 } else {
857 false
858 }
859 });
860 let text = tail_to_string(&project, &opts(true, false), &mut ticker);
861 let lines: Vec<&str> = text.lines().collect();
862 assert!(lines[0].starts_with("waiting for a run"), "{text}");
863 assert!(lines[1].contains("run run-late"));
864 }
865
866 #[test]
867 fn json_mode_never_prints_the_waiting_notice() {
868 let (_d, project) = project_with_events(&[]);
869 let events = project.join(".keel").join("events");
870 let mut ticker = ScriptTicker::new(move |n| {
871 if n == 0 {
872 std::fs::write(
873 events.join("run-late.ndjson"),
874 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"run-late\"}\n",
875 )
876 .unwrap();
877 true
878 } else {
879 false
880 }
881 });
882 let text = tail_to_string(&project, &opts(true, true), &mut ticker);
883 assert!(text.starts_with('{'), "pure NDJSON, no notices: {text}");
884 }
885
886 #[test]
889 fn malformed_and_non_object_lines_are_skipped() {
890 let body = "not json at all\n\
891 {\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"run-a\"}\n\
892 [1,2,3]\n\
893 \n";
894 let (_d, project) = project_with_events(&[("run-a", body)]);
895 let human = tail_to_string(&project, &opts(false, false), &mut no_tick());
896 assert_eq!(human.lines().count(), 1);
897 let ndjson = tail_to_string(&project, &opts(false, true), &mut no_tick());
898 assert_eq!(ndjson.lines().count(), 1);
899 assert!(ndjson.starts_with("{\"event\":\"run_start\""));
900 }
901
902 #[test]
903 fn cursor_drain_is_incremental() {
904 let dir = tempfile::TempDir::new().unwrap();
905 let path = dir.path().join("run-x.ndjson");
906 std::fs::write(
907 &path,
908 "{\"v\":1,\"seq\":0,\"ms\":0,\"event\":\"run_start\",\"run\":\"run-x\"}\n",
909 )
910 .unwrap();
911 let mut cursor = Cursor::open(&path).unwrap();
912 let o = opts(false, true);
913
914 let mut out = Vec::new();
915 cursor.drain(&o, &mut out).unwrap();
916 assert_eq!(String::from_utf8(out).unwrap().lines().count(), 1);
917
918 let mut out = Vec::new();
920 cursor.drain(&o, &mut out).unwrap();
921 assert!(out.is_empty());
922 }
923}