use std::fmt::Write as _;
use std::io::IsTerminal as _;
use anstyle::{AnsiColor, Style};
const BOX_WIDTH: usize = 72;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Preflight {
Ready,
NotARepository { repo_path: String },
EmptyRepository,
OutputNotWritable { path: String, reason: String },
RepoPathMissing { repo_path: String },
}
impl Preflight {
#[must_use]
pub fn is_ready(&self) -> bool {
matches!(self, Preflight::Ready)
}
}
#[derive(Debug, Clone)]
pub struct Banner<'a> {
pub codelore_version: &'a str,
pub gix_version: &'a str,
pub duckdb_version: &'a str,
pub repo_path: String,
pub branch: Option<String>,
pub head_short: Option<String>,
pub analysis: &'a str,
pub options_summary: String,
pub preflight: Preflight,
}
impl Banner<'_> {
#[must_use]
pub fn render(&self, use_color: bool) -> String {
let title = Style::new().bold();
let label = Style::new().dimmed();
let head_sha = Style::new().fg_color(Some(AnsiColor::Yellow.into())).bold();
let rule_style = Style::new().dimmed();
let ok = Style::new().fg_color(Some(AnsiColor::Green.into())).bold();
let fail = Style::new().fg_color(Some(AnsiColor::Red.into())).bold();
let s = |sty: Style| if use_color { sty } else { Style::new() };
let rule = "─".repeat(BOX_WIDTH);
let left = format!("codelore {}", self.codelore_version);
let right = format!("gix {} · duckdb {}", self.gix_version, self.duckdb_version);
let inner_width = BOX_WIDTH.saturating_sub(2);
let pad = inner_width
.saturating_sub(left.len())
.saturating_sub(right.len());
let mut out = String::with_capacity(BOX_WIDTH * 10);
let _ = writeln!(out, "{}{rule}{:#}", s(rule_style), s(rule_style));
let _ = writeln!(
out,
" {}{left}{:#}{} {}{right}{:#}",
s(title),
s(title),
" ".repeat(pad),
s(label),
s(label),
);
let _ = writeln!(out, "{}{rule}{:#}", s(rule_style), s(rule_style));
let _ = writeln!(
out,
" {}Repo:{:#} {}",
s(label),
s(label),
self.repo_path
);
let branch_line = match (&self.branch, &self.head_short) {
(Some(b), Some(sha)) => format!("{b} @ {}{sha}{:#}", s(head_sha), s(head_sha)),
(Some(b), None) => format!("{b} (no commits yet)"),
(None, Some(sha)) => format!("(detached) @ {}{sha}{:#}", s(head_sha), s(head_sha)),
(None, None) => "(none — no HEAD)".to_string(),
};
let _ = writeln!(out, " {}Branch:{:#} {}", s(label), s(label), branch_line);
let _ = writeln!(
out,
" {}Analysis:{:#} {} ({})",
s(label),
s(label),
self.analysis,
self.options_summary
);
let (mark_style, mark, status_text, hint) = match &self.preflight {
Preflight::Ready => (s(ok), "✓", "ready".to_string(), None),
Preflight::NotARepository { repo_path } => (
s(fail),
"✗",
"not a git repository".to_string(),
Some(format!(
"run codelore from the repository root, or pass --repo <repo-root>; \
only run `git init` in {repo_path} if it truly isn't a git repository yet"
)),
),
Preflight::EmptyRepository => (
s(fail),
"✗",
"repository has no commits".to_string(),
Some("codelore needs at least one commit to compute history-based metrics".into()),
),
Preflight::OutputNotWritable { path, reason } => (
s(fail),
"✗",
format!("--output not writable: {path}"),
Some(format!("reason: {reason}")),
),
Preflight::RepoPathMissing { repo_path } => (
s(fail),
"✗",
"repo path does not exist".to_string(),
Some(format!("no such directory: {repo_path}")),
),
};
let _ = writeln!(
out,
" {}Status:{:#} {}{mark}{:#} {status_text}",
s(label),
s(label),
mark_style,
mark_style,
);
if let Some(h) = hint {
let _ = writeln!(out, " {}Hint:{:#} {h}", s(label), s(label));
}
let _ = writeln!(out, "{}{rule}{:#}", s(rule_style), s(rule_style));
out
}
}
#[derive(Debug, Clone)]
pub struct Footer<'a> {
pub analysis: &'a str,
pub elapsed: std::time::Duration,
pub rows: Option<usize>,
}
impl Footer<'_> {
#[must_use]
pub fn render(&self, use_color: bool) -> String {
let ok = Style::new().fg_color(Some(AnsiColor::Green.into())).bold();
let label = Style::new().dimmed();
let rule_style = Style::new().dimmed();
let s = |sty: Style| if use_color { sty } else { Style::new() };
let rule = "─".repeat(BOX_WIDTH);
let elapsed = humanize_duration(self.elapsed);
let rows_fragment = match self.rows {
Some(n) => format!(" — {n} row{}", if n == 1 { "" } else { "s" }),
None => String::new(),
};
let mut out = String::with_capacity(BOX_WIDTH * 3);
let _ = writeln!(out, "{}{rule}{:#}", s(rule_style), s(rule_style));
let _ = writeln!(
out,
" {}✓{:#} {} completed in {}{rows_fragment}",
s(ok),
s(ok),
self.analysis,
elapsed,
);
let _ = writeln!(out, "{}{rule}{:#}", s(rule_style), s(rule_style));
let _ = label;
out
}
}
#[must_use]
pub fn humanize_duration(d: std::time::Duration) -> String {
let total_ms = d.as_millis();
if total_ms < 1 {
return "<1ms".to_string();
}
if total_ms < 1_000 {
return format!("{total_ms}ms");
}
let total_secs = d.as_secs_f64();
if total_secs < 60.0 {
return format!("{total_secs:.1}s");
}
let total_secs_int = d.as_secs();
let hours = total_secs_int / 3600;
let minutes = (total_secs_int % 3600) / 60;
let seconds = total_secs_int % 60;
if hours > 0 {
return format!("{hours}h {minutes}m {seconds}s");
}
format!("{minutes}m {seconds}s")
}
#[must_use]
pub fn should_color() -> bool {
if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) {
return false;
}
if std::env::var("CLICOLOR_FORCE").is_ok_and(|v| v != "0" && !v.is_empty()) {
return true;
}
std::io::stderr().is_terminal()
}
#[must_use]
pub fn should_print(quiet: bool, no_banner: bool) -> bool {
if quiet || no_banner {
return false;
}
std::io::stderr().is_terminal()
}
#[cfg(test)]
mod tests {
use super::*;
fn ready_fixture() -> Banner<'static> {
Banner {
codelore_version: "0.1.2",
gix_version: "0.85.0",
duckdb_version: "1.10504.0",
repo_path: "/Users/emrec/Projects/greenfield-api".to_string(),
branch: Some("feature/GFBUGS-132-tr2-licenses-modify-perm".to_string()),
head_short: Some("a891295".to_string()),
analysis: "hotspots",
options_summary: "min-revs=5, rows=10".to_string(),
preflight: Preflight::Ready,
}
}
#[test]
fn render_plain_is_pure_ascii_plus_box_drawing() {
let plain = ready_fixture().render(false);
assert!(
!plain.contains('\x1b'),
"plain render leaked an escape: {plain:?}"
);
for needle in [
"codelore 0.1.2",
"gix 0.85.0 · duckdb 1.10504.0",
"Repo:",
"Branch:",
"Analysis:",
"hotspots",
"min-revs=5, rows=10",
"Status:",
"ready",
"─",
] {
assert!(plain.contains(needle), "missing {needle:?} in:\n{plain}");
}
}
#[test]
fn render_styled_contains_ansi_escapes() {
let styled = ready_fixture().render(true);
assert!(
styled.contains('\x1b'),
"styled render produced no ANSI bytes"
);
assert!(styled.contains("codelore 0.1.2"));
assert!(styled.contains("hotspots"));
}
#[test]
fn detached_head_renders_without_branch() {
let mut b = ready_fixture();
b.branch = None;
let out = b.render(false);
assert!(out.contains("(detached)"));
assert!(out.contains("a891295"));
}
#[test]
fn not_a_repo_renders_failure_with_hint() {
let mut b = ready_fixture();
b.preflight = Preflight::NotARepository {
repo_path: "/tmp/random-folder".to_string(),
};
let out = b.render(false);
assert!(out.contains("✗"), "missing fail mark");
assert!(out.contains("not a git repository"));
assert!(out.contains("Hint:"));
assert!(out.contains("repository root"));
assert!(out.contains("--repo <repo-root>"));
let repo_root_idx = out
.find("repository root")
.expect("mentions repository root");
let git_init_idx = out.find("git init").expect("still mentions git init");
assert!(
repo_root_idx < git_init_idx,
"the repository-root remedy must lead; git init must be demoted after it: {out}"
);
}
#[test]
fn empty_repo_renders_failure_with_hint() {
let mut b = ready_fixture();
b.head_short = None;
b.preflight = Preflight::EmptyRepository;
let out = b.render(false);
assert!(out.contains("repository has no commits"));
assert!(out.contains("Hint:"));
assert!(out.contains("at least one commit"));
}
#[test]
fn output_not_writable_renders_failure_with_reason() {
let mut b = ready_fixture();
b.preflight = Preflight::OutputNotWritable {
path: "/root/out.csv".to_string(),
reason: "Permission denied (os error 13)".to_string(),
};
let out = b.render(false);
assert!(out.contains("--output not writable"));
assert!(out.contains("/root/out.csv"));
assert!(out.contains("Permission denied"));
}
#[test]
fn preflight_is_ready_classification() {
assert!(Preflight::Ready.is_ready());
assert!(!Preflight::EmptyRepository.is_ready());
assert!(
!Preflight::NotARepository {
repo_path: "x".into()
}
.is_ready()
);
}
#[test]
fn humanize_duration_breakpoints() {
use std::time::Duration;
assert_eq!(humanize_duration(Duration::from_micros(500)), "<1ms");
assert_eq!(humanize_duration(Duration::from_millis(234)), "234ms");
assert_eq!(humanize_duration(Duration::from_millis(4_300)), "4.3s");
assert_eq!(humanize_duration(Duration::new(59, 0)), "59.0s");
assert_eq!(humanize_duration(Duration::new(60, 0)), "1m 0s");
assert_eq!(humanize_duration(Duration::new(154, 0)), "2m 34s");
assert_eq!(humanize_duration(Duration::new(3_912, 0)), "1h 5m 12s");
}
#[test]
fn footer_renders_with_row_count() {
let f = Footer {
analysis: "hotspots",
elapsed: std::time::Duration::from_millis(4_321),
rows: Some(10),
};
let out = f.render(false);
assert!(out.contains("hotspots"));
assert!(out.contains("completed in 4.3s"));
assert!(out.contains("10 rows"));
assert!(!out.contains('\x1b'), "plain footer leaked an escape");
}
#[test]
fn footer_singular_row() {
let f = Footer {
analysis: "summary",
elapsed: std::time::Duration::from_millis(50),
rows: Some(1),
};
let out = f.render(false);
assert!(out.contains("1 row"));
assert!(!out.contains("rows"), "should be singular for n=1");
}
#[test]
fn footer_omits_row_count_when_none() {
let f = Footer {
analysis: "sqlite",
elapsed: std::time::Duration::from_secs(12),
rows: None,
};
let out = f.render(false);
assert!(out.contains("sqlite"));
assert!(out.contains("12.0s"));
assert!(!out.contains("row"));
}
}