1#![allow(dead_code)]
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ForeignKeyInfo {
15 pub field_name: String,
17 pub target_model: String,
19 pub target_table: String,
21 pub validated: bool,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum TestPattern {
28 None,
30 PerController,
32 Unified,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum FactoryPattern {
39 None,
41 PerModel,
43 Unified,
45}
46
47#[derive(Debug, Clone)]
49pub struct ProjectConventions {
50 pub has_tests_dir: bool,
52 pub has_factories_dir: bool,
54 pub has_inertia_pages: bool,
56 pub existing_models: Vec<String>,
58 pub test_pattern: TestPattern,
60 pub factory_pattern: FactoryPattern,
62 pub test_file_count: usize,
64 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
83pub struct ProjectAnalyzer {
85 root: PathBuf,
86}
87
88impl ProjectAnalyzer {
89 pub fn new(root: PathBuf) -> Self {
91 Self { root }
92 }
93
94 pub fn current_dir() -> Self {
96 Self::new(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
97 }
98
99 pub fn analyze(&self) -> ProjectConventions {
101 let mut conventions = ProjectConventions::default();
102
103 self.detect_tests(&mut conventions);
105
106 self.detect_factories(&mut conventions);
108
109 self.detect_inertia_pages(&mut conventions);
111
112 self.detect_models(&mut conventions);
114
115 conventions
116 }
117
118 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 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 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 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 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 if let Ok(entries) = fs::read_dir(&pages_dir) {
174 for entry in entries.flatten() {
175 let path = entry.path();
176 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 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 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 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 fn file_exists(&self, dir: &Path, filename: &str) -> bool {
235 dir.join(filename).exists()
236 }
237
238 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 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 if name_str != "mod" {
267 models.push(name_str);
268 }
269 }
270 }
271 }
272 }
273 models.sort();
274 models
275 }
276
277 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 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 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
319fn 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
332fn 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); }
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 let user_fk = fks.iter().find(|f| f.field_name == "user_id").unwrap();
463 assert!(user_fk.validated);
464
465 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 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"), ("status_id", "bigint"), ("box_id", "bigint"), ("company_id", "bigint"), ("day_id", "bigint"), ];
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 assert!(analyzer.model_exists("user"));
533 assert!(analyzer.model_exists("USER"));
534 assert!(analyzer.model_exists("blog_post"));
535
536 assert!(analyzer.model_exists("User"));
538 assert!(analyzer.model_exists("BlogPost"));
539
540 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}