1use std::collections::BTreeMap;
2use std::ffi::OsStr;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result, bail};
8use ignore::gitignore::{Gitignore, GitignoreBuilder};
9use serde::{Deserialize, Serialize};
10
11pub mod discover;
12
13pub use discover::{
14 Confidence, DiscoverOptions, DiscoverSummary, DiscoveredRoot, RootCategory, RootKind,
15 category_token, discover_roots, relative_display, render_discover_json,
16 render_discover_markdown, summarize_roots,
17};
18
19pub const NAME: &str = "Projd";
20pub const VERSION: &str = env!("CARGO_PKG_VERSION");
21
22pub fn describe() -> &'static str {
23 "Scan software projects and generate structured reports."
24}
25
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27pub struct ProjectScan {
28 pub root: PathBuf,
29 pub project_name: String,
30 pub identity: ProjectIdentity,
31 pub indicators: Vec<ProjectIndicator>,
32 pub languages: Vec<LanguageSummary>,
33 pub build_systems: Vec<BuildSystemSummary>,
34 pub code: CodeSummary,
35 pub dependencies: DependencySummary,
36 pub tests: TestSummary,
37 pub hygiene: ScanHygiene,
38 pub risks: RiskSummary,
39 pub health: HealthSummary,
40 pub documentation: DocumentationSummary,
41 pub license: LicenseSummary,
42 pub ci: CiSummary,
43 pub vcs: VcsSummary,
44 pub containers: ContainerSummary,
45 pub files_scanned: usize,
46 pub skipped_dirs: Vec<PathBuf>,
47 pub report: ProjectReport,
48}
49
50impl ProjectScan {
51 pub fn has_language(&self, kind: LanguageKind) -> bool {
52 self.languages.iter().any(|language| language.kind == kind)
53 }
54
55 pub fn has_build_system(&self, kind: BuildSystemKind) -> bool {
56 self.build_systems
57 .iter()
58 .any(|build_system| build_system.kind == kind)
59 }
60
61 pub fn has_risk(&self, code: RiskCode) -> bool {
62 self.risks.findings.iter().any(|risk| risk.code == code)
63 }
64
65 pub fn risk(&self, code: RiskCode) -> Option<&RiskFinding> {
66 self.risks.findings.iter().find(|risk| risk.code == code)
67 }
68}
69
70#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
71pub struct ProjectIdentity {
72 pub name: String,
73 pub version: Option<String>,
74 pub kind: ProjectKind,
75 pub source: IdentitySource,
76}
77
78#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
79#[serde(rename_all = "kebab-case")]
80pub enum ProjectKind {
81 RustWorkspace,
82 RustPackage,
83 NodePackage,
84 PythonProject,
85 Generic,
86}
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum IdentitySource {
91 CargoToml,
92 PackageJson,
93 PyprojectToml,
94 DirectoryName,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
98pub struct ProjectIndicator {
99 pub kind: IndicatorKind,
100 pub path: PathBuf,
101}
102
103#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "kebab-case")]
105pub enum IndicatorKind {
106 RustWorkspace,
107 RustPackage,
108 GitRepository,
109 Readme,
110}
111
112#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
113pub struct LanguageSummary {
114 pub kind: LanguageKind,
115 pub files: usize,
116}
117
118#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
119#[serde(rename_all = "kebab-case")]
120pub enum LanguageKind {
121 Rust,
122 TypeScript,
123 JavaScript,
124 Python,
125 C,
126 Cpp,
127 Go,
128}
129
130#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
131pub struct BuildSystemSummary {
132 pub kind: BuildSystemKind,
133 pub path: PathBuf,
134}
135
136#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
137#[serde(rename_all = "kebab-case")]
138pub enum BuildSystemKind {
139 Cargo,
140 NodePackage,
141 PythonProject,
142 PythonRequirements,
143 CMake,
144 GoModule,
145}
146
147#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
148pub struct CodeSummary {
149 pub languages: Vec<CodeLanguageSummary>,
150 pub total_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, Debug, Eq, PartialEq, Serialize, Deserialize)]
158pub struct CodeLanguageSummary {
159 pub kind: LanguageKind,
160 pub files: usize,
161 pub total_lines: usize,
162 pub code_lines: usize,
163 pub comment_lines: usize,
164 pub blank_lines: usize,
165}
166
167#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
168struct CodeLineCounts {
169 total_lines: usize,
170 code_lines: usize,
171 comment_lines: usize,
172 blank_lines: usize,
173}
174
175#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
176pub struct DependencySummary {
177 pub ecosystems: Vec<DependencyEcosystemSummary>,
178 pub total_manifests: usize,
179 pub total_dependencies: usize,
180}
181
182#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
183pub struct DependencyEcosystemSummary {
184 pub ecosystem: DependencyEcosystem,
185 pub source: DependencySource,
186 pub manifest: PathBuf,
187 pub lockfile: Option<PathBuf>,
188 pub normal: usize,
189 pub development: usize,
190 pub build: usize,
191 pub optional: usize,
192 pub total: usize,
193}
194
195#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum DependencyEcosystem {
198 Rust,
199 Node,
200 Python,
201}
202
203#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
204#[serde(rename_all = "kebab-case")]
205pub enum DependencySource {
206 CargoToml,
207 PackageJson,
208 PyprojectToml,
209 RequirementsTxt,
210}
211
212#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
213pub struct TestSummary {
214 pub has_tests_dir: bool,
215 pub test_files: usize,
216 pub test_directories: Vec<PathBuf>,
217 pub commands: Vec<TestCommand>,
218}
219
220#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
221pub struct TestCommand {
222 pub ecosystem: TestEcosystem,
223 pub source: PathBuf,
224 pub name: String,
225 pub command: String,
226}
227
228#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
229#[serde(rename_all = "kebab-case")]
230pub enum TestEcosystem {
231 Rust,
232 Node,
233 Python,
234 CMake,
235}
236
237#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
238pub struct ScanHygiene {
239 pub has_gitignore: bool,
240 pub has_ignore: bool,
241}
242
243#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
244pub struct RiskSummary {
245 pub findings: Vec<RiskFinding>,
246 pub total: usize,
247 pub high: usize,
248 pub medium: usize,
249 pub low: usize,
250 pub info: usize,
251}
252
253#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
254pub struct RiskFinding {
255 pub severity: RiskSeverity,
256 pub code: RiskCode,
257 pub message: String,
258 pub path: Option<PathBuf>,
259}
260
261#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
262pub struct HealthSummary {
263 pub grade: ProjectHealth,
264 pub score: usize,
265 pub risk_level: RiskSeverity,
266 pub signals: Vec<HealthSignal>,
267}
268
269#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
270#[serde(rename_all = "kebab-case")]
271pub enum ProjectHealth {
272 Healthy,
273 NeedsAttention,
274 Risky,
275 Unknown,
276}
277
278#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
279pub struct HealthSignal {
280 pub kind: HealthSignalKind,
281 pub status: HealthSignalStatus,
282 pub score_delta: i32,
283 pub evidence: String,
284}
285
286#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
287#[serde(rename_all = "kebab-case")]
288pub enum HealthSignalKind {
289 Readme,
290 License,
291 Ci,
292 Tests,
293 Lockfiles,
294 Vcs,
295 Activity,
296 Docs,
297}
298
299#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
300#[serde(rename_all = "kebab-case")]
301pub enum HealthSignalStatus {
302 Pass,
303 Warn,
304 Info,
305}
306
307#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
308#[serde(rename_all = "kebab-case")]
309pub enum RiskSeverity {
310 High,
311 Medium,
312 Low,
313 Info,
314}
315
316#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
317#[serde(rename_all = "kebab-case")]
318pub enum RiskCode {
319 MissingReadme,
320 MissingLicense,
321 MissingCi,
322 NoTestsDetected,
323 ManifestWithoutLockfile,
324 LargeProjectWithoutIgnoreRules,
325 UnknownLicense,
326 MultipleVcsRootsFound,
327 NestedVcsRoot,
328}
329
330#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
331pub struct DocumentationSummary {
332 pub has_readme: bool,
333 pub has_license: bool,
334 pub has_docs_dir: bool,
335}
336
337#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
338pub struct LicenseSummary {
339 pub path: Option<PathBuf>,
340 pub kind: LicenseKind,
341}
342
343#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
344#[serde(rename_all = "kebab-case")]
345pub enum LicenseKind {
346 Mit,
347 Apache2,
348 Gpl,
349 Bsd,
350 #[default]
351 Unknown,
352 Missing,
353}
354
355#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
356pub struct CiSummary {
357 pub has_github_actions: bool,
358 pub has_gitee_go: bool,
359 pub has_gitlab_ci: bool,
360 pub has_circle_ci: bool,
361 pub has_jenkins: bool,
362 pub providers: Vec<CiProviderSummary>,
363}
364
365#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
366pub struct CiProviderSummary {
367 pub provider: CiProvider,
368 pub path: PathBuf,
369}
370
371#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
372#[serde(rename_all = "kebab-case")]
373pub enum CiProvider {
374 GithubActions,
375 GiteeGo,
376 GitlabCi,
377 CircleCi,
378 Jenkins,
379}
380
381impl CiSummary {
382 pub fn has_provider(&self, provider: CiProvider) -> bool {
383 match provider {
384 CiProvider::GithubActions => self.has_github_actions,
385 CiProvider::GiteeGo => self.has_gitee_go,
386 CiProvider::GitlabCi => self.has_gitlab_ci,
387 CiProvider::CircleCi => self.has_circle_ci,
388 CiProvider::Jenkins => self.has_jenkins,
389 }
390 }
391
392 fn has_any_provider(&self) -> bool {
393 !self.providers.is_empty()
394 || self.has_github_actions
395 || self.has_gitee_go
396 || self.has_gitlab_ci
397 || self.has_circle_ci
398 || self.has_jenkins
399 }
400
401 fn add_provider(&mut self, provider: CiProvider, path: PathBuf) {
402 match provider {
403 CiProvider::GithubActions => self.has_github_actions = true,
404 CiProvider::GiteeGo => self.has_gitee_go = true,
405 CiProvider::GitlabCi => self.has_gitlab_ci = true,
406 CiProvider::CircleCi => self.has_circle_ci = true,
407 CiProvider::Jenkins => self.has_jenkins = true,
408 }
409
410 if self
411 .providers
412 .iter()
413 .any(|existing| existing.provider == provider && existing.path == path)
414 {
415 return;
416 }
417
418 self.providers.push(CiProviderSummary { provider, path });
419 }
420}
421
422#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum VcsKind {
425 #[default]
426 None,
427 Git,
428 Hg,
429 Svn,
430 Fossil,
431 Bzr,
432}
433
434impl VcsKind {
435 pub fn label(self) -> &'static str {
436 match self {
437 Self::None => "none",
438 Self::Git => "git",
439 Self::Hg => "hg",
440 Self::Svn => "svn",
441 Self::Fossil => "fossil",
442 Self::Bzr => "bzr",
443 }
444 }
445
446 pub fn ref_label(self) -> &'static str {
449 match self {
450 Self::Svn => "URL",
451 _ => "Branch",
452 }
453 }
454}
455
456#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
457pub struct VcsSummary {
458 pub kind: VcsKind,
459 pub is_repository: bool,
460 pub root: Option<PathBuf>,
461 pub branch: Option<String>,
463 pub revision: Option<String>,
465 pub last_commit: Option<String>,
467 pub is_dirty: bool,
468 pub tracked_modified_files: usize,
469 pub untracked_files: usize,
470 pub activity: VcsActivity,
471}
472
473#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
474pub struct VcsActivity {
475 pub days_since_last_commit: Option<u32>,
476 pub commits_last_90d: Option<u32>,
477 pub contributors_count: Option<u32>,
478 pub first_commit_date: Option<String>,
481 pub contributors: Vec<VcsContributor>,
484}
485
486#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
487pub struct VcsContributor {
488 pub name: String,
489 pub email: String,
490 pub commit_count: u32,
491}
492
493#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
494pub struct ContainerSummary {
495 pub has_dockerfile: bool,
496 pub has_compose_file: bool,
497}
498
499#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
500pub struct ProjectReport {
501 pub summary: String,
502 pub next_steps: Vec<String>,
503}
504
505#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
506pub struct MultiProjectScan {
507 pub root: PathBuf,
508 pub summary: MultiProjectSummary,
509 pub roots: Vec<ProjectScan>,
510 pub skipped: Vec<MultiProjectScanError>,
511}
512
513#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
514pub struct MultiProjectSummary {
515 pub total: usize,
516 pub by_kind: BTreeMap<RootKind, usize>,
517 pub by_grade: BTreeMap<ProjectHealth, usize>,
518 pub by_risk_level: BTreeMap<RiskSeverity, usize>,
519 pub files_scanned: usize,
520}
521
522#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
523pub struct MultiProjectScanError {
524 pub path: PathBuf,
525 pub message: String,
526}
527
528#[derive(Clone, Debug, Default)]
532pub struct ScanOptions {
533 pub detailed_contributors: bool,
537}
538
539pub fn scan_path(path: impl AsRef<Path>) -> Result<ProjectScan> {
540 scan_path_with(path, &ScanOptions::default())
541}
542
543pub fn scan_path_with(path: impl AsRef<Path>, opts: &ScanOptions) -> Result<ProjectScan> {
544 let root = path.as_ref();
545 let metadata =
546 fs::metadata(root).with_context(|| format!("failed to inspect `{}`", root.display()))?;
547
548 if !metadata.is_dir() {
549 bail!("project path must be a directory: `{}`", root.display());
550 }
551
552 let root = root
553 .canonicalize()
554 .with_context(|| format!("failed to resolve `{}`", root.display()))?;
555 let project_name = root
556 .file_name()
557 .and_then(|value| value.to_str())
558 .unwrap_or("project")
559 .to_owned();
560 let identity = detect_identity(&root, &project_name);
561 let indicators = detect_indicators(&root);
562 let inventory = scan_inventory(&root)?;
563 let multi_root_check = detect_multi_root_situation(&root);
564 let risks = build_risk_summary(&inventory, &multi_root_check);
565 let vcs = detect_vcs_summary(&root, opts);
566 let health = build_health_summary(&inventory, &risks, &vcs);
567 let report = build_report(&identity.name, &indicators, &inventory, &health);
568
569 Ok(ProjectScan {
570 root,
571 project_name: identity.name.clone(),
572 identity,
573 indicators,
574 languages: inventory.languages,
575 build_systems: inventory.build_systems,
576 code: inventory.code,
577 dependencies: inventory.dependencies,
578 tests: inventory.tests,
579 hygiene: inventory.hygiene,
580 risks,
581 health,
582 documentation: inventory.documentation,
583 license: inventory.license,
584 ci: inventory.ci,
585 vcs,
586 containers: inventory.containers,
587 files_scanned: inventory.files_scanned,
588 skipped_dirs: inventory.skipped_dirs,
589 report,
590 })
591}
592
593pub fn scan_paths_recursive(
594 path: impl AsRef<Path>,
595 opts: &discover::DiscoverOptions,
596) -> Result<MultiProjectScan> {
597 scan_paths_recursive_with(path, opts, &ScanOptions::default())
598}
599
600pub fn scan_paths_recursive_with(
601 path: impl AsRef<Path>,
602 discover_opts: &discover::DiscoverOptions,
603 scan_opts: &ScanOptions,
604) -> Result<MultiProjectScan> {
605 let input = path.as_ref();
606 let canonical = fs::canonicalize(input)
607 .with_context(|| format!("failed to resolve `{}`", input.display()))?;
608 if !canonical.is_dir() {
609 bail!("`{}` is not a directory", canonical.display());
610 }
611
612 let roots = discover::discover_roots(&canonical, discover_opts)?;
613
614 let mut scans: Vec<ProjectScan> = Vec::new();
615 let mut skipped: Vec<MultiProjectScanError> = Vec::new();
616 for entry in &roots {
617 match scan_path_with(&entry.path, scan_opts) {
618 Ok(scan) => scans.push(scan),
619 Err(err) => skipped.push(MultiProjectScanError {
620 path: entry.path.clone(),
621 message: format!("{err:#}"),
622 }),
623 }
624 }
625
626 let summary = build_multi_summary(&roots, &scans);
627
628 Ok(MultiProjectScan {
629 root: canonical,
630 summary,
631 roots: scans,
632 skipped,
633 })
634}
635
636fn build_multi_summary(
637 discovered: &[discover::DiscoveredRoot],
638 scans: &[ProjectScan],
639) -> MultiProjectSummary {
640 let mut by_kind: BTreeMap<RootKind, usize> = BTreeMap::new();
641 for entry in discovered {
642 for kind in &entry.kinds {
643 *by_kind.entry(*kind).or_insert(0) += 1;
644 }
645 }
646
647 let mut by_grade: BTreeMap<ProjectHealth, usize> = BTreeMap::new();
648 let mut by_risk_level: BTreeMap<RiskSeverity, usize> = BTreeMap::new();
649 let mut files_scanned = 0usize;
650 for scan in scans {
651 *by_grade.entry(scan.health.grade).or_insert(0) += 1;
652 *by_risk_level.entry(scan.health.risk_level).or_insert(0) += 1;
653 files_scanned = files_scanned.saturating_add(scan.files_scanned);
654 }
655
656 MultiProjectSummary {
657 total: scans.len(),
658 by_kind,
659 by_grade,
660 by_risk_level,
661 files_scanned,
662 }
663}
664
665pub fn render_multi_markdown(scan: &MultiProjectScan) -> String {
666 let mut out = String::new();
667 out.push_str("# Projd Recursive Scan Report\n\n");
668 out.push_str(&format!("- Root: `{}`\n", scan.root.display()));
669 out.push_str(&format!("- Total project roots: {}\n", scan.summary.total));
670 if !scan.summary.by_grade.is_empty() {
671 let parts: Vec<String> = [
672 ProjectHealth::Healthy,
673 ProjectHealth::NeedsAttention,
674 ProjectHealth::Risky,
675 ProjectHealth::Unknown,
676 ]
677 .iter()
678 .filter_map(|grade| {
679 scan.summary
680 .by_grade
681 .get(grade)
682 .map(|count| format!("{}: {}", grade.as_str(), count))
683 })
684 .collect();
685 if !parts.is_empty() {
686 out.push_str(&format!("- Grade: {}\n", parts.join(" / ")));
687 }
688 }
689 if !scan.summary.by_risk_level.is_empty() {
690 let parts: Vec<String> = [
691 RiskSeverity::High,
692 RiskSeverity::Medium,
693 RiskSeverity::Low,
694 RiskSeverity::Info,
695 ]
696 .iter()
697 .filter_map(|level| {
698 scan.summary
699 .by_risk_level
700 .get(level)
701 .map(|count| format!("{}: {}", level.as_str(), count))
702 })
703 .collect();
704 if !parts.is_empty() {
705 out.push_str(&format!("- Risk level: {}\n", parts.join(" / ")));
706 }
707 }
708 out.push_str(&format!(
709 "- Files scanned: {}\n",
710 scan.summary.files_scanned
711 ));
712 if !scan.skipped.is_empty() {
713 out.push_str(&format!("- Skipped: {}\n", scan.skipped.len()));
714 }
715 out.push('\n');
716
717 if scan.roots.is_empty() {
718 out.push_str("No project roots found.\n");
719 if !scan.skipped.is_empty() {
720 out.push_str("\n## Skipped\n\n");
721 for entry in &scan.skipped {
722 out.push_str(&format!(
723 "- `{}`: {}\n",
724 entry.path.display(),
725 entry.message
726 ));
727 }
728 }
729 return out;
730 }
731
732 for (index, project) in scan.roots.iter().enumerate() {
733 let rel = discover::relative_display(&scan.root, &project.root);
734 out.push_str(&format!(
735 "## [{}/{}] `{}` — {} ({})\n\n",
736 index + 1,
737 scan.roots.len(),
738 rel,
739 project.health.grade.as_str(),
740 project.health.score
741 ));
742 out.push_str(&render_markdown(project));
743 out.push('\n');
744 }
745
746 if !scan.skipped.is_empty() {
747 out.push_str("## Skipped\n\n");
748 for entry in &scan.skipped {
749 out.push_str(&format!(
750 "- `{}`: {}\n",
751 entry.path.display(),
752 entry.message
753 ));
754 }
755 }
756 out
757}
758
759pub fn render_multi_json(scan: &MultiProjectScan) -> Result<String> {
760 serde_json::to_string_pretty(scan).context("failed to serialize MultiProjectScan as JSON")
761}
762
763pub fn render_markdown(scan: &ProjectScan) -> String {
764 let mut output = String::new();
765 output.push_str("# Project Report\n\n");
766 output.push_str(&format!("Project: `{}`\n\n", scan.project_name));
767 output.push_str(&format!("Root: `{}`\n\n", scan.root.display()));
768 output.push_str("## Identity\n\n");
769 output.push_str(&format!("- Name: `{}`\n", scan.identity.name));
770 output.push_str(&format!(
771 "- Version: {}\n",
772 scan.identity
773 .version
774 .as_deref()
775 .map(|version| format!("`{version}`"))
776 .unwrap_or_else(|| "unknown".to_owned())
777 ));
778 output.push_str(&format!("- Kind: {:?}\n", scan.identity.kind));
779 output.push_str(&format!("- Source: {:?}\n\n", scan.identity.source));
780 output.push_str("## Summary\n\n");
781 output.push_str(&scan.report.summary);
782 output.push_str("\n\n## Health\n\n");
783 output.push_str(&format!("- Grade: `{}`\n", scan.health.grade.as_str()));
784 output.push_str(&format!("- Score: {}/100\n", scan.health.score));
785 output.push_str(&format!(
786 "- Risk level: `{}`\n",
787 scan.health.risk_level.as_str()
788 ));
789 output.push_str("- Signals:\n");
790 for signal in &scan.health.signals {
791 output.push_str(&format!(
792 " - {}: {} ({:+}) - {}\n",
793 signal.kind.as_str(),
794 signal.status.as_str(),
795 signal.score_delta,
796 signal.evidence
797 ));
798 }
799
800 output.push_str("\n## Scan Observations\n\n");
801 output.push_str(&format!("- Files scanned: {}\n", scan.files_scanned));
802 output.push_str(&format!(
803 "- Skipped directories: {}\n",
804 scan.skipped_dirs.len()
805 ));
806 output.push_str(&format!(
807 "- `.gitignore`: {}\n",
808 yes_no(scan.hygiene.has_gitignore)
809 ));
810 output.push_str(&format!(
811 "- `.ignore`: {}\n",
812 yes_no(scan.hygiene.has_ignore)
813 ));
814
815 output.push_str("\n## Indicators\n\n");
816
817 if scan.indicators.is_empty() {
818 output.push_str("- No known project indicators detected.\n");
819 } else {
820 for indicator in &scan.indicators {
821 output.push_str(&format!(
822 "- {:?}: `{}`\n",
823 indicator.kind,
824 indicator.path.display()
825 ));
826 }
827 }
828
829 output.push_str("\n## Languages\n\n");
830 if scan.languages.is_empty() {
831 output.push_str("- No source language files detected.\n");
832 } else {
833 for language in &scan.languages {
834 output.push_str(&format!(
835 "- {:?}: {} file(s)\n",
836 language.kind, language.files
837 ));
838 }
839 }
840
841 output.push_str("\n## Code Statistics\n\n");
842 output.push_str(&format!(
843 "- Total source files: {}\n",
844 scan.code.total_files
845 ));
846 output.push_str(&format!("- Total lines: {}\n", scan.code.total_lines));
847 output.push_str(&format!("- Code lines: {}\n", scan.code.code_lines));
848 output.push_str(&format!("- Comment lines: {}\n", scan.code.comment_lines));
849 output.push_str(&format!("- Blank lines: {}\n", scan.code.blank_lines));
850 if scan.code.languages.is_empty() {
851 output.push_str("- No code statistics detected.\n");
852 } else {
853 output.push_str("- By language:\n");
854 for language in &scan.code.languages {
855 output.push_str(&format!(
856 " - {:?}: {} file(s), {} total, {} code, {} comment, {} blank\n",
857 language.kind,
858 language.files,
859 language.total_lines,
860 language.code_lines,
861 language.comment_lines,
862 language.blank_lines
863 ));
864 }
865 }
866
867 output.push_str("\n## Build Systems\n\n");
868 if scan.build_systems.is_empty() {
869 output.push_str("- No known build systems detected.\n");
870 } else {
871 for build_system in &scan.build_systems {
872 output.push_str(&format!(
873 "- {:?}: `{}`\n",
874 build_system.kind,
875 build_system.path.display()
876 ));
877 }
878 }
879
880 output.push_str("\n## Dependencies\n\n");
881 output.push_str(&format!(
882 "- Total manifests: {}\n",
883 scan.dependencies.total_manifests
884 ));
885 output.push_str(&format!(
886 "- Total dependency entries: {}\n",
887 scan.dependencies.total_dependencies
888 ));
889 if scan.dependencies.ecosystems.is_empty() {
890 output.push_str("- No dependency manifests detected.\n");
891 } else {
892 for summary in &scan.dependencies.ecosystems {
893 output.push_str(&format!(
894 "- {:?} {:?}: `{}` normal {}, dev {}, build {}, optional {}, total {}, lockfile {}\n",
895 summary.ecosystem,
896 summary.source,
897 summary.manifest.display(),
898 summary.normal,
899 summary.development,
900 summary.build,
901 summary.optional,
902 summary.total,
903 yes_no(summary.lockfile.is_some())
904 ));
905 }
906 }
907
908 output.push_str("\n## Tests\n\n");
909 output.push_str(&format!(
910 "- Test directories: {}\n",
911 scan.tests.test_directories.len()
912 ));
913 output.push_str(&format!("- Test files: {}\n", scan.tests.test_files));
914 if scan.tests.commands.is_empty() {
915 output.push_str("- Commands: none detected\n");
916 } else {
917 output.push_str("- Commands:\n");
918 for command in &scan.tests.commands {
919 output.push_str(&format!(
920 " - {:?}: {} (`{}`)\n",
921 command.ecosystem,
922 command.command,
923 command.source.display()
924 ));
925 }
926 }
927
928 output.push_str("\n## Risks\n\n");
929 if scan.risks.findings.is_empty() {
930 output.push_str("- No risks detected by current rules.\n");
931 } else {
932 for risk in &scan.risks.findings {
933 output.push_str(&format!(
934 "- {} {}: {}",
935 risk.severity.as_str(),
936 risk.code.as_str(),
937 risk.message
938 ));
939 if let Some(path) = &risk.path {
940 output.push_str(&format!(" (`{}`)", path.display()));
941 }
942 output.push('\n');
943 }
944 }
945
946 output.push_str("\n## Project Facilities\n\n");
947 output.push_str(&format!(
948 "- README: {}\n",
949 yes_no(scan.documentation.has_readme)
950 ));
951 output.push_str(&format!(
952 "- License: {}\n",
953 yes_no(scan.documentation.has_license)
954 ));
955 output.push_str(&format!("- License kind: {:?}\n", scan.license.kind));
956 output.push_str(&format!(
957 "- docs/: {}\n",
958 yes_no(scan.documentation.has_docs_dir)
959 ));
960 output.push_str(&format!(
961 "- GitHub Actions: {}\n",
962 yes_no(scan.ci.has_github_actions)
963 ));
964 output.push_str(&format!("- Gitee Go: {}\n", yes_no(scan.ci.has_gitee_go)));
965 output.push_str(&format!("- GitLab CI: {}\n", yes_no(scan.ci.has_gitlab_ci)));
966 output.push_str(&format!("- CircleCI: {}\n", yes_no(scan.ci.has_circle_ci)));
967 output.push_str(&format!("- Jenkins: {}\n", yes_no(scan.ci.has_jenkins)));
968 output.push_str(&format!(
969 "- {} repository: {}\n",
970 scan.vcs.kind.label(),
971 yes_no(scan.vcs.is_repository)
972 ));
973 if let Some(days) = scan.vcs.activity.days_since_last_commit {
974 output.push_str(&format!("- Activity: last commit {days} day(s) ago\n"));
975 }
976 if let Some(count) = scan.vcs.activity.commits_last_90d {
977 output.push_str(&format!("- Commits (90d): {count}\n"));
978 }
979 if let Some(count) = scan.vcs.activity.contributors_count {
980 output.push_str(&format!("- Contributors: {count}\n"));
981 }
982 if let Some(first) = &scan.vcs.activity.first_commit_date {
983 output.push_str(&format!("- First commit: {first}\n"));
984 }
985 output.push_str(&format!(
986 "- Dockerfile: {}\n",
987 yes_no(scan.containers.has_dockerfile)
988 ));
989 output.push_str(&format!(
990 "- Compose file: {}\n",
991 yes_no(scan.containers.has_compose_file)
992 ));
993
994 output.push_str("\n## Next Steps\n\n");
995 for step in &scan.report.next_steps {
996 output.push_str(&format!("- {step}\n"));
997 }
998
999 output
1000}
1001
1002pub fn render_json(scan: &ProjectScan) -> Result<String> {
1003 serde_json::to_string_pretty(scan).context("failed to render scan result as json")
1004}
1005
1006struct ScanInventory {
1007 languages: Vec<LanguageSummary>,
1008 build_systems: Vec<BuildSystemSummary>,
1009 code: CodeSummary,
1010 dependencies: DependencySummary,
1011 tests: TestSummary,
1012 hygiene: ScanHygiene,
1013 documentation: DocumentationSummary,
1014 license: LicenseSummary,
1015 ci: CiSummary,
1016 containers: ContainerSummary,
1017 files_scanned: usize,
1018 skipped_dirs: Vec<PathBuf>,
1019}
1020
1021fn detect_identity(root: &Path, fallback_name: &str) -> ProjectIdentity {
1022 detect_cargo_identity(root, fallback_name)
1023 .or_else(|| detect_node_identity(root))
1024 .or_else(|| detect_python_identity(root))
1025 .unwrap_or_else(|| ProjectIdentity {
1026 name: fallback_name.to_owned(),
1027 version: None,
1028 kind: ProjectKind::Generic,
1029 source: IdentitySource::DirectoryName,
1030 })
1031}
1032
1033fn detect_cargo_identity(root: &Path, fallback_name: &str) -> Option<ProjectIdentity> {
1034 let manifest = read_toml_value(&root.join("Cargo.toml"))?;
1035
1036 if let Some(package) = manifest.get("package") {
1037 let name = package.get("name")?.as_str()?.to_owned();
1038 let version = package
1039 .get("version")
1040 .and_then(|value| value.as_str())
1041 .map(str::to_owned);
1042
1043 return Some(ProjectIdentity {
1044 name,
1045 version,
1046 kind: ProjectKind::RustPackage,
1047 source: IdentitySource::CargoToml,
1048 });
1049 }
1050
1051 if manifest.get("workspace").is_some() {
1052 let version = manifest
1053 .get("workspace")
1054 .and_then(|workspace| workspace.get("package"))
1055 .and_then(|package| package.get("version"))
1056 .and_then(|value| value.as_str())
1057 .map(str::to_owned);
1058
1059 return Some(ProjectIdentity {
1060 name: fallback_name.to_owned(),
1061 version,
1062 kind: ProjectKind::RustWorkspace,
1063 source: IdentitySource::CargoToml,
1064 });
1065 }
1066
1067 None
1068}
1069
1070fn detect_node_identity(root: &Path) -> Option<ProjectIdentity> {
1071 let manifest = read_json_value(&root.join("package.json"))?;
1072 let name = manifest.get("name")?.as_str()?.to_owned();
1073 let version = manifest
1074 .get("version")
1075 .and_then(|value| value.as_str())
1076 .map(str::to_owned);
1077
1078 Some(ProjectIdentity {
1079 name,
1080 version,
1081 kind: ProjectKind::NodePackage,
1082 source: IdentitySource::PackageJson,
1083 })
1084}
1085
1086fn detect_python_identity(root: &Path) -> Option<ProjectIdentity> {
1087 let manifest = read_toml_value(&root.join("pyproject.toml"))?;
1088 let project = manifest.get("project")?;
1089 let name = project.get("name")?.as_str()?.to_owned();
1090 let version = project
1091 .get("version")
1092 .and_then(|value| value.as_str())
1093 .map(str::to_owned);
1094
1095 Some(ProjectIdentity {
1096 name,
1097 version,
1098 kind: ProjectKind::PythonProject,
1099 source: IdentitySource::PyprojectToml,
1100 })
1101}
1102
1103fn read_toml_value(path: &Path) -> Option<toml::Value> {
1104 let content = fs::read_to_string(path).ok()?;
1105 toml::from_str(&content).ok()
1106}
1107
1108fn read_json_value(path: &Path) -> Option<serde_json::Value> {
1109 let content = fs::read_to_string(path).ok()?;
1110 serde_json::from_str(&content).ok()
1111}
1112
1113fn build_dependency_summary(manifests: Vec<PathBuf>) -> DependencySummary {
1114 let mut ecosystems = Vec::new();
1115
1116 for manifest in manifests {
1117 let Some(file_name) = manifest.file_name().and_then(|value| value.to_str()) else {
1118 continue;
1119 };
1120
1121 let summary = match file_name {
1122 "Cargo.toml" => summarize_cargo_dependencies(&manifest),
1123 "package.json" => summarize_node_dependencies(&manifest),
1124 "pyproject.toml" => summarize_pyproject_dependencies(&manifest),
1125 "requirements.txt" => summarize_requirements_dependencies(&manifest),
1126 _ => None,
1127 };
1128
1129 if let Some(summary) = summary {
1130 ecosystems.push(summary);
1131 }
1132 }
1133
1134 let total_manifests = ecosystems.len();
1135 let total_dependencies = ecosystems.iter().map(|summary| summary.total).sum();
1136
1137 DependencySummary {
1138 ecosystems,
1139 total_manifests,
1140 total_dependencies,
1141 }
1142}
1143
1144fn build_test_summary(mut tests: TestSummary, sources: Vec<PathBuf>) -> TestSummary {
1145 for source in sources {
1146 let Some(file_name) = source.file_name().and_then(|value| value.to_str()) else {
1147 continue;
1148 };
1149
1150 match file_name {
1151 "Cargo.toml" => add_rust_test_command(&mut tests, &source),
1152 "package.json" => add_node_test_commands(&mut tests, &source),
1153 "pyproject.toml" => add_python_test_command(&mut tests, &source),
1154 "CMakeLists.txt" => add_cmake_test_command(&mut tests, &source),
1155 _ => {}
1156 }
1157 }
1158
1159 tests
1160}
1161
1162struct MultiRootCheck {
1163 siblings: Vec<PathBuf>,
1164 nested: Vec<PathBuf>,
1165}
1166
1167fn detect_multi_root_situation(root: &Path) -> MultiRootCheck {
1168 let default_opts = discover::DiscoverOptions {
1169 min_confidence: discover::Confidence::Strong,
1170 ..discover::DiscoverOptions::default()
1171 };
1172 let default_roots = discover::discover_roots(root, &default_opts).unwrap_or_default();
1173 let siblings: Vec<PathBuf> = default_roots
1174 .iter()
1175 .filter(|entry| entry.path != root)
1176 .map(|entry| entry.path.clone())
1177 .collect();
1178
1179 let nested_opts = discover::DiscoverOptions {
1180 min_confidence: discover::Confidence::Strong,
1181 nested_vcs: true,
1182 ..discover::DiscoverOptions::default()
1183 };
1184 let nested_roots_all = discover::discover_roots(root, &nested_opts).unwrap_or_default();
1185 let nested: Vec<PathBuf> = nested_roots_all
1186 .iter()
1187 .filter(|entry| !default_roots.iter().any(|d| d.path == entry.path))
1188 .map(|entry| entry.path.clone())
1189 .collect();
1190
1191 MultiRootCheck { siblings, nested }
1192}
1193
1194fn build_risk_summary(inventory: &ScanInventory, multi_root: &MultiRootCheck) -> RiskSummary {
1195 let mut findings = Vec::new();
1196
1197 if !inventory.documentation.has_readme {
1198 findings.push(RiskFinding {
1199 severity: RiskSeverity::Medium,
1200 code: RiskCode::MissingReadme,
1201 message: "No README file detected.".to_owned(),
1202 path: None,
1203 });
1204 }
1205
1206 if !inventory.documentation.has_license {
1207 findings.push(RiskFinding {
1208 severity: RiskSeverity::Medium,
1209 code: RiskCode::MissingLicense,
1210 message: "No LICENSE file detected.".to_owned(),
1211 path: None,
1212 });
1213 } else if inventory.license.kind == LicenseKind::Unknown {
1214 findings.push(RiskFinding {
1215 severity: RiskSeverity::Low,
1216 code: RiskCode::UnknownLicense,
1217 message: "License file was detected, but the license type was not recognized."
1218 .to_owned(),
1219 path: inventory.license.path.clone(),
1220 });
1221 }
1222
1223 if !inventory.ci.has_any_provider() {
1224 findings.push(RiskFinding {
1225 severity: RiskSeverity::Low,
1226 code: RiskCode::MissingCi,
1227 message: "No known CI workflow detected.".to_owned(),
1228 path: None,
1229 });
1230 }
1231
1232 if inventory.tests.test_files == 0 && inventory.tests.commands.is_empty() {
1233 findings.push(RiskFinding {
1234 severity: RiskSeverity::Medium,
1235 code: RiskCode::NoTestsDetected,
1236 message: "No test files or test commands detected.".to_owned(),
1237 path: None,
1238 });
1239 }
1240
1241 for summary in &inventory.dependencies.ecosystems {
1242 if summary.total > 0 && summary.lockfile.is_none() {
1243 findings.push(RiskFinding {
1244 severity: RiskSeverity::Low,
1245 code: RiskCode::ManifestWithoutLockfile,
1246 message: "Dependency manifest has entries but no lockfile was detected.".to_owned(),
1247 path: Some(summary.manifest.clone()),
1248 });
1249 }
1250 }
1251
1252 if inventory.files_scanned >= 1000
1253 && !inventory.hygiene.has_gitignore
1254 && !inventory.hygiene.has_ignore
1255 {
1256 findings.push(RiskFinding {
1257 severity: RiskSeverity::Info,
1258 code: RiskCode::LargeProjectWithoutIgnoreRules,
1259 message: "Large project scanned without .gitignore or .ignore rules.".to_owned(),
1260 path: None,
1261 });
1262 }
1263
1264 if multi_root.siblings.len() >= 2 {
1265 findings.push(RiskFinding {
1266 severity: RiskSeverity::Medium,
1267 code: RiskCode::MultipleVcsRootsFound,
1268 message: format!(
1269 "{} additional project root(s) detected under this path; consider `projd scan --recursive` or `projd discover`.",
1270 multi_root.siblings.len()
1271 ),
1272 path: None,
1273 });
1274 }
1275
1276 if !multi_root.nested.is_empty() {
1277 findings.push(RiskFinding {
1278 severity: RiskSeverity::Low,
1279 code: RiskCode::NestedVcsRoot,
1280 message: format!(
1281 "{} nested VCS root(s) detected (vendored or submodule); pass --nested-vcs to enumerate them.",
1282 multi_root.nested.len()
1283 ),
1284 path: multi_root.nested.first().cloned(),
1285 });
1286 }
1287
1288 RiskSummary::from_findings(findings)
1289}
1290
1291impl RiskSummary {
1292 fn from_findings(findings: Vec<RiskFinding>) -> Self {
1293 let total = findings.len();
1294 let high = findings
1295 .iter()
1296 .filter(|risk| risk.severity == RiskSeverity::High)
1297 .count();
1298 let medium = findings
1299 .iter()
1300 .filter(|risk| risk.severity == RiskSeverity::Medium)
1301 .count();
1302 let low = findings
1303 .iter()
1304 .filter(|risk| risk.severity == RiskSeverity::Low)
1305 .count();
1306 let info = findings
1307 .iter()
1308 .filter(|risk| risk.severity == RiskSeverity::Info)
1309 .count();
1310
1311 Self {
1312 findings,
1313 total,
1314 high,
1315 medium,
1316 low,
1317 info,
1318 }
1319 }
1320}
1321
1322impl RiskSeverity {
1323 fn as_str(self) -> &'static str {
1324 match self {
1325 Self::High => "high",
1326 Self::Medium => "medium",
1327 Self::Low => "low",
1328 Self::Info => "info",
1329 }
1330 }
1331}
1332
1333impl ProjectHealth {
1334 pub fn as_str(self) -> &'static str {
1335 match self {
1336 Self::Healthy => "healthy",
1337 Self::NeedsAttention => "needs-attention",
1338 Self::Risky => "risky",
1339 Self::Unknown => "unknown",
1340 }
1341 }
1342}
1343
1344impl HealthSignalKind {
1345 fn as_str(self) -> &'static str {
1346 match self {
1347 Self::Readme => "readme",
1348 Self::License => "license",
1349 Self::Ci => "ci",
1350 Self::Tests => "tests",
1351 Self::Lockfiles => "lockfiles",
1352 Self::Vcs => "vcs",
1353 Self::Activity => "activity",
1354 Self::Docs => "docs",
1355 }
1356 }
1357}
1358
1359impl HealthSignalStatus {
1360 fn as_str(self) -> &'static str {
1361 match self {
1362 Self::Pass => "pass",
1363 Self::Warn => "warn",
1364 Self::Info => "info",
1365 }
1366 }
1367}
1368
1369fn build_health_summary(
1370 inventory: &ScanInventory,
1371 risks: &RiskSummary,
1372 vcs: &VcsSummary,
1373) -> HealthSummary {
1374 let mut signals = Vec::new();
1375
1376 signals.push(HealthSignal {
1377 kind: HealthSignalKind::Readme,
1378 status: pass_warn(inventory.documentation.has_readme),
1379 score_delta: if inventory.documentation.has_readme {
1380 10
1381 } else {
1382 -10
1383 },
1384 evidence: if inventory.documentation.has_readme {
1385 "README detected".to_owned()
1386 } else {
1387 "no README file detected".to_owned()
1388 },
1389 });
1390 signals.push(HealthSignal {
1391 kind: HealthSignalKind::License,
1392 status: pass_warn(inventory.documentation.has_license),
1393 score_delta: if inventory.documentation.has_license {
1394 20
1395 } else {
1396 -20
1397 },
1398 evidence: match (&inventory.license.path, inventory.license.kind) {
1399 (Some(path), kind) => format!("{} ({})", path.display(), kind.as_str()),
1400 (None, LicenseKind::Missing) => "no license file detected".to_owned(),
1401 (None, kind) => kind.as_str().to_owned(),
1402 },
1403 });
1404 signals.push(HealthSignal {
1405 kind: HealthSignalKind::Ci,
1406 status: pass_warn(inventory.ci.has_any_provider()),
1407 score_delta: if inventory.ci.has_any_provider() {
1408 20
1409 } else {
1410 -15
1411 },
1412 evidence: if inventory.ci.providers.is_empty() {
1413 "no known CI provider detected".to_owned()
1414 } else {
1415 let mut providers = inventory
1416 .ci
1417 .providers
1418 .iter()
1419 .map(|provider| provider.provider.as_str())
1420 .collect::<Vec<_>>();
1421 providers.sort_unstable();
1422 providers.dedup();
1423 providers.join(", ")
1424 },
1425 });
1426 let has_tests = inventory.tests.test_files > 0 || !inventory.tests.commands.is_empty();
1427 signals.push(HealthSignal {
1428 kind: HealthSignalKind::Tests,
1429 status: pass_warn(has_tests),
1430 score_delta: if has_tests { 20 } else { -20 },
1431 evidence: format!(
1432 "{} test file(s), {} command source(s)",
1433 inventory.tests.test_files,
1434 inventory.tests.commands.len()
1435 ),
1436 });
1437
1438 let lockfiles_ok = lockfiles_ok(&inventory.dependencies);
1439 signals.push(HealthSignal {
1440 kind: HealthSignalKind::Lockfiles,
1441 status: pass_warn(lockfiles_ok),
1442 score_delta: if lockfiles_ok { 10 } else { -10 },
1443 evidence: lockfile_evidence(&inventory.dependencies),
1444 });
1445
1446 let (vcs_status, vcs_delta, vcs_evidence) = if !vcs.is_repository {
1447 (
1448 HealthSignalStatus::Info,
1449 0,
1450 "no source control detected".to_owned(),
1451 )
1452 } else if vcs.is_dirty {
1453 (
1454 HealthSignalStatus::Warn,
1455 5,
1456 format!(
1457 "{} {}, dirty, {} modified, {} untracked",
1458 vcs.kind.label(),
1459 vcs.branch.as_deref().unwrap_or("unknown ref"),
1460 vcs.tracked_modified_files,
1461 vcs.untracked_files
1462 ),
1463 )
1464 } else {
1465 (
1466 HealthSignalStatus::Pass,
1467 15,
1468 format!(
1469 "{} {}, clean",
1470 vcs.kind.label(),
1471 vcs.branch.as_deref().unwrap_or("unknown ref")
1472 ),
1473 )
1474 };
1475 signals.push(HealthSignal {
1476 kind: HealthSignalKind::Vcs,
1477 status: vcs_status,
1478 score_delta: vcs_delta,
1479 evidence: vcs_evidence,
1480 });
1481
1482 let (activity_status, activity_delta, activity_evidence) =
1483 match vcs.activity.days_since_last_commit {
1484 None => (HealthSignalStatus::Info, 0, "activity unknown".to_owned()),
1485 Some(days) if days < 90 => (
1486 HealthSignalStatus::Pass,
1487 10,
1488 format!("active, last commit {days} day(s) ago"),
1489 ),
1490 Some(days) if days < 365 => (
1491 HealthSignalStatus::Warn,
1492 -5,
1493 format!("stale, last commit {days} day(s) ago"),
1494 ),
1495 Some(days) => (
1496 HealthSignalStatus::Warn,
1497 -10,
1498 format!("dormant, last commit {days} day(s) ago"),
1499 ),
1500 };
1501 signals.push(HealthSignal {
1502 kind: HealthSignalKind::Activity,
1503 status: activity_status,
1504 score_delta: activity_delta,
1505 evidence: activity_evidence,
1506 });
1507
1508 signals.push(HealthSignal {
1509 kind: HealthSignalKind::Docs,
1510 status: if inventory.documentation.has_docs_dir {
1511 HealthSignalStatus::Pass
1512 } else {
1513 HealthSignalStatus::Info
1514 },
1515 score_delta: if inventory.documentation.has_docs_dir {
1516 5
1517 } else {
1518 0
1519 },
1520 evidence: if inventory.documentation.has_docs_dir {
1521 "docs directory detected".to_owned()
1522 } else {
1523 "no docs directory detected".to_owned()
1524 },
1525 });
1526
1527 let positive_score = signals
1528 .iter()
1529 .filter(|signal| signal.score_delta > 0)
1530 .map(|signal| signal.score_delta as usize)
1531 .sum::<usize>();
1532 let score = positive_score
1533 .saturating_sub(risks.high * 20)
1534 .saturating_sub(risks.medium * 8)
1535 .saturating_sub(risks.low * 3)
1536 .min(100);
1537 let risk_level = overall_risk_level(risks);
1538 let grade = project_health_grade(score, risk_level, inventory.files_scanned);
1539
1540 HealthSummary {
1541 grade,
1542 score,
1543 risk_level,
1544 signals,
1545 }
1546}
1547
1548fn pass_warn(pass: bool) -> HealthSignalStatus {
1549 if pass {
1550 HealthSignalStatus::Pass
1551 } else {
1552 HealthSignalStatus::Warn
1553 }
1554}
1555
1556fn lockfiles_ok(dependencies: &DependencySummary) -> bool {
1557 dependencies
1558 .ecosystems
1559 .iter()
1560 .all(|summary| summary.total == 0 || summary.lockfile.is_some())
1561}
1562
1563fn lockfile_evidence(dependencies: &DependencySummary) -> String {
1564 if dependencies.ecosystems.is_empty() {
1565 return "no dependency manifests".to_owned();
1566 }
1567
1568 let lockfiles = dependencies
1569 .ecosystems
1570 .iter()
1571 .filter(|summary| summary.lockfile.is_some())
1572 .count();
1573 let missing = dependencies
1574 .ecosystems
1575 .iter()
1576 .filter(|summary| summary.total > 0 && summary.lockfile.is_none())
1577 .count();
1578
1579 format!(
1580 "{} manifest(s), {} lockfile(s), {} missing",
1581 dependencies.total_manifests, lockfiles, missing
1582 )
1583}
1584
1585fn overall_risk_level(risks: &RiskSummary) -> RiskSeverity {
1586 if risks.high > 0 {
1587 RiskSeverity::High
1588 } else if risks.medium > 0 {
1589 RiskSeverity::Medium
1590 } else if risks.low > 0 {
1591 RiskSeverity::Low
1592 } else {
1593 RiskSeverity::Info
1594 }
1595}
1596
1597fn project_health_grade(
1598 score: usize,
1599 risk_level: RiskSeverity,
1600 files_scanned: usize,
1601) -> ProjectHealth {
1602 if files_scanned == 0 {
1603 return ProjectHealth::Unknown;
1604 }
1605
1606 match (score, risk_level) {
1607 (_, RiskSeverity::High) => ProjectHealth::Risky,
1608 (80..=100, RiskSeverity::Info | RiskSeverity::Low) => ProjectHealth::Healthy,
1609 (50..=79, _) | (80..=100, RiskSeverity::Medium) => ProjectHealth::NeedsAttention,
1610 _ => ProjectHealth::Risky,
1611 }
1612}
1613
1614impl RiskCode {
1615 fn as_str(self) -> &'static str {
1616 match self {
1617 Self::MissingReadme => "missing-readme",
1618 Self::MissingLicense => "missing-license",
1619 Self::MissingCi => "missing-ci",
1620 Self::NoTestsDetected => "no-tests-detected",
1621 Self::ManifestWithoutLockfile => "manifest-without-lockfile",
1622 Self::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
1623 Self::UnknownLicense => "unknown-license",
1624 Self::MultipleVcsRootsFound => "multiple-vcs-roots-found",
1625 Self::NestedVcsRoot => "nested-vcs-root",
1626 }
1627 }
1628}
1629
1630impl LicenseKind {
1631 fn as_str(self) -> &'static str {
1632 match self {
1633 Self::Mit => "MIT",
1634 Self::Apache2 => "Apache-2.0",
1635 Self::Gpl => "GPL",
1636 Self::Bsd => "BSD",
1637 Self::Unknown => "unknown",
1638 Self::Missing => "missing",
1639 }
1640 }
1641}
1642
1643impl CiProvider {
1644 fn as_str(self) -> &'static str {
1645 match self {
1646 Self::GithubActions => "GitHub Actions",
1647 Self::GiteeGo => "Gitee Go",
1648 Self::GitlabCi => "GitLab CI",
1649 Self::CircleCi => "CircleCI",
1650 Self::Jenkins => "Jenkins",
1651 }
1652 }
1653}
1654
1655fn add_rust_test_command(tests: &mut TestSummary, source: &Path) {
1656 add_test_command(
1657 tests,
1658 TestCommand {
1659 ecosystem: TestEcosystem::Rust,
1660 source: source.to_path_buf(),
1661 name: "cargo test".to_owned(),
1662 command: "cargo test".to_owned(),
1663 },
1664 );
1665}
1666
1667fn add_node_test_commands(tests: &mut TestSummary, source: &Path) {
1668 let Some(value) = read_json_value(source) else {
1669 return;
1670 };
1671 let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) else {
1672 return;
1673 };
1674
1675 for (name, command) in scripts {
1676 if name == "test" || name.starts_with("test:") {
1677 if let Some(command) = command.as_str() {
1678 add_test_command(
1679 tests,
1680 TestCommand {
1681 ecosystem: TestEcosystem::Node,
1682 source: source.to_path_buf(),
1683 name: name.to_owned(),
1684 command: command.to_owned(),
1685 },
1686 );
1687 }
1688 }
1689 }
1690}
1691
1692fn add_python_test_command(tests: &mut TestSummary, source: &Path) {
1693 let Some(value) = read_toml_value(source) else {
1694 return;
1695 };
1696
1697 let has_pytest_config = value
1698 .get("tool")
1699 .and_then(|tool| tool.get("pytest"))
1700 .and_then(|pytest| pytest.get("ini_options"))
1701 .is_some();
1702
1703 if has_pytest_config {
1704 add_test_command(
1705 tests,
1706 TestCommand {
1707 ecosystem: TestEcosystem::Python,
1708 source: source.to_path_buf(),
1709 name: "pytest".to_owned(),
1710 command: "pytest".to_owned(),
1711 },
1712 );
1713 }
1714}
1715
1716fn add_cmake_test_command(tests: &mut TestSummary, source: &Path) {
1717 let Ok(content) = fs::read_to_string(source) else {
1718 return;
1719 };
1720 let lower = content.to_ascii_lowercase();
1721
1722 if lower.contains("enable_testing") || lower.contains("add_test") {
1723 add_test_command(
1724 tests,
1725 TestCommand {
1726 ecosystem: TestEcosystem::CMake,
1727 source: source.to_path_buf(),
1728 name: "ctest".to_owned(),
1729 command: "ctest".to_owned(),
1730 },
1731 );
1732 }
1733}
1734
1735fn add_test_command(tests: &mut TestSummary, command: TestCommand) {
1736 if tests.commands.iter().any(|existing| {
1737 existing.ecosystem == command.ecosystem
1738 && existing.source == command.source
1739 && existing.name == command.name
1740 && existing.command == command.command
1741 }) {
1742 return;
1743 }
1744
1745 tests.commands.push(command);
1746}
1747
1748fn summarize_cargo_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1749 let value = read_toml_value(manifest)?;
1750 let normal = count_toml_table_entries(value.get("dependencies"));
1751 let development = count_toml_table_entries(value.get("dev-dependencies"));
1752 let build = count_toml_table_entries(value.get("build-dependencies"));
1753 let optional = count_optional_cargo_dependencies(value.get("dependencies"));
1754 let total = normal + development + build;
1755
1756 Some(DependencyEcosystemSummary {
1757 ecosystem: DependencyEcosystem::Rust,
1758 source: DependencySource::CargoToml,
1759 manifest: manifest.to_path_buf(),
1760 lockfile: find_nearest_lockfile(manifest, &["Cargo.lock"]),
1761 normal,
1762 development,
1763 build,
1764 optional,
1765 total,
1766 })
1767}
1768
1769fn summarize_node_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1770 let value = read_json_value(manifest)?;
1771 let normal = count_json_object_entries(value.get("dependencies"));
1772 let development = count_json_object_entries(value.get("devDependencies"));
1773 let optional = count_json_object_entries(value.get("optionalDependencies"));
1774 let total = normal + development + optional;
1775
1776 Some(DependencyEcosystemSummary {
1777 ecosystem: DependencyEcosystem::Node,
1778 source: DependencySource::PackageJson,
1779 manifest: manifest.to_path_buf(),
1780 lockfile: find_sibling_lockfile(
1781 manifest,
1782 &[
1783 "pnpm-lock.yaml",
1784 "yarn.lock",
1785 "package-lock.json",
1786 "bun.lockb",
1787 ],
1788 ),
1789 normal,
1790 development,
1791 build: 0,
1792 optional,
1793 total,
1794 })
1795}
1796
1797fn summarize_pyproject_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1798 let value = read_toml_value(manifest)?;
1799 let project = value.get("project");
1800 let normal = project
1801 .and_then(|project| project.get("dependencies"))
1802 .and_then(|dependencies| dependencies.as_array())
1803 .map_or(0, Vec::len);
1804 let optional = project
1805 .and_then(|project| project.get("optional-dependencies"))
1806 .and_then(|dependencies| dependencies.as_table())
1807 .map(|groups| {
1808 groups
1809 .values()
1810 .filter_map(|value| value.as_array())
1811 .map(Vec::len)
1812 .sum()
1813 })
1814 .unwrap_or(0);
1815 let development = project
1816 .and_then(|project| project.get("optional-dependencies"))
1817 .and_then(|dependencies| dependencies.get("dev"))
1818 .and_then(|dependencies| dependencies.as_array())
1819 .map_or(0, Vec::len);
1820 let total = normal + optional;
1821
1822 Some(DependencyEcosystemSummary {
1823 ecosystem: DependencyEcosystem::Python,
1824 source: DependencySource::PyprojectToml,
1825 manifest: manifest.to_path_buf(),
1826 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1827 normal,
1828 development,
1829 build: 0,
1830 optional,
1831 total,
1832 })
1833}
1834
1835fn summarize_requirements_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
1836 let content = fs::read_to_string(manifest).ok()?;
1837 let normal = content
1838 .lines()
1839 .map(str::trim)
1840 .filter(|line| is_requirement_entry(line))
1841 .count();
1842
1843 Some(DependencyEcosystemSummary {
1844 ecosystem: DependencyEcosystem::Python,
1845 source: DependencySource::RequirementsTxt,
1846 manifest: manifest.to_path_buf(),
1847 lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
1848 normal,
1849 development: 0,
1850 build: 0,
1851 optional: 0,
1852 total: normal,
1853 })
1854}
1855
1856fn count_toml_table_entries(value: Option<&toml::Value>) -> usize {
1857 value
1858 .and_then(|value| value.as_table())
1859 .map_or(0, |table| table.len())
1860}
1861
1862fn count_optional_cargo_dependencies(value: Option<&toml::Value>) -> usize {
1863 value
1864 .and_then(|value| value.as_table())
1865 .map(|dependencies| {
1866 dependencies
1867 .values()
1868 .filter(|value| {
1869 value
1870 .as_table()
1871 .and_then(|table| table.get("optional"))
1872 .and_then(|optional| optional.as_bool())
1873 .unwrap_or(false)
1874 })
1875 .count()
1876 })
1877 .unwrap_or(0)
1878}
1879
1880fn count_json_object_entries(value: Option<&serde_json::Value>) -> usize {
1881 value
1882 .and_then(|value| value.as_object())
1883 .map_or(0, |object| object.len())
1884}
1885
1886fn is_requirement_entry(line: &str) -> bool {
1887 !line.is_empty() && !line.starts_with('#') && !line.starts_with('-') && !line.starts_with("--")
1888}
1889
1890fn find_nearest_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1891 let mut current = manifest.parent();
1892 while let Some(dir) = current {
1893 if let Some(lockfile) = find_lockfile_in_dir(dir, names) {
1894 return Some(lockfile);
1895 }
1896 current = dir.parent();
1897 }
1898
1899 None
1900}
1901
1902fn find_sibling_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
1903 find_lockfile_in_dir(manifest.parent()?, names)
1904}
1905
1906fn find_lockfile_in_dir(dir: &Path, names: &[&str]) -> Option<PathBuf> {
1907 names
1908 .iter()
1909 .map(|name| dir.join(name))
1910 .find(|path| path.is_file())
1911}
1912
1913fn detect_vcs_summary(root: &Path, opts: &ScanOptions) -> VcsSummary {
1914 if root.join(".git").exists() {
1915 return detect_git_into(root, opts);
1916 }
1917 if root.join(".hg").is_dir() {
1918 return detect_hg_into(root);
1919 }
1920 if root.join(".svn").is_dir() {
1921 return detect_svn_into(root);
1922 }
1923 if root.join(".fslckout").is_file() || root.join("_FOSSIL_").is_file() {
1924 return VcsSummary {
1925 kind: VcsKind::Fossil,
1926 is_repository: true,
1927 root: Some(root.to_path_buf()),
1928 ..VcsSummary::default()
1929 };
1930 }
1931 if root.join(".bzr").is_dir() {
1932 return VcsSummary {
1933 kind: VcsKind::Bzr,
1934 is_repository: true,
1935 root: Some(root.to_path_buf()),
1936 ..VcsSummary::default()
1937 };
1938 }
1939 VcsSummary::default()
1940}
1941
1942fn detect_git_into(root: &Path, opts: &ScanOptions) -> VcsSummary {
1943 let detected_root = run_vcs_output("git", root, &["rev-parse", "--show-toplevel"])
1944 .and_then(|output| output.lines().next().map(PathBuf::from))
1945 .or_else(|| Some(root.to_path_buf()));
1946
1947 let branch = run_vcs_output("git", root, &["branch", "--show-current"])
1948 .and_then(|output| non_empty_trimmed(&output));
1949 let revision = run_vcs_output("git", root, &["rev-parse", "HEAD"])
1950 .and_then(|output| non_empty_trimmed(&output));
1951 let last_commit = run_vcs_output("git", root, &["log", "-1", "--format=%cI"])
1952 .and_then(|output| non_empty_trimmed(&output));
1953 let porcelain = run_vcs_output("git", root, &["status", "--porcelain"]).unwrap_or_default();
1954 let mut tracked_modified_files = 0;
1955 let mut untracked_files = 0;
1956
1957 for line in porcelain.lines() {
1958 if line.starts_with("??") {
1959 untracked_files += 1;
1960 } else if !line.trim().is_empty() {
1961 tracked_modified_files += 1;
1962 }
1963 }
1964
1965 let activity = detect_git_activity(root, opts);
1966
1967 VcsSummary {
1968 kind: VcsKind::Git,
1969 is_repository: true,
1970 root: detected_root,
1971 branch,
1972 revision,
1973 last_commit,
1974 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
1975 tracked_modified_files,
1976 untracked_files,
1977 activity,
1978 }
1979}
1980
1981fn detect_git_activity(root: &Path, opts: &ScanOptions) -> VcsActivity {
1982 let last_commit_ts = run_vcs_output("git", root, &["log", "-1", "--format=%ct"])
1983 .and_then(|s| s.trim().parse::<i64>().ok());
1984 let days_since_last_commit = last_commit_ts.and_then(|ts| {
1985 let now = std::time::SystemTime::now()
1986 .duration_since(std::time::UNIX_EPOCH)
1987 .ok()?
1988 .as_secs() as i64;
1989 let delta = (now - ts).max(0);
1990 Some((delta / 86_400) as u32)
1991 });
1992
1993 let commits_last_90d = run_vcs_output(
1994 "git",
1995 root,
1996 &["rev-list", "--count", "--since=90 days ago", "HEAD"],
1997 )
1998 .and_then(|s| s.trim().parse::<u32>().ok());
1999
2000 let contributors_count =
2001 run_vcs_output("git", root, &["shortlog", "-sne", "HEAD"]).map(|output| {
2002 output
2003 .lines()
2004 .filter(|line| !line.trim().is_empty())
2005 .count() as u32
2006 });
2007
2008 let first_commit_date = run_vcs_output(
2009 "git",
2010 root,
2011 &["log", "--max-parents=0", "--format=%aI", "HEAD"],
2012 )
2013 .and_then(|output| output.lines().next().map(|s| s.trim().to_owned()))
2014 .filter(|s| !s.is_empty());
2015
2016 let contributors = if opts.detailed_contributors {
2017 parse_git_shortlog(
2018 &run_vcs_output("git", root, &["shortlog", "-sne", "HEAD"]).unwrap_or_default(),
2019 )
2020 } else {
2021 Vec::new()
2022 };
2023
2024 VcsActivity {
2025 days_since_last_commit,
2026 commits_last_90d,
2027 contributors_count,
2028 first_commit_date,
2029 contributors,
2030 }
2031}
2032
2033fn parse_git_shortlog(text: &str) -> Vec<VcsContributor> {
2034 let mut out = Vec::new();
2035 for line in text.lines() {
2036 let trimmed = line.trim();
2037 if trimmed.is_empty() {
2038 continue;
2039 }
2040 let mut parts = trimmed.splitn(2, char::is_whitespace);
2042 let count_str = parts.next().unwrap_or("");
2043 let rest = parts.next().unwrap_or("").trim();
2044 let Ok(count) = count_str.parse::<u32>() else {
2045 continue;
2046 };
2047 let (name, email) = match (rest.rfind('<'), rest.rfind('>')) {
2048 (Some(open), Some(close)) if open < close => {
2049 let name = rest[..open].trim().to_owned();
2050 let email = rest[open + 1..close].trim().to_owned();
2051 (name, email)
2052 }
2053 _ => (rest.to_owned(), String::new()),
2054 };
2055 out.push(VcsContributor {
2056 name,
2057 email,
2058 commit_count: count,
2059 });
2060 }
2061 out
2062}
2063
2064fn detect_hg_into(root: &Path) -> VcsSummary {
2065 let detected_root = run_vcs_output("hg", root, &["root"])
2066 .and_then(|output| output.lines().next().map(PathBuf::from))
2067 .or_else(|| Some(root.to_path_buf()));
2068
2069 let branch = run_vcs_output("hg", root, &["branch"]).and_then(|s| non_empty_trimmed(&s));
2070 let revision = run_vcs_output("hg", root, &["log", "-r", ".", "--template", "{node}"])
2071 .and_then(|s| non_empty_trimmed(&s));
2072 let last_commit = run_vcs_output(
2073 "hg",
2074 root,
2075 &["log", "-r", ".", "--template", "{date|isodatesec}"],
2076 )
2077 .and_then(|s| non_empty_trimmed(&s));
2078
2079 let status_text = run_vcs_output("hg", root, &["status"]).unwrap_or_default();
2080 let mut tracked_modified_files = 0;
2081 let mut untracked_files = 0;
2082 for line in status_text.lines() {
2083 let mut chars = line.chars();
2084 match chars.next() {
2085 Some('?') => untracked_files += 1,
2086 Some(c) if "MARC!".contains(c) => tracked_modified_files += 1,
2087 _ => {}
2088 }
2089 }
2090
2091 VcsSummary {
2092 kind: VcsKind::Hg,
2093 is_repository: true,
2094 root: detected_root,
2095 branch,
2096 revision,
2097 last_commit,
2098 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
2099 tracked_modified_files,
2100 untracked_files,
2101 activity: VcsActivity::default(),
2102 }
2103}
2104
2105fn detect_svn_into(root: &Path) -> VcsSummary {
2106 let detected_root = run_vcs_output("svn", root, &["info", "--show-item", "wc-root"])
2107 .and_then(|s| non_empty_trimmed(&s))
2108 .map(PathBuf::from)
2109 .or_else(|| Some(root.to_path_buf()));
2110 let branch = run_vcs_output("svn", root, &["info", "--show-item", "url"])
2111 .and_then(|s| non_empty_trimmed(&s));
2112 let revision = run_vcs_output("svn", root, &["info", "--show-item", "revision"])
2113 .and_then(|s| non_empty_trimmed(&s));
2114 let last_commit = run_vcs_output("svn", root, &["info", "--show-item", "last-changed-date"])
2115 .and_then(|s| non_empty_trimmed(&s));
2116
2117 let status_text = run_vcs_output("svn", root, &["status"]).unwrap_or_default();
2118 let mut tracked_modified_files = 0;
2119 let mut untracked_files = 0;
2120 for line in status_text.lines() {
2121 let mut chars = line.chars();
2122 match chars.next() {
2123 Some('?') => untracked_files += 1,
2124 Some(c) if "ADMRC!~".contains(c) => tracked_modified_files += 1,
2125 _ => {}
2126 }
2127 }
2128
2129 VcsSummary {
2130 kind: VcsKind::Svn,
2131 is_repository: true,
2132 root: detected_root,
2133 branch,
2134 revision,
2135 last_commit,
2136 is_dirty: tracked_modified_files > 0 || untracked_files > 0,
2137 tracked_modified_files,
2138 untracked_files,
2139 activity: VcsActivity::default(),
2140 }
2141}
2142
2143fn run_vcs_output(program: &str, root: &Path, args: &[&str]) -> Option<String> {
2144 let output = Command::new(program)
2145 .args(args)
2146 .current_dir(root)
2147 .output()
2148 .ok()?;
2149
2150 if !output.status.success() {
2151 return None;
2152 }
2153
2154 Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
2155}
2156
2157fn non_empty_trimmed(value: &str) -> Option<String> {
2158 let value = value.trim();
2159 if value.is_empty() {
2160 None
2161 } else {
2162 Some(value.to_owned())
2163 }
2164}
2165
2166fn detect_indicators(root: &Path) -> Vec<ProjectIndicator> {
2167 let checks = [
2168 ("Cargo.toml", IndicatorKind::RustWorkspace),
2169 (".git", IndicatorKind::GitRepository),
2170 ("README.md", IndicatorKind::Readme),
2171 ];
2172
2173 let mut indicators = Vec::new();
2174 for (relative, kind) in checks {
2175 let path = root.join(relative);
2176 if path.exists() {
2177 indicators.push(ProjectIndicator { kind, path });
2178 }
2179 }
2180
2181 let cargo_manifest = root.join("Cargo.toml");
2182 if cargo_manifest.exists() {
2183 indicators.push(ProjectIndicator {
2184 kind: IndicatorKind::RustPackage,
2185 path: cargo_manifest,
2186 });
2187 }
2188
2189 indicators
2190}
2191
2192fn scan_inventory(root: &Path) -> Result<ScanInventory> {
2193 let mut inventory = MutableInventory::default();
2194 let ignore_matcher = IgnoreMatcher::new(root);
2195 walk_project(root, &ignore_matcher, &mut inventory)?;
2196
2197 Ok(ScanInventory {
2198 languages: inventory.languages,
2199 build_systems: inventory.build_systems,
2200 code: inventory.code,
2201 dependencies: build_dependency_summary(inventory.dependency_manifests),
2202 tests: build_test_summary(inventory.tests, inventory.test_command_sources),
2203 hygiene: inventory.hygiene,
2204 documentation: inventory.documentation,
2205 license: build_license_summary(&inventory.license_files),
2206 ci: inventory.ci,
2207 containers: inventory.containers,
2208 files_scanned: inventory.files_scanned,
2209 skipped_dirs: inventory.skipped_dirs,
2210 })
2211}
2212
2213fn walk_project(
2214 path: &Path,
2215 ignore_matcher: &IgnoreMatcher,
2216 inventory: &mut MutableInventory,
2217) -> Result<()> {
2218 let local_ignore_matcher = ignore_matcher.with_child_ignores(path);
2219 let mut entries = Vec::new();
2220
2221 for entry in
2222 fs::read_dir(path).with_context(|| format!("failed to read `{}`", path.display()))?
2223 {
2224 let entry =
2225 entry.with_context(|| format!("failed to read entry under `{}`", path.display()))?;
2226 entries.push(entry);
2227 }
2228
2229 entries.sort_by_key(|entry| entry.file_name());
2230
2231 for entry in entries {
2232 let entry_path = entry.path();
2233 let file_type = entry
2234 .file_type()
2235 .with_context(|| format!("failed to inspect `{}`", entry_path.display()))?;
2236
2237 if file_type.is_dir() {
2238 if should_skip_dir(entry.file_name().as_os_str())
2239 || local_ignore_matcher.is_ignored(&entry_path, true)
2240 {
2241 inventory.add_skipped_dir(entry_path);
2242 continue;
2243 }
2244
2245 observe_directory(&entry_path, inventory);
2246 walk_project(&entry_path, &local_ignore_matcher, inventory)?;
2247 continue;
2248 }
2249
2250 if file_type.is_file() {
2251 if local_ignore_matcher.is_ignored(&entry_path, false) {
2252 continue;
2253 }
2254
2255 inventory.files_scanned += 1;
2256 observe_file(&entry_path, inventory);
2257 }
2258 }
2259
2260 Ok(())
2261}
2262
2263fn should_skip_dir(name: &OsStr) -> bool {
2264 matches!(
2265 name.to_str(),
2266 Some(".git" | ".hg" | ".svn" | "target" | "node_modules")
2267 )
2268}
2269
2270#[derive(Clone)]
2271struct IgnoreMatcher {
2272 matchers: Vec<Gitignore>,
2273}
2274
2275impl IgnoreMatcher {
2276 fn new(root: &Path) -> Self {
2277 Self {
2278 matchers: Self::build_matchers(root),
2279 }
2280 }
2281
2282 fn with_child_ignores(&self, dir: &Path) -> Self {
2283 let mut matchers = self.matchers.clone();
2284 matchers.extend(Self::build_matchers(dir));
2285 Self { matchers }
2286 }
2287
2288 fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
2289 self.matchers
2290 .iter()
2291 .rev()
2292 .find_map(|matcher| {
2293 let result = matcher.matched(path, is_dir);
2294 if result.is_none() {
2295 None
2296 } else {
2297 Some(result.is_ignore())
2298 }
2299 })
2300 .unwrap_or(false)
2301 }
2302
2303 fn build_matchers(dir: &Path) -> Vec<Gitignore> {
2304 [".gitignore", ".ignore"]
2305 .into_iter()
2306 .filter_map(|file_name| Self::build_matcher(dir, file_name))
2307 .collect()
2308 }
2309
2310 fn build_matcher(dir: &Path, file_name: &str) -> Option<Gitignore> {
2311 let path = dir.join(file_name);
2312 if !path.is_file() {
2313 return None;
2314 }
2315
2316 let mut builder = GitignoreBuilder::new(dir);
2317 let _ = builder.add(&path);
2318 builder.build().ok()
2319 }
2320}
2321
2322fn observe_directory(path: &Path, inventory: &mut MutableInventory) {
2323 if path.file_name().and_then(|value| value.to_str()) == Some("docs") {
2324 inventory.documentation.has_docs_dir = true;
2325 }
2326 if is_test_directory(path) {
2327 add_test_directory(&mut inventory.tests, path.to_path_buf());
2328 }
2329}
2330
2331fn observe_file(path: &Path, inventory: &mut MutableInventory) {
2332 if let Some(kind) = language_for_path(path) {
2333 inventory.add_language(kind);
2334 inventory.add_code_file(kind, path);
2335 }
2336
2337 if let Some(kind) = build_system_for_path(path) {
2338 inventory.add_build_system(kind, path.to_path_buf());
2339 }
2340 if is_dependency_manifest(path) {
2341 inventory.add_dependency_manifest(path.to_path_buf());
2342 }
2343 if is_test_file(path) {
2344 inventory.tests.test_files += 1;
2345 }
2346 if is_test_command_source(path) {
2347 inventory.add_test_command_source(path.to_path_buf());
2348 }
2349 observe_hygiene_file(path, &mut inventory.hygiene);
2350
2351 observe_documentation_file(path, &mut inventory.documentation);
2352 if is_license_file(path) {
2353 inventory.add_license_file(path.to_path_buf());
2354 }
2355 observe_ci_file(path, &mut inventory.ci);
2356 observe_container_file(path, &mut inventory.containers);
2357}
2358
2359fn language_for_path(path: &Path) -> Option<LanguageKind> {
2360 let extension = path
2361 .extension()
2362 .and_then(|value| value.to_str())
2363 .map(str::to_ascii_lowercase)?;
2364
2365 match extension.as_str() {
2366 "rs" => Some(LanguageKind::Rust),
2367 "ts" | "tsx" => Some(LanguageKind::TypeScript),
2368 "js" | "jsx" | "mjs" | "cjs" => Some(LanguageKind::JavaScript),
2369 "py" => Some(LanguageKind::Python),
2370 "c" => Some(LanguageKind::C),
2371 "cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some(LanguageKind::Cpp),
2372 "go" => Some(LanguageKind::Go),
2373 _ => None,
2374 }
2375}
2376
2377fn count_code_lines(path: &Path, kind: LanguageKind) -> CodeLineCounts {
2378 let Ok(content) = fs::read_to_string(path) else {
2379 return CodeLineCounts::default();
2380 };
2381
2382 let mut counts = CodeLineCounts::default();
2383 let mut in_block_comment = false;
2384
2385 for line in content.lines() {
2386 counts.total_lines += 1;
2387 let trimmed = line.trim();
2388
2389 if trimmed.is_empty() {
2390 counts.blank_lines += 1;
2391 continue;
2392 }
2393
2394 let classification = classify_code_line(trimmed, kind, &mut in_block_comment);
2395 if classification.has_comment {
2396 counts.comment_lines += 1;
2397 }
2398 if classification.has_code {
2399 counts.code_lines += 1;
2400 }
2401 }
2402
2403 counts
2404}
2405
2406#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2407struct CodeLineClassification {
2408 has_code: bool,
2409 has_comment: bool,
2410}
2411
2412fn classify_code_line(
2413 mut line: &str,
2414 kind: LanguageKind,
2415 in_block_comment: &mut bool,
2416) -> CodeLineClassification {
2417 if !supports_c_style_block_comments(kind) {
2418 return classify_simple_comment_line(line, kind);
2419 }
2420
2421 let mut classification = CodeLineClassification::default();
2422
2423 loop {
2424 if *in_block_comment {
2425 classification.has_comment = true;
2426 if let Some(end) = line.find("*/") {
2427 *in_block_comment = false;
2428 line = &line[end + 2..];
2429 if line.trim().is_empty() {
2430 break;
2431 }
2432 continue;
2433 }
2434 break;
2435 }
2436
2437 let current = line.trim_start();
2438 if current.is_empty() {
2439 break;
2440 }
2441 if current.starts_with("//") {
2442 classification.has_comment = true;
2443 break;
2444 }
2445
2446 let block_start = current.find("/*");
2447 let line_comment_start = current.find("//");
2448
2449 match (block_start, line_comment_start) {
2450 (Some(block), Some(comment)) if comment < block => {
2451 classification.has_comment = true;
2452 if !current[..comment].trim().is_empty() {
2453 classification.has_code = true;
2454 }
2455 break;
2456 }
2457 (Some(block), _) => {
2458 classification.has_comment = true;
2459 if !current[..block].trim().is_empty() {
2460 classification.has_code = true;
2461 }
2462 let after_block_start = ¤t[block + 2..];
2463 if let Some(end) = after_block_start.find("*/") {
2464 line = &after_block_start[end + 2..];
2465 if line.trim().is_empty() {
2466 break;
2467 }
2468 continue;
2469 }
2470 *in_block_comment = true;
2471 break;
2472 }
2473 (None, Some(comment)) => {
2474 classification.has_comment = true;
2475 if !current[..comment].trim().is_empty() {
2476 classification.has_code = true;
2477 }
2478 break;
2479 }
2480 (None, None) => {
2481 classification.has_code = true;
2482 break;
2483 }
2484 }
2485 }
2486
2487 classification
2488}
2489
2490fn classify_simple_comment_line(line: &str, kind: LanguageKind) -> CodeLineClassification {
2491 let markers = simple_comment_markers(kind);
2492
2493 if markers.iter().any(|marker| line.starts_with(marker)) {
2494 CodeLineClassification {
2495 has_code: false,
2496 has_comment: true,
2497 }
2498 } else {
2499 CodeLineClassification {
2500 has_code: true,
2501 has_comment: markers
2502 .iter()
2503 .any(|marker| line.find(marker).is_some_and(|index| index > 0)),
2504 }
2505 }
2506}
2507
2508fn supports_c_style_block_comments(kind: LanguageKind) -> bool {
2509 matches!(
2510 kind,
2511 LanguageKind::Rust
2512 | LanguageKind::TypeScript
2513 | LanguageKind::JavaScript
2514 | LanguageKind::C
2515 | LanguageKind::Cpp
2516 | LanguageKind::Go
2517 )
2518}
2519
2520fn simple_comment_markers(kind: LanguageKind) -> &'static [&'static str] {
2521 match kind {
2522 LanguageKind::Python => &["#"],
2523 LanguageKind::Rust
2524 | LanguageKind::TypeScript
2525 | LanguageKind::JavaScript
2526 | LanguageKind::C
2527 | LanguageKind::Cpp
2528 | LanguageKind::Go => &["//"],
2529 }
2530}
2531
2532fn build_system_for_path(path: &Path) -> Option<BuildSystemKind> {
2533 let file_name = path.file_name()?.to_str()?;
2534
2535 match file_name {
2536 "Cargo.toml" => Some(BuildSystemKind::Cargo),
2537 "package.json" => Some(BuildSystemKind::NodePackage),
2538 "pyproject.toml" => Some(BuildSystemKind::PythonProject),
2539 "requirements.txt" => Some(BuildSystemKind::PythonRequirements),
2540 "CMakeLists.txt" => Some(BuildSystemKind::CMake),
2541 "go.mod" => Some(BuildSystemKind::GoModule),
2542 _ => None,
2543 }
2544}
2545
2546fn is_dependency_manifest(path: &Path) -> bool {
2547 matches!(
2548 path.file_name().and_then(|value| value.to_str()),
2549 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "requirements.txt")
2550 )
2551}
2552
2553fn is_test_command_source(path: &Path) -> bool {
2554 matches!(
2555 path.file_name().and_then(|value| value.to_str()),
2556 Some("Cargo.toml" | "package.json" | "pyproject.toml" | "CMakeLists.txt")
2557 )
2558}
2559
2560fn is_test_directory(path: &Path) -> bool {
2561 matches!(
2562 path.file_name().and_then(|value| value.to_str()),
2563 Some("tests" | "test" | "__tests__" | "spec")
2564 )
2565}
2566
2567fn add_test_directory(tests: &mut TestSummary, path: PathBuf) {
2568 tests.has_tests_dir = true;
2569 if tests
2570 .test_directories
2571 .iter()
2572 .any(|existing| existing == &path)
2573 {
2574 return;
2575 }
2576
2577 tests.test_directories.push(path);
2578}
2579
2580fn is_test_file(path: &Path) -> bool {
2581 let file_name = path
2582 .file_name()
2583 .and_then(|value| value.to_str())
2584 .unwrap_or_default()
2585 .to_ascii_lowercase();
2586 let extension = path
2587 .extension()
2588 .and_then(|value| value.to_str())
2589 .unwrap_or_default()
2590 .to_ascii_lowercase();
2591
2592 if is_under_named_dir(path, "tests") || is_under_named_dir(path, "__tests__") {
2593 return matches!(
2594 extension.as_str(),
2595 "rs" | "js" | "jsx" | "ts" | "tsx" | "py"
2596 );
2597 }
2598
2599 file_name.ends_with("_test.rs")
2600 || file_name.ends_with(".test.js")
2601 || file_name.ends_with(".test.jsx")
2602 || file_name.ends_with(".test.ts")
2603 || file_name.ends_with(".test.tsx")
2604 || file_name.ends_with(".spec.js")
2605 || file_name.ends_with(".spec.jsx")
2606 || file_name.ends_with(".spec.ts")
2607 || file_name.ends_with(".spec.tsx")
2608 || (file_name.starts_with("test_") && file_name.ends_with(".py"))
2609 || file_name.ends_with("_test.py")
2610 || file_name.ends_with("_test.cpp")
2611 || file_name.ends_with("_test.cc")
2612 || file_name.ends_with("_test.cxx")
2613 || file_name.ends_with("_tests.cpp")
2614}
2615
2616fn is_under_named_dir(path: &Path, directory_name: &str) -> bool {
2617 path.components()
2618 .any(|component| component == Component::Normal(OsStr::new(directory_name)))
2619}
2620
2621fn observe_documentation_file(path: &Path, documentation: &mut DocumentationSummary) {
2622 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2623 return;
2624 };
2625 let file_name = file_name.to_ascii_lowercase();
2626
2627 if file_name == "readme.md" || file_name == "readme" {
2628 documentation.has_readme = true;
2629 }
2630 if file_name == "license" || file_name.starts_with("license.") {
2631 documentation.has_license = true;
2632 }
2633}
2634
2635fn is_license_file(path: &Path) -> bool {
2636 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2637 return false;
2638 };
2639 let file_name = file_name.to_ascii_lowercase();
2640 file_name == "license" || file_name.starts_with("license.")
2641}
2642
2643fn build_license_summary(files: &[PathBuf]) -> LicenseSummary {
2644 let Some(path) = files.first() else {
2645 return LicenseSummary {
2646 path: None,
2647 kind: LicenseKind::Missing,
2648 };
2649 };
2650
2651 let content = fs::read_to_string(path).unwrap_or_default();
2652 LicenseSummary {
2653 path: Some(path.clone()),
2654 kind: detect_license_kind(&content),
2655 }
2656}
2657
2658fn detect_license_kind(content: &str) -> LicenseKind {
2659 let lower = content.to_ascii_lowercase();
2660 let normalized = lower.trim();
2661
2662 if normalized == "mit"
2663 || normalized == "mit license"
2664 || lower.contains("mit license")
2665 || lower.contains("permission is hereby granted")
2666 {
2667 LicenseKind::Mit
2668 } else if normalized == "apache-2.0"
2669 || normalized == "apache 2.0"
2670 || lower.contains("apache license")
2671 || lower.contains("apache-2.0")
2672 || lower.contains("www.apache.org/licenses/license-2.0")
2673 {
2674 LicenseKind::Apache2
2675 } else if normalized.starts_with("gpl")
2676 || lower.contains("gnu general public license")
2677 || lower.contains("gpl")
2678 {
2679 LicenseKind::Gpl
2680 } else if normalized.starts_with("bsd")
2681 || (lower.contains("bsd") && lower.contains("redistribution and use"))
2682 {
2683 LicenseKind::Bsd
2684 } else {
2685 LicenseKind::Unknown
2686 }
2687}
2688
2689fn observe_hygiene_file(path: &Path, hygiene: &mut ScanHygiene) {
2690 match path.file_name().and_then(|value| value.to_str()) {
2691 Some(".gitignore") => hygiene.has_gitignore = true,
2692 Some(".ignore") => hygiene.has_ignore = true,
2693 _ => {}
2694 }
2695}
2696
2697fn observe_ci_file(path: &Path, ci: &mut CiSummary) {
2698 if has_component_pair(path, ".github", "workflows") {
2699 ci.add_provider(CiProvider::GithubActions, path.to_path_buf());
2700 }
2701 if has_component(path, ".workflow") {
2702 ci.add_provider(CiProvider::GiteeGo, path.to_path_buf());
2703 }
2704 if path.file_name().and_then(|value| value.to_str()) == Some(".gitlab-ci.yml") {
2705 ci.add_provider(CiProvider::GitlabCi, path.to_path_buf());
2706 }
2707 if has_component(path, ".circleci") {
2708 ci.add_provider(CiProvider::CircleCi, path.to_path_buf());
2709 }
2710 if path.file_name().and_then(|value| value.to_str()) == Some("Jenkinsfile") {
2711 ci.add_provider(CiProvider::Jenkins, path.to_path_buf());
2712 }
2713}
2714
2715fn has_component(path: &Path, name: &str) -> bool {
2716 path.components()
2717 .any(|component| component == Component::Normal(OsStr::new(name)))
2718}
2719
2720fn has_component_pair(path: &Path, first: &str, second: &str) -> bool {
2721 let mut components = path.components();
2722 while let Some(component) = components.next() {
2723 if component == Component::Normal(OsStr::new(first))
2724 && components.next() == Some(Component::Normal(OsStr::new(second)))
2725 {
2726 return true;
2727 }
2728 }
2729
2730 false
2731}
2732
2733fn observe_container_file(path: &Path, containers: &mut ContainerSummary) {
2734 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
2735 return;
2736 };
2737 let file_name = file_name.to_ascii_lowercase();
2738
2739 if file_name == "dockerfile" || file_name.starts_with("dockerfile.") {
2740 containers.has_dockerfile = true;
2741 }
2742 if matches!(
2743 file_name.as_str(),
2744 "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml"
2745 ) {
2746 containers.has_compose_file = true;
2747 }
2748}
2749
2750#[derive(Default)]
2751struct MutableInventory {
2752 languages: Vec<LanguageSummary>,
2753 build_systems: Vec<BuildSystemSummary>,
2754 code: CodeSummary,
2755 dependency_manifests: Vec<PathBuf>,
2756 tests: TestSummary,
2757 test_command_sources: Vec<PathBuf>,
2758 hygiene: ScanHygiene,
2759 documentation: DocumentationSummary,
2760 license_files: Vec<PathBuf>,
2761 ci: CiSummary,
2762 containers: ContainerSummary,
2763 files_scanned: usize,
2764 skipped_dirs: Vec<PathBuf>,
2765}
2766
2767impl MutableInventory {
2768 fn add_language(&mut self, kind: LanguageKind) {
2769 if let Some(language) = self
2770 .languages
2771 .iter_mut()
2772 .find(|language| language.kind == kind)
2773 {
2774 language.files += 1;
2775 return;
2776 }
2777
2778 self.languages.push(LanguageSummary { kind, files: 1 });
2779 }
2780
2781 fn add_code_file(&mut self, kind: LanguageKind, path: &Path) {
2782 let counts = count_code_lines(path, kind);
2783 self.code.total_files += 1;
2784 self.code.total_lines += counts.total_lines;
2785 self.code.code_lines += counts.code_lines;
2786 self.code.comment_lines += counts.comment_lines;
2787 self.code.blank_lines += counts.blank_lines;
2788
2789 if let Some(language) = self
2790 .code
2791 .languages
2792 .iter_mut()
2793 .find(|language| language.kind == kind)
2794 {
2795 language.files += 1;
2796 language.total_lines += counts.total_lines;
2797 language.code_lines += counts.code_lines;
2798 language.comment_lines += counts.comment_lines;
2799 language.blank_lines += counts.blank_lines;
2800 return;
2801 }
2802
2803 self.code.languages.push(CodeLanguageSummary {
2804 kind,
2805 files: 1,
2806 total_lines: counts.total_lines,
2807 code_lines: counts.code_lines,
2808 comment_lines: counts.comment_lines,
2809 blank_lines: counts.blank_lines,
2810 });
2811 }
2812
2813 fn add_build_system(&mut self, kind: BuildSystemKind, path: PathBuf) {
2814 if self
2815 .build_systems
2816 .iter()
2817 .any(|build_system| build_system.kind == kind && build_system.path == path)
2818 {
2819 return;
2820 }
2821
2822 self.build_systems.push(BuildSystemSummary { kind, path });
2823 }
2824
2825 fn add_dependency_manifest(&mut self, path: PathBuf) {
2826 if self
2827 .dependency_manifests
2828 .iter()
2829 .any(|existing| existing == &path)
2830 {
2831 return;
2832 }
2833
2834 self.dependency_manifests.push(path);
2835 }
2836
2837 fn add_test_command_source(&mut self, path: PathBuf) {
2838 if self
2839 .test_command_sources
2840 .iter()
2841 .any(|existing| existing == &path)
2842 {
2843 return;
2844 }
2845
2846 self.test_command_sources.push(path);
2847 }
2848
2849 fn add_license_file(&mut self, path: PathBuf) {
2850 if self.license_files.iter().any(|existing| existing == &path) {
2851 return;
2852 }
2853
2854 self.license_files.push(path);
2855 }
2856
2857 fn add_skipped_dir(&mut self, path: PathBuf) {
2858 if self.skipped_dirs.iter().any(|existing| existing == &path) {
2859 return;
2860 }
2861
2862 self.skipped_dirs.push(path);
2863 }
2864}
2865
2866fn build_report(
2867 project_name: &str,
2868 indicators: &[ProjectIndicator],
2869 inventory: &ScanInventory,
2870 health: &HealthSummary,
2871) -> ProjectReport {
2872 let summary = if indicators.is_empty() {
2873 format!("{project_name} was scanned, but no known project markers were detected yet.")
2874 } else {
2875 format!(
2876 "{project_name} was scanned across {} file(s), with {} project marker(s), {} language(s), {} build system(s), and {} health detected.",
2877 inventory.files_scanned,
2878 indicators.len(),
2879 inventory.languages.len(),
2880 inventory.build_systems.len(),
2881 health.grade.as_str()
2882 )
2883 };
2884
2885 ProjectReport {
2886 summary,
2887 next_steps: vec![
2888 "Add language, dependency, and repository activity scanners.".to_owned(),
2889 "Keep CLI and GUI features backed by projd-core data structures.".to_owned(),
2890 "Export Markdown and JSON reports before adding richer GUI visualizations.".to_owned(),
2891 ],
2892 }
2893}
2894
2895fn yes_no(value: bool) -> &'static str {
2896 if value { "yes" } else { "no" }
2897}
2898
2899#[cfg(test)]
2900mod tests {
2901 use super::*;
2902
2903 #[test]
2904 fn renders_markdown_without_indicators() {
2905 let scan = ProjectScan {
2906 root: PathBuf::from("example"),
2907 project_name: "example".to_owned(),
2908 identity: ProjectIdentity {
2909 name: "example".to_owned(),
2910 version: None,
2911 kind: ProjectKind::Generic,
2912 source: IdentitySource::DirectoryName,
2913 },
2914 indicators: Vec::new(),
2915 languages: Vec::new(),
2916 build_systems: Vec::new(),
2917 code: CodeSummary::default(),
2918 dependencies: DependencySummary::default(),
2919 tests: TestSummary::default(),
2920 hygiene: ScanHygiene::default(),
2921 risks: RiskSummary::default(),
2922 health: HealthSummary {
2923 grade: ProjectHealth::Unknown,
2924 score: 0,
2925 risk_level: RiskSeverity::Info,
2926 signals: Vec::new(),
2927 },
2928 documentation: DocumentationSummary::default(),
2929 license: LicenseSummary::default(),
2930 ci: CiSummary::default(),
2931 vcs: VcsSummary::default(),
2932 containers: ContainerSummary::default(),
2933 files_scanned: 0,
2934 skipped_dirs: Vec::new(),
2935 report: ProjectReport {
2936 summary: "example summary".to_owned(),
2937 next_steps: vec!["next".to_owned()],
2938 },
2939 };
2940
2941 let rendered = render_markdown(&scan);
2942
2943 assert!(rendered.contains("# Project Report"));
2944 assert!(rendered.contains("example summary"));
2945 assert!(rendered.contains("No known project indicators"));
2946 }
2947}