use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuthSource {
Inherited,
Explicit,
#[default]
None,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStatus {
pub source: AuthSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
pub const MANIFEST_FILE: &str = ".llmenv-manifest.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheManifest {
pub content_hash: String,
pub owned: BTreeSet<String>,
#[serde(default)]
pub active_tags: BTreeSet<String>,
#[serde(default)]
pub enabled_bundles: BTreeSet<String>,
#[serde(default)]
pub auth_status: AuthStatus,
}
impl CacheManifest {
#[must_use]
pub fn new(content_hash: impl Into<String>, owned: impl IntoIterator<Item = PathBuf>) -> Self {
let owned = owned
.into_iter()
.map(|p| normalize_rel(&p))
.filter(|p| p != MANIFEST_FILE && !p.is_empty())
.filter(|p| !crate::paths::is_unsafe_join_target(p))
.collect();
Self {
content_hash: content_hash.into(),
owned,
active_tags: BTreeSet::new(),
enabled_bundles: BTreeSet::new(),
auth_status: AuthStatus::default(),
}
}
#[must_use]
pub fn with_auth_status(mut self, auth_status: AuthStatus) -> Self {
self.auth_status = auth_status;
self
}
#[must_use]
pub fn with_selection(
mut self,
active_tags: BTreeSet<String>,
enabled_bundles: BTreeSet<String>,
) -> Self {
self.active_tags = active_tags;
self.enabled_bundles = enabled_bundles;
self
}
pub fn read(folder: &Path) -> anyhow::Result<Option<Self>> {
let path = folder.join(MANIFEST_FILE);
match std::fs::read(&path) {
Ok(bytes) => match serde_json::from_slice(&bytes) {
Ok(manifest) => Ok(Some(manifest)),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"ignoring corrupt cache manifest; treating folder as unowned"
);
Ok(None)
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(anyhow::anyhow!(
"reading cache manifest {}: {e}",
path.display()
)),
}
}
pub fn write(&self, folder: &Path) -> anyhow::Result<()> {
let path = folder.join(MANIFEST_FILE);
let json = serde_json::to_string_pretty(self)?;
crate::paths::write_owner_only_atomic(&path, json.as_bytes())
.map_err(|e| anyhow::anyhow!("writing cache manifest {}: {e}", path.display()))?;
Ok(())
}
#[must_use]
pub fn stale_against(&self, current: &CacheManifest) -> Vec<String> {
self.owned.difference(¤t.owned).cloned().collect()
}
}
fn normalize_rel(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn new_drops_the_dotfile_and_empty_paths() {
let m = CacheManifest::new(
"abc",
vec![
PathBuf::from("CLAUDE.md"),
PathBuf::from(MANIFEST_FILE),
PathBuf::new(),
],
);
assert_eq!(m.content_hash, "abc");
assert_eq!(
m.owned,
BTreeSet::from(["CLAUDE.md".to_string()]),
"the manifest never records itself or empty paths"
);
}
#[test]
fn new_drops_traversal_and_absolute_paths() {
let m = CacheManifest::new(
"abc",
vec![
PathBuf::from("CLAUDE.md"),
PathBuf::from("../../../etc/passwd"),
PathBuf::from("/etc/shadow"),
PathBuf::from("rules/../../escape.md"),
],
);
assert_eq!(
m.owned,
BTreeSet::from(["CLAUDE.md".to_string()]),
"only the safe relative path is recorded"
);
}
#[test]
fn new_has_empty_selection_by_default() {
let m = CacheManifest::new("abc", vec![PathBuf::from("CLAUDE.md")]);
assert!(m.active_tags.is_empty());
assert!(m.enabled_bundles.is_empty());
}
#[test]
fn with_selection_records_plaintext_and_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let m = CacheManifest::new("deadbeef", vec![PathBuf::from("CLAUDE.md")]).with_selection(
BTreeSet::from(["rust".to_string(), "backend".to_string()]),
BTreeSet::from(["core".to_string()]),
);
m.write(tmp.path()).unwrap();
let back = CacheManifest::read(tmp.path()).unwrap().unwrap();
assert_eq!(back, m);
assert_eq!(
back.active_tags,
BTreeSet::from(["backend".to_string(), "rust".to_string()])
);
assert_eq!(back.enabled_bundles, BTreeSet::from(["core".to_string()]));
}
#[test]
fn manifest_without_selection_keys_deserializes() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(MANIFEST_FILE),
br#"{"content_hash":"abc","owned":["CLAUDE.md"]}"#,
)
.unwrap();
let back = CacheManifest::read(tmp.path()).unwrap().unwrap();
assert_eq!(back.content_hash, "abc");
assert!(back.active_tags.is_empty());
assert!(back.enabled_bundles.is_empty());
}
#[test]
fn manifest_without_auth_status_deserializes() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(MANIFEST_FILE),
br#"{"content_hash":"abc","owned":["CLAUDE.md"]}"#,
)
.unwrap();
let back = CacheManifest::read(tmp.path()).unwrap().unwrap();
assert_eq!(back.auth_status.source, AuthSource::None);
assert!(back.auth_status.id.is_none());
assert!(back.auth_status.email.is_none());
}
#[test]
fn with_auth_status_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let status = AuthStatus {
source: AuthSource::Inherited,
id: Some("some-uuid-1234".to_string()),
email: Some("user@example.com".to_string()),
};
let m = CacheManifest::new("deadbeef", vec![PathBuf::from("CLAUDE.md")])
.with_auth_status(status.clone());
m.write(tmp.path()).unwrap();
let back = CacheManifest::read(tmp.path()).unwrap().unwrap();
assert_eq!(back.auth_status, status);
}
#[test]
fn read_absent_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(CacheManifest::read(tmp.path()).unwrap(), None);
}
#[test]
fn read_corrupt_is_none_not_error() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(MANIFEST_FILE), b"{ not json").unwrap();
assert_eq!(CacheManifest::read(tmp.path()).unwrap(), None);
}
#[test]
fn write_then_read_roundtrips() {
let tmp = tempfile::tempdir().unwrap();
let m = CacheManifest::new(
"deadbeef",
vec![PathBuf::from("settings.json"), PathBuf::from("rules/a.md")],
);
m.write(tmp.path()).unwrap();
let back = CacheManifest::read(tmp.path()).unwrap().unwrap();
assert_eq!(back, m);
}
#[test]
fn stale_against_is_previous_minus_current() {
let prev = CacheManifest::new(
"h1",
vec![
PathBuf::from("CLAUDE.md"),
PathBuf::from("rules/old.md"),
PathBuf::from("settings.json"),
],
);
let cur = CacheManifest::new(
"h2",
vec![PathBuf::from("CLAUDE.md"), PathBuf::from("settings.json")],
);
let stale = prev.stale_against(&cur);
assert_eq!(stale, vec!["rules/old.md".to_string()]);
}
#[test]
fn stale_against_empty_when_current_superset() {
let prev = CacheManifest::new("h1", vec![PathBuf::from("CLAUDE.md")]);
let cur = CacheManifest::new(
"h2",
vec![PathBuf::from("CLAUDE.md"), PathBuf::from("new.md")],
);
assert!(prev.stale_against(&cur).is_empty());
}
mod properties {
use super::*;
use proptest::prelude::*;
fn arb_rel() -> impl Strategy<Value = String> {
"[a-z0-9_]{1,8}(/[a-z0-9_]{1,8}){0,2}"
}
fn arb_manifest() -> impl Strategy<Value = CacheManifest> {
(
"[a-f0-9]{0,64}",
prop::collection::vec(arb_rel().prop_map(PathBuf::from), 0..8),
)
.prop_map(|(hash, owned)| CacheManifest::new(hash, owned))
}
proptest! {
#[test]
fn serde_roundtrips(m in arb_manifest()) {
let json = serde_json::to_string(&m).unwrap();
let back: CacheManifest = serde_json::from_str(&json).unwrap();
prop_assert_eq!(back, m, "manifest must survive a JSON round-trip");
}
#[test]
fn stale_is_previous_minus_current(prev in arb_manifest(), cur in arb_manifest()) {
let stale: BTreeSet<String> = prev.stale_against(&cur).into_iter().collect();
prop_assert!(stale.is_subset(&prev.owned), "stale ⊆ previous.owned");
prop_assert!(
stale.is_disjoint(&cur.owned),
"stale never names a still-owned (current) path"
);
let expected: BTreeSet<String> =
prev.owned.difference(&cur.owned).cloned().collect();
prop_assert_eq!(stale, expected);
}
}
}
}