Skip to main content

claude_native/rules/
navigation.rs

1use std::collections::HashMap;
2
3use crate::rules::*;
4use crate::scan::ProjectContext;
5
6// ── Rule 3.1: Clear directory structure ─────────────────────────────
7
8pub struct ClearDirectoryStructure;
9
10impl Rule for ClearDirectoryStructure {
11    fn id(&self) -> &str { "3.1" }
12    fn name(&self) -> &str { "Clear directory structure" }
13    fn dimension(&self) -> Dimension { Dimension::Navigation }
14    fn severity(&self) -> Severity { Severity::Medium }
15
16    fn check(&self, ctx: &ProjectContext) -> RuleResult {
17        let root_source_files = ctx.all_files.iter()
18            .filter(|f| {
19                f.relative_path.parent().map(|p| p == std::path::Path::new("")).unwrap_or(true)
20                    && !f.is_test
21            })
22            .count();
23
24        if root_source_files > 15 {
25            return self.fail(
26                &format!("{root_source_files} source files at project root (>15)"),
27                Suggestion {
28                    priority: SuggestionPriority::HighImpact,
29                    title: "Organize root files into directories".into(),
30                    description: "Move source files into logical directories (src/, lib/, utils/).".into(),
31                    effort: Effort::HalfDay,
32                },
33            );
34        }
35
36        let crowded = find_crowded_dirs(ctx);
37        if !crowded.is_empty() {
38            self.warn(
39                &format!("Directories with >15 source files:\n{}", crowded.join("\n")),
40                Suggestion {
41                    priority: SuggestionPriority::NiceToHave,
42                    title: "Split crowded directories".into(),
43                    description: format!("Consider sub-grouping:\n{}", crowded.join("\n")),
44                    effort: Effort::Hour,
45                },
46            )
47        } else {
48            self.pass()
49        }
50    }
51}
52
53fn find_crowded_dirs(ctx: &ProjectContext) -> Vec<String> {
54    let mut dir_counts: HashMap<String, usize> = HashMap::new();
55    for f in &ctx.all_files {
56        if !f.is_test && !f.is_generated {
57            let dir = f.relative_path.parent()
58                .map(|p| p.to_string_lossy().to_string())
59                .unwrap_or_default();
60            *dir_counts.entry(dir).or_insert(0) += 1;
61        }
62    }
63    dir_counts.iter()
64        .filter(|(_, &count)| count > 15)
65        .map(|(dir, count)| format!("  {dir}/ ({count} files)"))
66        .collect()
67}
68
69// ── Rule 3.2: Consistent naming ────────────────────────────────────
70
71pub struct ConsistentNaming;
72
73const SKIP_NAMES: &[&str] = &[
74    "Makefile", "Dockerfile", "Gemfile", "Rakefile", "README",
75    "CLAUDE", "CHANGELOG", "LICENSE", "CONTRIBUTING", "GOLDEN",
76    "Cargo", "Pipfile", "Procfile", "Vagrantfile", "MEMORY",
77];
78
79impl Rule for ConsistentNaming {
80    fn id(&self) -> &str { "3.2" }
81    fn name(&self) -> &str { "Consistent file naming conventions" }
82    fn dimension(&self) -> Dimension { Dimension::Navigation }
83    fn severity(&self) -> Severity { Severity::Medium }
84
85    fn check(&self, ctx: &ProjectContext) -> RuleResult {
86        let (counts, total) = count_naming_conventions(ctx);
87        if total < 5 {
88            return self.pass();
89        }
90        let max_convention = counts.iter().max().copied().unwrap_or(0);
91        let consistency = max_convention as f64 / total as f64;
92        naming_result(self, consistency)
93    }
94}
95
96fn count_naming_conventions(ctx: &ProjectContext) -> ([usize; 4], usize) {
97    let mut counts = [0usize; 4]; // snake, kebab, camel, pascal
98    let mut total = 0;
99
100    for f in &ctx.all_files {
101        let stem = match f.path.file_stem().and_then(|s| s.to_str()) {
102            Some(s) => s,
103            None => continue,
104        };
105        if should_skip_name(stem) { continue; }
106        total += 1;
107        counts[classify_name(stem)] += 1;
108    }
109    (counts, total)
110}
111
112fn should_skip_name(stem: &str) -> bool {
113    SKIP_NAMES.iter().any(|s| stem.starts_with(s))
114        || stem.starts_with('.')
115        || stem.len() < 2
116        || (stem == stem.to_uppercase() && !stem.contains('-'))
117}
118
119fn classify_name(stem: &str) -> usize {
120    let is_lower = stem == stem.to_lowercase();
121    let has_underscore = stem.contains('_');
122    let has_dash = stem.contains('-');
123    let has_upper = stem.chars().any(|c| c.is_uppercase());
124
125    if (has_underscore && is_lower) || (is_lower && !has_underscore && !has_dash) {
126        0 // snake_case (includes single-word lowercase)
127    } else if has_dash && is_lower {
128        1 // kebab-case
129    } else if stem.chars().next().map(|c| c.is_lowercase()).unwrap_or(false) && has_upper {
130        2 // camelCase
131    } else if stem.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) {
132        3 // PascalCase
133    } else {
134        0 // default to snake
135    }
136}
137
138fn naming_result(rule: &ConsistentNaming, consistency: f64) -> RuleResult {
139    if consistency >= 0.8 {
140        rule.pass()
141    } else if consistency >= 0.6 {
142        rule.warn(
143            &format!("File naming is ~{:.0}% consistent (target: >80%)", consistency * 100.0),
144            Suggestion {
145                priority: SuggestionPriority::NiceToHave,
146                title: "Standardize file naming".into(),
147                description: "Pick one convention and apply consistently.".into(),
148                effort: Effort::Hour,
149            },
150        )
151    } else {
152        rule.fail(
153            &format!("File naming is only ~{:.0}% consistent", consistency * 100.0),
154            Suggestion {
155                priority: SuggestionPriority::NiceToHave,
156                title: "Standardize file naming".into(),
157                description: "Mixed naming conventions force Claude to search instead of predict.".into(),
158                effort: Effort::HalfDay,
159            },
160        )
161    }
162}
163
164// ── Rule 3.3: Obvious entry points ─────────────────────────────────
165
166pub struct ObviousEntryPoints;
167
168impl Rule for ObviousEntryPoints {
169    fn id(&self) -> &str { "3.3" }
170    fn name(&self) -> &str { "Entry points are obvious" }
171    fn dimension(&self) -> Dimension { Dimension::Navigation }
172    fn severity(&self) -> Severity { Severity::Low }
173
174    fn check(&self, ctx: &ProjectContext) -> RuleResult {
175        let entry_names = [
176            "main.rs", "main.go", "main.py", "main.dart", "main.ts", "main.js",
177            "index.ts", "index.js", "index.tsx", "index.jsx",
178            "app.py", "app.ts", "app.js", "app.rb",
179            "manage.py", "server.ts", "server.js", "lib.rs", "mod.rs",
180        ];
181        let has_entry = ctx.all_files.iter().any(|f| {
182            f.path.file_name().and_then(|n| n.to_str())
183                .map(|n| entry_names.contains(&n)).unwrap_or(false)
184        });
185        let documented = ctx.claude_md_content.as_ref().map(|c| {
186            let l = c.to_lowercase();
187            l.contains("entry point") || l.contains("entrypoint")
188        }).unwrap_or(false);
189
190        if has_entry || documented {
191            self.pass()
192        } else {
193            self.warn(
194                "No obvious entry point found (main.*, index.*, app.*)",
195                Suggestion {
196                    priority: SuggestionPriority::NiceToHave,
197                    title: "Document entry point in CLAUDE.md".into(),
198                    description: "Add 'Entry point: src/server.ts' to CLAUDE.md.".into(),
199                    effort: Effort::Minutes,
200                },
201            )
202        }
203    }
204}