use crate::ui_state::content_hash;
use std::path::{Path, PathBuf};
pub trait FileIo {
fn read(&self, path: &Path) -> std::io::Result<Option<Vec<u8>>>;
fn write(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()>;
fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()>;
fn remove(&self, path: &Path) -> std::io::Result<()>;
fn list_dir(&self, dir: &Path) -> std::io::Result<Vec<PathBuf>>;
}
pub(crate) fn load_snapshot(
io: &dyn FileIo,
path: &Path,
) -> std::io::Result<(String, Option<u64>)> {
Ok(match io.read(path)? {
Some(bytes) => (
String::from_utf8_lossy(&bytes).into_owned(),
Some(content_hash(&bytes)),
),
None => (String::new(), None),
})
}
pub(crate) fn is_pristine(draft: &str, loaded: Option<u64>) -> bool {
match loaded {
Some(hash) => content_hash(draft.as_bytes()) == hash,
None => draft.is_empty(),
}
}
fn temp_path(dest: &Path) -> PathBuf {
let name = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config");
let dir = dest.parent().unwrap_or_else(|| Path::new("."));
crate::scratch::temp_in(dir, name)
}
pub(crate) enum Commit {
Ok(u64),
Conflict,
}
pub(crate) struct Staged {
dest: PathBuf,
temp: PathBuf,
draft_hash: u64,
}
pub(crate) fn stage(io: &dyn FileIo, dest: &Path, draft: &[u8]) -> std::io::Result<Staged> {
let temp = temp_path(dest);
io.write(&temp, draft)?;
Ok(Staged {
dest: dest.to_path_buf(),
temp,
draft_hash: content_hash(draft),
})
}
impl Staged {
pub(crate) fn temp(&self) -> &Path {
&self.temp
}
pub(crate) fn discard(self, io: &dyn FileIo) -> std::io::Result<()> {
io.remove(&self.temp)
}
pub(crate) fn commit(self, io: &dyn FileIo, loaded: Option<u64>) -> std::io::Result<Commit> {
let on_disk = io.read(&self.dest)?.map(|b| content_hash(&b));
if on_disk != loaded {
io.remove(&self.temp)?;
return Ok(Commit::Conflict);
}
io.rename(&self.temp, &self.dest)?;
Ok(Commit::Ok(self.draft_hash))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::FakeFs;
fn dest() -> PathBuf {
PathBuf::from("/cfg/models.yaml")
}
#[test]
fn temp_path_sits_beside_dest_and_falls_back() {
let pid = std::process::id();
assert_eq!(
temp_path(&dest()),
PathBuf::from(format!("/cfg/.models.yaml.yog-tmp-{pid}"))
);
assert_eq!(
temp_path(Path::new("/")),
PathBuf::from(format!("./.config.yog-tmp-{pid}"))
);
}
#[test]
fn load_snapshot_distinguishes_absent_from_present() {
let fs = FakeFs::seed(&dest(), b"hi");
assert_eq!(load_snapshot(&fs, &dest()).unwrap().0, "hi");
assert!(load_snapshot(&fs, &dest()).unwrap().1.is_some());
let (text, hash) = load_snapshot(&fs, Path::new("/cfg/gone.yaml")).unwrap();
assert_eq!(text, "");
assert_eq!(hash, None);
}
#[test]
fn pristine_is_the_untouched_buffer_absent_or_present() {
let loaded = Some(content_hash(b"hi"));
assert!(is_pristine("hi", loaded));
assert!(!is_pristine("hi there", loaded));
assert!(!is_pristine("", loaded));
assert!(is_pristine("", None));
assert!(!is_pristine("seed", None));
}
#[test]
fn stage_then_commit_renames_and_reports_hash() {
let fs = FakeFs::seed(&dest(), b"A");
let loaded = load_snapshot(&fs, &dest()).unwrap().1;
let staged = stage(&fs, &dest(), b"B").unwrap();
let temp = staged.temp().to_path_buf();
assert!(matches!(
staged.commit(&fs, loaded).unwrap(),
Commit::Ok(h) if h == content_hash(b"B")
));
assert_eq!(fs.get(&dest()), Some(b"B".to_vec()));
assert_eq!(fs.get(&temp), None);
}
#[test]
fn stage_then_discard_removes_the_temp() {
let fs = FakeFs::seed(&dest(), b"A");
let staged = stage(&fs, &dest(), b"B").unwrap();
let temp = staged.temp().to_path_buf();
staged.discard(&fs).unwrap();
assert_eq!(fs.get(&temp), None);
assert_eq!(fs.get(&dest()), Some(b"A".to_vec()));
}
#[test]
fn commit_refuses_a_moved_snapshot() {
let fs = FakeFs::seed(&dest(), b"A");
let loaded = load_snapshot(&fs, &dest()).unwrap().1;
let staged = stage(&fs, &dest(), b"B").unwrap();
let temp = staged.temp().to_path_buf();
fs.map().insert(dest(), b"C".to_vec());
assert!(matches!(
staged.commit(&fs, loaded).unwrap(),
Commit::Conflict
));
assert_eq!(fs.get(&dest()), Some(b"C".to_vec()));
assert_eq!(fs.get(&temp), None);
}
}