omni_dev/cli/voice/
reflect.rs1use std::io::Write;
12use std::path::PathBuf;
13
14use anyhow::{bail, Result};
15use clap::Parser;
16
17use crate::claude::client::create_default_claude_client;
18use crate::voice::clock::SystemClock;
19use crate::voice::det::SystemUlidRng;
20use crate::voice::reflect::{run_reflect, ReflectOptions, TranscriptSource};
21
22#[derive(Parser)]
34pub struct ReflectCommand {
35 #[arg(value_name = "TRANSCRIPT")]
38 pub transcript: Option<PathBuf>,
39
40 #[arg(long)]
44 pub session: Option<String>,
45}
46
47impl ReflectCommand {
48 pub async fn execute(self) -> Result<()> {
51 let source = resolve_source(self.transcript, self.session)?;
52 if let TranscriptSource::Path(p) = &source {
57 if !p.exists() {
58 bail!("transcript file does not exist: {}", p.display());
59 }
60 }
61 let ai = create_default_claude_client(None, None)
62 .await?
63 .into_ai_client();
64 let opts = ReflectOptions {
65 source,
66 ulid_rng: Box::new(SystemUlidRng),
67 clock: Box::new(SystemClock),
68 ai,
69 session_root_override: None,
70 };
71 let mut buf: Vec<u8> = Vec::new();
77 run_reflect(opts, &mut buf).await?;
78 let mut stdout = std::io::stdout().lock();
79 stdout.write_all(&buf)?;
80 stdout.flush()?;
81 Ok(())
82 }
83}
84
85fn resolve_source(
86 transcript: Option<PathBuf>,
87 session: Option<String>,
88) -> Result<TranscriptSource> {
89 match (transcript, session) {
90 (Some(_), Some(_)) => {
91 bail!("voice reflect: pass either a transcript path or --session, not both")
92 }
93 (Some(path), None) => {
94 if path.as_os_str() == "-" {
95 Ok(TranscriptSource::Stdin)
96 } else {
97 Ok(TranscriptSource::Path(path))
98 }
99 }
100 (None, Some(id)) => Ok(TranscriptSource::Session(id)),
101 (None, None) => Ok(TranscriptSource::Stdin),
102 }
103}
104
105#[cfg(test)]
106#[allow(clippy::unwrap_used, clippy::expect_used)]
107mod tests {
108 use super::*;
109 use clap::Parser;
110
111 #[derive(Parser)]
112 struct TestCli {
113 #[command(flatten)]
114 reflect: ReflectCommand,
115 }
116
117 #[test]
118 fn parses_no_args_defaults_to_stdin() {
119 let cli = TestCli::try_parse_from(["test"]).unwrap();
120 assert!(cli.reflect.transcript.is_none());
121 assert!(cli.reflect.session.is_none());
122 let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
123 assert!(matches!(source, TranscriptSource::Stdin));
124 }
125
126 #[test]
127 fn parses_positional_path() {
128 let cli = TestCli::try_parse_from(["test", "/tmp/t.jsonl"]).unwrap();
129 let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
130 assert!(
131 matches!(source, TranscriptSource::Path(p) if p == std::path::Path::new("/tmp/t.jsonl"))
132 );
133 }
134
135 #[test]
136 fn parses_dash_as_stdin() {
137 let cli = TestCli::try_parse_from(["test", "-"]).unwrap();
138 let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
139 assert!(matches!(source, TranscriptSource::Stdin));
140 }
141
142 #[test]
143 fn parses_session_flag() {
144 let cli = TestCli::try_parse_from(["test", "--session", "morning"]).unwrap();
145 let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
146 match source {
147 TranscriptSource::Session(s) => assert_eq!(s, "morning"),
148 other => panic!("expected Session, got {other:?}"),
149 }
150 }
151
152 #[test]
153 fn rejects_both_transcript_and_session() {
154 let cli =
155 TestCli::try_parse_from(["test", "/tmp/t.jsonl", "--session", "morning"]).unwrap();
156 let err = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap_err();
157 assert!(
158 err.to_string()
159 .contains("either a transcript path or --session, not both"),
160 "got: {err}"
161 );
162 }
163}