ferrous_forge/
formatting.rs

1//! Code formatting and auto-correction module
2//!
3//! This module provides integration with rustfmt for code formatting
4//! validation and automatic correction.
5
6use crate::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use std::process::Command;
10
11/// Formatting check result
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct FormatResult {
14    /// Whether the code is properly formatted
15    pub formatted: bool,
16    /// Files that need formatting
17    pub unformatted_files: Vec<String>,
18    /// Suggested changes
19    pub suggestions: Vec<FormatSuggestion>,
20}
21
22/// A formatting suggestion for a file
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct FormatSuggestion {
25    /// File path
26    pub file: String,
27    /// Line number
28    pub line: usize,
29    /// Description of the formatting issue
30    pub description: String,
31}
32
33impl FormatResult {
34    /// Generate a human-readable report
35    pub fn report(&self) -> String {
36        let mut report = String::new();
37
38        if self.formatted {
39            report.push_str("āœ… Code formatting check passed - All files properly formatted!\n");
40        } else {
41            report.push_str(&format!(
42                "āš ļø Code formatting issues found in {} files\n\n",
43                self.unformatted_files.len()
44            ));
45
46            report.push_str("Files needing formatting:\n");
47            for file in &self.unformatted_files {
48                report.push_str(&format!("  • {}\n", file));
49            }
50
51            if !self.suggestions.is_empty() {
52                report.push_str("\nFormatting suggestions:\n");
53                for suggestion in &self.suggestions.iter().take(10).collect::<Vec<_>>() {
54                    report.push_str(&format!(
55                        "  {}:{} - {}\n",
56                        suggestion.file, suggestion.line, suggestion.description
57                    ));
58                }
59
60                if self.suggestions.len() > 10 {
61                    report.push_str(&format!(
62                        "  ... and {} more suggestions\n",
63                        self.suggestions.len() - 10
64                    ));
65                }
66            }
67
68            report.push_str(
69                "\nšŸ’” Run 'ferrous-forge fix --format' to automatically fix these issues\n",
70            );
71        }
72
73        report
74    }
75}
76
77/// Check code formatting
78pub async fn check_formatting(project_path: &Path) -> Result<FormatResult> {
79    // Ensure rustfmt is installed
80    ensure_rustfmt_installed().await?;
81
82    // Run cargo fmt with check mode
83    let output = Command::new("cargo")
84        .args(&["fmt", "--", "--check", "--verbose"])
85        .current_dir(project_path)
86        .output()
87        .map_err(|e| Error::process(format!("Failed to run cargo fmt: {}", e)))?;
88
89    // Parse the output
90    parse_format_output(&output.stdout, &output.stderr, output.status.success())
91}
92
93/// Auto-format code
94pub async fn auto_format(project_path: &Path) -> Result<()> {
95    // Ensure rustfmt is installed
96    ensure_rustfmt_installed().await?;
97
98    println!("šŸ”§ Auto-formatting code...");
99
100    // Run cargo fmt
101    let output = Command::new("cargo")
102        .arg("fmt")
103        .current_dir(project_path)
104        .output()
105        .map_err(|e| Error::process(format!("Failed to run cargo fmt: {}", e)))?;
106
107    if output.status.success() {
108        println!("✨ Code formatted successfully!");
109        Ok(())
110    } else {
111        let stderr = String::from_utf8_lossy(&output.stderr);
112        Err(Error::process(format!("Formatting failed: {}", stderr)))
113    }
114}
115
116/// Check formatting for a specific file
117pub async fn check_file_formatting(file_path: &Path) -> Result<bool> {
118    // Ensure rustfmt is installed
119    ensure_rustfmt_installed().await?;
120
121    // Run rustfmt with check mode on single file
122    let output = Command::new("rustfmt")
123        .args(&[
124            "--check",
125            file_path
126                .to_str()
127                .ok_or_else(|| Error::process("Invalid file path"))?,
128        ])
129        .output()
130        .map_err(|e| Error::process(format!("Failed to run rustfmt: {}", e)))?;
131
132    Ok(output.status.success())
133}
134
135/// Format a specific file
136pub async fn format_file(file_path: &Path) -> Result<()> {
137    // Ensure rustfmt is installed
138    ensure_rustfmt_installed().await?;
139
140    // Run rustfmt on single file
141    let output = Command::new("rustfmt")
142        .arg(
143            file_path
144                .to_str()
145                .ok_or_else(|| Error::process("Invalid file path"))?,
146        )
147        .output()
148        .map_err(|e| Error::process(format!("Failed to run rustfmt: {}", e)))?;
149
150    if output.status.success() {
151        Ok(())
152    } else {
153        let stderr = String::from_utf8_lossy(&output.stderr);
154        Err(Error::process(format!(
155            "Failed to format {}: {}",
156            file_path.display(),
157            stderr
158        )))
159    }
160}
161
162/// Get formatting diff without applying changes
163pub async fn get_format_diff(project_path: &Path) -> Result<String> {
164    // Ensure rustfmt is installed
165    ensure_rustfmt_installed().await?;
166
167    // Run cargo fmt with diff output
168    let output = Command::new("cargo")
169        .args(&["fmt", "--", "--check", "--emit=stdout"])
170        .current_dir(project_path)
171        .output()
172        .map_err(|e| Error::process(format!("Failed to run cargo fmt: {}", e)))?;
173
174    Ok(String::from_utf8_lossy(&output.stdout).to_string())
175}
176
177/// Ensure rustfmt is installed
178async fn ensure_rustfmt_installed() -> Result<()> {
179    let check = Command::new("rustfmt").arg("--version").output();
180
181    if check
182        .as_ref()
183        .map_or(true, |output| !output.status.success())
184    {
185        println!("šŸ“¦ Installing rustfmt...");
186
187        let install = Command::new("rustup")
188            .args(&["component", "add", "rustfmt"])
189            .output()
190            .map_err(|e| Error::process(format!("Failed to install rustfmt: {}", e)))?;
191
192        if !install.status.success() {
193            return Err(Error::process("Failed to install rustfmt"));
194        }
195
196        println!("āœ… rustfmt installed successfully");
197    }
198
199    Ok(())
200}
201
202/// Parse formatting check output
203fn parse_format_output(stdout: &[u8], stderr: &[u8], success: bool) -> Result<FormatResult> {
204    if success {
205        return Ok(FormatResult {
206            formatted: true,
207            unformatted_files: vec![],
208            suggestions: vec![],
209        });
210    }
211
212    let stderr_str = String::from_utf8_lossy(stderr);
213    let stdout_str = String::from_utf8_lossy(stdout);
214
215    let mut unformatted_files = Vec::new();
216    let mut suggestions = Vec::new();
217
218    // Parse file names from stderr (rustfmt --check output)
219    for line in stderr_str.lines() {
220        if line.starts_with("Diff in") {
221            if let Some(file) = line.strip_prefix("Diff in ") {
222                let file = file.trim_end_matches(" at line 1:");
223                let file = file.trim_end_matches(':');
224                unformatted_files.push(file.to_string());
225            }
226        }
227    }
228
229    // Parse suggestions from stdout if available
230    for line in stdout_str.lines() {
231        if line.starts_with("warning:") || line.contains("formatting") {
232            // Extract file and line info if present
233            if let Some(pos) = line.find(".rs:") {
234                let start = line.rfind('/').unwrap_or(0);
235                let file = &line[start..pos + 3];
236
237                // Try to extract line number
238                let line_num = if let Some(num_start) = line[pos + 3..].find(':') {
239                    line[pos + 4..pos + 3 + num_start].parse().unwrap_or(0)
240                } else {
241                    0
242                };
243
244                suggestions.push(FormatSuggestion {
245                    file: file.to_string(),
246                    line: line_num,
247                    description: "Formatting required".to_string(),
248                });
249            }
250        }
251    }
252
253    Ok(FormatResult {
254        formatted: false,
255        unformatted_files,
256        suggestions,
257    })
258}
259
260/// Apply formatting configuration
261pub async fn apply_rustfmt_config(project_path: &Path) -> Result<()> {
262    let rustfmt_toml = project_path.join("rustfmt.toml");
263
264    if !rustfmt_toml.exists() {
265        // Create default rustfmt.toml
266        let config = r#"# Ferrous Forge rustfmt configuration
267edition = "2021"
268max_width = 100
269hard_tabs = false
270tab_spaces = 4
271newline_style = "Auto"
272use_small_heuristics = "Default"
273reorder_imports = true
274reorder_modules = true
275remove_nested_parens = true
276format_strings = false
277format_macro_matchers = false
278format_macro_bodies = true
279empty_item_single_line = true
280struct_lit_single_line = true
281fn_single_line = false
282where_single_line = false
283imports_indent = "Block"
284imports_layout = "Mixed"
285merge_derives = true
286group_imports = "StdExternalCrate"
287reorder_impl_items = false
288spaces_around_ranges = false
289trailing_semicolon = true
290trailing_comma = "Vertical"
291match_block_trailing_comma = false
292blank_lines_upper_bound = 1
293blank_lines_lower_bound = 0
294"#;
295
296        tokio::fs::write(&rustfmt_toml, config)
297            .await
298            .map_err(|e| Error::process(format!("Failed to create rustfmt.toml: {}", e)))?;
299
300        println!("āœ… Created rustfmt.toml with Ferrous Forge standards");
301    }
302
303    Ok(())
304}
305
306#[cfg(test)]
307#[allow(clippy::expect_used, clippy::unwrap_used)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_format_result_formatted() {
313        let result = FormatResult {
314            formatted: true,
315            unformatted_files: vec![],
316            suggestions: vec![],
317        };
318
319        assert!(result.formatted);
320        assert!(result.unformatted_files.is_empty());
321        assert!(result.suggestions.is_empty());
322    }
323
324    #[test]
325    fn test_format_result_unformatted() {
326        let result = FormatResult {
327            formatted: false,
328            unformatted_files: vec!["src/main.rs".to_string()],
329            suggestions: vec![FormatSuggestion {
330                file: "src/main.rs".to_string(),
331                line: 10,
332                description: "Formatting required".to_string(),
333            }],
334        };
335
336        assert!(!result.formatted);
337        assert_eq!(result.unformatted_files.len(), 1);
338        assert_eq!(result.suggestions.len(), 1);
339    }
340}