use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use resopt::{
AnalysisOptions, Policy, analyze_with_progress, apply, create_plan, inventory, restore, scan,
};
use serde::Serialize;
use std::{
fs,
io::{self, Write},
path::PathBuf,
process::{Command, ExitCode},
};
#[derive(Parser)]
#[command(
name = "resopt",
version,
about = "Reviewable resource optimization for Apple projects"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Doctor {
#[arg(long)]
json: bool,
},
Scan {
#[arg(default_value = ".")]
root: PathBuf,
#[arg(long)]
json: bool,
#[arg(long)]
catalog_only: bool,
},
Analyze {
#[arg(default_value = ".")]
root: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long, value_delimiter = ',', default_value = "75,85,95")]
qualities: Vec<u8>,
#[arg(long, default_value_t = 2)]
jobs: usize,
#[arg(long, default_value_t = 0)]
min_input_bytes: u64,
#[arg(long)]
probe_only: bool,
#[arg(long, default_value_t = 1.0 / 255.0 + 0.000001)]
max_alpha_error: f32,
#[arg(long)]
json: bool,
},
Report { directory: PathBuf },
Serve {
directory: PathBuf,
#[arg(long, default_value_t = 0)]
port: u16,
},
Plan {
#[arg(default_value = ".")]
root: PathBuf,
#[arg(long)]
out: PathBuf,
#[arg(long)]
policy: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Apply {
directory: PathBuf,
#[arg(long)]
json: bool,
},
Restore {
directory: PathBuf,
#[arg(long)]
json: bool,
},
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
if error
.downcast_ref::<io::Error>()
.is_some_and(|error| error.kind() == io::ErrorKind::BrokenPipe)
{
return ExitCode::SUCCESS;
}
eprintln!("resopt: {error:#}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<()> {
let mut stdout = io::stdout().lock();
match cli.command {
Commands::Serve { directory, port } => resopt::serve(directory, port)?,
Commands::Report { directory } => {
let path = resopt::refresh_report(directory)?;
writeln!(
stdout,
"Updated {} (measurements and resources unchanged)",
path.display()
)?;
}
Commands::Doctor { json } => {
let report = serde_json::json!({
"schema_version": 1,
"resopt": env!("CARGO_PKG_VERSION"),
"backends": [
{"name":"oxipng", "version":"10.2.1", "status":"embedded", "mode":"strict_lossless_png"},
{"name":"Apple ImageIO", "available":resopt::image_backend_available(), "mode":"image_analysis_jpeg_heic"}
],
"optional_tools": [
{"name":"sips", "available":std::path::Path::new("/usr/bin/sips").is_file(), "backend_implemented":false},
{"name":"ffmpeg", "available":available("ffmpeg"), "backend_implemented":false},
{"name":"ffprobe", "available":available("ffprobe"), "backend_implemented":false}
]
});
if json {
output_json(&mut stdout, &report)?;
} else {
writeln!(stdout, "resopt {}", env!("CARGO_PKG_VERSION"))?;
writeln!(
stdout,
"PNG: embedded Oxipng 10.2.1; no separate executable required"
)?;
writeln!(
stdout,
"JPEG/HEIC analysis: {}",
if resopt::image_backend_available() {
"Apple ImageIO (native; no sips/ffmpeg install required)"
} else {
"requires macOS; scan and lossless PNG plan remain available"
}
)?;
for tool in report["optional_tools"]
.as_array()
.context("invalid doctor report")?
{
writeln!(
stdout,
"{}: {} (future backend; not required)",
tool["name"].as_str().unwrap_or("unknown"),
if tool["available"] == true {
"available"
} else {
"not found"
}
)?;
}
}
}
Commands::Scan {
root,
json,
catalog_only: true,
} => {
let inventory = scan(root)?;
if json {
output_json(&mut stdout, &inventory)?;
} else {
writeln!(
stdout,
"{} catalogs; {} referenced files; {} eligible for PNG analysis",
inventory.catalogs,
inventory.assets.len(),
inventory
.assets
.iter()
.filter(|asset| asset.eligible)
.count()
)?;
for asset in &inventory.assets {
writeln!(
stdout,
"{}\t{} bytes\t{}",
asset.path.display(),
asset.bytes,
asset.reason.as_deref().unwrap_or("eligible")
)?;
}
for diagnostic in &inventory.diagnostics {
eprintln!("warning: {diagnostic}");
}
}
}
Commands::Scan {
root,
json,
catalog_only: false,
} => {
let report = inventory(root)?;
if json {
output_json(&mut stdout, &report)?;
} else {
writeln!(
stdout,
"{} catalogs; {} resource files; {} source/tooling files excluded",
report.catalogs,
report.assets.len(),
report.skipped_source_or_tooling_files
)?;
for resource in &report.assets {
writeln!(
stdout,
"{}\t{} bytes\t{}\t{}{}",
resource.path.display(),
resource.bytes,
resource.kind,
resource.format,
if resource.extension_mismatch {
" (extension mismatch)"
} else {
""
}
)?;
}
for diagnostic in &report.diagnostics {
eprintln!("warning: {diagnostic}");
}
}
}
Commands::Analyze {
root,
out,
qualities,
jobs,
min_input_bytes,
probe_only,
max_alpha_error,
json,
} => {
let options = AnalysisOptions {
qualities,
jobs,
min_input_bytes,
probe_only,
max_alpha_error,
..AnalysisOptions::default()
};
let report = analyze_with_progress(root, &out, options, |done, total| {
if done % 25 == 0 || done == total {
eprintln!("analyze {done}/{total}");
}
})?;
if json {
output_json(&mut stdout, &report)?;
} else {
writeln!(
stdout,
"Analyzed {} resources; {} with image candidates; {} potential source bytes saved",
report.resources.len(),
report
.status_counts
.get("candidates_available")
.unwrap_or(&0),
report.potential_source_bytes_saved
)?;
writeln!(
stdout,
"Review {}/report.html and analysis.json. Lossy candidates require visual review; sources were not modified.",
out.display()
)?;
for (status, count) in &report.status_counts {
writeln!(stdout, "{status}: {count}")?;
}
}
}
Commands::Plan {
root,
out,
policy,
json,
} => {
let policy = match policy {
Some(path) => {
toml::from_str::<Policy>(&fs::read_to_string(path).context("reading policy")?)
.context("invalid policy")?
}
None => Policy::default(),
};
let plan = create_plan(root, &out, policy)?;
if json {
output_json(&mut stdout, &plan)?;
} else {
writeln!(
stdout,
"{} candidates; {} source bytes saved; {} skipped",
plan.candidates.len(),
plan.savings_bytes(),
plan.skipped.len()
)?;
for item in &plan.candidates {
writeln!(
stdout,
"{}\t{} -> {} bytes (-{:.1}%)",
item.path.display(),
item.original_bytes,
item.optimized_bytes,
(item.original_bytes - item.optimized_bytes) as f64 * 100.0
/ item.original_bytes as f64
)?;
}
for (path, reason) in &plan.skipped {
writeln!(stdout, "SKIP\t{}\t{reason}", path.display())?;
}
for diagnostic in &plan.diagnostics {
eprintln!("warning: {diagnostic}");
}
writeln!(
stdout,
"Review {}/plan.json; sources are unchanged. Savings do not measure app size.",
out.display()
)?;
}
}
Commands::Apply { directory, json } => {
let report = apply(directory)?;
if json {
output_json(&mut stdout, &report)?;
} else {
writeln!(
stdout,
"Applied {}; already current {}; saved {} source bytes",
report.changed, report.already_current, report.source_bytes_saved
)?;
}
}
Commands::Restore { directory, json } => {
let report = restore(directory)?;
if json {
output_json(&mut stdout, &report)?;
} else {
writeln!(
stdout,
"Restored {}; already original {}",
report.changed, report.already_current
)?;
}
}
}
Ok(())
}
fn available(program: &str) -> bool {
Command::new(program)
.arg("-version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn output_json(output: &mut impl Write, value: &impl Serialize) -> Result<()> {
serde_json::to_writer_pretty(&mut *output, value)?;
writeln!(output)?;
Ok(())
}