use std::fs;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use projd_core::{
BuildSystemKind, DependencyEcosystem, LanguageKind, ProjectKind, ProjectScan, RiskCode,
RiskSeverity, render_json, render_markdown, scan_path,
};
#[derive(Debug, Parser)]
#[command(name = "projd")]
#[command(version, about = projd_core::describe())]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Scan {
path: PathBuf,
#[arg(short, long)]
format: Option<OutputFormat>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
overwrite: bool,
#[arg(long)]
no_unicode: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum OutputFormat {
Terminal,
Markdown,
Json,
}
impl OutputFormat {
fn detect_path(path: &Path) -> Option<Self> {
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
match extension.as_str() {
"md" | "markdown" => Some(Self::Markdown),
"json" => Some(Self::Json),
_ => None,
}
}
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Command::Scan {
path,
format,
output,
overwrite,
no_unicode,
}) => {
let format =
choose_output_format(format, output.as_deref(), io::stdout().is_terminal());
let scan = scan_path(path)?;
let rendered = render_scan(
&scan,
format,
TerminalRenderOptions {
unicode: !no_unicode,
bar_width: 20,
},
)?;
write_or_print(rendered, output, overwrite)
}
None => {
println!("{} {}", projd_core::NAME, projd_core::VERSION);
println!("{}", projd_core::describe());
Ok(())
}
}
}
fn choose_output_format(
format: Option<OutputFormat>,
output: Option<&Path>,
stdout_is_terminal: bool,
) -> OutputFormat {
if let Some(format) = format {
return format;
}
if let Some(format) = output.and_then(OutputFormat::detect_path) {
return format;
}
if output.is_none() && stdout_is_terminal {
OutputFormat::Terminal
} else {
OutputFormat::Markdown
}
}
fn render_scan(
scan: &ProjectScan,
format: OutputFormat,
terminal_options: TerminalRenderOptions,
) -> Result<String> {
match format {
OutputFormat::Terminal => Ok(render_terminal(scan, terminal_options)),
OutputFormat::Markdown => Ok(render_markdown(scan)),
OutputFormat::Json => render_json(scan).map(|json| format!("{json}\n")),
}
}
#[derive(Clone, Copy, Debug)]
struct TerminalRenderOptions {
unicode: bool,
bar_width: usize,
}
fn render_terminal(scan: &ProjectScan, options: TerminalRenderOptions) -> String {
let mut output = String::new();
let separator = if options.unicode { " · " } else { " | " };
let version = scan
.identity
.version
.as_deref()
.map(|version| format!(" v{version}"))
.unwrap_or_default();
output.push_str("Projd Scan Report\n");
output.push_str(&format!(
"{}{}{}{}{}{} files scanned\n",
scan.identity.name,
version,
separator,
project_kind_label(scan.identity.kind),
separator,
scan.files_scanned,
));
output.push_str(&format!("Root: {}\n\n", scan.root.display()));
output.push_str("Health\n");
output.push_str(&status_line("README", scan.documentation.has_readme));
output.push_str(&status_line("License", scan.documentation.has_license));
output.push_str(&status_line("docs/", scan.documentation.has_docs_dir));
output.push_str(&status_line("CI", scan.ci.has_github_actions));
output.push_str(&status_line(
"Tests",
scan.tests.test_files > 0 || !scan.tests.commands.is_empty(),
));
output.push_str(&status_line("Lockfiles", lockfiles_ok(scan)));
output.push_str("\nLanguages\n");
if scan.languages.is_empty() {
output.push_str(" none detected\n");
} else {
let total_files = scan
.languages
.iter()
.map(|language| language.files)
.sum::<usize>();
let mut languages = scan.languages.iter().collect::<Vec<_>>();
languages.sort_by(|left, right| {
right
.files
.cmp(&left.files)
.then_with(|| language_label(left.kind).cmp(language_label(right.kind)))
});
for language in languages {
let percent = percentage(language.files, total_files);
output.push_str(&format!(
" {:<12} {} {:>3}% {:>4} file(s)\n",
language_label(language.kind),
bar(language.files, total_files, options),
percent,
language.files
));
}
}
output.push_str("\nBuild Systems\n");
if scan.build_systems.is_empty() {
output.push_str(" none detected\n");
} else {
for build_system in &scan.build_systems {
output.push_str(&format!(
" {:<12} {}\n",
build_system_label(build_system.kind),
build_system.path.display()
));
}
}
output.push_str("\nDependencies\n");
output.push_str(&format!(
" {:<12} {}\n",
"Manifests", scan.dependencies.total_manifests
));
output.push_str(&format!(
" {:<12} {}\n",
"Entries", scan.dependencies.total_dependencies
));
if scan.dependencies.ecosystems.is_empty() {
output.push_str(" none detected\n");
} else {
let total_dependencies = scan.dependencies.total_dependencies;
for summary in &scan.dependencies.ecosystems {
let lockfile = if summary.total == 0 {
"no deps"
} else if summary.lockfile.is_some() {
"lockfile OK"
} else {
"lockfile missing"
};
output.push_str(&format!(
" {:<12} {} {:>4} dep(s) {}\n",
dependency_label(summary.ecosystem),
bar(summary.total, total_dependencies, options),
summary.total,
lockfile
));
}
}
output.push_str("\nTests\n");
output.push_str(&format!(
" {:<12} {}\n",
"Directories",
scan.tests.test_directories.len()
));
output.push_str(&format!(" {:<12} {}\n", "Files", scan.tests.test_files));
if scan.tests.commands.is_empty() {
output.push_str(" Commands none detected\n");
} else {
for command in &scan.tests.commands {
output.push_str(&format!(" Command {}\n", command.command));
}
}
output.push_str("\nRisks\n");
if scan.risks.findings.is_empty() {
output.push_str(" none detected\n");
} else {
output.push_str(&format!(
" {:<12} high {}, medium {}, low {}, info {}\n",
"Counts", scan.risks.high, scan.risks.medium, scan.risks.low, scan.risks.info
));
for risk in &scan.risks.findings {
output.push_str(&format!(
" {:<8} {:<30} {}\n",
risk_severity_label(risk.severity),
risk_code_label(risk.code),
risk.message
));
}
}
output
}
fn status_line(label: &str, ok: bool) -> String {
let status = if ok { "OK" } else { "Missing" };
format!(" {label:<12} {status}\n")
}
fn lockfiles_ok(scan: &ProjectScan) -> bool {
scan.dependencies
.ecosystems
.iter()
.all(|summary| summary.total == 0 || summary.lockfile.is_some())
}
fn bar(value: usize, total: usize, options: TerminalRenderOptions) -> String {
let width = options.bar_width.max(4);
let filled = if total == 0 {
0
} else {
((value * width) + (total / 2)) / total
}
.min(width);
let empty = width - filled;
let (filled_char, empty_char) = if options.unicode {
('█', '░')
} else {
('#', '.')
};
format!(
"{}{}",
filled_char.to_string().repeat(filled),
empty_char.to_string().repeat(empty)
)
}
fn percentage(value: usize, total: usize) -> usize {
if total == 0 {
0
} else {
((value * 100) + (total / 2)) / total
}
}
fn project_kind_label(kind: ProjectKind) -> &'static str {
match kind {
ProjectKind::RustWorkspace => "Rust workspace",
ProjectKind::RustPackage => "Rust package",
ProjectKind::NodePackage => "Node package",
ProjectKind::PythonProject => "Python project",
ProjectKind::Generic => "Generic project",
}
}
fn language_label(kind: LanguageKind) -> &'static str {
match kind {
LanguageKind::Rust => "Rust",
LanguageKind::TypeScript => "TypeScript",
LanguageKind::JavaScript => "JavaScript",
LanguageKind::Python => "Python",
LanguageKind::C => "C",
LanguageKind::Cpp => "C++",
LanguageKind::Go => "Go",
}
}
fn build_system_label(kind: BuildSystemKind) -> &'static str {
match kind {
BuildSystemKind::Cargo => "Cargo",
BuildSystemKind::NodePackage => "Node",
BuildSystemKind::PythonProject => "Python",
BuildSystemKind::PythonRequirements => "Requirements",
BuildSystemKind::CMake => "CMake",
BuildSystemKind::GoModule => "Go module",
}
}
fn dependency_label(ecosystem: DependencyEcosystem) -> &'static str {
match ecosystem {
DependencyEcosystem::Rust => "Rust",
DependencyEcosystem::Node => "Node",
DependencyEcosystem::Python => "Python",
}
}
fn risk_severity_label(severity: RiskSeverity) -> &'static str {
match severity {
RiskSeverity::High => "HIGH",
RiskSeverity::Medium => "MEDIUM",
RiskSeverity::Low => "LOW",
RiskSeverity::Info => "INFO",
}
}
fn risk_code_label(code: RiskCode) -> &'static str {
match code {
RiskCode::MissingReadme => "missing-readme",
RiskCode::MissingLicense => "missing-license",
RiskCode::MissingCi => "missing-ci",
RiskCode::NoTestsDetected => "no-tests-detected",
RiskCode::ManifestWithoutLockfile => "manifest-without-lockfile",
RiskCode::LargeProjectWithoutIgnoreRules => "large-without-ignore-rules",
}
}
fn write_or_print(rendered: String, output: Option<PathBuf>, overwrite: bool) -> Result<()> {
let Some(output) = output else {
print!("{rendered}");
return Ok(());
};
if output.exists() && !overwrite {
bail!(
"refusing to overwrite existing output file `{}`",
output.display()
);
}
fs::write(&output, rendered)
.with_context(|| format!("failed to write output file `{}`", output.display()))?;
Ok(())
}