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