use std::path::Path;
use anyhow::Result;
use clap::{Args, ValueEnum};
use rusqlite::Connection;
use crate::cli::CliOutput;
use crate::confidence::calibrate::{CalibrationReport, DEFAULT_WINDOW_DAYS, calibrate_from_shadow};
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
pub enum OutputFormat {
#[default]
Json,
Table,
}
#[derive(Args, Debug, Clone)]
pub struct CalibrateArgs {
#[command(subcommand)]
pub subcommand: CalibrateSubcommand,
}
#[derive(clap::Subcommand, Debug, Clone)]
pub enum CalibrateSubcommand {
Confidence(CalibrateConfidenceArgs),
}
#[derive(Args, Debug, Clone)]
pub struct CalibrateConfidenceArgs {
#[arg(long, default_value_t = true)]
pub from_shadow: bool,
#[arg(long, default_value_t = DEFAULT_WINDOW_DAYS)]
pub days: i64,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
pub output_format: OutputFormat,
}
pub fn run(db_path: &Path, args: &CalibrateConfidenceArgs, out: &mut CliOutput<'_>) -> Result<i32> {
if !args.from_shadow {
writeln!(
out.stderr,
"calibrate confidence: --from-shadow is the only supported mode in v0.7.0; \
pass --from-shadow to scan the observation table."
)?;
return Ok(2);
}
let conn = Connection::open(db_path)?;
let report = calibrate_from_shadow(&conn, args.days, chrono::Utc::now())?;
let buf = match args.output_format {
OutputFormat::Json => serde_json::to_string_pretty(&report)?,
OutputFormat::Table => render_table(&report),
};
writeln!(out.stdout, "{buf}")?;
Ok(0)
}
fn render_table(report: &CalibrationReport) -> String {
let mut out = String::new();
out.push_str(&format!(
"CONFIDENCE CALIBRATION REPORT (window: {} days, observations: {})\n\n",
report.window_days, report.total_observations
));
out.push_str(&format!(
"{:<24} {:<12} {:>6} {:>6} {:>6} HISTOGRAM (0.0..1.0)\n",
"NAMESPACE", "SOURCE", "COUNT", "MEDIAN", "MEAN"
));
if report.baselines.is_empty() {
out.push_str("(no observations in window)\n");
return out;
}
for b in &report.baselines {
let hist: String = b
.buckets
.iter()
.map(|c| if *c == 0 { '.' } else { '#' })
.collect();
out.push_str(&format!(
"{:<24} {:<12} {:>6} {:>6.2} {:>6.2} {hist}\n",
b.namespace, b.source, b.count, b.median, b.mean,
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::TestEnv;
#[test]
fn run_rejects_without_from_shadow() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let args = CalibrateConfidenceArgs {
from_shadow: false,
days: 30,
output_format: OutputFormat::Json,
};
let code = {
let mut out = env.output();
run(&db, &args, &mut out).expect("ok")
};
assert_eq!(code, 2);
assert!(env.stderr_str().contains("--from-shadow"));
}
#[test]
fn run_json_output_on_fresh_db() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let _ = crate::storage::open(&db).unwrap();
let args = CalibrateConfidenceArgs {
from_shadow: true,
days: 7,
output_format: OutputFormat::Json,
};
let code = {
let mut out = env.output();
run(&db, &args, &mut out).expect("ok")
};
assert_eq!(code, 0);
let parsed: serde_json::Value =
serde_json::from_str(env.stdout_str().trim()).expect("json");
assert_eq!(parsed["window_days"].as_i64(), Some(7));
assert_eq!(parsed["total_observations"].as_i64(), Some(0));
}
#[test]
fn run_table_output_on_fresh_db() {
let mut env = TestEnv::fresh();
let db = env.db_path.clone();
let _ = crate::storage::open(&db).unwrap();
let args = CalibrateConfidenceArgs {
from_shadow: true,
days: 30,
output_format: OutputFormat::Table,
};
let code = {
let mut out = env.output();
run(&db, &args, &mut out).expect("ok")
};
assert_eq!(code, 0);
assert!(env.stdout_str().contains("CONFIDENCE CALIBRATION REPORT"));
}
fn empty_report() -> CalibrationReport {
CalibrationReport {
window_days: 30,
total_observations: 0,
baselines: Vec::new(),
}
}
#[test]
fn render_table_handles_empty() {
let s = render_table(&empty_report());
assert!(s.contains("window: 30 days"));
assert!(s.contains("no observations in window"));
}
#[test]
fn render_table_emits_one_row_per_baseline() {
let r = CalibrationReport {
window_days: 7,
total_observations: 3,
baselines: vec![crate::confidence::calibrate::PerSourceBaseline {
namespace: "ns".to_string(),
source: "user".to_string(),
count: 3,
median: 0.5,
mean: 0.55,
buckets: [0, 0, 1, 0, 1, 1, 0, 0, 0, 0],
}],
};
let s = render_table(&r);
assert!(s.contains("ns"));
assert!(s.contains("user"));
assert!(s.contains("0.50"));
}
}