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    path::{Path, PathBuf}
23};
24
25use masterror::AppResult;
26
27use crate::error::IoError;
28
29/// Result of mod.rs detection.
30///
31/// Contains information about a found `mod.rs` file and the suggested fix.
32#[derive(Debug, Clone)]
33pub struct ModRsIssue {
34    /// Path to the mod.rs file
35    pub path:      PathBuf,
36    /// Suggested new path after fix
37    pub suggested: PathBuf,
38    /// Human-readable message
39    pub message:   String,
40    /// Line number (always 1 for file-level issues)
41    pub line:      usize,
42    /// Column number (always 1 for file-level issues)
43    pub column:    usize
44}
45
46/// Result of mod.rs analysis.
47///
48/// Contains all found `mod.rs` files in the analyzed path.
49#[derive(Debug, Default)]
50pub struct ModRsResult {
51    /// List of found mod.rs issues
52    pub issues: Vec<ModRsIssue>
53}
54
55impl ModRsResult {
56    /// Creates new empty result.
57    #[inline]
58    pub fn new() -> Self {
59        Self {
60            issues: Vec::new()
61        }
62    }
63
64    /// Returns total number of issues found.
65    #[inline]
66    pub fn len(&self) -> usize {
67        self.issues.len()
68    }
69
70    /// Checks if no issues were found.
71    #[inline]
72    pub fn is_empty(&self) -> bool {
73        self.issues.is_empty()
74    }
75}
76
77/// Finds all `mod.rs` files in the given path.
78///
79/// Recursively searches for files named `mod.rs` that should be converted
80/// to the modern module naming convention.
81///
82/// # Arguments
83///
84/// * `path` - Root path to search in
85///
86/// # Returns
87///
88/// `AppResult<ModRsResult>` containing all found `mod.rs` files
89///
90/// # Examples
91///
92/// ```no_run
93/// use cargo_quality::mod_rs::find_mod_rs_issues;
94///
95/// let result = find_mod_rs_issues("src/").unwrap();
96/// println!("Found {} mod.rs files", result.len());
97/// ```
98pub fn find_mod_rs_issues(path: &str) -> AppResult<ModRsResult> {
99    let root = Path::new(path);
100    let mut result = ModRsResult::new();
101
102    if root.is_file() {
103        if is_mod_rs(root)
104            && let Some(issue) = create_issue(root)
105        {
106            result.issues.push(issue);
107        }
108        return Ok(result);
109    }
110
111    collect_mod_rs_recursive(root, &mut result)?;
112    Ok(result)
113}
114
115/// Recursively collects mod.rs files from directory.
116///
117/// # Arguments
118///
119/// * `dir` - Directory to search in
120/// * `result` - Result accumulator
121fn collect_mod_rs_recursive(dir: &Path, result: &mut ModRsResult) -> AppResult<()> {
122    let entries = read_dir(dir).map_err(IoError::from)?;
123
124    for entry in entries {
125        let entry = entry.map_err(IoError::from)?;
126        let path = entry.path();
127
128        if path.is_dir() {
129            collect_mod_rs_recursive(&path, result)?;
130        } else if is_mod_rs(&path)
131            && let Some(issue) = create_issue(&path)
132        {
133            result.issues.push(issue);
134        }
135    }
136
137    Ok(())
138}
139
140/// Checks if path points to a mod.rs file.
141///
142/// # Arguments
143///
144/// * `path` - Path to check
145///
146/// # Returns
147///
148/// `true` if the file is named `mod.rs`
149#[inline]
150fn is_mod_rs(path: &Path) -> bool {
151    path.file_name()
152        .and_then(|n| n.to_str())
153        .map(|n| n == "mod.rs")
154        .unwrap_or(false)
155}
156
157/// Creates an issue for a mod.rs file.
158///
159/// # Arguments
160///
161/// * `path` - Path to the mod.rs file
162///
163/// # Returns
164///
165/// `Some(ModRsIssue)` if the file has a valid parent directory
166fn create_issue(path: &Path) -> Option<ModRsIssue> {
167    let parent = path.parent()?;
168    let module_name = parent.file_name()?.to_str()?;
169    let grandparent = parent.parent()?;
170
171    let suggested = grandparent.join(format!("{}.rs", module_name));
172
173    Some(ModRsIssue {
174        path: path.to_path_buf(),
175        suggested,
176        message: format!(
177            "Use `{}.rs` instead of `{}/mod.rs` (modern module style)",
178            module_name, module_name
179        ),
180        line: 1,
181        column: 1
182    })
183}
184
185/// Fixes a single mod.rs file by renaming and moving it.
186///
187/// Converts `src/foo/mod.rs` to `src/foo.rs` by:
188/// 1. Reading the content of mod.rs
189/// 2. Writing it to the new location (parent_name.rs)
190/// 3. Removing the original mod.rs file
191/// 4. Removing the empty parent directory if it becomes empty
192///
193/// # Arguments
194///
195/// * `issue` - The mod.rs issue to fix
196///
197/// # Returns
198///
199/// `AppResult<()>` - Ok if fix was successful
200///
201/// # Examples
202///
203/// ```no_run
204/// use cargo_quality::mod_rs::{find_mod_rs_issues, fix_mod_rs};
205///
206/// let result = find_mod_rs_issues("src/").unwrap();
207/// for issue in result.issues {
208///     fix_mod_rs(&issue).unwrap();
209/// }
210/// ```
211pub fn fix_mod_rs(issue: &ModRsIssue) -> AppResult<()> {
212    rename(&issue.path, &issue.suggested).map_err(IoError::from)?;
213    if let Some(parent) = issue.path.parent()
214        && is_directory_empty(parent)?
215    {
216        remove_directory(parent).map_err(IoError::from)?;
217    }
218    Ok(())
219}
220
221/// Fixes all mod.rs files found in the given path.
222///
223/// # Arguments
224///
225/// * `path` - Root path to search and fix
226///
227/// # Returns
228///
229/// `AppResult<usize>` - Number of files fixed
230///
231/// # Examples
232///
233/// ```no_run
234/// use cargo_quality::mod_rs::fix_all_mod_rs;
235///
236/// let fixed = fix_all_mod_rs("src/").unwrap();
237/// println!("Fixed {} mod.rs files", fixed);
238/// ```
239pub fn fix_all_mod_rs(path: &str) -> AppResult<usize> {
240    let result = find_mod_rs_issues(path)?;
241    let count = result.len();
242
243    for issue in result.issues {
244        fix_mod_rs(&issue)?;
245    }
246
247    Ok(count)
248}
249
250/// Checks if a directory is empty.
251///
252/// # Arguments
253///
254/// * `dir` - Directory path to check
255///
256/// # Returns
257///
258/// `AppResult<bool>` - true if directory has no entries
259fn is_directory_empty(dir: &Path) -> AppResult<bool> {
260    let mut entries = read_dir(dir).map_err(IoError::from)?;
261    Ok(entries.next().is_none())
262}
263
264#[cfg(test)]
265mod tests {
266    use std::fs::{create_dir, read_to_string, write};
267
268    use tempfile::TempDir;
269
270    use super::*;
271
272    #[test]
273    fn test_find_no_mod_rs() {
274        let temp = TempDir::new().unwrap();
275        let file = temp.path().join("lib.rs");
276        write(&file, "fn main() {}").unwrap();
277
278        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
279        assert!(result.is_empty());
280    }
281
282    #[test]
283    fn test_find_mod_rs() {
284        let temp = TempDir::new().unwrap();
285        let subdir = temp.path().join("analyzers");
286        create_dir(&subdir).unwrap();
287        let mod_rs = subdir.join("mod.rs");
288        write(&mod_rs, "pub mod test;").unwrap();
289
290        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
291        assert_eq!(result.len(), 1);
292        assert!(result.issues[0].message.contains("analyzers"));
293    }
294
295    #[test]
296    fn test_find_multiple_mod_rs() {
297        let temp = TempDir::new().unwrap();
298
299        let dir1 = temp.path().join("foo");
300        create_dir(&dir1).unwrap();
301        write(dir1.join("mod.rs"), "// foo").unwrap();
302
303        let dir2 = temp.path().join("bar");
304        create_dir(&dir2).unwrap();
305        write(dir2.join("mod.rs"), "// bar").unwrap();
306
307        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
308        assert_eq!(result.len(), 2);
309    }
310
311    #[test]
312    fn test_fix_mod_rs() {
313        let temp = TempDir::new().unwrap();
314        let subdir = temp.path().join("utils");
315        create_dir(&subdir).unwrap();
316        let mod_rs = subdir.join("mod.rs");
317        write(&mod_rs, "pub fn helper() {}").unwrap();
318
319        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
320        assert_eq!(result.len(), 1);
321
322        fix_mod_rs(&result.issues[0]).unwrap();
323
324        assert!(!mod_rs.exists());
325        let new_file = temp.path().join("utils.rs");
326        assert!(new_file.exists());
327        assert_eq!(read_to_string(&new_file).unwrap(), "pub fn helper() {}");
328        assert!(!subdir.exists());
329    }
330
331    #[test]
332    fn test_fix_mod_rs_keeps_dir_with_other_files() {
333        let temp = TempDir::new().unwrap();
334        let subdir = temp.path().join("services");
335        create_dir(&subdir).unwrap();
336        write(subdir.join("mod.rs"), "pub mod api;").unwrap();
337        write(subdir.join("api.rs"), "fn api() {}").unwrap();
338
339        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
340        fix_mod_rs(&result.issues[0]).unwrap();
341
342        assert!(subdir.exists());
343        assert!(subdir.join("api.rs").exists());
344        assert!(temp.path().join("services.rs").exists());
345    }
346
347    #[test]
348    fn test_fix_all_mod_rs() {
349        let temp = TempDir::new().unwrap();
350
351        let dir1 = temp.path().join("module1");
352        create_dir(&dir1).unwrap();
353        write(dir1.join("mod.rs"), "// 1").unwrap();
354
355        let dir2 = temp.path().join("module2");
356        create_dir(&dir2).unwrap();
357        write(dir2.join("mod.rs"), "// 2").unwrap();
358
359        let fixed = fix_all_mod_rs(temp.path().to_str().unwrap()).unwrap();
360        assert_eq!(fixed, 2);
361
362        assert!(temp.path().join("module1.rs").exists());
363        assert!(temp.path().join("module2.rs").exists());
364    }
365
366    #[test]
367    fn test_issue_message() {
368        let temp = TempDir::new().unwrap();
369        let subdir = temp.path().join("handlers");
370        create_dir(&subdir).unwrap();
371        write(subdir.join("mod.rs"), "").unwrap();
372
373        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
374        assert!(result.issues[0].message.contains("handlers.rs"));
375        assert!(result.issues[0].message.contains("handlers/mod.rs"));
376    }
377
378    #[test]
379    fn test_suggested_path() {
380        let temp = TempDir::new().unwrap();
381        let subdir = temp.path().join("core");
382        create_dir(&subdir).unwrap();
383        write(subdir.join("mod.rs"), "").unwrap();
384
385        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
386        assert_eq!(result.issues[0].suggested, temp.path().join("core.rs"));
387    }
388
389    #[test]
390    fn test_nested_mod_rs() {
391        let temp = TempDir::new().unwrap();
392        let level1 = temp.path().join("level1");
393        let level2 = level1.join("level2");
394        create_dir(&level1).unwrap();
395        create_dir(&level2).unwrap();
396        write(level2.join("mod.rs"), "// nested").unwrap();
397
398        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
399        assert_eq!(result.len(), 1);
400        assert!(result.issues[0].message.contains("level2"));
401        assert_eq!(result.issues[0].suggested, level1.join("level2.rs"));
402    }
403
404    #[test]
405    fn test_single_file_check() {
406        let temp = TempDir::new().unwrap();
407        let subdir = temp.path().join("single");
408        create_dir(&subdir).unwrap();
409        let mod_rs = subdir.join("mod.rs");
410        write(&mod_rs, "").unwrap();
411
412        let result = find_mod_rs_issues(mod_rs.to_str().unwrap()).unwrap();
413        assert_eq!(result.len(), 1);
414    }
415
416    #[test]
417    fn test_non_mod_rs_file() {
418        let temp = TempDir::new().unwrap();
419        let file = temp.path().join("lib.rs");
420        write(&file, "fn main() {}").unwrap();
421
422        let result = find_mod_rs_issues(file.to_str().unwrap()).unwrap();
423        assert!(result.is_empty());
424    }
425
426    #[test]
427    fn test_line_column() {
428        let temp = TempDir::new().unwrap();
429        let subdir = temp.path().join("pos");
430        create_dir(&subdir).unwrap();
431        write(subdir.join("mod.rs"), "").unwrap();
432
433        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
434        assert_eq!(result.issues[0].line, 1);
435        assert_eq!(result.issues[0].column, 1);
436    }
437
438    #[test]
439    fn test_empty_directory() {
440        let temp = TempDir::new().unwrap();
441        let result = find_mod_rs_issues(temp.path().to_str().unwrap()).unwrap();
442        assert!(result.is_empty());
443    }
444
445    #[test]
446    fn test_result_default() {
447        let result = ModRsResult::default();
448        assert!(result.is_empty());
449        assert_eq!(result.len(), 0);
450    }
451}