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