use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use shift_preflight::report::fmt_tokens;
use shift_preflight::{DriveMode, ShiftConfig, SvgMode};
use std::io::{IsTerminal, Read};
#[derive(Parser, Debug)]
#[command(name = "shift", version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg()]
file: Option<String>,
#[arg(short, long, default_value = "openai", value_parser = ["openai", "anthropic", "claude"])]
provider: String,
#[arg(short, long, default_value = "balanced", value_parser = ["performance", "perf", "balanced", "bal", "economy", "eco"])]
mode: String,
#[arg(long, default_value = "raster", value_parser = ["raster", "source", "hybrid"])]
svg_mode: String,
#[arg(short, long, default_value = "json", value_parser = ["json", "report", "json-report", "both"])]
output: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
profile: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
no_stats: bool,
#[arg(short, long)]
verbose: bool,
}
#[derive(Subcommand, Debug)]
enum Commands {
Gain {
#[arg(long)]
daily: bool,
#[arg(long, value_parser = ["json"])]
format: Option<String>,
},
}
const MAX_STDIN_BYTES: u64 = 500_000_000;
fn main() {
if let Err(e) = run() {
eprintln!("shift: error: {:#}", e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
if let Some(cmd) = &cli.command {
return match cmd {
Commands::Gain { daily, format } => run_gain(*daily, format.as_deref()),
};
}
let input = read_input(&cli.file)?;
let payload: serde_json::Value =
serde_json::from_str(&input).context("failed to parse input as JSON")?;
let drive_mode: DriveMode = cli.mode.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let svg_mode: SvgMode = cli
.svg_mode
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let provider = if cli.provider == "claude" {
"anthropic".to_string()
} else {
cli.provider.clone()
};
let config = ShiftConfig {
mode: drive_mode,
svg_mode,
provider: provider.clone(),
model: cli.model,
dry_run: cli.dry_run,
verbose: cli.verbose,
profile_path: cli.profile,
limits: shift_preflight::SafetyLimits::default(),
};
if cli.verbose {
eprintln!(
"shift: mode={}, provider={}, svg_mode={}, dry_run={}",
config.mode, config.provider, config.svg_mode, config.dry_run
);
}
let (result, report) = shift_preflight::process(&payload, &config)?;
if !cli.no_stats && !cli.dry_run && report.images_found > 0 {
let record = shift_preflight::stats::record_from_report(&report, &provider);
if let Err(e) = shift_preflight::stats::record_run(&record, None) {
eprintln!("shift: warning: failed to save stats: {}", e);
}
}
match cli.output.as_str() {
"json" => {
let json = serde_json::to_string_pretty(&result)?;
println!("{}", json);
if cli.verbose || cli.dry_run {
eprintln!("{}", report);
}
}
"report" => {
println!("{}", report);
}
"json-report" => {
let json = serde_json::to_string_pretty(&report)?;
println!("{}", json);
}
"both" => {
eprintln!("{}", report);
let json = serde_json::to_string_pretty(&result)?;
println!("{}", json);
}
_ => unreachable!(),
}
Ok(())
}
fn run_gain(daily: bool, format: Option<&str>) -> Result<()> {
let load_result = shift_preflight::stats::load_records(None)?;
let records = load_result.records;
if records.is_empty() {
println!("No SHIFT runs recorded yet. Stats are saved automatically after each run.");
println!("Use --no-stats to disable.");
return Ok(());
}
if daily {
let days = shift_preflight::stats::daily_breakdown(&records);
if format == Some("json") {
let json_days: Vec<serde_json::Value> = days
.iter()
.map(|d| {
serde_json::json!({
"date": d.date,
"runs": d.runs,
"images": d.images,
"openai_tokens_saved": d.openai_saved,
"anthropic_tokens_saved": d.anthropic_saved,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_days)?);
} else {
println!("=== SHIFT Daily Token Savings ===\n");
println!(
"{:<12} {:>5} {:>7} {:>15} {:>15}",
"Date", "Runs", "Images", "OpenAI saved", "Anthropic saved"
);
println!("{}", "-".repeat(58));
for d in &days {
println!(
"{:<12} {:>5} {:>7} {:>15} {:>15}",
d.date,
d.runs,
d.images,
fmt_tokens(d.openai_saved),
fmt_tokens(d.anthropic_saved),
);
}
}
} else {
let summary = shift_preflight::stats::summarize(&records);
if format == Some("json") {
let json = serde_json::json!({
"total_runs": summary.total_runs,
"total_images": summary.total_images,
"total_modified": summary.total_modified,
"bytes_saved": summary.bytes_saved(),
"openai_tokens_before": summary.total_openai_before,
"openai_tokens_after": summary.total_openai_after,
"openai_tokens_saved": summary.openai_saved(),
"openai_pct": summary.openai_pct(),
"anthropic_tokens_before": summary.total_anthropic_before,
"anthropic_tokens_after": summary.total_anthropic_after,
"anthropic_tokens_saved": summary.anthropic_saved(),
"anthropic_pct": summary.anthropic_pct(),
});
println!("{}", serde_json::to_string_pretty(&json)?);
} else {
println!("=== SHIFT Cumulative Savings ===\n");
println!("Runs: {}", summary.total_runs);
println!(
"Images: {} processed, {} modified",
summary.total_images, summary.total_modified
);
println!("Bytes: {} saved", fmt_bytes(summary.bytes_saved()));
if load_result.skipped_lines > 0 {
println!(
"Warning: {} corrupted stats line(s) skipped",
load_result.skipped_lines
);
}
println!();
println!("Token Savings (estimated):");
if summary.total_openai_before > 0 {
println!(
" OpenAI: {} -> {} tokens ({:.1}% saved)",
fmt_tokens(summary.total_openai_before),
fmt_tokens(summary.total_openai_after),
summary.openai_pct()
);
}
if summary.total_anthropic_before > 0 {
println!(
" Anthropic: {} -> {} tokens ({:.1}% saved)",
fmt_tokens(summary.total_anthropic_before),
fmt_tokens(summary.total_anthropic_after),
summary.anthropic_pct()
);
}
}
}
Ok(())
}
fn fmt_bytes(n: u64) -> String {
if n < 1_024 {
format!("{} B", n)
} else if n < 1_048_576 {
format!("{:.1} KB", n as f64 / 1_024.0)
} else {
format!("{:.1} MB", n as f64 / 1_048_576.0)
}
}
fn read_input(file: &Option<String>) -> Result<String> {
match file {
Some(path) => {
std::fs::read_to_string(path).with_context(|| format!("failed to read {}", path))
}
None => {
if std::io::stdin().is_terminal() {
anyhow::bail!(
"no input provided. Usage:\n shift <file.json>\n cat request.json | shift\n shift gain (show cumulative savings)"
);
}
let mut buf = String::new();
std::io::stdin()
.take(MAX_STDIN_BYTES)
.read_to_string(&mut buf)
.context("failed to read stdin")?;
Ok(buf)
}
}
}