Skip to main content

cargo_quality/differ/
apply.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Application of selected diff entries to files on disk.
5//!
6//! Used by interactive mode to write only the changes the user accepted. Each
7//! entry carries the underlying [`crate::analyzer::TextEdit`], so changes are
8//! applied through the same [`crate::fixer::apply_suggestions`] engine as the
9//! `fix` command — collision-safe and comment-preserving. An entry is skipped
10//! if the file no longer matches the line the diff was generated from.
11
12use std::fs;
13
14use masterror::AppResult;
15
16use super::types::{DiffResult, FileDiff};
17use crate::{error::IoError, fixer::apply_suggestions};
18
19/// Applies selected diff entries to their files.
20///
21/// Each file's accepted entries are turned back into suggestions and applied
22/// through [`apply_suggestions`], so the result is collision-safe and preserves
23/// comments and formatting — identical to the `fix` command.
24///
25/// # Arguments
26///
27/// * `result` - Selected diff entries grouped by file
28///
29/// # Returns
30///
31/// `AppResult<usize>` - Number of line changes written across all files
32///
33/// # Errors
34///
35/// Returns an error if reading or writing a file fails.
36pub fn apply_diff(result: &DiffResult) -> AppResult<usize> {
37    let mut applied = 0;
38
39    for file in &result.files {
40        applied += apply_file(file)?;
41    }
42
43    Ok(applied)
44}
45
46/// Applies the entries of a single file diff.
47///
48/// # Arguments
49///
50/// * `file` - File diff with the entries to apply
51///
52/// # Returns
53///
54/// `AppResult<usize>` - Number of line changes written for this file
55fn apply_file(file: &FileDiff) -> AppResult<usize> {
56    if file.entries.is_empty() {
57        return Ok(0);
58    }
59
60    let content = fs::read_to_string(&file.path).map_err(IoError::from)?;
61    let lines: Vec<&str> = content.lines().collect();
62
63    let mut suggestions = Vec::new();
64    for entry in &file.entries {
65        let idx = entry.line.saturating_sub(1);
66        if lines
67            .get(idx)
68            .is_some_and(|line| *line == entry.preview.original)
69        {
70            suggestions.push(entry.suggestion.clone());
71        }
72    }
73
74    if suggestions.is_empty() {
75        return Ok(0);
76    }
77
78    let updated = apply_suggestions(&content, &suggestions);
79    fs::write(&file.path, updated).map_err(IoError::from)?;
80
81    Ok(suggestions.len())
82}
83
84#[cfg(test)]
85mod tests {
86    use std::path::Path;
87
88    use tempfile::TempDir;
89
90    use super::*;
91    use crate::analyzers::default_analyzers;
92
93    fn diff_for(path: &Path) -> DiffResult {
94        let file =
95            super::super::generate_diff(path.to_str().unwrap(), &default_analyzers()).unwrap();
96        let mut result = DiffResult::new();
97        result.add_file(file);
98        result
99    }
100
101    #[test]
102    fn test_apply_rewrites_and_preserves_comments() {
103        let temp = TempDir::new().unwrap();
104        let path = temp.path().join("a.rs");
105        fs::write(
106            &path,
107            "//! Module doc\n\nfn main() {\n    // note\n    let x = std::fs::read_to_string(\"f\");\n}\n"
108        )
109        .unwrap();
110
111        let applied = apply_diff(&diff_for(&path)).unwrap();
112        assert_eq!(applied, 3, "path rewrite, comment removal, notes insertion");
113
114        let output = fs::read_to_string(&path).unwrap();
115        assert!(output.contains("use std::fs::read_to_string;"));
116        assert!(output.contains("let x = read_to_string(\"f\");"));
117        assert!(!output.contains("std::fs::read_to_string("));
118        assert!(output.contains("/// - note"), "comment moved to doc block");
119        assert!(!output.contains("    // note"), "inline comment removed");
120        assert!(output.starts_with("//! Module doc"));
121        assert!(output.ends_with('\n'));
122    }
123
124    #[test]
125    fn test_apply_dedups_imports() {
126        let temp = TempDir::new().unwrap();
127        let path = temp.path().join("c.rs");
128        fs::write(
129            &path,
130            "fn main() {\n    let a = std::fs::read_to_string(\"a\");\n    let b = std::fs::read_to_string(\"b\");\n}\n"
131        )
132        .unwrap();
133
134        let applied = apply_diff(&diff_for(&path)).unwrap();
135        assert_eq!(applied, 2);
136
137        let output = fs::read_to_string(&path).unwrap();
138        assert_eq!(output.matches("use std::fs::read_to_string;").count(), 1);
139    }
140
141    #[test]
142    fn test_apply_is_collision_safe() {
143        let temp = TempDir::new().unwrap();
144        let path = temp.path().join("d.rs");
145        fs::write(
146            &path,
147            "fn main() {\n    let a = std::fs::read(\"x\");\n    let b = crate::helpers::read(\"y\");\n}\n"
148        )
149        .unwrap();
150
151        let result = diff_for(&path);
152        assert_eq!(
153            result.total_changes(),
154            0,
155            "colliding reads produce no changes"
156        );
157
158        let applied = apply_diff(&result).unwrap();
159        assert_eq!(applied, 0);
160        assert!(
161            fs::read_to_string(&path)
162                .unwrap()
163                .contains("std::fs::read(\"x\")")
164        );
165    }
166
167    #[test]
168    fn test_apply_skips_when_file_changed() {
169        let temp = TempDir::new().unwrap();
170        let path = temp.path().join("e.rs");
171        fs::write(
172            &path,
173            "fn main() {\n    let x = std::fs::read_to_string(\"f\");\n}\n"
174        )
175        .unwrap();
176
177        let result = diff_for(&path);
178        fs::write(&path, "fn main() {\n    let y = 1;\n}\n").unwrap();
179
180        let applied = apply_diff(&result).unwrap();
181        assert_eq!(applied, 0, "stale entry is skipped");
182        assert!(fs::read_to_string(&path).unwrap().contains("let y = 1;"));
183    }
184
185    #[test]
186    fn test_apply_empty_result() {
187        let applied = apply_diff(&DiffResult::new()).unwrap();
188        assert_eq!(applied, 0);
189    }
190}