use std::path::PathBuf;
use anyhow::Result;
use chrono::Utc;
use clap::Args;
use serde_json::{Value, json};
use agentstatedeveloper_core::{
Engine,
scorecard::{ScorecardOptions, compute},
};
use crate::config::Config;
#[derive(Debug, Args)]
pub struct ScorecardArgs {
#[arg(long)]
pub json: bool,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub drill_down: Option<String>,
#[arg(long)]
pub trend: bool,
#[arg(long)]
pub history_path: Option<PathBuf>,
#[arg(long, default_value_t = 10)]
pub limit: usize,
#[arg(long)]
pub scope: Option<String>,
#[arg(long)]
pub paths: Option<String>,
}
fn history_path(cfg: &Config, override_path: Option<&PathBuf>) -> PathBuf {
if let Some(p) = override_path {
return p.clone();
}
let mut p = cfg.db_path.clone();
let stem = p
.file_stem()
.map(|s| format!("{}.scorecard-history.json", s.to_string_lossy()))
.unwrap_or_else(|| "asd-scorecard-history.json".to_string());
p.set_file_name(stem);
p
}
fn load_history(path: &PathBuf) -> Vec<Value> {
if !path.exists() {
return vec![];
}
let s = std::fs::read_to_string(path).unwrap_or_default();
serde_json::from_str::<Vec<Value>>(&s).unwrap_or_default()
}
fn save_snapshot(path: &PathBuf, snapshot: &Value) {
let mut history = load_history(path);
history.push(snapshot.clone());
if history.len() > 100 {
history.drain(..history.len() - 100);
}
let _ = std::fs::write(
path,
serde_json::to_string_pretty(&history).unwrap_or_default(),
);
}
pub fn run(cfg: &Config, args: ScorecardArgs) -> Result<()> {
if !args.quiet {
if let Some(warn) = agentstatedeveloper_core::stale_warning(&cfg.db_path, 3600) {
eprintln!("{warn}");
}
}
let engine = Engine::open_sqlite(&cfg.db_path)?;
let card = compute(
&engine,
&cfg.db_path,
&ScorecardOptions {
scope: args.scope.as_deref(),
paths: args.paths.as_deref(),
drill_down: args.drill_down.as_deref(),
drill_limit: args.limit,
},
);
if card.matched_nothing {
let note = if card.scoped {
"no symbols matched the path filter — try broadening --scope/--paths"
} else {
"no symbols indexed — run `asd index` first"
};
let zero = json!({
"note": note,
"scores": { "truth": 0, "feedback": 0, "change": 0, "uncertainty": 0, "workflow": 0, "overall": 0 }
});
println!("{}", serde_json::to_string_pretty(&zero)?);
return Ok(());
}
let scores = card.scores;
let scoped = card.scoped;
let paths_filter = card.data_quality.scope.clone().unwrap_or_default();
let total_symbols = card.details.total_symbols;
let feedback_count = card.details.feedback_entries;
let total_ledger_entries = card.details.total_ledger_entries;
let ctx_tagged_entries = card.details.ctx_tagged_ledger_entries;
let structured_tokens = card.token_economy.structured_tokens;
let source_read_tokens = card.token_economy.source_read_tokens_est;
let ratio_x = card.token_economy.ratio_x;
let sparse_note: Option<&str> = card
.data_quality
.sparse_db
.then_some(card.data_quality.note.as_str());
let now = Utc::now().to_rfc3339();
let hist_path = history_path(cfg, args.history_path.as_ref());
let trend_obj: Option<Value> = if args.trend {
let history = load_history(&hist_path);
history.last().map(|prev| {
let prev_scores = prev.get("scores").cloned().unwrap_or(json!({}));
let dims = [
"truth",
"feedback",
"change",
"uncertainty",
"workflow",
"overall",
];
let mut deltas = serde_json::Map::new();
for dim in dims {
let current = scores.get(dim).unwrap_or(scores.overall) as i64;
let prev_val = prev_scores.get(dim).and_then(Value::as_i64).unwrap_or(0);
let delta = current - prev_val;
deltas.insert(
dim.to_string(),
json!({
"current": current,
"previous": prev_val,
"delta": delta,
"trend": if delta > 0 { "▲" } else if delta < 0 { "▼" } else { "─" },
}),
);
}
json!({
"compared_to": prev.get("timestamp").cloned().unwrap_or(json!("unknown")),
"dimensions": deltas,
})
})
} else {
None
};
let snapshot = json!({
"timestamp": now,
"scores": {
"truth": scores.truth,
"feedback": scores.feedback,
"change": scores.change,
"uncertainty": scores.uncertainty,
"workflow": scores.workflow,
"overall": scores.overall,
},
});
save_snapshot(&hist_path, &snapshot);
if args.json {
let mut out = json!({ "timestamp": now });
let obj = out.as_object_mut().expect("json! built an object");
for (k, v) in card
.to_json()
.as_object()
.expect("to_json built an object")
.clone()
{
obj.insert(k, v);
}
if let Some(ref t) = trend_obj {
out.as_object_mut()
.unwrap()
.insert("trend".into(), t.clone());
}
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
println!("ASD Benchmark Scorecard");
if scoped {
println!(" scope: {}", paths_filter.join(", "));
}
println!("{:-<40}", "");
let dim_names = [
("Truth Model ", "truth", scores.truth),
("Feedback Loop ", "feedback", scores.feedback),
("Change Model ", "change", scores.change),
("Uncertainty Model", "uncertainty", scores.uncertainty),
("Workflow ", "workflow", scores.workflow),
];
for (label, key, val) in &dim_names {
let bar: String = (0..val / 5)
.map(|_| '█')
.chain((val / 5..20).map(|_| '░'))
.collect();
let trend_suffix = if let Some(ref t) = trend_obj {
let sym = t
.get("dimensions")
.and_then(|d| d.get(*key))
.and_then(|d| d.get("trend"))
.and_then(Value::as_str)
.unwrap_or("─");
let delta = t
.get("dimensions")
.and_then(|d| d.get(*key))
.and_then(|d| d.get("delta"))
.and_then(Value::as_i64)
.unwrap_or(0);
format!(" {sym}{:+}", delta)
} else {
String::new()
};
println!("{label} {bar} {:3}/100{trend_suffix}", val);
}
println!("{:-<40}", "");
let trend_overall = if let Some(ref t) = trend_obj {
let sym = t
.get("dimensions")
.and_then(|d| d.get("overall"))
.and_then(|d| d.get("trend"))
.and_then(Value::as_str)
.unwrap_or("─");
let delta = t
.get("dimensions")
.and_then(|d| d.get("overall"))
.and_then(|d| d.get("delta"))
.and_then(Value::as_i64)
.unwrap_or(0);
format!(" {sym}{:+}", delta)
} else {
String::new()
};
println!(
"Overall {:3}/100{trend_overall}",
scores.overall
);
println!();
println!("Symbols indexed: {}", total_symbols);
println!("Feedback entries: {}", feedback_count);
println!("Ledger entries: {}", total_ledger_entries);
println!("CTX-tagged: {}", ctx_tagged_entries);
println!(
"Token economy: ~{:.1}x vs source ({} index tok vs ~{} source tok, est.)",
ratio_x, structured_tokens, source_read_tokens
);
if let Some(ref note) = sparse_note {
println!("\nNote: {note}");
}
if let Some(drill) = card.drill_down.as_ref().filter(|d| d.total_gaps > 0) {
let (dimension, total_gaps) = (&drill.dimension, drill.total_gaps);
println!("\n## Drill-down: {dimension} gaps ({total_gaps} symbols)");
for row in &drill.gap_symbols {
println!(" {} ({})", row.qname, row.file);
}
if drill.omitted > 0 {
println!(" … and {} more (use --limit to show more)", drill.omitted);
}
}
if args.trend {
if let Some(ref t) = trend_obj {
let compared = t
.get("compared_to")
.and_then(Value::as_str)
.unwrap_or("unknown");
println!("\nTrend vs: {compared}");
} else {
println!("\nNo previous snapshot found — this run has been saved as the baseline.");
}
}
Ok(())
}