use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use serde::{Deserialize, Serialize};
pub const NAME: &str = "Projd";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn describe() -> &'static str {
"Scan software projects and generate structured reports."
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ProjectScan {
pub root: PathBuf,
pub project_name: String,
pub identity: ProjectIdentity,
pub indicators: Vec<ProjectIndicator>,
pub languages: Vec<LanguageSummary>,
pub build_systems: Vec<BuildSystemSummary>,
pub code: CodeSummary,
pub dependencies: DependencySummary,
pub tests: TestSummary,
pub hygiene: ScanHygiene,
pub risks: RiskSummary,
pub health: HealthSummary,
pub documentation: DocumentationSummary,
pub license: LicenseSummary,
pub ci: CiSummary,
pub git: GitSummary,
pub containers: ContainerSummary,
pub files_scanned: usize,
pub skipped_dirs: Vec<PathBuf>,
pub report: ProjectReport,
}
impl ProjectScan {
pub fn has_language(&self, kind: LanguageKind) -> bool {
self.languages.iter().any(|language| language.kind == kind)
}
pub fn has_build_system(&self, kind: BuildSystemKind) -> bool {
self.build_systems
.iter()
.any(|build_system| build_system.kind == kind)
}
pub fn has_risk(&self, code: RiskCode) -> bool {
self.risks.findings.iter().any(|risk| risk.code == code)
}
pub fn risk(&self, code: RiskCode) -> Option<&RiskFinding> {
self.risks.findings.iter().find(|risk| risk.code == code)
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ProjectIdentity {
pub name: String,
pub version: Option<String>,
pub kind: ProjectKind,
pub source: IdentitySource,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectKind {
RustWorkspace,
RustPackage,
NodePackage,
PythonProject,
Generic,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IdentitySource {
CargoToml,
PackageJson,
PyprojectToml,
DirectoryName,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ProjectIndicator {
pub kind: IndicatorKind,
pub path: PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum IndicatorKind {
RustWorkspace,
RustPackage,
GitRepository,
Readme,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct LanguageSummary {
pub kind: LanguageKind,
pub files: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LanguageKind {
Rust,
TypeScript,
JavaScript,
Python,
C,
Cpp,
Go,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct BuildSystemSummary {
pub kind: BuildSystemKind,
pub path: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BuildSystemKind {
Cargo,
NodePackage,
PythonProject,
PythonRequirements,
CMake,
GoModule,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct CodeSummary {
pub languages: Vec<CodeLanguageSummary>,
pub total_files: usize,
pub total_lines: usize,
pub code_lines: usize,
pub comment_lines: usize,
pub blank_lines: usize,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct CodeLanguageSummary {
pub kind: LanguageKind,
pub files: usize,
pub total_lines: usize,
pub code_lines: usize,
pub comment_lines: usize,
pub blank_lines: usize,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct CodeLineCounts {
total_lines: usize,
code_lines: usize,
comment_lines: usize,
blank_lines: usize,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct DependencySummary {
pub ecosystems: Vec<DependencyEcosystemSummary>,
pub total_manifests: usize,
pub total_dependencies: usize,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct DependencyEcosystemSummary {
pub ecosystem: DependencyEcosystem,
pub source: DependencySource,
pub manifest: PathBuf,
pub lockfile: Option<PathBuf>,
pub normal: usize,
pub development: usize,
pub build: usize,
pub optional: usize,
pub total: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DependencyEcosystem {
Rust,
Node,
Python,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DependencySource {
CargoToml,
PackageJson,
PyprojectToml,
RequirementsTxt,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct TestSummary {
pub has_tests_dir: bool,
pub test_files: usize,
pub test_directories: Vec<PathBuf>,
pub commands: Vec<TestCommand>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct TestCommand {
pub ecosystem: TestEcosystem,
pub source: PathBuf,
pub name: String,
pub command: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TestEcosystem {
Rust,
Node,
Python,
CMake,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ScanHygiene {
pub has_gitignore: bool,
pub has_ignore: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct RiskSummary {
pub findings: Vec<RiskFinding>,
pub total: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
pub info: usize,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct RiskFinding {
pub severity: RiskSeverity,
pub code: RiskCode,
pub message: String,
pub path: Option<PathBuf>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct HealthSummary {
pub grade: ProjectHealth,
pub score: usize,
pub risk_level: RiskSeverity,
pub signals: Vec<HealthSignal>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectHealth {
Healthy,
NeedsAttention,
Risky,
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct HealthSignal {
pub kind: HealthSignalKind,
pub status: HealthSignalStatus,
pub score_delta: i32,
pub evidence: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HealthSignalKind {
Readme,
License,
Ci,
Tests,
Lockfiles,
Git,
Docs,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HealthSignalStatus {
Pass,
Warn,
Info,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RiskSeverity {
High,
Medium,
Low,
Info,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RiskCode {
MissingReadme,
MissingLicense,
MissingCi,
NoTestsDetected,
ManifestWithoutLockfile,
LargeProjectWithoutIgnoreRules,
UnknownLicense,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct DocumentationSummary {
pub has_readme: bool,
pub has_license: bool,
pub has_docs_dir: bool,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct LicenseSummary {
pub path: Option<PathBuf>,
pub kind: LicenseKind,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LicenseKind {
Mit,
Apache2,
Gpl,
Bsd,
#[default]
Unknown,
Missing,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct CiSummary {
pub has_github_actions: bool,
pub has_gitee_go: bool,
pub has_gitlab_ci: bool,
pub has_circle_ci: bool,
pub has_jenkins: bool,
pub providers: Vec<CiProviderSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct CiProviderSummary {
pub provider: CiProvider,
pub path: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CiProvider {
GithubActions,
GiteeGo,
GitlabCi,
CircleCi,
Jenkins,
}
impl CiSummary {
pub fn has_provider(&self, provider: CiProvider) -> bool {
match provider {
CiProvider::GithubActions => self.has_github_actions,
CiProvider::GiteeGo => self.has_gitee_go,
CiProvider::GitlabCi => self.has_gitlab_ci,
CiProvider::CircleCi => self.has_circle_ci,
CiProvider::Jenkins => self.has_jenkins,
}
}
fn has_any_provider(&self) -> bool {
!self.providers.is_empty()
|| self.has_github_actions
|| self.has_gitee_go
|| self.has_gitlab_ci
|| self.has_circle_ci
|| self.has_jenkins
}
fn add_provider(&mut self, provider: CiProvider, path: PathBuf) {
match provider {
CiProvider::GithubActions => self.has_github_actions = true,
CiProvider::GiteeGo => self.has_gitee_go = true,
CiProvider::GitlabCi => self.has_gitlab_ci = true,
CiProvider::CircleCi => self.has_circle_ci = true,
CiProvider::Jenkins => self.has_jenkins = true,
}
if self
.providers
.iter()
.any(|existing| existing.provider == provider && existing.path == path)
{
return;
}
self.providers.push(CiProviderSummary { provider, path });
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct GitSummary {
pub is_repository: bool,
pub root: Option<PathBuf>,
pub branch: Option<String>,
pub last_commit: Option<String>,
pub is_dirty: bool,
pub tracked_modified_files: usize,
pub untracked_files: usize,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct ContainerSummary {
pub has_dockerfile: bool,
pub has_compose_file: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct ProjectReport {
pub summary: String,
pub next_steps: Vec<String>,
}
pub fn scan_path(path: impl AsRef<Path>) -> Result<ProjectScan> {
let root = path.as_ref();
let metadata =
fs::metadata(root).with_context(|| format!("failed to inspect `{}`", root.display()))?;
if !metadata.is_dir() {
bail!("project path must be a directory: `{}`", root.display());
}
let root = root
.canonicalize()
.with_context(|| format!("failed to resolve `{}`", root.display()))?;
let project_name = root
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("project")
.to_owned();
let identity = detect_identity(&root, &project_name);
let indicators = detect_indicators(&root);
let inventory = scan_inventory(&root)?;
let risks = build_risk_summary(&inventory);
let git = detect_git_summary(&root);
let health = build_health_summary(&inventory, &risks, &git);
let report = build_report(&identity.name, &indicators, &inventory, &health);
Ok(ProjectScan {
root,
project_name: identity.name.clone(),
identity,
indicators,
languages: inventory.languages,
build_systems: inventory.build_systems,
code: inventory.code,
dependencies: inventory.dependencies,
tests: inventory.tests,
hygiene: inventory.hygiene,
risks,
health,
documentation: inventory.documentation,
license: inventory.license,
ci: inventory.ci,
git,
containers: inventory.containers,
files_scanned: inventory.files_scanned,
skipped_dirs: inventory.skipped_dirs,
report,
})
}
pub fn render_markdown(scan: &ProjectScan) -> String {
let mut output = String::new();
output.push_str("# Project Report\n\n");
output.push_str(&format!("Project: `{}`\n\n", scan.project_name));
output.push_str(&format!("Root: `{}`\n\n", scan.root.display()));
output.push_str("## Identity\n\n");
output.push_str(&format!("- Name: `{}`\n", scan.identity.name));
output.push_str(&format!(
"- Version: {}\n",
scan.identity
.version
.as_deref()
.map(|version| format!("`{version}`"))
.unwrap_or_else(|| "unknown".to_owned())
));
output.push_str(&format!("- Kind: {:?}\n", scan.identity.kind));
output.push_str(&format!("- Source: {:?}\n\n", scan.identity.source));
output.push_str("## Summary\n\n");
output.push_str(&scan.report.summary);
output.push_str("\n\n## Health\n\n");
output.push_str(&format!("- Grade: `{}`\n", scan.health.grade.as_str()));
output.push_str(&format!("- Score: {}/100\n", scan.health.score));
output.push_str(&format!(
"- Risk level: `{}`\n",
scan.health.risk_level.as_str()
));
output.push_str("- Signals:\n");
for signal in &scan.health.signals {
output.push_str(&format!(
" - {}: {} ({:+}) - {}\n",
signal.kind.as_str(),
signal.status.as_str(),
signal.score_delta,
signal.evidence
));
}
output.push_str("\n## Scan Observations\n\n");
output.push_str(&format!("- Files scanned: {}\n", scan.files_scanned));
output.push_str(&format!(
"- Skipped directories: {}\n",
scan.skipped_dirs.len()
));
output.push_str(&format!(
"- `.gitignore`: {}\n",
yes_no(scan.hygiene.has_gitignore)
));
output.push_str(&format!(
"- `.ignore`: {}\n",
yes_no(scan.hygiene.has_ignore)
));
output.push_str("\n## Indicators\n\n");
if scan.indicators.is_empty() {
output.push_str("- No known project indicators detected.\n");
} else {
for indicator in &scan.indicators {
output.push_str(&format!(
"- {:?}: `{}`\n",
indicator.kind,
indicator.path.display()
));
}
}
output.push_str("\n## Languages\n\n");
if scan.languages.is_empty() {
output.push_str("- No source language files detected.\n");
} else {
for language in &scan.languages {
output.push_str(&format!(
"- {:?}: {} file(s)\n",
language.kind, language.files
));
}
}
output.push_str("\n## Code Statistics\n\n");
output.push_str(&format!(
"- Total source files: {}\n",
scan.code.total_files
));
output.push_str(&format!("- Total lines: {}\n", scan.code.total_lines));
output.push_str(&format!("- Code lines: {}\n", scan.code.code_lines));
output.push_str(&format!("- Comment lines: {}\n", scan.code.comment_lines));
output.push_str(&format!("- Blank lines: {}\n", scan.code.blank_lines));
if scan.code.languages.is_empty() {
output.push_str("- No code statistics detected.\n");
} else {
output.push_str("- By language:\n");
for language in &scan.code.languages {
output.push_str(&format!(
" - {:?}: {} file(s), {} total, {} code, {} comment, {} blank\n",
language.kind,
language.files,
language.total_lines,
language.code_lines,
language.comment_lines,
language.blank_lines
));
}
}
output.push_str("\n## Build Systems\n\n");
if scan.build_systems.is_empty() {
output.push_str("- No known build systems detected.\n");
} else {
for build_system in &scan.build_systems {
output.push_str(&format!(
"- {:?}: `{}`\n",
build_system.kind,
build_system.path.display()
));
}
}
output.push_str("\n## Dependencies\n\n");
output.push_str(&format!(
"- Total manifests: {}\n",
scan.dependencies.total_manifests
));
output.push_str(&format!(
"- Total dependency entries: {}\n",
scan.dependencies.total_dependencies
));
if scan.dependencies.ecosystems.is_empty() {
output.push_str("- No dependency manifests detected.\n");
} else {
for summary in &scan.dependencies.ecosystems {
output.push_str(&format!(
"- {:?} {:?}: `{}` normal {}, dev {}, build {}, optional {}, total {}, lockfile {}\n",
summary.ecosystem,
summary.source,
summary.manifest.display(),
summary.normal,
summary.development,
summary.build,
summary.optional,
summary.total,
yes_no(summary.lockfile.is_some())
));
}
}
output.push_str("\n## Tests\n\n");
output.push_str(&format!(
"- Test directories: {}\n",
scan.tests.test_directories.len()
));
output.push_str(&format!("- Test files: {}\n", scan.tests.test_files));
if scan.tests.commands.is_empty() {
output.push_str("- Commands: none detected\n");
} else {
output.push_str("- Commands:\n");
for command in &scan.tests.commands {
output.push_str(&format!(
" - {:?}: {} (`{}`)\n",
command.ecosystem,
command.command,
command.source.display()
));
}
}
output.push_str("\n## Risks\n\n");
if scan.risks.findings.is_empty() {
output.push_str("- No risks detected by current rules.\n");
} else {
for risk in &scan.risks.findings {
output.push_str(&format!(
"- {} {}: {}",
risk.severity.as_str(),
risk.code.as_str(),
risk.message
));
if let Some(path) = &risk.path {
output.push_str(&format!(" (`{}`)", path.display()));
}
output.push('\n');
}
}
output.push_str("\n## Project Facilities\n\n");
output.push_str(&format!(
"- README: {}\n",
yes_no(scan.documentation.has_readme)
));
output.push_str(&format!(
"- License: {}\n",
yes_no(scan.documentation.has_license)
));
output.push_str(&format!("- License kind: {:?}\n", scan.license.kind));
output.push_str(&format!(
"- docs/: {}\n",
yes_no(scan.documentation.has_docs_dir)
));
output.push_str(&format!(
"- GitHub Actions: {}\n",
yes_no(scan.ci.has_github_actions)
));
output.push_str(&format!("- Gitee Go: {}\n", yes_no(scan.ci.has_gitee_go)));
output.push_str(&format!("- GitLab CI: {}\n", yes_no(scan.ci.has_gitlab_ci)));
output.push_str(&format!("- CircleCI: {}\n", yes_no(scan.ci.has_circle_ci)));
output.push_str(&format!("- Jenkins: {}\n", yes_no(scan.ci.has_jenkins)));
output.push_str(&format!(
"- Git repository: {}\n",
yes_no(scan.git.is_repository)
));
output.push_str(&format!(
"- Dockerfile: {}\n",
yes_no(scan.containers.has_dockerfile)
));
output.push_str(&format!(
"- Compose file: {}\n",
yes_no(scan.containers.has_compose_file)
));
output.push_str("\n## Next Steps\n\n");
for step in &scan.report.next_steps {
output.push_str(&format!("- {step}\n"));
}
output
}
pub fn render_json(scan: &ProjectScan) -> Result<String> {
serde_json::to_string_pretty(scan).context("failed to render scan result as json")
}
struct ScanInventory {
languages: Vec<LanguageSummary>,
build_systems: Vec<BuildSystemSummary>,
code: CodeSummary,
dependencies: DependencySummary,
tests: TestSummary,
hygiene: ScanHygiene,
documentation: DocumentationSummary,
license: LicenseSummary,
ci: CiSummary,
containers: ContainerSummary,
files_scanned: usize,
skipped_dirs: Vec<PathBuf>,
}
fn detect_identity(root: &Path, fallback_name: &str) -> ProjectIdentity {
detect_cargo_identity(root, fallback_name)
.or_else(|| detect_node_identity(root))
.or_else(|| detect_python_identity(root))
.unwrap_or_else(|| ProjectIdentity {
name: fallback_name.to_owned(),
version: None,
kind: ProjectKind::Generic,
source: IdentitySource::DirectoryName,
})
}
fn detect_cargo_identity(root: &Path, fallback_name: &str) -> Option<ProjectIdentity> {
let manifest = read_toml_value(&root.join("Cargo.toml"))?;
if let Some(package) = manifest.get("package") {
let name = package.get("name")?.as_str()?.to_owned();
let version = package
.get("version")
.and_then(|value| value.as_str())
.map(str::to_owned);
return Some(ProjectIdentity {
name,
version,
kind: ProjectKind::RustPackage,
source: IdentitySource::CargoToml,
});
}
if manifest.get("workspace").is_some() {
let version = manifest
.get("workspace")
.and_then(|workspace| workspace.get("package"))
.and_then(|package| package.get("version"))
.and_then(|value| value.as_str())
.map(str::to_owned);
return Some(ProjectIdentity {
name: fallback_name.to_owned(),
version,
kind: ProjectKind::RustWorkspace,
source: IdentitySource::CargoToml,
});
}
None
}
fn detect_node_identity(root: &Path) -> Option<ProjectIdentity> {
let manifest = read_json_value(&root.join("package.json"))?;
let name = manifest.get("name")?.as_str()?.to_owned();
let version = manifest
.get("version")
.and_then(|value| value.as_str())
.map(str::to_owned);
Some(ProjectIdentity {
name,
version,
kind: ProjectKind::NodePackage,
source: IdentitySource::PackageJson,
})
}
fn detect_python_identity(root: &Path) -> Option<ProjectIdentity> {
let manifest = read_toml_value(&root.join("pyproject.toml"))?;
let project = manifest.get("project")?;
let name = project.get("name")?.as_str()?.to_owned();
let version = project
.get("version")
.and_then(|value| value.as_str())
.map(str::to_owned);
Some(ProjectIdentity {
name,
version,
kind: ProjectKind::PythonProject,
source: IdentitySource::PyprojectToml,
})
}
fn read_toml_value(path: &Path) -> Option<toml::Value> {
let content = fs::read_to_string(path).ok()?;
toml::from_str(&content).ok()
}
fn read_json_value(path: &Path) -> Option<serde_json::Value> {
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn build_dependency_summary(manifests: Vec<PathBuf>) -> DependencySummary {
let mut ecosystems = Vec::new();
for manifest in manifests {
let Some(file_name) = manifest.file_name().and_then(|value| value.to_str()) else {
continue;
};
let summary = match file_name {
"Cargo.toml" => summarize_cargo_dependencies(&manifest),
"package.json" => summarize_node_dependencies(&manifest),
"pyproject.toml" => summarize_pyproject_dependencies(&manifest),
"requirements.txt" => summarize_requirements_dependencies(&manifest),
_ => None,
};
if let Some(summary) = summary {
ecosystems.push(summary);
}
}
let total_manifests = ecosystems.len();
let total_dependencies = ecosystems.iter().map(|summary| summary.total).sum();
DependencySummary {
ecosystems,
total_manifests,
total_dependencies,
}
}
fn build_test_summary(mut tests: TestSummary, sources: Vec<PathBuf>) -> TestSummary {
for source in sources {
let Some(file_name) = source.file_name().and_then(|value| value.to_str()) else {
continue;
};
match file_name {
"Cargo.toml" => add_rust_test_command(&mut tests, &source),
"package.json" => add_node_test_commands(&mut tests, &source),
"pyproject.toml" => add_python_test_command(&mut tests, &source),
"CMakeLists.txt" => add_cmake_test_command(&mut tests, &source),
_ => {}
}
}
tests
}
fn build_risk_summary(inventory: &ScanInventory) -> RiskSummary {
let mut findings = Vec::new();
if !inventory.documentation.has_readme {
findings.push(RiskFinding {
severity: RiskSeverity::Medium,
code: RiskCode::MissingReadme,
message: "No README file detected.".to_owned(),
path: None,
});
}
if !inventory.documentation.has_license {
findings.push(RiskFinding {
severity: RiskSeverity::Medium,
code: RiskCode::MissingLicense,
message: "No LICENSE file detected.".to_owned(),
path: None,
});
} else if inventory.license.kind == LicenseKind::Unknown {
findings.push(RiskFinding {
severity: RiskSeverity::Low,
code: RiskCode::UnknownLicense,
message: "License file was detected, but the license type was not recognized."
.to_owned(),
path: inventory.license.path.clone(),
});
}
if !inventory.ci.has_any_provider() {
findings.push(RiskFinding {
severity: RiskSeverity::Low,
code: RiskCode::MissingCi,
message: "No known CI workflow detected.".to_owned(),
path: None,
});
}
if inventory.tests.test_files == 0 && inventory.tests.commands.is_empty() {
findings.push(RiskFinding {
severity: RiskSeverity::Medium,
code: RiskCode::NoTestsDetected,
message: "No test files or test commands detected.".to_owned(),
path: None,
});
}
for summary in &inventory.dependencies.ecosystems {
if summary.total > 0 && summary.lockfile.is_none() {
findings.push(RiskFinding {
severity: RiskSeverity::Low,
code: RiskCode::ManifestWithoutLockfile,
message: "Dependency manifest has entries but no lockfile was detected.".to_owned(),
path: Some(summary.manifest.clone()),
});
}
}
if inventory.files_scanned >= 1000
&& !inventory.hygiene.has_gitignore
&& !inventory.hygiene.has_ignore
{
findings.push(RiskFinding {
severity: RiskSeverity::Info,
code: RiskCode::LargeProjectWithoutIgnoreRules,
message: "Large project scanned without .gitignore or .ignore rules.".to_owned(),
path: None,
});
}
RiskSummary::from_findings(findings)
}
impl RiskSummary {
fn from_findings(findings: Vec<RiskFinding>) -> Self {
let total = findings.len();
let high = findings
.iter()
.filter(|risk| risk.severity == RiskSeverity::High)
.count();
let medium = findings
.iter()
.filter(|risk| risk.severity == RiskSeverity::Medium)
.count();
let low = findings
.iter()
.filter(|risk| risk.severity == RiskSeverity::Low)
.count();
let info = findings
.iter()
.filter(|risk| risk.severity == RiskSeverity::Info)
.count();
Self {
findings,
total,
high,
medium,
low,
info,
}
}
}
impl RiskSeverity {
fn as_str(self) -> &'static str {
match self {
Self::High => "high",
Self::Medium => "medium",
Self::Low => "low",
Self::Info => "info",
}
}
}
impl ProjectHealth {
pub fn as_str(self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::NeedsAttention => "needs-attention",
Self::Risky => "risky",
Self::Unknown => "unknown",
}
}
}
impl HealthSignalKind {
fn as_str(self) -> &'static str {
match self {
Self::Readme => "readme",
Self::License => "license",
Self::Ci => "ci",
Self::Tests => "tests",
Self::Lockfiles => "lockfiles",
Self::Git => "git",
Self::Docs => "docs",
}
}
}
impl HealthSignalStatus {
fn as_str(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Warn => "warn",
Self::Info => "info",
}
}
}
fn build_health_summary(
inventory: &ScanInventory,
risks: &RiskSummary,
git: &GitSummary,
) -> HealthSummary {
let mut signals = Vec::new();
signals.push(HealthSignal {
kind: HealthSignalKind::Readme,
status: pass_warn(inventory.documentation.has_readme),
score_delta: if inventory.documentation.has_readme {
10
} else {
-10
},
evidence: if inventory.documentation.has_readme {
"README detected".to_owned()
} else {
"no README file detected".to_owned()
},
});
signals.push(HealthSignal {
kind: HealthSignalKind::License,
status: pass_warn(inventory.documentation.has_license),
score_delta: if inventory.documentation.has_license {
20
} else {
-20
},
evidence: match (&inventory.license.path, inventory.license.kind) {
(Some(path), kind) => format!("{} ({})", path.display(), kind.as_str()),
(None, LicenseKind::Missing) => "no license file detected".to_owned(),
(None, kind) => kind.as_str().to_owned(),
},
});
signals.push(HealthSignal {
kind: HealthSignalKind::Ci,
status: pass_warn(inventory.ci.has_any_provider()),
score_delta: if inventory.ci.has_any_provider() {
20
} else {
-15
},
evidence: if inventory.ci.providers.is_empty() {
"no known CI provider detected".to_owned()
} else {
let mut providers = inventory
.ci
.providers
.iter()
.map(|provider| provider.provider.as_str())
.collect::<Vec<_>>();
providers.sort_unstable();
providers.dedup();
providers.join(", ")
},
});
let has_tests = inventory.tests.test_files > 0 || !inventory.tests.commands.is_empty();
signals.push(HealthSignal {
kind: HealthSignalKind::Tests,
status: pass_warn(has_tests),
score_delta: if has_tests { 20 } else { -20 },
evidence: format!(
"{} test file(s), {} command source(s)",
inventory.tests.test_files,
inventory.tests.commands.len()
),
});
let lockfiles_ok = lockfiles_ok(&inventory.dependencies);
signals.push(HealthSignal {
kind: HealthSignalKind::Lockfiles,
status: pass_warn(lockfiles_ok),
score_delta: if lockfiles_ok { 10 } else { -10 },
evidence: lockfile_evidence(&inventory.dependencies),
});
let (git_status, git_delta, git_evidence) = if !git.is_repository {
(
HealthSignalStatus::Info,
0,
"not a git repository".to_owned(),
)
} else if git.is_dirty {
(
HealthSignalStatus::Warn,
5,
format!(
"{}, dirty, {} modified, {} untracked",
git.branch.as_deref().unwrap_or("unknown branch"),
git.tracked_modified_files,
git.untracked_files
),
)
} else {
(
HealthSignalStatus::Pass,
15,
format!(
"{}, clean",
git.branch.as_deref().unwrap_or("unknown branch")
),
)
};
signals.push(HealthSignal {
kind: HealthSignalKind::Git,
status: git_status,
score_delta: git_delta,
evidence: git_evidence,
});
signals.push(HealthSignal {
kind: HealthSignalKind::Docs,
status: if inventory.documentation.has_docs_dir {
HealthSignalStatus::Pass
} else {
HealthSignalStatus::Info
},
score_delta: if inventory.documentation.has_docs_dir {
5
} else {
0
},
evidence: if inventory.documentation.has_docs_dir {
"docs directory detected".to_owned()
} else {
"no docs directory detected".to_owned()
},
});
let positive_score = signals
.iter()
.filter(|signal| signal.score_delta > 0)
.map(|signal| signal.score_delta as usize)
.sum::<usize>();
let score = positive_score
.saturating_sub(risks.high * 20)
.saturating_sub(risks.medium * 8)
.saturating_sub(risks.low * 3)
.min(100);
let risk_level = overall_risk_level(risks);
let grade = project_health_grade(score, risk_level, inventory.files_scanned);
HealthSummary {
grade,
score,
risk_level,
signals,
}
}
fn pass_warn(pass: bool) -> HealthSignalStatus {
if pass {
HealthSignalStatus::Pass
} else {
HealthSignalStatus::Warn
}
}
fn lockfiles_ok(dependencies: &DependencySummary) -> bool {
dependencies
.ecosystems
.iter()
.all(|summary| summary.total == 0 || summary.lockfile.is_some())
}
fn lockfile_evidence(dependencies: &DependencySummary) -> String {
if dependencies.ecosystems.is_empty() {
return "no dependency manifests".to_owned();
}
let lockfiles = dependencies
.ecosystems
.iter()
.filter(|summary| summary.lockfile.is_some())
.count();
let missing = dependencies
.ecosystems
.iter()
.filter(|summary| summary.total > 0 && summary.lockfile.is_none())
.count();
format!(
"{} manifest(s), {} lockfile(s), {} missing",
dependencies.total_manifests, lockfiles, missing
)
}
fn overall_risk_level(risks: &RiskSummary) -> RiskSeverity {
if risks.high > 0 {
RiskSeverity::High
} else if risks.medium > 0 {
RiskSeverity::Medium
} else if risks.low > 0 {
RiskSeverity::Low
} else {
RiskSeverity::Info
}
}
fn project_health_grade(
score: usize,
risk_level: RiskSeverity,
files_scanned: usize,
) -> ProjectHealth {
if files_scanned == 0 {
return ProjectHealth::Unknown;
}
match (score, risk_level) {
(_, RiskSeverity::High) => ProjectHealth::Risky,
(80..=100, RiskSeverity::Info | RiskSeverity::Low) => ProjectHealth::Healthy,
(50..=79, _) | (80..=100, RiskSeverity::Medium) => ProjectHealth::NeedsAttention,
_ => ProjectHealth::Risky,
}
}
impl RiskCode {
fn as_str(self) -> &'static str {
match self {
Self::MissingReadme => "missing-readme",
Self::MissingLicense => "missing-license",
Self::MissingCi => "missing-ci",
Self::NoTestsDetected => "no-tests-detected",
Self::ManifestWithoutLockfile => "manifest-without-lockfile",
Self::LargeProjectWithoutIgnoreRules => "large-project-without-ignore-rules",
Self::UnknownLicense => "unknown-license",
}
}
}
impl LicenseKind {
fn as_str(self) -> &'static str {
match self {
Self::Mit => "MIT",
Self::Apache2 => "Apache-2.0",
Self::Gpl => "GPL",
Self::Bsd => "BSD",
Self::Unknown => "unknown",
Self::Missing => "missing",
}
}
}
impl CiProvider {
fn as_str(self) -> &'static str {
match self {
Self::GithubActions => "GitHub Actions",
Self::GiteeGo => "Gitee Go",
Self::GitlabCi => "GitLab CI",
Self::CircleCi => "CircleCI",
Self::Jenkins => "Jenkins",
}
}
}
fn add_rust_test_command(tests: &mut TestSummary, source: &Path) {
add_test_command(
tests,
TestCommand {
ecosystem: TestEcosystem::Rust,
source: source.to_path_buf(),
name: "cargo test".to_owned(),
command: "cargo test".to_owned(),
},
);
}
fn add_node_test_commands(tests: &mut TestSummary, source: &Path) {
let Some(value) = read_json_value(source) else {
return;
};
let Some(scripts) = value.get("scripts").and_then(|value| value.as_object()) else {
return;
};
for (name, command) in scripts {
if name == "test" || name.starts_with("test:") {
if let Some(command) = command.as_str() {
add_test_command(
tests,
TestCommand {
ecosystem: TestEcosystem::Node,
source: source.to_path_buf(),
name: name.to_owned(),
command: command.to_owned(),
},
);
}
}
}
}
fn add_python_test_command(tests: &mut TestSummary, source: &Path) {
let Some(value) = read_toml_value(source) else {
return;
};
let has_pytest_config = value
.get("tool")
.and_then(|tool| tool.get("pytest"))
.and_then(|pytest| pytest.get("ini_options"))
.is_some();
if has_pytest_config {
add_test_command(
tests,
TestCommand {
ecosystem: TestEcosystem::Python,
source: source.to_path_buf(),
name: "pytest".to_owned(),
command: "pytest".to_owned(),
},
);
}
}
fn add_cmake_test_command(tests: &mut TestSummary, source: &Path) {
let Ok(content) = fs::read_to_string(source) else {
return;
};
let lower = content.to_ascii_lowercase();
if lower.contains("enable_testing") || lower.contains("add_test") {
add_test_command(
tests,
TestCommand {
ecosystem: TestEcosystem::CMake,
source: source.to_path_buf(),
name: "ctest".to_owned(),
command: "ctest".to_owned(),
},
);
}
}
fn add_test_command(tests: &mut TestSummary, command: TestCommand) {
if tests.commands.iter().any(|existing| {
existing.ecosystem == command.ecosystem
&& existing.source == command.source
&& existing.name == command.name
&& existing.command == command.command
}) {
return;
}
tests.commands.push(command);
}
fn summarize_cargo_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
let value = read_toml_value(manifest)?;
let normal = count_toml_table_entries(value.get("dependencies"));
let development = count_toml_table_entries(value.get("dev-dependencies"));
let build = count_toml_table_entries(value.get("build-dependencies"));
let optional = count_optional_cargo_dependencies(value.get("dependencies"));
let total = normal + development + build;
Some(DependencyEcosystemSummary {
ecosystem: DependencyEcosystem::Rust,
source: DependencySource::CargoToml,
manifest: manifest.to_path_buf(),
lockfile: find_nearest_lockfile(manifest, &["Cargo.lock"]),
normal,
development,
build,
optional,
total,
})
}
fn summarize_node_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
let value = read_json_value(manifest)?;
let normal = count_json_object_entries(value.get("dependencies"));
let development = count_json_object_entries(value.get("devDependencies"));
let optional = count_json_object_entries(value.get("optionalDependencies"));
let total = normal + development + optional;
Some(DependencyEcosystemSummary {
ecosystem: DependencyEcosystem::Node,
source: DependencySource::PackageJson,
manifest: manifest.to_path_buf(),
lockfile: find_sibling_lockfile(
manifest,
&[
"pnpm-lock.yaml",
"yarn.lock",
"package-lock.json",
"bun.lockb",
],
),
normal,
development,
build: 0,
optional,
total,
})
}
fn summarize_pyproject_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
let value = read_toml_value(manifest)?;
let project = value.get("project");
let normal = project
.and_then(|project| project.get("dependencies"))
.and_then(|dependencies| dependencies.as_array())
.map_or(0, Vec::len);
let optional = project
.and_then(|project| project.get("optional-dependencies"))
.and_then(|dependencies| dependencies.as_table())
.map(|groups| {
groups
.values()
.filter_map(|value| value.as_array())
.map(Vec::len)
.sum()
})
.unwrap_or(0);
let development = project
.and_then(|project| project.get("optional-dependencies"))
.and_then(|dependencies| dependencies.get("dev"))
.and_then(|dependencies| dependencies.as_array())
.map_or(0, Vec::len);
let total = normal + optional;
Some(DependencyEcosystemSummary {
ecosystem: DependencyEcosystem::Python,
source: DependencySource::PyprojectToml,
manifest: manifest.to_path_buf(),
lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
normal,
development,
build: 0,
optional,
total,
})
}
fn summarize_requirements_dependencies(manifest: &Path) -> Option<DependencyEcosystemSummary> {
let content = fs::read_to_string(manifest).ok()?;
let normal = content
.lines()
.map(str::trim)
.filter(|line| is_requirement_entry(line))
.count();
Some(DependencyEcosystemSummary {
ecosystem: DependencyEcosystem::Python,
source: DependencySource::RequirementsTxt,
manifest: manifest.to_path_buf(),
lockfile: find_sibling_lockfile(manifest, &["uv.lock", "poetry.lock", "pdm.lock"]),
normal,
development: 0,
build: 0,
optional: 0,
total: normal,
})
}
fn count_toml_table_entries(value: Option<&toml::Value>) -> usize {
value
.and_then(|value| value.as_table())
.map_or(0, |table| table.len())
}
fn count_optional_cargo_dependencies(value: Option<&toml::Value>) -> usize {
value
.and_then(|value| value.as_table())
.map(|dependencies| {
dependencies
.values()
.filter(|value| {
value
.as_table()
.and_then(|table| table.get("optional"))
.and_then(|optional| optional.as_bool())
.unwrap_or(false)
})
.count()
})
.unwrap_or(0)
}
fn count_json_object_entries(value: Option<&serde_json::Value>) -> usize {
value
.and_then(|value| value.as_object())
.map_or(0, |object| object.len())
}
fn is_requirement_entry(line: &str) -> bool {
!line.is_empty() && !line.starts_with('#') && !line.starts_with('-') && !line.starts_with("--")
}
fn find_nearest_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
let mut current = manifest.parent();
while let Some(dir) = current {
if let Some(lockfile) = find_lockfile_in_dir(dir, names) {
return Some(lockfile);
}
current = dir.parent();
}
None
}
fn find_sibling_lockfile(manifest: &Path, names: &[&str]) -> Option<PathBuf> {
find_lockfile_in_dir(manifest.parent()?, names)
}
fn find_lockfile_in_dir(dir: &Path, names: &[&str]) -> Option<PathBuf> {
names
.iter()
.map(|name| dir.join(name))
.find(|path| path.is_file())
}
fn detect_git_summary(root: &Path) -> GitSummary {
let Some(git_root) = run_git_output(root, &["rev-parse", "--show-toplevel"])
.and_then(|output| output.lines().next().map(PathBuf::from))
else {
return GitSummary::default();
};
let branch = run_git_output(root, &["branch", "--show-current"])
.and_then(|output| non_empty_trimmed(&output));
let last_commit = run_git_output(root, &["log", "-1", "--format=%cI"])
.and_then(|output| non_empty_trimmed(&output));
let porcelain = run_git_output(root, &["status", "--porcelain"]).unwrap_or_default();
let mut tracked_modified_files = 0;
let mut untracked_files = 0;
for line in porcelain.lines() {
if line.starts_with("??") {
untracked_files += 1;
} else if !line.trim().is_empty() {
tracked_modified_files += 1;
}
}
GitSummary {
is_repository: true,
root: Some(git_root),
branch,
last_commit,
is_dirty: tracked_modified_files > 0 || untracked_files > 0,
tracked_modified_files,
untracked_files,
}
}
fn run_git_output(root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn non_empty_trimmed(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_owned())
}
}
fn detect_indicators(root: &Path) -> Vec<ProjectIndicator> {
let checks = [
("Cargo.toml", IndicatorKind::RustWorkspace),
(".git", IndicatorKind::GitRepository),
("README.md", IndicatorKind::Readme),
];
let mut indicators = Vec::new();
for (relative, kind) in checks {
let path = root.join(relative);
if path.exists() {
indicators.push(ProjectIndicator { kind, path });
}
}
let cargo_manifest = root.join("Cargo.toml");
if cargo_manifest.exists() {
indicators.push(ProjectIndicator {
kind: IndicatorKind::RustPackage,
path: cargo_manifest,
});
}
indicators
}
fn scan_inventory(root: &Path) -> Result<ScanInventory> {
let mut inventory = MutableInventory::default();
let ignore_matcher = IgnoreMatcher::new(root);
walk_project(root, &ignore_matcher, &mut inventory)?;
Ok(ScanInventory {
languages: inventory.languages,
build_systems: inventory.build_systems,
code: inventory.code,
dependencies: build_dependency_summary(inventory.dependency_manifests),
tests: build_test_summary(inventory.tests, inventory.test_command_sources),
hygiene: inventory.hygiene,
documentation: inventory.documentation,
license: build_license_summary(&inventory.license_files),
ci: inventory.ci,
containers: inventory.containers,
files_scanned: inventory.files_scanned,
skipped_dirs: inventory.skipped_dirs,
})
}
fn walk_project(
path: &Path,
ignore_matcher: &IgnoreMatcher,
inventory: &mut MutableInventory,
) -> Result<()> {
let local_ignore_matcher = ignore_matcher.with_child_ignores(path);
let mut entries = Vec::new();
for entry in
fs::read_dir(path).with_context(|| format!("failed to read `{}`", path.display()))?
{
let entry =
entry.with_context(|| format!("failed to read entry under `{}`", path.display()))?;
entries.push(entry);
}
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let entry_path = entry.path();
let file_type = entry
.file_type()
.with_context(|| format!("failed to inspect `{}`", entry_path.display()))?;
if file_type.is_dir() {
if should_skip_dir(entry.file_name().as_os_str())
|| local_ignore_matcher.is_ignored(&entry_path, true)
{
inventory.add_skipped_dir(entry_path);
continue;
}
observe_directory(&entry_path, inventory);
walk_project(&entry_path, &local_ignore_matcher, inventory)?;
continue;
}
if file_type.is_file() {
if local_ignore_matcher.is_ignored(&entry_path, false) {
continue;
}
inventory.files_scanned += 1;
observe_file(&entry_path, inventory);
}
}
Ok(())
}
fn should_skip_dir(name: &OsStr) -> bool {
matches!(
name.to_str(),
Some(".git" | ".hg" | ".svn" | "target" | "node_modules")
)
}
#[derive(Clone)]
struct IgnoreMatcher {
matchers: Vec<Gitignore>,
}
impl IgnoreMatcher {
fn new(root: &Path) -> Self {
Self {
matchers: Self::build_matchers(root),
}
}
fn with_child_ignores(&self, dir: &Path) -> Self {
let mut matchers = self.matchers.clone();
matchers.extend(Self::build_matchers(dir));
Self { matchers }
}
fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
self.matchers
.iter()
.rev()
.find_map(|matcher| {
let result = matcher.matched(path, is_dir);
if result.is_none() {
None
} else {
Some(result.is_ignore())
}
})
.unwrap_or(false)
}
fn build_matchers(dir: &Path) -> Vec<Gitignore> {
[".gitignore", ".ignore"]
.into_iter()
.filter_map(|file_name| Self::build_matcher(dir, file_name))
.collect()
}
fn build_matcher(dir: &Path, file_name: &str) -> Option<Gitignore> {
let path = dir.join(file_name);
if !path.is_file() {
return None;
}
let mut builder = GitignoreBuilder::new(dir);
let _ = builder.add(&path);
builder.build().ok()
}
}
fn observe_directory(path: &Path, inventory: &mut MutableInventory) {
if path.file_name().and_then(|value| value.to_str()) == Some("docs") {
inventory.documentation.has_docs_dir = true;
}
if is_test_directory(path) {
add_test_directory(&mut inventory.tests, path.to_path_buf());
}
}
fn observe_file(path: &Path, inventory: &mut MutableInventory) {
if let Some(kind) = language_for_path(path) {
inventory.add_language(kind);
inventory.add_code_file(kind, path);
}
if let Some(kind) = build_system_for_path(path) {
inventory.add_build_system(kind, path.to_path_buf());
}
if is_dependency_manifest(path) {
inventory.add_dependency_manifest(path.to_path_buf());
}
if is_test_file(path) {
inventory.tests.test_files += 1;
}
if is_test_command_source(path) {
inventory.add_test_command_source(path.to_path_buf());
}
observe_hygiene_file(path, &mut inventory.hygiene);
observe_documentation_file(path, &mut inventory.documentation);
if is_license_file(path) {
inventory.add_license_file(path.to_path_buf());
}
observe_ci_file(path, &mut inventory.ci);
observe_container_file(path, &mut inventory.containers);
}
fn language_for_path(path: &Path) -> Option<LanguageKind> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.map(str::to_ascii_lowercase)?;
match extension.as_str() {
"rs" => Some(LanguageKind::Rust),
"ts" | "tsx" => Some(LanguageKind::TypeScript),
"js" | "jsx" | "mjs" | "cjs" => Some(LanguageKind::JavaScript),
"py" => Some(LanguageKind::Python),
"c" => Some(LanguageKind::C),
"cc" | "cpp" | "cxx" | "hpp" | "hxx" => Some(LanguageKind::Cpp),
"go" => Some(LanguageKind::Go),
_ => None,
}
}
fn count_code_lines(path: &Path, kind: LanguageKind) -> CodeLineCounts {
let Ok(content) = fs::read_to_string(path) else {
return CodeLineCounts::default();
};
let mut counts = CodeLineCounts::default();
let mut in_block_comment = false;
for line in content.lines() {
counts.total_lines += 1;
let trimmed = line.trim();
if trimmed.is_empty() {
counts.blank_lines += 1;
continue;
}
let classification = classify_code_line(trimmed, kind, &mut in_block_comment);
if classification.has_comment {
counts.comment_lines += 1;
}
if classification.has_code {
counts.code_lines += 1;
}
}
counts
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct CodeLineClassification {
has_code: bool,
has_comment: bool,
}
fn classify_code_line(
mut line: &str,
kind: LanguageKind,
in_block_comment: &mut bool,
) -> CodeLineClassification {
if !supports_c_style_block_comments(kind) {
return classify_simple_comment_line(line, kind);
}
let mut classification = CodeLineClassification::default();
loop {
if *in_block_comment {
classification.has_comment = true;
if let Some(end) = line.find("*/") {
*in_block_comment = false;
line = &line[end + 2..];
if line.trim().is_empty() {
break;
}
continue;
}
break;
}
let current = line.trim_start();
if current.is_empty() {
break;
}
if current.starts_with("//") {
classification.has_comment = true;
break;
}
let block_start = current.find("/*");
let line_comment_start = current.find("//");
match (block_start, line_comment_start) {
(Some(block), Some(comment)) if comment < block => {
classification.has_comment = true;
if !current[..comment].trim().is_empty() {
classification.has_code = true;
}
break;
}
(Some(block), _) => {
classification.has_comment = true;
if !current[..block].trim().is_empty() {
classification.has_code = true;
}
let after_block_start = ¤t[block + 2..];
if let Some(end) = after_block_start.find("*/") {
line = &after_block_start[end + 2..];
if line.trim().is_empty() {
break;
}
continue;
}
*in_block_comment = true;
break;
}
(None, Some(comment)) => {
classification.has_comment = true;
if !current[..comment].trim().is_empty() {
classification.has_code = true;
}
break;
}
(None, None) => {
classification.has_code = true;
break;
}
}
}
classification
}
fn classify_simple_comment_line(line: &str, kind: LanguageKind) -> CodeLineClassification {
let markers = simple_comment_markers(kind);
if markers.iter().any(|marker| line.starts_with(marker)) {
CodeLineClassification {
has_code: false,
has_comment: true,
}
} else {
CodeLineClassification {
has_code: true,
has_comment: markers
.iter()
.any(|marker| line.find(marker).is_some_and(|index| index > 0)),
}
}
}
fn supports_c_style_block_comments(kind: LanguageKind) -> bool {
matches!(
kind,
LanguageKind::Rust
| LanguageKind::TypeScript
| LanguageKind::JavaScript
| LanguageKind::C
| LanguageKind::Cpp
| LanguageKind::Go
)
}
fn simple_comment_markers(kind: LanguageKind) -> &'static [&'static str] {
match kind {
LanguageKind::Python => &["#"],
LanguageKind::Rust
| LanguageKind::TypeScript
| LanguageKind::JavaScript
| LanguageKind::C
| LanguageKind::Cpp
| LanguageKind::Go => &["//"],
}
}
fn build_system_for_path(path: &Path) -> Option<BuildSystemKind> {
let file_name = path.file_name()?.to_str()?;
match file_name {
"Cargo.toml" => Some(BuildSystemKind::Cargo),
"package.json" => Some(BuildSystemKind::NodePackage),
"pyproject.toml" => Some(BuildSystemKind::PythonProject),
"requirements.txt" => Some(BuildSystemKind::PythonRequirements),
"CMakeLists.txt" => Some(BuildSystemKind::CMake),
"go.mod" => Some(BuildSystemKind::GoModule),
_ => None,
}
}
fn is_dependency_manifest(path: &Path) -> bool {
matches!(
path.file_name().and_then(|value| value.to_str()),
Some("Cargo.toml" | "package.json" | "pyproject.toml" | "requirements.txt")
)
}
fn is_test_command_source(path: &Path) -> bool {
matches!(
path.file_name().and_then(|value| value.to_str()),
Some("Cargo.toml" | "package.json" | "pyproject.toml" | "CMakeLists.txt")
)
}
fn is_test_directory(path: &Path) -> bool {
matches!(
path.file_name().and_then(|value| value.to_str()),
Some("tests" | "test" | "__tests__" | "spec")
)
}
fn add_test_directory(tests: &mut TestSummary, path: PathBuf) {
tests.has_tests_dir = true;
if tests
.test_directories
.iter()
.any(|existing| existing == &path)
{
return;
}
tests.test_directories.push(path);
}
fn is_test_file(path: &Path) -> bool {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if is_under_named_dir(path, "tests") || is_under_named_dir(path, "__tests__") {
return matches!(
extension.as_str(),
"rs" | "js" | "jsx" | "ts" | "tsx" | "py"
);
}
file_name.ends_with("_test.rs")
|| file_name.ends_with(".test.js")
|| file_name.ends_with(".test.jsx")
|| file_name.ends_with(".test.ts")
|| file_name.ends_with(".test.tsx")
|| file_name.ends_with(".spec.js")
|| file_name.ends_with(".spec.jsx")
|| file_name.ends_with(".spec.ts")
|| file_name.ends_with(".spec.tsx")
|| (file_name.starts_with("test_") && file_name.ends_with(".py"))
|| file_name.ends_with("_test.py")
|| file_name.ends_with("_test.cpp")
|| file_name.ends_with("_test.cc")
|| file_name.ends_with("_test.cxx")
|| file_name.ends_with("_tests.cpp")
}
fn is_under_named_dir(path: &Path, directory_name: &str) -> bool {
path.components()
.any(|component| component == Component::Normal(OsStr::new(directory_name)))
}
fn observe_documentation_file(path: &Path, documentation: &mut DocumentationSummary) {
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return;
};
let file_name = file_name.to_ascii_lowercase();
if file_name == "readme.md" || file_name == "readme" {
documentation.has_readme = true;
}
if file_name == "license" || file_name.starts_with("license.") {
documentation.has_license = true;
}
}
fn is_license_file(path: &Path) -> bool {
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
let file_name = file_name.to_ascii_lowercase();
file_name == "license" || file_name.starts_with("license.")
}
fn build_license_summary(files: &[PathBuf]) -> LicenseSummary {
let Some(path) = files.first() else {
return LicenseSummary {
path: None,
kind: LicenseKind::Missing,
};
};
let content = fs::read_to_string(path).unwrap_or_default();
LicenseSummary {
path: Some(path.clone()),
kind: detect_license_kind(&content),
}
}
fn detect_license_kind(content: &str) -> LicenseKind {
let lower = content.to_ascii_lowercase();
let normalized = lower.trim();
if normalized == "mit"
|| normalized == "mit license"
|| lower.contains("mit license")
|| lower.contains("permission is hereby granted")
{
LicenseKind::Mit
} else if normalized == "apache-2.0"
|| normalized == "apache 2.0"
|| lower.contains("apache license")
|| lower.contains("apache-2.0")
|| lower.contains("www.apache.org/licenses/license-2.0")
{
LicenseKind::Apache2
} else if normalized.starts_with("gpl")
|| lower.contains("gnu general public license")
|| lower.contains("gpl")
{
LicenseKind::Gpl
} else if normalized.starts_with("bsd")
|| (lower.contains("bsd") && lower.contains("redistribution and use"))
{
LicenseKind::Bsd
} else {
LicenseKind::Unknown
}
}
fn observe_hygiene_file(path: &Path, hygiene: &mut ScanHygiene) {
match path.file_name().and_then(|value| value.to_str()) {
Some(".gitignore") => hygiene.has_gitignore = true,
Some(".ignore") => hygiene.has_ignore = true,
_ => {}
}
}
fn observe_ci_file(path: &Path, ci: &mut CiSummary) {
if has_component_pair(path, ".github", "workflows") {
ci.add_provider(CiProvider::GithubActions, path.to_path_buf());
}
if has_component(path, ".workflow") {
ci.add_provider(CiProvider::GiteeGo, path.to_path_buf());
}
if path.file_name().and_then(|value| value.to_str()) == Some(".gitlab-ci.yml") {
ci.add_provider(CiProvider::GitlabCi, path.to_path_buf());
}
if has_component(path, ".circleci") {
ci.add_provider(CiProvider::CircleCi, path.to_path_buf());
}
if path.file_name().and_then(|value| value.to_str()) == Some("Jenkinsfile") {
ci.add_provider(CiProvider::Jenkins, path.to_path_buf());
}
}
fn has_component(path: &Path, name: &str) -> bool {
path.components()
.any(|component| component == Component::Normal(OsStr::new(name)))
}
fn has_component_pair(path: &Path, first: &str, second: &str) -> bool {
let mut components = path.components();
while let Some(component) = components.next() {
if component == Component::Normal(OsStr::new(first))
&& components.next() == Some(Component::Normal(OsStr::new(second)))
{
return true;
}
}
false
}
fn observe_container_file(path: &Path, containers: &mut ContainerSummary) {
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return;
};
let file_name = file_name.to_ascii_lowercase();
if file_name == "dockerfile" || file_name.starts_with("dockerfile.") {
containers.has_dockerfile = true;
}
if matches!(
file_name.as_str(),
"docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml"
) {
containers.has_compose_file = true;
}
}
#[derive(Default)]
struct MutableInventory {
languages: Vec<LanguageSummary>,
build_systems: Vec<BuildSystemSummary>,
code: CodeSummary,
dependency_manifests: Vec<PathBuf>,
tests: TestSummary,
test_command_sources: Vec<PathBuf>,
hygiene: ScanHygiene,
documentation: DocumentationSummary,
license_files: Vec<PathBuf>,
ci: CiSummary,
containers: ContainerSummary,
files_scanned: usize,
skipped_dirs: Vec<PathBuf>,
}
impl MutableInventory {
fn add_language(&mut self, kind: LanguageKind) {
if let Some(language) = self
.languages
.iter_mut()
.find(|language| language.kind == kind)
{
language.files += 1;
return;
}
self.languages.push(LanguageSummary { kind, files: 1 });
}
fn add_code_file(&mut self, kind: LanguageKind, path: &Path) {
let counts = count_code_lines(path, kind);
self.code.total_files += 1;
self.code.total_lines += counts.total_lines;
self.code.code_lines += counts.code_lines;
self.code.comment_lines += counts.comment_lines;
self.code.blank_lines += counts.blank_lines;
if let Some(language) = self
.code
.languages
.iter_mut()
.find(|language| language.kind == kind)
{
language.files += 1;
language.total_lines += counts.total_lines;
language.code_lines += counts.code_lines;
language.comment_lines += counts.comment_lines;
language.blank_lines += counts.blank_lines;
return;
}
self.code.languages.push(CodeLanguageSummary {
kind,
files: 1,
total_lines: counts.total_lines,
code_lines: counts.code_lines,
comment_lines: counts.comment_lines,
blank_lines: counts.blank_lines,
});
}
fn add_build_system(&mut self, kind: BuildSystemKind, path: PathBuf) {
if self
.build_systems
.iter()
.any(|build_system| build_system.kind == kind && build_system.path == path)
{
return;
}
self.build_systems.push(BuildSystemSummary { kind, path });
}
fn add_dependency_manifest(&mut self, path: PathBuf) {
if self
.dependency_manifests
.iter()
.any(|existing| existing == &path)
{
return;
}
self.dependency_manifests.push(path);
}
fn add_test_command_source(&mut self, path: PathBuf) {
if self
.test_command_sources
.iter()
.any(|existing| existing == &path)
{
return;
}
self.test_command_sources.push(path);
}
fn add_license_file(&mut self, path: PathBuf) {
if self.license_files.iter().any(|existing| existing == &path) {
return;
}
self.license_files.push(path);
}
fn add_skipped_dir(&mut self, path: PathBuf) {
if self.skipped_dirs.iter().any(|existing| existing == &path) {
return;
}
self.skipped_dirs.push(path);
}
}
fn build_report(
project_name: &str,
indicators: &[ProjectIndicator],
inventory: &ScanInventory,
health: &HealthSummary,
) -> ProjectReport {
let summary = if indicators.is_empty() {
format!("{project_name} was scanned, but no known project markers were detected yet.")
} else {
format!(
"{project_name} was scanned across {} file(s), with {} project marker(s), {} language(s), {} build system(s), and {} health detected.",
inventory.files_scanned,
indicators.len(),
inventory.languages.len(),
inventory.build_systems.len(),
health.grade.as_str()
)
};
ProjectReport {
summary,
next_steps: vec![
"Add language, dependency, and repository activity scanners.".to_owned(),
"Keep CLI and GUI features backed by projd-core data structures.".to_owned(),
"Export Markdown and JSON reports before adding richer GUI visualizations.".to_owned(),
],
}
}
fn yes_no(value: bool) -> &'static str {
if value { "yes" } else { "no" }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_markdown_without_indicators() {
let scan = ProjectScan {
root: PathBuf::from("example"),
project_name: "example".to_owned(),
identity: ProjectIdentity {
name: "example".to_owned(),
version: None,
kind: ProjectKind::Generic,
source: IdentitySource::DirectoryName,
},
indicators: Vec::new(),
languages: Vec::new(),
build_systems: Vec::new(),
code: CodeSummary::default(),
dependencies: DependencySummary::default(),
tests: TestSummary::default(),
hygiene: ScanHygiene::default(),
risks: RiskSummary::default(),
health: HealthSummary {
grade: ProjectHealth::Unknown,
score: 0,
risk_level: RiskSeverity::Info,
signals: Vec::new(),
},
documentation: DocumentationSummary::default(),
license: LicenseSummary::default(),
ci: CiSummary::default(),
git: GitSummary::default(),
containers: ContainerSummary::default(),
files_scanned: 0,
skipped_dirs: Vec::new(),
report: ProjectReport {
summary: "example summary".to_owned(),
next_steps: vec!["next".to_owned()],
},
};
let rendered = render_markdown(&scan);
assert!(rendered.contains("# Project Report"));
assert!(rendered.contains("example summary"));
assert!(rendered.contains("No known project indicators"));
}
}