use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptEntry {
pub timestamp: String,
pub speaker: String,
pub text: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
}
pub struct TranscriptLogger {
path: PathBuf,
}
impl TranscriptLogger {
pub fn new(workspace: &Path, session_id: &str) -> std::io::Result<Self> {
let dir = workspace.join(".clawft").join("transcripts");
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{session_id}.jsonl"));
Ok(Self { path })
}
pub async fn log(&self, entry: &TranscriptEntry) -> std::io::Result<()> {
let mut line = serde_json::to_string(entry)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
line.push('\n');
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.await?;
file.write_all(line.as_bytes()).await?;
file.flush().await?;
Ok(())
}
pub async fn read_all(&self) -> std::io::Result<Vec<TranscriptEntry>> {
let content = tokio::fs::read_to_string(&self.path).await?;
let entries: Vec<TranscriptEntry> = content
.lines()
.filter(|l| !l.is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
Ok(entries)
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transcript_entry_serde_roundtrip() {
let entry = TranscriptEntry {
timestamp: "2026-02-24T12:00:00Z".into(),
speaker: "user".into(),
text: "hello world".into(),
source: "local".into(),
confidence: Some(0.95),
language: Some("en".into()),
duration_ms: Some(1500),
};
let json = serde_json::to_string(&entry).unwrap();
let restored: TranscriptEntry = serde_json::from_str(&json).unwrap();
assert_eq!(restored.text, "hello world");
assert_eq!(restored.speaker, "user");
assert!((restored.confidence.unwrap() - 0.95).abs() < f32::EPSILON);
}
#[test]
fn transcript_entry_optional_fields_omitted() {
let entry = TranscriptEntry {
timestamp: "2026-02-24T12:00:00Z".into(),
speaker: "agent".into(),
text: "hi".into(),
source: "cloud:openai-whisper".into(),
confidence: None,
language: None,
duration_ms: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("confidence"));
assert!(!json.contains("language"));
assert!(!json.contains("duration_ms"));
}
#[tokio::test]
async fn logger_write_and_read() {
let tmp_dir = std::env::temp_dir().join("clawft_test_transcript");
let _ = std::fs::remove_dir_all(&tmp_dir);
let logger = TranscriptLogger::new(&tmp_dir, "test-session-001").unwrap();
assert!(logger.path().to_string_lossy().contains("test-session-001.jsonl"));
let entry1 = TranscriptEntry {
timestamp: "2026-02-24T12:00:00Z".into(),
speaker: "user".into(),
text: "what time is it".into(),
source: "local".into(),
confidence: Some(0.85),
language: Some("en".into()),
duration_ms: Some(2000),
};
let entry2 = TranscriptEntry {
timestamp: "2026-02-24T12:00:01Z".into(),
speaker: "agent".into(),
text: "it is noon".into(),
source: "local".into(),
confidence: None,
language: None,
duration_ms: None,
};
logger.log(&entry1).await.unwrap();
logger.log(&entry2).await.unwrap();
let entries = logger.read_all().await.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].speaker, "user");
assert_eq!(entries[0].text, "what time is it");
assert_eq!(entries[1].speaker, "agent");
assert_eq!(entries[1].text, "it is noon");
let _ = std::fs::remove_dir_all(&tmp_dir);
}
#[test]
fn logger_creates_directory() {
let tmp_dir = std::env::temp_dir().join("clawft_test_transcript_dir");
let _ = std::fs::remove_dir_all(&tmp_dir);
let logger = TranscriptLogger::new(&tmp_dir, "dir-test").unwrap();
assert!(logger.path().parent().unwrap().exists());
let _ = std::fs::remove_dir_all(&tmp_dir);
}
}