Skip to main content

ctx/
audit.rs

1//! Code quality audit module.
2//!
3//! Provides automated code quality analysis with scoring for CI integration.
4//! Analyzes complexity, duplication potential, documentation coverage,
5//! modularity, and naming conventions.
6//!
7//! Supports incremental mode for pre-commit hooks, auditing only changed files.
8
9use 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/// Configuration for audit analysis.
21#[derive(Debug, Clone)]
22pub struct AuditConfig {
23    /// Categories to analyze (empty = all)
24    pub categories: Vec<String>,
25    /// Path to audit
26    pub path: PathBuf,
27    /// Only audit changed files
28    pub incremental: bool,
29    /// Minimum score threshold
30    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/// Severity level for quality issues.
51#[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/// A quality issue found during audit.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct QualityIssue {
78    /// Severity level
79    pub severity: Severity,
80    /// Category this issue belongs to
81    pub category: String,
82    /// File path where issue was found
83    pub file: String,
84    /// Line number (if applicable)
85    pub line: Option<u32>,
86    /// Issue description
87    pub message: String,
88    /// Suggested fix (if applicable)
89    pub suggestion: Option<String>,
90}
91
92/// Score for a single category.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CategoryScore {
95    /// Category name
96    pub name: String,
97    /// Score (0.0-10.0)
98    pub score: f32,
99    /// Number of issues found
100    pub issue_count: usize,
101    /// Weight for overall calculation
102    pub weight: f32,
103}
104
105/// Complete quality report.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct QualityReport {
108    /// Overall score (0.0-10.0)
109    pub overall_score: f32,
110    /// Whether the audit passed (score >= threshold)
111    pub passed: bool,
112    /// Score threshold (if specified)
113    pub threshold: Option<f32>,
114    /// Scores by category
115    pub categories: Vec<CategoryScore>,
116    /// Issues found
117    pub issues: Vec<QualityIssue>,
118    /// Total symbol count
119    pub total_symbols: usize,
120    /// Total function count
121    pub total_functions: usize,
122}
123
124impl QualityReport {
125    /// Create a new empty report.
126    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    /// Add a category score.
139    pub fn add_category(&mut self, score: CategoryScore) {
140        self.categories.push(score);
141    }
142
143    /// Add an issue.
144    pub fn add_issue(&mut self, issue: QualityIssue) {
145        self.issues.push(issue);
146    }
147
148    /// Calculate the overall score from category scores.
149    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        // Check threshold
157        if let Some(threshold) = self.threshold {
158            self.passed = self.overall_score >= threshold;
159        }
160    }
161
162    /// Get issues by severity.
163    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    /// Format as text output.
171    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        // Categories
180        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        // Critical issues
192        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        // Warnings
212        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        // Threshold result
232        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    /// Format as markdown output.
250    pub fn format_markdown(&self) -> String {
251        let mut output = String::new();
252
253        output.push_str("# Code Quality Audit\n\n");
254
255        // Summary
256        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        // Categories table
270        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        // Issues by severity
285        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        // Statistics
327        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    /// Format as JSON output.
340    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
351/// Category weights for overall score calculation.
352const 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
358/// Threshold constants for quality scoring.
359pub mod thresholds {
360    /// Coverage thresholds for documentation scoring.
361    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    /// Naming violation rate thresholds.
367    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    /// Modularity thresholds.
373    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
378/// Get changed files from git (staged and unstaged).
379pub fn get_changed_files(root: &PathBuf) -> Result<HashSet<String>> {
380    let mut changed_files = HashSet::new();
381
382    // Get staged files
383    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    // Get unstaged modified files
399    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    // Get untracked files
415    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
433/// Run a complete quality audit.
434pub 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    // Get all symbols using a broad search
443    // We use "%" pattern to get all symbols with a high limit
444    let mut symbols = db.find_symbols("%", 100000)?;
445
446    // If incremental mode, filter to only changed files
447    if config.incremental {
448        let changed_files = get_changed_files(&config.path)?;
449        if changed_files.is_empty() {
450            // No changes, return perfect score
451            report.overall_score = 10.0;
452            report.passed = true;
453            return Ok(report);
454        }
455
456        // Filter symbols to only those in changed files
457        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    // Complexity analysis
475    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()) // Default good score if no analytics
480        };
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    // Duplication analysis (simplified - based on similar function names)
493    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    // Documentation coverage
507    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    // Modularity analysis
521    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    // Naming conventions
539    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
556/// Score complexity based on fan-out metrics.
557fn score_complexity(
558    analytics: &Analytics,
559    _symbols: &[Symbol],
560) -> Result<(f32, Vec<QualityIssue>)> {
561    let mut issues = Vec::new();
562
563    // Get complexity data from analytics
564    let complexity_results = analytics.complexity_analysis(20)?;
565
566    // Count high-complexity functions
567    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    // Generate issues for high-complexity functions
581    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    // Calculate score
603    let score = calculate_complexity_score(critical_count, high_count, medium_count);
604
605    Ok((score, issues))
606}
607
608/// Calculate complexity score based on issue counts.
609fn calculate_complexity_score(critical: usize, high: usize, medium: usize) -> f32 {
610    // Score based on severity distribution
611    if critical > 0 {
612        // Critical issues significantly reduce score
613        (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
627/// Score duplication by looking for similar function names.
628fn 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    // Group functions by simplified name (without numeric suffixes)
635    for symbol in symbols
636        .iter()
637        .filter(|s| s.kind.as_str() == "function" || s.kind.as_str() == "method")
638    {
639        // Remove numeric suffixes like _1, _2, etc.
640        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    // Find potential duplicates (3+ similar names)
650    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    // Calculate score based on duplicate patterns
673    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
686/// Score documentation coverage for public symbols.
687fn score_coverage(symbols: &[Symbol]) -> (f32, Vec<QualityIssue>) {
688    let mut issues = Vec::new();
689
690    // Only check public symbols
691    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    // Count documented symbols (have brief or docstring)
701    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    // Report undocumented public functions/methods (limit to avoid spam)
709    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    // Calculate score based on coverage percentage
733    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
749/// Score modularity based on file dependencies.
750fn score_modularity(analytics: &Analytics) -> Result<(f32, Vec<QualityIssue>)> {
751    let mut issues = Vec::new();
752
753    // Get file dependencies
754    let deps = analytics.file_dependencies()?;
755
756    // Count cross-file dependencies
757    let total_deps = deps.len();
758    let external_deps = deps.iter().filter(|(_, t, _)| t == "external").count();
759
760    // High external dependency ratio might indicate poor modularity
761    let external_ratio = if total_deps > 0 {
762        external_deps as f32 / total_deps as f32
763    } else {
764        0.0
765    };
766
767    // Look for files with too many outgoing dependencies
768    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    // Calculate score
789    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
802/// Score naming convention consistency.
803fn 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        // Check naming conventions based on kind
811        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    // Calculate score based on violation percentage
838    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
860/// Check if a name is snake_case.
861fn is_snake_case(name: &str) -> bool {
862    if name.is_empty() {
863        return true;
864    }
865    // Allow leading underscore for private
866    let name = name.strip_prefix('_').unwrap_or(name);
867    if name.is_empty() {
868        return true;
869    }
870
871    // Must be lowercase with underscores
872    name.chars()
873        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
874}
875
876/// Check if a name is PascalCase.
877fn is_pascal_case(name: &str) -> bool {
878    if name.is_empty() {
879        return true;
880    }
881    // First char must be uppercase
882    let first = name.chars().next().unwrap();
883    if !first.is_ascii_uppercase() {
884        return false;
885    }
886    // No underscores (except for generic params like T_1)
887    !name.contains('_') || name.chars().filter(|&c| c == '_').count() <= 1
888}
889
890/// Check if a name is SCREAMING_SNAKE_CASE.
891fn 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
899/// Suggest a fix for a naming convention violation.
900fn 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
911/// Convert a name to snake_case.
912fn 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
923/// Convert a name to PascalCase.
924fn 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
940/// Capitalize the first letter of a string.
941fn 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        // Test that incremental audit with no changes returns a perfect score
1054        let mut report = QualityReport::new();
1055        report.overall_score = 10.0;
1056        report.passed = true;
1057
1058        // Verify the report is valid
1059        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        // Create a temp git repo
1069        let temp_dir = TempDir::new().unwrap();
1070        let root = temp_dir.path().to_path_buf();
1071
1072        // Initialize git repo
1073        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        // Create a file and commit it
1092        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        // Modify the file
1105        fs::write(root.join("test.rs"), "fn test() { println!(\"hello\"); }").unwrap();
1106
1107        // Get changed files
1108        let changed = get_changed_files(&root).unwrap();
1109
1110        // Should contain the modified file
1111        assert!(
1112            changed.contains("test.rs"),
1113            "Should find changed file: {:?}",
1114            changed
1115        );
1116    }
1117}