use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use walkdir::WalkDir;
use crate::error::PackError;
use crate::manifest::{ClaudeInfo, SkipRecord, SymlinkRecord, WorktreeRecord};
use crate::rules::PackRules;
const NOISE_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
const CLAUDE_DIR: &str = ".claude";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Dir,
Symlink,
}
#[derive(Debug, Clone)]
pub struct Entry {
pub rel: String,
pub abs: PathBuf,
pub kind: EntryKind,
pub size: u64,
}
#[derive(Debug, Default)]
pub struct Scan {
pub entries: Vec<Entry>,
pub skipped_cache: Vec<SkipRecord>,
pub skipped_secret: Vec<SkipRecord>,
pub symlinks: Vec<SymlinkRecord>,
pub claude: ClaudeInfo,
pub worktrees: Vec<WorktreeRecord>,
}
impl Scan {
pub fn total_bytes(&self) -> u64 {
self.entries.iter().map(|e| e.size).sum()
}
pub fn file_count(&self) -> u64 {
self.entries
.iter()
.filter(|e| e.kind == EntryKind::File)
.count() as u64
}
pub fn symlink_count(&self) -> u64 {
self.entries
.iter()
.filter(|e| e.kind == EntryKind::Symlink)
.count() as u64
}
}
pub fn scan(root: &Path) -> Result<Scan, PackError> {
scan_with(root, &PackRules::default())
}
pub fn scan_with(root: &Path, rules: &PackRules) -> Result<Scan, PackError> {
if !root.is_dir() {
return Err(PackError::NotADirectory(root.to_path_buf()));
}
let root = &canonicalize_or(root);
let mut scan = Scan::default();
let mut claude_link_targets: Vec<PathBuf> = Vec::new();
let walker = WalkDir::new(root)
.follow_links(false)
.min_depth(1)
.sort_by_file_name()
.into_iter();
let it = walker.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if e.file_type().is_symlink() {
return true;
}
if e.file_type().is_dir() && rules.is_cache_dir(name.as_ref()) {
return false;
}
true
});
collect_cache_records(root, rules, &mut scan)?;
for next in it {
let entry = next?;
let abs = entry.path().to_path_buf();
let Some(rel) = rel_path(root, &abs) else {
continue;
};
let name = entry.file_name().to_string_lossy().to_string();
if NOISE_FILES.contains(&name.as_str()) {
continue;
}
let file_type = entry.file_type();
let in_claude = rel == CLAUDE_DIR || rel.starts_with(&format!("{CLAUDE_DIR}/"));
if file_type.is_symlink() {
let target = std::fs::read_link(&abs)?;
if in_claude {
scan.claude.symlink_count += 1;
claude_link_targets.push(target);
} else {
scan.symlinks.push(SymlinkRecord {
path: rel.clone(),
target: target.to_string_lossy().into_owned(),
outside_root: resolves_outside(root, &abs, &target),
});
}
scan.entries.push(Entry {
rel,
abs,
kind: EntryKind::Symlink,
size: 0,
});
continue;
}
if file_type.is_dir() {
if rel == CLAUDE_DIR {
scan.claude.present = true;
}
scan.entries.push(Entry {
rel,
abs,
kind: EntryKind::Dir,
size: 0,
});
continue;
}
if let Some(reason) = rules.secret_reason(&name) {
scan.skipped_secret.push(SkipRecord { path: rel, reason });
continue;
}
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
scan.entries.push(Entry {
rel,
abs,
kind: EntryKind::File,
size,
});
}
scan.claude.link_roots = summarize_link_roots(&claude_link_targets);
scan.worktrees = discover_worktrees(root)?;
Ok(scan)
}
fn collect_cache_records(root: &Path, rules: &PackRules, scan: &mut Scan) -> Result<(), PackError> {
let walker = WalkDir::new(root)
.follow_links(false)
.min_depth(1)
.sort_by_file_name()
.into_iter();
let mut it = walker.filter_entry(|e| {
if e.file_type().is_symlink() {
return false;
}
if !e.file_type().is_dir() {
return false;
}
true
});
while let Some(next) = it.next() {
let entry = next?;
let name = entry.file_name().to_string_lossy().to_string();
if !rules.is_cache_dir(&name) {
continue;
}
if let Some(rel) = rel_path(root, entry.path()) {
scan.skipped_cache.push(SkipRecord {
path: rel,
reason: format!("cache directory: {name}"),
});
}
it.skip_current_dir();
}
Ok(())
}
fn rel_path(root: &Path, abs: &Path) -> Option<String> {
let rel = abs.strip_prefix(root).ok()?;
let s = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if s.is_empty() { None } else { Some(s) }
}
fn resolves_outside(root: &Path, link_path: &Path, target: &Path) -> bool {
let joined = if target.is_absolute() {
target.to_path_buf()
} else {
match link_path.parent() {
Some(parent) => parent.join(target),
None => return true,
}
};
!canonicalize_or(&joined).starts_with(canonicalize_or(root))
}
fn canonicalize_or(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| normalize(path))
}
pub(crate) fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
fn summarize_link_roots(targets: &[PathBuf]) -> Vec<String> {
const MAX_ROOTS: usize = 10;
const MIN_SHARED_DEPTH: usize = 4;
let parents: BTreeSet<PathBuf> = targets
.iter()
.filter(|t| t.is_absolute())
.filter_map(|t| t.parent().map(normalize))
.collect();
if parents.is_empty() {
return Vec::new();
}
let parents: Vec<PathBuf> = parents.into_iter().collect();
if let Some(shared) = common_prefix(&parents)
&& shared.components().count() >= MIN_SHARED_DEPTH
{
return vec![shared.to_string_lossy().into_owned()];
}
parents
.iter()
.take(MAX_ROOTS)
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
fn common_prefix(paths: &[PathBuf]) -> Option<PathBuf> {
let mut iter = paths.iter();
let mut prefix: Vec<_> = iter.next()?.components().collect();
for path in iter {
let comps: Vec<_> = path.components().collect();
let shared = prefix
.iter()
.zip(comps.iter())
.take_while(|(a, b)| a == b)
.count();
prefix.truncate(shared);
if prefix.is_empty() {
return None;
}
}
Some(prefix.iter().collect())
}
fn discover_worktrees(root: &Path) -> Result<Vec<WorktreeRecord>, PackError> {
let admin = root.join(".git").join("worktrees");
if !admin.is_dir() {
return Ok(Vec::new());
}
let mut records = Vec::new();
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&admin)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
for dir in dirs {
let Some(name) = dir.file_name().map(|n| n.to_string_lossy().into_owned()) else {
continue;
};
let gitdir_file = dir.join("gitdir");
let Ok(contents) = std::fs::read_to_string(&gitdir_file) else {
continue;
};
let dot_git = PathBuf::from(contents.trim());
let Some(worktree_root) = dot_git.parent() else {
continue;
};
let resolved = canonicalize_or(worktree_root);
let rel = rel_path(root, &resolved);
records.push(WorktreeRecord {
name,
included: rel.is_some(),
path: rel,
source_path: worktree_root.to_string_lossy().into_owned(),
});
}
Ok(records)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("mkdir should succeed in test");
}
fs::write(path, b"x").expect("write should succeed in test");
}
fn rels(scan: &Scan) -> Vec<String> {
scan.entries.iter().map(|e| e.rel.clone()).collect()
}
#[test]
fn test_scan_partitions_tree() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
touch(&root.join("src/main.rs"));
touch(&root.join(".git/HEAD"));
touch(&root.join("workspace/journal.md"));
touch(&root.join("workspace/.journal.db"));
touch(&root.join(".mcp.json"));
touch(&root.join("target/debug/binary"));
touch(&root.join("crates/inner/target/x.rlib"));
touch(&root.join(".env"));
touch(&root.join(".env.example"));
touch(&root.join("key.pem"));
let scan = scan(root).expect("scan should succeed");
let packed = rels(&scan);
assert!(packed.contains(&"src/main.rs".to_string()));
assert!(
packed.contains(&".git/HEAD".to_string()),
"`.git` must travel"
);
assert!(packed.contains(&"workspace/journal.md".to_string()));
assert!(
packed.contains(&"workspace/.journal.db".to_string()),
"journal database is exactly the local state a pack exists to carry"
);
assert!(packed.contains(&".mcp.json".to_string()));
assert!(packed.contains(&".env.example".to_string()));
assert!(
!packed.iter().any(|p| p.starts_with("target/")),
"cache tree must not be packed"
);
assert!(
!packed.iter().any(|p| p.contains("/target/")),
"nested cache tree must not be packed"
);
assert!(!packed.contains(&".env".to_string()));
assert!(!packed.contains(&"key.pem".to_string()));
let secrets: Vec<&str> = scan
.skipped_secret
.iter()
.map(|s| s.path.as_str())
.collect();
assert!(secrets.contains(&".env"));
assert!(secrets.contains(&"key.pem"));
let caches: Vec<&str> = scan.skipped_cache.iter().map(|s| s.path.as_str()).collect();
assert!(caches.contains(&"target"));
assert!(caches.contains(&"crates/inner/target"));
}
#[cfg(unix)]
#[test]
fn test_scan_records_symlinks_outside_claude() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let outside = TempDir::new().expect("tempdir");
touch(&root.join("real.txt"));
std::os::unix::fs::symlink(root.join("real.txt"), root.join("inside-link"))
.expect("symlink");
std::os::unix::fs::symlink(outside.path().join("far.txt"), root.join("outside-link"))
.expect("symlink");
let scan = scan(root).expect("scan should succeed");
assert_eq!(scan.symlinks.len(), 2);
let inside = scan
.symlinks
.iter()
.find(|s| s.path == "inside-link")
.expect("inside link recorded");
let outside_rec = scan
.symlinks
.iter()
.find(|s| s.path == "outside-link")
.expect("outside link recorded");
assert!(!inside.outside_root);
assert!(outside_rec.outside_root);
assert!(rels(&scan).contains(&"outside-link".to_string()));
}
#[cfg(unix)]
#[test]
fn test_scan_aggregates_claude_links() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let profiles = TempDir::new().expect("tempdir");
let agents = profiles.path().join("sets/coding/agents");
let rules = profiles.path().join("sets/base/rules");
fs::create_dir_all(&agents).expect("mkdir");
fs::create_dir_all(&rules).expect("mkdir");
touch(&agents.join("a.md"));
touch(&rules.join("b.md"));
fs::create_dir_all(root.join(".claude/agents")).expect("mkdir");
fs::create_dir_all(root.join(".claude/rules")).expect("mkdir");
std::os::unix::fs::symlink(agents.join("a.md"), root.join(".claude/agents/a.md"))
.expect("symlink");
std::os::unix::fs::symlink(rules.join("b.md"), root.join(".claude/rules/b.md"))
.expect("symlink");
let scan = scan(root).expect("scan should succeed");
assert!(scan.claude.present);
assert_eq!(scan.claude.symlink_count, 2);
assert!(
scan.symlinks.is_empty(),
"`.claude` links must not appear in the per-link list"
);
assert_eq!(
scan.claude.link_roots.len(),
1,
"a shared profiles root collapses to one entry, got {:?}",
scan.claude.link_roots
);
assert!(rels(&scan).contains(&".claude/agents/a.md".to_string()));
}
#[test]
fn test_scan_without_worktrees() {
let dir = TempDir::new().expect("tempdir");
touch(&dir.path().join(".git/HEAD"));
let scan = scan(dir.path()).expect("scan should succeed");
assert!(scan.worktrees.is_empty());
}
#[test]
fn test_scan_discovers_inside_worktree() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let wt = root.join(".worktrees/feature");
touch(&wt.join("file.txt"));
fs::write(wt.join(".git"), "gitdir: /ignored\n").expect("write");
let admin = root.join(".git/worktrees/feature");
fs::create_dir_all(&admin).expect("mkdir");
fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.expect("write");
let scan = scan(root).expect("scan should succeed");
assert_eq!(scan.worktrees.len(), 1);
let rec = &scan.worktrees[0];
assert_eq!(rec.name, "feature");
assert_eq!(rec.path.as_deref(), Some(".worktrees/feature"));
assert!(rec.included);
assert!(rels(&scan).contains(&".worktrees/feature/file.txt".to_string()));
}
#[test]
fn test_scan_reports_outside_worktree_without_including_it() {
let dir = TempDir::new().expect("tempdir");
let root = dir.path();
let elsewhere = TempDir::new().expect("tempdir");
let wt = elsewhere.path().join("detached");
touch(&wt.join("file.txt"));
let admin = root.join(".git/worktrees/detached");
fs::create_dir_all(&admin).expect("mkdir");
fs::write(
admin.join("gitdir"),
format!("{}\n", wt.join(".git").display()),
)
.expect("write");
let scan = scan(root).expect("scan should succeed");
assert_eq!(scan.worktrees.len(), 1);
assert!(!scan.worktrees[0].included);
assert!(scan.worktrees[0].path.is_none());
assert!(!rels(&scan).iter().any(|p| p.contains("detached/file.txt")));
}
#[test]
fn test_scan_rejects_non_directory() {
let dir = TempDir::new().expect("tempdir");
let file = dir.path().join("f.txt");
touch(&file);
assert!(matches!(scan(&file), Err(PackError::NotADirectory(_))));
}
#[test]
fn test_summarize_link_roots_collapses_shared_prefix() {
let targets = vec![
PathBuf::from("/home/u/.config/profiles/sets/coding/agents/a.md"),
PathBuf::from("/home/u/.config/profiles/sets/base/rules/b.md"),
];
let roots = summarize_link_roots(&targets);
assert_eq!(roots, vec!["/home/u/.config/profiles/sets".to_string()]);
}
#[test]
fn test_summarize_link_roots_keeps_scattered_parents() {
let targets = vec![PathBuf::from("/opt/a/x.md"), PathBuf::from("/srv/b/y.md")];
let roots = summarize_link_roots(&targets);
assert_eq!(roots.len(), 2);
}
#[test]
fn test_resolves_outside_relative_target() {
let root = Path::new("/proj");
assert!(!resolves_outside(
root,
Path::new("/proj/sub/link"),
Path::new("../file.txt")
));
assert!(resolves_outside(
root,
Path::new("/proj/sub/link"),
Path::new("../../escape.txt")
));
}
}