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.
7//! Line replacements are keyed by line number and guarded by an equality check
8//! against the recorded original, so a change is skipped if the file no longer
9//! matches what the diff was generated from.
10
11use std::fs;
12
13use masterror::AppResult;
14
15use super::types::{DiffResult, FileDiff};
16use crate::error::IoError;
17
18/// Applies selected diff entries to their files.
19///
20/// For each file, replaces recorded lines with their modified form and inserts
21/// deduplicated `use` imports after leading module docs and inner attributes.
22///
23/// # Arguments
24///
25/// * `result` - Selected diff entries grouped by file
26///
27/// # Returns
28///
29/// `AppResult<usize>` - Number of line changes written across all files
30///
31/// # Errors
32///
33/// Returns an error if reading or writing a file fails.
34pub fn apply_diff(result: &DiffResult) -> AppResult<usize> {
35    let mut applied = 0;
36
37    for file in &result.files {
38        applied += apply_file(file)?;
39    }
40
41    Ok(applied)
42}
43
44/// Applies the entries of a single file diff.
45///
46/// # Arguments
47///
48/// * `file` - File diff with the entries to apply
49///
50/// # Returns
51///
52/// `AppResult<usize>` - Number of line changes written for this file
53fn apply_file(file: &FileDiff) -> AppResult<usize> {
54    if file.entries.is_empty() {
55        return Ok(0);
56    }
57
58    let content = fs::read_to_string(&file.path).map_err(IoError::from)?;
59    let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
60    let mut imports: Vec<String> = Vec::new();
61    let mut applied = 0;
62
63    for entry in &file.entries {
64        let idx = entry.line.saturating_sub(1);
65
66        let Some(line) = lines.get_mut(idx) else {
67            continue;
68        };
69
70        if *line != entry.original {
71            continue;
72        }
73
74        *line = entry.modified.clone();
75
76        if let Some(import) = &entry.import
77            && !imports.contains(import)
78        {
79            imports.push(import.clone());
80        }
81
82        applied += 1;
83    }
84
85    if applied == 0 {
86        return Ok(0);
87    }
88
89    insert_imports(&mut lines, imports);
90
91    let mut text = lines.join("\n");
92    if content.ends_with('\n') {
93        text.push('\n');
94    }
95
96    fs::write(&file.path, text).map_err(IoError::from)?;
97
98    Ok(applied)
99}
100
101/// Inserts import lines after leading module docs and inner attributes.
102///
103/// Skips a leading run of blank lines, non-doc `//` comments, module docs
104/// (`//!`), and inner attributes (`#!`) so the imports remain valid Rust.
105///
106/// # Arguments
107///
108/// * `lines` - File lines to modify in place
109/// * `imports` - Deduplicated import statements to insert
110fn insert_imports(lines: &mut Vec<String>, imports: Vec<String>) {
111    if imports.is_empty() {
112        return;
113    }
114
115    let mut insert_at = 0;
116    for (index, line) in lines.iter().enumerate() {
117        let trimmed = line.trim_start();
118        if trimmed.is_empty()
119            || trimmed.starts_with("//!")
120            || trimmed.starts_with("#!")
121            || (trimmed.starts_with("//") && !trimmed.starts_with("///"))
122        {
123            insert_at = index + 1;
124        } else {
125            break;
126        }
127    }
128
129    for (offset, import) in imports.into_iter().enumerate() {
130        lines.insert(insert_at + offset, import);
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use tempfile::TempDir;
137
138    use super::*;
139    use crate::differ::types::DiffEntry;
140
141    fn entry(line: usize, original: &str, modified: &str, import: Option<&str>) -> DiffEntry {
142        DiffEntry {
143            line,
144            analyzer: "path_import".to_string(),
145            original: original.to_string(),
146            modified: modified.to_string(),
147            description: "desc".to_string(),
148            import: import.map(str::to_string)
149        }
150    }
151
152    #[test]
153    fn test_apply_rewrites_line_and_inserts_import() {
154        let temp = TempDir::new().unwrap();
155        let path = temp.path().join("a.rs");
156        fs::write(
157            &path,
158            "//! Module doc\n\nfn main() {\n    let x = std::fs::read_to_string(\"f\");\n}\n"
159        )
160        .unwrap();
161
162        let mut result = DiffResult::new();
163        let mut file = FileDiff::new(path.to_str().unwrap().to_string());
164        file.add_entry(entry(
165            4,
166            "    let x = std::fs::read_to_string(\"f\");",
167            "    let x = read_to_string(\"f\");",
168            Some("use std::fs::read_to_string;")
169        ));
170        result.add_file(file);
171
172        let applied = apply_diff(&result).unwrap();
173        assert_eq!(applied, 1);
174
175        let output = fs::read_to_string(&path).unwrap();
176        assert!(output.contains("use std::fs::read_to_string;"));
177        assert!(output.contains("let x = read_to_string(\"f\");"));
178        assert!(!output.contains("std::fs::read_to_string("));
179        assert!(output.starts_with("//! Module doc"));
180        assert!(output.ends_with('\n'));
181    }
182
183    #[test]
184    fn test_apply_skips_mismatched_original() {
185        let temp = TempDir::new().unwrap();
186        let path = temp.path().join("b.rs");
187        fs::write(&path, "fn main() {\n    let x = 1;\n}\n").unwrap();
188
189        let mut result = DiffResult::new();
190        let mut file = FileDiff::new(path.to_str().unwrap().to_string());
191        file.add_entry(entry(2, "    let x = 2;", "    let x = changed;", None));
192        result.add_file(file);
193
194        let applied = apply_diff(&result).unwrap();
195        assert_eq!(applied, 0);
196
197        let output = fs::read_to_string(&path).unwrap();
198        assert!(output.contains("let x = 1;"));
199    }
200
201    #[test]
202    fn test_apply_dedups_imports() {
203        let temp = TempDir::new().unwrap();
204        let path = temp.path().join("c.rs");
205        fs::write(
206            &path,
207            "fn main() {\n    let a = std::fs::read_to_string(\"a\");\n    let b = std::fs::read_to_string(\"b\");\n}\n"
208        )
209        .unwrap();
210
211        let mut result = DiffResult::new();
212        let mut file = FileDiff::new(path.to_str().unwrap().to_string());
213        file.add_entry(entry(
214            2,
215            "    let a = std::fs::read_to_string(\"a\");",
216            "    let a = read_to_string(\"a\");",
217            Some("use std::fs::read_to_string;")
218        ));
219        file.add_entry(entry(
220            3,
221            "    let b = std::fs::read_to_string(\"b\");",
222            "    let b = read_to_string(\"b\");",
223            Some("use std::fs::read_to_string;")
224        ));
225        result.add_file(file);
226
227        let applied = apply_diff(&result).unwrap();
228        assert_eq!(applied, 2);
229
230        let output = fs::read_to_string(&path).unwrap();
231        assert_eq!(output.matches("use std::fs::read_to_string;").count(), 1);
232    }
233
234    #[test]
235    fn test_apply_empty_result() {
236        let result = DiffResult::new();
237        let applied = apply_diff(&result).unwrap();
238        assert_eq!(applied, 0);
239    }
240}