Skip to main content

cargo_quality/
mod_rs.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Module for detecting and fixing `mod.rs` files.
5//!
6//! This module provides functionality to find `mod.rs` files in a project
7//! and convert them to the modern module naming convention where modules
8//! are named after their parent directory.
9//!
10//! # Example
11//!
12//! ```text
13//! Before: src/analyzers/mod.rs
14//! After:  src/analyzers.rs
15//! ```
16//!
17//! The `mod.rs` file content is moved to a file named after the parent
18//! directory, placed one level up in the directory hierarchy.
19
20use 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/// Result of mod.rs detection.
32///
33/// Contains information about a found `mod.rs` file and the suggested fix.
34#[derive(Debug, Clone)]
35pub struct ModRsIssue {
36    /// Path to the mod.rs file
37    pub path:      PathBuf,
38    /// Suggested new path after fix
39    pub suggested: PathBuf,
40    /// Human-readable message
41    pub message:   String,
42    /// Line number (always 1 for file-level issues)
43    pub line:      usize,
44    /// Column number (always 1 for file-level issues)
45    pub column:    usize
46}
47
48/// Result of mod.rs analysis.
49///
50/// Contains all found `mod.rs` files in the analyzed path.
51#[derive(Debug, Default)]
52pub struct ModRsResult {
53    /// List of found mod.rs issues
54    pub issues: Vec<ModRsIssue>
55}
56
57impl ModRsResult {
58    /// Creates new empty result.
59    #[inline]
60    pub fn new() -> Self {
61        Self {
62            issues: Vec::new()
63        }
64    }
65
66    /// Returns total number of issues found.
67    #[inline]
68    pub fn len(&self) -> usize {
69        self.issues.len()
70    }
71
72    /// Checks if no issues were found.
73    #[inline]
74    pub fn is_empty(&self) -> bool {
75        self.issues.is_empty()
76    }
77}
78
79/// Finds all `mod.rs` files in the given path.
80///
81/// Recursively searches for files named `mod.rs` that should be converted
82/// to the modern module naming convention.
83///
84/// # Arguments
85///
86/// * `path` - Root path to search in
87///
88/// # Returns
89///
90/// `AppResult<ModRsResult>` containing all found `mod.rs` files
91///
92/// # Examples
93///
94/// ```no_run
95/// use cargo_quality::mod_rs::find_mod_rs_issues;
96///
97/// let result = find_mod_rs_issues("src/").unwrap();
98/// println!("Found {} mod.rs files", result.len());
99/// ```
100pub 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
117/// Collects mod.rs files while respecting ignore rules.
118///
119/// Walks the tree with [`WalkBuilder`], which honors `.gitignore`/`.ignore`
120/// and skips hidden directories (e.g. `.git`), matching
121/// [`crate::file_utils::collect_rust_files`]. This prevents scanning build
122/// artifacts and vendored dependencies under `target/`.
123///
124/// # Arguments
125///
126/// * `path` - Root directory to search in
127/// * `result` - Result accumulator
128fn 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/// Checks if path points to a mod.rs file.
149///
150/// # Arguments
151///
152/// * `path` - Path to check
153///
154/// # Returns
155///
156/// `true` if the file is named `mod.rs`
157#[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
165/// Creates an issue for a mod.rs file.
166///
167/// # Arguments
168///
169/// * `path` - Path to the mod.rs file
170///
171/// # Returns
172///
173/// `Some(ModRsIssue)` if the file has a valid parent directory
174fn 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
193/// Fixes a single mod.rs file by renaming and moving it.
194///
195/// Converts `src/foo/mod.rs` to `src/foo.rs` by:
196/// 1. Reading the content of mod.rs
197/// 2. Writing it to the new location (parent_name.rs)
198/// 3. Removing the original mod.rs file
199/// 4. Removing the empty parent directory if it becomes empty
200///
201/// # Arguments
202///
203/// * `issue` - The mod.rs issue to fix
204///
205/// # Returns
206///
207/// `AppResult<()>` - Ok if fix was successful
208///
209/// # Examples
210///
211/// ```no_run
212/// use cargo_quality::mod_rs::{find_mod_rs_issues, fix_mod_rs};
213///
214/// let result = find_mod_rs_issues("src/").unwrap();
215/// for issue in result.issues {
216///     fix_mod_rs(&issue).unwrap();
217/// }
218/// ```
219pub 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
240/// Fixes all mod.rs files found in the given path.
241///
242/// # Arguments
243///
244/// * `path` - Root path to search and fix
245///
246/// # Returns
247///
248/// `AppResult<usize>` - Number of files fixed
249///
250/// # Examples
251///
252/// ```no_run
253/// use cargo_quality::mod_rs::fix_all_mod_rs;
254///
255/// let fixed = fix_all_mod_rs("src/").unwrap();
256/// println!("Fixed {} mod.rs files", fixed);
257/// ```
258pub 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
279/// Checks if a directory is empty.
280///
281/// # Arguments
282///
283/// * `dir` - Directory path to check
284///
285/// # Returns
286///
287/// `AppResult<bool>` - true if directory has no entries
288fn 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}