use std::{
collections::BTreeMap,
fs,
io::{self, Write},
os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::SystemTime,
};
use crate::protocol::RecoveryEntry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionEntry {
pub cmd: String,
pub group: Option<String>,
pub name: Option<String>,
}
pub type SessionConfig = BTreeMap<String, Vec<SessionEntry>>;
pub const FLEETCOM_CONFIG_DIR: &str = "FLEETCOM_CONFIG_DIR";
const DISALLOWED: &[char] = &['*', '"', '/', '\\', '<', '>', ':', '|', '?', '.'];
const MAX_STEM_BYTES: usize = 250;
fn sanitize(name: &str) -> String {
let mut out = String::new();
for c in name.trim().chars() {
let c = if c.is_control() || DISALLOWED.contains(&c) {
'_'
} else {
c
};
if out.len() + c.len_utf8() > MAX_STEM_BYTES {
break;
}
out.push(c);
}
out
}
fn prefix_bytes(s: &str, max: usize) -> &str {
if s.len() <= max {
return s;
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
pub fn sessions_dir(root: Option<PathBuf>) -> Option<PathBuf> {
root.or_else(|| std::env::var(FLEETCOM_CONFIG_DIR).ok().map(PathBuf::from))
.or_else(|| dirs::config_dir().map(|c| c.join("fleetcom")))
.map(|base| base.join("sessions"))
}
const FORMAT_VERSION: u64 = 1;
fn dirs_json(cfg: &SessionConfig) -> jzon::JsonValue {
let mut dirs = jzon::JsonValue::new_object();
for (dir, entries) in cfg {
let mut arr = jzon::JsonValue::new_array();
for e in entries {
let member = if e.group.is_none() && e.name.is_none() {
jzon::JsonValue::from(e.cmd.as_str())
} else {
let mut m = jzon::JsonValue::new_object();
let _ = m.insert("cmd", e.cmd.as_str());
if let Some(g) = &e.group {
let _ = m.insert("group", g.as_str());
}
if let Some(n) = &e.name {
let _ = m.insert("name", n.as_str());
}
m
};
let _ = arr.push(member);
}
let _ = dirs.insert(dir, arr);
}
dirs
}
fn to_json(name: &str, cfg: &SessionConfig) -> String {
let mut obj = jzon::JsonValue::new_object();
let _ = obj.insert("version", FORMAT_VERSION);
let _ = obj.insert("name", name);
let _ = obj.insert("dirs", dirs_json(cfg));
obj.pretty(2)
}
pub fn fingerprint_json(cfg: &SessionConfig) -> String {
dirs_json(cfg).dump()
}
fn from_json(text: &str) -> io::Result<(Option<String>, SessionConfig)> {
let parsed = jzon::parse(text).map_err(|e| io::Error::other(e.to_string()))?;
let version = &parsed["version"];
if !version.is_null() {
match version.as_u64() {
Some(n) if (1..=FORMAT_VERSION).contains(&n) => {}
Some(n) if n > FORMAT_VERSION => {
return Err(io::Error::other(format!(
"session format version {n} is newer than this fleetcom \
(supports {FORMAT_VERSION}); load it with a newer build"
)));
}
_ => {
return Err(io::Error::other(format!(
"session format version {} is not one this fleetcom reads \
(supports {FORMAT_VERSION}); load it with a newer build",
version.dump()
)));
}
}
}
let (name, dirs, flat) = if parsed["dirs"].is_object() {
let name = parsed["name"].as_str().map(str::to_string);
(name, &parsed["dirs"], false)
} else {
(None, &parsed, true)
};
let mut cfg = SessionConfig::new();
for (dir, val) in dirs.entries() {
if flat && dir == "version" {
continue;
}
let entries = val
.members()
.filter_map(|m| {
if let Some(cmd) = m.as_str() {
return Some(SessionEntry {
cmd: cmd.to_string(),
group: None,
name: None,
});
}
let cmd = m["cmd"].as_str()?.to_string();
let group = match &m["group"] {
g if g.is_null() => None,
g => Some(g.as_str()?.to_string()),
};
let name = match &m["name"] {
n if n.is_null() => None,
n => Some(n.as_str()?.to_string()),
};
Some(SessionEntry { cmd, group, name })
})
.collect();
cfg.insert(dir.to_string(), entries);
}
Ok((name, cfg))
}
static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
fn ensure_private_dir(dir: &Path) -> io::Result<()> {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
if fs::metadata(dir)?.permissions().mode() & 0o077 != 0 {
fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn write_atomic(dir: &Path, file_name: &str, contents: &str) -> io::Result<PathBuf> {
let file = dir.join(file_name);
let pid = std::process::id();
let (mut tmp_file, tmp) = loop {
let n = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
let suffix = format!(".{pid}.{n}.tmp");
let stem = prefix_bytes(file_name, 254 - suffix.len());
let candidate = dir.join(format!(".{stem}{suffix}"));
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&candidate)
{
Ok(f) => break (f, candidate),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
};
let written = (|| {
tmp_file.write_all(contents.as_bytes())?;
tmp_file.sync_all()?;
fs::rename(&tmp, &file)
})();
if written.is_err() {
let _ = fs::remove_file(&tmp);
}
written?;
Ok(file)
}
pub fn save_in(dir: &Path, name: &str, cfg: &SessionConfig) -> io::Result<PathBuf> {
ensure_private_dir(dir)?;
let trimmed = name.trim();
let file_name = format!("{}.json", sanitize(name));
let file = dir.join(&file_name);
match fs::read_to_string(&file) {
Ok(text) => {
if let Ok((Some(stored), _)) = from_json(&text)
&& stored != trimmed
{
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"session \"{trimmed}\" collides with existing \"{stored}\" \
(both map to {file_name})"
),
));
}
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
write_atomic(dir, &file_name, &to_json(trimmed, cfg))
}
pub fn load_in(dir: &Path, name: &str) -> io::Result<SessionConfig> {
let file = dir.join(format!("{}.json", sanitize(name)));
from_json(&fs::read_to_string(file)?).map(|(_, cfg)| cfg)
}
pub fn list_in(dir: &Path) -> Vec<String> {
let mut names = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) == Some("json")
&& let Some(stem) = p.file_stem().and_then(|s| s.to_str())
{
let stored = fs::read_to_string(&p)
.ok()
.and_then(|t| from_json(&t).ok())
.and_then(|(name, _)| name);
names.push(stored.unwrap_or_else(|| stem.to_string()));
}
}
}
names.sort();
names
}
const RECOVERY_KEEP: usize = 10;
pub fn recovery_dir(sessions_root: &Path) -> PathBuf {
sessions_root.join("recovery")
}
fn civil_utc(t: SystemTime) -> (i64, u32, u32, u64, u64, u64) {
let secs = t
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (y, m, d) = crate::format::civil_from_days((secs / 86_400) as i64);
let tod = secs % 86_400;
(y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60)
}
pub fn recovery_stem(start: SystemTime, pid: u32) -> String {
let (y, m, d, hh, mm, ss) = civil_utc(start);
format!("{y:04}{m:02}{d:02}-{hh:02}{mm:02}{ss:02}-{pid}")
}
pub fn recovery_label(now: SystemTime) -> String {
let (y, m, d, hh, mm, _) = civil_utc(now);
format!("autosaved {y:04}-{m:02}-{d:02} {hh:02}:{mm:02}")
}
pub fn save_recovery_in(
dir: &Path,
file_stem: &str,
name: &str,
cfg: &SessionConfig,
) -> io::Result<PathBuf> {
ensure_private_dir(dir)?;
let file = write_atomic(dir, &format!("{file_stem}.json"), &to_json(name, cfg))?;
prune_recovery(dir, file_stem);
Ok(file)
}
pub fn list_recovery_in(dir: &Path) -> Vec<RecoveryEntry> {
let mut out = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok((stored, cfg)) = fs::read_to_string(&p).and_then(|t| from_json(&t)) else {
continue;
};
let tasks =
u32::try_from(cfg.values().map(Vec::len).sum::<usize>()).unwrap_or(u32::MAX);
let age_secs = fs::metadata(&p)
.and_then(|m| m.modified())
.ok()
.and_then(|mtime| SystemTime::now().duration_since(mtime).ok())
.map_or(0, |d| d.as_secs());
out.push(RecoveryEntry {
stem: stem.to_string(),
label: stored.unwrap_or_else(|| stem.to_string()),
tasks,
age_secs,
});
}
}
out.sort_by(|a, b| b.stem.cmp(&a.stem));
out
}
fn valid_recovery_stem(stem: &str) -> bool {
!stem.is_empty() && !stem.contains(['/', '\\', '.'])
}
pub fn load_recovery_in(dir: &Path, stem: &str) -> io::Result<SessionConfig> {
if !valid_recovery_stem(stem) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid recovery stem {stem:?}"),
));
}
from_json(&fs::read_to_string(dir.join(format!("{stem}.json")))?).map(|(_, cfg)| cfg)
}
fn stem_pid(stem: &str) -> Option<i32> {
let (_, pid) = stem.rsplit_once('-')?;
if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
pid.parse::<i32>().ok().filter(|p| *p > 0)
}
fn stem_names_live_writer(stem: &str) -> bool {
use nix::{errno::Errno, sys::signal::kill, unistd::Pid};
stem_pid(stem).is_some_and(|pid| !matches!(kill(Pid::from_raw(pid), None), Err(Errno::ESRCH)))
}
fn prune_recovery(dir: &Path, keep_stem: &str) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let keep_name = format!("{keep_stem}.json");
let mut snapshots: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("json"))
.filter(|p| p.file_name().and_then(|n| n.to_str()) != Some(keep_name.as_str()))
.filter(|p| {
!p.file_stem()
.and_then(|s| s.to_str())
.is_some_and(stem_names_live_writer)
})
.collect();
if snapshots.len() < RECOVERY_KEEP {
return;
}
snapshots.sort();
for old in &snapshots[..snapshots.len() - (RECOVERY_KEEP - 1)] {
let _ = fs::remove_file(old);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::temp;
fn e(cmd: &str) -> SessionEntry {
SessionEntry {
cmd: cmd.into(),
group: None,
name: None,
}
}
fn ge(cmd: &str, group: &str) -> SessionEntry {
SessionEntry {
cmd: cmd.into(),
group: Some(group.into()),
name: None,
}
}
fn ne(cmd: &str, name: &str) -> SessionEntry {
SessionEntry {
cmd: cmd.into(),
group: None,
name: Some(name.into()),
}
}
fn gne(cmd: &str, group: &str, name: &str) -> SessionEntry {
SessionEntry {
cmd: cmd.into(),
group: Some(group.into()),
name: Some(name.into()),
}
}
#[test]
fn round_trips_dirs_and_commands() {
let dir = temp("session_roundtrip");
let mut cfg = SessionConfig::new();
cfg.insert("~/proj".into(), vec![e("cargo test"), e("vim")]);
cfg.insert("/tmp".into(), vec![e("top")]);
save_in(&dir, "work", &cfg).unwrap();
assert_eq!(load_in(&dir, "work").unwrap(), cfg);
assert_eq!(list_in(&dir), vec!["work".to_string()]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn round_trips_mixed_grouped_and_ungrouped_entries() {
let dir = temp("session_mixed");
let mut cfg = SessionConfig::new();
cfg.insert(
"~/proj".into(),
vec![ge("cargo test", "ci"), e("vim"), ge("top", "ops")],
);
save_in(&dir, "mixed", &cfg).unwrap();
assert_eq!(load_in(&dir, "mixed").unwrap(), cfg);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn round_trips_named_entries() {
let dir = temp("session_named");
let mut cfg = SessionConfig::new();
cfg.insert(
"~/proj".into(),
vec![
gne("cargo test", "ci", "unit tests"),
ne("vim", "editor"),
ge("top", "ops"),
e("plain"),
],
);
save_in(&dir, "named", &cfg).unwrap();
assert_eq!(load_in(&dir, "named").unwrap(), cfg);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn parses_the_pre_group_string_only_format() {
let (name, cfg) = from_json(r#"{"~/proj": ["cargo test", "vim"]}"#).unwrap();
assert_eq!(name, None);
assert_eq!(cfg["~/proj"], vec![e("cargo test"), e("vim")]);
}
#[test]
fn parses_the_pre_name_object_format() {
let (name, cfg) =
from_json(r#"{"~/proj": [{"cmd": "cargo test", "group": "ci"}]}"#).unwrap();
assert_eq!(name, None);
assert_eq!(cfg["~/proj"], vec![ge("cargo test", "ci")]);
}
#[test]
fn group_free_config_writes_string_members_in_the_wrapper() {
let mut cfg = SessionConfig::new();
cfg.insert("~/proj".into(), vec![e("cargo test"), e("vim")]);
cfg.insert("/tmp".into(), vec![e("top")]);
let expected = "{\n \"version\": 1,\n \"name\": \"work\",\n \"dirs\": {\n \"/tmp\": [\n \"top\"\n ],\n \"~/proj\": [\n \"cargo test\",\n \"vim\"\n ]\n }\n}";
assert_eq!(to_json("work", &cfg), expected);
}
#[test]
fn save_writes_version_1_and_load_accepts_it() {
let dir = temp("session_version_roundtrip");
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
let file = save_in(&dir, "versioned", &cfg).unwrap();
assert!(
fs::read_to_string(&file)
.unwrap()
.contains("\"version\": 1")
);
assert_eq!(load_in(&dir, "versioned").unwrap(), cfg);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn missing_version_means_version_1() {
let (name, cfg) = from_json(r#"{"name": "old", "dirs": {"~/proj": ["vim"]}}"#).unwrap();
assert_eq!(name, Some("old".to_string()));
assert_eq!(cfg["~/proj"], vec![e("vim")]);
}
#[test]
fn explicit_version_1_loads() {
let (_, cfg) =
from_json(r#"{"version": 1, "name": "v", "dirs": {"~/proj": ["vim"]}}"#).unwrap();
assert_eq!(cfg["~/proj"], vec![e("vim")]);
}
#[test]
fn newer_version_refuses_naming_both_versions() {
let err = from_json(r#"{"version": 2, "name": "v", "dirs": {}}"#).unwrap_err();
assert_eq!(
err.to_string(),
"session format version 2 is newer than this fleetcom (supports 1); \
load it with a newer build"
);
}
#[test]
fn version_zero_refuses_as_unreadable_not_newer() {
let err = from_json(r#"{"version": 0, "name": "v", "dirs": {}}"#).unwrap_err();
assert_eq!(
err.to_string(),
"session format version 0 is not one this fleetcom reads \
(supports 1); load it with a newer build"
);
}
#[test]
fn non_numeric_version_refuses() {
let err = from_json(r#"{"version": "2.0", "name": "v", "dirs": {}}"#).unwrap_err();
assert_eq!(
err.to_string(),
"session format version \"2.0\" is not one this fleetcom reads \
(supports 1); load it with a newer build"
);
}
#[test]
fn refused_load_yields_err_with_nothing_to_resave() {
let dir = temp("session_version_refuse");
fs::write(
dir.join("future.json"),
r#"{"version": 3, "name": "future", "dirs": {"~/p": ["vim"]}}"#,
)
.unwrap();
let err = load_in(&dir, "future").unwrap_err();
assert!(err.to_string().contains("version 3"), "{err}");
assert!(err.to_string().contains("supports 1"), "{err}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn flat_version_member_does_not_become_a_directory() {
let (name, cfg) = from_json(r#"{"version": 1, "~/proj": ["vim"]}"#).unwrap();
assert_eq!(name, None);
assert!(!cfg.contains_key("version"));
assert_eq!(cfg["~/proj"], vec![e("vim")]);
}
#[test]
fn malformed_object_members_drop_without_error() {
let (_, cfg) = from_json(
r#"{"d": [
{"group": "g"},
{"cmd": 3},
{"cmd": "x", "group": 5},
{"cmd": "y", "name": 5},
42,
{"cmd": "bare"},
{"cmd": "n", "group": null},
{"cmd": "m", "name": null},
{"cmd": "ok", "group": "api"},
{"cmd": "named", "name": "web"},
"plain"
]}"#,
)
.unwrap();
assert_eq!(
cfg["d"],
vec![
e("bare"),
e("n"),
e("m"),
ge("ok", "api"),
ne("named", "web"),
e("plain")
]
);
}
#[test]
fn sanitizes_names() {
assert_eq!(sanitize("my/session"), "my_session");
assert_eq!(sanitize(" a.b "), "a_b");
}
#[test]
fn caps_names_at_250_bytes() {
assert_eq!(sanitize(&"a".repeat(250)), "a".repeat(250));
let capped = sanitize(&"a".repeat(251));
assert_eq!(capped, "a".repeat(250));
assert_eq!(format!("{capped}.json").len(), 255);
}
#[test]
fn cap_drops_a_multibyte_char_whole() {
let capped = sanitize(&format!("{}é", "a".repeat(249)));
assert_eq!(capped, "a".repeat(249));
}
#[test]
fn long_names_save_within_name_max() {
let dir = temp("session_long_name");
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
let name = "n".repeat(255);
let file = save_in(&dir, &name, &cfg).unwrap();
assert_eq!(file.file_name().unwrap().len(), 255);
assert_eq!(load_in(&dir, &name).unwrap(), cfg);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn saves_owner_only_and_fixes_legacy_permissions() {
let dir = temp("session_mode");
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("run --api-key hunter2")]);
let file = save_in(&dir, "keys", &cfg).unwrap();
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&file), 0o600);
fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap();
save_in(&dir, "keys", &cfg).unwrap();
assert_eq!(mode(&file), 0o600);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn sessions_dir_is_created_private_and_retightened() {
let base = temp("session_dir_mode");
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
let nested = base.join("parent").join("sessions");
save_in(&nested, "fresh", &cfg).unwrap();
assert_eq!(mode(&nested), 0o700);
assert_eq!(mode(&base.join("parent")), 0o700);
let loose = base.join("loose");
fs::create_dir(&loose).unwrap();
fs::set_permissions(&loose, fs::Permissions::from_mode(0o755)).unwrap();
save_in(&loose, "old", &cfg).unwrap();
assert_eq!(mode(&loose), 0o700);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn save_leaves_no_temp_file() {
let dir = temp("session_notemp");
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
save_in(&dir, "clean", &cfg).unwrap();
let names: Vec<String> = fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert_eq!(names, vec!["clean.json".to_string()]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn refuses_saves_that_collide_after_sanitize() {
let dir = temp("session_collide");
let mut first = SessionConfig::new();
first.insert("~/one".into(), vec![e("cargo test")]);
save_in(&dir, "a/b", &first).unwrap();
let mut second = SessionConfig::new();
second.insert("~/two".into(), vec![e("vim")]);
let err = save_in(&dir, "a.b", &second).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
assert!(err.to_string().contains("\"a.b\""), "{err}");
assert!(err.to_string().contains("\"a/b\""), "{err}");
assert_eq!(load_in(&dir, "a/b").unwrap(), first);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn loads_and_lists_legacy_flat_schema_files() {
let dir = temp("session_legacy");
fs::write(dir.join("old.json"), r#"{"~/proj": ["cargo test"]}"#).unwrap();
assert_eq!(
load_in(&dir, "old").unwrap()["~/proj"],
vec![e("cargo test")]
);
assert_eq!(list_in(&dir), vec!["old".to_string()]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn legacy_file_resaves_under_its_own_stem() {
let dir = temp("session_legacy_resave");
fs::write(dir.join("mine.json"), r#"{"~/old": ["vim"]}"#).unwrap();
let mut cfg = SessionConfig::new();
cfg.insert("~/new".into(), vec![e("top")]);
save_in(&dir, "mine", &cfg).unwrap();
assert_eq!(load_in(&dir, "mine").unwrap(), cfg);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn lists_stored_names_for_new_schema_files() {
let dir = temp("session_list_names");
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
save_in(&dir, "a/b", &cfg).unwrap();
fs::write(dir.join("legacy.json"), r#"{"~/x": ["top"]}"#).unwrap();
assert_eq!(list_in(&dir), vec!["a/b".to_string(), "legacy".to_string()]);
for n in list_in(&dir) {
load_in(&dir, &n).unwrap();
}
let _ = fs::remove_dir_all(&dir);
}
fn recovery_instant() -> SystemTime {
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_784_021_415)
}
const DEAD_FIXTURE_PID: u32 = 9_999_999;
fn dead_child_pid() -> u32 {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("exit 0")
.spawn()
.unwrap();
let pid = child.id();
child.wait().unwrap();
pid
}
#[test]
fn recovery_stem_and_label_render_utc() {
assert_eq!(
recovery_stem(recovery_instant(), 4242),
"20260714-093015-4242"
);
assert_eq!(
recovery_label(recovery_instant()),
"autosaved 2026-07-14 09:30"
);
}
#[test]
fn recovery_snapshot_round_trips_with_version_and_label() {
let base = temp("session_recovery_roundtrip");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![gne("cargo run", "api", "server")]);
let stem = recovery_stem(recovery_instant(), 4242);
let label = recovery_label(recovery_instant());
let file = save_recovery_in(&rec, &stem, &label, &cfg).unwrap();
assert_eq!(file, rec.join("20260714-093015-4242.json"));
let text = fs::read_to_string(&file).unwrap();
assert!(text.contains("\"version\": 1"), "{text}");
let (stored, parsed) = from_json(&text).unwrap();
assert_eq!(stored.as_deref(), Some("autosaved 2026-07-14 09:30"));
assert_eq!(parsed, cfg);
assert_eq!(load_in(&rec, &stem).unwrap(), cfg);
let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&rec), 0o700);
assert_eq!(mode(&file), 0o600);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn recovery_prune_keeps_the_newest_ten() {
let base = temp("session_recovery_prune");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
for i in 1..=12u32 {
let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}");
save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap();
}
let mut names: Vec<String> = fs::read_dir(&rec)
.unwrap()
.flatten()
.map(|e| e.file_name().into_string().unwrap())
.collect();
names.sort();
let expected: Vec<String> = (3..=12u32)
.map(|i| format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}.json"))
.collect();
assert_eq!(names, expected, "prune must drop exactly the oldest two");
let _ = fs::remove_dir_all(&base);
}
#[test]
fn recovery_prune_exempts_the_active_stem() {
let base = temp("session_recovery_prune_active");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
for i in 1..=10u32 {
let stem = format!("20260715-0930{i:02}-{DEAD_FIXTURE_PID}");
save_recovery_in(&rec, &stem, "autosaved 2026-07-15 09:30", &cfg).unwrap();
}
let active_stem = format!("20260714-093000-{DEAD_FIXTURE_PID}");
let active =
save_recovery_in(&rec, &active_stem, "autosaved 2026-07-15 09:30", &cfg).unwrap();
assert!(active.exists(), "the just-written snapshot must survive");
let mut names: Vec<String> = fs::read_dir(&rec)
.unwrap()
.flatten()
.map(|e| e.file_name().into_string().unwrap())
.collect();
names.sort();
let mut expected = vec![format!("{active_stem}.json")];
expected
.extend((2..=10u32).map(|i| format!("20260715-0930{i:02}-{DEAD_FIXTURE_PID}.json")));
assert_eq!(
names, expected,
"the active file plus the nine newest others must remain"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn recovery_prune_below_limit_removes_nothing() {
let base = temp("session_recovery_prune_few");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
for i in 1..=5u32 {
let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}");
save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap();
}
assert_eq!(
fs::read_dir(&rec).unwrap().flatten().count(),
5,
"no file may be pruned below the retention limit"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn recovery_prune_exempts_live_pid_stems() {
let base = temp("session_recovery_prune_live");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
let live_stem = format!("20260101-000000-{}", std::process::id());
save_recovery_in(&rec, &live_stem, "autosaved 2026-01-01 00:00", &cfg).unwrap();
for i in 1..=11u32 {
let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}");
save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap();
}
let mut names: Vec<String> = fs::read_dir(&rec)
.unwrap()
.flatten()
.map(|e| e.file_name().into_string().unwrap())
.collect();
names.sort();
let mut expected = vec![format!("{live_stem}.json")];
expected
.extend((2..=11u32).map(|i| format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}.json")));
assert_eq!(
names, expected,
"the live writer's file must survive; the oldest dead file must not"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn recovery_prune_removes_dead_pid_stems() {
let base = temp("session_recovery_prune_dead");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
let oldest = format!("20260101-000000-{}", dead_child_pid());
save_recovery_in(&rec, &oldest, "autosaved 2026-01-01 00:00", &cfg).unwrap();
for i in 1..=10u32 {
let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}");
save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap();
}
assert!(
!rec.join(format!("{oldest}.json")).exists(),
"a dead writer's snapshot is an ordinary prune candidate"
);
assert_eq!(
fs::read_dir(&rec).unwrap().flatten().count(),
RECOVERY_KEEP,
"dead-stem retention must converge to the bound"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn recovery_prune_ignores_malformed_pid_suffixes() {
let base = temp("session_recovery_prune_malformed");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
let malformed = [
"20260101-000000-x42", "20260101-000001-99999999999", "20260101-000002-", "20260101-000003-0", ];
for stem in malformed {
save_recovery_in(&rec, stem, "autosaved 2026-01-01 00:00", &cfg).unwrap();
}
for i in 1..=10u32 {
let stem = format!("20260714-0930{i:02}-{DEAD_FIXTURE_PID}");
save_recovery_in(&rec, &stem, "autosaved 2026-07-14 09:30", &cfg).unwrap();
}
for stem in malformed {
assert!(
!rec.join(format!("{stem}.json")).exists(),
"malformed stem {stem:?} must be pruned like any candidate"
);
}
assert_eq!(
fs::read_dir(&rec).unwrap().flatten().count(),
RECOVERY_KEEP,
"only the well-formed newest files may remain"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn list_ignores_the_recovery_subdirectory() {
let dir = temp("session_list_recovery");
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![e("vim")]);
save_in(&dir, "real", &cfg).unwrap();
save_recovery_in(
&recovery_dir(&dir),
&recovery_stem(recovery_instant(), 7),
&recovery_label(recovery_instant()),
&cfg,
)
.unwrap();
assert_eq!(list_in(&dir), vec!["real".to_string()]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn recovery_listing_is_newest_first_and_skips_corrupt_files() {
let base = temp("session_recovery_list");
let rec = recovery_dir(&base);
assert!(
list_recovery_in(&rec).is_empty(),
"a missing recovery dir must list empty"
);
let mut one = SessionConfig::new();
one.insert("~/a".into(), vec![e("vim")]);
let mut three = SessionConfig::new();
three.insert("~/a".into(), vec![e("vim"), e("top")]);
three.insert("~/b".into(), vec![e("make")]);
save_recovery_in(
&rec,
"20260714-093015-11",
"autosaved 2026-07-14 09:30",
&one,
)
.unwrap();
save_recovery_in(
&rec,
"20260715-070000-22",
"autosaved 2026-07-15 07:00",
&three,
)
.unwrap();
fs::write(rec.join("20260716-000000-33.json"), "{not json").unwrap();
let entries = list_recovery_in(&rec);
assert_eq!(
entries.len(),
2,
"the corrupt snapshot must drop alone: {entries:?}"
);
assert_eq!(entries[0].stem, "20260715-070000-22");
assert_eq!(entries[0].label, "autosaved 2026-07-15 07:00");
assert_eq!(entries[0].tasks, 3);
assert_eq!(entries[1].stem, "20260714-093015-11");
assert_eq!(entries[1].label, "autosaved 2026-07-14 09:30");
assert_eq!(entries[1].tasks, 1);
assert!(
entries.iter().all(|en| en.age_secs < 3600),
"just-written files must read near-zero ages: {entries:?}"
);
let _ = fs::remove_dir_all(&base);
}
#[test]
fn load_recovery_in_loads_by_stem_and_rejects_traversal() {
let base = temp("session_recovery_load");
let rec = recovery_dir(&base);
let mut cfg = SessionConfig::new();
cfg.insert("~/p".into(), vec![gne("cargo run", "api", "server")]);
save_recovery_in(
&rec,
"20260714-093015-11",
"autosaved 2026-07-14 09:30",
&cfg,
)
.unwrap();
assert_eq!(load_recovery_in(&rec, "20260714-093015-11").unwrap(), cfg);
for bad in ["../x", "a/b", "a.b", "a\\b", ""] {
let err = load_recovery_in(&rec, bad).unwrap_err();
assert_eq!(
err.kind(),
io::ErrorKind::InvalidInput,
"stem {bad:?} must be refused, got {err}"
);
}
assert_eq!(
load_recovery_in(&rec, "20990101-000000-1")
.unwrap_err()
.kind(),
io::ErrorKind::NotFound
);
let _ = fs::remove_dir_all(&base);
}
}