use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::Args as ClapArgs;
use scrybe_core::{repair_session, RepairOutcome};
use crate::runtime::{expand_root, load_or_default_config, resolve_session_folder};
#[derive(ClapArgs, Debug)]
pub struct Args {
pub id_or_folder: String,
#[arg(long)]
pub root: Option<PathBuf>,
}
#[allow(clippy::unused_async)]
pub async fn run(args: Args) -> Result<()> {
let root = if let Some(p) = args.root.as_deref() {
expand_root(p)
} else {
let cfg = load_or_default_config()?;
expand_root(&cfg.storage.root)
};
let folder = resolve_session_folder(&root, &args.id_or_folder)
.with_context(|| format!("resolving session {}", args.id_or_folder))?;
clear_stale_session_lock(&folder)?;
match repair_session(&folder)
.with_context(|| format!("repairing session at {}", folder.display()))?
{
RepairOutcome::Repaired(report) => {
println!(
"scrybe repair: recovered {:.1}s of {}-channel audio to {}",
report.encoded_secs,
report.channels,
report.audio_path.display()
);
if report.wrote_meta {
println!(
"scrybe repair: wrote a reconstructed meta.toml (title, STT/LLM/diarizer \
names, and consent details were never durably recorded before the crash)"
);
}
}
RepairOutcome::MetadataReconstructed(report) => {
println!(
"scrybe repair: audio was already complete ({:.1}s, {} channels); wrote reconstructed meta.toml",
report.encoded_secs, report.channels
);
println!(
"scrybe repair: run `scrybe notes {}` to regenerate notes.md",
folder.display()
);
}
RepairOutcome::NothingToRepair => {
println!(
"scrybe repair: nothing to repair in {} (no journal/, or already merged)",
folder.display()
);
}
}
Ok(())
}
fn clear_stale_session_lock(folder: &std::path::Path) -> Result<()> {
let lock_path = folder.join(scrybe_core::storage::PID_LOCK_NAME);
if !lock_path.exists() {
return Ok(());
}
if crate::commands::doctor::pid_alive_from_lock(&lock_path)? {
anyhow::bail!(
"session at {} is still owned by the process in {}",
folder.display(),
lock_path.display()
);
}
std::fs::remove_file(&lock_path)
.with_context(|| format!("removing stale session lock {}", lock_path.display()))
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::cast_precision_loss
)]
mod tests {
use super::*;
#[tokio::test]
async fn test_run_reports_nothing_to_repair_when_no_journal_present() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("2026-04-29-1430-clean-01HXYZ");
std::fs::create_dir(&folder).unwrap();
run(Args {
id_or_folder: "2026-04-29-1430-clean-01HXYZ".into(),
root: Some(dir.path().to_path_buf()),
})
.await
.unwrap();
}
#[cfg(unix)]
#[test]
fn test_clear_stale_session_lock_removes_dead_owner() {
let mut exited = std::process::Command::new("true").spawn().unwrap();
let dead_pid = exited.id();
exited.wait().unwrap();
let dir = tempfile::tempdir().unwrap();
let lock = dir.path().join(scrybe_core::storage::PID_LOCK_NAME);
std::fs::write(&lock, dead_pid.to_string()).unwrap();
clear_stale_session_lock(dir.path()).unwrap();
assert!(!lock.exists());
}
#[tokio::test]
async fn test_run_recovers_audio_from_crashed_session() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("2026-04-29-1430-crashed-01HZZZ");
std::fs::create_dir(&folder).unwrap();
let journal_dir = folder.join("journal");
let writer = scrybe_core::pipeline::JournalWriter::spawn(
&journal_dir,
scrybe_core::FrameSource::Mic,
16_000,
1,
)
.unwrap();
let samples: std::sync::Arc<[f32]> =
(0..16_000).map(|i| (i as f32 * 0.01).sin() * 0.5).collect();
writer.push(samples);
let summary = writer.finish().unwrap();
scrybe_core::pipeline::journal::write_manifest(
&journal_dir,
&scrybe_core::pipeline::JournalManifest {
mic: Some(scrybe_core::pipeline::JournalAnchor {
first_frame_epoch_ms: 1_735_000_000_000,
sample_rate: summary.sample_rate,
channels: summary.channels,
frames_written: summary.frames_written,
first_frame_timestamp_ns: None,
last_frame_end_timestamp_ns: None,
}),
system: None,
},
)
.unwrap();
run(Args {
id_or_folder: "2026-04-29-1430-crashed-01HZZZ".into(),
root: Some(dir.path().to_path_buf()),
})
.await
.unwrap();
assert!(folder.join("audio.opus").exists());
assert!(folder.join("meta.toml").exists());
assert!(!journal_dir.exists());
}
#[tokio::test]
async fn test_run_returns_error_when_session_does_not_exist() {
let dir = tempfile::tempdir().unwrap();
let err = run(Args {
id_or_folder: "nonexistent".into(),
root: Some(dir.path().to_path_buf()),
})
.await
.unwrap_err();
assert!(err.to_string().contains("nonexistent"));
}
}