use std::io::Write;
use std::path::{Path, PathBuf};
use rigswig_core::change::Plan;
use rigswig_core::read::redact::sha256_hex;
#[derive(Debug, Clone)]
pub struct Applied {
pub path: PathBuf,
pub file: String,
pub before: Option<String>,
pub after_hash: String,
pub backup: Option<PathBuf>,
}
fn current(path: &Path) -> Result<Option<String>, String> {
match std::fs::symlink_metadata(path) {
Ok(m) if m.file_type().is_symlink() => {
Err("that settings file is a link; rigswig does not write through links".into())
}
Ok(_) => std::fs::read_to_string(path).map(Some).map_err(|_| "that settings file cannot be read".into()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) => Err("that settings file cannot be read".into()),
}
}
fn write_atomic(path: &Path, text: &str) -> Result<(), String> {
let dir = path.parent().ok_or("no folder for that file")?;
std::fs::create_dir_all(dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let tmp = dir.join(format!(".{name}.rigswig-{}.tmp", crate::ui::random_hex()));
let result = (|| {
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp)?;
f.write_all(text.as_bytes())?;
f.sync_all()?;
std::fs::rename(&tmp, path)
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result.map_err(|e| format!("could not write {}: {e}", path.display()))
}
pub fn apply(plan: &Plan, before_hash: Option<&str>, backups: &Path) -> Result<Applied, String> {
let now = current(&plan.path)?;
let now_hash = now.as_deref().map(sha256_hex);
if now_hash.as_deref() != before_hash || now_hash != plan.before_hash {
return Err("the file changed since you looked at it; review the change again".into());
}
let backup = match &now {
Some(text) => {
std::fs::create_dir_all(backups).map_err(|e| format!("could not create the backup folder: {e}"))?;
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let name = plan.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let b = backups.join(format!("{secs}-{}-{name}", &crate::ui::random_hex()[..8]));
std::fs::write(&b, text).map_err(|e| format!("could not save a backup: {e}"))?;
Some(b)
}
None => None,
};
write_atomic(&plan.path, &plan.after)?;
Ok(Applied {
path: plan.path.clone(),
file: plan.file.clone(),
before: now,
after_hash: sha256_hex(&plan.after),
backup,
})
}
pub fn undo(a: &Applied) -> Result<(), String> {
let now = current(&a.path)?;
if now.as_deref().map(sha256_hex).as_deref() != Some(a.after_hash.as_str()) {
return Err(format!("{} changed since the change was applied; restore it by hand from the backup", a.file));
}
match &a.before {
Some(text) => write_atomic(&a.path, text),
None => std::fs::remove_file(&a.path).map_err(|e| format!("could not remove {}: {e}", a.path.display())),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rigswig_core::change::{Change, Scope};
fn dir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("rigswig-writes-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn plan(path: &Path, before: Option<&str>, after: &str) -> Plan {
Plan {
change: Change::Plugin { id: "a@b".into(), scope: Scope::Local, on: false },
file: "settings.local.json".into(),
path: path.to_path_buf(),
before_hash: before.map(sha256_hex),
after: after.into(),
creates_file: before.is_none(),
diff: Vec::new(),
tokens_before: 0,
tokens_after: 0,
takes_effect: true,
notes: Vec::new(),
}
}
#[test]
fn apply_backs_up_and_undo_restores() {
let d = dir("roundtrip");
let f = d.join(".claude/settings.json");
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(&f, "old\n").unwrap();
let p = plan(&f, Some("old\n"), "new\n");
let a = apply(&p, p.before_hash.as_deref(), &d.join("backups")).unwrap();
assert_eq!(std::fs::read_to_string(&f).unwrap(), "new\n");
assert_eq!(std::fs::read_to_string(a.backup.as_ref().unwrap()).unwrap(), "old\n");
assert!(
std::fs::read_dir(f.parent().unwrap()).unwrap().all(|e| !e
.unwrap()
.file_name()
.to_string_lossy()
.contains(".tmp"))
);
undo(&a).unwrap();
assert_eq!(std::fs::read_to_string(&f).unwrap(), "old\n");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_created_file_is_removed_by_undo() {
let d = dir("create");
let f = d.join(".claude/settings.local.json");
let p = plan(&f, None, "{}\n");
let a = apply(&p, None, &d.join("backups")).unwrap();
assert!(a.backup.is_none());
undo(&a).unwrap();
assert!(!f.exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn stale_plans_links_and_edited_files_are_refused() {
let d = dir("refuse");
let f = d.join("s.json");
std::fs::write(&f, "old\n").unwrap();
let p = plan(&f, Some("old\n"), "new\n");
std::fs::write(&f, "someone else\n").unwrap();
assert!(apply(&p, p.before_hash.as_deref(), &d.join("b")).unwrap_err().contains("changed since"));
std::fs::write(&f, "old\n").unwrap();
assert!(
apply(&p, Some("not-the-hash"), &d.join("b")).unwrap_err().contains("changed since"),
"the page's hash is checked too"
);
let a = apply(&p, p.before_hash.as_deref(), &d.join("b")).unwrap();
std::fs::write(&f, "edited after\n").unwrap();
assert!(undo(&a).unwrap_err().contains("by hand"));
assert_eq!(std::fs::read_to_string(&f).unwrap(), "edited after\n", "undo never overwrites someone else's edit");
#[cfg(unix)]
{
let target = d.join("target.json");
std::fs::write(&target, "t\n").unwrap();
let link = d.join("link.json");
std::os::unix::fs::symlink(&target, &link).unwrap();
let lp = plan(&link, Some("t\n"), "x\n");
assert!(apply(&lp, lp.before_hash.as_deref(), &d.join("b")).unwrap_err().contains("link"));
assert_eq!(std::fs::read_to_string(&target).unwrap(), "t\n");
}
let _ = std::fs::remove_dir_all(&d);
}
}