debtmap/debt/
smells.rs

1use crate::core::{DebtItem, DebtType, FunctionMetrics, Priority};
2use std::path::{Path, PathBuf};
3
4/// Represents different types of code smells
5#[derive(Debug, Clone, PartialEq)]
6pub enum SmellType {
7    LongParameterList,
8    LargeClass,
9    LongMethod,
10    FeatureEnvy,
11    DataClump,
12    DeepNesting,
13    DuplicateCode,
14}
15
16/// A detected code smell with its location and details
17#[derive(Debug, Clone)]
18pub struct CodeSmell {
19    pub smell_type: SmellType,
20    pub location: PathBuf,
21    pub line: usize,
22    pub message: String,
23    pub severity: Priority,
24}
25
26impl CodeSmell {
27    /// Convert a code smell to a debt item
28    pub fn to_debt_item(&self) -> DebtItem {
29        DebtItem {
30            id: format!(
31                "smell-{:?}-{}-{}",
32                self.smell_type,
33                self.location.display(),
34                self.line
35            ),
36            debt_type: DebtType::CodeSmell,
37            priority: self.severity,
38            file: self.location.clone(),
39            line: self.line,
40            column: None,
41            message: self.message.clone(),
42            context: None,
43        }
44    }
45}
46
47/// Detect long parameter lists in functions
48pub fn detect_long_parameter_list(func: &FunctionMetrics, param_count: usize) -> Option<CodeSmell> {
49    const THRESHOLD: usize = 5;
50
51    if param_count > THRESHOLD {
52        Some(CodeSmell {
53            smell_type: SmellType::LongParameterList,
54            location: func.file.clone(),
55            line: func.line,
56            message: format!(
57                "Function '{}' has {} parameters (threshold: {})",
58                func.name, param_count, THRESHOLD
59            ),
60            severity: if param_count > THRESHOLD * 2 {
61                Priority::High
62            } else {
63                Priority::Medium
64            },
65        })
66    } else {
67        None
68    }
69}
70
71/// Detect large classes/modules based on line count
72pub fn detect_large_module(path: &Path, line_count: usize) -> Option<CodeSmell> {
73    const THRESHOLD: usize = 300;
74
75    if line_count > THRESHOLD {
76        Some(CodeSmell {
77            smell_type: SmellType::LargeClass,
78            location: path.to_path_buf(),
79            line: 1,
80            message: format!("Module has {line_count} lines (threshold: {THRESHOLD})"),
81            severity: if line_count > THRESHOLD * 2 {
82                Priority::High
83            } else {
84                Priority::Medium
85            },
86        })
87    } else {
88        None
89    }
90}
91
92/// Detect long methods/functions
93pub fn detect_long_method(func: &FunctionMetrics) -> Option<CodeSmell> {
94    const THRESHOLD: usize = 50;
95
96    if func.length > THRESHOLD {
97        Some(CodeSmell {
98            smell_type: SmellType::LongMethod,
99            location: func.file.clone(),
100            line: func.line,
101            message: format!(
102                "Function '{}' has {} lines (threshold: {})",
103                func.name, func.length, THRESHOLD
104            ),
105            severity: if func.length > THRESHOLD * 2 {
106                Priority::High
107            } else {
108                Priority::Medium
109            },
110        })
111    } else {
112        None
113    }
114}
115
116/// Detect deep nesting in functions
117pub fn detect_deep_nesting(func: &FunctionMetrics) -> Option<CodeSmell> {
118    const THRESHOLD: u32 = 4;
119
120    if func.nesting > THRESHOLD {
121        Some(CodeSmell {
122            smell_type: SmellType::DeepNesting,
123            location: func.file.clone(),
124            line: func.line,
125            message: format!(
126                "Function '{}' has nesting depth of {} (threshold: {})",
127                func.name, func.nesting, THRESHOLD
128            ),
129            severity: if func.nesting > THRESHOLD * 2 {
130                Priority::High
131            } else {
132                Priority::Medium
133            },
134        })
135    } else {
136        None
137    }
138}
139
140/// Analyze a function for all code smells
141pub fn analyze_function_smells(func: &FunctionMetrics, param_count: usize) -> Vec<CodeSmell> {
142    let mut smells = Vec::new();
143
144    if let Some(smell) = detect_long_parameter_list(func, param_count) {
145        smells.push(smell);
146    }
147
148    if let Some(smell) = detect_long_method(func) {
149        smells.push(smell);
150    }
151
152    if let Some(smell) = detect_deep_nesting(func) {
153        smells.push(smell);
154    }
155
156    smells
157}
158
159/// Analyze a file for module-level code smells
160pub fn analyze_module_smells(path: &Path, line_count: usize) -> Vec<CodeSmell> {
161    let mut smells = Vec::new();
162
163    if let Some(smell) = detect_large_module(path, line_count) {
164        smells.push(smell);
165    }
166
167    smells
168}
169
170/// Detect feature envy - methods that use other class data more than their own
171/// This is a simplified version that looks for method calls on other objects
172pub fn detect_feature_envy(content: &str, path: &Path) -> Vec<CodeSmell> {
173    let mut smells = Vec::new();
174    let mut object_calls: std::collections::HashMap<String, usize> =
175        std::collections::HashMap::new();
176    let mut self_calls = 0;
177
178    // Count method calls per object
179    for line in content.lines() {
180        // Count self calls
181        self_calls += line.matches("self.").count();
182
183        // Look for pattern: identifier.method_call
184        // Simple regex-like pattern matching without regex
185        let trimmed = line.trim();
186        if let Some(dot_pos) = trimmed.find('.') {
187            if dot_pos > 0 {
188                let before_dot = &trimmed[..dot_pos];
189                let object_name = before_dot.split_whitespace().last().unwrap_or("");
190
191                // Skip if it's self or if it doesn't look like an identifier
192                if !object_name.is_empty()
193                    && object_name != "self"
194                    && object_name
195                        .chars()
196                        .next()
197                        .is_some_and(|c| c.is_alphabetic() || c == '_')
198                    && !object_name.contains('(')
199                    && !object_name.contains('"')
200                    && !object_name.contains('\'')
201                {
202                    *object_calls.entry(object_name.to_string()).or_insert(0) += 1;
203                }
204            }
205        }
206    }
207
208    // Check if any object is used more than self
209    for (object, count) in object_calls {
210        if count >= 3 && count > self_calls {
211            smells.push(CodeSmell {
212                smell_type: SmellType::FeatureEnvy,
213                location: path.to_path_buf(),
214                line: 1, // We don't track specific lines in this simple implementation
215                message: format!(
216                    "Possible feature envy: {} calls to '{}' vs {} self calls",
217                    count, object, self_calls
218                ),
219                severity: if count > 5 {
220                    Priority::Medium
221                } else {
222                    Priority::Low
223                },
224            });
225        }
226    }
227
228    smells
229}
230
231/// Detect data clumps - groups of parameters that often appear together
232pub fn detect_data_clumps(functions: &[FunctionMetrics]) -> Vec<CodeSmell> {
233    let mut smells = Vec::new();
234
235    // This is a simplified implementation
236    // In a real implementation, we'd analyze actual parameter names and types
237    for i in 0..functions.len() {
238        for j in i + 1..functions.len() {
239            // If two functions are in the same file and have similar high parameter counts,
240            // they might have data clumps
241            if functions[i].file == functions[j].file {
242                // This is a placeholder - real implementation would compare actual parameters
243                if functions[i].length > 30 && functions[j].length > 30 {
244                    smells.push(CodeSmell {
245                        smell_type: SmellType::DataClump,
246                        location: functions[i].file.clone(),
247                        line: functions[i].line,
248                        message: format!(
249                            "Functions '{}' and '{}' may share data clumps",
250                            functions[i].name, functions[j].name
251                        ),
252                        severity: Priority::Low,
253                    });
254                    break; // Only report once per function
255                }
256            }
257        }
258    }
259
260    smells
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::core::FunctionMetrics;
267    use std::path::PathBuf;
268
269    #[test]
270    fn test_detect_data_clumps_empty_functions() {
271        let functions = vec![];
272        let smells = detect_data_clumps(&functions);
273        assert_eq!(
274            smells.len(),
275            0,
276            "No smells should be detected for empty input"
277        );
278    }
279
280    #[test]
281    fn test_detect_data_clumps_single_function() {
282        let functions = vec![FunctionMetrics {
283            name: "large_function".to_string(),
284            file: PathBuf::from("src/lib.rs"),
285            line: 10,
286            cyclomatic: 5,
287            cognitive: 10,
288            nesting: 2,
289            length: 35,
290            is_test: false,
291            visibility: None,
292            is_trait_method: false,
293            in_test_module: false,
294            entropy_score: None,
295            is_pure: None,
296            purity_confidence: None,
297        }];
298        let smells = detect_data_clumps(&functions);
299        assert_eq!(
300            smells.len(),
301            0,
302            "Single function cannot have data clumps with itself"
303        );
304    }
305
306    #[test]
307    fn test_detect_data_clumps_different_files() {
308        let functions = vec![
309            FunctionMetrics {
310                name: "function_a".to_string(),
311                file: PathBuf::from("src/module_a.rs"),
312                line: 10,
313                cyclomatic: 5,
314                cognitive: 10,
315                nesting: 2,
316                length: 35,
317                is_test: false,
318                visibility: None,
319                is_trait_method: false,
320                in_test_module: false,
321                entropy_score: None,
322                is_pure: None,
323                purity_confidence: None,
324            },
325            FunctionMetrics {
326                name: "function_b".to_string(),
327                file: PathBuf::from("src/module_b.rs"),
328                line: 20,
329                cyclomatic: 5,
330                cognitive: 10,
331                nesting: 2,
332                length: 35,
333                is_test: false,
334                visibility: None,
335                is_trait_method: false,
336                in_test_module: false,
337                entropy_score: None,
338                is_pure: None,
339                purity_confidence: None,
340            },
341        ];
342        let smells = detect_data_clumps(&functions);
343        assert_eq!(
344            smells.len(),
345            0,
346            "Functions in different files should not be reported as data clumps"
347        );
348    }
349
350    #[test]
351    fn test_detect_data_clumps_same_file_large_functions() {
352        let functions = vec![
353            FunctionMetrics {
354                name: "process_user_data".to_string(),
355                file: PathBuf::from("src/user_handler.rs"),
356                line: 10,
357                cyclomatic: 8,
358                cognitive: 15,
359                nesting: 3,
360                length: 40,
361                is_test: false,
362                visibility: None,
363                is_trait_method: false,
364                in_test_module: false,
365                entropy_score: None,
366                is_pure: None,
367                purity_confidence: None,
368            },
369            FunctionMetrics {
370                name: "validate_user_data".to_string(),
371                file: PathBuf::from("src/user_handler.rs"),
372                line: 60,
373                cyclomatic: 6,
374                cognitive: 12,
375                nesting: 2,
376                length: 35,
377                is_test: false,
378                visibility: None,
379                is_trait_method: false,
380                in_test_module: false,
381                entropy_score: None,
382                is_pure: None,
383                purity_confidence: None,
384            },
385        ];
386        let smells = detect_data_clumps(&functions);
387        assert_eq!(
388            smells.len(),
389            1,
390            "Should detect data clump for large functions in same file"
391        );
392
393        let smell = &smells[0];
394        assert_eq!(smell.smell_type, SmellType::DataClump);
395        assert_eq!(smell.location, PathBuf::from("src/user_handler.rs"));
396        assert_eq!(smell.line, 10);
397        assert!(smell.message.contains("process_user_data"));
398        assert!(smell.message.contains("validate_user_data"));
399        assert_eq!(smell.severity, Priority::Low);
400    }
401
402    #[test]
403    fn test_detect_data_clumps_multiple_clumps() {
404        let functions = vec![
405            FunctionMetrics {
406                name: "func_a".to_string(),
407                file: PathBuf::from("src/module.rs"),
408                line: 10,
409                cyclomatic: 5,
410                cognitive: 10,
411                nesting: 2,
412                length: 35,
413                is_test: false,
414                visibility: None,
415                is_trait_method: false,
416                in_test_module: false,
417                entropy_score: None,
418                is_pure: None,
419                purity_confidence: None,
420            },
421            FunctionMetrics {
422                name: "func_b".to_string(),
423                file: PathBuf::from("src/module.rs"),
424                line: 50,
425                cyclomatic: 5,
426                cognitive: 10,
427                nesting: 2,
428                length: 32,
429                is_test: false,
430                visibility: None,
431                is_trait_method: false,
432                in_test_module: false,
433                entropy_score: None,
434                is_pure: None,
435                purity_confidence: None,
436            },
437            FunctionMetrics {
438                name: "func_c".to_string(),
439                file: PathBuf::from("src/module.rs"),
440                line: 90,
441                cyclomatic: 5,
442                cognitive: 10,
443                nesting: 2,
444                length: 31,
445                is_test: false,
446                visibility: None,
447                is_trait_method: false,
448                in_test_module: false,
449                entropy_score: None,
450                is_pure: None,
451                purity_confidence: None,
452            },
453            FunctionMetrics {
454                name: "small_func".to_string(),
455                file: PathBuf::from("src/module.rs"),
456                line: 130,
457                cyclomatic: 2,
458                cognitive: 3,
459                nesting: 1,
460                length: 10,
461                is_test: false,
462                visibility: None,
463                is_trait_method: false,
464                in_test_module: false,
465                entropy_score: None,
466                is_pure: None,
467                purity_confidence: None,
468            },
469        ];
470        let smells = detect_data_clumps(&functions);
471
472        // Should detect clumps between func_a & func_b, func_a & func_c
473        // But due to break after first match per function, we get 2 smells (one for func_a, one for func_b)
474        assert_eq!(smells.len(), 2, "Should detect multiple data clumps");
475
476        // First smell should be between func_a and func_b
477        assert_eq!(smells[0].line, 10);
478        assert!(smells[0].message.contains("func_a"));
479        assert!(smells[0].message.contains("func_b"));
480
481        // Second smell should be between func_b and func_c
482        assert_eq!(smells[1].line, 50);
483        assert!(smells[1].message.contains("func_b"));
484        assert!(smells[1].message.contains("func_c"));
485    }
486
487    #[test]
488    fn test_detect_long_parameter_list() {
489        let func = FunctionMetrics {
490            name: "test_func".to_string(),
491            file: PathBuf::from("src/test.rs"),
492            line: 10,
493            cyclomatic: 5,
494            cognitive: 10,
495            nesting: 2,
496            length: 20,
497            is_test: false,
498            visibility: None,
499            is_trait_method: false,
500            in_test_module: false,
501            entropy_score: None,
502            is_pure: None,
503            purity_confidence: None,
504        };
505
506        // Test with parameter count below threshold
507        let smell = detect_long_parameter_list(&func, 3);
508        assert!(smell.is_none(), "Should not detect smell for 3 parameters");
509
510        // Test with parameter count at threshold
511        let smell = detect_long_parameter_list(&func, 5);
512        assert!(smell.is_none(), "Should not detect smell at threshold");
513
514        // Test with parameter count above threshold
515        let smell = detect_long_parameter_list(&func, 6);
516        assert!(smell.is_some(), "Should detect smell for 6 parameters");
517        let smell = smell.unwrap();
518        assert_eq!(smell.smell_type, SmellType::LongParameterList);
519        assert_eq!(smell.severity, Priority::Medium);
520        assert!(smell.message.contains("6 parameters"));
521
522        // Test with parameter count way above threshold (high severity)
523        let smell = detect_long_parameter_list(&func, 12);
524        assert!(smell.is_some(), "Should detect smell for 12 parameters");
525        let smell = smell.unwrap();
526        assert_eq!(smell.severity, Priority::High);
527    }
528
529    #[test]
530    fn test_detect_large_module() {
531        let path = PathBuf::from("src/large_module.rs");
532
533        // Test with line count below threshold
534        let smell = detect_large_module(&path, 250);
535        assert!(smell.is_none(), "Should not detect smell for 250 lines");
536
537        // Test with line count at threshold
538        let smell = detect_large_module(&path, 300);
539        assert!(smell.is_none(), "Should not detect smell at threshold");
540
541        // Test with line count above threshold
542        let smell = detect_large_module(&path, 350);
543        assert!(smell.is_some(), "Should detect smell for 350 lines");
544        let smell = smell.unwrap();
545        assert_eq!(smell.smell_type, SmellType::LargeClass);
546        assert_eq!(smell.severity, Priority::Medium);
547        assert!(smell.message.contains("350 lines"));
548
549        // Test with line count way above threshold (high severity)
550        let smell = detect_large_module(&path, 700);
551        assert!(smell.is_some(), "Should detect smell for 700 lines");
552        let smell = smell.unwrap();
553        assert_eq!(smell.severity, Priority::High);
554    }
555
556    #[test]
557    fn test_detect_long_method() {
558        let func = FunctionMetrics {
559            name: "long_func".to_string(),
560            file: PathBuf::from("src/test.rs"),
561            line: 10,
562            cyclomatic: 5,
563            cognitive: 10,
564            nesting: 2,
565            length: 40,
566            is_test: false,
567            visibility: None,
568            is_trait_method: false,
569            in_test_module: false,
570            entropy_score: None,
571            is_pure: None,
572            purity_confidence: None,
573        };
574
575        // Test with length below threshold
576        let smell = detect_long_method(&func);
577        assert!(smell.is_none(), "Should not detect smell for 40 lines");
578
579        // Test with length above threshold
580        let mut long_func = func.clone();
581        long_func.length = 60;
582        let smell = detect_long_method(&long_func);
583        assert!(smell.is_some(), "Should detect smell for 60 lines");
584        let smell = smell.unwrap();
585        assert_eq!(smell.smell_type, SmellType::LongMethod);
586        assert_eq!(smell.severity, Priority::Medium);
587        assert!(smell.message.contains("60 lines"));
588
589        // Test with length way above threshold (high severity)
590        long_func.length = 120;
591        let smell = detect_long_method(&long_func);
592        assert!(smell.is_some(), "Should detect smell for 120 lines");
593        let smell = smell.unwrap();
594        assert_eq!(smell.severity, Priority::High);
595    }
596
597    #[test]
598    fn test_detect_deep_nesting() {
599        let func = FunctionMetrics {
600            name: "nested_func".to_string(),
601            file: PathBuf::from("src/test.rs"),
602            line: 10,
603            cyclomatic: 5,
604            cognitive: 10,
605            nesting: 3,
606            length: 30,
607            is_test: false,
608            visibility: None,
609            is_trait_method: false,
610            in_test_module: false,
611            entropy_score: None,
612            is_pure: None,
613            purity_confidence: None,
614        };
615
616        // Test with nesting below threshold
617        let smell = detect_deep_nesting(&func);
618        assert!(
619            smell.is_none(),
620            "Should not detect smell for nesting depth 3"
621        );
622
623        // Test with nesting at threshold
624        let mut nested_func = func.clone();
625        nested_func.nesting = 4;
626        let smell = detect_deep_nesting(&nested_func);
627        assert!(smell.is_none(), "Should not detect smell at threshold");
628
629        // Test with nesting above threshold
630        nested_func.nesting = 5;
631        let smell = detect_deep_nesting(&nested_func);
632        assert!(smell.is_some(), "Should detect smell for nesting depth 5");
633        let smell = smell.unwrap();
634        assert_eq!(smell.smell_type, SmellType::DeepNesting);
635        assert_eq!(smell.severity, Priority::Medium);
636        assert!(smell.message.contains("nesting depth of 5"));
637
638        // Test with nesting way above threshold (high severity)
639        nested_func.nesting = 10;
640        let smell = detect_deep_nesting(&nested_func);
641        assert!(smell.is_some(), "Should detect smell for nesting depth 10");
642        let smell = smell.unwrap();
643        assert_eq!(smell.severity, Priority::High);
644    }
645
646    #[test]
647    fn test_analyze_function_smells() {
648        let func = FunctionMetrics {
649            name: "complex_func".to_string(),
650            file: PathBuf::from("src/test.rs"),
651            line: 10,
652            cyclomatic: 5,
653            cognitive: 10,
654            nesting: 5,
655            length: 60,
656            is_test: false,
657            visibility: None,
658            is_trait_method: false,
659            in_test_module: false,
660            entropy_score: None,
661            is_pure: None,
662            purity_confidence: None,
663        };
664
665        // Test function with multiple smells
666        let smells = analyze_function_smells(&func, 7);
667        assert_eq!(smells.len(), 3, "Should detect 3 smells");
668
669        // Verify each smell type is detected
670        let smell_types: Vec<SmellType> = smells.iter().map(|s| s.smell_type.clone()).collect();
671        assert!(smell_types.contains(&SmellType::LongParameterList));
672        assert!(smell_types.contains(&SmellType::LongMethod));
673        assert!(smell_types.contains(&SmellType::DeepNesting));
674
675        // Test function with no smells
676        let clean_func = FunctionMetrics {
677            name: "clean_func".to_string(),
678            file: PathBuf::from("src/test.rs"),
679            line: 10,
680            cyclomatic: 3,
681            cognitive: 5,
682            nesting: 2,
683            length: 20,
684            is_test: false,
685            visibility: None,
686            is_trait_method: false,
687            in_test_module: false,
688            entropy_score: None,
689            is_pure: None,
690            purity_confidence: None,
691        };
692
693        let smells = analyze_function_smells(&clean_func, 3);
694        assert_eq!(smells.len(), 0, "Clean function should have no smells");
695    }
696
697    #[test]
698    fn test_analyze_module_smells() {
699        let path = PathBuf::from("src/module.rs");
700
701        // Test small module with no smells
702        let smells = analyze_module_smells(&path, 200);
703        assert_eq!(smells.len(), 0, "Small module should have no smells");
704
705        // Test large module
706        let smells = analyze_module_smells(&path, 400);
707        assert_eq!(smells.len(), 1, "Large module should have 1 smell");
708        assert_eq!(smells[0].smell_type, SmellType::LargeClass);
709
710        // Test edge case with exactly threshold
711        let smells = analyze_module_smells(&path, 300);
712        assert_eq!(smells.len(), 0, "Module at threshold should have no smells");
713    }
714
715    #[test]
716    fn test_detect_feature_envy() {
717        let path = PathBuf::from("src/test.rs");
718
719        // Test with no feature envy
720        let content = r#"
721            fn process_data(&self) {
722                self.validate();
723                self.transform();
724                self.save();
725            }
726        "#;
727        let smells = detect_feature_envy(content, &path);
728        assert_eq!(smells.len(), 0, "Should not detect feature envy");
729
730        // Test with feature envy pattern
731        let content = r#"
732            fn process_order(&self, customer: &Customer) {
733                customer.validate_address();
734                customer.check_credit();
735                customer.update_status();
736                customer.send_notification();
737                customer.log_activity();
738                self.save();
739            }
740        "#;
741        let smells = detect_feature_envy(content, &path);
742        assert!(!smells.is_empty(), "Should detect feature envy");
743        assert_eq!(smells[0].smell_type, SmellType::FeatureEnvy);
744        assert!(smells[0].message.contains("customer"));
745
746        // Test with multiple objects
747        let content = r#"
748            fn coordinate(&self, order: &Order, payment: &Payment) {
749                order.validate();
750                order.calculate_total();
751                order.apply_discount();
752                payment.process();
753                payment.verify();
754                payment.record();
755            }
756        "#;
757        let smells = detect_feature_envy(content, &path);
758        assert_eq!(
759            smells.len(),
760            2,
761            "Should detect feature envy for both objects"
762        );
763    }
764
765    #[test]
766    fn test_code_smell_to_debt_item() {
767        let smell = CodeSmell {
768            smell_type: SmellType::LongMethod,
769            location: PathBuf::from("src/test.rs"),
770            line: 42,
771            message: "Test message".to_string(),
772            severity: Priority::High,
773        };
774
775        let debt_item = smell.to_debt_item();
776        assert_eq!(debt_item.debt_type, DebtType::CodeSmell);
777        assert_eq!(debt_item.file, PathBuf::from("src/test.rs"));
778        assert_eq!(debt_item.line, 42);
779        assert_eq!(debt_item.message, "Test message");
780        assert_eq!(debt_item.priority, Priority::High);
781    }
782}