1use std::ffi::OsStr;
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result, bail};
7use ignore::gitignore::{Gitignore, GitignoreBuilder};
8use serde::{Deserialize, Serialize};
9
10pub const NAME: &str = "Projd";
11pub const VERSION: &str = env!("CARGO_PKG_VERSION");
12
13pub fn describe() -> &'static str {
14 "Scan software projects and generate structured reports."
15}
16
17#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
18pub struct ProjectScan {
19 pub root: PathBuf,
20 pub project_name: String,
21 pub identity: ProjectIdentity,
22 pub indicators: Vec<ProjectIndicator>,
23 pub languages: Vec<LanguageSummary>,
24 pub build_systems: Vec<BuildSystemSummary>,
25 pub dependencies: DependencySummary,
26 pub tests: TestSummary,
27 pub hygiene: ScanHygiene,
28 pub risks: RiskSummary,
29 pub documentation: DocumentationSummary,
30 pub license: LicenseSummary,
31 pub ci: CiSummary,
32 pub git: GitSummary,
33 pub containers: ContainerSummary,
34 pub files_scanned: usize,
35 pub skipped_dirs: Vec<PathBuf>,
36 pub report: ProjectReport,
37}
38
39impl ProjectScan {
40 pub fn has_language(&self, kind: LanguageKind) -> bool {
41 self.languages.iter().any(|language| language.kind == kind)
42 }
43
44 pub fn has_build_system(&self, kind: BuildSystemKind) -> bool {
45 self.build_systems
46 .iter()
47 .any(|build_system| build_system.kind == kind)
48 }
49
50 pub fn has_risk(&self, code: RiskCode) -> bool {
51 self.risks.findings.iter().any(|risk| risk.code == code)
52 }
53
54 pub fn risk(&self, code: RiskCode) -> Option<&RiskFinding> {
55 self.risks.findings.iter().find(|risk| risk.code == code)
56 }
57}
58
59#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct ProjectIdentity {
61 pub name: String,
62 pub version: Option<String>,
63 pub kind: ProjectKind,
64 pub source: IdentitySource,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum ProjectKind {
70 RustWorkspace,
71 RustPackage,
72 NodePackage,
73 PythonProject,
74 Generic,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum IdentitySource {
80 CargoToml,
81 PackageJson,
82 PyprojectToml,
83 DirectoryName,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct ProjectIndicator {
88 pub kind: IndicatorKind,
89 pub path: PathBuf,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum IndicatorKind {
95 RustWorkspace,
96 RustPackage,
97 GitRepository,
98 Readme,
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
102pub struct LanguageSummary {
103 pub kind: LanguageKind,
104 pub files: usize,
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "kebab-case")]
109pub enum LanguageKind {
110 Rust,
111 TypeScript,
112 JavaScript,
113 Python,
114 C,
115 Cpp,
116 Go,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120pub struct BuildSystemSummary {
121 pub kind: BuildSystemKind,
122 pub path: PathBuf,
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
126#[serde(rename_all = "kebab-case")]
127pub enum BuildSystemKind {
128 Cargo,
129 NodePackage,
130 PythonProject,
131 PythonRequirements,
132 CMake,
133 GoModule,
134}
135
136#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
137pub struct DependencySummary {
138 pub ecosystems: Vec<DependencyEcosystemSummary>,
139 pub total_manifests: usize,
140 pub total_dependencies: usize,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
144pub struct DependencyEcosystemSummary {
145 pub ecosystem: DependencyEcosystem,
146 pub source: DependencySource,
147 pub manifest: PathBuf,
148 pub lockfile: Option<PathBuf>,
149 pub normal: usize,
150 pub development: usize,
151 pub build: usize,
152 pub optional: usize,
153 pub total: usize,
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
157#[serde(rename_all = "kebab-case")]
158pub enum DependencyEcosystem {
159 Rust,
160 Node,
161 Python,
162}
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "kebab-case")]
166pub enum DependencySource {
167 CargoToml,
168 PackageJson,
169 PyprojectToml,
170 RequirementsTxt,
171}
172
173#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
174pub struct TestSummary {
175 pub has_tests_dir: bool,
176 pub test_files: usize,
177 pub test_directories: Vec<PathBuf>,
178 pub commands: Vec<TestCommand>,
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
182pub struct TestCommand {
183 pub ecosystem: TestEcosystem,
184 pub source: PathBuf,
185 pub name: String,
186 pub command: String,
187}
188
189#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
190#[serde(rename_all = "kebab-case")]
191pub enum TestEcosystem {
192 Rust,
193 Node,
194 Python,
195 CMake,
196}
197
198#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
199pub struct ScanHygiene {
200 pub has_gitignore: bool,
201 pub has_ignore: bool,
202}
203
204#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
205pub struct RiskSummary {
206 pub findings: Vec<RiskFinding>,
207 pub total: usize,
208 pub high: usize,
209 pub medium: usize,
210 pub low: usize,
211 pub info: usize,
212}
213
214#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
215pub struct RiskFinding {
216 pub severity: RiskSeverity,
217 pub code: RiskCode,
218 pub message: String,
219 pub path: Option<PathBuf>,
220}
221
222#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "kebab-case")]
224pub enum RiskSeverity {
225 High,
226 Medium,
227 Low,
228 Info,
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
232#[serde(rename_all = "kebab-case")]
233pub enum RiskCode {
234 MissingReadme,
235 MissingLicense,
236 MissingCi,
237 NoTestsDetected,
238 ManifestWithoutLockfile,
239 LargeProjectWithoutIgnoreRules,
240 UnknownLicense,
241}
242
243#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
244pub struct DocumentationSummary {
245 pub has_readme: bool,
246 pub has_license: bool,
247 pub has_docs_dir: bool,
248}
249
250#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
251pub struct LicenseSummary {
252 pub path: Option<PathBuf>,
253 pub kind: LicenseKind,
254}
255
256#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "kebab-case")]
258pub enum LicenseKind {
259 Mit,
260 Apache2,
261 Gpl,
262 Bsd,
263 #[default]
264 Unknown,
265 Missing,
266}
267
268#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
269pub struct CiSummary {
270 pub has_github_actions: bool,
271 pub has_gitee_go: bool,
272 pub has_gitlab_ci: bool,
273 pub has_circle_ci: bool,
274 pub has_jenkins: bool,
275}
276
277#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "kebab-case")]
279pub enum CiProvider {
280 GithubActions,
281 GiteeGo,
282 GitlabCi,
283 CircleCi,
284 Jenkins,
285}
286
287impl CiSummary {
288 pub fn has_provider(&self, provider: CiProvider) -> bool {
289 match provider {
290 CiProvider::GithubActions => self.has_github_actions,
291 CiProvider::GiteeGo => self.has_gitee_go,
292 CiProvider::GitlabCi => self.has_gitlab_ci,
293 CiProvider::CircleCi => self.has_circle_ci,
294 CiProvider::Jenkins => self.has_jenkins,
295 }
296 }
297
298 fn has_any_provider(&self) -> bool {
299 self.has_github_actions
300 || self.has_gitee_go
301 || self.has_gitlab_ci
302 || self.has_circle_ci
303 || self.has_jenkins
304 }
305}
306
307#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
308pub struct GitSummary {
309 pub is_repository: bool,
310 pub root: Option<PathBuf>,
311 pub branch: Option<String>,
312 pub last_commit: Option<String>,
313 pub is_dirty: bool,
314 pub tracked_modified_files: usize,
315 pub untracked_files: usize,
316}
317
318#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
319pub struct ContainerSummary {
320 pub has_dockerfile: bool,
321 pub has_compose_file: bool,
322}
323
324#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
325pub struct ProjectReport {
326 pub summary: String,
327 pub next_steps: Vec<String>,
328}
329
330pub fn scan_path(path: impl AsRef<Path>) -> Result<ProjectScan> {
331 let root = path.as_ref();
332 let metadata =
333 fs::metadata(root).with_context(|| format!("failed to inspect `{}`", root.display()))?;
334
335 if !metadata.is_dir() {
336 bail!("project path must be a directory: `{}`", root.display());
337 }
338
339 let root = root
340 .canonicalize()
341 .with_context(|| format!("failed to resolve `{}`", root.display()))?;
342 let project_name = root
343 .file_name()
344 .and_then(|value| value.to_str())
345 .unwrap_or("project")
346 .to_owned();
347 let identity = detect_identity(&root, &project_name);
348 let indicators = detect_indicators(&root);
349 let inventory = scan_inventory(&root)?;
350 let risks = build_risk_summary(&inventory);
351 let report = build_report(&identity.name, &indicators, &inventory);
352 let git = detect_git_summary(&root);
353
354 Ok(ProjectScan {
355 root,
356 project_name: identity.name.clone(),
357 identity,
358 indicators,
359 languages: inventory.languages,
360 build_systems: inventory.build_systems,
361 dependencies: inventory.dependencies,
362 tests: inventory.tests,
363 hygiene: inventory.hygiene,
364 risks,
365 documentation: inventory.documentation,
366 license: inventory.license,
367 ci: inventory.ci,
368 git,
369 containers: inventory.containers,
370 files_scanned: inventory.files_scanned,
371 skipped_dirs: inventory.skipped_dirs,
372 report,
373 })
374}
375
376pub fn render_markdown(scan: &ProjectScan) -> String {
377 let mut output = String::new();
378 output.push_str("# Project Report\n\n");
379 output.push_str(&format!("Project: `{}`\n\n", scan.project_name));
380 output.push_str(&format!("Root: `{}`\n\n", scan.root.display()));
381 output.push_str("## Identity\n\n");
382 output.push_str(&format!("- Name: `{}`\n", scan.identity.name));
383 output.push_str(&format!(
384 "- Version: {}\n",
385 scan.identity
386 .version
387 .as_deref()
388 .map(|version| format!("`{version}`"))
389 .unwrap_or_else(|| "unknown".to_owned())
390 ));
391 output.push_str(&format!("- Kind: {:?}\n", scan.identity.kind));
392 output.push_str(&format!("- Source: {:?}\n\n", scan.identity.source));
393 output.push_str("## Summary\n\n");
394 output.push_str(&scan.report.summary);
395 output.push_str("\n\n## Indicators\n\n");
396
397 if scan.indicators.is_empty() {
398 output.push_str("- No known project indicators detected.\n");
399 } else {
400 for indicator in &scan.indicators {
401 output.push_str(&format!(
402 "- {:?}: `{}`\n",
403 indicator.kind,
404 indicator.path.display()
405 ));
406 }
407 }
408
409 output.push_str("\n## Languages\n\n");
410 if scan.languages.is_empty() {
411 output.push_str("- No source language files detected.\n");
412 } else {
413 for language in &scan.languages {
414 output.push_str(&format!(
415 "- {:?}: {} file(s)\n",
416 language.kind, language.files
417 ));
418 }
419 }
420
421 output.push_str("\n## Build Systems\n\n");
422 if scan.build_systems.is_empty() {
423 output.push_str("- No known build systems detected.\n");
424 } else {
425 for build_system in &scan.build_systems {
426 output.push_str(&format!(
427 "- {:?}: `{}`\n",
428 build_system.kind,
429 build_system.path.display()
430 ));
431 }
432 }
433
434 output.push_str("\n## Dependencies\n\n");
435 output.push_str(&format!(
436 "- Total manifests: {}\n",
437 scan.dependencies.total_manifests
438 ));
439 output.push_str(&format!(
440 "- Total dependency entries: {}\n",
441 scan.dependencies.total_dependencies
442 ));
443 if scan.dependencies.ecosystems.is_empty() {
444 output.push_str("- No dependency manifests detected.\n");
445 } else {
446 for summary in &scan.dependencies.ecosystems {
447 output.push_str(&format!(
448 "- {:?} {:?}: `{}` normal {}, dev {}, build {}, optional {}, total {}, lockfile {}\n",
449 summary.ecosystem,
450 summary.source,
451 summary.manifest.display(),
452 summary.normal,
453 summary.development,
454 summary.build,
455 summary.optional,
456 summary.total,
457 yes_no(summary.lockfile.is_some())
458 ));
459 }
460 }
461
462 output.push_str("\n## Tests\n\n");
463 output.push_str(&format!(
464 "- Test directories: {}\n",
465 scan.tests.test_directories.len()
466 ));
467 output.push_str(&format!("- Test files: {}\n", scan.tests.test_files));
468 if scan.tests.commands.is_empty() {
469 output.push_str("- Commands: none detected\n");
470 } else {
471 output.push_str("- Commands:\n");
472 for command in &scan.tests.commands {
473 output.push_str(&format!(
474 " - {:?}: {} (`{}`)\n",
475 command.ecosystem,
476 command.command,
477 command.source.display()
478 ));
479 }
480 }
481
482 output.push_str("\n## Risks\n\n");
483 if scan.risks.findings.is_empty() {
484 output.push_str("- No risks detected by current rules.\n");
485 } else {
486 for risk in &scan.risks.findings {
487 output.push_str(&format!(
488 "- {} {}: {}",
489 risk.severity.as_str(),
490 risk.code.as_str(),
491 risk.message
492 ));
493 if let Some(path) = &risk.path {
494 output.push_str(&format!(" (`{}`)", path.display()));
495 }
496 output.push('\n');
497 }
498 }
499
500 output.push_str("\n## Project Facilities\n\n");
501 output.push_str(&format!(
502 "- README: {}\n",
503 yes_no(scan.documentation.has_readme)
504 ));
505 output.push_str(&format!(
506 "- License: {}\n",
507 yes_no(scan.documentation.has_license)
508 ));
509 output.push_str(&format!("- License kind: {:?}\n", scan.license.kind));
510 output.push_str(&format!(
511 "- docs/: {}\n",
512 yes_no(scan.documentation.has_docs_dir)
513 ));
514 output.push_str(&format!(
515 "- GitHub Actions: {}\n",
516 yes_no(scan.ci.has_github_actions)
517 ));
518 output.push_str(&format!("- Gitee Go: {}\n", yes_no(scan.ci.has_gitee_go)));
519 output.push_str(&format!("- GitLab CI: {}\n", yes_no(scan.ci.has_gitlab_ci)));
520 output.push_str(&format!("- CircleCI: {}\n", yes_no(scan.ci.has_circle_ci)));
521 output.push_str(&format!("- Jenkins: {}\n", yes_no(scan.ci.has_jenkins)));
522 output.push_str(&format!(
523 "- Git repository: {}\n",
524 yes_no(scan.git.is_repository)
525 ));
526 output.push_str(&format!(
527 "- Dockerfile: {}\n",
528 yes_no(scan.containers.has_dockerfile)
529 ));
530 output.push_str(&format!(
531 "- Compose file: {}\n",
532 yes_no(scan.containers.has_compose_file)
533 ));
534
535 output.push_str("\n## Next Steps\n\n");
536 for step in &scan.report.next_steps {
537 output.push_str(&format!("- {step}\n"));
538 }
539
540 output
541}
542
543pub fn render_json(scan: &ProjectScan) -> Result<String> {
544 serde_json::to_string_pretty(scan).context("failed to render scan result as json")
545}
546
547struct ScanInventory {
548 languages: Vec<LanguageSummary>,
549 build_systems: Vec<BuildSystemSummary>,
550 dependencies: DependencySummary,
551 tests: TestSummary,
552 hygiene: ScanHygiene,
553 documentation: DocumentationSummary,
554 license: LicenseSummary,
555 ci: CiSummary,
556 containers: ContainerSummary,
557 files_scanned: usize,
558 skipped_dirs: Vec<PathBuf>,
559}
560
561fn detect_identity(root: &Path, fallback_name: &str) -> ProjectIdentity {
562 detect_cargo_identity(root, fallback_name)
563 .or_else(|| detect_node_identity(root))
564 .or_else(|| detect_python_identity(root))
565 .unwrap_or_else(|| ProjectIdentity {
566 name: fallback_name.to_owned(),
567 version: None,
568 kind: ProjectKind::Generic,
569 source: IdentitySource::DirectoryName,
570 })
571}
572
573fn detect_cargo_identity(root: &Path, fallback_name: &str) -> Option<ProjectIdentity> {
574 let manifest = read_toml_value(&root.join("Cargo.toml"))?;
575
576 if let Some(package) = manifest.get("package") {
577 let name = package.get("name")?.as_str()?.to_owned();
578 let version = package
579 .get("version")
580 .and_then(|value| value.as_str())
581 .map(str::to_owned);
582
583 return Some(ProjectIdentity {
584 name,
585 version,
586 kind: ProjectKind::RustPackage,
587 source: IdentitySource::CargoToml,
588 });
589 }
590
591 if manifest.get("workspace").is_some() {
592 let version = manifest
593 .get("workspace")
594 .and_then(|workspace| workspace.get("package"))
595 .and_then(|package| package.get("version"))
596 .and_then(|value| value.as_str())
597 .map(str::to_owned);
598
599 return Some(ProjectIdentity {
600 name: fallback_name.to_owned(),
601 version,
602 kind: ProjectKind::RustWorkspace,
603 source: IdentitySource::CargoToml,
604 });
605 }
606
607 None
608}
609
610fn detect_node_identity(root: &Path) -> Option<ProjectIdentity> {
611 let manifest = read_json_value(&root.join("package.json"))?;
612 let name = manifest.get("name")?.as_str()?.to_owned();
613 let version = manifest
614 .get("version")
615 .and_then(|value| value.as_str())
616 .map(str::to_owned);
617
618 Some(ProjectIdentity {
619 name,
620 version,
621 kind: ProjectKind::NodePackage,
622 source: IdentitySource::PackageJson,
623 })
624}
625
626fn detect_python_identity(root: &Path) -> Option<ProjectIdentity> {
627 let manifest = read_toml_value(&root.join("pyproject.toml"))?;
628 let project = manifest.get("project")?;
629 let name = project.get("name")?.as_str()?.to_owned();
630 let version = project
631 .get("version")
632 .and_then(|value| value.as_str())
633 .map(str::to_owned);
634
635 Some(ProjectIdentity {
636 name,
637 version,
638 kind: ProjectKind::PythonProject,
639 source: IdentitySource::PyprojectToml,
640 })
641}
642
643fn read_toml_value(path: &Path) -> Option<toml::Value> {
644 let content = fs::read_to_string(path).ok()?;
645 toml::from_str(&content).ok()
646}
647
648fn read_json_value(path: &Path) -> Option<serde_json::Value> {
649 let content = fs::read_to_string(path).ok()?;
650 serde_json::from_str(&content).ok()
651}
652
653fn build_dependency_summary(manifests: Vec<PathBuf>) -> DependencySummary {
654 let mut ecosystems = Vec::new();
655
656 for manifest in manifests {
657 let Some(file_name) = manifest.file_name().and_then(|value| value.to_str()) else {
658 continue;
659 };
660
661 let summary = match file_name {
662 "Cargo.toml" => summarize_cargo_dependencies(&manifest),
663 "package.json" => summarize_node_dependencies(&manifest),
664 "pyproject.toml" => summarize_pyproject_dependencies(&manifest),
665 "requirements.txt" => summarize_requirements_dependencies(&manifest),
666 _ => None,
667 };
668
669 if let Some(summary) = summary {
670 ecosystems.push(summary);
671 }
672 }
673
674 let total_manifests = ecosystems.len();
675 let total_dependencies = ecosystems.iter().map(|summary| summary.total).sum();
676
677 DependencySummary {
678 ecosystems,
679 total_manifests,
680 total_dependencies,
681 }
682}
683
684fn build_test_summary(mut tests: TestSummary, sources: Vec<PathBuf>) -> TestSummary {
685 for source in sources {
686 let Some(file_name) = source.file_name().and_then(|value| value.to_str()) else {
687 continue;
688 };
689
690 match file_name {
691 "Cargo.toml" => add_rust_test_command(&mut tests, &source),
692 "package.json" => add_node_test_commands(&mut tests, &source),
693 "pyproject.toml" => add_python_test_command(&mut tests, &source),
694 "CMakeLists.txt" => add_cmake_test_command(&mut tests, &source),
695 _ => {}
696 }
697 }
698
699 tests
700}
701
702fn build_risk_summary(inventory: &ScanInventory) -> RiskSummary {
703 let mut findings = Vec::new();
704
705 if !inventory.documentation.has_readme {
706 findings.push(RiskFinding {
707 severity: RiskSeverity::Medium,
708 code: RiskCode::MissingReadme,
709 message: "No README file detected.".to_owned(),
710 path: None,
711 });
712 }
713
714 if !inventory.documentation.has_license {
715 findings.push(RiskFinding {
716 severity: RiskSeverity::Medium,
717 code: RiskCode::MissingLicense,
718 message: "No LICENSE file detected.".to_owned(),
719 path: None,
720 });
721 } else if inventory.license.kind == LicenseKind::Unknown {
722 findings.push(RiskFinding {
723 severity: RiskSeverity::Low,
724 code: RiskCode::UnknownLicense,
725 message: "License file was detected, but the license type was not recognized."
726 .to_owned(),
727 path: inventory.license.path.clone(),
728 });
729 }
730
731 if !inventory.ci.has_any_provider() {
732 findings.push(RiskFinding {
733 severity: RiskSeverity::Low,
734 code: RiskCode::MissingCi,
735 message: "No known CI workflow detected.".to_owned(),
736 path: None,
737 });
738 }
739
740 if inventory.tests.test_files == 0 && inventory.tests.commands.is_empty() {
741 findings.push(RiskFinding {
742 severity: RiskSeverity::Medium,
743 code: RiskCode::NoTestsDetected,
744 message: "No test files or test commands detected.".to_owned(),
745 path: None,
746 });
747 }
748
749 for summary in &inventory.dependencies.ecosystems {
750 if summary.total > 0 && summary.lockfile.is_none() {
751 findings.push(RiskFinding {
752 severity: RiskSeverity::Low,
753 code: RiskCode::ManifestWithoutLockfile,
754 message: "Dependency manifest has entries but no lockfile was detected.".to_owned(),
755 path: Some(summary.manifest.clone()),
756 });
757 }
758 }
759
760 if inventory.files_scanned >= 1000
761 && !inventory.hygiene.has_gitignore
762 && !inventory.hygiene.has_ignore
763 {
764 findings.push(RiskFinding {
765 severity: RiskSeverity::Info,
766 code: RiskCode::LargeProjectWithoutIgnoreRules,
767 message: "Large project scanned without .gitignore or .ignore rules.".to_owned(),
768 path: None,
769 });
770 }
771
772 RiskSummary::from_findings(findings)
773}
774
775impl RiskSummary {
776 fn from_findings(findings: Vec<RiskFinding>) -> Self {
777 let total = findings.len();
778 let high = findings
779 .iter()
780 .filter(|risk| risk.severity == RiskSeverity::High)
781 .count();
782 let medium = findings
783 .iter()
784 .filter(|risk| risk.severity == RiskSeverity::Medium)
785 .count();
786 let low = findings
787 .iter()
788 .filter(|risk| risk.severity == RiskSeverity::Low)
789 .count();
790 let info = findings
791 .iter()
792 .filter(|risk| risk.severity == RiskSeverity::Info)
793 .count();
794
795 Self {
796 findings,
797 total,
798 high,
799 medium,
800 low,
801 info,
802 }
803 }
804}
805
806impl RiskSeverity {
807 fn as_str(self) -> &'static str {
808 match self {
809 Self::High => "high",
810 Self::Medium => "medium",
811 Self::Low => "low",
812 Self::Info => "info",
813 }
814 }
815}
816
817impl RiskCode {
818 fn as_str(self) -> &'static str {
819 match self {
820 Self::MissingReadme => "missing-readme",
821 Self::MissingLicense => "missing-license",
822 Self::MissingCi => "missing-ci",
823 Self::NoTestsDetected => "no-tests-detected",
824 Self::ManifestWithoutLockfile => "manifest-without-lockfile",
825 Self::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
826 Self::UnknownLicense => "unknown-license",
827 }
828 }
829}
830
831fn add_rust_test_command(tests: &mut TestSummary, source: &Path) {
832 add_test_command(
833 tests,
834 TestCommand {
835 ecosystem: TestEcosystem::Rust,
836 source: source.to_path_buf(),
837 name: "cargo test".to_owned(),
838 command: "cargo test".to_owned(),
839 },
840 );
841}
842
843fn add_node_test_commands(tests: &mut TestSummary, source: &Path) {
844 let Some(value) = read_json_value(source) else {
845 return;
846 };
847 let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) else {
848 return;
849 };
850
851 for (name, command) in scripts {
852 if name == "test" || name.starts_with("test:") {
853 if let Some(command) = command.as_str() {
854 add_test_command(
855 tests,
856 TestCommand {
857 ecosystem: TestEcosystem::Node,
858 source: source.to_path_buf(),
859 name: name.to_owned(),
860 command: command.to_owned(),
861 },
862 );
863 }
864 }
865 }
866}
867
868fn add_python_test_command(tests: &mut TestSummary, source: &Path) {
869 let Some(value) = read_toml_value(source) else {
870 return;
871 };
872
873 let has_pytest_config = value
874 .get("tool")
875 .and_then(|tool| tool.get("pytest"))
876 .and_then(|pytest| pytest.get("ini_options"))
877 .is_some();
878
879 if has_pytest_config {
880 add_test_command(
881 tests,
882 TestCommand {
883 ecosystem: TestEcosystem::Python,
884 source: source.to_path_buf(),
885 name: "pytest".to_owned(),
886 command: "pytest".to_owned(),
887 },
888 );
889 }
890}
891
892fn add_cmake_test_command(tests: &mut TestSummary, source: &Path) {
893 let Ok(content) = fs::read_to_string(source) else {
894 return;
895 };
896 let lower = content.to_ascii_lowercase();
897
898 if lower.contains("enable_testing") || lower.contains("add_test") {
899 add_test_command(
900 tests,
901 TestCommand {
902 ecosystem: TestEcosystem::CMake,
903 source: source.to_path_buf(),
904 name: "ctest".to_owned(),
905 command: "ctest".to_owned(),
906 },
907 );
908 }
909}
910
911fn add_test_command(tests: &mut TestSummary, command: TestCommand) {
912 if tests.commands.iter().any(|existing| {
913 existing.ecosystem == command.ecosystem
914 && existing.source == command.source
915 && existing.name == command.name
916 && existing.command == command.command
917 }) {
918 return;
919 }
920
921 tests.commands.push(command);
922}
923
924fn summarize_cargo_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
925 let value = read_toml_value(manifest)?;
926 let normal = count_toml_table_entries(value.get("dependencies"));
927 let development = count_toml_table_entries(value.get("dev-dependencies"));
928 let build = count_toml_table_entries(value.get("build-dependencies"));
929 let optional = count_optional_cargo_dependencies(value.get("dependencies"));
930 let total = normal + development + build;
931
932 Some(DependencyEcosystemSummary {
933 ecosystem: DependencyEcosystem::Rust,
934 source: DependencySource::CargoToml,
935 manifest: manifest.to_path_buf(),
936 lockfile: find_nearest_lockfile(manifest, &["Cargo.lock"]),
937 normal,
938 development,
939 build,
940 optional,
941 total,
942 })
943}
944
945fn summarize_node_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
946 let value = read_json_value(manifest)?;
947 let normal = count_json_object_entries(value.get("dependencies"));
948 let development = count_json_object_entries(value.get("devDependencies"));
949 let optional = count_json_object_entries(value.get("optionalDependencies"));
950 let total = normal + development + optional;
951
952 Some(DependencyEcosystemSummary {
953 ecosystem: DependencyEcosystem::Node,
954 source: DependencySource::PackageJson,
955 manifest: manifest.to_path_buf(),
956 lockfile: find_sibling_lockfile(
957 manifest,
958 &[
959 "pnpm-lock.yaml",
960 "yarn.lock",
961 "package-lock.json",
962 "bun.lockb",
963 ],
964 ),
965 normal,
966 development,
967 build: 0,
968 optional,
969 total,
970 })
971}
972
973fn summarize_pyproject_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
974 let value = read_toml_value(manifest)?;
975 let project = value.get("project");
976 let normal = project
977 .and_then(|project| project.get("dependencies"))
978 .and_then(|dependencies| dependencies.as_array())
979 .map_or(0, Vec::len);
980 let optional = project
981 .and_then(|project| project.get("optional-dependencies"))
982 .and_then(|dependencies| dependencies.as_table())
983 .map(|groups| {
984 groups
985 .values()
986 .filter_map(|value| value.as_array())
987 .map(Vec::len)
988 .sum()
989 })
990 .unwrap_or(0);
991 let development = project
992 .and_then(|project| project.get("optional-dependencies"))
993 .and_then(|dependencies| dependencies.get("dev"))
994 .and_then(|dependencies| dependencies.as_array())
995 .map_or(0, Vec::len);
996 let total = normal + optional;
997
998 Some(DependencyEcosystemSummary {
999 ecosystem: DependencyEcosystem::Python,
1000 source: DependencySource::PyprojectToml,
1001 manifest: manifest.to_path_buf(),
1002 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1003 normal,
1004 development,
1005 build: 0,
1006 optional,
1007 total,
1008 })
1009}
1010
1011fn summarize_requirements_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1012 let content = fs::read_to_string(manifest).ok()?;
1013 let normal = content
1014 .lines()
1015 .map(str::trim)
1016 .filter(|line| is_requirement_entry(line))
1017 .count();
1018
1019 Some(DependencyEcosystemSummary {
1020 ecosystem: DependencyEcosystem::Python,
1021 source: DependencySource::RequirementsTxt,
1022 manifest: manifest.to_path_buf(),
1023 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1024 normal,
1025 development: 0,
1026 build: 0,
1027 optional: 0,
1028 total: normal,
1029 })
1030}
1031
1032fn count_toml_table_entries(value: Option<&toml::Value>) -> usize {
1033 value
1034 .and_then(|value| value.as_table())
1035 .map_or(0, |table| table.len())
1036}
1037
1038fn count_optional_cargo_dependencies(value: Option<&toml::Value>) -> usize {
1039 value
1040 .and_then(|value| value.as_table())
1041 .map(|dependencies| {
1042 dependencies
1043 .values()
1044 .filter(|value| {
1045 value
1046 .as_table()
1047 .and_then(|table| table.get("optional"))
1048 .and_then(|optional| optional.as_bool())
1049 .unwrap_or(false)
1050 })
1051 .count()
1052 })
1053 .unwrap_or(0)
1054}
1055
1056fn count_json_object_entries(value: Option<&serde_json::Value>) -> usize {
1057 value
1058 .and_then(|value| value.as_object())
1059 .map_or(0, |object| object.len())
1060}
1061
1062fn is_requirement_entry(line: &str) -> bool {
1063 !line.is_empty() && !line.starts_with('#') && !line.starts_with('-') && !line.starts_with("--")
1064}
1065
1066fn find_nearest_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1067 let mut current = manifest.parent();
1068 while let Some(dir) = current {
1069 if let Some(lockfile) = find_lockfile_in_dir(dir, names) {
1070 return Some(lockfile);
1071 }
1072 current = dir.parent();
1073 }
1074
1075 None
1076}
1077
1078fn find_sibling_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1079 find_lockfile_in_dir(manifest.parent()?, names)
1080}
1081
1082fn find_lockfile_in_dir(dir: &Path, names: &[&str]) -> Option<PathBuf> {
1083 names
1084 .iter()
1085 .map(|name| dir.join(name))
1086 .find(|path| path.is_file())
1087}
1088
1089fn detect_git_summary(root: &Path) -> GitSummary {
1090 let Some(git_root) = run_git_output(root, &["rev-parse", "--show-toplevel"])
1091 .and_then(|output| output.lines().next().map(PathBuf::from))
1092 else {
1093 return GitSummary::default();
1094 };
1095
1096 let branch = run_git_output(root, &["branch", "--show-current"])
1097 .and_then(|output| non_empty_trimmed(&output));
1098 let last_commit = run_git_output(root, &["log", "-1", "--format=%cI"])
1099 .and_then(|output| non_empty_trimmed(&output));
1100 let porcelain = run_git_output(root, &["status", "--porcelain"]).unwrap_or_default();
1101 let mut tracked_modified_files = 0;
1102 let mut untracked_files = 0;
1103
1104 for line in porcelain.lines() {
1105 if line.starts_with("??") {
1106 untracked_files += 1;
1107 } else if !line.trim().is_empty() {
1108 tracked_modified_files += 1;
1109 }
1110 }
1111
1112 GitSummary {
1113 is_repository: true,
1114 root: Some(git_root),
1115 branch,
1116 last_commit,
1117 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1118 tracked_modified_files,
1119 untracked_files,
1120 }
1121}
1122
1123fn run_git_output(root: &Path, args: &[&str]) -> Option<String> {
1124 let output = Command::new("git")
1125 .args(args)
1126 .current_dir(root)
1127 .output()
1128 .ok()?;
1129
1130 if !output.status.success() {
1131 return None;
1132 }
1133
1134 Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1135}
1136
1137fn non_empty_trimmed(value: &str) -> Option<String> {
1138 let value = value.trim();
1139 if value.is_empty() {
1140 None
1141 } else {
1142 Some(value.to_owned())
1143 }
1144}
1145
1146fn detect_indicators(root: &Path) -> Vec<ProjectIndicator> {
1147 let checks = [
1148 ("Cargo.toml", IndicatorKind::RustWorkspace),
1149 (".git", IndicatorKind::GitRepository),
1150 ("README.md", IndicatorKind::Readme),
1151 ];
1152
1153 let mut indicators = Vec::new();
1154 for (relative, kind) in checks {
1155 let path = root.join(relative);
1156 if path.exists() {
1157 indicators.push(ProjectIndicator { kind, path });
1158 }
1159 }
1160
1161 let cargo_manifest = root.join("Cargo.toml");
1162 if cargo_manifest.exists() {
1163 indicators.push(ProjectIndicator {
1164 kind: IndicatorKind::RustPackage,
1165 path: cargo_manifest,
1166 });
1167 }
1168
1169 indicators
1170}
1171
1172fn scan_inventory(root: &Path) -> Result<ScanInventory> {
1173 let mut inventory = MutableInventory::default();
1174 let ignore_matcher = IgnoreMatcher::new(root);
1175 walk_project(root, &ignore_matcher, &mut inventory)?;
1176
1177 Ok(ScanInventory {
1178 languages: inventory.languages,
1179 build_systems: inventory.build_systems,
1180 dependencies: build_dependency_summary(inventory.dependency_manifests),
1181 tests: build_test_summary(inventory.tests, inventory.test_command_sources),
1182 hygiene: inventory.hygiene,
1183 documentation: inventory.documentation,
1184 license: build_license_summary(&inventory.license_files),
1185 ci: inventory.ci,
1186 containers: inventory.containers,
1187 files_scanned: inventory.files_scanned,
1188 skipped_dirs: inventory.skipped_dirs,
1189 })
1190}
1191
1192fn walk_project(
1193 path: &Path,
1194 ignore_matcher: &IgnoreMatcher,
1195 inventory: &mut MutableInventory,
1196) -> Result<()> {
1197 let local_ignore_matcher = ignore_matcher.with_child_ignores(path);
1198 let mut entries = Vec::new();
1199
1200 for entry in
1201 fs::read_dir(path).with_context(|| format!("failed to read `{}`", path.display()))?
1202 {
1203 let entry =
1204 entry.with_context(|| format!("failed to read entry under `{}`", path.display()))?;
1205 entries.push(entry);
1206 }
1207
1208 entries.sort_by_key(|entry| entry.file_name());
1209
1210 for entry in entries {
1211 let entry_path = entry.path();
1212 let file_type = entry
1213 .file_type()
1214 .with_context(|| format!("failed to inspect `{}`", entry_path.display()))?;
1215
1216 if file_type.is_dir() {
1217 if should_skip_dir(entry.file_name().as_os_str())
1218 || local_ignore_matcher.is_ignored(&entry_path, true)
1219 {
1220 inventory.add_skipped_dir(entry_path);
1221 continue;
1222 }
1223
1224 observe_directory(&entry_path, inventory);
1225 walk_project(&entry_path, &local_ignore_matcher, inventory)?;
1226 continue;
1227 }
1228
1229 if file_type.is_file() {
1230 if local_ignore_matcher.is_ignored(&entry_path, false) {
1231 continue;
1232 }
1233
1234 inventory.files_scanned += 1;
1235 observe_file(&entry_path, inventory);
1236 }
1237 }
1238
1239 Ok(())
1240}
1241
1242fn should_skip_dir(name: &OsStr) -> bool {
1243 matches!(
1244 name.to_str(),
1245 Some(".git" | ".hg" | ".svn" | "target" | "node_modules")
1246 )
1247}
1248
1249#[derive(Clone)]
1250struct IgnoreMatcher {
1251 matchers: Vec<Gitignore>,
1252}
1253
1254impl IgnoreMatcher {
1255 fn new(root: &Path) -> Self {
1256 Self {
1257 matchers: Self::build_matchers(root),
1258 }
1259 }
1260
1261 fn with_child_ignores(&self, dir: &Path) -> Self {
1262 let mut matchers = self.matchers.clone();
1263 matchers.extend(Self::build_matchers(dir));
1264 Self { matchers }
1265 }
1266
1267 fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
1268 self.matchers
1269 .iter()
1270 .rev()
1271 .find_map(|matcher| {
1272 let result = matcher.matched(path, is_dir);
1273 if result.is_none() {
1274 None
1275 } else {
1276 Some(result.is_ignore())
1277 }
1278 })
1279 .unwrap_or(false)
1280 }
1281
1282 fn build_matchers(dir: &Path) -> Vec<Gitignore> {
1283 [".gitignore", ".ignore"]
1284 .into_iter()
1285 .filter_map(|file_name| Self::build_matcher(dir, file_name))
1286 .collect()
1287 }
1288
1289 fn build_matcher(dir: &Path, file_name: &str) -> Option<Gitignore> {
1290 let path = dir.join(file_name);
1291 if !path.is_file() {
1292 return None;
1293 }
1294
1295 let mut builder = GitignoreBuilder::new(dir);
1296 let _ = builder.add(&path);
1297 builder.build().ok()
1298 }
1299}
1300
1301fn observe_directory(path: &Path, inventory: &mut MutableInventory) {
1302 if path.file_name().and_then(|value| value.to_str()) == Some("docs") {
1303 inventory.documentation.has_docs_dir = true;
1304 }
1305 if is_test_directory(path) {
1306 add_test_directory(&mut inventory.tests, path.to_path_buf());
1307 }
1308}
1309
1310fn observe_file(path: &Path, inventory: &mut MutableInventory) {
1311 if let Some(kind) = language_for_path(path) {
1312 inventory.add_language(kind);
1313 }
1314
1315 if let Some(kind) = build_system_for_path(path) {
1316 inventory.add_build_system(kind, path.to_path_buf());
1317 }
1318 if is_dependency_manifest(path) {
1319 inventory.add_dependency_manifest(path.to_path_buf());
1320 }
1321 if is_test_file(path) {
1322 inventory.tests.test_files += 1;
1323 }
1324 if is_test_command_source(path) {
1325 inventory.add_test_command_source(path.to_path_buf());
1326 }
1327 observe_hygiene_file(path, &mut inventory.hygiene);
1328
1329 observe_documentation_file(path, &mut inventory.documentation);
1330 if is_license_file(path) {
1331 inventory.add_license_file(path.to_path_buf());
1332 }
1333 observe_ci_file(path, &mut inventory.ci);
1334 observe_container_file(path, &mut inventory.containers);
1335}
1336
1337fn language_for_path(path: &Path) -> Option<LanguageKind> {
1338 let extension = path
1339 .extension()
1340 .and_then(|value| value.to_str())
1341 .map(str::to_ascii_lowercase)?;
1342
1343 match extension.as_str() {
1344 "rs" => Some(LanguageKind::Rust),
1345 "ts" | "tsx" => Some(LanguageKind::TypeScript),
1346 "js" | "jsx" | "mjs" | "cjs" => Some(LanguageKind::JavaScript),
1347 "py" => Some(LanguageKind::Python),
1348 "c" => Some(LanguageKind::C),
1349 "cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some(LanguageKind::Cpp),
1350 "go" => Some(LanguageKind::Go),
1351 _ => None,
1352 }
1353}
1354
1355fn build_system_for_path(path: &Path) -> Option<BuildSystemKind> {
1356 let file_name = path.file_name()?.to_str()?;
1357
1358 match file_name {
1359 "Cargo.toml" => Some(BuildSystemKind::Cargo),
1360 "package.json" => Some(BuildSystemKind::NodePackage),
1361 "pyproject.toml" => Some(BuildSystemKind::PythonProject),
1362 "requirements.txt" => Some(BuildSystemKind::PythonRequirements),
1363 "CMakeLists.txt" => Some(BuildSystemKind::CMake),
1364 "go.mod" => Some(BuildSystemKind::GoModule),
1365 _ => None,
1366 }
1367}
1368
1369fn is_dependency_manifest(path: &Path) -> bool {
1370 matches!(
1371 path.file_name().and_then(|value| value.to_str()),
1372 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "requirements.txt")
1373 )
1374}
1375
1376fn is_test_command_source(path: &Path) -> bool {
1377 matches!(
1378 path.file_name().and_then(|value| value.to_str()),
1379 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "CMakeLists.txt")
1380 )
1381}
1382
1383fn is_test_directory(path: &Path) -> bool {
1384 matches!(
1385 path.file_name().and_then(|value| value.to_str()),
1386 Some("tests" | "test" | "__tests__" | "spec")
1387 )
1388}
1389
1390fn add_test_directory(tests: &mut TestSummary, path: PathBuf) {
1391 tests.has_tests_dir = true;
1392 if tests
1393 .test_directories
1394 .iter()
1395 .any(|existing| existing == &path)
1396 {
1397 return;
1398 }
1399
1400 tests.test_directories.push(path);
1401}
1402
1403fn is_test_file(path: &Path) -> bool {
1404 let file_name = path
1405 .file_name()
1406 .and_then(|value| value.to_str())
1407 .unwrap_or_default()
1408 .to_ascii_lowercase();
1409 let extension = path
1410 .extension()
1411 .and_then(|value| value.to_str())
1412 .unwrap_or_default()
1413 .to_ascii_lowercase();
1414
1415 if is_under_named_dir(path, "tests") || is_under_named_dir(path, "__tests__") {
1416 return matches!(
1417 extension.as_str(),
1418 "rs" | "js" | "jsx" | "ts" | "tsx" | "py"
1419 );
1420 }
1421
1422 file_name.ends_with("_test.rs")
1423 || file_name.ends_with(".test.js")
1424 || file_name.ends_with(".test.jsx")
1425 || file_name.ends_with(".test.ts")
1426 || file_name.ends_with(".test.tsx")
1427 || file_name.ends_with(".spec.js")
1428 || file_name.ends_with(".spec.jsx")
1429 || file_name.ends_with(".spec.ts")
1430 || file_name.ends_with(".spec.tsx")
1431 || (file_name.starts_with("test_") && file_name.ends_with(".py"))
1432 || file_name.ends_with("_test.py")
1433 || file_name.ends_with("_test.cpp")
1434 || file_name.ends_with("_test.cc")
1435 || file_name.ends_with("_test.cxx")
1436 || file_name.ends_with("_tests.cpp")
1437}
1438
1439fn is_under_named_dir(path: &Path, directory_name: &str) -> bool {
1440 path.components()
1441 .any(|component| component == Component::Normal(OsStr::new(directory_name)))
1442}
1443
1444fn observe_documentation_file(path: &Path, documentation: &mut DocumentationSummary) {
1445 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
1446 return;
1447 };
1448 let file_name = file_name.to_ascii_lowercase();
1449
1450 if file_name == "readme.md" || file_name == "readme" {
1451 documentation.has_readme = true;
1452 }
1453 if file_name == "license" || file_name.starts_with("license.") {
1454 documentation.has_license = true;
1455 }
1456}
1457
1458fn is_license_file(path: &Path) -> bool {
1459 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
1460 return false;
1461 };
1462 let file_name = file_name.to_ascii_lowercase();
1463 file_name == "license" || file_name.starts_with("license.")
1464}
1465
1466fn build_license_summary(files: &[PathBuf]) -> LicenseSummary {
1467 let Some(path) = files.first() else {
1468 return LicenseSummary {
1469 path: None,
1470 kind: LicenseKind::Missing,
1471 };
1472 };
1473
1474 let content = fs::read_to_string(path).unwrap_or_default();
1475 LicenseSummary {
1476 path: Some(path.clone()),
1477 kind: detect_license_kind(&content),
1478 }
1479}
1480
1481fn detect_license_kind(content: &str) -> LicenseKind {
1482 let lower = content.to_ascii_lowercase();
1483 let normalized = lower.trim();
1484
1485 if normalized == "mit"
1486 || normalized == "mit license"
1487 || lower.contains("mit license")
1488 || lower.contains("permission is hereby granted")
1489 {
1490 LicenseKind::Mit
1491 } else if normalized == "apache-2.0"
1492 || normalized == "apache 2.0"
1493 || lower.contains("apache license")
1494 || lower.contains("apache-2.0")
1495 || lower.contains("www.apache.org/licenses/license-2.0")
1496 {
1497 LicenseKind::Apache2
1498 } else if normalized.starts_with("gpl")
1499 || lower.contains("gnu general public license")
1500 || lower.contains("gpl")
1501 {
1502 LicenseKind::Gpl
1503 } else if normalized.starts_with("bsd")
1504 || (lower.contains("bsd") && lower.contains("redistribution and use"))
1505 {
1506 LicenseKind::Bsd
1507 } else {
1508 LicenseKind::Unknown
1509 }
1510}
1511
1512fn observe_hygiene_file(path: &Path, hygiene: &mut ScanHygiene) {
1513 match path.file_name().and_then(|value| value.to_str()) {
1514 Some(".gitignore") => hygiene.has_gitignore = true,
1515 Some(".ignore") => hygiene.has_ignore = true,
1516 _ => {}
1517 }
1518}
1519
1520fn observe_ci_file(path: &Path, ci: &mut CiSummary) {
1521 if has_component_pair(path, ".github", "workflows") {
1522 ci.has_github_actions = true;
1523 }
1524 if has_component(path, ".workflow") {
1525 ci.has_gitee_go = true;
1526 }
1527 if path.file_name().and_then(|value| value.to_str()) == Some(".gitlab-ci.yml") {
1528 ci.has_gitlab_ci = true;
1529 }
1530 if has_component(path, ".circleci") {
1531 ci.has_circle_ci = true;
1532 }
1533 if path.file_name().and_then(|value| value.to_str()) == Some("Jenkinsfile") {
1534 ci.has_jenkins = true;
1535 }
1536}
1537
1538fn has_component(path: &Path, name: &str) -> bool {
1539 path.components()
1540 .any(|component| component == Component::Normal(OsStr::new(name)))
1541}
1542
1543fn has_component_pair(path: &Path, first: &str, second: &str) -> bool {
1544 let mut components = path.components();
1545 while let Some(component) = components.next() {
1546 if component == Component::Normal(OsStr::new(first))
1547 && components.next() == Some(Component::Normal(OsStr::new(second)))
1548 {
1549 return true;
1550 }
1551 }
1552
1553 false
1554}
1555
1556fn observe_container_file(path: &Path, containers: &mut ContainerSummary) {
1557 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
1558 return;
1559 };
1560 let file_name = file_name.to_ascii_lowercase();
1561
1562 if file_name == "dockerfile" || file_name.starts_with("dockerfile.") {
1563 containers.has_dockerfile = true;
1564 }
1565 if matches!(
1566 file_name.as_str(),
1567 "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml"
1568 ) {
1569 containers.has_compose_file = true;
1570 }
1571}
1572
1573#[derive(Default)]
1574struct MutableInventory {
1575 languages: Vec<LanguageSummary>,
1576 build_systems: Vec<BuildSystemSummary>,
1577 dependency_manifests: Vec<PathBuf>,
1578 tests: TestSummary,
1579 test_command_sources: Vec<PathBuf>,
1580 hygiene: ScanHygiene,
1581 documentation: DocumentationSummary,
1582 license_files: Vec<PathBuf>,
1583 ci: CiSummary,
1584 containers: ContainerSummary,
1585 files_scanned: usize,
1586 skipped_dirs: Vec<PathBuf>,
1587}
1588
1589impl MutableInventory {
1590 fn add_language(&mut self, kind: LanguageKind) {
1591 if let Some(language) = self
1592 .languages
1593 .iter_mut()
1594 .find(|language| language.kind == kind)
1595 {
1596 language.files += 1;
1597 return;
1598 }
1599
1600 self.languages.push(LanguageSummary { kind, files: 1 });
1601 }
1602
1603 fn add_build_system(&mut self, kind: BuildSystemKind, path: PathBuf) {
1604 if self
1605 .build_systems
1606 .iter()
1607 .any(|build_system| build_system.kind == kind && build_system.path == path)
1608 {
1609 return;
1610 }
1611
1612 self.build_systems.push(BuildSystemSummary { kind, path });
1613 }
1614
1615 fn add_dependency_manifest(&mut self, path: PathBuf) {
1616 if self
1617 .dependency_manifests
1618 .iter()
1619 .any(|existing| existing == &path)
1620 {
1621 return;
1622 }
1623
1624 self.dependency_manifests.push(path);
1625 }
1626
1627 fn add_test_command_source(&mut self, path: PathBuf) {
1628 if self
1629 .test_command_sources
1630 .iter()
1631 .any(|existing| existing == &path)
1632 {
1633 return;
1634 }
1635
1636 self.test_command_sources.push(path);
1637 }
1638
1639 fn add_license_file(&mut self, path: PathBuf) {
1640 if self.license_files.iter().any(|existing| existing == &path) {
1641 return;
1642 }
1643
1644 self.license_files.push(path);
1645 }
1646
1647 fn add_skipped_dir(&mut self, path: PathBuf) {
1648 if self.skipped_dirs.iter().any(|existing| existing == &path) {
1649 return;
1650 }
1651
1652 self.skipped_dirs.push(path);
1653 }
1654}
1655
1656fn build_report(
1657 project_name: &str,
1658 indicators: &[ProjectIndicator],
1659 inventory: &ScanInventory,
1660) -> ProjectReport {
1661 let summary = if indicators.is_empty() {
1662 format!("{project_name} was scanned, but no known project markers were detected yet.")
1663 } else {
1664 format!(
1665 "{project_name} was scanned across {} file(s), with {} project marker(s), {} language(s), and {} build system(s) detected.",
1666 inventory.files_scanned,
1667 indicators.len(),
1668 inventory.languages.len(),
1669 inventory.build_systems.len()
1670 )
1671 };
1672
1673 ProjectReport {
1674 summary,
1675 next_steps: vec![
1676 "Add language, dependency, and repository activity scanners.".to_owned(),
1677 "Keep CLI and GUI features backed by projd-core data structures.".to_owned(),
1678 "Export Markdown and JSON reports before adding richer GUI visualizations.".to_owned(),
1679 ],
1680 }
1681}
1682
1683fn yes_no(value: bool) -> &'static str {
1684 if value { "yes" } else { "no" }
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689 use super::*;
1690
1691 #[test]
1692 fn renders_markdown_without_indicators() {
1693 let scan = ProjectScan {
1694 root: PathBuf::from("/tmp/example"),
1695 project_name: "example".to_owned(),
1696 identity: ProjectIdentity {
1697 name: "example".to_owned(),
1698 version: None,
1699 kind: ProjectKind::Generic,
1700 source: IdentitySource::DirectoryName,
1701 },
1702 indicators: Vec::new(),
1703 languages: Vec::new(),
1704 build_systems: Vec::new(),
1705 dependencies: DependencySummary::default(),
1706 tests: TestSummary::default(),
1707 hygiene: ScanHygiene::default(),
1708 risks: RiskSummary::default(),
1709 documentation: DocumentationSummary::default(),
1710 license: LicenseSummary::default(),
1711 ci: CiSummary::default(),
1712 git: GitSummary::default(),
1713 containers: ContainerSummary::default(),
1714 files_scanned: 0,
1715 skipped_dirs: Vec::new(),
1716 report: ProjectReport {
1717 summary: "example summary".to_owned(),
1718 next_steps: vec!["next".to_owned()],
1719 },
1720 };
1721
1722 let rendered = render_markdown(&scan);
1723
1724 assert!(rendered.contains("# Project Report"));
1725 assert!(rendered.contains("example summary"));
1726 assert!(rendered.contains("No known project indicators"));
1727 }
1728}