Skip to main content

keel_cli/
tail.rs

1//! `keel tail` — the live view of what Keel is doing for a running program:
2//! attempts, backoff waits, breaker open/half-open/close, rate-limit queueing,
3//! cache hits (dx-spec §6 — "the conversion moment").
4//!
5//! # Where the events come from
6//!
7//! The engine's event sink (`keel-core/src/events.rs`) appends one NDJSON
8//! line per event to `.keel/events/<run>.ndjson`. `keel tail` only *reads*
9//! those files — no daemon, no IPC (dx invariant 3). The line format is
10//! versioned (`"v": 1`) but non-contract; this module parses lines as plain
11//! JSON values so an unknown future event degrades to a generic line instead
12//! of a parse failure.
13//!
14//! # Modes
15//!
16//! - default: follow the newest run file, polling for appended lines and for
17//!   a newer run superseding it (a new `keel run` mid-tail rotates the view;
18//!   the new run's `run_start` header announces the switch). Runs until
19//!   interrupted.
20//! - `--run <id>`: pin one run; rotation is disabled.
21//! - `--no-follow`: render what is already recorded and exit — the
22//!   deterministic snapshot mode (golden-testable byte-for-byte).
23//! - `--json`: raw NDJSON passthrough with sorted keys (serde_json maps are
24//!   `BTreeMap`s), one event per line — an agent can stream-parse it.
25//!
26//! # Determinism and color
27//!
28//! The human renderer derives everything from the event line (`ms` is
29//! engine-elapsed, never wall time), so `--no-follow` output is byte-stable
30//! for a fixed feed. ANSI color is plain SGR, applied only when stdout is a
31//! terminal and `NO_COLOR` is unset ([`color_enabled`]); the `color` flag is
32//! explicit in [`TailOptions`] so tests pin both looks.
33
34use 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
45/// File extension of a run's event feed (mirrors the sink; non-contract).
46const EVENTS_EXT: &str = "ndjson";
47
48/// How often the production follow loop polls for new bytes / newer runs.
49const POLL_INTERVAL: Duration = Duration::from_millis(150);
50
51/// What `keel tail` was asked to do. `follow` is the default CLI behavior;
52/// `--no-follow` flips it off for deterministic snapshots.
53#[derive(Debug, Clone)]
54pub struct TailOptions {
55    /// Apply ANSI color to the human view (resolved by [`color_enabled`]).
56    pub color: bool,
57    /// Keep polling for new events (and newer runs) until stopped.
58    pub follow: bool,
59    /// Emit raw NDJSON (sorted keys) instead of the human view.
60    pub json: bool,
61    /// Pin a specific run id instead of following the newest run.
62    pub run: Option<String>,
63}
64
65/// Drives the follow loop's waiting, injected so tests never sleep for real:
66/// each call separates two polls and its return value decides continuation.
67pub trait Ticker {
68    /// Wait one poll interval. Return `false` to stop following — the
69    /// production ticker never does; `keel tail` runs until interrupted.
70    fn tick(&mut self) -> bool;
71}
72
73/// Production ticker: a plain sleep between cheap re-reads (no file-watcher
74/// dependency; the feed is a local append-only file).
75#[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/// Whether the human view should use ANSI color: stdout is a terminal and
96/// `NO_COLOR` is unset or empty (<https://no-color.org>).
97#[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
103/// Run `keel tail` for `project`, writing rendered events to `out`. Errors
104/// (no `.keel/`, no runs in snapshot mode, an unknown `--run`) come back as a
105/// [`Rendered`] guidance report for the caller to emit; success streams to
106/// `out` and returns once following stops ([`Ticker::tick`] returning false,
107/// or immediately under `--no-follow`).
108pub 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            // The reader went away (e.g. `keel tail | head`); stop cleanly.
150            return Ok(());
151        }
152        // Run rotation: a strictly newer run supersedes the one being
153        // followed — its run_start header announces the switch. Never when
154        // --run pinned the choice.
155        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; // drain the new run before the next tick
163        }
164        let _ = out.flush();
165        if !opts.follow || !ticker.tick() {
166            break;
167        }
168    }
169    let _ = out.flush();
170    Ok(())
171}
172
173/// Pick the event file to read: the pinned run when `--run` was given (an
174/// unknown id is a guidance error listing what exists), otherwise the newest
175/// run by name — run ids are zero-padded hex epoch-milliseconds, so the
176/// lexically greatest stem is the latest run. `None` means no runs yet.
177fn 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
188/// The run ids recorded under `dir` (stems of `*.ndjson`), sorted ascending.
189fn 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
207/// The newest run's `(stem, path)` under `dir`, if any.
208fn 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
214/// A position in one run's feed. The open file handle keeps its own read
215/// offset (appends show up on the next read); `carry` buffers a trailing
216/// partial line until its newline arrives, so a mid-write line is never
217/// rendered half-formed.
218struct 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    /// Read newly appended bytes and render every *complete* line. Read
238    /// errors are treated as "no new data" (transient; the next poll
239    /// retries); write errors propagate — the output side is gone.
240    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
254/// Render one feed line: JSON mode re-serializes the parsed value (compact,
255/// keys sorted by serde_json's `BTreeMap`); human mode formats it via
256/// [`human_line`]. Lines that are not JSON objects are skipped — the feed is
257/// best-effort observability, never a hard failure.
258fn 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/// ANSI tones the human view uses. `Plain` renders no codes at all.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279enum Tone {
280    Cyan,
281    Dim,
282    Green,
283    Plain,
284    Red,
285    Yellow,
286}
287
288/// Wrap `text` in the tone's SGR codes when color is on. Padding must be
289/// applied *before* painting — the escape codes have zero display width.
290fn 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
303/// Engine-elapsed `ms` as a fixed-width clock, `mm:ss.mmm` (grows past 99
304/// minutes naturally).
305fn 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
312/// A wait/cooldown duration for humans: `450ms` below a second, else seconds
313/// with tenths (`1.5s`, `30s`).
314fn 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
324/// "1 attempt" / "3 attempts".
325fn fmt_attempts(n: u64) -> String {
326    if n == 1 {
327        "1 attempt".to_owned()
328    } else {
329        format!("{n} attempts")
330    }
331}
332
333/// Format one parsed event for humans:
334/// `mm:ss.mmm  <call>  <target>  <verb>  <detail>`, with the verb tone-coded
335/// (green ok / yellow degradation / red failure). Unknown event kinds render
336/// generically (dim verb, no detail) — the vocabulary may grow. Returns
337/// `None` only when the line has no `ms`/`event` envelope at all.
338fn 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
440/// A what/why/next guidance report (exit 1, stderr) — the tail equivalents of
441/// `keel flows`' soft errors, with the remedy spelled out.
442fn 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
461/// `--no-follow` with nothing recorded yet.
462fn 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
473/// `--run <id>` named a run that is not on disk.
474fn 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
492/// An event file that exists but cannot be opened/read.
493fn 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    /// A ticker driven by a closure over the tick index — tests script file
521    /// mutations between polls and bound the loop, with zero real sleeps.
522    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    /// A never-ticking ticker for `--no-follow` calls.
542    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    // ---- renderer -------------------------------------------------------
568
569    #[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        // An event kind this build has never heard of still gets a line.
663        assert_eq!(
664            line(&ev(5, "budget_exceeded", json!({}))).unwrap(),
665            "00:00.005  t-000001  api.example.com          budget_exceeded"
666        );
667        // No envelope at all → nothing to render.
668        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        // Verb padded first, then wrapped — codes sit outside the 8 columns.
679        assert!(colored.contains("\u{1b}[36mcall    \u{1b}[0m"));
680        // Time column is dimmed.
681        assert!(colored.starts_with("\u{1b}[2m00:00.005\u{1b}[0m"));
682        // And the plain tone paints nothing.
683        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    // ---- selection and errors -------------------------------------------
699
700    #[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    // ---- following ------------------------------------------------------
762
763    #[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        // Snapshot mode sees no complete line yet.
796        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        // The reassembled line comes out exactly once, keys sorted.
814        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    // ---- line hygiene ---------------------------------------------------
887
888    #[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        // Nothing new → nothing rendered (no re-reads from the start).
919        let mut out = Vec::new();
920        cursor.drain(&o, &mut out).unwrap();
921        assert!(out.is_empty());
922    }
923}