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    pub agents_md_content: Option<String>,
67
68    // Derived analysis
69    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    // Compiled .claudeignore glob set
81    pub(crate) ignore_set: Option<GlobSet>,
82    // Raw patterns from .claudeignore (for claudeignore_contains checks)
83    pub(crate) ignore_patterns: Vec<String>,
84
85    // Cached file reads
86    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    /// Get source files excluding tests, generated, and claudeignored files.
107    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    /// Count actual test functions across all test files.
128    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    /// Check if .claudeignore contains a pattern that would match the given string.
153    /// Used by rules to verify specific things are being ignored.
154    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    /// Check if a file path matches any .claudeignore glob pattern.
199    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