1use std::io::Write;
21use std::time::Duration;
22
23use anyhow::Result;
24
25use crate::voice::transcriber::{SpeakerId, TranscriptEvent};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum OutputFormat {
31 Jsonl,
33 Md,
35}
36
37pub fn detect_format(explicit: Option<OutputFormat>, stdout_is_tty: bool) -> OutputFormat {
44 match explicit {
45 Some(fmt) => fmt,
46 None if stdout_is_tty => OutputFormat::Md,
47 None => OutputFormat::Jsonl,
48 }
49}
50
51pub fn render_jsonl<I, W>(events: I, w: &mut W) -> Result<()>
58where
59 I: IntoIterator<Item = Result<TranscriptEvent>>,
60 W: Write,
61{
62 for event in events {
63 let event = event?;
64 serde_json::to_writer(&mut *w, &event)?;
65 writeln!(w)?;
66 w.flush()?;
67 }
68 Ok(())
69}
70
71pub fn render_markdown<I, W>(events: I, w: &mut W) -> Result<()>
79where
80 I: IntoIterator<Item = Result<TranscriptEvent>>,
81 W: Write,
82{
83 let mut group: Option<MarkdownParagraph> = None;
84 for event in events {
85 let event = event?;
86 let TranscriptEvent::Final {
87 text,
88 start,
89 speaker,
90 ..
91 } = event
92 else {
93 continue;
94 };
95 match group.as_mut() {
96 Some(existing) if existing.speaker == speaker => {
97 existing.push_segment(&text);
98 }
99 _ => {
100 if let Some(prev) = group.take() {
101 prev.write(w)?;
102 w.flush()?;
103 }
104 group = Some(MarkdownParagraph::new(start, speaker, text));
105 }
106 }
107 }
108 if let Some(last) = group {
109 last.write(w)?;
110 w.flush()?;
111 }
112 Ok(())
113}
114
115struct MarkdownParagraph {
116 start: Duration,
117 speaker: Option<SpeakerId>,
118 text: String,
119}
120
121impl MarkdownParagraph {
122 fn new(start: Duration, speaker: Option<SpeakerId>, text: String) -> Self {
123 Self {
124 start,
125 speaker,
126 text,
127 }
128 }
129
130 fn push_segment(&mut self, segment: &str) {
131 if !self.text.is_empty() && !segment.is_empty() {
135 self.text.push(' ');
136 }
137 self.text.push_str(segment);
138 }
139
140 fn write<W: Write>(&self, w: &mut W) -> Result<()> {
141 let ts = fmt_timestamp(self.start);
142 match self.speaker.as_deref() {
143 Some(name) => writeln!(w, "[{ts}] **{name}**: {}", self.text)?,
144 None => writeln!(w, "[{ts}] {}", self.text)?,
145 }
146 writeln!(w)?;
147 Ok(())
148 }
149}
150
151fn fmt_timestamp(d: Duration) -> String {
158 let total = d.as_secs();
159 let h = total / 3600;
160 let m = (total % 3600) / 60;
161 let s = total % 60;
162 format!("{h:02}:{m:02}:{s:02}")
163}
164
165use crate::voice::events::{ItemClass, Priority};
168use crate::voice::reconcile::{ReconciledDecision, ReconciledItem};
169
170#[must_use]
180pub fn render_todos_md(items: &[ReconciledItem]) -> String {
181 let mut sorted: Vec<&ReconciledItem> = items.iter().collect();
182 sorted.sort_by_key(|i| i.created_event_id);
183
184 let mut out = String::from("# Todos\n");
185 for priority in [Priority::High, Priority::Normal, Priority::Low] {
186 let bucket: Vec<&&ReconciledItem> = sorted
187 .iter()
188 .filter(|i| i.item.priority == priority)
189 .collect();
190 if bucket.is_empty() {
191 continue;
192 }
193 out.push('\n');
194 out.push_str(&format!("## {} priority\n\n", priority_label(&priority)));
195 for entry in bucket {
196 out.push_str(&render_todo_line(entry));
197 out.push('\n');
198 }
199 }
200 out
201}
202
203fn priority_label(p: &Priority) -> &'static str {
204 match p {
205 Priority::High => "High",
206 Priority::Normal => "Normal",
207 Priority::Low => "Low",
208 }
209}
210
211fn render_todo_line(entry: &ReconciledItem) -> String {
212 let prefix = match entry.item.class {
213 ItemClass::Question => "- ?",
214 _ => "- [ ]",
215 };
216 let id_short = short_id(&entry.item.id);
217 match entry.item.valid_until {
218 Some(vu) => format!(
219 "{prefix} {text} — *expires {ts}* `{id_short}`",
220 text = entry.item.text,
221 ts = vu.to_rfc3339(),
222 ),
223 None => format!("{prefix} {text} `{id_short}`", text = entry.item.text),
224 }
225}
226
227#[must_use]
233pub fn render_decisions_md(decisions: &[ReconciledDecision]) -> String {
234 let mut sorted: Vec<&ReconciledDecision> = decisions.iter().collect();
235 sorted.sort_by_key(|d| std::cmp::Reverse(d.created_event_id));
236
237 let mut out = String::from("# Decisions\n");
238 if sorted.is_empty() {
239 return out;
240 }
241 out.push('\n');
242 for entry in sorted {
243 let id_short = short_id(&entry.decision.id);
244 out.push_str(&format!(
245 "- **{text}** `{id_short}`\n",
246 text = entry.decision.text,
247 ));
248 if !entry.decision.alternatives.is_empty() {
249 out.push_str(&format!(
250 " Alternatives considered: {}\n",
251 entry.decision.alternatives.join(", "),
252 ));
253 }
254 }
255 out
256}
257
258fn short_id(u: &ulid::Ulid) -> String {
259 let s = u.to_string();
260 s.chars().take(8).collect()
261}
262
263#[cfg(test)]
264#[allow(clippy::unwrap_used, clippy::expect_used)]
265mod tests {
266 use super::*;
267 use crate::voice::transcriber::EndpointKind;
268
269 struct AlwaysFailWriter;
273
274 impl Write for AlwaysFailWriter {
275 fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
276 Err(std::io::Error::other("forced write failure"))
277 }
278 fn flush(&mut self) -> std::io::Result<()> {
279 Ok(())
280 }
281 }
282
283 struct FlushFailWriter;
287
288 impl Write for FlushFailWriter {
289 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
290 Ok(buf.len())
291 }
292 fn flush(&mut self) -> std::io::Result<()> {
293 Err(std::io::Error::other("forced flush failure"))
294 }
295 }
296
297 fn final_event(text: &str, start_secs: u64, speaker: Option<&str>) -> TranscriptEvent {
298 TranscriptEvent::Final {
299 event_id: ulid::Ulid::from_parts(0, u128::from(start_secs) + 1),
300 text: text.to_string(),
301 start: Duration::from_secs(start_secs),
302 end: Duration::from_secs(start_secs + 1),
303 confidence: 0.9,
304 words: None,
305 speaker: speaker.map(str::to_string),
306 revisable: false,
307 }
308 }
309
310 fn render_md_to_string<I>(events: I) -> String
311 where
312 I: IntoIterator<Item = TranscriptEvent>,
313 {
314 let mut buf: Vec<u8> = Vec::new();
315 render_markdown(events.into_iter().map(Ok), &mut buf).unwrap();
316 String::from_utf8(buf).unwrap()
317 }
318
319 fn render_jsonl_to_string<I>(events: I) -> String
320 where
321 I: IntoIterator<Item = TranscriptEvent>,
322 {
323 let mut buf: Vec<u8> = Vec::new();
324 render_jsonl(events.into_iter().map(Ok), &mut buf).unwrap();
325 String::from_utf8(buf).unwrap()
326 }
327
328 #[test]
329 fn detect_format_explicit_wins_over_tty() {
330 assert_eq!(
331 detect_format(Some(OutputFormat::Jsonl), true),
332 OutputFormat::Jsonl
333 );
334 assert_eq!(
335 detect_format(Some(OutputFormat::Md), false),
336 OutputFormat::Md
337 );
338 }
339
340 #[test]
341 fn detect_format_tty_defaults_to_md() {
342 assert_eq!(detect_format(None, true), OutputFormat::Md);
343 }
344
345 #[test]
346 fn detect_format_pipe_defaults_to_jsonl() {
347 assert_eq!(detect_format(None, false), OutputFormat::Jsonl);
348 }
349
350 #[test]
351 fn fmt_timestamp_pads_zero() {
352 assert_eq!(fmt_timestamp(Duration::from_secs(0)), "00:00:00");
353 assert_eq!(fmt_timestamp(Duration::from_secs(7)), "00:00:07");
354 assert_eq!(fmt_timestamp(Duration::from_secs(75)), "00:01:15");
355 assert_eq!(fmt_timestamp(Duration::from_secs(3_600)), "01:00:00");
356 assert_eq!(
357 fmt_timestamp(Duration::from_secs(3_600 + 23 * 60 + 45)),
358 "01:23:45"
359 );
360 }
361
362 #[test]
363 fn fmt_timestamp_truncates_subsecond() {
364 assert_eq!(fmt_timestamp(Duration::from_millis(1_900)), "00:00:01");
367 }
368
369 #[test]
370 fn render_jsonl_emits_one_line_per_event_and_flushes() {
371 let out = render_jsonl_to_string([
372 final_event("hello", 0, None),
373 TranscriptEvent::Endpoint {
374 at: Duration::from_millis(1_500),
375 kind: EndpointKind::StreamEnd,
376 },
377 ]);
378 let lines: Vec<&str> = out.lines().collect();
379 assert_eq!(lines.len(), 2);
380 assert!(lines[0].starts_with(r#"{"type":"final""#));
381 assert!(lines[0].contains(r#""text":"hello""#));
382 assert!(lines[1].starts_with(r#"{"type":"endpoint""#));
383 assert!(lines[1].contains(r#""at":1.5"#));
384 assert!(out.ends_with('\n'));
386 }
387
388 #[test]
389 fn render_jsonl_propagates_stream_error() {
390 let events: Vec<Result<TranscriptEvent>> = vec![Err(anyhow::anyhow!("backend exploded"))];
391 let mut buf: Vec<u8> = Vec::new();
392 let err = render_jsonl(events, &mut buf).unwrap_err();
393 assert!(err.to_string().contains("backend exploded"));
394 }
395
396 #[test]
397 fn render_markdown_propagates_stream_error() {
398 let events: Vec<Result<TranscriptEvent>> = vec![Err(anyhow::anyhow!("backend exploded"))];
399 let mut buf: Vec<u8> = Vec::new();
400 let err = render_markdown(events, &mut buf).unwrap_err();
401 assert!(err.to_string().contains("backend exploded"));
402 }
403
404 #[test]
405 fn render_markdown_handles_empty_text_segment_in_group() {
406 let out = render_md_to_string([
411 final_event("hello", 0, Some("alice")),
412 final_event("", 2, Some("alice")),
413 ]);
414 assert_eq!(out, "[00:00:00] **alice**: hello\n\n");
415 }
416
417 #[test]
418 fn render_markdown_skips_partial_and_endpoint() {
419 let out = render_md_to_string([
420 TranscriptEvent::Partial {
421 text: "ignored".into(),
422 start: Duration::from_secs(0),
423 end: Duration::from_secs(1),
424 words: None,
425 speaker: None,
426 },
427 final_event("kept", 0, None),
428 TranscriptEvent::Endpoint {
429 at: Duration::from_secs(2),
430 kind: EndpointKind::StreamEnd,
431 },
432 ]);
433 assert!(!out.contains("ignored"));
434 assert!(out.contains("kept"));
435 assert!(!out.contains("[00:00:02]"));
437 }
438
439 #[test]
440 fn render_markdown_groups_consecutive_same_speaker_finals() {
441 let out = render_md_to_string([
442 final_event("hello", 0, Some("alice")),
443 final_event("world", 2, Some("alice")),
444 final_event("hi", 4, Some("bob")),
445 ]);
446 let expected = "[00:00:00] **alice**: hello world\n\n[00:00:04] **bob**: hi\n\n";
448 assert_eq!(out, expected);
449 }
450
451 #[test]
452 fn render_markdown_groups_consecutive_none_speaker_finals() {
453 let out =
454 render_md_to_string([final_event("alpha", 0, None), final_event("beta", 3, None)]);
455 assert_eq!(out, "[00:00:00] alpha beta\n\n");
458 }
459
460 #[test]
461 fn render_markdown_speaker_change_starts_new_paragraph() {
462 let out = render_md_to_string([
463 final_event("a", 0, Some("alice")),
464 final_event("b", 1, None),
465 final_event("c", 2, Some("alice")),
466 ]);
467 let expected = "[00:00:00] **alice**: a\n\n[00:00:01] b\n\n[00:00:02] **alice**: c\n\n";
469 assert_eq!(out, expected);
470 }
471
472 #[test]
473 fn render_jsonl_propagates_writer_error() {
474 let events = [Ok(final_event("a", 0, None))];
475 let err = render_jsonl(events, &mut AlwaysFailWriter).unwrap_err();
476 assert!(err.to_string().contains("forced write failure"));
477 }
478
479 #[test]
480 fn render_jsonl_propagates_flush_error() {
481 let events = [Ok(final_event("a", 0, None))];
482 let err = render_jsonl(events, &mut FlushFailWriter).unwrap_err();
483 assert!(err.to_string().contains("forced flush failure"));
484 }
485
486 #[test]
487 fn render_markdown_propagates_writer_error_with_speaker() {
488 let events = [Ok(final_event("a", 0, Some("alice")))];
491 let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
492 assert!(err.to_string().contains("forced write failure"));
493 }
494
495 #[test]
496 fn render_markdown_propagates_writer_error_no_speaker() {
497 let events = [Ok(final_event("a", 0, None))];
499 let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
500 assert!(err.to_string().contains("forced write failure"));
501 }
502
503 #[test]
504 fn render_markdown_propagates_flush_error_at_paragraph_end() {
505 let events = [Ok(final_event("a", 0, None))];
508 let err = render_markdown(events, &mut FlushFailWriter).unwrap_err();
509 assert!(err.to_string().contains("forced flush failure"));
510 }
511
512 #[test]
513 fn render_markdown_propagates_writer_error_at_paragraph_break() {
514 let events = [
521 Ok(final_event("a", 0, Some("alice"))),
522 Ok(final_event("b", 1, Some("bob"))),
523 ];
524 let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
525 assert!(err.to_string().contains("forced write failure"));
526 }
527
528 #[test]
529 fn render_markdown_empty_input_writes_nothing() {
530 let out = render_md_to_string::<[TranscriptEvent; 0]>([]);
531 assert_eq!(out, "");
532 }
533}