use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::io::{self, IsTerminal, Stdout};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use comfy_table::{Attribute, Cell, ContentArrangement, Table, presets::UTF8_FULL};
use crossterm::{
cursor::{Hide, Show},
event::{self, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use projd_core::{
BuildSystemKind, CiProvider, Confidence, DependencyEcosystem, DiscoverOptions, DiscoverSummary,
DiscoveredRoot, LanguageKind, LicenseKind, ProjectHealth, ProjectKind, ProjectScan, RiskCode,
RiskSeverity, RootKind, category_token, discover_roots, relative_display, render_discover_json,
render_discover_markdown, render_json, render_markdown, scan_path, summarize_roots,
};
use ratatui::{
Frame, Terminal,
backend::{CrosstermBackend, TestBackend},
buffer::Buffer,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Sparkline, Wrap},
};
#[derive(Debug, Parser)]
#[command(name = "projd")]
#[command(version, about = projd_core::describe())]
#[command(after_help = "Examples:
projd scan .
projd scan . --format terminal --style cinematic
projd tui .
projd tui . --snapshot
projd completion zsh")]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
#[command(after_help = "Examples:
projd scan .
projd scan . --format terminal
projd scan . --format terminal --style cinematic
projd scan . --format json --output scan.json
projd scan . --format terminal --details")]
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,
},
#[command(after_help = "Examples:
projd tui .
projd tui . --snapshot
Controls:
q or Esc Quit
r Rescan
Tab Rotate focused panel")]
Tui {
path: PathBuf,
#[arg(long)]
snapshot: bool,
},
#[command(after_help = "Examples:
projd discover .
projd discover ~/code --include-kind cargo,npm
projd discover . --expand-workspaces
projd discover . --nested-vcs
projd discover . --min-confidence strong
projd discover . --format json")]
Discover {
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 = 8)]
max_depth: usize,
#[arg(long, default_value_t = ConfidenceFlag::Medium)]
min_confidence: ConfidenceFlag,
#[arg(long, value_delimiter = ',', num_args = 1..)]
include_kind: Vec<String>,
#[arg(long)]
expand_workspaces: bool,
#[arg(long)]
nested_vcs: bool,
},
#[command(after_help = "Examples:
projd completion zsh
projd completion bash
projd completion fish
projd completion zsh --print")]
Completion {
shell: Shell,
#[arg(long)]
print: 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,
Cinematic,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum ConfidenceFlag {
Weak,
Medium,
Strong,
}
impl std::fmt::Display for ConfidenceFlag {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let value = match self {
Self::Weak => "weak",
Self::Medium => "medium",
Self::Strong => "strong",
};
formatter.write_str(value)
}
}
impl ConfidenceFlag {
fn to_core(self) -> Confidence {
match self {
Self::Weak => Confidence::Weak,
Self::Medium => Confidence::Medium,
Self::Strong => Confidence::Strong,
}
}
}
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",
Self::Cinematic => "cinematic",
};
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)
}
Some(Command::Tui { path, snapshot }) => run_tui(path, snapshot),
Some(Command::Discover {
path,
format,
output,
overwrite,
no_unicode,
color,
max_depth,
min_confidence,
include_kind,
expand_workspaces,
nested_vcs,
}) => handle_discover(
path,
format,
output,
overwrite,
no_unicode,
color,
max_depth,
min_confidence,
include_kind,
expand_workspaces,
nested_vcs,
),
Some(Command::Completion { shell, print }) => handle_completion(shell, print),
None => {
println!("{} {}", projd_core::NAME, projd_core::VERSION);
println!("{}", projd_core::describe());
println!("Run `projd --help` for commands and examples.");
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")),
}
}
fn handle_completion(shell: Shell, print: bool) -> Result<()> {
if print {
let mut command = Cli::command();
generate(shell, &mut command, "projd", &mut io::stdout());
return Ok(());
}
let target = completion_target(shell)?;
let mut buffer = Vec::new();
let mut command = Cli::command();
generate(shell, &mut command, "projd", &mut buffer);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create `{}`", parent.display()))?;
}
fs::write(&target, buffer)
.with_context(|| format!("failed to write completion file `{}`", target.display()))?;
println!(
"Installed {shell} completions for projd at {}",
target.display()
);
if shell == Shell::Zsh {
println!("If completion is not active yet, ensure your .zshrc has:");
println!(" fpath=({} $fpath)", target.parent().unwrap().display());
println!(" autoload -Uz compinit");
println!(" compinit");
} else if shell == Shell::Bash {
println!("Open a new shell, or source the file to use it immediately.");
} else if shell == Shell::Fish {
println!(
"Open a new fish shell, or run `source {}`.",
target.display()
);
}
Ok(())
}
fn handle_discover(
path: PathBuf,
format: Option<OutputFormat>,
output: Option<PathBuf>,
overwrite: bool,
no_unicode: bool,
color: ColorChoice,
max_depth: usize,
min_confidence: ConfidenceFlag,
include_kind: Vec<String>,
expand_workspaces: bool,
nested_vcs: bool,
) -> Result<()> {
let stdout_is_terminal = io::stdout().is_terminal();
let format = choose_output_format(format, output.as_deref(), stdout_is_terminal);
let mut include_kinds: BTreeSet<RootKind> = BTreeSet::new();
for token in include_kind {
for piece in token.split(',') {
let piece = piece.trim();
if piece.is_empty() {
continue;
}
let kind = RootKind::from_token(piece)
.with_context(|| format!("unknown root kind `{piece}`"))?;
include_kinds.insert(kind);
}
}
let opts = DiscoverOptions {
max_depth,
min_confidence: min_confidence.to_core(),
include_kinds: if include_kinds.is_empty() {
None
} else {
Some(include_kinds)
},
expand_workspaces,
nested_vcs,
};
let canonical = fs::canonicalize(&path)
.with_context(|| format!("failed to resolve `{}`", path.display()))?;
let roots = discover_roots(&path, &opts)?;
let summary = summarize_roots(&roots);
let rendered = match format {
OutputFormat::Terminal => render_discover_terminal(
&canonical,
&roots,
&summary,
DiscoverTerminalOptions {
unicode: !no_unicode,
color: color.enabled(stdout_is_terminal),
},
),
OutputFormat::Markdown => render_discover_markdown(&canonical, &roots, &summary),
OutputFormat::Json => {
let json = render_discover_json(&canonical, &roots, &summary)?;
format!("{json}\n")
}
};
write_or_print(rendered, output, overwrite)
}
#[derive(Clone, Copy, Debug)]
struct DiscoverTerminalOptions {
unicode: bool,
color: bool,
}
fn render_discover_terminal(
root: &Path,
roots: &[DiscoveredRoot],
summary: &DiscoverSummary,
options: DiscoverTerminalOptions,
) -> String {
let _ = options.color; let mut out = String::new();
out.push_str("Projd Discover Report\n");
out.push_str(&format!("Root: {}\n", root.display()));
out.push_str(&format!("Found {} project root(s)\n", summary.total));
if !summary.by_confidence.is_empty() {
let parts: Vec<String> = [Confidence::Strong, Confidence::Medium, Confidence::Weak]
.iter()
.filter_map(|level| {
summary
.by_confidence
.get(level)
.map(|count| format!("{}: {}", level.token(), count))
})
.collect();
if !parts.is_empty() {
out.push_str(&format!("Confidence: {}\n", parts.join(" · ")));
}
}
if roots.is_empty() {
out.push('\n');
out.push_str("No project roots found.\n");
return out;
}
let mut table = Table::new();
if options.unicode {
table.load_preset(UTF8_FULL);
}
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec![
Cell::new("Path").add_attribute(Attribute::Bold),
Cell::new("Kinds").add_attribute(Attribute::Bold),
Cell::new("Confidence").add_attribute(Attribute::Bold),
Cell::new("Category").add_attribute(Attribute::Bold),
]);
for entry in roots {
let rel = relative_display(root, &entry.path);
let kinds: Vec<&'static str> = entry.kinds.iter().map(|k| k.token()).collect();
let mut cats: BTreeSet<&'static str> = BTreeSet::new();
for kind in &entry.kinds {
cats.insert(category_token(kind.category()));
}
let cats_vec: Vec<&'static str> = cats.into_iter().collect();
table.add_row(vec![
Cell::new(rel),
Cell::new(kinds.join(", ")),
Cell::new(entry.confidence.token()),
Cell::new(cats_vec.join(", ")),
]);
}
out.push('\n');
out.push_str(&table.to_string());
out.push('\n');
out
}
fn completion_target(shell: Shell) -> Result<PathBuf> {
let home = home_dir().context("could not determine home directory")?;
match shell {
Shell::Zsh => Ok(home.join(".zfunc").join("_projd")),
Shell::Bash => Ok(home
.join(".local")
.join("share")
.join("bash-completion")
.join("completions")
.join("projd")),
Shell::Fish => Ok(home
.join(".config")
.join("fish")
.join("completions")
.join("projd.fish")),
Shell::Elvish | Shell::PowerShell => {
bail!(
"automatic install for {shell} is not supported yet; use `projd completion {shell} --print`"
)
}
_ => bail!("automatic install for {shell} is not supported; use `--print`"),
}
}
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn run_tui(path: PathBuf, snapshot: bool) -> Result<()> {
let scan = scan_path(&path)?;
if snapshot {
print!("{}", render_tui_snapshot(&scan)?);
return Ok(());
}
if !io::stdout().is_terminal() {
bail!(
"projd tui requires an interactive terminal; use --snapshot for non-interactive output"
);
}
run_tui_interactive(path, scan)
}
fn run_tui_interactive(path: PathBuf, scan: ProjectScan) -> Result<()> {
let terminal_session = TerminalSession::enter()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend).context("failed to initialize terminal UI")?;
terminal.clear().context("failed to clear terminal UI")?;
let mut app = TuiApp::new(path, scan);
let tick_rate = Duration::from_millis(120);
let mut last_tick = Instant::now();
loop {
terminal
.draw(|frame| draw_tui(frame, &app))
.context("failed to draw terminal UI")?;
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if event::poll(timeout).context("failed to poll terminal events")?
&& let Event::Key(key) = event::read().context("failed to read terminal event")?
{
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('r') | KeyCode::Char('R') => app.rescan()?,
KeyCode::Tab => app.next_focus(),
_ => {}
}
}
if last_tick.elapsed() >= tick_rate {
app.tick();
last_tick = Instant::now();
}
}
drop(terminal_session);
Ok(())
}
fn render_tui_snapshot(scan: &ProjectScan) -> Result<String> {
let backend = TestBackend::new(110, 36);
let mut terminal = Terminal::new(backend).context("failed to initialize snapshot backend")?;
let app = TuiApp::snapshot(scan.clone());
terminal
.draw(|frame| draw_tui(frame, &app))
.context("failed to draw terminal snapshot")?;
Ok(buffer_to_string(terminal.backend().buffer()))
}
fn buffer_to_string(buffer: &Buffer) -> String {
let area = *buffer.area();
let mut output = String::new();
for y in area.y..area.y + area.height {
let mut line = String::new();
for x in area.x..area.x + area.width {
if let Some(cell) = buffer.cell((x, y)) {
line.push_str(cell.symbol());
}
}
output.push_str(line.trim_end());
output.push('\n');
}
output
}
struct TerminalSession;
impl TerminalSession {
fn enter() -> Result<Self> {
enable_raw_mode().context("failed to enable raw terminal mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, Hide)
.context("failed to enter alternate terminal screen")?;
Ok(Self)
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = disable_raw_mode();
let mut stdout: Stdout = io::stdout();
let _ = execute!(stdout, Show, LeaveAlternateScreen);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TuiFocus {
Signals,
Languages,
Alerts,
}
#[derive(Debug)]
struct TuiApp {
path: PathBuf,
scan: ProjectScan,
tick: u64,
focus: TuiFocus,
notice: Option<String>,
}
impl TuiApp {
fn new(path: PathBuf, scan: ProjectScan) -> Self {
Self {
path,
scan,
tick: 0,
focus: TuiFocus::Signals,
notice: None,
}
}
fn snapshot(scan: ProjectScan) -> Self {
Self {
path: scan.root.clone(),
scan,
tick: 24,
focus: TuiFocus::Signals,
notice: None,
}
}
fn rescan(&mut self) -> Result<()> {
self.scan = scan_path(&self.path)?;
self.tick = 0;
self.notice = Some("scan refreshed".to_owned());
Ok(())
}
fn tick(&mut self) {
self.tick = self.tick.saturating_add(1);
if self.tick > 8 {
self.notice = None;
}
}
fn next_focus(&mut self) {
self.focus = match self.focus {
TuiFocus::Signals => TuiFocus::Languages,
TuiFocus::Languages => TuiFocus::Alerts,
TuiFocus::Alerts => TuiFocus::Signals,
};
}
fn reveal_percent(&self) -> u16 {
((self.tick.min(20) * 5) as u16).min(100)
}
fn animated_score(&self) -> u16 {
let target = self.scan.health.score.min(100) as u16;
((u32::from(target) * u32::from(self.reveal_percent())) / 100) as u16
}
fn visible_count(&self, total: usize) -> usize {
total.min(((self.tick as usize) / 2).saturating_add(1))
}
}
fn draw_tui(frame: &mut Frame<'_>, app: &TuiApp) {
let area = frame.area();
let root = Block::default()
.title(Line::from(" PROJD // LIVE PROJECT DIAGNOSTIC "))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = root.inner(area);
frame.render_widget(root, area);
if inner.width < 72 || inner.height < 24 {
let compact = Paragraph::new(vec![
Line::from("PROJD // LIVE PROJECT DIAGNOSTIC"),
Line::from("terminal window too small"),
Line::from("q quit r rescan tab focus"),
])
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Cyan));
frame.render_widget(compact, inner);
return;
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(8),
Constraint::Min(10),
Constraint::Length(5),
])
.margin(1)
.split(inner);
draw_tui_header(frame, rows[0], app);
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(44), Constraint::Percentage(56)])
.split(rows[1]);
draw_tui_health(frame, top[0], app);
draw_tui_identity(frame, top[1], app);
let middle = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
.split(rows[2]);
draw_tui_signals(frame, middle[0], app);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(52), Constraint::Percentage(48)])
.split(middle[1]);
draw_tui_languages(frame, right[0], app);
draw_tui_telemetry(frame, right[1], app);
draw_tui_alerts(frame, rows[3], app);
}
fn draw_tui_header(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) {
let version = app
.scan
.identity
.version
.as_deref()
.map(|version| format!(" v{version}"))
.unwrap_or_default();
let mut lines = vec![
Line::from(vec![
Span::styled(
"PROJD // LIVE PROJECT DIAGNOSTIC",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
frame_phase(app.tick),
Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(format!(
"{}{} | {} | {} files scanned",
app.scan.identity.name,
version,
project_kind_label(app.scan.identity.kind),
app.scan.files_scanned
)),
];
if let Some(notice) = &app.notice {
lines.push(Line::from(vec![
Span::styled("NOTICE ", Style::default().fg(Color::Yellow)),
Span::raw(notice.clone()),
]));
} else {
lines.push(Line::from("q quit r rescan tab focus"));
}
let header = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue)),
)
.wrap(Wrap { trim: true });
frame.render_widget(header, area);
}
fn draw_tui_health(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) {
let score = app.animated_score();
let health_color = health_color(app.scan.health.grade);
let block = focus_block("HEALTH CORE", app.focus == TuiFocus::Signals);
let gauge = Gauge::default()
.block(block)
.gauge_style(
Style::default()
.fg(health_color)
.bg(Color::Black)
.add_modifier(Modifier::BOLD),
)
.label(format!(
"HEALTH :: {:03}/100 {}",
score,
project_health_label(app.scan.health.grade)
))
.percent(score);
frame.render_widget(gauge, area);
}
fn draw_tui_identity(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) {
let providers = ci_provider_labels(&app.scan);
let build_systems = aggregate_build_systems(&app.scan)
.into_iter()
.map(|item| format!("{} x{}", item.label, item.count))
.collect::<Vec<_>>();
let dependency_line = format!(
"{} manifest(s), {} {}",
app.scan.dependencies.total_manifests,
app.scan.dependencies.total_dependencies,
pluralize(app.scan.dependencies.total_dependencies, "entry", "entries")
);
let lines = vec![
Line::from(vec![
Span::styled("ROOT ", Style::default().fg(Color::DarkGray)),
Span::raw(app.scan.root.display().to_string()),
]),
Line::from(vec![
Span::styled("RISK ", Style::default().fg(Color::DarkGray)),
Span::styled(
risk_severity_label(app.scan.health.risk_level),
Style::default().fg(risk_color(app.scan.health.risk_level)),
),
]),
Line::from(vec![
Span::styled("BUILD ", Style::default().fg(Color::DarkGray)),
Span::raw(if build_systems.is_empty() {
"none".to_owned()
} else {
build_systems.join(" / ")
}),
]),
Line::from(vec![
Span::styled("DEPS ", Style::default().fg(Color::DarkGray)),
Span::raw(dependency_line),
]),
Line::from(vec![
Span::styled("CI ", Style::default().fg(Color::DarkGray)),
Span::raw(if providers.is_empty() {
"none".to_owned()
} else {
providers.join(" / ")
}),
]),
];
let panel = Paragraph::new(lines)
.block(panel_block("MISSION BRIEF"))
.wrap(Wrap { trim: true });
frame.render_widget(panel, area);
}
fn draw_tui_signals(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) {
let rows = signal_rows(&app.scan);
let visible = app.visible_count(rows.len());
let items = rows
.into_iter()
.take(visible)
.map(|row| {
let pulse = match row.status {
SignalStatus::Ok => Span::styled("ONLINE ", Style::default().fg(Color::Green)),
SignalStatus::Warn => Span::styled("DEGRADED ", Style::default().fg(Color::Yellow)),
SignalStatus::Info => Span::styled("UNKNOWN ", Style::default().fg(Color::Blue)),
};
ListItem::new(Line::from(vec![
Span::styled(
format!("{:<10}", row.signal),
Style::default().fg(Color::Cyan),
),
pulse,
Span::raw(format!("{} ({})", row.evidence, row.impact)),
]))
})
.collect::<Vec<_>>();
let list = List::new(items).block(focus_block("SIGNAL MATRIX", app.focus == TuiFocus::Signals));
frame.render_widget(list, area);
}
fn draw_tui_languages(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) {
let total_files = app
.scan
.languages
.iter()
.map(|language| language.files)
.sum::<usize>();
let mut languages = app.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)))
});
let mut lines = Vec::new();
for language in languages.into_iter().take(6) {
let pct = percentage(language.files, total_files);
lines.push(Line::from(vec![
Span::styled(
format!("{:<11}", language_label(language.kind)),
Style::default().fg(Color::Cyan),
),
Span::raw(format!(" {:>3}% ", pct)),
Span::styled(
mini_bar(pct, 18, app.reveal_percent()),
Style::default().fg(Color::LightBlue),
),
Span::raw(format!(" {} file(s)", language.files)),
]));
}
if lines.is_empty() {
lines.push(Line::from("no language signatures detected"));
}
let panel = Paragraph::new(lines)
.block(focus_block(
"LANGUAGE SPECTRUM",
app.focus == TuiFocus::Languages,
))
.wrap(Wrap { trim: true });
frame.render_widget(panel, area);
}
fn draw_tui_telemetry(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Min(3)])
.split(area);
let spark_data = sparkline_data(app);
let sparkline = Sparkline::default()
.block(panel_block("BUILD + DEPENDENCY GRID"))
.data(spark_data)
.max(100)
.style(Style::default().fg(Color::LightMagenta));
frame.render_widget(sparkline, chunks[0]);
let telemetry = Paragraph::new(vec![
Line::from(format!(
"code field {} source file(s), {} code lines, {} comment lines",
app.scan.code.total_files, app.scan.code.code_lines, app.scan.code.comment_lines
)),
Line::from(format!(
"tests {} file(s), {} command source(s)",
app.scan.tests.test_files,
app.scan.tests.commands.len()
)),
Line::from(format!(
"vcs {} {}",
app.scan.vcs.kind.label(),
if app.scan.vcs.is_repository {
vcs_worktree_status(&app.scan)
} else {
"not detected".to_owned()
}
)),
])
.block(panel_block("TEST + CI TELEMETRY"))
.wrap(Wrap { trim: true });
frame.render_widget(telemetry, chunks[1]);
}
fn draw_tui_alerts(frame: &mut Frame<'_>, area: Rect, app: &TuiApp) {
let risks = aggregate_risks(&app.scan);
let visible = app.visible_count(risks.len().max(1));
let mut lines = vec![Line::from(format!(
"high {} / medium {} / low {} / info {}",
app.scan.risks.high, app.scan.risks.medium, app.scan.risks.low, app.scan.risks.info
))];
if risks.is_empty() {
lines.push(Line::from(vec![Span::styled(
"NO ACTIVE RISK FINDINGS",
Style::default().fg(Color::Green),
)]));
} else {
for risk in risks.into_iter().take(visible).take(3) {
lines.push(Line::from(vec![
Span::styled(
format!("{:<7}", risk_severity_label(risk.severity)),
Style::default().fg(risk_color(risk.severity)),
),
Span::raw(format!(
" {:<30} {} finding(s) -> {}",
risk_code_label(risk.code),
risk.count,
risk_action_label(risk.code)
)),
]));
}
}
let panel = Paragraph::new(lines)
.block(focus_block("ALERT FEED", app.focus == TuiFocus::Alerts))
.wrap(Wrap { trim: true });
frame.render_widget(panel, area);
}
#[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),
TerminalStyle::Cinematic => render_terminal_cinematic(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.vcs.is_repository {
output.push_str(&format!(
" {:<12} {} repo\n",
"Type",
scan.vcs.kind.label()
));
output.push_str(&format!(
" {:<12} {}\n",
scan.vcs.kind.ref_label(),
scan.vcs.branch.as_deref().unwrap_or("unknown")
));
if let Some(revision) = &scan.vcs.revision {
output.push_str(&format!(
" {:<12} {}\n",
"Revision",
short_revision(revision)
));
}
let status = if scan.vcs.is_dirty {
format!(
"dirty, {} modified, {} untracked",
scan.vcs.tracked_modified_files, scan.vcs.untracked_files
)
} else {
"clean".to_owned()
};
output.push_str(&format!(" {:<12} {}\n", "Status", status));
if let Some(last_commit) = &scan.vcs.last_commit {
output.push_str(&format!(" {:<12} {}\n", "Last commit", last_commit));
}
if options.details
&& let Some(root) = &scan.vcs.root
{
output.push_str(&format!(" {:<12} {}\n", "VCS root", root.display()));
}
} else {
output.push_str(" Source ctrl none\n");
if options.details {
output.push_str(" VCS 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 render_terminal_cinematic(scan: &ProjectScan, options: TerminalRenderOptions) -> String {
let mut output = String::new();
let width = cinematic_width(options);
let version = scan
.identity
.version
.as_deref()
.map(|version| format!(" v{version}"))
.unwrap_or_default();
let project_line = format!(
"{}{} / {} / {} files scanned",
scan.identity.name,
version,
project_kind_label(scan.identity.kind),
scan.files_scanned
);
push_cinematic_rule(&mut output, width, options);
push_cinematic_center(
&mut output,
width,
&style("PROJD // PROJECT DIAGNOSTIC", AnsiStyle::BoldCyan, options),
options,
);
push_cinematic_center(&mut output, width, &project_line, options);
push_cinematic_rule(&mut output, width, options);
output.push('\n');
push_cinematic_kv(
&mut output,
"ROOT",
&scan.root.display().to_string(),
options,
);
push_cinematic_kv(
&mut output,
"HEALTH",
&format!(
"{} {}",
style(
&format!("{:03}/100", scan.health.score),
health_style(scan.health.grade),
options
),
project_health_label(scan.health.grade)
),
options,
);
push_cinematic_kv(
&mut output,
"RISK",
&style(
risk_severity_label(scan.health.risk_level),
risk_severity_style(scan.health.risk_level),
options,
),
options,
);
if scan.vcs.is_repository {
push_cinematic_kv(
&mut output,
"VCS",
&format!(
"{} {} / {}",
scan.vcs.kind.label().to_ascii_uppercase(),
scan.vcs.branch.as_deref().unwrap_or("unknown"),
vcs_worktree_status(scan)
),
options,
);
} else {
push_cinematic_kv(&mut output, "VCS", "not detected", options);
}
output.push('\n');
push_cinematic_section(&mut output, "SIGNAL MATRIX", width, options);
for row in signal_rows(scan) {
let pulse = match row.status {
SignalStatus::Ok => style("ONLINE", AnsiStyle::Green, options),
SignalStatus::Warn => style("DEGRADED", AnsiStyle::Yellow, options),
SignalStatus::Info => style("UNKNOWN", AnsiStyle::Blue, options),
};
output.push_str(&format!(
" {:<10} {:<18} {} ({})\n",
row.signal, pulse, row.evidence, row.impact
));
}
output.push('\n');
push_cinematic_section(&mut output, "LANGUAGE SPECTRUM", width, options);
let total_files = scan
.languages
.iter()
.map(|language| language.files)
.sum::<usize>();
if scan.languages.is_empty() {
output.push_str(" no language signatures detected\n");
} else {
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.into_iter().take(6) {
output.push_str(&format!(
" {:<12} {} {:>3}% {:>4} file(s)\n",
language_label(language.kind),
style(
&bar(language.files, total_files, options),
AnsiStyle::Blue,
options
),
percentage(language.files, total_files),
language.files
));
}
}
output.push('\n');
push_cinematic_section(&mut output, "BUILD + DEPENDENCY GRID", width, options);
let build_summary = aggregate_build_systems(scan)
.into_iter()
.map(|item| format!("{} x{}", item.label, item.count))
.collect::<Vec<_>>();
output.push_str(&format!(
" build systems {}\n",
if build_summary.is_empty() {
"none".to_owned()
} else {
build_summary.join(" / ")
}
));
output.push_str(&format!(
" dependencies {} manifest(s), {} {}\n",
scan.dependencies.total_manifests,
scan.dependencies.total_dependencies,
pluralize(scan.dependencies.total_dependencies, "entry", "entries")
));
output.push_str(&format!(
" code field {} source file(s), {} code lines, {} comment lines\n",
scan.code.total_files, scan.code.code_lines, scan.code.comment_lines
));
output.push('\n');
push_cinematic_section(&mut output, "TEST + CI TELEMETRY", width, options);
output.push_str(&format!(
" tests {} file(s), {} command source(s)\n",
scan.tests.test_files,
scan.tests.commands.len()
));
let providers = ci_provider_labels(scan);
output.push_str(&format!(
" ci channels {}\n",
if providers.is_empty() {
"none".to_owned()
} else {
providers.join(" / ")
}
));
output.push('\n');
push_cinematic_section(&mut output, "ALERT FEED", width, options);
if scan.risks.findings.is_empty() {
output.push_str(&format!(
" {}\n",
style("NO ACTIVE RISK FINDINGS", AnsiStyle::Green, options)
));
} else {
output.push_str(&format!(
" high {} / medium {} / low {} / info {}\n",
scan.risks.high, scan.risks.medium, scan.risks.low, scan.risks.info
));
for risk in aggregate_risks(scan).into_iter().take(6) {
output.push_str(&format!(
" {:<8} {:<32} {} finding(s) -> {}\n",
style(
risk_severity_label(risk.severity),
risk_severity_style(risk.severity),
options
),
risk_code_label(risk.code),
risk.count,
risk_action_label(risk.code)
));
}
}
if options.details {
output.push('\n');
push_cinematic_section(&mut output, "EVIDENCE TRACE", width, options);
if let Some(path) = &scan.license.path {
output.push_str(&format!(" license {}\n", path.display()));
}
for provider in &scan.ci.providers {
output.push_str(&format!(
" ci path {} ({})\n",
provider.path.display(),
ci_provider_label(provider.provider)
));
}
for command in &scan.tests.commands {
output.push_str(&format!(
" test source {} ({})\n",
command.source.display(),
command.command
));
}
for risk in &scan.risks.findings {
if let Some(path) = &risk.path {
output.push_str(&format!(
" risk path {} ({})\n",
path.display(),
risk_code_label(risk.code)
));
}
}
}
output.push('\n');
push_cinematic_rule(&mut output, width, options);
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: "VCS",
status: vcs_signal_status(scan),
evidence: vcs_evidence(scan),
impact: if scan.vcs.is_repository && !scan.vcs.is_dirty {
"+15"
} else if scan.vcs.is_repository {
"+5"
} else {
"0"
},
},
]
}
fn bool_status(ok: bool) -> SignalStatus {
if ok {
SignalStatus::Ok
} else {
SignalStatus::Warn
}
}
fn vcs_signal_status(scan: &ProjectScan) -> SignalStatus {
if !scan.vcs.is_repository {
SignalStatus::Info
} else if scan.vcs.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 vcs_evidence(scan: &ProjectScan) -> String {
if !scan.vcs.is_repository {
return "no source control detected".to_owned();
}
let label = scan.vcs.kind.label();
let reference = scan.vcs.branch.as_deref().unwrap_or("unknown ref");
if scan.vcs.is_dirty {
format!(
"{label} {reference}, dirty, {} modified, {} untracked",
scan.vcs.tracked_modified_files, scan.vcs.untracked_files
)
} else {
format!("{label} {reference}, clean")
}
}
fn short_revision(revision: &str) -> String {
let trimmed = revision.trim();
let truncated: String = trimmed.chars().take(12).collect();
if trimmed.chars().count() > truncated.chars().count() {
format!("{truncated}…")
} else {
truncated
}
}
fn vcs_worktree_status(scan: &ProjectScan) -> String {
if scan.vcs.is_dirty {
format!(
"dirty, {} modified, {} untracked",
scan.vcs.tracked_modified_files, scan.vcs.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.vcs.is_repository {
table.add_row(vec![
cell("Kind", options),
Cell::new(scan.vcs.kind.label()),
]);
table.add_row(vec![
cell(scan.vcs.kind.ref_label(), options),
Cell::new(scan.vcs.branch.as_deref().unwrap_or("unknown")),
]);
if let Some(revision) = &scan.vcs.revision {
table.add_row(vec![
cell("Revision", options),
Cell::new(short_revision(revision)),
]);
}
table.add_row(vec![
cell("Status", options),
Cell::new(vcs_worktree_status(scan)),
]);
if let Some(last_commit) = &scan.vcs.last_commit {
table.add_row(vec![cell("Last commit", options), Cell::new(last_commit)]);
}
if options.details
&& let Some(root) = &scan.vcs.root
{
table.add_row(vec![cell("VCS root", options), Cell::new(root.display())]);
}
} else {
table.add_row(vec![cell("Kind", options), Cell::new("none")]);
if options.details {
table.add_row(vec![cell("VCS 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 cinematic_width(options: TerminalRenderOptions) -> usize {
(options.bar_width + 38).clamp(48, 76)
}
fn push_cinematic_rule(output: &mut String, width: usize, options: TerminalRenderOptions) {
let (left, fill, right) = if options.unicode {
("╔", "═", "╗")
} else {
("+", "=", "+")
};
output.push_str(left);
output.push_str(&fill.repeat(width.saturating_sub(2)));
output.push_str(right);
output.push('\n');
}
fn push_cinematic_center(
output: &mut String,
width: usize,
value: &str,
options: TerminalRenderOptions,
) {
let visual_len = strip_ansi(value).chars().count();
let inner_width = width.saturating_sub(4).max(visual_len);
let left_pad = inner_width.saturating_sub(visual_len) / 2;
let right_pad = inner_width.saturating_sub(visual_len + left_pad);
let (left, right) = if options.unicode {
("║", "║")
} else {
("|", "|")
};
output.push_str(left);
output.push(' ');
output.push_str(&" ".repeat(left_pad));
output.push_str(value);
output.push_str(&" ".repeat(right_pad));
output.push(' ');
output.push_str(right);
output.push('\n');
}
fn push_cinematic_kv(
output: &mut String,
label: &str,
value: &str,
options: TerminalRenderOptions,
) {
output.push_str(" ");
output.push_str(&style(label, AnsiStyle::BoldCyan, options));
output.push_str(" :: ");
output.push_str(value);
output.push('\n');
}
fn push_cinematic_section(
output: &mut String,
label: &str,
width: usize,
options: TerminalRenderOptions,
) {
let marker = if options.unicode { "▸" } else { ">" };
let rule = if options.unicode { "─" } else { "-" };
let line_len = width.saturating_sub(label.len() + 6).max(6);
output.push_str(&style(
&format!("{marker} {label} {}", rule.repeat(line_len)),
AnsiStyle::BoldCyan,
options,
));
output.push('\n');
}
fn panel_block(title: &'static str) -> Block<'static> {
Block::default()
.title(Line::from(title))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue))
}
fn focus_block(title: &'static str, focused: bool) -> Block<'static> {
let style = if focused {
Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Blue)
};
Block::default()
.title(Line::from(title))
.borders(Borders::ALL)
.border_style(style)
}
fn frame_phase(tick: u64) -> &'static str {
match tick % 4 {
0 => "[SCAN]",
1 => "[TRACE]",
2 => "[LOCK]",
_ => "[SYNC]",
}
}
fn health_color(health: ProjectHealth) -> Color {
match health {
ProjectHealth::Healthy => Color::Green,
ProjectHealth::NeedsAttention => Color::Yellow,
ProjectHealth::Risky => Color::Red,
ProjectHealth::Unknown => Color::DarkGray,
}
}
fn risk_color(severity: RiskSeverity) -> Color {
match severity {
RiskSeverity::High => Color::Red,
RiskSeverity::Medium => Color::Yellow,
RiskSeverity::Low => Color::Blue,
RiskSeverity::Info => Color::DarkGray,
}
}
fn mini_bar(percent: usize, width: usize, reveal_percent: u16) -> String {
let visible_percent = ((percent as u32 * u32::from(reveal_percent)) / 100) as usize;
let filled = ((visible_percent * width) + 50) / 100;
let empty = width.saturating_sub(filled);
format!("{}{}", "█".repeat(filled), "░".repeat(empty))
}
fn sparkline_data(app: &TuiApp) -> Vec<u64> {
let base = [
app.scan.health.score as u64,
app.scan.code.total_files.min(100) as u64,
app.scan.code.code_lines.min(100) as u64,
app.scan.dependencies.total_manifests.min(100) as u64,
app.scan.dependencies.total_dependencies.min(100) as u64,
app.scan.tests.test_files.min(100) as u64,
app.scan.tests.commands.len().min(100) as u64,
ci_provider_count(&app.scan).min(100) as u64,
app.scan.risks.findings.len().min(100) as u64,
app.scan.files_scanned.min(100) as u64,
];
let reveal = u64::from(app.reveal_percent());
base.into_iter()
.enumerate()
.map(|(index, value)| {
let pulse = ((app.tick + index as u64) % 7) * 3;
((value * reveal) / 100 + pulse).min(100)
})
.collect()
}
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 health_style(health: ProjectHealth) -> AnsiStyle {
match health {
ProjectHealth::Healthy => AnsiStyle::Green,
ProjectHealth::NeedsAttention => AnsiStyle::Yellow,
ProjectHealth::Risky => AnsiStyle::Red,
ProjectHealth::Unknown => 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 strip_ansi(value: &str) -> String {
let mut stripped = String::new();
let mut chars = value.chars().peekable();
while let Some(value) = chars.next() {
if value == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for value in chars.by_ref() {
if value == 'm' {
break;
}
}
} else {
stripped.push(value);
}
}
stripped
}
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 pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str {
if count == 1 { singular } else { plural }
}
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(())
}