1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
30#[clap(rename_all = "lower")]
31pub enum What {
32 Transcript,
34 Todos,
36 Decisions,
38 All,
40}
41
42pub struct ReviewOptions {
45 pub session_id: String,
47 pub what: What,
49 pub ulid_rng: Box<dyn UlidRng>,
51 pub clock: Box<dyn Clock>,
53 pub session_root_override: Option<PathBuf>,
57}
58
59pub 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
121fn 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 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 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 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 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 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}