//! Headless CLI; source-preserving conversion with atomic destination publication. use mb3_dat::{Document, Limits, Version, World}; use std::ffi::OsString; use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io::{self, BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; const HELP: &str = "nanobrush inspect INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush validate INPUT [--from auto|txt|dat] [LIMITS]\nnanobrush convert INPUT OUTPUT --to txt|dat-201501280|dat-201512200 [--from auto|txt|dat] [--force] [LIMITS]\nLIMITS: --max-bytes N --max-items N --max-string-bytes N --max-owned-bytes N\nUse -- before positional paths beginning with a dash. TXT input is UTF-8; output has a BOM.\nExit: 0 success/help, 1 invalid data or I/O failure, 2 usage error.\n"; #[derive(Debug)] pub struct Error { pub usage: bool, message: String, } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.message) } } impl std::error::Error for Error {} impl From for Error { fn from(e: io::Error) -> Self { fail(e.to_string()) } } fn fail(message: impl Into) -> Error { Error { usage: false, message: message.into(), } } fn usage(message: impl Into) -> Error { Error { usage: true, message: message.into(), } } type Result = std::result::Result; #[derive(Clone, Copy)] enum InputFormat { Auto, Txt, Dat, } #[derive(Clone, Copy)] enum To { Txt, Dat(Version), } struct Options { command: String, paths: Vec, from: InputFormat, to: Option, force: bool, limits: Limits, } fn parse(args: impl IntoIterator) -> Result> { let mut args = args.into_iter(); let command = args.next().ok_or_else(|| usage(HELP))?; if command == "--help" || command == "-h" { return Ok(None); } let command = command .into_string() .map_err(|_| usage("command must be UTF-8"))?; if !matches!(command.as_str(), "inspect" | "validate" | "convert") { return Err(usage("expected inspect, validate or convert; use --help")); } let mut options = Options { command, paths: Vec::new(), from: InputFormat::Auto, to: None, force: false, limits: Limits::default(), }; let mut positional = false; let mut seen = std::collections::HashSet::new(); while let Some(arg) = args.next() { if !positional && arg == "--" { positional = true; continue; } if !positional && (arg == "--help" || arg == "-h") { return Ok(None); } if !positional && arg.as_encoded_bytes().starts_with(b"-") { let flag = arg.to_str().ok_or_else(|| usage("option must be UTF-8"))?; if !seen.insert(flag.to_owned()) { return Err(usage(format!("duplicate option {flag}"))); } if flag == "--force" { options.force = true; continue; } if !matches!( flag, "--from" | "--to" | "--max-bytes" | "--max-items" | "--max-string-bytes" | "--max-owned-bytes" ) { return Err(usage(format!("unknown option {flag}"))); } let value = args .next() .ok_or_else(|| usage(format!("missing value for {flag}")))?; let value = value .to_str() .ok_or_else(|| usage("option value must be UTF-8"))?; match flag { "--from" => { options.from = match value { "auto" => InputFormat::Auto, "txt" => InputFormat::Txt, "dat" => InputFormat::Dat, _ => return Err(usage("--from requires auto, txt or dat")), } } "--to" => { options.to = Some(match value { "txt" => To::Txt, "dat-201501280" => To::Dat(Version::V201501280), "dat-201512200" => To::Dat(Version::V201512200), _ => { return Err(usage("--to requires txt, dat-201501280 or dat-201512200")); } }) } _ => { if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { return Err(usage("limits must be nonnegative decimal integers")); } let n = value .parse::() .map_err(|_| usage("limit outside platform usize range"))?; match flag { "--max-bytes" => options.limits.max_bytes = n, "--max-items" => options.limits.max_items = n, "--max-string-bytes" => options.limits.max_string_bytes = n, _ => options.limits.max_owned_bytes = n, } } } } else { options.paths.push(arg.into()); } } if options.command == "convert" { if options.paths.len() != 2 || options.to.is_none() { return Err(usage("convert requires INPUT OUTPUT --to FORMAT")); } } else if options.paths.len() != 1 || options.to.is_some() || options.force { return Err(usage( "inspect/validate require one INPUT and no --to/--force", )); } Ok(Some(options)) } struct Input { world: World, version: Option, } fn load(path: &Path, from: InputFormat, limits: &Limits) -> Result { let mut file = BufReader::new(File::open(path).map_err(|e| fail(format!("{}: {e}", path.display())))?); let dat = match from { InputFormat::Dat => true, InputFormat::Txt => false, InputFormat::Auto => file.fill_buf()?.starts_with(b"Mb3World"), }; if dat { let d = mb3_dat::read(file, limits).map_err(|e| fail(format!("{}: {e}", path.display())))?; Ok(Input { world: d.world, version: Some(d.version), }) } else { let world = mb3_txt::read(file, limits).map_err(|e| fail(format!("{}: {e}", path.display())))?; Ok(Input { world, version: None, }) } } static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); struct Temp(PathBuf); impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_file(&self.0); } } fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool { #[cfg(unix)] { use std::os::unix::fs::MetadataExt; a.dev() == b.dev() && a.ino() == b.ino() } #[cfg(not(unix))] { let _ = (a, b); false } } fn save(input: &Path, output: &Path, bytes: &[u8], force: bool) -> Result<()> { let parent = output .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let parent = fs::canonicalize(parent)?; let name = output .file_name() .ok_or_else(|| fail("output requires a filename"))?; let output = parent.join(name); let input_meta = fs::metadata(input)?; match fs::symlink_metadata(&output) { Ok(meta) => { if !meta.is_file() || meta.file_type().is_symlink() { return Err(fail( "output must be a regular file, not a symlink or directory", )); } if same_file(&input_meta, &meta) || fs::canonicalize(input)? == fs::canonicalize(&output)? { return Err(fail( "input and output must be different files (including hard links)", )); } if !force { return Err(fail("output exists; use --force to replace it")); } } Err(e) if e.kind() == io::ErrorKind::NotFound => (), Err(e) => return Err(e.into()), } let mut created = None; for _ in 0..100 { let path = parent.join(format!( ".nanobrush-{}-{}.tmp", std::process::id(), NEXT_TEMP.fetch_add(1, Ordering::Relaxed) )); let mut options = OpenOptions::new(); options.create_new(true).write(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } match options.open(&path) { Ok(file) => { created = Some((Temp(path), file)); break; } Err(e) if e.kind() == io::ErrorKind::AlreadyExists => (), Err(e) => return Err(e.into()), } } let (temp, mut file) = created.ok_or_else(|| fail("cannot allocate a unique output temporary file"))?; file.write_all(bytes)?; file.sync_all()?; drop(file); publish(temp, &output, force) } fn publish(temp: Temp, output: &Path, force: bool) -> Result<()> { if force { fs::rename(&temp.0, output)?; } else { fs::hard_link(&temp.0, output)?; } Ok(()) } /// Execute without exiting the process. Errors distinguish usage (exit 2) from /// data/I/O (exit 1). Arguments exclude the executable's name. pub fn run(args: impl IntoIterator, stdout: &mut dyn Write) -> Result<()> { let Some(o) = parse(args)? else { stdout.write_all(HELP.as_bytes())?; return Ok(()); }; let input = load(&o.paths[0], o.from, &o.limits)?; match o.command.as_str() { "inspect" => { let w = &input.world; let planes: usize = w.brushes.iter().map(|b| b.planes.len()).sum(); let polygons: usize = w .brushes .iter() .flat_map(|b| &b.planes) .map(|p| p.polygon.as_ref().map_or(0, Vec::len)) .sum(); let anchors: usize = w.patches.iter().map(|p| p.anchors.len()).sum(); let points: usize = w .patches .iter() .map(|p| p.computed.as_ref().map_or(0, |c| c.points.len())) .sum(); let normals: usize = w .patches .iter() .map(|p| p.computed.as_ref().map_or(0, |c| c.normals.len())) .sum(); let version = input .version .map_or("null".into(), |v| (v as u32).to_string()); writeln!( stdout, "{{\"format\":\"{}\",\"dat_version\":{},\"computed\":{},\"brushes\":{},\"planes\":{},\"patches\":{},\"string_properties\":{},\"uv_properties\":{},\"polygon_vertices\":{},\"anchors\":{},\"points\":{},\"normals\":{}}}", if input.version.is_some() { "mb3dat" } else { "mb3txt" }, version, w.computed, w.brushes.len(), planes, w.patches.len(), w.string_properties.len(), w.uv_properties.len(), polygons, anchors, points, normals )?; } "validate" => writeln!( stdout, "valid: {}", if input.version.is_some() { "mb3dat" } else { "mb3txt" } )?, _ => { let bytes = match o.to.ok_or_else(|| usage("missing --to"))? { To::Txt => { mb3_txt::encode(&input.world, &o.limits).map_err(|e| fail(e.to_string()))? } To::Dat(version) => mb3_dat::encode( &Document { version, world: input.world, }, &o.limits, ) .map_err(|e| fail(e.to_string()))?, }; save(&o.paths[0], &o.paths[1], &bytes, o.force)?; } } Ok(()) } #[cfg(test)] mod tests;