use super::audio::{m4a_duration_secs, ogg_duration_secs};
use crate::config::Config;
use crate::error::TalkError;
use crate::recording_cache;
use std::path::{Path, PathBuf};
const AUDIO_EXTENSIONS: &[&str] = &["ogg", "m4a", "mp4", "aac"];
fn has_audio_extension(path: &Path) -> bool {
let ext = match path.extension().and_then(|e| e.to_str()) {
Some(e) => e.to_ascii_lowercase(),
None => return false,
};
AUDIO_EXTENSIONS.iter().any(|&candidate| candidate == ext)
}
fn audio_duration_secs(path: &Path) -> Option<f64> {
let ext = path
.extension()
.and_then(|e| e.to_str())?
.to_ascii_lowercase();
match ext.as_str() {
"ogg" | "opus" => ogg_duration_secs(path),
"m4a" | "mp4" | "aac" => m4a_duration_secs(path),
_ => None,
}
}
pub(super) struct RecordingEntry {
pub(super) path: PathBuf,
pub(super) date_label: String,
pub(super) duration_label: String,
pub(super) size_label: String,
pub(super) transcript_full: String,
pub(super) transcript_preview: String,
pub(super) status: crate::recording_cache::TranscriptStatus,
}
const TRANSCRIPT_PREVIEW_CHARS: usize = 200;
fn transcript_variants(raw: &str) -> (String, String) {
let full = raw.replace('\n', " ");
let preview = if full.chars().count() > TRANSCRIPT_PREVIEW_CHARS {
let truncated: String = full.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
format!("{truncated}…")
} else {
full.clone()
};
(full, preview)
}
fn date_label_from_stem(stem: &str) -> String {
if stem.len() >= 19 {
let date_part = &stem[..10];
let time_part = stem[11..19].replace('-', ":");
format!("{} {}", date_part, time_part)
} else {
stem.to_string()
}
}
pub(super) fn format_duration(secs: f64) -> String {
let total = secs.round() as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{}:{:02}:{:02}", h, m, s)
} else {
format!("{}:{:02}", m, s)
}
}
pub(super) fn format_size(bytes: u64) -> String {
if bytes >= 1_000_000 {
format!("{:.1} MB", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{} KB", bytes / 1_000)
} else {
format!("{} B", bytes)
}
}
fn collect_audio_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), TalkError> {
let entries = std::fs::read_dir(dir).map_err(|e| {
TalkError::Config(format!(
"failed to read recordings directory {}: {}",
dir.display(),
e
))
})?;
for entry in entries {
let entry = entry
.map_err(|e| TalkError::Config(format!("failed to read directory entry: {}", e)))?;
let path = entry.path();
if path.is_symlink() {
continue;
}
if path.is_dir() {
if let Err(err) = collect_audio_recursive(&path, out) {
log::warn!("list_audio: skipping subtree {}: {}", path.display(), err);
}
} else if has_audio_extension(&path) {
out.push(path);
}
}
Ok(())
}
fn collect_audio_flat(dir: &Path) -> Result<Vec<PathBuf>, TalkError> {
let entries = std::fs::read_dir(dir).map_err(|e| {
TalkError::Config(format!(
"failed to read recordings directory {}: {}",
dir.display(),
e
))
})?;
let mut audio = Vec::new();
for entry in entries {
let entry = entry
.map_err(|e| TalkError::Config(format!("failed to read directory entry: {}", e)))?;
let path = entry.path();
if !path.is_symlink() && path.is_file() && has_audio_extension(&path) {
audio.push(path);
}
}
Ok(audio)
}
fn sort_recording_paths_newest_first(audio: &mut [PathBuf]) {
audio.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct RecordingNavigation {
pub(crate) previous: Option<PathBuf>,
pub(crate) next: Option<PathBuf>,
}
fn navigation_in_collection(
current: &Path,
mut paths: Vec<PathBuf>,
) -> Option<RecordingNavigation> {
sort_recording_paths_newest_first(&mut paths);
let current_index = paths.iter().position(|path| {
std::fs::canonicalize(path)
.map(|canonical| canonical == current)
.unwrap_or(false)
})?;
Some(RecordingNavigation {
previous: current_index
.checked_sub(1)
.and_then(|index| paths.get(index).cloned()),
next: paths.get(current_index + 1).cloned(),
})
}
fn canonical_root(path: &Path) -> Option<PathBuf> {
std::fs::canonicalize(path)
.ok()
.filter(|root| root.is_dir())
}
fn recording_navigation_in_roots(
current: &Path,
cache_root: &Path,
output_root: &Path,
) -> Result<Option<RecordingNavigation>, TalkError> {
let current = match std::fs::canonicalize(current) {
Ok(path) => path,
Err(_) => return Ok(None),
};
if let Some(cache_root) = canonical_root(cache_root) {
if current.starts_with(&cache_root) {
if let Some(navigation) =
navigation_in_collection(¤t, collect_audio_flat(&cache_root)?)
{
return Ok(Some(navigation));
}
}
}
if let Some(output_root) = canonical_root(output_root) {
if current.starts_with(&output_root) {
let mut audio = Vec::new();
collect_audio_recursive(&output_root, &mut audio)?;
return Ok(navigation_in_collection(¤t, audio));
}
}
Ok(None)
}
pub(crate) fn recording_navigation(
current: &Path,
output_root: &Path,
) -> Result<Option<RecordingNavigation>, TalkError> {
let cache_root = recording_cache::recordings_dir()?;
recording_navigation_in_roots(current, &cache_root, output_root)
}
pub(super) fn list_ogg_recordings() -> Result<Vec<RecordingEntry>, TalkError> {
let t = std::time::Instant::now();
let config = Config::load(None)?;
log::debug!("list_audio: config load {:.0?}", t.elapsed());
list_ogg_recordings_in_dir(&config.output_dir, &config)
}
fn list_ogg_recordings_in_dir(
dir: &Path,
config: &Config,
) -> Result<Vec<RecordingEntry>, TalkError> {
let t = std::time::Instant::now();
if !dir.exists() {
return Ok(Vec::new());
}
let mut audio: Vec<PathBuf> = Vec::new();
collect_audio_recursive(dir, &mut audio)?;
sort_recording_paths_newest_first(&mut audio);
let mut result = Vec::with_capacity(audio.len());
for audio_path in audio {
let stem = audio_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let date_label = date_label_from_stem(stem);
let duration_label = audio_duration_secs(&audio_path)
.map(format_duration)
.unwrap_or_else(|| "?:??".to_string());
let size_label = std::fs::metadata(&audio_path)
.map(|m| format_size(m.len()))
.unwrap_or_else(|_| "?".to_string());
let status = match crate::recording_cache::get_transcript(&audio_path) {
status @ crate::recording_cache::TranscriptStatus::InProgress => status,
_ => match crate::transcription::read_cached_transcript(&audio_path, config) {
Some(text) => crate::recording_cache::TranscriptStatus::Available(text),
None => crate::recording_cache::TranscriptStatus::NotAvailable,
},
};
let (transcript_full, transcript_preview) = match &status {
crate::recording_cache::TranscriptStatus::Available(text) => transcript_variants(text),
_ => (String::new(), String::new()),
};
result.push(RecordingEntry {
path: audio_path,
date_label,
duration_label,
size_label,
transcript_full,
transcript_preview,
status,
});
}
log::debug!(
"list_audio: {} entries, total {:.0?}",
result.len(),
t.elapsed(),
);
Ok(result)
}
pub(super) fn list_cache_recordings() -> Result<Vec<RecordingEntry>, TalkError> {
let config = Config::load(None)?;
let dir = recording_cache::recordings_dir()?;
list_cache_recordings_in_dir(&dir, &config)
}
fn list_cache_recordings_in_dir(
dir: &Path,
config: &Config,
) -> Result<Vec<RecordingEntry>, TalkError> {
let t = std::time::Instant::now();
if !dir.exists() {
return Ok(Vec::new());
}
let mut audio = collect_audio_flat(dir)?;
sort_recording_paths_newest_first(&mut audio);
let mut result = Vec::with_capacity(audio.len());
for audio_path in audio {
let stem = audio_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let date_label = date_label_from_stem(stem);
let duration_label = audio_duration_secs(&audio_path)
.map(format_duration)
.unwrap_or_else(|| "?:??".to_string());
let size_label = std::fs::metadata(&audio_path)
.map(|m| format_size(m.len()))
.unwrap_or_else(|_| "?".to_string());
let status = match recording_cache::get_transcript(&audio_path) {
status @ recording_cache::TranscriptStatus::InProgress => status,
_ => match crate::transcription::read_cached_transcript(&audio_path, config) {
Some(text) => recording_cache::TranscriptStatus::Available(text),
None => recording_cache::TranscriptStatus::NotAvailable,
},
};
let (transcript_full, transcript_preview) = match &status {
recording_cache::TranscriptStatus::Available(text) => transcript_variants(text),
_ => (String::new(), String::new()),
};
result.push(RecordingEntry {
path: audio_path,
date_label,
duration_label,
size_label,
transcript_full,
transcript_preview,
status,
});
}
log::debug!(
"list_cache: {} entries, total {:.0?}",
result.len(),
t.elapsed(),
);
Ok(result)
}
pub(super) fn delete_recording(file_path: &std::path::Path) -> Result<(), TalkError> {
delete_recording_in_dir(file_path, recording_cache::recordings_dir().ok().as_deref())
}
fn delete_recording_in_dir(file_path: &Path, dir: Option<&Path>) -> Result<(), TalkError> {
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let stem = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if let Err(e) = std::fs::remove_file(file_path) {
log::warn!("failed to remove {}: {}", file_path.display(), e);
}
let is_known_audio = ext == "wav" || AUDIO_EXTENSIONS.contains(&ext);
if is_known_audio && !stem.is_empty() {
if let Some(dir) = dir {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with(stem)
&& path.extension().and_then(|e| e.to_str()) == Some("yml")
{
if let Err(e) = std::fs::remove_file(&path) {
log::warn!("failed to remove metadata {}: {}", path.display(), e);
}
}
}
}
let wf_path = dir.join(format!("{}.wf", stem));
if wf_path.exists() {
if let Err(e) = std::fs::remove_file(&wf_path) {
log::warn!(
"failed to remove waterfall cache {}: {}",
wf_path.display(),
e
);
}
}
}
}
Ok(())
}
pub(super) fn open_in_file_manager(file_path: &std::path::Path, parent_window: >k4::Window) {
let gio_file = gtk4::gio::File::for_path(file_path);
let launcher = gtk4::FileLauncher::new(Some(&gio_file));
launcher.open_containing_folder(
Some(parent_window),
gtk4::gio::Cancellable::NONE,
|result| {
if let Err(e) = result {
log::warn!("failed to open file manager: {}", e);
}
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{MistralConfig, ProvidersConfig};
use crate::recording_cache::TranscriptionCache;
use crate::transcription::TranscriptionResult;
fn listing_config(root: &Path) -> Config {
Config {
output_dir: root.to_path_buf(),
providers: ProvidersConfig {
mistral: Some(MistralConfig {
api_key: "test-key".into(),
url: None,
model: "voxtral-mini-2507".into(),
context_bias: None,
tts_model: "voxtral-mini-tts-latest".into(),
tts_voice: None,
tts_voices: None,
}),
openai: None,
parakeet: None,
kokoro: None,
},
indicators: None,
transcription: None,
speak: None,
paste: None,
audio: None,
recording: None,
}
}
fn cached_text(path: &Path, text: &str) {
TranscriptionCache::store(
path,
crate::config::Provider::Mistral,
"voxtral-mini-2507",
false,
&TranscriptionResult {
text: text.into(),
..TranscriptionResult::default()
},
)
.expect("write sidecar");
}
#[test]
fn assembled_output_entries_mix_flat_imports_and_nested_recordings_newest_first() {
let temp = tempfile::tempdir().expect("tempdir");
let old = temp.path().join("2026-04-01T10-00-00+0200.m4a");
let nested = temp.path().join("2026/04");
std::fs::create_dir_all(&nested).expect("nested dir");
let new = nested.join("2026-04-02T10-00-00+0200.ogg");
std::fs::write(&old, b"import").expect("import");
std::fs::write(&new, b"recording").expect("recording");
cached_text(&old, "sidecar text");
recording_cache::write_pick(&old, "mistral", "voxtral-mini-2507", false, "edited\ntext")
.expect("pick");
let rows = list_ogg_recordings_in_dir(temp.path(), &listing_config(temp.path()))
.expect("assembled rows");
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].path, new);
assert_eq!(rows[0].date_label, "2026-04-02 10:00:00");
assert_eq!(
rows[0].status,
recording_cache::TranscriptStatus::NotAvailable
);
assert_eq!(rows[0].size_label, "9 B");
assert_eq!(rows[1].path, old);
assert_eq!(
rows[1].status,
recording_cache::TranscriptStatus::Available("edited\ntext".into())
);
assert_eq!(rows[1].transcript_full, "edited text");
assert_eq!(rows[1].transcript_preview, "edited text");
}
#[test]
fn assembled_cache_entry_prefers_active_pick_lock_over_sidecar_and_pick() {
let temp = tempfile::tempdir().expect("tempdir");
let audio = temp.path().join("2026-04-02T10-00-00+0200.ogg");
std::fs::write(&audio, b"audio").expect("audio");
cached_text(&audio, "sidecar text");
recording_cache::write_pick(&audio, "mistral", "voxtral-mini-2507", false, "old pick")
.expect("pick");
recording_cache::acquire_pick_lock(&audio).expect("pick lock");
let rows = list_cache_recordings_in_dir(temp.path(), &listing_config(temp.path()))
.expect("assembled rows");
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].status,
recording_cache::TranscriptStatus::InProgress
);
assert_eq!(
(&*rows[0].transcript_full, &*rows[0].transcript_preview),
("", "")
);
}
#[test]
fn assembled_cache_entry_falls_back_to_default_sidecar() {
let temp = tempfile::tempdir().expect("tempdir");
let audio = temp.path().join("2026-04-02T10-00-00+0200.ogg");
std::fs::write(&audio, b"audio").expect("audio");
cached_text(&audio, "sidecar text");
let rows = list_cache_recordings_in_dir(temp.path(), &listing_config(temp.path()))
.expect("assembled rows");
assert_eq!(rows.len(), 1);
assert_eq!(
rows[0].status,
recording_cache::TranscriptStatus::Available("sidecar text".into())
);
assert_eq!(rows[0].transcript_full, "sidecar text");
}
#[test]
fn deletion_removes_audio_sidecars_and_waterfall() {
let temp = tempfile::tempdir().expect("tempdir");
let audio = temp.path().join("memo.ogg");
let pick = temp.path().join("memo.pick.yml");
let sidecar = temp.path().join("memo_mistral_model_oneshot.yml");
let waterfall = temp.path().join("memo.wf");
for path in [&audio, &pick, &sidecar, &waterfall] {
std::fs::write(path, b"fixture").expect("fixture");
}
delete_recording_in_dir(&audio, Some(temp.path())).expect("delete");
for path in [&audio, &pick, &sidecar, &waterfall] {
assert!(!path.exists(), "{} should be removed", path.display());
}
}
#[test]
#[ignore = "BUG: deletion matches YAML sidecars by raw stem prefix and removes another recording's metadata"]
fn deletion_preserves_similarly_prefixed_recording() {
let temp = tempfile::tempdir().expect("tempdir");
let audio = temp.path().join("memo.ogg");
let other = temp.path().join("memo-extra.ogg");
let other_pick = temp.path().join("memo-extra.pick.yml");
for path in [&audio, &other, &other_pick] {
std::fs::write(path, b"fixture").expect("fixture");
}
delete_recording_in_dir(&audio, Some(temp.path())).expect("delete");
assert!(other.exists());
assert!(other_pick.exists(), "another recording's pick must survive");
}
#[test]
fn transcript_variants_empty_stays_empty() {
let (full, preview) = transcript_variants("");
assert_eq!(full, "");
assert_eq!(preview, "");
}
#[test]
fn transcript_variants_short_text_is_not_truncated() {
let raw = "Hello, world!";
let (full, preview) = transcript_variants(raw);
assert_eq!(full, "Hello, world!");
assert_eq!(preview, "Hello, world!");
assert!(!preview.contains('…'));
}
#[test]
fn transcript_variants_collapses_newlines_to_spaces() {
let raw = "first line\nsecond line\nthird";
let (full, preview) = transcript_variants(raw);
assert_eq!(full, "first line second line third");
assert_eq!(preview, "first line second line third");
}
#[test]
fn transcript_variants_boundary_exactly_preview_chars() {
let raw: String = "a".repeat(TRANSCRIPT_PREVIEW_CHARS);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full.chars().count(), TRANSCRIPT_PREVIEW_CHARS);
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS);
assert_eq!(full, raw);
assert_eq!(preview, raw);
assert!(!preview.contains('…'));
}
#[test]
fn transcript_variants_long_text_is_truncated_with_ellipsis() {
let raw: String = "b".repeat(TRANSCRIPT_PREVIEW_CHARS + 1);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert_eq!(full, raw);
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert!(preview.ends_with('…'));
let without_ellipsis: String = preview.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
assert_eq!(without_ellipsis, "b".repeat(TRANSCRIPT_PREVIEW_CHARS));
}
#[test]
fn transcript_variants_very_long_text_preserves_full() {
let raw: String = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full, raw);
assert!(full.chars().count() > TRANSCRIPT_PREVIEW_CHARS);
assert!(preview.ends_with('…'));
let expected_prefix: String = full.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
assert!(preview.starts_with(&expected_prefix));
}
#[test]
fn transcript_variants_multibyte_chars_counted_correctly() {
let raw: String = "漢".repeat(TRANSCRIPT_PREVIEW_CHARS + 1);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert!(preview.ends_with('…'));
let without_ellipsis: String = preview.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
assert_eq!(without_ellipsis, "漢".repeat(TRANSCRIPT_PREVIEW_CHARS));
}
#[test]
fn transcript_variants_long_text_with_newlines() {
let long_line = "word ".repeat(60); let raw = format!("{long_line}\n{long_line}");
let (full, preview) = transcript_variants(&raw);
assert!(!full.contains('\n'));
assert!(!preview.contains('\n'));
assert!(full.chars().count() > TRANSCRIPT_PREVIEW_CHARS);
assert!(preview.ends_with('…'));
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
}
use tempfile::TempDir;
fn basename(p: &Path) -> &str {
p.file_name().and_then(|n| n.to_str()).unwrap_or("")
}
#[test]
fn collect_audio_flat_layout() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("a.ogg"), b"").unwrap();
std::fs::write(dir.join("b.ogg"), b"").unwrap();
std::fs::write(dir.join("ignored.txt"), b"").unwrap();
std::fs::write(dir.join("also-ignored.wf"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(names, vec!["a.ogg", "b.ogg"]);
}
#[test]
fn collect_audio_nested_layout() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
let nested_2026_04 = dir.join("2026").join("04");
let nested_2025_12 = dir.join("2025").join("12");
std::fs::create_dir_all(&nested_2026_04).unwrap();
std::fs::create_dir_all(&nested_2025_12).unwrap();
std::fs::write(nested_2026_04.join("2026-04-10T08-23-15+0200.ogg"), b"").unwrap();
std::fs::write(nested_2025_12.join("2025-12-27T04-31-23+0100.ogg"), b"").unwrap();
std::fs::write(
nested_2026_04.join("2026-04-10T08-23-15+0200-voxtral.json"),
b"",
)
.unwrap();
std::fs::write(nested_2026_04.join("2026-04-10T08-23-15+0200.txt"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
assert_eq!(out.len(), 2, "should find exactly 2 nested .ogg files");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(
names,
vec![
"2025-12-27T04-31-23+0100.ogg".to_string(),
"2026-04-10T08-23-15+0200.ogg".to_string(),
]
);
}
#[test]
fn collect_audio_mixed_flat_and_nested() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("2026-04-05T10-00-00+0200.ogg"), b"").unwrap();
let nested = dir.join("2026").join("04");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("2026-04-10T10-00-00+0200.ogg"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
assert_eq!(out.len(), 2, "should find both flat and nested files");
out.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
let names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
assert_eq!(
names,
vec![
"2026-04-05T10-00-00+0200.ogg".to_string(),
"2026-04-10T10-00-00+0200.ogg".to_string(),
],
"file-name-based sort must place the Apr-5 flat file before the Apr-10 nested file"
);
}
#[test]
fn collect_audio_skips_symlinks() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("real.ogg"), b"").unwrap();
std::os::unix::fs::symlink(dir.join("real.ogg"), dir.join("link.ogg")).unwrap();
std::os::unix::fs::symlink(dir, dir.join("self-link")).unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
assert_eq!(out.len(), 1, "symlinks must be skipped");
assert_eq!(basename(&out[0]), "real.ogg");
}
#[test]
fn collect_audio_empty_directory() {
let tmp = TempDir::new().expect("tempdir");
let mut out = Vec::new();
collect_audio_recursive(tmp.path(), &mut out).expect("collect");
assert!(out.is_empty());
}
#[test]
fn collect_audio_deep_nesting() {
let tmp = TempDir::new().expect("tempdir");
let deep = tmp.path().join("2026").join("04").join("10");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("memo.ogg"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(tmp.path(), &mut out).expect("collect");
assert_eq!(out.len(), 1);
assert_eq!(basename(&out[0]), "memo.ogg");
}
#[test]
fn collect_audio_picks_up_m4a_files() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("memo.m4a"), b"").unwrap();
std::fs::write(dir.join("clip.MP4"), b"").unwrap(); std::fs::write(dir.join("stream.aac"), b"").unwrap();
std::fs::write(dir.join("ignored.txt"), b"").unwrap();
std::fs::write(dir.join("also-ignored.wav"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(names, vec!["clip.MP4", "memo.m4a", "stream.aac"]);
}
#[test]
fn collect_audio_mixed_ogg_and_m4a() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("2026").join("04");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("2026-04-10T08-23-15+0200.ogg"), b"").unwrap();
std::fs::write(nested.join("imported-memo.m4a"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(tmp.path(), &mut out).expect("collect");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(
names,
vec![
"2026-04-10T08-23-15+0200.ogg".to_string(),
"imported-memo.m4a".to_string(),
]
);
}
#[test]
fn has_audio_extension_is_case_insensitive() {
assert!(has_audio_extension(Path::new("foo.M4A")));
assert!(has_audio_extension(Path::new("foo.m4a")));
assert!(has_audio_extension(Path::new("foo.OGG")));
assert!(has_audio_extension(Path::new("foo.mp4")));
assert!(has_audio_extension(Path::new("foo.aac")));
assert!(!has_audio_extension(Path::new("foo.wav"))); assert!(!has_audio_extension(Path::new("foo.txt")));
assert!(!has_audio_extension(Path::new("foo")));
}
#[test]
fn audio_duration_secs_dispatches_correctly() {
let tmp = TempDir::new().expect("tempdir");
let unknown = tmp.path().join("clip.flac");
std::fs::write(&unknown, b"x").unwrap();
assert_eq!(audio_duration_secs(&unknown), None);
let bare = tmp.path().join("noext");
std::fs::write(&bare, b"x").unwrap();
assert_eq!(audio_duration_secs(&bare), None);
}
#[test]
fn recording_navigation_uses_flat_cache_newest_first() {
let tmp = TempDir::new().expect("tempdir");
let cache = tmp.path().join("cache");
let output = tmp.path().join("output");
std::fs::create_dir_all(cache.join("ignored-nested")).expect("create cache");
std::fs::create_dir_all(&output).expect("create output");
let newest = cache.join("2026-04-03T10-00-00+0200.ogg");
let current = cache.join("2026-04-02T10-00-00+0200.ogg");
let oldest = cache.join("2026-04-01T10-00-00+0200.ogg");
for path in [&newest, ¤t, &oldest] {
std::fs::write(path, b"").expect("write audio");
}
std::fs::write(
cache.join("ignored-nested/2026-04-04T10-00-00+0200.ogg"),
b"",
)
.expect("write nested cache audio");
let navigation = recording_navigation_in_roots(¤t, &cache, &output)
.expect("resolve navigation")
.expect("cache collection");
assert_eq!(navigation.previous.as_deref(), Some(newest.as_path()));
assert_eq!(navigation.next.as_deref(), Some(oldest.as_path()));
}
#[test]
fn recording_navigation_uses_recursive_output_and_disables_boundaries() {
let tmp = TempDir::new().expect("tempdir");
let cache = tmp.path().join("cache");
let output = tmp.path().join("output");
std::fs::create_dir_all(&cache).expect("create cache");
std::fs::create_dir_all(output.join("2026/04")).expect("create output");
let newest = output.join("2026/04/2026-04-03T10-00-00+0200.ogg");
let oldest = output.join("2026-04-01T10-00-00+0200.ogg");
std::fs::write(&newest, b"").expect("write newest");
std::fs::write(&oldest, b"").expect("write oldest");
let newest_navigation = recording_navigation_in_roots(&newest, &cache, &output)
.expect("resolve newest")
.expect("output collection");
assert_eq!(newest_navigation.previous, None);
assert_eq!(newest_navigation.next.as_deref(), Some(oldest.as_path()));
let oldest_navigation = recording_navigation_in_roots(&oldest, &cache, &output)
.expect("resolve oldest")
.expect("output collection");
assert_eq!(
oldest_navigation.previous.as_deref(),
Some(newest.as_path())
);
assert_eq!(oldest_navigation.next, None);
}
#[test]
fn recording_navigation_falls_through_to_output_nested_under_cache() {
let tmp = TempDir::new().expect("tempdir");
let cache = tmp.path().join("cache");
let output = cache.join("output").join("2026").join("04");
std::fs::create_dir_all(&output).expect("create nested output");
let newest = output.join("2026-04-03T10-00-00+0200.ogg");
let oldest = output.join("2026-04-01T10-00-00+0200.ogg");
std::fs::write(&newest, b"").expect("write newest");
std::fs::write(&oldest, b"").expect("write oldest");
let newest_navigation = recording_navigation_in_roots(&newest, &cache, &output)
.expect("resolve newest")
.expect("nested output collection");
assert_eq!(newest_navigation.previous, None);
assert_eq!(newest_navigation.next.as_deref(), Some(oldest.as_path()));
let oldest_navigation = recording_navigation_in_roots(&oldest, &cache, &output)
.expect("resolve oldest")
.expect("nested output collection");
assert_eq!(
oldest_navigation.previous.as_deref(),
Some(newest.as_path())
);
assert_eq!(oldest_navigation.next, None);
}
#[test]
fn recording_navigation_canonicalizes_current_and_rejects_foreign_files() {
let tmp = TempDir::new().expect("tempdir");
let cache = tmp.path().join("cache");
let output = tmp.path().join("output");
let foreign_dir = tmp.path().join("foreign");
std::fs::create_dir_all(&cache).expect("create cache");
std::fs::create_dir_all(&output).expect("create output");
std::fs::create_dir_all(&foreign_dir).expect("create foreign");
let current = cache.join("2026-04-02T10-00-00+0200.ogg");
let older = cache.join("2026-04-01T10-00-00+0200.ogg");
std::fs::write(¤t, b"").expect("write current");
std::fs::write(&older, b"").expect("write older");
let retry_last = cache.join("last_recording.ogg");
std::os::unix::fs::symlink(¤t, &retry_last).expect("create retry-last symlink");
let navigation = recording_navigation_in_roots(&retry_last, &cache, &output)
.expect("resolve symlink")
.expect("cache collection");
assert_eq!(navigation.previous, None);
assert_eq!(navigation.next.as_deref(), Some(older.as_path()));
let foreign = foreign_dir.join("2026-04-04T10-00-00+0200.ogg");
std::fs::write(&foreign, b"").expect("write foreign");
assert_eq!(
recording_navigation_in_roots(&foreign, &cache, &output).expect("resolve foreign"),
None
);
}
}