use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExeFingerprint {
pub path: PathBuf,
pub mtime_nanos: i64,
pub size: u64,
}
impl ExeFingerprint {
pub fn of(path: &Path) -> Option<ExeFingerprint> {
let canon = std::fs::canonicalize(path).ok()?;
let meta = std::fs::metadata(&canon).ok()?;
let mtime = meta.modified().ok()?;
let nanos = mtime.duration_since(UNIX_EPOCH).ok()?.as_nanos();
let mtime_nanos = i64::try_from(nanos).ok()?;
Some(ExeFingerprint {
path: canon,
mtime_nanos,
size: meta.len(),
})
}
pub fn current() -> Option<ExeFingerprint> {
ExeFingerprint::of(&std::env::current_exe().ok()?)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DriftState {
Fresh,
Drifted {
running: ExeFingerprint,
on_disk: ExeFingerprint,
},
DaemonDown,
Unknown,
}
pub fn classify(running: Option<&ExeFingerprint>, on_disk: Option<&ExeFingerprint>) -> DriftState {
match (running, on_disk) {
(Some(r), Some(d)) => {
if r.path != d.path || r.mtime_nanos != d.mtime_nanos || r.size != d.size {
DriftState::Drifted {
running: r.clone(),
on_disk: d.clone(),
}
} else {
DriftState::Fresh
}
}
_ => DriftState::Unknown,
}
}
pub fn drift_warning(state: &DriftState, pid: Option<u32>) -> Option<String> {
match state {
DriftState::Drifted { .. } => {
let who = match pid {
Some(p) => format!("the running daemon (pid {p})"),
None => "the running daemon".to_string(),
};
Some(format!(
"fno agents: {who} is an older build than the installed binary; \
run `fno agents restart` to pick up the new build."
))
}
DriftState::Fresh | DriftState::DaemonDown | DriftState::Unknown => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
fn tmp_path(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("fno_drift_{}_{}_{tag}", std::process::id(), {
use std::sync::atomic::{AtomicU32, Ordering};
static C: AtomicU32 = AtomicU32::new(0);
C.fetch_add(1, Ordering::Relaxed)
}));
p
}
fn write_file(path: &Path, bytes: &[u8]) {
let mut f = fs::File::create(path).unwrap();
f.write_all(bytes).unwrap();
f.flush().unwrap();
}
#[test]
fn of_missing_path_is_none() {
let p = tmp_path("missing");
assert!(ExeFingerprint::of(&p).is_none());
}
#[test]
fn of_roundtrips_and_tracks_size_change() {
let p = tmp_path("rt");
write_file(&p, b"hello");
let a = ExeFingerprint::of(&p).expect("fingerprint");
let b = ExeFingerprint::of(&p).expect("fingerprint again");
assert_eq!(a, b, "same file fingerprints equal");
assert_eq!(a.size, 5);
write_file(&p, b"hello world!!");
let c = ExeFingerprint::of(&p).expect("fingerprint after grow");
assert_ne!(a, c, "size change yields a different fingerprint");
assert_eq!(c.size, 13);
fs::remove_file(&p).ok();
}
#[test]
fn classify_fresh_when_equal() {
let p = tmp_path("fresh");
write_file(&p, b"bin");
let fp = ExeFingerprint::of(&p).unwrap();
assert_eq!(classify(Some(&fp), Some(&fp)), DriftState::Fresh);
assert_eq!(drift_warning(&DriftState::Fresh, Some(1)), None);
fs::remove_file(&p).ok();
}
#[test]
fn classify_content_drift_when_size_differs() {
let p = tmp_path("content");
write_file(&p, b"old");
let running = ExeFingerprint::of(&p).unwrap();
let on_disk = ExeFingerprint {
size: running.size + 7,
..running.clone()
};
match classify(Some(&running), Some(&on_disk)) {
DriftState::Drifted { .. } => {}
other => panic!("expected Drifted, got {other:?}"),
}
fs::remove_file(&p).ok();
}
#[test]
fn classify_path_drift_when_path_differs() {
let a = ExeFingerprint {
path: PathBuf::from("/opt/a/fno-agents-daemon"),
mtime_nanos: 100,
size: 10,
};
let b = ExeFingerprint {
path: PathBuf::from("/home/u/.cargo/bin/fno-agents-daemon"),
mtime_nanos: 100,
size: 10,
};
match classify(Some(&a), Some(&b)) {
DriftState::Drifted { .. } => {}
other => panic!("expected Drifted, got {other:?}"),
}
}
#[test]
fn classify_unknown_when_either_missing() {
let fp = ExeFingerprint {
path: PathBuf::from("/x"),
mtime_nanos: 1,
size: 1,
};
assert_eq!(classify(None, Some(&fp)), DriftState::Unknown);
assert_eq!(classify(Some(&fp), None), DriftState::Unknown);
assert_eq!(classify(None, None), DriftState::Unknown);
assert_eq!(drift_warning(&DriftState::Unknown, None), None);
assert_eq!(drift_warning(&DriftState::DaemonDown, None), None);
}
#[test]
fn drift_warning_names_restart_verb() {
let fp = ExeFingerprint {
path: PathBuf::from("/x"),
mtime_nanos: 1,
size: 1,
};
let state = DriftState::Drifted {
running: fp.clone(),
on_disk: fp,
};
let msg = drift_warning(&state, Some(91627)).expect("warns on drift");
assert!(msg.contains("fno agents restart"), "names the remedy verb");
assert!(msg.contains("build"), "describes a build mismatch");
assert!(msg.contains("91627"), "names the pid when known");
}
}