Skip to main content

projd_core/
lib.rs

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    /// Display label for the `branch` field. SVN uses URL semantics; others use
449    /// branch names.
450    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    /// git/hg/fossil/bzr: branch name. svn: repository URL.
464    pub branch: Option<String>,
465    /// Latest revision identifier (commit hash, changeset id, svn revision number).
466    pub revision: Option<String>,
467    /// ISO-style timestamp of the latest commit, format passed through from the VCS.
468    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    /// ISO-style timestamp of the repository's first commit (may be misleading
481    /// for shallow clones).
482    pub first_commit_date: Option<String>,
483    /// Per-contributor breakdown. Always empty unless `ScanOptions.detailed_contributors`
484    /// was set on the scan that produced this summary.
485    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/// Options that adjust how `scan_path_with` (and `scan_paths_recursive`) build
531/// the resulting `ProjectScan`. Defaults stay conservative: no personal data
532/// is collected.
533#[derive(Clone, Debug, Default)]
534pub struct ScanOptions {
535    /// When true, populate `VcsActivity.contributors` with names/emails parsed
536    /// from `git shortlog`. Defaults to false to keep privacy-sensitive data
537    /// off by default.
538    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        // Format: "    <count>\t<name> <<email>>"
2033        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        // bzr status -SV format: "<flag1><flag2>  <path>" where flag1/flag2 are
2235        // status codes. We only need the first code column.
2236        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                // Untracked rows also appear as "? <path>" with '?' in the
2246                // leftmost column.
2247                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 = &current[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}