use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::Serialize;
use sha2::{Digest, Sha256};
use tokio::sync::mpsc;
use super::cache::ContentHashCache;
use super::monitor::ConfigChangeRequest;
const PROJECT_ROOT_CACHE_CAP: usize = 32;
type ProjectRootCache = Mutex<VecDeque<(String, Option<PathBuf>)>>;
static PROJECT_ROOT_CACHE: std::sync::LazyLock<ProjectRootCache> =
std::sync::LazyLock::new(|| Mutex::new(VecDeque::with_capacity(PROJECT_ROOT_CACHE_CAP)));
#[derive(Debug, Clone, Serialize)]
pub struct DeclaredSource {
pub source_id: String,
pub content_hash: String,
pub path_hash: String,
}
#[derive(thiserror::Error, Debug)]
pub enum EnrichError {
#[error("cwd missing from session_start payload")]
CwdMissing,
}
pub async fn enrich_session_start(
data: &mut Option<serde_json::Value>,
cache: &ContentHashCache,
monitor_tx: Option<&mpsc::Sender<ConfigChangeRequest>>,
) -> Result<(), EnrichError> {
let cwd = data
.as_ref()
.and_then(|d| d.get("cwd"))
.and_then(|v| v.as_str())
.ok_or(EnrichError::CwdMissing)?
.to_string();
let _ = try_register_project_from_cwd(&cwd, monitor_tx).await;
let mut mcp_sources: Vec<DeclaredSource> = Vec::new();
let mut skill_sources: Vec<DeclaredSource> = Vec::new();
let mut rules_pairs: Vec<([u8; 32], [u8; 32])> = Vec::new();
for entry in cache.snapshot() {
let path_hash_hex = hex::encode(entry.path_hash);
let source_id = format!("{}:{}:{}", entry.agent, entry.kind, path_hash_hex);
let declared = DeclaredSource {
source_id,
content_hash: hex::encode(entry.content_hash),
path_hash: path_hash_hex,
};
match entry.kind.as_str() {
"mcp" => mcp_sources.push(declared),
"skill" => skill_sources.push(declared),
"rules" => rules_pairs.push((entry.path_hash, entry.content_hash)),
_ => {}
}
}
rules_pairs.sort_by_key(|a| a.0);
let mut combined = Sha256::new();
for (ph, ch) in &rules_pairs {
combined.update(ph);
combined.update(ch);
}
let rules_hash_hex = hex::encode(combined.finalize());
let cwd_hash_hex = hex::encode(Sha256::digest(cwd.as_bytes()));
if let Some(obj) = data.as_mut().and_then(|v| v.as_object_mut()) {
obj.insert(
"openlatch.declared_mcp_sources".into(),
serde_json::to_value(&mcp_sources).unwrap_or_default(),
);
obj.insert(
"openlatch.declared_skills".into(),
serde_json::to_value(&skill_sources).unwrap_or_default(),
);
obj.insert(
"openlatch.declared_rules_hash".into(),
serde_json::Value::String(rules_hash_hex),
);
obj.insert(
"openlatch.cwd".into(),
serde_json::Value::String(cwd_hash_hex),
);
}
let bucket = match mcp_sources.len() + skill_sources.len() + rules_pairs.len() {
0..=5 => "1-5",
6..=20 => "6-20",
21..=50 => "21-50",
_ => "50+",
};
crate::telemetry::capture_global(crate::telemetry::Event::config_session_start_enriched(
"claude-code",
bucket,
));
Ok(())
}
pub async fn try_register_project_from_cwd(
cwd: &str,
monitor_tx: Option<&mpsc::Sender<ConfigChangeRequest>>,
) -> Option<PathBuf> {
let project_root = cached_project_root(cwd);
if let (Some(root), Some(tx)) = (&project_root, monitor_tx) {
let _ = tx
.send(ConfigChangeRequest::ProjectScopeRegister {
project_root: root.clone(),
})
.await;
}
project_root
}
fn cached_project_root(cwd: &str) -> Option<PathBuf> {
if let Ok(cache) = PROJECT_ROOT_CACHE.lock() {
if let Some((_, hit)) = cache.iter().find(|(k, _)| k == cwd) {
return hit.clone();
}
drop(cache);
}
let resolved = discover_project_root(Path::new(cwd));
if let Ok(mut cache) = PROJECT_ROOT_CACHE.lock() {
if !cache.iter().any(|(k, _)| k == cwd) {
if cache.len() >= PROJECT_ROOT_CACHE_CAP {
cache.pop_front();
}
cache.push_back((cwd.to_string(), resolved.clone()));
}
}
resolved
}
pub fn discover_project_root(cwd: &Path) -> Option<PathBuf> {
const MARKERS: &[&str] = &[".git", ".hg", ".svn", ".openlatch", ".mcp.json"];
let mut current = cwd.to_path_buf();
for _ in 0..5 {
for marker in MARKERS {
if current.join(marker).exists() {
return Some(current);
}
}
if !current.pop() {
break;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::daemon::config_monitor::cache::CacheEntry;
use std::time::Instant;
fn cache_entry(path: &str, kind: &str, content: u8) -> CacheEntry {
CacheEntry {
path: PathBuf::from(path),
subpath: None,
path_hash: Sha256::digest(path.as_bytes()).into(),
content_hash: [content; 32],
last_observed: Instant::now(),
kind: kind.into(),
agent: "claude-code".into(),
}
}
#[tokio::test]
async fn enrich_populates_declared_sources_from_cache() {
let cache = ContentHashCache::new(64);
cache.insert(cache_entry("/etc/claude/.mcp.json", "mcp", 1));
cache.insert(cache_entry("/etc/claude/skills/a.md", "skill", 2));
cache.insert(cache_entry("/etc/claude/CLAUDE.md", "rules", 3));
let mut data = Some(serde_json::json!({"cwd": "/tmp/test"}));
enrich_session_start(&mut data, &cache, None).await.unwrap();
let data = data.unwrap();
let mcp = data.get("openlatch.declared_mcp_sources").unwrap();
assert_eq!(mcp.as_array().unwrap().len(), 1);
assert_eq!(
mcp[0]["content_hash"].as_str().unwrap(),
hex::encode([1u8; 32])
);
let skills = data.get("openlatch.declared_skills").unwrap();
assert_eq!(skills.as_array().unwrap().len(), 1);
let rules_hash = data.get("openlatch.declared_rules_hash").unwrap();
assert!(rules_hash.is_string());
assert!(data.get("openlatch.cwd").unwrap().is_string());
}
#[tokio::test]
async fn enrich_returns_err_when_cwd_missing() {
let cache = ContentHashCache::new(64);
let mut data = Some(serde_json::json!({}));
let err = enrich_session_start(&mut data, &cache, None)
.await
.unwrap_err();
assert!(matches!(err, EnrichError::CwdMissing));
}
#[test]
fn discover_project_root_walks_up_to_git() {
let tmp = tempfile::tempdir().unwrap();
let project = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(project.join(".git")).unwrap();
let cwd = project.join("d").join("e");
std::fs::create_dir_all(&cwd).unwrap();
let root = discover_project_root(&cwd).unwrap();
assert_eq!(root, project);
}
#[test]
fn discover_project_root_returns_none_outside_project() {
let synthetic = PathBuf::from("/__nonexistent_openlatch_test__/a/b/c/d");
assert!(discover_project_root(&synthetic).is_none());
}
#[test]
fn rules_hash_is_order_independent() {
let cache_a = ContentHashCache::new(64);
cache_a.insert(cache_entry("/a/CLAUDE.md", "rules", 1));
cache_a.insert(cache_entry("/b/CLAUDE.md", "rules", 2));
let cache_b = ContentHashCache::new(64);
cache_b.insert(cache_entry("/b/CLAUDE.md", "rules", 2));
cache_b.insert(cache_entry("/a/CLAUDE.md", "rules", 1));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let hash_a = runtime.block_on(async {
let mut data = Some(serde_json::json!({"cwd": "/x"}));
enrich_session_start(&mut data, &cache_a, None)
.await
.unwrap();
data.unwrap()["openlatch.declared_rules_hash"]
.as_str()
.unwrap()
.to_string()
});
let hash_b = runtime.block_on(async {
let mut data = Some(serde_json::json!({"cwd": "/x"}));
enrich_session_start(&mut data, &cache_b, None)
.await
.unwrap();
data.unwrap()["openlatch.declared_rules_hash"]
.as_str()
.unwrap()
.to_string()
});
assert_eq!(hash_a, hash_b);
}
}