mod cli;
mod error;
mod extract;
mod inject;
mod json_path;
mod model;
mod output;
mod strip;
use cli::CliResult;
use error::AppError;
use model::Output;
use std::collections::BTreeMap;
use std::process;
fn run() -> Result<(), AppError> {
match cli::parse_args().map_err(|e| AppError::Usage(e.to_string()))? {
CliResult::Help => {
println!(
"Usage: ime <file> [-o <path>] [-s] [--set K=V] [--set-json <json>] [-k <path>]"
);
println!("Options:");
println!(
" -o, --output <path> write result (or modified file) to <path>; default: stdout / in-place"
);
println!(" -s, --strip strip all metadata (JPEG & PNG only)");
println!(" --set K=V set a metadata tag; can be repeated");
println!(" flat: --set Artist=\"Jane\"");
println!(" nested: --set .workflow.nodes[0].type=KSampler");
println!(" --set-json <json> merge a JSON object into metadata");
println!(
" each top-level key becomes a tag; existing tags are preserved"
);
println!(" -k, --key <path> extract a single value using dot-notation");
println!(
" e.g. -k Exif.Model or -k PngText.workflow.nodes[0].type"
);
println!(
" string values that contain JSON are traversed automatically"
);
println!(" -v, --version print version");
println!(" -h, --help print this help");
Ok(())
}
CliResult::Version => {
println!("{}", env!("CARGO_PKG_VERSION"));
Ok(())
}
CliResult::Args(args) => {
let has_inject = !args.set.is_empty() || args.set_json.is_some();
let strip_temp: Option<String> = if args.strip && has_inject {
let t = format!("{}.strip_tmp", args.file);
strip::strip_metadata(&args.file, Some(&t))
.map_err(|e| AppError::Runtime(e.to_string()))?;
Some(t)
} else if args.strip {
strip::strip_metadata(&args.file, args.output.as_deref())
.map_err(|e| AppError::Runtime(e.to_string()))?;
return Ok(());
} else {
None
};
let source = strip_temp.as_deref().unwrap_or(&args.file);
if has_inject {
let mut parser = nom_exif::MediaParser::new();
let flat_tags =
build_inject_tags(source, &args.set, args.set_json.as_deref(), &mut parser)
.map_err(AppError::Runtime)?;
let dest = if strip_temp.is_some() && args.output.is_none() {
Some(args.file.as_str())
} else {
args.output.as_deref()
};
inject::inject_metadata(source, dest, &flat_tags)
.map_err(|e| AppError::Runtime(e.to_string()))?;
if let Some(ref t) = strip_temp {
std::fs::remove_file(t).ok();
}
return Ok(());
}
let mut parser = nom_exif::MediaParser::new();
let metadata = extract::extract(source, &mut parser)
.map_err(|e| AppError::Runtime(e.to_string()))?;
if let Some(key_path) = &args.key {
let root: serde_json::Value = serde_json::to_value(&metadata)
.map_err(|e| AppError::Runtime(e.to_string()))?;
match json_path::extract(root, key_path) {
Some(serde_json::Value::String(s)) => println!("{}", s),
Some(other) => {
let pretty = serde_json::to_string_pretty(&other)
.map_err(|e| AppError::Runtime(e.to_string()))?;
println!("{}", pretty);
}
None => {
return Err(AppError::Runtime(format!(
"Key '{}' not found in metadata",
key_path
)));
}
}
return Ok(());
}
output::write_output(&metadata, args.output.as_deref())
.map_err(|e| AppError::Runtime(e.to_string()))?;
Ok(())
}
}
}
fn build_inject_tags(
source_file: &str,
raw_set: &BTreeMap<String, String>,
set_json: Option<&str>,
parser: &mut nom_exif::MediaParser,
) -> Result<BTreeMap<String, String>, String> {
let mut flat: BTreeMap<String, String> = BTreeMap::new();
let mut nested: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
for (raw_key, value) in raw_set {
let key = raw_key.trim_start_matches('.');
if let Some((root, sub)) = split_root_and_sub(key) {
nested.entry(root).or_default().push((sub, value.clone()));
} else {
flat.insert(key.to_string(), value.clone());
}
}
if !nested.is_empty() {
let current: Output = extract::extract(source_file, parser)
.map_err(|e| format!("Failed to read metadata for nested --set: {}", e))?;
for (root_tag, modifications) in nested {
let current_str = current
.values()
.find_map(|dir| dir.get(&root_tag))
.map(String::as_str)
.unwrap_or("{}");
let mut json: serde_json::Value = serde_json::from_str(current_str)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
for (sub_path, raw_value) in modifications {
let new_val = serde_json::from_str(&raw_value)
.unwrap_or(serde_json::Value::String(raw_value));
json = json_path::set_at_path(json, &sub_path, new_val);
}
flat.insert(root_tag, serde_json::to_string(&json).unwrap_or_default());
}
}
if let Some(json_str) = set_json {
let obj: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| format!("--set-json: invalid JSON — {}", e))?;
let map = obj.as_object().ok_or_else(|| {
"--set-json: value must be a JSON object, not an array or scalar".to_string()
})?;
for (k, v) in map {
let str_val = match v {
serde_json::Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
};
flat.entry(k.clone()).or_insert(str_val);
}
}
Ok(flat)
}
fn split_root_and_sub(path: &str) -> Option<(String, String)> {
let sep = path.find(['.', '['])?;
let root = path[..sep].to_string();
let sub = if path.as_bytes()[sep] == b'.' {
path[sep + 1..].to_string()
} else {
path[sep..].to_string()
};
if root.is_empty() {
None
} else {
Some((root, sub))
}
}
fn main() {
if let Err(e) = run() {
eprintln!("{}", e);
process::exit(e.exit_code());
}
}