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 mod discover;
11
12pub use discover::{
13 Confidence, DiscoverOptions, DiscoverSummary, DiscoveredRoot, RootCategory, RootKind,
14 category_token, discover_roots, relative_display, render_discover_json,
15 render_discover_markdown, summarize_roots,
16};
17
18pub const NAME: &str = "Projd";
19pub const VERSION: &str = env!("CARGO_PKG_VERSION");
20
21pub fn describe() -> &'static str {
22 "Scan software projects and generate structured reports."
23}
24
25#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
26pub struct ProjectScan {
27 pub root: PathBuf,
28 pub project_name: String,
29 pub identity: ProjectIdentity,
30 pub indicators: Vec<ProjectIndicator>,
31 pub languages: Vec<LanguageSummary>,
32 pub build_systems: Vec<BuildSystemSummary>,
33 pub code: CodeSummary,
34 pub dependencies: DependencySummary,
35 pub tests: TestSummary,
36 pub hygiene: ScanHygiene,
37 pub risks: RiskSummary,
38 pub health: HealthSummary,
39 pub documentation: DocumentationSummary,
40 pub license: LicenseSummary,
41 pub ci: CiSummary,
42 pub vcs: VcsSummary,
43 pub containers: ContainerSummary,
44 pub files_scanned: usize,
45 pub skipped_dirs: Vec<PathBuf>,
46 pub report: ProjectReport,
47}
48
49impl ProjectScan {
50 pub fn has_language(&self, kind: LanguageKind) -> bool {
51 self.languages.iter().any(|language| language.kind == kind)
52 }
53
54 pub fn has_build_system(&self, kind: BuildSystemKind) -> bool {
55 self.build_systems
56 .iter()
57 .any(|build_system| build_system.kind == kind)
58 }
59
60 pub fn has_risk(&self, code: RiskCode) -> bool {
61 self.risks.findings.iter().any(|risk| risk.code == code)
62 }
63
64 pub fn risk(&self, code: RiskCode) -> Option<&RiskFinding> {
65 self.risks.findings.iter().find(|risk| risk.code == code)
66 }
67}
68
69#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
70pub struct ProjectIdentity {
71 pub name: String,
72 pub version: Option<String>,
73 pub kind: ProjectKind,
74 pub source: IdentitySource,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum ProjectKind {
80 RustWorkspace,
81 RustPackage,
82 NodePackage,
83 PythonProject,
84 Generic,
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
88#[serde(rename_all = "kebab-case")]
89pub enum IdentitySource {
90 CargoToml,
91 PackageJson,
92 PyprojectToml,
93 DirectoryName,
94}
95
96#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
97pub struct ProjectIndicator {
98 pub kind: IndicatorKind,
99 pub path: PathBuf,
100}
101
102#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum IndicatorKind {
105 RustWorkspace,
106 RustPackage,
107 GitRepository,
108 Readme,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
112pub struct LanguageSummary {
113 pub kind: LanguageKind,
114 pub files: usize,
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum LanguageKind {
120 Rust,
121 TypeScript,
122 JavaScript,
123 Python,
124 C,
125 Cpp,
126 Go,
127}
128
129#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
130pub struct BuildSystemSummary {
131 pub kind: BuildSystemKind,
132 pub path: PathBuf,
133}
134
135#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
136#[serde(rename_all = "kebab-case")]
137pub enum BuildSystemKind {
138 Cargo,
139 NodePackage,
140 PythonProject,
141 PythonRequirements,
142 CMake,
143 GoModule,
144}
145
146#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
147pub struct CodeSummary {
148 pub languages: Vec<CodeLanguageSummary>,
149 pub total_files: usize,
150 pub total_lines: usize,
151 pub code_lines: usize,
152 pub comment_lines: usize,
153 pub blank_lines: usize,
154}
155
156#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
157pub struct CodeLanguageSummary {
158 pub kind: LanguageKind,
159 pub files: usize,
160 pub total_lines: usize,
161 pub code_lines: usize,
162 pub comment_lines: usize,
163 pub blank_lines: usize,
164}
165
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167struct CodeLineCounts {
168 total_lines: usize,
169 code_lines: usize,
170 comment_lines: usize,
171 blank_lines: usize,
172}
173
174#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
175pub struct DependencySummary {
176 pub ecosystems: Vec<DependencyEcosystemSummary>,
177 pub total_manifests: usize,
178 pub total_dependencies: usize,
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
182pub struct DependencyEcosystemSummary {
183 pub ecosystem: DependencyEcosystem,
184 pub source: DependencySource,
185 pub manifest: PathBuf,
186 pub lockfile: Option<PathBuf>,
187 pub normal: usize,
188 pub development: usize,
189 pub build: usize,
190 pub optional: usize,
191 pub total: usize,
192}
193
194#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "kebab-case")]
196pub enum DependencyEcosystem {
197 Rust,
198 Node,
199 Python,
200}
201
202#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "kebab-case")]
204pub enum DependencySource {
205 CargoToml,
206 PackageJson,
207 PyprojectToml,
208 RequirementsTxt,
209}
210
211#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
212pub struct TestSummary {
213 pub has_tests_dir: bool,
214 pub test_files: usize,
215 pub test_directories: Vec<PathBuf>,
216 pub commands: Vec<TestCommand>,
217}
218
219#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
220pub struct TestCommand {
221 pub ecosystem: TestEcosystem,
222 pub source: PathBuf,
223 pub name: String,
224 pub command: String,
225}
226
227#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
228#[serde(rename_all = "kebab-case")]
229pub enum TestEcosystem {
230 Rust,
231 Node,
232 Python,
233 CMake,
234}
235
236#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
237pub struct ScanHygiene {
238 pub has_gitignore: bool,
239 pub has_ignore: bool,
240}
241
242#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
243pub struct RiskSummary {
244 pub findings: Vec<RiskFinding>,
245 pub total: usize,
246 pub high: usize,
247 pub medium: usize,
248 pub low: usize,
249 pub info: usize,
250}
251
252#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
253pub struct RiskFinding {
254 pub severity: RiskSeverity,
255 pub code: RiskCode,
256 pub message: String,
257 pub path: Option<PathBuf>,
258}
259
260#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
261pub struct HealthSummary {
262 pub grade: ProjectHealth,
263 pub score: usize,
264 pub risk_level: RiskSeverity,
265 pub signals: Vec<HealthSignal>,
266}
267
268#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
269#[serde(rename_all = "kebab-case")]
270pub enum ProjectHealth {
271 Healthy,
272 NeedsAttention,
273 Risky,
274 Unknown,
275}
276
277#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
278pub struct HealthSignal {
279 pub kind: HealthSignalKind,
280 pub status: HealthSignalStatus,
281 pub score_delta: i32,
282 pub evidence: String,
283}
284
285#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
286#[serde(rename_all = "kebab-case")]
287pub enum HealthSignalKind {
288 Readme,
289 License,
290 Ci,
291 Tests,
292 Lockfiles,
293 Vcs,
294 Docs,
295}
296
297#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
298#[serde(rename_all = "kebab-case")]
299pub enum HealthSignalStatus {
300 Pass,
301 Warn,
302 Info,
303}
304
305#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
306#[serde(rename_all = "kebab-case")]
307pub enum RiskSeverity {
308 High,
309 Medium,
310 Low,
311 Info,
312}
313
314#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
315#[serde(rename_all = "kebab-case")]
316pub enum RiskCode {
317 MissingReadme,
318 MissingLicense,
319 MissingCi,
320 NoTestsDetected,
321 ManifestWithoutLockfile,
322 LargeProjectWithoutIgnoreRules,
323 UnknownLicense,
324}
325
326#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
327pub struct DocumentationSummary {
328 pub has_readme: bool,
329 pub has_license: bool,
330 pub has_docs_dir: bool,
331}
332
333#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
334pub struct LicenseSummary {
335 pub path: Option<PathBuf>,
336 pub kind: LicenseKind,
337}
338
339#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
340#[serde(rename_all = "kebab-case")]
341pub enum LicenseKind {
342 Mit,
343 Apache2,
344 Gpl,
345 Bsd,
346 #[default]
347 Unknown,
348 Missing,
349}
350
351#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
352pub struct CiSummary {
353 pub has_github_actions: bool,
354 pub has_gitee_go: bool,
355 pub has_gitlab_ci: bool,
356 pub has_circle_ci: bool,
357 pub has_jenkins: bool,
358 pub providers: Vec<CiProviderSummary>,
359}
360
361#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
362pub struct CiProviderSummary {
363 pub provider: CiProvider,
364 pub path: PathBuf,
365}
366
367#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
368#[serde(rename_all = "kebab-case")]
369pub enum CiProvider {
370 GithubActions,
371 GiteeGo,
372 GitlabCi,
373 CircleCi,
374 Jenkins,
375}
376
377impl CiSummary {
378 pub fn has_provider(&self, provider: CiProvider) -> bool {
379 match provider {
380 CiProvider::GithubActions => self.has_github_actions,
381 CiProvider::GiteeGo => self.has_gitee_go,
382 CiProvider::GitlabCi => self.has_gitlab_ci,
383 CiProvider::CircleCi => self.has_circle_ci,
384 CiProvider::Jenkins => self.has_jenkins,
385 }
386 }
387
388 fn has_any_provider(&self) -> bool {
389 !self.providers.is_empty()
390 || self.has_github_actions
391 || self.has_gitee_go
392 || self.has_gitlab_ci
393 || self.has_circle_ci
394 || self.has_jenkins
395 }
396
397 fn add_provider(&mut self, provider: CiProvider, path: PathBuf) {
398 match provider {
399 CiProvider::GithubActions => self.has_github_actions = true,
400 CiProvider::GiteeGo => self.has_gitee_go = true,
401 CiProvider::GitlabCi => self.has_gitlab_ci = true,
402 CiProvider::CircleCi => self.has_circle_ci = true,
403 CiProvider::Jenkins => self.has_jenkins = true,
404 }
405
406 if self
407 .providers
408 .iter()
409 .any(|existing| existing.provider == provider && existing.path == path)
410 {
411 return;
412 }
413
414 self.providers.push(CiProviderSummary { provider, path });
415 }
416}
417
418#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
419#[serde(rename_all = "snake_case")]
420pub enum VcsKind {
421 #[default]
422 None,
423 Git,
424 Hg,
425 Svn,
426 Fossil,
427 Bzr,
428}
429
430impl VcsKind {
431 pub fn label(self) -> &'static str {
432 match self {
433 Self::None => "none",
434 Self::Git => "git",
435 Self::Hg => "hg",
436 Self::Svn => "svn",
437 Self::Fossil => "fossil",
438 Self::Bzr => "bzr",
439 }
440 }
441
442 pub fn ref_label(self) -> &'static str {
445 match self {
446 Self::Svn => "URL",
447 _ => "Branch",
448 }
449 }
450}
451
452#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
453pub struct VcsSummary {
454 pub kind: VcsKind,
455 pub is_repository: bool,
456 pub root: Option<PathBuf>,
457 pub branch: Option<String>,
459 pub revision: Option<String>,
461 pub last_commit: Option<String>,
463 pub is_dirty: bool,
464 pub tracked_modified_files: usize,
465 pub untracked_files: usize,
466}
467
468#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
469pub struct ContainerSummary {
470 pub has_dockerfile: bool,
471 pub has_compose_file: bool,
472}
473
474#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
475pub struct ProjectReport {
476 pub summary: String,
477 pub next_steps: Vec<String>,
478}
479
480pub fn scan_path(path: impl AsRef<Path>) -> Result<ProjectScan> {
481 let root = path.as_ref();
482 let metadata =
483 fs::metadata(root).with_context(|| format!("failed to inspect `{}`", root.display()))?;
484
485 if !metadata.is_dir() {
486 bail!("project path must be a directory: `{}`", root.display());
487 }
488
489 let root = root
490 .canonicalize()
491 .with_context(|| format!("failed to resolve `{}`", root.display()))?;
492 let project_name = root
493 .file_name()
494 .and_then(|value| value.to_str())
495 .unwrap_or("project")
496 .to_owned();
497 let identity = detect_identity(&root, &project_name);
498 let indicators = detect_indicators(&root);
499 let inventory = scan_inventory(&root)?;
500 let risks = build_risk_summary(&inventory);
501 let vcs = detect_vcs_summary(&root);
502 let health = build_health_summary(&inventory, &risks, &vcs);
503 let report = build_report(&identity.name, &indicators, &inventory, &health);
504
505 Ok(ProjectScan {
506 root,
507 project_name: identity.name.clone(),
508 identity,
509 indicators,
510 languages: inventory.languages,
511 build_systems: inventory.build_systems,
512 code: inventory.code,
513 dependencies: inventory.dependencies,
514 tests: inventory.tests,
515 hygiene: inventory.hygiene,
516 risks,
517 health,
518 documentation: inventory.documentation,
519 license: inventory.license,
520 ci: inventory.ci,
521 vcs,
522 containers: inventory.containers,
523 files_scanned: inventory.files_scanned,
524 skipped_dirs: inventory.skipped_dirs,
525 report,
526 })
527}
528
529pub fn render_markdown(scan: &ProjectScan) -> String {
530 let mut output = String::new();
531 output.push_str("# Project Report\n\n");
532 output.push_str(&format!("Project: `{}`\n\n", scan.project_name));
533 output.push_str(&format!("Root: `{}`\n\n", scan.root.display()));
534 output.push_str("## Identity\n\n");
535 output.push_str(&format!("- Name: `{}`\n", scan.identity.name));
536 output.push_str(&format!(
537 "- Version: {}\n",
538 scan.identity
539 .version
540 .as_deref()
541 .map(|version| format!("`{version}`"))
542 .unwrap_or_else(|| "unknown".to_owned())
543 ));
544 output.push_str(&format!("- Kind: {:?}\n", scan.identity.kind));
545 output.push_str(&format!("- Source: {:?}\n\n", scan.identity.source));
546 output.push_str("## Summary\n\n");
547 output.push_str(&scan.report.summary);
548 output.push_str("\n\n## Health\n\n");
549 output.push_str(&format!("- Grade: `{}`\n", scan.health.grade.as_str()));
550 output.push_str(&format!("- Score: {}/100\n", scan.health.score));
551 output.push_str(&format!(
552 "- Risk level: `{}`\n",
553 scan.health.risk_level.as_str()
554 ));
555 output.push_str("- Signals:\n");
556 for signal in &scan.health.signals {
557 output.push_str(&format!(
558 " - {}: {} ({:+}) - {}\n",
559 signal.kind.as_str(),
560 signal.status.as_str(),
561 signal.score_delta,
562 signal.evidence
563 ));
564 }
565
566 output.push_str("\n## Scan Observations\n\n");
567 output.push_str(&format!("- Files scanned: {}\n", scan.files_scanned));
568 output.push_str(&format!(
569 "- Skipped directories: {}\n",
570 scan.skipped_dirs.len()
571 ));
572 output.push_str(&format!(
573 "- `.gitignore`: {}\n",
574 yes_no(scan.hygiene.has_gitignore)
575 ));
576 output.push_str(&format!(
577 "- `.ignore`: {}\n",
578 yes_no(scan.hygiene.has_ignore)
579 ));
580
581 output.push_str("\n## Indicators\n\n");
582
583 if scan.indicators.is_empty() {
584 output.push_str("- No known project indicators detected.\n");
585 } else {
586 for indicator in &scan.indicators {
587 output.push_str(&format!(
588 "- {:?}: `{}`\n",
589 indicator.kind,
590 indicator.path.display()
591 ));
592 }
593 }
594
595 output.push_str("\n## Languages\n\n");
596 if scan.languages.is_empty() {
597 output.push_str("- No source language files detected.\n");
598 } else {
599 for language in &scan.languages {
600 output.push_str(&format!(
601 "- {:?}: {} file(s)\n",
602 language.kind, language.files
603 ));
604 }
605 }
606
607 output.push_str("\n## Code Statistics\n\n");
608 output.push_str(&format!(
609 "- Total source files: {}\n",
610 scan.code.total_files
611 ));
612 output.push_str(&format!("- Total lines: {}\n", scan.code.total_lines));
613 output.push_str(&format!("- Code lines: {}\n", scan.code.code_lines));
614 output.push_str(&format!("- Comment lines: {}\n", scan.code.comment_lines));
615 output.push_str(&format!("- Blank lines: {}\n", scan.code.blank_lines));
616 if scan.code.languages.is_empty() {
617 output.push_str("- No code statistics detected.\n");
618 } else {
619 output.push_str("- By language:\n");
620 for language in &scan.code.languages {
621 output.push_str(&format!(
622 " - {:?}: {} file(s), {} total, {} code, {} comment, {} blank\n",
623 language.kind,
624 language.files,
625 language.total_lines,
626 language.code_lines,
627 language.comment_lines,
628 language.blank_lines
629 ));
630 }
631 }
632
633 output.push_str("\n## Build Systems\n\n");
634 if scan.build_systems.is_empty() {
635 output.push_str("- No known build systems detected.\n");
636 } else {
637 for build_system in &scan.build_systems {
638 output.push_str(&format!(
639 "- {:?}: `{}`\n",
640 build_system.kind,
641 build_system.path.display()
642 ));
643 }
644 }
645
646 output.push_str("\n## Dependencies\n\n");
647 output.push_str(&format!(
648 "- Total manifests: {}\n",
649 scan.dependencies.total_manifests
650 ));
651 output.push_str(&format!(
652 "- Total dependency entries: {}\n",
653 scan.dependencies.total_dependencies
654 ));
655 if scan.dependencies.ecosystems.is_empty() {
656 output.push_str("- No dependency manifests detected.\n");
657 } else {
658 for summary in &scan.dependencies.ecosystems {
659 output.push_str(&format!(
660 "- {:?} {:?}: `{}` normal {}, dev {}, build {}, optional {}, total {}, lockfile {}\n",
661 summary.ecosystem,
662 summary.source,
663 summary.manifest.display(),
664 summary.normal,
665 summary.development,
666 summary.build,
667 summary.optional,
668 summary.total,
669 yes_no(summary.lockfile.is_some())
670 ));
671 }
672 }
673
674 output.push_str("\n## Tests\n\n");
675 output.push_str(&format!(
676 "- Test directories: {}\n",
677 scan.tests.test_directories.len()
678 ));
679 output.push_str(&format!("- Test files: {}\n", scan.tests.test_files));
680 if scan.tests.commands.is_empty() {
681 output.push_str("- Commands: none detected\n");
682 } else {
683 output.push_str("- Commands:\n");
684 for command in &scan.tests.commands {
685 output.push_str(&format!(
686 " - {:?}: {} (`{}`)\n",
687 command.ecosystem,
688 command.command,
689 command.source.display()
690 ));
691 }
692 }
693
694 output.push_str("\n## Risks\n\n");
695 if scan.risks.findings.is_empty() {
696 output.push_str("- No risks detected by current rules.\n");
697 } else {
698 for risk in &scan.risks.findings {
699 output.push_str(&format!(
700 "- {} {}: {}",
701 risk.severity.as_str(),
702 risk.code.as_str(),
703 risk.message
704 ));
705 if let Some(path) = &risk.path {
706 output.push_str(&format!(" (`{}`)", path.display()));
707 }
708 output.push('\n');
709 }
710 }
711
712 output.push_str("\n## Project Facilities\n\n");
713 output.push_str(&format!(
714 "- README: {}\n",
715 yes_no(scan.documentation.has_readme)
716 ));
717 output.push_str(&format!(
718 "- License: {}\n",
719 yes_no(scan.documentation.has_license)
720 ));
721 output.push_str(&format!("- License kind: {:?}\n", scan.license.kind));
722 output.push_str(&format!(
723 "- docs/: {}\n",
724 yes_no(scan.documentation.has_docs_dir)
725 ));
726 output.push_str(&format!(
727 "- GitHub Actions: {}\n",
728 yes_no(scan.ci.has_github_actions)
729 ));
730 output.push_str(&format!("- Gitee Go: {}\n", yes_no(scan.ci.has_gitee_go)));
731 output.push_str(&format!("- GitLab CI: {}\n", yes_no(scan.ci.has_gitlab_ci)));
732 output.push_str(&format!("- CircleCI: {}\n", yes_no(scan.ci.has_circle_ci)));
733 output.push_str(&format!("- Jenkins: {}\n", yes_no(scan.ci.has_jenkins)));
734 output.push_str(&format!(
735 "- {} repository: {}\n",
736 scan.vcs.kind.label(),
737 yes_no(scan.vcs.is_repository)
738 ));
739 output.push_str(&format!(
740 "- Dockerfile: {}\n",
741 yes_no(scan.containers.has_dockerfile)
742 ));
743 output.push_str(&format!(
744 "- Compose file: {}\n",
745 yes_no(scan.containers.has_compose_file)
746 ));
747
748 output.push_str("\n## Next Steps\n\n");
749 for step in &scan.report.next_steps {
750 output.push_str(&format!("- {step}\n"));
751 }
752
753 output
754}
755
756pub fn render_json(scan: &ProjectScan) -> Result<String> {
757 serde_json::to_string_pretty(scan).context("failed to render scan result as json")
758}
759
760struct ScanInventory {
761 languages: Vec<LanguageSummary>,
762 build_systems: Vec<BuildSystemSummary>,
763 code: CodeSummary,
764 dependencies: DependencySummary,
765 tests: TestSummary,
766 hygiene: ScanHygiene,
767 documentation: DocumentationSummary,
768 license: LicenseSummary,
769 ci: CiSummary,
770 containers: ContainerSummary,
771 files_scanned: usize,
772 skipped_dirs: Vec<PathBuf>,
773}
774
775fn detect_identity(root: &Path, fallback_name: &str) -> ProjectIdentity {
776 detect_cargo_identity(root, fallback_name)
777 .or_else(|| detect_node_identity(root))
778 .or_else(|| detect_python_identity(root))
779 .unwrap_or_else(|| ProjectIdentity {
780 name: fallback_name.to_owned(),
781 version: None,
782 kind: ProjectKind::Generic,
783 source: IdentitySource::DirectoryName,
784 })
785}
786
787fn detect_cargo_identity(root: &Path, fallback_name: &str) -> Option<ProjectIdentity> {
788 let manifest = read_toml_value(&root.join("Cargo.toml"))?;
789
790 if let Some(package) = manifest.get("package") {
791 let name = package.get("name")?.as_str()?.to_owned();
792 let version = package
793 .get("version")
794 .and_then(|value| value.as_str())
795 .map(str::to_owned);
796
797 return Some(ProjectIdentity {
798 name,
799 version,
800 kind: ProjectKind::RustPackage,
801 source: IdentitySource::CargoToml,
802 });
803 }
804
805 if manifest.get("workspace").is_some() {
806 let version = manifest
807 .get("workspace")
808 .and_then(|workspace| workspace.get("package"))
809 .and_then(|package| package.get("version"))
810 .and_then(|value| value.as_str())
811 .map(str::to_owned);
812
813 return Some(ProjectIdentity {
814 name: fallback_name.to_owned(),
815 version,
816 kind: ProjectKind::RustWorkspace,
817 source: IdentitySource::CargoToml,
818 });
819 }
820
821 None
822}
823
824fn detect_node_identity(root: &Path) -> Option<ProjectIdentity> {
825 let manifest = read_json_value(&root.join("package.json"))?;
826 let name = manifest.get("name")?.as_str()?.to_owned();
827 let version = manifest
828 .get("version")
829 .and_then(|value| value.as_str())
830 .map(str::to_owned);
831
832 Some(ProjectIdentity {
833 name,
834 version,
835 kind: ProjectKind::NodePackage,
836 source: IdentitySource::PackageJson,
837 })
838}
839
840fn detect_python_identity(root: &Path) -> Option<ProjectIdentity> {
841 let manifest = read_toml_value(&root.join("pyproject.toml"))?;
842 let project = manifest.get("project")?;
843 let name = project.get("name")?.as_str()?.to_owned();
844 let version = project
845 .get("version")
846 .and_then(|value| value.as_str())
847 .map(str::to_owned);
848
849 Some(ProjectIdentity {
850 name,
851 version,
852 kind: ProjectKind::PythonProject,
853 source: IdentitySource::PyprojectToml,
854 })
855}
856
857fn read_toml_value(path: &Path) -> Option<toml::Value> {
858 let content = fs::read_to_string(path).ok()?;
859 toml::from_str(&content).ok()
860}
861
862fn read_json_value(path: &Path) -> Option<serde_json::Value> {
863 let content = fs::read_to_string(path).ok()?;
864 serde_json::from_str(&content).ok()
865}
866
867fn build_dependency_summary(manifests: Vec<PathBuf>) -> DependencySummary {
868 let mut ecosystems = Vec::new();
869
870 for manifest in manifests {
871 let Some(file_name) = manifest.file_name().and_then(|value| value.to_str()) else {
872 continue;
873 };
874
875 let summary = match file_name {
876 "Cargo.toml" => summarize_cargo_dependencies(&manifest),
877 "package.json" => summarize_node_dependencies(&manifest),
878 "pyproject.toml" => summarize_pyproject_dependencies(&manifest),
879 "requirements.txt" => summarize_requirements_dependencies(&manifest),
880 _ => None,
881 };
882
883 if let Some(summary) = summary {
884 ecosystems.push(summary);
885 }
886 }
887
888 let total_manifests = ecosystems.len();
889 let total_dependencies = ecosystems.iter().map(|summary| summary.total).sum();
890
891 DependencySummary {
892 ecosystems,
893 total_manifests,
894 total_dependencies,
895 }
896}
897
898fn build_test_summary(mut tests: TestSummary, sources: Vec<PathBuf>) -> TestSummary {
899 for source in sources {
900 let Some(file_name) = source.file_name().and_then(|value| value.to_str()) else {
901 continue;
902 };
903
904 match file_name {
905 "Cargo.toml" => add_rust_test_command(&mut tests, &source),
906 "package.json" => add_node_test_commands(&mut tests, &source),
907 "pyproject.toml" => add_python_test_command(&mut tests, &source),
908 "CMakeLists.txt" => add_cmake_test_command(&mut tests, &source),
909 _ => {}
910 }
911 }
912
913 tests
914}
915
916fn build_risk_summary(inventory: &ScanInventory) -> RiskSummary {
917 let mut findings = Vec::new();
918
919 if !inventory.documentation.has_readme {
920 findings.push(RiskFinding {
921 severity: RiskSeverity::Medium,
922 code: RiskCode::MissingReadme,
923 message: "No README file detected.".to_owned(),
924 path: None,
925 });
926 }
927
928 if !inventory.documentation.has_license {
929 findings.push(RiskFinding {
930 severity: RiskSeverity::Medium,
931 code: RiskCode::MissingLicense,
932 message: "No LICENSE file detected.".to_owned(),
933 path: None,
934 });
935 } else if inventory.license.kind == LicenseKind::Unknown {
936 findings.push(RiskFinding {
937 severity: RiskSeverity::Low,
938 code: RiskCode::UnknownLicense,
939 message: "License file was detected, but the license type was not recognized."
940 .to_owned(),
941 path: inventory.license.path.clone(),
942 });
943 }
944
945 if !inventory.ci.has_any_provider() {
946 findings.push(RiskFinding {
947 severity: RiskSeverity::Low,
948 code: RiskCode::MissingCi,
949 message: "No known CI workflow detected.".to_owned(),
950 path: None,
951 });
952 }
953
954 if inventory.tests.test_files == 0 && inventory.tests.commands.is_empty() {
955 findings.push(RiskFinding {
956 severity: RiskSeverity::Medium,
957 code: RiskCode::NoTestsDetected,
958 message: "No test files or test commands detected.".to_owned(),
959 path: None,
960 });
961 }
962
963 for summary in &inventory.dependencies.ecosystems {
964 if summary.total > 0 && summary.lockfile.is_none() {
965 findings.push(RiskFinding {
966 severity: RiskSeverity::Low,
967 code: RiskCode::ManifestWithoutLockfile,
968 message: "Dependency manifest has entries but no lockfile was detected.".to_owned(),
969 path: Some(summary.manifest.clone()),
970 });
971 }
972 }
973
974 if inventory.files_scanned >= 1000
975 && !inventory.hygiene.has_gitignore
976 && !inventory.hygiene.has_ignore
977 {
978 findings.push(RiskFinding {
979 severity: RiskSeverity::Info,
980 code: RiskCode::LargeProjectWithoutIgnoreRules,
981 message: "Large project scanned without .gitignore or .ignore rules.".to_owned(),
982 path: None,
983 });
984 }
985
986 RiskSummary::from_findings(findings)
987}
988
989impl RiskSummary {
990 fn from_findings(findings: Vec<RiskFinding>) -> Self {
991 let total = findings.len();
992 let high = findings
993 .iter()
994 .filter(|risk| risk.severity == RiskSeverity::High)
995 .count();
996 let medium = findings
997 .iter()
998 .filter(|risk| risk.severity == RiskSeverity::Medium)
999 .count();
1000 let low = findings
1001 .iter()
1002 .filter(|risk| risk.severity == RiskSeverity::Low)
1003 .count();
1004 let info = findings
1005 .iter()
1006 .filter(|risk| risk.severity == RiskSeverity::Info)
1007 .count();
1008
1009 Self {
1010 findings,
1011 total,
1012 high,
1013 medium,
1014 low,
1015 info,
1016 }
1017 }
1018}
1019
1020impl RiskSeverity {
1021 fn as_str(self) -> &'static str {
1022 match self {
1023 Self::High => "high",
1024 Self::Medium => "medium",
1025 Self::Low => "low",
1026 Self::Info => "info",
1027 }
1028 }
1029}
1030
1031impl ProjectHealth {
1032 pub fn as_str(self) -> &'static str {
1033 match self {
1034 Self::Healthy => "healthy",
1035 Self::NeedsAttention => "needs-attention",
1036 Self::Risky => "risky",
1037 Self::Unknown => "unknown",
1038 }
1039 }
1040}
1041
1042impl HealthSignalKind {
1043 fn as_str(self) -> &'static str {
1044 match self {
1045 Self::Readme => "readme",
1046 Self::License => "license",
1047 Self::Ci => "ci",
1048 Self::Tests => "tests",
1049 Self::Lockfiles => "lockfiles",
1050 Self::Vcs => "vcs",
1051 Self::Docs => "docs",
1052 }
1053 }
1054}
1055
1056impl HealthSignalStatus {
1057 fn as_str(self) -> &'static str {
1058 match self {
1059 Self::Pass => "pass",
1060 Self::Warn => "warn",
1061 Self::Info => "info",
1062 }
1063 }
1064}
1065
1066fn build_health_summary(
1067 inventory: &ScanInventory,
1068 risks: &RiskSummary,
1069 vcs: &VcsSummary,
1070) -> HealthSummary {
1071 let mut signals = Vec::new();
1072
1073 signals.push(HealthSignal {
1074 kind: HealthSignalKind::Readme,
1075 status: pass_warn(inventory.documentation.has_readme),
1076 score_delta: if inventory.documentation.has_readme {
1077 10
1078 } else {
1079 -10
1080 },
1081 evidence: if inventory.documentation.has_readme {
1082 "README detected".to_owned()
1083 } else {
1084 "no README file detected".to_owned()
1085 },
1086 });
1087 signals.push(HealthSignal {
1088 kind: HealthSignalKind::License,
1089 status: pass_warn(inventory.documentation.has_license),
1090 score_delta: if inventory.documentation.has_license {
1091 20
1092 } else {
1093 -20
1094 },
1095 evidence: match (&inventory.license.path, inventory.license.kind) {
1096 (Some(path), kind) => format!("{} ({})", path.display(), kind.as_str()),
1097 (None, LicenseKind::Missing) => "no license file detected".to_owned(),
1098 (None, kind) => kind.as_str().to_owned(),
1099 },
1100 });
1101 signals.push(HealthSignal {
1102 kind: HealthSignalKind::Ci,
1103 status: pass_warn(inventory.ci.has_any_provider()),
1104 score_delta: if inventory.ci.has_any_provider() {
1105 20
1106 } else {
1107 -15
1108 },
1109 evidence: if inventory.ci.providers.is_empty() {
1110 "no known CI provider detected".to_owned()
1111 } else {
1112 let mut providers = inventory
1113 .ci
1114 .providers
1115 .iter()
1116 .map(|provider| provider.provider.as_str())
1117 .collect::<Vec<_>>();
1118 providers.sort_unstable();
1119 providers.dedup();
1120 providers.join(", ")
1121 },
1122 });
1123 let has_tests = inventory.tests.test_files > 0 || !inventory.tests.commands.is_empty();
1124 signals.push(HealthSignal {
1125 kind: HealthSignalKind::Tests,
1126 status: pass_warn(has_tests),
1127 score_delta: if has_tests { 20 } else { -20 },
1128 evidence: format!(
1129 "{} test file(s), {} command source(s)",
1130 inventory.tests.test_files,
1131 inventory.tests.commands.len()
1132 ),
1133 });
1134
1135 let lockfiles_ok = lockfiles_ok(&inventory.dependencies);
1136 signals.push(HealthSignal {
1137 kind: HealthSignalKind::Lockfiles,
1138 status: pass_warn(lockfiles_ok),
1139 score_delta: if lockfiles_ok { 10 } else { -10 },
1140 evidence: lockfile_evidence(&inventory.dependencies),
1141 });
1142
1143 let (vcs_status, vcs_delta, vcs_evidence) = if !vcs.is_repository {
1144 (
1145 HealthSignalStatus::Info,
1146 0,
1147 "no source control detected".to_owned(),
1148 )
1149 } else if vcs.is_dirty {
1150 (
1151 HealthSignalStatus::Warn,
1152 5,
1153 format!(
1154 "{} {}, dirty, {} modified, {} untracked",
1155 vcs.kind.label(),
1156 vcs.branch.as_deref().unwrap_or("unknown ref"),
1157 vcs.tracked_modified_files,
1158 vcs.untracked_files
1159 ),
1160 )
1161 } else {
1162 (
1163 HealthSignalStatus::Pass,
1164 15,
1165 format!(
1166 "{} {}, clean",
1167 vcs.kind.label(),
1168 vcs.branch.as_deref().unwrap_or("unknown ref")
1169 ),
1170 )
1171 };
1172 signals.push(HealthSignal {
1173 kind: HealthSignalKind::Vcs,
1174 status: vcs_status,
1175 score_delta: vcs_delta,
1176 evidence: vcs_evidence,
1177 });
1178 signals.push(HealthSignal {
1179 kind: HealthSignalKind::Docs,
1180 status: if inventory.documentation.has_docs_dir {
1181 HealthSignalStatus::Pass
1182 } else {
1183 HealthSignalStatus::Info
1184 },
1185 score_delta: if inventory.documentation.has_docs_dir {
1186 5
1187 } else {
1188 0
1189 },
1190 evidence: if inventory.documentation.has_docs_dir {
1191 "docs directory detected".to_owned()
1192 } else {
1193 "no docs directory detected".to_owned()
1194 },
1195 });
1196
1197 let positive_score = signals
1198 .iter()
1199 .filter(|signal| signal.score_delta > 0)
1200 .map(|signal| signal.score_delta as usize)
1201 .sum::<usize>();
1202 let score = positive_score
1203 .saturating_sub(risks.high * 20)
1204 .saturating_sub(risks.medium * 8)
1205 .saturating_sub(risks.low * 3)
1206 .min(100);
1207 let risk_level = overall_risk_level(risks);
1208 let grade = project_health_grade(score, risk_level, inventory.files_scanned);
1209
1210 HealthSummary {
1211 grade,
1212 score,
1213 risk_level,
1214 signals,
1215 }
1216}
1217
1218fn pass_warn(pass: bool) -> HealthSignalStatus {
1219 if pass {
1220 HealthSignalStatus::Pass
1221 } else {
1222 HealthSignalStatus::Warn
1223 }
1224}
1225
1226fn lockfiles_ok(dependencies: &DependencySummary) -> bool {
1227 dependencies
1228 .ecosystems
1229 .iter()
1230 .all(|summary| summary.total == 0 || summary.lockfile.is_some())
1231}
1232
1233fn lockfile_evidence(dependencies: &DependencySummary) -> String {
1234 if dependencies.ecosystems.is_empty() {
1235 return "no dependency manifests".to_owned();
1236 }
1237
1238 let lockfiles = dependencies
1239 .ecosystems
1240 .iter()
1241 .filter(|summary| summary.lockfile.is_some())
1242 .count();
1243 let missing = dependencies
1244 .ecosystems
1245 .iter()
1246 .filter(|summary| summary.total > 0 && summary.lockfile.is_none())
1247 .count();
1248
1249 format!(
1250 "{} manifest(s), {} lockfile(s), {} missing",
1251 dependencies.total_manifests, lockfiles, missing
1252 )
1253}
1254
1255fn overall_risk_level(risks: &RiskSummary) -> RiskSeverity {
1256 if risks.high > 0 {
1257 RiskSeverity::High
1258 } else if risks.medium > 0 {
1259 RiskSeverity::Medium
1260 } else if risks.low > 0 {
1261 RiskSeverity::Low
1262 } else {
1263 RiskSeverity::Info
1264 }
1265}
1266
1267fn project_health_grade(
1268 score: usize,
1269 risk_level: RiskSeverity,
1270 files_scanned: usize,
1271) -> ProjectHealth {
1272 if files_scanned == 0 {
1273 return ProjectHealth::Unknown;
1274 }
1275
1276 match (score, risk_level) {
1277 (_, RiskSeverity::High) => ProjectHealth::Risky,
1278 (80..=100, RiskSeverity::Info | RiskSeverity::Low) => ProjectHealth::Healthy,
1279 (50..=79, _) | (80..=100, RiskSeverity::Medium) => ProjectHealth::NeedsAttention,
1280 _ => ProjectHealth::Risky,
1281 }
1282}
1283
1284impl RiskCode {
1285 fn as_str(self) -> &'static str {
1286 match self {
1287 Self::MissingReadme => "missing-readme",
1288 Self::MissingLicense => "missing-license",
1289 Self::MissingCi => "missing-ci",
1290 Self::NoTestsDetected => "no-tests-detected",
1291 Self::ManifestWithoutLockfile => "manifest-without-lockfile",
1292 Self::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
1293 Self::UnknownLicense => "unknown-license",
1294 }
1295 }
1296}
1297
1298impl LicenseKind {
1299 fn as_str(self) -> &'static str {
1300 match self {
1301 Self::Mit => "MIT",
1302 Self::Apache2 => "Apache-2.0",
1303 Self::Gpl => "GPL",
1304 Self::Bsd => "BSD",
1305 Self::Unknown => "unknown",
1306 Self::Missing => "missing",
1307 }
1308 }
1309}
1310
1311impl CiProvider {
1312 fn as_str(self) -> &'static str {
1313 match self {
1314 Self::GithubActions => "GitHub Actions",
1315 Self::GiteeGo => "Gitee Go",
1316 Self::GitlabCi => "GitLab CI",
1317 Self::CircleCi => "CircleCI",
1318 Self::Jenkins => "Jenkins",
1319 }
1320 }
1321}
1322
1323fn add_rust_test_command(tests: &mut TestSummary, source: &Path) {
1324 add_test_command(
1325 tests,
1326 TestCommand {
1327 ecosystem: TestEcosystem::Rust,
1328 source: source.to_path_buf(),
1329 name: "cargo test".to_owned(),
1330 command: "cargo test".to_owned(),
1331 },
1332 );
1333}
1334
1335fn add_node_test_commands(tests: &mut TestSummary, source: &Path) {
1336 let Some(value) = read_json_value(source) else {
1337 return;
1338 };
1339 let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) else {
1340 return;
1341 };
1342
1343 for (name, command) in scripts {
1344 if name == "test" || name.starts_with("test:") {
1345 if let Some(command) = command.as_str() {
1346 add_test_command(
1347 tests,
1348 TestCommand {
1349 ecosystem: TestEcosystem::Node,
1350 source: source.to_path_buf(),
1351 name: name.to_owned(),
1352 command: command.to_owned(),
1353 },
1354 );
1355 }
1356 }
1357 }
1358}
1359
1360fn add_python_test_command(tests: &mut TestSummary, source: &Path) {
1361 let Some(value) = read_toml_value(source) else {
1362 return;
1363 };
1364
1365 let has_pytest_config = value
1366 .get("tool")
1367 .and_then(|tool| tool.get("pytest"))
1368 .and_then(|pytest| pytest.get("ini_options"))
1369 .is_some();
1370
1371 if has_pytest_config {
1372 add_test_command(
1373 tests,
1374 TestCommand {
1375 ecosystem: TestEcosystem::Python,
1376 source: source.to_path_buf(),
1377 name: "pytest".to_owned(),
1378 command: "pytest".to_owned(),
1379 },
1380 );
1381 }
1382}
1383
1384fn add_cmake_test_command(tests: &mut TestSummary, source: &Path) {
1385 let Ok(content) = fs::read_to_string(source) else {
1386 return;
1387 };
1388 let lower = content.to_ascii_lowercase();
1389
1390 if lower.contains("enable_testing") || lower.contains("add_test") {
1391 add_test_command(
1392 tests,
1393 TestCommand {
1394 ecosystem: TestEcosystem::CMake,
1395 source: source.to_path_buf(),
1396 name: "ctest".to_owned(),
1397 command: "ctest".to_owned(),
1398 },
1399 );
1400 }
1401}
1402
1403fn add_test_command(tests: &mut TestSummary, command: TestCommand) {
1404 if tests.commands.iter().any(|existing| {
1405 existing.ecosystem == command.ecosystem
1406 && existing.source == command.source
1407 && existing.name == command.name
1408 && existing.command == command.command
1409 }) {
1410 return;
1411 }
1412
1413 tests.commands.push(command);
1414}
1415
1416fn summarize_cargo_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1417 let value = read_toml_value(manifest)?;
1418 let normal = count_toml_table_entries(value.get("dependencies"));
1419 let development = count_toml_table_entries(value.get("dev-dependencies"));
1420 let build = count_toml_table_entries(value.get("build-dependencies"));
1421 let optional = count_optional_cargo_dependencies(value.get("dependencies"));
1422 let total = normal + development + build;
1423
1424 Some(DependencyEcosystemSummary {
1425 ecosystem: DependencyEcosystem::Rust,
1426 source: DependencySource::CargoToml,
1427 manifest: manifest.to_path_buf(),
1428 lockfile: find_nearest_lockfile(manifest, &["Cargo.lock"]),
1429 normal,
1430 development,
1431 build,
1432 optional,
1433 total,
1434 })
1435}
1436
1437fn summarize_node_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1438 let value = read_json_value(manifest)?;
1439 let normal = count_json_object_entries(value.get("dependencies"));
1440 let development = count_json_object_entries(value.get("devDependencies"));
1441 let optional = count_json_object_entries(value.get("optionalDependencies"));
1442 let total = normal + development + optional;
1443
1444 Some(DependencyEcosystemSummary {
1445 ecosystem: DependencyEcosystem::Node,
1446 source: DependencySource::PackageJson,
1447 manifest: manifest.to_path_buf(),
1448 lockfile: find_sibling_lockfile(
1449 manifest,
1450 &[
1451 "pnpm-lock.yaml",
1452 "yarn.lock",
1453 "package-lock.json",
1454 "bun.lockb",
1455 ],
1456 ),
1457 normal,
1458 development,
1459 build: 0,
1460 optional,
1461 total,
1462 })
1463}
1464
1465fn summarize_pyproject_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1466 let value = read_toml_value(manifest)?;
1467 let project = value.get("project");
1468 let normal = project
1469 .and_then(|project| project.get("dependencies"))
1470 .and_then(|dependencies| dependencies.as_array())
1471 .map_or(0, Vec::len);
1472 let optional = project
1473 .and_then(|project| project.get("optional-dependencies"))
1474 .and_then(|dependencies| dependencies.as_table())
1475 .map(|groups| {
1476 groups
1477 .values()
1478 .filter_map(|value| value.as_array())
1479 .map(Vec::len)
1480 .sum()
1481 })
1482 .unwrap_or(0);
1483 let development = project
1484 .and_then(|project| project.get("optional-dependencies"))
1485 .and_then(|dependencies| dependencies.get("dev"))
1486 .and_then(|dependencies| dependencies.as_array())
1487 .map_or(0, Vec::len);
1488 let total = normal + optional;
1489
1490 Some(DependencyEcosystemSummary {
1491 ecosystem: DependencyEcosystem::Python,
1492 source: DependencySource::PyprojectToml,
1493 manifest: manifest.to_path_buf(),
1494 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1495 normal,
1496 development,
1497 build: 0,
1498 optional,
1499 total,
1500 })
1501}
1502
1503fn summarize_requirements_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1504 let content = fs::read_to_string(manifest).ok()?;
1505 let normal = content
1506 .lines()
1507 .map(str::trim)
1508 .filter(|line| is_requirement_entry(line))
1509 .count();
1510
1511 Some(DependencyEcosystemSummary {
1512 ecosystem: DependencyEcosystem::Python,
1513 source: DependencySource::RequirementsTxt,
1514 manifest: manifest.to_path_buf(),
1515 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1516 normal,
1517 development: 0,
1518 build: 0,
1519 optional: 0,
1520 total: normal,
1521 })
1522}
1523
1524fn count_toml_table_entries(value: Option<&toml::Value>) -> usize {
1525 value
1526 .and_then(|value| value.as_table())
1527 .map_or(0, |table| table.len())
1528}
1529
1530fn count_optional_cargo_dependencies(value: Option<&toml::Value>) -> usize {
1531 value
1532 .and_then(|value| value.as_table())
1533 .map(|dependencies| {
1534 dependencies
1535 .values()
1536 .filter(|value| {
1537 value
1538 .as_table()
1539 .and_then(|table| table.get("optional"))
1540 .and_then(|optional| optional.as_bool())
1541 .unwrap_or(false)
1542 })
1543 .count()
1544 })
1545 .unwrap_or(0)
1546}
1547
1548fn count_json_object_entries(value: Option<&serde_json::Value>) -> usize {
1549 value
1550 .and_then(|value| value.as_object())
1551 .map_or(0, |object| object.len())
1552}
1553
1554fn is_requirement_entry(line: &str) -> bool {
1555 !line.is_empty() && !line.starts_with('#') && !line.starts_with('-') && !line.starts_with("--")
1556}
1557
1558fn find_nearest_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1559 let mut current = manifest.parent();
1560 while let Some(dir) = current {
1561 if let Some(lockfile) = find_lockfile_in_dir(dir, names) {
1562 return Some(lockfile);
1563 }
1564 current = dir.parent();
1565 }
1566
1567 None
1568}
1569
1570fn find_sibling_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1571 find_lockfile_in_dir(manifest.parent()?, names)
1572}
1573
1574fn find_lockfile_in_dir(dir: &Path, names: &[&str]) -> Option<PathBuf> {
1575 names
1576 .iter()
1577 .map(|name| dir.join(name))
1578 .find(|path| path.is_file())
1579}
1580
1581fn detect_vcs_summary(root: &Path) -> VcsSummary {
1582 if root.join(".git").exists() {
1583 return detect_git_into(root);
1584 }
1585 if root.join(".hg").is_dir() {
1586 return detect_hg_into(root);
1587 }
1588 if root.join(".svn").is_dir() {
1589 return detect_svn_into(root);
1590 }
1591 if root.join(".fslckout").is_file() || root.join("_FOSSIL_").is_file() {
1592 return VcsSummary {
1593 kind: VcsKind::Fossil,
1594 is_repository: true,
1595 root: Some(root.to_path_buf()),
1596 ..VcsSummary::default()
1597 };
1598 }
1599 if root.join(".bzr").is_dir() {
1600 return VcsSummary {
1601 kind: VcsKind::Bzr,
1602 is_repository: true,
1603 root: Some(root.to_path_buf()),
1604 ..VcsSummary::default()
1605 };
1606 }
1607 VcsSummary::default()
1608}
1609
1610fn detect_git_into(root: &Path) -> VcsSummary {
1611 let detected_root = run_vcs_output("git", root, &["rev-parse", "--show-toplevel"])
1612 .and_then(|output| output.lines().next().map(PathBuf::from))
1613 .or_else(|| Some(root.to_path_buf()));
1614
1615 let branch = run_vcs_output("git", root, &["branch", "--show-current"])
1616 .and_then(|output| non_empty_trimmed(&output));
1617 let revision = run_vcs_output("git", root, &["rev-parse", "HEAD"])
1618 .and_then(|output| non_empty_trimmed(&output));
1619 let last_commit = run_vcs_output("git", root, &["log", "-1", "--format=%cI"])
1620 .and_then(|output| non_empty_trimmed(&output));
1621 let porcelain = run_vcs_output("git", root, &["status", "--porcelain"]).unwrap_or_default();
1622 let mut tracked_modified_files = 0;
1623 let mut untracked_files = 0;
1624
1625 for line in porcelain.lines() {
1626 if line.starts_with("??") {
1627 untracked_files += 1;
1628 } else if !line.trim().is_empty() {
1629 tracked_modified_files += 1;
1630 }
1631 }
1632
1633 VcsSummary {
1634 kind: VcsKind::Git,
1635 is_repository: true,
1636 root: detected_root,
1637 branch,
1638 revision,
1639 last_commit,
1640 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1641 tracked_modified_files,
1642 untracked_files,
1643 }
1644}
1645
1646fn detect_hg_into(root: &Path) -> VcsSummary {
1647 let detected_root = run_vcs_output("hg", root, &["root"])
1648 .and_then(|output| output.lines().next().map(PathBuf::from))
1649 .or_else(|| Some(root.to_path_buf()));
1650
1651 let branch = run_vcs_output("hg", root, &["branch"]).and_then(|s| non_empty_trimmed(&s));
1652 let revision = run_vcs_output("hg", root, &["log", "-r", ".", "--template", "{node}"])
1653 .and_then(|s| non_empty_trimmed(&s));
1654 let last_commit = run_vcs_output(
1655 "hg",
1656 root,
1657 &["log", "-r", ".", "--template", "{date|isodatesec}"],
1658 )
1659 .and_then(|s| non_empty_trimmed(&s));
1660
1661 let status_text = run_vcs_output("hg", root, &["status"]).unwrap_or_default();
1662 let mut tracked_modified_files = 0;
1663 let mut untracked_files = 0;
1664 for line in status_text.lines() {
1665 let mut chars = line.chars();
1666 match chars.next() {
1667 Some('?') => untracked_files += 1,
1668 Some(c) if "MARC!".contains(c) => tracked_modified_files += 1,
1669 _ => {}
1670 }
1671 }
1672
1673 VcsSummary {
1674 kind: VcsKind::Hg,
1675 is_repository: true,
1676 root: detected_root,
1677 branch,
1678 revision,
1679 last_commit,
1680 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1681 tracked_modified_files,
1682 untracked_files,
1683 }
1684}
1685
1686fn detect_svn_into(root: &Path) -> VcsSummary {
1687 let detected_root = run_vcs_output("svn", root, &["info", "--show-item", "wc-root"])
1688 .and_then(|s| non_empty_trimmed(&s))
1689 .map(PathBuf::from)
1690 .or_else(|| Some(root.to_path_buf()));
1691 let branch = run_vcs_output("svn", root, &["info", "--show-item", "url"])
1692 .and_then(|s| non_empty_trimmed(&s));
1693 let revision = run_vcs_output("svn", root, &["info", "--show-item", "revision"])
1694 .and_then(|s| non_empty_trimmed(&s));
1695 let last_commit = run_vcs_output("svn", root, &["info", "--show-item", "last-changed-date"])
1696 .and_then(|s| non_empty_trimmed(&s));
1697
1698 let status_text = run_vcs_output("svn", root, &["status"]).unwrap_or_default();
1699 let mut tracked_modified_files = 0;
1700 let mut untracked_files = 0;
1701 for line in status_text.lines() {
1702 let mut chars = line.chars();
1703 match chars.next() {
1704 Some('?') => untracked_files += 1,
1705 Some(c) if "ADMRC!~".contains(c) => tracked_modified_files += 1,
1706 _ => {}
1707 }
1708 }
1709
1710 VcsSummary {
1711 kind: VcsKind::Svn,
1712 is_repository: true,
1713 root: detected_root,
1714 branch,
1715 revision,
1716 last_commit,
1717 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1718 tracked_modified_files,
1719 untracked_files,
1720 }
1721}
1722
1723fn run_vcs_output(program: &str, root: &Path, args: &[&str]) -> Option<String> {
1724 let output = Command::new(program)
1725 .args(args)
1726 .current_dir(root)
1727 .output()
1728 .ok()?;
1729
1730 if !output.status.success() {
1731 return None;
1732 }
1733
1734 Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1735}
1736
1737fn non_empty_trimmed(value: &str) -> Option<String> {
1738 let value = value.trim();
1739 if value.is_empty() {
1740 None
1741 } else {
1742 Some(value.to_owned())
1743 }
1744}
1745
1746fn detect_indicators(root: &Path) -> Vec<ProjectIndicator> {
1747 let checks = [
1748 ("Cargo.toml", IndicatorKind::RustWorkspace),
1749 (".git", IndicatorKind::GitRepository),
1750 ("README.md", IndicatorKind::Readme),
1751 ];
1752
1753 let mut indicators = Vec::new();
1754 for (relative, kind) in checks {
1755 let path = root.join(relative);
1756 if path.exists() {
1757 indicators.push(ProjectIndicator { kind, path });
1758 }
1759 }
1760
1761 let cargo_manifest = root.join("Cargo.toml");
1762 if cargo_manifest.exists() {
1763 indicators.push(ProjectIndicator {
1764 kind: IndicatorKind::RustPackage,
1765 path: cargo_manifest,
1766 });
1767 }
1768
1769 indicators
1770}
1771
1772fn scan_inventory(root: &Path) -> Result<ScanInventory> {
1773 let mut inventory = MutableInventory::default();
1774 let ignore_matcher = IgnoreMatcher::new(root);
1775 walk_project(root, &ignore_matcher, &mut inventory)?;
1776
1777 Ok(ScanInventory {
1778 languages: inventory.languages,
1779 build_systems: inventory.build_systems,
1780 code: inventory.code,
1781 dependencies: build_dependency_summary(inventory.dependency_manifests),
1782 tests: build_test_summary(inventory.tests, inventory.test_command_sources),
1783 hygiene: inventory.hygiene,
1784 documentation: inventory.documentation,
1785 license: build_license_summary(&inventory.license_files),
1786 ci: inventory.ci,
1787 containers: inventory.containers,
1788 files_scanned: inventory.files_scanned,
1789 skipped_dirs: inventory.skipped_dirs,
1790 })
1791}
1792
1793fn walk_project(
1794 path: &Path,
1795 ignore_matcher: &IgnoreMatcher,
1796 inventory: &mut MutableInventory,
1797) -> Result<()> {
1798 let local_ignore_matcher = ignore_matcher.with_child_ignores(path);
1799 let mut entries = Vec::new();
1800
1801 for entry in
1802 fs::read_dir(path).with_context(|| format!("failed to read `{}`", path.display()))?
1803 {
1804 let entry =
1805 entry.with_context(|| format!("failed to read entry under `{}`", path.display()))?;
1806 entries.push(entry);
1807 }
1808
1809 entries.sort_by_key(|entry| entry.file_name());
1810
1811 for entry in entries {
1812 let entry_path = entry.path();
1813 let file_type = entry
1814 .file_type()
1815 .with_context(|| format!("failed to inspect `{}`", entry_path.display()))?;
1816
1817 if file_type.is_dir() {
1818 if should_skip_dir(entry.file_name().as_os_str())
1819 || local_ignore_matcher.is_ignored(&entry_path, true)
1820 {
1821 inventory.add_skipped_dir(entry_path);
1822 continue;
1823 }
1824
1825 observe_directory(&entry_path, inventory);
1826 walk_project(&entry_path, &local_ignore_matcher, inventory)?;
1827 continue;
1828 }
1829
1830 if file_type.is_file() {
1831 if local_ignore_matcher.is_ignored(&entry_path, false) {
1832 continue;
1833 }
1834
1835 inventory.files_scanned += 1;
1836 observe_file(&entry_path, inventory);
1837 }
1838 }
1839
1840 Ok(())
1841}
1842
1843fn should_skip_dir(name: &OsStr) -> bool {
1844 matches!(
1845 name.to_str(),
1846 Some(".git" | ".hg" | ".svn" | "target" | "node_modules")
1847 )
1848}
1849
1850#[derive(Clone)]
1851struct IgnoreMatcher {
1852 matchers: Vec<Gitignore>,
1853}
1854
1855impl IgnoreMatcher {
1856 fn new(root: &Path) -> Self {
1857 Self {
1858 matchers: Self::build_matchers(root),
1859 }
1860 }
1861
1862 fn with_child_ignores(&self, dir: &Path) -> Self {
1863 let mut matchers = self.matchers.clone();
1864 matchers.extend(Self::build_matchers(dir));
1865 Self { matchers }
1866 }
1867
1868 fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
1869 self.matchers
1870 .iter()
1871 .rev()
1872 .find_map(|matcher| {
1873 let result = matcher.matched(path, is_dir);
1874 if result.is_none() {
1875 None
1876 } else {
1877 Some(result.is_ignore())
1878 }
1879 })
1880 .unwrap_or(false)
1881 }
1882
1883 fn build_matchers(dir: &Path) -> Vec<Gitignore> {
1884 [".gitignore", ".ignore"]
1885 .into_iter()
1886 .filter_map(|file_name| Self::build_matcher(dir, file_name))
1887 .collect()
1888 }
1889
1890 fn build_matcher(dir: &Path, file_name: &str) -> Option<Gitignore> {
1891 let path = dir.join(file_name);
1892 if !path.is_file() {
1893 return None;
1894 }
1895
1896 let mut builder = GitignoreBuilder::new(dir);
1897 let _ = builder.add(&path);
1898 builder.build().ok()
1899 }
1900}
1901
1902fn observe_directory(path: &Path, inventory: &mut MutableInventory) {
1903 if path.file_name().and_then(|value| value.to_str()) == Some("docs") {
1904 inventory.documentation.has_docs_dir = true;
1905 }
1906 if is_test_directory(path) {
1907 add_test_directory(&mut inventory.tests, path.to_path_buf());
1908 }
1909}
1910
1911fn observe_file(path: &Path, inventory: &mut MutableInventory) {
1912 if let Some(kind) = language_for_path(path) {
1913 inventory.add_language(kind);
1914 inventory.add_code_file(kind, path);
1915 }
1916
1917 if let Some(kind) = build_system_for_path(path) {
1918 inventory.add_build_system(kind, path.to_path_buf());
1919 }
1920 if is_dependency_manifest(path) {
1921 inventory.add_dependency_manifest(path.to_path_buf());
1922 }
1923 if is_test_file(path) {
1924 inventory.tests.test_files += 1;
1925 }
1926 if is_test_command_source(path) {
1927 inventory.add_test_command_source(path.to_path_buf());
1928 }
1929 observe_hygiene_file(path, &mut inventory.hygiene);
1930
1931 observe_documentation_file(path, &mut inventory.documentation);
1932 if is_license_file(path) {
1933 inventory.add_license_file(path.to_path_buf());
1934 }
1935 observe_ci_file(path, &mut inventory.ci);
1936 observe_container_file(path, &mut inventory.containers);
1937}
1938
1939fn language_for_path(path: &Path) -> Option<LanguageKind> {
1940 let extension = path
1941 .extension()
1942 .and_then(|value| value.to_str())
1943 .map(str::to_ascii_lowercase)?;
1944
1945 match extension.as_str() {
1946 "rs" => Some(LanguageKind::Rust),
1947 "ts" | "tsx" => Some(LanguageKind::TypeScript),
1948 "js" | "jsx" | "mjs" | "cjs" => Some(LanguageKind::JavaScript),
1949 "py" => Some(LanguageKind::Python),
1950 "c" => Some(LanguageKind::C),
1951 "cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some(LanguageKind::Cpp),
1952 "go" => Some(LanguageKind::Go),
1953 _ => None,
1954 }
1955}
1956
1957fn count_code_lines(path: &Path, kind: LanguageKind) -> CodeLineCounts {
1958 let Ok(content) = fs::read_to_string(path) else {
1959 return CodeLineCounts::default();
1960 };
1961
1962 let mut counts = CodeLineCounts::default();
1963 let mut in_block_comment = false;
1964
1965 for line in content.lines() {
1966 counts.total_lines += 1;
1967 let trimmed = line.trim();
1968
1969 if trimmed.is_empty() {
1970 counts.blank_lines += 1;
1971 continue;
1972 }
1973
1974 let classification = classify_code_line(trimmed, kind, &mut in_block_comment);
1975 if classification.has_comment {
1976 counts.comment_lines += 1;
1977 }
1978 if classification.has_code {
1979 counts.code_lines += 1;
1980 }
1981 }
1982
1983 counts
1984}
1985
1986#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1987struct CodeLineClassification {
1988 has_code: bool,
1989 has_comment: bool,
1990}
1991
1992fn classify_code_line(
1993 mut line: &str,
1994 kind: LanguageKind,
1995 in_block_comment: &mut bool,
1996) -> CodeLineClassification {
1997 if !supports_c_style_block_comments(kind) {
1998 return classify_simple_comment_line(line, kind);
1999 }
2000
2001 let mut classification = CodeLineClassification::default();
2002
2003 loop {
2004 if *in_block_comment {
2005 classification.has_comment = true;
2006 if let Some(end) = line.find("*/") {
2007 *in_block_comment = false;
2008 line = &line[end + 2..];
2009 if line.trim().is_empty() {
2010 break;
2011 }
2012 continue;
2013 }
2014 break;
2015 }
2016
2017 let current = line.trim_start();
2018 if current.is_empty() {
2019 break;
2020 }
2021 if current.starts_with("//") {
2022 classification.has_comment = true;
2023 break;
2024 }
2025
2026 let block_start = current.find("/*");
2027 let line_comment_start = current.find("//");
2028
2029 match (block_start, line_comment_start) {
2030 (Some(block), Some(comment)) if comment < block => {
2031 classification.has_comment = true;
2032 if !current[..comment].trim().is_empty() {
2033 classification.has_code = true;
2034 }
2035 break;
2036 }
2037 (Some(block), _) => {
2038 classification.has_comment = true;
2039 if !current[..block].trim().is_empty() {
2040 classification.has_code = true;
2041 }
2042 let after_block_start = ¤t[block + 2..];
2043 if let Some(end) = after_block_start.find("*/") {
2044 line = &after_block_start[end + 2..];
2045 if line.trim().is_empty() {
2046 break;
2047 }
2048 continue;
2049 }
2050 *in_block_comment = true;
2051 break;
2052 }
2053 (None, Some(comment)) => {
2054 classification.has_comment = true;
2055 if !current[..comment].trim().is_empty() {
2056 classification.has_code = true;
2057 }
2058 break;
2059 }
2060 (None, None) => {
2061 classification.has_code = true;
2062 break;
2063 }
2064 }
2065 }
2066
2067 classification
2068}
2069
2070fn classify_simple_comment_line(line: &str, kind: LanguageKind) -> CodeLineClassification {
2071 let markers = simple_comment_markers(kind);
2072
2073 if markers.iter().any(|marker| line.starts_with(marker)) {
2074 CodeLineClassification {
2075 has_code: false,
2076 has_comment: true,
2077 }
2078 } else {
2079 CodeLineClassification {
2080 has_code: true,
2081 has_comment: markers
2082 .iter()
2083 .any(|marker| line.find(marker).is_some_and(|index| index > 0)),
2084 }
2085 }
2086}
2087
2088fn supports_c_style_block_comments(kind: LanguageKind) -> bool {
2089 matches!(
2090 kind,
2091 LanguageKind::Rust
2092 | LanguageKind::TypeScript
2093 | LanguageKind::JavaScript
2094 | LanguageKind::C
2095 | LanguageKind::Cpp
2096 | LanguageKind::Go
2097 )
2098}
2099
2100fn simple_comment_markers(kind: LanguageKind) -> &'static [&'static str] {
2101 match kind {
2102 LanguageKind::Python => &["#"],
2103 LanguageKind::Rust
2104 | LanguageKind::TypeScript
2105 | LanguageKind::JavaScript
2106 | LanguageKind::C
2107 | LanguageKind::Cpp
2108 | LanguageKind::Go => &["//"],
2109 }
2110}
2111
2112fn build_system_for_path(path: &Path) -> Option<BuildSystemKind> {
2113 let file_name = path.file_name()?.to_str()?;
2114
2115 match file_name {
2116 "Cargo.toml" => Some(BuildSystemKind::Cargo),
2117 "package.json" => Some(BuildSystemKind::NodePackage),
2118 "pyproject.toml" => Some(BuildSystemKind::PythonProject),
2119 "requirements.txt" => Some(BuildSystemKind::PythonRequirements),
2120 "CMakeLists.txt" => Some(BuildSystemKind::CMake),
2121 "go.mod" => Some(BuildSystemKind::GoModule),
2122 _ => None,
2123 }
2124}
2125
2126fn is_dependency_manifest(path: &Path) -> bool {
2127 matches!(
2128 path.file_name().and_then(|value| value.to_str()),
2129 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "requirements.txt")
2130 )
2131}
2132
2133fn is_test_command_source(path: &Path) -> bool {
2134 matches!(
2135 path.file_name().and_then(|value| value.to_str()),
2136 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "CMakeLists.txt")
2137 )
2138}
2139
2140fn is_test_directory(path: &Path) -> bool {
2141 matches!(
2142 path.file_name().and_then(|value| value.to_str()),
2143 Some("tests" | "test" | "__tests__" | "spec")
2144 )
2145}
2146
2147fn add_test_directory(tests: &mut TestSummary, path: PathBuf) {
2148 tests.has_tests_dir = true;
2149 if tests
2150 .test_directories
2151 .iter()
2152 .any(|existing| existing == &path)
2153 {
2154 return;
2155 }
2156
2157 tests.test_directories.push(path);
2158}
2159
2160fn is_test_file(path: &Path) -> bool {
2161 let file_name = path
2162 .file_name()
2163 .and_then(|value| value.to_str())
2164 .unwrap_or_default()
2165 .to_ascii_lowercase();
2166 let extension = path
2167 .extension()
2168 .and_then(|value| value.to_str())
2169 .unwrap_or_default()
2170 .to_ascii_lowercase();
2171
2172 if is_under_named_dir(path, "tests") || is_under_named_dir(path, "__tests__") {
2173 return matches!(
2174 extension.as_str(),
2175 "rs" | "js" | "jsx" | "ts" | "tsx" | "py"
2176 );
2177 }
2178
2179 file_name.ends_with("_test.rs")
2180 || file_name.ends_with(".test.js")
2181 || file_name.ends_with(".test.jsx")
2182 || file_name.ends_with(".test.ts")
2183 || file_name.ends_with(".test.tsx")
2184 || file_name.ends_with(".spec.js")
2185 || file_name.ends_with(".spec.jsx")
2186 || file_name.ends_with(".spec.ts")
2187 || file_name.ends_with(".spec.tsx")
2188 || (file_name.starts_with("test_") && file_name.ends_with(".py"))
2189 || file_name.ends_with("_test.py")
2190 || file_name.ends_with("_test.cpp")
2191 || file_name.ends_with("_test.cc")
2192 || file_name.ends_with("_test.cxx")
2193 || file_name.ends_with("_tests.cpp")
2194}
2195
2196fn is_under_named_dir(path: &Path, directory_name: &str) -> bool {
2197 path.components()
2198 .any(|component| component == Component::Normal(OsStr::new(directory_name)))
2199}
2200
2201fn observe_documentation_file(path: &Path, documentation: &mut DocumentationSummary) {
2202 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2203 return;
2204 };
2205 let file_name = file_name.to_ascii_lowercase();
2206
2207 if file_name == "readme.md" || file_name == "readme" {
2208 documentation.has_readme = true;
2209 }
2210 if file_name == "license" || file_name.starts_with("license.") {
2211 documentation.has_license = true;
2212 }
2213}
2214
2215fn is_license_file(path: &Path) -> bool {
2216 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2217 return false;
2218 };
2219 let file_name = file_name.to_ascii_lowercase();
2220 file_name == "license" || file_name.starts_with("license.")
2221}
2222
2223fn build_license_summary(files: &[PathBuf]) -> LicenseSummary {
2224 let Some(path) = files.first() else {
2225 return LicenseSummary {
2226 path: None,
2227 kind: LicenseKind::Missing,
2228 };
2229 };
2230
2231 let content = fs::read_to_string(path).unwrap_or_default();
2232 LicenseSummary {
2233 path: Some(path.clone()),
2234 kind: detect_license_kind(&content),
2235 }
2236}
2237
2238fn detect_license_kind(content: &str) -> LicenseKind {
2239 let lower = content.to_ascii_lowercase();
2240 let normalized = lower.trim();
2241
2242 if normalized == "mit"
2243 || normalized == "mit license"
2244 || lower.contains("mit license")
2245 || lower.contains("permission is hereby granted")
2246 {
2247 LicenseKind::Mit
2248 } else if normalized == "apache-2.0"
2249 || normalized == "apache 2.0"
2250 || lower.contains("apache license")
2251 || lower.contains("apache-2.0")
2252 || lower.contains("www.apache.org/licenses/license-2.0")
2253 {
2254 LicenseKind::Apache2
2255 } else if normalized.starts_with("gpl")
2256 || lower.contains("gnu general public license")
2257 || lower.contains("gpl")
2258 {
2259 LicenseKind::Gpl
2260 } else if normalized.starts_with("bsd")
2261 || (lower.contains("bsd") && lower.contains("redistribution and use"))
2262 {
2263 LicenseKind::Bsd
2264 } else {
2265 LicenseKind::Unknown
2266 }
2267}
2268
2269fn observe_hygiene_file(path: &Path, hygiene: &mut ScanHygiene) {
2270 match path.file_name().and_then(|value| value.to_str()) {
2271 Some(".gitignore") => hygiene.has_gitignore = true,
2272 Some(".ignore") => hygiene.has_ignore = true,
2273 _ => {}
2274 }
2275}
2276
2277fn observe_ci_file(path: &Path, ci: &mut CiSummary) {
2278 if has_component_pair(path, ".github", "workflows") {
2279 ci.add_provider(CiProvider::GithubActions, path.to_path_buf());
2280 }
2281 if has_component(path, ".workflow") {
2282 ci.add_provider(CiProvider::GiteeGo, path.to_path_buf());
2283 }
2284 if path.file_name().and_then(|value| value.to_str()) == Some(".gitlab-ci.yml") {
2285 ci.add_provider(CiProvider::GitlabCi, path.to_path_buf());
2286 }
2287 if has_component(path, ".circleci") {
2288 ci.add_provider(CiProvider::CircleCi, path.to_path_buf());
2289 }
2290 if path.file_name().and_then(|value| value.to_str()) == Some("Jenkinsfile") {
2291 ci.add_provider(CiProvider::Jenkins, path.to_path_buf());
2292 }
2293}
2294
2295fn has_component(path: &Path, name: &str) -> bool {
2296 path.components()
2297 .any(|component| component == Component::Normal(OsStr::new(name)))
2298}
2299
2300fn has_component_pair(path: &Path, first: &str, second: &str) -> bool {
2301 let mut components = path.components();
2302 while let Some(component) = components.next() {
2303 if component == Component::Normal(OsStr::new(first))
2304 && components.next() == Some(Component::Normal(OsStr::new(second)))
2305 {
2306 return true;
2307 }
2308 }
2309
2310 false
2311}
2312
2313fn observe_container_file(path: &Path, containers: &mut ContainerSummary) {
2314 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2315 return;
2316 };
2317 let file_name = file_name.to_ascii_lowercase();
2318
2319 if file_name == "dockerfile" || file_name.starts_with("dockerfile.") {
2320 containers.has_dockerfile = true;
2321 }
2322 if matches!(
2323 file_name.as_str(),
2324 "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml"
2325 ) {
2326 containers.has_compose_file = true;
2327 }
2328}
2329
2330#[derive(Default)]
2331struct MutableInventory {
2332 languages: Vec<LanguageSummary>,
2333 build_systems: Vec<BuildSystemSummary>,
2334 code: CodeSummary,
2335 dependency_manifests: Vec<PathBuf>,
2336 tests: TestSummary,
2337 test_command_sources: Vec<PathBuf>,
2338 hygiene: ScanHygiene,
2339 documentation: DocumentationSummary,
2340 license_files: Vec<PathBuf>,
2341 ci: CiSummary,
2342 containers: ContainerSummary,
2343 files_scanned: usize,
2344 skipped_dirs: Vec<PathBuf>,
2345}
2346
2347impl MutableInventory {
2348 fn add_language(&mut self, kind: LanguageKind) {
2349 if let Some(language) = self
2350 .languages
2351 .iter_mut()
2352 .find(|language| language.kind == kind)
2353 {
2354 language.files += 1;
2355 return;
2356 }
2357
2358 self.languages.push(LanguageSummary { kind, files: 1 });
2359 }
2360
2361 fn add_code_file(&mut self, kind: LanguageKind, path: &Path) {
2362 let counts = count_code_lines(path, kind);
2363 self.code.total_files += 1;
2364 self.code.total_lines += counts.total_lines;
2365 self.code.code_lines += counts.code_lines;
2366 self.code.comment_lines += counts.comment_lines;
2367 self.code.blank_lines += counts.blank_lines;
2368
2369 if let Some(language) = self
2370 .code
2371 .languages
2372 .iter_mut()
2373 .find(|language| language.kind == kind)
2374 {
2375 language.files += 1;
2376 language.total_lines += counts.total_lines;
2377 language.code_lines += counts.code_lines;
2378 language.comment_lines += counts.comment_lines;
2379 language.blank_lines += counts.blank_lines;
2380 return;
2381 }
2382
2383 self.code.languages.push(CodeLanguageSummary {
2384 kind,
2385 files: 1,
2386 total_lines: counts.total_lines,
2387 code_lines: counts.code_lines,
2388 comment_lines: counts.comment_lines,
2389 blank_lines: counts.blank_lines,
2390 });
2391 }
2392
2393 fn add_build_system(&mut self, kind: BuildSystemKind, path: PathBuf) {
2394 if self
2395 .build_systems
2396 .iter()
2397 .any(|build_system| build_system.kind == kind && build_system.path == path)
2398 {
2399 return;
2400 }
2401
2402 self.build_systems.push(BuildSystemSummary { kind, path });
2403 }
2404
2405 fn add_dependency_manifest(&mut self, path: PathBuf) {
2406 if self
2407 .dependency_manifests
2408 .iter()
2409 .any(|existing| existing == &path)
2410 {
2411 return;
2412 }
2413
2414 self.dependency_manifests.push(path);
2415 }
2416
2417 fn add_test_command_source(&mut self, path: PathBuf) {
2418 if self
2419 .test_command_sources
2420 .iter()
2421 .any(|existing| existing == &path)
2422 {
2423 return;
2424 }
2425
2426 self.test_command_sources.push(path);
2427 }
2428
2429 fn add_license_file(&mut self, path: PathBuf) {
2430 if self.license_files.iter().any(|existing| existing == &path) {
2431 return;
2432 }
2433
2434 self.license_files.push(path);
2435 }
2436
2437 fn add_skipped_dir(&mut self, path: PathBuf) {
2438 if self.skipped_dirs.iter().any(|existing| existing == &path) {
2439 return;
2440 }
2441
2442 self.skipped_dirs.push(path);
2443 }
2444}
2445
2446fn build_report(
2447 project_name: &str,
2448 indicators: &[ProjectIndicator],
2449 inventory: &ScanInventory,
2450 health: &HealthSummary,
2451) -> ProjectReport {
2452 let summary = if indicators.is_empty() {
2453 format!("{project_name} was scanned, but no known project markers were detected yet.")
2454 } else {
2455 format!(
2456 "{project_name} was scanned across {} file(s), with {} project marker(s), {} language(s), {} build system(s), and {} health detected.",
2457 inventory.files_scanned,
2458 indicators.len(),
2459 inventory.languages.len(),
2460 inventory.build_systems.len(),
2461 health.grade.as_str()
2462 )
2463 };
2464
2465 ProjectReport {
2466 summary,
2467 next_steps: vec![
2468 "Add language, dependency, and repository activity scanners.".to_owned(),
2469 "Keep CLI and GUI features backed by projd-core data structures.".to_owned(),
2470 "Export Markdown and JSON reports before adding richer GUI visualizations.".to_owned(),
2471 ],
2472 }
2473}
2474
2475fn yes_no(value: bool) -> &'static str {
2476 if value { "yes" } else { "no" }
2477}
2478
2479#[cfg(test)]
2480mod tests {
2481 use super::*;
2482
2483 #[test]
2484 fn renders_markdown_without_indicators() {
2485 let scan = ProjectScan {
2486 root: PathBuf::from("example"),
2487 project_name: "example".to_owned(),
2488 identity: ProjectIdentity {
2489 name: "example".to_owned(),
2490 version: None,
2491 kind: ProjectKind::Generic,
2492 source: IdentitySource::DirectoryName,
2493 },
2494 indicators: Vec::new(),
2495 languages: Vec::new(),
2496 build_systems: Vec::new(),
2497 code: CodeSummary::default(),
2498 dependencies: DependencySummary::default(),
2499 tests: TestSummary::default(),
2500 hygiene: ScanHygiene::default(),
2501 risks: RiskSummary::default(),
2502 health: HealthSummary {
2503 grade: ProjectHealth::Unknown,
2504 score: 0,
2505 risk_level: RiskSeverity::Info,
2506 signals: Vec::new(),
2507 },
2508 documentation: DocumentationSummary::default(),
2509 license: LicenseSummary::default(),
2510 ci: CiSummary::default(),
2511 vcs: VcsSummary::default(),
2512 containers: ContainerSummary::default(),
2513 files_scanned: 0,
2514 skipped_dirs: Vec::new(),
2515 report: ProjectReport {
2516 summary: "example summary".to_owned(),
2517 next_steps: vec!["next".to_owned()],
2518 },
2519 };
2520
2521 let rendered = render_markdown(&scan);
2522
2523 assert!(rendered.contains("# Project Report"));
2524 assert!(rendered.contains("example summary"));
2525 assert!(rendered.contains("No known project indicators"));
2526 }
2527}