use crate::profile::Paths;
use anyhow::{Context, Result};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Facts(BTreeMap<String, BTreeMap<String, bool>>);
impl Paths {
pub fn facts(&self) -> std::path::PathBuf {
self.root.join("facts.json")
}
}
impl Facts {
pub fn load(paths: &Paths) -> Self {
let path = paths.facts();
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
eprintln!("omh: could not read {} — {e}", path.display());
return Self::default();
}
};
match serde_json::from_str(&raw) {
Ok(facts) => facts,
Err(e) => {
eprintln!(
"omh: {} is not readable as measurements ({e}), so nothing is \
assumed about this image",
path.display()
);
Self::default()
}
}
}
pub fn about(&self, tag: &str) -> BTreeMap<String, bool> {
self.0.get(tag).cloned().unwrap_or_default()
}
pub fn unseen(&self, tag: &str, wanted: &BTreeSet<String>) -> Vec<String> {
let known = self.0.get(tag);
wanted
.iter()
.filter(|p| !known.is_some_and(|k| k.contains_key(*p)))
.cloned()
.collect()
}
pub fn learn(&mut self, tag: &str, outcomes: &[crate::doctor::Outcome]) {
let entry = self.0.entry(tag.to_string()).or_default();
for o in outcomes {
entry.insert(o.name.clone(), o.ok);
}
}
pub fn save(&self, paths: &Paths) -> Result<()> {
let path = paths.facts();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&path, serde_json::to_string_pretty(self)?)
.with_context(|| format!("writing {}", path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> (tempfile::TempDir, Paths) {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
(dir, paths)
}
fn resolves(facts: &Facts, tag: &str, program: &str) -> Option<bool> {
facts.about(tag).get(program).copied()
}
fn outcome(name: &str, ok: bool) -> crate::doctor::Outcome {
crate::doctor::Outcome {
name: name.to_string(),
ok,
detail: if ok {
"resolves".into()
} else {
"not installed in the sandbox".into()
},
}
}
#[test]
fn a_program_nobody_probed_is_unknown_rather_than_missing() {
let mut facts = Facts::default();
facts.learn("omh/claude:abc", &[outcome("cargo", true)]);
assert_eq!(resolves(&facts, "omh/claude:abc", "cargo"), Some(true));
assert_eq!(
resolves(&facts, "omh/claude:abc", "shellcheck"),
None,
"a program in no probe is unknown, and unknown suppresses nothing"
);
assert_eq!(
resolves(&Facts::default(), "omh/claude:abc", "cargo"),
None,
"and an empty cache knows nothing about anything"
);
}
#[test]
fn what_is_known_about_one_image_is_not_known_about_another() {
let mut facts = Facts::default();
facts.learn("omh/claude:with-rust", &[outcome("cargo", true)]);
assert_eq!(resolves(&facts, "omh/claude:plain", "cargo"), None);
assert_eq!(
facts.about("omh/claude:plain"),
BTreeMap::new(),
"a tag nobody probed knows nothing"
);
assert_eq!(
facts.about("omh/claude:with-rust"),
BTreeMap::from([("cargo".to_string(), true)])
);
}
#[test]
fn a_tag_with_a_slash_and_a_colon_survives_the_round_trip() {
let (_d, paths) = fixture();
let tag = "omh/claude-code:9f2ab1c0";
let mut facts = Facts::default();
facts.learn(tag, &[outcome("cargo", true), outcome("cc", false)]);
facts.save(&paths).unwrap();
let read = Facts::load(&paths);
assert_eq!(read, facts, "what was written is what comes back");
assert_eq!(resolves(&read, tag, "cargo"), Some(true));
assert_eq!(resolves(&read, tag, "cc"), Some(false));
}
#[test]
fn only_programs_nobody_has_asked_about_are_probed_again() {
let mut facts = Facts::default();
facts.learn(
"omh/claude:abc",
&[outcome("cargo", true), outcome("cc", false)],
);
let wanted = BTreeSet::from([
"cargo".to_string(),
"cc".to_string(),
"shellcheck".to_string(),
]);
assert_eq!(
facts.unseen("omh/claude:abc", &wanted),
vec!["shellcheck".to_string()],
"a recorded `false` is an answer, not a reason to ask again"
);
assert_eq!(
facts.unseen("omh/claude:other", &wanted),
vec![
"cargo".to_string(),
"cc".to_string(),
"shellcheck".to_string()
],
"and an image nobody has probed owes every question"
);
}
#[test]
fn a_cache_omh_cannot_read_is_not_a_sandbox_with_nothing_in_it() {
let (_d, paths) = fixture();
std::fs::create_dir_all(&paths.root).unwrap();
std::fs::write(paths.facts(), "{ this is not json").unwrap();
let facts = Facts::load(&paths);
assert_eq!(
resolves(&facts, "omh/claude:abc", "cargo"),
None,
"unreadable is cannot-tell, and cannot-tell suppresses nothing"
);
assert_eq!(facts, Facts::default());
}
#[test]
fn a_new_measurement_replaces_the_one_before_it() {
let mut facts = Facts::default();
facts.learn("omh/claude:abc", &[outcome("cargo", false)]);
facts.learn("omh/claude:abc", &[outcome("cargo", true)]);
assert_eq!(resolves(&facts, "omh/claude:abc", "cargo"), Some(true));
}
}