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::{analyzer::Suggestion, 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.get(idx).is_some_and(|line| *line == entry.original) {
67            suggestions.push(Suggestion {
68                edit:   entry.edit.clone(),
69                import: entry.import.clone()
70            });
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::get_analyzers;
92
93    fn diff_for(path: &Path) -> DiffResult {
94        let file = super::super::generate_diff(path.to_str().unwrap(), &get_analyzers()).unwrap();
95        let mut result = DiffResult::new();
96        result.add_file(file);
97        result
98    }
99
100    #[test]
101    fn test_apply_rewrites_and_preserves_comments() {
102        let temp = TempDir::new().unwrap();
103        let path = temp.path().join("a.rs");
104        fs::write(
105            &path,
106            "//! Module doc\n\nfn main() {\n    // note\n    let x = std::fs::read_to_string(\"f\");\n}\n"
107        )
108        .unwrap();
109
110        let applied = apply_diff(&diff_for(&path)).unwrap();
111        assert_eq!(applied, 1);
112
113        let output = fs::read_to_string(&path).unwrap();
114        assert!(output.contains("use std::fs::read_to_string;"));
115        assert!(output.contains("let x = read_to_string(\"f\");"));
116        assert!(!output.contains("std::fs::read_to_string("));
117        assert!(output.contains("// note"), "comment preserved");
118        assert!(output.starts_with("//! Module doc"));
119        assert!(output.ends_with('\n'));
120    }
121
122    #[test]
123    fn test_apply_dedups_imports() {
124        let temp = TempDir::new().unwrap();
125        let path = temp.path().join("c.rs");
126        fs::write(
127            &path,
128            "fn main() {\n    let a = std::fs::read_to_string(\"a\");\n    let b = std::fs::read_to_string(\"b\");\n}\n"
129        )
130        .unwrap();
131
132        let applied = apply_diff(&diff_for(&path)).unwrap();
133        assert_eq!(applied, 2);
134
135        let output = fs::read_to_string(&path).unwrap();
136        assert_eq!(output.matches("use std::fs::read_to_string;").count(), 1);
137    }
138
139    #[test]
140    fn test_apply_is_collision_safe() {
141        let temp = TempDir::new().unwrap();
142        let path = temp.path().join("d.rs");
143        fs::write(
144            &path,
145            "fn main() {\n    let a = std::fs::read(\"x\");\n    let b = other::helpers::read(\"y\");\n}\n"
146        )
147        .unwrap();
148
149        let result = diff_for(&path);
150        assert_eq!(
151            result.total_changes(),
152            0,
153            "colliding reads produce no changes"
154        );
155
156        let applied = apply_diff(&result).unwrap();
157        assert_eq!(applied, 0);
158        assert!(
159            fs::read_to_string(&path)
160                .unwrap()
161                .contains("std::fs::read(\"x\")")
162        );
163    }
164
165    #[test]
166    fn test_apply_skips_when_file_changed() {
167        let temp = TempDir::new().unwrap();
168        let path = temp.path().join("e.rs");
169        fs::write(
170            &path,
171            "fn main() {\n    let x = std::fs::read_to_string(\"f\");\n}\n"
172        )
173        .unwrap();
174
175        let result = diff_for(&path);
176        fs::write(&path, "fn main() {\n    let y = 1;\n}\n").unwrap();
177
178        let applied = apply_diff(&result).unwrap();
179        assert_eq!(applied, 0, "stale entry is skipped");
180        assert!(fs::read_to_string(&path).unwrap().contains("let y = 1;"));
181    }
182
183    #[test]
184    fn test_apply_empty_result() {
185        let applied = apply_diff(&DiffResult::new()).unwrap();
186        assert_eq!(applied, 0);
187    }
188}