acp/annotate/heuristics/
path.rs

1//! @acp:module "Path Heuristics"
2//! @acp:summary "Infers annotations from file path patterns"
3//! @acp:domain cli
4//! @acp:layer service
5//! @acp:stability experimental
6//!
7//! # Path Heuristics
8//!
9//! Analyzes file paths to infer appropriate annotations:
10//! - Domain inference from directory names (auth → authentication)
11//! - Layer inference from directory structure (handlers → handler)
12//! - Module naming from path components
13
14use std::collections::HashMap;
15use std::path::Path;
16
17use crate::annotate::{AnnotationType, Suggestion, SuggestionSource};
18
19/// @acp:summary "Common directory → domain mappings"
20static PATH_DOMAIN_MAPPINGS: &[(&str, &str)] = &[
21    ("auth", "authentication"),
22    ("authentication", "authentication"),
23    ("login", "authentication"),
24    ("user", "users"),
25    ("users", "users"),
26    ("account", "users"),
27    ("billing", "billing"),
28    ("payment", "billing"),
29    ("payments", "billing"),
30    ("stripe", "billing"),
31    ("api", "api"),
32    ("rest", "api"),
33    ("graphql", "api"),
34    ("grpc", "api"),
35    ("db", "database"),
36    ("database", "database"),
37    ("models", "database"),
38    ("repository", "database"),
39    ("repositories", "database"),
40    ("cache", "caching"),
41    ("redis", "caching"),
42    ("queue", "messaging"),
43    ("messaging", "messaging"),
44    ("events", "messaging"),
45    ("pubsub", "messaging"),
46    ("util", "utilities"),
47    ("utils", "utilities"),
48    ("helpers", "utilities"),
49    ("common", "utilities"),
50    ("shared", "utilities"),
51    ("test", "testing"),
52    ("tests", "testing"),
53    ("spec", "testing"),
54    ("__tests__", "testing"),
55    ("e2e", "testing"),
56    ("integration", "testing"),
57    ("config", "configuration"),
58    ("settings", "configuration"),
59    ("middleware", "middleware"),
60    ("interceptor", "middleware"),
61    ("handlers", "handlers"),
62    ("controllers", "handlers"),
63    ("services", "services"),
64    ("domain", "domain"),
65    ("core", "core"),
66    ("lib", "library"),
67    ("pkg", "library"),
68    ("internal", "internal"),
69    ("vendor", "vendor"),
70    ("third_party", "vendor"),
71    ("security", "security"),
72    ("crypto", "security"),
73    ("notifications", "notifications"),
74    ("email", "notifications"),
75    ("sms", "notifications"),
76    ("analytics", "analytics"),
77    ("metrics", "analytics"),
78    ("monitoring", "monitoring"),
79    ("logging", "monitoring"),
80    ("admin", "administration"),
81    ("dashboard", "administration"),
82];
83
84/// @acp:summary "Common directory → layer mappings"
85static PATH_LAYER_MAPPINGS: &[(&str, &str)] = &[
86    ("handlers", "handler"),
87    ("controllers", "handler"),
88    ("api", "handler"),
89    ("routes", "handler"),
90    ("endpoints", "handler"),
91    ("services", "service"),
92    ("usecases", "service"),
93    ("application", "service"),
94    ("repository", "repository"),
95    ("repositories", "repository"),
96    ("dao", "repository"),
97    ("db", "repository"),
98    ("models", "model"),
99    ("entities", "model"),
100    ("domain", "model"),
101    ("middleware", "middleware"),
102    ("interceptors", "middleware"),
103    ("filters", "middleware"),
104    ("guards", "middleware"),
105    ("utils", "utility"),
106    ("helpers", "utility"),
107    ("lib", "utility"),
108    ("common", "utility"),
109    ("config", "config"),
110    ("settings", "config"),
111];
112
113/// @acp:summary "Infers annotations from file path patterns"
114/// @acp:lock normal
115pub struct PathHeuristics {
116    /// Custom domain mappings (path component → domain name)
117    custom_domain_mappings: HashMap<String, String>,
118}
119
120impl PathHeuristics {
121    /// @acp:summary "Creates a new path heuristics analyzer"
122    pub fn new() -> Self {
123        Self {
124            custom_domain_mappings: HashMap::new(),
125        }
126    }
127
128    /// @acp:summary "Adds custom domain mappings"
129    pub fn with_domain_mappings(mut self, mappings: HashMap<String, String>) -> Self {
130        self.custom_domain_mappings = mappings;
131        self
132    }
133
134    /// @acp:summary "Generates suggestions based on file path"
135    pub fn suggest(&self, file_path: &str, target: &str, line: usize) -> Vec<Suggestion> {
136        let mut suggestions = Vec::new();
137        let path = Path::new(file_path);
138
139        // Check each path component for domain/layer patterns
140        for component in path.components() {
141            let comp_str = component.as_os_str().to_string_lossy().to_lowercase();
142
143            // Check custom mappings first
144            if let Some(domain) = self.custom_domain_mappings.get(&comp_str) {
145                suggestions.push(
146                    Suggestion::domain(target, line, domain, SuggestionSource::Heuristic)
147                        .with_confidence(0.8),
148                );
149            }
150
151            // Check standard domain mappings
152            for (pattern, domain) in PATH_DOMAIN_MAPPINGS.iter() {
153                if comp_str == *pattern || comp_str.contains(pattern) {
154                    suggestions.push(
155                        Suggestion::domain(target, line, *domain, SuggestionSource::Heuristic)
156                            .with_confidence(0.7),
157                    );
158                    break; // Only one domain per component
159                }
160            }
161
162            // Check layer mappings
163            for (pattern, layer) in PATH_LAYER_MAPPINGS.iter() {
164                if comp_str == *pattern || comp_str.contains(pattern) {
165                    suggestions.push(
166                        Suggestion::layer(target, line, *layer, SuggestionSource::Heuristic)
167                            .with_confidence(0.6),
168                    );
169                    break; // Only one layer per component
170                }
171            }
172        }
173
174        // Deduplicate suggestions by value
175        let mut seen_domains = std::collections::HashSet::new();
176        let mut seen_layers = std::collections::HashSet::new();
177
178        suggestions.retain(|s| match s.annotation_type {
179            AnnotationType::Domain => seen_domains.insert(s.value.clone()),
180            AnnotationType::Layer => seen_layers.insert(s.value.clone()),
181            _ => true,
182        });
183
184        suggestions
185    }
186
187    /// @acp:summary "Infers a module name from file path"
188    ///
189    /// Uses directory name + file name to generate a human-readable module name.
190    pub fn infer_module_name(&self, file_path: &str) -> Option<String> {
191        let path = Path::new(file_path);
192
193        let file_stem = path.file_stem()?.to_string_lossy();
194
195        // Skip generic file names
196        let generic_names = ["index", "mod", "main", "lib", "__init__", "init"];
197        if generic_names.contains(&file_stem.as_ref()) {
198            // Use parent directory name instead
199            let parent = path.parent()?.file_name()?.to_string_lossy();
200            return Some(humanize_name(&parent));
201        }
202
203        Some(humanize_name(&file_stem))
204    }
205
206    /// @acp:summary "Checks if a path is in a test directory"
207    pub fn is_test_path(&self, file_path: &str) -> bool {
208        let path_lower = file_path.to_lowercase();
209        let test_indicators = [
210            "/test/",
211            "/tests/",
212            "/__tests__/",
213            "/spec/",
214            "/e2e/",
215            ".test.",
216            ".spec.",
217            "_test.",
218            "_spec.",
219        ];
220
221        test_indicators.iter().any(|ind| path_lower.contains(ind))
222    }
223}
224
225impl Default for PathHeuristics {
226    fn default() -> Self {
227        Self::new()
228    }
229}
230
231/// @acp:summary "Converts an identifier to a human-readable name"
232fn humanize_name(name: &str) -> String {
233    // Split on underscores, hyphens, and camelCase
234    let mut words = Vec::new();
235    let mut current = String::new();
236
237    for (i, c) in name.chars().enumerate() {
238        if c == '_' || c == '-' {
239            if !current.is_empty() {
240                words.push(current);
241                current = String::new();
242            }
243        } else if c.is_uppercase() && i > 0 {
244            if !current.is_empty() {
245                words.push(current);
246                current = String::new();
247            }
248            current.push(c);
249        } else {
250            current.push(c);
251        }
252    }
253
254    if !current.is_empty() {
255        words.push(current);
256    }
257
258    // Capitalize first word, lowercase the rest
259    words
260        .iter()
261        .enumerate()
262        .map(|(i, w)| {
263            if i == 0 {
264                let mut chars = w.chars();
265                match chars.next() {
266                    None => String::new(),
267                    Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
268                }
269            } else {
270                w.to_lowercase()
271            }
272        })
273        .collect::<Vec<_>>()
274        .join(" ")
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_suggest_domain_from_path() {
283        let heuristics = PathHeuristics::new();
284
285        let suggestions = heuristics.suggest("src/auth/service.ts", "AuthService", 10);
286        let has_auth_domain = suggestions
287            .iter()
288            .any(|s| s.annotation_type == AnnotationType::Domain && s.value == "authentication");
289        assert!(has_auth_domain);
290
291        let suggestions = heuristics.suggest("src/billing/payments.ts", "ProcessPayment", 10);
292        let has_billing_domain = suggestions
293            .iter()
294            .any(|s| s.annotation_type == AnnotationType::Domain && s.value == "billing");
295        assert!(has_billing_domain);
296    }
297
298    #[test]
299    fn test_suggest_layer_from_path() {
300        let heuristics = PathHeuristics::new();
301
302        let suggestions = heuristics.suggest("src/handlers/user.ts", "UserHandler", 10);
303        let has_handler_layer = suggestions
304            .iter()
305            .any(|s| s.annotation_type == AnnotationType::Layer && s.value == "handler");
306        assert!(has_handler_layer);
307
308        let suggestions = heuristics.suggest("src/services/auth.ts", "AuthService", 10);
309        let has_service_layer = suggestions
310            .iter()
311            .any(|s| s.annotation_type == AnnotationType::Layer && s.value == "service");
312        assert!(has_service_layer);
313    }
314
315    #[test]
316    fn test_infer_module_name() {
317        let heuristics = PathHeuristics::new();
318
319        assert_eq!(
320            heuristics.infer_module_name("src/auth/session_service.ts"),
321            Some("Session service".to_string())
322        );
323
324        assert_eq!(
325            heuristics.infer_module_name("src/auth/index.ts"),
326            Some("Auth".to_string())
327        );
328    }
329
330    #[test]
331    fn test_is_test_path() {
332        let heuristics = PathHeuristics::new();
333
334        assert!(heuristics.is_test_path("src/__tests__/auth.test.ts"));
335        assert!(heuristics.is_test_path("tests/integration/user.spec.ts"));
336        assert!(!heuristics.is_test_path("src/services/auth.ts"));
337    }
338
339    #[test]
340    fn test_humanize_name() {
341        assert_eq!(humanize_name("user_service"), "User service");
342        assert_eq!(humanize_name("UserService"), "User service");
343        assert_eq!(humanize_name("auth-handler"), "Auth handler");
344    }
345
346    #[test]
347    fn test_custom_domain_mappings() {
348        let mut mappings = HashMap::new();
349        mappings.insert("checkout".to_string(), "commerce".to_string());
350
351        let heuristics = PathHeuristics::new().with_domain_mappings(mappings);
352        let suggestions = heuristics.suggest("src/checkout/cart.ts", "Cart", 10);
353
354        let has_commerce = suggestions
355            .iter()
356            .any(|s| s.annotation_type == AnnotationType::Domain && s.value == "commerce");
357        assert!(has_commerce);
358    }
359}