use crate::Paths;
use crate::error::Result;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PathEntry {
pub path: PathBuf,
pub category: String,
pub sha256: String,
pub size: u64,
}
fn target_roots(paths: &Paths) -> Vec<(&'static str, PathBuf)> {
let h = &paths.user_home;
vec![
("settings", h.join(".claude/settings.json")),
("settings_local", h.join(".claude/settings.local.json")),
("local_config", h.join(".claude.json")), ("claude_md", h.join(".claude/CLAUDE.md")), ("rules", h.join(".claude/rules")), ("skills", h.join(".claude/skills")),
("agents", h.join(".claude/agents")),
("plugins", h.join(".claude/plugins/marketplaces")),
("settings_project", PathBuf::from(".claude/settings.json")),
("mcp_project", PathBuf::from(".mcp.json")),
("manifest_npm", PathBuf::from("package.json")),
("manifest_cargo", PathBuf::from("Cargo.toml")),
("manifest_python", PathBuf::from("pyproject.toml")),
("lockfile_npm", PathBuf::from("package-lock.json")),
("lockfile_yarn", PathBuf::from("yarn.lock")),
("lockfile_cargo", PathBuf::from("Cargo.lock")),
("lockfile_poetry", PathBuf::from("poetry.lock")),
("lockfile_uv", PathBuf::from("uv.lock")),
("env_project", PathBuf::from(".env")),
]
}
pub fn collect(paths: &Paths) -> Result<Vec<PathEntry>> {
let mut out = Vec::new();
for (category, root) in target_roots(paths) {
if !root.exists() {
continue;
}
if root.is_file() {
out.extend(path_entries(&root, category)?);
} else if root.is_dir() {
walk(&root, category, &mut out)?;
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
const SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
".venv",
"venv",
"__pycache__",
".cache",
".idea",
".vscode",
"dist",
"build",
".next",
".turbo",
];
const SKIP_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
fn should_skip(name: &str, is_dir: bool) -> bool {
if is_dir {
SKIP_DIRS.contains(&name)
} else {
SKIP_FILES.contains(&name)
}
}
fn walk(dir: &Path, category: &str, out: &mut Vec<PathEntry>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if should_skip(&name_str, file_type.is_dir()) {
continue;
}
if file_type.is_dir() {
walk(&path, category, out)?;
} else if file_type.is_file() {
out.extend(path_entries(&path, category)?);
}
}
Ok(())
}
fn path_entries(path: &Path, category: &str) -> Result<Vec<PathEntry>> {
let metadata = fs::metadata(path)?;
if !metadata.is_file() {
return Ok(Vec::new());
}
let bytes = fs::read(path)?;
if category == "local_config" {
if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
return Ok(extract_local_config_entries(path, &json));
}
}
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
Ok(vec![PathEntry {
path: canonical_path,
category: category.to_string(),
sha256: sha256_hex(&bytes),
size: metadata.len(),
}])
}
const LOCAL_CONFIG_WATCH_KEYS: &[&str] = &["mcpServers", "hooks", "permissions"];
fn extract_local_config_entries(path: &Path, json: &serde_json::Value) -> Vec<PathEntry> {
let mut out = Vec::new();
for key in LOCAL_CONFIG_WATCH_KEYS {
if let Some(value) = json.get(key) {
out.push(virtual_entry(path, "local_config", key, value));
}
}
if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
for (proj_name, proj_val) in projects {
for key in LOCAL_CONFIG_WATCH_KEYS {
if let Some(value) = proj_val.get(key) {
let fragment = format!("projects.{proj_name}.{key}");
out.push(virtual_entry(path, "local_config", &fragment, value));
}
}
}
}
if out.is_empty() {
out.push(virtual_entry(
path,
"local_config",
"(no-watched-block)",
&serde_json::Value::Null,
));
}
out
}
fn virtual_entry(
path: &Path,
category: &str,
fragment: &str,
value: &serde_json::Value,
) -> PathEntry {
let canonical = serde_json::to_string(value).unwrap_or_default();
let size = canonical.len() as u64;
let sha256 = sha256_hex(canonical.as_bytes());
let virtual_path = PathBuf::from(format!("{}#{fragment}", path.display()));
PathEntry {
path: virtual_path,
category: category.to_string(),
sha256,
size,
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
format!("{:x}", h.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn path_entries_computes_sha256_for_regular_file() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(b"hello agentsec").unwrap();
let entries = path_entries(tmp.path(), "test").unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.size, 14);
assert_eq!(e.sha256.len(), 64);
assert_eq!(e.category, "test");
}
#[test]
fn walk_collects_files_recursively() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
fs::write(dir.path().join("a.txt"), "a").unwrap();
fs::write(sub.join("b.txt"), "bb").unwrap();
let mut out = Vec::new();
walk(dir.path(), "x", &mut out).unwrap();
assert_eq!(out.len(), 2);
}
#[test]
fn local_config_emits_virtual_entries_per_watch_block() {
let body = r#"{
"mcpServers": {"a": {"command": "x"}},
"permissions": {"allow": []},
"lastSessionId": "noise-should-be-ignored",
"counters": {"step": 42},
"projects": {
"/path/p": {
"hooks": {"UserPromptSubmit": []},
"mcpServers": {"b": {"command": "y"}}
}
}
}"#;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(body.as_bytes()).unwrap();
let entries = path_entries(tmp.path(), "local_config").unwrap();
let fragments: Vec<String> = entries
.iter()
.map(|e| {
e.path
.to_string_lossy()
.rsplit_once('#')
.map(|(_, frag)| frag.to_string())
.unwrap_or_default()
})
.collect();
assert!(fragments.contains(&"mcpServers".to_string()));
assert!(fragments.contains(&"permissions".to_string()));
assert!(fragments.iter().any(|f| f == "projects./path/p.hooks"));
assert!(fragments.iter().any(|f| f == "projects./path/p.mcpServers"));
assert!(!fragments.iter().any(|f| f == "lastSessionId"));
assert!(!fragments.iter().any(|f| f == "counters"));
}
#[test]
fn local_config_with_no_watched_block_emits_sentinel() {
let body = r#"{"unrelated": 1}"#;
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(body.as_bytes()).unwrap();
let entries = path_entries(tmp.path(), "local_config").unwrap();
assert_eq!(entries.len(), 1);
assert!(
entries[0]
.path
.to_string_lossy()
.ends_with("#(no-watched-block)")
);
}
#[test]
fn local_config_with_unparseable_json_falls_back_to_full_hash() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(b"not json at all").unwrap();
let entries = path_entries(tmp.path(), "local_config").unwrap();
assert_eq!(entries.len(), 1);
assert!(!entries[0].path.to_string_lossy().contains('#'));
}
}