use std::collections::BTreeMap;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use axgf_rs::boundary::envelope::{DiagnosticCode, Envelope, Severity, Status};
use axgf_rs::{
add_entity, create_bundle, deduplicate, delete_entity, export_bundle, import_bundle, inspect,
update_entity, validate, DeletePolicy, EntityKind,
};
use base64::Engine as _;
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde_json::Value;
#[derive(Parser)]
#[command(
name = "axgf",
version,
about = "Command-line interface for the Axiom Genealogy Format reference library",
long_about = "One subcommand per V1 API function on the axgf-rs boundary. \
Default output is a concise human summary; `--json` selects \
the raw envelope (pipeable through `jq`); `-q/--quiet` \
carries the outcome in the exit code."
)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[arg(short, long, global = true)]
quiet: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Create {
#[arg(long, value_name = "NAME", alias = "family-name")]
name: Option<String>,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
Import {
#[command(flatten)]
input: InputPath,
},
Export {
#[command(flatten)]
input: InputPath,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
Inspect {
#[command(flatten)]
input: InputPath,
},
Validate {
#[command(flatten)]
input: InputPath,
},
Add {
#[arg(value_enum)]
kind: CliEntityKind,
#[command(flatten)]
input: InputPath,
#[arg(long, value_name = "PATH", alias = "entity")]
data: PathBuf,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
Update {
#[arg(value_enum)]
kind: CliEntityKind,
#[command(flatten)]
input: InputPath,
#[arg(long, value_name = "PATH", alias = "entity")]
data: PathBuf,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
Delete {
#[arg(value_enum)]
kind: CliEntityKind,
#[command(flatten)]
input: InputPath,
#[arg(long, value_name = "UUID")]
id: String,
#[arg(long, value_enum, default_value_t = CliPolicy::Reject)]
policy: CliPolicy,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
Dedup {
#[command(flatten)]
input: InputPath,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
},
#[cfg(feature = "gedcom")]
ConvertGedcom {
#[command(flatten)]
input: InputPath,
#[arg(short, long, value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long, default_value_t = 0.8)]
confidence: f64,
#[arg(long, default_value = "en")]
place_lang: String,
},
}
#[derive(Args, Debug)]
#[group(required = true, multiple = false)]
struct InputPath {
#[arg(value_name = "PATH")]
positional: Option<PathBuf>,
#[arg(long = "input", value_name = "PATH")]
flag: Option<PathBuf>,
}
impl InputPath {
fn path(&self) -> &Path {
self.positional
.as_deref()
.or(self.flag.as_deref())
.expect("clap group ensures one is set")
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
#[value(rename_all = "lowercase")]
enum CliEntityKind {
Person,
Family,
Event,
Link,
Occupation,
Source,
Place,
Document,
}
impl From<CliEntityKind> for EntityKind {
fn from(k: CliEntityKind) -> Self {
match k {
CliEntityKind::Person => Self::Person,
CliEntityKind::Family => Self::Family,
CliEntityKind::Event => Self::Event,
CliEntityKind::Link => Self::Link,
CliEntityKind::Occupation => Self::Occupation,
CliEntityKind::Source => Self::Source,
CliEntityKind::Place => Self::Place,
CliEntityKind::Document => Self::Document,
}
}
}
#[derive(Copy, Clone, Debug, ValueEnum)]
#[value(rename_all = "lowercase")]
enum CliPolicy {
Reject,
Cascade,
Orphan,
}
impl From<CliPolicy> for DeletePolicy {
fn from(p: CliPolicy) -> Self {
match p {
CliPolicy::Reject => Self::Reject,
CliPolicy::Cascade => Self::Cascade,
CliPolicy::Orphan => Self::Orphan,
}
}
}
fn read_bytes(path: &Path) -> io::Result<Vec<u8>> {
if path.as_os_str() == "-" {
let mut buf = Vec::new();
io::stdin().lock().read_to_end(&mut buf)?;
Ok(buf)
} else {
std::fs::read(path)
}
}
fn is_axgf_path(path: &Path) -> bool {
path.extension()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case("axgf"))
.unwrap_or(false)
}
fn io_error_envelope(context: &str, err: impl std::fmt::Display) -> Envelope {
Envelope::error(DiagnosticCode::Internal, format!("{context}: {err}"))
}
fn read_flat_bundle(path: &Path) -> Result<String, Envelope> {
if is_axgf_path(path) {
let bytes = read_bytes(path)
.map_err(|e| io_error_envelope(&format!("reading {}", path.display()), e))?;
let env = import_bundle(&bytes);
if env.status == Status::Error {
return Err(env);
}
Ok(env.data.to_string())
} else {
let bytes = read_bytes(path)
.map_err(|e| io_error_envelope(&format!("reading {}", path.display()), e))?;
String::from_utf8(bytes)
.map_err(|e| io_error_envelope(&format!("reading {}", path.display()), e))
}
}
fn write_bundle(path: &Path, flat: &Value) -> Result<usize, Envelope> {
if is_axgf_path(path) {
let flat_text = flat.to_string();
let env = export_bundle(&flat_text);
if env.status == Status::Error {
return Err(env);
}
let b64 = env
.data
.get("zip_base64")
.and_then(Value::as_str)
.ok_or_else(|| {
Envelope::error(
DiagnosticCode::Internal,
"export_bundle envelope did not contain zip_base64",
)
})?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64)
.map_err(|e| io_error_envelope("decoding zip_base64", e))?;
write_atomic(path, &bytes).map(|_| bytes.len())
} else {
let text = serde_json::to_string(flat)
.map_err(|e| io_error_envelope("serializing flat bundle", e))?;
write_atomic(path, text.as_bytes()).map(|_| text.len())
}
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), Envelope> {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
let file_name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("axgf-tmp");
let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
let tmp_path = match parent {
Some(p) => p.join(&tmp_name),
None => PathBuf::from(&tmp_name),
};
std::fs::write(&tmp_path, bytes)
.map_err(|e| io_error_envelope(&format!("writing {}", tmp_path.display()), e))?;
std::fs::rename(&tmp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
io_error_envelope(&format!("renaming to {}", path.display()), e)
})
}
#[derive(Copy, Clone, Debug)]
enum OutputMode {
Json,
Human,
Quiet,
}
impl OutputMode {
fn resolve(cli: &Cli) -> Self {
if cli.quiet {
Self::Quiet
} else if cli.json {
Self::Json
} else {
Self::Human
}
}
}
struct RunResult {
envelope: Envelope,
validate_report: bool,
}
impl RunResult {
fn from(env: Envelope) -> Self {
Self {
envelope: env,
validate_report: false,
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
let mode = OutputMode::resolve(&cli);
let result = execute(cli.command, mode);
let exit = pick_exit(&result);
match mode {
OutputMode::Json => {
let _ = writeln!(io::stdout(), "{}", result.envelope.to_json());
}
OutputMode::Quiet => {}
OutputMode::Human => {
if result.envelope.status == Status::Error {
emit_errors_stderr(&result.envelope);
}
}
}
exit
}
fn pick_exit(r: &RunResult) -> ExitCode {
if r.envelope.status == Status::Error {
return ExitCode::from(1);
}
if r.validate_report
&& r.envelope
.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
{
return ExitCode::from(2);
}
ExitCode::SUCCESS
}
fn emit_errors_stderr(env: &Envelope) {
for d in &env.diagnostics {
let _ = writeln!(io::stderr(), "{}: {}", d.code.as_str(), d.message);
}
}
fn execute(cmd: Command, mode: OutputMode) -> RunResult {
match cmd {
Command::Create { name, output } => cmd_create(name, output, mode),
Command::Import { input } => cmd_import(input, mode),
Command::Export { input, output } => cmd_export(input, output, mode),
Command::Inspect { input } => cmd_inspect(input, mode),
Command::Validate { input } => cmd_validate(input, mode),
Command::Add {
kind,
input,
data,
output,
} => cmd_mutate("add", kind, input, output, mode, |flat| {
let entity = match read_text_bytes(&data) {
Ok(s) => s,
Err(e) => return e,
};
add_entity(&flat, kind.into(), &entity)
}),
Command::Update {
kind,
input,
data,
output,
} => cmd_mutate("updated", kind, input, output, mode, |flat| {
let entity = match read_text_bytes(&data) {
Ok(s) => s,
Err(e) => return e,
};
update_entity(&flat, kind.into(), &entity)
}),
Command::Delete {
kind,
input,
id,
policy,
output,
} => cmd_mutate("deleted", kind, input, output, mode, |flat| {
delete_entity(&flat, kind.into(), &id, policy.into())
}),
Command::Dedup { input, output } => cmd_dedup(input, output, mode),
#[cfg(feature = "gedcom")]
Command::ConvertGedcom {
input,
output,
confidence,
place_lang,
} => cmd_convert_gedcom(input, output, confidence, place_lang, mode),
}
}
fn read_text_bytes(path: &Path) -> Result<String, Envelope> {
let bytes = read_bytes(path)
.map_err(|e| io_error_envelope(&format!("reading {}", path.display()), e))?;
String::from_utf8(bytes)
.map_err(|e| io_error_envelope(&format!("reading {}", path.display()), e))
}
fn cmd_create(name: Option<String>, output: Option<PathBuf>, mode: OutputMode) -> RunResult {
let env = create_bundle(name.as_deref());
if output.is_none() && !matches!(mode, OutputMode::Json | OutputMode::Quiet) {
return RunResult::from(Envelope::error(
DiagnosticCode::Internal,
"create requires -o/--output (or --json to print the envelope)",
));
}
if env.status == Status::Error {
return RunResult::from(env);
}
if let Some(out) = output.as_ref() {
let bytes_written = match write_bundle(out, &env.data) {
Ok(n) => n,
Err(e) => return RunResult::from(e),
};
if matches!(mode, OutputMode::Human) {
print_key_value_table("created bundle", key_value_manifest(&env.data));
print_wrote(out, bytes_written);
}
} else if matches!(mode, OutputMode::Human) {
print_key_value_table("created bundle", key_value_manifest(&env.data));
}
RunResult::from(env)
}
fn cmd_import(input: InputPath, mode: OutputMode) -> RunResult {
let path = input.path();
let bytes = match read_bytes(path) {
Ok(b) => b,
Err(e) => {
return RunResult::from(io_error_envelope(&format!("reading {}", path.display()), e))
}
};
let env = import_bundle(&bytes);
if matches!(mode, OutputMode::Human) && env.status == Status::Ok {
print_key_value_table(
&format!("imported {}", display_short(path)),
key_value_manifest(&env.data),
);
emit_grouped_diagnostics_stderr(&env);
}
RunResult::from(env)
}
fn cmd_export(input: InputPath, output: Option<PathBuf>, mode: OutputMode) -> RunResult {
let path = input.path();
let flat = match read_flat_bundle(path) {
Ok(s) => s,
Err(e) => return RunResult::from(e),
};
let env = export_bundle(&flat);
if env.status == Status::Error {
return RunResult::from(env);
}
if output.is_none() && !matches!(mode, OutputMode::Json | OutputMode::Quiet) {
return RunResult::from(Envelope::error(
DiagnosticCode::Internal,
"export requires -o/--output (or --json to receive base64 in the envelope)",
));
}
if let Some(out) = output.as_ref() {
let bytes_written = if is_axgf_path(out) {
let b64 = env
.data
.get("zip_base64")
.and_then(Value::as_str)
.unwrap_or("");
let zip_bytes = match base64::engine::general_purpose::STANDARD.decode(b64) {
Ok(b) => b,
Err(e) => return RunResult::from(io_error_envelope("decoding zip_base64", e)),
};
if let Err(e) = write_atomic(out, &zip_bytes) {
return RunResult::from(e);
}
zip_bytes.len()
} else {
let parsed: Value = match serde_json::from_str(&flat) {
Ok(v) => v,
Err(e) => {
return RunResult::from(io_error_envelope("re-serializing flat bundle", e))
}
};
match write_bundle(out, &parsed) {
Ok(n) => n,
Err(e) => return RunResult::from(e),
}
};
if matches!(mode, OutputMode::Human) {
let _ = writeln!(io::stdout(), "exported {}", display_short(path));
print_wrote(out, bytes_written);
}
}
RunResult::from(env)
}
fn cmd_inspect(input: InputPath, mode: OutputMode) -> RunResult {
let path = input.path();
let flat = match read_flat_bundle(path) {
Ok(s) => s,
Err(e) => return RunResult::from(e),
};
let env = inspect(&flat);
if matches!(mode, OutputMode::Human) && env.status == Status::Ok {
let _ = writeln!(io::stdout(), "{}", display_short(path));
let mut rows: Vec<(String, String)> = Vec::new();
if let Some(v) = env.data["manifest"].get("axgf").and_then(Value::as_str) {
rows.push(("axgf".into(), v.into()));
}
if let Some(v) = env.data["manifest"]["family"]
.get("name")
.and_then(Value::as_str)
{
rows.push(("family".into(), v.into()));
}
for (label, key) in stat_labels() {
let n = env.data["stats"]
.get(key)
.and_then(Value::as_u64)
.unwrap_or(0);
rows.push((label.into(), n.to_string()));
}
print_rows(rows);
emit_grouped_diagnostics_stderr(&env);
}
RunResult::from(env)
}
fn cmd_validate(input: InputPath, mode: OutputMode) -> RunResult {
let path = input.path();
let flat = match read_flat_bundle(path) {
Ok(s) => s,
Err(e) => return RunResult::from(e),
};
let env = validate(&flat);
if matches!(mode, OutputMode::Human) && env.status == Status::Ok {
let _ = writeln!(io::stdout(), "validated {}", display_short(path));
let errors = env.data.get("errors").and_then(Value::as_u64).unwrap_or(0);
let warnings = env
.data
.get("warnings")
.and_then(Value::as_u64)
.unwrap_or(0);
let mut rows: Vec<(String, String)> = vec![
("errors".into(), errors.to_string()),
("warnings".into(), warnings.to_string()),
];
for (code, count) in group_by_code(&env.diagnostics) {
rows.push((code, count.to_string()));
}
print_rows(rows);
}
RunResult {
envelope: env,
validate_report: true,
}
}
fn cmd_mutate<F>(
verb: &str,
kind: CliEntityKind,
input: InputPath,
output: Option<PathBuf>,
mode: OutputMode,
op: F,
) -> RunResult
where
F: FnOnce(String) -> Envelope,
{
let in_path = input.path().to_path_buf();
let flat = match read_flat_bundle(&in_path) {
Ok(s) => s,
Err(e) => return RunResult::from(e),
};
let env = op(flat);
if env.status == Status::Error {
return RunResult::from(env);
}
let dest = output.as_deref().unwrap_or(&in_path);
let bundle = env.data.get("bundle").cloned().unwrap_or(Value::Null);
let bytes_written = match write_bundle(dest, &bundle) {
Ok(n) => n,
Err(e) => return RunResult::from(e),
};
if matches!(mode, OutputMode::Human) {
let id = env
.data
.get("id")
.and_then(Value::as_str)
.unwrap_or("<unknown>");
let action_word = match verb {
"add" => "added",
_ => verb,
};
let _ = writeln!(
io::stdout(),
"{action_word} {kind} {id}",
kind = kind_singular(kind)
);
emit_grouped_diagnostics_stderr(&env);
print_wrote(dest, bytes_written);
}
RunResult::from(env)
}
fn kind_singular(k: CliEntityKind) -> &'static str {
EntityKind::from(k).singular()
}
fn cmd_dedup(input: InputPath, output: Option<PathBuf>, mode: OutputMode) -> RunResult {
let in_path = input.path().to_path_buf();
let flat = match read_flat_bundle(&in_path) {
Ok(s) => s,
Err(e) => return RunResult::from(e),
};
let env = deduplicate(&flat);
if env.status == Status::Error {
return RunResult::from(env);
}
let dest = output.as_deref().unwrap_or(&in_path);
let bundle = env.data.get("bundle").cloned().unwrap_or(Value::Null);
let bytes_written = match write_bundle(dest, &bundle) {
Ok(n) => n,
Err(e) => return RunResult::from(e),
};
if matches!(mode, OutputMode::Human) {
let _ = writeln!(io::stdout(), "deduplicated {}", display_short(&in_path));
let merged_p = env
.data
.get("merged_persons")
.and_then(Value::as_u64)
.unwrap_or(0);
let merged_f = env
.data
.get("merged_families")
.and_then(Value::as_u64)
.unwrap_or(0);
let manual = env
.data
.get("manual_review")
.and_then(Value::as_u64)
.unwrap_or(0);
print_rows(vec![
("merged persons".into(), merged_p.to_string()),
("merged families".into(), merged_f.to_string()),
("manual review".into(), manual.to_string()),
]);
emit_grouped_diagnostics_stderr(&env);
print_wrote(dest, bytes_written);
}
RunResult::from(env)
}
#[cfg(feature = "gedcom")]
fn cmd_convert_gedcom(
input: InputPath,
output: Option<PathBuf>,
confidence: f64,
place_lang: String,
mode: OutputMode,
) -> RunResult {
let path = input.path();
let bytes = match read_bytes(path) {
Ok(b) => b,
Err(e) => {
return RunResult::from(io_error_envelope(&format!("reading {}", path.display()), e))
}
};
let env = axgf_rs::convert_gedcom(&bytes, confidence, &place_lang);
if env.status == Status::Error {
return RunResult::from(env);
}
if output.is_none() && !matches!(mode, OutputMode::Json | OutputMode::Quiet) {
return RunResult::from(Envelope::error(
DiagnosticCode::Internal,
"convert-gedcom requires -o/--output (or --json to print the envelope)",
));
}
if let Some(out) = output.as_ref() {
let bundle = env.data.get("bundle").cloned().unwrap_or(Value::Null);
let bytes_written = match write_bundle(out, &bundle) {
Ok(n) => n,
Err(e) => return RunResult::from(e),
};
if matches!(mode, OutputMode::Human) {
let _ = writeln!(io::stdout(), "converted {}", display_short(path));
print_key_value_table_headerless(key_value_manifest(
&env.data.get("bundle").cloned().unwrap_or(Value::Null),
));
emit_grouped_diagnostics_stderr(&env);
print_wrote(out, bytes_written);
}
}
RunResult::from(env)
}
fn stat_labels() -> [(&'static str, &'static str); 8] {
[
("persons", "persons"),
("families", "families"),
("events", "events"),
("links", "links"),
("occupations", "occupations"),
("sources", "sources"),
("places", "places"),
("documents", "documents"),
]
}
fn key_value_manifest(flat: &Value) -> Vec<(String, String)> {
let mut rows: Vec<(String, String)> = Vec::new();
let stats = flat.get("manifest").and_then(|m| m.get("stats"));
for (label, key) in stat_labels() {
let n = stats
.and_then(|s| s.get(key))
.and_then(Value::as_u64)
.or_else(|| {
flat.get(key)
.and_then(|v| v.as_object().map(|m| m.len() as u64))
})
.unwrap_or(0);
rows.push((label.into(), n.to_string()));
}
rows
}
fn print_key_value_table(header: &str, rows: Vec<(String, String)>) {
let _ = writeln!(io::stdout(), "{header}");
print_rows(rows);
}
fn print_key_value_table_headerless(rows: Vec<(String, String)>) {
print_rows(rows);
}
fn print_rows(rows: Vec<(String, String)>) {
let label_w = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let value_w = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
let all_numeric = rows
.iter()
.all(|(_, v)| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()));
for (k, v) in rows {
if all_numeric {
let _ = writeln!(
io::stdout(),
" {k:<label_w$} {v:>value_w$}",
label_w = label_w,
value_w = value_w
);
} else {
let _ = writeln!(io::stdout(), " {k:<label_w$} {v}", label_w = label_w);
}
}
}
fn print_wrote(path: &Path, bytes: usize) {
let _ = writeln!(
io::stdout(),
"wrote {} ({})",
display_short(path),
format_size(bytes)
);
}
fn display_short(path: &Path) -> String {
path.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| path.display().to_string())
}
fn format_size(n: usize) -> String {
const KIB: usize = 1024;
const MIB: usize = 1024 * 1024;
if n < KIB {
format!("{n} B")
} else if n < MIB {
format!("{} KiB", n / KIB)
} else {
let m = n as f64 / MIB as f64;
if m >= 10.0 {
format!("{m:.0} MiB")
} else {
format!("{m:.1} MiB")
}
}
}
fn group_by_code(diags: &[axgf_rs::boundary::envelope::Diagnostic]) -> Vec<(String, usize)> {
let mut order: Vec<String> = Vec::new();
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
for d in diags {
let key = d.code.as_str().to_string();
if !counts.contains_key(&key) {
order.push(key.clone());
}
*counts.entry(key).or_insert(0) += 1;
}
order
.into_iter()
.map(|k| {
let n = counts.get(&k).copied().unwrap_or(0);
(k, n)
})
.collect()
}
fn emit_grouped_diagnostics_stderr(env: &Envelope) {
if env.diagnostics.is_empty() {
return;
}
let mut grouped = group_by_code(&env.diagnostics);
grouped.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let label_w = grouped.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (code, count) in grouped {
let _ = writeln!(
io::stderr(),
" {code:<label_w$} {count}",
label_w = label_w
);
}
}