Skip to main content

omni_dev/voice/
review.rs

1//! `voice review` driver — wraps the pure [`crate::voice::reconcile`]
2//! function with the I/O the CLI needs.
3//!
4//! Responsibilities:
5//! - Open the session under the configured voice root.
6//! - For `What::Transcript`: stream `transcript.jsonl` through the
7//!   existing markdown renderer to the caller's `Write`.
8//! - For the other variants: call `reconcile()`, atomic-write the
9//!   selected markdown file(s) under the session root, and append the
10//!   synthesised TTL-expiry events to `events.jsonl`.
11//!
12//! The TTL pass runs whenever the selected mode reads `events.jsonl`
13//! (`Todos`, `Decisions`, `All`) — even when only one markdown file
14//! is being written, so the log stays consistent regardless of which
15//! `--what` the user invokes.
16
17use std::io::Write;
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21
22use crate::voice::clock::Clock;
23use crate::voice::det::UlidRng;
24use crate::voice::reconcile::reconcile;
25use crate::voice::render::render_markdown;
26use crate::voice::session::{self, Session};
27
28/// Selects which artefacts the review command should materialise.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
30#[clap(rename_all = "lower")]
31pub enum What {
32    /// Render `transcript.jsonl` to stdout as markdown. No TTL pass.
33    Transcript,
34    /// Write `todos.md` under the session root. Runs TTL pass.
35    Todos,
36    /// Write `decisions.md` under the session root. Runs TTL pass.
37    Decisions,
38    /// Write both `todos.md` and `decisions.md`. Runs TTL pass.
39    All,
40}
41
42/// Inputs for [`run_review`]. Mirrors [`crate::voice::reflect::ReflectOptions`]:
43/// pluggable RNG and clock so tests can pin both.
44pub struct ReviewOptions {
45    /// Session id under the voice root.
46    pub session_id: String,
47    /// Which artefacts to materialise.
48    pub what: What,
49    /// ULID source for synthesised TTL-expiry events.
50    pub ulid_rng: Box<dyn UlidRng>,
51    /// Wall-clock source for `now` and synthesised event timestamps.
52    pub clock: Box<dyn Clock>,
53    /// Override the voice root directory (test hook). When `None` the
54    /// standard `~/.omni-dev/voice/` root (or `OMNI_DEV_VOICE_ROOT`) is
55    /// used.
56    pub session_root_override: Option<PathBuf>,
57}
58
59/// Runs one `voice review` invocation end-to-end.
60///
61/// `stdout` is only written to for `What::Transcript`; the other
62/// variants write files under the session root and leave `stdout`
63/// untouched.
64pub fn run_review<W: Write>(opts: ReviewOptions, stdout: &mut W) -> Result<()> {
65    let ReviewOptions {
66        session_id,
67        what,
68        mut ulid_rng,
69        clock,
70        session_root_override,
71    } = opts;
72
73    let voice_root = match session_root_override {
74        Some(path) => path,
75        None => session::voice_root()?,
76    };
77    let session = session::open_or_create_under(&voice_root, &session_id)?;
78
79    match what {
80        What::Transcript => render_transcript(&session, stdout),
81        What::Todos | What::Decisions | What::All => {
82            run_reconcile_and_write(&session, what, ulid_rng.as_mut(), clock.as_ref())
83        }
84    }
85}
86
87fn render_transcript<W: Write>(session: &Session, w: &mut W) -> Result<()> {
88    let events = session::read_transcript(&session.paths.transcript).with_context(|| {
89        format!(
90            "reading transcript at {}",
91            session.paths.transcript.display()
92        )
93    })?;
94    render_markdown(events.into_iter().map(Ok), w)
95}
96
97fn run_reconcile_and_write(
98    session: &Session,
99    what: What,
100    rng: &mut dyn UlidRng,
101    clock: &dyn Clock,
102) -> Result<()> {
103    let events = session.read_events()?;
104    let out = reconcile(&events, &session.meta.ttl_defaults, clock.now(), rng);
105
106    let write_todos = matches!(what, What::Todos | What::All);
107    let write_decisions = matches!(what, What::Decisions | What::All);
108
109    if write_todos {
110        let path = session.paths.root.join("todos.md");
111        atomic_write(&path, out.todos_md.as_bytes())?;
112    }
113    if write_decisions {
114        let path = session.paths.root.join("decisions.md");
115        atomic_write(&path, out.decisions_md.as_bytes())?;
116    }
117    session.append_events(&out.new_expiry_events)?;
118    Ok(())
119}
120
121/// Writes `bytes` to `path` atomically by way of `<path>.tmp` + rename.
122///
123/// Mirrors the temp-then-rename pattern used by
124/// [`session::write_meta`] — keeps the in-tree convention consistent
125/// and avoids the `NamedTempFile` dependency surface for what is just
126/// a two-line operation.
127fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
128    let tmp = path.with_extension(temp_extension(path));
129    std::fs::write(&tmp, bytes)
130        .with_context(|| format!("writing temp file at {}", tmp.display()))?;
131    std::fs::rename(&tmp, path)
132        .with_context(|| format!("renaming temp file to {}", path.display()))?;
133    Ok(())
134}
135
136fn temp_extension(path: &Path) -> String {
137    match path.extension().and_then(|e| e.to_str()) {
138        Some(ext) => format!("{ext}.tmp"),
139        None => "tmp".to_string(),
140    }
141}
142
143#[cfg(test)]
144#[allow(clippy::unwrap_used, clippy::expect_used)]
145mod tests {
146    use super::*;
147    use crate::voice::clock::FixedClock;
148    use crate::voice::det::CountingUlidRng;
149    use crate::voice::events::{Event, EventKind, ItemClass, ItemCreate, Provenance, ReflectionId};
150    use tempfile::TempDir;
151
152    fn fixed_now() -> chrono::DateTime<chrono::Utc> {
153        use chrono::TimeZone;
154        chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap()
155    }
156
157    fn make_create(eid: u128, iid: u128, text: &str, ts: chrono::DateTime<chrono::Utc>) -> Event {
158        Event {
159            event_id: ulid::Ulid::from_parts(0, eid),
160            ts,
161            reflection_id: ReflectionId::Ulid(ulid::Ulid::from_parts(0, 100)),
162            provenance: Provenance {
163                transcript_span: None,
164                model: None,
165                prompt_version: None,
166            },
167            kind: EventKind::ItemCreate(ItemCreate {
168                item_id: ulid::Ulid::from_parts(0, iid),
169                class: ItemClass::Todo,
170                text: text.into(),
171                priority: None,
172                valid_until: None,
173                tags: None,
174            }),
175        }
176    }
177
178    fn build_opts(root: &Path, session_id: &str, what: What) -> ReviewOptions {
179        ReviewOptions {
180            session_id: session_id.into(),
181            what,
182            ulid_rng: Box::new(CountingUlidRng::new()),
183            clock: Box::new(FixedClock(fixed_now())),
184            session_root_override: Some(root.to_path_buf()),
185        }
186    }
187
188    #[test]
189    fn what_all_writes_both_files_and_appends_ttl_events() {
190        let tmp = TempDir::new().unwrap();
191        let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
192        // Stale todo: created 10 days ago, no valid_until → uses class
193        // default (7d) → expired at fixed_now().
194        let event = make_create(1, 1, "stale", fixed_now() - chrono::Duration::days(10));
195        session.append_events(&[event]).unwrap();
196
197        let mut out: Vec<u8> = Vec::new();
198        let opts = build_opts(tmp.path(), "s1", What::All);
199        run_review(opts, &mut out).unwrap();
200        assert!(out.is_empty(), "All-mode should not write to stdout");
201
202        assert!(session.paths.root.join("todos.md").exists());
203        assert!(session.paths.root.join("decisions.md").exists());
204
205        // Events log grew by exactly one synthesised expiry line.
206        let after = session::read_events(&session.paths.events).unwrap();
207        assert_eq!(after.len(), 2, "{after:?}");
208    }
209
210    #[test]
211    fn what_todos_writes_only_todos_md() {
212        let tmp = TempDir::new().unwrap();
213        let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
214        let event = make_create(1, 1, "active", fixed_now());
215        session.append_events(&[event]).unwrap();
216
217        let mut out: Vec<u8> = Vec::new();
218        let opts = build_opts(tmp.path(), "s1", What::Todos);
219        run_review(opts, &mut out).unwrap();
220
221        assert!(session.paths.root.join("todos.md").exists());
222        assert!(!session.paths.root.join("decisions.md").exists());
223    }
224
225    #[test]
226    fn what_decisions_writes_only_decisions_md() {
227        let tmp = TempDir::new().unwrap();
228        let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
229        // Seed both a todo (so TTL pass has something to expire) and a
230        // decision (so decisions.md is non-empty).
231        let create_event = make_create(1, 1, "active", fixed_now());
232        let decision_event = Event {
233            event_id: ulid::Ulid::from_parts(0, 2),
234            ts: fixed_now(),
235            reflection_id: ReflectionId::Ulid(ulid::Ulid::from_parts(0, 100)),
236            provenance: Provenance {
237                transcript_span: None,
238                model: None,
239                prompt_version: None,
240            },
241            kind: EventKind::DecisionRecord(crate::voice::events::DecisionRecord {
242                decision_id: ulid::Ulid::from_parts(0, 50),
243                text: "use ULIDs".into(),
244                alternatives: None,
245            }),
246        };
247        session
248            .append_events(&[create_event, decision_event])
249            .unwrap();
250
251        let mut out: Vec<u8> = Vec::new();
252        let opts = build_opts(tmp.path(), "s1", What::Decisions);
253        run_review(opts, &mut out).unwrap();
254
255        assert!(!session.paths.root.join("todos.md").exists());
256        assert!(session.paths.root.join("decisions.md").exists());
257    }
258
259    #[test]
260    fn rerunning_review_is_idempotent_for_already_expired_items() {
261        let tmp = TempDir::new().unwrap();
262        let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
263        let event = make_create(1, 1, "stale", fixed_now() - chrono::Duration::days(10));
264        session.append_events(&[event]).unwrap();
265
266        let mut buf: Vec<u8> = Vec::new();
267        run_review(build_opts(tmp.path(), "s1", What::All), &mut buf).unwrap();
268        let after_first = session::read_events(&session.paths.events).unwrap();
269        run_review(build_opts(tmp.path(), "s1", What::All), &mut buf).unwrap();
270        let after_second = session::read_events(&session.paths.events).unwrap();
271
272        assert_eq!(
273            after_first.len(),
274            after_second.len(),
275            "second review should add no new events"
276        );
277    }
278
279    #[test]
280    fn what_transcript_streams_to_caller_writer() {
281        let tmp = TempDir::new().unwrap();
282        let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
283        // Seed a transcript with one Final.
284        let final_evt = crate::voice::TranscriptEvent::Final {
285            event_id: ulid::Ulid::from_parts(0, 1),
286            text: "hello".into(),
287            start: std::time::Duration::from_secs(0),
288            end: std::time::Duration::from_secs(1),
289            confidence: 1.0,
290            words: None,
291            speaker: None,
292            revisable: false,
293        };
294        let line = serde_json::to_string(&final_evt).unwrap() + "\n";
295        std::fs::write(&session.paths.transcript, line).unwrap();
296
297        let mut out: Vec<u8> = Vec::new();
298        let opts = build_opts(tmp.path(), "s1", What::Transcript);
299        run_review(opts, &mut out).unwrap();
300        let body = String::from_utf8(out).unwrap();
301        assert!(body.contains("hello"), "got: {body}");
302        // Transcript mode must not touch the session dir.
303        assert!(!session.paths.root.join("todos.md").exists());
304        assert!(!session.paths.root.join("decisions.md").exists());
305    }
306
307    #[test]
308    fn atomic_write_handles_extensionless_path() {
309        let tmp = TempDir::new().unwrap();
310        let target = tmp.path().join("noext");
311        atomic_write(&target, b"hi").unwrap();
312        let body = std::fs::read_to_string(&target).unwrap();
313        assert_eq!(body, "hi");
314    }
315}