Skip to main content

claude_native/scan/
mod.rs

1pub 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/// A single file's metadata
14#[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/// A discovered package manifest
25#[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/// Everything the rule engine needs about the project.
45/// Built once during scan, shared immutably with all rules.
46#[derive(Debug)]
47pub struct ProjectContext {
48    pub root: PathBuf,
49    pub project_type: Option<ProjectType>,
50
51    // File system state
52    pub all_files: Vec<FileInfo>,
53    pub total_file_count: usize,
54    pub max_depth: usize,
55    pub directories: Vec<PathBuf>,
56
57    // Key file contents (read once, shared by many rules)
58    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    // Derived analysis
67    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    // Compiled .claudeignore glob set
78    pub(crate) ignore_set: Option<GlobSet>,
79    // Raw patterns from .claudeignore (for claudeignore_contains checks)
80    pub(crate) ignore_patterns: Vec<String>,
81
82    // Cached file reads
83    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    /// Get source files excluding tests, generated, and claudeignored files.
104    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    /// Count actual test functions across all test files.
125    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    /// Check if .claudeignore contains a pattern that would match the given string.
150    /// Used by rules to verify specific things are being ignored.
151    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    /// Check if a file path matches any .claudeignore glob pattern.
196    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