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 Ok(vec![PathEntry {
194 path: path.to_path_buf(),
195 category: category.to_string(),
196 sha256: sha256_hex(&bytes),
197 size: metadata.len(),
198 }])
199}
200
201const LOCAL_CONFIG_WATCH_KEYS: &[&str] = &["mcpServers", "hooks", "permissions"];
204
205fn extract_local_config_entries(path: &Path, json: &serde_json::Value) -> Vec<PathEntry> {
206 let mut out = Vec::new();
207
208 for key in LOCAL_CONFIG_WATCH_KEYS {
210 if let Some(value) = json.get(key) {
211 out.push(virtual_entry(path, "local_config", key, value));
212 }
213 }
214
215 if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
217 for (proj_name, proj_val) in projects {
218 for key in LOCAL_CONFIG_WATCH_KEYS {
219 if let Some(value) = proj_val.get(key) {
220 let fragment = format!("projects.{proj_name}.{key}");
221 out.push(virtual_entry(path, "local_config", &fragment, value));
222 }
223 }
224 }
225 }
226
227 if out.is_empty() {
228 out.push(virtual_entry(
231 path,
232 "local_config",
233 "(no-watched-block)",
234 &serde_json::Value::Null,
235 ));
236 }
237 out
238}
239
240fn virtual_entry(
241 path: &Path,
242 category: &str,
243 fragment: &str,
244 value: &serde_json::Value,
245) -> PathEntry {
246 let canonical = serde_json::to_string(value).unwrap_or_default();
247 let size = canonical.len() as u64;
248 let sha256 = sha256_hex(canonical.as_bytes());
249 let virtual_path = PathBuf::from(format!("{}#{fragment}", path.display()));
250 PathEntry {
251 path: virtual_path,
252 category: category.to_string(),
253 sha256,
254 size,
255 }
256}
257
258fn sha256_hex(bytes: &[u8]) -> String {
259 let mut h = Sha256::new();
260 h.update(bytes);
261 format!("{:x}", h.finalize())
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use std::io::Write;
268
269 #[test]
270 fn path_entries_computes_sha256_for_regular_file() {
271 let mut tmp = tempfile::NamedTempFile::new().unwrap();
272 tmp.write_all(b"hello agentsec").unwrap();
273 let entries = path_entries(tmp.path(), "test").unwrap();
274 assert_eq!(entries.len(), 1);
275 let e = &entries[0];
276 assert_eq!(e.size, 14);
277 assert_eq!(e.sha256.len(), 64);
278 assert_eq!(e.category, "test");
279 }
280
281 #[test]
282 fn walk_collects_files_recursively() {
283 let dir = tempfile::tempdir().unwrap();
284 let sub = dir.path().join("sub");
285 fs::create_dir(&sub).unwrap();
286 fs::write(dir.path().join("a.txt"), "a").unwrap();
287 fs::write(sub.join("b.txt"), "bb").unwrap();
288 let mut out = Vec::new();
289 walk(dir.path(), "x", &mut out).unwrap();
290 assert_eq!(out.len(), 2);
291 }
292
293 #[test]
294 fn local_config_emits_virtual_entries_per_watch_block() {
295 let body = r#"{
297 "mcpServers": {"a": {"command": "x"}},
298 "permissions": {"allow": []},
299 "lastSessionId": "noise-should-be-ignored",
300 "counters": {"step": 42},
301 "projects": {
302 "/path/p": {
303 "hooks": {"UserPromptSubmit": []},
304 "mcpServers": {"b": {"command": "y"}}
305 }
306 }
307 }"#;
308 let mut tmp = tempfile::NamedTempFile::new().unwrap();
309 tmp.write_all(body.as_bytes()).unwrap();
310
311 let entries = path_entries(tmp.path(), "local_config").unwrap();
312 let fragments: Vec<String> = entries
313 .iter()
314 .map(|e| {
315 e.path
316 .to_string_lossy()
317 .rsplit_once('#')
318 .map(|(_, frag)| frag.to_string())
319 .unwrap_or_default()
320 })
321 .collect();
322 assert!(fragments.contains(&"mcpServers".to_string()));
323 assert!(fragments.contains(&"permissions".to_string()));
324 assert!(fragments.iter().any(|f| f == "projects./path/p.hooks"));
325 assert!(fragments.iter().any(|f| f == "projects./path/p.mcpServers"));
326 assert!(!fragments.iter().any(|f| f == "lastSessionId"));
328 assert!(!fragments.iter().any(|f| f == "counters"));
329 }
330
331 #[test]
332 fn local_config_with_no_watched_block_emits_sentinel() {
333 let body = r#"{"unrelated": 1}"#;
334 let mut tmp = tempfile::NamedTempFile::new().unwrap();
335 tmp.write_all(body.as_bytes()).unwrap();
336 let entries = path_entries(tmp.path(), "local_config").unwrap();
337 assert_eq!(entries.len(), 1);
338 assert!(
339 entries[0]
340 .path
341 .to_string_lossy()
342 .ends_with("#(no-watched-block)")
343 );
344 }
345
346 #[test]
347 fn local_config_with_unparseable_json_falls_back_to_full_hash() {
348 let mut tmp = tempfile::NamedTempFile::new().unwrap();
349 tmp.write_all(b"not json at all").unwrap();
350 let entries = path_entries(tmp.path(), "local_config").unwrap();
351 assert_eq!(entries.len(), 1);
352 assert!(!entries[0].path.to_string_lossy().contains('#'));
354 }
355}