1use crate::analyzer::CodeIssue;
4use crate::language::Language;
5use crate::style_ir::StyleIr;
6use crate::treesitter::engine::ParsedFile;
7use std::collections::HashMap;
8
9pub trait SignalDetector: Send + Sync {
18 fn signal(&self) -> StyleSignal;
19 fn supported_languages(&self) -> &'static [Language];
20
21 fn count_violations(&self, file: &ParsedFile) -> usize;
23
24 fn count_violations_with_ir(&self, _ir: &StyleIr, file: &ParsedFile) -> usize {
27 self.count_violations(file)
28 }
29
30 fn skips_test_files(&self) -> bool {
33 true
34 }
35
36 fn detect_findings(
45 &self,
46 file: &ParsedFile,
47 is_test_file: bool,
48 skip_tests_config: bool,
49 ) -> Vec<(StyleSignal, usize)> {
50 let skip = is_test_file && self.skips_test_files() && skip_tests_config;
51 let count = if skip { 0 } else { self.count_violations(file) };
52 if count > 0 {
53 vec![(self.signal(), count)]
54 } else {
55 vec![]
56 }
57 }
58
59 fn detect_findings_with_ir(
61 &self,
62 ir: &StyleIr,
63 file: &ParsedFile,
64 is_test_file: bool,
65 skip_tests_config: bool,
66 ) -> Vec<(StyleSignal, usize)> {
67 let skip = is_test_file && self.skips_test_files() && skip_tests_config;
68 let count = if skip {
69 0
70 } else {
71 self.count_violations_with_ir(ir, file)
72 };
73 if count > 0 {
74 vec![(self.signal(), count)]
75 } else {
76 vec![]
77 }
78 }
79}
80
81pub fn violations_to_score(count: usize, total_lines: usize) -> f64 {
83 let k_lines = (total_lines as f64 / 1000.0).max(0.001);
84 let density = count as f64 / k_lines;
85 ((density + 1.0).log2() * 6.0).min(25.0)
86}
87
88pub fn aggregate_detector_scores(
90 detectors: &[Box<dyn SignalDetector>],
91 files: &[ParsedFile],
92 is_test_files: &[bool],
93 skip_tests_config: bool,
94) -> HashMap<StyleSignal, f64> {
95 let mut total_counts: HashMap<StyleSignal, usize> = HashMap::new();
96 let mut total_lines: HashMap<StyleSignal, usize> = HashMap::new();
97
98 for (i, file) in files.iter().enumerate() {
99 let is_test = is_test_files.get(i).copied().unwrap_or(false);
100 let lang = file.language;
101 let ir = StyleIr::from_parsed(file);
102 for detector in detectors {
103 if !detector.supported_languages().contains(&lang) {
104 continue;
105 }
106 let signal = detector.signal();
107 let skip = is_test && detector.skips_test_files() && skip_tests_config;
108 let raw = if skip {
109 0
110 } else if let Some(ref ir) = ir {
111 detector.count_violations_with_ir(ir, file)
112 } else {
113 detector.count_violations(file)
114 };
115 let count = if is_test {
116 (raw as f64 * 0.2).round() as usize
117 } else {
118 raw
119 };
120 *total_counts.entry(signal).or_insert(0) += count;
121 *total_lines.entry(signal).or_insert(0) += file.content.lines().count();
122 }
123 }
124
125 let mut scores = HashMap::new();
126 for (signal, count) in total_counts {
127 let lines = total_lines.get(&signal).copied().unwrap_or(1);
128 scores.insert(signal, violations_to_score(count, lines));
129 }
130 scores
131}
132
133pub use crate::detectors::{
134 CodeSmellsDetector, DuplicationDetector, LegacyCodeDetector, LineCountSmellDetector,
135 NamingChaosDetector, NestedHellDetector, PanicAddictionDetector, TodoMountainDetector,
136};
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub enum StyleSignal {
140 Duplication,
141 PanicAddiction,
142 NamingChaos,
143 NestedHell,
144 HotfixCulture,
145 OverEngineering,
146 CodeSmells,
147 LegacyCode,
148 TodoMountain,
149 LineCountSmell,
150}
151
152impl StyleSignal {
153 pub fn all() -> &'static [StyleSignal] {
154 &[
155 StyleSignal::Duplication,
156 StyleSignal::PanicAddiction,
157 StyleSignal::NamingChaos,
158 StyleSignal::NestedHell,
159 StyleSignal::HotfixCulture,
160 StyleSignal::OverEngineering,
161 StyleSignal::CodeSmells,
162 StyleSignal::LegacyCode,
163 StyleSignal::TodoMountain,
164 StyleSignal::LineCountSmell,
165 ]
166 }
167
168 pub fn display_name(&self) -> &'static str {
169 match self {
170 StyleSignal::Duplication => "Duplication",
171 StyleSignal::PanicAddiction => "Panic Addiction",
172 StyleSignal::NamingChaos => "Naming Chaos",
173 StyleSignal::NestedHell => "Nested Hell",
174 StyleSignal::HotfixCulture => "Hotfix Culture",
175 StyleSignal::OverEngineering => "Over-Engineering",
176 StyleSignal::CodeSmells => "Code Smells",
177 StyleSignal::LegacyCode => "Legacy Code",
178 StyleSignal::TodoMountain => "Todo Mountain",
179 StyleSignal::LineCountSmell => "Line Count Smell",
180 }
181 }
182
183 pub fn display_name_zh(&self) -> String {
184 match self {
185 StyleSignal::Duplication => "重复代码",
186 StyleSignal::PanicAddiction => "恐慌成瘾",
187 StyleSignal::NamingChaos => "命名混乱",
188 StyleSignal::NestedHell => "嵌套地狱",
189 StyleSignal::HotfixCulture => "热修复文化",
190 StyleSignal::OverEngineering => "过度工程",
191 StyleSignal::CodeSmells => "代码异味",
192 StyleSignal::LegacyCode => "遗留代码",
193 StyleSignal::TodoMountain => "待办堆积",
194 StyleSignal::LineCountSmell => "文件过长",
195 }
196 .to_string()
197 }
198}
199
200pub struct LanguageCapabilityMatrix;
216
217impl LanguageCapabilityMatrix {
218 pub fn supported_signals(lang: Language) -> &'static [StyleSignal] {
220 if lang.has_tree_sitter_grammar() {
221 StyleSignal::all()
222 } else {
223 &[]
224 }
225 }
226
227 pub fn direct_signals(lang: Language) -> &'static [StyleSignal] {
229 static ALL_DIRECT: &[StyleSignal] = &[
230 StyleSignal::Duplication,
231 StyleSignal::CodeSmells,
232 StyleSignal::PanicAddiction,
233 StyleSignal::NamingChaos,
234 StyleSignal::NestedHell,
235 StyleSignal::HotfixCulture,
236 StyleSignal::OverEngineering,
237 StyleSignal::LegacyCode,
238 StyleSignal::TodoMountain,
239 StyleSignal::LineCountSmell,
240 ];
241 if lang.has_tree_sitter_grammar() {
242 ALL_DIRECT
243 } else {
244 &[]
245 }
246 }
247
248 pub fn supports_signal(lang: Language, signal: StyleSignal) -> bool {
250 Self::supported_signals(lang).contains(&signal)
251 }
252
253 pub fn has_direct_detector(lang: Language, signal: StyleSignal) -> bool {
255 Self::direct_signals(lang).contains(&signal)
256 }
257}
258
259pub fn classify_rule(rule_name: &str) -> StyleSignal {
260 match rule_name {
261 "code-duplication" | "cross-file-duplication" => StyleSignal::Duplication,
262 "unwrap-abuse" | "panic-abuse" | "bare-except" | "bare-rescue" | "empty-catch"
263 | "println-debugging" => StyleSignal::PanicAddiction,
264 "terrible-naming"
265 | "single-letter-variable"
266 | "meaningless-naming"
267 | "hungarian-notation"
268 | "abbreviation-abuse"
269 | "c-naming"
270 | "go-receiver-name"
271 | "go-mixed-caps"
272 | "ruby-predicate-method"
273 | "python-naming"
274 | "constant-name" => StyleSignal::NamingChaos,
275 "deep-nesting"
276 | "cyclomatic-complexity"
277 | "c-nesting"
278 | "complex-closure"
279 | "go-else-return"
280 | "negated-if" => StyleSignal::NestedHell,
281 "commented-code" | "c-commented-code" | "dead-code" | "c-dead-code" => {
282 StyleSignal::LegacyCode
283 }
284 "todo-comment" | "todo-fixme" | "todo-bug" | "todo-hack" => StyleSignal::TodoMountain,
285 "too-many-params" | "god-function" | "long-function" | "c-long-function"
286 | "c-god-function" | "module-complexity" | "trait-complexity" | "generic-abuse" => {
287 StyleSignal::OverEngineering
288 }
289 "file-too-long" => StyleSignal::LineCountSmell,
290 _ => StyleSignal::CodeSmells,
291 }
292}
293
294#[derive(Debug, Clone)]
300pub struct StyleProfile {
301 pub signal_scores: HashMap<StyleSignal, f64>,
302 pub dominant_signal: Option<StyleSignal>,
303}
304
305impl StyleProfile {
306 pub fn from_signal_scores(signal_scores: HashMap<StyleSignal, f64>) -> Self {
308 let dominant_signal = StyleSignal::all()
309 .iter()
310 .max_by(|a, b| {
311 let sa = signal_scores.get(a).copied().unwrap_or(0.0);
312 let sb = signal_scores.get(b).copied().unwrap_or(0.0);
313 sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
314 })
315 .copied();
316 Self {
317 signal_scores,
318 dominant_signal,
319 }
320 }
321
322 pub fn from_signal_counts(counts: HashMap<StyleSignal, u32>) -> Self {
326 let max_count = counts.values().copied().max().unwrap_or(1).max(1) as f64;
327 let signal_scores: HashMap<StyleSignal, f64> = counts
328 .iter()
329 .map(|(s, &c)| (*s, c as f64 / max_count * 25.0))
330 .collect();
331 Self::from_signal_scores(signal_scores)
332 }
333
334 pub fn score(&self, signal: StyleSignal) -> f64 {
335 self.signal_scores.get(&signal).copied().unwrap_or(0.0)
336 }
337
338 pub fn infer_personality_type(&self) -> &'static str {
345 let dup = self.score(StyleSignal::Duplication);
346 let panic = self.score(StyleSignal::PanicAddiction);
347 let naming = self.score(StyleSignal::NamingChaos);
348 let nested = self.score(StyleSignal::NestedHell);
349 let hotfix = self.score(StyleSignal::HotfixCulture);
350 let over_eng = self.score(StyleSignal::OverEngineering);
351
352 if dup >= 12.0 && dup >= panic && dup >= naming && dup >= nested {
354 return "The Copy-Paste Artist";
355 }
356 if panic >= 12.0 && panic >= dup && panic >= naming && panic >= nested {
357 return "The YOLO Engineer";
358 }
359 if nested >= 12.0 && nested >= naming && nested >= hotfix {
360 return "The Trait Wizard";
361 }
362 if naming >= 12.0 && naming >= nested {
363 return "The Legacy Necromancer";
364 }
365 if hotfix >= 12.0 {
366 return "The Hotfix Mercenary";
367 }
368
369 if dup >= 6.0 && panic >= 6.0 {
371 return "The Startup Survivor";
372 }
373 if (naming >= 6.0 && nested >= 6.0) || over_eng >= 12.0 {
374 return "The Academic Wizard";
375 }
376 if over_eng >= 6.0 {
377 return "The Academic Wizard";
378 }
379
380 "The Enterprise Bureaucrat"
381 }
382}
383
384pub fn compute_signal_scores(
385 issues: &[CodeIssue],
386 total_lines: usize,
387) -> HashMap<StyleSignal, f64> {
388 let k_lines = total_lines as f64 / 1000.0;
389 let mut counts: HashMap<StyleSignal, usize> = HashMap::new();
390
391 for issue in issues {
392 let signal = classify_rule(&issue.rule_name);
393 *counts.entry(signal).or_insert(0) += 1;
394 }
395
396 let mut scores = HashMap::new();
397 for signal in StyleSignal::all() {
398 let count = counts.get(signal).copied().unwrap_or(0);
399 let density = if k_lines > 0.0 {
400 count as f64 / k_lines
401 } else {
402 0.0
403 };
404 let score = ((density + 1.0).log2() * 6.0).min(25.0);
405 scores.insert(*signal, score);
406 }
407
408 scores
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use std::path::PathBuf;
415
416 fn make_issue(rule_name: &str) -> CodeIssue {
417 CodeIssue {
418 file_path: PathBuf::from("test.rs"),
419 line: 1,
420 column: 0,
421 rule_name: rule_name.to_string(),
422 message: String::new(),
423 severity: crate::analyzer::Severity::Spicy,
424 }
425 }
426
427 #[test]
431 fn test_display_name_all_variants() {
432 let mut names = std::collections::HashSet::new();
433 for s in StyleSignal::all() {
434 let name = s.display_name();
435 assert!(!name.is_empty(), "{:?}.display_name should not be empty", s);
436 assert!(
437 names.insert(name),
438 "{:?}.display_name '{}' is not unique",
439 s,
440 name
441 );
442 }
443 }
444
445 #[test]
447 fn test_display_name_zh_all_variants() {
448 let mut names = std::collections::HashSet::new();
449 for s in StyleSignal::all() {
450 let name = s.display_name_zh();
451 assert!(
452 !name.is_empty(),
453 "{:?}.display_name_zh should not be empty",
454 s
455 );
456 assert!(
457 names.insert(name.clone()),
458 "{:?}.display_name_zh '{}' is not unique",
459 s,
460 name
461 );
462 }
463 }
464
465 #[test]
469 fn test_classify_duplication_all() {
470 assert_eq!(
471 classify_rule("code-duplication"),
472 StyleSignal::Duplication,
473 "code-duplication"
474 );
475 assert_eq!(
476 classify_rule("cross-file-duplication"),
477 StyleSignal::Duplication,
478 "cross-file-duplication"
479 );
480 }
481
482 #[test]
484 fn test_classify_panic_all() {
485 for name in &[
486 "unwrap-abuse",
487 "panic-abuse",
488 "bare-except",
489 "bare-rescue",
490 "empty-catch",
491 "println-debugging",
492 ] {
493 assert_eq!(
494 classify_rule(name),
495 StyleSignal::PanicAddiction,
496 "{name} should map to PanicAddiction"
497 );
498 }
499 }
500
501 #[test]
503 fn test_classify_naming_all() {
504 for name in &[
505 "terrible-naming",
506 "single-letter-variable",
507 "meaningless-naming",
508 "hungarian-notation",
509 "abbreviation-abuse",
510 "c-naming",
511 "go-receiver-name",
512 "go-mixed-caps",
513 "ruby-predicate-method",
514 "python-naming",
515 "constant-name",
516 ] {
517 assert_eq!(
518 classify_rule(name),
519 StyleSignal::NamingChaos,
520 "{name} should map to NamingChaos"
521 );
522 }
523 }
524
525 #[test]
527 fn test_classify_nested_all() {
528 for name in &[
529 "deep-nesting",
530 "cyclomatic-complexity",
531 "c-nesting",
532 "complex-closure",
533 "go-else-return",
534 "negated-if",
535 ] {
536 assert_eq!(
537 classify_rule(name),
538 StyleSignal::NestedHell,
539 "{name} should map to NestedHell"
540 );
541 }
542 }
543
544 #[test]
546 fn test_classify_legacy_code() {
547 for name in &[
548 "commented-code",
549 "c-commented-code",
550 "dead-code",
551 "c-dead-code",
552 ] {
553 assert_eq!(
554 classify_rule(name),
555 StyleSignal::LegacyCode,
556 "{name} should map to LegacyCode"
557 );
558 }
559 }
560
561 #[test]
563 fn test_classify_todo_mountain() {
564 for name in &["todo-comment", "todo-fixme", "todo-bug", "todo-hack"] {
565 assert_eq!(
566 classify_rule(name),
567 StyleSignal::TodoMountain,
568 "{name} should map to TodoMountain"
569 );
570 }
571 }
572
573 #[test]
575 fn test_classify_over_engineering_all() {
576 for name in &[
577 "too-many-params",
578 "god-function",
579 "long-function",
580 "c-long-function",
581 "c-god-function",
582 "module-complexity",
583 "trait-complexity",
584 "generic-abuse",
585 ] {
586 assert_eq!(
587 classify_rule(name),
588 StyleSignal::OverEngineering,
589 "{name} should map to OverEngineering"
590 );
591 }
592 }
593
594 #[test]
596 fn test_classify_line_count_smell() {
597 assert_eq!(classify_rule("file-too-long"), StyleSignal::LineCountSmell);
598 }
599
600 #[test]
602 fn test_classify_code_smells_fallback() {
603 assert_eq!(classify_rule("magic-number"), StyleSignal::CodeSmells);
604 assert_eq!(classify_rule("unknown-rule"), StyleSignal::CodeSmells);
605 assert_eq!(classify_rule(""), StyleSignal::CodeSmells);
606 assert_eq!(classify_rule("rust-doc-example"), StyleSignal::CodeSmells);
607 }
608
609 #[test]
614 fn test_matrix_supported_all_grammar_languages() {
615 for lang in crate::language::LANGUAGES_WITH_GRAMMAR {
616 let sigs = LanguageCapabilityMatrix::supported_signals(*lang);
617 assert_eq!(
618 sigs.len(),
619 10,
620 "{} should support 10 signals",
621 lang.display_name()
622 );
623 }
624 }
625
626 #[test]
628 fn test_matrix_supported_unknown() {
629 let sigs = LanguageCapabilityMatrix::supported_signals(Language::Unknown);
630 assert!(sigs.is_empty(), "Unknown should have no supported signals");
631 }
632
633 #[test]
635 fn test_matrix_supports_signal_rust_panic() {
636 assert!(LanguageCapabilityMatrix::supports_signal(
637 Language::Rust,
638 StyleSignal::PanicAddiction
639 ));
640 }
641
642 #[test]
644 fn test_matrix_supports_signal_unknown() {
645 assert!(!LanguageCapabilityMatrix::supports_signal(
646 Language::Unknown,
647 StyleSignal::PanicAddiction
648 ));
649 }
650
651 #[test]
653 fn test_matrix_direct_signals_rust() {
654 let sigs = LanguageCapabilityMatrix::direct_signals(Language::Rust);
655 for signal in StyleSignal::all() {
656 assert!(
657 sigs.contains(signal),
658 "Rust should have direct {}",
659 signal.display_name()
660 );
661 }
662 assert_eq!(sigs.len(), 10, "Rust has all 10 direct signals");
663 }
664
665 #[test]
667 fn test_matrix_direct_signals_go() {
668 let sigs = LanguageCapabilityMatrix::direct_signals(Language::Go);
669 for signal in StyleSignal::all() {
670 assert!(
671 sigs.contains(signal),
672 "Go should have direct {}",
673 signal.display_name()
674 );
675 }
676 assert_eq!(sigs.len(), 10, "Go has all 10 direct signals");
677 }
678
679 #[test]
681 fn test_matrix_direct_signals_python() {
682 let sigs = LanguageCapabilityMatrix::direct_signals(Language::Python);
683 for signal in StyleSignal::all() {
684 assert!(
685 sigs.contains(signal),
686 "Python should have direct {}",
687 signal.display_name()
688 );
689 }
690 assert_eq!(sigs.len(), 10, "Python has all 10 direct signals");
691 }
692
693 #[test]
695 fn test_matrix_has_direct_detector_rust() {
696 assert!(LanguageCapabilityMatrix::has_direct_detector(
697 Language::Rust,
698 StyleSignal::PanicAddiction
699 ));
700 assert!(LanguageCapabilityMatrix::has_direct_detector(
701 Language::Swift,
702 StyleSignal::PanicAddiction
703 ));
704 assert!(LanguageCapabilityMatrix::has_direct_detector(
705 Language::Zig,
706 StyleSignal::PanicAddiction
707 ));
708 assert!(!LanguageCapabilityMatrix::has_direct_detector(
709 Language::Unknown,
710 StyleSignal::PanicAddiction
711 ));
712 }
713
714 #[test]
719 fn test_compute_signal_scores_empty() {
720 let scores = compute_signal_scores(&[], 1000);
721 assert_eq!(scores.len(), 10, "all 10 signals present");
722 for s in StyleSignal::all() {
723 assert!(
724 (scores[s] - 0.0).abs() < f64::EPSILON,
725 "empty issues => {s:?} = {}",
726 scores[s]
727 );
728 }
729 }
730
731 #[test]
734 fn test_compute_signal_scores_mixed() {
735 let issues = vec![
736 make_issue("unwrap-abuse"),
737 make_issue("unwrap-abuse"),
738 make_issue("deep-nesting"),
739 make_issue("terrible-naming"),
740 ];
741 let scores = compute_signal_scores(&issues, 1000);
742 assert!(
743 scores[&StyleSignal::PanicAddiction] > scores[&StyleSignal::NamingChaos],
744 "2 panics should score higher than 1 naming"
745 );
746 assert!(
747 scores[&StyleSignal::PanicAddiction] > scores[&StyleSignal::NestedHell],
748 "2 panics should score higher than 1 nesting"
749 );
750 }
751
752 #[test]
755 fn test_compute_signal_scores_zero_lines() {
756 let issues = vec![make_issue("unwrap-abuse")];
757 let scores = compute_signal_scores(&issues, 0);
758 assert!(
761 scores.values().all(|&s| s >= 0.0),
762 "zero lines should not produce NaN or negative"
763 );
764 assert!(
765 scores[&StyleSignal::PanicAddiction] > 0.0
766 || (scores[&StyleSignal::PanicAddiction] - 0.0).abs() < f64::EPSILON,
767 "score with zero lines should be >= 0"
768 );
769 }
770
771 #[test]
773 fn test_compute_signal_scores_capped() {
774 let issues: Vec<_> = (0..1000).map(|_| make_issue("unwrap-abuse")).collect();
776 let scores = compute_signal_scores(&issues, 1);
777 assert!(
778 scores[&StyleSignal::PanicAddiction] <= 25.0,
779 "score should be capped at 25, got {}",
780 scores[&StyleSignal::PanicAddiction]
781 );
782 }
783
784 #[test]
787 fn test_compute_signal_scores_category_independence() {
788 let issues = vec![make_issue("deep-nesting")];
789 let scores = compute_signal_scores(&issues, 1000);
790 assert!(
791 scores[&StyleSignal::NestedHell] > 0.0,
792 "NestedHell should be non-zero"
793 );
794 for s in StyleSignal::all() {
795 if *s != StyleSignal::NestedHell {
796 assert!(
797 (scores[s] - 0.0).abs() < f64::EPSILON,
798 "only NestedHell should be non-zero, but {s:?} = {}",
799 scores[s]
800 );
801 }
802 }
803 }
804
805 #[test]
808 fn test_compute_signal_scores_density_scaling() {
809 let issues = vec![make_issue("unwrap-abuse")];
810 let sparse = compute_signal_scores(&issues, 100_000); let dense = compute_signal_scores(&issues, 10); assert!(
813 dense[&StyleSignal::PanicAddiction] > sparse[&StyleSignal::PanicAddiction],
814 "dense (10 lines) should score higher than sparse (100k lines)"
815 );
816 }
817
818 fn make_profile(scores: &[(StyleSignal, f64)]) -> StyleProfile {
821 let map: HashMap<StyleSignal, f64> = scores.iter().cloned().collect();
822 StyleProfile::from_signal_scores(map)
823 }
824
825 #[test]
828 fn test_style_profile_empty() {
829 let p = StyleProfile::from_signal_scores(HashMap::new());
830 assert_eq!(p.dominant_signal, Some(StyleSignal::LineCountSmell));
831 assert_eq!(p.score(StyleSignal::Duplication), 0.0);
832 assert_eq!(p.infer_personality_type(), "The Enterprise Bureaucrat");
833 }
834
835 #[test]
837 fn test_style_profile_copy_paste() {
838 let p = make_profile(&[
839 (StyleSignal::Duplication, 15.0),
840 (StyleSignal::PanicAddiction, 3.0),
841 ]);
842 assert_eq!(p.dominant_signal, Some(StyleSignal::Duplication));
843 assert_eq!(p.infer_personality_type(), "The Copy-Paste Artist");
844 }
845
846 #[test]
848 fn test_style_profile_yolo() {
849 let p = make_profile(&[
850 (StyleSignal::PanicAddiction, 15.0),
851 (StyleSignal::NamingChaos, 3.0),
852 ]);
853 assert_eq!(p.infer_personality_type(), "The YOLO Engineer");
854 }
855
856 #[test]
858 fn test_style_profile_trait_wizard() {
859 let p = make_profile(&[
860 (StyleSignal::NestedHell, 15.0),
861 (StyleSignal::NamingChaos, 2.0),
862 ]);
863 assert_eq!(p.infer_personality_type(), "The Trait Wizard");
864 }
865
866 #[test]
868 fn test_style_profile_legacy_necromancer() {
869 let p = make_profile(&[
870 (StyleSignal::NamingChaos, 15.0),
871 (StyleSignal::NestedHell, 3.0),
872 ]);
873 assert_eq!(p.infer_personality_type(), "The Legacy Necromancer");
874 }
875
876 #[test]
878 fn test_style_profile_hotfix() {
879 let p = make_profile(&[(StyleSignal::HotfixCulture, 15.0)]);
880 assert_eq!(p.infer_personality_type(), "The Hotfix Mercenary");
881 }
882
883 #[test]
885 fn test_style_profile_startup() {
886 let p = make_profile(&[
887 (StyleSignal::Duplication, 8.0),
888 (StyleSignal::PanicAddiction, 7.0),
889 ]);
890 assert_eq!(p.infer_personality_type(), "The Startup Survivor");
891 }
892
893 #[test]
896 fn test_style_profile_academic_compound() {
897 let p = make_profile(&[
898 (StyleSignal::NamingChaos, 8.0),
899 (StyleSignal::NestedHell, 7.0),
900 ]);
901 assert_eq!(p.infer_personality_type(), "The Academic Wizard");
902 }
903
904 #[test]
906 fn test_style_profile_academic_over_eng() {
907 let p = make_profile(&[(StyleSignal::OverEngineering, 15.0)]);
908 assert_eq!(p.infer_personality_type(), "The Academic Wizard");
909 }
910
911 #[test]
913 fn test_style_profile_enterprise() {
914 let p = make_profile(&[
915 (StyleSignal::Duplication, 4.0),
916 (StyleSignal::HotfixCulture, 3.0),
917 ]);
918 assert_eq!(p.infer_personality_type(), "The Enterprise Bureaucrat");
919 }
920
921 #[test]
923 fn test_style_profile_all_zero() {
924 let p = make_profile(&[
925 (StyleSignal::Duplication, 0.0),
926 (StyleSignal::PanicAddiction, 0.0),
927 (StyleSignal::NamingChaos, 0.0),
928 (StyleSignal::NestedHell, 0.0),
929 (StyleSignal::HotfixCulture, 0.0),
930 (StyleSignal::OverEngineering, 0.0),
931 (StyleSignal::CodeSmells, 0.0),
932 (StyleSignal::LegacyCode, 0.0),
933 (StyleSignal::TodoMountain, 0.0),
934 (StyleSignal::LineCountSmell, 0.0),
935 ]);
936 assert_eq!(p.dominant_signal, Some(StyleSignal::LineCountSmell));
938 assert_eq!(p.infer_personality_type(), "The Enterprise Bureaucrat");
939 }
940
941 use crate::treesitter::engine::TreeSitterEngine;
944
945 fn parse_rust(code: &str) -> ParsedFile {
946 let engine = TreeSitterEngine::new();
947 engine
948 .parse_file(std::path::Path::new("test.rs"), code)
949 .expect("Rust parse should succeed")
950 }
951
952 #[test]
954 fn test_violations_to_score_zero() {
955 let score = violations_to_score(0, 1000);
956 assert!(
957 (score - 0.0).abs() < f64::EPSILON,
958 "0 violations => score 0, got {score}"
959 );
960 }
961
962 #[test]
964 fn test_violations_to_score_increasing() {
965 let low = violations_to_score(1, 1000);
966 let high = violations_to_score(10, 1000);
967 assert!(
968 high > low,
969 "more violations => higher score, {high} <= {low}"
970 );
971 }
972
973 #[test]
975 fn test_violations_to_score_capped() {
976 let score = violations_to_score(1_000_000, 1);
977 assert!(score <= 25.0, "score should be capped at 25, got {score}");
978 }
979
980 #[test]
982 fn test_aggregate_detector_scores() {
983 let files = vec![parse_rust("fn a() { let x = v.unwrap(); }")];
984 let test_flags = vec![false];
985 let detectors: Vec<Box<dyn SignalDetector>> = vec![Box::new(PanicAddictionDetector::new())];
986 let scores = aggregate_detector_scores(&detectors, &files, &test_flags, true);
987 let panic_score = scores
988 .get(&StyleSignal::PanicAddiction)
989 .copied()
990 .unwrap_or(0.0);
991 assert!(
992 panic_score > 0.0,
993 "PanicAddiction score should be > 0, got {panic_score}"
994 );
995 assert!(
996 panic_score <= 25.0,
997 "PanicAddiction score should be <= 25, got {panic_score}"
998 );
999 }
1000}