1use crate::error::SklearsError;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9pub type Result<T> = std::result::Result<T, SklearsError>;
11
12#[derive(Debug, Clone)]
14pub struct FormattingConfig {
15 pub check_rustfmt: bool,
17 pub check_clippy: bool,
19 pub clippy_lints: Vec<String>,
21 pub exclude_paths: Vec<PathBuf>,
23 pub max_line_length: usize,
25 pub require_docs: bool,
27 pub ml_specific_rules: MLFormattingRules,
29}
30
31#[derive(Debug, Clone)]
33pub struct MLFormattingRules {
34 pub require_param_types: bool,
36 pub enforce_ml_naming: bool,
38 pub require_input_validation: bool,
40 pub max_function_complexity: usize,
42 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#[derive(Debug, Clone)]
78pub struct FormattingReport {
79 pub passed: bool,
81 pub rustfmt_result: Option<CheckResult>,
83 pub clippy_result: Option<CheckResult>,
85 pub ml_rules_result: Option<MLRulesResult>,
87 pub summary: FormattingSummary,
89}
90
91#[derive(Debug, Clone)]
93pub struct CheckResult {
94 pub passed: bool,
96 pub issues: Vec<FormattingIssue>,
98 pub output: String,
100 pub exit_code: i32,
102}
103
104#[derive(Debug, Clone)]
106pub struct MLRulesResult {
107 pub passed: bool,
109 pub param_type_issues: Vec<FormattingIssue>,
111 pub naming_issues: Vec<FormattingIssue>,
113 pub validation_issues: Vec<FormattingIssue>,
115 pub complexity_issues: Vec<FormattingIssue>,
117 pub error_handling_issues: Vec<FormattingIssue>,
119}
120
121#[derive(Debug, Clone)]
123pub struct FormattingIssue {
124 pub file: PathBuf,
126 pub line: Option<usize>,
128 pub column: Option<usize>,
130 pub severity: IssueSeverity,
132 pub message: String,
134 pub suggestion: Option<String>,
136 pub rule: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum IssueSeverity {
143 Error,
145 Warning,
147 Info,
149}
150
151#[derive(Debug, Clone)]
153pub struct FormattingSummary {
154 pub files_checked: usize,
156 pub error_count: usize,
158 pub warning_count: usize,
160 pub info_count: usize,
162 pub files_with_issues: Vec<PathBuf>,
164}
165
166pub struct CodeFormatter {
168 config: FormattingConfig,
169}
170
171impl CodeFormatter {
172 pub fn new() -> Self {
174 Self {
175 config: FormattingConfig::default(),
176 }
177 }
178
179 pub fn with_config(config: FormattingConfig) -> Self {
181 Self { config }
182 }
183
184 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 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 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 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 self.generate_summary(&mut report);
244
245 Ok(report)
246 }
247
248 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 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 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 fn check_ml_rules<P: AsRef<Path>>(&self, _path: P) -> Result<MLRulesResult> {
303 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 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 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 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 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 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), error_count,
436 warning_count,
437 info_count,
438 files_with_issues,
439 };
440 }
441
442 pub fn fix_issues<P: AsRef<Path>>(&self, path: P) -> Result<FormattingReport> {
444 let path = path.as_ref();
445
446 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 self.check_all(path)
458 }
459
460 pub fn config(&self) -> &FormattingConfig {
462 &self.config
463 }
464
465 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
477pub struct FormattingConfigBuilder {
479 config: FormattingConfig,
480}
481
482impl FormattingConfigBuilder {
483 pub fn new() -> Self {
485 Self {
486 config: FormattingConfig::default(),
487 }
488 }
489
490 pub fn check_rustfmt(mut self, enable: bool) -> Self {
492 self.config.check_rustfmt = enable;
493 self
494 }
495
496 pub fn check_clippy(mut self, enable: bool) -> Self {
498 self.config.check_clippy = enable;
499 self
500 }
501
502 pub fn clippy_lints(mut self, lints: Vec<String>) -> Self {
504 self.config.clippy_lints = lints;
505 self
506 }
507
508 pub fn max_line_length(mut self, length: usize) -> Self {
510 self.config.max_line_length = length;
511 self
512 }
513
514 pub fn require_docs(mut self, require: bool) -> Self {
516 self.config.require_docs = require;
517 self
518 }
519
520 pub fn ml_rules(mut self, rules: MLFormattingRules) -> Self {
522 self.config.ml_specific_rules = rules;
523 self
524 }
525
526 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}