use mkit_core::layout::RepoLayout;
use std::collections::HashSet;
use std::fs;
#[cfg(unix)]
use std::fs::File;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use mkit_core::hash::{HEX_LEN, Hash, to_hex};
use mkit_core::protocol::PackKey;
use tempfile::NamedTempFile;
use super::DispatchError;
#[derive(Debug)]
pub(crate) struct AppliedPacks {
set: HashSet<Hash>,
path: PathBuf,
dirty: bool,
}
impl AppliedPacks {
pub(crate) fn load(layout: &RepoLayout, remote: &str) -> Result<Self, DispatchError> {
validate_remote_name(remote)?;
let path = record_path(layout, remote);
let set = match fs::read(&path) {
Ok(bytes) => parse(&bytes),
Err(e) if e.kind() == io::ErrorKind::NotFound => HashSet::new(),
Err(e) => return Err(DispatchError::Io(e)),
};
Ok(Self {
set,
path,
dirty: false,
})
}
pub(crate) fn load_or_empty(layout: &RepoLayout, remote: &str) -> Self {
Self::load(layout, remote).unwrap_or_else(|e| {
eprintln!(
"warning: could not read applied-packs record for remote '{remote}' ({e}); continuing without redownload-avoidance for this fetch"
);
Self {
set: HashSet::new(),
path: record_path(layout, remote),
dirty: false,
}
})
}
pub(crate) fn contains(&self, key: &PackKey) -> bool {
self.set.contains(&key.into_hash())
}
pub(crate) fn insert(&mut self, key: &PackKey) {
self.set.insert(key.into_hash());
self.dirty = true;
}
pub(crate) fn persist(&mut self) -> Result<(), DispatchError> {
if !self.dirty {
return Ok(());
}
write(&self.path, &self.set)?;
self.dirty = false;
Ok(())
}
pub(crate) fn clear(&mut self) {
self.set.clear();
self.dirty = true;
}
pub(crate) fn remove_record(layout: &RepoLayout, remote: &str) -> Result<(), DispatchError> {
validate_remote_name(remote)?;
match fs::remove_file(record_path(layout, remote)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(DispatchError::Io(e)),
}
}
pub(crate) fn rename_record(
layout: &RepoLayout,
old: &str,
new: &str,
) -> Result<(), DispatchError> {
validate_remote_name(old)?;
validate_remote_name(new)?;
match fs::rename(record_path(layout, old), record_path(layout, new)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(DispatchError::Io(e)),
}
}
}
fn validate_remote_name(remote: &str) -> Result<(), DispatchError> {
if mkit_core::refs::validate_ref_name(remote) {
Ok(())
} else {
Err(DispatchError::InvalidRemoteName(remote.to_string()))
}
}
fn record_file_name(remote: &str) -> String {
remote.replace('/', "%2F")
}
fn record_path(layout: &RepoLayout, remote: &str) -> PathBuf {
layout.applied_packs_dir().join(record_file_name(remote))
}
fn parse(bytes: &[u8]) -> HashSet<Hash> {
let mut set = HashSet::new();
let Ok(s) = core::str::from_utf8(bytes) else {
return set;
};
for line in s.split('\n') {
let trimmed = line.trim_end_matches('\r');
if let Some(h) = mkit_core::refs::parse_lowercase_hash(trimmed.as_bytes()) {
set.insert(h);
}
}
set
}
fn write(path: &Path, set: &HashSet<Hash>) -> Result<(), DispatchError> {
let mut hexes: Vec<String> = set.iter().map(to_hex).collect();
hexes.sort_unstable();
let mut body = String::with_capacity(hexes.len() * (HEX_LEN + 1));
for hex in &hexes {
body.push_str(hex);
body.push('\n');
}
write_atomic(path, body.as_bytes())?;
Ok(())
}
fn write_atomic(final_path: &Path, bytes: &[u8]) -> io::Result<()> {
let parent = final_path
.parent()
.expect("applied_packs::write_atomic: path has parent");
fs::create_dir_all(parent)?;
let file_name = final_path
.file_name()
.expect("applied_packs::write_atomic: path has file name")
.to_string_lossy();
let tmp_prefix = format!(".{file_name}.tmp");
let mut tmp = NamedTempFile::with_prefix_in(tmp_prefix, parent)?;
tmp.as_file_mut().write_all(bytes)?;
tmp.as_file_mut().sync_all()?;
tmp.persist(final_path).map_err(|e| e.error)?;
sync_parent_dir(parent)?;
Ok(())
}
#[cfg(unix)]
fn sync_parent_dir(parent: &Path) -> io::Result<()> {
match File::open(parent) {
Ok(dir) => dir.sync_all(),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(not(unix))]
#[allow(clippy::unnecessary_wraps)]
fn sync_parent_dir(_parent: &Path) -> io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use mkit_core::hash;
use tempfile::TempDir;
fn h(seed: &str) -> Hash {
hash::hash(seed.as_bytes())
}
fn pk(seed: &str) -> PackKey {
PackKey::from_hash(h(seed))
}
fn fresh_mkit_dir() -> (TempDir, RepoLayout) {
let dir = TempDir::new().unwrap();
let mkit_dir = RepoLayout::single(dir.path());
fs::create_dir_all(mkit_dir.common_dir()).unwrap();
(dir, mkit_dir)
}
#[test]
fn load_missing_yields_empty_set() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let applied = AppliedPacks::load(&mkit_dir, "origin").unwrap();
assert!(!applied.contains(&pk("a")));
}
#[test]
fn insert_persist_reload_round_trip() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let k1 = pk("pack-1");
let k2 = pk("pack-2");
{
let mut applied = AppliedPacks::load(&mkit_dir, "origin").unwrap();
applied.insert(&k1);
applied.insert(&k2);
applied.persist().unwrap();
}
let reloaded = AppliedPacks::load(&mkit_dir, "origin").unwrap();
assert!(reloaded.contains(&k1));
assert!(reloaded.contains(&k2));
assert!(!reloaded.contains(&pk("pack-3")));
}
#[test]
fn separate_remotes_get_separate_files() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let k1 = pk("pack-1");
let mut origin = AppliedPacks::load(&mkit_dir, "origin").unwrap();
origin.insert(&k1);
origin.persist().unwrap();
let upstream = AppliedPacks::load(&mkit_dir, "upstream").unwrap();
assert!(
!upstream.contains(&k1),
"records must not leak across remotes"
);
}
#[test]
fn malformed_lines_are_ignored_on_load() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let path = mkit_dir.applied_packs_dir().join("origin");
fs::create_dir_all(path.parent().unwrap()).unwrap();
let valid = h("valid");
let mut content = String::new();
content.push_str("short\n");
content.push_str(&to_hex(&valid));
content.push('\n');
content.push_str(&"F".repeat(64));
content.push('\n');
content.push_str(&"z".repeat(64));
content.push('\n');
fs::write(&path, content).unwrap();
let applied = AppliedPacks::load(&mkit_dir, "origin").unwrap();
assert!(applied.contains(&PackKey::from_hash(valid)));
}
#[test]
fn atomic_rewrite_leaves_no_tmp_file_on_success() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let mut applied = AppliedPacks::load(&mkit_dir, "origin").unwrap();
applied.insert(&pk("pack-1"));
applied.persist().unwrap();
let dir = mkit_dir.applied_packs_dir();
let leftover: Vec<_> = fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
leftover.is_empty(),
"no .tmp file should survive a successful persist"
);
}
#[test]
fn persist_is_noop_when_not_dirty() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let mut applied = AppliedPacks::load(&mkit_dir, "origin").unwrap();
applied.persist().unwrap();
assert!(!mkit_dir.applied_packs_dir().exists());
}
#[test]
fn clear_empties_the_record_in_memory_and_marks_it_dirty() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let k1 = pk("pack-1");
let mut applied = AppliedPacks::load(&mkit_dir, "origin").unwrap();
applied.insert(&k1);
applied.persist().unwrap();
assert!(applied.contains(&k1));
applied.clear();
assert!(!applied.contains(&k1));
let reloaded = AppliedPacks::load(&mkit_dir, "origin").unwrap();
assert!(reloaded.contains(&k1));
applied.persist().unwrap();
let reloaded_after_persist = AppliedPacks::load(&mkit_dir, "origin").unwrap();
assert!(!reloaded_after_persist.contains(&k1));
}
#[test]
fn multi_segment_remote_round_trips() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let k1 = pk("pack-1");
{
let mut applied = AppliedPacks::load(&mkit_dir, "team/upstream").unwrap();
applied.insert(&k1);
applied.persist().unwrap();
}
let dir = mkit_dir.applied_packs_dir();
let entries: Vec<_> = fs::read_dir(&dir).unwrap().filter_map(Result::ok).collect();
assert_eq!(entries.len(), 1, "exactly one flat record file per remote");
assert!(
entries[0].file_type().unwrap().is_file(),
"the `/` must not create a subdirectory"
);
assert_eq!(
entries[0].file_name().to_string_lossy(),
"team%2Fupstream",
"the `/` is percent-encoded in the on-disk filename"
);
let reloaded = AppliedPacks::load(&mkit_dir, "team/upstream").unwrap();
assert!(reloaded.contains(&k1));
let other = AppliedPacks::load(&mkit_dir, "teamupstream").unwrap();
assert!(!other.contains(&k1), "encoded names must not collide");
}
#[test]
fn remove_record_deletes_and_is_idempotent_when_missing() {
let (_dir, mkit_dir) = fresh_mkit_dir();
AppliedPacks::remove_record(&mkit_dir, "origin").unwrap();
let k1 = pk("pack-1");
let mut applied = AppliedPacks::load(&mkit_dir, "origin").unwrap();
applied.insert(&k1);
applied.persist().unwrap();
let path = mkit_dir.applied_packs_dir().join("origin");
assert!(path.is_file(), "precondition: record persisted");
AppliedPacks::remove_record(&mkit_dir, "origin").unwrap();
assert!(!path.exists(), "remove_record must delete the record file");
let reloaded = AppliedPacks::load(&mkit_dir, "origin").unwrap();
assert!(!reloaded.contains(&k1));
AppliedPacks::remove_record(&mkit_dir, "origin").unwrap();
}
#[test]
fn rename_record_moves_the_record_and_leaves_no_orphan() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let k1 = pk("pack-1");
let mut applied = AppliedPacks::load(&mkit_dir, "old").unwrap();
applied.insert(&k1);
applied.persist().unwrap();
AppliedPacks::rename_record(&mkit_dir, "old", "new").unwrap();
assert!(
!mkit_dir.applied_packs_dir().join("old").exists(),
"no orphan record may remain under the old name"
);
let renamed = AppliedPacks::load(&mkit_dir, "new").unwrap();
assert!(renamed.contains(&k1), "the record must move, not clear");
let old = AppliedPacks::load(&mkit_dir, "old").unwrap();
assert!(!old.contains(&k1));
}
#[test]
fn rename_record_missing_source_is_ok() {
let (_dir, mkit_dir) = fresh_mkit_dir();
AppliedPacks::rename_record(&mkit_dir, "old", "new").unwrap();
assert!(!mkit_dir.applied_packs_dir().exists());
}
#[test]
fn rename_record_handles_multi_segment_names() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let k1 = pk("pack-1");
let mut applied = AppliedPacks::load(&mkit_dir, "team/upstream").unwrap();
applied.insert(&k1);
applied.persist().unwrap();
AppliedPacks::rename_record(&mkit_dir, "team/upstream", "archive/upstream").unwrap();
let dir = mkit_dir.applied_packs_dir();
let names: Vec<String> = fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["archive%2Fupstream".to_owned()]);
let renamed = AppliedPacks::load(&mkit_dir, "archive/upstream").unwrap();
assert!(renamed.contains(&k1));
AppliedPacks::remove_record(&mkit_dir, "archive/upstream").unwrap();
assert!(!dir.join("archive%2Fupstream").exists());
}
#[test]
fn lifecycle_ops_reject_invalid_names() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let err = AppliedPacks::remove_record(&mkit_dir, "..").unwrap_err();
assert!(matches!(err, DispatchError::InvalidRemoteName(_)));
let err = AppliedPacks::rename_record(&mkit_dir, "..", "new").unwrap_err();
assert!(matches!(err, DispatchError::InvalidRemoteName(_)));
let err = AppliedPacks::rename_record(&mkit_dir, "old", "../escape").unwrap_err();
assert!(matches!(err, DispatchError::InvalidRemoteName(_)));
}
#[test]
fn remote_name_rejects_backslash() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let err = AppliedPacks::load(&mkit_dir, "evil\\remote").unwrap_err();
assert!(matches!(err, DispatchError::InvalidRemoteName(_)));
}
#[test]
fn remote_name_rejects_dotdot_and_empty() {
let (_dir, mkit_dir) = fresh_mkit_dir();
let err = AppliedPacks::load(&mkit_dir, "..").unwrap_err();
assert!(matches!(err, DispatchError::InvalidRemoteName(_)));
let err = AppliedPacks::load(&mkit_dir, "../escape").unwrap_err();
assert!(matches!(err, DispatchError::InvalidRemoteName(_)));
let err = AppliedPacks::load(&mkit_dir, "").unwrap_err();
assert!(matches!(err, DispatchError::InvalidRemoteName(_)));
}
}