use std::io::Write;
use std::time::Duration;
use anyhow::Result;
use crate::voice::transcriber::{SpeakerId, TranscriptEvent};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Jsonl,
Md,
}
pub fn detect_format(explicit: Option<OutputFormat>, stdout_is_tty: bool) -> OutputFormat {
match explicit {
Some(fmt) => fmt,
None if stdout_is_tty => OutputFormat::Md,
None => OutputFormat::Jsonl,
}
}
pub fn render_jsonl<I, W>(events: I, w: &mut W) -> Result<()>
where
I: IntoIterator<Item = Result<TranscriptEvent>>,
W: Write,
{
for event in events {
let event = event?;
serde_json::to_writer(&mut *w, &event)?;
writeln!(w)?;
w.flush()?;
}
Ok(())
}
pub fn render_markdown<I, W>(events: I, w: &mut W) -> Result<()>
where
I: IntoIterator<Item = Result<TranscriptEvent>>,
W: Write,
{
let mut group: Option<MarkdownParagraph> = None;
for event in events {
let event = event?;
let TranscriptEvent::Final {
text,
start,
speaker,
..
} = event
else {
continue;
};
match group.as_mut() {
Some(existing) if existing.speaker == speaker => {
existing.push_segment(&text);
}
_ => {
if let Some(prev) = group.take() {
prev.write(w)?;
w.flush()?;
}
group = Some(MarkdownParagraph::new(start, speaker, text));
}
}
}
if let Some(last) = group {
last.write(w)?;
w.flush()?;
}
Ok(())
}
struct MarkdownParagraph {
start: Duration,
speaker: Option<SpeakerId>,
text: String,
}
impl MarkdownParagraph {
fn new(start: Duration, speaker: Option<SpeakerId>, text: String) -> Self {
Self {
start,
speaker,
text,
}
}
fn push_segment(&mut self, segment: &str) {
if !self.text.is_empty() && !segment.is_empty() {
self.text.push(' ');
}
self.text.push_str(segment);
}
fn write<W: Write>(&self, w: &mut W) -> Result<()> {
let ts = fmt_timestamp(self.start);
match self.speaker.as_deref() {
Some(name) => writeln!(w, "[{ts}] **{name}**: {}", self.text)?,
None => writeln!(w, "[{ts}] {}", self.text)?,
}
writeln!(w)?;
Ok(())
}
}
fn fmt_timestamp(d: Duration) -> String {
let total = d.as_secs();
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
format!("{h:02}:{m:02}:{s:02}")
}
use crate::voice::events::{ItemClass, Priority};
use crate::voice::reconcile::{ReconciledDecision, ReconciledItem};
#[must_use]
pub fn render_todos_md(items: &[ReconciledItem]) -> String {
let mut sorted: Vec<&ReconciledItem> = items.iter().collect();
sorted.sort_by_key(|i| i.created_event_id);
let mut out = String::from("# Todos\n");
for priority in [Priority::High, Priority::Normal, Priority::Low] {
let bucket: Vec<&&ReconciledItem> = sorted
.iter()
.filter(|i| i.item.priority == priority)
.collect();
if bucket.is_empty() {
continue;
}
out.push('\n');
out.push_str(&format!("## {} priority\n\n", priority_label(&priority)));
for entry in bucket {
out.push_str(&render_todo_line(entry));
out.push('\n');
}
}
out
}
fn priority_label(p: &Priority) -> &'static str {
match p {
Priority::High => "High",
Priority::Normal => "Normal",
Priority::Low => "Low",
}
}
fn render_todo_line(entry: &ReconciledItem) -> String {
let prefix = match entry.item.class {
ItemClass::Question => "- ?",
_ => "- [ ]",
};
let id_short = short_id(&entry.item.id);
match entry.item.valid_until {
Some(vu) => format!(
"{prefix} {text} — *expires {ts}* `{id_short}`",
text = entry.item.text,
ts = vu.to_rfc3339(),
),
None => format!("{prefix} {text} `{id_short}`", text = entry.item.text),
}
}
#[must_use]
pub fn render_decisions_md(decisions: &[ReconciledDecision]) -> String {
let mut sorted: Vec<&ReconciledDecision> = decisions.iter().collect();
sorted.sort_by_key(|d| std::cmp::Reverse(d.created_event_id));
let mut out = String::from("# Decisions\n");
if sorted.is_empty() {
return out;
}
out.push('\n');
for entry in sorted {
let id_short = short_id(&entry.decision.id);
out.push_str(&format!(
"- **{text}** `{id_short}`\n",
text = entry.decision.text,
));
if !entry.decision.alternatives.is_empty() {
out.push_str(&format!(
" Alternatives considered: {}\n",
entry.decision.alternatives.join(", "),
));
}
}
out
}
fn short_id(u: &ulid::Ulid) -> String {
let s = u.to_string();
s.chars().take(8).collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::voice::transcriber::EndpointKind;
struct AlwaysFailWriter;
impl Write for AlwaysFailWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("forced write failure"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
struct FlushFailWriter;
impl Write for FlushFailWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Err(std::io::Error::other("forced flush failure"))
}
}
fn final_event(text: &str, start_secs: u64, speaker: Option<&str>) -> TranscriptEvent {
TranscriptEvent::Final {
event_id: ulid::Ulid::from_parts(0, u128::from(start_secs) + 1),
text: text.to_string(),
start: Duration::from_secs(start_secs),
end: Duration::from_secs(start_secs + 1),
confidence: 0.9,
words: None,
speaker: speaker.map(str::to_string),
revisable: false,
}
}
fn render_md_to_string<I>(events: I) -> String
where
I: IntoIterator<Item = TranscriptEvent>,
{
let mut buf: Vec<u8> = Vec::new();
render_markdown(events.into_iter().map(Ok), &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
fn render_jsonl_to_string<I>(events: I) -> String
where
I: IntoIterator<Item = TranscriptEvent>,
{
let mut buf: Vec<u8> = Vec::new();
render_jsonl(events.into_iter().map(Ok), &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
#[test]
fn detect_format_explicit_wins_over_tty() {
assert_eq!(
detect_format(Some(OutputFormat::Jsonl), true),
OutputFormat::Jsonl
);
assert_eq!(
detect_format(Some(OutputFormat::Md), false),
OutputFormat::Md
);
}
#[test]
fn detect_format_tty_defaults_to_md() {
assert_eq!(detect_format(None, true), OutputFormat::Md);
}
#[test]
fn detect_format_pipe_defaults_to_jsonl() {
assert_eq!(detect_format(None, false), OutputFormat::Jsonl);
}
#[test]
fn fmt_timestamp_pads_zero() {
assert_eq!(fmt_timestamp(Duration::from_secs(0)), "00:00:00");
assert_eq!(fmt_timestamp(Duration::from_secs(7)), "00:00:07");
assert_eq!(fmt_timestamp(Duration::from_secs(75)), "00:01:15");
assert_eq!(fmt_timestamp(Duration::from_secs(3_600)), "01:00:00");
assert_eq!(
fmt_timestamp(Duration::from_secs(3_600 + 23 * 60 + 45)),
"01:23:45"
);
}
#[test]
fn fmt_timestamp_truncates_subsecond() {
assert_eq!(fmt_timestamp(Duration::from_millis(1_900)), "00:00:01");
}
#[test]
fn render_jsonl_emits_one_line_per_event_and_flushes() {
let out = render_jsonl_to_string([
final_event("hello", 0, None),
TranscriptEvent::Endpoint {
at: Duration::from_millis(1_500),
kind: EndpointKind::StreamEnd,
},
]);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with(r#"{"type":"final""#));
assert!(lines[0].contains(r#""text":"hello""#));
assert!(lines[1].starts_with(r#"{"type":"endpoint""#));
assert!(lines[1].contains(r#""at":1.5"#));
assert!(out.ends_with('\n'));
}
#[test]
fn render_jsonl_propagates_stream_error() {
let events: Vec<Result<TranscriptEvent>> = vec![Err(anyhow::anyhow!("backend exploded"))];
let mut buf: Vec<u8> = Vec::new();
let err = render_jsonl(events, &mut buf).unwrap_err();
assert!(err.to_string().contains("backend exploded"));
}
#[test]
fn render_markdown_propagates_stream_error() {
let events: Vec<Result<TranscriptEvent>> = vec![Err(anyhow::anyhow!("backend exploded"))];
let mut buf: Vec<u8> = Vec::new();
let err = render_markdown(events, &mut buf).unwrap_err();
assert!(err.to_string().contains("backend exploded"));
}
#[test]
fn render_markdown_handles_empty_text_segment_in_group() {
let out = render_md_to_string([
final_event("hello", 0, Some("alice")),
final_event("", 2, Some("alice")),
]);
assert_eq!(out, "[00:00:00] **alice**: hello\n\n");
}
#[test]
fn render_markdown_skips_partial_and_endpoint() {
let out = render_md_to_string([
TranscriptEvent::Partial {
text: "ignored".into(),
start: Duration::from_secs(0),
end: Duration::from_secs(1),
words: None,
speaker: None,
},
final_event("kept", 0, None),
TranscriptEvent::Endpoint {
at: Duration::from_secs(2),
kind: EndpointKind::StreamEnd,
},
]);
assert!(!out.contains("ignored"));
assert!(out.contains("kept"));
assert!(!out.contains("[00:00:02]"));
}
#[test]
fn render_markdown_groups_consecutive_same_speaker_finals() {
let out = render_md_to_string([
final_event("hello", 0, Some("alice")),
final_event("world", 2, Some("alice")),
final_event("hi", 4, Some("bob")),
]);
let expected = "[00:00:00] **alice**: hello world\n\n[00:00:04] **bob**: hi\n\n";
assert_eq!(out, expected);
}
#[test]
fn render_markdown_groups_consecutive_none_speaker_finals() {
let out =
render_md_to_string([final_event("alpha", 0, None), final_event("beta", 3, None)]);
assert_eq!(out, "[00:00:00] alpha beta\n\n");
}
#[test]
fn render_markdown_speaker_change_starts_new_paragraph() {
let out = render_md_to_string([
final_event("a", 0, Some("alice")),
final_event("b", 1, None),
final_event("c", 2, Some("alice")),
]);
let expected = "[00:00:00] **alice**: a\n\n[00:00:01] b\n\n[00:00:02] **alice**: c\n\n";
assert_eq!(out, expected);
}
#[test]
fn render_jsonl_propagates_writer_error() {
let events = [Ok(final_event("a", 0, None))];
let err = render_jsonl(events, &mut AlwaysFailWriter).unwrap_err();
assert!(err.to_string().contains("forced write failure"));
}
#[test]
fn render_jsonl_propagates_flush_error() {
let events = [Ok(final_event("a", 0, None))];
let err = render_jsonl(events, &mut FlushFailWriter).unwrap_err();
assert!(err.to_string().contains("forced flush failure"));
}
#[test]
fn render_markdown_propagates_writer_error_with_speaker() {
let events = [Ok(final_event("a", 0, Some("alice")))];
let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
assert!(err.to_string().contains("forced write failure"));
}
#[test]
fn render_markdown_propagates_writer_error_no_speaker() {
let events = [Ok(final_event("a", 0, None))];
let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
assert!(err.to_string().contains("forced write failure"));
}
#[test]
fn render_markdown_propagates_flush_error_at_paragraph_end() {
let events = [Ok(final_event("a", 0, None))];
let err = render_markdown(events, &mut FlushFailWriter).unwrap_err();
assert!(err.to_string().contains("forced flush failure"));
}
#[test]
fn render_markdown_propagates_writer_error_at_paragraph_break() {
let events = [
Ok(final_event("a", 0, Some("alice"))),
Ok(final_event("b", 1, Some("bob"))),
];
let err = render_markdown(events, &mut AlwaysFailWriter).unwrap_err();
assert!(err.to_string().contains("forced write failure"));
}
#[test]
fn render_markdown_empty_input_writes_nothing() {
let out = render_md_to_string::<[TranscriptEvent; 0]>([]);
assert_eq!(out, "");
}
}