use std::fs;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use comfy_table::{Attribute, Cell, ContentArrangement, Table, presets::UTF8_FULL};
use projd_core::{
BuildSystemKind, CiProvider, DependencyEcosystem, LanguageKind, LicenseKind, ProjectHealth,
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, default_value_t = TerminalStyle::Table)]
style: TerminalStyle,
#[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,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum TerminalStyle {
Table,
Compact,
Plain,
}
impl std::fmt::Display for TerminalStyle {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let value = match self {
Self::Table => "table",
Self::Compact => "compact",
Self::Plain => "plain",
};
formatter.write_str(value)
}
}
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,
style,
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),
style,
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,
style: TerminalStyle,
details: bool,
}
fn render_terminal(scan: &ProjectScan, options: TerminalRenderOptions) -> String {
match options.style {
TerminalStyle::Table => render_terminal_table(scan, options, TableDensity::Full),
TerminalStyle::Compact => render_terminal_table(scan, options, TableDensity::Compact),
TerminalStyle::Plain => render_terminal_plain(scan, options),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TableDensity {
Full,
Compact,
}
fn render_terminal_table(
scan: &ProjectScan,
options: TerminalRenderOptions,
density: TableDensity,
) -> String {
let mut output = String::new();
let version = scan
.identity
.version
.as_deref()
.map(|version| format!(" v{version}"))
.unwrap_or_default();
output.push_str(&format!(
"{}\n",
style("Projd Scan Report", AnsiStyle::BoldCyan, options)
));
output.push_str(&format!(
"{}{} · {} · {} files scanned\n\n",
scan.identity.name,
version,
project_kind_label(scan.identity.kind),
scan.files_scanned
));
let mut summary = terminal_table(density, options);
summary.add_rows([
vec![cell("Path", options), Cell::new(scan.root.display())],
vec![
cell("Profile", options),
Cell::new(project_kind_label(scan.identity.kind)),
],
vec![
cell("Health", options),
health_cell(scan.health.grade, options),
],
vec![
cell("Health Score", options),
Cell::new(format!("{}/100", scan.health.score)),
],
vec![
cell("Risk Level", options),
severity_cell(
scan.health.risk_level,
risk_severity_label(scan.health.risk_level),
options,
),
],
]);
output.push_str(&summary.to_string());
output.push_str("\n\n");
push_section(&mut output, "Signals", options);
let mut signals = terminal_table(density, options);
signals.set_header(vec![
cell("Signal", options),
cell("Status", options),
cell("Evidence", options),
cell("Impact", options),
]);
for row in signal_rows(scan) {
signals.add_row(vec![
cell(row.signal, options),
status_value_cell(row.status, options),
Cell::new(row.evidence),
Cell::new(row.impact),
]);
}
output.push_str(&signals.to_string());
output.push_str("\n\n");
output.push_str(&source_control_table(scan, density, options));
output.push('\n');
output.push_str(&ci_providers_table(scan, density, options));
output.push('\n');
output.push_str(&languages_table(scan, density, options));
output.push('\n');
output.push_str(&code_stats_table(scan, density, options));
output.push('\n');
output.push_str(&build_systems_table(scan, density, options));
output.push('\n');
output.push_str(&dependencies_table(scan, density, options));
output.push('\n');
output.push_str(&tests_table(scan, density, options));
output.push('\n');
output.push_str(&risks_table(scan, density, options));
if options.details {
output.push('\n');
output.push('\n');
output.push_str(&details_table(scan, density, options));
}
output
}
fn render_terminal_plain(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(&format!(
" {:<12} {} ({}/100)\n",
"Grade",
project_health_label(scan.health.grade),
scan.health.score
));
output.push_str(&format!(
" {:<12} {}\n",
"Risk",
risk_severity_label(scan.health.risk_level)
));
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("Code Statistics", options));
output.push_str(&format!(
" {:<12} {} source file(s), {} total, {} code, {} comment, {} blank\n",
"Code",
scan.code.total_files,
scan.code.total_lines,
scan.code.code_lines,
scan.code.comment_lines,
scan.code.blank_lines
));
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 terminal_table(density: TableDensity, options: TerminalRenderOptions) -> Table {
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
if matches!(density, TableDensity::Full) && options.unicode {
table.load_preset(UTF8_FULL);
} else {
table.load_preset(comfy_table::presets::NOTHING);
}
table
}
fn push_section(output: &mut String, label: &str, options: TerminalRenderOptions) {
output.push_str(&format!(
"{label}\n",
label = style(label, AnsiStyle::BoldCyan, options)
));
}
fn cell(value: impl ToString, options: TerminalRenderOptions) -> Cell {
let cell = Cell::new(value);
if options.color {
cell.add_attribute(Attribute::Bold)
} else {
cell
}
}
fn status_value_cell(status: SignalStatus, options: TerminalRenderOptions) -> Cell {
let style = match status {
SignalStatus::Ok => AnsiStyle::Green,
SignalStatus::Warn => AnsiStyle::Yellow,
SignalStatus::Info => AnsiStyle::Blue,
};
Cell::new(crate::style(status.as_str(), style, options))
}
fn health_cell(health: ProjectHealth, options: TerminalRenderOptions) -> Cell {
let style = match health {
ProjectHealth::Healthy => AnsiStyle::Green,
ProjectHealth::NeedsAttention => AnsiStyle::Yellow,
ProjectHealth::Risky => AnsiStyle::Red,
ProjectHealth::Unknown => AnsiStyle::Dim,
};
Cell::new(crate::style(project_health_label(health), style, options))
}
fn severity_cell(
severity: RiskSeverity,
value: impl ToString,
options: TerminalRenderOptions,
) -> Cell {
Cell::new(crate::style(
&value.to_string(),
risk_severity_style(severity),
options,
))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SignalStatus {
Ok,
Warn,
Info,
}
impl SignalStatus {
fn as_str(self) -> &'static str {
match self {
Self::Ok => "OK",
Self::Warn => "Warn",
Self::Info => "Info",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct SignalRow {
signal: &'static str,
status: SignalStatus,
evidence: String,
impact: &'static str,
}
fn signal_rows(scan: &ProjectScan) -> Vec<SignalRow> {
vec![
SignalRow {
signal: "README",
status: bool_status(scan.documentation.has_readme),
evidence: if scan.documentation.has_readme {
"README detected".to_owned()
} else {
"no README file".to_owned()
},
impact: if scan.documentation.has_readme {
"+10"
} else {
"-10"
},
},
SignalRow {
signal: "License",
status: bool_status(scan.documentation.has_license),
evidence: license_evidence(scan),
impact: if scan.documentation.has_license {
"+20"
} else {
"-20"
},
},
SignalRow {
signal: "CI",
status: bool_status(ci_provider_count(scan) > 0),
evidence: ci_evidence(scan),
impact: if ci_provider_count(scan) > 0 {
"+20"
} else {
"-15"
},
},
SignalRow {
signal: "Tests",
status: bool_status(scan.tests.test_files > 0 || !scan.tests.commands.is_empty()),
evidence: format!(
"{} test file(s), {} command source(s)",
scan.tests.test_files,
scan.tests.commands.len()
),
impact: if scan.tests.test_files > 0 || !scan.tests.commands.is_empty() {
"+20"
} else {
"-20"
},
},
SignalRow {
signal: "Lockfiles",
status: bool_status(lockfiles_ok(scan)),
evidence: lockfile_evidence(scan),
impact: if lockfiles_ok(scan) { "+10" } else { "-10" },
},
SignalRow {
signal: "Git",
status: git_signal_status(scan),
evidence: git_evidence(scan),
impact: if scan.git.is_repository && !scan.git.is_dirty {
"+15"
} else if scan.git.is_repository {
"+5"
} else {
"0"
},
},
]
}
fn bool_status(ok: bool) -> SignalStatus {
if ok {
SignalStatus::Ok
} else {
SignalStatus::Warn
}
}
fn git_signal_status(scan: &ProjectScan) -> SignalStatus {
if !scan.git.is_repository {
SignalStatus::Info
} else if scan.git.is_dirty {
SignalStatus::Warn
} else {
SignalStatus::Ok
}
}
fn license_evidence(scan: &ProjectScan) -> String {
match (&scan.license.path, scan.license.kind) {
(Some(path), kind) => format!("{} ({})", path.display(), license_kind_label(kind)),
(None, LicenseKind::Missing) => "no license file".to_owned(),
(None, kind) => license_kind_label(kind).to_owned(),
}
}
fn ci_evidence(scan: &ProjectScan) -> String {
let providers = ci_provider_labels(scan);
if providers.is_empty() {
"no CI provider".to_owned()
} else {
providers.join(", ")
}
}
fn git_evidence(scan: &ProjectScan) -> String {
if !scan.git.is_repository {
return "not a git repository".to_owned();
}
let branch = scan.git.branch.as_deref().unwrap_or("unknown branch");
if scan.git.is_dirty {
format!(
"{branch}, dirty, {} modified, {} untracked",
scan.git.tracked_modified_files, scan.git.untracked_files
)
} else {
format!("{branch}, clean")
}
}
fn git_worktree_status(scan: &ProjectScan) -> String {
if scan.git.is_dirty {
format!(
"dirty, {} modified, {} untracked",
scan.git.tracked_modified_files, scan.git.untracked_files
)
} else {
"clean".to_owned()
}
}
fn lockfile_evidence(scan: &ProjectScan) -> String {
if scan.dependencies.ecosystems.is_empty() {
return "no dependency manifests".to_owned();
}
let lockfiles = scan
.dependencies
.ecosystems
.iter()
.filter(|summary| summary.lockfile.is_some())
.count();
let missing = scan
.dependencies
.ecosystems
.iter()
.filter(|summary| summary.total > 0 && summary.lockfile.is_none())
.count();
format!(
"{} manifest(s), {} lockfile(s), {} missing",
scan.dependencies.total_manifests, lockfiles, missing
)
}
fn source_control_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Source Control", options);
let mut table = terminal_table(density, options);
table.set_header(vec![cell("Field", options), cell("Value", options)]);
if scan.git.is_repository {
table.add_row(vec![cell("Git repo", options), Cell::new("yes")]);
table.add_row(vec![
cell("Branch", options),
Cell::new(scan.git.branch.as_deref().unwrap_or("unknown")),
]);
table.add_row(vec![
cell("Status", options),
Cell::new(git_worktree_status(scan)),
]);
if let Some(last_commit) = &scan.git.last_commit {
table.add_row(vec![cell("Last commit", options), Cell::new(last_commit)]);
}
if options.details
&& let Some(root) = &scan.git.root
{
table.add_row(vec![cell("Git root", options), Cell::new(root.display())]);
}
} else {
table.add_row(vec![cell("Git repo", options), Cell::new("no")]);
if options.details {
table.add_row(vec![cell("Git root", options), Cell::new("none")]);
}
}
output.push_str(&table.to_string());
output
}
fn ci_providers_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "CI Providers", options);
let mut table = terminal_table(density, options);
table.set_header(vec![
cell("Provider", options),
cell("Status", options),
cell("Evidence", options),
]);
if scan.ci.providers.is_empty() {
table.add_row(vec![
cell("CI", options),
status_value_cell(SignalStatus::Warn, options),
Cell::new("none detected"),
]);
} else {
for provider in aggregate_ci_providers(scan) {
table.add_row(vec![
cell(provider.label, options),
status_value_cell(SignalStatus::Ok, options),
Cell::new(if options.details {
provider.paths.join(", ")
} else {
format!("{} path(s)", provider.paths.len())
}),
]);
}
}
output.push_str(&table.to_string());
output
}
#[derive(Debug, Eq, PartialEq)]
struct CiProviderAggregate {
label: &'static str,
paths: Vec<String>,
}
fn aggregate_ci_providers(scan: &ProjectScan) -> Vec<CiProviderAggregate> {
let mut aggregates = Vec::<CiProviderAggregate>::new();
for provider in &scan.ci.providers {
let label = ci_provider_label(provider.provider);
let path = provider.path.display().to_string();
if let Some(existing) = aggregates.iter_mut().find(|item| item.label == label) {
existing.paths.push(path);
} else {
aggregates.push(CiProviderAggregate {
label,
paths: vec![path],
});
}
}
aggregates.sort_by(|left, right| left.label.cmp(right.label));
aggregates
}
fn languages_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Languages", options);
let mut table = terminal_table(density, options);
table.set_header(vec![
cell("Language", options),
cell("Files", options),
cell("Share", options),
cell("Bar", options),
]);
if scan.languages.is_empty() {
table.add_row(vec![
cell("none", options),
Cell::new("0"),
Cell::new("0%"),
Cell::new(bar(0, 0, options)),
]);
} 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 {
table.add_row(vec![
cell(language_label(language.kind), options),
Cell::new(language.files),
Cell::new(format!("{}%", percentage(language.files, total_files))),
Cell::new(style(
&bar(language.files, total_files, options),
AnsiStyle::Blue,
options,
)),
]);
}
}
output.push_str(&table.to_string());
output
}
fn code_stats_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Code Statistics", options);
let mut table = terminal_table(density, options);
table.set_header(vec![
cell("Language", options),
cell("Files", options),
cell("Total", options),
cell("Code", options),
cell("Comments", options),
cell("Blank", options),
]);
if scan.code.languages.is_empty() {
table.add_row(vec![
cell("none", options),
Cell::new("0"),
Cell::new("0"),
Cell::new("0"),
Cell::new("0"),
Cell::new("0"),
]);
} else {
table.add_row(vec![
cell("Total", options),
Cell::new(scan.code.total_files),
Cell::new(scan.code.total_lines),
Cell::new(scan.code.code_lines),
Cell::new(scan.code.comment_lines),
Cell::new(scan.code.blank_lines),
]);
let mut languages = scan.code.languages.iter().collect::<Vec<_>>();
languages.sort_by(|left, right| {
right
.code_lines
.cmp(&left.code_lines)
.then_with(|| language_label(left.kind).cmp(language_label(right.kind)))
});
for language in languages {
table.add_row(vec![
cell(language_label(language.kind), options),
Cell::new(language.files),
Cell::new(language.total_lines),
Cell::new(language.code_lines),
Cell::new(language.comment_lines),
Cell::new(language.blank_lines),
]);
}
}
output.push_str(&table.to_string());
output
}
fn build_systems_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Build Systems", options);
let mut table = terminal_table(density, options);
table.set_header(vec![cell("System", options), cell("Manifests", options)]);
if scan.build_systems.is_empty() {
table.add_row(vec![cell("none", options), Cell::new("0")]);
} else {
for build_system in aggregate_build_systems(scan) {
table.add_row(vec![
cell(build_system.label, options),
Cell::new(build_system.count),
]);
}
}
output.push_str(&table.to_string());
output
}
fn dependencies_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Dependencies", options);
let mut table = terminal_table(density, options);
table.set_header(vec![
cell("Ecosystem", options),
cell("Manifests", options),
cell("Entries", options),
cell("Lockfiles", options),
cell("Missing", options),
]);
if scan.dependencies.ecosystems.is_empty() {
table.add_row(vec![
cell("none", options),
Cell::new("0"),
Cell::new("0"),
Cell::new("0"),
Cell::new("0"),
]);
} else {
for summary in aggregate_dependencies(scan) {
table.add_row(vec![
cell(summary.label, options),
Cell::new(summary.manifests),
Cell::new(summary.total_dependencies),
Cell::new(summary.lockfiles),
Cell::new(summary.missing_lockfiles),
]);
}
}
output.push_str(&table.to_string());
output
}
fn tests_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Tests", options);
let mut table = terminal_table(density, options);
table.set_header(vec![
cell("Metric", options),
cell("Count", options),
cell("Evidence", options),
]);
table.add_row(vec![
cell("Directories", options),
Cell::new(scan.tests.test_directories.len()),
Cell::new("test directories"),
]);
table.add_row(vec![
cell("Files", options),
Cell::new(scan.tests.test_files),
Cell::new("test source files"),
]);
if scan.tests.commands.is_empty() {
table.add_row(vec![
cell("Commands", options),
Cell::new("0"),
Cell::new("none detected"),
]);
} else {
for command in aggregate_test_commands(scan) {
table.add_row(vec![
cell("Command", options),
Cell::new(format!("{} source(s)", command.sources)),
Cell::new(command.command),
]);
}
}
output.push_str(&table.to_string());
output
}
fn risks_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Risks", options);
let mut table = terminal_table(density, options);
table.set_header(vec![
cell("Severity", options),
cell("Code", options),
cell("Count", options),
cell("Action", options),
]);
if scan.risks.findings.is_empty() {
table.add_row(vec![
severity_cell(RiskSeverity::Info, "INFO", options),
Cell::new("none"),
Cell::new("0"),
Cell::new("no action"),
]);
} else {
for risk in aggregate_risks(scan) {
table.add_row(vec![
severity_cell(risk.severity, risk_severity_label(risk.severity), options),
Cell::new(risk_code_label(risk.code)),
Cell::new(format!("{} finding(s)", risk.count)),
Cell::new(risk_action_label(risk.code)),
]);
}
}
output.push_str(&table.to_string());
output
}
fn details_table(
scan: &ProjectScan,
density: TableDensity,
options: TerminalRenderOptions,
) -> String {
let mut output = String::new();
push_section(&mut output, "Details", options);
let mut table = terminal_table(density, options);
table.set_header(vec![
cell("Kind", options),
cell("Path", options),
cell("Context", options),
]);
if let Some(path) = &scan.license.path {
table.add_row(vec![
cell("License path", options),
Cell::new(path.display()),
Cell::new(license_kind_label(scan.license.kind)),
]);
}
for summary in &scan.dependencies.ecosystems {
table.add_row(vec![
cell("Dependency manifest", options),
Cell::new(summary.manifest.display()),
Cell::new(dependency_label(summary.ecosystem)),
]);
if let Some(lockfile) = &summary.lockfile {
table.add_row(vec![
cell("Lockfile", options),
Cell::new(lockfile.display()),
Cell::new(dependency_label(summary.ecosystem)),
]);
}
}
for provider in &scan.ci.providers {
table.add_row(vec![
cell("CI path", options),
Cell::new(provider.path.display()),
Cell::new(ci_provider_label(provider.provider)),
]);
}
for command in &scan.tests.commands {
table.add_row(vec![
cell("Test source", options),
Cell::new(command.source.display()),
Cell::new(&command.command),
]);
}
for risk in &scan.risks.findings {
if let Some(path) = &risk.path {
table.add_row(vec![
cell("Risk path", options),
Cell::new(path.display()),
Cell::new(risk_code_label(risk.code)),
]);
}
}
if table.row_iter().next().is_none() {
table.add_row(vec![
cell("none", options),
Cell::new(""),
Cell::new("no detailed paths"),
]);
}
output.push_str(&table.to_string());
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 project_health_label(health: ProjectHealth) -> &'static str {
match health {
ProjectHealth::Healthy => "healthy",
ProjectHealth::NeedsAttention => "needs-attention",
ProjectHealth::Risky => "risky",
ProjectHealth::Unknown => "unknown",
}
}
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 risk_action_label(code: RiskCode) -> &'static str {
match code {
RiskCode::MissingReadme => "add README",
RiskCode::MissingLicense => "add LICENSE",
RiskCode::MissingCi => "add CI workflow",
RiskCode::NoTestsDetected => "add test command",
RiskCode::ManifestWithoutLockfile => "check lock policy",
RiskCode::LargeProjectWithoutIgnoreRules => "add ignore rules",
RiskCode::UnknownLicense => "review license text",
}
}
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(())
}