Skip to main content

rustqual/
lib.rs

1mod analyzer;
2mod cli;
3mod config;
4mod coupling;
5mod dry;
6mod findings;
7mod normalize;
8mod pipeline;
9mod report;
10mod scope;
11mod srp;
12mod structural;
13mod tq;
14mod watch;
15
16use std::path::Path;
17
18use clap::{CommandFactory, Parser};
19
20use cli::{Cli, OutputFormat};
21use config::Config;
22
23/// Determine output format from CLI flags.
24/// Operation: conditional logic.
25fn determine_output_format(cli: &Cli) -> OutputFormat {
26    if let Some(ref fmt) = cli.format {
27        fmt.clone()
28    } else if cli.json {
29        OutputFormat::Json
30    } else {
31        OutputFormat::Text
32    }
33}
34
35/// Handle the --init command: write a rustqual.toml config file.
36/// Operation: logic to check file existence and write.
37fn handle_init(content: &str) -> Result<(), i32> {
38    let path = Path::new("rustqual.toml");
39    if path.exists() {
40        eprintln!("Error: rustqual.toml already exists in the current directory.");
41        return Err(1);
42    }
43    match std::fs::write(path, content) {
44        Ok(()) => {
45            eprintln!("Created rustqual.toml with tailored configuration.");
46            Ok(())
47        }
48        Err(e) => {
49            eprintln!("Error writing rustqual.toml: {e}");
50            Err(1)
51        }
52    }
53}
54
55/// Handle the --completions command: generate shell completions.
56/// Integration: orchestrates clap_complete::generate with Cli::command.
57fn handle_completions(shell: clap_complete::Shell) {
58    clap_complete::generate(
59        shell,
60        &mut Cli::command(),
61        "rustqual",
62        &mut std::io::stdout(),
63    );
64}
65
66/// Load config from an explicit config file path.
67/// Operation: error handling logic.
68fn load_explicit_config(config_path: &Path) -> Result<Config, i32> {
69    match std::fs::read_to_string(config_path) {
70        Ok(content) => match toml::from_str(&content) {
71            Ok(c) => Ok(c),
72            Err(e) => {
73                eprintln!("Error parsing config: {e}");
74                Err(2)
75            }
76        },
77        Err(e) => {
78            eprintln!("Error reading config: {e}");
79            Err(2)
80        }
81    }
82}
83
84/// Load config via auto-discovery from the project path.
85/// Operation: error mapping logic.
86fn load_auto_config(path: &Path) -> Result<Config, i32> {
87    Config::load(path).map_err(|e| {
88        eprintln!("Error: {e}");
89        2
90    })
91}
92
93/// Load configuration from CLI args or auto-discovery.
94/// Integration: delegates to load_explicit_config or load_auto_config.
95fn load_config(cli: &Cli) -> Result<Config, i32> {
96    cli.config
97        .as_ref()
98        .map(|p| load_explicit_config(p))
99        .unwrap_or_else(|| load_auto_config(&cli.path))
100}
101
102/// Load, compile, and apply CLI overrides to config.
103/// Integration: orchestrates load_config, compile, apply_cli_overrides, validate_weights.
104fn setup_config(cli: &Cli) -> Result<Config, i32> {
105    let mut config = load_config(cli)?;
106    config.compile();
107    apply_cli_overrides(&mut config, cli);
108    validate_config_weights(&config)?;
109    Ok(config)
110}
111
112/// Validate config settings that require cross-field checks.
113/// Operation: error mapping logic.
114fn validate_config_weights(config: &Config) -> Result<(), i32> {
115    config::validate_weights(config).map_err(|e| {
116        eprintln!("Error: {e}");
117        2
118    })
119}
120
121/// Apply CLI flag overrides to config.
122/// Operation: conditional logic on CLI flags.
123fn apply_cli_overrides(config: &mut Config, cli: &Cli) {
124    if cli.strict_closures {
125        config.strict_closures = true;
126    }
127    if cli.strict_iterators {
128        config.strict_iterator_chains = true;
129    }
130    if cli.allow_recursion {
131        config.allow_recursion = true;
132    }
133    if cli.strict_error_propagation {
134        config.strict_error_propagation = true;
135    }
136    if cli.fail_on_warnings {
137        config.fail_on_warnings = true;
138    }
139    if let Some(ref coverage) = cli.coverage {
140        config.test.coverage_file = Some(coverage.display().to_string());
141    }
142}
143
144/// Handle --save-baseline: write results to a JSON file.
145/// Operation: serialization + file write logic.
146fn handle_save_baseline(
147    path: &Path,
148    all_results: &[analyzer::FunctionAnalysis],
149    summary: &report::Summary,
150) -> Result<(), i32> {
151    let baseline = report::create_baseline(all_results, summary);
152    match std::fs::write(path, baseline) {
153        Ok(()) => {
154            eprintln!("Baseline saved to {}", path.display());
155            Ok(())
156        }
157        Err(e) => {
158            eprintln!("Error saving baseline: {e}");
159            Err(1)
160        }
161    }
162}
163
164/// Handle --compare: compare current results against baseline.
165/// Operation: file read + comparison logic.
166fn handle_compare(
167    path: &Path,
168    all_results: &[analyzer::FunctionAnalysis],
169    summary: &report::Summary,
170) -> Result<bool, i32> {
171    let baseline_content = std::fs::read_to_string(path).map_err(|e| {
172        eprintln!("Error reading baseline: {e}");
173        1
174    })?;
175    Ok(report::print_comparison(
176        &baseline_content,
177        all_results,
178        summary,
179    ))
180}
181
182/// Check --min-quality-score gate.
183/// Operation: conditional check.
184fn check_min_quality_score(min_score: f64, summary: &report::Summary) -> Result<(), i32> {
185    let actual = summary.quality_score * analyzer::PERCENTAGE_MULTIPLIER;
186    if actual < min_score {
187        eprintln!(
188            "Quality score {:.1}% is below minimum {:.1}%",
189            actual, min_score,
190        );
191        return Err(1);
192    }
193    Ok(())
194}
195
196/// Print a stderr warning if the suppression ratio exceeds the configured maximum.
197/// Operation: conditional formatting logic.
198fn warn_suppression_ratio(summary: &report::Summary, max_ratio: f64) {
199    if !summary.suppression_ratio_exceeded || summary.total == 0 {
200        return;
201    }
202    eprintln!(
203        "Warning: {} suppression(s) found ({:.1}% of functions, max: {:.1}%)",
204        summary.all_suppressions,
205        summary.all_suppressions as f64 / summary.total as f64 * analyzer::PERCENTAGE_MULTIPLIER,
206        max_ratio * analyzer::PERCENTAGE_MULTIPLIER,
207    );
208}
209
210/// Check --fail-on-warnings gate.
211/// Operation: conditional check.
212fn check_fail_on_warnings(config: &Config, summary: &report::Summary) -> Result<(), i32> {
213    if config.fail_on_warnings && summary.suppression_ratio_exceeded {
214        eprintln!("Error: warnings present and --fail-on-warnings is set");
215        return Err(1);
216    }
217    Ok(())
218}
219
220/// Apply quality gate checks from CLI flags.
221/// Integration: dispatches to check_min_quality_score.
222fn check_quality_gates(cli: &Cli, summary: &report::Summary) -> Result<(), i32> {
223    cli.min_quality_score
224        .iter()
225        .try_for_each(|&s| check_min_quality_score(s, summary))
226}
227
228/// Check default-fail gate: exit 1 on findings unless --no-fail.
229/// Operation: conditional check.
230fn check_default_fail(no_fail: bool, total_findings: usize) -> Result<(), i32> {
231    if !no_fail && total_findings > 0 {
232        return Err(1);
233    }
234    Ok(())
235}
236
237/// Apply all exit gates: warnings, fail-on-warnings, quality gates, default-fail.
238/// Integration: dispatches to warning + gate check functions.
239fn apply_exit_gates(cli: &Cli, config: &Config, summary: &report::Summary) -> Result<(), i32> {
240    warn_suppression_ratio(summary, config.max_suppression_ratio);
241    check_fail_on_warnings(config, summary)?;
242    check_quality_gates(cli, summary)?;
243    check_default_fail(cli.no_fail, summary.total_findings())
244}
245
246/// Sort results so violations come first, ordered by effort score (highest first).
247/// Operation: sorting logic.
248fn sort_by_effort(results: &mut [analyzer::FunctionAnalysis]) {
249    results.sort_by(|a, b| {
250        b.effort_score
251            .unwrap_or(0.0)
252            .partial_cmp(&a.effort_score.unwrap_or(0.0))
253            .unwrap_or(std::cmp::Ordering::Equal)
254    });
255}
256
257/// Entry point: parse CLI, load config, run analysis, check gates.
258pub fn run() -> Result<(), i32> {
259    let mut args: Vec<String> = std::env::args().collect();
260    // Support `cargo qual` invocation: cargo passes "qual" as first arg
261    if args.len() > 1 && args[1] == "qual" {
262        args.remove(1);
263    }
264    let mut cli = Cli::parse_from(args);
265    // Normalize Windows backslash paths to forward slashes
266    let normalized = cli.path.to_string_lossy().replace('\\', "/");
267    cli.path = std::path::PathBuf::from(normalized);
268
269    if cli.init {
270        let files = pipeline::collect_rust_files(&cli.path);
271        let content = if files.is_empty() {
272            config::generate_default_config().to_string()
273        } else {
274            let parsed = pipeline::read_and_parse_files(&files, &cli.path);
275            let default_config = Config::default();
276            let scope_refs: Vec<(&str, &syn::File)> =
277                parsed.iter().map(|(p, _, f)| (p.as_str(), f)).collect();
278            let scope = scope::ProjectScope::from_files(&scope_refs);
279            let analyzer_obj = analyzer::Analyzer::new(&default_config, &scope);
280            let all_results: Vec<_> = parsed
281                .iter()
282                .flat_map(|(path, _, syntax)| analyzer_obj.analyze_file(syntax, path))
283                .collect();
284            let metrics = config::init::extract_init_metrics(files.len(), &all_results);
285            config::generate_tailored_config(&metrics)
286        };
287        return handle_init(&content);
288    }
289    if let Some(shell) = cli.completions {
290        handle_completions(shell);
291        return Ok(());
292    }
293
294    let output_format = determine_output_format(&cli);
295    let config = setup_config(&cli)?;
296
297    if cli.watch {
298        return watch::run_watch_mode(&cli, &config, &output_format);
299    }
300
301    let files = pipeline::collect_filtered_files(&cli.path, &config);
302    let files = if let Some(ref git_ref) = cli.diff {
303        match pipeline::get_git_changed_files(&cli.path, git_ref) {
304            Ok(changed) => {
305                let filtered = pipeline::filter_to_changed(files, &changed);
306                eprintln!(
307                    "[diff mode: {} changed file(s) vs {git_ref}]",
308                    filtered.len()
309                );
310                filtered
311            }
312            Err(e) => {
313                eprintln!("Warning: {e}. Analyzing all files.");
314                files
315            }
316        }
317    } else {
318        files
319    };
320    if files.is_empty() {
321        eprintln!("No Rust source files found in {}", cli.path.display());
322        return Ok(());
323    }
324
325    let parsed = pipeline::read_and_parse_files(&files, &cli.path);
326    let mut analysis = pipeline::run_analysis(&parsed, &config);
327    if cli.sort_by_effort {
328        sort_by_effort(&mut analysis.results);
329    }
330    if cli.findings {
331        let entries = crate::report::findings_list::collect_all_findings(&analysis);
332        if entries.is_empty() {
333            println!("No findings.");
334        } else {
335            crate::report::findings_list::print_findings(&entries);
336        }
337    } else {
338        pipeline::output_results(
339            &analysis,
340            &output_format,
341            cli.verbose,
342            cli.suggestions,
343            &config,
344        );
345    }
346
347    cli.save_baseline
348        .as_ref()
349        .map(|p| handle_save_baseline(p, &analysis.results, &analysis.summary))
350        .transpose()?;
351    if let Some(ref compare_path) = cli.compare {
352        let regressed = handle_compare(compare_path, &analysis.results, &analysis.summary)?;
353        if cli.fail_on_regression && regressed {
354            return Err(1);
355        }
356    }
357
358    apply_exit_gates(&cli, &config, &analysis.summary)
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    // ── OutputFormat tests ──────────────────────────────────────
366
367    #[test]
368    fn test_output_format_text() {
369        assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
370    }
371
372    #[test]
373    fn test_output_format_json() {
374        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
375    }
376
377    #[test]
378    fn test_output_format_github() {
379        assert_eq!(
380            "github".parse::<OutputFormat>().unwrap(),
381            OutputFormat::Github
382        );
383    }
384
385    #[test]
386    fn test_output_format_dot() {
387        assert_eq!("dot".parse::<OutputFormat>().unwrap(), OutputFormat::Dot);
388    }
389
390    #[test]
391    fn test_output_format_sarif() {
392        assert_eq!(
393            "sarif".parse::<OutputFormat>().unwrap(),
394            OutputFormat::Sarif
395        );
396    }
397
398    #[test]
399    fn test_output_format_html() {
400        assert_eq!("html".parse::<OutputFormat>().unwrap(), OutputFormat::Html);
401    }
402
403    #[test]
404    fn test_output_format_ai() {
405        assert_eq!("ai".parse::<OutputFormat>().unwrap(), OutputFormat::Ai);
406    }
407
408    #[test]
409    fn test_output_format_ai_json() {
410        assert_eq!(
411            "ai-json".parse::<OutputFormat>().unwrap(),
412            OutputFormat::AiJson
413        );
414    }
415
416    #[test]
417    fn test_output_format_case_insensitive() {
418        assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
419        assert_eq!("Text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
420        assert_eq!(
421            "GITHUB".parse::<OutputFormat>().unwrap(),
422            OutputFormat::Github
423        );
424    }
425
426    #[test]
427    fn test_output_format_invalid() {
428        assert!("xml".parse::<OutputFormat>().is_err());
429        assert!("csv".parse::<OutputFormat>().is_err());
430    }
431
432    #[test]
433    fn test_output_format_default() {
434        assert_eq!(OutputFormat::default(), OutputFormat::Text);
435    }
436
437    // ── CLI override tests ──────────────────────────────────────
438
439    #[test]
440    fn test_apply_cli_overrides_strict_closures() {
441        let mut config = Config::default();
442        let cli = Cli::parse_from(["test", "--strict-closures"]);
443        apply_cli_overrides(&mut config, &cli);
444        assert!(config.strict_closures);
445    }
446
447    #[test]
448    fn test_apply_cli_overrides_allow_recursion() {
449        let mut config = Config::default();
450        let cli = Cli::parse_from(["test", "--allow-recursion"]);
451        apply_cli_overrides(&mut config, &cli);
452        assert!(config.allow_recursion);
453    }
454
455    #[test]
456    fn test_apply_cli_overrides_strict_error_propagation() {
457        let mut config = Config::default();
458        let cli = Cli::parse_from(["test", "--strict-error-propagation"]);
459        apply_cli_overrides(&mut config, &cli);
460        assert!(config.strict_error_propagation);
461    }
462
463    #[test]
464    fn test_apply_cli_overrides_strict_iterators() {
465        let mut config = Config::default();
466        let cli = Cli::parse_from(["test", "--strict-iterators"]);
467        apply_cli_overrides(&mut config, &cli);
468        assert!(config.strict_iterator_chains);
469    }
470
471    #[test]
472    fn test_apply_cli_overrides_no_flags() {
473        let mut config = Config::default();
474        let cli = Cli::parse_from(["test"]);
475        apply_cli_overrides(&mut config, &cli);
476        assert!(!config.strict_closures);
477        assert!(!config.strict_iterator_chains);
478        assert!(!config.allow_recursion);
479        assert!(!config.strict_error_propagation);
480    }
481
482    #[test]
483    fn test_fail_on_warnings_cli_parse() {
484        let cli = Cli::parse_from(["test", "--fail-on-warnings"]);
485        assert!(cli.fail_on_warnings);
486    }
487
488    #[test]
489    fn test_fail_on_warnings_default_false() {
490        let cli = Cli::parse_from(["test"]);
491        assert!(!cli.fail_on_warnings);
492    }
493
494    #[test]
495    fn test_apply_cli_overrides_fail_on_warnings() {
496        let mut config = Config::default();
497        let cli = Cli::parse_from(["test", "--fail-on-warnings"]);
498        apply_cli_overrides(&mut config, &cli);
499        assert!(config.fail_on_warnings);
500    }
501
502    #[test]
503    fn test_fail_on_warnings_config_default() {
504        let config = Config::default();
505        assert!(!config.fail_on_warnings);
506    }
507
508    // ── Gate function tests (Result-based) ─────────────────────
509
510    #[test]
511    fn test_check_fail_on_warnings_passes_when_no_warnings() {
512        let mut config = Config::default();
513        config.fail_on_warnings = true;
514        let summary = crate::report::Summary {
515            total: 10,
516            ..Default::default()
517        };
518        assert!(check_fail_on_warnings(&config, &summary).is_ok());
519    }
520
521    #[test]
522    fn test_check_fail_on_warnings_passes_when_disabled() {
523        let config = Config::default(); // fail_on_warnings = false
524        let summary = crate::report::Summary {
525            total: 10,
526            suppression_ratio_exceeded: true,
527            ..Default::default()
528        };
529        assert!(check_fail_on_warnings(&config, &summary).is_ok());
530    }
531
532    #[test]
533    fn test_check_fail_on_warnings_exits_when_triggered() {
534        let mut config = Config::default();
535        config.fail_on_warnings = true;
536        let summary = crate::report::Summary {
537            total: 10,
538            suppression_ratio_exceeded: true,
539            ..Default::default()
540        };
541        assert_eq!(check_fail_on_warnings(&config, &summary), Err(1));
542    }
543
544    #[test]
545    fn test_min_quality_score_cli_parse() {
546        let cli = Cli::parse_from(["test", "--min-quality-score", "80.0"]);
547        assert!((cli.min_quality_score.unwrap() - 80.0).abs() < f64::EPSILON);
548    }
549
550    #[test]
551    fn test_check_quality_gates_passes() {
552        let cli = Cli::parse_from(["test", "--min-quality-score", "50.0"]);
553        let mut summary = crate::report::Summary {
554            total: 10,
555            iosp_score: 1.0,
556            ..Default::default()
557        };
558        summary.compute_quality_score(&crate::config::sections::DEFAULT_QUALITY_WEIGHTS);
559        assert!(check_quality_gates(&cli, &summary).is_ok());
560    }
561
562    #[test]
563    fn test_check_min_quality_score_below_threshold() {
564        let mut summary = crate::report::Summary {
565            total: 10,
566            ..Default::default()
567        };
568        summary.quality_score = 0.5;
569        assert_eq!(check_min_quality_score(90.0, &summary), Err(1));
570    }
571
572    #[test]
573    fn test_check_min_quality_score_above_threshold() {
574        let mut summary = crate::report::Summary {
575            total: 10,
576            ..Default::default()
577        };
578        summary.quality_score = 0.95;
579        assert!(check_min_quality_score(90.0, &summary).is_ok());
580    }
581
582    #[test]
583    fn test_check_quality_gates_below_threshold() {
584        let cli = Cli::parse_from(["test", "--min-quality-score", "90.0"]);
585        let summary = crate::report::Summary {
586            total: 10,
587            quality_score: 0.5,
588            ..Default::default()
589        };
590        assert_eq!(check_quality_gates(&cli, &summary), Err(1));
591    }
592
593    #[test]
594    fn test_check_quality_gates_no_gate_set() {
595        let cli = Cli::parse_from(["test"]);
596        let summary = crate::report::Summary {
597            total: 10,
598            ..Default::default()
599        };
600        assert!(check_quality_gates(&cli, &summary).is_ok());
601    }
602
603    #[test]
604    fn test_check_default_fail_with_findings() {
605        assert_eq!(check_default_fail(false, 5), Err(1));
606    }
607
608    #[test]
609    fn test_check_default_fail_no_fail_mode() {
610        assert!(check_default_fail(true, 5).is_ok());
611    }
612
613    #[test]
614    fn test_check_default_fail_no_findings() {
615        assert!(check_default_fail(false, 0).is_ok());
616    }
617
618    #[test]
619    fn test_determine_output_format_explicit() {
620        let cli = Cli::parse_from(["test", "--format", "json"]);
621        assert_eq!(determine_output_format(&cli), OutputFormat::Json);
622    }
623
624    #[test]
625    fn test_determine_output_format_json_flag() {
626        let cli = Cli::parse_from(["test", "--json"]);
627        assert_eq!(determine_output_format(&cli), OutputFormat::Json);
628    }
629
630    #[test]
631    fn test_determine_output_format_default_text() {
632        let cli = Cli::parse_from(["test"]);
633        assert_eq!(determine_output_format(&cli), OutputFormat::Text);
634    }
635
636    #[test]
637    fn test_determine_output_format_explicit_overrides_json_flag() {
638        let cli = Cli::parse_from(["test", "--json", "--format", "html"]);
639        assert_eq!(determine_output_format(&cli), OutputFormat::Html);
640    }
641
642    // ── Init metrics tests ────────────────────────────────────────
643
644    #[test]
645    fn test_extract_init_metrics_empty() {
646        let m = config::init::extract_init_metrics(0, &[]);
647        assert_eq!(m.file_count, 0);
648        assert_eq!(m.function_count, 0);
649        assert_eq!(m.max_cognitive, 0);
650    }
651
652    #[test]
653    fn test_extract_init_metrics_with_complexity() {
654        let fa = crate::analyzer::FunctionAnalysis {
655            name: "f".into(),
656            file: "test.rs".into(),
657            line: 1,
658            classification: crate::analyzer::Classification::Operation,
659            parent_type: None,
660            suppressed: false,
661            complexity: Some(crate::analyzer::ComplexityMetrics {
662                cognitive_complexity: 12,
663                cyclomatic_complexity: 8,
664                max_nesting: 3,
665                function_lines: 45,
666                ..Default::default()
667            }),
668            qualified_name: "f".into(),
669            severity: None,
670            cognitive_warning: false,
671            cyclomatic_warning: false,
672            nesting_depth_warning: false,
673            function_length_warning: false,
674            unsafe_warning: false,
675            error_handling_warning: false,
676            complexity_suppressed: false,
677            own_calls: vec![],
678            parameter_count: 0,
679            is_trait_impl: false,
680            is_test: false,
681            effort_score: None,
682        };
683        let results = vec![fa];
684        let m = config::init::extract_init_metrics(5, &results);
685        assert_eq!(m.file_count, 5);
686        assert_eq!(m.function_count, 1);
687        assert_eq!(m.max_cognitive, 12);
688        assert_eq!(m.max_cyclomatic, 8);
689        assert_eq!(m.max_nesting_depth, 3);
690        assert_eq!(m.max_function_lines, 45);
691    }
692
693    // ── Diff CLI flag tests ──────────────────────────────────────
694
695    #[test]
696    fn test_diff_cli_default_ref() {
697        let cli = Cli::parse_from(["test", "--diff"]);
698        assert_eq!(cli.diff.as_deref(), Some("HEAD"));
699    }
700
701    #[test]
702    fn test_diff_cli_custom_ref() {
703        let cli = Cli::parse_from(["test", "--diff", "main"]);
704        assert_eq!(cli.diff.as_deref(), Some("main"));
705    }
706
707    #[test]
708    fn test_diff_cli_not_set() {
709        let cli = Cli::parse_from(["test"]);
710        assert!(cli.diff.is_none());
711    }
712}