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
23fn 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
35fn 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
55fn 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
66fn 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
84fn load_auto_config(path: &Path) -> Result<Config, i32> {
87 Config::load(path).map_err(|e| {
88 eprintln!("Error: {e}");
89 2
90 })
91}
92
93fn 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
102fn 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
112fn validate_config_weights(config: &Config) -> Result<(), i32> {
115 config::validate_weights(config).map_err(|e| {
116 eprintln!("Error: {e}");
117 2
118 })
119}
120
121fn 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
144fn 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
164fn 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
182fn 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
196fn 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
210fn 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
220fn 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
228fn 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
237fn 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
246fn 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
257pub fn run() -> Result<(), i32> {
259 let mut args: Vec<String> = std::env::args().collect();
260 if args.len() > 1 && args[1] == "qual" {
262 args.remove(1);
263 }
264 let mut cli = Cli::parse_from(args);
265 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 #[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 #[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 #[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(); 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 #[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 #[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}