use std::ffi::OsStr;
use std::fs;
use std::path::{Component, Path, PathBuf};
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 dependencies: DependencySummary,
pub tests: TestSummary,
pub hygiene: ScanHygiene,
pub risks: RiskSummary,
pub documentation: DocumentationSummary,
pub ci: CiSummary,
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 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, 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,
}
#[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 CiSummary {
pub has_github_actions: bool,
}
#[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 report = build_report(&identity.name, &indicators, &inventory);
Ok(ProjectScan {
root,
project_name: identity.name.clone(),
identity,
indicators,
languages: inventory.languages,
build_systems: inventory.build_systems,
dependencies: inventory.dependencies,
tests: inventory.tests,
hygiene: inventory.hygiene,
risks,
documentation: inventory.documentation,
ci: inventory.ci,
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## 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## 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!(
"- 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!(
"- 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>,
dependencies: DependencySummary,
tests: TestSummary,
hygiene: ScanHygiene,
documentation: DocumentationSummary,
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,
});
}
if !inventory.ci.has_github_actions {
findings.push(RiskFinding {
severity: RiskSeverity::Low,
code: RiskCode::MissingCi,
message: "No GitHub Actions 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 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",
}
}
}
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_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,
dependencies: build_dependency_summary(inventory.dependency_manifests),
tests: build_test_summary(inventory.tests, inventory.test_command_sources),
hygiene: inventory.hygiene,
documentation: inventory.documentation,
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);
}
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);
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 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 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) {
let mut components = path.components();
while let Some(component) = components.next() {
if component == Component::Normal(OsStr::new(".github"))
&& components.next() == Some(Component::Normal(OsStr::new("workflows")))
{
ci.has_github_actions = true;
return;
}
}
}
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>,
dependency_manifests: Vec<PathBuf>,
tests: TestSummary,
test_command_sources: Vec<PathBuf>,
hygiene: ScanHygiene,
documentation: DocumentationSummary,
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_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_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,
) -> 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), and {} build system(s) detected.",
inventory.files_scanned,
indicators.len(),
inventory.languages.len(),
inventory.build_systems.len()
)
};
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("/tmp/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(),
dependencies: DependencySummary::default(),
tests: TestSummary::default(),
hygiene: ScanHygiene::default(),
risks: RiskSummary::default(),
documentation: DocumentationSummary::default(),
ci: CiSummary::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"));
}
}