1use std::collections::BTreeSet;
20use std::path::{Component, Path, PathBuf};
21
22use walkdir::WalkDir;
23
24use crate::error::PackError;
25use crate::manifest::{ClaudeInfo, SkipRecord, SymlinkRecord, WorktreeRecord};
26use crate::rules::PackRules;
27
28const NOISE_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
30
31const CLAUDE_DIR: &str = ".claude";
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum EntryKind {
37 File,
39 Dir,
41 Symlink,
43}
44
45#[derive(Debug, Clone)]
47pub struct Entry {
48 pub rel: String,
50 pub abs: PathBuf,
52 pub kind: EntryKind,
54 pub size: u64,
56}
57
58#[derive(Debug, Default)]
60pub struct Scan {
61 pub entries: Vec<Entry>,
63 pub skipped_cache: Vec<SkipRecord>,
65 pub skipped_secret: Vec<SkipRecord>,
67 pub symlinks: Vec<SymlinkRecord>,
69 pub claude: ClaudeInfo,
71 pub worktrees: Vec<WorktreeRecord>,
73}
74
75impl Scan {
76 pub fn total_bytes(&self) -> u64 {
78 self.entries.iter().map(|e| e.size).sum()
79 }
80
81 pub fn file_count(&self) -> u64 {
83 self.entries
84 .iter()
85 .filter(|e| e.kind == EntryKind::File)
86 .count() as u64
87 }
88
89 pub fn symlink_count(&self) -> u64 {
91 self.entries
92 .iter()
93 .filter(|e| e.kind == EntryKind::Symlink)
94 .count() as u64
95 }
96}
97
98pub fn scan(root: &Path) -> Result<Scan, PackError> {
107 scan_with(root, &PackRules::default())
108}
109
110pub fn scan_with(root: &Path, rules: &PackRules) -> Result<Scan, PackError> {
126 if !root.is_dir() {
127 return Err(PackError::NotADirectory(root.to_path_buf()));
128 }
129 let root = &canonicalize_or(root);
134
135 let mut scan = Scan::default();
136 let mut claude_link_targets: Vec<PathBuf> = Vec::new();
137
138 let walker = WalkDir::new(root)
139 .follow_links(false)
140 .min_depth(1)
141 .sort_by_file_name()
142 .into_iter();
143
144 let it = walker.filter_entry(|e| {
147 let name = e.file_name().to_string_lossy();
148 if e.file_type().is_symlink() {
150 return true;
151 }
152 if e.file_type().is_dir() && rules.is_cache_dir(name.as_ref()) {
153 return false;
154 }
155 true
156 });
157
158 collect_cache_records(root, rules, &mut scan)?;
161
162 for next in it {
163 let entry = next?;
164 let abs = entry.path().to_path_buf();
165 let Some(rel) = rel_path(root, &abs) else {
166 continue;
167 };
168 let name = entry.file_name().to_string_lossy().to_string();
169
170 if NOISE_FILES.contains(&name.as_str()) {
171 continue;
172 }
173
174 let file_type = entry.file_type();
175 let in_claude = rel == CLAUDE_DIR || rel.starts_with(&format!("{CLAUDE_DIR}/"));
176
177 if file_type.is_symlink() {
178 let target = std::fs::read_link(&abs)?;
179 if in_claude {
180 scan.claude.symlink_count += 1;
182 claude_link_targets.push(target);
183 } else {
184 scan.symlinks.push(SymlinkRecord {
185 path: rel.clone(),
186 target: target.to_string_lossy().into_owned(),
187 outside_root: resolves_outside(root, &abs, &target),
188 });
189 }
190 scan.entries.push(Entry {
191 rel,
192 abs,
193 kind: EntryKind::Symlink,
194 size: 0,
195 });
196 continue;
197 }
198
199 if file_type.is_dir() {
200 if rel == CLAUDE_DIR {
201 scan.claude.present = true;
202 }
203 scan.entries.push(Entry {
204 rel,
205 abs,
206 kind: EntryKind::Dir,
207 size: 0,
208 });
209 continue;
210 }
211
212 if let Some(reason) = rules.secret_reason(&name) {
213 scan.skipped_secret.push(SkipRecord { path: rel, reason });
214 continue;
215 }
216
217 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
218 scan.entries.push(Entry {
219 rel,
220 abs,
221 kind: EntryKind::File,
222 size,
223 });
224 }
225
226 scan.claude.link_roots = summarize_link_roots(&claude_link_targets);
227 scan.worktrees = discover_worktrees(root)?;
228
229 Ok(scan)
230}
231
232fn collect_cache_records(root: &Path, rules: &PackRules, scan: &mut Scan) -> Result<(), PackError> {
239 let walker = WalkDir::new(root)
240 .follow_links(false)
241 .min_depth(1)
242 .sort_by_file_name()
243 .into_iter();
244
245 let mut it = walker.filter_entry(|e| {
246 if e.file_type().is_symlink() {
247 return false;
248 }
249 if !e.file_type().is_dir() {
250 return false;
251 }
252 true
253 });
254
255 while let Some(next) = it.next() {
256 let entry = next?;
257 let name = entry.file_name().to_string_lossy().to_string();
258 if !rules.is_cache_dir(&name) {
259 continue;
260 }
261 if let Some(rel) = rel_path(root, entry.path()) {
262 scan.skipped_cache.push(SkipRecord {
263 path: rel,
264 reason: format!("cache directory: {name}"),
265 });
266 }
267 it.skip_current_dir();
268 }
269
270 Ok(())
271}
272
273fn rel_path(root: &Path, abs: &Path) -> Option<String> {
275 let rel = abs.strip_prefix(root).ok()?;
276 let s = rel
277 .components()
278 .map(|c| c.as_os_str().to_string_lossy())
279 .collect::<Vec<_>>()
280 .join("/");
281 if s.is_empty() { None } else { Some(s) }
282}
283
284fn resolves_outside(root: &Path, link_path: &Path, target: &Path) -> bool {
292 let joined = if target.is_absolute() {
293 target.to_path_buf()
294 } else {
295 match link_path.parent() {
296 Some(parent) => parent.join(target),
297 None => return true,
298 }
299 };
300 !canonicalize_or(&joined).starts_with(canonicalize_or(root))
301}
302
303fn canonicalize_or(path: &Path) -> PathBuf {
306 std::fs::canonicalize(path).unwrap_or_else(|_| normalize(path))
307}
308
309pub(crate) fn normalize(path: &Path) -> PathBuf {
311 let mut out = PathBuf::new();
312 for component in path.components() {
313 match component {
314 Component::ParentDir => {
315 out.pop();
316 }
317 Component::CurDir => {}
318 other => out.push(other.as_os_str()),
319 }
320 }
321 out
322}
323
324fn summarize_link_roots(targets: &[PathBuf]) -> Vec<String> {
331 const MAX_ROOTS: usize = 10;
332 const MIN_SHARED_DEPTH: usize = 4;
335
336 let parents: BTreeSet<PathBuf> = targets
337 .iter()
338 .filter(|t| t.is_absolute())
339 .filter_map(|t| t.parent().map(normalize))
340 .collect();
341
342 if parents.is_empty() {
343 return Vec::new();
344 }
345
346 let parents: Vec<PathBuf> = parents.into_iter().collect();
347 if let Some(shared) = common_prefix(&parents)
348 && shared.components().count() >= MIN_SHARED_DEPTH
349 {
350 return vec![shared.to_string_lossy().into_owned()];
351 }
352
353 parents
354 .iter()
355 .take(MAX_ROOTS)
356 .map(|p| p.to_string_lossy().into_owned())
357 .collect()
358}
359
360fn common_prefix(paths: &[PathBuf]) -> Option<PathBuf> {
362 let mut iter = paths.iter();
363 let mut prefix: Vec<_> = iter.next()?.components().collect();
364
365 for path in iter {
366 let comps: Vec<_> = path.components().collect();
367 let shared = prefix
368 .iter()
369 .zip(comps.iter())
370 .take_while(|(a, b)| a == b)
371 .count();
372 prefix.truncate(shared);
373 if prefix.is_empty() {
374 return None;
375 }
376 }
377
378 Some(prefix.iter().collect())
379}
380
381fn discover_worktrees(root: &Path) -> Result<Vec<WorktreeRecord>, PackError> {
390 let admin = root.join(".git").join("worktrees");
391 if !admin.is_dir() {
392 return Ok(Vec::new());
393 }
394
395 let mut records = Vec::new();
396 let mut dirs: Vec<PathBuf> = std::fs::read_dir(&admin)?
397 .filter_map(|e| e.ok())
398 .map(|e| e.path())
399 .filter(|p| p.is_dir())
400 .collect();
401 dirs.sort();
402
403 for dir in dirs {
404 let Some(name) = dir.file_name().map(|n| n.to_string_lossy().into_owned()) else {
405 continue;
406 };
407 let gitdir_file = dir.join("gitdir");
408 let Ok(contents) = std::fs::read_to_string(&gitdir_file) else {
409 continue;
410 };
411 let dot_git = PathBuf::from(contents.trim());
414 let Some(worktree_root) = dot_git.parent() else {
415 continue;
416 };
417 let resolved = canonicalize_or(worktree_root);
420 let rel = rel_path(root, &resolved);
421 records.push(WorktreeRecord {
422 name,
423 included: rel.is_some(),
424 path: rel,
425 source_path: worktree_root.to_string_lossy().into_owned(),
426 });
427 }
428
429 Ok(records)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435 use std::fs;
436 use tempfile::TempDir;
437
438 fn touch(path: &Path) {
439 if let Some(parent) = path.parent() {
440 fs::create_dir_all(parent).expect("mkdir should succeed in test");
441 }
442 fs::write(path, b"x").expect("write should succeed in test");
443 }
444
445 fn rels(scan: &Scan) -> Vec<String> {
446 scan.entries.iter().map(|e| e.rel.clone()).collect()
447 }
448
449 #[test]
455 fn test_scan_partitions_tree() {
456 let dir = TempDir::new().expect("tempdir");
457 let root = dir.path();
458
459 touch(&root.join("src/main.rs"));
460 touch(&root.join(".git/HEAD"));
461 touch(&root.join("workspace/journal.md"));
462 touch(&root.join("workspace/.journal.db"));
463 touch(&root.join(".mcp.json"));
464 touch(&root.join("target/debug/binary"));
465 touch(&root.join("crates/inner/target/x.rlib"));
466 touch(&root.join(".env"));
467 touch(&root.join(".env.example"));
468 touch(&root.join("key.pem"));
469
470 let scan = scan(root).expect("scan should succeed");
471 let packed = rels(&scan);
472
473 assert!(packed.contains(&"src/main.rs".to_string()));
474 assert!(
475 packed.contains(&".git/HEAD".to_string()),
476 "`.git` must travel"
477 );
478 assert!(packed.contains(&"workspace/journal.md".to_string()));
479 assert!(
480 packed.contains(&"workspace/.journal.db".to_string()),
481 "journal database is exactly the local state a pack exists to carry"
482 );
483 assert!(packed.contains(&".mcp.json".to_string()));
484 assert!(packed.contains(&".env.example".to_string()));
485
486 assert!(
487 !packed.iter().any(|p| p.starts_with("target/")),
488 "cache tree must not be packed"
489 );
490 assert!(
491 !packed.iter().any(|p| p.contains("/target/")),
492 "nested cache tree must not be packed"
493 );
494 assert!(!packed.contains(&".env".to_string()));
495 assert!(!packed.contains(&"key.pem".to_string()));
496
497 let secrets: Vec<&str> = scan
498 .skipped_secret
499 .iter()
500 .map(|s| s.path.as_str())
501 .collect();
502 assert!(secrets.contains(&".env"));
503 assert!(secrets.contains(&"key.pem"));
504
505 let caches: Vec<&str> = scan.skipped_cache.iter().map(|s| s.path.as_str()).collect();
506 assert!(caches.contains(&"target"));
507 assert!(caches.contains(&"crates/inner/target"));
508 }
509
510 #[cfg(unix)]
512 #[test]
513 fn test_scan_records_symlinks_outside_claude() {
514 let dir = TempDir::new().expect("tempdir");
515 let root = dir.path();
516 let outside = TempDir::new().expect("tempdir");
517
518 touch(&root.join("real.txt"));
519 std::os::unix::fs::symlink(root.join("real.txt"), root.join("inside-link"))
520 .expect("symlink");
521 std::os::unix::fs::symlink(outside.path().join("far.txt"), root.join("outside-link"))
522 .expect("symlink");
523
524 let scan = scan(root).expect("scan should succeed");
525
526 assert_eq!(scan.symlinks.len(), 2);
527 let inside = scan
528 .symlinks
529 .iter()
530 .find(|s| s.path == "inside-link")
531 .expect("inside link recorded");
532 let outside_rec = scan
533 .symlinks
534 .iter()
535 .find(|s| s.path == "outside-link")
536 .expect("outside link recorded");
537 assert!(!inside.outside_root);
538 assert!(outside_rec.outside_root);
539
540 assert!(rels(&scan).contains(&"outside-link".to_string()));
541 }
542
543 #[cfg(unix)]
545 #[test]
546 fn test_scan_aggregates_claude_links() {
547 let dir = TempDir::new().expect("tempdir");
548 let root = dir.path();
549 let profiles = TempDir::new().expect("tempdir");
550 let agents = profiles.path().join("sets/coding/agents");
551 let rules = profiles.path().join("sets/base/rules");
552 fs::create_dir_all(&agents).expect("mkdir");
553 fs::create_dir_all(&rules).expect("mkdir");
554 touch(&agents.join("a.md"));
555 touch(&rules.join("b.md"));
556
557 fs::create_dir_all(root.join(".claude/agents")).expect("mkdir");
558 fs::create_dir_all(root.join(".claude/rules")).expect("mkdir");
559 std::os::unix::fs::symlink(agents.join("a.md"), root.join(".claude/agents/a.md"))
560 .expect("symlink");
561 std::os::unix::fs::symlink(rules.join("b.md"), root.join(".claude/rules/b.md"))
562 .expect("symlink");
563
564 let scan = scan(root).expect("scan should succeed");
565
566 assert!(scan.claude.present);
567 assert_eq!(scan.claude.symlink_count, 2);
568 assert!(
569 scan.symlinks.is_empty(),
570 "`.claude` links must not appear in the per-link list"
571 );
572 assert_eq!(
573 scan.claude.link_roots.len(),
574 1,
575 "a shared profiles root collapses to one entry, got {:?}",
576 scan.claude.link_roots
577 );
578 assert!(rels(&scan).contains(&".claude/agents/a.md".to_string()));
579 }
580
581 #[test]
583 fn test_scan_without_worktrees() {
584 let dir = TempDir::new().expect("tempdir");
585 touch(&dir.path().join(".git/HEAD"));
586 let scan = scan(dir.path()).expect("scan should succeed");
587 assert!(scan.worktrees.is_empty());
588 }
589
590 #[test]
592 fn test_scan_discovers_inside_worktree() {
593 let dir = TempDir::new().expect("tempdir");
594 let root = dir.path();
595 let wt = root.join(".worktrees/feature");
596 touch(&wt.join("file.txt"));
597 fs::write(wt.join(".git"), "gitdir: /ignored\n").expect("write");
598 let admin = root.join(".git/worktrees/feature");
599 fs::create_dir_all(&admin).expect("mkdir");
600 fs::write(
601 admin.join("gitdir"),
602 format!("{}\n", wt.join(".git").display()),
603 )
604 .expect("write");
605
606 let scan = scan(root).expect("scan should succeed");
607
608 assert_eq!(scan.worktrees.len(), 1);
609 let rec = &scan.worktrees[0];
610 assert_eq!(rec.name, "feature");
611 assert_eq!(rec.path.as_deref(), Some(".worktrees/feature"));
612 assert!(rec.included);
613 assert!(rels(&scan).contains(&".worktrees/feature/file.txt".to_string()));
614 }
615
616 #[test]
618 fn test_scan_reports_outside_worktree_without_including_it() {
619 let dir = TempDir::new().expect("tempdir");
620 let root = dir.path();
621 let elsewhere = TempDir::new().expect("tempdir");
622 let wt = elsewhere.path().join("detached");
623 touch(&wt.join("file.txt"));
624
625 let admin = root.join(".git/worktrees/detached");
626 fs::create_dir_all(&admin).expect("mkdir");
627 fs::write(
628 admin.join("gitdir"),
629 format!("{}\n", wt.join(".git").display()),
630 )
631 .expect("write");
632
633 let scan = scan(root).expect("scan should succeed");
634
635 assert_eq!(scan.worktrees.len(), 1);
636 assert!(!scan.worktrees[0].included);
637 assert!(scan.worktrees[0].path.is_none());
638 assert!(!rels(&scan).iter().any(|p| p.contains("detached/file.txt")));
639 }
640
641 #[test]
643 fn test_scan_rejects_non_directory() {
644 let dir = TempDir::new().expect("tempdir");
645 let file = dir.path().join("f.txt");
646 touch(&file);
647 assert!(matches!(scan(&file), Err(PackError::NotADirectory(_))));
648 }
649
650 #[test]
656 fn test_summarize_link_roots_collapses_shared_prefix() {
657 let targets = vec![
658 PathBuf::from("/home/u/.config/profiles/sets/coding/agents/a.md"),
659 PathBuf::from("/home/u/.config/profiles/sets/base/rules/b.md"),
660 ];
661 let roots = summarize_link_roots(&targets);
662 assert_eq!(roots, vec!["/home/u/.config/profiles/sets".to_string()]);
663 }
664
665 #[test]
667 fn test_summarize_link_roots_keeps_scattered_parents() {
668 let targets = vec![PathBuf::from("/opt/a/x.md"), PathBuf::from("/srv/b/y.md")];
669 let roots = summarize_link_roots(&targets);
670 assert_eq!(roots.len(), 2);
671 }
672
673 #[test]
675 fn test_resolves_outside_relative_target() {
676 let root = Path::new("/proj");
677 assert!(!resolves_outside(
678 root,
679 Path::new("/proj/sub/link"),
680 Path::new("../file.txt")
681 ));
682 assert!(resolves_outside(
683 root,
684 Path::new("/proj/sub/link"),
685 Path::new("../../escape.txt")
686 ));
687 }
688}