Download crates/nanobrush-cli/src/lib.rs from nmarshall/nanobrush: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/nmarshall/nanobrush/resolve/main/crates/nanobrush-cli/src/lib.rs
- Command line
-
hf download hf://nmarshall/nanobrush/crates/nanobrush-cli/src/lib.rs
-
curl -L -o lib.rs https://huggingface.co/nmarshall/nanobrush/resolve/main/crates/nanobrush-cli/src/lib.rs
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"; | |
| 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>; | |
| enum InputFormat { | |
| Auto, | |
| Txt, | |
| Dat, | |
| } | |
| 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 { | |
| { | |
| use std::os::unix::fs::MetadataExt; | |
| a.dev() == b.dev() && a.ino() == b.ino() | |
| } | |
| { | |
| 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); | |
| { | |
| 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(()) | |
| } | |
| mod tests; | |