mod commands;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use docrs::{Error, ErrorKind};
#[derive(Parser)]
#[command(
name = "docrs",
version,
about = "Preservation-first DOCX inspection and safe editing"
)]
pub struct Cli {
#[arg(long, global = true)]
json: bool,
#[arg(long, global = true)]
trusted: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
pub enum Command {
Diagnose {
file: std::path::PathBuf,
},
Diff {
a: std::path::PathBuf,
b: std::path::PathBuf,
},
Outline {
input: std::path::PathBuf,
#[arg(long, default_value = "current")]
view: String,
},
Find {
input: std::path::PathBuf,
text: String,
#[arg(long)]
normalized: bool,
#[arg(long, default_value = "current")]
view: String,
#[arg(long)]
story: Option<String>,
},
Replace {
input: std::path::PathBuf,
#[arg(long)]
find: String,
#[arg(long = "with")]
with_text: String,
#[arg(long)]
out: std::path::PathBuf,
#[arg(long)]
all: bool,
#[arg(long)]
normalized: bool,
#[arg(long)]
nth: Option<usize>,
#[arg(long)]
story: Option<String>,
#[arg(long)]
acknowledge_protection: bool,
},
TextDiff {
a: std::path::PathBuf,
b: std::path::PathBuf,
#[arg(long, default_value = "current")]
view: String,
},
Pending {
input: std::path::PathBuf,
},
Compare {
original: std::path::PathBuf,
revised: std::path::PathBuf,
#[arg(long)]
author: String,
#[arg(long)]
date: Option<String>,
#[arg(long)]
block: bool,
#[arg(long)]
materialize: Option<String>,
#[arg(long)]
acknowledge_protection: bool,
#[arg(long)]
out: std::path::PathBuf,
},
Revisions {
input: std::path::PathBuf,
},
Resolve {
input: std::path::PathBuf,
#[arg(long, conflicts_with = "reject", required_unless_present = "reject")]
accept: bool,
#[arg(long)]
reject: bool,
#[arg(long)]
author: Option<String>,
#[arg(long)]
out: std::path::PathBuf,
#[arg(long)]
acknowledge_protection: bool,
},
Append {
input: std::path::PathBuf,
source: std::path::PathBuf,
#[arg(long, default_value = "match_by_name")]
styles: String,
#[arg(long, default_value = "new_page")]
section: String,
#[arg(long, default_value = "destination")]
headers: String,
#[arg(long)]
out: std::path::PathBuf,
#[arg(long)]
acknowledge_protection: bool,
},
InsertFrom {
input: std::path::PathBuf,
source: std::path::PathBuf,
#[arg(long)]
start: String,
#[arg(long)]
anchor: String,
#[arg(long, conflicts_with = "count")]
end: Option<String>,
#[arg(long, default_value_t = 1)]
count: i64,
#[arg(long)]
exclude_start: bool,
#[arg(long, requires = "end")]
exclude_end: bool,
#[arg(long, default_value = "match_by_name")]
styles: String,
#[arg(long)]
out: std::path::PathBuf,
#[arg(long)]
acknowledge_protection: bool,
},
FromMarkdown {
input: std::path::PathBuf,
#[arg(long)]
from: Option<String>,
#[arg(long)]
reference: Option<std::path::PathBuf>,
#[arg(long)]
out: std::path::PathBuf,
#[arg(long, conflicts_with = "append")]
placeholder: Option<String>,
#[arg(long)]
append: bool,
#[arg(long = "style", value_name = "ROLE=STYLE")]
styles: Vec<String>,
#[arg(long)]
strict_styles: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
resource_path: Option<std::path::PathBuf>,
#[arg(long, conflicts_with = "no_toc")]
toc: bool,
#[arg(long)]
no_toc: bool,
#[arg(long)]
toc_depth: Option<i64>,
#[arg(long)]
toc_title: Option<String>,
#[arg(long, allow_hyphen_values = true, default_value_t = 0)]
shift_heading_level: i8,
#[arg(long)]
smart: bool,
#[arg(long)]
no_title_block: bool,
#[arg(long)]
no_properties: bool,
#[arg(long)]
no_captions: bool,
#[arg(long)]
converter_lists: bool,
#[arg(long)]
toc_page_numbers: bool,
#[arg(long)]
no_toc_numbers: bool,
#[arg(long)]
page_numbers: bool,
#[arg(long, conflicts_with = "no_cover_page")]
cover_page: bool,
#[arg(long)]
no_cover_page: bool,
#[arg(long)]
acknowledge_protection: bool,
#[arg(long)]
strict_package: bool,
},
MarkdownReference {
#[arg(long)]
out: std::path::PathBuf,
},
MarkdownStyles {
reference: Option<std::path::PathBuf>,
#[arg(long = "style", value_name = "ROLE=STYLE")]
styles: Vec<String>,
#[arg(long)]
strict_package: bool,
},
Layout {
file: std::path::PathBuf,
#[arg(long = "font", value_name = "FILE")]
fonts: Vec<std::path::PathBuf>,
},
MarkdownInspect {
reference: std::path::PathBuf,
#[arg(long = "style", value_name = "ROLE=STYLE")]
styles: Vec<String>,
#[arg(long)]
page_numbers: bool,
#[arg(long, conflicts_with = "no_cover_page")]
cover_page: bool,
#[arg(long)]
no_cover_page: bool,
#[arg(long)]
strict_package: bool,
},
Roundtrip {
input: std::path::PathBuf,
#[arg(long)]
out: std::path::PathBuf,
},
}
pub fn exit_code(err: &Error) -> u8 {
match err.kind() {
ErrorKind::Refusal(_) => 3,
ErrorKind::InvalidArgument => 2,
ErrorKind::ResourceLimit => 4,
ErrorKind::Io => 5,
ErrorKind::Internal => 70,
}
}
fn main() -> ExitCode {
let json = std::env::args_os().any(|arg| arg == "--json");
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(err) => return usage_error(err, json),
};
let limits = if cli.trusted {
docrs::Limits::trusted()
} else {
docrs::Limits::default()
};
match guarded(|| commands::run(&cli.command, &limits, cli.json)) {
Ok(code) => ExitCode::from(code),
Err(err) => {
if cli.json {
println!("{}", err.to_json());
} else {
eprintln!("docrs: {err}");
}
ExitCode::from(exit_code(&err))
}
}
}
fn usage_error(err: clap::Error, json: bool) -> ExitCode {
use clap::error::ErrorKind as Clap;
if !json || matches!(err.kind(), Clap::DisplayHelp | Clap::DisplayVersion) {
err.exit();
}
let reason = match err.kind() {
Clap::UnknownArgument => "unknown_argument",
Clap::InvalidSubcommand => "unknown_command",
Clap::MissingRequiredArgument => "missing_argument",
Clap::MissingSubcommand | Clap::DisplayHelpOnMissingArgumentOrSubcommand => {
"missing_command"
}
Clap::InvalidValue | Clap::ValueValidation | Clap::InvalidUtf8 => "invalid_value",
Clap::ArgumentConflict => "argument_conflict",
_ => "invalid_usage",
};
let message = err.render().to_string();
let message = message.trim().trim_start_matches("error: ");
let err = Error::invalid_argument(reason, message);
println!("{}", err.to_json());
ExitCode::from(exit_code(&err))
}
static PANIC_LOCATION: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
fn guarded(command: impl FnOnce() -> docrs::Result<u8>) -> docrs::Result<u8> {
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Some(location) = info.location()
&& let Ok(mut slot) = PANIC_LOCATION.lock()
{
*slot = Some(location.to_string());
}
if std::env::var_os("RUST_BACKTRACE").is_some_and(|value| value != "0") {
default_hook(info);
}
}));
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
#[cfg(debug_assertions)]
if std::env::var_os("DOCRS_TEST_PANIC").is_some() {
panic!("panic requested by DOCRS_TEST_PANIC");
}
command()
}))
.unwrap_or_else(|payload| {
let what = payload
.downcast_ref::<&str>()
.map(|text| text.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic".to_string());
let at = PANIC_LOCATION
.lock()
.ok()
.and_then(|mut slot| slot.take())
.map(|location| format!(" at {location}"))
.unwrap_or_default();
Err(Error::internal(
"internal_panic",
format!("docrs panicked{at}: {what} (this is a bug; please report it)"),
))
})
}