Skip to main content

ferro_cli/
analyzer.rs

1//! Project structure analyzer for convention detection.
2//!
3//! Scans project directories to detect existing patterns and conventions,
4//! enabling smart defaults for CLI commands like make:scaffold.
5
6// Allow unused code warnings as this module is used by make_scaffold in subsequent tasks
7#![allow(dead_code)]
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12/// Information about a detected foreign key relationship.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ForeignKeyInfo {
15    /// The field name (e.g., "user_id")
16    pub field_name: String,
17    /// The target model name in PascalCase (e.g., "User")
18    pub target_model: String,
19    /// The target table name in snake_case plural (e.g., "users")
20    pub target_table: String,
21    /// Whether the target model exists in the project
22    pub validated: bool,
23}
24
25/// Pattern for test file organization.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum TestPattern {
28    /// No test files detected
29    None,
30    /// Tests organized per controller (e.g., user_controller_test.rs)
31    PerController,
32    /// Tests in a unified test file
33    Unified,
34}
35
36/// Pattern for factory file organization.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum FactoryPattern {
39    /// No factory files detected
40    None,
41    /// Factories organized per model (e.g., user_factory.rs)
42    PerModel,
43    /// Factories in a unified factory file
44    Unified,
45}
46
47/// Detected project conventions and structure.
48#[derive(Debug, Clone)]
49pub struct ProjectConventions {
50    /// Whether src/tests/ directory exists
51    pub has_tests_dir: bool,
52    /// Whether src/factories/ directory exists
53    pub has_factories_dir: bool,
54    /// Whether frontend/src/pages/ directory exists with content
55    pub has_inertia_pages: bool,
56    /// List of existing model names (from src/models/)
57    pub existing_models: Vec<String>,
58    /// Detected test file organization pattern
59    pub test_pattern: TestPattern,
60    /// Detected factory file organization pattern
61    pub factory_pattern: FactoryPattern,
62    /// Number of existing test files
63    pub test_file_count: usize,
64    /// Number of existing factory files
65    pub factory_file_count: usize,
66}
67
68impl Default for ProjectConventions {
69    fn default() -> Self {
70        Self {
71            has_tests_dir: false,
72            has_factories_dir: false,
73            has_inertia_pages: false,
74            existing_models: Vec::new(),
75            test_pattern: TestPattern::None,
76            factory_pattern: FactoryPattern::None,
77            test_file_count: 0,
78            factory_file_count: 0,
79        }
80    }
81}
82
83/// Analyzer for detecting project conventions and patterns.
84pub struct ProjectAnalyzer {
85    root: PathBuf,
86}
87
88impl ProjectAnalyzer {
89    /// Create a new analyzer for the given project root.
90    pub fn new(root: PathBuf) -> Self {
91        Self { root }
92    }
93
94    /// Create an analyzer for the current working directory.
95    pub fn current_dir() -> Self {
96        Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
97    }
98
99    /// Analyze the project structure and return detected conventions.
100    pub fn analyze(&self) -> ProjectConventions {
101        let mut conventions = ProjectConventions::default();
102
103        // Detect tests directory and pattern
104        self.detect_tests(&mut conventions);
105
106        // Detect factories directory and pattern
107        self.detect_factories(&mut conventions);
108
109        // Detect Inertia pages
110        self.detect_inertia_pages(&mut conventions);
111
112        // Detect existing models
113        self.detect_models(&mut conventions);
114
115        conventions
116    }
117
118    /// Detect test directory presence and pattern.
119    fn detect_tests(&self, conventions: &mut ProjectConventions) {
120        let tests_dir = self.root.join("src/tests");
121
122        if !tests_dir.exists() || !tests_dir.is_dir() {
123            return;
124        }
125
126        conventions.has_tests_dir = true;
127
128        // Count test files and detect pattern
129        let test_files = self.count_files_matching(&tests_dir, "_controller_test.rs");
130        let unified_test = self.file_exists(&tests_dir, "tests.rs");
131
132        conventions.test_file_count = test_files;
133
134        if test_files > 0 {
135            conventions.test_pattern = TestPattern::PerController;
136        } else if unified_test {
137            conventions.test_pattern = TestPattern::Unified;
138        }
139    }
140
141    /// Detect factories directory presence and pattern.
142    fn detect_factories(&self, conventions: &mut ProjectConventions) {
143        let factories_dir = self.root.join("src/factories");
144
145        if !factories_dir.exists() || !factories_dir.is_dir() {
146            return;
147        }
148
149        conventions.has_factories_dir = true;
150
151        // Count factory files and detect pattern
152        let factory_files = self.count_files_matching(&factories_dir, "_factory.rs");
153        let unified_factory = self.file_exists(&factories_dir, "factory.rs");
154
155        conventions.factory_file_count = factory_files;
156
157        if factory_files > 0 {
158            conventions.factory_pattern = FactoryPattern::PerModel;
159        } else if unified_factory {
160            conventions.factory_pattern = FactoryPattern::Unified;
161        }
162    }
163
164    /// Detect Inertia pages directory presence and content.
165    fn detect_inertia_pages(&self, conventions: &mut ProjectConventions) {
166        let pages_dir = self.root.join("frontend/src/pages");
167
168        if !pages_dir.exists() || !pages_dir.is_dir() {
169            return;
170        }
171
172        // Check if there are any .tsx files (indicating Inertia pages)
173        if let Ok(entries) = fs::read_dir(&pages_dir) {
174            for entry in entries.flatten() {
175                let path = entry.path();
176                // Check for either .tsx files or subdirectories with .tsx files
177                if path.is_dir() {
178                    if self.has_tsx_files(&path) {
179                        conventions.has_inertia_pages = true;
180                        return;
181                    }
182                } else if path.extension().is_some_and(|ext| ext == "tsx") {
183                    conventions.has_inertia_pages = true;
184                    return;
185                }
186            }
187        }
188    }
189
190    /// Detect existing models from src/models/ directory.
191    fn detect_models(&self, conventions: &mut ProjectConventions) {
192        let models_dir = self.root.join("src/models");
193
194        if !models_dir.exists() || !models_dir.is_dir() {
195            return;
196        }
197
198        if let Ok(entries) = fs::read_dir(&models_dir) {
199            for entry in entries.flatten() {
200                let path = entry.path();
201                if path.is_file() {
202                    if let Some(name) = path.file_stem() {
203                        let name_str = name.to_string_lossy().to_string();
204                        // Skip mod.rs
205                        if name_str != "mod" {
206                            conventions.existing_models.push(name_str);
207                        }
208                    }
209                }
210            }
211        }
212
213        conventions.existing_models.sort();
214    }
215
216    /// Count files in a directory matching a suffix pattern.
217    fn count_files_matching(&self, dir: &Path, suffix: &str) -> usize {
218        let Ok(entries) = fs::read_dir(dir) else {
219            return 0;
220        };
221
222        entries
223            .filter_map(Result::ok)
224            .filter(|e| {
225                e.path().is_file()
226                    && e.path()
227                        .file_name()
228                        .is_some_and(|n| n.to_string_lossy().ends_with(suffix))
229            })
230            .count()
231    }
232
233    /// Check if a specific file exists in a directory.
234    fn file_exists(&self, dir: &Path, filename: &str) -> bool {
235        dir.join(filename).exists()
236    }
237
238    /// Check if a directory contains any .tsx files.
239    fn has_tsx_files(&self, dir: &Path) -> bool {
240        let Ok(entries) = fs::read_dir(dir) else {
241            return false;
242        };
243
244        entries.filter_map(Result::ok).any(|e| {
245            let path = e.path();
246            path.is_file() && path.extension().is_some_and(|ext| ext == "tsx")
247        })
248    }
249
250    /// List all existing model names (snake_case) in the project.
251    pub fn list_models(&self) -> Vec<String> {
252        let models_dir = self.root.join("src/models");
253
254        if !models_dir.exists() || !models_dir.is_dir() {
255            return Vec::new();
256        }
257
258        let mut models = Vec::new();
259        if let Ok(entries) = fs::read_dir(&models_dir) {
260            for entry in entries.flatten() {
261                let path = entry.path();
262                if path.is_file() {
263                    if let Some(name) = path.file_stem() {
264                        let name_str = name.to_string_lossy().to_string();
265                        // Skip mod.rs
266                        if name_str != "mod" {
267                            models.push(name_str);
268                        }
269                    }
270                }
271            }
272        }
273        models.sort();
274        models
275    }
276
277    /// Check if a model exists in the project (case-insensitive).
278    pub fn model_exists(&self, name: &str) -> bool {
279        let models = self.list_models();
280        let name_lower = name.to_lowercase();
281
282        models.iter().any(|m| {
283            m.to_lowercase() == name_lower || to_pascal_case(m).to_lowercase() == name_lower
284        })
285    }
286
287    /// Detect foreign key relationships from a list of fields.
288    ///
289    /// Fields ending in `_id` are considered potential foreign keys.
290    /// Returns information about each detected FK including whether
291    /// the target model exists in the project.
292    pub fn detect_foreign_keys(&self, fields: &[(&str, &str)]) -> Vec<ForeignKeyInfo> {
293        let mut fks = Vec::new();
294
295        for (field_name, _field_type) in fields {
296            if let Some(prefix) = field_name.strip_suffix("_id") {
297                // Skip "id" field itself
298                if prefix.is_empty() {
299                    continue;
300                }
301
302                let target_model = to_pascal_case(prefix);
303                let target_table = to_plural(prefix);
304                let validated = self.model_exists(&target_model);
305
306                fks.push(ForeignKeyInfo {
307                    field_name: field_name.to_string(),
308                    target_model,
309                    target_table,
310                    validated,
311                });
312            }
313        }
314
315        fks
316    }
317}
318
319/// Convert snake_case to PascalCase.
320fn to_pascal_case(s: &str) -> String {
321    s.split('_')
322        .map(|part| {
323            let mut chars = part.chars();
324            match chars.next() {
325                None => String::new(),
326                Some(first) => first.to_uppercase().chain(chars).collect(),
327            }
328        })
329        .collect()
330}
331
332/// Convert snake_case singular to snake_case plural.
333fn to_plural(s: &str) -> String {
334    if s.ends_with('s') || s.ends_with('x') || s.ends_with("ch") || s.ends_with("sh") {
335        format!("{s}es")
336    } else if s.ends_with('y')
337        && !s.ends_with("ay")
338        && !s.ends_with("ey")
339        && !s.ends_with("oy")
340        && !s.ends_with("uy")
341    {
342        format!("{}ies", &s[..s.len() - 1])
343    } else {
344        format!("{s}s")
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use std::fs;
352    use tempfile::TempDir;
353
354    fn create_test_project() -> TempDir {
355        TempDir::new().unwrap()
356    }
357
358    #[test]
359    fn test_analyzer_detects_empty_project() {
360        let temp = create_test_project();
361        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
362        let conventions = analyzer.analyze();
363
364        assert!(!conventions.has_tests_dir);
365        assert!(!conventions.has_factories_dir);
366        assert!(!conventions.has_inertia_pages);
367        assert!(conventions.existing_models.is_empty());
368        assert_eq!(conventions.test_pattern, TestPattern::None);
369        assert_eq!(conventions.factory_pattern, FactoryPattern::None);
370    }
371
372    #[test]
373    fn test_analyzer_detects_tests_directory() {
374        let temp = create_test_project();
375        let tests_dir = temp.path().join("src/tests");
376        fs::create_dir_all(&tests_dir).unwrap();
377        fs::write(tests_dir.join("user_controller_test.rs"), "").unwrap();
378        fs::write(tests_dir.join("post_controller_test.rs"), "").unwrap();
379
380        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
381        let conventions = analyzer.analyze();
382
383        assert!(conventions.has_tests_dir);
384        assert_eq!(conventions.test_pattern, TestPattern::PerController);
385        assert_eq!(conventions.test_file_count, 2);
386    }
387
388    #[test]
389    fn test_analyzer_detects_factories_directory() {
390        let temp = create_test_project();
391        let factories_dir = temp.path().join("src/factories");
392        fs::create_dir_all(&factories_dir).unwrap();
393        fs::write(factories_dir.join("user_factory.rs"), "").unwrap();
394        fs::write(factories_dir.join("mod.rs"), "").unwrap();
395
396        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
397        let conventions = analyzer.analyze();
398
399        assert!(conventions.has_factories_dir);
400        assert_eq!(conventions.factory_pattern, FactoryPattern::PerModel);
401        assert_eq!(conventions.factory_file_count, 1);
402    }
403
404    #[test]
405    fn test_analyzer_detects_inertia_pages() {
406        let temp = create_test_project();
407        let pages_dir = temp.path().join("frontend/src/pages/users");
408        fs::create_dir_all(&pages_dir).unwrap();
409        fs::write(pages_dir.join("Index.tsx"), "").unwrap();
410
411        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
412        let conventions = analyzer.analyze();
413
414        assert!(conventions.has_inertia_pages);
415    }
416
417    #[test]
418    fn test_analyzer_detects_models() {
419        let temp = create_test_project();
420        let models_dir = temp.path().join("src/models");
421        fs::create_dir_all(&models_dir).unwrap();
422        fs::write(models_dir.join("user.rs"), "").unwrap();
423        fs::write(models_dir.join("post.rs"), "").unwrap();
424        fs::write(models_dir.join("mod.rs"), "").unwrap();
425
426        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
427        let conventions = analyzer.analyze();
428
429        assert_eq!(conventions.existing_models, vec!["post", "user"]);
430    }
431
432    #[test]
433    fn test_detect_foreign_keys_simple() {
434        let temp = create_test_project();
435        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
436
437        let fields = [("user_id", "bigint"), ("title", "string")];
438        let fks = analyzer.detect_foreign_keys(&fields);
439
440        assert_eq!(fks.len(), 1);
441        assert_eq!(fks[0].field_name, "user_id");
442        assert_eq!(fks[0].target_model, "User");
443        assert_eq!(fks[0].target_table, "users");
444        assert!(!fks[0].validated); // No model exists
445    }
446
447    #[test]
448    fn test_detect_foreign_keys_validated() {
449        let temp = create_test_project();
450        let models_dir = temp.path().join("src/models");
451        fs::create_dir_all(&models_dir).unwrap();
452        fs::write(models_dir.join("user.rs"), "").unwrap();
453
454        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
455
456        let fields = [("user_id", "bigint"), ("category_id", "bigint")];
457        let fks = analyzer.detect_foreign_keys(&fields);
458
459        assert_eq!(fks.len(), 2);
460
461        // user_id should be validated (model exists)
462        let user_fk = fks.iter().find(|f| f.field_name == "user_id").unwrap();
463        assert!(user_fk.validated);
464
465        // category_id should not be validated (no model)
466        let category_fk = fks.iter().find(|f| f.field_name == "category_id").unwrap();
467        assert!(!category_fk.validated);
468    }
469
470    #[test]
471    fn test_detect_foreign_keys_compound_name() {
472        let temp = create_test_project();
473        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
474
475        let fields = [("blog_post_id", "bigint")];
476        let fks = analyzer.detect_foreign_keys(&fields);
477
478        assert_eq!(fks.len(), 1);
479        assert_eq!(fks[0].field_name, "blog_post_id");
480        assert_eq!(fks[0].target_model, "BlogPost");
481        assert_eq!(fks[0].target_table, "blog_posts");
482    }
483
484    #[test]
485    fn test_detect_foreign_keys_ignores_id_field() {
486        let temp = create_test_project();
487        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
488
489        // "id" field should not be detected as FK
490        let fields = [("id", "bigint"), ("user_id", "bigint")];
491        let fks = analyzer.detect_foreign_keys(&fields);
492
493        assert_eq!(fks.len(), 1);
494        assert_eq!(fks[0].field_name, "user_id");
495    }
496
497    #[test]
498    fn test_detect_foreign_keys_pluralization() {
499        let temp = create_test_project();
500        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
501
502        let fields = [
503            ("category_id", "bigint"), // category -> categories
504            ("status_id", "bigint"),   // status -> statuses
505            ("box_id", "bigint"),      // box -> boxes
506            ("company_id", "bigint"),  // company -> companies
507            ("day_id", "bigint"),      // day -> days (vowel + y)
508        ];
509        let fks = analyzer.detect_foreign_keys(&fields);
510
511        assert_eq!(fks.len(), 5);
512
513        let tables: Vec<_> = fks.iter().map(|f| f.target_table.as_str()).collect();
514        assert!(tables.contains(&"categories"));
515        assert!(tables.contains(&"statuses"));
516        assert!(tables.contains(&"boxes"));
517        assert!(tables.contains(&"companies"));
518        assert!(tables.contains(&"days"));
519    }
520
521    #[test]
522    fn test_model_exists_case_insensitive() {
523        let temp = create_test_project();
524        let models_dir = temp.path().join("src/models");
525        fs::create_dir_all(&models_dir).unwrap();
526        fs::write(models_dir.join("user.rs"), "").unwrap();
527        fs::write(models_dir.join("blog_post.rs"), "").unwrap();
528
529        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
530
531        // Should match by snake_case name
532        assert!(analyzer.model_exists("user"));
533        assert!(analyzer.model_exists("USER"));
534        assert!(analyzer.model_exists("blog_post"));
535
536        // Should match by PascalCase name
537        assert!(analyzer.model_exists("User"));
538        assert!(analyzer.model_exists("BlogPost"));
539
540        // Should not match non-existent
541        assert!(!analyzer.model_exists("category"));
542        assert!(!analyzer.model_exists("Category"));
543    }
544
545    #[test]
546    fn test_list_models() {
547        let temp = create_test_project();
548        let models_dir = temp.path().join("src/models");
549        fs::create_dir_all(&models_dir).unwrap();
550        fs::write(models_dir.join("user.rs"), "").unwrap();
551        fs::write(models_dir.join("post.rs"), "").unwrap();
552        fs::write(models_dir.join("mod.rs"), "").unwrap();
553
554        let analyzer = ProjectAnalyzer::new(temp.path().to_path_buf());
555        let models = analyzer.list_models();
556
557        assert_eq!(models, vec!["post", "user"]);
558    }
559
560    #[test]
561    fn test_to_pascal_case() {
562        assert_eq!(to_pascal_case("user"), "User");
563        assert_eq!(to_pascal_case("blog_post"), "BlogPost");
564        assert_eq!(
565            to_pascal_case("user_profile_settings"),
566            "UserProfileSettings"
567        );
568    }
569
570    #[test]
571    fn test_to_plural() {
572        assert_eq!(to_plural("user"), "users");
573        assert_eq!(to_plural("category"), "categories");
574        assert_eq!(to_plural("status"), "statuses");
575        assert_eq!(to_plural("box"), "boxes");
576        assert_eq!(to_plural("day"), "days");
577        assert_eq!(to_plural("key"), "keys");
578    }
579}