| 1 | //! Reader for the TUI's own `codewhale_tui::view_stack` trace records. |
| 2 | //! |
| 3 | //! Modal open/close coverage has an honesty problem: "the frame changed after |
| 4 | //! I pressed F1" is not evidence that a modal opened, and "the frame changed |
| 5 | //! back after Esc" is not evidence that it closed rather than being replaced. |
| 6 | //! `ViewStack::push` and its close paths already emit structured records with |
| 7 | //! the `ModalKind` and the resulting depth, so this reader consumes the |
| 8 | //! product's existing machine-readable signal instead of inventing a parallel |
| 9 | //! one for tests. |
| 10 | //! |
| 11 | //! Enable it by spawning the binary with |
| 12 | //! `RUST_LOG=warn,codewhale_tui::view_stack=debug` and a sealed `HOME`; the |
| 13 | //! subscriber writes to `$HOME/.codewhale/logs/tui-<date>-<pid>.log`. |
| 14 | |
| 15 | use std::path::{Path, PathBuf}; |
| 16 | use std::time::{Duration, Instant}; |
| 17 | |
| 18 | use anyhow::{Context, Result, anyhow}; |
| 19 | |
| 20 | pub const VIEW_STACK_RUST_LOG: &str = "warn,codewhale_tui::view_stack=debug"; |
| 21 | |
| 22 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 23 | pub struct ViewEvent { |
| 24 | /// `push`, `push_boxed`, `pop`, `close`, or `emit_and_close`, verbatim |
| 25 | /// from the record. |
| 26 | pub action: String, |
| 27 | /// Debug spelling of the `ModalKind`, e.g. `CommandPalette`. |
| 28 | pub kind: String, |
| 29 | /// Stack depth *after* the transition, as the product reported it. |
| 30 | pub depth: usize, |
| 31 | } |
| 32 | |
| 33 | impl ViewEvent { |
| 34 | pub fn is_open(&self) -> bool { |
| 35 | self.action.starts_with("push") |
| 36 | } |
| 37 | |
| 38 | pub fn is_close(&self) -> bool { |
| 39 | matches!(self.action.as_str(), "pop" | "close" | "emit_and_close") |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// Locate the log file the sealed-`HOME` child is writing to. Returns the most |
| 44 | /// recently modified `tui-*.log` so a re-spawned process in the same sealed |
| 45 | /// home does not resolve to a stale file. |
| 46 | pub fn log_path(home: &Path) -> Result<PathBuf> { |
| 47 | let dir = home.join(".codewhale").join("logs"); |
| 48 | let mut newest: Option<(std::time::SystemTime, PathBuf)> = None; |
| 49 | for entry in |
| 50 | std::fs::read_dir(&dir).with_context(|| format!("read sealed log dir {}", dir.display()))? |
| 51 | { |
| 52 | let entry = entry?; |
| 53 | let path = entry.path(); |
| 54 | let is_tui_log = path |
| 55 | .file_name() |
| 56 | .and_then(|name| name.to_str()) |
| 57 | .is_some_and(|name| name.starts_with("tui-") && name.ends_with(".log")); |
| 58 | if !is_tui_log { |
| 59 | continue; |
| 60 | } |
| 61 | let modified = entry.metadata()?.modified()?; |
| 62 | if newest.as_ref().is_none_or(|(seen, _)| modified >= *seen) { |
| 63 | newest = Some((modified, path)); |
| 64 | } |
| 65 | } |
| 66 | newest |
| 67 | .map(|(_, path)| path) |
| 68 | .ok_or_else(|| anyhow!("no tui-*.log under {}", dir.display())) |
| 69 | } |
| 70 | |
| 71 | /// Parse every view-stack transition currently on disk, in order. |
| 72 | pub fn read_events(home: &Path) -> Result<Vec<ViewEvent>> { |
| 73 | let path = log_path(home)?; |
| 74 | let contents = |
| 75 | std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; |
| 76 | Ok(parse_events(&contents)) |
| 77 | } |
| 78 | |
| 79 | /// Poll the log until at least `count` transitions are visible, or fail with |
| 80 | /// the transitions that *were* observed. The subscriber writes on its own |
| 81 | /// schedule, so a modal that has already repainted may not have been flushed |
| 82 | /// yet; this is a bounded wait on a real signal, never a fixed sleep. |
| 83 | pub fn wait_for_events(home: &Path, count: usize, timeout: Duration) -> Result<Vec<ViewEvent>> { |
| 84 | let budget = super::harness::ci_scaled(timeout); |
| 85 | let deadline = Instant::now() + budget; |
| 86 | let mut last: Vec<ViewEvent> = Vec::new(); |
| 87 | loop { |
| 88 | if let Ok(events) = read_events(home) { |
| 89 | last = events; |
| 90 | } |
| 91 | if last.len() >= count { |
| 92 | return Ok(last); |
| 93 | } |
| 94 | if Instant::now() >= deadline { |
| 95 | return Err(anyhow!( |
| 96 | "view-stack log never reached {count} transitions within {budget:?}; observed {:?}", |
| 97 | last |
| 98 | )); |
| 99 | } |
| 100 | std::thread::sleep(Duration::from_millis(40)); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | /// Poll until a newly appended transition satisfies `predicate`. Some views |
| 105 | /// are temporarily popped and restored while opening, so counting one record |
| 106 | /// per user gesture is not a stable contract; the semantic transition is. |
| 107 | pub fn wait_for_event_after<F>( |
| 108 | home: &Path, |
| 109 | after: usize, |
| 110 | timeout: Duration, |
| 111 | mut predicate: F, |
| 112 | ) -> Result<(Vec<ViewEvent>, ViewEvent)> |
| 113 | where |
| 114 | F: FnMut(&ViewEvent) -> bool, |
| 115 | { |
| 116 | let budget = super::harness::ci_scaled(timeout); |
| 117 | let deadline = Instant::now() + budget; |
| 118 | let mut last: Vec<ViewEvent> = Vec::new(); |
| 119 | loop { |
| 120 | if let Ok(events) = read_events(home) { |
| 121 | last = events; |
| 122 | } |
| 123 | if let Some(event) = last.iter().skip(after).find(|event| predicate(event)) { |
| 124 | return Ok((last.clone(), event.clone())); |
| 125 | } |
| 126 | if Instant::now() >= deadline { |
| 127 | return Err(anyhow!( |
| 128 | "view-stack log produced no matching transition after index {after} within \ |
| 129 | {budget:?}; observed {:?}", |
| 130 | last |
| 131 | )); |
| 132 | } |
| 133 | std::thread::sleep(Duration::from_millis(40)); |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | pub fn parse_events(contents: &str) -> Vec<ViewEvent> { |
| 138 | contents |
| 139 | .lines() |
| 140 | .filter(|line| line.contains("codewhale_tui::view_stack")) |
| 141 | .filter_map(parse_line) |
| 142 | .collect() |
| 143 | } |
| 144 | |
| 145 | fn parse_line(line: &str) -> Option<ViewEvent> { |
| 146 | let action = quoted_field(line, "action=")?; |
| 147 | let kind = bare_field(line, "kind=")?; |
| 148 | let depth = bare_field(line, "depth=")?.parse().ok()?; |
| 149 | Some(ViewEvent { |
| 150 | action, |
| 151 | kind, |
| 152 | depth, |
| 153 | }) |
| 154 | } |
| 155 | |
| 156 | /// `action="push"` → `push`. |
| 157 | fn quoted_field(line: &str, key: &str) -> Option<String> { |
| 158 | let rest = line.split_once(key)?.1; |
| 159 | let rest = rest.strip_prefix('"')?; |
| 160 | let end = rest.find('"')?; |
| 161 | Some(rest[..end].to_string()) |
| 162 | } |
| 163 | |
| 164 | /// `kind=CommandPalette depth=1` → `CommandPalette`. |
| 165 | fn bare_field(line: &str, key: &str) -> Option<String> { |
| 166 | let rest = line.split_once(key)?.1; |
| 167 | let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); |
| 168 | let value = rest[..end].trim(); |
| 169 | if value.is_empty() { |
| 170 | return None; |
| 171 | } |
| 172 | Some(value.to_string()) |
| 173 | } |
| 174 | |
| 175 | #[cfg(test)] |
| 176 | mod tests { |
| 177 | use super::*; |
| 178 | |
| 179 | const SAMPLE: &str = concat!( |
| 180 | "2026-07-26T12:00:00.100000Z WARN codewhale_tui::startup: unrelated line\n", |
| 181 | "2026-07-26T12:00:01.000000Z DEBUG codewhale_tui::view_stack: view pushed action=\"push\" kind=Help depth=1\n", |
| 182 | "2026-07-26T12:00:02.000000Z DEBUG codewhale_tui::view_stack: view pushed action=\"push_boxed\" kind=Pager depth=2\n", |
| 183 | "2026-07-26T12:00:03.000000Z DEBUG codewhale_tui::view_stack: view closed action=\"close\" kind=Pager depth=1\n", |
| 184 | ); |
| 185 | |
| 186 | #[test] |
| 187 | fn only_view_stack_records_are_parsed_and_order_is_preserved() { |
| 188 | let events = parse_events(SAMPLE); |
| 189 | |
| 190 | assert_eq!(events.len(), 3); |
| 191 | assert_eq!(events[0].kind, "Help"); |
| 192 | assert!(events[0].is_open()); |
| 193 | assert_eq!(events[1].action, "push_boxed"); |
| 194 | assert!(events[1].is_open()); |
| 195 | assert!(events[2].is_close()); |
| 196 | assert_eq!(events[2].depth, 1); |
| 197 | } |
| 198 | |
| 199 | #[test] |
| 200 | fn a_record_missing_its_fields_is_skipped_rather_than_guessed() { |
| 201 | let events = parse_events( |
| 202 | "2026-07-26T12:00:01.000000Z DEBUG codewhale_tui::view_stack: view pushed depth=1\n", |
| 203 | ); |
| 204 | |
| 205 | assert!(events.is_empty()); |
| 206 | } |
| 207 | } |
| 208 |