Skip to main content

projd_core/
lib.rs

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