claude_native/scan/
mod.rs1pub mod builder;
2pub mod classifiers;
3pub mod file_stats;
4
5use std::path::PathBuf;
6
7use globset::GlobSet;
8
9pub use builder::build_context;
10
11use crate::detection::ProjectType;
12
13#[derive(Debug, Clone)]
15pub struct FileInfo {
16 pub path: PathBuf,
17 pub relative_path: PathBuf,
18 pub line_count: usize,
19 pub size_bytes: u64,
20 pub is_test: bool,
21 pub is_generated: bool,
22}
23
24#[derive(Debug, Clone)]
26pub struct ManifestInfo {
27 pub path: PathBuf,
28 pub kind: ManifestKind,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ManifestKind {
33 PackageJson,
34 CargoToml,
35 GoMod,
36 PubspecYaml,
37 RequirementsTxt,
38 PyprojectToml,
39 Gemfile,
40 MixExs,
41 Other(String),
42}
43
44#[derive(Debug)]
47pub struct ProjectContext {
48 pub root: PathBuf,
49 pub project_type: Option<ProjectType>,
50
51 pub all_files: Vec<FileInfo>,
53 pub total_file_count: usize,
54 pub max_depth: usize,
55 pub directories: Vec<PathBuf>,
56
57 pub claude_md_content: Option<String>,
59 pub claude_md_path: Option<PathBuf>,
60 pub claudeignore_content: Option<String>,
61 pub readme_content: Option<String>,
62 pub settings_json: Option<serde_json::Value>,
63 pub package_json: Option<serde_json::Value>,
64 pub package_manifests: Vec<ManifestInfo>,
65
66 pub agents_md_content: Option<String>,
67
68 pub has_claude_dir: bool,
70 pub has_claude_rules_dir: bool,
71 pub has_claude_skills_dir: bool,
72 pub has_claude_agents_dir: bool,
73 pub subdirectory_claude_mds: Vec<PathBuf>,
74 pub test_files: Vec<PathBuf>,
75 pub ci_configs: Vec<PathBuf>,
76 pub env_files: Vec<PathBuf>,
77 pub lock_files: Vec<PathBuf>,
78 pub mcp_json_path: Option<PathBuf>,
79
80 pub(crate) ignore_set: Option<GlobSet>,
82 pub(crate) ignore_patterns: Vec<String>,
84
85 root_file_cache: std::collections::HashMap<String, String>,
87}
88
89impl ProjectContext {
90 pub fn has_file(&self, relative: &str) -> bool {
91 self.root.join(relative).exists()
92 }
93
94 pub fn has_claude_md(&self) -> bool {
95 self.claude_md_content.is_some()
96 }
97
98 pub fn read_root_file(&self, relative: &str) -> Option<&str> {
99 self.root_file_cache.get(relative).map(|s| s.as_str())
100 }
101
102 pub fn read_manifest_content(&self, filename: &str) -> Option<&str> {
103 self.root_file_cache.get(filename).map(|s| s.as_str())
104 }
105
106 pub fn source_files(&self) -> Vec<&FileInfo> {
108 self.all_files.iter()
109 .filter(|f| !f.is_test && !f.is_generated
110 && !self.is_claudeignored(&f.relative_path.to_string_lossy()))
111 .collect()
112 }
113
114 pub fn source_file_count(&self) -> usize {
115 self.source_files().len()
116 }
117
118 pub fn average_source_file_lines(&self) -> f64 {
119 let files: Vec<_> = self.source_files().into_iter()
120 .filter(|f| f.line_count > 0)
121 .collect();
122 if files.is_empty() { return 0.0; }
123 let total: usize = files.iter().map(|f| f.line_count).sum();
124 total as f64 / files.len() as f64
125 }
126
127 pub fn test_function_count(&self) -> usize {
129 let test_markers = ["#[test]", "fn test", "test(", "test '", "test \"", "it(", "it '", "def test_", "async fn test"];
130 self.test_files.iter().map(|tf| {
131 std::fs::read_to_string(tf).unwrap_or_default()
132 .lines()
133 .filter(|l| test_markers.iter().any(|m| l.trim().starts_with(m)))
134 .count()
135 }).sum()
136 }
137
138 pub fn claude_md_line_count(&self) -> usize {
139 self.claude_md_content
140 .as_ref()
141 .map(|c| c.lines().count())
142 .unwrap_or(0)
143 }
144
145 pub fn readme_line_count(&self) -> usize {
146 self.readme_content
147 .as_ref()
148 .map(|c| c.lines().count())
149 .unwrap_or(0)
150 }
151
152 pub fn claudeignore_contains(&self, pattern: &str) -> bool {
155 self.ignore_patterns.iter().any(|p| {
156 p.contains(pattern) || pattern.contains(p.trim_end_matches('/'))
157 })
158 }
159
160 pub fn settings_has_permissions(&self) -> bool {
161 self.settings_json
162 .as_ref()
163 .and_then(|v| v.get("permissions"))
164 .and_then(|p| p.get("allow"))
165 .and_then(|a| a.as_array())
166 .map(|a| !a.is_empty())
167 .unwrap_or(false)
168 }
169
170 pub fn settings_has_hooks(&self) -> bool {
171 self.settings_json
172 .as_ref()
173 .and_then(|v| v.get("hooks"))
174 .map(|h| h.is_object() && h.as_object().map(|o| !o.is_empty()).unwrap_or(false))
175 .unwrap_or(false)
176 }
177
178 pub fn has_post_tool_use_hook_for_format(&self) -> bool {
179 self.settings_json
180 .as_ref()
181 .and_then(|v| v.get("hooks"))
182 .and_then(|h| h.get("PostToolUse"))
183 .map(|ptu| {
184 let s = serde_json::to_string(ptu).unwrap_or_default();
185 s.contains("Edit") || s.contains("Write")
186 })
187 .unwrap_or(false)
188 }
189
190 pub fn has_pre_tool_use_protection_hook(&self) -> bool {
191 self.settings_json
192 .as_ref()
193 .and_then(|v| v.get("hooks"))
194 .and_then(|h| h.get("PreToolUse"))
195 .is_some()
196 }
197
198 pub fn is_claudeignored(&self, relative_path: &str) -> bool {
200 if let Some(ref gs) = self.ignore_set {
201 gs.is_match(relative_path)
202 } else {
203 false
204 }
205 }
206
207 pub fn mega_files(&self, threshold: usize) -> Vec<&FileInfo> {
208 self.all_files.iter()
209 .filter(|f| {
210 f.line_count > threshold
211 && !self.is_claudeignored(&f.relative_path.to_string_lossy())
212 })
213 .collect()
214 }
215
216 pub fn workspace_packages(&self) -> Vec<&PathBuf> {
217 let workspace_dirs = ["packages", "apps", "services", "libs"];
218 self.directories.iter()
219 .filter(|d| {
220 if let Ok(rel) = d.strip_prefix(&self.root) {
221 let components: Vec<_> = rel.components().collect();
222 components.len() == 1 && workspace_dirs.iter().any(|wd| {
223 d.parent().map(|p| p.file_name().map(|n| n == *wd).unwrap_or(false)).unwrap_or(false)
224 })
225 } else {
226 false
227 }
228 })
229 .collect()
230 }
231}
232