use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::voice::clock::Clock;
use crate::voice::det::UlidRng;
use crate::voice::reconcile::reconcile;
use crate::voice::render::render_markdown;
use crate::voice::session::{self, Session};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "lower")]
pub enum What {
Transcript,
Todos,
Decisions,
All,
}
pub struct ReviewOptions {
pub session_id: String,
pub what: What,
pub ulid_rng: Box<dyn UlidRng>,
pub clock: Box<dyn Clock>,
pub session_root_override: Option<PathBuf>,
}
pub fn run_review<W: Write>(opts: ReviewOptions, stdout: &mut W) -> Result<()> {
let ReviewOptions {
session_id,
what,
mut ulid_rng,
clock,
session_root_override,
} = opts;
let voice_root = match session_root_override {
Some(path) => path,
None => session::voice_root()?,
};
let session = session::open_or_create_under(&voice_root, &session_id)?;
match what {
What::Transcript => render_transcript(&session, stdout),
What::Todos | What::Decisions | What::All => {
run_reconcile_and_write(&session, what, ulid_rng.as_mut(), clock.as_ref())
}
}
}
fn render_transcript<W: Write>(session: &Session, w: &mut W) -> Result<()> {
let events = session::read_transcript(&session.paths.transcript).with_context(|| {
format!(
"reading transcript at {}",
session.paths.transcript.display()
)
})?;
render_markdown(events.into_iter().map(Ok), w)
}
fn run_reconcile_and_write(
session: &Session,
what: What,
rng: &mut dyn UlidRng,
clock: &dyn Clock,
) -> Result<()> {
let events = session.read_events()?;
let out = reconcile(&events, &session.meta.ttl_defaults, clock.now(), rng);
let write_todos = matches!(what, What::Todos | What::All);
let write_decisions = matches!(what, What::Decisions | What::All);
if write_todos {
let path = session.paths.root.join("todos.md");
atomic_write(&path, out.todos_md.as_bytes())?;
}
if write_decisions {
let path = session.paths.root.join("decisions.md");
atomic_write(&path, out.decisions_md.as_bytes())?;
}
session.append_events(&out.new_expiry_events)?;
Ok(())
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let tmp = path.with_extension(temp_extension(path));
std::fs::write(&tmp, bytes)
.with_context(|| format!("writing temp file at {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming temp file to {}", path.display()))?;
Ok(())
}
fn temp_extension(path: &Path) -> String {
match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::voice::clock::FixedClock;
use crate::voice::det::CountingUlidRng;
use crate::voice::events::{Event, EventKind, ItemClass, ItemCreate, Provenance, ReflectionId};
use tempfile::TempDir;
fn fixed_now() -> chrono::DateTime<chrono::Utc> {
use chrono::TimeZone;
chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap()
}
fn make_create(eid: u128, iid: u128, text: &str, ts: chrono::DateTime<chrono::Utc>) -> Event {
Event {
event_id: ulid::Ulid::from_parts(0, eid),
ts,
reflection_id: ReflectionId::Ulid(ulid::Ulid::from_parts(0, 100)),
provenance: Provenance {
transcript_span: None,
model: None,
prompt_version: None,
},
kind: EventKind::ItemCreate(ItemCreate {
item_id: ulid::Ulid::from_parts(0, iid),
class: ItemClass::Todo,
text: text.into(),
priority: None,
valid_until: None,
tags: None,
}),
}
}
fn build_opts(root: &Path, session_id: &str, what: What) -> ReviewOptions {
ReviewOptions {
session_id: session_id.into(),
what,
ulid_rng: Box::new(CountingUlidRng::new()),
clock: Box::new(FixedClock(fixed_now())),
session_root_override: Some(root.to_path_buf()),
}
}
#[test]
fn what_all_writes_both_files_and_appends_ttl_events() {
let tmp = TempDir::new().unwrap();
let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
let event = make_create(1, 1, "stale", fixed_now() - chrono::Duration::days(10));
session.append_events(&[event]).unwrap();
let mut out: Vec<u8> = Vec::new();
let opts = build_opts(tmp.path(), "s1", What::All);
run_review(opts, &mut out).unwrap();
assert!(out.is_empty(), "All-mode should not write to stdout");
assert!(session.paths.root.join("todos.md").exists());
assert!(session.paths.root.join("decisions.md").exists());
let after = session::read_events(&session.paths.events).unwrap();
assert_eq!(after.len(), 2, "{after:?}");
}
#[test]
fn what_todos_writes_only_todos_md() {
let tmp = TempDir::new().unwrap();
let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
let event = make_create(1, 1, "active", fixed_now());
session.append_events(&[event]).unwrap();
let mut out: Vec<u8> = Vec::new();
let opts = build_opts(tmp.path(), "s1", What::Todos);
run_review(opts, &mut out).unwrap();
assert!(session.paths.root.join("todos.md").exists());
assert!(!session.paths.root.join("decisions.md").exists());
}
#[test]
fn what_decisions_writes_only_decisions_md() {
let tmp = TempDir::new().unwrap();
let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
let create_event = make_create(1, 1, "active", fixed_now());
let decision_event = Event {
event_id: ulid::Ulid::from_parts(0, 2),
ts: fixed_now(),
reflection_id: ReflectionId::Ulid(ulid::Ulid::from_parts(0, 100)),
provenance: Provenance {
transcript_span: None,
model: None,
prompt_version: None,
},
kind: EventKind::DecisionRecord(crate::voice::events::DecisionRecord {
decision_id: ulid::Ulid::from_parts(0, 50),
text: "use ULIDs".into(),
alternatives: None,
}),
};
session
.append_events(&[create_event, decision_event])
.unwrap();
let mut out: Vec<u8> = Vec::new();
let opts = build_opts(tmp.path(), "s1", What::Decisions);
run_review(opts, &mut out).unwrap();
assert!(!session.paths.root.join("todos.md").exists());
assert!(session.paths.root.join("decisions.md").exists());
}
#[test]
fn rerunning_review_is_idempotent_for_already_expired_items() {
let tmp = TempDir::new().unwrap();
let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
let event = make_create(1, 1, "stale", fixed_now() - chrono::Duration::days(10));
session.append_events(&[event]).unwrap();
let mut buf: Vec<u8> = Vec::new();
run_review(build_opts(tmp.path(), "s1", What::All), &mut buf).unwrap();
let after_first = session::read_events(&session.paths.events).unwrap();
run_review(build_opts(tmp.path(), "s1", What::All), &mut buf).unwrap();
let after_second = session::read_events(&session.paths.events).unwrap();
assert_eq!(
after_first.len(),
after_second.len(),
"second review should add no new events"
);
}
#[test]
fn what_transcript_streams_to_caller_writer() {
let tmp = TempDir::new().unwrap();
let session = session::open_or_create_under(tmp.path(), "s1").unwrap();
let final_evt = crate::voice::TranscriptEvent::Final {
event_id: ulid::Ulid::from_parts(0, 1),
text: "hello".into(),
start: std::time::Duration::from_secs(0),
end: std::time::Duration::from_secs(1),
confidence: 1.0,
words: None,
speaker: None,
revisable: false,
};
let line = serde_json::to_string(&final_evt).unwrap() + "\n";
std::fs::write(&session.paths.transcript, line).unwrap();
let mut out: Vec<u8> = Vec::new();
let opts = build_opts(tmp.path(), "s1", What::Transcript);
run_review(opts, &mut out).unwrap();
let body = String::from_utf8(out).unwrap();
assert!(body.contains("hello"), "got: {body}");
assert!(!session.paths.root.join("todos.md").exists());
assert!(!session.paths.root.join("decisions.md").exists());
}
#[test]
fn atomic_write_handles_extensionless_path() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("noext");
atomic_write(&target, b"hi").unwrap();
let body = std::fs::read_to_string(&target).unwrap();
assert_eq!(body, "hi");
}
}