use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::path::Path;
const MAX_HASHED_BYTES: u64 = 16 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileStamp {
pub len: u64,
pub hash: u64,
}
impl FileStamp {
pub fn of_path(path: &Path) -> Option<Self> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() {
return None;
}
let len = metadata.len();
let mut hash = 0u64;
if len <= MAX_HASHED_BYTES {
let bytes = std::fs::read(path).ok()?;
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
hash = hasher.finish();
}
Some(Self { len, hash })
}
pub fn matches(&self, other: &FileStamp) -> bool {
self.len == other.len && self.hash == other.hash
}
}
#[derive(Debug, Clone)]
pub enum ReloadOutcome {
NoChange,
Applied {
actions: Vec<crate::loader::AppliedAction>,
},
Failed { error: String },
}
impl ReloadOutcome {
pub fn classify(actions: &[crate::loader::AppliedAction]) -> Self {
if actions.is_empty() {
return Self::NoChange;
}
let errors: Vec<String> = actions
.iter()
.filter_map(|a| {
a.status
.as_ref()
.err()
.map(|e| format!("{} ({}): {}", a.id, a.action, e))
})
.collect();
if errors.is_empty() {
Self::Applied {
actions: actions.to_vec(),
}
} else {
Self::Failed {
error: errors.join("; "),
}
}
}
pub fn summary(&self) -> String {
match self {
Self::NoChange => "no change".to_string(),
Self::Applied { actions } => {
let listed: Vec<String> = actions
.iter()
.map(|a| {
if a.verified {
format!("{}:{}", a.action, a.id)
} else {
format!("{}:{} (unverified)", a.action, a.id)
}
})
.collect();
format!("applied {}: {}", actions.len(), listed.join(", "))
}
Self::Failed { error } => format!("failed: {error}"),
}
}
}
impl fmt::Display for ReloadOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_content_yields_same_stamp() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entries.json");
std::fs::write(&path, b"{\"entry\":[]}\n").unwrap();
let a = FileStamp::of_path(&path).unwrap();
let b = FileStamp::of_path(&path).unwrap();
assert!(a.matches(&b));
assert_eq!(a, b);
assert_eq!(a.len, b"{\"entry\":[]}\n".len() as u64);
assert_ne!(a.hash, 0, "in-cap files must carry a content hash");
}
#[test]
fn equal_length_different_content_yields_different_stamps() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("entries.json");
std::fs::write(&path, b"disabled = false").unwrap();
let before = FileStamp::of_path(&path).unwrap();
std::fs::write(&path, b"disabled = true ").unwrap();
let after = FileStamp::of_path(&path).unwrap();
assert_eq!(before.len, after.len, "test precondition: equal lengths");
assert!(!before.matches(&after), "content change must flip the hash");
}
#[test]
fn missing_file_stamps_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(FileStamp::of_path(&dir.path().join("absent.toml")), None);
}
#[test]
fn oversized_files_fall_back_to_length_only() {
let len = MAX_HASHED_BYTES + 1;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.bin");
let f = std::fs::File::create(&path).unwrap();
f.set_len(len).unwrap();
let stamp = FileStamp::of_path(&path).unwrap();
assert_eq!(stamp.len, len);
assert_eq!(stamp.hash, 0, "over-cap files use length-only stamps");
}
fn action(
id: &str,
action: &'static str,
status: Result<(), String>,
) -> crate::loader::AppliedAction {
crate::loader::AppliedAction {
id: id.to_string(),
action,
status,
verified: true,
}
}
#[test]
fn reload_outcome_classifies_actions() {
let ok_batch = vec![
action("calc", "begin", Ok(())),
action("llm", "update-config", Ok(())),
];
match ReloadOutcome::classify(&ok_batch) {
ReloadOutcome::Applied { actions } => assert_eq!(actions.len(), 2),
other => panic!("all-ok batch must classify Applied, got {other:?}"),
}
let mixed = vec![
action("calc", "begin", Ok(())),
action("ghost", "rebuild-fiber", Err("no such plugin".to_string())),
action("gone", "retire", Err("dispose failed".to_string())),
];
match ReloadOutcome::classify(&mixed) {
ReloadOutcome::Failed { error } => {
assert!(error.contains("ghost"), "names failing id: {error}");
assert!(error.contains("no such plugin"), "carries error: {error}");
assert!(error.contains("gone"), "joins every error: {error}");
assert!(error.contains("; "), "errors joined with '; '");
}
other => panic!("error batch must classify Failed, got {other:?}"),
}
assert!(matches!(
ReloadOutcome::classify(&[]),
ReloadOutcome::NoChange
));
}
#[test]
fn reload_outcome_summary_strings() {
assert_eq!(ReloadOutcome::NoChange.summary(), "no change");
let ok_batch = vec![action("calc", "begin", Ok(()))];
let applied = ReloadOutcome::classify(&ok_batch);
let s = applied.summary();
assert!(s.starts_with("applied 1:"), "applied summary: {s}");
assert!(s.contains("begin:calc"), "lists action:id pairs: {s}");
let bad = vec![action("x", "retire", Err("boom".to_string()))];
let failed = ReloadOutcome::classify(&bad);
let s = failed.summary();
assert!(s.starts_with("failed:"), "failed summary: {s}");
assert!(s.contains("boom"), "failed summary carries error: {s}");
assert_eq!(format!("{applied}"), applied.summary());
}
}