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