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 \
replace Replace literal text tokens given with -r FIND=VALUE (slides, masters, layouts)\n\n\
All steps run by default (replace only does work when -r is given). Use --steps or --skip to control which run."
)]
Update(UpdateArgs),
Run(RunArgs),
Check(CheckArgs),
Diff(DiffArgs),
Info(InfoArgs),
#[command(
after_long_help = "Scans every slide, speaker-notes page, slide layout and slide master at ZIP level\n\
and lists each occurrence with its location, shape name and a snippet.\n\n\
EXIT CODES:\n \
0 at least one hit\n \
1 no hits (handy for scripts: `oa find out.pptx -t [country]` failing means the token is gone)\n \
2 error (file not found, not a PPTX, ...)\n\n\
-t is the first selector; charts or shapes by name may be added later."
)]
Find(FindArgs),
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(short = 'r', long, value_name = "FIND=VALUE")]
pub replace: Vec<String>,
#[arg(skip)]
pub replace_pairs: Vec<(String, 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 FindArgs {
#[arg(value_name = "FILE")]
pub file: String,
#[arg(short = 't', long = "text", value_name = "TEXT", required = true)]
pub text: Vec<String>,
#[arg(short = 'i', long)]
pub ignore_case: bool,
}
#[derive(Parser, Debug)]
pub struct CleanArgs {
#[arg(short, long)]
pub force: bool,
}
pub const VALID_STEPS: &[&str] = &["links", "tables", "deltas", "coloring", "charts", "replace"];
pub fn parse_replacement(s: &str) -> Result<(String, String), String> {
let Some((find, value)) = s.split_once('=') else {
return Err(format!("Invalid replacement {s:?} (expected FIND=VALUE, e.g. -r [country]=Japan)"));
};
let find = find.trim();
if find.is_empty() {
return Err(format!("Invalid replacement {s:?}: the text to find is empty"));
}
Ok((find.to_string(), value.trim().to_string()))
}
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(), 6);
assert_eq!(steps, vec!["links", "tables", "deltas", "coloring", "charts", "replace"]);
}
#[test]
fn test_parse_replacement() {
assert_eq!(parse_replacement("[country]=Japan").unwrap(), ("[country]".into(), "Japan".into()));
assert_eq!(parse_replacement("[country] = Japan").unwrap(), ("[country]".into(), "Japan".into()));
assert_eq!(parse_replacement("[a]=x=y").unwrap(), ("[a]".into(), "x=y".into()), "only the first = splits");
assert_eq!(parse_replacement("[a]=").unwrap(), ("[a]".into(), String::new()), "empty value deletes the token");
assert!(parse_replacement("=x").is_err());
assert!(parse_replacement(" =x").is_err());
assert!(parse_replacement("[a]").is_err());
}
#[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", "replace"]);
}
#[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");
}
}