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 VcsSummary {
1927            kind: VcsKind::Fossil,
1928            is_repository: true,
1929            root: Some(root.to_path_buf()),
1930            ..VcsSummary::default()
1931        };
1932    }
1933    if root.join(".bzr").is_dir() {
1934        return VcsSummary {
1935            kind: VcsKind::Bzr,
1936            is_repository: true,
1937            root: Some(root.to_path_buf()),
1938            ..VcsSummary::default()
1939        };
1940    }
1941    VcsSummary::default()
1942}
1943
1944fn detect_git_into(root: &Path, opts: &ScanOptions) -> VcsSummary {
1945    let detected_root = run_vcs_output("git", root, &["rev-parse", "--show-toplevel"])
1946        .and_then(|output| output.lines().next().map(PathBuf::from))
1947        .or_else(|| Some(root.to_path_buf()));
1948
1949    let branch = run_vcs_output("git", root, &["branch", "--show-current"])
1950        .and_then(|output| non_empty_trimmed(&output));
1951    let revision = run_vcs_output("git", root, &["rev-parse", "HEAD"])
1952        .and_then(|output| non_empty_trimmed(&output));
1953    let last_commit = run_vcs_output("git", root, &["log", "-1", "--format=%cI"])
1954        .and_then(|output| non_empty_trimmed(&output));
1955    let porcelain = run_vcs_output("git", root, &["status", "--porcelain"]).unwrap_or_default();
1956    let mut tracked_modified_files = 0;
1957    let mut untracked_files = 0;
1958
1959    for line in porcelain.lines() {
1960        if line.starts_with("??") {
1961            untracked_files += 1;
1962        } else if !line.trim().is_empty() {
1963            tracked_modified_files += 1;
1964        }
1965    }
1966
1967    let activity = detect_git_activity(root, opts);
1968
1969    VcsSummary {
1970        kind: VcsKind::Git,
1971        is_repository: true,
1972        root: detected_root,
1973        branch,
1974        revision,
1975        last_commit,
1976        is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1977        tracked_modified_files,
1978        untracked_files,
1979        activity,
1980    }
1981}
1982
1983fn detect_git_activity(root: &Path, opts: &ScanOptions) -> VcsActivity {
1984    let last_commit_ts = run_vcs_output("git", root, &["log", "-1", "--format=%ct"])
1985        .and_then(|s| s.trim().parse::<i64>().ok());
1986    let days_since_last_commit = last_commit_ts.and_then(|ts| {
1987        let now = std::time::SystemTime::now()
1988            .duration_since(std::time::UNIX_EPOCH)
1989            .ok()?
1990            .as_secs() as i64;
1991        let delta = (now - ts).max(0);
1992        Some((delta / 86_400) as u32)
1993    });
1994
1995    let commits_last_90d = run_vcs_output(
1996        "git",
1997        root,
1998        &["rev-list", "--count", "--since=90 days ago", "HEAD"],
1999    )
2000    .and_then(|s| s.trim().parse::<u32>().ok());
2001
2002    let contributors_count =
2003        run_vcs_output("git", root, &["shortlog", "-sne", "HEAD"]).map(|output| {
2004            output
2005                .lines()
2006                .filter(|line| !line.trim().is_empty())
2007                .count() as u32
2008        });
2009
2010    let first_commit_date = run_vcs_output(
2011        "git",
2012        root,
2013        &["log", "--max-parents=0", "--format=%aI", "HEAD"],
2014    )
2015    .and_then(|output| output.lines().next().map(|s| s.trim().to_owned()))
2016    .filter(|s| !s.is_empty());
2017
2018    let contributors = if opts.detailed_contributors {
2019        parse_git_shortlog(
2020            &run_vcs_output("git", root, &["shortlog", "-sne", "HEAD"]).unwrap_or_default(),
2021        )
2022    } else {
2023        Vec::new()
2024    };
2025
2026    VcsActivity {
2027        days_since_last_commit,
2028        commits_last_90d,
2029        contributors_count,
2030        first_commit_date,
2031        contributors,
2032    }
2033}
2034
2035fn parse_git_shortlog(text: &str) -> Vec<VcsContributor> {
2036    let mut out = Vec::new();
2037    for line in text.lines() {
2038        let trimmed = line.trim();
2039        if trimmed.is_empty() {
2040            continue;
2041        }
2042        // Format: "    <count>\t<name> <<email>>"
2043        let mut parts = trimmed.splitn(2, char::is_whitespace);
2044        let count_str = parts.next().unwrap_or("");
2045        let rest = parts.next().unwrap_or("").trim();
2046        let Ok(count) = count_str.parse::<u32>() else {
2047            continue;
2048        };
2049        let (name, email) = match (rest.rfind('<'), rest.rfind('>')) {
2050            (Some(open), Some(close)) if open < close => {
2051                let name = rest[..open].trim().to_owned();
2052                let email = rest[open + 1..close].trim().to_owned();
2053                (name, email)
2054            }
2055            _ => (rest.to_owned(), String::new()),
2056        };
2057        out.push(VcsContributor {
2058            name,
2059            email,
2060            commit_count: count,
2061        });
2062    }
2063    out
2064}
2065
2066fn detect_hg_into(root: &Path) -> VcsSummary {
2067    let detected_root = run_vcs_output("hg", root, &["root"])
2068        .and_then(|output| output.lines().next().map(PathBuf::from))
2069        .or_else(|| Some(root.to_path_buf()));
2070
2071    let branch = run_vcs_output("hg", root, &["branch"]).and_then(|s| non_empty_trimmed(&s));
2072    let revision = run_vcs_output("hg", root, &["log", "-r", ".", "--template", "{node}"])
2073        .and_then(|s| non_empty_trimmed(&s));
2074    let last_commit = run_vcs_output(
2075        "hg",
2076        root,
2077        &["log", "-r", ".", "--template", "{date|isodatesec}"],
2078    )
2079    .and_then(|s| non_empty_trimmed(&s));
2080
2081    let status_text = run_vcs_output("hg", root, &["status"]).unwrap_or_default();
2082    let mut tracked_modified_files = 0;
2083    let mut untracked_files = 0;
2084    for line in status_text.lines() {
2085        let mut chars = line.chars();
2086        match chars.next() {
2087            Some('?') => untracked_files += 1,
2088            Some(c) if "MARC!".contains(c) => tracked_modified_files += 1,
2089            _ => {}
2090        }
2091    }
2092
2093    VcsSummary {
2094        kind: VcsKind::Hg,
2095        is_repository: true,
2096        root: detected_root,
2097        branch,
2098        revision,
2099        last_commit,
2100        is_dirty: tracked_modified_files > 0 || untracked_files > 0,
2101        tracked_modified_files,
2102        untracked_files,
2103        activity: VcsActivity::default(),
2104    }
2105}
2106
2107fn detect_svn_into(root: &Path) -> VcsSummary {
2108    let detected_root = run_vcs_output("svn", root, &["info", "--show-item", "wc-root"])
2109        .and_then(|s| non_empty_trimmed(&s))
2110        .map(PathBuf::from)
2111        .or_else(|| Some(root.to_path_buf()));
2112    let branch = run_vcs_output("svn", root, &["info", "--show-item", "url"])
2113        .and_then(|s| non_empty_trimmed(&s));
2114    let revision = run_vcs_output("svn", root, &["info", "--show-item", "revision"])
2115        .and_then(|s| non_empty_trimmed(&s));
2116    let last_commit = run_vcs_output("svn", root, &["info", "--show-item", "last-changed-date"])
2117        .and_then(|s| non_empty_trimmed(&s));
2118
2119    let status_text = run_vcs_output("svn", root, &["status"]).unwrap_or_default();
2120    let mut tracked_modified_files = 0;
2121    let mut untracked_files = 0;
2122    for line in status_text.lines() {
2123        let mut chars = line.chars();
2124        match chars.next() {
2125            Some('?') => untracked_files += 1,
2126            Some(c) if "ADMRC!~".contains(c) => tracked_modified_files += 1,
2127            _ => {}
2128        }
2129    }
2130
2131    VcsSummary {
2132        kind: VcsKind::Svn,
2133        is_repository: true,
2134        root: detected_root,
2135        branch,
2136        revision,
2137        last_commit,
2138        is_dirty: tracked_modified_files > 0 || untracked_files > 0,
2139        tracked_modified_files,
2140        untracked_files,
2141        activity: VcsActivity::default(),
2142    }
2143}
2144
2145fn run_vcs_output(program: &str, root: &Path, args: &[&str]) -> Option<String> {
2146    let output = Command::new(program)
2147        .args(args)
2148        .current_dir(root)
2149        .output()
2150        .ok()?;
2151
2152    if !output.status.success() {
2153        return None;
2154    }
2155
2156    Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
2157}
2158
2159fn non_empty_trimmed(value: &str) -> Option<String> {
2160    let value = value.trim();
2161    if value.is_empty() {
2162        None
2163    } else {
2164        Some(value.to_owned())
2165    }
2166}
2167
2168fn detect_indicators(root: &Path) -> Vec<ProjectIndicator> {
2169    let checks = [
2170        ("Cargo.toml", IndicatorKind::RustWorkspace),
2171        (".git", IndicatorKind::GitRepository),
2172        ("README.md", IndicatorKind::Readme),
2173    ];
2174
2175    let mut indicators = Vec::new();
2176    for (relative, kind) in checks {
2177        let path = root.join(relative);
2178        if path.exists() {
2179            indicators.push(ProjectIndicator { kind, path });
2180        }
2181    }
2182
2183    let cargo_manifest = root.join("Cargo.toml");
2184    if cargo_manifest.exists() {
2185        indicators.push(ProjectIndicator {
2186            kind: IndicatorKind::RustPackage,
2187            path: cargo_manifest,
2188        });
2189    }
2190
2191    indicators
2192}
2193
2194fn scan_inventory(root: &Path) -> Result<ScanInventory> {
2195    let mut inventory = MutableInventory::default();
2196    let ignore_matcher = IgnoreMatcher::new(root);
2197    walk_project(root, &ignore_matcher, &mut inventory)?;
2198
2199    Ok(ScanInventory {
2200        languages: inventory.languages,
2201        build_systems: inventory.build_systems,
2202        code: inventory.code,
2203        dependencies: build_dependency_summary(inventory.dependency_manifests),
2204        tests: build_test_summary(inventory.tests, inventory.test_command_sources),
2205        hygiene: inventory.hygiene,
2206        documentation: inventory.documentation,
2207        license: build_license_summary(&inventory.license_files),
2208        ci: inventory.ci,
2209        containers: inventory.containers,
2210        files_scanned: inventory.files_scanned,
2211        skipped_dirs: inventory.skipped_dirs,
2212    })
2213}
2214
2215fn walk_project(
2216    path: &Path,
2217    ignore_matcher: &IgnoreMatcher,
2218    inventory: &mut MutableInventory,
2219) -> Result<()> {
2220    let local_ignore_matcher = ignore_matcher.with_child_ignores(path);
2221    let mut entries = Vec::new();
2222
2223    for entry in
2224        fs::read_dir(path).with_context(|| format!("failed to read `{}`", path.display()))?
2225    {
2226        let entry =
2227            entry.with_context(|| format!("failed to read entry under `{}`", path.display()))?;
2228        entries.push(entry);
2229    }
2230
2231    entries.sort_by_key(|entry| entry.file_name());
2232
2233    for entry in entries {
2234        let entry_path = entry.path();
2235        let file_type = entry
2236            .file_type()
2237            .with_context(|| format!("failed to inspect `{}`", entry_path.display()))?;
2238
2239        if file_type.is_dir() {
2240            if should_skip_dir(entry.file_name().as_os_str())
2241                || local_ignore_matcher.is_ignored(&entry_path, true)
2242            {
2243                inventory.add_skipped_dir(entry_path);
2244                continue;
2245            }
2246
2247            observe_directory(&entry_path, inventory);
2248            walk_project(&entry_path, &local_ignore_matcher, inventory)?;
2249            continue;
2250        }
2251
2252        if file_type.is_file() {
2253            if local_ignore_matcher.is_ignored(&entry_path, false) {
2254                continue;
2255            }
2256
2257            inventory.files_scanned += 1;
2258            observe_file(&entry_path, inventory);
2259        }
2260    }
2261
2262    Ok(())
2263}
2264
2265fn should_skip_dir(name: &OsStr) -> bool {
2266    matches!(
2267        name.to_str(),
2268        Some(".git" | ".hg" | ".svn" | "target" | "node_modules")
2269    )
2270}
2271
2272#[derive(Clone)]
2273struct IgnoreMatcher {
2274    matchers: Vec<Gitignore>,
2275}
2276
2277impl IgnoreMatcher {
2278    fn new(root: &Path) -> Self {
2279        Self {
2280            matchers: Self::build_matchers(root),
2281        }
2282    }
2283
2284    fn with_child_ignores(&self, dir: &Path) -> Self {
2285        let mut matchers = self.matchers.clone();
2286        matchers.extend(Self::build_matchers(dir));
2287        Self { matchers }
2288    }
2289
2290    fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
2291        self.matchers
2292            .iter()
2293            .rev()
2294            .find_map(|matcher| {
2295                let result = matcher.matched(path, is_dir);
2296                if result.is_none() {
2297                    None
2298                } else {
2299                    Some(result.is_ignore())
2300                }
2301            })
2302            .unwrap_or(false)
2303    }
2304
2305    fn build_matchers(dir: &Path) -> Vec<Gitignore> {
2306        [".gitignore", ".ignore"]
2307            .into_iter()
2308            .filter_map(|file_name| Self::build_matcher(dir, file_name))
2309            .collect()
2310    }
2311
2312    fn build_matcher(dir: &Path, file_name: &str) -> Option<Gitignore> {
2313        let path = dir.join(file_name);
2314        if !path.is_file() {
2315            return None;
2316        }
2317
2318        let mut builder = GitignoreBuilder::new(dir);
2319        let _ = builder.add(&path);
2320        builder.build().ok()
2321    }
2322}
2323
2324fn observe_directory(path: &Path, inventory: &mut MutableInventory) {
2325    if path.file_name().and_then(|value| value.to_str()) == Some("docs") {
2326        inventory.documentation.has_docs_dir = true;
2327    }
2328    if is_test_directory(path) {
2329        add_test_directory(&mut inventory.tests, path.to_path_buf());
2330    }
2331}
2332
2333fn observe_file(path: &Path, inventory: &mut MutableInventory) {
2334    if let Some(kind) = language_for_path(path) {
2335        inventory.add_language(kind);
2336        inventory.add_code_file(kind, path);
2337    }
2338
2339    if let Some(kind) = build_system_for_path(path) {
2340        inventory.add_build_system(kind, path.to_path_buf());
2341    }
2342    if is_dependency_manifest(path) {
2343        inventory.add_dependency_manifest(path.to_path_buf());
2344    }
2345    if is_test_file(path) {
2346        inventory.tests.test_files += 1;
2347    }
2348    if is_test_command_source(path) {
2349        inventory.add_test_command_source(path.to_path_buf());
2350    }
2351    observe_hygiene_file(path, &mut inventory.hygiene);
2352
2353    observe_documentation_file(path, &mut inventory.documentation);
2354    if is_license_file(path) {
2355        inventory.add_license_file(path.to_path_buf());
2356    }
2357    observe_ci_file(path, &mut inventory.ci);
2358    observe_container_file(path, &mut inventory.containers);
2359}
2360
2361fn language_for_path(path: &Path) -> Option<LanguageKind> {
2362    let extension = path
2363        .extension()
2364        .and_then(|value| value.to_str())
2365        .map(str::to_ascii_lowercase)?;
2366
2367    match extension.as_str() {
2368        "rs" => Some(LanguageKind::Rust),
2369        "ts" | "tsx" => Some(LanguageKind::TypeScript),
2370        "js" | "jsx" | "mjs" | "cjs" => Some(LanguageKind::JavaScript),
2371        "py" => Some(LanguageKind::Python),
2372        "c" => Some(LanguageKind::C),
2373        "cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some(LanguageKind::Cpp),
2374        "go" => Some(LanguageKind::Go),
2375        _ => None,
2376    }
2377}
2378
2379fn count_code_lines(path: &Path, kind: LanguageKind) -> CodeLineCounts {
2380    let Ok(content) = fs::read_to_string(path) else {
2381        return CodeLineCounts::default();
2382    };
2383
2384    let mut counts = CodeLineCounts::default();
2385    let mut in_block_comment = false;
2386
2387    for line in content.lines() {
2388        counts.total_lines += 1;
2389        let trimmed = line.trim();
2390
2391        if trimmed.is_empty() {
2392            counts.blank_lines += 1;
2393            continue;
2394        }
2395
2396        let classification = classify_code_line(trimmed, kind, &mut in_block_comment);
2397        if classification.has_comment {
2398            counts.comment_lines += 1;
2399        }
2400        if classification.has_code {
2401            counts.code_lines += 1;
2402        }
2403    }
2404
2405    counts
2406}
2407
2408#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2409struct CodeLineClassification {
2410    has_code: bool,
2411    has_comment: bool,
2412}
2413
2414fn classify_code_line(
2415    mut line: &str,
2416    kind: LanguageKind,
2417    in_block_comment: &mut bool,
2418) -> CodeLineClassification {
2419    if !supports_c_style_block_comments(kind) {
2420        return classify_simple_comment_line(line, kind);
2421    }
2422
2423    let mut classification = CodeLineClassification::default();
2424
2425    loop {
2426        if *in_block_comment {
2427            classification.has_comment = true;
2428            if let Some(end) = line.find("*/") {
2429                *in_block_comment = false;
2430                line = &line[end + 2..];
2431                if line.trim().is_empty() {
2432                    break;
2433                }
2434                continue;
2435            }
2436            break;
2437        }
2438
2439        let current = line.trim_start();
2440        if current.is_empty() {
2441            break;
2442        }
2443        if current.starts_with("//") {
2444            classification.has_comment = true;
2445            break;
2446        }
2447
2448        let block_start = current.find("/*");
2449        let line_comment_start = current.find("//");
2450
2451        match (block_start, line_comment_start) {
2452            (Some(block), Some(comment)) if comment < block => {
2453                classification.has_comment = true;
2454                if !current[..comment].trim().is_empty() {
2455                    classification.has_code = true;
2456                }
2457                break;
2458            }
2459            (Some(block), _) => {
2460                classification.has_comment = true;
2461                if !current[..block].trim().is_empty() {
2462                    classification.has_code = true;
2463                }
2464                let after_block_start = &current[block + 2..];
2465                if let Some(end) = after_block_start.find("*/") {
2466                    line = &after_block_start[end + 2..];
2467                    if line.trim().is_empty() {
2468                        break;
2469                    }
2470                    continue;
2471                }
2472                *in_block_comment = true;
2473                break;
2474            }
2475            (None, Some(comment)) => {
2476                classification.has_comment = true;
2477                if !current[..comment].trim().is_empty() {
2478                    classification.has_code = true;
2479                }
2480                break;
2481            }
2482            (None, None) => {
2483                classification.has_code = true;
2484                break;
2485            }
2486        }
2487    }
2488
2489    classification
2490}
2491
2492fn classify_simple_comment_line(line: &str, kind: LanguageKind) -> CodeLineClassification {
2493    let markers = simple_comment_markers(kind);
2494
2495    if markers.iter().any(|marker| line.starts_with(marker)) {
2496        CodeLineClassification {
2497            has_code: false,
2498            has_comment: true,
2499        }
2500    } else {
2501        CodeLineClassification {
2502            has_code: true,
2503            has_comment: markers
2504                .iter()
2505                .any(|marker| line.find(marker).is_some_and(|index| index > 0)),
2506        }
2507    }
2508}
2509
2510fn supports_c_style_block_comments(kind: LanguageKind) -> bool {
2511    matches!(
2512        kind,
2513        LanguageKind::Rust
2514            | LanguageKind::TypeScript
2515            | LanguageKind::JavaScript
2516            | LanguageKind::C
2517            | LanguageKind::Cpp
2518            | LanguageKind::Go
2519    )
2520}
2521
2522fn simple_comment_markers(kind: LanguageKind) -> &'static [&'static str] {
2523    match kind {
2524        LanguageKind::Python => &["#"],
2525        LanguageKind::Rust
2526        | LanguageKind::TypeScript
2527        | LanguageKind::JavaScript
2528        | LanguageKind::C
2529        | LanguageKind::Cpp
2530        | LanguageKind::Go => &["//"],
2531    }
2532}
2533
2534fn build_system_for_path(path: &Path) -> Option<BuildSystemKind> {
2535    let file_name = path.file_name()?.to_str()?;
2536
2537    match file_name {
2538        "Cargo.toml" => Some(BuildSystemKind::Cargo),
2539        "package.json" => Some(BuildSystemKind::NodePackage),
2540        "pyproject.toml" => Some(BuildSystemKind::PythonProject),
2541        "requirements.txt" => Some(BuildSystemKind::PythonRequirements),
2542        "CMakeLists.txt" => Some(BuildSystemKind::CMake),
2543        "go.mod" => Some(BuildSystemKind::GoModule),
2544        _ => None,
2545    }
2546}
2547
2548fn is_dependency_manifest(path: &Path) -> bool {
2549    matches!(
2550        path.file_name().and_then(|value| value.to_str()),
2551        Some("Cargo.toml" | "package.json" | "pyproject.toml" | "requirements.txt")
2552    )
2553}
2554
2555fn is_test_command_source(path: &Path) -> bool {
2556    matches!(
2557        path.file_name().and_then(|value| value.to_str()),
2558        Some("Cargo.toml" | "package.json" | "pyproject.toml" | "CMakeLists.txt")
2559    )
2560}
2561
2562fn is_test_directory(path: &Path) -> bool {
2563    matches!(
2564        path.file_name().and_then(|value| value.to_str()),
2565        Some("tests" | "test" | "__tests__" | "spec")
2566    )
2567}
2568
2569fn add_test_directory(tests: &mut TestSummary, path: PathBuf) {
2570    tests.has_tests_dir = true;
2571    if tests
2572        .test_directories
2573        .iter()
2574        .any(|existing| existing == &path)
2575    {
2576        return;
2577    }
2578
2579    tests.test_directories.push(path);
2580}
2581
2582fn is_test_file(path: &Path) -> bool {
2583    let file_name = path
2584        .file_name()
2585        .and_then(|value| value.to_str())
2586        .unwrap_or_default()
2587        .to_ascii_lowercase();
2588    let extension = path
2589        .extension()
2590        .and_then(|value| value.to_str())
2591        .unwrap_or_default()
2592        .to_ascii_lowercase();
2593
2594    if is_under_named_dir(path, "tests") || is_under_named_dir(path, "__tests__") {
2595        return matches!(
2596            extension.as_str(),
2597            "rs" | "js" | "jsx" | "ts" | "tsx" | "py"
2598        );
2599    }
2600
2601    file_name.ends_with("_test.rs")
2602        || file_name.ends_with(".test.js")
2603        || file_name.ends_with(".test.jsx")
2604        || file_name.ends_with(".test.ts")
2605        || file_name.ends_with(".test.tsx")
2606        || file_name.ends_with(".spec.js")
2607        || file_name.ends_with(".spec.jsx")
2608        || file_name.ends_with(".spec.ts")
2609        || file_name.ends_with(".spec.tsx")
2610        || (file_name.starts_with("test_") && file_name.ends_with(".py"))
2611        || file_name.ends_with("_test.py")
2612        || file_name.ends_with("_test.cpp")
2613        || file_name.ends_with("_test.cc")
2614        || file_name.ends_with("_test.cxx")
2615        || file_name.ends_with("_tests.cpp")
2616}
2617
2618fn is_under_named_dir(path: &Path, directory_name: &str) -> bool {
2619    path.components()
2620        .any(|component| component == Component::Normal(OsStr::new(directory_name)))
2621}
2622
2623fn observe_documentation_file(path: &Path, documentation: &mut DocumentationSummary) {
2624    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2625        return;
2626    };
2627    let file_name = file_name.to_ascii_lowercase();
2628
2629    if file_name == "readme.md" || file_name == "readme" {
2630        documentation.has_readme = true;
2631    }
2632    if file_name == "license" || file_name.starts_with("license.") {
2633        documentation.has_license = true;
2634    }
2635}
2636
2637fn is_license_file(path: &Path) -> bool {
2638    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2639        return false;
2640    };
2641    let file_name = file_name.to_ascii_lowercase();
2642    file_name == "license" || file_name.starts_with("license.")
2643}
2644
2645fn build_license_summary(files: &[PathBuf]) -> LicenseSummary {
2646    let Some(path) = files.first() else {
2647        return LicenseSummary {
2648            path: None,
2649            kind: LicenseKind::Missing,
2650        };
2651    };
2652
2653    let content = fs::read_to_string(path).unwrap_or_default();
2654    LicenseSummary {
2655        path: Some(path.clone()),
2656        kind: detect_license_kind(&content),
2657    }
2658}
2659
2660fn detect_license_kind(content: &str) -> LicenseKind {
2661    let lower = content.to_ascii_lowercase();
2662    let normalized = lower.trim();
2663
2664    if normalized == "mit"
2665        || normalized == "mit license"
2666        || lower.contains("mit license")
2667        || lower.contains("permission is hereby granted")
2668    {
2669        LicenseKind::Mit
2670    } else if normalized == "apache-2.0"
2671        || normalized == "apache 2.0"
2672        || lower.contains("apache license")
2673        || lower.contains("apache-2.0")
2674        || lower.contains("www.apache.org/licenses/license-2.0")
2675    {
2676        LicenseKind::Apache2
2677    } else if normalized.starts_with("gpl")
2678        || lower.contains("gnu general public license")
2679        || lower.contains("gpl")
2680    {
2681        LicenseKind::Gpl
2682    } else if normalized.starts_with("bsd")
2683        || (lower.contains("bsd") && lower.contains("redistribution and use"))
2684    {
2685        LicenseKind::Bsd
2686    } else {
2687        LicenseKind::Unknown
2688    }
2689}
2690
2691fn observe_hygiene_file(path: &Path, hygiene: &mut ScanHygiene) {
2692    match path.file_name().and_then(|value| value.to_str()) {
2693        Some(".gitignore") => hygiene.has_gitignore = true,
2694        Some(".ignore") => hygiene.has_ignore = true,
2695        _ => {}
2696    }
2697}
2698
2699fn observe_ci_file(path: &Path, ci: &mut CiSummary) {
2700    if has_component_pair(path, ".github", "workflows") {
2701        ci.add_provider(CiProvider::GithubActions, path.to_path_buf());
2702    }
2703    if has_component(path, ".workflow") {
2704        ci.add_provider(CiProvider::GiteeGo, path.to_path_buf());
2705    }
2706    if path.file_name().and_then(|value| value.to_str()) == Some(".gitlab-ci.yml") {
2707        ci.add_provider(CiProvider::GitlabCi, path.to_path_buf());
2708    }
2709    if has_component(path, ".circleci") {
2710        ci.add_provider(CiProvider::CircleCi, path.to_path_buf());
2711    }
2712    if path.file_name().and_then(|value| value.to_str()) == Some("Jenkinsfile") {
2713        ci.add_provider(CiProvider::Jenkins, path.to_path_buf());
2714    }
2715}
2716
2717fn has_component(path: &Path, name: &str) -> bool {
2718    path.components()
2719        .any(|component| component == Component::Normal(OsStr::new(name)))
2720}
2721
2722fn has_component_pair(path: &Path, first: &str, second: &str) -> bool {
2723    let mut components = path.components();
2724    while let Some(component) = components.next() {
2725        if component == Component::Normal(OsStr::new(first))
2726            && components.next() == Some(Component::Normal(OsStr::new(second)))
2727        {
2728            return true;
2729        }
2730    }
2731
2732    false
2733}
2734
2735fn observe_container_file(path: &Path, containers: &mut ContainerSummary) {
2736    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2737        return;
2738    };
2739    let file_name = file_name.to_ascii_lowercase();
2740
2741    if file_name == "dockerfile" || file_name.starts_with("dockerfile.") {
2742        containers.has_dockerfile = true;
2743    }
2744    if matches!(
2745        file_name.as_str(),
2746        "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml"
2747    ) {
2748        containers.has_compose_file = true;
2749    }
2750}
2751
2752#[derive(Default)]
2753struct MutableInventory {
2754    languages: Vec<LanguageSummary>,
2755    build_systems: Vec<BuildSystemSummary>,
2756    code: CodeSummary,
2757    dependency_manifests: Vec<PathBuf>,
2758    tests: TestSummary,
2759    test_command_sources: Vec<PathBuf>,
2760    hygiene: ScanHygiene,
2761    documentation: DocumentationSummary,
2762    license_files: Vec<PathBuf>,
2763    ci: CiSummary,
2764    containers: ContainerSummary,
2765    files_scanned: usize,
2766    skipped_dirs: Vec<PathBuf>,
2767}
2768
2769impl MutableInventory {
2770    fn add_language(&mut self, kind: LanguageKind) {
2771        if let Some(language) = self
2772            .languages
2773            .iter_mut()
2774            .find(|language| language.kind == kind)
2775        {
2776            language.files += 1;
2777            return;
2778        }
2779
2780        self.languages.push(LanguageSummary { kind, files: 1 });
2781    }
2782
2783    fn add_code_file(&mut self, kind: LanguageKind, path: &Path) {
2784        let counts = count_code_lines(path, kind);
2785        self.code.total_files += 1;
2786        self.code.total_lines += counts.total_lines;
2787        self.code.code_lines += counts.code_lines;
2788        self.code.comment_lines += counts.comment_lines;
2789        self.code.blank_lines += counts.blank_lines;
2790
2791        if let Some(language) = self
2792            .code
2793            .languages
2794            .iter_mut()
2795            .find(|language| language.kind == kind)
2796        {
2797            language.files += 1;
2798            language.total_lines += counts.total_lines;
2799            language.code_lines += counts.code_lines;
2800            language.comment_lines += counts.comment_lines;
2801            language.blank_lines += counts.blank_lines;
2802            return;
2803        }
2804
2805        self.code.languages.push(CodeLanguageSummary {
2806            kind,
2807            files: 1,
2808            total_lines: counts.total_lines,
2809            code_lines: counts.code_lines,
2810            comment_lines: counts.comment_lines,
2811            blank_lines: counts.blank_lines,
2812        });
2813    }
2814
2815    fn add_build_system(&mut self, kind: BuildSystemKind, path: PathBuf) {
2816        if self
2817            .build_systems
2818            .iter()
2819            .any(|build_system| build_system.kind == kind && build_system.path == path)
2820        {
2821            return;
2822        }
2823
2824        self.build_systems.push(BuildSystemSummary { kind, path });
2825    }
2826
2827    fn add_dependency_manifest(&mut self, path: PathBuf) {
2828        if self
2829            .dependency_manifests
2830            .iter()
2831            .any(|existing| existing == &path)
2832        {
2833            return;
2834        }
2835
2836        self.dependency_manifests.push(path);
2837    }
2838
2839    fn add_test_command_source(&mut self, path: PathBuf) {
2840        if self
2841            .test_command_sources
2842            .iter()
2843            .any(|existing| existing == &path)
2844        {
2845            return;
2846        }
2847
2848        self.test_command_sources.push(path);
2849    }
2850
2851    fn add_license_file(&mut self, path: PathBuf) {
2852        if self.license_files.iter().any(|existing| existing == &path) {
2853            return;
2854        }
2855
2856        self.license_files.push(path);
2857    }
2858
2859    fn add_skipped_dir(&mut self, path: PathBuf) {
2860        if self.skipped_dirs.iter().any(|existing| existing == &path) {
2861            return;
2862        }
2863
2864        self.skipped_dirs.push(path);
2865    }
2866}
2867
2868fn build_report(
2869    project_name: &str,
2870    indicators: &[ProjectIndicator],
2871    inventory: &ScanInventory,
2872    health: &HealthSummary,
2873) -> ProjectReport {
2874    let summary = if indicators.is_empty() {
2875        format!("{project_name} was scanned, but no known project markers were detected yet.")
2876    } else {
2877        format!(
2878            "{project_name} was scanned across {} file(s), with {} project marker(s), {} language(s), {} build system(s), and {} health detected.",
2879            inventory.files_scanned,
2880            indicators.len(),
2881            inventory.languages.len(),
2882            inventory.build_systems.len(),
2883            health.grade.as_str()
2884        )
2885    };
2886
2887    ProjectReport {
2888        summary,
2889        next_steps: vec![
2890            "Add language, dependency, and repository activity scanners.".to_owned(),
2891            "Keep CLI and GUI features backed by projd-core data structures.".to_owned(),
2892            "Export Markdown and JSON reports before adding richer GUI visualizations.".to_owned(),
2893        ],
2894    }
2895}
2896
2897fn yes_no(value: bool) -> &'static str {
2898    if value { "yes" } else { "no" }
2899}
2900
2901#[cfg(test)]
2902mod tests {
2903    use super::*;
2904
2905    #[test]
2906    fn renders_markdown_without_indicators() {
2907        let scan = ProjectScan {
2908            root: PathBuf::from("example"),
2909            project_name: "example".to_owned(),
2910            identity: ProjectIdentity {
2911                name: "example".to_owned(),
2912                version: None,
2913                kind: ProjectKind::Generic,
2914                source: IdentitySource::DirectoryName,
2915            },
2916            indicators: Vec::new(),
2917            languages: Vec::new(),
2918            build_systems: Vec::new(),
2919            code: CodeSummary::default(),
2920            dependencies: DependencySummary::default(),
2921            tests: TestSummary::default(),
2922            hygiene: ScanHygiene::default(),
2923            risks: RiskSummary::default(),
2924            health: HealthSummary {
2925                grade: ProjectHealth::Unknown,
2926                score: 0,
2927                risk_level: RiskSeverity::Info,
2928                signals: Vec::new(),
2929            },
2930            documentation: DocumentationSummary::default(),
2931            license: LicenseSummary::default(),
2932            ci: CiSummary::default(),
2933            vcs: VcsSummary::default(),
2934            containers: ContainerSummary::default(),
2935            files_scanned: 0,
2936            skipped_dirs: Vec::new(),
2937            report: ProjectReport {
2938                summary: "example summary".to_owned(),
2939                next_steps: vec!["next".to_owned()],
2940            },
2941        };
2942
2943        let rendered = render_markdown(&scan);
2944
2945        assert!(rendered.contains("# Project Report"));
2946        assert!(rendered.contains("example summary"));
2947        assert!(rendered.contains("No known project indicators"));
2948    }
2949}