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
10pub 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
162fn 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 if line.starts_with('!') {
179 continue;
180 }
181
182 patterns.push(line.to_string());
183
184 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 if let Ok(glob) = Glob::new(&glob_pattern) {
200 builder.add(glob);
201 }
202 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 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}