1use crate::config::Config;
42use crate::diagnostics::{Finding, Severity};
43use crate::manifest::CargoManifest;
44use once_cell::sync::Lazy;
45use rayon::prelude::*;
46use regex::Regex;
47use std::fs;
48use std::path::{Path, PathBuf};
49use walkdir::{DirEntry, WalkDir};
50
51static UNWRAP_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.unwrap\(\)").unwrap());
52static EXPECT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"\.expect\s*\("#).unwrap());
53static PRINTLN_DBG_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"(println!|dbg!)\s*\(").unwrap());
54static TODO_COMMENT_REGEX: Lazy<Regex> =
55 Lazy::new(|| Regex::new(r"//\s*(TODO|FIXME|XXX)").unwrap());
56static DENY_LINT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"#!\[deny\(([^)]+)\)\]").unwrap());
57
58fn is_rust_file(entry: &DirEntry) -> bool {
59 entry.file_type().is_file() && entry.path().extension().is_some_and(|ext| ext == "rs")
60}
61
62pub fn collect_rust_files(project_root: &Path) -> Vec<PathBuf> {
63 let mut rust_files = Vec::new();
64 let source_roots = [
65 project_root.join("src"),
66 project_root.join("tests"),
67 project_root.join("examples"),
68 project_root.join("benches"),
69 ];
70
71 for root in source_roots.iter().filter(|p| p.exists() && p.is_dir()) {
72 WalkDir::new(root)
73 .into_iter()
74 .filter_map(Result::ok) .filter(is_rust_file)
76 .for_each(|entry| rust_files.push(entry.path().to_path_buf()));
77 }
78 rust_files
79}
80
81fn is_library_file(file_path: &Path, project_root: &Path) -> bool {
82 let src_lib_path = project_root.join("src").join("lib.rs");
83
84 file_path.starts_with(project_root.join("src"))
85 && file_path != src_lib_path && !file_path.ends_with("main.rs") && file_path.components().all(|c| c.as_os_str() != "bin") && file_path != project_root.join("build.rs") }
90
91pub fn check_code_patterns(rust_files: &[PathBuf], project_root: &Path) -> Vec<Finding> {
92 let findings_from_all_files: Vec<Finding> = rust_files
93 .par_iter()
94 .flat_map(|file_path_ref| {
95 let file_path = &**file_path_ref;
96 let mut per_file_findings: Vec<Finding> = Vec::new();
97
98 let is_lib_context = is_library_file(file_path, project_root);
99
100
101 if file_path.ends_with("build.rs") {
103 }
106
107
108 let content = match fs::read_to_string(file_path) {
109 Ok(c) => c,
110 Err(e) => {
111 per_file_findings.push(Finding::new(
112 "IO001", format!("Failed to read file {file_path:?}: {e}"),
114 Severity::Warning,
115 Some(file_path.to_string_lossy().into_owned()),
116 ));
117 return per_file_findings;
118 }
119 };
120
121 for (line_num, line_content) in content.lines().enumerate() {
123 let line_number_for_finding = line_num + 1; if is_lib_context && UNWRAP_REGEX.is_match(line_content) && !file_path.ends_with("build.rs") {
127 per_file_findings.push(Finding::new(
128 "CODE001",
129 "'.unwrap()' used in library context. Consider using '?' or pattern matching.".to_string(),
130 Severity::Warning,
131 Some(file_path.to_string_lossy().into_owned()),
132 ).with_line(line_number_for_finding));
133 }
134
135 if is_lib_context && EXPECT_REGEX.is_match(line_content) && !file_path.ends_with("build.rs") {
137 per_file_findings.push(Finding::new(
138 "CODE002",
139 "'.expect()' used in library context. While better than unwrap, prefer '?' or specific error handling.".to_string(),
140 Severity::Note, Some(file_path.to_string_lossy().into_owned()),
142 ).with_line(line_number_for_finding));
143 }
144
145 if is_lib_context && PRINTLN_DBG_REGEX.is_match(line_content) && !file_path.ends_with("build.rs") {
147 per_file_findings.push(Finding::new(
151 "CODE003",
152 "Diagnostic macro (println! or dbg!) found in library context. Remove before release.".to_string(),
153 Severity::Note,
154 Some(file_path.to_string_lossy().into_owned()),
155 ).with_line(line_number_for_finding));
156 }
157
158 if TODO_COMMENT_REGEX.is_match(line_content) {
160 let comment_type = TODO_COMMENT_REGEX.captures(line_content).unwrap().get(1).unwrap().as_str();
161 per_file_findings.push(Finding::new(
162 "CODE004",
163 format!("Found '{comment_type}' comment. Address or create an issue for it."),
164 Severity::Note,
165 Some(file_path.to_string_lossy().into_owned()),
166 ).with_line(line_number_for_finding));
167 }
168 }
169
170 per_file_findings
171 }).collect();
172
173 findings_from_all_files
174}
175
176pub fn check_project_structure(
177 project_root: &Path,
178 manifest_data: Option<&CargoManifest>, ) -> Vec<Finding> {
181 let mut findings = Vec::new();
182
183 let has_lib_rs = project_root.join("src").join("lib.rs").is_file();
184 let has_main_rs = project_root.join("src").join("main.rs").is_file();
185 let has_bin_dir = project_root.join("src").join("bin").is_dir();
186
187 let is_likely_library = manifest_data.is_some_and(|m| {
190 m.package.as_ref().is_some_and(|p| {
193 let default_lib_name = p.name.replace('-', "_");
196 project_root
197 .join("src")
198 .join(format!("{default_lib_name}.rs"))
199 .exists()
200 || has_lib_rs
201 })
202 });
203
204 if let Some(_pkg) = manifest_data.and_then(|m| m.package.as_ref()) {
205 if !is_likely_library && !has_main_rs && !has_bin_dir {
207 findings.push(Finding::new(
208 "STRUCT001",
209 "Project has neither src/lib.rs, src/main.rs, nor src/bin/ directory. Is it a virtual workspace or missing source files?".to_string(),
210 Severity::Warning,
211 Some("Cargo.toml".to_string()), ));
213 }
214
215 let readme_path_md = project_root.join("README.md");
221 let readme_path_rst = project_root.join("README.rst"); if !readme_path_md.is_file() && !readme_path_rst.is_file() {
223 let readme_specified_and_false_or_exists = manifest_data
225 .and_then(|m| m.package.as_ref())
226 .and_then(|p| p.readme.as_ref())
227 .is_some_and(|r_val| {
228 if let Some(b) = r_val.as_bool() {
230 !b
232 } else if let Some(s) = r_val.as_str() {
233 project_root.join(s).is_file()
234 } else {
235 false }
237 });
238
239 if !readme_specified_and_false_or_exists {
240 findings.push(Finding::new(
241 "STRUCT002",
242 "Missing README.md file in project root. Consider adding one.".to_string(),
243 Severity::Note,
244 Some(readme_path_md.to_string_lossy().into_owned()),
245 ));
246 }
247 }
248
249 let license_files = [
252 "LICENSE",
253 "LICENSE.txt",
254 "LICENSE-MIT",
255 "LICENSE-APACHE",
256 "COPYING",
257 "UNLICENSE",
258 ];
259 let has_license_file = license_files.iter().any(|name| {
260 project_root.join(name).is_file()
261 || project_root.join(name.to_uppercase()).is_file()
262 || project_root.join(name.to_lowercase()).is_file()
263 });
264
265 if !has_license_file {
266 let license_file_specified = manifest_data
268 .and_then(|m| m.package.as_ref())
269 .is_some_and(|p| {
270 p.license.is_some() && p.license.as_deref() != Some("") });
275
276 if !license_file_specified
277 || manifest_data
278 .and_then(|m| m.package.as_ref())
279 .is_none_or(|p| p.license.is_none())
280 {
281 findings.push(Finding::new(
282 "STRUCT003",
283 "Missing LICENSE file in project root. Consider adding one (e.g., LICENSE-MIT or LICENSE-APACHE).".to_string(),
284 Severity::Warning, Some("Project Root".to_string()), ));
287 }
288 }
289 }
290
291 findings
292}
293
294pub fn check_missing_denied_lints(
297 project_root: &Path,
298 config: &Config, ) -> Vec<Finding> {
300 let mut findings = Vec::new();
301 let lib_rs_path = project_root.join("src/lib.rs");
303 let main_rs_path = project_root.join("src/main.rs");
304 let files_to_check = [&lib_rs_path, &main_rs_path];
305
306 let recommended_denials = vec!["warnings"]; for file_path in files_to_check.iter().filter(|p| p.exists()) {
310 if !config.is_check_enabled("LINT001") {
311 continue;
312 } if let Ok(content) = fs::read_to_string(file_path) {
315 let mut found_denials = std::collections::HashSet::new();
316 for cap in DENY_LINT_REGEX.captures_iter(&content) {
317 for lint in cap[1].split(',').map(|s| s.trim()) {
319 found_denials.insert(lint.to_string());
320 }
321 }
322
323 for rec_denial in &recommended_denials {
324 if !found_denials.contains(*rec_denial) {
325 findings.push(Finding::new(
326 "LINT001",
327 format!("Consider adding `#![deny({})]` to the top of {:?} for stricter linting.", rec_denial, file_path.file_name().unwrap_or_default()),
328 Severity::Note,
329 Some(file_path.to_string_lossy().into_owned())
330 ));
331 }
332 }
333 }
334 }
335 findings
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use crate::config::{ChecksConfig, Config};
342 use crate::diagnostics::Severity;
343 use crate::manifest::{CargoManifest, Package};
344 use std::collections::HashMap;
345 use std::fs;
346 use tempfile::TempDir;
347
348 fn create_test_dir() -> TempDir {
350 TempDir::new().expect("Failed to create temp dir")
351 }
352
353 fn create_test_config() -> Config {
355 let mut enabled_checks = HashMap::new();
356 enabled_checks.insert("LINT001".to_string(), true);
357
358 Config {
359 general: Default::default(),
360 checks: ChecksConfig {
361 enabled: enabled_checks,
362 },
363 }
364 }
365
366 fn create_test_manifest(name: &str) -> CargoManifest {
368 CargoManifest {
369 package: Some(Package {
370 name: name.to_string(),
371 version: "0.1.0".to_string(),
372 license: Some("MIT".to_string()),
373 readme: None,
374 description: Some("Test package".to_string()),
375 edition: Some("2021".to_string()),
376 repository: None,
377 }),
378 dependencies: None,
379 dev_dependencies: None,
380 build_dependencies: None,
381 }
382 }
383
384 #[test]
385 fn test_is_rust_file() {
386 let temp_dir = create_test_dir();
387 let rust_file = temp_dir.path().join("test.rs");
388 let non_rust_file = temp_dir.path().join("test.txt");
389
390 fs::write(&rust_file, "fn main() {}").unwrap();
391 fs::write(&non_rust_file, "hello").unwrap();
392
393 let rust_entry = WalkDir::new(&rust_file)
394 .into_iter()
395 .next()
396 .unwrap()
397 .unwrap();
398 let non_rust_entry = WalkDir::new(&non_rust_file)
399 .into_iter()
400 .next()
401 .unwrap()
402 .unwrap();
403
404 assert!(is_rust_file(&rust_entry));
405 assert!(!is_rust_file(&non_rust_entry));
406 }
407
408 #[test]
409 fn test_collect_rust_files() {
410 let temp_dir = create_test_dir();
411 let project_root = temp_dir.path();
412
413 let src_dir = project_root.join("src");
415 let tests_dir = project_root.join("tests");
416 let examples_dir = project_root.join("examples");
417
418 fs::create_dir_all(&src_dir).unwrap();
419 fs::create_dir_all(&tests_dir).unwrap();
420 fs::create_dir_all(&examples_dir).unwrap();
421
422 fs::write(src_dir.join("lib.rs"), "// lib content").unwrap();
424 fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
425 fs::write(tests_dir.join("integration_test.rs"), "// test content").unwrap();
426 fs::write(examples_dir.join("example.rs"), "fn main() {}").unwrap();
427
428 fs::write(src_dir.join("config.toml"), "[package]").unwrap();
430
431 let rust_files = collect_rust_files(project_root);
432
433 assert_eq!(rust_files.len(), 4);
434 assert!(rust_files.iter().any(|p| p.ends_with("lib.rs")));
435 assert!(rust_files.iter().any(|p| p.ends_with("main.rs")));
436 assert!(
437 rust_files
438 .iter()
439 .any(|p| p.ends_with("integration_test.rs"))
440 );
441 assert!(rust_files.iter().any(|p| p.ends_with("example.rs")));
442 }
443
444 #[test]
445 fn test_is_library_file() {
446 let temp_dir = create_test_dir();
447 let project_root = temp_dir.path();
448 let src_dir = project_root.join("src");
449 let bin_dir = src_dir.join("bin");
450
451 fs::create_dir_all(&src_dir).unwrap();
452 fs::create_dir_all(&bin_dir).unwrap();
453
454 let lib_rs = src_dir.join("lib.rs");
455 let main_rs = src_dir.join("main.rs");
456 let module_rs = src_dir.join("module.rs");
457 let bin_file = bin_dir.join("binary.rs");
458 let build_rs = project_root.join("build.rs");
459
460 assert!(!is_library_file(&lib_rs, project_root)); assert!(!is_library_file(&main_rs, project_root)); assert!(is_library_file(&module_rs, project_root)); assert!(!is_library_file(&bin_file, project_root)); assert!(!is_library_file(&build_rs, project_root)); }
467
468 #[test]
469 fn test_check_code_patterns_unwrap_detection() {
470 let temp_dir = create_test_dir();
471 let project_root = temp_dir.path();
472 let src_dir = project_root.join("src");
473
474 fs::create_dir_all(&src_dir).unwrap();
475
476 let test_file = src_dir.join("module.rs");
477 let test_content = r#"
478fn test_function() {
479 let result = some_operation().unwrap(); // This should be flagged
480 let other = another_op().expect("failed"); // This should be flagged too
481 println!("Debug output"); // This should be flagged in library context
482 // TODO: Fix this later // This should be flagged
483}
484"#;
485 fs::write(&test_file, test_content).unwrap();
486
487 let rust_files = vec![test_file];
488 let findings = check_code_patterns(&rust_files, project_root);
489
490 assert!(findings.iter().any(|f| f.code == "CODE001")); assert!(findings.iter().any(|f| f.code == "CODE002")); assert!(findings.iter().any(|f| f.code == "CODE003")); assert!(findings.iter().any(|f| f.code == "CODE004")); let unwrap_finding = findings.iter().find(|f| f.code == "CODE001").unwrap();
498 assert_eq!(unwrap_finding.line_number, Some(3));
499 }
500
501 #[test]
502 fn test_check_code_patterns_build_script_exclusion() {
503 let temp_dir = create_test_dir();
504 let project_root = temp_dir.path();
505
506 let build_rs = project_root.join("build.rs");
507 let build_content = r#"
508fn main() {
509 let result = some_operation().unwrap(); // This should NOT be flagged in build.rs
510 println!("Building..."); // This should NOT be flagged in build.rs
511}
512"#;
513 fs::write(&build_rs, build_content).unwrap();
514
515 let rust_files = vec![build_rs];
516 let findings = check_code_patterns(&rust_files, project_root);
517
518 assert!(!findings.iter().any(|f| f.code == "CODE001"));
520 assert!(!findings.iter().any(|f| f.code == "CODE002"));
521 assert!(!findings.iter().any(|f| f.code == "CODE003"));
522
523 }
526
527 #[test]
528 fn test_check_code_patterns_file_read_error() {
529 let temp_dir = create_test_dir();
530 let project_root = temp_dir.path();
531
532 let non_existent_file = project_root.join("nonexistent.rs");
534 let rust_files = vec![non_existent_file];
535
536 let findings = check_code_patterns(&rust_files, project_root);
537
538 assert_eq!(findings.len(), 1);
539 assert_eq!(findings[0].code, "IO001");
540 assert_eq!(findings[0].severity, Severity::Warning);
541 }
542
543 #[test]
544 fn test_check_project_structure_missing_source_files() {
545 let temp_dir = create_test_dir();
546 let project_root = temp_dir.path();
547
548 let manifest = create_test_manifest("test-project");
550
551 let findings = check_project_structure(project_root, Some(&manifest));
552
553 assert!(findings.iter().any(|f| f.code == "STRUCT001"));
554 }
555
556 #[test]
557 fn test_check_project_structure_missing_readme() {
558 let temp_dir = create_test_dir();
559 let project_root = temp_dir.path();
560 let src_dir = project_root.join("src");
561
562 fs::create_dir_all(&src_dir).unwrap();
563 fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
564
565 let manifest = create_test_manifest("test-project");
566
567 let findings = check_project_structure(project_root, Some(&manifest));
568
569 assert!(findings.iter().any(|f| f.code == "STRUCT002"));
570 }
571
572 #[test]
573 fn test_check_project_structure_missing_license() {
574 let temp_dir = create_test_dir();
575 let project_root = temp_dir.path();
576 let src_dir = project_root.join("src");
577
578 fs::create_dir_all(&src_dir).unwrap();
579 fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
580
581 let mut manifest = create_test_manifest("test-project");
582 manifest.package.as_mut().unwrap().license = None; let findings = check_project_structure(project_root, Some(&manifest));
585
586 assert!(findings.iter().any(|f| f.code == "STRUCT003"));
587 }
588
589 #[test]
590 fn test_check_project_structure_with_readme_and_license() {
591 let temp_dir = create_test_dir();
592 let project_root = temp_dir.path();
593 let src_dir = project_root.join("src");
594
595 fs::create_dir_all(&src_dir).unwrap();
596 fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
597 fs::write(project_root.join("README.md"), "# Test Project").unwrap();
598 fs::write(project_root.join("LICENSE"), "MIT License").unwrap();
599
600 let manifest = create_test_manifest("test-project");
601
602 let findings = check_project_structure(project_root, Some(&manifest));
603
604 assert!(!findings.iter().any(|f| f.code == "STRUCT002"));
606 assert!(!findings.iter().any(|f| f.code == "STRUCT003"));
607 }
608
609 #[test]
610 fn test_check_missing_denied_lints_missing_warnings() {
611 let temp_dir = create_test_dir();
612 let project_root = temp_dir.path();
613 let src_dir = project_root.join("src");
614
615 fs::create_dir_all(&src_dir).unwrap();
616
617 let lib_rs = src_dir.join("lib.rs");
618 let lib_content = r#"
619// No deny attributes here
620pub fn hello() {
621 println!("Hello, world!");
622}
623"#;
624 fs::write(&lib_rs, lib_content).unwrap();
625
626 let config = create_test_config();
627 let findings = check_missing_denied_lints(project_root, &config);
628
629 assert!(findings.iter().any(|f| f.code == "LINT001"));
630 }
631
632 #[test]
633 fn test_check_missing_denied_lints_has_warnings() {
634 let temp_dir = create_test_dir();
635 let project_root = temp_dir.path();
636 let src_dir = project_root.join("src");
637
638 fs::create_dir_all(&src_dir).unwrap();
639
640 let lib_rs = src_dir.join("lib.rs");
641 let lib_content = r#"
642#![deny(warnings)]
643
644pub fn hello() {
645 println!("Hello, world!");
646}
647"#;
648 fs::write(&lib_rs, lib_content).unwrap();
649
650 let config = create_test_config();
651 let findings = check_missing_denied_lints(project_root, &config);
652
653 assert!(!findings.iter().any(|f| f.code == "LINT001"));
655 }
656
657 #[test]
658 fn test_check_missing_denied_lints_disabled_check() {
659 let temp_dir = create_test_dir();
660 let project_root = temp_dir.path();
661 let src_dir = project_root.join("src");
662
663 fs::create_dir_all(&src_dir).unwrap();
664
665 let lib_rs = src_dir.join("lib.rs");
666 fs::write(&lib_rs, "pub fn hello() {}").unwrap();
667
668 let mut config = Config::default();
670 config.checks.enabled.insert("LINT001".to_string(), false);
671
672 let findings = check_missing_denied_lints(project_root, &config);
673
674 assert!(!findings.iter().any(|f| f.code == "LINT001"));
676 }
677
678 #[test]
679 fn test_regex_patterns() {
680 assert!(UNWRAP_REGEX.is_match("result.unwrap()"));
682 assert!(UNWRAP_REGEX.is_match("some_func().unwrap()"));
683 assert!(!UNWRAP_REGEX.is_match("unwrap_or_default()"));
684
685 assert!(EXPECT_REGEX.is_match(r#"result.expect("failed")"#));
686 assert!(EXPECT_REGEX.is_match("result.expect("));
687 assert!(!EXPECT_REGEX.is_match("expected_value"));
688
689 assert!(PRINTLN_DBG_REGEX.is_match("println!(\"hello\")"));
690 assert!(PRINTLN_DBG_REGEX.is_match("dbg!(value)"));
691 assert!(!PRINTLN_DBG_REGEX.is_match("print_value()"));
692
693 assert!(TODO_COMMENT_REGEX.is_match("// TODO: fix this"));
694 assert!(TODO_COMMENT_REGEX.is_match("// FIXME: broken"));
695 assert!(TODO_COMMENT_REGEX.is_match("//XXX urgent"));
696 assert!(!TODO_COMMENT_REGEX.is_match("// NOTE: this is fine"));
697
698 assert!(DENY_LINT_REGEX.is_match("#![deny(warnings)]"));
699 assert!(DENY_LINT_REGEX.is_match("#![deny(clippy::all, warnings)]"));
700 }
701
702 #[test]
703 fn test_parallel_processing() {
704 let temp_dir = create_test_dir();
705 let project_root = temp_dir.path();
706 let src_dir = project_root.join("src");
707
708 fs::create_dir_all(&src_dir).unwrap();
709
710 for i in 0..10 {
712 let file_path = src_dir.join(format!("module_{i}.rs"));
713 let content = format!(
714 r#"
715// TODO: implement module {i}
716pub fn function_{i}() {{
717 let result = some_operation().unwrap();
718}}
719"#
720 );
721 fs::write(&file_path, content).unwrap();
722 }
723
724 let rust_files = collect_rust_files(project_root);
725 let findings = check_code_patterns(&rust_files, project_root);
726
727 assert_eq!(findings.iter().filter(|f| f.code == "CODE001").count(), 10); assert_eq!(findings.iter().filter(|f| f.code == "CODE004").count(), 10); }
731}