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 = ¤t[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}