use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
name = "oa",
about = "Office Automation — update PowerPoint presentations with Excel data via COM",
version,
propagate_version = true
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(
after_long_help = "PIPELINE STEPS (executed in this order):\n \
links Re-point OLE links to a new Excel file\n \
tables Populate PPT tables from Excel ranges\n \
deltas Swap delta indicator arrows based on sign\n \
coloring Apply sign-based color coding (_ccst shapes)\n \
charts Update chart data links\n\n\
All steps run by default. Use --steps or --skip to control which run."
)]
Update(UpdateArgs),
Run(RunArgs),
Check(CheckArgs),
Diff(DiffArgs),
Info(InfoArgs),
Clean(CleanArgs),
Config,
}
#[derive(Parser, Debug)]
pub struct UpdateArgs {
#[arg(value_name = "FILES")]
pub files: Vec<String>,
#[arg(short, long, value_name = "PATH")]
pub excel: Option<String>,
#[arg(short, long)]
pub pick: bool,
#[arg(long, value_name = "PPT=XLSX")]
pub pair: Vec<String>,
#[arg(short, long, value_name = "PATH")]
pub output: Option<String>,
#[arg(long, value_delimiter = ',', value_name = "STEP,...")]
pub steps: Vec<String>,
#[arg(long, value_delimiter = ',', value_name = "STEP,...")]
pub skip: Vec<String>,
#[arg(long, value_name = "KEY=VALUE")]
pub set: Vec<String>,
#[arg(long)]
pub check: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(short, long)]
pub verbose: bool,
#[arg(short, long)]
pub quiet: bool,
}
#[derive(Parser, Debug)]
pub struct RunArgs {
#[arg(value_name = "RUNFILE")]
pub runfile: String,
#[arg(long)]
pub check: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(short, long)]
pub verbose: bool,
#[arg(short, long)]
pub quiet: bool,
}
#[derive(Parser, Debug)]
pub struct CheckArgs {
#[arg(value_name = "FILE")]
pub file: String,
#[arg(short, long, value_name = "PATH")]
pub excel: Option<String>,
#[arg(long, value_name = "KEY=VALUE")]
pub set: Vec<String>,
#[arg(short, long)]
pub verbose: bool,
}
#[derive(Parser, Debug)]
pub struct DiffArgs {
#[arg(value_name = "A.pptx")]
pub file_a: String,
#[arg(value_name = "B.pptx")]
pub file_b: String,
#[arg(short, long)]
pub verbose: bool,
}
#[derive(Parser, Debug)]
pub struct InfoArgs {
#[arg(value_name = "FILE")]
pub file: String,
#[arg(short, long)]
pub verbose: bool,
}
#[derive(Parser, Debug)]
pub struct CleanArgs {
#[arg(short, long)]
pub force: bool,
}
pub const VALID_STEPS: &[&str] = &["links", "tables", "deltas", "coloring", "charts"];
pub fn resolve_steps(steps: &[String], skip: &[String]) -> Result<Vec<String>, String> {
if !steps.is_empty() && !skip.is_empty() {
return Err("Cannot use both --steps and --skip at the same time".into());
}
let validate = |names: &[String]| -> Result<(), String> {
for name in names {
if !VALID_STEPS.contains(&name.as_str()) {
return Err(format!(
"Unknown step: {name:?}. Valid steps: {}",
VALID_STEPS.join(", ")
));
}
}
Ok(())
};
if !steps.is_empty() {
validate(steps)?;
return Ok(steps.to_vec());
}
if !skip.is_empty() {
validate(skip)?;
return Ok(VALID_STEPS
.iter()
.filter(|s| !skip.iter().any(|sk| sk == *s))
.map(|s| s.to_string())
.collect());
}
Ok(VALID_STEPS.iter().map(|s| s.to_string()).collect())
}
pub fn parse_pair(pair: &str) -> Result<(String, String), String> {
let parts: Vec<&str> = pair.split('=').collect();
if parts.len() < 2 {
return Err(format!("Invalid --pair format: {pair:?} (expected PPT=XLSX)"));
}
let mut segments: Vec<String> = Vec::new();
let mut i = 0;
while i < parts.len() {
if parts[i].len() == 1
&& parts[i].chars().next().unwrap().is_ascii_alphabetic()
&& i + 1 < parts.len()
&& (parts[i + 1].starts_with('\\') || parts[i + 1].starts_with('/'))
{
segments.push(format!("{}={}", parts[i], parts[i + 1]));
i += 2;
} else {
segments.push(parts[i].to_string());
i += 1;
}
}
if segments.len() != 2 {
return Err(format!(
"Invalid --pair format: {pair:?} (expected PPT=XLSX, got {} segments)",
segments.len()
));
}
Ok((segments[0].clone(), segments[1].clone()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_steps_default_all() {
let steps = resolve_steps(&[], &[]).unwrap();
assert_eq!(steps.len(), 5);
assert_eq!(steps, vec!["links", "tables", "deltas", "coloring", "charts"]);
}
#[test]
fn test_resolve_steps_include() {
let steps = resolve_steps(&["links".into(), "tables".into()], &[]).unwrap();
assert_eq!(steps, vec!["links", "tables"]);
}
#[test]
fn test_resolve_steps_skip() {
let steps = resolve_steps(&[], &["charts".into()]).unwrap();
assert_eq!(steps, vec!["links", "tables", "deltas", "coloring"]);
}
#[test]
fn test_resolve_steps_mutual_exclusion() {
let result = resolve_steps(&["links".into()], &["charts".into()]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Cannot use both"));
}
#[test]
fn test_resolve_steps_unknown_step() {
let result = resolve_steps(&["invalid".into()], &[]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown step"));
}
#[test]
fn test_resolve_steps_unknown_skip() {
let result = resolve_steps(&[], &["invalid".into()]);
assert!(result.is_err());
}
#[test]
fn test_parse_pair_simple() {
let (pptx, xlsx) = parse_pair("report.pptx=data.xlsx").unwrap();
assert_eq!(pptx, "report.pptx");
assert_eq!(xlsx, "data.xlsx");
}
#[test]
fn test_parse_pair_windows_paths() {
let (pptx, xlsx) = parse_pair(r"C=\Users\report.pptx=C=\Data\file.xlsx").unwrap();
assert_eq!(pptx, r"C=\Users\report.pptx");
assert_eq!(xlsx, r"C=\Data\file.xlsx");
}
#[test]
fn test_parse_pair_no_separator() {
let result = parse_pair("no_equals_here");
assert!(result.is_err());
}
#[test]
fn test_parse_pair_relative_paths() {
let (pptx, xlsx) = parse_pair("templates/report.pptx=data/us.xlsx").unwrap();
assert_eq!(pptx, "templates/report.pptx");
assert_eq!(xlsx, "data/us.xlsx");
}
}