use crate::safety::Effect;
use crate::value::Value;
use std::path::Path;
use std::sync::Mutex;
const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq)]
pub enum Before {
Contents(Vec<u8>),
Absent,
Irreversible(String),
}
#[derive(Clone, Debug)]
pub struct Entry {
pub seq: usize,
pub builtin: String,
pub effect: String,
pub path: String,
pub before: Before,
}
impl Entry {
pub fn reversible(&self) -> bool {
!matches!(self.before, Before::Irreversible(_))
}
}
lazy_static::lazy_static! {
static ref JOURNAL: Mutex<Vec<Entry>> = Mutex::new(Vec::new());
}
pub fn enabled() -> bool {
match std::env::var("AETHER_JOURNAL").ok().as_deref() {
Some("on") | Some("1") | Some("true") => true,
Some("off") | Some("0") | Some("false") => false,
_ => crate::safety::current_mode() == crate::safety::Mode::Agent,
}
}
fn is_journal_builtin(name: &str) -> bool {
matches!(name, "undo" | "journal" | "journal_clear" | "rewind")
}
fn total_bytes(entries: &[Entry]) -> usize {
entries
.iter()
.map(|e| match &e.before {
Before::Contents(b) => b.len(),
_ => 0,
})
.sum()
}
fn candidate_paths(args: &[Value]) -> Vec<String> {
let mut out = Vec::new();
for a in args {
if let Value::Str(s) = a {
if s.is_empty() || s.len() > 4096 || s.contains('\n') || s.contains('\0') {
continue;
}
let p = Path::new(s);
let plausible = p.is_file()
|| p.parent()
.map(|d| !d.as_os_str().is_empty() && d.is_dir())
.unwrap_or(false);
if plausible && !out.contains(s) {
out.push(s.clone());
}
}
}
out
}
pub fn record_before(builtin: &str, effect: Effect, args: &[Value]) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
if !matches!(effect, Effect::WriteLocal | Effect::Destructive) {
return;
}
let paths = candidate_paths(args);
if paths.is_empty() {
return;
}
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
let used = total_bytes(&j);
for path in paths {
let p = Path::new(&path);
let before = if p.is_file() {
match std::fs::metadata(p).map(|m| m.len() as usize) {
Ok(sz) if sz > MAX_FILE_BYTES => Before::Irreversible(format!(
"{sz} bytes exceeds the {MAX_FILE_BYTES}-byte capture limit"
)),
Ok(sz) if used + sz > MAX_TOTAL_BYTES => {
Before::Irreversible("session capture budget exhausted".into())
}
Ok(_) => match std::fs::read(p) {
Ok(bytes) => Before::Contents(bytes),
Err(e) => Before::Irreversible(format!("unreadable: {e}")),
},
Err(e) => Before::Irreversible(format!("unstattable: {e}")),
}
} else if p.exists() {
Before::Irreversible("path is a directory; trees are not captured".into())
} else {
Before::Absent
};
let seq = j.len() + 1;
j.push(Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path,
before,
});
}
}
pub fn record_irreversible(builtin: &str, effect: Effect, why: &str) {
if !enabled() || is_journal_builtin(builtin) {
return;
}
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
let seq = j.len() + 1;
j.push(Entry {
seq,
builtin: builtin.to_string(),
effect: effect.as_str().to_string(),
path: String::new(),
before: Before::Irreversible(why.to_string()),
});
}
#[derive(Clone, Debug, PartialEq)]
pub enum Outcome {
Restored(String),
Removed(String),
Skipped { what: String, why: String },
Failed { what: String, why: String },
}
pub fn undo(n: usize) -> Vec<Outcome> {
let mut j = JOURNAL.lock().unwrap_or_else(|e| e.into_inner());
let mut out = Vec::new();
for _ in 0..n {
let Some(entry) = j.pop() else { break };
let what = if entry.path.is_empty() {
entry.builtin.clone()
} else {
entry.path.clone()
};
match entry.before {
Before::Contents(bytes) => match std::fs::write(&entry.path, &bytes) {
Ok(()) => out.push(Outcome::Restored(what)),
Err(e) => out.push(Outcome::Failed {
what,
why: e.to_string(),
}),
},
Before::Absent => {
let p = Path::new(&entry.path);
if p.is_file() {
match std::fs::remove_file(p) {
Ok(()) => out.push(Outcome::Removed(what)),
Err(e) => out.push(Outcome::Failed {
what,
why: e.to_string(),
}),
}
} else {
out.push(Outcome::Skipped {
what,
why: "was absent before and is absent now".into(),
});
}
}
Before::Irreversible(why) => out.push(Outcome::Skipped { what, why }),
}
}
out
}
pub fn entries() -> Vec<Entry> {
JOURNAL
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|e| Entry {
before: match &e.before {
Before::Contents(b) => Before::Contents(vec![0; b.len().min(1)]),
other => other.clone(),
},
..e.clone()
})
.collect()
}
pub fn reversible_count() -> usize {
JOURNAL
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.filter(|e| e.reversible())
.count()
}
pub fn clear() {
JOURNAL.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_oversized_file_is_recorded_as_irreversible_not_skipped() {
let e = Entry {
seq: 1,
builtin: "file_write".into(),
effect: "write_local".into(),
path: "big.bin".into(),
before: Before::Irreversible("too big".into()),
};
assert!(!e.reversible());
}
#[test]
fn a_directory_target_is_refused_rather_than_half_captured() {
let e = Entry {
seq: 1,
builtin: "rm".into(),
effect: "destructive".into(),
path: ".".into(),
before: Before::Irreversible("path is a directory; trees are not captured".into()),
};
assert!(!e.reversible());
}
#[test]
fn journal_builtins_are_never_recorded() {
assert!(is_journal_builtin("undo"));
assert!(is_journal_builtin("journal"));
assert!(!is_journal_builtin("file_write"));
}
}