1use crate::Paths;
32use crate::error::Result;
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35use std::fs;
36use std::path::{Path, PathBuf};
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct PathEntry {
46 pub path: PathBuf,
49 pub category: String,
51 pub sha256: String,
53 pub size: u64,
55}
56
57fn target_roots(paths: &Paths) -> Vec<(&'static str, PathBuf)> {
62 let h = &paths.user_home;
63 vec![
64 ("settings", h.join(".claude/settings.json")),
66 ("settings_local", h.join(".claude/settings.local.json")),
67 ("local_config", h.join(".claude.json")), ("claude_md", h.join(".claude/CLAUDE.md")), ("rules", h.join(".claude/rules")), ("skills", h.join(".claude/skills")),
72 ("agents", h.join(".claude/agents")),
73 ("plugins", h.join(".claude/plugins/marketplaces")),
74 ("settings_project", PathBuf::from(".claude/settings.json")),
76 ("mcp_project", PathBuf::from(".mcp.json")),
77 ("manifest_npm", PathBuf::from("package.json")),
79 ("manifest_cargo", PathBuf::from("Cargo.toml")),
80 ("manifest_python", PathBuf::from("pyproject.toml")),
81 ("lockfile_npm", PathBuf::from("package-lock.json")),
82 ("lockfile_yarn", PathBuf::from("yarn.lock")),
83 ("lockfile_cargo", PathBuf::from("Cargo.lock")),
84 ("lockfile_poetry", PathBuf::from("poetry.lock")),
85 ("lockfile_uv", PathBuf::from("uv.lock")),
86 ("env_project", PathBuf::from(".env")),
88 ]
89}
90
91pub fn collect(paths: &Paths) -> Result<Vec<PathEntry>> {
105 let mut out = Vec::new();
106 for (category, root) in target_roots(paths) {
107 if !root.exists() {
108 continue;
109 }
110 if root.is_file() {
111 out.extend(path_entries(&root, category)?);
112 } else if root.is_dir() {
113 walk(&root, category, &mut out)?;
114 }
115 }
116 out.sort_by(|a, b| a.path.cmp(&b.path));
117 Ok(out)
118}
119
120const SKIP_DIRS: &[&str] = &[
123 ".git",
124 "node_modules",
125 "target",
126 ".venv",
127 "venv",
128 "__pycache__",
129 ".cache",
130 ".idea",
131 ".vscode",
132 "dist",
133 "build",
134 ".next",
135 ".turbo",
136];
137
138const SKIP_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
140
141fn should_skip(name: &str, is_dir: bool) -> bool {
142 if is_dir {
143 SKIP_DIRS.contains(&name)
144 } else {
145 SKIP_FILES.contains(&name)
146 }
147}
148
149fn walk(dir: &Path, category: &str, out: &mut Vec<PathEntry>) -> Result<()> {
150 for entry in fs::read_dir(dir)? {
151 let entry = entry?;
152 let path = entry.path();
153 let file_type = entry.file_type()?;
154 let name = entry.file_name();
155 let name_str = name.to_string_lossy();
156
157 if should_skip(&name_str, file_type.is_dir()) {
158 continue;
159 }
160
161 if file_type.is_dir() {
162 walk(&path, category, out)?;
163 } else if file_type.is_file() {
164 out.extend(path_entries(&path, category)?);
165 }
166 }
168 Ok(())
169}
170
171fn path_entries(path: &Path, category: &str) -> Result<Vec<PathEntry>> {
179 let metadata = fs::metadata(path)?;
180 if !metadata.is_file() {
181 return Ok(Vec::new());
182 }
183 let bytes = fs::read(path)?;
184
185 if category == "local_config" {
186 if let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) {
187 return Ok(extract_local_config_entries(path, &json));
188 }
189 }
192
193 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
198 Ok(vec![PathEntry {
199 path: canonical_path,
200 category: category.to_string(),
201 sha256: sha256_hex(&bytes),
202 size: metadata.len(),
203 }])
204}
205
206const LOCAL_CONFIG_WATCH_KEYS: &[&str] = &["mcpServers", "hooks", "permissions"];
209
210fn extract_local_config_entries(path: &Path, json: &serde_json::Value) -> Vec<PathEntry> {
211 let mut out = Vec::new();
212
213 for key in LOCAL_CONFIG_WATCH_KEYS {
215 if let Some(value) = json.get(key) {
216 out.push(virtual_entry(path, "local_config", key, value));
217 }
218 }
219
220 if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
222 for (proj_name, proj_val) in projects {
223 for key in LOCAL_CONFIG_WATCH_KEYS {
224 if let Some(value) = proj_val.get(key) {
225 let fragment = format!("projects.{proj_name}.{key}");
226 out.push(virtual_entry(path, "local_config", &fragment, value));
227 }
228 }
229 }
230 }
231
232 if out.is_empty() {
233 out.push(virtual_entry(
236 path,
237 "local_config",
238 "(no-watched-block)",
239 &serde_json::Value::Null,
240 ));
241 }
242 out
243}
244
245fn virtual_entry(
246 path: &Path,
247 category: &str,
248 fragment: &str,
249 value: &serde_json::Value,
250) -> PathEntry {
251 let canonical = serde_json::to_string(value).unwrap_or_default();
252 let size = canonical.len() as u64;
253 let sha256 = sha256_hex(canonical.as_bytes());
254 let virtual_path = PathBuf::from(format!("{}#{fragment}", path.display()));
255 PathEntry {
256 path: virtual_path,
257 category: category.to_string(),
258 sha256,
259 size,
260 }
261}
262
263fn sha256_hex(bytes: &[u8]) -> String {
264 let mut h = Sha256::new();
265 h.update(bytes);
266 format!("{:x}", h.finalize())
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use std::io::Write;
273
274 #[test]
275 fn path_entries_computes_sha256_for_regular_file() {
276 let mut tmp = tempfile::NamedTempFile::new().unwrap();
277 tmp.write_all(b"hello agentsec").unwrap();
278 let entries = path_entries(tmp.path(), "test").unwrap();
279 assert_eq!(entries.len(), 1);
280 let e = &entries[0];
281 assert_eq!(e.size, 14);
282 assert_eq!(e.sha256.len(), 64);
283 assert_eq!(e.category, "test");
284 }
285
286 #[test]
287 fn walk_collects_files_recursively() {
288 let dir = tempfile::tempdir().unwrap();
289 let sub = dir.path().join("sub");
290 fs::create_dir(&sub).unwrap();
291 fs::write(dir.path().join("a.txt"), "a").unwrap();
292 fs::write(sub.join("b.txt"), "bb").unwrap();
293 let mut out = Vec::new();
294 walk(dir.path(), "x", &mut out).unwrap();
295 assert_eq!(out.len(), 2);
296 }
297
298 #[test]
299 fn local_config_emits_virtual_entries_per_watch_block() {
300 let body = r#"{
302 "mcpServers": {"a": {"command": "x"}},
303 "permissions": {"allow": []},
304 "lastSessionId": "noise-should-be-ignored",
305 "counters": {"step": 42},
306 "projects": {
307 "/path/p": {
308 "hooks": {"UserPromptSubmit": []},
309 "mcpServers": {"b": {"command": "y"}}
310 }
311 }
312 }"#;
313 let mut tmp = tempfile::NamedTempFile::new().unwrap();
314 tmp.write_all(body.as_bytes()).unwrap();
315
316 let entries = path_entries(tmp.path(), "local_config").unwrap();
317 let fragments: Vec<String> = entries
318 .iter()
319 .map(|e| {
320 e.path
321 .to_string_lossy()
322 .rsplit_once('#')
323 .map(|(_, frag)| frag.to_string())
324 .unwrap_or_default()
325 })
326 .collect();
327 assert!(fragments.contains(&"mcpServers".to_string()));
328 assert!(fragments.contains(&"permissions".to_string()));
329 assert!(fragments.iter().any(|f| f == "projects./path/p.hooks"));
330 assert!(fragments.iter().any(|f| f == "projects./path/p.mcpServers"));
331 assert!(!fragments.iter().any(|f| f == "lastSessionId"));
333 assert!(!fragments.iter().any(|f| f == "counters"));
334 }
335
336 #[test]
337 fn local_config_with_no_watched_block_emits_sentinel() {
338 let body = r#"{"unrelated": 1}"#;
339 let mut tmp = tempfile::NamedTempFile::new().unwrap();
340 tmp.write_all(body.as_bytes()).unwrap();
341 let entries = path_entries(tmp.path(), "local_config").unwrap();
342 assert_eq!(entries.len(), 1);
343 assert!(
344 entries[0]
345 .path
346 .to_string_lossy()
347 .ends_with("#(no-watched-block)")
348 );
349 }
350
351 #[test]
352 fn local_config_with_unparseable_json_falls_back_to_full_hash() {
353 let mut tmp = tempfile::NamedTempFile::new().unwrap();
354 tmp.write_all(b"not json at all").unwrap();
355 let entries = path_entries(tmp.path(), "local_config").unwrap();
356 assert_eq!(entries.len(), 1);
357 assert!(!entries[0].path.to_string_lossy().contains('#'));
359 }
360}