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