1use std::collections::BTreeMap;
2use std::ffi::OsStr;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result, bail};
8use ignore::gitignore::{Gitignore, GitignoreBuilder};
9use serde::{Deserialize, Serialize};
10
11pub mod discover;
12pub mod html;
13
14pub use discover::{
15 Confidence, DiscoverOptions, DiscoverSummary, DiscoveredRoot, RootCategory, RootKind,
16 category_token, discover_roots, relative_display, render_discover_json,
17 render_discover_markdown, summarize_roots,
18};
19pub use html::{render_html, render_multi_html};
20
21pub const NAME: &str = "Projd";
22pub const VERSION: &str = env!("CARGO_PKG_VERSION");
23
24pub fn describe() -> &'static str {
25 "Scan software projects and generate structured reports."
26}
27
28#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
29pub struct ProjectScan {
30 pub root: PathBuf,
31 pub project_name: String,
32 pub identity: ProjectIdentity,
33 pub indicators: Vec<ProjectIndicator>,
34 pub languages: Vec<LanguageSummary>,
35 pub build_systems: Vec<BuildSystemSummary>,
36 pub code: CodeSummary,
37 pub dependencies: DependencySummary,
38 pub tests: TestSummary,
39 pub hygiene: ScanHygiene,
40 pub risks: RiskSummary,
41 pub health: HealthSummary,
42 pub documentation: DocumentationSummary,
43 pub license: LicenseSummary,
44 pub ci: CiSummary,
45 pub vcs: VcsSummary,
46 pub containers: ContainerSummary,
47 pub files_scanned: usize,
48 pub skipped_dirs: Vec<PathBuf>,
49 pub report: ProjectReport,
50}
51
52impl ProjectScan {
53 pub fn has_language(&self, kind: LanguageKind) -> bool {
54 self.languages.iter().any(|language| language.kind == kind)
55 }
56
57 pub fn has_build_system(&self, kind: BuildSystemKind) -> bool {
58 self.build_systems
59 .iter()
60 .any(|build_system| build_system.kind == kind)
61 }
62
63 pub fn has_risk(&self, code: RiskCode) -> bool {
64 self.risks.findings.iter().any(|risk| risk.code == code)
65 }
66
67 pub fn risk(&self, code: RiskCode) -> Option<&RiskFinding> {
68 self.risks.findings.iter().find(|risk| risk.code == code)
69 }
70}
71
72#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
73pub struct ProjectIdentity {
74 pub name: String,
75 pub version: Option<String>,
76 pub kind: ProjectKind,
77 pub source: IdentitySource,
78}
79
80#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "kebab-case")]
82pub enum ProjectKind {
83 RustWorkspace,
84 RustPackage,
85 NodePackage,
86 PythonProject,
87 Generic,
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
91#[serde(rename_all = "kebab-case")]
92pub enum IdentitySource {
93 CargoToml,
94 PackageJson,
95 PyprojectToml,
96 DirectoryName,
97}
98
99#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
100pub struct ProjectIndicator {
101 pub kind: IndicatorKind,
102 pub path: PathBuf,
103}
104
105#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
106#[serde(rename_all = "kebab-case")]
107pub enum IndicatorKind {
108 RustWorkspace,
109 RustPackage,
110 GitRepository,
111 Readme,
112}
113
114#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
115pub struct LanguageSummary {
116 pub kind: LanguageKind,
117 pub files: usize,
118}
119
120#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "kebab-case")]
122pub enum LanguageKind {
123 Rust,
124 TypeScript,
125 JavaScript,
126 Python,
127 C,
128 Cpp,
129 Go,
130}
131
132#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
133pub struct BuildSystemSummary {
134 pub kind: BuildSystemKind,
135 pub path: PathBuf,
136}
137
138#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum BuildSystemKind {
141 Cargo,
142 NodePackage,
143 PythonProject,
144 PythonRequirements,
145 CMake,
146 GoModule,
147}
148
149#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
150pub struct CodeSummary {
151 pub languages: Vec<CodeLanguageSummary>,
152 pub total_files: usize,
153 pub total_lines: usize,
154 pub code_lines: usize,
155 pub comment_lines: usize,
156 pub blank_lines: usize,
157}
158
159#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
160pub struct CodeLanguageSummary {
161 pub kind: LanguageKind,
162 pub files: usize,
163 pub total_lines: usize,
164 pub code_lines: usize,
165 pub comment_lines: usize,
166 pub blank_lines: usize,
167}
168
169#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
170struct CodeLineCounts {
171 total_lines: usize,
172 code_lines: usize,
173 comment_lines: usize,
174 blank_lines: usize,
175}
176
177#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
178pub struct DependencySummary {
179 pub ecosystems: Vec<DependencyEcosystemSummary>,
180 pub total_manifests: usize,
181 pub total_dependencies: usize,
182}
183
184#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
185pub struct DependencyEcosystemSummary {
186 pub ecosystem: DependencyEcosystem,
187 pub source: DependencySource,
188 pub manifest: PathBuf,
189 pub lockfile: Option<PathBuf>,
190 pub normal: usize,
191 pub development: usize,
192 pub build: usize,
193 pub optional: usize,
194 pub total: usize,
195}
196
197#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
198#[serde(rename_all = "kebab-case")]
199pub enum DependencyEcosystem {
200 Rust,
201 Node,
202 Python,
203}
204
205#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
206#[serde(rename_all = "kebab-case")]
207pub enum DependencySource {
208 CargoToml,
209 PackageJson,
210 PyprojectToml,
211 RequirementsTxt,
212}
213
214#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
215pub struct TestSummary {
216 pub has_tests_dir: bool,
217 pub test_files: usize,
218 pub test_directories: Vec<PathBuf>,
219 pub commands: Vec<TestCommand>,
220}
221
222#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
223pub struct TestCommand {
224 pub ecosystem: TestEcosystem,
225 pub source: PathBuf,
226 pub name: String,
227 pub command: String,
228}
229
230#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
231#[serde(rename_all = "kebab-case")]
232pub enum TestEcosystem {
233 Rust,
234 Node,
235 Python,
236 CMake,
237}
238
239#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
240pub struct ScanHygiene {
241 pub has_gitignore: bool,
242 pub has_ignore: bool,
243}
244
245#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
246pub struct RiskSummary {
247 pub findings: Vec<RiskFinding>,
248 pub total: usize,
249 pub high: usize,
250 pub medium: usize,
251 pub low: usize,
252 pub info: usize,
253}
254
255#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
256pub struct RiskFinding {
257 pub severity: RiskSeverity,
258 pub code: RiskCode,
259 pub message: String,
260 pub path: Option<PathBuf>,
261}
262
263#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
264pub struct HealthSummary {
265 pub grade: ProjectHealth,
266 pub score: usize,
267 pub risk_level: RiskSeverity,
268 pub signals: Vec<HealthSignal>,
269}
270
271#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum ProjectHealth {
274 Healthy,
275 NeedsAttention,
276 Risky,
277 Unknown,
278}
279
280#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
281pub struct HealthSignal {
282 pub kind: HealthSignalKind,
283 pub status: HealthSignalStatus,
284 pub score_delta: i32,
285 pub evidence: String,
286}
287
288#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
289#[serde(rename_all = "kebab-case")]
290pub enum HealthSignalKind {
291 Readme,
292 License,
293 Ci,
294 Tests,
295 Lockfiles,
296 Vcs,
297 Activity,
298 Docs,
299}
300
301#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
302#[serde(rename_all = "kebab-case")]
303pub enum HealthSignalStatus {
304 Pass,
305 Warn,
306 Info,
307}
308
309#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
310#[serde(rename_all = "kebab-case")]
311pub enum RiskSeverity {
312 High,
313 Medium,
314 Low,
315 Info,
316}
317
318#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
319#[serde(rename_all = "kebab-case")]
320pub enum RiskCode {
321 MissingReadme,
322 MissingLicense,
323 MissingCi,
324 NoTestsDetected,
325 ManifestWithoutLockfile,
326 LargeProjectWithoutIgnoreRules,
327 UnknownLicense,
328 MultipleVcsRootsFound,
329 NestedVcsRoot,
330}
331
332#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
333pub struct DocumentationSummary {
334 pub has_readme: bool,
335 pub has_license: bool,
336 pub has_docs_dir: bool,
337}
338
339#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
340pub struct LicenseSummary {
341 pub path: Option<PathBuf>,
342 pub kind: LicenseKind,
343}
344
345#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
346#[serde(rename_all = "kebab-case")]
347pub enum LicenseKind {
348 Mit,
349 Apache2,
350 Gpl,
351 Bsd,
352 #[default]
353 Unknown,
354 Missing,
355}
356
357#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
358pub struct CiSummary {
359 pub has_github_actions: bool,
360 pub has_gitee_go: bool,
361 pub has_gitlab_ci: bool,
362 pub has_circle_ci: bool,
363 pub has_jenkins: bool,
364 pub providers: Vec<CiProviderSummary>,
365}
366
367#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
368pub struct CiProviderSummary {
369 pub provider: CiProvider,
370 pub path: PathBuf,
371}
372
373#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
374#[serde(rename_all = "kebab-case")]
375pub enum CiProvider {
376 GithubActions,
377 GiteeGo,
378 GitlabCi,
379 CircleCi,
380 Jenkins,
381}
382
383impl CiSummary {
384 pub fn has_provider(&self, provider: CiProvider) -> bool {
385 match provider {
386 CiProvider::GithubActions => self.has_github_actions,
387 CiProvider::GiteeGo => self.has_gitee_go,
388 CiProvider::GitlabCi => self.has_gitlab_ci,
389 CiProvider::CircleCi => self.has_circle_ci,
390 CiProvider::Jenkins => self.has_jenkins,
391 }
392 }
393
394 fn has_any_provider(&self) -> bool {
395 !self.providers.is_empty()
396 || self.has_github_actions
397 || self.has_gitee_go
398 || self.has_gitlab_ci
399 || self.has_circle_ci
400 || self.has_jenkins
401 }
402
403 fn add_provider(&mut self, provider: CiProvider, path: PathBuf) {
404 match provider {
405 CiProvider::GithubActions => self.has_github_actions = true,
406 CiProvider::GiteeGo => self.has_gitee_go = true,
407 CiProvider::GitlabCi => self.has_gitlab_ci = true,
408 CiProvider::CircleCi => self.has_circle_ci = true,
409 CiProvider::Jenkins => self.has_jenkins = true,
410 }
411
412 if self
413 .providers
414 .iter()
415 .any(|existing| existing.provider == provider && existing.path == path)
416 {
417 return;
418 }
419
420 self.providers.push(CiProviderSummary { provider, path });
421 }
422}
423
424#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
425#[serde(rename_all = "snake_case")]
426pub enum VcsKind {
427 #[default]
428 None,
429 Git,
430 Hg,
431 Svn,
432 Fossil,
433 Bzr,
434}
435
436impl VcsKind {
437 pub fn label(self) -> &'static str {
438 match self {
439 Self::None => "none",
440 Self::Git => "git",
441 Self::Hg => "hg",
442 Self::Svn => "svn",
443 Self::Fossil => "fossil",
444 Self::Bzr => "bzr",
445 }
446 }
447
448 pub fn ref_label(self) -> &'static str {
451 match self {
452 Self::Svn => "URL",
453 _ => "Branch",
454 }
455 }
456}
457
458#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
459pub struct VcsSummary {
460 pub kind: VcsKind,
461 pub is_repository: bool,
462 pub root: Option<PathBuf>,
463 pub branch: Option<String>,
465 pub revision: Option<String>,
467 pub last_commit: Option<String>,
469 pub is_dirty: bool,
470 pub tracked_modified_files: usize,
471 pub untracked_files: usize,
472 pub activity: VcsActivity,
473}
474
475#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
476pub struct VcsActivity {
477 pub days_since_last_commit: Option<u32>,
478 pub commits_last_90d: Option<u32>,
479 pub contributors_count: Option<u32>,
480 pub first_commit_date: Option<String>,
483 pub contributors: Vec<VcsContributor>,
486}
487
488#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
489pub struct VcsContributor {
490 pub name: String,
491 pub email: String,
492 pub commit_count: u32,
493}
494
495#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
496pub struct ContainerSummary {
497 pub has_dockerfile: bool,
498 pub has_compose_file: bool,
499}
500
501#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
502pub struct ProjectReport {
503 pub summary: String,
504 pub next_steps: Vec<String>,
505}
506
507#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
508pub struct MultiProjectScan {
509 pub root: PathBuf,
510 pub summary: MultiProjectSummary,
511 pub roots: Vec<ProjectScan>,
512 pub skipped: Vec<MultiProjectScanError>,
513}
514
515#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
516pub struct MultiProjectSummary {
517 pub total: usize,
518 pub by_kind: BTreeMap<RootKind, usize>,
519 pub by_grade: BTreeMap<ProjectHealth, usize>,
520 pub by_risk_level: BTreeMap<RiskSeverity, usize>,
521 pub files_scanned: usize,
522}
523
524#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
525pub struct MultiProjectScanError {
526 pub path: PathBuf,
527 pub message: String,
528}
529
530#[derive(Clone, Debug, Default)]
534pub struct ScanOptions {
535 pub detailed_contributors: bool,
539}
540
541pub fn scan_path(path: impl AsRef<Path>) -> Result<ProjectScan> {
542 scan_path_with(path, &ScanOptions::default())
543}
544
545pub fn scan_path_with(path: impl AsRef<Path>, opts: &ScanOptions) -> Result<ProjectScan> {
546 let root = path.as_ref();
547 let metadata =
548 fs::metadata(root).with_context(|| format!("failed to inspect `{}`", root.display()))?;
549
550 if !metadata.is_dir() {
551 bail!("project path must be a directory: `{}`", root.display());
552 }
553
554 let root = root
555 .canonicalize()
556 .with_context(|| format!("failed to resolve `{}`", root.display()))?;
557 let project_name = root
558 .file_name()
559 .and_then(|value| value.to_str())
560 .unwrap_or("project")
561 .to_owned();
562 let identity = detect_identity(&root, &project_name);
563 let indicators = detect_indicators(&root);
564 let inventory = scan_inventory(&root)?;
565 let multi_root_check = detect_multi_root_situation(&root);
566 let risks = build_risk_summary(&inventory, &multi_root_check);
567 let vcs = detect_vcs_summary(&root, opts);
568 let health = build_health_summary(&inventory, &risks, &vcs);
569 let report = build_report(&identity.name, &indicators, &inventory, &health);
570
571 Ok(ProjectScan {
572 root,
573 project_name: identity.name.clone(),
574 identity,
575 indicators,
576 languages: inventory.languages,
577 build_systems: inventory.build_systems,
578 code: inventory.code,
579 dependencies: inventory.dependencies,
580 tests: inventory.tests,
581 hygiene: inventory.hygiene,
582 risks,
583 health,
584 documentation: inventory.documentation,
585 license: inventory.license,
586 ci: inventory.ci,
587 vcs,
588 containers: inventory.containers,
589 files_scanned: inventory.files_scanned,
590 skipped_dirs: inventory.skipped_dirs,
591 report,
592 })
593}
594
595pub fn scan_paths_recursive(
596 path: impl AsRef<Path>,
597 opts: &discover::DiscoverOptions,
598) -> Result<MultiProjectScan> {
599 scan_paths_recursive_with(path, opts, &ScanOptions::default())
600}
601
602pub fn scan_paths_recursive_with(
603 path: impl AsRef<Path>,
604 discover_opts: &discover::DiscoverOptions,
605 scan_opts: &ScanOptions,
606) -> Result<MultiProjectScan> {
607 let input = path.as_ref();
608 let canonical = fs::canonicalize(input)
609 .with_context(|| format!("failed to resolve `{}`", input.display()))?;
610 if !canonical.is_dir() {
611 bail!("`{}` is not a directory", canonical.display());
612 }
613
614 let roots = discover::discover_roots(&canonical, discover_opts)?;
615
616 let mut scans: Vec<ProjectScan> = Vec::new();
617 let mut skipped: Vec<MultiProjectScanError> = Vec::new();
618 for entry in &roots {
619 match scan_path_with(&entry.path, scan_opts) {
620 Ok(scan) => scans.push(scan),
621 Err(err) => skipped.push(MultiProjectScanError {
622 path: entry.path.clone(),
623 message: format!("{err:#}"),
624 }),
625 }
626 }
627
628 let summary = build_multi_summary(&roots, &scans);
629
630 Ok(MultiProjectScan {
631 root: canonical,
632 summary,
633 roots: scans,
634 skipped,
635 })
636}
637
638fn build_multi_summary(
639 discovered: &[discover::DiscoveredRoot],
640 scans: &[ProjectScan],
641) -> MultiProjectSummary {
642 let mut by_kind: BTreeMap<RootKind, usize> = BTreeMap::new();
643 for entry in discovered {
644 for kind in &entry.kinds {
645 *by_kind.entry(*kind).or_insert(0) += 1;
646 }
647 }
648
649 let mut by_grade: BTreeMap<ProjectHealth, usize> = BTreeMap::new();
650 let mut by_risk_level: BTreeMap<RiskSeverity, usize> = BTreeMap::new();
651 let mut files_scanned = 0usize;
652 for scan in scans {
653 *by_grade.entry(scan.health.grade).or_insert(0) += 1;
654 *by_risk_level.entry(scan.health.risk_level).or_insert(0) += 1;
655 files_scanned = files_scanned.saturating_add(scan.files_scanned);
656 }
657
658 MultiProjectSummary {
659 total: scans.len(),
660 by_kind,
661 by_grade,
662 by_risk_level,
663 files_scanned,
664 }
665}
666
667pub fn render_multi_markdown(scan: &MultiProjectScan) -> String {
668 let mut out = String::new();
669 out.push_str("# Projd Recursive Scan Report\n\n");
670 out.push_str(&format!("- Root: `{}`\n", scan.root.display()));
671 out.push_str(&format!("- Total project roots: {}\n", scan.summary.total));
672 if !scan.summary.by_grade.is_empty() {
673 let parts: Vec<String> = [
674 ProjectHealth::Healthy,
675 ProjectHealth::NeedsAttention,
676 ProjectHealth::Risky,
677 ProjectHealth::Unknown,
678 ]
679 .iter()
680 .filter_map(|grade| {
681 scan.summary
682 .by_grade
683 .get(grade)
684 .map(|count| format!("{}: {}", grade.as_str(), count))
685 })
686 .collect();
687 if !parts.is_empty() {
688 out.push_str(&format!("- Grade: {}\n", parts.join(" / ")));
689 }
690 }
691 if !scan.summary.by_risk_level.is_empty() {
692 let parts: Vec<String> = [
693 RiskSeverity::High,
694 RiskSeverity::Medium,
695 RiskSeverity::Low,
696 RiskSeverity::Info,
697 ]
698 .iter()
699 .filter_map(|level| {
700 scan.summary
701 .by_risk_level
702 .get(level)
703 .map(|count| format!("{}: {}", level.as_str(), count))
704 })
705 .collect();
706 if !parts.is_empty() {
707 out.push_str(&format!("- Risk level: {}\n", parts.join(" / ")));
708 }
709 }
710 out.push_str(&format!(
711 "- Files scanned: {}\n",
712 scan.summary.files_scanned
713 ));
714 if !scan.skipped.is_empty() {
715 out.push_str(&format!("- Skipped: {}\n", scan.skipped.len()));
716 }
717 out.push('\n');
718
719 if scan.roots.is_empty() {
720 out.push_str("No project roots found.\n");
721 if !scan.skipped.is_empty() {
722 out.push_str("\n## Skipped\n\n");
723 for entry in &scan.skipped {
724 out.push_str(&format!(
725 "- `{}`: {}\n",
726 entry.path.display(),
727 entry.message
728 ));
729 }
730 }
731 return out;
732 }
733
734 for (index, project) in scan.roots.iter().enumerate() {
735 let rel = discover::relative_display(&scan.root, &project.root);
736 out.push_str(&format!(
737 "## [{}/{}] `{}` — {} ({})\n\n",
738 index + 1,
739 scan.roots.len(),
740 rel,
741 project.health.grade.as_str(),
742 project.health.score
743 ));
744 out.push_str(&render_markdown(project));
745 out.push('\n');
746 }
747
748 if !scan.skipped.is_empty() {
749 out.push_str("## Skipped\n\n");
750 for entry in &scan.skipped {
751 out.push_str(&format!(
752 "- `{}`: {}\n",
753 entry.path.display(),
754 entry.message
755 ));
756 }
757 }
758 out
759}
760
761pub fn render_multi_json(scan: &MultiProjectScan) -> Result<String> {
762 serde_json::to_string_pretty(scan).context("failed to serialize MultiProjectScan as JSON")
763}
764
765pub fn render_markdown(scan: &ProjectScan) -> String {
766 let mut output = String::new();
767 output.push_str("# Project Report\n\n");
768 output.push_str(&format!("Project: `{}`\n\n", scan.project_name));
769 output.push_str(&format!("Root: `{}`\n\n", scan.root.display()));
770 output.push_str("## Identity\n\n");
771 output.push_str(&format!("- Name: `{}`\n", scan.identity.name));
772 output.push_str(&format!(
773 "- Version: {}\n",
774 scan.identity
775 .version
776 .as_deref()
777 .map(|version| format!("`{version}`"))
778 .unwrap_or_else(|| "unknown".to_owned())
779 ));
780 output.push_str(&format!("- Kind: {:?}\n", scan.identity.kind));
781 output.push_str(&format!("- Source: {:?}\n\n", scan.identity.source));
782 output.push_str("## Summary\n\n");
783 output.push_str(&scan.report.summary);
784 output.push_str("\n\n## Health\n\n");
785 output.push_str(&format!("- Grade: `{}`\n", scan.health.grade.as_str()));
786 output.push_str(&format!("- Score: {}/100\n", scan.health.score));
787 output.push_str(&format!(
788 "- Risk level: `{}`\n",
789 scan.health.risk_level.as_str()
790 ));
791 output.push_str("- Signals:\n");
792 for signal in &scan.health.signals {
793 output.push_str(&format!(
794 " - {}: {} ({:+}) - {}\n",
795 signal.kind.as_str(),
796 signal.status.as_str(),
797 signal.score_delta,
798 signal.evidence
799 ));
800 }
801
802 output.push_str("\n## Scan Observations\n\n");
803 output.push_str(&format!("- Files scanned: {}\n", scan.files_scanned));
804 output.push_str(&format!(
805 "- Skipped directories: {}\n",
806 scan.skipped_dirs.len()
807 ));
808 output.push_str(&format!(
809 "- `.gitignore`: {}\n",
810 yes_no(scan.hygiene.has_gitignore)
811 ));
812 output.push_str(&format!(
813 "- `.ignore`: {}\n",
814 yes_no(scan.hygiene.has_ignore)
815 ));
816
817 output.push_str("\n## Indicators\n\n");
818
819 if scan.indicators.is_empty() {
820 output.push_str("- No known project indicators detected.\n");
821 } else {
822 for indicator in &scan.indicators {
823 output.push_str(&format!(
824 "- {:?}: `{}`\n",
825 indicator.kind,
826 indicator.path.display()
827 ));
828 }
829 }
830
831 output.push_str("\n## Languages\n\n");
832 if scan.languages.is_empty() {
833 output.push_str("- No source language files detected.\n");
834 } else {
835 for language in &scan.languages {
836 output.push_str(&format!(
837 "- {:?}: {} file(s)\n",
838 language.kind, language.files
839 ));
840 }
841 }
842
843 output.push_str("\n## Code Statistics\n\n");
844 output.push_str(&format!(
845 "- Total source files: {}\n",
846 scan.code.total_files
847 ));
848 output.push_str(&format!("- Total lines: {}\n", scan.code.total_lines));
849 output.push_str(&format!("- Code lines: {}\n", scan.code.code_lines));
850 output.push_str(&format!("- Comment lines: {}\n", scan.code.comment_lines));
851 output.push_str(&format!("- Blank lines: {}\n", scan.code.blank_lines));
852 if scan.code.languages.is_empty() {
853 output.push_str("- No code statistics detected.\n");
854 } else {
855 output.push_str("- By language:\n");
856 for language in &scan.code.languages {
857 output.push_str(&format!(
858 " - {:?}: {} file(s), {} total, {} code, {} comment, {} blank\n",
859 language.kind,
860 language.files,
861 language.total_lines,
862 language.code_lines,
863 language.comment_lines,
864 language.blank_lines
865 ));
866 }
867 }
868
869 output.push_str("\n## Build Systems\n\n");
870 if scan.build_systems.is_empty() {
871 output.push_str("- No known build systems detected.\n");
872 } else {
873 for build_system in &scan.build_systems {
874 output.push_str(&format!(
875 "- {:?}: `{}`\n",
876 build_system.kind,
877 build_system.path.display()
878 ));
879 }
880 }
881
882 output.push_str("\n## Dependencies\n\n");
883 output.push_str(&format!(
884 "- Total manifests: {}\n",
885 scan.dependencies.total_manifests
886 ));
887 output.push_str(&format!(
888 "- Total dependency entries: {}\n",
889 scan.dependencies.total_dependencies
890 ));
891 if scan.dependencies.ecosystems.is_empty() {
892 output.push_str("- No dependency manifests detected.\n");
893 } else {
894 for summary in &scan.dependencies.ecosystems {
895 output.push_str(&format!(
896 "- {:?} {:?}: `{}` normal {}, dev {}, build {}, optional {}, total {}, lockfile {}\n",
897 summary.ecosystem,
898 summary.source,
899 summary.manifest.display(),
900 summary.normal,
901 summary.development,
902 summary.build,
903 summary.optional,
904 summary.total,
905 yes_no(summary.lockfile.is_some())
906 ));
907 }
908 }
909
910 output.push_str("\n## Tests\n\n");
911 output.push_str(&format!(
912 "- Test directories: {}\n",
913 scan.tests.test_directories.len()
914 ));
915 output.push_str(&format!("- Test files: {}\n", scan.tests.test_files));
916 if scan.tests.commands.is_empty() {
917 output.push_str("- Commands: none detected\n");
918 } else {
919 output.push_str("- Commands:\n");
920 for command in &scan.tests.commands {
921 output.push_str(&format!(
922 " - {:?}: {} (`{}`)\n",
923 command.ecosystem,
924 command.command,
925 command.source.display()
926 ));
927 }
928 }
929
930 output.push_str("\n## Risks\n\n");
931 if scan.risks.findings.is_empty() {
932 output.push_str("- No risks detected by current rules.\n");
933 } else {
934 for risk in &scan.risks.findings {
935 output.push_str(&format!(
936 "- {} {}: {}",
937 risk.severity.as_str(),
938 risk.code.as_str(),
939 risk.message
940 ));
941 if let Some(path) = &risk.path {
942 output.push_str(&format!(" (`{}`)", path.display()));
943 }
944 output.push('\n');
945 }
946 }
947
948 output.push_str("\n## Project Facilities\n\n");
949 output.push_str(&format!(
950 "- README: {}\n",
951 yes_no(scan.documentation.has_readme)
952 ));
953 output.push_str(&format!(
954 "- License: {}\n",
955 yes_no(scan.documentation.has_license)
956 ));
957 output.push_str(&format!("- License kind: {:?}\n", scan.license.kind));
958 output.push_str(&format!(
959 "- docs/: {}\n",
960 yes_no(scan.documentation.has_docs_dir)
961 ));
962 output.push_str(&format!(
963 "- GitHub Actions: {}\n",
964 yes_no(scan.ci.has_github_actions)
965 ));
966 output.push_str(&format!("- Gitee Go: {}\n", yes_no(scan.ci.has_gitee_go)));
967 output.push_str(&format!("- GitLab CI: {}\n", yes_no(scan.ci.has_gitlab_ci)));
968 output.push_str(&format!("- CircleCI: {}\n", yes_no(scan.ci.has_circle_ci)));
969 output.push_str(&format!("- Jenkins: {}\n", yes_no(scan.ci.has_jenkins)));
970 output.push_str(&format!(
971 "- {} repository: {}\n",
972 scan.vcs.kind.label(),
973 yes_no(scan.vcs.is_repository)
974 ));
975 if let Some(days) = scan.vcs.activity.days_since_last_commit {
976 output.push_str(&format!("- Activity: last commit {days} day(s) ago\n"));
977 }
978 if let Some(count) = scan.vcs.activity.commits_last_90d {
979 output.push_str(&format!("- Commits (90d): {count}\n"));
980 }
981 if let Some(count) = scan.vcs.activity.contributors_count {
982 output.push_str(&format!("- Contributors: {count}\n"));
983 }
984 if let Some(first) = &scan.vcs.activity.first_commit_date {
985 output.push_str(&format!("- First commit: {first}\n"));
986 }
987 output.push_str(&format!(
988 "- Dockerfile: {}\n",
989 yes_no(scan.containers.has_dockerfile)
990 ));
991 output.push_str(&format!(
992 "- Compose file: {}\n",
993 yes_no(scan.containers.has_compose_file)
994 ));
995
996 output.push_str("\n## Next Steps\n\n");
997 for step in &scan.report.next_steps {
998 output.push_str(&format!("- {step}\n"));
999 }
1000
1001 output
1002}
1003
1004pub fn render_json(scan: &ProjectScan) -> Result<String> {
1005 serde_json::to_string_pretty(scan).context("failed to render scan result as json")
1006}
1007
1008struct ScanInventory {
1009 languages: Vec<LanguageSummary>,
1010 build_systems: Vec<BuildSystemSummary>,
1011 code: CodeSummary,
1012 dependencies: DependencySummary,
1013 tests: TestSummary,
1014 hygiene: ScanHygiene,
1015 documentation: DocumentationSummary,
1016 license: LicenseSummary,
1017 ci: CiSummary,
1018 containers: ContainerSummary,
1019 files_scanned: usize,
1020 skipped_dirs: Vec<PathBuf>,
1021}
1022
1023fn detect_identity(root: &Path, fallback_name: &str) -> ProjectIdentity {
1024 detect_cargo_identity(root, fallback_name)
1025 .or_else(|| detect_node_identity(root))
1026 .or_else(|| detect_python_identity(root))
1027 .unwrap_or_else(|| ProjectIdentity {
1028 name: fallback_name.to_owned(),
1029 version: None,
1030 kind: ProjectKind::Generic,
1031 source: IdentitySource::DirectoryName,
1032 })
1033}
1034
1035fn detect_cargo_identity(root: &Path, fallback_name: &str) -> Option<ProjectIdentity> {
1036 let manifest = read_toml_value(&root.join("Cargo.toml"))?;
1037
1038 if let Some(package) = manifest.get("package") {
1039 let name = package.get("name")?.as_str()?.to_owned();
1040 let version = package
1041 .get("version")
1042 .and_then(|value| value.as_str())
1043 .map(str::to_owned);
1044
1045 return Some(ProjectIdentity {
1046 name,
1047 version,
1048 kind: ProjectKind::RustPackage,
1049 source: IdentitySource::CargoToml,
1050 });
1051 }
1052
1053 if manifest.get("workspace").is_some() {
1054 let version = manifest
1055 .get("workspace")
1056 .and_then(|workspace| workspace.get("package"))
1057 .and_then(|package| package.get("version"))
1058 .and_then(|value| value.as_str())
1059 .map(str::to_owned);
1060
1061 return Some(ProjectIdentity {
1062 name: fallback_name.to_owned(),
1063 version,
1064 kind: ProjectKind::RustWorkspace,
1065 source: IdentitySource::CargoToml,
1066 });
1067 }
1068
1069 None
1070}
1071
1072fn detect_node_identity(root: &Path) -> Option<ProjectIdentity> {
1073 let manifest = read_json_value(&root.join("package.json"))?;
1074 let name = manifest.get("name")?.as_str()?.to_owned();
1075 let version = manifest
1076 .get("version")
1077 .and_then(|value| value.as_str())
1078 .map(str::to_owned);
1079
1080 Some(ProjectIdentity {
1081 name,
1082 version,
1083 kind: ProjectKind::NodePackage,
1084 source: IdentitySource::PackageJson,
1085 })
1086}
1087
1088fn detect_python_identity(root: &Path) -> Option<ProjectIdentity> {
1089 let manifest = read_toml_value(&root.join("pyproject.toml"))?;
1090 let project = manifest.get("project")?;
1091 let name = project.get("name")?.as_str()?.to_owned();
1092 let version = project
1093 .get("version")
1094 .and_then(|value| value.as_str())
1095 .map(str::to_owned);
1096
1097 Some(ProjectIdentity {
1098 name,
1099 version,
1100 kind: ProjectKind::PythonProject,
1101 source: IdentitySource::PyprojectToml,
1102 })
1103}
1104
1105fn read_toml_value(path: &Path) -> Option<toml::Value> {
1106 let content = fs::read_to_string(path).ok()?;
1107 toml::from_str(&content).ok()
1108}
1109
1110fn read_json_value(path: &Path) -> Option<serde_json::Value> {
1111 let content = fs::read_to_string(path).ok()?;
1112 serde_json::from_str(&content).ok()
1113}
1114
1115fn build_dependency_summary(manifests: Vec<PathBuf>) -> DependencySummary {
1116 let mut ecosystems = Vec::new();
1117
1118 for manifest in manifests {
1119 let Some(file_name) = manifest.file_name().and_then(|value| value.to_str()) else {
1120 continue;
1121 };
1122
1123 let summary = match file_name {
1124 "Cargo.toml" => summarize_cargo_dependencies(&manifest),
1125 "package.json" => summarize_node_dependencies(&manifest),
1126 "pyproject.toml" => summarize_pyproject_dependencies(&manifest),
1127 "requirements.txt" => summarize_requirements_dependencies(&manifest),
1128 _ => None,
1129 };
1130
1131 if let Some(summary) = summary {
1132 ecosystems.push(summary);
1133 }
1134 }
1135
1136 let total_manifests = ecosystems.len();
1137 let total_dependencies = ecosystems.iter().map(|summary| summary.total).sum();
1138
1139 DependencySummary {
1140 ecosystems,
1141 total_manifests,
1142 total_dependencies,
1143 }
1144}
1145
1146fn build_test_summary(mut tests: TestSummary, sources: Vec<PathBuf>) -> TestSummary {
1147 for source in sources {
1148 let Some(file_name) = source.file_name().and_then(|value| value.to_str()) else {
1149 continue;
1150 };
1151
1152 match file_name {
1153 "Cargo.toml" => add_rust_test_command(&mut tests, &source),
1154 "package.json" => add_node_test_commands(&mut tests, &source),
1155 "pyproject.toml" => add_python_test_command(&mut tests, &source),
1156 "CMakeLists.txt" => add_cmake_test_command(&mut tests, &source),
1157 _ => {}
1158 }
1159 }
1160
1161 tests
1162}
1163
1164struct MultiRootCheck {
1165 siblings: Vec<PathBuf>,
1166 nested: Vec<PathBuf>,
1167}
1168
1169fn detect_multi_root_situation(root: &Path) -> MultiRootCheck {
1170 let default_opts = discover::DiscoverOptions {
1171 min_confidence: discover::Confidence::Strong,
1172 ..discover::DiscoverOptions::default()
1173 };
1174 let default_roots = discover::discover_roots(root, &default_opts).unwrap_or_default();
1175 let siblings: Vec<PathBuf> = default_roots
1176 .iter()
1177 .filter(|entry| entry.path != root)
1178 .map(|entry| entry.path.clone())
1179 .collect();
1180
1181 let nested_opts = discover::DiscoverOptions {
1182 min_confidence: discover::Confidence::Strong,
1183 nested_vcs: true,
1184 ..discover::DiscoverOptions::default()
1185 };
1186 let nested_roots_all = discover::discover_roots(root, &nested_opts).unwrap_or_default();
1187 let nested: Vec<PathBuf> = nested_roots_all
1188 .iter()
1189 .filter(|entry| !default_roots.iter().any(|d| d.path == entry.path))
1190 .map(|entry| entry.path.clone())
1191 .collect();
1192
1193 MultiRootCheck { siblings, nested }
1194}
1195
1196fn build_risk_summary(inventory: &ScanInventory, multi_root: &MultiRootCheck) -> RiskSummary {
1197 let mut findings = Vec::new();
1198
1199 if !inventory.documentation.has_readme {
1200 findings.push(RiskFinding {
1201 severity: RiskSeverity::Medium,
1202 code: RiskCode::MissingReadme,
1203 message: "No README file detected.".to_owned(),
1204 path: None,
1205 });
1206 }
1207
1208 if !inventory.documentation.has_license {
1209 findings.push(RiskFinding {
1210 severity: RiskSeverity::Medium,
1211 code: RiskCode::MissingLicense,
1212 message: "No LICENSE file detected.".to_owned(),
1213 path: None,
1214 });
1215 } else if inventory.license.kind == LicenseKind::Unknown {
1216 findings.push(RiskFinding {
1217 severity: RiskSeverity::Low,
1218 code: RiskCode::UnknownLicense,
1219 message: "License file was detected, but the license type was not recognized."
1220 .to_owned(),
1221 path: inventory.license.path.clone(),
1222 });
1223 }
1224
1225 if !inventory.ci.has_any_provider() {
1226 findings.push(RiskFinding {
1227 severity: RiskSeverity::Low,
1228 code: RiskCode::MissingCi,
1229 message: "No known CI workflow detected.".to_owned(),
1230 path: None,
1231 });
1232 }
1233
1234 if inventory.tests.test_files == 0 && inventory.tests.commands.is_empty() {
1235 findings.push(RiskFinding {
1236 severity: RiskSeverity::Medium,
1237 code: RiskCode::NoTestsDetected,
1238 message: "No test files or test commands detected.".to_owned(),
1239 path: None,
1240 });
1241 }
1242
1243 for summary in &inventory.dependencies.ecosystems {
1244 if summary.total > 0 && summary.lockfile.is_none() {
1245 findings.push(RiskFinding {
1246 severity: RiskSeverity::Low,
1247 code: RiskCode::ManifestWithoutLockfile,
1248 message: "Dependency manifest has entries but no lockfile was detected.".to_owned(),
1249 path: Some(summary.manifest.clone()),
1250 });
1251 }
1252 }
1253
1254 if inventory.files_scanned >= 1000
1255 && !inventory.hygiene.has_gitignore
1256 && !inventory.hygiene.has_ignore
1257 {
1258 findings.push(RiskFinding {
1259 severity: RiskSeverity::Info,
1260 code: RiskCode::LargeProjectWithoutIgnoreRules,
1261 message: "Large project scanned without .gitignore or .ignore rules.".to_owned(),
1262 path: None,
1263 });
1264 }
1265
1266 if multi_root.siblings.len() >= 2 {
1267 findings.push(RiskFinding {
1268 severity: RiskSeverity::Medium,
1269 code: RiskCode::MultipleVcsRootsFound,
1270 message: format!(
1271 "{} additional project root(s) detected under this path; consider `projd scan --recursive` or `projd discover`.",
1272 multi_root.siblings.len()
1273 ),
1274 path: None,
1275 });
1276 }
1277
1278 if !multi_root.nested.is_empty() {
1279 findings.push(RiskFinding {
1280 severity: RiskSeverity::Low,
1281 code: RiskCode::NestedVcsRoot,
1282 message: format!(
1283 "{} nested VCS root(s) detected (vendored or submodule); pass --nested-vcs to enumerate them.",
1284 multi_root.nested.len()
1285 ),
1286 path: multi_root.nested.first().cloned(),
1287 });
1288 }
1289
1290 RiskSummary::from_findings(findings)
1291}
1292
1293impl RiskSummary {
1294 fn from_findings(findings: Vec<RiskFinding>) -> Self {
1295 let total = findings.len();
1296 let high = findings
1297 .iter()
1298 .filter(|risk| risk.severity == RiskSeverity::High)
1299 .count();
1300 let medium = findings
1301 .iter()
1302 .filter(|risk| risk.severity == RiskSeverity::Medium)
1303 .count();
1304 let low = findings
1305 .iter()
1306 .filter(|risk| risk.severity == RiskSeverity::Low)
1307 .count();
1308 let info = findings
1309 .iter()
1310 .filter(|risk| risk.severity == RiskSeverity::Info)
1311 .count();
1312
1313 Self {
1314 findings,
1315 total,
1316 high,
1317 medium,
1318 low,
1319 info,
1320 }
1321 }
1322}
1323
1324impl RiskSeverity {
1325 fn as_str(self) -> &'static str {
1326 match self {
1327 Self::High => "high",
1328 Self::Medium => "medium",
1329 Self::Low => "low",
1330 Self::Info => "info",
1331 }
1332 }
1333}
1334
1335impl ProjectHealth {
1336 pub fn as_str(self) -> &'static str {
1337 match self {
1338 Self::Healthy => "healthy",
1339 Self::NeedsAttention => "needs-attention",
1340 Self::Risky => "risky",
1341 Self::Unknown => "unknown",
1342 }
1343 }
1344}
1345
1346impl HealthSignalKind {
1347 fn as_str(self) -> &'static str {
1348 match self {
1349 Self::Readme => "readme",
1350 Self::License => "license",
1351 Self::Ci => "ci",
1352 Self::Tests => "tests",
1353 Self::Lockfiles => "lockfiles",
1354 Self::Vcs => "vcs",
1355 Self::Activity => "activity",
1356 Self::Docs => "docs",
1357 }
1358 }
1359}
1360
1361impl HealthSignalStatus {
1362 fn as_str(self) -> &'static str {
1363 match self {
1364 Self::Pass => "pass",
1365 Self::Warn => "warn",
1366 Self::Info => "info",
1367 }
1368 }
1369}
1370
1371fn build_health_summary(
1372 inventory: &ScanInventory,
1373 risks: &RiskSummary,
1374 vcs: &VcsSummary,
1375) -> HealthSummary {
1376 let mut signals = Vec::new();
1377
1378 signals.push(HealthSignal {
1379 kind: HealthSignalKind::Readme,
1380 status: pass_warn(inventory.documentation.has_readme),
1381 score_delta: if inventory.documentation.has_readme {
1382 10
1383 } else {
1384 -10
1385 },
1386 evidence: if inventory.documentation.has_readme {
1387 "README detected".to_owned()
1388 } else {
1389 "no README file detected".to_owned()
1390 },
1391 });
1392 signals.push(HealthSignal {
1393 kind: HealthSignalKind::License,
1394 status: pass_warn(inventory.documentation.has_license),
1395 score_delta: if inventory.documentation.has_license {
1396 20
1397 } else {
1398 -20
1399 },
1400 evidence: match (&inventory.license.path, inventory.license.kind) {
1401 (Some(path), kind) => format!("{} ({})", path.display(), kind.as_str()),
1402 (None, LicenseKind::Missing) => "no license file detected".to_owned(),
1403 (None, kind) => kind.as_str().to_owned(),
1404 },
1405 });
1406 signals.push(HealthSignal {
1407 kind: HealthSignalKind::Ci,
1408 status: pass_warn(inventory.ci.has_any_provider()),
1409 score_delta: if inventory.ci.has_any_provider() {
1410 20
1411 } else {
1412 -15
1413 },
1414 evidence: if inventory.ci.providers.is_empty() {
1415 "no known CI provider detected".to_owned()
1416 } else {
1417 let mut providers = inventory
1418 .ci
1419 .providers
1420 .iter()
1421 .map(|provider| provider.provider.as_str())
1422 .collect::<Vec<_>>();
1423 providers.sort_unstable();
1424 providers.dedup();
1425 providers.join(", ")
1426 },
1427 });
1428 let has_tests = inventory.tests.test_files > 0 || !inventory.tests.commands.is_empty();
1429 signals.push(HealthSignal {
1430 kind: HealthSignalKind::Tests,
1431 status: pass_warn(has_tests),
1432 score_delta: if has_tests { 20 } else { -20 },
1433 evidence: format!(
1434 "{} test file(s), {} command source(s)",
1435 inventory.tests.test_files,
1436 inventory.tests.commands.len()
1437 ),
1438 });
1439
1440 let lockfiles_ok = lockfiles_ok(&inventory.dependencies);
1441 signals.push(HealthSignal {
1442 kind: HealthSignalKind::Lockfiles,
1443 status: pass_warn(lockfiles_ok),
1444 score_delta: if lockfiles_ok { 10 } else { -10 },
1445 evidence: lockfile_evidence(&inventory.dependencies),
1446 });
1447
1448 let (vcs_status, vcs_delta, vcs_evidence) = if !vcs.is_repository {
1449 (
1450 HealthSignalStatus::Info,
1451 0,
1452 "no source control detected".to_owned(),
1453 )
1454 } else if vcs.is_dirty {
1455 (
1456 HealthSignalStatus::Warn,
1457 5,
1458 format!(
1459 "{} {}, dirty, {} modified, {} untracked",
1460 vcs.kind.label(),
1461 vcs.branch.as_deref().unwrap_or("unknown ref"),
1462 vcs.tracked_modified_files,
1463 vcs.untracked_files
1464 ),
1465 )
1466 } else {
1467 (
1468 HealthSignalStatus::Pass,
1469 15,
1470 format!(
1471 "{} {}, clean",
1472 vcs.kind.label(),
1473 vcs.branch.as_deref().unwrap_or("unknown ref")
1474 ),
1475 )
1476 };
1477 signals.push(HealthSignal {
1478 kind: HealthSignalKind::Vcs,
1479 status: vcs_status,
1480 score_delta: vcs_delta,
1481 evidence: vcs_evidence,
1482 });
1483
1484 let (activity_status, activity_delta, activity_evidence) =
1485 match vcs.activity.days_since_last_commit {
1486 None => (HealthSignalStatus::Info, 0, "activity unknown".to_owned()),
1487 Some(days) if days < 90 => (
1488 HealthSignalStatus::Pass,
1489 10,
1490 format!("active, last commit {days} day(s) ago"),
1491 ),
1492 Some(days) if days < 365 => (
1493 HealthSignalStatus::Warn,
1494 -5,
1495 format!("stale, last commit {days} day(s) ago"),
1496 ),
1497 Some(days) => (
1498 HealthSignalStatus::Warn,
1499 -10,
1500 format!("dormant, last commit {days} day(s) ago"),
1501 ),
1502 };
1503 signals.push(HealthSignal {
1504 kind: HealthSignalKind::Activity,
1505 status: activity_status,
1506 score_delta: activity_delta,
1507 evidence: activity_evidence,
1508 });
1509
1510 signals.push(HealthSignal {
1511 kind: HealthSignalKind::Docs,
1512 status: if inventory.documentation.has_docs_dir {
1513 HealthSignalStatus::Pass
1514 } else {
1515 HealthSignalStatus::Info
1516 },
1517 score_delta: if inventory.documentation.has_docs_dir {
1518 5
1519 } else {
1520 0
1521 },
1522 evidence: if inventory.documentation.has_docs_dir {
1523 "docs directory detected".to_owned()
1524 } else {
1525 "no docs directory detected".to_owned()
1526 },
1527 });
1528
1529 let positive_score = signals
1530 .iter()
1531 .filter(|signal| signal.score_delta > 0)
1532 .map(|signal| signal.score_delta as usize)
1533 .sum::<usize>();
1534 let score = positive_score
1535 .saturating_sub(risks.high * 20)
1536 .saturating_sub(risks.medium * 8)
1537 .saturating_sub(risks.low * 3)
1538 .min(100);
1539 let risk_level = overall_risk_level(risks);
1540 let grade = project_health_grade(score, risk_level, inventory.files_scanned);
1541
1542 HealthSummary {
1543 grade,
1544 score,
1545 risk_level,
1546 signals,
1547 }
1548}
1549
1550fn pass_warn(pass: bool) -> HealthSignalStatus {
1551 if pass {
1552 HealthSignalStatus::Pass
1553 } else {
1554 HealthSignalStatus::Warn
1555 }
1556}
1557
1558fn lockfiles_ok(dependencies: &DependencySummary) -> bool {
1559 dependencies
1560 .ecosystems
1561 .iter()
1562 .all(|summary| summary.total == 0 || summary.lockfile.is_some())
1563}
1564
1565fn lockfile_evidence(dependencies: &DependencySummary) -> String {
1566 if dependencies.ecosystems.is_empty() {
1567 return "no dependency manifests".to_owned();
1568 }
1569
1570 let lockfiles = dependencies
1571 .ecosystems
1572 .iter()
1573 .filter(|summary| summary.lockfile.is_some())
1574 .count();
1575 let missing = dependencies
1576 .ecosystems
1577 .iter()
1578 .filter(|summary| summary.total > 0 && summary.lockfile.is_none())
1579 .count();
1580
1581 format!(
1582 "{} manifest(s), {} lockfile(s), {} missing",
1583 dependencies.total_manifests, lockfiles, missing
1584 )
1585}
1586
1587fn overall_risk_level(risks: &RiskSummary) -> RiskSeverity {
1588 if risks.high > 0 {
1589 RiskSeverity::High
1590 } else if risks.medium > 0 {
1591 RiskSeverity::Medium
1592 } else if risks.low > 0 {
1593 RiskSeverity::Low
1594 } else {
1595 RiskSeverity::Info
1596 }
1597}
1598
1599fn project_health_grade(
1600 score: usize,
1601 risk_level: RiskSeverity,
1602 files_scanned: usize,
1603) -> ProjectHealth {
1604 if files_scanned == 0 {
1605 return ProjectHealth::Unknown;
1606 }
1607
1608 match (score, risk_level) {
1609 (_, RiskSeverity::High) => ProjectHealth::Risky,
1610 (80..=100, RiskSeverity::Info | RiskSeverity::Low) => ProjectHealth::Healthy,
1611 (50..=79, _) | (80..=100, RiskSeverity::Medium) => ProjectHealth::NeedsAttention,
1612 _ => ProjectHealth::Risky,
1613 }
1614}
1615
1616impl RiskCode {
1617 fn as_str(self) -> &'static str {
1618 match self {
1619 Self::MissingReadme => "missing-readme",
1620 Self::MissingLicense => "missing-license",
1621 Self::MissingCi => "missing-ci",
1622 Self::NoTestsDetected => "no-tests-detected",
1623 Self::ManifestWithoutLockfile => "manifest-without-lockfile",
1624 Self::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
1625 Self::UnknownLicense => "unknown-license",
1626 Self::MultipleVcsRootsFound => "multiple-vcs-roots-found",
1627 Self::NestedVcsRoot => "nested-vcs-root",
1628 }
1629 }
1630}
1631
1632impl LicenseKind {
1633 fn as_str(self) -> &'static str {
1634 match self {
1635 Self::Mit => "MIT",
1636 Self::Apache2 => "Apache-2.0",
1637 Self::Gpl => "GPL",
1638 Self::Bsd => "BSD",
1639 Self::Unknown => "unknown",
1640 Self::Missing => "missing",
1641 }
1642 }
1643}
1644
1645impl CiProvider {
1646 fn as_str(self) -> &'static str {
1647 match self {
1648 Self::GithubActions => "GitHub Actions",
1649 Self::GiteeGo => "Gitee Go",
1650 Self::GitlabCi => "GitLab CI",
1651 Self::CircleCi => "CircleCI",
1652 Self::Jenkins => "Jenkins",
1653 }
1654 }
1655}
1656
1657fn add_rust_test_command(tests: &mut TestSummary, source: &Path) {
1658 add_test_command(
1659 tests,
1660 TestCommand {
1661 ecosystem: TestEcosystem::Rust,
1662 source: source.to_path_buf(),
1663 name: "cargo test".to_owned(),
1664 command: "cargo test".to_owned(),
1665 },
1666 );
1667}
1668
1669fn add_node_test_commands(tests: &mut TestSummary, source: &Path) {
1670 let Some(value) = read_json_value(source) else {
1671 return;
1672 };
1673 let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) else {
1674 return;
1675 };
1676
1677 for (name, command) in scripts {
1678 if name == "test" || name.starts_with("test:") {
1679 if let Some(command) = command.as_str() {
1680 add_test_command(
1681 tests,
1682 TestCommand {
1683 ecosystem: TestEcosystem::Node,
1684 source: source.to_path_buf(),
1685 name: name.to_owned(),
1686 command: command.to_owned(),
1687 },
1688 );
1689 }
1690 }
1691 }
1692}
1693
1694fn add_python_test_command(tests: &mut TestSummary, source: &Path) {
1695 let Some(value) = read_toml_value(source) else {
1696 return;
1697 };
1698
1699 let has_pytest_config = value
1700 .get("tool")
1701 .and_then(|tool| tool.get("pytest"))
1702 .and_then(|pytest| pytest.get("ini_options"))
1703 .is_some();
1704
1705 if has_pytest_config {
1706 add_test_command(
1707 tests,
1708 TestCommand {
1709 ecosystem: TestEcosystem::Python,
1710 source: source.to_path_buf(),
1711 name: "pytest".to_owned(),
1712 command: "pytest".to_owned(),
1713 },
1714 );
1715 }
1716}
1717
1718fn add_cmake_test_command(tests: &mut TestSummary, source: &Path) {
1719 let Ok(content) = fs::read_to_string(source) else {
1720 return;
1721 };
1722 let lower = content.to_ascii_lowercase();
1723
1724 if lower.contains("enable_testing") || lower.contains("add_test") {
1725 add_test_command(
1726 tests,
1727 TestCommand {
1728 ecosystem: TestEcosystem::CMake,
1729 source: source.to_path_buf(),
1730 name: "ctest".to_owned(),
1731 command: "ctest".to_owned(),
1732 },
1733 );
1734 }
1735}
1736
1737fn add_test_command(tests: &mut TestSummary, command: TestCommand) {
1738 if tests.commands.iter().any(|existing| {
1739 existing.ecosystem == command.ecosystem
1740 && existing.source == command.source
1741 && existing.name == command.name
1742 && existing.command == command.command
1743 }) {
1744 return;
1745 }
1746
1747 tests.commands.push(command);
1748}
1749
1750fn summarize_cargo_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1751 let value = read_toml_value(manifest)?;
1752 let normal = count_toml_table_entries(value.get("dependencies"));
1753 let development = count_toml_table_entries(value.get("dev-dependencies"));
1754 let build = count_toml_table_entries(value.get("build-dependencies"));
1755 let optional = count_optional_cargo_dependencies(value.get("dependencies"));
1756 let total = normal + development + build;
1757
1758 Some(DependencyEcosystemSummary {
1759 ecosystem: DependencyEcosystem::Rust,
1760 source: DependencySource::CargoToml,
1761 manifest: manifest.to_path_buf(),
1762 lockfile: find_nearest_lockfile(manifest, &["Cargo.lock"]),
1763 normal,
1764 development,
1765 build,
1766 optional,
1767 total,
1768 })
1769}
1770
1771fn summarize_node_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1772 let value = read_json_value(manifest)?;
1773 let normal = count_json_object_entries(value.get("dependencies"));
1774 let development = count_json_object_entries(value.get("devDependencies"));
1775 let optional = count_json_object_entries(value.get("optionalDependencies"));
1776 let total = normal + development + optional;
1777
1778 Some(DependencyEcosystemSummary {
1779 ecosystem: DependencyEcosystem::Node,
1780 source: DependencySource::PackageJson,
1781 manifest: manifest.to_path_buf(),
1782 lockfile: find_sibling_lockfile(
1783 manifest,
1784 &[
1785 "pnpm-lock.yaml",
1786 "yarn.lock",
1787 "package-lock.json",
1788 "bun.lockb",
1789 ],
1790 ),
1791 normal,
1792 development,
1793 build: 0,
1794 optional,
1795 total,
1796 })
1797}
1798
1799fn summarize_pyproject_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1800 let value = read_toml_value(manifest)?;
1801 let project = value.get("project");
1802 let normal = project
1803 .and_then(|project| project.get("dependencies"))
1804 .and_then(|dependencies| dependencies.as_array())
1805 .map_or(0, Vec::len);
1806 let optional = project
1807 .and_then(|project| project.get("optional-dependencies"))
1808 .and_then(|dependencies| dependencies.as_table())
1809 .map(|groups| {
1810 groups
1811 .values()
1812 .filter_map(|value| value.as_array())
1813 .map(Vec::len)
1814 .sum()
1815 })
1816 .unwrap_or(0);
1817 let development = project
1818 .and_then(|project| project.get("optional-dependencies"))
1819 .and_then(|dependencies| dependencies.get("dev"))
1820 .and_then(|dependencies| dependencies.as_array())
1821 .map_or(0, Vec::len);
1822 let total = normal + optional;
1823
1824 Some(DependencyEcosystemSummary {
1825 ecosystem: DependencyEcosystem::Python,
1826 source: DependencySource::PyprojectToml,
1827 manifest: manifest.to_path_buf(),
1828 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1829 normal,
1830 development,
1831 build: 0,
1832 optional,
1833 total,
1834 })
1835}
1836
1837fn summarize_requirements_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1838 let content = fs::read_to_string(manifest).ok()?;
1839 let normal = content
1840 .lines()
1841 .map(str::trim)
1842 .filter(|line| is_requirement_entry(line))
1843 .count();
1844
1845 Some(DependencyEcosystemSummary {
1846 ecosystem: DependencyEcosystem::Python,
1847 source: DependencySource::RequirementsTxt,
1848 manifest: manifest.to_path_buf(),
1849 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1850 normal,
1851 development: 0,
1852 build: 0,
1853 optional: 0,
1854 total: normal,
1855 })
1856}
1857
1858fn count_toml_table_entries(value: Option<&toml::Value>) -> usize {
1859 value
1860 .and_then(|value| value.as_table())
1861 .map_or(0, |table| table.len())
1862}
1863
1864fn count_optional_cargo_dependencies(value: Option<&toml::Value>) -> usize {
1865 value
1866 .and_then(|value| value.as_table())
1867 .map(|dependencies| {
1868 dependencies
1869 .values()
1870 .filter(|value| {
1871 value
1872 .as_table()
1873 .and_then(|table| table.get("optional"))
1874 .and_then(|optional| optional.as_bool())
1875 .unwrap_or(false)
1876 })
1877 .count()
1878 })
1879 .unwrap_or(0)
1880}
1881
1882fn count_json_object_entries(value: Option<&serde_json::Value>) -> usize {
1883 value
1884 .and_then(|value| value.as_object())
1885 .map_or(0, |object| object.len())
1886}
1887
1888fn is_requirement_entry(line: &str) -> bool {
1889 !line.is_empty() && !line.starts_with('#') && !line.starts_with('-') && !line.starts_with("--")
1890}
1891
1892fn find_nearest_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1893 let mut current = manifest.parent();
1894 while let Some(dir) = current {
1895 if let Some(lockfile) = find_lockfile_in_dir(dir, names) {
1896 return Some(lockfile);
1897 }
1898 current = dir.parent();
1899 }
1900
1901 None
1902}
1903
1904fn find_sibling_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1905 find_lockfile_in_dir(manifest.parent()?, names)
1906}
1907
1908fn find_lockfile_in_dir(dir: &Path, names: &[&str]) -> Option<PathBuf> {
1909 names
1910 .iter()
1911 .map(|name| dir.join(name))
1912 .find(|path| path.is_file())
1913}
1914
1915fn detect_vcs_summary(root: &Path, opts: &ScanOptions) -> VcsSummary {
1916 if root.join(".git").exists() {
1917 return detect_git_into(root, opts);
1918 }
1919 if root.join(".hg").is_dir() {
1920 return detect_hg_into(root);
1921 }
1922 if root.join(".svn").is_dir() {
1923 return detect_svn_into(root);
1924 }
1925 if root.join(".fslckout").is_file() || root.join("_FOSSIL_").is_file() {
1926 return detect_fossil_into(root);
1927 }
1928 if root.join(".bzr").is_dir() {
1929 return detect_bzr_into(root);
1930 }
1931 VcsSummary::default()
1932}
1933
1934fn detect_git_into(root: &Path, opts: &ScanOptions) -> VcsSummary {
1935 let detected_root = run_vcs_output("git", root, &["rev-parse", "--show-toplevel"])
1936 .and_then(|output| output.lines().next().map(PathBuf::from))
1937 .or_else(|| Some(root.to_path_buf()));
1938
1939 let branch = run_vcs_output("git", root, &["branch", "--show-current"])
1940 .and_then(|output| non_empty_trimmed(&output));
1941 let revision = run_vcs_output("git", root, &["rev-parse", "HEAD"])
1942 .and_then(|output| non_empty_trimmed(&output));
1943 let last_commit = run_vcs_output("git", root, &["log", "-1", "--format=%cI"])
1944 .and_then(|output| non_empty_trimmed(&output));
1945 let porcelain = run_vcs_output("git", root, &["status", "--porcelain"]).unwrap_or_default();
1946 let mut tracked_modified_files = 0;
1947 let mut untracked_files = 0;
1948
1949 for line in porcelain.lines() {
1950 if line.starts_with("??") {
1951 untracked_files += 1;
1952 } else if !line.trim().is_empty() {
1953 tracked_modified_files += 1;
1954 }
1955 }
1956
1957 let activity = detect_git_activity(root, opts);
1958
1959 VcsSummary {
1960 kind: VcsKind::Git,
1961 is_repository: true,
1962 root: detected_root,
1963 branch,
1964 revision,
1965 last_commit,
1966 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1967 tracked_modified_files,
1968 untracked_files,
1969 activity,
1970 }
1971}
1972
1973fn detect_git_activity(root: &Path, opts: &ScanOptions) -> VcsActivity {
1974 let last_commit_ts = run_vcs_output("git", root, &["log", "-1", "--format=%ct"])
1975 .and_then(|s| s.trim().parse::<i64>().ok());
1976 let days_since_last_commit = last_commit_ts.and_then(|ts| {
1977 let now = std::time::SystemTime::now()
1978 .duration_since(std::time::UNIX_EPOCH)
1979 .ok()?
1980 .as_secs() as i64;
1981 let delta = (now - ts).max(0);
1982 Some((delta / 86_400) as u32)
1983 });
1984
1985 let commits_last_90d = run_vcs_output(
1986 "git",
1987 root,
1988 &["rev-list", "--count", "--since=90 days ago", "HEAD"],
1989 )
1990 .and_then(|s| s.trim().parse::<u32>().ok());
1991
1992 let contributors_count =
1993 run_vcs_output("git", root, &["shortlog", "-sne", "HEAD"]).map(|output| {
1994 output
1995 .lines()
1996 .filter(|line| !line.trim().is_empty())
1997 .count() as u32
1998 });
1999
2000 let first_commit_date = run_vcs_output(
2001 "git",
2002 root,
2003 &["log", "--max-parents=0", "--format=%aI", "HEAD"],
2004 )
2005 .and_then(|output| output.lines().next().map(|s| s.trim().to_owned()))
2006 .filter(|s| !s.is_empty());
2007
2008 let contributors = if opts.detailed_contributors {
2009 parse_git_shortlog(
2010 &run_vcs_output("git", root, &["shortlog", "-sne", "HEAD"]).unwrap_or_default(),
2011 )
2012 } else {
2013 Vec::new()
2014 };
2015
2016 VcsActivity {
2017 days_since_last_commit,
2018 commits_last_90d,
2019 contributors_count,
2020 first_commit_date,
2021 contributors,
2022 }
2023}
2024
2025fn parse_git_shortlog(text: &str) -> Vec<VcsContributor> {
2026 let mut out = Vec::new();
2027 for line in text.lines() {
2028 let trimmed = line.trim();
2029 if trimmed.is_empty() {
2030 continue;
2031 }
2032 let mut parts = trimmed.splitn(2, char::is_whitespace);
2034 let count_str = parts.next().unwrap_or("");
2035 let rest = parts.next().unwrap_or("").trim();
2036 let Ok(count) = count_str.parse::<u32>() else {
2037 continue;
2038 };
2039 let (name, email) = match (rest.rfind('<'), rest.rfind('>')) {
2040 (Some(open), Some(close)) if open < close => {
2041 let name = rest[..open].trim().to_owned();
2042 let email = rest[open + 1..close].trim().to_owned();
2043 (name, email)
2044 }
2045 _ => (rest.to_owned(), String::new()),
2046 };
2047 out.push(VcsContributor {
2048 name,
2049 email,
2050 commit_count: count,
2051 });
2052 }
2053 out
2054}
2055
2056fn detect_hg_into(root: &Path) -> VcsSummary {
2057 let detected_root = run_vcs_output("hg", root, &["root"])
2058 .and_then(|output| output.lines().next().map(PathBuf::from))
2059 .or_else(|| Some(root.to_path_buf()));
2060
2061 let branch = run_vcs_output("hg", root, &["branch"]).and_then(|s| non_empty_trimmed(&s));
2062 let revision = run_vcs_output("hg", root, &["log", "-r", ".", "--template", "{node}"])
2063 .and_then(|s| non_empty_trimmed(&s));
2064 let last_commit = run_vcs_output(
2065 "hg",
2066 root,
2067 &["log", "-r", ".", "--template", "{date|isodatesec}"],
2068 )
2069 .and_then(|s| non_empty_trimmed(&s));
2070
2071 let status_text = run_vcs_output("hg", root, &["status"]).unwrap_or_default();
2072 let mut tracked_modified_files = 0;
2073 let mut untracked_files = 0;
2074 for line in status_text.lines() {
2075 let mut chars = line.chars();
2076 match chars.next() {
2077 Some('?') => untracked_files += 1,
2078 Some(c) if "MARC!".contains(c) => tracked_modified_files += 1,
2079 _ => {}
2080 }
2081 }
2082
2083 VcsSummary {
2084 kind: VcsKind::Hg,
2085 is_repository: true,
2086 root: detected_root,
2087 branch,
2088 revision,
2089 last_commit,
2090 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
2091 tracked_modified_files,
2092 untracked_files,
2093 activity: VcsActivity::default(),
2094 }
2095}
2096
2097fn detect_svn_into(root: &Path) -> VcsSummary {
2098 let detected_root = run_vcs_output("svn", root, &["info", "--show-item", "wc-root"])
2099 .and_then(|s| non_empty_trimmed(&s))
2100 .map(PathBuf::from)
2101 .or_else(|| Some(root.to_path_buf()));
2102 let branch = run_vcs_output("svn", root, &["info", "--show-item", "url"])
2103 .and_then(|s| non_empty_trimmed(&s));
2104 let revision = run_vcs_output("svn", root, &["info", "--show-item", "revision"])
2105 .and_then(|s| non_empty_trimmed(&s));
2106 let last_commit = run_vcs_output("svn", root, &["info", "--show-item", "last-changed-date"])
2107 .and_then(|s| non_empty_trimmed(&s));
2108
2109 let status_text = run_vcs_output("svn", root, &["status"]).unwrap_or_default();
2110 let mut tracked_modified_files = 0;
2111 let mut untracked_files = 0;
2112 for line in status_text.lines() {
2113 let mut chars = line.chars();
2114 match chars.next() {
2115 Some('?') => untracked_files += 1,
2116 Some(c) if "ADMRC!~".contains(c) => tracked_modified_files += 1,
2117 _ => {}
2118 }
2119 }
2120
2121 VcsSummary {
2122 kind: VcsKind::Svn,
2123 is_repository: true,
2124 root: detected_root,
2125 branch,
2126 revision,
2127 last_commit,
2128 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
2129 tracked_modified_files,
2130 untracked_files,
2131 activity: VcsActivity::default(),
2132 }
2133}
2134
2135fn detect_fossil_into(root: &Path) -> VcsSummary {
2136 let status_text = run_vcs_output("fossil", root, &["status"]).unwrap_or_default();
2137 let parsed = parse_fossil_status(&status_text);
2138 VcsSummary {
2139 kind: VcsKind::Fossil,
2140 is_repository: true,
2141 root: Some(root.to_path_buf()),
2142 branch: parsed.branch,
2143 revision: parsed.revision,
2144 last_commit: parsed.last_commit,
2145 is_dirty: parsed.tracked_modified_files > 0,
2146 tracked_modified_files: parsed.tracked_modified_files,
2147 untracked_files: 0,
2148 activity: VcsActivity::default(),
2149 }
2150}
2151
2152fn detect_bzr_into(root: &Path) -> VcsSummary {
2153 let branch = run_vcs_output("bzr", root, &["nick"]).and_then(|s| non_empty_trimmed(&s));
2154 let revision = run_vcs_output("bzr", root, &["revno"]).and_then(|s| non_empty_trimmed(&s));
2155 let log_text = run_vcs_output(
2156 "bzr",
2157 root,
2158 &["log", "-l1", "--log-format=long", "--timezone=utc"],
2159 )
2160 .unwrap_or_default();
2161 let last_commit = parse_bzr_log_timestamp(&log_text);
2162 let status_text = run_vcs_output("bzr", root, &["status", "-SV"]).unwrap_or_default();
2163 let parsed = parse_bzr_status(&status_text);
2164
2165 VcsSummary {
2166 kind: VcsKind::Bzr,
2167 is_repository: true,
2168 root: Some(root.to_path_buf()),
2169 branch,
2170 revision,
2171 last_commit,
2172 is_dirty: parsed.tracked_modified_files > 0 || parsed.untracked_files > 0,
2173 tracked_modified_files: parsed.tracked_modified_files,
2174 untracked_files: parsed.untracked_files,
2175 activity: VcsActivity::default(),
2176 }
2177}
2178
2179#[derive(Clone, Debug, Default, Eq, PartialEq)]
2180pub(crate) struct ParsedFossilStatus {
2181 pub branch: Option<String>,
2182 pub revision: Option<String>,
2183 pub last_commit: Option<String>,
2184 pub tracked_modified_files: usize,
2185}
2186
2187pub(crate) fn parse_fossil_status(text: &str) -> ParsedFossilStatus {
2188 let mut parsed = ParsedFossilStatus::default();
2189 for line in text.lines() {
2190 let trimmed = line.trim_end();
2191 if let Some(rest) = trimmed.strip_prefix("tags:") {
2192 parsed.branch = rest
2193 .split(',')
2194 .next()
2195 .map(str::trim)
2196 .filter(|s| !s.is_empty())
2197 .map(String::from);
2198 } else if let Some(rest) = trimmed.strip_prefix("checkout:") {
2199 let rest = rest.trim();
2200 let mut parts = rest.splitn(2, char::is_whitespace);
2201 parsed.revision = parts
2202 .next()
2203 .map(str::trim)
2204 .filter(|s| !s.is_empty())
2205 .map(String::from);
2206 parsed.last_commit = parts
2207 .next()
2208 .map(|s| s.trim().to_owned())
2209 .filter(|s| !s.is_empty());
2210 } else if is_fossil_status_code_line(trimmed) {
2211 parsed.tracked_modified_files += 1;
2212 }
2213 }
2214 parsed
2215}
2216
2217fn is_fossil_status_code_line(line: &str) -> bool {
2218 let first_word = line.trim_start().split_whitespace().next().unwrap_or("");
2219 matches!(
2220 first_word,
2221 "EDITED" | "ADDED" | "RENAMED" | "MISSING" | "CONFLICT" | "UPDATED" | "DELETED"
2222 )
2223}
2224
2225#[derive(Clone, Debug, Default, Eq, PartialEq)]
2226pub(crate) struct ParsedBzrStatus {
2227 pub tracked_modified_files: usize,
2228 pub untracked_files: usize,
2229}
2230
2231pub(crate) fn parse_bzr_status(text: &str) -> ParsedBzrStatus {
2232 let mut parsed = ParsedBzrStatus::default();
2233 for line in text.lines() {
2234 let mut chars = line.chars();
2237 let _flag0 = chars.next();
2238 let code = chars.next();
2239 match code {
2240 Some('M') | Some('A') | Some('D') | Some('R') | Some('K') | Some('N') => {
2241 parsed.tracked_modified_files += 1;
2242 }
2243 Some('?') => parsed.untracked_files += 1,
2244 _ => {
2245 if line.starts_with('?') {
2248 parsed.untracked_files += 1;
2249 }
2250 }
2251 }
2252 }
2253 parsed
2254}
2255
2256pub(crate) fn parse_bzr_log_timestamp(text: &str) -> Option<String> {
2257 for line in text.lines() {
2258 if let Some(rest) = line.trim_start().strip_prefix("timestamp:") {
2259 let value = rest.trim();
2260 if !value.is_empty() {
2261 return Some(value.to_owned());
2262 }
2263 }
2264 }
2265 None
2266}
2267
2268fn run_vcs_output(program: &str, root: &Path, args: &[&str]) -> Option<String> {
2269 let output = Command::new(program)
2270 .args(args)
2271 .current_dir(root)
2272 .output()
2273 .ok()?;
2274
2275 if !output.status.success() {
2276 return None;
2277 }
2278
2279 Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
2280}
2281
2282fn non_empty_trimmed(value: &str) -> Option<String> {
2283 let value = value.trim();
2284 if value.is_empty() {
2285 None
2286 } else {
2287 Some(value.to_owned())
2288 }
2289}
2290
2291fn detect_indicators(root: &Path) -> Vec<ProjectIndicator> {
2292 let checks = [
2293 ("Cargo.toml", IndicatorKind::RustWorkspace),
2294 (".git", IndicatorKind::GitRepository),
2295 ("README.md", IndicatorKind::Readme),
2296 ];
2297
2298 let mut indicators = Vec::new();
2299 for (relative, kind) in checks {
2300 let path = root.join(relative);
2301 if path.exists() {
2302 indicators.push(ProjectIndicator { kind, path });
2303 }
2304 }
2305
2306 let cargo_manifest = root.join("Cargo.toml");
2307 if cargo_manifest.exists() {
2308 indicators.push(ProjectIndicator {
2309 kind: IndicatorKind::RustPackage,
2310 path: cargo_manifest,
2311 });
2312 }
2313
2314 indicators
2315}
2316
2317fn scan_inventory(root: &Path) -> Result<ScanInventory> {
2318 let mut inventory = MutableInventory::default();
2319 let ignore_matcher = IgnoreMatcher::new(root);
2320 walk_project(root, &ignore_matcher, &mut inventory)?;
2321
2322 Ok(ScanInventory {
2323 languages: inventory.languages,
2324 build_systems: inventory.build_systems,
2325 code: inventory.code,
2326 dependencies: build_dependency_summary(inventory.dependency_manifests),
2327 tests: build_test_summary(inventory.tests, inventory.test_command_sources),
2328 hygiene: inventory.hygiene,
2329 documentation: inventory.documentation,
2330 license: build_license_summary(&inventory.license_files),
2331 ci: inventory.ci,
2332 containers: inventory.containers,
2333 files_scanned: inventory.files_scanned,
2334 skipped_dirs: inventory.skipped_dirs,
2335 })
2336}
2337
2338fn walk_project(
2339 path: &Path,
2340 ignore_matcher: &IgnoreMatcher,
2341 inventory: &mut MutableInventory,
2342) -> Result<()> {
2343 let local_ignore_matcher = ignore_matcher.with_child_ignores(path);
2344 let mut entries = Vec::new();
2345
2346 for entry in
2347 fs::read_dir(path).with_context(|| format!("failed to read `{}`", path.display()))?
2348 {
2349 let entry =
2350 entry.with_context(|| format!("failed to read entry under `{}`", path.display()))?;
2351 entries.push(entry);
2352 }
2353
2354 entries.sort_by_key(|entry| entry.file_name());
2355
2356 for entry in entries {
2357 let entry_path = entry.path();
2358 let file_type = entry
2359 .file_type()
2360 .with_context(|| format!("failed to inspect `{}`", entry_path.display()))?;
2361
2362 if file_type.is_dir() {
2363 if should_skip_dir(entry.file_name().as_os_str())
2364 || local_ignore_matcher.is_ignored(&entry_path, true)
2365 {
2366 inventory.add_skipped_dir(entry_path);
2367 continue;
2368 }
2369
2370 observe_directory(&entry_path, inventory);
2371 walk_project(&entry_path, &local_ignore_matcher, inventory)?;
2372 continue;
2373 }
2374
2375 if file_type.is_file() {
2376 if local_ignore_matcher.is_ignored(&entry_path, false) {
2377 continue;
2378 }
2379
2380 inventory.files_scanned += 1;
2381 observe_file(&entry_path, inventory);
2382 }
2383 }
2384
2385 Ok(())
2386}
2387
2388fn should_skip_dir(name: &OsStr) -> bool {
2389 matches!(
2390 name.to_str(),
2391 Some(".git" | ".hg" | ".svn" | "target" | "node_modules")
2392 )
2393}
2394
2395#[derive(Clone)]
2396struct IgnoreMatcher {
2397 matchers: Vec<Gitignore>,
2398}
2399
2400impl IgnoreMatcher {
2401 fn new(root: &Path) -> Self {
2402 Self {
2403 matchers: Self::build_matchers(root),
2404 }
2405 }
2406
2407 fn with_child_ignores(&self, dir: &Path) -> Self {
2408 let mut matchers = self.matchers.clone();
2409 matchers.extend(Self::build_matchers(dir));
2410 Self { matchers }
2411 }
2412
2413 fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
2414 self.matchers
2415 .iter()
2416 .rev()
2417 .find_map(|matcher| {
2418 let result = matcher.matched(path, is_dir);
2419 if result.is_none() {
2420 None
2421 } else {
2422 Some(result.is_ignore())
2423 }
2424 })
2425 .unwrap_or(false)
2426 }
2427
2428 fn build_matchers(dir: &Path) -> Vec<Gitignore> {
2429 [".gitignore", ".ignore"]
2430 .into_iter()
2431 .filter_map(|file_name| Self::build_matcher(dir, file_name))
2432 .collect()
2433 }
2434
2435 fn build_matcher(dir: &Path, file_name: &str) -> Option<Gitignore> {
2436 let path = dir.join(file_name);
2437 if !path.is_file() {
2438 return None;
2439 }
2440
2441 let mut builder = GitignoreBuilder::new(dir);
2442 let _ = builder.add(&path);
2443 builder.build().ok()
2444 }
2445}
2446
2447fn observe_directory(path: &Path, inventory: &mut MutableInventory) {
2448 if path.file_name().and_then(|value| value.to_str()) == Some("docs") {
2449 inventory.documentation.has_docs_dir = true;
2450 }
2451 if is_test_directory(path) {
2452 add_test_directory(&mut inventory.tests, path.to_path_buf());
2453 }
2454}
2455
2456fn observe_file(path: &Path, inventory: &mut MutableInventory) {
2457 if let Some(kind) = language_for_path(path) {
2458 inventory.add_language(kind);
2459 inventory.add_code_file(kind, path);
2460 }
2461
2462 if let Some(kind) = build_system_for_path(path) {
2463 inventory.add_build_system(kind, path.to_path_buf());
2464 }
2465 if is_dependency_manifest(path) {
2466 inventory.add_dependency_manifest(path.to_path_buf());
2467 }
2468 if is_test_file(path) {
2469 inventory.tests.test_files += 1;
2470 }
2471 if is_test_command_source(path) {
2472 inventory.add_test_command_source(path.to_path_buf());
2473 }
2474 observe_hygiene_file(path, &mut inventory.hygiene);
2475
2476 observe_documentation_file(path, &mut inventory.documentation);
2477 if is_license_file(path) {
2478 inventory.add_license_file(path.to_path_buf());
2479 }
2480 observe_ci_file(path, &mut inventory.ci);
2481 observe_container_file(path, &mut inventory.containers);
2482}
2483
2484fn language_for_path(path: &Path) -> Option<LanguageKind> {
2485 let extension = path
2486 .extension()
2487 .and_then(|value| value.to_str())
2488 .map(str::to_ascii_lowercase)?;
2489
2490 match extension.as_str() {
2491 "rs" => Some(LanguageKind::Rust),
2492 "ts" | "tsx" => Some(LanguageKind::TypeScript),
2493 "js" | "jsx" | "mjs" | "cjs" => Some(LanguageKind::JavaScript),
2494 "py" => Some(LanguageKind::Python),
2495 "c" => Some(LanguageKind::C),
2496 "cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some(LanguageKind::Cpp),
2497 "go" => Some(LanguageKind::Go),
2498 _ => None,
2499 }
2500}
2501
2502fn count_code_lines(path: &Path, kind: LanguageKind) -> CodeLineCounts {
2503 let Ok(content) = fs::read_to_string(path) else {
2504 return CodeLineCounts::default();
2505 };
2506
2507 let mut counts = CodeLineCounts::default();
2508 let mut in_block_comment = false;
2509
2510 for line in content.lines() {
2511 counts.total_lines += 1;
2512 let trimmed = line.trim();
2513
2514 if trimmed.is_empty() {
2515 counts.blank_lines += 1;
2516 continue;
2517 }
2518
2519 let classification = classify_code_line(trimmed, kind, &mut in_block_comment);
2520 if classification.has_comment {
2521 counts.comment_lines += 1;
2522 }
2523 if classification.has_code {
2524 counts.code_lines += 1;
2525 }
2526 }
2527
2528 counts
2529}
2530
2531#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2532struct CodeLineClassification {
2533 has_code: bool,
2534 has_comment: bool,
2535}
2536
2537fn classify_code_line(
2538 mut line: &str,
2539 kind: LanguageKind,
2540 in_block_comment: &mut bool,
2541) -> CodeLineClassification {
2542 if !supports_c_style_block_comments(kind) {
2543 return classify_simple_comment_line(line, kind);
2544 }
2545
2546 let mut classification = CodeLineClassification::default();
2547
2548 loop {
2549 if *in_block_comment {
2550 classification.has_comment = true;
2551 if let Some(end) = line.find("*/") {
2552 *in_block_comment = false;
2553 line = &line[end + 2..];
2554 if line.trim().is_empty() {
2555 break;
2556 }
2557 continue;
2558 }
2559 break;
2560 }
2561
2562 let current = line.trim_start();
2563 if current.is_empty() {
2564 break;
2565 }
2566 if current.starts_with("//") {
2567 classification.has_comment = true;
2568 break;
2569 }
2570
2571 let block_start = current.find("/*");
2572 let line_comment_start = current.find("//");
2573
2574 match (block_start, line_comment_start) {
2575 (Some(block), Some(comment)) if comment < block => {
2576 classification.has_comment = true;
2577 if !current[..comment].trim().is_empty() {
2578 classification.has_code = true;
2579 }
2580 break;
2581 }
2582 (Some(block), _) => {
2583 classification.has_comment = true;
2584 if !current[..block].trim().is_empty() {
2585 classification.has_code = true;
2586 }
2587 let after_block_start = ¤t[block + 2..];
2588 if let Some(end) = after_block_start.find("*/") {
2589 line = &after_block_start[end + 2..];
2590 if line.trim().is_empty() {
2591 break;
2592 }
2593 continue;
2594 }
2595 *in_block_comment = true;
2596 break;
2597 }
2598 (None, Some(comment)) => {
2599 classification.has_comment = true;
2600 if !current[..comment].trim().is_empty() {
2601 classification.has_code = true;
2602 }
2603 break;
2604 }
2605 (None, None) => {
2606 classification.has_code = true;
2607 break;
2608 }
2609 }
2610 }
2611
2612 classification
2613}
2614
2615fn classify_simple_comment_line(line: &str, kind: LanguageKind) -> CodeLineClassification {
2616 let markers = simple_comment_markers(kind);
2617
2618 if markers.iter().any(|marker| line.starts_with(marker)) {
2619 CodeLineClassification {
2620 has_code: false,
2621 has_comment: true,
2622 }
2623 } else {
2624 CodeLineClassification {
2625 has_code: true,
2626 has_comment: markers
2627 .iter()
2628 .any(|marker| line.find(marker).is_some_and(|index| index > 0)),
2629 }
2630 }
2631}
2632
2633fn supports_c_style_block_comments(kind: LanguageKind) -> bool {
2634 matches!(
2635 kind,
2636 LanguageKind::Rust
2637 | LanguageKind::TypeScript
2638 | LanguageKind::JavaScript
2639 | LanguageKind::C
2640 | LanguageKind::Cpp
2641 | LanguageKind::Go
2642 )
2643}
2644
2645fn simple_comment_markers(kind: LanguageKind) -> &'static [&'static str] {
2646 match kind {
2647 LanguageKind::Python => &["#"],
2648 LanguageKind::Rust
2649 | LanguageKind::TypeScript
2650 | LanguageKind::JavaScript
2651 | LanguageKind::C
2652 | LanguageKind::Cpp
2653 | LanguageKind::Go => &["//"],
2654 }
2655}
2656
2657fn build_system_for_path(path: &Path) -> Option<BuildSystemKind> {
2658 let file_name = path.file_name()?.to_str()?;
2659
2660 match file_name {
2661 "Cargo.toml" => Some(BuildSystemKind::Cargo),
2662 "package.json" => Some(BuildSystemKind::NodePackage),
2663 "pyproject.toml" => Some(BuildSystemKind::PythonProject),
2664 "requirements.txt" => Some(BuildSystemKind::PythonRequirements),
2665 "CMakeLists.txt" => Some(BuildSystemKind::CMake),
2666 "go.mod" => Some(BuildSystemKind::GoModule),
2667 _ => None,
2668 }
2669}
2670
2671fn is_dependency_manifest(path: &Path) -> bool {
2672 matches!(
2673 path.file_name().and_then(|value| value.to_str()),
2674 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "requirements.txt")
2675 )
2676}
2677
2678fn is_test_command_source(path: &Path) -> bool {
2679 matches!(
2680 path.file_name().and_then(|value| value.to_str()),
2681 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "CMakeLists.txt")
2682 )
2683}
2684
2685fn is_test_directory(path: &Path) -> bool {
2686 matches!(
2687 path.file_name().and_then(|value| value.to_str()),
2688 Some("tests" | "test" | "__tests__" | "spec")
2689 )
2690}
2691
2692fn add_test_directory(tests: &mut TestSummary, path: PathBuf) {
2693 tests.has_tests_dir = true;
2694 if tests
2695 .test_directories
2696 .iter()
2697 .any(|existing| existing == &path)
2698 {
2699 return;
2700 }
2701
2702 tests.test_directories.push(path);
2703}
2704
2705fn is_test_file(path: &Path) -> bool {
2706 let file_name = path
2707 .file_name()
2708 .and_then(|value| value.to_str())
2709 .unwrap_or_default()
2710 .to_ascii_lowercase();
2711 let extension = path
2712 .extension()
2713 .and_then(|value| value.to_str())
2714 .unwrap_or_default()
2715 .to_ascii_lowercase();
2716
2717 if is_under_named_dir(path, "tests") || is_under_named_dir(path, "__tests__") {
2718 return matches!(
2719 extension.as_str(),
2720 "rs" | "js" | "jsx" | "ts" | "tsx" | "py"
2721 );
2722 }
2723
2724 file_name.ends_with("_test.rs")
2725 || file_name.ends_with(".test.js")
2726 || file_name.ends_with(".test.jsx")
2727 || file_name.ends_with(".test.ts")
2728 || file_name.ends_with(".test.tsx")
2729 || file_name.ends_with(".spec.js")
2730 || file_name.ends_with(".spec.jsx")
2731 || file_name.ends_with(".spec.ts")
2732 || file_name.ends_with(".spec.tsx")
2733 || (file_name.starts_with("test_") && file_name.ends_with(".py"))
2734 || file_name.ends_with("_test.py")
2735 || file_name.ends_with("_test.cpp")
2736 || file_name.ends_with("_test.cc")
2737 || file_name.ends_with("_test.cxx")
2738 || file_name.ends_with("_tests.cpp")
2739}
2740
2741fn is_under_named_dir(path: &Path, directory_name: &str) -> bool {
2742 path.components()
2743 .any(|component| component == Component::Normal(OsStr::new(directory_name)))
2744}
2745
2746fn observe_documentation_file(path: &Path, documentation: &mut DocumentationSummary) {
2747 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2748 return;
2749 };
2750 let file_name = file_name.to_ascii_lowercase();
2751
2752 if file_name == "readme.md" || file_name == "readme" {
2753 documentation.has_readme = true;
2754 }
2755 if file_name == "license" || file_name.starts_with("license.") {
2756 documentation.has_license = true;
2757 }
2758}
2759
2760fn is_license_file(path: &Path) -> bool {
2761 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2762 return false;
2763 };
2764 let file_name = file_name.to_ascii_lowercase();
2765 file_name == "license" || file_name.starts_with("license.")
2766}
2767
2768fn build_license_summary(files: &[PathBuf]) -> LicenseSummary {
2769 let Some(path) = files.first() else {
2770 return LicenseSummary {
2771 path: None,
2772 kind: LicenseKind::Missing,
2773 };
2774 };
2775
2776 let content = fs::read_to_string(path).unwrap_or_default();
2777 LicenseSummary {
2778 path: Some(path.clone()),
2779 kind: detect_license_kind(&content),
2780 }
2781}
2782
2783fn detect_license_kind(content: &str) -> LicenseKind {
2784 let lower = content.to_ascii_lowercase();
2785 let normalized = lower.trim();
2786
2787 if normalized == "mit"
2788 || normalized == "mit license"
2789 || lower.contains("mit license")
2790 || lower.contains("permission is hereby granted")
2791 {
2792 LicenseKind::Mit
2793 } else if normalized == "apache-2.0"
2794 || normalized == "apache 2.0"
2795 || lower.contains("apache license")
2796 || lower.contains("apache-2.0")
2797 || lower.contains("www.apache.org/licenses/license-2.0")
2798 {
2799 LicenseKind::Apache2
2800 } else if normalized.starts_with("gpl")
2801 || lower.contains("gnu general public license")
2802 || lower.contains("gpl")
2803 {
2804 LicenseKind::Gpl
2805 } else if normalized.starts_with("bsd")
2806 || (lower.contains("bsd") && lower.contains("redistribution and use"))
2807 {
2808 LicenseKind::Bsd
2809 } else {
2810 LicenseKind::Unknown
2811 }
2812}
2813
2814fn observe_hygiene_file(path: &Path, hygiene: &mut ScanHygiene) {
2815 match path.file_name().and_then(|value| value.to_str()) {
2816 Some(".gitignore") => hygiene.has_gitignore = true,
2817 Some(".ignore") => hygiene.has_ignore = true,
2818 _ => {}
2819 }
2820}
2821
2822fn observe_ci_file(path: &Path, ci: &mut CiSummary) {
2823 if has_component_pair(path, ".github", "workflows") {
2824 ci.add_provider(CiProvider::GithubActions, path.to_path_buf());
2825 }
2826 if has_component(path, ".workflow") {
2827 ci.add_provider(CiProvider::GiteeGo, path.to_path_buf());
2828 }
2829 if path.file_name().and_then(|value| value.to_str()) == Some(".gitlab-ci.yml") {
2830 ci.add_provider(CiProvider::GitlabCi, path.to_path_buf());
2831 }
2832 if has_component(path, ".circleci") {
2833 ci.add_provider(CiProvider::CircleCi, path.to_path_buf());
2834 }
2835 if path.file_name().and_then(|value| value.to_str()) == Some("Jenkinsfile") {
2836 ci.add_provider(CiProvider::Jenkins, path.to_path_buf());
2837 }
2838}
2839
2840fn has_component(path: &Path, name: &str) -> bool {
2841 path.components()
2842 .any(|component| component == Component::Normal(OsStr::new(name)))
2843}
2844
2845fn has_component_pair(path: &Path, first: &str, second: &str) -> bool {
2846 let mut components = path.components();
2847 while let Some(component) = components.next() {
2848 if component == Component::Normal(OsStr::new(first))
2849 && components.next() == Some(Component::Normal(OsStr::new(second)))
2850 {
2851 return true;
2852 }
2853 }
2854
2855 false
2856}
2857
2858fn observe_container_file(path: &Path, containers: &mut ContainerSummary) {
2859 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2860 return;
2861 };
2862 let file_name = file_name.to_ascii_lowercase();
2863
2864 if file_name == "dockerfile" || file_name.starts_with("dockerfile.") {
2865 containers.has_dockerfile = true;
2866 }
2867 if matches!(
2868 file_name.as_str(),
2869 "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml"
2870 ) {
2871 containers.has_compose_file = true;
2872 }
2873}
2874
2875#[derive(Default)]
2876struct MutableInventory {
2877 languages: Vec<LanguageSummary>,
2878 build_systems: Vec<BuildSystemSummary>,
2879 code: CodeSummary,
2880 dependency_manifests: Vec<PathBuf>,
2881 tests: TestSummary,
2882 test_command_sources: Vec<PathBuf>,
2883 hygiene: ScanHygiene,
2884 documentation: DocumentationSummary,
2885 license_files: Vec<PathBuf>,
2886 ci: CiSummary,
2887 containers: ContainerSummary,
2888 files_scanned: usize,
2889 skipped_dirs: Vec<PathBuf>,
2890}
2891
2892impl MutableInventory {
2893 fn add_language(&mut self, kind: LanguageKind) {
2894 if let Some(language) = self
2895 .languages
2896 .iter_mut()
2897 .find(|language| language.kind == kind)
2898 {
2899 language.files += 1;
2900 return;
2901 }
2902
2903 self.languages.push(LanguageSummary { kind, files: 1 });
2904 }
2905
2906 fn add_code_file(&mut self, kind: LanguageKind, path: &Path) {
2907 let counts = count_code_lines(path, kind);
2908 self.code.total_files += 1;
2909 self.code.total_lines += counts.total_lines;
2910 self.code.code_lines += counts.code_lines;
2911 self.code.comment_lines += counts.comment_lines;
2912 self.code.blank_lines += counts.blank_lines;
2913
2914 if let Some(language) = self
2915 .code
2916 .languages
2917 .iter_mut()
2918 .find(|language| language.kind == kind)
2919 {
2920 language.files += 1;
2921 language.total_lines += counts.total_lines;
2922 language.code_lines += counts.code_lines;
2923 language.comment_lines += counts.comment_lines;
2924 language.blank_lines += counts.blank_lines;
2925 return;
2926 }
2927
2928 self.code.languages.push(CodeLanguageSummary {
2929 kind,
2930 files: 1,
2931 total_lines: counts.total_lines,
2932 code_lines: counts.code_lines,
2933 comment_lines: counts.comment_lines,
2934 blank_lines: counts.blank_lines,
2935 });
2936 }
2937
2938 fn add_build_system(&mut self, kind: BuildSystemKind, path: PathBuf) {
2939 if self
2940 .build_systems
2941 .iter()
2942 .any(|build_system| build_system.kind == kind && build_system.path == path)
2943 {
2944 return;
2945 }
2946
2947 self.build_systems.push(BuildSystemSummary { kind, path });
2948 }
2949
2950 fn add_dependency_manifest(&mut self, path: PathBuf) {
2951 if self
2952 .dependency_manifests
2953 .iter()
2954 .any(|existing| existing == &path)
2955 {
2956 return;
2957 }
2958
2959 self.dependency_manifests.push(path);
2960 }
2961
2962 fn add_test_command_source(&mut self, path: PathBuf) {
2963 if self
2964 .test_command_sources
2965 .iter()
2966 .any(|existing| existing == &path)
2967 {
2968 return;
2969 }
2970
2971 self.test_command_sources.push(path);
2972 }
2973
2974 fn add_license_file(&mut self, path: PathBuf) {
2975 if self.license_files.iter().any(|existing| existing == &path) {
2976 return;
2977 }
2978
2979 self.license_files.push(path);
2980 }
2981
2982 fn add_skipped_dir(&mut self, path: PathBuf) {
2983 if self.skipped_dirs.iter().any(|existing| existing == &path) {
2984 return;
2985 }
2986
2987 self.skipped_dirs.push(path);
2988 }
2989}
2990
2991fn build_report(
2992 project_name: &str,
2993 indicators: &[ProjectIndicator],
2994 inventory: &ScanInventory,
2995 health: &HealthSummary,
2996) -> ProjectReport {
2997 let summary = if indicators.is_empty() {
2998 format!("{project_name} was scanned, but no known project markers were detected yet.")
2999 } else {
3000 format!(
3001 "{project_name} was scanned across {} file(s), with {} project marker(s), {} language(s), {} build system(s), and {} health detected.",
3002 inventory.files_scanned,
3003 indicators.len(),
3004 inventory.languages.len(),
3005 inventory.build_systems.len(),
3006 health.grade.as_str()
3007 )
3008 };
3009
3010 ProjectReport {
3011 summary,
3012 next_steps: vec![
3013 "Add language, dependency, and repository activity scanners.".to_owned(),
3014 "Keep CLI and GUI features backed by projd-core data structures.".to_owned(),
3015 "Export Markdown and JSON reports before adding richer GUI visualizations.".to_owned(),
3016 ],
3017 }
3018}
3019
3020fn yes_no(value: bool) -> &'static str {
3021 if value { "yes" } else { "no" }
3022}
3023
3024#[cfg(test)]
3025mod tests {
3026 use super::*;
3027
3028 #[test]
3029 fn renders_markdown_without_indicators() {
3030 let scan = ProjectScan {
3031 root: PathBuf::from("example"),
3032 project_name: "example".to_owned(),
3033 identity: ProjectIdentity {
3034 name: "example".to_owned(),
3035 version: None,
3036 kind: ProjectKind::Generic,
3037 source: IdentitySource::DirectoryName,
3038 },
3039 indicators: Vec::new(),
3040 languages: Vec::new(),
3041 build_systems: Vec::new(),
3042 code: CodeSummary::default(),
3043 dependencies: DependencySummary::default(),
3044 tests: TestSummary::default(),
3045 hygiene: ScanHygiene::default(),
3046 risks: RiskSummary::default(),
3047 health: HealthSummary {
3048 grade: ProjectHealth::Unknown,
3049 score: 0,
3050 risk_level: RiskSeverity::Info,
3051 signals: Vec::new(),
3052 },
3053 documentation: DocumentationSummary::default(),
3054 license: LicenseSummary::default(),
3055 ci: CiSummary::default(),
3056 vcs: VcsSummary::default(),
3057 containers: ContainerSummary::default(),
3058 files_scanned: 0,
3059 skipped_dirs: Vec::new(),
3060 report: ProjectReport {
3061 summary: "example summary".to_owned(),
3062 next_steps: vec!["next".to_owned()],
3063 },
3064 };
3065
3066 let rendered = render_markdown(&scan);
3067
3068 assert!(rendered.contains("# Project Report"));
3069 assert!(rendered.contains("example summary"));
3070 assert!(rendered.contains("No known project indicators"));
3071 }
3072
3073 #[test]
3074 fn parse_fossil_status_extracts_branch_revision_and_dirty() {
3075 let sample = "\
3076repository: /tmp/r.fossil
3077checkout: 8b3c5e1d 2026-05-16 03:21:14 UTC
3078tags: trunk, release
3079EDITED src/main.rs
3080ADDED new.txt
3081";
3082 let parsed = parse_fossil_status(sample);
3083 assert_eq!(parsed.branch.as_deref(), Some("trunk"));
3084 assert_eq!(parsed.revision.as_deref(), Some("8b3c5e1d"));
3085 assert!(
3086 parsed
3087 .last_commit
3088 .as_deref()
3089 .unwrap_or_default()
3090 .contains("2026-05-16")
3091 );
3092 assert_eq!(parsed.tracked_modified_files, 2);
3093 }
3094
3095 #[test]
3096 fn parse_fossil_status_handles_empty_text() {
3097 let parsed = parse_fossil_status("");
3098 assert!(parsed.branch.is_none());
3099 assert!(parsed.revision.is_none());
3100 assert!(parsed.last_commit.is_none());
3101 assert_eq!(parsed.tracked_modified_files, 0);
3102 }
3103
3104 #[test]
3105 fn parse_fossil_status_ignores_unknown_keys() {
3106 let sample = "comment: whatever\nparent: abc 2026-05-15\nrepository: /x\n";
3107 let parsed = parse_fossil_status(sample);
3108 assert!(parsed.branch.is_none());
3109 assert!(parsed.revision.is_none());
3110 assert_eq!(parsed.tracked_modified_files, 0);
3111 }
3112
3113 #[test]
3114 fn parse_bzr_status_counts_modified_and_untracked() {
3115 let sample = " M modified.rs\n A added.rs\n D removed.rs\n? untracked.txt\n";
3116 let parsed = parse_bzr_status(sample);
3117 assert_eq!(parsed.tracked_modified_files, 3);
3118 assert_eq!(parsed.untracked_files, 1);
3119 }
3120
3121 #[test]
3122 fn parse_bzr_log_timestamp_extracts_iso_like_string() {
3123 let sample = "revno: 12\ncommitter: Alice <a@e.com>\ntimestamp: Thu 2026-05-16 03:21:14 +0000\nmessage:\n fix bug\n";
3124 assert_eq!(
3125 parse_bzr_log_timestamp(sample).as_deref(),
3126 Some("Thu 2026-05-16 03:21:14 +0000")
3127 );
3128 }
3129
3130 #[test]
3131 fn parse_bzr_log_timestamp_returns_none_when_missing() {
3132 assert!(parse_bzr_log_timestamp("no timestamp anywhere").is_none());
3133 assert!(parse_bzr_log_timestamp("").is_none());
3134 }
3135}