use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use thiserror::Error;
use super::store::StoredData;
const UUID_LEN: usize = 36;
pub(crate) fn validate(path: &Path, raw: &str) -> Result<StoredData, StoreIntegrity> {
serde_json::from_str::<StoredData>(raw).map_err(|e| analyze(path, raw, &e))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreIntegrity {
pub path: PathBuf,
pub total_bytes: usize,
pub valid_prefix_bytes: Option<usize>,
pub repairable_by_truncation: bool,
pub detail: String,
pub line: usize,
pub column: usize,
}
impl StoreIntegrity {
pub fn trailing_bytes(&self) -> usize {
self.total_bytes
.saturating_sub(self.valid_prefix_bytes.unwrap_or(0))
}
pub fn diagnostic(&self) -> String {
let head = format!(
"{} is corrupt: {} (line {}, column {})",
self.path.display(),
self.detail,
self.line,
self.column,
);
if self.repairable_by_truncation {
return format!(
"{head}; a complete JSON document ends at byte {} of {}, followed by {} trailing \
byte(s). Repair with `tm repair session-store`",
self.valid_prefix_bytes.unwrap_or(0),
self.total_bytes,
self.trailing_bytes(),
);
}
let why = match self.valid_prefix_bytes {
None => format!(
"no complete JSON document could be read from its {} byte(s)",
self.total_bytes
),
Some(_) if self.trailing_bytes() == 0 => format!(
"its {} byte(s) are one complete JSON document, but not a session store",
self.total_bytes
),
Some(valid) => format!(
"the complete JSON document at its first {valid} byte(s) is not a session store \
either"
),
};
format!(
"{head}; {why}. Truncation cannot recover it — restore a backup, or move the file aside to start fresh"
)
}
}
pub fn analyze(path: &Path, raw: &str, err: &serde_json::Error) -> StoreIntegrity {
let valid = valid_prefix_bytes(raw);
StoreIntegrity {
path: path.to_path_buf(),
total_bytes: raw.len(),
valid_prefix_bytes: valid,
repairable_by_truncation: valid
.is_some_and(|v| serde_json::from_str::<StoredData>(&raw[..v]).is_ok()),
detail: err.to_string(),
line: err.line(),
column: err.column(),
}
}
pub fn valid_prefix_bytes(raw: &str) -> Option<usize> {
let mut stream = serde_json::Deserializer::from_str(raw).into_iter::<serde_json::Value>();
match stream.next() {
Some(Ok(_)) => Some(stream.byte_offset()),
_ => None,
}
}
#[derive(Debug, Error)]
pub enum RepairError {
#[error("session store {0} parses cleanly; nothing to repair")]
NotCorrupt(PathBuf),
#[error(
"session store {path} holds no complete JSON document in its {total_bytes} byte(s); \
truncation cannot recover it — restore from a backup"
)]
NoValidPrefix {
path: PathBuf,
total_bytes: usize,
},
#[error(
"session store {path} holds valid JSON that is not a session store: {detail}; \
truncation cannot recover it — restore a backup, or move the file aside to start fresh"
)]
NotAStore {
path: PathBuf,
detail: String,
},
#[error(
"refusing to truncate {path}: the {discarded} discarded byte(s) name session id(s) that \
the recovered document does not contain ({}); re-run with --force to discard them anyway",
orphans.join(", ")
)]
OrphanedSessions {
path: PathBuf,
discarded: usize,
orphans: Vec<String>,
},
#[error("session store repair I/O error on {path}: {source}")]
Io {
path: PathBuf,
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub struct RepairPlan {
pub integrity: StoreIntegrity,
pub recovered_sessions: usize,
pub orphan_ids: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RepairOutcome {
pub backup: PathBuf,
pub kept_bytes: usize,
pub discarded_bytes: usize,
pub sessions: usize,
pub forced_orphans: Vec<String>,
}
pub fn plan(path: &Path) -> Result<RepairPlan, RepairError> {
plan_bytes(path, &read(path)?)
}
fn read(path: &Path) -> Result<String, RepairError> {
std::fs::read_to_string(path).map_err(|source| RepairError::Io {
path: path.to_path_buf(),
source,
})
}
fn plan_bytes(path: &Path, raw: &str) -> Result<RepairPlan, RepairError> {
let integrity = match validate(path, raw) {
Ok(_) => return Err(RepairError::NotCorrupt(path.to_path_buf())),
Err(integrity) => integrity,
};
let Some(valid) = integrity.valid_prefix_bytes else {
return Err(RepairError::NoValidPrefix {
path: path.to_path_buf(),
total_bytes: integrity.total_bytes,
});
};
let recovered = validate(path, &raw[..valid]).map_err(|i| RepairError::NotAStore {
path: path.to_path_buf(),
detail: i.detail,
})?;
let kept_ids = kept_ids(&recovered);
let orphan_ids = orphaned_ids(raw, valid, &kept_ids);
Ok(RepairPlan {
recovered_sessions: kept_ids.len(),
integrity,
orphan_ids,
})
}
pub fn repair(path: &Path, force: bool, now: DateTime<Utc>) -> Result<RepairOutcome, RepairError> {
let raw = read(path)?;
let plan = plan_bytes(path, &raw)?;
if !plan.orphan_ids.is_empty() && !force {
return Err(RepairError::OrphanedSessions {
path: path.to_path_buf(),
discarded: plan.integrity.trailing_bytes(),
orphans: plan.orphan_ids,
});
}
let backup = backup_path(path, now);
std::fs::write(&backup, &raw).map_err(|source| RepairError::Io {
path: backup.clone(),
source,
})?;
let kept = plan.integrity.valid_prefix_bytes.unwrap_or(raw.len());
std::fs::write(path, &raw[..kept]).map_err(|source| RepairError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(RepairOutcome {
backup,
kept_bytes: kept,
discarded_bytes: plan.integrity.trailing_bytes(),
sessions: plan.recovered_sessions,
forced_orphans: plan.orphan_ids,
})
}
pub fn backup_path(path: &Path, now: DateTime<Utc>) -> PathBuf {
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".corrupt-backup-{}", now.format("%Y%m%d-%H%M%S")));
path.with_file_name(name)
}
fn kept_ids(data: &StoredData) -> BTreeSet<String> {
data.sessions
.keys()
.map(|k| k.to_ascii_lowercase())
.collect()
}
fn orphaned_ids(raw: &str, valid: usize, kept: &BTreeSet<String>) -> Vec<String> {
let mut from = valid.saturating_sub(UUID_LEN - 1);
while from > 0 && !raw.is_char_boundary(from) {
from -= 1;
}
uuid_tokens(&raw[from..])
.into_iter()
.filter(|id| !kept.contains(id))
.collect()
}
#[cfg(test)]
pub(crate) fn loadable_store_json(sessions: &[(&str, &str)]) -> String {
let records: Vec<String> = sessions
.iter()
.map(|(id, task)| {
let fields = r#""tmux_name":"tm-5007","cwd":"/tmp","state":"active""#;
let created = r#""created_at":"2026-08-06T09:01:17Z""#;
format!(r#""{id}":{{"id":"{id}","task":"{task}",{fields},{created}}}"#)
})
.collect();
format!(r#"{{"sessions":{{{}}}}}"#, records.join(","))
}
#[cfg(test)]
#[path = "store_integrity_tests.rs"]
mod store_integrity_tests;
fn uuid_tokens(s: &str) -> BTreeSet<String> {
const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
let bytes = s.as_bytes();
let mut out = BTreeSet::new();
for start in 0..bytes.len().saturating_sub(UUID_LEN - 1) {
let window = &bytes[start..start + UUID_LEN];
let mut at = 0usize;
let shaped = GROUPS.iter().enumerate().all(|(i, len)| {
if i > 0 {
if window[at] != b'-' {
return false;
}
at += 1;
}
let ok = window[at..at + len].iter().all(u8::is_ascii_hexdigit);
at += len;
ok
});
if shaped && s.is_char_boundary(start) && s.is_char_boundary(start + UUID_LEN) {
out.insert(s[start..start + UUID_LEN].to_ascii_lowercase());
}
}
out
}