Skip to main content

claude_native/scan/
builder.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use walkdir::WalkDir;
6
7use crate::scan::classifiers::*;
8use crate::scan::{file_stats, FileInfo, ManifestInfo, ProjectContext};
9
10/// Build a ProjectContext by scanning a project directory.
11pub fn build_context(root: &Path) -> Result<ProjectContext> {
12    let root = root.canonicalize()?;
13    let scan = scan_directory(&root)?;
14    let keys = read_key_files(&root);
15    let claude = detect_claude_config(&root);
16    let total_file_count = scan.all_files.len();
17
18    Ok(ProjectContext {
19        root, project_type: None,
20        all_files: scan.all_files, total_file_count,
21        max_depth: scan.max_depth, directories: scan.directories,
22        claude_md_content: keys.claude_md_content, claude_md_path: keys.claude_md_path,
23        claudeignore_content: keys.claudeignore_content,
24        readme_content: keys.readme_content,
25        agents_md_content: keys.agents_md_content,
26        settings_json: keys.settings_json, package_json: keys.package_json,
27        package_manifests: scan.package_manifests,
28        has_claude_dir: claude.has_dir, has_claude_rules_dir: claude.has_rules,
29        has_claude_skills_dir: claude.has_skills,
30        has_claude_agents_dir: claude.has_agents,
31        subdirectory_claude_mds: scan.subdirectory_claude_mds,
32        test_files: scan.test_files, ci_configs: scan.ci_configs,
33        env_files: scan.env_files, lock_files: scan.lock_files,
34        mcp_json_path: claude.mcp_path,
35        ignore_set: keys.ignore_set, ignore_patterns: keys.ignore_patterns,
36        root_file_cache: keys.root_file_cache,
37    })
38}
39
40struct ScanResult {
41    all_files: Vec<FileInfo>, directories: Vec<PathBuf>,
42    max_depth: usize, test_files: Vec<PathBuf>,
43    ci_configs: Vec<PathBuf>, env_files: Vec<PathBuf>,
44    lock_files: Vec<PathBuf>, package_manifests: Vec<ManifestInfo>,
45    subdirectory_claude_mds: Vec<PathBuf>,
46}
47
48fn scan_directory(root: &Path) -> Result<ScanResult> {
49    let mut r = ScanResult {
50        all_files: vec![], directories: vec![], max_depth: 0,
51        test_files: vec![], ci_configs: vec![], env_files: vec![],
52        lock_files: vec![], package_manifests: vec![], subdirectory_claude_mds: vec![],
53    };
54    walk_directory(root, &mut r)?;
55    Ok(r)
56}
57
58struct KeyFiles {
59    claude_md_content: Option<String>, claude_md_path: Option<PathBuf>,
60    claudeignore_content: Option<String>, readme_content: Option<String>,
61    agents_md_content: Option<String>,
62    settings_json: Option<serde_json::Value>, package_json: Option<serde_json::Value>,
63    ignore_set: Option<GlobSet>, ignore_patterns: Vec<String>,
64    root_file_cache: std::collections::HashMap<String, String>,
65}
66
67fn read_key_files(root: &Path) -> KeyFiles {
68    let mut root_file_cache = std::collections::HashMap::new();
69    cache_root_files(root, &mut root_file_cache);
70    let (claude_md_content, claude_md_path) = read_claude_md(root);
71    let claudeignore_content = std::fs::read_to_string(root.join(".claudeignore")).ok();
72    let (ignore_set, ignore_patterns) = build_ignore_set(&claudeignore_content);
73    let readme_content = std::fs::read_to_string(root.join("README.md"))
74        .or_else(|_| std::fs::read_to_string(root.join("readme.md"))).ok();
75    let agents_md_content = std::fs::read_to_string(root.join("AGENTS.md")).ok();
76    let settings_json = std::fs::read_to_string(root.join(".claude/settings.json"))
77        .ok().and_then(|s| serde_json::from_str(&s).ok());
78    let package_json = std::fs::read_to_string(root.join("package.json"))
79        .ok().and_then(|s| serde_json::from_str(&s).ok());
80    KeyFiles { claude_md_content, claude_md_path, claudeignore_content, readme_content,
81        agents_md_content, settings_json, package_json, ignore_set, ignore_patterns, root_file_cache }
82}
83
84struct ClaudeConfig { has_dir: bool, has_rules: bool, has_skills: bool, has_agents: bool, mcp_path: Option<PathBuf> }
85
86fn detect_claude_config(root: &Path) -> ClaudeConfig {
87    let dir = root.join(".claude");
88    ClaudeConfig {
89        has_dir: dir.is_dir(),
90        has_rules: dir.join("rules").is_dir(),
91        has_skills: dir.join("skills").is_dir(),
92        has_agents: dir.join("agents").is_dir(),
93        mcp_path: if dir.join(".mcp.json").exists() { Some(dir.join(".mcp.json")) } else { None },
94    }
95}
96
97fn walk_directory(root: &Path, r: &mut ScanResult) -> Result<()> {
98    let walker = WalkDir::new(root).follow_links(false).into_iter()
99        .filter_entry(|e| !e.file_type().is_dir() || !should_skip_dir(e.file_name().to_str().unwrap_or("")));
100
101    for entry in walker {
102        let entry = entry?;
103        let path = entry.path().to_path_buf();
104        let depth = entry.depth();
105
106        if entry.file_type().is_dir() {
107            if depth > 0 { r.directories.push(path); }
108            if depth > r.max_depth { r.max_depth = depth; }
109            continue;
110        }
111
112        classify_file(&entry, root, depth, r);
113    }
114    Ok(())
115}
116
117fn classify_file(entry: &walkdir::DirEntry, root: &Path, depth: usize, r: &mut ScanResult) {
118    let path = entry.path().to_path_buf();
119    let file_name = entry.file_name().to_str().unwrap_or("");
120    let relative_path = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
121
122    if is_lock_file(file_name) { r.lock_files.push(path.clone()); }
123    if is_ci_config(&path) { r.ci_configs.push(path.clone()); }
124    if is_env_file(file_name) { r.env_files.push(path.clone()); }
125    if let Some(kind) = is_manifest(file_name) {
126        r.package_manifests.push(ManifestInfo { path: path.clone(), kind });
127    }
128    if file_name == "CLAUDE.md" && depth > 0 {
129        r.subdirectory_claude_mds.push(path.clone());
130    }
131    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
132        if is_source_extension(ext) || is_lock_file(file_name) || is_manifest(file_name).is_some() {
133            let metadata = entry.metadata().unwrap_or_else(|_| std::fs::metadata(&path).unwrap());
134            let is_test = is_test_file(&path);
135            let is_generated = is_generated_file(&path);
136            if is_test { r.test_files.push(path.clone()); }
137            r.all_files.push(FileInfo {
138                line_count: file_stats::count_lines(&path),
139                size_bytes: metadata.len(), is_test, is_generated,
140                path, relative_path,
141            });
142        }
143    }
144}
145
146fn cache_root_files(root: &Path, cache: &mut std::collections::HashMap<String, String>) {
147    let files_to_cache = [
148        "Cargo.toml", "package.json", "go.mod", "pubspec.yaml",
149        "requirements.txt", "pyproject.toml", "Gemfile", "mix.exs",
150        "template.yaml", "template.yml", "config.toml",
151    ];
152    for filename in &files_to_cache {
153        let path = root.join(filename);
154        if path.exists() {
155            if let Ok(content) = std::fs::read_to_string(&path) {
156                cache.insert(filename.to_string(), content);
157            }
158        }
159    }
160}
161
162/// Parse .claudeignore content into a compiled GlobSet and raw pattern list.
163fn build_ignore_set(content: &Option<String>) -> (Option<GlobSet>, Vec<String>) {
164    let content = match content {
165        Some(c) => c,
166        None => return (None, Vec::new()),
167    };
168
169    let mut builder = GlobSetBuilder::new();
170    let mut patterns = Vec::new();
171
172    for line in content.lines() {
173        let line = line.trim();
174        if line.is_empty() || line.starts_with('#') {
175            continue;
176        }
177        // Skip negation patterns for now (advanced feature)
178        if line.starts_with('!') {
179            continue;
180        }
181
182        patterns.push(line.to_string());
183
184        // Convert gitignore-style patterns to glob patterns:
185        // "target/" → "**/target/**"  (directory anywhere)
186        // "*.log"  → "**/*.log"       (extension anywhere)
187        // "Cargo.lock" → "**/Cargo.lock" (file anywhere)
188        let glob_pattern = if line.ends_with('/') {
189            format!("**/{}/**", line.trim_end_matches('/'))
190        } else if line.starts_with("**/") || line.starts_with('/') {
191            line.to_string()
192        } else if line.contains('/') {
193            line.to_string()
194        } else {
195            format!("**/{line}")
196        };
197
198        // Try to compile the glob; skip invalid patterns silently
199        if let Ok(glob) = Glob::new(&glob_pattern) {
200            builder.add(glob);
201        }
202        // For directory patterns, also match the dir name itself
203        if line.ends_with('/') {
204            let dir = line.trim_end_matches('/');
205            if let Ok(glob) = Glob::new(&format!("**/{dir}")) {
206                builder.add(glob);
207            }
208        }
209        // For bare names without extension, also treat as directory
210        if !line.contains('.') && !line.ends_with('/') && !line.contains('*') {
211            if let Ok(glob) = Glob::new(&format!("**/{line}/**")) {
212                builder.add(glob);
213            }
214        }
215    }
216
217    let set = builder.build().ok();
218    (set, patterns)
219}
220
221fn read_claude_md(root: &Path) -> (Option<String>, Option<PathBuf>) {
222    let path1 = root.join("CLAUDE.md");
223    if path1.exists() {
224        if let Ok(content) = std::fs::read_to_string(&path1) {
225            return (Some(content), Some(path1));
226        }
227    }
228    let path2 = root.join(".claude").join("CLAUDE.md");
229    if path2.exists() {
230        if let Ok(content) = std::fs::read_to_string(&path2) {
231            return (Some(content), Some(path2));
232        }
233    }
234    (None, None)
235}