use anyhow::{bail, Result};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Trigger {
File { path: String, hash: String },
Image { digest: String },
Base { version: String },
Symbol { name: String },
}
pub const IMAGE_NOW: &str = "current";
fn is_hash(s: &str, min: usize) -> bool {
(min..=40).contains(&s.len())
&& s.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
}
fn escapes(path: &str) -> bool {
path.contains('\\') || Path::new(path).components().any(|c| c.as_os_str() == "..")
}
impl Trigger {
pub fn parse(raw: &str) -> Result<Self> {
let Some((kind, rest)) = raw.split_once(':') else {
bail!("`{raw}` is not an invalidation trigger (file, image, base, symbol)");
};
if rest.is_empty() {
bail!("`{raw}` names a `{kind}` trigger with nothing in it");
}
Ok(match kind {
"file" => {
let Some((path, hash)) = rest.rsplit_once('@') else {
bail!("`{raw}` has no hash; an expiry with nothing to compare can never fire");
};
if path.is_empty() || hash.is_empty() {
bail!("`{raw}` needs both a path and a hash");
}
if escapes(path) {
bail!("`{path}` leaves the repo; a trigger names a file omh can check");
}
if !is_hash(hash, 7) {
bail!(
"`{hash}` is not a git hash; `stale` compares it against \
`git hash-object`, so nothing else can ever match"
);
}
Self::File {
path: path.into(),
hash: hash.into(),
}
}
"image" if rest == IMAGE_NOW => Self::Image {
digest: IMAGE_NOW.into(),
},
"image" if is_hash(rest, 40) => Self::Image {
digest: rest.into(),
},
"image" => bail!(
"`{rest}` is not a recipe digest — pin `image:{IMAGE_NOW}` and omh \
records what it would build now"
),
"base" if crate::base::parse_ym(rest).is_none() => {
bail!("`{rest}` is not a base-set version omh can read")
}
"base" => Self::Base {
version: rest.into(),
},
"symbol" => Self::Symbol { name: rest.into() },
other => {
bail!("`{other}` is not something omh can evaluate (file, image, base, symbol)")
}
})
}
pub fn render(&self) -> String {
match self {
Self::File { path, hash } => format!("file:{path}@{hash}"),
Self::Image { digest } => format!("image:{digest}"),
Self::Base { version } => format!("base:{version}"),
Self::Symbol { name } => format!("symbol:{name}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileFact {
Hash(String),
Absent,
Unreadable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fact<T> {
Known(T),
Unavailable(String),
}
impl<T> Default for Fact<T> {
fn default() -> Self {
Self::Unavailable("not gathered".into())
}
}
#[derive(Debug, Default)]
pub struct Facts {
pub files: BTreeMap<String, FileFact>,
pub images: Fact<BTreeSet<String>>,
pub base: Fact<String>,
pub symbols: Option<BTreeSet<String>>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
NoTrigger,
Fresh,
Stale {
because: String,
},
Unknown {
because: String,
},
}
pub fn normalise_path(raw: &str, repo: &Path) -> String {
let sandbox = format!("{}/", crate::container_workdir());
let trimmed = raw
.strip_prefix(&sandbox)
.or_else(|| raw.strip_prefix("./"))
.unwrap_or(raw);
Path::new(trimmed)
.strip_prefix(repo)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| trimmed.to_string())
}
pub fn evaluate(trigger: Option<&Trigger>, facts: &Facts) -> Verdict {
let Some(trigger) = trigger else {
return Verdict::NoTrigger;
};
match trigger {
Trigger::File { path, hash } => match facts.files.get(path) {
None | Some(FileFact::Unreadable) => Verdict::Unknown {
because: format!("`{path}` could not be read"),
},
Some(FileFact::Absent) => Verdict::Stale {
because: format!("`{path}` is gone; it was pinned at {hash}"),
},
Some(FileFact::Hash(now)) if now.starts_with(hash.as_str()) => Verdict::Fresh,
Some(FileFact::Hash(now)) => Verdict::Stale {
because: format!("`{path}` was {hash}, is now {now}"),
},
},
Trigger::Image { digest } => match &facts.images {
Fact::Unavailable(why) => Verdict::Unknown {
because: why.clone(),
},
Fact::Known(recipes) if recipes.contains(digest) => Verdict::Fresh,
Fact::Known(_) => Verdict::Stale {
because: format!("the sandbox image recipe changed; this note pinned {digest}"),
},
},
Trigger::Base { version } => {
let current = match &facts.base {
Fact::Unavailable(why) => {
return Verdict::Unknown {
because: why.clone(),
}
}
Fact::Known(v) => v,
};
let (Some(now), Some(pinned)) = (
crate::base::parse_ym(current),
crate::base::parse_ym(version),
) else {
return Verdict::Unknown {
because: format!("`{current}` or `{version}` is not a version omh can read"),
};
};
match now > pinned {
true => Verdict::Stale {
because: format!("the base set is {current}; this note pinned {version}"),
},
false => Verdict::Fresh,
}
}
Trigger::Symbol { name } => {
let Some(symbols) = &facts.symbols else {
return Verdict::Unknown {
because: "no indexed code graph reachable from the host".into(),
};
};
match symbols.contains(name) {
true => Verdict::Fresh,
false => Verdict::Stale {
because: format!("the code graph no longer contains `{name}`"),
},
}
}
}
}
pub fn gather(paths: &crate::profile::Paths, triggers: &[Trigger]) -> Facts {
let mut facts = Facts::default();
for trigger in triggers {
if let Trigger::File { path, .. } = trigger {
if facts.files.contains_key(path) {
continue;
}
let relative = normalise_path(path, &paths.repo);
let fact = match escapes(&relative) || Path::new(&relative).is_absolute() {
true => FileFact::Unreadable,
false => {
let full = paths.repo.join(&relative);
match std::fs::metadata(&full) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => FileFact::Absent,
Err(_) => FileFact::Unreadable,
Ok(_) => match hash_file(&paths.repo, &relative) {
Ok(h) => FileFact::Hash(h),
Err(_) => FileFact::Unreadable,
},
}
}
};
facts.files.insert(path.clone(), fact);
}
}
facts.images = match crate::image::recipe_digest(&crate::image::base_dockerfile()) {
Ok(d) => Fact::Known(BTreeSet::from([d])),
Err(e) => Fact::Unavailable(format!("could not digest the image recipe: {e:#}")),
};
facts.base = match crate::base::Manifest::load_dir(&paths.base()) {
Ok(m) => Fact::Known(m.version),
Err(e) => Fact::Unavailable(format!("{e:#}")),
};
facts
}
fn hash_file(repo: &Path, path: &str) -> Result<String> {
use anyhow::Context;
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["hash-object", "--"])
.arg(path)
.output()
.with_context(|| format!("running git hash-object in {}", repo.display()))?;
if !out.status.success() {
bail!(
"git hash-object: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let hash = String::from_utf8_lossy(&out.stdout).trim().to_string();
if hash.is_empty() {
bail!("git hash-object printed nothing for `{path}`");
}
Ok(hash)
}
#[derive(Debug)]
pub struct Judged {
pub key: String,
pub layer: crate::memory::Layer,
pub recorded: String,
pub verdict: Verdict,
}
pub fn judge(paths: &crate::profile::Paths, notes: &[crate::memory::Note]) -> Result<Vec<Judged>> {
let triggers: Vec<Trigger> = notes
.iter()
.filter_map(|n| n.invalidated_by.as_deref())
.filter_map(|raw| Trigger::parse(raw).ok())
.collect();
let facts = gather(paths, &triggers);
Ok(notes
.iter()
.map(|n| {
let parsed = n.invalidated_by.as_deref().map(Trigger::parse);
let verdict = match parsed {
None => Verdict::NoTrigger,
Some(Err(e)) => Verdict::Unknown {
because: format!("{e}"),
},
Some(Ok(t)) => evaluate(Some(&t), &facts),
};
Judged {
key: n.key.clone(),
layer: n.layer,
recorded: n.recorded.clone(),
verdict,
}
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn facts() -> Facts {
Facts::default()
}
fn repo_with(files: &[(&str, &str)]) -> (tempfile::TempDir, crate::profile::Paths) {
let dir = tempfile::tempdir().unwrap();
let paths = crate::profile::Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
std::fs::create_dir_all(&paths.repo).unwrap();
for (name, body) in files {
let p = paths.repo.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
(dir, paths)
}
fn note_pinning(trigger: Option<&str>) -> crate::memory::Note {
crate::memory::Note {
key: "k".into(),
kind: crate::memory::Kind::Surprise,
source: "session s01, claude".into(),
recorded: "2026-08-07".into(),
invalidated_by: trigger.map(|t| t.into()),
body: String::new(),
layer: crate::memory::Layer::Local,
path: std::path::PathBuf::from("k.md"),
}
}
fn git_init(repo: &Path) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(["init", "-q", "-b", "main"])
.output()
.expect("git must be installed");
assert!(out.status.success());
}
#[test]
fn a_trigger_omh_could_never_evaluate_is_refused_at_the_door() {
for raw in [
"file:src/main.rs@zzzz",
"file:src/main.rs@abc",
"file:src/main.rs@ABC123DEF456A",
"base:banana",
"image:sha256-4f2a",
"image:",
"base:",
"symbol:",
] {
assert!(
Trigger::parse(raw).is_err(),
"`{raw}` names an expiry omh cannot evaluate"
);
}
}
#[test]
fn the_pins_a_writer_produces_are_accepted() {
for raw in [
"file:src/main.rs@ce013625030ba8dba906f756967f9e9ca394464a",
"file:src/main.rs@ce01362",
"base:2026.08",
"image:ce013625030ba8dba906f756967f9e9ca394464a",
"image:current",
"symbol:GUEST_HOME",
] {
assert!(
Trigger::parse(raw).is_ok(),
"`{raw}` is a pin omh can honour"
);
}
}
#[test]
fn a_trigger_path_that_leaves_the_repo_is_refused() {
for raw in [
"file:../../../../etc/passwd@ce01362",
"file:a/../../b@ce01362",
"file:a\\b@ce01362",
] {
assert!(Trigger::parse(raw).is_err(), "`{raw}` leaves the store");
}
}
#[test]
fn gather_never_stats_a_path_outside_the_repo() {
let (_d, paths) = repo_with(&[("in.txt", "x")]);
let outside = Trigger::File {
path: "/etc/passwd".into(),
hash: "ce01362".into(),
};
let facts = gather(&paths, std::slice::from_ref(&outside));
assert_eq!(
facts.files.get("/etc/passwd"),
Some(&FileFact::Unreadable),
"a path omh cannot confine to the repo is not a fact about the repo"
);
assert!(
matches!(evaluate(Some(&outside), &facts), Verdict::Unknown { .. }),
"and it is never reported as gone"
);
}
#[test]
fn an_abbreviated_hash_matches_the_hash_it_abbreviates() {
let mut facts = facts();
facts.files.insert(
"t.txt".into(),
FileFact::Hash("ce013625030ba8dba906f756967f9e9ca394464a".into()),
);
let short = Trigger::parse("file:t.txt@ce01362").unwrap();
assert_eq!(evaluate(Some(&short), &facts), Verdict::Fresh);
let wrong = Trigger::parse("file:t.txt@ce01363").unwrap();
assert!(matches!(
evaluate(Some(&wrong), &facts),
Verdict::Stale { .. }
));
}
#[test]
fn judge_reports_a_file_that_really_changed_as_stale() {
let (_d, paths) = repo_with(&[("tracked.txt", "before\n")]);
git_init(&paths.repo);
let pinned = hash_file(&paths.repo, "tracked.txt").unwrap();
let fresh = note_pinning(Some(&format!("file:tracked.txt@{pinned}")));
assert_eq!(
judge(&paths, std::slice::from_ref(&fresh)).unwrap()[0].verdict,
Verdict::Fresh,
"the hash omh computes must be the hash a note would have pinned"
);
std::fs::write(paths.repo.join("tracked.txt"), "after\n").unwrap();
assert!(
matches!(
judge(&paths, std::slice::from_ref(&fresh)).unwrap()[0].verdict,
Verdict::Stale { .. }
),
"and the change has to reach the verdict"
);
}
#[test]
fn judge_agrees_with_the_recipe_digest_a_note_would_pin() {
let (_d, paths) = repo_with(&[]);
let now = crate::image::recipe_digest(&crate::image::base_dockerfile()).unwrap();
let pinned = note_pinning(Some(&format!("image:{now}")));
assert_eq!(
judge(&paths, std::slice::from_ref(&pinned)).unwrap()[0].verdict,
Verdict::Fresh,
"a note pinning today's recipe is current"
);
let stale = note_pinning(Some(&format!("image:{}", "0".repeat(40))));
assert!(
matches!(
judge(&paths, std::slice::from_ref(&stale)).unwrap()[0].verdict,
Verdict::Stale { .. }
),
"and one pinning a recipe omh would not build now is not"
);
}
#[test]
fn a_harness_recipe_is_never_pinned_while_it_carries_an_unstable_tag() {
let shipped = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/adapters"));
let harnesses = crate::adapter::Adapter::load_dir(shipped).unwrap();
assert!(!harnesses.is_empty(), "no adapters to check: {shipped:?}");
let (_d, paths) = repo_with(&[]);
std::fs::create_dir_all(paths.adapters()).unwrap();
for entry in std::fs::read_dir(shipped).unwrap().flatten() {
if entry.path().extension().is_some_and(|e| e == "toml") {
std::fs::copy(entry.path(), paths.adapters().join(entry.file_name())).unwrap();
}
}
let pinned = match gather(&paths, &[]).images {
Fact::Known(set) => set,
Fact::Unavailable(why) => panic!("the base recipe must be digestible: {why}"),
};
for adapter in &harnesses {
let recipe = crate::image::harness_dockerfile(adapter);
let carries_unstable_tag = recipe.contains(&crate::image::base_tag());
let digest = crate::image::recipe_digest(&recipe).unwrap();
assert!(
carries_unstable_tag,
"`{}`'s recipe no longer embeds `base_tag()` — a harness digest \
is now safe to pin, and this test has become the obstacle",
adapter.name
);
assert!(
!pinned.contains(&digest),
"`{}`'s recipe digest is pinned while the recipe still embeds \
`base_tag()`, a DefaultHasher value std does not guarantee \
across releases. Render it stably first — substitute the base's \
recipe digest for its tag — or leave `image:` base-only.",
adapter.name
);
}
}
#[test]
fn gather_leaves_the_symbol_set_unknown_rather_than_empty() {
let (_d, paths) = repo_with(&[]);
assert_eq!(gather(&paths, &[]).symbols, None);
}
#[test]
fn gather_folds_a_sandbox_path_the_way_the_write_path_would() {
let (_d, paths) = repo_with(&[("tracked.txt", "x\n")]);
git_init(&paths.repo);
let pinned = hash_file(&paths.repo, "tracked.txt").unwrap();
let sandbox = Trigger::File {
path: format!("{}/tracked.txt", crate::container_workdir()),
hash: pinned,
};
let facts = gather(&paths, std::slice::from_ref(&sandbox));
assert_eq!(
evaluate(Some(&sandbox), &facts),
Verdict::Fresh,
"the file is present and unchanged"
);
}
#[cfg(unix)]
#[test]
fn a_file_omh_may_not_read_is_not_reported_as_gone() {
use std::os::unix::fs::PermissionsExt;
let (_d, paths) = repo_with(&[("locked/secret.txt", "x\n")]);
git_init(&paths.repo);
let dir = paths.repo.join("locked");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000)).unwrap();
let t = Trigger::File {
path: "locked/secret.txt".into(),
hash: "ce01362".into(),
};
let facts = gather(&paths, std::slice::from_ref(&t));
let verdict = evaluate(Some(&t), &facts);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(
matches!(verdict, Verdict::Unknown { .. }),
"a file omh cannot read has not been deleted: {verdict:?}"
);
}
#[test]
fn an_unreadable_base_manifest_says_so_rather_than_claiming_none_is_installed() {
let (_d, paths) = repo_with(&[]);
std::fs::create_dir_all(paths.base()).unwrap();
std::fs::write(paths.base().join("2026.09.toml"), "this is not toml {{{").unwrap();
let t = Trigger::Base {
version: "2026.08".into(),
};
let facts = gather(&paths, std::slice::from_ref(&t));
let Verdict::Unknown { because } = evaluate(Some(&t), &facts) else {
panic!("a manifest omh cannot read is not an answer");
};
assert!(
because.contains("2026.09"),
"name what could not be read: {because}"
);
}
#[test]
fn gathering_facts_hashes_only_the_files_notes_name() {
let (_d, paths) = repo_with(&[
("named.rs", "x"),
("unnamed.rs", "y"),
("deep/also-unnamed.rs", "z"),
]);
let triggers = vec![Trigger::parse("file:named.rs@01d0111").unwrap()];
let facts = gather(&paths, &triggers);
assert_eq!(facts.files.len(), 1, "only what was asked about");
assert!(facts.files.contains_key("named.rs"));
}
#[test]
fn a_present_file_hashes_and_a_missing_one_is_absent() {
let (_d, paths) = repo_with(&[("here.rs", "content")]);
let triggers = vec![
Trigger::parse("file:here.rs@01d0111").unwrap(),
Trigger::parse("file:gone.rs@01d0111").unwrap(),
];
let facts = gather(&paths, &triggers);
assert!(matches!(facts.files["here.rs"], FileFact::Hash(_)));
assert_eq!(facts.files["gone.rs"], FileFact::Absent);
}
#[test]
fn a_files_hash_matches_what_git_would_record_for_it() {
let (_d, paths) = repo_with(&[("f.txt", "hello\n")]);
let facts = gather(&paths, &[Trigger::parse("file:f.txt@0000001").unwrap()]);
assert_eq!(
facts.files["f.txt"],
FileFact::Hash("ce013625030ba8dba906f756967f9e9ca394464a".into()),
"git's own blob hash for `hello\\n`"
);
}
#[test]
fn a_note_whose_trigger_will_not_parse_is_unknown_not_untriggered() {
let (_d, paths) = repo_with(&[]);
let mut note = crate::memory::Note {
key: "k".into(),
kind: crate::memory::Kind::Surprise,
source: "session s01, claude".into(),
recorded: "2026-08-07".into(),
invalidated_by: Some("vibes:soon".into()),
body: String::new(),
layer: crate::memory::Layer::Local,
path: std::path::PathBuf::from("k.md"),
};
let judged = judge(&paths, std::slice::from_ref(¬e)).unwrap();
assert!(matches!(judged[0].verdict, Verdict::Unknown { .. }));
note.invalidated_by = None;
let judged = judge(&paths, std::slice::from_ref(¬e)).unwrap();
assert_eq!(judged[0].verdict, Verdict::NoTrigger);
}
#[test]
fn every_invalidation_kind_in_the_spec_round_trips() {
for raw in [
"file:src/main.rs@9f2c1a4e",
"image:4f2a000000000000000000000000000000000000",
"base:2026.08",
"symbol:GUEST_HOME",
] {
let parsed = Trigger::parse(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
assert_eq!(parsed.render(), raw);
}
}
#[test]
fn an_unknown_invalidation_kind_is_refused_rather_than_stored() {
for raw in ["vibes:soon", "2026-08-07", "file", "", "when:tuesday"] {
assert!(Trigger::parse(raw).is_err(), "{raw:?} is not a trigger");
}
let err = Trigger::parse("vibes:soon").unwrap_err().to_string();
assert!(err.contains("vibes"), "name what was not understood: {err}");
for kind in ["file", "image", "base", "symbol"] {
assert!(err.contains(kind), "list what is accepted: {err}");
}
}
#[test]
fn a_file_path_containing_an_at_sign_still_parses() {
let t = Trigger::parse("file:vendor/@scope/pkg/x.ts@abc1230").unwrap();
assert_eq!(
t,
Trigger::File {
path: "vendor/@scope/pkg/x.ts".into(),
hash: "abc1230".into()
}
);
}
#[test]
fn a_file_trigger_without_a_hash_is_refused() {
assert!(Trigger::parse("file:src/main.rs").is_err());
assert!(Trigger::parse("file:src/main.rs@").is_err());
assert!(Trigger::parse("file:@abc").is_err());
}
#[test]
fn a_path_recorded_inside_the_sandbox_is_stored_repo_relative() {
let repo = std::path::Path::new("/Users/x/proj");
for raw in ["/work/src/main.rs", "src/main.rs", "./src/main.rs"] {
assert_eq!(normalise_path(raw, repo), "src/main.rs", "from {raw:?}");
}
assert_eq!(
normalise_path("/Users/x/proj/src/main.rs", repo),
"src/main.rs",
"a host absolute path inside the repo is relative too"
);
}
#[test]
fn a_note_with_no_trigger_is_never_stale() {
assert_eq!(evaluate(None, &facts()), Verdict::NoTrigger);
}
#[test]
fn a_note_is_stale_when_the_file_it_pinned_changed() {
let mut f = facts();
f.files
.insert("src/main.rs".into(), FileFact::Hash("d1ff333".into()));
let t = Trigger::parse("file:src/main.rs@0a1b2c3").unwrap();
let v = evaluate(Some(&t), &f);
assert!(matches!(v, Verdict::Stale { .. }), "{v:?}");
let Verdict::Stale { because } = v else {
unreachable!()
};
assert!(
because.contains("0a1b2c3") && because.contains("d1ff333"),
"{because}"
);
}
#[test]
fn an_unchanged_file_is_not_stale() {
let mut f = facts();
f.files.insert(
"src/main.rs".into(),
FileFact::Hash("5a3e5a3f00000000000000000000000000000000".into()),
);
let t = Trigger::parse("file:src/main.rs@5a3e5a3").unwrap();
assert_eq!(evaluate(Some(&t), &f), Verdict::Fresh);
}
#[test]
fn a_note_is_stale_when_the_file_it_pinned_was_deleted() {
let mut f = facts();
f.files.insert("gone.rs".into(), FileFact::Absent);
let t = Trigger::parse("file:gone.rs@abc0001").unwrap();
assert!(matches!(evaluate(Some(&t), &f), Verdict::Stale { .. }));
}
#[test]
fn an_unreadable_file_is_unknown_rather_than_stale() {
let mut f = facts();
f.files.insert("locked.rs".into(), FileFact::Unreadable);
let t = Trigger::parse("file:locked.rs@abc0001").unwrap();
assert!(
matches!(evaluate(Some(&t), &f), Verdict::Unknown { .. }),
"a file omh could not read says nothing about the note"
);
}
#[test]
fn a_note_is_stale_when_the_image_recipe_changed() {
let mut f = facts();
f.images = Fact::Known(BTreeSet::from([
"abcdef0000000000000000000000000000000000".to_string()
]));
assert!(matches!(
evaluate(
Some(&Trigger::parse("image:01d0000000000000000000000000000000000000").unwrap()),
&f
),
Verdict::Stale { .. }
));
assert_eq!(
evaluate(
Some(&Trigger::parse("image:abcdef0000000000000000000000000000000000").unwrap()),
&f
),
Verdict::Fresh
);
}
#[test]
fn an_image_trigger_is_unknown_when_no_recipe_is_available() {
assert!(matches!(
evaluate(
Some(&Trigger::parse("image:0000000000000000000000000000000000000001").unwrap()),
&facts()
),
Verdict::Unknown { .. }
));
}
#[test]
fn a_note_is_stale_when_the_base_set_was_re_cut() {
let mut f = facts();
f.base = Fact::Known("2026.09".into());
assert!(matches!(
evaluate(Some(&Trigger::parse("base:2026.08").unwrap()), &f),
Verdict::Stale { .. }
));
assert_eq!(
evaluate(Some(&Trigger::parse("base:2026.09").unwrap()), &f),
Verdict::Fresh
);
}
#[test]
fn base_versions_are_compared_numerically_not_lexicographically() {
let mut f = facts();
f.base = Fact::Known("2027.10".into());
assert!(
matches!(
evaluate(Some(&Trigger::parse("base:2027.2").unwrap()), &f),
Verdict::Stale { .. }
),
"2027.10 is newer than 2027.2, which a string sort denies"
);
f.base = Fact::Known("2027.2".into());
assert_eq!(
evaluate(Some(&Trigger::parse("base:2027.10").unwrap()), &f),
Verdict::Fresh
);
}
#[test]
fn a_base_version_omh_cannot_parse_is_unknown_rather_than_stale() {
let mut f = facts();
f.base = Fact::Known("not-a-version".into());
assert!(matches!(
evaluate(Some(&Trigger::parse("base:2026.08").unwrap()), &f),
Verdict::Unknown { .. }
));
f.base = Fact::Unavailable("no base set installed".into());
assert!(matches!(
evaluate(Some(&Trigger::parse("base:2026.08").unwrap()), &f),
Verdict::Unknown { .. }
));
}
#[test]
fn a_symbol_trigger_is_unknown_when_no_graph_is_reachable() {
let v = evaluate(
Some(&Trigger::parse("symbol:GUEST_HOME").unwrap()),
&facts(),
);
assert!(matches!(v, Verdict::Unknown { .. }), "{v:?}");
let Verdict::Unknown { because } = v else {
unreachable!()
};
assert!(because.contains("graph"), "say why: {because}");
}
#[test]
fn a_symbol_trigger_fires_when_a_graph_says_the_symbol_is_gone() {
let mut f = facts();
f.symbols = Some(["STILL_HERE".to_string()].into_iter().collect());
assert!(matches!(
evaluate(Some(&Trigger::parse("symbol:GONE").unwrap()), &f),
Verdict::Stale { .. }
));
assert_eq!(
evaluate(Some(&Trigger::parse("symbol:STILL_HERE").unwrap()), &f),
Verdict::Fresh
);
}
#[test]
fn nothing_is_ever_fresh_because_omh_knows_nothing() {
for raw in [
"file:x.rs@abc0001",
"image:1000000000000000000000000000000000000000",
"base:2026.08",
"symbol:X",
] {
let t = Trigger::parse(raw).unwrap();
assert_ne!(
evaluate(Some(&t), &facts()),
Verdict::Fresh,
"{raw} reported fresh against facts omh does not have"
);
}
}
}