use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
#[serde(default, rename = "agent")]
pub agents: Vec<AgentManifest>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentManifest {
pub name: String,
#[serde(default, rename = "path")]
pub paths: Vec<AgentPath>,
#[serde(default)]
pub exclude: Option<ExcludeBlock>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigScope {
Enterprise,
Personal,
Project,
Local,
}
impl ConfigScope {
pub fn as_str(self) -> &'static str {
match self {
ConfigScope::Enterprise => "enterprise",
ConfigScope::Personal => "personal",
ConfigScope::Project => "project",
ConfigScope::Local => "local",
}
}
pub fn is_project_rooted(self) -> bool {
matches!(self, ConfigScope::Project | ConfigScope::Local)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentPath {
pub kind: String,
#[serde(default)]
pub scope: Option<ConfigScope>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub paths_relative: Vec<String>,
#[serde(default)]
pub paths_glob: Option<String>,
#[serde(default)]
pub paths_glob_relative: Vec<String>,
#[serde(default)]
pub json_slice_paths: Vec<JsonSlicePath>,
pub watch_strategy: WatchStrategy,
#[serde(default)]
pub slice_kind_subpath: bool,
}
impl AgentPath {
pub fn is_project_scoped(&self) -> bool {
self.scope.is_some_and(|s| s.is_project_rooted())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JsonSlicePath {
pub path: String,
pub json_pointer: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ExcludeBlock {
#[serde(default)]
pub patterns: Vec<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WatchStrategy {
ExactFile,
ExactFileWithSlice,
Glob,
ExactFileAndGlob,
}
#[derive(thiserror::Error, Debug)]
pub enum ManifestError {
#[error("manifest read failed at {0}: {1}")]
ReadFailed(String, std::io::Error),
#[error("manifest parse failed at {0}: {1}")]
ParseFailed(String, String),
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum PathExpansionError {
#[error("home directory not found")]
HomeNotFound,
#[error("app-data directory not found")]
AppDataNotFound,
#[error("project-scope path used without a project root")]
ProjectScopeWithoutRoot,
#[error("unknown variable: {0}")]
UnknownVariable(String),
#[error("env var missing: {0}")]
EnvVarMissing(String),
#[error("unterminated variable expansion in: {0}")]
UnterminatedVariable(String),
}
const EMBEDDED_MANIFEST: &str = include_str!("../../../manifests/agent_configs.toml");
static EMBEDDED_PARSED: std::sync::LazyLock<Result<Manifest, String>> =
std::sync::LazyLock::new(|| {
toml::from_str::<Manifest>(EMBEDDED_MANIFEST).map_err(|e| e.to_string())
});
pub fn load_embedded() -> Result<Manifest, ManifestError> {
#[cfg(any(test, feature = "insecure-test-keys"))]
if let Ok(path) = std::env::var("OPENLATCH_INVENTORY_MANIFEST_PATH") {
if !path.is_empty() {
let raw = std::fs::read_to_string(&path)
.map_err(|e| ManifestError::ReadFailed(path.clone(), e))?;
return parse_str(&raw, &path);
}
}
EMBEDDED_PARSED
.clone()
.map_err(|msg| ManifestError::ParseFailed("embedded".to_string(), msg))
}
#[cfg(any(test, feature = "insecure-test-keys"))]
fn parse_str(raw: &str, source: &str) -> Result<Manifest, ManifestError> {
toml::from_str::<Manifest>(raw)
.map_err(|e| ManifestError::ParseFailed(source.to_string(), e.to_string()))
}
pub fn expand_path(
input: &str,
project_root: Option<&Path>,
) -> Result<PathBuf, PathExpansionError> {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next();
let mut var = String::new();
let mut closed = false;
for c in chars.by_ref() {
if c == '}' {
closed = true;
break;
}
var.push(c);
}
if !closed {
return Err(PathExpansionError::UnterminatedVariable(input.to_string()));
}
out.push_str(&resolve_var(&var, project_root)?);
} else {
out.push(c);
}
}
Ok(PathBuf::from(out))
}
fn resolve_var(name: &str, project_root: Option<&Path>) -> Result<String, PathExpansionError> {
match name {
"CLAUDE_CONFIG_DIR" => crate::hooks::claude_code::config_dir()
.map(|p| p.display().to_string())
.ok_or(PathExpansionError::HomeNotFound),
"CLAUDE_STATE_DIR" => crate::hooks::claude_code::state_dir()
.map(|p| p.display().to_string())
.ok_or(PathExpansionError::HomeNotFound),
"HOME" => dirs::home_dir()
.map(|p| p.display().to_string())
.ok_or(PathExpansionError::HomeNotFound),
"APPDATA" => dirs::data_dir()
.map(|p| p.display().to_string())
.ok_or(PathExpansionError::AppDataNotFound),
"VSCODE_USER" => dirs::config_dir()
.map(|p| p.join("Code").join("User").display().to_string())
.ok_or(PathExpansionError::AppDataNotFound),
"OPENLATCH_DIR" => Ok(crate::config::openlatch_dir().display().to_string()),
"SYSTEM_CLAUDE_DIR" => Ok(system_claude_dir()),
"PROJECT" => project_root
.map(|p| p.display().to_string())
.ok_or(PathExpansionError::ProjectScopeWithoutRoot),
other if other.starts_with("env:") => {
let env_name = &other[4..];
std::env::var(env_name).map_err(|_| PathExpansionError::EnvVarMissing(env_name.into()))
}
other => Err(PathExpansionError::UnknownVariable(other.into())),
}
}
fn system_claude_dir() -> String {
#[cfg(target_os = "linux")]
{
return "/etc/claude-code".to_string();
}
#[cfg(target_os = "macos")]
{
return "/Library/Application Support/Claude".to_string();
}
#[cfg(target_os = "windows")]
{
let base = std::env::var("ProgramData").unwrap_or_else(|_| "C:\\ProgramData".to_string());
return format!("{base}\\Claude");
}
#[allow(unreachable_code)]
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_manifest_parses() {
let m = load_embedded().expect("embedded manifest must parse");
assert!(!m.agents.is_empty(), "embedded manifest declares agents");
let claude = m
.agents
.iter()
.find(|a| a.name == "claude-code")
.expect("claude-code agent present");
assert!(
claude.paths.iter().any(|p| p.kind == "mcp"),
"claude-code includes an mcp path entry"
);
}
fn claude_code_absolute_templates() -> Vec<String> {
let m = load_embedded().expect("embedded manifest must parse");
let agent = m
.agents
.iter()
.find(|a| a.name == "claude-code")
.expect("claude-code agent present");
let mut out = Vec::new();
for ap in &agent.paths {
out.extend(ap.paths.iter().cloned());
out.extend(ap.paths_glob.iter().cloned());
out.extend(ap.json_slice_paths.iter().map(|s| s.path.clone()));
}
out.extend(
agent
.exclude
.iter()
.flat_map(|e| e.patterns.iter().cloned()),
);
out
}
#[test]
fn embedded_manifest_follows_a_relocated_config_dir() {
let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
let env = crate::daemon::identity::test_support::EnvGuard::clear();
let relocated = tempfile::tempdir().expect("temp dir");
env.set("CLAUDE_CONFIG_DIR", relocated.path());
let home = dirs::home_dir().expect("home dir");
let default_config_dir = home.join(".claude");
let default_state_file = home.join(".claude.json");
let templates = claude_code_absolute_templates();
assert!(
!templates.is_empty(),
"the sweep must actually see the manifest's templates"
);
let mut under_relocated = 0usize;
for template in &templates {
let expanded = expand_path(template, None)
.unwrap_or_else(|e| panic!("{template} must expand: {e}"));
assert!(
!expanded.starts_with(&default_config_dir),
"{template} resolved to {} — inside the default config directory \
while $CLAUDE_CONFIG_DIR points at {}",
expanded.display(),
relocated.path().display()
);
assert_ne!(
expanded,
default_state_file,
"{template} resolved to the default state file while \
$CLAUDE_CONFIG_DIR points at {}",
relocated.path().display()
);
if expanded.starts_with(relocated.path()) {
under_relocated += 1;
}
}
assert_eq!(
under_relocated,
templates.len() - 1,
"exactly one template (the SYSTEM_CLAUDE_DIR one) may sit outside \
the relocated directory"
);
}
#[test]
fn claude_dir_variables_track_the_env_var() {
let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
let env = crate::daemon::identity::test_support::EnvGuard::clear();
let home = dirs::home_dir().expect("home dir");
assert_eq!(
expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).unwrap(),
home.join(".claude").join("settings.json")
);
assert_eq!(
expand_path("${CLAUDE_STATE_DIR}/.claude.json", None).unwrap(),
home.join(".claude.json")
);
let relocated = tempfile::tempdir().expect("temp dir");
env.set("CLAUDE_CONFIG_DIR", relocated.path());
assert_eq!(
expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).unwrap(),
relocated.path().join("settings.json")
);
assert_eq!(
expand_path("${CLAUDE_STATE_DIR}/.claude.json", None).unwrap(),
relocated.path().join(".claude.json")
);
env.set("CLAUDE_CONFIG_DIR", "");
assert_eq!(
expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).unwrap(),
home.join(".claude").join("settings.json")
);
}
#[test]
fn claude_config_dir_resolver_is_not_the_env_form() {
let _lock = crate::daemon::identity::ENV_LOCK.blocking_lock();
let _env = crate::daemon::identity::test_support::EnvGuard::clear();
assert!(expand_path("${CLAUDE_CONFIG_DIR}/settings.json", None).is_ok());
assert_eq!(
expand_path("${env:CLAUDE_CONFIG_DIR}/settings.json", None).unwrap_err(),
PathExpansionError::EnvVarMissing("CLAUDE_CONFIG_DIR".into())
);
}
#[test]
fn expand_path_home() {
let p = expand_path("${HOME}/.claude/.mcp.json", None).unwrap();
let home = dirs::home_dir().unwrap();
assert_eq!(p, home.join(".claude").join(".mcp.json"));
}
#[test]
fn expand_path_no_vars_returns_input() {
let p = expand_path("/etc/openlatch/config.toml", None).unwrap();
assert_eq!(p, PathBuf::from("/etc/openlatch/config.toml"));
}
#[test]
fn expand_path_project_without_root_errors() {
let err = expand_path("${PROJECT}/.claude/CLAUDE.md", None).unwrap_err();
assert_eq!(err, PathExpansionError::ProjectScopeWithoutRoot);
}
#[test]
fn expand_path_project_with_root() {
let root = PathBuf::from("/tmp/myproject");
let p = expand_path("${PROJECT}/.claude/CLAUDE.md", Some(&root)).unwrap();
assert_eq!(p, root.join(".claude").join("CLAUDE.md"));
}
#[test]
fn expand_path_env_var_present() {
let key = "OPENLATCH_TEST_EXPAND_PATH_PRESENT";
std::env::set_var(key, "/x/y");
let p = expand_path(&format!("${{env:{key}}}/file"), None).unwrap();
std::env::remove_var(key);
assert_eq!(p, PathBuf::from("/x/y/file"));
}
#[test]
fn expand_path_env_var_missing() {
let key = "OPENLATCH_TEST_EXPAND_PATH_MISSING";
std::env::remove_var(key);
let err = expand_path(&format!("${{env:{key}}}/file"), None).unwrap_err();
assert_eq!(err, PathExpansionError::EnvVarMissing(key.into()));
}
#[test]
fn expand_path_unknown_variable() {
let err = expand_path("${NOPE}/file", None).unwrap_err();
assert_eq!(err, PathExpansionError::UnknownVariable("NOPE".into()));
}
#[test]
fn expand_path_unterminated_variable() {
let err = expand_path("${HOME/file", None).unwrap_err();
assert!(matches!(err, PathExpansionError::UnterminatedVariable(_)));
}
#[test]
fn expand_path_openlatch_dir() {
let p = expand_path("${OPENLATCH_DIR}/state.json", None).unwrap();
let expected = crate::config::openlatch_dir().join("state.json");
assert_eq!(p, expected);
}
#[test]
fn parse_str_invalid_toml_reports_source() {
let err = parse_str("not toml = =", "fixture").unwrap_err();
match err {
ManifestError::ParseFailed(source, _) => assert_eq!(source, "fixture"),
_ => panic!("expected ParseFailed"),
}
}
#[test]
fn watch_strategy_round_trip() {
let raw = r#"
[[agent]]
name = "demo"
[[agent.path]]
kind = "mcp"
paths = ["/tmp/x.json"]
watch_strategy = "exact_file"
"#;
let m: Manifest = toml::from_str(raw).unwrap();
assert_eq!(
m.agents[0].paths[0].watch_strategy,
WatchStrategy::ExactFile
);
}
#[test]
fn config_scope_round_trip() {
for (variant, wire) in [
(ConfigScope::Enterprise, "enterprise"),
(ConfigScope::Personal, "personal"),
(ConfigScope::Project, "project"),
(ConfigScope::Local, "local"),
] {
let s = serde_json::to_string(&variant).unwrap();
assert_eq!(s, format!("\"{wire}\""));
let parsed: ConfigScope = serde_json::from_str(&s).unwrap();
assert_eq!(parsed, variant);
assert_eq!(variant.as_str(), wire);
}
}
#[test]
fn config_scope_parsed_from_toml() {
let raw = r#"
[[agent]]
name = "demo"
[[agent.path]]
kind = "mcp"
scope = "personal"
paths = ["/tmp/x.json"]
watch_strategy = "exact_file"
"#;
let m: Manifest = toml::from_str(raw).unwrap();
assert_eq!(m.agents[0].paths[0].scope, Some(ConfigScope::Personal));
}
#[test]
fn config_scope_rejects_unknown_variant() {
let raw = r#"
[[agent]]
name = "demo"
[[agent.path]]
kind = "mcp"
scope = "bogus"
paths = ["/tmp/x.json"]
watch_strategy = "exact_file"
"#;
let err = parse_str(raw, "fixture").unwrap_err();
assert!(matches!(err, ManifestError::ParseFailed(_, _)));
}
#[test]
fn expand_path_system_claude_dir() {
let p = expand_path("${SYSTEM_CLAUDE_DIR}/mcp.json", None).unwrap();
assert_eq!(p.file_name().and_then(|s| s.to_str()), Some("mcp.json"));
let parent = p
.parent()
.expect("system claude dir resolves to a non-empty path");
#[cfg(target_os = "linux")]
assert_eq!(parent, std::path::Path::new("/etc/claude-code"));
#[cfg(target_os = "macos")]
assert_eq!(
parent,
std::path::Path::new("/Library/Application Support/Claude")
);
#[cfg(target_os = "windows")]
{
assert_eq!(
parent.file_name().and_then(|s| s.to_str()),
Some("Claude"),
"{}",
parent.display()
);
}
}
}