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