use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use ulid::Ulid;
use crate::{catalog, paths, span};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActiveRec {
pub rec_id: String,
pub name: String,
pub started_at: String,
#[serde(default)]
pub transcript_path: Option<String>,
#[serde(default)]
pub origin_cwd: Option<String>,
#[serde(default)]
pub bound_session: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Recording {
pub rec_id: String,
pub name: String,
pub started_at: String,
pub ended_at: String,
pub steps: usize,
#[serde(default)]
pub cwd: Option<String>,
}
pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_default()
}
pub fn migrate_legacy_active() {
let Ok(legacy) = paths::legacy_active_flag() else {
return;
};
let Ok(contents) = std::fs::read_to_string(&legacy) else {
return; };
let Ok(active) = serde_json::from_str::<ActiveRec>(&contents) else {
return; };
if write_active(&active).is_ok() {
let _ = std::fs::remove_file(&legacy);
}
}
pub fn read_active_all() -> Vec<ActiveRec> {
migrate_legacy_active();
let Ok(dir) = paths::active_dir() else {
return Vec::new();
};
let mut actives: Vec<ActiveRec> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(active) = serde_json::from_str::<ActiveRec>(&contents)
{
actives.push(active);
}
}
}
actives.sort_by(|a, b| b.rec_id.cmp(&a.rec_id));
actives
}
pub fn write_active(active: &ActiveRec) -> Result<()> {
paths::ensure_dirs()?;
let path = paths::active_file(&active.rec_id)?;
write_atomic(&path, serde_json::to_string_pretty(active)?.as_bytes())
}
fn remove_active(rec_id: &str) -> Result<()> {
let path = paths::active_file(rec_id)?;
let _ = std::fs::remove_file(path);
Ok(())
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
let stem = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("active");
let tmp = dir.join(format!(".{stem}.tmp.{}", std::process::id()));
std::fs::write(&tmp, bytes).with_context(|| format!("could not write {}", tmp.display()))?;
std::fs::rename(&tmp, path).with_context(|| format!("could not install {}", path.display()))?;
Ok(())
}
pub fn start(name: Option<String>) -> Result<()> {
paths::ensure_dirs()?;
migrate_legacy_active();
let rec_id = Ulid::new().to_string();
let name = name.unwrap_or_else(|| "rec".to_string());
let origin_cwd = std::env::current_dir()
.ok()
.map(|p| p.display().to_string());
let active = ActiveRec {
rec_id: rec_id.clone(),
name: name.clone(),
started_at: now_rfc3339(),
transcript_path: None,
origin_cwd,
bound_session: None,
};
write_active(&active)?;
let span_path = paths::span_file(&rec_id)?;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&span_path)
.with_context(|| format!("could not open span {}", span_path.display()))?;
println!("{} recording \"{name}\"", crate::style::red("●"));
let others = read_active_all().len().saturating_sub(1);
if others > 0 {
println!(
" ({others} other recording(s) already active — this one binds to the session that next acts here)"
);
}
println!(" do the task, then: galdr rec stop [name]");
Ok(())
}
pub fn stop(reference: Option<&str>) -> Result<()> {
let actives = read_active_all();
if actives.is_empty() {
bail!("no active recording");
}
let target = match reference.map(str::trim).filter(|s| !s.is_empty()) {
Some(reference) => resolve_active(&actives, reference)?,
None if actives.len() == 1 => actives.into_iter().next().unwrap(),
None => {
let list = actives
.iter()
.map(|a| format!("{} ({})", a.name, a.rec_id))
.collect::<Vec<_>>()
.join(", ");
bail!("multiple recordings active — specify which: {list}");
}
};
stop_one(&target)
}
fn stop_one(active: &ActiveRec) -> Result<()> {
let span_path = paths::span_file(&active.rec_id)?;
let _ = span::fsync(&span_path);
let events = span::read_span(&span_path).unwrap_or_default();
let steps = events.len();
let cwd = events.last().and_then(|e| e.cwd.clone());
let recording = Recording {
rec_id: active.rec_id.clone(),
name: active.name.clone(),
started_at: active.started_at.clone(),
ended_at: now_rfc3339(),
steps,
cwd,
};
paths::ensure_dirs()?;
let rec_path = paths::recording_file(&active.rec_id)?;
std::fs::write(&rec_path, serde_json::to_string_pretty(&recording)?)?;
remove_active(&active.rec_id)?;
let _ = catalog::sync_closed_recording(&recording, &events);
crate::ipc::notify_best_effort(&crate::ipc::Request::RecordingClosed {
recording: recording.clone(),
});
let plural = if steps == 1 { "" } else { "s" };
println!(
"{} stopped \"{}\" — {steps} step{plural}",
crate::style::accent("■"),
active.name
);
println!(" turn it into a skill: galdr distill");
Ok(())
}
fn resolve_active(actives: &[ActiveRec], reference: &str) -> Result<ActiveRec> {
if let Some(active) = actives.iter().find(|a| a.rec_id == reference) {
return Ok(active.clone());
}
let upper = reference.to_ascii_uppercase();
let by_prefix: Vec<&ActiveRec> = actives
.iter()
.filter(|a| a.rec_id.starts_with(&upper))
.collect();
if by_prefix.len() == 1 {
return Ok(by_prefix[0].clone());
}
if by_prefix.len() > 1 {
bail!(
"`{reference}` matches {} active recordings — add more characters, or use the name (see `galdr rec status`).",
by_prefix.len()
);
}
let by_name: Vec<&ActiveRec> = actives.iter().filter(|a| a.name == reference).collect();
match by_name.as_slice() {
[one] => Ok((*one).clone()),
[] => bail!("no active recording matches `{reference}` — run `galdr rec status`."),
many => bail!(
"`{reference}` matches {} active recordings named that — use the rec_id (see `galdr rec status`).",
many.len()
),
}
}
pub fn all_recordings() -> Vec<Recording> {
let Ok(dir) = paths::recordings_dir() else {
return Vec::new();
};
let mut recordings: Vec<Recording> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(rec) = serde_json::from_str::<Recording>(&contents)
{
recordings.push(rec);
}
}
}
recordings.sort_by(|a, b| b.rec_id.cmp(&a.rec_id));
recordings
}
pub fn resolve_ref(reference: Option<&str>) -> Result<String> {
resolve_in(&all_recordings(), reference)
}
fn resolve_in(recordings: &[Recording], reference: Option<&str>) -> Result<String> {
if recordings.is_empty() {
bail!("no recordings yet — record one first with `galdr rec start <name>`.");
}
let Some(reference) = reference.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(recordings[0].rec_id.clone()); };
if let Some(rec) = recordings.iter().find(|r| r.rec_id == reference) {
return Ok(rec.rec_id.clone());
}
let upper = reference.to_ascii_uppercase();
let by_prefix: Vec<&Recording> = recordings
.iter()
.filter(|r| r.rec_id.starts_with(&upper))
.collect();
if by_prefix.len() == 1 {
return Ok(by_prefix[0].rec_id.clone());
}
if by_prefix.len() > 1 {
bail!(
"`{reference}` matches {} recordings — add more characters, or use the name (see `galdr list`).",
by_prefix.len()
);
}
if let Some(rec) = recordings.iter().find(|r| r.name == reference) {
return Ok(rec.rec_id.clone());
}
bail!("no recording matches `{reference}` — run `galdr list` to see your recordings.");
}
pub fn list() -> Result<()> {
let recordings = all_recordings();
if recordings.is_empty() {
println!("(no recordings yet — use `galdr rec start <name>`)");
return Ok(());
}
for rec in &recordings {
println!(
"{} {} {} steps {}",
crate::style::dim(&rec.rec_id),
crate::style::accent(&format!("{:<20}", rec.name)),
rec.steps,
crate::style::dim(&rec.started_at),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(id: &str, name: &str) -> Recording {
Recording {
rec_id: id.into(),
name: name.into(),
started_at: "2026-01-01T00:00:00Z".into(),
ended_at: "2026-01-01T00:01:00Z".into(),
steps: 3,
cwd: None,
}
}
fn fixture() -> Vec<Recording> {
vec![
rec("01KW9Z00000000000000000002", "weekly-report"),
rec("01KW9Z00000000000000000001", "ship-preview"),
rec("01KW9A00000000000000000000", "weekly-report"),
]
}
#[test]
fn none_resolves_to_the_most_recent() {
assert_eq!(
resolve_in(&fixture(), None).unwrap(),
"01KW9Z00000000000000000002"
);
}
#[test]
fn exact_id_and_unique_prefix_resolve() {
let recs = fixture();
assert_eq!(
resolve_in(&recs, Some("01KW9Z00000000000000000001")).unwrap(),
"01KW9Z00000000000000000001"
);
assert_eq!(
resolve_in(&recs, Some("01kw9z00000000000000000001")).unwrap(),
"01KW9Z00000000000000000001"
);
}
#[test]
fn name_resolves_to_the_most_recent_of_that_name() {
assert_eq!(
resolve_in(&fixture(), Some("weekly-report")).unwrap(),
"01KW9Z00000000000000000002"
);
}
#[test]
fn ambiguous_prefix_and_unknown_ref_fail_with_guidance() {
let recs = fixture();
let ambiguous = resolve_in(&recs, Some("01KW9Z")).unwrap_err().to_string();
assert!(ambiguous.contains("matches 2 recordings"), "{ambiguous}");
let miss = resolve_in(&recs, Some("nope")).unwrap_err().to_string();
assert!(miss.contains("galdr list"), "{miss}");
}
#[test]
fn no_recordings_is_a_friendly_error() {
let err = resolve_in(&[], None).unwrap_err().to_string();
assert!(err.contains("galdr rec start"), "{err}");
}
}