use std::io::Write;
use std::path::PathBuf;
use anyhow::{bail, Result};
use clap::Parser;
use crate::claude::client::create_default_claude_client;
use crate::voice::clock::SystemClock;
use crate::voice::det::SystemUlidRng;
use crate::voice::reflect::{run_reflect, ReflectOptions, TranscriptSource};
#[derive(Parser)]
pub struct ReflectCommand {
#[arg(value_name = "TRANSCRIPT")]
pub transcript: Option<PathBuf>,
#[arg(long)]
pub session: Option<String>,
}
impl ReflectCommand {
pub async fn execute(self) -> Result<()> {
let source = resolve_source(self.transcript, self.session)?;
if let TranscriptSource::Path(p) = &source {
if !p.exists() {
bail!("transcript file does not exist: {}", p.display());
}
}
let ai = create_default_claude_client(None, None)
.await?
.into_ai_client();
let opts = ReflectOptions {
source,
ulid_rng: Box::new(SystemUlidRng),
clock: Box::new(SystemClock),
ai,
session_root_override: None,
};
let mut buf: Vec<u8> = Vec::new();
run_reflect(opts, &mut buf).await?;
let mut stdout = std::io::stdout().lock();
stdout.write_all(&buf)?;
stdout.flush()?;
Ok(())
}
}
fn resolve_source(
transcript: Option<PathBuf>,
session: Option<String>,
) -> Result<TranscriptSource> {
match (transcript, session) {
(Some(_), Some(_)) => {
bail!("voice reflect: pass either a transcript path or --session, not both")
}
(Some(path), None) => {
if path.as_os_str() == "-" {
Ok(TranscriptSource::Stdin)
} else {
Ok(TranscriptSource::Path(path))
}
}
(None, Some(id)) => Ok(TranscriptSource::Session(id)),
(None, None) => Ok(TranscriptSource::Stdin),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct TestCli {
#[command(flatten)]
reflect: ReflectCommand,
}
#[test]
fn parses_no_args_defaults_to_stdin() {
let cli = TestCli::try_parse_from(["test"]).unwrap();
assert!(cli.reflect.transcript.is_none());
assert!(cli.reflect.session.is_none());
let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
assert!(matches!(source, TranscriptSource::Stdin));
}
#[test]
fn parses_positional_path() {
let cli = TestCli::try_parse_from(["test", "/tmp/t.jsonl"]).unwrap();
let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
assert!(
matches!(source, TranscriptSource::Path(p) if p == std::path::Path::new("/tmp/t.jsonl"))
);
}
#[test]
fn parses_dash_as_stdin() {
let cli = TestCli::try_parse_from(["test", "-"]).unwrap();
let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
assert!(matches!(source, TranscriptSource::Stdin));
}
#[test]
fn parses_session_flag() {
let cli = TestCli::try_parse_from(["test", "--session", "morning"]).unwrap();
let source = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap();
match source {
TranscriptSource::Session(s) => assert_eq!(s, "morning"),
other => panic!("expected Session, got {other:?}"),
}
}
#[test]
fn rejects_both_transcript_and_session() {
let cli =
TestCli::try_parse_from(["test", "/tmp/t.jsonl", "--session", "morning"]).unwrap();
let err = resolve_source(cli.reflect.transcript, cli.reflect.session).unwrap_err();
assert!(
err.to_string()
.contains("either a transcript path or --session, not both"),
"got: {err}"
);
}
}