Skip to main content

cargo_quality/differ/
generator.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use std::fs;
5
6use masterror::AppResult;
7
8use super::types::{ChangePreview, DiffEntry, FileDiff};
9use crate::{
10    analyzer::{Analyzer, Suggestion},
11    error::{IoError, ParseError}
12};
13
14/// Generates diff showing proposed changes.
15///
16/// Analyzes files and compares current state with proposed fixes.
17///
18/// # Arguments
19///
20/// * `file_path` - Path to analyze
21/// * `analyzers` - List of analyzers to apply
22///
23/// # Returns
24///
25/// `AppResult<FileDiff>` - Diff results or error
26///
27/// # Examples
28///
29/// ```no_run
30/// use cargo_quality::{analyzers::default_analyzers, differ::generate_diff};
31/// let diff = generate_diff("src/main.rs", &default_analyzers()).unwrap();
32/// ```
33pub fn generate_diff(file_path: &str, analyzers: &[Box<dyn Analyzer>]) -> AppResult<FileDiff> {
34    let content = fs::read_to_string(file_path).map_err(IoError::from)?;
35    let ast = syn::parse_file(&content).map_err(ParseError::from)?;
36
37    let mut file_diff = FileDiff::new(file_path.to_string());
38
39    for analyzer in analyzers {
40        for suggestion in analyzer.suggestions(&ast, &content)? {
41            file_diff.add_entry(entry_from_suggestion(analyzer.name(), &content, suggestion));
42        }
43    }
44
45    Ok(file_diff)
46}
47
48/// Builds a displayable diff entry from a fix suggestion.
49///
50/// Derives the affected line number and its before/after text from the
51/// suggestion's byte-range edit, and keeps the edit for application.
52///
53/// # Arguments
54///
55/// * `analyzer` - Name of the analyzer that produced the suggestion
56/// * `content` - Original source code
57/// * `suggestion` - Suggestion to render
58///
59/// # Returns
60///
61/// A `DiffEntry` for display and application
62fn entry_from_suggestion(analyzer: &str, content: &str, suggestion: Suggestion) -> DiffEntry {
63    let start = suggestion.edit.range.start;
64    let end = suggestion.edit.range.end;
65
66    let line = content[..start]
67        .bytes()
68        .filter(|&byte| byte == b'\n')
69        .count()
70        + 1;
71    let line_start = content[..start].rfind('\n').map_or(0, |index| index + 1);
72    let line_end = content[start..]
73        .find('\n')
74        .map_or(content.len(), |index| start + index);
75
76    let original = content[line_start..line_end].to_string();
77    let rel_start = start - line_start;
78    let rel_end = (end - line_start).min(original.len());
79    let modified = format!(
80        "{}{}{}",
81        &original[..rel_start],
82        suggestion.edit.replacement,
83        &original[rel_end..]
84    );
85
86    DiffEntry {
87        line,
88        analyzer: analyzer.to_string(),
89        preview: ChangePreview {
90            original,
91            modified,
92            description: format!("{} fix", analyzer)
93        },
94        suggestion
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use tempfile::TempDir;
101
102    use super::*;
103    use crate::analyzers::default_analyzers;
104
105    #[test]
106    fn test_generate_diff_integration() {
107        let temp_dir = TempDir::new().unwrap();
108        let file_path = temp_dir.path().join("test.rs");
109        std::fs::write(
110            &file_path,
111            "fn main() { let x = std::fs::read_to_string(\"f\"); }"
112        )
113        .unwrap();
114
115        let analyzers = default_analyzers();
116        let result = generate_diff(file_path.to_str().unwrap(), &analyzers);
117
118        assert!(result.is_ok());
119    }
120
121    #[test]
122    fn test_generate_diff_no_issues() {
123        let temp_dir = TempDir::new().unwrap();
124        let file_path = temp_dir.path().join("test.rs");
125        std::fs::write(&file_path, "fn main() {}").unwrap();
126
127        let analyzers = default_analyzers();
128        let result = generate_diff(file_path.to_str().unwrap(), &analyzers);
129
130        assert!(result.is_ok());
131    }
132
133    #[test]
134    fn test_generate_diff_invalid_syntax() {
135        let temp_dir = TempDir::new().unwrap();
136        let file_path = temp_dir.path().join("test.rs");
137        std::fs::write(&file_path, "fn main() { invalid syntax +++").unwrap();
138
139        let analyzers = default_analyzers();
140        let result = generate_diff(file_path.to_str().unwrap(), &analyzers);
141
142        assert!(result.is_err());
143    }
144
145    #[test]
146    fn test_path_import_included_in_diff() {
147        let temp_dir = TempDir::new().unwrap();
148        let file_path = temp_dir.path().join("test.rs");
149        std::fs::write(
150            &file_path,
151            "fn main() { let x = std::fs::read_to_string(\"f\"); }"
152        )
153        .unwrap();
154
155        let analyzers = default_analyzers();
156        let result = generate_diff(file_path.to_str().unwrap(), &analyzers).unwrap();
157
158        assert!(
159            result.entries.iter().any(|e| e.analyzer == "path_import"),
160            "path_import should be included in diff with suggestions"
161        );
162    }
163
164    #[test]
165    fn test_format_args_excluded_from_diff_without_suggestion() {
166        let temp_dir = TempDir::new().unwrap();
167        let file_path = temp_dir.path().join("test.rs");
168        std::fs::write(
169            &file_path,
170            "fn main() { println!(\"Hello {}\", \"world\"); }"
171        )
172        .unwrap();
173
174        let analyzers = default_analyzers();
175        let result = generate_diff(file_path.to_str().unwrap(), &analyzers).unwrap();
176
177        for entry in &result.entries {
178            assert_ne!(entry.analyzer, "format_args");
179        }
180    }
181}