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("\nšŸ’” Run 'ferrous-forge fix --format' to automatically fix these issues\n");
69        }
70        
71        report
72    }
73}
74
75/// Check code formatting
76pub async fn check_formatting(project_path: &Path) -> Result<FormatResult> {
77    // Ensure rustfmt is installed
78    ensure_rustfmt_installed().await?;
79    
80    // Run cargo fmt with check mode
81    let output = Command::new("cargo")
82        .args(&["fmt", "--", "--check", "--verbose"])
83        .current_dir(project_path)
84        .output()
85        .map_err(|e| Error::process(format!("Failed to run cargo fmt: {}", e)))?;
86    
87    // Parse the output
88    parse_format_output(&output.stdout, &output.stderr, output.status.success())
89}
90
91/// Auto-format code
92pub async fn auto_format(project_path: &Path) -> Result<()> {
93    // Ensure rustfmt is installed
94    ensure_rustfmt_installed().await?;
95    
96    println!("šŸ”§ Auto-formatting code...");
97    
98    // Run cargo fmt
99    let output = Command::new("cargo")
100        .arg("fmt")
101        .current_dir(project_path)
102        .output()
103        .map_err(|e| Error::process(format!("Failed to run cargo fmt: {}", e)))?;
104    
105    if output.status.success() {
106        println!("✨ Code formatted successfully!");
107        Ok(())
108    } else {
109        let stderr = String::from_utf8_lossy(&output.stderr);
110        Err(Error::process(format!("Formatting failed: {}", stderr)))
111    }
112}
113
114/// Check formatting for a specific file
115pub async fn check_file_formatting(file_path: &Path) -> Result<bool> {
116    // Ensure rustfmt is installed
117    ensure_rustfmt_installed().await?;
118    
119    // Run rustfmt with check mode on single file
120    let output = Command::new("rustfmt")
121        .args(&["--check", file_path.to_str().ok_or_else(|| Error::process("Invalid file path"))?])
122        .output()
123        .map_err(|e| Error::process(format!("Failed to run rustfmt: {}", e)))?;
124    
125    Ok(output.status.success())
126}
127
128/// Format a specific file
129pub async fn format_file(file_path: &Path) -> Result<()> {
130    // Ensure rustfmt is installed
131    ensure_rustfmt_installed().await?;
132    
133    // Run rustfmt on single file
134    let output = Command::new("rustfmt")
135        .arg(file_path.to_str().ok_or_else(|| Error::process("Invalid file path"))?)
136        .output()
137        .map_err(|e| Error::process(format!("Failed to run rustfmt: {}", e)))?;
138    
139    if output.status.success() {
140        Ok(())
141    } else {
142        let stderr = String::from_utf8_lossy(&output.stderr);
143        Err(Error::process(format!("Failed to format {}: {}", file_path.display(), stderr)))
144    }
145}
146
147/// Get formatting diff without applying changes
148pub async fn get_format_diff(project_path: &Path) -> Result<String> {
149    // Ensure rustfmt is installed
150    ensure_rustfmt_installed().await?;
151    
152    // Run cargo fmt with diff output
153    let output = Command::new("cargo")
154        .args(&["fmt", "--", "--check", "--emit=stdout"])
155        .current_dir(project_path)
156        .output()
157        .map_err(|e| Error::process(format!("Failed to run cargo fmt: {}", e)))?;
158    
159    Ok(String::from_utf8_lossy(&output.stdout).to_string())
160}
161
162/// Ensure rustfmt is installed
163async fn ensure_rustfmt_installed() -> Result<()> {
164    let check = Command::new("rustfmt")
165        .arg("--version")
166        .output();
167    
168    if check.as_ref().map_or(true, |output| !output.status.success()) {
169        println!("šŸ“¦ Installing rustfmt...");
170        
171        let install = Command::new("rustup")
172            .args(&["component", "add", "rustfmt"])
173            .output()
174            .map_err(|e| Error::process(format!("Failed to install rustfmt: {}", e)))?;
175        
176        if !install.status.success() {
177            return Err(Error::process("Failed to install rustfmt"));
178        }
179        
180        println!("āœ… rustfmt installed successfully");
181    }
182    
183    Ok(())
184}
185
186/// Parse formatting check output
187fn parse_format_output(stdout: &[u8], stderr: &[u8], success: bool) -> Result<FormatResult> {
188    if success {
189        return Ok(FormatResult {
190            formatted: true,
191            unformatted_files: vec![],
192            suggestions: vec![],
193        });
194    }
195    
196    let stderr_str = String::from_utf8_lossy(stderr);
197    let stdout_str = String::from_utf8_lossy(stdout);
198    
199    let mut unformatted_files = Vec::new();
200    let mut suggestions = Vec::new();
201    
202    // Parse file names from stderr (rustfmt --check output)
203    for line in stderr_str.lines() {
204        if line.starts_with("Diff in") {
205            if let Some(file) = line.strip_prefix("Diff in ") {
206                let file = file.trim_end_matches(" at line 1:");
207                let file = file.trim_end_matches(':');
208                unformatted_files.push(file.to_string());
209            }
210        }
211    }
212    
213    // Parse suggestions from stdout if available
214    for line in stdout_str.lines() {
215        if line.starts_with("warning:") || line.contains("formatting") {
216            // Extract file and line info if present
217            if let Some(pos) = line.find(".rs:") {
218                let start = line.rfind('/').unwrap_or(0);
219                let file = &line[start..pos + 3];
220                
221                // Try to extract line number
222                let line_num = if let Some(num_start) = line[pos + 3..].find(':') {
223                    line[pos + 4..pos + 3 + num_start].parse().unwrap_or(0)
224                } else {
225                    0
226                };
227                
228                suggestions.push(FormatSuggestion {
229                    file: file.to_string(),
230                    line: line_num,
231                    description: "Formatting required".to_string(),
232                });
233            }
234        }
235    }
236    
237    Ok(FormatResult {
238        formatted: false,
239        unformatted_files,
240        suggestions,
241    })
242}
243
244/// Apply formatting configuration
245pub async fn apply_rustfmt_config(project_path: &Path) -> Result<()> {
246    let rustfmt_toml = project_path.join("rustfmt.toml");
247    
248    if !rustfmt_toml.exists() {
249        // Create default rustfmt.toml
250        let config = r#"# Ferrous Forge rustfmt configuration
251edition = "2021"
252max_width = 100
253hard_tabs = false
254tab_spaces = 4
255newline_style = "Auto"
256use_small_heuristics = "Default"
257reorder_imports = true
258reorder_modules = true
259remove_nested_parens = true
260format_strings = false
261format_macro_matchers = false
262format_macro_bodies = true
263empty_item_single_line = true
264struct_lit_single_line = true
265fn_single_line = false
266where_single_line = false
267imports_indent = "Block"
268imports_layout = "Mixed"
269merge_derives = true
270group_imports = "StdExternalCrate"
271reorder_impl_items = false
272spaces_around_ranges = false
273trailing_semicolon = true
274trailing_comma = "Vertical"
275match_block_trailing_comma = false
276blank_lines_upper_bound = 1
277blank_lines_lower_bound = 0
278"#;
279        
280        tokio::fs::write(&rustfmt_toml, config)
281            .await
282            .map_err(|e| Error::process(format!("Failed to create rustfmt.toml: {}", e)))?;
283        
284        println!("āœ… Created rustfmt.toml with Ferrous Forge standards");
285    }
286    
287    Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    
294    #[test]
295    fn test_format_result_formatted() {
296        let result = FormatResult {
297            formatted: true,
298            unformatted_files: vec![],
299            suggestions: vec![],
300        };
301        
302        assert!(result.formatted);
303        assert!(result.unformatted_files.is_empty());
304        assert!(result.suggestions.is_empty());
305    }
306    
307    #[test]
308    fn test_format_result_unformatted() {
309        let result = FormatResult {
310            formatted: false,
311            unformatted_files: vec!["src/main.rs".to_string()],
312            suggestions: vec![FormatSuggestion {
313                file: "src/main.rs".to_string(),
314                line: 10,
315                description: "Formatting required".to_string(),
316            }],
317        };
318        
319        assert!(!result.formatted);
320        assert_eq!(result.unformatted_files.len(), 1);
321        assert_eq!(result.suggestions.len(), 1);
322    }
323}