Skip to main content

projd_core/
lib.rs

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