Skip to main content

claude_native/rules/
token_suggestions.rs

1use crate::detection::{Language, ProjectType};
2use crate::rules::*;
3use crate::scan::ProjectContext;
4
5// ═══════════════════════════════════════════════════════════════════
6// R4: Index/barrel file per module (language-aware)
7// ═══════════════════════════════════════════════════════════════════
8
9pub struct LanguageAwareIndexFiles;
10
11impl Rule for LanguageAwareIndexFiles {
12    fn id(&self) -> &str { "7.6" }
13    fn name(&self) -> &str { "Modules have language-appropriate index files" }
14    fn dimension(&self) -> Dimension { Dimension::Navigation }
15    fn severity(&self) -> Severity { Severity::Low }
16
17    fn check(&self, ctx: &ProjectContext) -> RuleResult {
18        let pt = match &ctx.project_type {
19            Some(pt) => pt,
20            None => return self.skip(),
21        };
22
23        let expected = expected_index_files(&pt.languages);
24        if expected.is_empty() { return self.pass(); }
25
26        let source_dirs = significant_source_dirs(ctx);
27        if source_dirs.is_empty() { return self.pass(); }
28
29        let missing: Vec<String> = source_dirs.iter()
30            .filter(|d| !expected.iter().any(|idx| d.join(idx).exists()))
31            .filter_map(|d| d.strip_prefix(&ctx.root).ok())
32            .map(|p| p.to_string_lossy().to_string())
33            .collect();
34
35        if missing.is_empty() {
36            self.pass()
37        } else {
38            let idx_names = expected.join(" or ");
39            self.warn(
40                &format!("{} dirs lack {idx_names}", missing.len()),
41                Suggestion {
42                    priority: SuggestionPriority::NiceToHave,
43                    title: format!("Add {idx_names} to modules"),
44                    description: format!(
45                        "For your project ({:?}), add {idx_names} to each module directory. \
46                         Claude reads the index file to understand module exports without scanning all files.\n\
47                         Missing in: {}",
48                        pt.languages.first().unwrap_or(&Language::Other("unknown".into())),
49                        missing.iter().take(5).cloned().collect::<Vec<_>>().join(", ")
50                    ),
51                    effort: Effort::Hour,
52                },
53            )
54        }
55    }
56}
57
58fn expected_index_files(langs: &[Language]) -> Vec<&'static str> {
59    for lang in langs {
60        match lang {
61            Language::Rust => return vec!["mod.rs"],
62            Language::TypeScript => return vec!["index.ts", "index.tsx"],
63            Language::JavaScript => return vec!["index.js", "index.jsx"],
64            Language::Python => return vec!["__init__.py"],
65            Language::Dart => return vec!["index.dart"],
66            Language::Go => {}, // Go uses package-level, no index
67            Language::Ruby => return vec!["index.rb"],
68            Language::CSharp => {}, // Namespace-based
69            _ => {}
70        }
71    }
72    vec![]
73}
74
75fn significant_source_dirs(ctx: &ProjectContext) -> Vec<std::path::PathBuf> {
76    use std::collections::HashMap;
77    let skip = [".claude", ".github", "tests", "test", "docs", "examples", "target"];
78    let code_exts = ["rs", "ts", "tsx", "js", "py", "go", "dart", "kt", "java", "rb", "cs", "swift"];
79    let mut counts: HashMap<std::path::PathBuf, usize> = HashMap::new();
80
81    for f in ctx.source_files() {
82        let is_code = f.path.extension().and_then(|e| e.to_str())
83            .map(|e| code_exts.contains(&e)).unwrap_or(false);
84        if !is_code { continue; }
85        if let Some(parent) = f.path.parent() {
86            if parent == ctx.root { continue; }
87            // Skip src/ root — it uses lib.rs as crate entry, not mod.rs
88            if parent.file_name().map(|n| n == "src").unwrap_or(false)
89                && parent.parent().map(|p| p == ctx.root).unwrap_or(false) { continue; }
90            let name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
91            if name.starts_with('.') || skip.contains(&name) { continue; }
92            *counts.entry(parent.to_path_buf()).or_insert(0) += 1;
93        }
94    }
95    counts.into_iter().filter(|(_, c)| *c >= 3).map(|(p, _)| p).collect()
96}
97
98// ═══════════════════════════════════════════════════════════════════
99// R5+R8: Public API and constants at top of files
100// ═══════════════════════════════════════════════════════════════════
101
102pub struct PublicApiAtTop;
103
104impl Rule for PublicApiAtTop {
105    fn id(&self) -> &str { "7.7" }
106    fn name(&self) -> &str { "Public APIs/exports at top of files" }
107    fn dimension(&self) -> Dimension { Dimension::ContextEfficiency }
108    fn severity(&self) -> Severity { Severity::Low }
109
110    fn check(&self, ctx: &ProjectContext) -> RuleResult {
111        let mut buried_count = 0;
112        let sample = ctx.source_files();
113        let sample: Vec<_> = sample.iter().take(20).collect();
114
115        for f in &sample {
116            if f.line_count < 50 { continue; } // skip small files
117            if let Ok(content) = std::fs::read_to_string(&f.path) {
118                if has_buried_public_api(&content) {
119                    buried_count += 1;
120                }
121            }
122        }
123
124        if buried_count == 0 {
125            self.pass()
126        } else {
127            self.warn(
128                &format!("{buried_count} files have public APIs below private code"),
129                Suggestion {
130                    priority: SuggestionPriority::NiceToHave,
131                    title: "Move public APIs to top of files".into(),
132                    description: "Public functions/exports should be above private helpers. \
133                        Claude reads top-down — if exports are at line 200, it reads 200 lines to find the API. \
134                        Put `pub fn`, `export`, or constants at the top. Saves ~100 tokens per file read.".into(),
135                    effort: Effort::Hour,
136                },
137            )
138        }
139    }
140}
141
142fn has_buried_public_api(content: &str) -> bool {
143    // Skip files that are primarily trait implementations (e.g., Rule trait impls)
144    // These naturally have helpers before pub structs — that's fine.
145    if content.contains("impl Rule for") || content.contains("impl Handler for") {
146        return false;
147    }
148
149    let mut first_private_line: Option<usize> = None;
150    let mut last_public_line: Option<usize> = None;
151
152    for (i, line) in content.lines().enumerate() {
153        let t = line.trim();
154        // Only count standalone pub fn/struct at module level (not inside impl blocks)
155        if (t.starts_with("pub fn ") || t.starts_with("pub struct ")
156            || t.starts_with("pub enum ") || t.starts_with("pub type ")
157            || t.starts_with("export ") || t.starts_with("export default"))
158            && !t.contains("(&self")  // skip methods inside impl blocks
159        {
160            last_public_line = Some(i);
161        }
162        // Private standalone functions (not methods)
163        if (t.starts_with("fn ") && !t.starts_with("fn main") && !t.contains("(&self"))
164            || (t.starts_with("struct ") && !t.starts_with("pub"))
165            || (t.starts_with("def ") && !t.starts_with("def _"))
166        {
167            if first_private_line.is_none() {
168                first_private_line = Some(i);
169            }
170        }
171    }
172
173    match (first_private_line, last_public_line) {
174        (Some(priv_line), Some(pub_line)) => pub_line > priv_line + 30,
175        _ => false,
176    }
177}
178
179// R6, R7, R10, R13 are in token_checks.rs