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 dependencies: DependencySummary,
26    pub tests: TestSummary,
27    pub hygiene: ScanHygiene,
28    pub risks: RiskSummary,
29    pub documentation: DocumentationSummary,
30    pub license: LicenseSummary,
31    pub ci: CiSummary,
32    pub git: GitSummary,
33    pub containers: ContainerSummary,
34    pub files_scanned: usize,
35    pub skipped_dirs: Vec<PathBuf>,
36    pub report: ProjectReport,
37}
38
39impl ProjectScan {
40    pub fn has_language(&self, kind: LanguageKind) -> bool {
41        self.languages.iter().any(|language| language.kind == kind)
42    }
43
44    pub fn has_build_system(&self, kind: BuildSystemKind) -> bool {
45        self.build_systems
46            .iter()
47            .any(|build_system| build_system.kind == kind)
48    }
49
50    pub fn has_risk(&self, code: RiskCode) -> bool {
51        self.risks.findings.iter().any(|risk| risk.code == code)
52    }
53
54    pub fn risk(&self, code: RiskCode) -> Option<&RiskFinding> {
55        self.risks.findings.iter().find(|risk| risk.code == code)
56    }
57}
58
59#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
60pub struct ProjectIdentity {
61    pub name: String,
62    pub version: Option<String>,
63    pub kind: ProjectKind,
64    pub source: IdentitySource,
65}
66
67#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case")]
69pub enum ProjectKind {
70    RustWorkspace,
71    RustPackage,
72    NodePackage,
73    PythonProject,
74    Generic,
75}
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum IdentitySource {
80    CargoToml,
81    PackageJson,
82    PyprojectToml,
83    DirectoryName,
84}
85
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
87pub struct ProjectIndicator {
88    pub kind: IndicatorKind,
89    pub path: PathBuf,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum IndicatorKind {
95    RustWorkspace,
96    RustPackage,
97    GitRepository,
98    Readme,
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
102pub struct LanguageSummary {
103    pub kind: LanguageKind,
104    pub files: usize,
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
108#[serde(rename_all = "kebab-case")]
109pub enum LanguageKind {
110    Rust,
111    TypeScript,
112    JavaScript,
113    Python,
114    C,
115    Cpp,
116    Go,
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120pub struct BuildSystemSummary {
121    pub kind: BuildSystemKind,
122    pub path: PathBuf,
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
126#[serde(rename_all = "kebab-case")]
127pub enum BuildSystemKind {
128    Cargo,
129    NodePackage,
130    PythonProject,
131    PythonRequirements,
132    CMake,
133    GoModule,
134}
135
136#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
137pub struct DependencySummary {
138    pub ecosystems: Vec<DependencyEcosystemSummary>,
139    pub total_manifests: usize,
140    pub total_dependencies: usize,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
144pub struct DependencyEcosystemSummary {
145    pub ecosystem: DependencyEcosystem,
146    pub source: DependencySource,
147    pub manifest: PathBuf,
148    pub lockfile: Option<PathBuf>,
149    pub normal: usize,
150    pub development: usize,
151    pub build: usize,
152    pub optional: usize,
153    pub total: usize,
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
157#[serde(rename_all = "kebab-case")]
158pub enum DependencyEcosystem {
159    Rust,
160    Node,
161    Python,
162}
163
164#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
165#[serde(rename_all = "kebab-case")]
166pub enum DependencySource {
167    CargoToml,
168    PackageJson,
169    PyprojectToml,
170    RequirementsTxt,
171}
172
173#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
174pub struct TestSummary {
175    pub has_tests_dir: bool,
176    pub test_files: usize,
177    pub test_directories: Vec<PathBuf>,
178    pub commands: Vec<TestCommand>,
179}
180
181#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
182pub struct TestCommand {
183    pub ecosystem: TestEcosystem,
184    pub source: PathBuf,
185    pub name: String,
186    pub command: String,
187}
188
189#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
190#[serde(rename_all = "kebab-case")]
191pub enum TestEcosystem {
192    Rust,
193    Node,
194    Python,
195    CMake,
196}
197
198#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
199pub struct ScanHygiene {
200    pub has_gitignore: bool,
201    pub has_ignore: bool,
202}
203
204#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
205pub struct RiskSummary {
206    pub findings: Vec<RiskFinding>,
207    pub total: usize,
208    pub high: usize,
209    pub medium: usize,
210    pub low: usize,
211    pub info: usize,
212}
213
214#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
215pub struct RiskFinding {
216    pub severity: RiskSeverity,
217    pub code: RiskCode,
218    pub message: String,
219    pub path: Option<PathBuf>,
220}
221
222#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
223#[serde(rename_all = "kebab-case")]
224pub enum RiskSeverity {
225    High,
226    Medium,
227    Low,
228    Info,
229}
230
231#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
232#[serde(rename_all = "kebab-case")]
233pub enum RiskCode {
234    MissingReadme,
235    MissingLicense,
236    MissingCi,
237    NoTestsDetected,
238    ManifestWithoutLockfile,
239    LargeProjectWithoutIgnoreRules,
240    UnknownLicense,
241}
242
243#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
244pub struct DocumentationSummary {
245    pub has_readme: bool,
246    pub has_license: bool,
247    pub has_docs_dir: bool,
248}
249
250#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
251pub struct LicenseSummary {
252    pub path: Option<PathBuf>,
253    pub kind: LicenseKind,
254}
255
256#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
257#[serde(rename_all = "kebab-case")]
258pub enum LicenseKind {
259    Mit,
260    Apache2,
261    Gpl,
262    Bsd,
263    #[default]
264    Unknown,
265    Missing,
266}
267
268#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
269pub struct CiSummary {
270    pub has_github_actions: bool,
271    pub has_gitee_go: bool,
272    pub has_gitlab_ci: bool,
273    pub has_circle_ci: bool,
274    pub has_jenkins: bool,
275}
276
277#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
278#[serde(rename_all = "kebab-case")]
279pub enum CiProvider {
280    GithubActions,
281    GiteeGo,
282    GitlabCi,
283    CircleCi,
284    Jenkins,
285}
286
287impl CiSummary {
288    pub fn has_provider(&self, provider: CiProvider) -> bool {
289        match provider {
290            CiProvider::GithubActions => self.has_github_actions,
291            CiProvider::GiteeGo => self.has_gitee_go,
292            CiProvider::GitlabCi => self.has_gitlab_ci,
293            CiProvider::CircleCi => self.has_circle_ci,
294            CiProvider::Jenkins => self.has_jenkins,
295        }
296    }
297
298    fn has_any_provider(&self) -> bool {
299        self.has_github_actions
300            || self.has_gitee_go
301            || self.has_gitlab_ci
302            || self.has_circle_ci
303            || self.has_jenkins
304    }
305}
306
307#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
308pub struct GitSummary {
309    pub is_repository: bool,
310    pub root: Option<PathBuf>,
311    pub branch: Option<String>,
312    pub last_commit: Option<String>,
313    pub is_dirty: bool,
314    pub tracked_modified_files: usize,
315    pub untracked_files: usize,
316}
317
318#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
319pub struct ContainerSummary {
320    pub has_dockerfile: bool,
321    pub has_compose_file: bool,
322}
323
324#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
325pub struct ProjectReport {
326    pub summary: String,
327    pub next_steps: Vec<String>,
328}
329
330pub fn scan_path(path: impl AsRef<Path>) -> Result<ProjectScan> {
331    let root = path.as_ref();
332    let metadata =
333        fs::metadata(root).with_context(|| format!("failed to inspect `{}`", root.display()))?;
334
335    if !metadata.is_dir() {
336        bail!("project path must be a directory: `{}`", root.display());
337    }
338
339    let root = root
340        .canonicalize()
341        .with_context(|| format!("failed to resolve `{}`", root.display()))?;
342    let project_name = root
343        .file_name()
344        .and_then(|value| value.to_str())
345        .unwrap_or("project")
346        .to_owned();
347    let identity = detect_identity(&root, &project_name);
348    let indicators = detect_indicators(&root);
349    let inventory = scan_inventory(&root)?;
350    let risks = build_risk_summary(&inventory);
351    let report = build_report(&identity.name, &indicators, &inventory);
352    let git = detect_git_summary(&root);
353
354    Ok(ProjectScan {
355        root,
356        project_name: identity.name.clone(),
357        identity,
358        indicators,
359        languages: inventory.languages,
360        build_systems: inventory.build_systems,
361        dependencies: inventory.dependencies,
362        tests: inventory.tests,
363        hygiene: inventory.hygiene,
364        risks,
365        documentation: inventory.documentation,
366        license: inventory.license,
367        ci: inventory.ci,
368        git,
369        containers: inventory.containers,
370        files_scanned: inventory.files_scanned,
371        skipped_dirs: inventory.skipped_dirs,
372        report,
373    })
374}
375
376pub fn render_markdown(scan: &ProjectScan) -> String {
377    let mut output = String::new();
378    output.push_str("# Project Report\n\n");
379    output.push_str(&format!("Project: `{}`\n\n", scan.project_name));
380    output.push_str(&format!("Root: `{}`\n\n", scan.root.display()));
381    output.push_str("## Identity\n\n");
382    output.push_str(&format!("- Name: `{}`\n", scan.identity.name));
383    output.push_str(&format!(
384        "- Version: {}\n",
385        scan.identity
386            .version
387            .as_deref()
388            .map(|version| format!("`{version}`"))
389            .unwrap_or_else(|| "unknown".to_owned())
390    ));
391    output.push_str(&format!("- Kind: {:?}\n", scan.identity.kind));
392    output.push_str(&format!("- Source: {:?}\n\n", scan.identity.source));
393    output.push_str("## Summary\n\n");
394    output.push_str(&scan.report.summary);
395    output.push_str("\n\n## Indicators\n\n");
396
397    if scan.indicators.is_empty() {
398        output.push_str("- No known project indicators detected.\n");
399    } else {
400        for indicator in &scan.indicators {
401            output.push_str(&format!(
402                "- {:?}: `{}`\n",
403                indicator.kind,
404                indicator.path.display()
405            ));
406        }
407    }
408
409    output.push_str("\n## Languages\n\n");
410    if scan.languages.is_empty() {
411        output.push_str("- No source language files detected.\n");
412    } else {
413        for language in &scan.languages {
414            output.push_str(&format!(
415                "- {:?}: {} file(s)\n",
416                language.kind, language.files
417            ));
418        }
419    }
420
421    output.push_str("\n## Build Systems\n\n");
422    if scan.build_systems.is_empty() {
423        output.push_str("- No known build systems detected.\n");
424    } else {
425        for build_system in &scan.build_systems {
426            output.push_str(&format!(
427                "- {:?}: `{}`\n",
428                build_system.kind,
429                build_system.path.display()
430            ));
431        }
432    }
433
434    output.push_str("\n## Dependencies\n\n");
435    output.push_str(&format!(
436        "- Total manifests: {}\n",
437        scan.dependencies.total_manifests
438    ));
439    output.push_str(&format!(
440        "- Total dependency entries: {}\n",
441        scan.dependencies.total_dependencies
442    ));
443    if scan.dependencies.ecosystems.is_empty() {
444        output.push_str("- No dependency manifests detected.\n");
445    } else {
446        for summary in &scan.dependencies.ecosystems {
447            output.push_str(&format!(
448                "- {:?} {:?}: `{}` normal {}, dev {}, build {}, optional {}, total {}, lockfile {}\n",
449                summary.ecosystem,
450                summary.source,
451                summary.manifest.display(),
452                summary.normal,
453                summary.development,
454                summary.build,
455                summary.optional,
456                summary.total,
457                yes_no(summary.lockfile.is_some())
458            ));
459        }
460    }
461
462    output.push_str("\n## Tests\n\n");
463    output.push_str(&format!(
464        "- Test directories: {}\n",
465        scan.tests.test_directories.len()
466    ));
467    output.push_str(&format!("- Test files: {}\n", scan.tests.test_files));
468    if scan.tests.commands.is_empty() {
469        output.push_str("- Commands: none detected\n");
470    } else {
471        output.push_str("- Commands:\n");
472        for command in &scan.tests.commands {
473            output.push_str(&format!(
474                "  - {:?}: {} (`{}`)\n",
475                command.ecosystem,
476                command.command,
477                command.source.display()
478            ));
479        }
480    }
481
482    output.push_str("\n## Risks\n\n");
483    if scan.risks.findings.is_empty() {
484        output.push_str("- No risks detected by current rules.\n");
485    } else {
486        for risk in &scan.risks.findings {
487            output.push_str(&format!(
488                "- {} {}: {}",
489                risk.severity.as_str(),
490                risk.code.as_str(),
491                risk.message
492            ));
493            if let Some(path) = &risk.path {
494                output.push_str(&format!(" (`{}`)", path.display()));
495            }
496            output.push('\n');
497        }
498    }
499
500    output.push_str("\n## Project Facilities\n\n");
501    output.push_str(&format!(
502        "- README: {}\n",
503        yes_no(scan.documentation.has_readme)
504    ));
505    output.push_str(&format!(
506        "- License: {}\n",
507        yes_no(scan.documentation.has_license)
508    ));
509    output.push_str(&format!("- License kind: {:?}\n", scan.license.kind));
510    output.push_str(&format!(
511        "- docs/: {}\n",
512        yes_no(scan.documentation.has_docs_dir)
513    ));
514    output.push_str(&format!(
515        "- GitHub Actions: {}\n",
516        yes_no(scan.ci.has_github_actions)
517    ));
518    output.push_str(&format!("- Gitee Go: {}\n", yes_no(scan.ci.has_gitee_go)));
519    output.push_str(&format!("- GitLab CI: {}\n", yes_no(scan.ci.has_gitlab_ci)));
520    output.push_str(&format!("- CircleCI: {}\n", yes_no(scan.ci.has_circle_ci)));
521    output.push_str(&format!("- Jenkins: {}\n", yes_no(scan.ci.has_jenkins)));
522    output.push_str(&format!(
523        "- Git repository: {}\n",
524        yes_no(scan.git.is_repository)
525    ));
526    output.push_str(&format!(
527        "- Dockerfile: {}\n",
528        yes_no(scan.containers.has_dockerfile)
529    ));
530    output.push_str(&format!(
531        "- Compose file: {}\n",
532        yes_no(scan.containers.has_compose_file)
533    ));
534
535    output.push_str("\n## Next Steps\n\n");
536    for step in &scan.report.next_steps {
537        output.push_str(&format!("- {step}\n"));
538    }
539
540    output
541}
542
543pub fn render_json(scan: &ProjectScan) -> Result<String> {
544    serde_json::to_string_pretty(scan).context("failed to render scan result as json")
545}
546
547struct ScanInventory {
548    languages: Vec<LanguageSummary>,
549    build_systems: Vec<BuildSystemSummary>,
550    dependencies: DependencySummary,
551    tests: TestSummary,
552    hygiene: ScanHygiene,
553    documentation: DocumentationSummary,
554    license: LicenseSummary,
555    ci: CiSummary,
556    containers: ContainerSummary,
557    files_scanned: usize,
558    skipped_dirs: Vec<PathBuf>,
559}
560
561fn detect_identity(root: &Path, fallback_name: &str) -> ProjectIdentity {
562    detect_cargo_identity(root, fallback_name)
563        .or_else(|| detect_node_identity(root))
564        .or_else(|| detect_python_identity(root))
565        .unwrap_or_else(|| ProjectIdentity {
566            name: fallback_name.to_owned(),
567            version: None,
568            kind: ProjectKind::Generic,
569            source: IdentitySource::DirectoryName,
570        })
571}
572
573fn detect_cargo_identity(root: &Path, fallback_name: &str) -> Option<ProjectIdentity> {
574    let manifest = read_toml_value(&root.join("Cargo.toml"))?;
575
576    if let Some(package) = manifest.get("package") {
577        let name = package.get("name")?.as_str()?.to_owned();
578        let version = package
579            .get("version")
580            .and_then(|value| value.as_str())
581            .map(str::to_owned);
582
583        return Some(ProjectIdentity {
584            name,
585            version,
586            kind: ProjectKind::RustPackage,
587            source: IdentitySource::CargoToml,
588        });
589    }
590
591    if manifest.get("workspace").is_some() {
592        let version = manifest
593            .get("workspace")
594            .and_then(|workspace| workspace.get("package"))
595            .and_then(|package| package.get("version"))
596            .and_then(|value| value.as_str())
597            .map(str::to_owned);
598
599        return Some(ProjectIdentity {
600            name: fallback_name.to_owned(),
601            version,
602            kind: ProjectKind::RustWorkspace,
603            source: IdentitySource::CargoToml,
604        });
605    }
606
607    None
608}
609
610fn detect_node_identity(root: &Path) -> Option<ProjectIdentity> {
611    let manifest = read_json_value(&root.join("package.json"))?;
612    let name = manifest.get("name")?.as_str()?.to_owned();
613    let version = manifest
614        .get("version")
615        .and_then(|value| value.as_str())
616        .map(str::to_owned);
617
618    Some(ProjectIdentity {
619        name,
620        version,
621        kind: ProjectKind::NodePackage,
622        source: IdentitySource::PackageJson,
623    })
624}
625
626fn detect_python_identity(root: &Path) -> Option<ProjectIdentity> {
627    let manifest = read_toml_value(&root.join("pyproject.toml"))?;
628    let project = manifest.get("project")?;
629    let name = project.get("name")?.as_str()?.to_owned();
630    let version = project
631        .get("version")
632        .and_then(|value| value.as_str())
633        .map(str::to_owned);
634
635    Some(ProjectIdentity {
636        name,
637        version,
638        kind: ProjectKind::PythonProject,
639        source: IdentitySource::PyprojectToml,
640    })
641}
642
643fn read_toml_value(path: &Path) -> Option<toml::Value> {
644    let content = fs::read_to_string(path).ok()?;
645    toml::from_str(&content).ok()
646}
647
648fn read_json_value(path: &Path) -> Option<serde_json::Value> {
649    let content = fs::read_to_string(path).ok()?;
650    serde_json::from_str(&content).ok()
651}
652
653fn build_dependency_summary(manifests: Vec<PathBuf>) -> DependencySummary {
654    let mut ecosystems = Vec::new();
655
656    for manifest in manifests {
657        let Some(file_name) = manifest.file_name().and_then(|value| value.to_str()) else {
658            continue;
659        };
660
661        let summary = match file_name {
662            "Cargo.toml" => summarize_cargo_dependencies(&manifest),
663            "package.json" => summarize_node_dependencies(&manifest),
664            "pyproject.toml" => summarize_pyproject_dependencies(&manifest),
665            "requirements.txt" => summarize_requirements_dependencies(&manifest),
666            _ => None,
667        };
668
669        if let Some(summary) = summary {
670            ecosystems.push(summary);
671        }
672    }
673
674    let total_manifests = ecosystems.len();
675    let total_dependencies = ecosystems.iter().map(|summary| summary.total).sum();
676
677    DependencySummary {
678        ecosystems,
679        total_manifests,
680        total_dependencies,
681    }
682}
683
684fn build_test_summary(mut tests: TestSummary, sources: Vec<PathBuf>) -> TestSummary {
685    for source in sources {
686        let Some(file_name) = source.file_name().and_then(|value| value.to_str()) else {
687            continue;
688        };
689
690        match file_name {
691            "Cargo.toml" => add_rust_test_command(&mut tests, &source),
692            "package.json" => add_node_test_commands(&mut tests, &source),
693            "pyproject.toml" => add_python_test_command(&mut tests, &source),
694            "CMakeLists.txt" => add_cmake_test_command(&mut tests, &source),
695            _ => {}
696        }
697    }
698
699    tests
700}
701
702fn build_risk_summary(inventory: &ScanInventory) -> RiskSummary {
703    let mut findings = Vec::new();
704
705    if !inventory.documentation.has_readme {
706        findings.push(RiskFinding {
707            severity: RiskSeverity::Medium,
708            code: RiskCode::MissingReadme,
709            message: "No README file detected.".to_owned(),
710            path: None,
711        });
712    }
713
714    if !inventory.documentation.has_license {
715        findings.push(RiskFinding {
716            severity: RiskSeverity::Medium,
717            code: RiskCode::MissingLicense,
718            message: "No LICENSE file detected.".to_owned(),
719            path: None,
720        });
721    } else if inventory.license.kind == LicenseKind::Unknown {
722        findings.push(RiskFinding {
723            severity: RiskSeverity::Low,
724            code: RiskCode::UnknownLicense,
725            message: "License file was detected, but the license type was not recognized."
726                .to_owned(),
727            path: inventory.license.path.clone(),
728        });
729    }
730
731    if !inventory.ci.has_any_provider() {
732        findings.push(RiskFinding {
733            severity: RiskSeverity::Low,
734            code: RiskCode::MissingCi,
735            message: "No known CI workflow detected.".to_owned(),
736            path: None,
737        });
738    }
739
740    if inventory.tests.test_files == 0 && inventory.tests.commands.is_empty() {
741        findings.push(RiskFinding {
742            severity: RiskSeverity::Medium,
743            code: RiskCode::NoTestsDetected,
744            message: "No test files or test commands detected.".to_owned(),
745            path: None,
746        });
747    }
748
749    for summary in &inventory.dependencies.ecosystems {
750        if summary.total > 0 && summary.lockfile.is_none() {
751            findings.push(RiskFinding {
752                severity: RiskSeverity::Low,
753                code: RiskCode::ManifestWithoutLockfile,
754                message: "Dependency manifest has entries but no lockfile was detected.".to_owned(),
755                path: Some(summary.manifest.clone()),
756            });
757        }
758    }
759
760    if inventory.files_scanned >= 1000
761        && !inventory.hygiene.has_gitignore
762        && !inventory.hygiene.has_ignore
763    {
764        findings.push(RiskFinding {
765            severity: RiskSeverity::Info,
766            code: RiskCode::LargeProjectWithoutIgnoreRules,
767            message: "Large project scanned without .gitignore or .ignore rules.".to_owned(),
768            path: None,
769        });
770    }
771
772    RiskSummary::from_findings(findings)
773}
774
775impl RiskSummary {
776    fn from_findings(findings: Vec<RiskFinding>) -> Self {
777        let total = findings.len();
778        let high = findings
779            .iter()
780            .filter(|risk| risk.severity == RiskSeverity::High)
781            .count();
782        let medium = findings
783            .iter()
784            .filter(|risk| risk.severity == RiskSeverity::Medium)
785            .count();
786        let low = findings
787            .iter()
788            .filter(|risk| risk.severity == RiskSeverity::Low)
789            .count();
790        let info = findings
791            .iter()
792            .filter(|risk| risk.severity == RiskSeverity::Info)
793            .count();
794
795        Self {
796            findings,
797            total,
798            high,
799            medium,
800            low,
801            info,
802        }
803    }
804}
805
806impl RiskSeverity {
807    fn as_str(self) -> &'static str {
808        match self {
809            Self::High => "high",
810            Self::Medium => "medium",
811            Self::Low => "low",
812            Self::Info => "info",
813        }
814    }
815}
816
817impl RiskCode {
818    fn as_str(self) -> &'static str {
819        match self {
820            Self::MissingReadme => "missing-readme",
821            Self::MissingLicense => "missing-license",
822            Self::MissingCi => "missing-ci",
823            Self::NoTestsDetected => "no-tests-detected",
824            Self::ManifestWithoutLockfile => "manifest-without-lockfile",
825            Self::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
826            Self::UnknownLicense => "unknown-license",
827        }
828    }
829}
830
831fn add_rust_test_command(tests: &mut TestSummary, source: &Path) {
832    add_test_command(
833        tests,
834        TestCommand {
835            ecosystem: TestEcosystem::Rust,
836            source: source.to_path_buf(),
837            name: "cargo test".to_owned(),
838            command: "cargo test".to_owned(),
839        },
840    );
841}
842
843fn add_node_test_commands(tests: &mut TestSummary, source: &Path) {
844    let Some(value) = read_json_value(source) else {
845        return;
846    };
847    let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) else {
848        return;
849    };
850
851    for (name, command) in scripts {
852        if name == "test" || name.starts_with("test:") {
853            if let Some(command) = command.as_str() {
854                add_test_command(
855                    tests,
856                    TestCommand {
857                        ecosystem: TestEcosystem::Node,
858                        source: source.to_path_buf(),
859                        name: name.to_owned(),
860                        command: command.to_owned(),
861                    },
862                );
863            }
864        }
865    }
866}
867
868fn add_python_test_command(tests: &mut TestSummary, source: &Path) {
869    let Some(value) = read_toml_value(source) else {
870        return;
871    };
872
873    let has_pytest_config = value
874        .get("tool")
875        .and_then(|tool| tool.get("pytest"))
876        .and_then(|pytest| pytest.get("ini_options"))
877        .is_some();
878
879    if has_pytest_config {
880        add_test_command(
881            tests,
882            TestCommand {
883                ecosystem: TestEcosystem::Python,
884                source: source.to_path_buf(),
885                name: "pytest".to_owned(),
886                command: "pytest".to_owned(),
887            },
888        );
889    }
890}
891
892fn add_cmake_test_command(tests: &mut TestSummary, source: &Path) {
893    let Ok(content) = fs::read_to_string(source) else {
894        return;
895    };
896    let lower = content.to_ascii_lowercase();
897
898    if lower.contains("enable_testing") || lower.contains("add_test") {
899        add_test_command(
900            tests,
901            TestCommand {
902                ecosystem: TestEcosystem::CMake,
903                source: source.to_path_buf(),
904                name: "ctest".to_owned(),
905                command: "ctest".to_owned(),
906            },
907        );
908    }
909}
910
911fn add_test_command(tests: &mut TestSummary, command: TestCommand) {
912    if tests.commands.iter().any(|existing| {
913        existing.ecosystem == command.ecosystem
914            && existing.source == command.source
915            && existing.name == command.name
916            && existing.command == command.command
917    }) {
918        return;
919    }
920
921    tests.commands.push(command);
922}
923
924fn summarize_cargo_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
925    let value = read_toml_value(manifest)?;
926    let normal = count_toml_table_entries(value.get("dependencies"));
927    let development = count_toml_table_entries(value.get("dev-dependencies"));
928    let build = count_toml_table_entries(value.get("build-dependencies"));
929    let optional = count_optional_cargo_dependencies(value.get("dependencies"));
930    let total = normal + development + build;
931
932    Some(DependencyEcosystemSummary {
933        ecosystem: DependencyEcosystem::Rust,
934        source: DependencySource::CargoToml,
935        manifest: manifest.to_path_buf(),
936        lockfile: find_nearest_lockfile(manifest, &["Cargo.lock"]),
937        normal,
938        development,
939        build,
940        optional,
941        total,
942    })
943}
944
945fn summarize_node_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
946    let value = read_json_value(manifest)?;
947    let normal = count_json_object_entries(value.get("dependencies"));
948    let development = count_json_object_entries(value.get("devDependencies"));
949    let optional = count_json_object_entries(value.get("optionalDependencies"));
950    let total = normal + development + optional;
951
952    Some(DependencyEcosystemSummary {
953        ecosystem: DependencyEcosystem::Node,
954        source: DependencySource::PackageJson,
955        manifest: manifest.to_path_buf(),
956        lockfile: find_sibling_lockfile(
957            manifest,
958            &[
959                "pnpm-lock.yaml",
960                "yarn.lock",
961                "package-lock.json",
962                "bun.lockb",
963            ],
964        ),
965        normal,
966        development,
967        build: 0,
968        optional,
969        total,
970    })
971}
972
973fn summarize_pyproject_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
974    let value = read_toml_value(manifest)?;
975    let project = value.get("project");
976    let normal = project
977        .and_then(|project| project.get("dependencies"))
978        .and_then(|dependencies| dependencies.as_array())
979        .map_or(0, Vec::len);
980    let optional = project
981        .and_then(|project| project.get("optional-dependencies"))
982        .and_then(|dependencies| dependencies.as_table())
983        .map(|groups| {
984            groups
985                .values()
986                .filter_map(|value| value.as_array())
987                .map(Vec::len)
988                .sum()
989        })
990        .unwrap_or(0);
991    let development = project
992        .and_then(|project| project.get("optional-dependencies"))
993        .and_then(|dependencies| dependencies.get("dev"))
994        .and_then(|dependencies| dependencies.as_array())
995        .map_or(0, Vec::len);
996    let total = normal + optional;
997
998    Some(DependencyEcosystemSummary {
999        ecosystem: DependencyEcosystem::Python,
1000        source: DependencySource::PyprojectToml,
1001        manifest: manifest.to_path_buf(),
1002        lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1003        normal,
1004        development,
1005        build: 0,
1006        optional,
1007        total,
1008    })
1009}
1010
1011fn summarize_requirements_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1012    let content = fs::read_to_string(manifest).ok()?;
1013    let normal = content
1014        .lines()
1015        .map(str::trim)
1016        .filter(|line| is_requirement_entry(line))
1017        .count();
1018
1019    Some(DependencyEcosystemSummary {
1020        ecosystem: DependencyEcosystem::Python,
1021        source: DependencySource::RequirementsTxt,
1022        manifest: manifest.to_path_buf(),
1023        lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1024        normal,
1025        development: 0,
1026        build: 0,
1027        optional: 0,
1028        total: normal,
1029    })
1030}
1031
1032fn count_toml_table_entries(value: Option<&toml::Value>) -> usize {
1033    value
1034        .and_then(|value| value.as_table())
1035        .map_or(0, |table| table.len())
1036}
1037
1038fn count_optional_cargo_dependencies(value: Option<&toml::Value>) -> usize {
1039    value
1040        .and_then(|value| value.as_table())
1041        .map(|dependencies| {
1042            dependencies
1043                .values()
1044                .filter(|value| {
1045                    value
1046                        .as_table()
1047                        .and_then(|table| table.get("optional"))
1048                        .and_then(|optional| optional.as_bool())
1049                        .unwrap_or(false)
1050                })
1051                .count()
1052        })
1053        .unwrap_or(0)
1054}
1055
1056fn count_json_object_entries(value: Option<&serde_json::Value>) -> usize {
1057    value
1058        .and_then(|value| value.as_object())
1059        .map_or(0, |object| object.len())
1060}
1061
1062fn is_requirement_entry(line: &str) -> bool {
1063    !line.is_empty() && !line.starts_with('#') && !line.starts_with('-') && !line.starts_with("--")
1064}
1065
1066fn find_nearest_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1067    let mut current = manifest.parent();
1068    while let Some(dir) = current {
1069        if let Some(lockfile) = find_lockfile_in_dir(dir, names) {
1070            return Some(lockfile);
1071        }
1072        current = dir.parent();
1073    }
1074
1075    None
1076}
1077
1078fn find_sibling_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1079    find_lockfile_in_dir(manifest.parent()?, names)
1080}
1081
1082fn find_lockfile_in_dir(dir: &Path, names: &[&str]) -> Option<PathBuf> {
1083    names
1084        .iter()
1085        .map(|name| dir.join(name))
1086        .find(|path| path.is_file())
1087}
1088
1089fn detect_git_summary(root: &Path) -> GitSummary {
1090    let Some(git_root) = run_git_output(root, &["rev-parse", "--show-toplevel"])
1091        .and_then(|output| output.lines().next().map(PathBuf::from))
1092    else {
1093        return GitSummary::default();
1094    };
1095
1096    let branch = run_git_output(root, &["branch", "--show-current"])
1097        .and_then(|output| non_empty_trimmed(&output));
1098    let last_commit = run_git_output(root, &["log", "-1", "--format=%cI"])
1099        .and_then(|output| non_empty_trimmed(&output));
1100    let porcelain = run_git_output(root, &["status", "--porcelain"]).unwrap_or_default();
1101    let mut tracked_modified_files = 0;
1102    let mut untracked_files = 0;
1103
1104    for line in porcelain.lines() {
1105        if line.starts_with("??") {
1106            untracked_files += 1;
1107        } else if !line.trim().is_empty() {
1108            tracked_modified_files += 1;
1109        }
1110    }
1111
1112    GitSummary {
1113        is_repository: true,
1114        root: Some(git_root),
1115        branch,
1116        last_commit,
1117        is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1118        tracked_modified_files,
1119        untracked_files,
1120    }
1121}
1122
1123fn run_git_output(root: &Path, args: &[&str]) -> Option<String> {
1124    let output = Command::new("git")
1125        .args(args)
1126        .current_dir(root)
1127        .output()
1128        .ok()?;
1129
1130    if !output.status.success() {
1131        return None;
1132    }
1133
1134    Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1135}
1136
1137fn non_empty_trimmed(value: &str) -> Option<String> {
1138    let value = value.trim();
1139    if value.is_empty() {
1140        None
1141    } else {
1142        Some(value.to_owned())
1143    }
1144}
1145
1146fn detect_indicators(root: &Path) -> Vec<ProjectIndicator> {
1147    let checks = [
1148        ("Cargo.toml", IndicatorKind::RustWorkspace),
1149        (".git", IndicatorKind::GitRepository),
1150        ("README.md", IndicatorKind::Readme),
1151    ];
1152
1153    let mut indicators = Vec::new();
1154    for (relative, kind) in checks {
1155        let path = root.join(relative);
1156        if path.exists() {
1157            indicators.push(ProjectIndicator { kind, path });
1158        }
1159    }
1160
1161    let cargo_manifest = root.join("Cargo.toml");
1162    if cargo_manifest.exists() {
1163        indicators.push(ProjectIndicator {
1164            kind: IndicatorKind::RustPackage,
1165            path: cargo_manifest,
1166        });
1167    }
1168
1169    indicators
1170}
1171
1172fn scan_inventory(root: &Path) -> Result<ScanInventory> {
1173    let mut inventory = MutableInventory::default();
1174    let ignore_matcher = IgnoreMatcher::new(root);
1175    walk_project(root, &ignore_matcher, &mut inventory)?;
1176
1177    Ok(ScanInventory {
1178        languages: inventory.languages,
1179        build_systems: inventory.build_systems,
1180        dependencies: build_dependency_summary(inventory.dependency_manifests),
1181        tests: build_test_summary(inventory.tests, inventory.test_command_sources),
1182        hygiene: inventory.hygiene,
1183        documentation: inventory.documentation,
1184        license: build_license_summary(&inventory.license_files),
1185        ci: inventory.ci,
1186        containers: inventory.containers,
1187        files_scanned: inventory.files_scanned,
1188        skipped_dirs: inventory.skipped_dirs,
1189    })
1190}
1191
1192fn walk_project(
1193    path: &Path,
1194    ignore_matcher: &IgnoreMatcher,
1195    inventory: &mut MutableInventory,
1196) -> Result<()> {
1197    let local_ignore_matcher = ignore_matcher.with_child_ignores(path);
1198    let mut entries = Vec::new();
1199
1200    for entry in
1201        fs::read_dir(path).with_context(|| format!("failed to read `{}`", path.display()))?
1202    {
1203        let entry =
1204            entry.with_context(|| format!("failed to read entry under `{}`", path.display()))?;
1205        entries.push(entry);
1206    }
1207
1208    entries.sort_by_key(|entry| entry.file_name());
1209
1210    for entry in entries {
1211        let entry_path = entry.path();
1212        let file_type = entry
1213            .file_type()
1214            .with_context(|| format!("failed to inspect `{}`", entry_path.display()))?;
1215
1216        if file_type.is_dir() {
1217            if should_skip_dir(entry.file_name().as_os_str())
1218                || local_ignore_matcher.is_ignored(&entry_path, true)
1219            {
1220                inventory.add_skipped_dir(entry_path);
1221                continue;
1222            }
1223
1224            observe_directory(&entry_path, inventory);
1225            walk_project(&entry_path, &local_ignore_matcher, inventory)?;
1226            continue;
1227        }
1228
1229        if file_type.is_file() {
1230            if local_ignore_matcher.is_ignored(&entry_path, false) {
1231                continue;
1232            }
1233
1234            inventory.files_scanned += 1;
1235            observe_file(&entry_path, inventory);
1236        }
1237    }
1238
1239    Ok(())
1240}
1241
1242fn should_skip_dir(name: &OsStr) -> bool {
1243    matches!(
1244        name.to_str(),
1245        Some(".git" | ".hg" | ".svn" | "target" | "node_modules")
1246    )
1247}
1248
1249#[derive(Clone)]
1250struct IgnoreMatcher {
1251    matchers: Vec<Gitignore>,
1252}
1253
1254impl IgnoreMatcher {
1255    fn new(root: &Path) -> Self {
1256        Self {
1257            matchers: Self::build_matchers(root),
1258        }
1259    }
1260
1261    fn with_child_ignores(&self, dir: &Path) -> Self {
1262        let mut matchers = self.matchers.clone();
1263        matchers.extend(Self::build_matchers(dir));
1264        Self { matchers }
1265    }
1266
1267    fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
1268        self.matchers
1269            .iter()
1270            .rev()
1271            .find_map(|matcher| {
1272                let result = matcher.matched(path, is_dir);
1273                if result.is_none() {
1274                    None
1275                } else {
1276                    Some(result.is_ignore())
1277                }
1278            })
1279            .unwrap_or(false)
1280    }
1281
1282    fn build_matchers(dir: &Path) -> Vec<Gitignore> {
1283        [".gitignore", ".ignore"]
1284            .into_iter()
1285            .filter_map(|file_name| Self::build_matcher(dir, file_name))
1286            .collect()
1287    }
1288
1289    fn build_matcher(dir: &Path, file_name: &str) -> Option<Gitignore> {
1290        let path = dir.join(file_name);
1291        if !path.is_file() {
1292            return None;
1293        }
1294
1295        let mut builder = GitignoreBuilder::new(dir);
1296        let _ = builder.add(&path);
1297        builder.build().ok()
1298    }
1299}
1300
1301fn observe_directory(path: &Path, inventory: &mut MutableInventory) {
1302    if path.file_name().and_then(|value| value.to_str()) == Some("docs") {
1303        inventory.documentation.has_docs_dir = true;
1304    }
1305    if is_test_directory(path) {
1306        add_test_directory(&mut inventory.tests, path.to_path_buf());
1307    }
1308}
1309
1310fn observe_file(path: &Path, inventory: &mut MutableInventory) {
1311    if let Some(kind) = language_for_path(path) {
1312        inventory.add_language(kind);
1313    }
1314
1315    if let Some(kind) = build_system_for_path(path) {
1316        inventory.add_build_system(kind, path.to_path_buf());
1317    }
1318    if is_dependency_manifest(path) {
1319        inventory.add_dependency_manifest(path.to_path_buf());
1320    }
1321    if is_test_file(path) {
1322        inventory.tests.test_files += 1;
1323    }
1324    if is_test_command_source(path) {
1325        inventory.add_test_command_source(path.to_path_buf());
1326    }
1327    observe_hygiene_file(path, &mut inventory.hygiene);
1328
1329    observe_documentation_file(path, &mut inventory.documentation);
1330    if is_license_file(path) {
1331        inventory.add_license_file(path.to_path_buf());
1332    }
1333    observe_ci_file(path, &mut inventory.ci);
1334    observe_container_file(path, &mut inventory.containers);
1335}
1336
1337fn language_for_path(path: &Path) -> Option<LanguageKind> {
1338    let extension = path
1339        .extension()
1340        .and_then(|value| value.to_str())
1341        .map(str::to_ascii_lowercase)?;
1342
1343    match extension.as_str() {
1344        "rs" => Some(LanguageKind::Rust),
1345        "ts" | "tsx" => Some(LanguageKind::TypeScript),
1346        "js" | "jsx" | "mjs" | "cjs" => Some(LanguageKind::JavaScript),
1347        "py" => Some(LanguageKind::Python),
1348        "c" => Some(LanguageKind::C),
1349        "cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some(LanguageKind::Cpp),
1350        "go" => Some(LanguageKind::Go),
1351        _ => None,
1352    }
1353}
1354
1355fn build_system_for_path(path: &Path) -> Option<BuildSystemKind> {
1356    let file_name = path.file_name()?.to_str()?;
1357
1358    match file_name {
1359        "Cargo.toml" => Some(BuildSystemKind::Cargo),
1360        "package.json" => Some(BuildSystemKind::NodePackage),
1361        "pyproject.toml" => Some(BuildSystemKind::PythonProject),
1362        "requirements.txt" => Some(BuildSystemKind::PythonRequirements),
1363        "CMakeLists.txt" => Some(BuildSystemKind::CMake),
1364        "go.mod" => Some(BuildSystemKind::GoModule),
1365        _ => None,
1366    }
1367}
1368
1369fn is_dependency_manifest(path: &Path) -> bool {
1370    matches!(
1371        path.file_name().and_then(|value| value.to_str()),
1372        Some("Cargo.toml" | "package.json" | "pyproject.toml" | "requirements.txt")
1373    )
1374}
1375
1376fn is_test_command_source(path: &Path) -> bool {
1377    matches!(
1378        path.file_name().and_then(|value| value.to_str()),
1379        Some("Cargo.toml" | "package.json" | "pyproject.toml" | "CMakeLists.txt")
1380    )
1381}
1382
1383fn is_test_directory(path: &Path) -> bool {
1384    matches!(
1385        path.file_name().and_then(|value| value.to_str()),
1386        Some("tests" | "test" | "__tests__" | "spec")
1387    )
1388}
1389
1390fn add_test_directory(tests: &mut TestSummary, path: PathBuf) {
1391    tests.has_tests_dir = true;
1392    if tests
1393        .test_directories
1394        .iter()
1395        .any(|existing| existing == &path)
1396    {
1397        return;
1398    }
1399
1400    tests.test_directories.push(path);
1401}
1402
1403fn is_test_file(path: &Path) -> bool {
1404    let file_name = path
1405        .file_name()
1406        .and_then(|value| value.to_str())
1407        .unwrap_or_default()
1408        .to_ascii_lowercase();
1409    let extension = path
1410        .extension()
1411        .and_then(|value| value.to_str())
1412        .unwrap_or_default()
1413        .to_ascii_lowercase();
1414
1415    if is_under_named_dir(path, "tests") || is_under_named_dir(path, "__tests__") {
1416        return matches!(
1417            extension.as_str(),
1418            "rs" | "js" | "jsx" | "ts" | "tsx" | "py"
1419        );
1420    }
1421
1422    file_name.ends_with("_test.rs")
1423        || file_name.ends_with(".test.js")
1424        || file_name.ends_with(".test.jsx")
1425        || file_name.ends_with(".test.ts")
1426        || file_name.ends_with(".test.tsx")
1427        || file_name.ends_with(".spec.js")
1428        || file_name.ends_with(".spec.jsx")
1429        || file_name.ends_with(".spec.ts")
1430        || file_name.ends_with(".spec.tsx")
1431        || (file_name.starts_with("test_") && file_name.ends_with(".py"))
1432        || file_name.ends_with("_test.py")
1433        || file_name.ends_with("_test.cpp")
1434        || file_name.ends_with("_test.cc")
1435        || file_name.ends_with("_test.cxx")
1436        || file_name.ends_with("_tests.cpp")
1437}
1438
1439fn is_under_named_dir(path: &Path, directory_name: &str) -> bool {
1440    path.components()
1441        .any(|component| component == Component::Normal(OsStr::new(directory_name)))
1442}
1443
1444fn observe_documentation_file(path: &Path, documentation: &mut DocumentationSummary) {
1445    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
1446        return;
1447    };
1448    let file_name = file_name.to_ascii_lowercase();
1449
1450    if file_name == "readme.md" || file_name == "readme" {
1451        documentation.has_readme = true;
1452    }
1453    if file_name == "license" || file_name.starts_with("license.") {
1454        documentation.has_license = true;
1455    }
1456}
1457
1458fn is_license_file(path: &Path) -> bool {
1459    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
1460        return false;
1461    };
1462    let file_name = file_name.to_ascii_lowercase();
1463    file_name == "license" || file_name.starts_with("license.")
1464}
1465
1466fn build_license_summary(files: &[PathBuf]) -> LicenseSummary {
1467    let Some(path) = files.first() else {
1468        return LicenseSummary {
1469            path: None,
1470            kind: LicenseKind::Missing,
1471        };
1472    };
1473
1474    let content = fs::read_to_string(path).unwrap_or_default();
1475    LicenseSummary {
1476        path: Some(path.clone()),
1477        kind: detect_license_kind(&content),
1478    }
1479}
1480
1481fn detect_license_kind(content: &str) -> LicenseKind {
1482    let lower = content.to_ascii_lowercase();
1483    let normalized = lower.trim();
1484
1485    if normalized == "mit"
1486        || normalized == "mit license"
1487        || lower.contains("mit license")
1488        || lower.contains("permission is hereby granted")
1489    {
1490        LicenseKind::Mit
1491    } else if normalized == "apache-2.0"
1492        || normalized == "apache 2.0"
1493        || lower.contains("apache license")
1494        || lower.contains("apache-2.0")
1495        || lower.contains("www.apache.org/licenses/license-2.0")
1496    {
1497        LicenseKind::Apache2
1498    } else if normalized.starts_with("gpl")
1499        || lower.contains("gnu general public license")
1500        || lower.contains("gpl")
1501    {
1502        LicenseKind::Gpl
1503    } else if normalized.starts_with("bsd")
1504        || (lower.contains("bsd") && lower.contains("redistribution and use"))
1505    {
1506        LicenseKind::Bsd
1507    } else {
1508        LicenseKind::Unknown
1509    }
1510}
1511
1512fn observe_hygiene_file(path: &Path, hygiene: &mut ScanHygiene) {
1513    match path.file_name().and_then(|value| value.to_str()) {
1514        Some(".gitignore") => hygiene.has_gitignore = true,
1515        Some(".ignore") => hygiene.has_ignore = true,
1516        _ => {}
1517    }
1518}
1519
1520fn observe_ci_file(path: &Path, ci: &mut CiSummary) {
1521    if has_component_pair(path, ".github", "workflows") {
1522        ci.has_github_actions = true;
1523    }
1524    if has_component(path, ".workflow") {
1525        ci.has_gitee_go = true;
1526    }
1527    if path.file_name().and_then(|value| value.to_str()) == Some(".gitlab-ci.yml") {
1528        ci.has_gitlab_ci = true;
1529    }
1530    if has_component(path, ".circleci") {
1531        ci.has_circle_ci = true;
1532    }
1533    if path.file_name().and_then(|value| value.to_str()) == Some("Jenkinsfile") {
1534        ci.has_jenkins = true;
1535    }
1536}
1537
1538fn has_component(path: &Path, name: &str) -> bool {
1539    path.components()
1540        .any(|component| component == Component::Normal(OsStr::new(name)))
1541}
1542
1543fn has_component_pair(path: &Path, first: &str, second: &str) -> bool {
1544    let mut components = path.components();
1545    while let Some(component) = components.next() {
1546        if component == Component::Normal(OsStr::new(first))
1547            && components.next() == Some(Component::Normal(OsStr::new(second)))
1548        {
1549            return true;
1550        }
1551    }
1552
1553    false
1554}
1555
1556fn observe_container_file(path: &Path, containers: &mut ContainerSummary) {
1557    let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
1558        return;
1559    };
1560    let file_name = file_name.to_ascii_lowercase();
1561
1562    if file_name == "dockerfile" || file_name.starts_with("dockerfile.") {
1563        containers.has_dockerfile = true;
1564    }
1565    if matches!(
1566        file_name.as_str(),
1567        "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml"
1568    ) {
1569        containers.has_compose_file = true;
1570    }
1571}
1572
1573#[derive(Default)]
1574struct MutableInventory {
1575    languages: Vec<LanguageSummary>,
1576    build_systems: Vec<BuildSystemSummary>,
1577    dependency_manifests: Vec<PathBuf>,
1578    tests: TestSummary,
1579    test_command_sources: Vec<PathBuf>,
1580    hygiene: ScanHygiene,
1581    documentation: DocumentationSummary,
1582    license_files: Vec<PathBuf>,
1583    ci: CiSummary,
1584    containers: ContainerSummary,
1585    files_scanned: usize,
1586    skipped_dirs: Vec<PathBuf>,
1587}
1588
1589impl MutableInventory {
1590    fn add_language(&mut self, kind: LanguageKind) {
1591        if let Some(language) = self
1592            .languages
1593            .iter_mut()
1594            .find(|language| language.kind == kind)
1595        {
1596            language.files += 1;
1597            return;
1598        }
1599
1600        self.languages.push(LanguageSummary { kind, files: 1 });
1601    }
1602
1603    fn add_build_system(&mut self, kind: BuildSystemKind, path: PathBuf) {
1604        if self
1605            .build_systems
1606            .iter()
1607            .any(|build_system| build_system.kind == kind && build_system.path == path)
1608        {
1609            return;
1610        }
1611
1612        self.build_systems.push(BuildSystemSummary { kind, path });
1613    }
1614
1615    fn add_dependency_manifest(&mut self, path: PathBuf) {
1616        if self
1617            .dependency_manifests
1618            .iter()
1619            .any(|existing| existing == &path)
1620        {
1621            return;
1622        }
1623
1624        self.dependency_manifests.push(path);
1625    }
1626
1627    fn add_test_command_source(&mut self, path: PathBuf) {
1628        if self
1629            .test_command_sources
1630            .iter()
1631            .any(|existing| existing == &path)
1632        {
1633            return;
1634        }
1635
1636        self.test_command_sources.push(path);
1637    }
1638
1639    fn add_license_file(&mut self, path: PathBuf) {
1640        if self.license_files.iter().any(|existing| existing == &path) {
1641            return;
1642        }
1643
1644        self.license_files.push(path);
1645    }
1646
1647    fn add_skipped_dir(&mut self, path: PathBuf) {
1648        if self.skipped_dirs.iter().any(|existing| existing == &path) {
1649            return;
1650        }
1651
1652        self.skipped_dirs.push(path);
1653    }
1654}
1655
1656fn build_report(
1657    project_name: &str,
1658    indicators: &[ProjectIndicator],
1659    inventory: &ScanInventory,
1660) -> ProjectReport {
1661    let summary = if indicators.is_empty() {
1662        format!("{project_name} was scanned, but no known project markers were detected yet.")
1663    } else {
1664        format!(
1665            "{project_name} was scanned across {} file(s), with {} project marker(s), {} language(s), and {} build system(s) detected.",
1666            inventory.files_scanned,
1667            indicators.len(),
1668            inventory.languages.len(),
1669            inventory.build_systems.len()
1670        )
1671    };
1672
1673    ProjectReport {
1674        summary,
1675        next_steps: vec![
1676            "Add language, dependency, and repository activity scanners.".to_owned(),
1677            "Keep CLI and GUI features backed by projd-core data structures.".to_owned(),
1678            "Export Markdown and JSON reports before adding richer GUI visualizations.".to_owned(),
1679        ],
1680    }
1681}
1682
1683fn yes_no(value: bool) -> &'static str {
1684    if value { "yes" } else { "no" }
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689    use super::*;
1690
1691    #[test]
1692    fn renders_markdown_without_indicators() {
1693        let scan = ProjectScan {
1694            root: PathBuf::from("/tmp/example"),
1695            project_name: "example".to_owned(),
1696            identity: ProjectIdentity {
1697                name: "example".to_owned(),
1698                version: None,
1699                kind: ProjectKind::Generic,
1700                source: IdentitySource::DirectoryName,
1701            },
1702            indicators: Vec::new(),
1703            languages: Vec::new(),
1704            build_systems: Vec::new(),
1705            dependencies: DependencySummary::default(),
1706            tests: TestSummary::default(),
1707            hygiene: ScanHygiene::default(),
1708            risks: RiskSummary::default(),
1709            documentation: DocumentationSummary::default(),
1710            license: LicenseSummary::default(),
1711            ci: CiSummary::default(),
1712            git: GitSummary::default(),
1713            containers: ContainerSummary::default(),
1714            files_scanned: 0,
1715            skipped_dirs: Vec::new(),
1716            report: ProjectReport {
1717                summary: "example summary".to_owned(),
1718                next_steps: vec!["next".to_owned()],
1719            },
1720        };
1721
1722        let rendered = render_markdown(&scan);
1723
1724        assert!(rendered.contains("# Project Report"));
1725        assert!(rendered.contains("example summary"));
1726        assert!(rendered.contains("No known project indicators"));
1727    }
1728}