sklears_core/
formatting.rs

1/// Automated code formatting and linting utilities
2///
3/// This module provides tools for checking and enforcing code formatting standards
4/// specific to machine learning code patterns in the sklears ecosystem.
5use crate::error::SklearsError;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9/// Result type alias for formatting operations
10pub type Result<T> = std::result::Result<T, SklearsError>;
11
12/// Configuration for code formatting checks
13#[derive(Debug, Clone)]
14pub struct FormattingConfig {
15    /// Enable rustfmt checking
16    pub check_rustfmt: bool,
17    /// Enable clippy checking
18    pub check_clippy: bool,
19    /// Custom clippy lints to enable
20    pub clippy_lints: Vec<String>,
21    /// Paths to exclude from formatting checks
22    pub exclude_paths: Vec<PathBuf>,
23    /// Maximum allowed line length
24    pub max_line_length: usize,
25    /// Require documentation for public items
26    pub require_docs: bool,
27    /// ML-specific formatting rules
28    pub ml_specific_rules: MLFormattingRules,
29}
30
31/// ML-specific formatting rules
32#[derive(Debug, Clone)]
33pub struct MLFormattingRules {
34    /// Require type annotations for ML parameters
35    pub require_param_types: bool,
36    /// Enforce consistent naming for ML concepts
37    pub enforce_ml_naming: bool,
38    /// Require validation for ML inputs
39    pub require_input_validation: bool,
40    /// Maximum complexity for ML functions
41    pub max_function_complexity: usize,
42    /// Require error handling for ML operations
43    pub require_error_handling: bool,
44}
45
46impl Default for FormattingConfig {
47    fn default() -> Self {
48        Self {
49            check_rustfmt: true,
50            check_clippy: true,
51            clippy_lints: vec![
52                "clippy::pedantic".to_string(),
53                "clippy::cargo".to_string(),
54                "clippy::nursery".to_string(),
55            ],
56            exclude_paths: vec![PathBuf::from("target"), PathBuf::from("*.lock")],
57            max_line_length: 100,
58            require_docs: true,
59            ml_specific_rules: MLFormattingRules::default(),
60        }
61    }
62}
63
64impl Default for MLFormattingRules {
65    fn default() -> Self {
66        Self {
67            require_param_types: true,
68            enforce_ml_naming: true,
69            require_input_validation: true,
70            max_function_complexity: 10,
71            require_error_handling: true,
72        }
73    }
74}
75
76/// Result of formatting checks
77#[derive(Debug, Clone)]
78pub struct FormattingReport {
79    /// Overall formatting status
80    pub passed: bool,
81    /// Rustfmt check results
82    pub rustfmt_result: Option<CheckResult>,
83    /// Clippy check results
84    pub clippy_result: Option<CheckResult>,
85    /// ML-specific rule check results
86    pub ml_rules_result: Option<MLRulesResult>,
87    /// Summary of all issues found
88    pub summary: FormattingSummary,
89}
90
91/// Result of a specific formatting check
92#[derive(Debug, Clone)]
93pub struct CheckResult {
94    /// Whether the check passed
95    pub passed: bool,
96    /// Issues found during the check
97    pub issues: Vec<FormattingIssue>,
98    /// Command output (stdout/stderr)
99    pub output: String,
100    /// Exit code from the formatting tool
101    pub exit_code: i32,
102}
103
104/// ML-specific rules check result
105#[derive(Debug, Clone)]
106pub struct MLRulesResult {
107    /// Whether all ML rules passed
108    pub passed: bool,
109    /// Parameter type annotation issues
110    pub param_type_issues: Vec<FormattingIssue>,
111    /// ML naming convention issues
112    pub naming_issues: Vec<FormattingIssue>,
113    /// Input validation issues
114    pub validation_issues: Vec<FormattingIssue>,
115    /// Function complexity issues
116    pub complexity_issues: Vec<FormattingIssue>,
117    /// Error handling issues
118    pub error_handling_issues: Vec<FormattingIssue>,
119}
120
121/// Individual formatting issue
122#[derive(Debug, Clone)]
123pub struct FormattingIssue {
124    /// File path where the issue was found
125    pub file: PathBuf,
126    /// Line number (if applicable)
127    pub line: Option<usize>,
128    /// Column number (if applicable)
129    pub column: Option<usize>,
130    /// Issue severity
131    pub severity: IssueSeverity,
132    /// Description of the issue
133    pub message: String,
134    /// Suggested fix (if available)
135    pub suggestion: Option<String>,
136    /// Rule that triggered this issue
137    pub rule: String,
138}
139
140/// Severity level of formatting issues
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum IssueSeverity {
143    /// Error that must be fixed
144    Error,
145    /// Warning that should be addressed
146    Warning,
147    /// Information/suggestion
148    Info,
149}
150
151/// Summary of all formatting checks
152#[derive(Debug, Clone)]
153pub struct FormattingSummary {
154    /// Total number of files checked
155    pub files_checked: usize,
156    /// Number of errors found
157    pub error_count: usize,
158    /// Number of warnings found
159    pub warning_count: usize,
160    /// Number of info issues found
161    pub info_count: usize,
162    /// Files with issues
163    pub files_with_issues: Vec<PathBuf>,
164}
165
166/// Main formatter for checking code quality
167pub struct CodeFormatter {
168    config: FormattingConfig,
169}
170
171impl CodeFormatter {
172    /// Create a new code formatter with default configuration
173    pub fn new() -> Self {
174        Self {
175            config: FormattingConfig::default(),
176        }
177    }
178
179    /// Create a new code formatter with custom configuration
180    pub fn with_config(config: FormattingConfig) -> Self {
181        Self { config }
182    }
183
184    /// Run all formatting checks on the specified path
185    pub fn check_all<P: AsRef<Path>>(&self, path: P) -> Result<FormattingReport> {
186        let path = path.as_ref();
187
188        let mut report = FormattingReport {
189            passed: true,
190            rustfmt_result: None,
191            clippy_result: None,
192            ml_rules_result: None,
193            summary: FormattingSummary {
194                files_checked: 0,
195                error_count: 0,
196                warning_count: 0,
197                info_count: 0,
198                files_with_issues: Vec::new(),
199            },
200        };
201
202        // Run rustfmt check
203        if self.config.check_rustfmt {
204            match self.check_rustfmt(path) {
205                Ok(result) => {
206                    report.passed &= result.passed;
207                    report.rustfmt_result = Some(result);
208                }
209                Err(e) => {
210                    log::warn!("Failed to run rustfmt check: {e}");
211                    report.passed = false;
212                }
213            }
214        }
215
216        // Run clippy check
217        if self.config.check_clippy {
218            match self.check_clippy(path) {
219                Ok(result) => {
220                    report.passed &= result.passed;
221                    report.clippy_result = Some(result);
222                }
223                Err(e) => {
224                    log::warn!("Failed to run clippy check: {e}");
225                    report.passed = false;
226                }
227            }
228        }
229
230        // Run ML-specific rules check
231        match self.check_ml_rules(path) {
232            Ok(result) => {
233                report.passed &= result.passed;
234                report.ml_rules_result = Some(result);
235            }
236            Err(e) => {
237                log::warn!("Failed to run ML rules check: {e}");
238                report.passed = false;
239            }
240        }
241
242        // Generate summary
243        self.generate_summary(&mut report);
244
245        Ok(report)
246    }
247
248    /// Check rustfmt formatting
249    fn check_rustfmt<P: AsRef<Path>>(&self, path: P) -> Result<CheckResult> {
250        let output = Command::new("rustfmt")
251            .arg("--check")
252            .arg("--config")
253            .arg(format!("max_width={}", self.config.max_line_length))
254            .arg(path.as_ref())
255            .stdout(Stdio::piped())
256            .stderr(Stdio::piped())
257            .output()
258            .map_err(|e| SklearsError::InvalidInput(format!("Failed to run rustfmt: {e}")))?;
259
260        let passed = output.status.success();
261        let output_str = String::from_utf8_lossy(&output.stderr).to_string();
262        let issues = self.parse_rustfmt_output(&output_str);
263
264        Ok(CheckResult {
265            passed,
266            issues,
267            output: output_str,
268            exit_code: output.status.code().unwrap_or(-1),
269        })
270    }
271
272    /// Check clippy lints
273    fn check_clippy<P: AsRef<Path>>(&self, path: P) -> Result<CheckResult> {
274        let mut cmd = Command::new("cargo");
275        cmd.arg("clippy").arg("--").arg("-D").arg("warnings");
276
277        // Add custom lints
278        for lint in &self.config.clippy_lints {
279            cmd.arg("-W").arg(lint);
280        }
281
282        let output = cmd
283            .current_dir(path.as_ref())
284            .stdout(Stdio::piped())
285            .stderr(Stdio::piped())
286            .output()
287            .map_err(|e| SklearsError::InvalidInput(format!("Failed to run clippy: {e}")))?;
288
289        let passed = output.status.success();
290        let output_str = String::from_utf8_lossy(&output.stderr).to_string();
291        let issues = self.parse_clippy_output(&output_str);
292
293        Ok(CheckResult {
294            passed,
295            issues,
296            output: output_str,
297            exit_code: output.status.code().unwrap_or(-1),
298        })
299    }
300
301    /// Check ML-specific formatting rules
302    fn check_ml_rules<P: AsRef<Path>>(&self, _path: P) -> Result<MLRulesResult> {
303        // This is a simplified implementation
304        // In a full implementation, this would parse Rust AST and check for ML-specific patterns
305
306        let result = MLRulesResult {
307            passed: true,
308            param_type_issues: Vec::new(),
309            naming_issues: Vec::new(),
310            validation_issues: Vec::new(),
311            complexity_issues: Vec::new(),
312            error_handling_issues: Vec::new(),
313        };
314
315        Ok(result)
316    }
317
318    /// Parse rustfmt output into formatting issues
319    fn parse_rustfmt_output(&self, output: &str) -> Vec<FormattingIssue> {
320        let mut issues = Vec::new();
321
322        for line in output.lines() {
323            if line.contains("Diff in") {
324                if let Some(file_path) = line.split_whitespace().nth(2) {
325                    issues.push(FormattingIssue {
326                        file: PathBuf::from(file_path),
327                        line: None,
328                        column: None,
329                        severity: IssueSeverity::Error,
330                        message: "File is not properly formatted".to_string(),
331                        suggestion: Some("Run 'cargo fmt' to fix formatting".to_string()),
332                        rule: "rustfmt".to_string(),
333                    });
334                }
335            }
336        }
337
338        issues
339    }
340
341    /// Parse clippy output into formatting issues
342    fn parse_clippy_output(&self, output: &str) -> Vec<FormattingIssue> {
343        let mut issues = Vec::new();
344
345        for line in output.lines() {
346            if line.contains("warning:") || line.contains("error:") {
347                // Parse clippy output format: "file:line:column: level: message"
348                let parts: Vec<&str> = line.splitn(5, ':').collect();
349                if parts.len() >= 5 {
350                    let file = PathBuf::from(parts[0]);
351                    let line = parts[1].parse().ok();
352                    let column = parts[2].parse().ok();
353                    let severity = match parts[3].trim() {
354                        "error" => IssueSeverity::Error,
355                        "warning" => IssueSeverity::Warning,
356                        _ => IssueSeverity::Info,
357                    };
358                    let message = parts[4].trim().to_string();
359
360                    issues.push(FormattingIssue {
361                        file,
362                        line,
363                        column,
364                        severity,
365                        message,
366                        suggestion: None,
367                        rule: "clippy".to_string(),
368                    });
369                }
370            }
371        }
372
373        issues
374    }
375
376    /// Generate summary statistics for the formatting report
377    fn generate_summary(&self, report: &mut FormattingReport) {
378        let mut files_with_issues = Vec::new();
379        let mut error_count = 0;
380        let mut warning_count = 0;
381        let mut info_count = 0;
382
383        // Count issues from all check results
384        if let Some(ref result) = report.rustfmt_result {
385            for issue in &result.issues {
386                match issue.severity {
387                    IssueSeverity::Error => error_count += 1,
388                    IssueSeverity::Warning => warning_count += 1,
389                    IssueSeverity::Info => info_count += 1,
390                }
391                if !files_with_issues.contains(&issue.file) {
392                    files_with_issues.push(issue.file.clone());
393                }
394            }
395        }
396
397        if let Some(ref result) = report.clippy_result {
398            for issue in &result.issues {
399                match issue.severity {
400                    IssueSeverity::Error => error_count += 1,
401                    IssueSeverity::Warning => warning_count += 1,
402                    IssueSeverity::Info => info_count += 1,
403                }
404                if !files_with_issues.contains(&issue.file) {
405                    files_with_issues.push(issue.file.clone());
406                }
407            }
408        }
409
410        if let Some(ref result) = report.ml_rules_result {
411            let all_ml_issues = [
412                &result.param_type_issues,
413                &result.naming_issues,
414                &result.validation_issues,
415                &result.complexity_issues,
416                &result.error_handling_issues,
417            ];
418
419            for issues in all_ml_issues {
420                for issue in issues {
421                    match issue.severity {
422                        IssueSeverity::Error => error_count += 1,
423                        IssueSeverity::Warning => warning_count += 1,
424                        IssueSeverity::Info => info_count += 1,
425                    }
426                    if !files_with_issues.contains(&issue.file) {
427                        files_with_issues.push(issue.file.clone());
428                    }
429                }
430            }
431        }
432
433        report.summary = FormattingSummary {
434            files_checked: files_with_issues.len().max(1), // At least 1 if any checks were run
435            error_count,
436            warning_count,
437            info_count,
438            files_with_issues,
439        };
440    }
441
442    /// Fix formatting issues automatically where possible
443    pub fn fix_issues<P: AsRef<Path>>(&self, path: P) -> Result<FormattingReport> {
444        let path = path.as_ref();
445
446        // Run rustfmt to fix formatting
447        if self.config.check_rustfmt {
448            let _output = Command::new("rustfmt")
449                .arg(path)
450                .stdout(Stdio::piped())
451                .stderr(Stdio::piped())
452                .output()
453                .map_err(|e| SklearsError::InvalidInput(format!("Failed to run rustfmt: {e}")))?;
454        }
455
456        // Re-run checks to see what was fixed
457        self.check_all(path)
458    }
459
460    /// Get the current configuration
461    pub fn config(&self) -> &FormattingConfig {
462        &self.config
463    }
464
465    /// Update the configuration
466    pub fn set_config(&mut self, config: FormattingConfig) {
467        self.config = config;
468    }
469}
470
471impl Default for CodeFormatter {
472    fn default() -> Self {
473        Self::new()
474    }
475}
476
477/// Builder for creating formatting configurations
478pub struct FormattingConfigBuilder {
479    config: FormattingConfig,
480}
481
482impl FormattingConfigBuilder {
483    /// Create a new configuration builder
484    pub fn new() -> Self {
485        Self {
486            config: FormattingConfig::default(),
487        }
488    }
489
490    /// Enable or disable rustfmt checking
491    pub fn check_rustfmt(mut self, enable: bool) -> Self {
492        self.config.check_rustfmt = enable;
493        self
494    }
495
496    /// Enable or disable clippy checking
497    pub fn check_clippy(mut self, enable: bool) -> Self {
498        self.config.check_clippy = enable;
499        self
500    }
501
502    /// Add custom clippy lints
503    pub fn clippy_lints(mut self, lints: Vec<String>) -> Self {
504        self.config.clippy_lints = lints;
505        self
506    }
507
508    /// Set maximum line length
509    pub fn max_line_length(mut self, length: usize) -> Self {
510        self.config.max_line_length = length;
511        self
512    }
513
514    /// Enable or disable documentation requirements
515    pub fn require_docs(mut self, require: bool) -> Self {
516        self.config.require_docs = require;
517        self
518    }
519
520    /// Set ML-specific formatting rules
521    pub fn ml_rules(mut self, rules: MLFormattingRules) -> Self {
522        self.config.ml_specific_rules = rules;
523        self
524    }
525
526    /// Build the configuration
527    pub fn build(self) -> FormattingConfig {
528        self.config
529    }
530}
531
532impl Default for FormattingConfigBuilder {
533    fn default() -> Self {
534        Self::new()
535    }
536}
537
538#[allow(non_snake_case)]
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_formatting_config_default() {
545        let config = FormattingConfig::default();
546        assert!(config.check_rustfmt);
547        assert!(config.check_clippy);
548        assert_eq!(config.max_line_length, 100);
549        assert!(config.require_docs);
550    }
551
552    #[test]
553    fn test_formatting_config_builder() {
554        let config = FormattingConfigBuilder::new()
555            .check_rustfmt(false)
556            .max_line_length(120)
557            .require_docs(false)
558            .build();
559
560        assert!(!config.check_rustfmt);
561        assert_eq!(config.max_line_length, 120);
562        assert!(!config.require_docs);
563    }
564
565    #[test]
566    fn test_code_formatter_creation() {
567        let formatter = CodeFormatter::new();
568        assert!(formatter.config().check_rustfmt);
569        assert!(formatter.config().check_clippy);
570    }
571
572    #[test]
573    fn test_formatting_issue_creation() {
574        let issue = FormattingIssue {
575            file: PathBuf::from("test.rs"),
576            line: Some(10),
577            column: Some(5),
578            severity: IssueSeverity::Warning,
579            message: "Test issue".to_string(),
580            suggestion: Some("Fix it".to_string()),
581            rule: "test_rule".to_string(),
582        };
583
584        assert_eq!(issue.file, PathBuf::from("test.rs"));
585        assert_eq!(issue.line, Some(10));
586        assert_eq!(issue.severity, IssueSeverity::Warning);
587    }
588
589    #[test]
590    fn test_ml_formatting_rules_default() {
591        let rules = MLFormattingRules::default();
592        assert!(rules.require_param_types);
593        assert!(rules.enforce_ml_naming);
594        assert!(rules.require_input_validation);
595        assert_eq!(rules.max_function_complexity, 10);
596        assert!(rules.require_error_handling);
597    }
598
599    #[test]
600    fn test_parse_rustfmt_output() {
601        let formatter = CodeFormatter::new();
602        let output = "Diff in src/test.rs at line 1:\n -old line\n +new line";
603        let issues = formatter.parse_rustfmt_output(output);
604
605        assert_eq!(issues.len(), 1);
606        assert_eq!(issues[0].severity, IssueSeverity::Error);
607        assert!(issues[0].message.contains("not properly formatted"));
608    }
609
610    #[test]
611    fn test_parse_clippy_output() {
612        let formatter = CodeFormatter::new();
613        let output = "src/test.rs:10:5: warning: unused variable";
614        let issues = formatter.parse_clippy_output(output);
615
616        assert_eq!(issues.len(), 1);
617        assert_eq!(issues[0].line, Some(10));
618        assert_eq!(issues[0].column, Some(5));
619        assert_eq!(issues[0].severity, IssueSeverity::Warning);
620    }
621
622    #[test]
623    fn test_issue_severity_ordering() {
624        assert_eq!(IssueSeverity::Error, IssueSeverity::Error);
625        assert_ne!(IssueSeverity::Error, IssueSeverity::Warning);
626        assert_ne!(IssueSeverity::Warning, IssueSeverity::Info);
627    }
628}