Skip to main content

vissue_core/
events.rs

1//! Append-only change log and generation counter, so a poller notices a write
2//! without re-parsing every issues.org.
3//!
4//! Two files sit beside the project directories:
5//!
6//! - `.vault-events.gen`, a monotonic counter; comparing it against the last
7//!   value seen is the cheap change check.
8//! - `.vault-events.jsonl`, one event per line, read with a `since` sequence.
9//!
10//! The names are fixed rather than derived from the product name: existing
11//! readers watch these paths, and renaming them would silence every poller.
12//!
13//! Emission is best-effort and never fails a write. Set `VISSUE_EVENTS=0` to
14//! turn it off, for a caller that needs the tracker to stay
15//! untouched.
16
17use anyhow::Context;
18
19use crate::error::{Error, Result};
20use fs2::FileExt;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::fs::{self, OpenOptions};
24use std::io::Write;
25use std::path::{Path, PathBuf};
26use std::sync::{
27    Arc, Mutex, OnceLock,
28    atomic::{AtomicU64, Ordering},
29};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use crate::config::Layout;
33
34static LAST_SEQ: AtomicU64 = AtomicU64::new(0);
35static LAST_EMIT_MS_BY_DIR: OnceLock<Mutex<HashMap<PathBuf, u64>>> = OnceLock::new();
36static PROCESS_LOCKS: OnceLock<Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>> = OnceLock::new();
37
38const LOCK_NAME: &str = ".vault-events.lock";
39
40fn with_events_lock<R, F>(dir: &Path, f: F) -> Result<R>
41where
42    F: FnOnce() -> Result<R>,
43{
44    let key = dir.to_path_buf();
45    let mutex = {
46        let mut map = PROCESS_LOCKS
47            .get_or_init(|| Mutex::new(HashMap::new()))
48            .lock()
49            .unwrap_or_else(|p| p.into_inner());
50        map.entry(key)
51            .or_insert_with(|| Arc::new(Mutex::new(())))
52            .clone()
53    };
54    let _proc = mutex.lock().unwrap_or_else(|p| p.into_inner());
55    fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
56    let lock_path = dir.join(LOCK_NAME);
57    let file = OpenOptions::new()
58        .create(true)
59        .read(true)
60        .append(true)
61        .open(&lock_path)
62        .with_context(|| format!("open {}", lock_path.display()))?;
63    file.lock_exclusive()
64        .with_context(|| format!("lock {}", lock_path.display()))?;
65    let result = f();
66    let _ = FileExt::unlock(&file);
67    let _ = file;
68    result
69}
70
71/// Repeated writes inside this window bump the generation without appending a
72/// line, so an editor or agent saving in a burst does not flood the log.
73const DEBOUNCE_MS: u64 = 2000;
74
75const LOG_NAME: &str = ".vault-events.jsonl";
76const GEN_NAME: &str = ".vault-events.gen";
77
78/// One entry in the change log.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Event {
81    /// Monotonic sequence, mirrored in the generation file.
82    pub seq: u64,
83    /// Unix seconds.
84    pub ts: u64,
85    /// `issues_write`, `ping`, and so on.
86    pub kind: String,
87    /// Project touched by the event, when the kind is project-scoped.
88    pub project: Option<String>,
89    /// Issue id touched by the event, when the kind is issue-scoped.
90    pub id: Option<String>,
91    /// Path of the file that changed, when one did.
92    pub path: Option<String>,
93    /// Free-form extra text (a ping message, say).
94    pub detail: Option<String>,
95}
96
97/// Whether emission is enabled. `VISSUE_EVENTS=0` disables it.
98pub fn enabled() -> bool {
99    !matches!(
100        crate::process_env::var("VISSUE_EVENTS").as_deref(),
101        Ok("0") | Ok("false") | Ok("off")
102    )
103}
104
105/// Path of the JSONL change log under `dir`.
106pub fn log_path(dir: &Path) -> PathBuf {
107    dir.join(LOG_NAME)
108}
109
110/// Path of the generation counter file under `dir`.
111pub fn gen_path(dir: &Path) -> PathBuf {
112    dir.join(GEN_NAME)
113}
114
115fn now_secs() -> u64 {
116    SystemTime::now()
117        .duration_since(UNIX_EPOCH)
118        .map(|d| d.as_secs())
119        .unwrap_or(0)
120}
121
122fn now_millis() -> u64 {
123    SystemTime::now()
124        .duration_since(UNIX_EPOCH)
125        .map(|d| d.as_millis() as u64)
126        .unwrap_or(0)
127}
128
129fn read_gen(dir: &Path) -> u64 {
130    fs::read_to_string(gen_path(dir))
131        .ok()
132        .and_then(|s| s.trim().parse().ok())
133        .unwrap_or(0)
134}
135
136fn write_gen(dir: &Path, seq: u64) -> Result<()> {
137    fs::create_dir_all(dir)?;
138    let target = gen_path(dir);
139    // A shared temporary name races: concurrent emitters would rename each
140    // other's file out from under themselves.
141    let tmp = dir.join(format!("{GEN_NAME}.tmp.{}-{}", std::process::id(), seq));
142    fs::write(&tmp, format!("{seq}\n"))?;
143    if let Err(e) = fs::rename(&tmp, &target) {
144        let _ = fs::remove_file(&tmp);
145        return Err(e)
146            .with_context(|| format!("rename {} -> {}", tmp.display(), target.display()))
147            .map_err(crate::error::Error::from);
148    }
149    Ok(())
150}
151
152/// The current generation. A poller compares this against the last value it
153/// saw; a difference means something changed.
154pub fn generation_in(dir: &Path) -> u64 {
155    let g = read_gen(dir);
156    let _ = LAST_SEQ.fetch_max(g, Ordering::Relaxed);
157    g
158}
159
160/// Append one event and return the new sequence.
161///
162/// # Errors
163///
164/// Returns an error if the event directory cannot be created, locked, or
165/// written.
166pub fn emit_in(
167    dir: &Path,
168    kind: &str,
169    project: Option<&str>,
170    id: Option<&str>,
171    path: Option<&Path>,
172    detail: Option<&str>,
173) -> Result<u64> {
174    with_events_lock(dir, || {
175        fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
176
177        let prev = read_gen(dir).max(LAST_SEQ.load(Ordering::Relaxed));
178        let seq = prev.saturating_add(1);
179        LAST_SEQ.store(seq, Ordering::Relaxed);
180
181        let event = Event {
182            seq,
183            ts: now_secs(),
184            kind: kind.to_string(),
185            project: project.map(|s| s.to_string()),
186            id: id.map(|s| s.to_string()),
187            path: path.map(|p| p.display().to_string()),
188            detail: detail.map(|s| s.to_string()),
189        };
190
191        if kind == "issues_write" {
192            let now_ms = now_millis();
193            let key = dir.to_path_buf();
194            let mut last_by_dir = LAST_EMIT_MS_BY_DIR
195                .get_or_init(|| Mutex::new(HashMap::new()))
196                .lock()
197                .unwrap_or_else(|poisoned| poisoned.into_inner());
198            let previous = last_by_dir.get(&key).copied().unwrap_or(0);
199            if previous > 0 && now_ms.saturating_sub(previous) < DEBOUNCE_MS {
200                // Inside the window: advance the generation so pollers still wake,
201                // but leave the log alone.
202                write_gen(dir, seq)?;
203                LAST_SEQ.store(seq, Ordering::Relaxed);
204                last_by_dir.insert(key, now_ms);
205                return Ok(seq);
206            }
207            last_by_dir.insert(key, now_ms);
208        }
209
210        let line = serde_json::to_string(&event)?;
211        let mut file = OpenOptions::new()
212            .create(true)
213            .append(true)
214            .open(log_path(dir))?;
215        writeln!(file, "{line}")?;
216        file.flush()?;
217        write_gen(dir, seq)?;
218        Ok(seq)
219    })
220}
221
222/// Record that a project's issues.org was rewritten. Called by the store after
223/// a successful write; failure here never fails the write.
224///
225/// # Errors
226///
227/// Returns an error if the event directory cannot be created, locked, or
228/// written.
229pub fn emit_issues_write(dir: &Path, project: &str, path: &Path) -> Result<u64> {
230    emit_in(
231        dir,
232        "issues_write",
233        Some(project),
234        None,
235        Some(path),
236        Some("issues.org updated"),
237    )
238}
239
240/// kind=state_change, id=Some(id), detail=Some("FROM->TO"), project set.
241/// NOT debounced (unlike issues_write).
242///
243/// # Errors
244///
245/// Returns an error if the event directory cannot be created, locked, or
246/// written.
247pub fn emit_state_change(
248    layout: &Layout,
249    project: &str,
250    id: &str,
251    from: &str,
252    to: &str,
253) -> Result<u64> {
254    emit_in(
255        &events_dir(layout),
256        "state_change",
257        Some(project),
258        Some(id),
259        None,
260        Some(&format!("{from}->{to}")),
261    )
262}
263
264/// Events with a sequence above `since_seq`, most recent last.
265///
266/// # Errors
267///
268/// Returns an error if the log file exists but cannot be read.
269pub fn since_in(dir: &Path, since_seq: u64, limit: usize) -> Result<Vec<Event>> {
270    let log = log_path(dir);
271    if !log.is_file() {
272        return Ok(Vec::new());
273    }
274    let text = fs::read_to_string(&log)?;
275    let mut out = Vec::new();
276    for line in text.lines().rev() {
277        if line.trim().is_empty() {
278            continue;
279        }
280        let event: Event = match serde_json::from_str(line) {
281            Ok(e) => e,
282            Err(_) => continue,
283        };
284        if event.seq <= since_seq {
285            break;
286        }
287        out.push(event);
288        if out.len() >= limit {
289            break;
290        }
291    }
292    out.reverse();
293    Ok(out)
294}
295
296/// As [`since_in`], narrowed to a project, a kind, or both.
297///
298/// # Errors
299///
300/// Returns an error if the log file exists but cannot be read.
301pub fn since_filtered_in(
302    dir: &Path,
303    since_seq: u64,
304    limit: usize,
305    project: Option<&str>,
306    kind: Option<&str>,
307) -> Result<Vec<Event>> {
308    let mut events = since_in(dir, since_seq, limit.saturating_mul(4).max(limit))?;
309    if let Some(p) = project {
310        events.retain(|e| e.project.as_deref() == Some(p));
311    }
312    if let Some(k) = kind {
313        events.retain(|e| e.kind == k);
314    }
315    events.truncate(limit);
316    Ok(events)
317}
318
319// --- Layout-facing wrappers, called by the CLI and MCP server. ---
320
321/// The directory holding the event files: the same one holding the projects.
322pub fn events_dir(layout: &Layout) -> PathBuf {
323    layout.projects_dir()
324}
325
326/// The current generation for this layout's event directory.
327pub fn generation(layout: &Layout) -> u64 {
328    generation_in(&events_dir(layout))
329}
330
331/// Events with a sequence above `since_seq` for this layout.
332///
333/// # Errors
334///
335/// Returns an error if the log file exists but cannot be read.
336pub fn since(layout: &Layout, since_seq: u64, limit: usize) -> Result<Vec<Event>> {
337    since_in(&events_dir(layout), since_seq, limit)
338}
339
340/// Text plus a trailing JSON block, the shape existing readers parse.
341///
342/// # Errors
343///
344/// Returns an error if the log file exists but cannot be read.
345pub fn since_report(layout: &Layout, since_seq: u64, limit: usize) -> Result<String> {
346    let dir = events_dir(layout);
347    let events = since_in(&dir, since_seq, limit)?;
348    Ok(render_events(&dir, since_seq, events))
349}
350
351fn render_events(dir: &Path, since_seq: u64, events: Vec<Event>) -> String {
352    let generation_now = generation_in(dir);
353    let mut text = format!(
354        "generation={} since={} count={}\n",
355        generation_now,
356        since_seq,
357        events.len()
358    );
359    for e in &events {
360        text.push_str(&format!(
361            "{}\t{}\t{}\t{:?}\t{:?}\t{:?}\n",
362            e.seq, e.ts, e.kind, e.project, e.id, e.path
363        ));
364    }
365    let data = serde_json::json!({
366        "generation": generation_now,
367        "since": since_seq,
368        "events": events,
369        "log": log_path(dir).display().to_string(),
370        "gen_file": gen_path(dir).display().to_string(),
371    });
372    text.push_str("\n---json---\n");
373    text.push_str(&data.to_string());
374    text.push('\n');
375    text
376}
377
378/// Append a manual event, waking pollers without touching an issues.org.
379///
380/// # Errors
381///
382/// Returns an error if the event directory cannot be created, locked, or
383/// written.
384pub fn ping_report(layout: &Layout, detail: Option<&str>) -> Result<String> {
385    let dir = events_dir(layout);
386    let seq = emit_in(
387        &dir,
388        "ping",
389        None,
390        None,
391        None,
392        detail.or(Some("manual ping")),
393    )?;
394    Ok(format!(
395        "ping seq={} generation={}\nlog={}\n",
396        seq,
397        generation_in(&dir),
398        log_path(&dir).display()
399    ))
400}
401
402/// Block until the generation passes `last`, or the timeout expires. Returns
403/// the generation either way; the caller compares it against `last` to tell
404/// which happened.
405///
406/// # Errors
407///
408/// The signature is `Result` to match the other event verbs. This path does
409/// not fail.
410pub fn wait_generation(layout: &Layout, last: u64, poll_ms: u64, timeout_ms: u64) -> Result<u64> {
411    let dir = events_dir(layout);
412    let start = std::time::Instant::now();
413    loop {
414        let g = generation_in(&dir);
415        if g > last {
416            return Ok(g);
417        }
418        if start.elapsed().as_millis() as u64 >= timeout_ms {
419            return Ok(g);
420        }
421        std::thread::sleep(std::time::Duration::from_millis(poll_ms.max(50)));
422    }
423}
424
425/// Outcome of [`wait_until_terminal`].
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum TerminalWait {
428    /// The issue reached DONE.
429    Done {
430        /// Generation at the moment the terminal state was observed.
431        generation: u64,
432    },
433    /// The issue reached CANCELLED.
434    Cancelled {
435        /// Generation at the moment the terminal state was observed.
436        generation: u64,
437    },
438    /// The timeout expired while the issue was still non-terminal.
439    Timeout {
440        /// Generation at the moment the timeout expired.
441        generation: u64,
442        /// Heading state when the wait gave up.
443        state: String,
444    },
445}
446
447/// Poll the issue state. Re-read on generation change or poll interval.
448/// Missing id is an error (IssueNotFound).
449///
450/// # Errors
451///
452/// Returns [`Error::IssueNotFound`] if `id` is not in the catalog, or an
453/// error if a project file cannot be read or parsed.
454pub fn wait_until_terminal(
455    layout: &Layout,
456    id: &str,
457    poll_ms: u64,
458    timeout_ms: u64,
459) -> Result<TerminalWait> {
460    let dir = events_dir(layout);
461    let start = std::time::Instant::now();
462    let mut last_gen = generation_in(&dir);
463    loop {
464        let heading = crate::store::find_by_id(layout, id)?
465            .ok_or_else(|| Error::IssueNotFound { id: id.to_string() })?
466            .0;
467        let generation = generation_in(&dir);
468        match heading.state.as_str() {
469            "DONE" => return Ok(TerminalWait::Done { generation }),
470            "CANCELLED" => return Ok(TerminalWait::Cancelled { generation }),
471            _ => {}
472        }
473        if start.elapsed().as_millis() as u64 >= timeout_ms {
474            return Ok(TerminalWait::Timeout {
475                generation,
476                state: heading.state,
477            });
478        }
479        // Wake on a generation bump or when the poll interval elapses.
480        let poll = poll_ms.max(50);
481        let slice = 50_u64.min(poll);
482        let wake = std::time::Instant::now();
483        loop {
484            let now_gen = generation_in(&dir);
485            if now_gen != last_gen {
486                last_gen = now_gen;
487                break;
488            }
489            if wake.elapsed().as_millis() as u64 >= poll {
490                break;
491            }
492            if start.elapsed().as_millis() as u64 >= timeout_ms {
493                break;
494            }
495            std::thread::sleep(std::time::Duration::from_millis(slice));
496        }
497    }
498}
499
500/// The last `n` events in the log.
501///
502/// Counted from the end of the log rather than back from the generation: a
503/// debounced burst advances the generation without appending, so a sequence
504/// window that wide can hold fewer lines than the caller asked for, or none.
505///
506/// # Errors
507///
508/// Returns an error if the log file exists but cannot be read.
509pub fn tail_in(dir: &Path, n: usize) -> Result<Vec<Event>> {
510    let log = log_path(dir);
511    if !log.is_file() {
512        return Ok(Vec::new());
513    }
514    let text = fs::read_to_string(&log)?;
515    let mut out: Vec<Event> = text
516        .lines()
517        .rev()
518        .filter(|line| !line.trim().is_empty())
519        .filter_map(|line| serde_json::from_str(line).ok())
520        .take(n)
521        .collect();
522    out.reverse();
523    Ok(out)
524}
525
526/// The last `n` events, for a reader that only wants what is recent.
527///
528/// # Errors
529///
530/// Returns an error if the log file exists but cannot be read.
531pub fn tail_report(layout: &Layout, n: usize) -> Result<String> {
532    let dir = events_dir(layout);
533    let events = tail_in(&dir, n)?;
534    let since_seq = events.first().map(|e| e.seq.saturating_sub(1)).unwrap_or(0);
535    Ok(render_events(&dir, since_seq, events))
536}
537
538/// Add the event files to a `.gitignore` that already exists beside them. Best
539/// effort: a tracker without one is left alone.
540///
541/// # Errors
542///
543/// Returns an error if an existing `.gitignore` cannot be read or appended.
544pub fn ensure_gitignore_hint(dir: &Path) -> Result<()> {
545    let gitignore = dir.join(".gitignore");
546    if !gitignore.is_file() {
547        return Ok(());
548    }
549    let current = fs::read_to_string(&gitignore)?;
550    if current.contains(".vault-events") {
551        return Ok(());
552    }
553    let mut file = OpenOptions::new().append(true).open(&gitignore)?;
554    writeln!(
555        file,
556        "\n# agent ping stream (local)\n{LOG_NAME}\n{GEN_NAME}\n"
557    )?;
558    Ok(())
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::config::DEFAULT_PREFIX;
565    use crate::model::TODO_HEADER;
566
567    #[test]
568    fn a_sequence_advances_and_reads_back() {
569        let dir = tempfile::tempdir().unwrap();
570        let d = dir.path();
571        let first = emit_in(d, "ping", None, None, None, Some("a")).unwrap();
572        let second = emit_in(
573            d,
574            "issues_write",
575            Some("atlas"),
576            Some("atlas-1a2b"),
577            Some(Path::new("atlas/issues.org")),
578            None,
579        )
580        .unwrap();
581        assert!(second > first);
582        assert_eq!(generation_in(d), second);
583
584        let events = since_in(d, first, 10).unwrap();
585        assert_eq!(events.len(), 1);
586        assert_eq!(events[0].kind, "issues_write");
587        assert_eq!(events[0].project.as_deref(), Some("atlas"));
588    }
589
590    #[test]
591    fn filters_narrow_by_project_and_kind() {
592        let dir = tempfile::tempdir().unwrap();
593        let d = dir.path();
594        emit_in(d, "ping", None, None, None, None).unwrap();
595        emit_in(d, "manual", Some("atlas"), None, None, None).unwrap();
596        emit_in(d, "manual", Some("beacon"), None, None, None).unwrap();
597
598        let by_project = since_filtered_in(d, 0, 10, Some("atlas"), None).unwrap();
599        assert_eq!(by_project.len(), 1);
600        let by_kind = since_filtered_in(d, 0, 10, None, Some("ping")).unwrap();
601        assert_eq!(by_kind.len(), 1);
602        assert_eq!(by_kind[0].kind, "ping");
603    }
604
605    #[test]
606    fn the_report_carries_a_json_block() {
607        let dir = tempfile::tempdir().unwrap();
608        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
609        ping_report(&layout, Some("hello")).unwrap();
610        let text = since_report(&layout, 0, 10).unwrap();
611        assert!(text.starts_with("generation="), "{text}");
612        let (_, json) = text.split_once("---json---").expect("json block present");
613        let parsed: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
614        assert_eq!(parsed["events"][0]["kind"], "ping");
615        assert_eq!(parsed["events"][0]["detail"], "hello");
616    }
617
618    #[test]
619    fn the_gitignore_hint_only_touches_an_existing_file() {
620        let dir = tempfile::tempdir().unwrap();
621        ensure_gitignore_hint(dir.path()).unwrap();
622        assert!(!dir.path().join(".gitignore").exists());
623
624        fs::write(dir.path().join(".gitignore"), "target\n").unwrap();
625        ensure_gitignore_hint(dir.path()).unwrap();
626        let text = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
627        assert!(text.contains(LOG_NAME), "{text}");
628        assert!(text.contains(GEN_NAME), "{text}");
629
630        // Running again must not append a second copy.
631        ensure_gitignore_hint(dir.path()).unwrap();
632        let again = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
633        assert_eq!(text, again);
634    }
635
636    #[test]
637    fn concurrent_emits_assign_unique_sequences() {
638        use std::sync::Arc;
639        use std::thread;
640
641        let dir = tempfile::tempdir().unwrap();
642        let d = Arc::new(dir.path().to_path_buf());
643        let handles: Vec<_> = (0..16)
644            .map(|i| {
645                let d = Arc::clone(&d);
646                thread::spawn(move || emit_in(&d, "ping", None, None, None, Some(&format!("{i}"))))
647            })
648            .collect();
649        let mut seqs = Vec::new();
650        for handle in handles {
651            seqs.push(handle.join().unwrap().unwrap());
652        }
653        seqs.sort_unstable();
654        seqs.dedup();
655        assert_eq!(seqs.len(), 16, "duplicate event sequences: {seqs:?}");
656        assert_eq!(generation_in(&d), *seqs.last().unwrap());
657    }
658
659    #[test]
660    fn a_tail_counts_lines_not_sequence_numbers() {
661        let dir = tempfile::tempdir().unwrap();
662        let d = dir.path();
663        for i in 0..5 {
664            emit_in(d, "manual", None, None, None, Some(&format!("event {i}"))).unwrap();
665        }
666        // A ping with no log line of its own would still move the generation
667        // past the window a sequence-derived tail would look in.
668        let tailed = tail_in(d, 3).unwrap();
669        assert_eq!(tailed.len(), 3, "{tailed:?}");
670        assert_eq!(tailed[2].detail.as_deref(), Some("event 4"));
671        assert_eq!(tailed[0].detail.as_deref(), Some("event 2"));
672        assert!(tail_in(d, 50).unwrap().len() == 5);
673    }
674
675    #[test]
676    fn waiting_returns_the_current_generation_on_timeout() {
677        let dir = tempfile::tempdir().unwrap();
678        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
679        let g = generation(&layout);
680        let waited = wait_generation(&layout, g + 100, 50, 120).unwrap();
681        assert!(waited <= g + 100, "timed out without advancing");
682    }
683
684    fn layout_with_issue(state: &str, id: &str) -> (tempfile::TempDir, Layout) {
685        let dir = tempfile::tempdir().unwrap();
686        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
687        let path = layout.project_issues_path("sample");
688        fs::create_dir_all(path.parent().unwrap()).unwrap();
689        fs::write(
690            &path,
691            format!(
692                "#+TITLE: sample issues\n{TODO_HEADER}\n\n* {state} [#B] wait target\n:PROPERTIES:\n:ID:         {id}\n:END:\n"
693            ),
694        )
695        .unwrap();
696        (dir, layout)
697    }
698
699    #[test]
700    fn emit_state_change_writes_kind_id_and_detail() {
701        let dir = tempfile::tempdir().unwrap();
702        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
703        let seq = emit_state_change(&layout, "sample", "sample-aaaa", "TODO", "CANCELLED").unwrap();
704        let events = since(&layout, 0, 10).unwrap();
705        assert_eq!(events.len(), 1);
706        assert_eq!(events[0].seq, seq);
707        assert_eq!(events[0].kind, "state_change");
708        assert_eq!(events[0].id.as_deref(), Some("sample-aaaa"));
709        assert_eq!(events[0].project.as_deref(), Some("sample"));
710        assert_eq!(events[0].detail.as_deref(), Some("TODO->CANCELLED"));
711    }
712
713    #[test]
714    fn two_rapid_state_changes_both_appear_in_the_log() {
715        let dir = tempfile::tempdir().unwrap();
716        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
717        emit_state_change(&layout, "sample", "sample-aaaa", "TODO", "STARTED").unwrap();
718        emit_state_change(&layout, "sample", "sample-aaaa", "STARTED", "DONE").unwrap();
719        let events = since(&layout, 0, 10).unwrap();
720        assert_eq!(events.len(), 2, "{events:?}");
721        assert_eq!(events[0].kind, "state_change");
722        assert_eq!(events[1].kind, "state_change");
723        assert_eq!(events[0].detail.as_deref(), Some("TODO->STARTED"));
724        assert_eq!(events[1].detail.as_deref(), Some("STARTED->DONE"));
725        assert_eq!(events[0].id.as_deref(), Some("sample-aaaa"));
726        assert_eq!(events[1].id.as_deref(), Some("sample-aaaa"));
727    }
728
729    #[test]
730    fn wait_until_terminal_returns_done_immediately() {
731        let (_dir, layout) = layout_with_issue("DONE", "sample-done");
732        let waited = wait_until_terminal(&layout, "sample-done", 50, 120).unwrap();
733        match waited {
734            TerminalWait::Done { .. } => {}
735            other => panic!("expected Done, got {other:?}"),
736        }
737    }
738
739    #[test]
740    fn wait_until_terminal_returns_cancelled() {
741        let (_dir, layout) = layout_with_issue("CANCELLED", "sample-canc");
742        let waited = wait_until_terminal(&layout, "sample-canc", 50, 120).unwrap();
743        match waited {
744            TerminalWait::Cancelled { .. } => {}
745            other => panic!("expected Cancelled, got {other:?}"),
746        }
747    }
748
749    #[test]
750    fn wait_until_terminal_times_out_on_started() {
751        let (_dir, layout) = layout_with_issue("STARTED", "sample-work");
752        let waited = wait_until_terminal(&layout, "sample-work", 50, 120).unwrap();
753        match waited {
754            TerminalWait::Timeout { state, .. } => assert_eq!(state, "STARTED"),
755            other => panic!("expected Timeout, got {other:?}"),
756        }
757    }
758}