use std::fs;
use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};
use crate::applog;
use crate::diagnostic::{Diagnostic, Reason};
use crate::digest::Digest;
use crate::error::RkError;
pub const SNAPSHOTS_DIR: &str = "snapshots";
pub const REPLACED_DIR: &str = "replaced";
pub const FORGE_DIR: &str = "forge";
pub const REFS_DIR: &str = "refs";
#[must_use]
pub fn repo_key(canonical_target: &Path) -> String {
let digest = Digest::of(canonical_target.display().to_string().as_bytes()).to_string();
let leaf = canonical_target.file_name().map_or_else(
|| "root".to_owned(),
|name| name.to_string_lossy().into_owned(),
);
format!("{leaf}-{}", &digest[..12])
}
#[must_use]
pub fn timestamp() -> String {
applog::now_utc()
.chars()
.filter(char::is_ascii_alphanumeric)
.collect()
}
pub fn claim(runs: &Path, stamp: &str) -> std::io::Result<PathBuf> {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(runs)?;
let mut counter = 1_u32;
loop {
let name = if counter == 1 {
stamp.to_owned()
} else {
format!("{stamp}-{counter}")
};
let candidate = runs.join(name);
match fs::DirBuilder::new().mode(0o700).create(&candidate) {
Ok(()) => return Ok(candidate),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => counter += 1,
Err(error) => return Err(error),
}
}
}
#[derive(Debug, Clone)]
pub struct Snapshot {
root: PathBuf,
}
impl Snapshot {
pub fn begin(canonical_target: &Path) -> Result<Self, RkError> {
let Some(state) = applog::state_root() else {
return Err(RkError::refusal(
Diagnostic::new(
Reason::PrerequisiteUnmet,
"no state root resolves, so nothing can be saved before it is changed, and nothing was written",
)
.expected("a state root under XDG_STATE_HOME or HOME")
.action("set XDG_STATE_HOME or HOME, and run it again")
.target_state("unchanged"),
));
};
let runs = state.join(SNAPSHOTS_DIR).join(repo_key(canonical_target));
Ok(Self {
root: claim(&runs, ×tamp())?,
})
}
#[must_use]
pub const fn at(root: PathBuf) -> Self {
Self { root }
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn save(&self, relative: &Path, bytes: &[u8]) -> Result<PathBuf, RkError> {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)?;
}
match fs::read(&path) {
Ok(existing) if existing == bytes => return Ok(path),
Ok(_) => {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"a saved copy with other bytes already stands at {}, and nothing was written",
path.display()
),
)
.expected("no saved copy at that path, or one holding the same bytes")
.action("move the saved copy aside, then run it again")
.target_state("unchanged"),
));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
crate::atomic::write(&path, bytes)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
Ok(path)
}
pub fn save_replaced(&self, destination: &str, bytes: &[u8]) -> Result<PathBuf, RkError> {
self.save(&Path::new(REPLACED_DIR).join(destination), bytes)
}
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::PermissionsExt as _;
use std::path::Path;
use super::{Snapshot, claim, repo_key, timestamp};
#[test]
fn the_repo_key_reads_as_a_name_and_keeps_targets_apart() {
let one = repo_key(Path::new("/srv/a/widget"));
let two = repo_key(Path::new("/srv/b/widget"));
assert!(one.starts_with("widget-"), "{one}");
assert_eq!(one.len(), "widget-".len() + 12);
assert_ne!(one, two);
}
#[test]
fn the_timestamp_is_compact_and_utc() {
let stamp = timestamp();
assert_eq!(stamp.len(), "20261009T153000Z".len(), "{stamp}");
assert!(stamp.ends_with('Z'));
assert_eq!(stamp.as_bytes()[8], b'T');
}
#[test]
fn two_runs_in_one_second_keep_both_copies() {
let dir = tempfile::tempdir().expect("a scratch dir exists");
let runs = dir.path().join("snapshots").join("widget-000000000000");
let stamp = "20261009T213000Z";
let first = Snapshot::at(claim(&runs, stamp).expect("claims"));
let second = Snapshot::at(claim(&runs, stamp).expect("claims again"));
assert_ne!(first.root(), second.root());
assert!(second.root().ends_with("20261009T213000Z-2"));
let kept = first.save_replaced("AGENTS.md", b"first\n").expect("saves");
let again = second
.save_replaced("AGENTS.md", b"second\n")
.expect("saves");
assert_eq!(std::fs::read(kept).expect("reads"), b"first\n");
assert_eq!(std::fs::read(again).expect("reads"), b"second\n");
let mode = std::fs::metadata(second.root())
.expect("stats")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn a_saved_copy_is_never_overwritten_with_other_bytes() {
let dir = tempfile::tempdir().expect("a scratch dir exists");
let snapshot = Snapshot::at(dir.path().join("snap"));
let saved = snapshot
.save_replaced(".github/workflows/release.yml", b"ours\n")
.expect("saves");
assert_eq!(std::fs::read(&saved).expect("reads"), b"ours\n");
assert!(saved.ends_with("replaced/.github/workflows/release.yml"));
snapshot
.save_replaced(".github/workflows/release.yml", b"ours\n")
.expect("the same bytes save again");
let refused = snapshot
.save_replaced(".github/workflows/release.yml", b"theirs\n")
.expect_err("other bytes refuse");
assert_eq!(refused.exit_code(), 73);
assert_eq!(std::fs::read(&saved).expect("reads"), b"ours\n");
}
}