Skip to main content

omni_dev/cli/voice/
review.rs

1//! `omni-dev voice review` — reconcile a session's `events.jsonl`
2//! into materialised markdown.
3//!
4//! Writes `todos.md` / `decisions.md` and (optionally) renders the
5//! transcript. See [`crate::voice::review`] for the driver and
6//! [`crate::voice::reconcile`] for the pure logic.
7
8use std::io::Write;
9
10use anyhow::Result;
11use clap::Parser;
12
13use crate::voice::clock::SystemClock;
14use crate::voice::det::SystemUlidRng;
15use crate::voice::review::{run_review, ReviewOptions, What};
16
17/// Reconciles a session's reflection log into materialised markdown.
18///
19/// Reads `~/.omni-dev/voice/<session-id>/events.jsonl`, computes
20/// projections per #799's reconciliation invariants, applies TTL
21/// expiry against the session's class-default TTLs, and writes
22/// `todos.md` / `decisions.md` under the session directory. Any
23/// synthesised `item.expire { reason: ttl }` events are appended back
24/// to `events.jsonl`.
25#[derive(Parser)]
26pub struct ReviewCommand {
27    /// Session id under `~/.omni-dev/voice/<id>/`.
28    #[arg(value_name = "SESSION_ID")]
29    pub session_id: String,
30
31    /// Which artefact to materialise. `all` (default) writes both
32    /// markdown files and applies the TTL pass. `transcript` renders
33    /// `transcript.jsonl` to stdout instead.
34    #[arg(long, value_enum, default_value_t = What::All)]
35    pub what: What,
36}
37
38impl ReviewCommand {
39    /// Executes the review command. Sync because reconciliation does
40    /// no AI calls — the `voice` dispatch wraps this in an immediately
41    /// ready future.
42    pub fn execute(self) -> Result<()> {
43        let opts = ReviewOptions {
44            session_id: self.session_id,
45            what: self.what,
46            ulid_rng: Box::new(SystemUlidRng),
47            clock: Box::new(SystemClock),
48            session_root_override: None,
49        };
50        let mut buf: Vec<u8> = Vec::new();
51        run_review(opts, &mut buf)?;
52        let mut stdout = std::io::stdout().lock();
53        stdout.write_all(&buf)?;
54        stdout.flush()?;
55        Ok(())
56    }
57}
58
59#[cfg(test)]
60#[allow(clippy::unwrap_used, clippy::expect_used)]
61mod tests {
62    use super::*;
63    use clap::Parser;
64
65    #[derive(Parser)]
66    struct TestCli {
67        #[command(flatten)]
68        review: ReviewCommand,
69    }
70
71    #[test]
72    fn parses_session_id_and_defaults_to_all() {
73        let cli = TestCli::try_parse_from(["test", "demo"]).unwrap();
74        assert_eq!(cli.review.session_id, "demo");
75        assert_eq!(cli.review.what, What::All);
76    }
77
78    #[test]
79    fn parses_what_flag() {
80        let cli = TestCli::try_parse_from(["test", "demo", "--what", "todos"]).unwrap();
81        assert_eq!(cli.review.what, What::Todos);
82    }
83
84    #[test]
85    fn rejects_unknown_what_value() {
86        let result = TestCli::try_parse_from(["test", "demo", "--what", "garbage"]);
87        let Err(err) = result else {
88            panic!("expected parse failure for unknown --what value");
89        };
90        assert!(err.to_string().contains("invalid value"));
91    }
92
93    #[test]
94    fn rejects_missing_session_id() {
95        let result = TestCli::try_parse_from(["test"]);
96        let Err(err) = result else {
97            panic!("expected parse failure when SESSION_ID is missing");
98        };
99        assert!(err.to_string().to_lowercase().contains("session"));
100    }
101}