nmarshall's picture
formats: add txt conversion cli
18d65b9
Raw History Blame Contribute Delete
12.5 kB
//! 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<io::Error> for Error {
fn from(e: io::Error) -> Self {
fail(e.to_string())
}
}
fn fail(message: impl Into<String>) -> Error {
Error {
usage: false,
message: message.into(),
}
}
fn usage(message: impl Into<String>) -> Error {
Error {
usage: true,
message: message.into(),
}
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Clone, Copy)]
enum InputFormat {
Auto,
Txt,
Dat,
}
#[derive(Clone, Copy)]
enum To {
Txt,
Dat(Version),
}
struct Options {
command: String,
paths: Vec<PathBuf>,
from: InputFormat,
to: Option<To>,
force: bool,
limits: Limits,
}
fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Option<Options>> {
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::<usize>()
.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<Version>,
}
fn load(path: &Path, from: InputFormat, limits: &Limits) -> Result<Input> {
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<Item = OsString>, 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;