1use std::collections::HashSet;
10use std::path::PathBuf;
11use std::process::Command;
12
13use serde::{Deserialize, Serialize};
14
15use crate::analytics::Analytics;
16use crate::db::models::{Symbol, Visibility};
17use crate::db::Database;
18use crate::error::{CtxError, Result};
19
20#[derive(Debug, Clone)]
22pub struct AuditConfig {
23 pub categories: Vec<String>,
25 pub path: PathBuf,
27 pub incremental: bool,
29 pub min_score: Option<f32>,
31}
32
33impl Default for AuditConfig {
34 fn default() -> Self {
35 Self {
36 categories: vec![
37 "complexity".to_string(),
38 "duplication".to_string(),
39 "coverage".to_string(),
40 "modularity".to_string(),
41 "naming".to_string(),
42 ],
43 path: PathBuf::from("."),
44 incremental: false,
45 min_score: None,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "lowercase")]
53pub enum Severity {
54 Critical,
55 Warning,
56 Info,
57}
58
59impl Severity {
60 pub fn as_str(&self) -> &'static str {
61 match self {
62 Severity::Critical => "critical",
63 Severity::Warning => "warning",
64 Severity::Info => "info",
65 }
66 }
67}
68
69impl std::fmt::Display for Severity {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "{}", self.as_str())
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct QualityIssue {
78 pub severity: Severity,
80 pub category: String,
82 pub file: String,
84 pub line: Option<u32>,
86 pub message: String,
88 pub suggestion: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CategoryScore {
95 pub name: String,
97 pub score: f32,
99 pub issue_count: usize,
101 pub weight: f32,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct QualityReport {
108 pub overall_score: f32,
110 pub passed: bool,
112 pub threshold: Option<f32>,
114 pub categories: Vec<CategoryScore>,
116 pub issues: Vec<QualityIssue>,
118 pub total_symbols: usize,
120 pub total_functions: usize,
122}
123
124impl QualityReport {
125 pub fn new() -> Self {
127 Self {
128 overall_score: 0.0,
129 passed: true,
130 threshold: None,
131 categories: Vec::new(),
132 issues: Vec::new(),
133 total_symbols: 0,
134 total_functions: 0,
135 }
136 }
137
138 pub fn add_category(&mut self, score: CategoryScore) {
140 self.categories.push(score);
141 }
142
143 pub fn add_issue(&mut self, issue: QualityIssue) {
145 self.issues.push(issue);
146 }
147
148 pub fn calculate_overall(&mut self) {
150 let total_weight: f32 = self.categories.iter().map(|c| c.weight).sum();
151 if total_weight > 0.0 {
152 let weighted_sum: f32 = self.categories.iter().map(|c| c.score * c.weight).sum();
153 self.overall_score = weighted_sum / total_weight;
154 }
155
156 if let Some(threshold) = self.threshold {
158 self.passed = self.overall_score >= threshold;
159 }
160 }
161
162 pub fn issues_by_severity(&self, severity: Severity) -> Vec<&QualityIssue> {
164 self.issues
165 .iter()
166 .filter(|i| i.severity == severity)
167 .collect()
168 }
169
170 pub fn format_text(&self) -> String {
172 let mut output = String::new();
173
174 output.push_str("Code Quality Audit\n");
175 output.push_str("==================\n\n");
176
177 output.push_str(&format!("Overall Score: {:.1}/10\n\n", self.overall_score));
178
179 output.push_str("Categories:\n");
181 for cat in &self.categories {
182 output.push_str(&format!(
183 " {:12} {:.1}/10 ({} issues)\n",
184 format!("{}:", capitalize(&cat.name)),
185 cat.score,
186 cat.issue_count
187 ));
188 }
189 output.push('\n');
190
191 let critical = self.issues_by_severity(Severity::Critical);
193 if !critical.is_empty() {
194 output.push_str(&format!("Critical Issues ({}):\n", critical.len()));
195 for issue in critical.iter().take(5) {
196 if let Some(line) = issue.line {
197 output.push_str(&format!(
198 " [CRIT] {}:{} - {}\n",
199 issue.file, line, issue.message
200 ));
201 } else {
202 output.push_str(&format!(" [CRIT] {} - {}\n", issue.file, issue.message));
203 }
204 }
205 if critical.len() > 5 {
206 output.push_str(&format!(" ... and {} more\n", critical.len() - 5));
207 }
208 output.push('\n');
209 }
210
211 let warnings = self.issues_by_severity(Severity::Warning);
213 if !warnings.is_empty() {
214 output.push_str(&format!("Warnings ({}):\n", warnings.len()));
215 for issue in warnings.iter().take(5) {
216 if let Some(line) = issue.line {
217 output.push_str(&format!(
218 " [WARN] {}:{} - {}\n",
219 issue.file, line, issue.message
220 ));
221 } else {
222 output.push_str(&format!(" [WARN] {} - {}\n", issue.file, issue.message));
223 }
224 }
225 if warnings.len() > 5 {
226 output.push_str(&format!(" ... and {} more\n", warnings.len() - 5));
227 }
228 output.push('\n');
229 }
230
231 if let Some(threshold) = self.threshold {
233 if self.passed {
234 output.push_str(&format!(
235 "✓ Score {:.1} meets threshold {:.1}\n",
236 self.overall_score, threshold
237 ));
238 } else {
239 output.push_str(&format!(
240 "✗ Score {:.1} below threshold {:.1}\n",
241 self.overall_score, threshold
242 ));
243 }
244 }
245
246 output
247 }
248
249 pub fn format_markdown(&self) -> String {
251 let mut output = String::new();
252
253 output.push_str("# Code Quality Audit\n\n");
254
255 output.push_str(&format!(
257 "**Overall Score: {:.1}/10**\n\n",
258 self.overall_score
259 ));
260
261 if let Some(threshold) = self.threshold {
262 if self.passed {
263 output.push_str(&format!("✅ Passed (threshold: {:.1})\n\n", threshold));
264 } else {
265 output.push_str(&format!("❌ Failed (threshold: {:.1})\n\n", threshold));
266 }
267 }
268
269 output.push_str("## Categories\n\n");
271 output.push_str("| Category | Score | Issues | Weight |\n");
272 output.push_str("|----------|-------|--------|--------|\n");
273 for cat in &self.categories {
274 output.push_str(&format!(
275 "| {} | {:.1}/10 | {} | {:.0}% |\n",
276 capitalize(&cat.name),
277 cat.score,
278 cat.issue_count,
279 cat.weight * 100.0
280 ));
281 }
282 output.push('\n');
283
284 let critical = self.issues_by_severity(Severity::Critical);
286 if !critical.is_empty() {
287 output.push_str("## Critical Issues\n\n");
288 for issue in &critical {
289 if let Some(line) = issue.line {
290 output.push_str(&format!(
291 "- **{}:{}** - {}\n",
292 issue.file, line, issue.message
293 ));
294 } else {
295 output.push_str(&format!("- **{}** - {}\n", issue.file, issue.message));
296 }
297 if let Some(ref suggestion) = issue.suggestion {
298 output.push_str(&format!(" - *Suggestion: {}*\n", suggestion));
299 }
300 }
301 output.push('\n');
302 }
303
304 let warnings = self.issues_by_severity(Severity::Warning);
305 if !warnings.is_empty() {
306 output.push_str("## Warnings\n\n");
307 for issue in warnings.iter().take(20) {
308 if let Some(line) = issue.line {
309 output.push_str(&format!(
310 "- **{}:{}** - {}\n",
311 issue.file, line, issue.message
312 ));
313 } else {
314 output.push_str(&format!("- **{}** - {}\n", issue.file, issue.message));
315 }
316 }
317 if warnings.len() > 20 {
318 output.push_str(&format!(
319 "\n*... and {} more warnings*\n",
320 warnings.len() - 20
321 ));
322 }
323 output.push('\n');
324 }
325
326 output.push_str("## Statistics\n\n");
328 output.push_str(&format!("- Total symbols: {}\n", self.total_symbols));
329 output.push_str(&format!("- Total functions: {}\n", self.total_functions));
330 output.push_str(&format!(
331 "- Issues: {} critical, {} warnings\n",
332 critical.len(),
333 warnings.len()
334 ));
335
336 output
337 }
338
339 pub fn format_json(&self) -> Result<String> {
341 Ok(serde_json::to_string_pretty(self)?)
342 }
343}
344
345impl Default for QualityReport {
346 fn default() -> Self {
347 Self::new()
348 }
349}
350
351const WEIGHT_COMPLEXITY: f32 = 0.25;
353const WEIGHT_DUPLICATION: f32 = 0.20;
354const WEIGHT_COVERAGE: f32 = 0.20;
355const WEIGHT_MODULARITY: f32 = 0.20;
356const WEIGHT_NAMING: f32 = 0.15;
357
358pub mod thresholds {
360 pub const COVERAGE_EXCELLENT: f32 = 0.95;
362 pub const COVERAGE_GOOD: f32 = 0.80;
363 pub const COVERAGE_ACCEPTABLE: f32 = 0.60;
364 pub const COVERAGE_POOR: f32 = 0.40;
365
366 pub const NAMING_EXCELLENT: f32 = 0.01;
368 pub const NAMING_GOOD: f32 = 0.05;
369 pub const NAMING_ACCEPTABLE: f32 = 0.20;
370 pub const NAMING_POOR: f32 = 0.40;
371
372 pub const HIGH_COUPLING_THRESHOLD: usize = 20;
374 pub const EXTERNAL_RATIO_HIGH: f32 = 0.5;
375 pub const EXTERNAL_RATIO_MEDIUM: f32 = 0.3;
376}
377
378pub fn get_changed_files(root: &PathBuf) -> Result<HashSet<String>> {
380 let mut changed_files = HashSet::new();
381
382 let staged_output = Command::new("git")
384 .args(["diff", "--cached", "--name-only"])
385 .current_dir(root)
386 .output()
387 .map_err(|e| CtxError::git(format!("Failed to run git: {}", e)))?;
388
389 if staged_output.status.success() {
390 let output = String::from_utf8_lossy(&staged_output.stdout);
391 for line in output.lines() {
392 if !line.is_empty() {
393 changed_files.insert(line.to_string());
394 }
395 }
396 }
397
398 let unstaged_output = Command::new("git")
400 .args(["diff", "--name-only"])
401 .current_dir(root)
402 .output()
403 .map_err(|e| CtxError::git(format!("Failed to run git: {}", e)))?;
404
405 if unstaged_output.status.success() {
406 let output = String::from_utf8_lossy(&unstaged_output.stdout);
407 for line in output.lines() {
408 if !line.is_empty() {
409 changed_files.insert(line.to_string());
410 }
411 }
412 }
413
414 let untracked_output = Command::new("git")
416 .args(["ls-files", "--others", "--exclude-standard"])
417 .current_dir(root)
418 .output()
419 .map_err(|e| CtxError::git(format!("Failed to run git: {}", e)))?;
420
421 if untracked_output.status.success() {
422 let output = String::from_utf8_lossy(&untracked_output.stdout);
423 for line in output.lines() {
424 if !line.is_empty() {
425 changed_files.insert(line.to_string());
426 }
427 }
428 }
429
430 Ok(changed_files)
431}
432
433pub fn run_audit(
435 db: &Database,
436 analytics: Option<&Analytics>,
437 config: &AuditConfig,
438) -> Result<QualityReport> {
439 let mut report = QualityReport::new();
440 report.threshold = config.min_score;
441
442 let mut symbols = db.find_symbols("%", 100000)?;
445
446 if config.incremental {
448 let changed_files = get_changed_files(&config.path)?;
449 if changed_files.is_empty() {
450 report.overall_score = 10.0;
452 report.passed = true;
453 return Ok(report);
454 }
455
456 symbols.retain(|s| {
458 changed_files
459 .iter()
460 .any(|f| s.file_path.ends_with(f) || f.ends_with(&s.file_path))
461 });
462 }
463
464 report.total_symbols = symbols.len();
465 report.total_functions = symbols
466 .iter()
467 .filter(|s| s.kind.as_str() == "function" || s.kind.as_str() == "method")
468 .count();
469
470 let should_run = |cat: &str| -> bool {
471 config.categories.is_empty() || config.categories.iter().any(|c| c == cat)
472 };
473
474 if should_run("complexity") {
476 let (score, issues) = if let Some(a) = analytics {
477 score_complexity(a, &symbols)?
478 } else {
479 (8.0, Vec::new()) };
481 report.add_category(CategoryScore {
482 name: "complexity".to_string(),
483 score,
484 issue_count: issues.len(),
485 weight: WEIGHT_COMPLEXITY,
486 });
487 for issue in issues {
488 report.add_issue(issue);
489 }
490 }
491
492 if should_run("duplication") {
494 let (score, issues) = score_duplication(&symbols);
495 report.add_category(CategoryScore {
496 name: "duplication".to_string(),
497 score,
498 issue_count: issues.len(),
499 weight: WEIGHT_DUPLICATION,
500 });
501 for issue in issues {
502 report.add_issue(issue);
503 }
504 }
505
506 if should_run("coverage") {
508 let (score, issues) = score_coverage(&symbols);
509 report.add_category(CategoryScore {
510 name: "coverage".to_string(),
511 score,
512 issue_count: issues.len(),
513 weight: WEIGHT_COVERAGE,
514 });
515 for issue in issues {
516 report.add_issue(issue);
517 }
518 }
519
520 if should_run("modularity") {
522 let (score, issues) = if let Some(a) = analytics {
523 score_modularity(a)?
524 } else {
525 (8.0, Vec::new())
526 };
527 report.add_category(CategoryScore {
528 name: "modularity".to_string(),
529 score,
530 issue_count: issues.len(),
531 weight: WEIGHT_MODULARITY,
532 });
533 for issue in issues {
534 report.add_issue(issue);
535 }
536 }
537
538 if should_run("naming") {
540 let (score, issues) = score_naming(&symbols);
541 report.add_category(CategoryScore {
542 name: "naming".to_string(),
543 score,
544 issue_count: issues.len(),
545 weight: WEIGHT_NAMING,
546 });
547 for issue in issues {
548 report.add_issue(issue);
549 }
550 }
551
552 report.calculate_overall();
553 Ok(report)
554}
555
556fn score_complexity(
558 analytics: &Analytics,
559 _symbols: &[Symbol],
560) -> Result<(f32, Vec<QualityIssue>)> {
561 let mut issues = Vec::new();
562
563 let complexity_results = analytics.complexity_analysis(20)?;
565
566 let critical_count = complexity_results
568 .iter()
569 .filter(|r| r.severity == "critical")
570 .count();
571 let high_count = complexity_results
572 .iter()
573 .filter(|r| r.severity == "high")
574 .count();
575 let medium_count = complexity_results
576 .iter()
577 .filter(|r| r.severity == "medium")
578 .count();
579
580 for result in complexity_results
582 .iter()
583 .filter(|r| r.severity == "critical" || r.severity == "high")
584 {
585 issues.push(QualityIssue {
586 severity: if result.severity == "critical" {
587 Severity::Critical
588 } else {
589 Severity::Warning
590 },
591 category: "complexity".to_string(),
592 file: result.file_path.clone(),
593 line: Some(result.line),
594 message: format!(
595 "{}: fan-out {} (threshold: 20)",
596 result.name, result.fan_out
597 ),
598 suggestion: Some("Extract helper functions to reduce complexity".to_string()),
599 });
600 }
601
602 let score = calculate_complexity_score(critical_count, high_count, medium_count);
604
605 Ok((score, issues))
606}
607
608fn calculate_complexity_score(critical: usize, high: usize, medium: usize) -> f32 {
610 if critical > 0 {
612 (4.0 - (critical as f32 * 0.5).min(3.0)).max(1.0)
614 } else if high > 5 {
615 5.0 - ((high - 5) as f32 * 0.2).min(2.0)
616 } else if high > 0 {
617 6.0 - (high as f32 * 0.2)
618 } else if medium > 10 {
619 7.0 - ((medium - 10) as f32 * 0.1).min(1.0)
620 } else if medium > 0 {
621 8.0 - (medium as f32 * 0.1)
622 } else {
623 10.0
624 }
625}
626
627fn score_duplication(symbols: &[Symbol]) -> (f32, Vec<QualityIssue>) {
629 use std::collections::HashMap;
630
631 let mut issues = Vec::new();
632 let mut name_counts: HashMap<String, Vec<&Symbol>> = HashMap::new();
633
634 for symbol in symbols
636 .iter()
637 .filter(|s| s.kind.as_str() == "function" || s.kind.as_str() == "method")
638 {
639 let base_name = symbol
641 .name
642 .trim_end_matches(|c: char| c.is_ascii_digit() || c == '_')
643 .to_string();
644 if base_name.len() >= 4 {
645 name_counts.entry(base_name).or_default().push(symbol);
646 }
647 }
648
649 let duplicates: Vec<_> = name_counts
651 .iter()
652 .filter(|(_, syms)| syms.len() >= 3)
653 .collect();
654
655 for (name, syms) in &duplicates {
656 if syms.len() >= 5 {
657 issues.push(QualityIssue {
658 severity: Severity::Warning,
659 category: "duplication".to_string(),
660 file: syms[0].file_path.clone(),
661 line: Some(syms[0].line_start),
662 message: format!(
663 "Potential code duplication: {} functions with similar name '{}'",
664 syms.len(),
665 name
666 ),
667 suggestion: Some("Consider extracting shared logic".to_string()),
668 });
669 }
670 }
671
672 let score = if duplicates.is_empty() {
674 10.0
675 } else if duplicates.len() <= 2 {
676 8.0
677 } else if duplicates.len() <= 5 {
678 6.0
679 } else {
680 4.0
681 };
682
683 (score, issues)
684}
685
686fn score_coverage(symbols: &[Symbol]) -> (f32, Vec<QualityIssue>) {
688 let mut issues = Vec::new();
689
690 let public_symbols: Vec<_> = symbols
692 .iter()
693 .filter(|s| s.visibility == Visibility::Public)
694 .collect();
695
696 if public_symbols.is_empty() {
697 return (10.0, issues);
698 }
699
700 let documented_count = public_symbols
702 .iter()
703 .filter(|s| s.brief.is_some() || s.docstring.is_some())
704 .count();
705
706 let coverage = documented_count as f32 / public_symbols.len() as f32;
707
708 for symbol in public_symbols
710 .iter()
711 .filter(|s| {
712 s.brief.is_none()
713 && s.docstring.is_none()
714 && (s.kind.as_str() == "function" || s.kind.as_str() == "method")
715 })
716 .take(10)
717 {
718 issues.push(QualityIssue {
719 severity: Severity::Info,
720 category: "coverage".to_string(),
721 file: symbol.file_path.clone(),
722 line: Some(symbol.line_start),
723 message: format!(
724 "Undocumented public {}: {}",
725 symbol.kind.as_str(),
726 symbol.name
727 ),
728 suggestion: Some("Add documentation comment".to_string()),
729 });
730 }
731
732 use thresholds::*;
734 let score = if coverage >= COVERAGE_EXCELLENT {
735 10.0
736 } else if coverage >= COVERAGE_GOOD {
737 8.0
738 } else if coverage >= COVERAGE_ACCEPTABLE {
739 6.0
740 } else if coverage >= COVERAGE_POOR {
741 4.0
742 } else {
743 2.0
744 };
745
746 (score, issues)
747}
748
749fn score_modularity(analytics: &Analytics) -> Result<(f32, Vec<QualityIssue>)> {
751 let mut issues = Vec::new();
752
753 let deps = analytics.file_dependencies()?;
755
756 let total_deps = deps.len();
758 let external_deps = deps.iter().filter(|(_, t, _)| t == "external").count();
759
760 let external_ratio = if total_deps > 0 {
762 external_deps as f32 / total_deps as f32
763 } else {
764 0.0
765 };
766
767 use std::collections::HashMap;
769 let mut file_dep_counts: HashMap<&str, usize> = HashMap::new();
770 for (source, _, _) in &deps {
771 *file_dep_counts.entry(source).or_default() += 1;
772 }
773
774 for (file, count) in file_dep_counts
775 .iter()
776 .filter(|(_, &c)| c > thresholds::HIGH_COUPLING_THRESHOLD)
777 {
778 issues.push(QualityIssue {
779 severity: Severity::Warning,
780 category: "modularity".to_string(),
781 file: file.to_string(),
782 line: None,
783 message: format!("High coupling: {} outgoing dependencies", count),
784 suggestion: Some("Consider splitting into smaller modules".to_string()),
785 });
786 }
787
788 let score = if external_ratio > thresholds::EXTERNAL_RATIO_HIGH {
790 5.0
791 } else if external_ratio > thresholds::EXTERNAL_RATIO_MEDIUM {
792 6.0
793 } else if issues.is_empty() {
794 9.0 - (external_ratio * 2.0)
795 } else {
796 7.0 - (issues.len() as f32 * 0.5).min(2.0)
797 };
798
799 Ok((score.clamp(2.0, 10.0), issues))
800}
801
802fn score_naming(symbols: &[Symbol]) -> (f32, Vec<QualityIssue>) {
804 let mut issues = Vec::new();
805 let mut violations = 0;
806
807 for symbol in symbols {
808 let name = &symbol.name;
809
810 let is_valid = match symbol.kind.as_str() {
812 "function" | "method" => is_snake_case(name),
813 "struct" | "enum" | "class" | "interface" | "type" => is_pascal_case(name),
814 "constant" => is_screaming_snake_case(name) || is_snake_case(name),
815 _ => true,
816 };
817
818 if !is_valid {
819 violations += 1;
820 if violations <= 10 {
821 issues.push(QualityIssue {
822 severity: Severity::Info,
823 category: "naming".to_string(),
824 file: symbol.file_path.clone(),
825 line: Some(symbol.line_start),
826 message: format!(
827 "{} '{}' doesn't follow naming convention",
828 symbol.kind.as_str(),
829 name
830 ),
831 suggestion: Some(suggest_name_fix(symbol.kind.as_str(), name)),
832 });
833 }
834 }
835 }
836
837 let total = symbols.len();
839 if total == 0 {
840 return (10.0, issues);
841 }
842
843 let violation_rate = violations as f32 / total as f32;
844 use thresholds::*;
845 let score = if violation_rate <= NAMING_EXCELLENT {
846 10.0
847 } else if violation_rate <= NAMING_GOOD {
848 8.0
849 } else if violation_rate <= NAMING_ACCEPTABLE {
850 6.0
851 } else if violation_rate <= NAMING_POOR {
852 4.0
853 } else {
854 2.0
855 };
856
857 (score, issues)
858}
859
860fn is_snake_case(name: &str) -> bool {
862 if name.is_empty() {
863 return true;
864 }
865 let name = name.strip_prefix('_').unwrap_or(name);
867 if name.is_empty() {
868 return true;
869 }
870
871 name.chars()
873 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
874}
875
876fn is_pascal_case(name: &str) -> bool {
878 if name.is_empty() {
879 return true;
880 }
881 let first = name.chars().next().unwrap();
883 if !first.is_ascii_uppercase() {
884 return false;
885 }
886 !name.contains('_') || name.chars().filter(|&c| c == '_').count() <= 1
888}
889
890fn is_screaming_snake_case(name: &str) -> bool {
892 if name.is_empty() {
893 return true;
894 }
895 name.chars()
896 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
897}
898
899fn suggest_name_fix(kind: &str, name: &str) -> String {
901 match kind {
902 "function" | "method" => format!("Use snake_case: {}", to_snake_case(name)),
903 "struct" | "enum" | "class" | "interface" | "type" => {
904 format!("Use PascalCase: {}", to_pascal_case(name))
905 }
906 "constant" => format!("Use SCREAMING_SNAKE_CASE: {}", name.to_uppercase()),
907 _ => "Follow language naming conventions".to_string(),
908 }
909}
910
911fn to_snake_case(name: &str) -> String {
913 let mut result = String::new();
914 for (i, c) in name.chars().enumerate() {
915 if c.is_ascii_uppercase() && i > 0 {
916 result.push('_');
917 }
918 result.push(c.to_ascii_lowercase());
919 }
920 result
921}
922
923fn to_pascal_case(name: &str) -> String {
925 let mut result = String::new();
926 let mut capitalize_next = true;
927 for c in name.chars() {
928 if c == '_' {
929 capitalize_next = true;
930 } else if capitalize_next {
931 result.push(c.to_ascii_uppercase());
932 capitalize_next = false;
933 } else {
934 result.push(c.to_ascii_lowercase());
935 }
936 }
937 result
938}
939
940fn capitalize(s: &str) -> String {
942 let mut chars = s.chars();
943 match chars.next() {
944 None => String::new(),
945 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
946 }
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn test_snake_case() {
955 assert!(is_snake_case("hello_world"));
956 assert!(is_snake_case("_private"));
957 assert!(is_snake_case("simple"));
958 assert!(!is_snake_case("HelloWorld"));
959 assert!(!is_snake_case("helloWorld"));
960 }
961
962 #[test]
963 fn test_pascal_case() {
964 assert!(is_pascal_case("HelloWorld"));
965 assert!(is_pascal_case("Simple"));
966 assert!(!is_pascal_case("hello_world"));
967 assert!(!is_pascal_case("helloWorld"));
968 }
969
970 #[test]
971 fn test_screaming_snake_case() {
972 assert!(is_screaming_snake_case("HELLO_WORLD"));
973 assert!(is_screaming_snake_case("SIMPLE"));
974 assert!(!is_screaming_snake_case("hello_world"));
975 assert!(!is_screaming_snake_case("HelloWorld"));
976 }
977
978 #[test]
979 fn test_quality_report_format() {
980 let mut report = QualityReport::new();
981 report.add_category(CategoryScore {
982 name: "complexity".to_string(),
983 score: 7.5,
984 issue_count: 5,
985 weight: 0.25,
986 });
987 report.add_category(CategoryScore {
988 name: "coverage".to_string(),
989 score: 8.0,
990 issue_count: 3,
991 weight: 0.20,
992 });
993 report.calculate_overall();
994
995 let text = report.format_text();
996 assert!(text.contains("Code Quality Audit"));
997 assert!(text.contains("Complexity:"));
998 assert!(text.contains("Coverage:"));
999 }
1000
1001 #[test]
1002 fn test_calculate_overall_score() {
1003 let mut report = QualityReport::new();
1004 report.add_category(CategoryScore {
1005 name: "test1".to_string(),
1006 score: 8.0,
1007 issue_count: 0,
1008 weight: 0.5,
1009 });
1010 report.add_category(CategoryScore {
1011 name: "test2".to_string(),
1012 score: 6.0,
1013 issue_count: 0,
1014 weight: 0.5,
1015 });
1016 report.calculate_overall();
1017
1018 assert!((report.overall_score - 7.0).abs() < 0.01);
1019 }
1020
1021 #[test]
1022 fn test_threshold_pass() {
1023 let mut report = QualityReport::new();
1024 report.threshold = Some(7.0);
1025 report.add_category(CategoryScore {
1026 name: "test".to_string(),
1027 score: 8.0,
1028 issue_count: 0,
1029 weight: 1.0,
1030 });
1031 report.calculate_overall();
1032
1033 assert!(report.passed);
1034 }
1035
1036 #[test]
1037 fn test_threshold_fail() {
1038 let mut report = QualityReport::new();
1039 report.threshold = Some(7.0);
1040 report.add_category(CategoryScore {
1041 name: "test".to_string(),
1042 score: 6.0,
1043 issue_count: 0,
1044 weight: 1.0,
1045 });
1046 report.calculate_overall();
1047
1048 assert!(!report.passed);
1049 }
1050
1051 #[test]
1052 fn test_audit_incremental_no_changes() {
1053 let mut report = QualityReport::new();
1055 report.overall_score = 10.0;
1056 report.passed = true;
1057
1058 assert_eq!(report.overall_score, 10.0);
1060 assert!(report.passed);
1061 }
1062
1063 #[test]
1064 fn test_get_changed_files() {
1065 use std::fs;
1066 use tempfile::TempDir;
1067
1068 let temp_dir = TempDir::new().unwrap();
1070 let root = temp_dir.path().to_path_buf();
1071
1072 Command::new("git")
1074 .args(["init"])
1075 .current_dir(&root)
1076 .output()
1077 .expect("Failed to init git");
1078
1079 Command::new("git")
1080 .args(["config", "user.email", "test@test.com"])
1081 .current_dir(&root)
1082 .output()
1083 .expect("Failed to set git email");
1084
1085 Command::new("git")
1086 .args(["config", "user.name", "Test"])
1087 .current_dir(&root)
1088 .output()
1089 .expect("Failed to set git name");
1090
1091 fs::write(root.join("test.rs"), "fn test() {}").unwrap();
1093 Command::new("git")
1094 .args(["add", "test.rs"])
1095 .current_dir(&root)
1096 .output()
1097 .expect("Failed to git add");
1098 Command::new("git")
1099 .args(["commit", "-m", "initial"])
1100 .current_dir(&root)
1101 .output()
1102 .expect("Failed to git commit");
1103
1104 fs::write(root.join("test.rs"), "fn test() { println!(\"hello\"); }").unwrap();
1106
1107 let changed = get_changed_files(&root).unwrap();
1109
1110 assert!(
1112 changed.contains("test.rs"),
1113 "Should find changed file: {:?}",
1114 changed
1115 );
1116 }
1117}