use crate::error::SklearsError;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
pub type Result<T> = std::result::Result<T, SklearsError>;
#[derive(Debug, Clone)]
pub struct FormattingConfig {
pub check_rustfmt: bool,
pub check_clippy: bool,
pub clippy_lints: Vec<String>,
pub exclude_paths: Vec<PathBuf>,
pub max_line_length: usize,
pub require_docs: bool,
pub ml_specific_rules: MLFormattingRules,
}
#[derive(Debug, Clone)]
pub struct MLFormattingRules {
pub require_param_types: bool,
pub enforce_ml_naming: bool,
pub require_input_validation: bool,
pub max_function_complexity: usize,
pub require_error_handling: bool,
}
impl Default for FormattingConfig {
fn default() -> Self {
Self {
check_rustfmt: true,
check_clippy: true,
clippy_lints: vec![
"clippy::pedantic".to_string(),
"clippy::cargo".to_string(),
"clippy::nursery".to_string(),
],
exclude_paths: vec![PathBuf::from("target"), PathBuf::from("*.lock")],
max_line_length: 100,
require_docs: true,
ml_specific_rules: MLFormattingRules::default(),
}
}
}
impl Default for MLFormattingRules {
fn default() -> Self {
Self {
require_param_types: true,
enforce_ml_naming: true,
require_input_validation: true,
max_function_complexity: 10,
require_error_handling: true,
}
}
}
#[derive(Debug, Clone)]
pub struct FormattingReport {
pub passed: bool,
pub rustfmt_result: Option<CheckResult>,
pub clippy_result: Option<CheckResult>,
pub ml_rules_result: Option<MLRulesResult>,
pub summary: FormattingSummary,
}
#[derive(Debug, Clone)]
pub struct CheckResult {
pub passed: bool,
pub issues: Vec<FormattingIssue>,
pub output: String,
pub exit_code: i32,
}
#[derive(Debug, Clone)]
pub struct MLRulesResult {
pub passed: bool,
pub param_type_issues: Vec<FormattingIssue>,
pub naming_issues: Vec<FormattingIssue>,
pub validation_issues: Vec<FormattingIssue>,
pub complexity_issues: Vec<FormattingIssue>,
pub error_handling_issues: Vec<FormattingIssue>,
}
#[derive(Debug, Clone)]
pub struct FormattingIssue {
pub file: PathBuf,
pub line: Option<usize>,
pub column: Option<usize>,
pub severity: IssueSeverity,
pub message: String,
pub suggestion: Option<String>,
pub rule: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone)]
pub struct FormattingSummary {
pub files_checked: usize,
pub error_count: usize,
pub warning_count: usize,
pub info_count: usize,
pub files_with_issues: Vec<PathBuf>,
}
pub struct CodeFormatter {
config: FormattingConfig,
}
impl CodeFormatter {
pub fn new() -> Self {
Self {
config: FormattingConfig::default(),
}
}
pub fn with_config(config: FormattingConfig) -> Self {
Self { config }
}
pub fn check_all<P: AsRef<Path>>(&self, path: P) -> Result<FormattingReport> {
let path = path.as_ref();
let mut report = FormattingReport {
passed: true,
rustfmt_result: None,
clippy_result: None,
ml_rules_result: None,
summary: FormattingSummary {
files_checked: 0,
error_count: 0,
warning_count: 0,
info_count: 0,
files_with_issues: Vec::new(),
},
};
if self.config.check_rustfmt {
match self.check_rustfmt(path) {
Ok(result) => {
report.passed &= result.passed;
report.rustfmt_result = Some(result);
}
Err(e) => {
log::warn!("Failed to run rustfmt check: {e}");
report.passed = false;
}
}
}
if self.config.check_clippy {
match self.check_clippy(path) {
Ok(result) => {
report.passed &= result.passed;
report.clippy_result = Some(result);
}
Err(e) => {
log::warn!("Failed to run clippy check: {e}");
report.passed = false;
}
}
}
match self.check_ml_rules(path) {
Ok(result) => {
report.passed &= result.passed;
report.ml_rules_result = Some(result);
}
Err(e) => {
log::warn!("Failed to run ML rules check: {e}");
report.passed = false;
}
}
self.generate_summary(&mut report);
Ok(report)
}
fn check_rustfmt<P: AsRef<Path>>(&self, path: P) -> Result<CheckResult> {
let output = Command::new("rustfmt")
.arg("--check")
.arg("--config")
.arg(format!("max_width={}", self.config.max_line_length))
.arg(path.as_ref())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| SklearsError::InvalidInput(format!("Failed to run rustfmt: {e}")))?;
let passed = output.status.success();
let output_str = String::from_utf8_lossy(&output.stderr).to_string();
let issues = self.parse_rustfmt_output(&output_str);
Ok(CheckResult {
passed,
issues,
output: output_str,
exit_code: output.status.code().unwrap_or(-1),
})
}
fn check_clippy<P: AsRef<Path>>(&self, path: P) -> Result<CheckResult> {
let mut cmd = Command::new("cargo");
cmd.arg("clippy").arg("--").arg("-D").arg("warnings");
for lint in &self.config.clippy_lints {
cmd.arg("-W").arg(lint);
}
let output = cmd
.current_dir(path.as_ref())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| SklearsError::InvalidInput(format!("Failed to run clippy: {e}")))?;
let passed = output.status.success();
let output_str = String::from_utf8_lossy(&output.stderr).to_string();
let issues = self.parse_clippy_output(&output_str);
Ok(CheckResult {
passed,
issues,
output: output_str,
exit_code: output.status.code().unwrap_or(-1),
})
}
fn check_ml_rules<P: AsRef<Path>>(&self, _path: P) -> Result<MLRulesResult> {
let result = MLRulesResult {
passed: true,
param_type_issues: Vec::new(),
naming_issues: Vec::new(),
validation_issues: Vec::new(),
complexity_issues: Vec::new(),
error_handling_issues: Vec::new(),
};
Ok(result)
}
fn parse_rustfmt_output(&self, output: &str) -> Vec<FormattingIssue> {
let mut issues = Vec::new();
for line in output.lines() {
if line.contains("Diff in") {
if let Some(file_path) = line.split_whitespace().nth(2) {
issues.push(FormattingIssue {
file: PathBuf::from(file_path),
line: None,
column: None,
severity: IssueSeverity::Error,
message: "File is not properly formatted".to_string(),
suggestion: Some("Run 'cargo fmt' to fix formatting".to_string()),
rule: "rustfmt".to_string(),
});
}
}
}
issues
}
fn parse_clippy_output(&self, output: &str) -> Vec<FormattingIssue> {
let mut issues = Vec::new();
for line in output.lines() {
if line.contains("warning:") || line.contains("error:") {
let parts: Vec<&str> = line.splitn(5, ':').collect();
if parts.len() >= 5 {
let file = PathBuf::from(parts[0]);
let line = parts[1].parse().ok();
let column = parts[2].parse().ok();
let severity = match parts[3].trim() {
"error" => IssueSeverity::Error,
"warning" => IssueSeverity::Warning,
_ => IssueSeverity::Info,
};
let message = parts[4].trim().to_string();
issues.push(FormattingIssue {
file,
line,
column,
severity,
message,
suggestion: None,
rule: "clippy".to_string(),
});
}
}
}
issues
}
fn generate_summary(&self, report: &mut FormattingReport) {
let mut files_with_issues = Vec::new();
let mut error_count = 0;
let mut warning_count = 0;
let mut info_count = 0;
if let Some(ref result) = report.rustfmt_result {
for issue in &result.issues {
match issue.severity {
IssueSeverity::Error => error_count += 1,
IssueSeverity::Warning => warning_count += 1,
IssueSeverity::Info => info_count += 1,
}
if !files_with_issues.contains(&issue.file) {
files_with_issues.push(issue.file.clone());
}
}
}
if let Some(ref result) = report.clippy_result {
for issue in &result.issues {
match issue.severity {
IssueSeverity::Error => error_count += 1,
IssueSeverity::Warning => warning_count += 1,
IssueSeverity::Info => info_count += 1,
}
if !files_with_issues.contains(&issue.file) {
files_with_issues.push(issue.file.clone());
}
}
}
if let Some(ref result) = report.ml_rules_result {
let all_ml_issues = [
&result.param_type_issues,
&result.naming_issues,
&result.validation_issues,
&result.complexity_issues,
&result.error_handling_issues,
];
for issues in all_ml_issues {
for issue in issues {
match issue.severity {
IssueSeverity::Error => error_count += 1,
IssueSeverity::Warning => warning_count += 1,
IssueSeverity::Info => info_count += 1,
}
if !files_with_issues.contains(&issue.file) {
files_with_issues.push(issue.file.clone());
}
}
}
}
report.summary = FormattingSummary {
files_checked: files_with_issues.len().max(1), error_count,
warning_count,
info_count,
files_with_issues,
};
}
pub fn fix_issues<P: AsRef<Path>>(&self, path: P) -> Result<FormattingReport> {
let path = path.as_ref();
if self.config.check_rustfmt {
let _output = Command::new("rustfmt")
.arg(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| SklearsError::InvalidInput(format!("Failed to run rustfmt: {e}")))?;
}
self.check_all(path)
}
pub fn config(&self) -> &FormattingConfig {
&self.config
}
pub fn set_config(&mut self, config: FormattingConfig) {
self.config = config;
}
}
impl Default for CodeFormatter {
fn default() -> Self {
Self::new()
}
}
pub struct FormattingConfigBuilder {
config: FormattingConfig,
}
impl FormattingConfigBuilder {
pub fn new() -> Self {
Self {
config: FormattingConfig::default(),
}
}
pub fn check_rustfmt(mut self, enable: bool) -> Self {
self.config.check_rustfmt = enable;
self
}
pub fn check_clippy(mut self, enable: bool) -> Self {
self.config.check_clippy = enable;
self
}
pub fn clippy_lints(mut self, lints: Vec<String>) -> Self {
self.config.clippy_lints = lints;
self
}
pub fn max_line_length(mut self, length: usize) -> Self {
self.config.max_line_length = length;
self
}
pub fn require_docs(mut self, require: bool) -> Self {
self.config.require_docs = require;
self
}
pub fn ml_rules(mut self, rules: MLFormattingRules) -> Self {
self.config.ml_specific_rules = rules;
self
}
pub fn build(self) -> FormattingConfig {
self.config
}
}
impl Default for FormattingConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_formatting_config_default() {
let config = FormattingConfig::default();
assert!(config.check_rustfmt);
assert!(config.check_clippy);
assert_eq!(config.max_line_length, 100);
assert!(config.require_docs);
}
#[test]
fn test_formatting_config_builder() {
let config = FormattingConfigBuilder::new()
.check_rustfmt(false)
.max_line_length(120)
.require_docs(false)
.build();
assert!(!config.check_rustfmt);
assert_eq!(config.max_line_length, 120);
assert!(!config.require_docs);
}
#[test]
fn test_code_formatter_creation() {
let formatter = CodeFormatter::new();
assert!(formatter.config().check_rustfmt);
assert!(formatter.config().check_clippy);
}
#[test]
fn test_formatting_issue_creation() {
let issue = FormattingIssue {
file: PathBuf::from("test.rs"),
line: Some(10),
column: Some(5),
severity: IssueSeverity::Warning,
message: "Test issue".to_string(),
suggestion: Some("Fix it".to_string()),
rule: "test_rule".to_string(),
};
assert_eq!(issue.file, PathBuf::from("test.rs"));
assert_eq!(issue.line, Some(10));
assert_eq!(issue.severity, IssueSeverity::Warning);
}
#[test]
fn test_ml_formatting_rules_default() {
let rules = MLFormattingRules::default();
assert!(rules.require_param_types);
assert!(rules.enforce_ml_naming);
assert!(rules.require_input_validation);
assert_eq!(rules.max_function_complexity, 10);
assert!(rules.require_error_handling);
}
#[test]
fn test_parse_rustfmt_output() {
let formatter = CodeFormatter::new();
let output = "Diff in src/test.rs at line 1:\n -old line\n +new line";
let issues = formatter.parse_rustfmt_output(output);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, IssueSeverity::Error);
assert!(issues[0].message.contains("not properly formatted"));
}
#[test]
fn test_parse_clippy_output() {
let formatter = CodeFormatter::new();
let output = "src/test.rs:10:5: warning: unused variable";
let issues = formatter.parse_clippy_output(output);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].line, Some(10));
assert_eq!(issues[0].column, Some(5));
assert_eq!(issues[0].severity, IssueSeverity::Warning);
}
#[test]
fn test_issue_severity_ordering() {
assert_eq!(IssueSeverity::Error, IssueSeverity::Error);
assert_ne!(IssueSeverity::Error, IssueSeverity::Warning);
assert_ne!(IssueSeverity::Warning, IssueSeverity::Info);
}
}