use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::Parser;
#[cfg(feature = "fast-alloc")]
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Parser, Debug)]
#[command(
name = "jubarte",
version,
about = "Generate a tracked-changes (redline) .docx from two documents",
long_about = None,
after_help = "EXAMPLES:\n \
jubarte contract.docx contract-rev2.docx\n \
→ writes contract_v_contract-rev2.docx next to the original\n\n \
jubarte -b old.docx -m new.docx -o redline.docx --author \"Legal\"\n \
jubarte a.docx b.docx --force --quiet",
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(value_name = "ORIGINAL")]
original_pos: Option<PathBuf>,
#[arg(value_name = "MODIFIED")]
modified_pos: Option<PathBuf>,
#[arg(short = 'b', long = "original", value_name = "FILE")]
original: Option<PathBuf>,
#[arg(short = 'm', long = "modified", value_name = "FILE")]
modified: Option<PathBuf>,
#[arg(short = 'o', long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(short = 'a', long, value_name = "NAME", default_value = "Redline")]
author: String,
#[arg(
short = 'd',
long,
value_name = "ISO8601",
default_value = "1970-01-01T00:00:00Z"
)]
date: String,
#[arg(long)]
force: bool,
#[arg(short = 'q', long)]
quiet: bool,
#[arg(long, value_name = "RATIO")]
detail_threshold: Option<f64>,
#[arg(long)]
powertools_faithful: bool,
#[arg(long, hide = true)]
no_paragraph_merge: bool,
}
#[derive(clap::Subcommand, Debug)]
enum Command {
Revisions {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long)]
json: bool,
},
Accept {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(short = 'o', long, value_name = "FILE")]
output: PathBuf,
#[arg(long)]
force: bool,
},
Reject {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(short = 'o', long, value_name = "FILE")]
output: PathBuf,
#[arg(long)]
force: bool,
},
Convert {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(short = 'o', long, value_name = "FILE")]
output: Option<PathBuf>,
#[arg(long)]
force: bool,
#[arg(long)]
compress: bool,
},
}
fn ensure_writable(output: &Path, force: bool) -> Result<(), String> {
if output.exists() && !force {
return Err(format!(
"output '{}' already exists (use --force to overwrite)",
output.display()
));
}
Ok(())
}
fn run_resolution<E: std::fmt::Debug>(
file: &Path,
output: &Path,
force: bool,
apply: fn(&[u8]) -> Result<Vec<u8>, E>,
what: &str,
) -> Result<(), String> {
ensure_writable(output, force)?;
let bytes = std::fs::read(file).map_err(|e| format!("reading {}: {e}", file.display()))?;
let out = apply(&bytes).map_err(|e| format!("{what} failed: {e:?}"))?;
std::fs::write(output, &out).map_err(|e| format!("writing {}: {e}", output.display()))
}
fn run_convert(
file: &Path,
output: Option<&Path>,
force: bool,
compress: bool,
) -> Result<(), String> {
let output = output
.map(Path::to_path_buf)
.unwrap_or_else(|| file.with_extension("pdf"));
ensure_writable(&output, force)?;
let bytes = std::fs::read(file).map_err(|e| format!("reading {}: {e}", file.display()))?;
let options = jubarte::convert::PdfOptions { compress };
let pdf = jubarte::convert::docx_to_pdf_with(&bytes, options)
.map_err(|e| format!("convert failed: {e}"))?;
std::fs::write(&output, &pdf).map_err(|e| format!("writing {}: {e}", output.display()))?;
let pages = jubarte::convert::pdf_page_count(&pdf);
println!(
"wrote {} ({} bytes, {pages} page{})",
output.display(),
pdf.len(),
if pages == 1 { "" } else { "s" }
);
Ok(())
}
fn run_revisions(file: &Path, json: bool) -> Result<(), String> {
let bytes = std::fs::read(file).map_err(|e| format!("reading {}: {e}", file.display()))?;
let settings = jubarte::comparer::WmlComparerSettings::default();
let revs = jubarte::document_comparer::get_revisions(&bytes, &settings)
.map_err(|e| format!("get_revisions failed: {e:?}"))?;
if json {
for r in &revs {
println!("{}", jubarte::document_comparer::revision_to_json(r));
}
} else {
for r in &revs {
let text = r.text.as_deref().unwrap_or("");
let preview: String = text.chars().take(60).collect();
println!(
"{:?}\t{}\t{}\t{:?}",
r.revision_type,
r.author.as_deref().unwrap_or("-"),
r.part_name,
preview
);
}
println!("{} revision(s)", revs.len());
}
Ok(())
}
#[derive(Debug, PartialEq)]
struct Job {
original: PathBuf,
modified: PathBuf,
output: PathBuf,
author: String,
date: String,
force: bool,
quiet: bool,
detail_threshold: Option<f64>,
powertools_faithful: bool,
no_paragraph_merge: bool,
}
impl Cli {
fn resolve(self) -> Result<Job, String> {
let original = self
.original
.or(self.original_pos)
.ok_or("missing ORIGINAL document (a positional arg or --original/-b)")?;
let modified = self
.modified
.or(self.modified_pos)
.ok_or("missing MODIFIED document (a positional arg or --modified/-m)")?;
let output = self
.output
.unwrap_or_else(|| default_output(&original, &modified));
Ok(Job {
original,
modified,
output,
author: self.author,
date: self.date,
force: self.force,
quiet: self.quiet,
detail_threshold: self.detail_threshold,
powertools_faithful: self.powertools_faithful,
no_paragraph_merge: self.no_paragraph_merge,
})
}
}
fn default_output(original: &Path, modified: &Path) -> PathBuf {
let stem = |p: &Path| {
p.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "doc".to_string())
};
let name = format!("{}_v_{}.docx", stem(original), stem(modified));
match original.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(dir) => dir.join(name),
None => PathBuf::from(name),
}
}
fn run(job: &Job) -> Result<(), String> {
ensure_writable(&job.output, job.force)?;
let original = std::fs::read(&job.original)
.map_err(|e| format!("reading {}: {e}", job.original.display()))?;
let modified = std::fs::read(&job.modified)
.map_err(|e| format!("reading {}: {e}", job.modified.display()))?;
let base = if job.powertools_faithful {
jubarte::comparer::WmlComparerSettings::powertools_faithful()
} else {
jubarte::comparer::WmlComparerSettings::default()
};
let settings = jubarte::comparer::WmlComparerSettings {
author_for_revisions: job.author.clone(),
date_time_for_revisions: job.date.clone(),
detail_threshold: job.detail_threshold.unwrap_or(base.detail_threshold),
merge_replaced_paragraphs: if job.no_paragraph_merge {
false
} else {
base.merge_replaced_paragraphs
},
..base
};
let out = jubarte::document_comparer::compare_documents_with_settings(
&original, &modified, &settings,
)
.map_err(|e| format!("compare failed: {e:?}"))?;
std::fs::write(&job.output, &out)
.map_err(|e| format!("writing {}: {e}", job.output.display()))?;
if !job.quiet {
println!("wrote {} ({} bytes)", job.output.display(), out.len());
}
Ok(())
}
fn exit_code(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
match cli.command {
Some(Command::Revisions { file, json }) => {
return exit_code(run_revisions(&file, json));
}
Some(Command::Accept {
file,
output,
force,
}) => {
return exit_code(run_resolution(
&file,
&output,
force,
jubarte::document_comparer::accept_revisions,
"accept",
));
}
Some(Command::Reject {
file,
output,
force,
}) => {
return exit_code(run_resolution(
&file,
&output,
force,
jubarte::document_comparer::reject_revisions,
"reject",
));
}
Some(Command::Convert {
file,
output,
force,
compress,
}) => {
return exit_code(run_convert(&file, output.as_deref(), force, compress));
}
None => {}
}
let job = match cli.resolve() {
Ok(job) => job,
Err(e) => {
eprintln!("error: {e}");
eprintln!("try 'jubarte --help'");
return ExitCode::from(2);
}
};
exit_code(run(&job))
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn job_of(args: &[&str]) -> Job {
Cli::try_parse_from(args)
.expect("parse")
.resolve()
.expect("resolve")
}
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn positional_args_and_default_output() {
let j = job_of(&["jubarte", "a.docx", "b.docx"]);
assert_eq!(j.original, PathBuf::from("a.docx"));
assert_eq!(j.modified, PathBuf::from("b.docx"));
assert_eq!(j.output, PathBuf::from("a_v_b.docx"));
assert_eq!(j.author, "Redline");
assert_eq!(j.date, "1970-01-01T00:00:00Z");
assert!(!j.force && !j.quiet);
}
#[test]
fn default_output_uses_original_directory_and_stems() {
let o = default_output(
Path::new("docs/contract.docx"),
Path::new("rev/contract-2.docx"),
);
assert_eq!(o, PathBuf::from("docs/contract_v_contract-2.docx"));
let o2 = default_output(Path::new("contract.docx"), Path::new("contract-2.docx"));
assert_eq!(o2, PathBuf::from("contract_v_contract-2.docx"));
}
#[test]
fn named_flags_override_positionals() {
let j = job_of(&[
"jubarte",
"a.docx",
"b.docx",
"-b",
"real-orig.docx",
"--modified",
"real-mod.docx",
]);
assert_eq!(j.original, PathBuf::from("real-orig.docx"));
assert_eq!(j.modified, PathBuf::from("real-mod.docx"));
}
#[test]
fn all_options_long_and_short() {
let j = job_of(&[
"jubarte",
"-b",
"o.docx",
"-m",
"n.docx",
"-o",
"out.docx",
"-a",
"Jane Doe",
"-d",
"2024-01-02T00:00:00Z",
"--force",
"--quiet",
]);
assert_eq!(j.output, PathBuf::from("out.docx"));
assert_eq!(j.author, "Jane Doe");
assert_eq!(j.date, "2024-01-02T00:00:00Z");
assert!(j.force && j.quiet);
}
#[test]
fn flags_can_supply_both_inputs_without_positionals() {
let j = job_of(&["jubarte", "--original", "x.docx", "--modified", "y.docx"]);
assert_eq!(j.original, PathBuf::from("x.docx"));
assert_eq!(j.modified, PathBuf::from("y.docx"));
assert_eq!(j.output, PathBuf::from("x_v_y.docx"));
}
#[test]
fn double_dash_treats_rest_as_positionals() {
let j = job_of(&["jubarte", "--", "-weird-name.docx", "b.docx"]);
assert_eq!(j.original, PathBuf::from("-weird-name.docx"));
assert_eq!(j.modified, PathBuf::from("b.docx"));
}
#[test]
fn help_and_version_are_handled_by_clap() {
use clap::error::ErrorKind;
let help = Cli::try_parse_from(["jubarte", "--help"]).unwrap_err();
assert_eq!(help.kind(), ErrorKind::DisplayHelp);
let ver = Cli::try_parse_from(["jubarte", "-V"]).unwrap_err();
assert_eq!(ver.kind(), ErrorKind::DisplayVersion);
}
#[test]
fn missing_inputs_error_at_resolve() {
let only_one = Cli::try_parse_from(["jubarte", "one.docx"])
.unwrap()
.resolve();
assert!(only_one.unwrap_err().contains("missing MODIFIED"));
let none = Cli::try_parse_from(["jubarte"]).unwrap().resolve();
assert!(none.unwrap_err().contains("missing ORIGINAL"));
}
#[test]
fn extra_positional_and_unknown_flag_rejected_by_clap() {
use clap::error::ErrorKind;
let extra = Cli::try_parse_from(["jubarte", "a.docx", "b.docx", "c.docx"]).unwrap_err();
assert_eq!(extra.kind(), ErrorKind::UnknownArgument);
let bogus = Cli::try_parse_from(["jubarte", "--bogus"]).unwrap_err();
assert_eq!(bogus.kind(), ErrorKind::UnknownArgument);
let missing_val = Cli::try_parse_from(["jubarte", "--author"]).unwrap_err();
assert_eq!(missing_val.kind(), ErrorKind::InvalidValue);
}
#[test]
fn revisions_subcommand_parses_with_default_json_false() {
let cli = Cli::try_parse_from(["jubarte", "revisions", "file.docx"]).unwrap();
match cli.command {
Some(Command::Revisions { file, json }) => {
assert_eq!(file, PathBuf::from("file.docx"));
assert!(!json);
}
other => panic!("expected revisions subcommand, got {other:?}"),
}
}
#[test]
fn revisions_subcommand_json_flag_parses() {
let cli = Cli::try_parse_from(["jubarte", "revisions", "file.docx", "--json"]).unwrap();
match cli.command {
Some(Command::Revisions { json, .. }) => assert!(json),
other => panic!("expected revisions subcommand, got {other:?}"),
}
}
#[test]
fn revisions_subcommand_missing_file_is_clap_error() {
use clap::error::ErrorKind;
let err = Cli::try_parse_from(["jubarte", "revisions"]).unwrap_err();
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}
#[test]
fn plain_compare_positionals_leave_command_none() {
let cli = Cli::try_parse_from(["jubarte", "a.docx", "b.docx"]).unwrap();
assert!(cli.command.is_none());
let job = cli.resolve().unwrap();
assert_eq!(job.original, PathBuf::from("a.docx"));
assert_eq!(job.modified, PathBuf::from("b.docx"));
}
#[test]
fn accept_subcommand_parses_file_output_and_force() {
let cli =
Cli::try_parse_from(["jubarte", "accept", "rl.docx", "-o", "out.docx", "--force"])
.unwrap();
match cli.command {
Some(Command::Accept {
file,
output,
force,
}) => {
assert_eq!(file, PathBuf::from("rl.docx"));
assert_eq!(output, PathBuf::from("out.docx"));
assert!(force);
}
other => panic!("expected accept subcommand, got {other:?}"),
}
}
#[test]
fn reject_subcommand_parses_file_output_and_defaults_force_false() {
let cli = Cli::try_parse_from(["jubarte", "reject", "rl.docx", "-o", "out.docx"]).unwrap();
match cli.command {
Some(Command::Reject {
file,
output,
force,
}) => {
assert_eq!(file, PathBuf::from("rl.docx"));
assert_eq!(output, PathBuf::from("out.docx"));
assert!(!force);
}
other => panic!("expected reject subcommand, got {other:?}"),
}
}
#[test]
fn reject_subcommand_requires_output() {
use clap::error::ErrorKind;
let err = Cli::try_parse_from(["jubarte", "reject", "rl.docx"]).unwrap_err();
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
}
#[test]
fn positional_named_revisions_is_parsed_as_subcommand() {
let cli = Cli::try_parse_from(["jubarte", "revisions", "b.docx"]).unwrap();
match cli.command {
Some(Command::Revisions { file, .. }) => assert_eq!(file, PathBuf::from("b.docx")),
other => panic!("expected revisions subcommand, got {other:?}"),
}
}
}