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 has_claude_dir: bool,
68 pub has_claude_rules_dir: bool,
69 pub has_claude_skills_dir: bool,
70 pub subdirectory_claude_mds: Vec<PathBuf>,
71 pub test_files: Vec<PathBuf>,
72 pub ci_configs: Vec<PathBuf>,
73 pub env_files: Vec<PathBuf>,
74 pub lock_files: Vec<PathBuf>,
75 pub mcp_json_path: Option<PathBuf>,
76
77 pub(crate) ignore_set: Option<GlobSet>,
79 pub(crate) ignore_patterns: Vec<String>,
81
82 root_file_cache: std::collections::HashMap<String, String>,
84}
85
86impl ProjectContext {
87 pub fn has_file(&self, relative: &str) -> bool {
88 self.root.join(relative).exists()
89 }
90
91 pub fn has_claude_md(&self) -> bool {
92 self.claude_md_content.is_some()
93 }
94
95 pub fn read_root_file(&self, relative: &str) -> Option<&str> {
96 self.root_file_cache.get(relative).map(|s| s.as_str())
97 }
98
99 pub fn read_manifest_content(&self, filename: &str) -> Option<&str> {
100 self.root_file_cache.get(filename).map(|s| s.as_str())
101 }
102
103 pub fn source_files(&self) -> Vec<&FileInfo> {
105 self.all_files.iter()
106 .filter(|f| !f.is_test && !f.is_generated
107 && !self.is_claudeignored(&f.relative_path.to_string_lossy()))
108 .collect()
109 }
110
111 pub fn source_file_count(&self) -> usize {
112 self.source_files().len()
113 }
114
115 pub fn average_source_file_lines(&self) -> f64 {
116 let files: Vec<_> = self.source_files().into_iter()
117 .filter(|f| f.line_count > 0)
118 .collect();
119 if files.is_empty() { return 0.0; }
120 let total: usize = files.iter().map(|f| f.line_count).sum();
121 total as f64 / files.len() as f64
122 }
123
124 pub fn test_function_count(&self) -> usize {
126 let test_markers = ["#[test]", "fn test", "test(", "test '", "test \"", "it(", "it '", "def test_", "async fn test"];
127 self.test_files.iter().map(|tf| {
128 std::fs::read_to_string(tf).unwrap_or_default()
129 .lines()
130 .filter(|l| test_markers.iter().any(|m| l.trim().starts_with(m)))
131 .count()
132 }).sum()
133 }
134
135 pub fn claude_md_line_count(&self) -> usize {
136 self.claude_md_content
137 .as_ref()
138 .map(|c| c.lines().count())
139 .unwrap_or(0)
140 }
141
142 pub fn readme_line_count(&self) -> usize {
143 self.readme_content
144 .as_ref()
145 .map(|c| c.lines().count())
146 .unwrap_or(0)
147 }
148
149 pub fn claudeignore_contains(&self, pattern: &str) -> bool {
152 self.ignore_patterns.iter().any(|p| {
153 p.contains(pattern) || pattern.contains(p.trim_end_matches('/'))
154 })
155 }
156
157 pub fn settings_has_permissions(&self) -> bool {
158 self.settings_json
159 .as_ref()
160 .and_then(|v| v.get("permissions"))
161 .and_then(|p| p.get("allow"))
162 .and_then(|a| a.as_array())
163 .map(|a| !a.is_empty())
164 .unwrap_or(false)
165 }
166
167 pub fn settings_has_hooks(&self) -> bool {
168 self.settings_json
169 .as_ref()
170 .and_then(|v| v.get("hooks"))
171 .map(|h| h.is_object() && h.as_object().map(|o| !o.is_empty()).unwrap_or(false))
172 .unwrap_or(false)
173 }
174
175 pub fn has_post_tool_use_hook_for_format(&self) -> bool {
176 self.settings_json
177 .as_ref()
178 .and_then(|v| v.get("hooks"))
179 .and_then(|h| h.get("PostToolUse"))
180 .map(|ptu| {
181 let s = serde_json::to_string(ptu).unwrap_or_default();
182 s.contains("Edit") || s.contains("Write")
183 })
184 .unwrap_or(false)
185 }
186
187 pub fn has_pre_tool_use_protection_hook(&self) -> bool {
188 self.settings_json
189 .as_ref()
190 .and_then(|v| v.get("hooks"))
191 .and_then(|h| h.get("PreToolUse"))
192 .is_some()
193 }
194
195 pub fn is_claudeignored(&self, relative_path: &str) -> bool {
197 if let Some(ref gs) = self.ignore_set {
198 gs.is_match(relative_path)
199 } else {
200 false
201 }
202 }
203
204 pub fn mega_files(&self, threshold: usize) -> Vec<&FileInfo> {
205 self.all_files.iter()
206 .filter(|f| {
207 f.line_count > threshold
208 && !self.is_claudeignored(&f.relative_path.to_string_lossy())
209 })
210 .collect()
211 }
212
213 pub fn workspace_packages(&self) -> Vec<&PathBuf> {
214 let workspace_dirs = ["packages", "apps", "services", "libs"];
215 self.directories.iter()
216 .filter(|d| {
217 if let Ok(rel) = d.strip_prefix(&self.root) {
218 let components: Vec<_> = rel.components().collect();
219 components.len() == 1 && workspace_dirs.iter().any(|wd| {
220 d.parent().map(|p| p.file_name().map(|n| n == *wd).unwrap_or(false)).unwrap_or(false)
221 })
222 } else {
223 false
224 }
225 })
226 .collect()
227 }
228}
229