use crate::config::Config;
use crate::error::MarkdownError;
use chrono::{DateTime, Local, LocalResult, NaiveDate, NaiveDateTime, TimeZone};
use schemars::JsonSchema;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ContentType {
Meeting,
Memo,
Dictation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum OutputStatus {
Complete,
NoSpeech,
TranscriptOnly,
Degraded,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConsentBasis {
VerbalAllParties,
NoticeInInvite,
RecordedDisclosed,
#[serde(rename = "na")]
NotApplicable,
Unattested,
}
impl ConsentBasis {
pub fn as_str(self) -> &'static str {
match self {
Self::VerbalAllParties => "verbal_all_parties",
Self::NoticeInInvite => "notice_in_invite",
Self::RecordedDisclosed => "recorded_disclosed",
Self::NotApplicable => "na",
Self::Unattested => "unattested",
}
}
}
impl FromStr for ConsentBasis {
type Err = String;
fn from_str(raw: &str) -> Result<Self, Self::Err> {
match raw.trim() {
"verbal_all_parties" => Ok(Self::VerbalAllParties),
"notice_in_invite" => Ok(Self::NoticeInInvite),
"recorded_disclosed" => Ok(Self::RecordedDisclosed),
"na" => Ok(Self::NotApplicable),
"unattested" => Ok(Self::Unattested),
other => Err(format!(
"unknown consent basis: {other}. Use verbal_all_parties, notice_in_invite, recorded_disclosed, na, or unattested."
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ProcessingWarning {
pub step: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RecordingHealth {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice_stem_active_ratio: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_stem_active_ratio: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_dominant_ratio: Option<f32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capture_warnings: Vec<CaptureWarning>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diarization_path: Option<DiarizationPath>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum DiarizationPath {
StemEnergy,
Ml,
MlBleedDegraded,
None,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CaptureWarning {
pub kind: crate::diarize::FailureKind,
pub source: crate::diarize::CaptureSource,
pub message: String,
pub diagnostic_confidence: crate::diarize::DiagnosticConfidence,
}
impl From<crate::diarize::DegradedCapture> for RecordingHealth {
fn from(reason: crate::diarize::DegradedCapture) -> Self {
RecordingHealth::from_degraded_capture(reason, DiarizationPath::None)
}
}
impl RecordingHealth {
pub fn from_degraded_capture(
reason: crate::diarize::DegradedCapture,
diarization_path: DiarizationPath,
) -> Self {
let message = match &reason.failure_kind {
crate::diarize::FailureKind::Silent => {
if diarization_path == DiarizationPath::MlBleedDegraded {
"System audio was silent during capture; speaker labels were recovered from degraded mic bleed with low confidence.".to_string()
} else {
"System audio was silent during capture; transcript was left unlabeled."
.to_string()
}
}
crate::diarize::FailureKind::Sparse => {
if diarization_path == DiarizationPath::MlBleedDegraded {
"System audio did not contain sustained transcript-aligned remote speech; speaker labels were recovered from degraded mic bleed with low confidence.".to_string()
} else {
"System audio did not contain sustained transcript-aligned remote speech; transcript was left unlabeled.".to_string()
}
}
_ => {
if diarization_path == DiarizationPath::MlBleedDegraded {
"Capture health degraded diarization; speaker labels were recovered from degraded mic bleed with low confidence.".to_string()
} else {
"Capture health degraded diarization; transcript was left unlabeled."
.to_string()
}
}
};
RecordingHealth {
voice_stem_active_ratio: reason.voice_active_ratio,
system_stem_active_ratio: reason.system_active_ratio,
system_dominant_ratio: None,
capture_warnings: vec![CaptureWarning {
kind: reason.failure_kind,
source: reason.capture_source,
message,
diagnostic_confidence: reason.diagnostic_confidence,
}],
diarization_path: Some(diarization_path),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Frontmatter {
pub title: String,
pub r#type: ContentType,
#[serde(deserialize_with = "deserialize_local_datetime")]
pub date: DateTime<Local>,
#[serde(default = "default_duration")]
pub duration: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<OutputStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub processing_warnings: Vec<ProcessingWarning>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attendees: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attendees_raw: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub calendar_event: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub people: Vec<String>,
#[serde(default, skip_serializing_if = "EntityLinks::is_empty")]
pub entities: EntityLinks,
#[serde(skip_serializing_if = "Option::is_none")]
pub device: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, deserialize_with = "deserialize_optional_local_datetime")]
pub captured_at: Option<DateTime<Local>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub action_items: Vec<ActionItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub decisions: Vec<Decision>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub intents: Vec<Intent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recorded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub consent: Option<ConsentBasis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub consent_notice: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<Visibility>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub speaker_map: Vec<crate::diarize::SpeakerAttribution>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recording_health: Option<RecordingHealth>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
#[serde(skip)]
pub filter_diagnosis: Option<String>,
}
fn default_duration() -> String {
"0s".into()
}
fn deserialize_local_datetime<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(LocalDateTimeVisitor)
}
fn deserialize_optional_local_datetime<'de, D>(
deserializer: D,
) -> Result<Option<DateTime<Local>>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
value
.as_deref()
.map(parse_frontmatter_local_datetime)
.transpose()
.map_err(de::Error::custom)
}
struct LocalDateTimeVisitor;
impl Visitor<'_> for LocalDateTimeVisitor {
type Value = DateTime<Local>;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("an RFC3339 timestamp, local timestamp, or YYYY-MM-DD date")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
parse_frontmatter_local_datetime(value).map_err(E::custom)
}
fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&value)
}
}
fn parse_frontmatter_local_datetime(raw: &str) -> Result<DateTime<Local>, String> {
let value = raw.trim();
if value.is_empty() {
return Err("empty date".into());
}
if let Ok(parsed) = DateTime::parse_from_rfc3339(value) {
return Ok(parsed.with_timezone(&Local));
}
for format in [
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S",
] {
if let Ok(naive) = NaiveDateTime::parse_from_str(value, format) {
return local_datetime_from_naive(naive);
}
}
if let Ok(date) = NaiveDate::parse_from_str(value, "%Y-%m-%d") {
if let Some(naive) = date.and_hms_opt(0, 0, 0) {
return local_datetime_from_naive(naive);
}
}
Err(format!(
"invalid date `{}` (expected YYYY-MM-DD, local timestamp, or RFC3339 timestamp)",
value
))
}
fn local_datetime_from_naive(naive: NaiveDateTime) -> Result<DateTime<Local>, String> {
match Local.from_local_datetime(&naive) {
LocalResult::Single(dt) | LocalResult::Ambiguous(dt, _) => Ok(dt),
LocalResult::None => Err(format!("local datetime `{}` does not exist", naive)),
}
}
impl Frontmatter {
pub fn normalized_attendees(&self) -> Vec<String> {
let mut attendees = self.attendees.clone();
if let Some(raw) = &self.attendees_raw {
for attendee in parse_attendees_raw(raw) {
if !attendees
.iter()
.any(|existing| attendee_key(existing) == attendee_key(&attendee))
{
attendees.push(attendee);
}
}
}
attendees
}
}
fn attendee_key(value: &str) -> String {
value.trim().to_lowercase()
}
pub fn parse_attendees_raw(raw: &str) -> Vec<String> {
raw.split(',')
.filter_map(|token| {
let trimmed = token.trim();
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
return None;
}
if let Some(name) = trimmed
.strip_suffix(')')
.and_then(|value| value.rsplit_once('(').map(|(name, _)| name.trim()))
.filter(|name| !name.is_empty())
{
return Some(name.to_string());
}
if let Some(name) = trimmed
.strip_suffix('>')
.and_then(|value| value.rsplit_once('<').map(|(name, _)| name.trim()))
.filter(|name| !name.is_empty())
{
return Some(name.to_string());
}
Some(trimmed.to_string())
})
.fold(Vec::new(), |mut acc, attendee| {
if !acc
.iter()
.any(|existing| attendee_key(existing) == attendee_key(&attendee))
{
acc.push(attendee);
}
acc
})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct EntityLinks {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub people: Vec<EntityRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub projects: Vec<EntityRef>,
}
impl EntityLinks {
pub fn is_empty(&self) -> bool {
self.people.is_empty() && self.projects.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EntityRef {
pub slug: String,
pub label: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ActionItem {
pub assignee: String,
pub task: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub due: Option<String>,
pub status: String, }
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Decision {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub topic: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supersedes: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum IntentKind {
ActionItem,
Decision,
OpenQuestion,
Commitment,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Intent {
pub kind: IntentKind,
pub what: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub who: Option<String>,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub by_date: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Visibility {
Private,
Team,
}
#[derive(Debug, Clone, Serialize)]
pub struct WriteResult {
pub path: PathBuf,
pub title: String,
pub word_count: usize,
pub content_type: ContentType,
}
fn render_markdown(
frontmatter: &Frontmatter,
transcript: &str,
summary: Option<&str>,
user_notes: Option<&str>,
retry_audio_path: &Path,
) -> Result<String, MarkdownError> {
let yaml = serde_yaml::to_string(frontmatter)
.map_err(|e| MarkdownError::SerializationError(e.to_string()))?;
let mut content = format!("---\n{}---\n\n", yaml);
if let Some(summary_text) = summary {
content.push_str("## Summary\n\n");
content.push_str(summary_text);
content.push_str("\n\n");
}
if frontmatter.status == Some(OutputStatus::NoSpeech) {
content.push_str("*No speech detected in this recording.*\n\n");
if let Some(diagnosis) = &frontmatter.filter_diagnosis {
content.push_str(&format!("**Diagnosis**: {}\n\n", diagnosis));
}
content.push_str(&format!(
"**Retry audio**: `{}`\n\n",
retry_audio_path.display()
));
content.push_str(&format!(
"To retry after adjusting your transcription settings:\n`minutes process {}`\n\n",
retry_audio_path.display()
));
}
if let Some(notes) = user_notes {
content.push_str("## Notes\n\n");
for line in notes.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
content.push_str(&format!("- {}\n", trimmed));
}
}
content.push('\n');
}
content.push_str("## Transcript\n\n");
content.push_str(transcript);
content.push('\n');
Ok(content)
}
pub fn write(
frontmatter: &Frontmatter,
transcript: &str,
summary: Option<&str>,
user_notes: Option<&str>,
config: &Config,
) -> Result<WriteResult, MarkdownError> {
write_with_retry_path(frontmatter, transcript, summary, user_notes, None, config)
}
pub fn write_with_retry_path(
frontmatter: &Frontmatter,
transcript: &str,
summary: Option<&str>,
user_notes: Option<&str>,
retry_audio_path: Option<&Path>,
config: &Config,
) -> Result<WriteResult, MarkdownError> {
let output_dir = match frontmatter.r#type {
ContentType::Memo => config.output_dir.join("memos"),
ContentType::Meeting => config.output_dir.clone(),
ContentType::Dictation => config.output_dir.join("dictations"),
};
fs::create_dir_all(&output_dir)
.map_err(|e| MarkdownError::OutputDirError(format!("{}: {}", output_dir.display(), e)))?;
let slug = generate_slug(
&frontmatter.title,
frontmatter.date,
frontmatter.recorded_by.as_deref(),
);
let path = resolve_collision(&output_dir, &slug);
let content = render_markdown(
frontmatter,
transcript,
summary,
user_notes,
retry_audio_path.unwrap_or(&path),
)?;
fs::write(&path, &content)?;
let mode = match frontmatter.visibility {
Some(Visibility::Team) => 0o640,
_ => 0o600,
};
set_permissions(&path, mode)?;
let word_count = transcript.split_whitespace().count();
tracing::info!(
path = %path.display(),
words = word_count,
content_type = ?frontmatter.r#type,
"wrote meeting markdown"
);
Ok(WriteResult {
path,
title: frontmatter.title.clone(),
word_count,
content_type: frontmatter.r#type,
})
}
pub fn rewrite(
path: &Path,
frontmatter: &Frontmatter,
transcript: &str,
summary: Option<&str>,
user_notes: Option<&str>,
) -> Result<WriteResult, MarkdownError> {
rewrite_with_retry_path(path, frontmatter, transcript, summary, user_notes, None)
}
pub fn rewrite_with_retry_path(
path: &Path,
frontmatter: &Frontmatter,
transcript: &str,
summary: Option<&str>,
user_notes: Option<&str>,
retry_audio_path: Option<&Path>,
) -> Result<WriteResult, MarkdownError> {
let content = render_markdown(
frontmatter,
transcript,
summary,
user_notes,
retry_audio_path.unwrap_or(path),
)?;
let tmp = path.with_extension("md.tmp");
fs::write(&tmp, content)?;
let mode = match frontmatter.visibility {
Some(Visibility::Team) => 0o640,
_ => 0o600,
};
set_permissions(&tmp, mode)?;
fs::rename(&tmp, path)?;
let word_count = transcript.split_whitespace().count();
Ok(WriteResult {
path: path.to_path_buf(),
title: frontmatter.title.clone(),
word_count,
content_type: frontmatter.r#type,
})
}
pub fn rename_meeting(path: &Path, new_title: &str) -> Result<PathBuf, MarkdownError> {
let new_title = new_title.trim();
if new_title.is_empty() {
return Err(MarkdownError::RenameRefused("new title is empty".into()));
}
if new_title.contains('\n') || new_title.contains('\r') {
return Err(MarkdownError::RenameRefused(
"new title contains newlines".into(),
));
}
let original = fs::read_to_string(path)?;
let (fm_str, _body) = split_frontmatter(&original);
if fm_str.is_empty() {
return Err(MarkdownError::RenameRefused(
"file has no YAML frontmatter — not a Minutes meeting".into(),
));
}
let parsed: Frontmatter = serde_yaml::from_str(fm_str).map_err(|e| {
MarkdownError::RenameRefused(format!("frontmatter does not parse as YAML: {}", e))
})?;
let original_title = parsed.title.trim().to_string();
if original_title.is_empty() {
return Err(MarkdownError::RenameRefused(
"current frontmatter title is empty".into(),
));
}
let value: serde_yaml::Value = serde_yaml::from_str(fm_str).map_err(|e| {
MarkdownError::RenameRefused(format!("frontmatter generic parse failed: {}", e))
})?;
let title_value = value
.get("title")
.ok_or_else(|| MarkdownError::RenameRefused("no `title` field in frontmatter".into()))?;
if !title_value.is_string() {
return Err(MarkdownError::RenameRefused(
"title is not a plain scalar — rename via your text editor".into(),
));
}
if original_title == new_title {
return Ok(path.to_path_buf());
}
let title_lines: Vec<(usize, &str)> = fm_str
.lines()
.enumerate()
.filter(|(_, line)| {
let trimmed = line.trim_start();
trimmed.starts_with("title:") && !trimmed.starts_with("title::")
})
.collect();
if title_lines.is_empty() {
return Err(MarkdownError::RenameRefused(
"could not locate `title:` line in frontmatter".into(),
));
}
if title_lines.len() > 1 {
return Err(MarkdownError::RenameRefused(
"multiple `title:` lines in frontmatter — refusing to rename".into(),
));
}
let (title_line_index, original_title_line) = title_lines[0];
let after_colon = original_title_line
.trim_start()
.trim_start_matches("title:")
.trim_start();
if after_colon.starts_with('&') || after_colon.starts_with('*') || after_colon.starts_with('!')
{
return Err(MarkdownError::RenameRefused(
"title line uses YAML anchor/alias/tag — rename via your text editor".into(),
));
}
let leading_marker = after_colon.chars().next();
if matches!(leading_marker, Some('>') | Some('|')) {
return Err(MarkdownError::RenameRefused(
"title is a folded or literal block scalar — rename via your text editor".into(),
));
}
let new_title_line = format!("title: {}", yaml_quote(new_title));
let mut new_fm_lines: Vec<String> = fm_str.lines().map(String::from).collect();
new_fm_lines[title_line_index] = new_title_line;
let new_fm_text = new_fm_lines.join("\n");
let body_start = original
.find("\n---")
.map(|idx| {
let after = idx + 4;
original[after..]
.find('\n')
.map(|n| after + n + 1)
.unwrap_or(original.len())
})
.unwrap_or(original.len());
let new_content = format!("---\n{}\n---\n{}", new_fm_text, &original[body_start..]);
let tmp_path = path.with_extension("md.rename.tmp");
fs::write(&tmp_path, &new_content)?;
let original_mode = preserved_file_mode(path);
if let Err(e) = set_permissions(&tmp_path, original_mode) {
let _ = fs::remove_file(&tmp_path);
return Err(e);
}
let written = match fs::read_to_string(&tmp_path) {
Ok(s) => s,
Err(e) => {
let _ = fs::remove_file(&tmp_path);
return Err(MarkdownError::Io(e));
}
};
let (written_fm, _) = split_frontmatter(&written);
if let Err(e) = serde_yaml::from_str::<Frontmatter>(written_fm) {
let _ = fs::remove_file(&tmp_path);
return Err(MarkdownError::RenameRefused(format!(
"post-write validation failed; original file unchanged: {}",
e
)));
}
fs::rename(&tmp_path, path)?;
let new_slug = generate_slug(new_title, parsed.date, parsed.recorded_by.as_deref());
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let final_path = if path.file_name().and_then(|n| n.to_str()) == Some(new_slug.as_str()) {
path.to_path_buf()
} else {
let target = resolve_collision(parent, &new_slug);
fs::rename(path, &target)?;
target
};
Ok(final_path)
}
fn yaml_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
use std::fmt::Write;
write!(out, "\\x{:02x}", c as u32).expect("write to string");
}
c => out.push(c),
}
}
out.push('"');
out
}
fn generate_slug(title: &str, date: DateTime<Local>, recorded_by: Option<&str>) -> String {
let date_prefix = date.format("%Y-%m-%d").to_string();
let title_slug: String = title
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
let name_suffix = recorded_by
.map(|name| {
let short: String = name
.split_whitespace()
.next()
.unwrap_or(name)
.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.take(10)
.collect();
if short.is_empty() {
String::new()
} else {
format!("-{}", short)
}
})
.unwrap_or_default();
let slug = if title_slug.is_empty() {
format!("{}-untitled{}", date_prefix, name_suffix)
} else {
let truncated: String = title_slug.chars().take(60).collect();
format!("{}-{}{}", date_prefix, truncated, name_suffix)
};
format!("{}.md", slug)
}
fn resolve_collision(dir: &Path, filename: &str) -> PathBuf {
let path = dir.join(filename);
if !path.exists() {
return path;
}
let stem = filename.trim_end_matches(".md");
for i in 2..=999 {
let candidate = dir.join(format!("{}-{}.md", stem, i));
if !candidate.exists() {
return candidate;
}
}
let ts = chrono::Local::now().timestamp();
dir.join(format!("{}-{}.md", stem, ts))
}
fn set_permissions(path: &Path, _mode: u32) -> Result<(), MarkdownError> {
#[cfg(unix)]
{
let perms = fs::Permissions::from_mode(_mode);
fs::set_permissions(path, perms)?;
}
Ok(())
}
fn preserved_file_mode(_path: &Path) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(_path) {
return meta.permissions().mode() & 0o7777;
}
}
0o600
}
pub fn split_frontmatter(content: &str) -> (&str, &str) {
if !content.starts_with("---") {
return ("", content);
}
if let Some(end) = content[3..].find("\n---") {
let fm_end = end + 3;
let body_start = fm_end + 4; let body_start = content[body_start..]
.find('\n')
.map(|i| body_start + i + 1)
.unwrap_or(body_start);
(&content[3..fm_end], &content[body_start..])
} else {
("", content)
}
}
pub fn extract_field(frontmatter: &str, key: &str) -> Option<String> {
let prefix = format!("{}:", key);
for line in frontmatter.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix(&prefix) {
return Some(
value
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string(),
);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_frontmatter() -> Frontmatter {
Frontmatter {
title: "Test Meeting".into(),
r#type: ContentType::Meeting,
date: Local::now(),
duration: "5m 30s".into(),
source: None,
status: Some(OutputStatus::Complete),
tags: vec![],
attendees: vec![],
attendees_raw: None,
calendar_event: None,
people: vec![],
entities: EntityLinks::default(),
device: None,
captured_at: None,
context: None,
action_items: vec![],
decisions: vec![],
intents: vec![],
recorded_by: None,
consent: None,
consent_notice: None,
visibility: None,
speaker_map: vec![],
recording_health: None,
processing_warnings: Vec::new(),
template: None,
filter_diagnosis: None,
}
}
#[test]
fn frontmatter_accepts_manual_date_only_values() {
use chrono::Datelike;
let input = "title: Test\ntype: meeting\ndate: 2024-05-14\n";
let parsed: Frontmatter = serde_yaml::from_str(input).unwrap();
assert_eq!(parsed.date.year(), 2024);
assert_eq!(parsed.date.month(), 5);
assert_eq!(parsed.date.day(), 14);
assert_eq!(parsed.duration, "0s");
}
#[test]
fn consent_basis_serializes_expected_strings() {
assert_eq!(
serde_yaml::to_string(&ConsentBasis::VerbalAllParties).unwrap(),
"verbal_all_parties\n"
);
assert_eq!(
ConsentBasis::RecordedDisclosed.as_str(),
"recorded_disclosed"
);
assert_eq!(
"na".parse::<ConsentBasis>().unwrap(),
ConsentBasis::NotApplicable
);
assert!("mystery".parse::<ConsentBasis>().is_err());
}
#[test]
fn frontmatter_consent_fields_are_optional_and_serialize_when_present() {
let legacy: Frontmatter =
serde_yaml::from_str("title: Test\ntype: meeting\ndate: 2026-06-04T10:00:00-07:00\n")
.unwrap();
assert_eq!(legacy.consent, None);
assert_eq!(legacy.consent_notice, None);
let mut fm = test_frontmatter();
let without_consent = serde_yaml::to_string(&fm).unwrap();
assert!(!without_consent.contains("consent:"));
assert!(!without_consent.contains("consent_notice:"));
fm.consent = Some(ConsentBasis::NoticeInInvite);
fm.consent_notice = Some("Shared in the calendar invite.".into());
let with_consent = serde_yaml::to_string(&fm).unwrap();
assert!(with_consent.contains("consent: notice_in_invite"));
assert!(with_consent.contains("consent_notice: Shared in the calendar invite."));
}
#[test]
fn frontmatter_accepts_local_timestamps_without_offset() {
use chrono::{Datelike, Timelike};
let input = "title: Test\ntype: meeting\ndate: \"2026-05-14T10:30:45\"\n";
let parsed: Frontmatter = serde_yaml::from_str(input).unwrap();
assert_eq!(parsed.date.year(), 2026);
assert_eq!(parsed.date.month(), 5);
assert_eq!(parsed.date.day(), 14);
assert_eq!(parsed.date.hour(), 10);
assert_eq!(parsed.date.minute(), 30);
assert_eq!(parsed.date.second(), 45);
}
#[test]
fn frontmatter_keeps_rfc3339_dates_working() {
let input = "title: Test\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 5m\n";
let parsed: Frontmatter = serde_yaml::from_str(input).unwrap();
assert_eq!(
parsed.date.with_timezone(&chrono::Utc).to_rfc3339(),
"2026-03-17T19:00:00+00:00"
);
assert_eq!(parsed.duration, "5m");
}
#[test]
fn generates_correct_slug() {
let date = Local::now();
let slug = generate_slug("Q2 Planning Discussion", date, None);
let prefix = date.format("%Y-%m-%d").to_string();
assert!(slug.starts_with(&prefix));
assert!(slug.contains("q2-planning-discussion"));
assert!(slug.ends_with(".md"));
}
#[test]
fn generates_untitled_slug_for_empty_title() {
let date = Local::now();
let slug = generate_slug("", date, None);
assert!(slug.contains("untitled"));
}
#[test]
fn generates_slug_with_recorder_name() {
let date = Local::now();
let slug = generate_slug("Q2 Planning", date, Some("Mat Silverstein"));
assert!(slug.contains("-mat"));
assert!(slug.ends_with(".md"));
}
#[test]
#[cfg(unix)]
fn visibility_team_sets_0640_permissions() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let mut fm = test_frontmatter();
fm.visibility = Some(Visibility::Team);
let result = write(&fm, "Hello world", None, None, &config).unwrap();
let metadata = fs::metadata(&result.path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o640, "team visibility should set 0640 permissions");
}
#[test]
fn frontmatter_with_recorded_by_roundtrips() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let mut fm = test_frontmatter();
fm.recorded_by = Some("Mat".into());
let result = write(&fm, "Transcript", None, None, &config).unwrap();
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("recorded_by: Mat"));
}
#[test]
fn json_schema_generates_valid_schema() {
let schema = schemars::schema_for!(Frontmatter);
insta::assert_json_snapshot!(schema);
}
#[test]
fn frontmatter_with_speaker_map_roundtrips() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let mut fm = test_frontmatter();
fm.speaker_map = vec![crate::diarize::SpeakerAttribution {
speaker_label: "SPEAKER_1".into(),
name: "Mat".into(),
confidence: crate::diarize::Confidence::Medium,
source: crate::diarize::AttributionSource::Deterministic,
}];
let result = write(&fm, "transcript", None, None, &config).unwrap();
let content = std::fs::read_to_string(&result.path).unwrap();
assert!(
content.contains("speaker_map:"),
"speaker_map should appear in YAML"
);
assert!(content.contains("SPEAKER_1"), "speaker label should appear");
assert!(content.contains("medium"), "confidence should be lowercase");
assert!(
content.contains("deterministic"),
"source should be lowercase"
);
}
#[test]
fn recording_health_absent_roundtrips_as_omitted() {
let input = "---\ntitle: Test Meeting\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 5m\nstatus: complete\n---\n\n## Transcript\n\nHello.\n";
let (fm, body) = split_frontmatter(input);
let frontmatter: Frontmatter = serde_yaml::from_str(fm).unwrap();
assert!(frontmatter.recording_health.is_none());
let yaml = serde_yaml::to_string(&frontmatter).unwrap();
let output = format!("---\n{}---\n{}", yaml, body);
assert!(!yaml.contains("recording_health"));
assert_eq!(split_frontmatter(&output).1.as_bytes(), body.as_bytes());
}
#[test]
fn recording_health_populated_roundtrips_structurally() {
let input = "---\ntitle: Test Meeting\ntype: meeting\ndate: 2026-03-17T12:00:00-07:00\nduration: 5m\nrecording_health:\n voice_stem_active_ratio: 0.31\n system_stem_active_ratio: 0.0\n system_dominant_ratio: 0.12\n capture_warnings:\n - kind: silent\n source: system\n message: System audio was silent during capture.\n diagnostic_confidence: inferred\n diarization_path: ml-bleed-degraded\n---\n\n## Transcript\n\nHello.\n";
let (fm, body) = split_frontmatter(input);
let frontmatter: Frontmatter = serde_yaml::from_str(fm).unwrap();
let health = frontmatter.recording_health.as_ref().unwrap();
assert_eq!(health.voice_stem_active_ratio, Some(0.31));
assert_eq!(health.system_stem_active_ratio, Some(0.0));
assert_eq!(health.system_dominant_ratio, Some(0.12));
assert_eq!(
health.diarization_path,
Some(DiarizationPath::MlBleedDegraded)
);
assert_eq!(health.capture_warnings.len(), 1);
assert_eq!(
health.capture_warnings[0].kind,
crate::diarize::FailureKind::Silent
);
assert_eq!(
health.capture_warnings[0].source,
crate::diarize::CaptureSource::System
);
assert_eq!(
health.capture_warnings[0].diagnostic_confidence,
crate::diarize::DiagnosticConfidence::Inferred
);
let yaml = serde_yaml::to_string(&frontmatter).unwrap();
let output = format!("---\n{}---\n{}", yaml, body);
let reparsed: Frontmatter = serde_yaml::from_str(split_frontmatter(&output).0).unwrap();
assert_eq!(reparsed.recording_health, frontmatter.recording_health);
assert_eq!(split_frontmatter(&output).1.as_bytes(), body.as_bytes());
}
#[test]
fn processing_warnings_roundtrip_through_yaml() {
let input = "---\ntitle: Failed Summary Meeting\ntype: meeting\ndate: 2026-04-01T10:00:00-07:00\nduration: 45m\nstatus: degraded\nprocessing_warnings:\n - step: summarize\n reason: summarize_failed\n timeout_secs: 300\n message: Summarization via agent `opencode` produced no output.\n---\n\n## Transcript\n\nHello.\n";
let (fm, body) = split_frontmatter(input);
let frontmatter: Frontmatter = serde_yaml::from_str(fm).unwrap();
assert_eq!(frontmatter.status, Some(OutputStatus::Degraded));
assert_eq!(frontmatter.processing_warnings.len(), 1);
let w = &frontmatter.processing_warnings[0];
assert_eq!(w.step, "summarize");
assert_eq!(w.reason, "summarize_failed");
assert_eq!(w.timeout_secs, Some(300));
assert!(w.message.as_ref().unwrap().contains("opencode"));
let yaml = serde_yaml::to_string(&frontmatter).unwrap();
let output = format!("---\n{}---\n{}", yaml, body);
let (reparsed_fm, reparsed_body) = split_frontmatter(&output);
let reparsed: Frontmatter = serde_yaml::from_str(reparsed_fm).unwrap();
assert_eq!(reparsed.status, frontmatter.status);
assert_eq!(
reparsed.processing_warnings,
frontmatter.processing_warnings
);
assert_eq!(reparsed_body.as_bytes(), body.as_bytes());
assert!(yaml.contains("status: degraded"));
assert!(yaml.contains("processing_warnings:"));
assert!(yaml.contains("step: summarize"));
}
#[test]
fn processing_warnings_omitted_when_empty() {
let input = "---\ntitle: Normal Meeting\ntype: meeting\ndate: 2026-04-01T10:00:00-07:00\nduration: 5m\nstatus: complete\n---\n\n## Transcript\n\nHello.\n";
let (fm, _) = split_frontmatter(input);
let frontmatter: Frontmatter = serde_yaml::from_str(fm).unwrap();
assert!(frontmatter.processing_warnings.is_empty());
let yaml = serde_yaml::to_string(&frontmatter).unwrap();
assert!(!yaml.contains("processing_warnings"));
}
#[test]
fn frontmatter_without_speaker_map_omits_field() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let fm = test_frontmatter(); let result = write(&fm, "transcript", None, None, &config).unwrap();
let content = std::fs::read_to_string(&result.path).unwrap();
assert!(
!content.contains("speaker_map"),
"empty speaker_map should be omitted"
);
}
#[test]
fn resolves_filename_collisions() {
let dir = TempDir::new().unwrap();
let filename = "2026-03-17-test.md";
let path1 = resolve_collision(dir.path(), filename);
assert_eq!(path1.file_name().unwrap(), filename);
fs::write(&path1, "first").unwrap();
let path2 = resolve_collision(dir.path(), filename);
assert_eq!(
path2.file_name().unwrap().to_str().unwrap(),
"2026-03-17-test-2.md"
);
}
#[test]
#[cfg(unix)]
fn writes_markdown_with_correct_permissions() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let fm = test_frontmatter();
let result = write(&fm, "Hello world transcript", None, None, &config).unwrap();
assert!(result.path.exists());
assert_eq!(result.word_count, 3);
let metadata = fs::metadata(&result.path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "file should have 0600 permissions");
}
#[test]
fn writes_memo_to_memos_subdirectory() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let fm = Frontmatter {
r#type: ContentType::Memo,
source: Some("voice-memos".into()),
..test_frontmatter()
};
let result = write(&fm, "Voice memo text", None, None, &config).unwrap();
assert!(result.path.to_str().unwrap().contains("memos"));
}
#[test]
fn frontmatter_serializes_intents_when_present() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let mut fm = test_frontmatter();
fm.intents = vec![Intent {
kind: IntentKind::Commitment,
what: "Share revised pricing model".into(),
who: Some("sarah".into()),
status: "open".into(),
by_date: Some("Tuesday".into()),
}];
let result = write(&fm, "Transcript", None, None, &config).unwrap();
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("intents:"));
assert!(content.contains("kind: commitment"));
assert!(content.contains("who: sarah"));
assert!(content.contains("by_date: Tuesday"));
}
#[test]
fn parses_attendees_raw_names_and_fallbacks() {
let attendees = parse_attendees_raw(
"Alice Smith (alice@example.com), bob@example.com, Carol Jones <carol@example.com>, Alice Smith (alice@example.com)",
);
assert_eq!(
attendees,
vec![
"Alice Smith".to_string(),
"bob@example.com".to_string(),
"Carol Jones".to_string()
]
);
}
#[test]
fn normalized_attendees_merges_structured_and_raw_values() {
let mut fm = test_frontmatter();
fm.attendees = vec!["Alice Smith".into()];
fm.attendees_raw =
Some("Alice Smith (alice@example.com), Bob Brown (bob@example.com)".into());
assert_eq!(
fm.normalized_attendees(),
vec!["Alice Smith".to_string(), "Bob Brown".to_string()]
);
}
#[test]
fn frontmatter_serializes_entities_when_present() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let mut fm = test_frontmatter();
fm.people = vec!["Alex Chen".into()];
fm.entities = EntityLinks {
people: vec![EntityRef {
slug: "sarah-chen".into(),
label: "Alex Chen".into(),
aliases: vec!["sarah".into()],
}],
projects: vec![EntityRef {
slug: "pricing-review".into(),
label: "Pricing Review".into(),
aliases: vec!["pricing".into()],
}],
};
let result = write(&fm, "Transcript", None, None, &config).unwrap();
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("entities:"));
assert!(content.contains("slug: sarah-chen"));
assert!(content.contains("label: Alex Chen"));
assert!(content.contains("slug: pricing-review"));
}
#[test]
fn frontmatter_serializes_tags_when_present() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let mut fm = test_frontmatter();
fm.r#type = ContentType::Memo;
fm.tags = vec![
"memo".into(),
"source:voice-memos".into(),
"project:pricing-idea".into(),
];
let result = write(&fm, "Transcript", None, None, &config).unwrap();
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("tags:"));
assert!(content.contains("- memo"));
assert!(content.contains("- source:voice-memos"));
assert!(content.contains("- project:pricing-idea"));
}
fn write_meeting(dir: &TempDir, slug: &str, frontmatter_yaml: &str, body: &str) -> PathBuf {
let path = dir.path().join(slug);
let content = format!("---\n{}---\n{}", frontmatter_yaml, body);
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn rename_meeting_renames_plain_title_in_place() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-pricing-review.md",
"title: \"Pricing Review\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\n[00:00] Hello\n",
);
let new_path = rename_meeting(&path, "Quarterly Pricing").expect("rename should succeed");
let content = std::fs::read_to_string(&new_path).unwrap();
assert!(content.contains("title: \"Quarterly Pricing\""));
assert!(content.contains("[00:00] Hello"));
let (fm, _) = split_frontmatter(&content);
let parsed: Frontmatter = serde_yaml::from_str(fm).unwrap();
assert_eq!(parsed.title, "Quarterly Pricing");
assert!(
new_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains("quarterly-pricing"),
"expected slug rename, got {}",
new_path.display()
);
assert!(!path.exists());
}
#[test]
fn rename_meeting_handles_unquoted_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-team-sync.md",
"title: Team Sync\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHello\n",
);
let new_path = rename_meeting(&path, "Team Standup").unwrap();
let content = std::fs::read_to_string(&new_path).unwrap();
assert!(content.contains("title: \"Team Standup\""));
}
#[test]
fn rename_meeting_preserves_user_added_sections() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-call.md",
"title: \"Call\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Summary\n\nWent well\n\n## Custom Section From User\n\nHand-edited stuff\n\n## Transcript\n\n[00:00] Hi\n",
);
let new_path = rename_meeting(&path, "Important Call").unwrap();
let content = std::fs::read_to_string(&new_path).unwrap();
assert!(content.contains("## Custom Section From User"));
assert!(content.contains("Hand-edited stuff"));
}
#[test]
fn rename_meeting_refuses_folded_scalar_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-folded.md",
"title: >\n Pricing\n Review\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let original = std::fs::read_to_string(&path).unwrap();
let err = rename_meeting(&path, "Q4 Pricing").unwrap_err();
assert!(matches!(err, MarkdownError::RenameRefused(_)));
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(original, after);
}
#[test]
fn rename_meeting_refuses_literal_block_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-literal.md",
"title: |\n Multi\n line\n title\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let original = std::fs::read_to_string(&path).unwrap();
let err = rename_meeting(&path, "Single Line").unwrap_err();
assert!(matches!(err, MarkdownError::RenameRefused(_)));
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(original, after);
}
#[test]
fn rename_meeting_refuses_anchored_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-anchored.md",
"title: &meeting_title \"Pricing Review\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let original = std::fs::read_to_string(&path).unwrap();
let err = rename_meeting(&path, "Q4 Pricing").unwrap_err();
assert!(matches!(err, MarkdownError::RenameRefused(_)));
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(original, after);
}
#[test]
fn rename_meeting_refuses_empty_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-empty.md",
"title: \"Pricing\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let err = rename_meeting(&path, " ").unwrap_err();
assert!(matches!(err, MarkdownError::RenameRefused(_)));
}
#[test]
fn rename_meeting_refuses_newline_in_new_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-nl.md",
"title: \"Pricing\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let err = rename_meeting(&path, "First\nSecond").unwrap_err();
assert!(matches!(err, MarkdownError::RenameRefused(_)));
}
#[test]
fn rename_meeting_refuses_file_without_frontmatter() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("plain.md");
std::fs::write(&path, "no frontmatter here\n").unwrap();
let err = rename_meeting(&path, "Anything").unwrap_err();
assert!(matches!(err, MarkdownError::RenameRefused(_)));
}
#[test]
fn rename_meeting_quotes_special_chars_in_new_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-call.md",
"title: \"Call\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let new_path = rename_meeting(&path, "Quote \"this\" and \\that").unwrap();
let content = std::fs::read_to_string(&new_path).unwrap();
let (fm, _) = split_frontmatter(&content);
let parsed: Frontmatter = serde_yaml::from_str(fm).unwrap();
assert_eq!(parsed.title, "Quote \"this\" and \\that");
}
#[test]
fn rename_meeting_resolves_slug_collision() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-call.md",
"title: \"Call\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
std::fs::write(
dir.path().join("2026-04-07-pricing-review.md"),
"---\ntitle: existing\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n---\n",
)
.unwrap();
let new_path = rename_meeting(&path, "Pricing Review").unwrap();
let name = new_path.file_name().unwrap().to_str().unwrap();
assert!(
name.starts_with("2026-04-07-pricing-review-") && name.ends_with(".md"),
"expected collision-resolved slug, got {}",
name
);
}
#[test]
fn rename_meeting_refuses_aliased_title() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-aliased.md",
"title: *meeting_title\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let original = std::fs::read_to_string(&path).unwrap();
let err = rename_meeting(&path, "Q4 Pricing").unwrap_err();
assert!(matches!(err, MarkdownError::RenameRefused(_)));
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(original, after);
}
#[test]
fn rename_meeting_handles_crlf_line_endings() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("2026-04-07-crlf.md");
let content = "---\r\n\
title: \"Pricing\"\r\n\
type: meeting\r\n\
date: 2026-04-07T10:00:00-07:00\r\n\
duration: 0\r\n\
---\r\n\
## Transcript\r\n\
\r\n\
Hi\r\n";
std::fs::write(&path, content).unwrap();
let new_path = rename_meeting(&path, "Quarterly Pricing").unwrap();
let after = std::fs::read_to_string(&new_path).unwrap();
let (fm, body) = split_frontmatter(&after);
let parsed: Frontmatter = serde_yaml::from_str(fm).unwrap();
assert_eq!(parsed.title, "Quarterly Pricing");
assert!(body.contains("## Transcript"));
assert!(body.contains("Hi"));
}
#[test]
fn rename_meeting_post_write_validation_rolls_back_on_corruption() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-validate.md",
"title: \"Old\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let _ = rename_meeting(&path, "New").unwrap();
let entries: Vec<String> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
for name in &entries {
assert!(
!name.ends_with(".md.rename.tmp"),
"leftover tmp file: {} (entries: {:?})",
name,
entries
);
}
}
#[cfg(unix)]
#[test]
fn rename_meeting_preserves_user_chosen_file_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-mode.md",
"title: \"Old\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let new_path = rename_meeting(&path, "New").unwrap();
let after_meta = std::fs::metadata(&new_path).unwrap();
let after_mode = after_meta.permissions().mode() & 0o777;
assert_eq!(
after_mode, 0o644,
"rename should preserve the original file mode (0o644), got 0o{:o}",
after_mode
);
}
#[test]
fn rename_meeting_no_op_when_title_unchanged() {
let dir = TempDir::new().unwrap();
let path = write_meeting(
&dir,
"2026-04-07-pricing-review.md",
"title: \"Pricing Review\"\ntype: meeting\ndate: 2026-04-07T10:00:00-07:00\nduration: 0\n",
"## Transcript\n\nHi\n",
);
let original = std::fs::read_to_string(&path).unwrap();
let result = rename_meeting(&path, "Pricing Review").unwrap();
assert_eq!(result, path);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(original, after);
}
#[test]
fn yaml_quote_escapes_required_chars() {
assert_eq!(yaml_quote("plain"), r#""plain""#);
assert_eq!(yaml_quote("with \"quotes\""), r#""with \"quotes\"""#);
assert_eq!(yaml_quote("back\\slash"), r#""back\\slash""#);
assert_eq!(yaml_quote("tab\there"), r#""tab\there""#);
}
#[test]
fn no_speech_output_includes_retry_instructions() {
let dir = TempDir::new().unwrap();
let config = Config {
output_dir: dir.path().to_path_buf(),
..Config::default()
};
let audio = dir.path().join("capture.wav");
let fm = Frontmatter {
status: Some(OutputStatus::NoSpeech),
filter_diagnosis: Some("audio: 5.0s, whisper produced 3 segments, no_speech filter: -3 → 0, final: 0 words".into()),
..test_frontmatter()
};
let result = write_with_retry_path(&fm, "", None, None, Some(&audio), &config).unwrap();
let content = fs::read_to_string(&result.path).unwrap();
assert!(content.contains("No speech detected"));
assert!(content.contains("**Diagnosis**:"));
assert!(content.contains("no_speech filter"));
assert!(content.contains(audio.display().to_string().as_str()));
assert!(content.contains("minutes process"));
}
}