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, CiProvider, DependencyEcosystem, LanguageKind, LicenseKind, 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,
#[arg(long, default_value_t = ColorChoice::Auto)]
color: ColorChoice,
#[arg(long, default_value_t = 80)]
width: usize,
#[arg(long)]
details: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum OutputFormat {
Terminal,
Markdown,
Json,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum ColorChoice {
Auto,
Always,
Never,
}
impl std::fmt::Display for ColorChoice {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let value = match self {
Self::Auto => "auto",
Self::Always => "always",
Self::Never => "never",
};
formatter.write_str(value)
}
}
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,
color,
width,
details,
}) => {
let stdout_is_terminal = io::stdout().is_terminal();
let format = choose_output_format(format, output.as_deref(), stdout_is_terminal);
let scan = scan_path(path)?;
let rendered = render_scan(
&scan,
format,
TerminalRenderOptions {
unicode: !no_unicode,
bar_width: bar_width_for_terminal(width),
color: color.enabled(stdout_is_terminal),
details,
},
)?;
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,
color: bool,
details: bool,
}
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(&heading("Health", options));
output.push_str(&status_line(
"README",
scan.documentation.has_readme,
options,
));
output.push_str(&status_line(
"License",
scan.documentation.has_license,
options,
));
output.push_str(&status_line(
"docs/",
scan.documentation.has_docs_dir,
options,
));
output.push_str(&status_line("CI", ci_provider_count(scan) > 0, options));
output.push_str(&status_line(
"Tests",
scan.tests.test_files > 0 || !scan.tests.commands.is_empty(),
options,
));
output.push_str(&status_line("Lockfiles", lockfiles_ok(scan), options));
output.push('\n');
output.push_str(&heading("Source Control", options));
if scan.git.is_repository {
output.push_str(&format!(" {:<12} Git repo\n", "Type"));
output.push_str(&format!(
" {:<12} {}\n",
"Branch",
scan.git.branch.as_deref().unwrap_or("unknown")
));
let status = if scan.git.is_dirty {
format!(
"dirty, {} modified, {} untracked",
scan.git.tracked_modified_files, scan.git.untracked_files
)
} else {
"clean".to_owned()
};
output.push_str(&format!(" {:<12} {}\n", "Status", status));
if let Some(last_commit) = &scan.git.last_commit {
output.push_str(&format!(" {:<12} {}\n", "Last commit", last_commit));
}
if options.details
&& let Some(root) = &scan.git.root
{
output.push_str(&format!(" {:<12} {}\n", "Git root", root.display()));
}
} else {
output.push_str(" Git repo no\n");
if options.details {
output.push_str(" Git root none\n");
}
}
output.push('\n');
output.push_str(&heading("License", options));
output.push_str(&format!(
" {:<12} {}\n",
"Kind",
license_kind_label(scan.license.kind)
));
if options.details
&& let Some(path) = &scan.license.path
{
output.push_str(&format!(" {:<12} {}\n", "Path", path.display()));
output.push_str(&format!(" {:<12} {}\n", "License path", path.display()));
}
output.push('\n');
output.push_str(&heading("CI Providers", options));
let providers = ci_provider_labels(scan);
if providers.is_empty() {
output.push_str(" none detected\n");
} else {
output.push_str(&format!(" {:<12} {}\n", "Detected", providers.join(", ")));
if options.details {
for provider in &scan.ci.providers {
output.push_str(&format!(
" {:<12} {} ({})\n",
"CI path",
provider.path.display(),
ci_provider_label(provider.provider)
));
}
}
}
output.push('\n');
output.push_str(&heading("Languages", options));
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),
style(
&bar(language.files, total_files, options),
AnsiStyle::Blue,
options
),
percent,
language.files
));
}
}
output.push('\n');
output.push_str(&heading("Build Systems", options));
if scan.build_systems.is_empty() {
output.push_str(" none detected\n");
} else {
for build_system in aggregate_build_systems(scan) {
output.push_str(&format!(
" {:<12} {:>3} manifest(s)\n",
build_system.label, build_system.count
));
}
}
output.push('\n');
output.push_str(&heading("Dependencies", options));
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 aggregate_dependencies(scan) {
output.push_str(&format!(
" {:<12} {:>3} manifest(s) {} {:>4} dep(s) {} lockfile(s), {} missing\n",
summary.label,
summary.manifests,
style(
&bar(summary.total_dependencies, total_dependencies, options),
AnsiStyle::Blue,
options
),
summary.total_dependencies,
style(&summary.lockfiles.to_string(), AnsiStyle::Green, options),
style(
&summary.missing_lockfiles.to_string(),
if summary.missing_lockfiles == 0 {
AnsiStyle::Green
} else {
AnsiStyle::Yellow
},
options
),
));
}
}
output.push('\n');
output.push_str(&heading("Tests", options));
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 aggregate_test_commands(scan) {
output.push_str(&format!(
" {:<12} {:>3} source(s)\n",
command.command, command.sources
));
}
}
output.push('\n');
output.push_str(&heading("Risks", options));
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 aggregate_risks(scan) {
let severity = risk_severity_label(risk.severity);
output.push_str(&format!(
" {:<8} {:<30} {} finding(s)\n",
style(severity, risk_severity_style(risk.severity), options),
risk_code_label(risk.code),
risk.count
));
}
}
if options.details {
output.push('\n');
output.push_str(&heading("Details", options));
for summary in &scan.dependencies.ecosystems {
output.push_str(&format!(
" {:<22} {}\n",
"Dependency manifest",
summary.manifest.display()
));
if let Some(lockfile) = &summary.lockfile {
output.push_str(&format!(" {:<22} {}\n", "Lockfile", lockfile.display()));
}
}
for command in &scan.tests.commands {
output.push_str(&format!(
" {:<22} {}\n",
"Test source",
command.source.display()
));
}
for risk in &scan.risks.findings {
if let Some(path) = &risk.path {
output.push_str(&format!(
" {:<22} {} ({})\n",
"Risk path",
path.display(),
risk_code_label(risk.code)
));
}
}
}
output
}
fn heading(label: &str, options: TerminalRenderOptions) -> String {
format!("{}\n", style(label, AnsiStyle::BoldCyan, options))
}
fn status_line(label: &str, ok: bool, options: TerminalRenderOptions) -> String {
let status = if ok {
style("OK", AnsiStyle::Green, options)
} else {
style("Missing", AnsiStyle::Yellow, options)
};
format!(" {label:<12} {status}\n")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AnsiStyle {
BoldCyan,
Blue,
Green,
Yellow,
Red,
Dim,
}
fn style(value: &str, style: AnsiStyle, options: TerminalRenderOptions) -> String {
if !options.color {
return value.to_owned();
}
let code = match style {
AnsiStyle::BoldCyan => "1;36",
AnsiStyle::Blue => "34",
AnsiStyle::Green => "32",
AnsiStyle::Yellow => "33",
AnsiStyle::Red => "31",
AnsiStyle::Dim => "2",
};
format!("\x1b[{code}m{value}\x1b[0m")
}
fn risk_severity_style(severity: RiskSeverity) -> AnsiStyle {
match severity {
RiskSeverity::High => AnsiStyle::Red,
RiskSeverity::Medium => AnsiStyle::Yellow,
RiskSeverity::Low => AnsiStyle::Blue,
RiskSeverity::Info => AnsiStyle::Dim,
}
}
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 bar_width_for_terminal(width: usize) -> usize {
width.saturating_sub(34).clamp(10, 32)
}
impl ColorChoice {
fn enabled(self, stdout_is_terminal: bool) -> bool {
match self {
Self::Auto => stdout_is_terminal,
Self::Always => true,
Self::Never => false,
}
}
}
#[derive(Debug, Eq, PartialEq)]
struct BuildSystemAggregate {
label: &'static str,
count: usize,
}
fn aggregate_build_systems(scan: &ProjectScan) -> Vec<BuildSystemAggregate> {
let mut aggregates = Vec::<BuildSystemAggregate>::new();
for build_system in &scan.build_systems {
let label = build_system_label(build_system.kind);
if let Some(existing) = aggregates.iter_mut().find(|item| item.label == label) {
existing.count += 1;
} else {
aggregates.push(BuildSystemAggregate { label, count: 1 });
}
}
aggregates.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.label.cmp(right.label))
});
aggregates
}
#[derive(Debug, Eq, PartialEq)]
struct DependencyAggregate {
label: &'static str,
manifests: usize,
total_dependencies: usize,
lockfiles: usize,
missing_lockfiles: usize,
}
fn aggregate_dependencies(scan: &ProjectScan) -> Vec<DependencyAggregate> {
let mut aggregates = Vec::<DependencyAggregate>::new();
for summary in &scan.dependencies.ecosystems {
let label = dependency_label(summary.ecosystem);
let has_lockfile = summary.lockfile.is_some();
if let Some(existing) = aggregates.iter_mut().find(|item| item.label == label) {
existing.manifests += 1;
existing.total_dependencies += summary.total;
if has_lockfile {
existing.lockfiles += 1;
} else if summary.total > 0 {
existing.missing_lockfiles += 1;
}
} else {
aggregates.push(DependencyAggregate {
label,
manifests: 1,
total_dependencies: summary.total,
lockfiles: usize::from(has_lockfile),
missing_lockfiles: usize::from(!has_lockfile && summary.total > 0),
});
}
}
aggregates.sort_by(|left, right| {
right
.total_dependencies
.cmp(&left.total_dependencies)
.then_with(|| left.label.cmp(right.label))
});
aggregates
}
#[derive(Debug, Eq, PartialEq)]
struct TestCommandAggregate {
command: String,
sources: usize,
}
fn aggregate_test_commands(scan: &ProjectScan) -> Vec<TestCommandAggregate> {
let mut aggregates = Vec::<TestCommandAggregate>::new();
for command in &scan.tests.commands {
if let Some(existing) = aggregates
.iter_mut()
.find(|item| item.command == command.command)
{
existing.sources += 1;
} else {
aggregates.push(TestCommandAggregate {
command: command.command.clone(),
sources: 1,
});
}
}
aggregates.sort_by(|left, right| {
right
.sources
.cmp(&left.sources)
.then_with(|| left.command.cmp(&right.command))
});
aggregates
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct RiskAggregate {
severity: RiskSeverity,
code: RiskCode,
count: usize,
}
fn aggregate_risks(scan: &ProjectScan) -> Vec<RiskAggregate> {
let mut aggregates = Vec::<RiskAggregate>::new();
for risk in &scan.risks.findings {
if let Some(existing) = aggregates
.iter_mut()
.find(|item| item.code == risk.code && item.severity == risk.severity)
{
existing.count += 1;
} else {
aggregates.push(RiskAggregate {
severity: risk.severity,
code: risk.code,
count: 1,
});
}
}
aggregates.sort_by(|left, right| {
risk_severity_rank(left.severity)
.cmp(&risk_severity_rank(right.severity))
.then_with(|| risk_code_label(left.code).cmp(risk_code_label(right.code)))
});
aggregates
}
fn risk_severity_rank(severity: RiskSeverity) -> usize {
match severity {
RiskSeverity::High => 0,
RiskSeverity::Medium => 1,
RiskSeverity::Low => 2,
RiskSeverity::Info => 3,
}
}
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",
RiskCode::UnknownLicense => "unknown-license",
}
}
fn license_kind_label(kind: LicenseKind) -> &'static str {
match kind {
LicenseKind::Mit => "MIT",
LicenseKind::Apache2 => "Apache-2.0",
LicenseKind::Gpl => "GPL",
LicenseKind::Bsd => "BSD",
LicenseKind::Unknown => "unknown",
LicenseKind::Missing => "missing",
}
}
fn ci_provider_count(scan: &ProjectScan) -> usize {
ci_provider_labels(scan).len()
}
fn ci_provider_labels(scan: &ProjectScan) -> Vec<&'static str> {
let mut labels = Vec::new();
if scan.ci.has_github_actions {
labels.push("GitHub Actions");
}
if scan.ci.has_gitee_go {
labels.push("Gitee Go");
}
if scan.ci.has_gitlab_ci {
labels.push("GitLab CI");
}
if scan.ci.has_circle_ci {
labels.push("CircleCI");
}
if scan.ci.has_jenkins {
labels.push("Jenkins");
}
labels
}
fn ci_provider_label(provider: CiProvider) -> &'static str {
match provider {
CiProvider::GithubActions => "GitHub Actions",
CiProvider::GiteeGo => "Gitee Go",
CiProvider::GitlabCi => "GitLab CI",
CiProvider::CircleCi => "CircleCI",
CiProvider::Jenkins => "Jenkins",
}
}
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(())
}