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