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}