1use std::{
21 fs::{read_dir, remove_dir as remove_directory, rename},
22 io,
23 path::{Path, PathBuf}
24};
25
26use ignore::WalkBuilder;
27use masterror::AppResult;
28
29use crate::error::IoError;
30
31#[derive(Debug, Clone)]
35pub struct ModRsIssue {
36 pub path: PathBuf,
38 pub suggested: PathBuf,
40 pub message: String,
42 pub line: usize,
44 pub column: usize
46}
47
48#[derive(Debug, Default)]
52pub struct ModRsResult {
53 pub issues: Vec<ModRsIssue>
55}
56
57impl ModRsResult {
58 #[inline]
60 pub fn new() -> Self {
61 Self {
62 issues: Vec::new()
63 }
64 }
65
66 #[inline]
68 pub fn len(&self) -> usize {
69 self.issues.len()
70 }
71
72 #[inline]
74 pub fn is_empty(&self) -> bool {
75 self.issues.is_empty()
76 }
77}
78
79pub fn find_mod_rs_issues(path: &str) -> AppResult<ModRsResult> {
101 let root = Path::new(path);
102 let mut result = ModRsResult::new();
103
104 if root.is_file() {
105 if is_mod_rs(root)
106 && let Some(issue) = create_issue(root)
107 {
108 result.issues.push(issue);
109 }
110 return Ok(result);
111 }
112
113 collect_mod_rs_walked(path, &mut result);
114 Ok(result)
115}
116
117fn collect_mod_rs_walked(path: &str, result: &mut ModRsResult) {
129 for entry in WalkBuilder::new(path)
130 .follow_links(true)
131 .git_ignore(true)
132 .git_global(true)
133 .git_exclude(true)
134 .build()
135 .flatten()
136 {
137 let entry_path = entry.path();
138
139 if entry.file_type().is_some_and(|ft| ft.is_file())
140 && is_mod_rs(entry_path)
141 && let Some(issue) = create_issue(entry_path)
142 {
143 result.issues.push(issue);
144 }
145 }
146}
147
148#[inline]
158fn is_mod_rs(path: &Path) -> bool {
159 path.file_name()
160 .and_then(|n| n.to_str())
161 .map(|n| n == "mod.rs")
162 .unwrap_or(false)
163}
164
165fn create_issue(path: &Path) -> Option<ModRsIssue> {
175 let parent = path.parent()?;
176 let module_name = parent.file_name()?.to_str()?;
177 let grandparent = parent.parent()?;
178
179 let suggested = grandparent.join(format!("{}.rs", module_name));
180
181 Some(ModRsIssue {
182 path: path.to_path_buf(),
183 suggested,
184 message: format!(
185 "Use `{}.rs` instead of `{}/mod.rs` (modern module style)",
186 module_name, module_name
187 ),
188 line: 1,
189 column: 1
190 })
191}
192
193pub fn fix_mod_rs(issue: &ModRsIssue) -> AppResult<()> {
220 if issue.suggested.exists() {
221 return Err(IoError::from(io::Error::new(
222 io::ErrorKind::AlreadyExists,
223 format!(
224 "target already exists, refusing to overwrite: {}",
225 issue.suggested.display()
226 )
227 ))
228 .into());
229 }
230
231 rename(&issue.path, &issue.suggested).map_err(IoError::from)?;
232 if let Some(parent) = issue.path.parent()
233 && is_directory_empty(parent)?
234 {
235 remove_directory(parent).map_err(IoError::from)?;
236 }
237 Ok(())
238}
239
240pub fn fix_all_mod_rs(path: &str) -> AppResult<usize> {
259 let result = find_mod_rs_issues(path)?;
260 let mut applied = 0;
261
262 for issue in result.issues {
263 if issue.suggested.exists() {
264 eprintln!(
265 "Skipping {}: target {} already exists",
266 issue.path.display(),
267 issue.suggested.display()
268 );
269 continue;
270 }
271
272 fix_mod_rs(&issue)?;
273 applied += 1;
274 }
275
276 Ok(applied)
277}
278
279fn is_directory_empty(dir: &Path) -> AppResult<bool> {
289 let mut entries = read_dir(dir).map_err(IoError::from)?;
290 Ok(entries.next().is_none())
291}
292
293#[cfg(test)]
294mod tests {
295 use std::fs::{create_dir, read_to_string, write};
296
297 use tempfile::TempDir;
298
299 use super::*;
300
301 #[test]
302 fn test_find_no_mod_rs() {
303 let temp = TempDir::new().unwrap();
304 let file = temp.path().join("lib.rs");
305 write(&file, "fn main() {}").unwrap();
306
307 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
308 assert!(result.is_empty());
309 }
310
311 #[test]
312 fn test_find_mod_rs() {
313 let temp = TempDir::new().unwrap();
314 let subdir = temp.path().join("analyzers");
315 create_dir(&subdir).unwrap();
316 let mod_rs = subdir.join("mod.rs");
317 write(&mod_rs, "pub mod test;").unwrap();
318
319 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
320 assert_eq!(result.len(), 1);
321 assert!(result.issues[0].message.contains("analyzers"));
322 }
323
324 #[test]
325 fn test_find_multiple_mod_rs() {
326 let temp = TempDir::new().unwrap();
327
328 let dir1 = temp.path().join("foo");
329 create_dir(&dir1).unwrap();
330 write(dir1.join("mod.rs"), "// foo").unwrap();
331
332 let dir2 = temp.path().join("bar");
333 create_dir(&dir2).unwrap();
334 write(dir2.join("mod.rs"), "// bar").unwrap();
335
336 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
337 assert_eq!(result.len(), 2);
338 }
339
340 #[test]
341 fn test_fix_mod_rs() {
342 let temp = TempDir::new().unwrap();
343 let subdir = temp.path().join("utils");
344 create_dir(&subdir).unwrap();
345 let mod_rs = subdir.join("mod.rs");
346 write(&mod_rs, "pub fn helper() {}").unwrap();
347
348 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
349 assert_eq!(result.len(), 1);
350
351 fix_mod_rs(&result.issues[0]).unwrap();
352
353 assert!(!mod_rs.exists());
354 let new_file = temp.path().join("utils.rs");
355 assert!(new_file.exists());
356 assert_eq!(read_to_string(&new_file).unwrap(), "pub fn helper() {}");
357 assert!(!subdir.exists());
358 }
359
360 #[test]
361 fn test_fix_mod_rs_keeps_dir_with_other_files() {
362 let temp = TempDir::new().unwrap();
363 let subdir = temp.path().join("services");
364 create_dir(&subdir).unwrap();
365 write(subdir.join("mod.rs"), "pub mod api;").unwrap();
366 write(subdir.join("api.rs"), "fn api() {}").unwrap();
367
368 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
369 fix_mod_rs(&result.issues[0]).unwrap();
370
371 assert!(subdir.exists());
372 assert!(subdir.join("api.rs").exists());
373 assert!(temp.path().join("services.rs").exists());
374 }
375
376 #[test]
377 fn test_fix_all_mod_rs() {
378 let temp = TempDir::new().unwrap();
379
380 let dir1 = temp.path().join("module1");
381 create_dir(&dir1).unwrap();
382 write(dir1.join("mod.rs"), "// 1").unwrap();
383
384 let dir2 = temp.path().join("module2");
385 create_dir(&dir2).unwrap();
386 write(dir2.join("mod.rs"), "// 2").unwrap();
387
388 let fixed = fix_all_mod_rs(temp.path().to_str().unwrap()).unwrap();
389 assert_eq!(fixed, 2);
390
391 assert!(temp.path().join("module1.rs").exists());
392 assert!(temp.path().join("module2.rs").exists());
393 }
394
395 #[test]
396 fn test_fix_mod_rs_refuses_to_overwrite_existing() {
397 let temp = TempDir::new().unwrap();
398 let subdir = temp.path().join("foo");
399 create_dir(&subdir).unwrap();
400 write(subdir.join("mod.rs"), "MOD CONTENT").unwrap();
401 let sibling = temp.path().join("foo.rs");
402 write(&sibling, "KEEP ME").unwrap();
403
404 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
405 assert!(fix_mod_rs(&result.issues[0]).is_err());
406 assert_eq!(read_to_string(&sibling).unwrap(), "KEEP ME");
407 assert!(subdir.join("mod.rs").exists());
408 }
409
410 #[test]
411 fn test_fix_all_skips_conflicting_target() {
412 let temp = TempDir::new().unwrap();
413 let subdir = temp.path().join("foo");
414 create_dir(&subdir).unwrap();
415 write(subdir.join("mod.rs"), "MOD CONTENT").unwrap();
416 write(temp.path().join("foo.rs"), "KEEP ME").unwrap();
417
418 let applied = fix_all_mod_rs(temp.path().to_str().unwrap()).unwrap();
419 assert_eq!(applied, 0);
420 assert_eq!(
421 read_to_string(temp.path().join("foo.rs")).unwrap(),
422 "KEEP ME"
423 );
424 assert!(subdir.join("mod.rs").exists());
425 }
426
427 #[test]
428 fn test_scan_respects_gitignore_and_hidden_dirs() {
429 let temp = TempDir::new().unwrap();
430
431 create_dir(temp.path().join(".git")).unwrap();
432 create_dir(temp.path().join(".git").join("h")).unwrap();
433 write(temp.path().join(".git").join("h").join("mod.rs"), "x").unwrap();
434
435 write(temp.path().join(".gitignore"), "target/\n").unwrap();
436 create_dir(temp.path().join("target")).unwrap();
437 create_dir(temp.path().join("target").join("dep")).unwrap();
438 write(temp.path().join("target").join("dep").join("mod.rs"), "x").unwrap();
439
440 create_dir(temp.path().join("src")).unwrap();
441 let src_foo = temp.path().join("src").join("foo");
442 create_dir(&src_foo).unwrap();
443 write(src_foo.join("mod.rs"), "pub mod a;").unwrap();
444
445 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
446 assert_eq!(result.len(), 1);
447 assert!(result.issues[0].message.contains("foo"));
448 }
449
450 #[test]
451 fn test_issue_message() {
452 let temp = TempDir::new().unwrap();
453 let subdir = temp.path().join("handlers");
454 create_dir(&subdir).unwrap();
455 write(subdir.join("mod.rs"), "").unwrap();
456
457 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
458 assert!(result.issues[0].message.contains("handlers.rs"));
459 assert!(result.issues[0].message.contains("handlers/mod.rs"));
460 }
461
462 #[test]
463 fn test_suggested_path() {
464 let temp = TempDir::new().unwrap();
465 let subdir = temp.path().join("core");
466 create_dir(&subdir).unwrap();
467 write(subdir.join("mod.rs"), "").unwrap();
468
469 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
470 assert_eq!(result.issues[0].suggested, temp.path().join("core.rs"));
471 }
472
473 #[test]
474 fn test_nested_mod_rs() {
475 let temp = TempDir::new().unwrap();
476 let level1 = temp.path().join("level1");
477 let level2 = level1.join("level2");
478 create_dir(&level1).unwrap();
479 create_dir(&level2).unwrap();
480 write(level2.join("mod.rs"), "// nested").unwrap();
481
482 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
483 assert_eq!(result.len(), 1);
484 assert!(result.issues[0].message.contains("level2"));
485 assert_eq!(result.issues[0].suggested, level1.join("level2.rs"));
486 }
487
488 #[test]
489 fn test_single_file_check() {
490 let temp = TempDir::new().unwrap();
491 let subdir = temp.path().join("single");
492 create_dir(&subdir).unwrap();
493 let mod_rs = subdir.join("mod.rs");
494 write(&mod_rs, "").unwrap();
495
496 let result = find_mod_rs_issues(mod_rs.to_str().unwrap()).unwrap();
497 assert_eq!(result.len(), 1);
498 }
499
500 #[test]
501 fn test_non_mod_rs_file() {
502 let temp = TempDir::new().unwrap();
503 let file = temp.path().join("lib.rs");
504 write(&file, "fn main() {}").unwrap();
505
506 let result = find_mod_rs_issues(file.to_str().unwrap()).unwrap();
507 assert!(result.is_empty());
508 }
509
510 #[test]
511 fn test_line_column() {
512 let temp = TempDir::new().unwrap();
513 let subdir = temp.path().join("pos");
514 create_dir(&subdir).unwrap();
515 write(subdir.join("mod.rs"), "").unwrap();
516
517 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
518 assert_eq!(result.issues[0].line, 1);
519 assert_eq!(result.issues[0].column, 1);
520 }
521
522 #[test]
523 fn test_empty_directory() {
524 let temp = TempDir::new().unwrap();
525 let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
526 assert!(result.is_empty());
527 }
528
529 #[test]
530 fn test_result_default() {
531 let result = ModRsResult::default();
532 assert!(result.is_empty());
533 assert_eq!(result.len(), 0);
534 }
535}