1use crate::analyzer::{CodeIssue, Severity};
8use crate::signals::{classify_rule, StyleProfile, StyleSignal};
9use std::collections::HashMap;
10use std::hash::{Hash, Hasher};
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
17pub struct FindingId(String);
18
19impl FindingId {
20 pub fn new(seed: &str) -> Self {
21 let mut hasher = std::hash::DefaultHasher::new();
22 seed.hash(&mut hasher);
23 Self(format!("{:016x}", hasher.finish())[..12].to_string())
24 }
25
26 pub fn as_str(&self) -> &str {
27 &self.0
28 }
29}
30
31#[derive(Debug, Clone)]
35pub struct CodeLocation {
36 pub file_path: PathBuf,
37 pub line: usize,
38 pub column: usize,
39 pub span: Option<TextSpan>,
40 pub symbol_name: Option<String>,
41}
42
43#[derive(Debug, Clone)]
45pub struct TextSpan {
46 pub start_line: usize,
47 pub start_column: usize,
48 pub end_line: usize,
49 pub end_column: usize,
50}
51
52#[derive(Debug, Clone)]
56pub struct RuleMeta {
57 pub name: String,
58 pub category: StyleCategory,
59 pub intent: RuleIntent,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum StyleCategory {
65 Naming,
66 Complexity,
67 Duplication,
68 Comments,
69 DebuggingLeftovers,
70 Structure,
71 Consistency,
72 DependencyStyle,
73}
74
75impl StyleCategory {
76 pub fn display_name(&self) -> &'static str {
77 match self {
78 StyleCategory::Naming => "Naming",
79 StyleCategory::Complexity => "Complexity",
80 StyleCategory::Duplication => "Duplication",
81 StyleCategory::Comments => "Comments",
82 StyleCategory::DebuggingLeftovers => "Debugging Leftovers",
83 StyleCategory::Structure => "Structure",
84 StyleCategory::Consistency => "Consistency",
85 StyleCategory::DependencyStyle => "Dependency Style",
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum RuleIntent {
93 Readability,
94 Maintainability,
95 TeamConvention,
96 NoiseReduction,
97 CognitiveLoad,
98}
99
100impl RuleIntent {
101 pub fn display_name(&self) -> &'static str {
102 match self {
103 RuleIntent::Readability => "Readability",
104 RuleIntent::Maintainability => "Maintainability",
105 RuleIntent::TeamConvention => "Team Convention",
106 RuleIntent::NoiseReduction => "Noise Reduction",
107 RuleIntent::CognitiveLoad => "Cognitive Load",
108 }
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum Confidence {
117 Low,
118 Medium,
119 High,
120}
121
122impl Confidence {
123 pub fn display_name(&self) -> &'static str {
124 match self {
125 Confidence::Low => "Low",
126 Confidence::Medium => "Medium",
127 Confidence::High => "High",
128 }
129 }
130
131 pub fn score(&self) -> f64 {
132 match self {
133 Confidence::Low => 0.3,
134 Confidence::Medium => 0.6,
135 Confidence::High => 1.0,
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
144pub struct Evidence {
145 pub snippet: Option<String>,
146 pub metric: Option<EvidenceMetric>,
147 pub nearby_context: Vec<String>,
148}
149
150#[derive(Debug, Clone)]
152pub struct EvidenceMetric {
153 pub name: String,
154 pub value: f64,
155 pub threshold: f64,
156 pub unit: String,
157}
158
159#[derive(Debug, Clone)]
163pub struct StyleSuggestion {
164 pub title: String,
165 pub explanation: String,
166 pub quick_fix_hint: Option<String>,
167 pub safer_alternative: Option<String>,
168}
169
170#[derive(Debug, Clone)]
177pub struct StyleFinding {
178 pub id: FindingId,
179 pub location: CodeLocation,
180 pub rule: RuleMeta,
181 pub signal: StyleSignal,
182 pub severity: Severity,
183 pub confidence: Confidence,
184 pub evidence: Evidence,
185 pub suggestion: Option<StyleSuggestion>,
186}
187
188impl StyleFinding {
189 pub fn for_signal(signal: StyleSignal, violation_count: usize, file_path: PathBuf) -> Self {
191 let id = FindingId::new(&format!(
192 "signal:{:?}:{violation_count}:{}",
193 signal,
194 file_path.display()
195 ));
196 let severity = if violation_count > 10 {
197 Severity::Nuclear
198 } else if violation_count > 3 {
199 Severity::Spicy
200 } else {
201 Severity::Mild
202 };
203 StyleFinding {
204 id,
205 location: CodeLocation {
206 file_path,
207 line: 0,
208 column: 0,
209 span: None,
210 symbol_name: None,
211 },
212 rule: RuleMeta {
213 name: signal.display_name().to_string(),
214 category: StyleCategory::Consistency,
215 intent: RuleIntent::Maintainability,
216 },
217 signal,
218 severity,
219 confidence: Confidence::High,
220 evidence: Evidence {
221 snippet: Some(format!(
222 "{violation_count} {} violations",
223 signal.display_name()
224 )),
225 metric: Some(EvidenceMetric {
226 name: "violations".to_string(),
227 value: violation_count as f64,
228 threshold: 0.0,
229 unit: "count".to_string(),
230 }),
231 nearby_context: Vec::new(),
232 },
233 suggestion: None,
234 }
235 }
236
237 pub fn to_code_issue(&self) -> CodeIssue {
240 CodeIssue {
241 file_path: self.location.file_path.clone(),
242 line: self.location.line,
243 column: self.location.column,
244 rule_name: self.rule.name.clone(),
245 message: self
246 .evidence
247 .snippet
248 .clone()
249 .unwrap_or_else(|| format!("{:?}", self.signal)),
250 severity: self.severity.clone(),
251 }
252 }
253}
254
255impl From<&CodeIssue> for StyleFinding {
256 fn from(issue: &CodeIssue) -> Self {
257 let id = FindingId::new(&format!(
258 "{}:{}:{}:{}",
259 issue.file_path.display(),
260 issue.line,
261 issue.rule_name,
262 issue.message,
263 ));
264 let signal = classify_rule(&issue.rule_name);
265 let location = CodeLocation {
266 file_path: issue.file_path.clone(),
267 line: issue.line,
268 column: issue.column,
269 span: None,
270 symbol_name: None,
271 };
272 let rule = RuleMeta {
273 name: issue.rule_name.clone(),
274 category: rule_to_category(&issue.rule_name),
275 intent: rule_to_intent(&issue.rule_name),
276 };
277 let confidence = rule_to_confidence(&issue.rule_name);
278 let evidence = Evidence {
279 snippet: Some(issue.message.clone()),
280 metric: None,
281 nearby_context: Vec::new(),
282 };
283
284 StyleFinding {
285 id,
286 location,
287 rule,
288 signal,
289 severity: issue.severity.clone(),
290 confidence,
291 evidence,
292 suggestion: None,
293 }
294 }
295}
296
297fn rule_to_category(rule_name: &str) -> StyleCategory {
300 match rule_name {
301 n if n.contains("naming")
302 || n.contains("letter")
303 || n.contains("hungarian")
304 || n.contains("abbreviation")
305 || n.contains("name")
306 || n.contains("predicate") =>
307 {
308 StyleCategory::Naming
309 }
310 n if n.contains("nest")
311 || n.contains("complex")
312 || n.contains("function_length")
313 || n.contains("long-function")
314 || n.contains("god-function")
315 || n.contains("too-many-params")
316 || n.contains("module-complexity")
317 || n.contains("trait-complexity") =>
318 {
319 StyleCategory::Complexity
320 }
321 n if n.contains("duplicat") => StyleCategory::Duplication,
322 n if n.contains("todo")
323 || n.contains("fixme")
324 || n.contains("commented")
325 || n.contains("dead-code") =>
326 {
327 StyleCategory::Comments
328 }
329 n if n.contains("println")
330 || n.contains("unwrap")
331 || n.contains("panic")
332 || n.contains("except")
333 || n.contains("rescue") =>
334 {
335 StyleCategory::DebuggingLeftovers
336 }
337 n if n.contains("file-too-long")
338 || n.contains("module-nesting")
339 || n.contains("import") =>
340 {
341 StyleCategory::Structure
342 }
343 n if n.contains("generic") || n.contains("magic") || n.contains("constant-name") => {
344 StyleCategory::Consistency
345 }
346 _ => StyleCategory::Consistency,
347 }
348}
349
350fn rule_to_intent(rule_name: &str) -> RuleIntent {
351 match rule_name {
352 n if n.contains("naming")
353 || n.contains("letter")
354 || n.contains("name")
355 || n.contains("hungarian")
356 || n.contains("abbreviation") =>
357 {
358 RuleIntent::Readability
359 }
360 n if n.contains("nest")
361 || n.contains("complex")
362 || n.contains("long-function")
363 || n.contains("god-function")
364 || n.contains("too-many-params") =>
365 {
366 RuleIntent::CognitiveLoad
367 }
368 n if n.contains("duplicat") => RuleIntent::Maintainability,
369 n if n.contains("todo")
370 || n.contains("fixme")
371 || n.contains("commented")
372 || n.contains("dead-code") =>
373 {
374 RuleIntent::NoiseReduction
375 }
376 n if n.contains("println")
377 || n.contains("unwrap")
378 || n.contains("panic")
379 || n.contains("except")
380 || n.contains("rescue") =>
381 {
382 RuleIntent::NoiseReduction
383 }
384 n if n.contains("magic") || n.contains("constant-name") || n.contains("generic") => {
385 RuleIntent::Maintainability
386 }
387 _ => RuleIntent::Readability,
388 }
389}
390
391fn rule_to_confidence(rule_name: &str) -> Confidence {
392 if matches!(
394 rule_name,
395 "magic-number"
396 | "code-duplication"
397 | "cross-file-duplication"
398 | "unwrap-abuse"
399 | "panic-abuse"
400 | "empty-catch"
401 | "bare-except"
402 | "bare-rescue"
403 | "println-debugging"
404 | "dead-code"
405 | "commented-code"
406 | "file-too-long"
407 ) {
408 return Confidence::High;
409 }
410
411 if matches!(
413 rule_name,
414 "deep-nesting"
415 | "cyclomatic-complexity"
416 | "complex-closure"
417 | "long-function"
418 | "god-function"
419 | "too-many-params"
420 | "module-complexity"
421 | "trait-complexity"
422 | "todo-comment"
423 | "todo-fixme"
424 | "todo-bug"
425 | "todo-hack"
426 ) {
427 return Confidence::Medium;
428 }
429
430 Confidence::Low
432}
433
434pub fn compute_signal_scores_from_findings(
444 findings: &[StyleFinding],
445 total_lines: usize,
446) -> HashMap<StyleSignal, f64> {
447 let k_lines = (total_lines as f64 / 1000.0).max(0.001);
448 let mut counts: HashMap<StyleSignal, usize> = HashMap::new();
449
450 for finding in findings {
451 *counts.entry(finding.signal).or_insert(0) += 1;
452 }
453
454 let mut scores = HashMap::new();
455 for signal in StyleSignal::all() {
456 let count = counts.get(signal).copied().unwrap_or(0);
457 let density = count as f64 / k_lines;
458 let score = ((density + 1.0).log2() * 6.0).min(25.0);
459 scores.insert(*signal, score);
460 }
461
462 scores
463}
464
465pub fn build_profile_from_findings(findings: &[StyleFinding], total_lines: usize) -> StyleProfile {
467 let signal_scores = compute_signal_scores_from_findings(findings, total_lines);
468 StyleProfile::from_signal_scores(signal_scores)
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use std::path::PathBuf;
475
476 fn make_issue(rule: &str) -> CodeIssue {
477 CodeIssue {
478 file_path: PathBuf::from("src/main.rs"),
479 line: 42,
480 column: 5,
481 rule_name: rule.to_string(),
482 message: format!("{rule} detected"),
483 severity: Severity::Spicy,
484 }
485 }
486
487 #[test]
491 fn test_finding_id_format() {
492 let id = FindingId::new("test-seed");
493 assert_eq!(id.as_str().len(), 12, "id should be 12 hex chars");
494 assert!(id.as_str().chars().all(|c| c.is_ascii_hexdigit()));
495 }
496
497 #[test]
499 fn test_finding_id_deterministic() {
500 let a = FindingId::new("hello");
501 let b = FindingId::new("hello");
502 assert_eq!(a, b);
503 }
504
505 #[test]
507 fn test_finding_id_distinct() {
508 let a = FindingId::new("hello");
509 let b = FindingId::new("world");
510 assert_ne!(a, b);
511 }
512
513 #[test]
517 fn test_category_naming() {
518 for name in &[
519 "terrible-naming",
520 "single-letter-variable",
521 "meaningless-naming",
522 "hungarian-notation",
523 "abbreviation-abuse",
524 "go-receiver-name",
525 "ruby-predicate-method",
526 ] {
527 assert_eq!(
528 rule_to_category(name),
529 StyleCategory::Naming,
530 "{name} should be Naming"
531 );
532 }
533 }
534
535 #[test]
537 fn test_category_complexity() {
538 for name in &[
539 "deep-nesting",
540 "cyclomatic-complexity",
541 "complex-closure",
542 "long-function",
543 "god-function",
544 "too-many-params",
545 "module-complexity",
546 ] {
547 assert_eq!(
548 rule_to_category(name),
549 StyleCategory::Complexity,
550 "{name} should be Complexity"
551 );
552 }
553 }
554
555 #[test]
557 fn test_category_duplication() {
558 assert_eq!(
559 rule_to_category("code-duplication"),
560 StyleCategory::Duplication
561 );
562 assert_eq!(
563 rule_to_category("cross-file-duplication"),
564 StyleCategory::Duplication
565 );
566 }
567
568 #[test]
572 fn test_confidence_high() {
573 for name in &[
574 "magic-number",
575 "code-duplication",
576 "unwrap-abuse",
577 "empty-catch",
578 "dead-code",
579 ] {
580 assert_eq!(
581 rule_to_confidence(name),
582 Confidence::High,
583 "{name} should be High confidence"
584 );
585 }
586 }
587
588 #[test]
590 fn test_confidence_medium() {
591 for name in &[
592 "deep-nesting",
593 "long-function",
594 "todo-comment",
595 "too-many-params",
596 ] {
597 assert_eq!(
598 rule_to_confidence(name),
599 Confidence::Medium,
600 "{name} should be Medium confidence"
601 );
602 }
603 }
604
605 #[test]
607 fn test_confidence_low() {
608 for name in &[
609 "terrible-naming",
610 "single-letter-variable",
611 "abbreviation-abuse",
612 "go-receiver-name",
613 ] {
614 assert_eq!(
615 rule_to_confidence(name),
616 Confidence::Low,
617 "{name} should be Low confidence"
618 );
619 }
620 }
621
622 #[test]
626 fn test_finding_from_issue_basic() {
627 let issue = make_issue("unwrap-abuse");
628 let finding = StyleFinding::from(&issue);
629 assert_eq!(finding.location.file_path, PathBuf::from("src/main.rs"));
630 assert_eq!(finding.location.line, 42);
631 assert_eq!(finding.rule.name, "unwrap-abuse");
632 assert_eq!(finding.signal, StyleSignal::PanicAddiction);
633 assert_eq!(finding.severity, Severity::Spicy);
634 assert_eq!(finding.confidence, Confidence::High);
635 }
636
637 #[test]
639 fn test_finding_id_from_issue_deterministic() {
640 let issue = make_issue("deep-nesting");
641 let a = StyleFinding::from(&issue).id;
642 let b = StyleFinding::from(&issue).id;
643 assert_eq!(a, b, "same issue should produce same finding id");
644 }
645
646 #[test]
648 fn test_category_fallback_consistency() {
649 let cat = rule_to_category("zzz-unmatched-rule");
650 assert_eq!(cat, StyleCategory::Consistency);
651 }
652
653 #[test]
655 fn test_intent_mapping() {
656 assert_eq!(rule_to_intent("deep-nesting"), RuleIntent::CognitiveLoad);
657 assert_eq!(rule_to_intent("unwrap-abuse"), RuleIntent::NoiseReduction);
658 assert_eq!(
659 rule_to_intent("code-duplication"),
660 RuleIntent::Maintainability
661 );
662 assert_eq!(rule_to_intent("terrible-naming"), RuleIntent::Readability);
663 }
664
665 #[test]
667 fn test_confidence_score_values() {
668 assert!((Confidence::High.score() - 1.0).abs() < f64::EPSILON);
669 assert!((Confidence::Medium.score() - 0.6).abs() < f64::EPSILON);
670 assert!((Confidence::Low.score() - 0.3).abs() < f64::EPSILON);
671 }
672
673 #[test]
675 fn test_display_names_non_empty() {
676 for cat in &[
677 StyleCategory::Naming,
678 StyleCategory::Complexity,
679 StyleCategory::Duplication,
680 StyleCategory::Comments,
681 StyleCategory::DebuggingLeftovers,
682 StyleCategory::Structure,
683 StyleCategory::Consistency,
684 StyleCategory::DependencyStyle,
685 ] {
686 assert!(
687 !cat.display_name().is_empty(),
688 "{:?} display_name empty",
689 cat
690 );
691 }
692 for intent in &[
693 RuleIntent::Readability,
694 RuleIntent::Maintainability,
695 RuleIntent::TeamConvention,
696 RuleIntent::NoiseReduction,
697 RuleIntent::CognitiveLoad,
698 ] {
699 assert!(
700 !intent.display_name().is_empty(),
701 "{:?} display_name empty",
702 intent
703 );
704 }
705 for conf in &[Confidence::Low, Confidence::Medium, Confidence::High] {
706 assert!(
707 !conf.display_name().is_empty(),
708 "{:?} display_name empty",
709 conf
710 );
711 }
712 }
713
714 #[test]
716 fn test_evidence_snippet() {
717 let issue = make_issue("deep-nesting");
718 let finding = StyleFinding::from(&issue);
719 assert_eq!(
720 finding.evidence.snippet.as_deref(),
721 Some("deep-nesting detected")
722 );
723 }
724
725 #[test]
727 fn test_suggestion_default_none() {
728 let issue = make_issue("long-function");
729 let finding = StyleFinding::from(&issue);
730 assert!(finding.suggestion.is_none());
731 }
732
733 fn finding(signal: StyleSignal, severity: Severity) -> StyleFinding {
736 let issue = CodeIssue {
737 file_path: PathBuf::from("test.rs"),
738 line: 1,
739 column: 1,
740 rule_name: format!("{:?}", signal).to_lowercase(),
741 message: "test".to_string(),
742 severity,
743 };
744 let mut f = StyleFinding::from(&issue);
746 f.signal = signal;
747 f
748 }
749
750 #[test]
752 fn test_signal_scores_from_findings() {
753 let findings = vec![
754 finding(StyleSignal::Duplication, Severity::Nuclear),
755 finding(StyleSignal::Duplication, Severity::Spicy),
756 finding(StyleSignal::PanicAddiction, Severity::Mild),
757 ];
758 let scores = compute_signal_scores_from_findings(&findings, 1000);
759 assert!(
760 *scores.get(&StyleSignal::Duplication).unwrap_or(&0.0)
761 > *scores.get(&StyleSignal::PanicAddiction).unwrap_or(&0.0),
762 "Duplication (2 count) should score higher than PanicAddiction (1 count)"
763 );
764 }
765
766 #[test]
768 fn test_signal_scores_from_findings_empty() {
769 let scores = compute_signal_scores_from_findings(&[], 1000);
770 for signal in StyleSignal::all() {
771 assert_eq!(
772 scores.get(signal).copied().unwrap_or(0.0),
773 0.0,
774 "empty findings => all signal scores 0"
775 );
776 }
777 }
778
779 #[test]
781 fn test_build_profile_from_findings_dominant() {
782 let findings = vec![
783 finding(StyleSignal::Duplication, Severity::Nuclear),
784 finding(StyleSignal::Duplication, Severity::Nuclear),
785 finding(StyleSignal::Duplication, Severity::Nuclear),
786 finding(StyleSignal::NestedHell, Severity::Mild),
787 ];
788 let profile = build_profile_from_findings(&findings, 1000);
789 assert_eq!(
790 profile.dominant_signal,
791 Some(StyleSignal::Duplication),
792 "3 duplicates vs 1 nested => Duplication should be dominant"
793 );
794 }
795
796 #[test]
798 fn test_build_profile_from_findings_personality() {
799 let findings = vec![
800 finding(StyleSignal::Duplication, Severity::Nuclear),
801 finding(StyleSignal::Duplication, Severity::Nuclear),
802 finding(StyleSignal::Duplication, Severity::Nuclear),
803 finding(StyleSignal::Duplication, Severity::Nuclear),
804 finding(StyleSignal::Duplication, Severity::Nuclear),
805 finding(StyleSignal::Duplication, Severity::Nuclear),
806 finding(StyleSignal::Duplication, Severity::Nuclear),
807 finding(StyleSignal::Duplication, Severity::Nuclear),
808 ];
809 let profile = build_profile_from_findings(&findings, 100);
810 let personality = profile.infer_personality_type();
811 assert_eq!(
812 personality, "The Copy-Paste Artist",
813 "massive duplication => Copy-Paste Artist, got {personality}"
814 );
815 }
816}