Skip to main content

projd_core/
lib.rs

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