use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use badge_maker::{BadgeBuilder, Logo, Style as BmStyle};
use aristo_core::badge::{compute_tier, Tier, TierComputation};
use aristo_core::index::{IdNamespace, IndexEntry, IndexFile, Status};
use aristo_core::walk::{count_fns_per_module_with, WalkOptions};
use crate::commands::index::workspace_or_error;
use crate::commands::show::read_index;
use crate::preflight::{emit_advisory_if_stale, freshness_check};
use crate::{CliError, CliResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Style {
Flat,
FlatSquare,
Plastic,
}
impl Style {
pub(crate) fn parse(raw: &str) -> Result<Self, String> {
match raw {
"flat" => Ok(Self::Flat),
"flat-square" => Ok(Self::FlatSquare),
"plastic" => Ok(Self::Plastic),
other => Err(format!(
"unknown --style `{other}`; expected `flat`, `flat-square`, or `plastic`"
)),
}
}
pub(crate) fn label(self) -> &'static str {
match self {
Self::Flat => "flat",
Self::FlatSquare => "flat-square",
Self::Plastic => "plastic",
}
}
fn to_badge_maker(self) -> BmStyle {
match self {
Self::Flat => BmStyle::Flat,
Self::FlatSquare => BmStyle::FlatSquare,
Self::Plastic => BmStyle::Plastic,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Metric {
Tier,
Count,
Rate,
}
impl Metric {
pub(crate) fn parse(raw: &str) -> Result<Self, String> {
match raw {
"tier" => Ok(Self::Tier),
"count" => Ok(Self::Count),
"rate" => Ok(Self::Rate),
other => Err(format!(
"unknown --metric `{other}`; expected `count`, `rate`, or `tier`"
)),
}
}
}
pub(crate) fn run(out: Option<PathBuf>, style: Style, metric: Metric) -> CliResult<()> {
let ws = workspace_or_error()?;
emit_advisory_if_stale(&freshness_check(&ws));
let index = read_index(&ws.index_path())?;
let counters = Counters::from(&index);
let fn_counts =
count_fns_per_module_with(&ws.root, &WalkOptions::none()).map_err(|e| CliError::Other {
message: format!("failed to walk source for badge coverage: {e}"),
exit_code: 1,
})?;
let default_method = ws.load_config().verify.default_method;
let computation = compute_tier(&index, &fn_counts, default_method);
let svg = render_svg(&counters, &computation, style, metric);
match out {
Some(path) => write_to_file(&ws.root, &path, &svg, &counters, &computation, style),
None => write_to_stdout(&svg),
}
}
fn write_to_file(
root: &Path,
out_rel: &Path,
svg: &str,
counters: &Counters,
computation: &TierComputation,
style: Style,
) -> CliResult<()> {
let abs = if out_rel.is_absolute() {
out_rel.to_path_buf()
} else {
root.join(out_rel)
};
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).map_err(CliError::Io)?;
}
fs::write(&abs, svg).map_err(CliError::Io)?;
println!("→ Reading .aristo/index.toml … ok");
println!(
"→ Computing metrics: aristos-count={}, verification-rate={}%, score={:.2}, tier={}",
counters.aristos_count,
counters.verification_rate_pct,
computation.visible_score,
computation.tier.label(),
);
println!("→ Writing {} ({} style)", out_rel.display(), style.label(),);
println!("ok: badge written. Embed in README:");
println!();
println!(" ", out_rel.display());
let _ = root;
Ok(())
}
#[aristo::intent(
"When `--out` is omitted, the SVG goes to stdout and ALL progress / \
advisory output goes to stderr — never to stdout. A regression that \
emitted a progress line to stdout in this mode would corrupt the \
SVG, breaking any consumer that pipes `aristo badge > foo.svg`. \
The freshness-preflight advisory already lives on stderr; the \
badge command MUST inherit that discipline for the no-`--out` path.",
verify = "neural",
id = "badge_stdout_mode_keeps_svg_uncorrupted"
)]
fn write_to_stdout(svg: &str) -> CliResult<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
handle.write_all(svg.as_bytes()).map_err(CliError::Io)?;
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Counters {
pub total: usize,
pub aristos_count: usize,
pub verification_rate_pct: u32,
}
impl Counters {
pub(crate) fn from(index: &IndexFile) -> Self {
let mut total = 0usize;
let mut aristos_count = 0usize;
let mut verified_or_better = 0usize;
let mut intent_total = 0usize;
for (id, entry) in &index.entries {
total += 1;
if matches!(id.namespace(), IdNamespace::Aristos) {
aristos_count += 1;
}
if let IndexEntry::Intent(e) = entry {
intent_total += 1;
if is_verified_state(e.status) {
verified_or_better += 1;
}
}
}
let verification_rate_pct = if intent_total == 0 {
0
} else {
((verified_or_better as f64 / intent_total as f64) * 100.0).round() as u32
};
Self {
total,
aristos_count,
verification_rate_pct,
}
}
}
#[aristo::intent(
"`verification-rate` counts only intents (not assumes — assumes are \
external invariants, never internally verified per A5) and only \
terminal-clean statuses (Verified / Tested / Neural). Including \
Unknown / Stale / Counterexample / Orphan / Forged / PendingDeepen / \
Inconclusive would inflate the badge's headline number with \
non-verified annotations, defeating the public-trust signal the \
badge exists to broadcast.",
verify = "neural",
id = "badge_verification_rate_counts_only_terminal_clean_intents"
)]
fn is_verified_state(status: Status) -> bool {
matches!(status, Status::Verified | Status::Tested | Status::Neural)
}
const LABEL: &str = "aristo";
const LABEL_COLOR: &str = "#2b2824";
const BRIDGE_GLYPH: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 16.5 Q12 5 21 16.5"/><path d="M3 16.5h18"/><path d="M8 11v5.5"/><path d="M12 8v8.5"/><path d="M16 11v5.5"/></svg>"##;
fn render_svg(
counters: &Counters,
computation: &TierComputation,
style: Style,
metric: Metric,
) -> String {
let value = headline_value(counters, computation, metric);
let color = value_color(computation.tier, metric);
BadgeBuilder::new()
.label(LABEL)
.message(&value)
.label_color_parse(LABEL_COLOR)
.color_parse(color)
.style(style.to_badge_maker())
.logo(Logo::SVGLogo {
svg: BRIDGE_GLYPH.to_string(),
color: None,
width: 14,
padding: 3,
})
.build()
.expect("badge inputs are fixed valid colors + style")
.svg()
}
fn headline_value(counters: &Counters, computation: &TierComputation, metric: Metric) -> String {
match metric {
Metric::Tier => computation.tier.label().to_string(),
Metric::Count => format!("✓ {}", counters.total),
Metric::Rate => format!("{}%", counters.verification_rate_pct),
}
}
fn value_color(tier: Tier, metric: Metric) -> &'static str {
match metric {
Metric::Tier => tier.color_hex(),
Metric::Count | Metric::Rate => "#4c1",
}
}
#[cfg(test)]
mod tests {
use super::*;
use aristo_core::index::{
AnnotationId, ArtaId, AssumeEntry, BindingState, CommitHash, CoveredRegion, IntentEntry,
Meta, Sha256, VerifiedOutcome, VerifyLevel, VerifyMethod,
};
use std::collections::BTreeMap;
fn sha(c: char) -> Sha256 {
Sha256::parse(&format!("sha256:{}", c.to_string().repeat(64))).unwrap()
}
fn intent(verify: VerifyLevel, status: Status, server_bound: bool) -> IndexEntry {
IndexEntry::Intent(IntentEntry {
text: "x".into(),
verify,
status,
text_hash: sha('a'),
body_hash: sha('b'),
file: "src/lib.rs".into(),
site: "fn x (line 1)".into(),
covered_region: CoveredRegion::Function,
binding: if server_bound {
BindingState::Certified {
linked: ArtaId::parse("arta_op4q3z9NbV").unwrap(),
verified_outcome: VerifiedOutcome::parse(&format!("v1:{}", "A".repeat(86)))
.unwrap(),
last_verified_at_commit: CommitHash::parse(&"a".repeat(40)).unwrap(),
}
} else {
BindingState::Local
},
parent: None,
last_critiqued_at_text_hash: None,
last_critique_finding_count: None,
})
}
fn assume() -> IndexEntry {
IndexEntry::Assume(AssumeEntry {
text: "y".into(),
status: Status::Unknown,
text_hash: sha('a'),
body_hash: sha('b'),
file: "src/lib.rs".into(),
site: "fn y (line 2)".into(),
covered_region: CoveredRegion::Function,
linked: None,
parent: None,
})
}
fn make_index(entries: Vec<(&str, IndexEntry)>) -> IndexFile {
let mut map = BTreeMap::new();
for (id, entry) in entries {
map.insert(AnnotationId::parse(id).unwrap(), entry);
}
IndexFile {
meta: Meta {
schema_version: 1,
generated_by: None,
generated_at: None,
source_root: None,
},
entries: map,
}
}
fn sample_computation() -> TierComputation {
let index = make_index(vec![(
"a",
intent(
VerifyLevel::Method(VerifyMethod::Neural),
Status::Neural,
false,
),
)]);
let fn_counts: BTreeMap<std::path::PathBuf, u32> =
[(std::path::PathBuf::from("src/lib.rs"), 1u32)]
.into_iter()
.collect();
compute_tier(&index, &fn_counts, None)
}
#[test]
fn style_parses_three_documented_forms() {
assert_eq!(Style::parse("flat"), Ok(Style::Flat));
assert_eq!(Style::parse("flat-square"), Ok(Style::FlatSquare));
assert_eq!(Style::parse("plastic"), Ok(Style::Plastic));
}
#[test]
fn style_rejects_unknown_form() {
let err = Style::parse("for-the-badge").unwrap_err();
assert!(err.contains("unknown --style"), "got: {err}");
assert!(err.contains("for-the-badge"), "got: {err}");
}
#[test]
fn metric_parses_three_documented_forms() {
assert_eq!(Metric::parse("tier"), Ok(Metric::Tier));
assert_eq!(Metric::parse("count"), Ok(Metric::Count));
assert_eq!(Metric::parse("rate"), Ok(Metric::Rate));
}
#[test]
fn metric_rejects_unknown_form() {
let err = Metric::parse("quality").unwrap_err();
assert!(err.contains("unknown --metric"), "got: {err}");
assert!(err.contains("quality"), "got: {err}");
assert!(
err.contains("count") && err.contains("rate") && err.contains("tier"),
"diagnostic should list all three valid values; got: {err}"
);
}
#[test]
fn counters_total_includes_all_entries() {
let index = make_index(vec![
(
"a",
intent(VerifyLevel::Bool(false), Status::Unknown, false),
),
("b", assume()),
(
"aristos:c",
intent(
VerifyLevel::Method(VerifyMethod::Full),
Status::Verified,
true,
),
),
]);
let c = Counters::from(&index);
assert_eq!(c.total, 3);
}
#[test]
fn counters_aristos_count_filters_by_namespace() {
let index = make_index(vec![
(
"local",
intent(VerifyLevel::Bool(false), Status::Unknown, false),
),
(
"aristos:one",
intent(
VerifyLevel::Method(VerifyMethod::Full),
Status::Verified,
true,
),
),
(
"aristos:two",
intent(
VerifyLevel::Method(VerifyMethod::Full),
Status::Verified,
true,
),
),
]);
let c = Counters::from(&index);
assert_eq!(c.aristos_count, 2);
}
#[test]
fn counters_verification_rate_excludes_assumes() {
let index = make_index(vec![
(
"a",
intent(
VerifyLevel::Method(VerifyMethod::Full),
Status::Verified,
false,
),
),
(
"b",
intent(VerifyLevel::Bool(false), Status::Unknown, false),
),
("c", assume()),
]);
let c = Counters::from(&index);
assert_eq!(c.verification_rate_pct, 50);
}
fn demo_counters() -> Counters {
Counters {
total: 47,
aristos_count: 20,
verification_rate_pct: 80,
}
}
fn arete_computation() -> TierComputation {
TierComputation {
verifiable: 1,
verification_ratio: 1.0,
coverage_score: 1.0,
articulation_floor: 0.05,
visible_score: 1.0,
arete_gate_met: true,
tier: Tier::Arete,
}
}
#[test]
fn render_svg_default_metric_emits_tier_label_in_message() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Tier,
);
assert!(
svg.contains(sample_computation().tier.label()),
"tier label must appear in the tier-metric SVG; got:\n{svg}"
);
}
#[test]
fn render_svg_count_metric_preserves_slice_31_surface() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Count,
);
assert!(svg.contains("✓ 47"), "expected `✓ 47`; got:\n{svg}");
}
#[test]
fn render_svg_rate_metric_emits_percentage_value() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Rate,
);
assert!(svg.contains("80%"), "expected `80%`; got:\n{svg}");
}
#[test]
fn render_svg_tier_metric_fills_message_with_palette_color() {
let computation = sample_computation();
assert_eq!(computation.tier, Tier::Adept);
let svg = render_svg(
&demo_counters(),
&computation,
Style::FlatSquare,
Metric::Tier,
);
assert!(
svg.contains("#c0362c"),
"Adept message half is International Orange; got:\n{svg}"
);
}
#[test]
fn render_svg_count_metric_keeps_slice_31_green() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Count,
);
assert!(
svg.contains("#4c1"),
"count metric keeps the slice-31 green; got:\n{svg}"
);
}
#[test]
fn render_svg_has_valid_svg_framing() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Tier,
);
assert!(svg.starts_with("<svg "), "got:\n{svg}");
assert!(svg.trim_end().ends_with("</svg>"), "got:\n{svg}");
}
#[test]
fn render_svg_labels_aristo_on_the_dark_stone_half() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Tier,
);
assert!(
svg.contains("aristo"),
"the `aristo` label is present; got:\n{svg}"
);
assert!(
svg.contains(r##"fill="#2b2824""##),
"dark stone label half; got:\n{svg}"
);
}
#[test]
fn render_svg_embeds_bridge_glyph_as_inline_logo() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Tier,
);
assert!(
svg.contains("<image"),
"logo <image> element present; got:\n{svg}"
);
assert!(
svg.contains("data:image/svg+xml;base64,"),
"logo inlined as a data-URI (offline, self-contained); got:\n{svg}"
);
}
#[test]
fn render_svg_flat_square_uses_crisp_edges() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::FlatSquare,
Metric::Tier,
);
assert!(
svg.contains("crispEdges"),
"flat-square renders square (crisp) edges; got:\n{svg}"
);
}
#[test]
fn render_svg_flat_style_rounds_corners() {
let svg = render_svg(
&demo_counters(),
&sample_computation(),
Style::Flat,
Metric::Tier,
);
assert!(
svg.contains(r#"rx="3""#),
"flat style rounds corners (rx=3); got:\n{svg}"
);
}
#[test]
fn render_svg_arete_tier_uses_gold_color_and_glyph() {
let svg = render_svg(
&demo_counters(),
&arete_computation(),
Style::FlatSquare,
Metric::Tier,
);
assert!(svg.contains("#d4a017"), "Areté gold color; got:\n{svg}");
assert!(svg.contains("✦"), "Areté ✦ glyph; got:\n{svg}");
assert!(svg.contains("Areté"), "Areté label; got:\n{svg}");
}
}