Implement OSDInspector offline tool (#85)
Some checks failed
Native code generation / deterministic (push) Successful in 18m30s
Imaging and meshing gate / native (push) Successful in 5m41s
JPEG 2000 feature / linux (push) Successful in 2m53s
Skia feature / linux (push) Has been cancelled
Native Rust workspace compile / compile (push) Has been cancelled
Some checks failed
Native code generation / deterministic (push) Successful in 18m30s
Imaging and meshing gate / native (push) Successful in 5m41s
JPEG 2000 feature / linux (push) Successful in 2m53s
Skia feature / linux (push) Has been cancelled
Native Rust workspace compile / compile (push) Has been cancelled
This commit is contained in:
124
Cargo.lock
generated
124
Cargo.lock
generated
@@ -26,6 +26,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aotuv_lancer_vorbis_sys"
|
name = "aotuv_lancer_vorbis_sys"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -209,6 +259,46 @@ dependencies = [
|
|||||||
"libloading",
|
"libloading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -218,6 +308,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -747,6 +843,12 @@ version = "2.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -949,10 +1051,8 @@ dependencies = [
|
|||||||
name = "libremetaverse-programs"
|
name = "libremetaverse-programs"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"clap",
|
||||||
"libremetaverse",
|
"libremetaverse",
|
||||||
"libremetaverse-imaging-skia",
|
|
||||||
"libremetaverse-voice-vivox",
|
|
||||||
"libremetaverse-voice-webrtc",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1333,6 +1433,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -1927,6 +2033,12 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5c0e04424e733e69714ca1bbb9204c1a57f09f5493439520f9f68c132ad25eec"
|
checksum = "5c0e04424e733e69714ca1bbb9204c1a57f09f5493439520f9f68c132ad25eec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
@@ -2251,6 +2363,12 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.24.0"
|
version = "1.24.0"
|
||||||
|
|||||||
@@ -482,3 +482,12 @@ and no callback runs under an internal lock. The grammar, state precedence,
|
|||||||
normalization rules, service orchestration, limits, and ownership boundary are
|
normalization rules, service orchestration, limits, and ownership boundary are
|
||||||
documented in the
|
documented in the
|
||||||
[`RLV protocol guide`](crates/libremetaverse-rlv/README.md).
|
[`RLV protocol guide`](crates/libremetaverse-rlv/README.md).
|
||||||
|
|
||||||
|
### Milestone 11
|
||||||
|
|
||||||
|
The native `osd-inspector` program is the first completed program target. It
|
||||||
|
inspects, validates, and converts bounded JSON, XML, binary, and notation LLSD,
|
||||||
|
and performs deterministic Primitive-to-OSD round trips entirely through the
|
||||||
|
public Rust APIs. Its command aliases, standard-stream behavior, exit codes,
|
||||||
|
resource limits, isolated CLI tests, and the status of every remaining program
|
||||||
|
are documented in the [`native programs guide`](programs/README.md).
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ license.workspace = true
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libremetaverse = { path = "../crates/libremetaverse" }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
libremetaverse-imaging-skia = { path = "../crates/libremetaverse-imaging-skia" }
|
libremetaverse = { path = "../crates/libremetaverse", default-features = false }
|
||||||
libremetaverse-voice-vivox = { path = "../crates/libremetaverse-voice-vivox" }
|
|
||||||
libremetaverse-voice-webrtc = { path = "../crates/libremetaverse-voice-webrtc" }
|
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
62
programs/README.md
Normal file
62
programs/README.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Native programs
|
||||||
|
|
||||||
|
The `libremetaverse-programs` package owns one native Rust binary for every
|
||||||
|
program in the pinned LibreMetaverse source snapshot. The source inventory and
|
||||||
|
hashes remain in `upstream-programs.json`; implementation status is tracked
|
||||||
|
here so a source entry is never mistaken for a completed port.
|
||||||
|
|
||||||
|
| Binary | Upstream project | Status |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `osd-inspector` | OSDInspector | Implemented and tested offline |
|
||||||
|
| `simple-bot` | SimpleBot | Pending milestone 11 issue #86 |
|
||||||
|
| `packet-dump` | PacketDump | Pending milestone 11 issue #87 |
|
||||||
|
| `prim-inspector` | PrimInspector | Pending milestone 11 issue #88 |
|
||||||
|
| `inventory-explorer` | InventoryExplorer | Pending milestone 11 issue #89 |
|
||||||
|
| `irc-gateway` | IRCGateway | Pending milestone 11 issue #90 |
|
||||||
|
| `test-client` | TestClient | Pending milestone 11 issues #91–#94 |
|
||||||
|
| `vivox-test` | VivoxTest | Pending milestone 11 issue #95 |
|
||||||
|
| `webrtc-test` | WebRtcTest | Pending milestone 11 issue #96 |
|
||||||
|
|
||||||
|
## OSDInspector
|
||||||
|
|
||||||
|
`osd-inspector` is a bounded, offline command-line client of the public native
|
||||||
|
StructuredData and Primitive APIs. It does not initialize a grid client, read
|
||||||
|
credentials, load a native codec, or invoke a .NET process.
|
||||||
|
|
||||||
|
```text
|
||||||
|
osd-inspector inspect <file> # alias: i
|
||||||
|
osd-inspector convert <input> <format> <out> # alias: c
|
||||||
|
osd-inspector validate <file> # alias: v
|
||||||
|
osd-inspector prim-to-osd
|
||||||
|
osd-inspector osd-to-prim <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
The supported output formats are `json` (`j`), `xml` (`x`), `binary`
|
||||||
|
(`bin` or `b`), and `notation` (`llsd` or `n`). Use `-` as an input or output
|
||||||
|
path for standard input or standard output. Format detection uses the filename
|
||||||
|
extension as a hint and then checks every native parser, so binary and notation
|
||||||
|
LLSD work through files and pipes as well as JSON and XML.
|
||||||
|
|
||||||
|
Input is read through a bounded buffer and is limited to the StructuredData
|
||||||
|
binary allocation limit by default. `--max-input-bytes <BYTES>` can lower that
|
||||||
|
ceiling for constrained callers. StructuredData also enforces its depth, node,
|
||||||
|
and aggregate allocation limits while parsing.
|
||||||
|
|
||||||
|
Normal results are written to stdout and diagnostics to stderr. Exit status is
|
||||||
|
stable for scripts:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| 0 | Success |
|
||||||
|
| 2 | Command-line usage error |
|
||||||
|
| 3 | File or standard-stream I/O error |
|
||||||
|
| 4 | Invalid or oversized OSD input |
|
||||||
|
| 5 | Primitive conversion or output serialization error |
|
||||||
|
|
||||||
|
Run the issue-focused CLI suite and the related translated StructuredData cases
|
||||||
|
with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo test -p libremetaverse-programs --test osd_inspector_cli --locked
|
||||||
|
cargo test --manifest-path tests/compat/Cargo.toml --test structured_data --locked
|
||||||
|
```
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
fn main() -> std::process::ExitCode {
|
fn main() -> std::process::ExitCode {
|
||||||
libremetaverse_programs::pending_program("OSDInspector")
|
libremetaverse_programs::osd_inspector::main_entry()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Rust targets corresponding to the upstream example and tool projects.
|
//! Rust targets corresponding to the upstream example and tool projects.
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
pub mod osd_inspector;
|
||||||
|
|
||||||
pub use libremetaverse::shim::pending_program;
|
pub use libremetaverse::shim::pending_program;
|
||||||
|
|||||||
529
programs/src/osd_inspector.rs
Normal file
529
programs/src/osd_inspector.rs
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
//! Bounded, offline command implementation for the `OSDInspector` tool.
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
use libremetaverse::structured_data::{OSD, OSDParser, OSDType};
|
||||||
|
use libremetaverse::types::{Material, PCode, PathCurve, ProfileCurve, Quaternion, UUID, Vector3};
|
||||||
|
use libremetaverse::{Primitive, PrimitiveConstructionData, PrimitiveObjectProperties};
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, BufReader, BufWriter, Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
/// Successful command completion.
|
||||||
|
pub const EXIT_SUCCESS: u8 = 0;
|
||||||
|
/// Command-line usage errors. Clap also uses this value for invalid arguments.
|
||||||
|
pub const EXIT_USAGE: u8 = 2;
|
||||||
|
/// File or standard-stream I/O failed.
|
||||||
|
pub const EXIT_IO: u8 = 3;
|
||||||
|
/// Input was too large or was not valid OSD.
|
||||||
|
pub const EXIT_INVALID_OSD: u8 = 4;
|
||||||
|
/// Valid OSD could not be interpreted as a primitive.
|
||||||
|
pub const EXIT_INVALID_PRIMITIVE: u8 = 5;
|
||||||
|
|
||||||
|
const DEFAULT_MAX_INPUT_BYTES: u64 = OSD::DEFAULT_MAX_BINARY_BYTES as u64;
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "osd-inspector",
|
||||||
|
version,
|
||||||
|
about = "Inspect, convert, and validate LibreMetaverse structured data offline",
|
||||||
|
long_about = None,
|
||||||
|
arg_required_else_help = true
|
||||||
|
)]
|
||||||
|
pub struct Cli {
|
||||||
|
/// Maximum accepted input size. Parsing also enforces the library depth and node limits.
|
||||||
|
#[arg(long, global = true, default_value_t = DEFAULT_MAX_INPUT_BYTES, value_name = "BYTES")]
|
||||||
|
max_input_bytes: u64,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Command,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
enum Command {
|
||||||
|
/// Display an OSD value's type and recursively formatted structure.
|
||||||
|
#[command(alias = "i")]
|
||||||
|
Inspect {
|
||||||
|
/// Input file, or '-' for standard input.
|
||||||
|
input: PathBuf,
|
||||||
|
},
|
||||||
|
/// Convert OSD to JSON, XML, binary LLSD, or notation LLSD.
|
||||||
|
#[command(alias = "c")]
|
||||||
|
Convert {
|
||||||
|
/// Input file, or '-' for standard input.
|
||||||
|
input: PathBuf,
|
||||||
|
/// Output format (json/j, xml/x, binary/bin/b, notation/llsd/n).
|
||||||
|
format: OutputFormat,
|
||||||
|
/// Output file, or '-' for standard output.
|
||||||
|
output: PathBuf,
|
||||||
|
},
|
||||||
|
/// Validate an OSD value and report its root shape.
|
||||||
|
#[command(alias = "v")]
|
||||||
|
Validate {
|
||||||
|
/// Input file, or '-' for standard input.
|
||||||
|
input: PathBuf,
|
||||||
|
},
|
||||||
|
/// Emit a deterministic sample cube as pretty JSON.
|
||||||
|
PrimToOsd,
|
||||||
|
/// Parse an OSD value as a Primitive and summarize it.
|
||||||
|
OsdToPrim {
|
||||||
|
/// Input file, or '-' for standard input.
|
||||||
|
input: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||||
|
enum OutputFormat {
|
||||||
|
#[value(alias = "j")]
|
||||||
|
Json,
|
||||||
|
#[value(alias = "x")]
|
||||||
|
Xml,
|
||||||
|
#[value(alias = "bin", alias = "b")]
|
||||||
|
Binary,
|
||||||
|
#[value(alias = "llsd", alias = "n")]
|
||||||
|
Notation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum InputFormat {
|
||||||
|
Json,
|
||||||
|
Xml,
|
||||||
|
Binary,
|
||||||
|
Notation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum InspectorError {
|
||||||
|
Io { action: String, source: io::Error },
|
||||||
|
InputTooLarge { limit: u64 },
|
||||||
|
InvalidOsd { input: String },
|
||||||
|
Serialization { format: OutputFormat },
|
||||||
|
InvalidPrimitive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InspectorError {
|
||||||
|
const fn exit_code(&self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Io { .. } => EXIT_IO,
|
||||||
|
Self::InputTooLarge { .. } | Self::InvalidOsd { .. } => EXIT_INVALID_OSD,
|
||||||
|
Self::Serialization { .. } | Self::InvalidPrimitive => EXIT_INVALID_PRIMITIVE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for InspectorError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io { action, source } => write!(formatter, "{action}: {source}"),
|
||||||
|
Self::InputTooLarge { limit } => {
|
||||||
|
write!(formatter, "input exceeds the configured {limit}-byte limit")
|
||||||
|
}
|
||||||
|
Self::InvalidOsd { input } => write!(formatter, "invalid OSD input: {input}"),
|
||||||
|
Self::Serialization { format } => {
|
||||||
|
write!(formatter, "could not serialize OSD as {format:?}")
|
||||||
|
}
|
||||||
|
Self::InvalidPrimitive => write!(formatter, "OSD value is not a Primitive map"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses command-line arguments with clap and runs the offline tool.
|
||||||
|
#[must_use]
|
||||||
|
pub fn main_entry() -> ExitCode {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let stdin = io::stdin();
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let stderr = io::stderr();
|
||||||
|
let mut input = stdin.lock();
|
||||||
|
let mut output = stdout.lock();
|
||||||
|
let mut errors = stderr.lock();
|
||||||
|
match execute(cli, &mut input, &mut output) {
|
||||||
|
Ok(()) => ExitCode::from(EXIT_SUCCESS),
|
||||||
|
Err(error) => {
|
||||||
|
let _ = writeln!(errors, "osd-inspector: {error}");
|
||||||
|
ExitCode::from(error.exit_code())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(cli: Cli, stdin: &mut dyn Read, stdout: &mut dyn Write) -> Result<(), InspectorError> {
|
||||||
|
if cli.max_input_bytes == 0 {
|
||||||
|
return Err(InspectorError::InputTooLarge { limit: 0 });
|
||||||
|
}
|
||||||
|
match cli.command {
|
||||||
|
Command::Inspect { input } => inspect(&input, cli.max_input_bytes, stdin, stdout),
|
||||||
|
Command::Convert {
|
||||||
|
input,
|
||||||
|
format,
|
||||||
|
output,
|
||||||
|
} => convert(&input, format, &output, cli.max_input_bytes, stdin, stdout),
|
||||||
|
Command::Validate { input } => validate(&input, cli.max_input_bytes, stdin, stdout),
|
||||||
|
Command::PrimToOsd => primitive_to_osd(stdout),
|
||||||
|
Command::OsdToPrim { input } => {
|
||||||
|
osd_to_primitive(&input, cli.max_input_bytes, stdin, stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect(
|
||||||
|
input: &Path,
|
||||||
|
limit: u64,
|
||||||
|
stdin: &mut dyn Read,
|
||||||
|
output: &mut dyn Write,
|
||||||
|
) -> Result<(), InspectorError> {
|
||||||
|
let bytes = read_input(input, limit, stdin)?;
|
||||||
|
let osd = parse_osd(&bytes, input)?;
|
||||||
|
writeln!(output, "File: {}", display_path(input)).map_err(stdout_error)?;
|
||||||
|
writeln!(output, "Type: {}", type_name(osd.type_())).map_err(stdout_error)?;
|
||||||
|
writeln!(output, "Size: {} bytes", bytes.len()).map_err(stdout_error)?;
|
||||||
|
writeln!(output, "\nStructure:\n{}", "-".repeat(60)).map_err(stdout_error)?;
|
||||||
|
display_osd(&osd, 0, output)?;
|
||||||
|
writeln!(output, "{}", "-".repeat(60)).map_err(stdout_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert(
|
||||||
|
input: &Path,
|
||||||
|
format: OutputFormat,
|
||||||
|
destination: &Path,
|
||||||
|
limit: u64,
|
||||||
|
stdin: &mut dyn Read,
|
||||||
|
stdout: &mut dyn Write,
|
||||||
|
) -> Result<(), InspectorError> {
|
||||||
|
let bytes = read_input(input, limit, stdin)?;
|
||||||
|
let osd = parse_osd(&bytes, input)?;
|
||||||
|
let encoded = serialize(osd, format)?;
|
||||||
|
if is_standard_stream(destination) {
|
||||||
|
stdout.write_all(&encoded).map_err(stdout_error)?;
|
||||||
|
stdout.flush().map_err(stdout_error)?;
|
||||||
|
} else {
|
||||||
|
write_file(destination, &encoded)?;
|
||||||
|
writeln!(
|
||||||
|
stdout,
|
||||||
|
"Converted {} to {}: {} ({} bytes)",
|
||||||
|
display_path(input),
|
||||||
|
format_name(format),
|
||||||
|
destination.display(),
|
||||||
|
encoded.len()
|
||||||
|
)
|
||||||
|
.map_err(stdout_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(
|
||||||
|
input: &Path,
|
||||||
|
limit: u64,
|
||||||
|
stdin: &mut dyn Read,
|
||||||
|
output: &mut dyn Write,
|
||||||
|
) -> Result<(), InspectorError> {
|
||||||
|
let bytes = read_input(input, limit, stdin)?;
|
||||||
|
let osd = parse_osd(&bytes, input)?;
|
||||||
|
writeln!(output, "Valid OSD file").map_err(stdout_error)?;
|
||||||
|
writeln!(output, " Type: {}", type_name(osd.type_())).map_err(stdout_error)?;
|
||||||
|
match osd {
|
||||||
|
OSD::Map(values) => writeln!(output, " Keys: {}", values.len()).map_err(stdout_error)?,
|
||||||
|
OSD::Array(values) => {
|
||||||
|
writeln!(output, " Elements: {}", values.len()).map_err(stdout_error)?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primitive_to_osd(output: &mut dyn Write) -> Result<(), InspectorError> {
|
||||||
|
let osd = sample_primitive()
|
||||||
|
.and_then(|primitive| primitive.get_osd())
|
||||||
|
.map_err(|_| InspectorError::InvalidPrimitive)?;
|
||||||
|
let json = OSDParser::serialize_json_string(osd, Some(true)).map_err(|_| {
|
||||||
|
InspectorError::Serialization {
|
||||||
|
format: OutputFormat::Json,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
writeln!(output, "{json}").map_err(stdout_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn osd_to_primitive(
|
||||||
|
input: &Path,
|
||||||
|
limit: u64,
|
||||||
|
stdin: &mut dyn Read,
|
||||||
|
output: &mut dyn Write,
|
||||||
|
) -> Result<(), InspectorError> {
|
||||||
|
let bytes = read_input(input, limit, stdin)?;
|
||||||
|
let osd = parse_osd(&bytes, input)?;
|
||||||
|
if !matches!(osd, OSD::Map(_)) {
|
||||||
|
return Err(InspectorError::InvalidPrimitive);
|
||||||
|
}
|
||||||
|
let primitive = Primitive::from_osd(osd).map_err(|_| InspectorError::InvalidPrimitive)?;
|
||||||
|
let name = primitive
|
||||||
|
.properties
|
||||||
|
.as_ref()
|
||||||
|
.map_or("Unknown", |properties| properties.name.as_str());
|
||||||
|
writeln!(output, "Successfully parsed Primitive:").map_err(stdout_error)?;
|
||||||
|
writeln!(output, " ID: {}", primitive.id).map_err(stdout_error)?;
|
||||||
|
writeln!(output, " Name: {name}").map_err(stdout_error)?;
|
||||||
|
writeln!(output, " Type: {:?}", primitive.type_()).map_err(stdout_error)?;
|
||||||
|
writeln!(output, " Position: {}", primitive.position.to_string()).map_err(stdout_error)?;
|
||||||
|
writeln!(output, " Scale: {}", primitive.scale.to_string()).map_err(stdout_error)?;
|
||||||
|
writeln!(output, " Material: {:?}", primitive.prim_data.material).map_err(stdout_error)?;
|
||||||
|
writeln!(output, " PCode: {:?}", primitive.prim_data.p_code).map_err(stdout_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_primitive() -> Result<Primitive, libremetaverse::Error> {
|
||||||
|
let mut primitive = Primitive::new_with_constructor()?;
|
||||||
|
primitive.id = UUID::new_with_string("11111111-2222-3333-4444-555555555555".into())?;
|
||||||
|
primitive.local_id = 12_345;
|
||||||
|
primitive.position = Vector3 {
|
||||||
|
x: 128.0,
|
||||||
|
y: 128.0,
|
||||||
|
z: 25.0,
|
||||||
|
};
|
||||||
|
primitive.rotation = Quaternion::identity();
|
||||||
|
primitive.scale = Vector3 {
|
||||||
|
x: 1.0,
|
||||||
|
y: 1.0,
|
||||||
|
z: 1.0,
|
||||||
|
};
|
||||||
|
let mut construction = PrimitiveConstructionData::new_with_constructor()?;
|
||||||
|
construction.p_code = PCode::Prim;
|
||||||
|
construction.material = Material::Wood;
|
||||||
|
construction.path_curve = PathCurve::Line;
|
||||||
|
construction.path_scale_x = 1.0;
|
||||||
|
construction.path_scale_y = 1.0;
|
||||||
|
construction.set_profile_curve_with_property(ProfileCurve::Square);
|
||||||
|
primitive.prim_data = construction;
|
||||||
|
let mut properties = PrimitiveObjectProperties::new()?;
|
||||||
|
properties.name = "Example Cube".into();
|
||||||
|
properties.description = "A simple cube created with LibreMetaverse".into();
|
||||||
|
primitive.properties = Some(properties);
|
||||||
|
Ok(primitive)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_input(path: &Path, limit: u64, stdin: &mut dyn Read) -> Result<Vec<u8>, InspectorError> {
|
||||||
|
if is_standard_stream(path) {
|
||||||
|
return read_bounded(stdin, limit, "reading standard input");
|
||||||
|
}
|
||||||
|
let file = File::open(path).map_err(|source| InspectorError::Io {
|
||||||
|
action: format!("opening {}", path.display()),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
if file.metadata().map_or(0, |value| value.len()) > limit {
|
||||||
|
return Err(InspectorError::InputTooLarge { limit });
|
||||||
|
}
|
||||||
|
read_bounded(
|
||||||
|
&mut BufReader::new(file),
|
||||||
|
limit,
|
||||||
|
&format!("reading {}", path.display()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_bounded(input: &mut dyn Read, limit: u64, action: &str) -> Result<Vec<u8>, InspectorError> {
|
||||||
|
let capacity = usize::try_from(limit.min(64 * 1024)).unwrap_or(64 * 1024);
|
||||||
|
let mut bytes = Vec::with_capacity(capacity);
|
||||||
|
input
|
||||||
|
.take(limit.saturating_add(1))
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|source| InspectorError::Io {
|
||||||
|
action: action.into(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
|
||||||
|
return Err(InspectorError::InputTooLarge { limit });
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_file(path: &Path, bytes: &[u8]) -> Result<(), InspectorError> {
|
||||||
|
let file = File::create(path).map_err(|source| InspectorError::Io {
|
||||||
|
action: format!("creating {}", path.display()),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let mut writer = BufWriter::new(file);
|
||||||
|
writer
|
||||||
|
.write_all(bytes)
|
||||||
|
.and_then(|()| writer.flush())
|
||||||
|
.map_err(|source| InspectorError::Io {
|
||||||
|
action: format!("writing {}", path.display()),
|
||||||
|
source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_osd(bytes: &[u8], path: &Path) -> Result<OSD, InspectorError> {
|
||||||
|
if bytes.is_empty() || bytes.iter().all(u8::is_ascii_whitespace) {
|
||||||
|
return Err(InspectorError::InvalidOsd {
|
||||||
|
input: display_path(path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let hinted = format_hint(path, bytes);
|
||||||
|
let mut attempts = vec![hinted];
|
||||||
|
for format in [
|
||||||
|
InputFormat::Json,
|
||||||
|
InputFormat::Xml,
|
||||||
|
InputFormat::Binary,
|
||||||
|
InputFormat::Notation,
|
||||||
|
] {
|
||||||
|
if !attempts.contains(&format) {
|
||||||
|
attempts.push(format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for format in attempts {
|
||||||
|
if let Ok(value) = parse_as(bytes, format) {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(InspectorError::InvalidOsd {
|
||||||
|
input: display_path(path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_as(bytes: &[u8], format: InputFormat) -> Result<OSD, libremetaverse::Error> {
|
||||||
|
match format {
|
||||||
|
InputFormat::Json => OSDParser::deserialize_json_with_string(
|
||||||
|
String::from_utf8(bytes.to_vec()).map_err(|_| libremetaverse::Error::Argument)?,
|
||||||
|
),
|
||||||
|
InputFormat::Xml => OSDParser::deserialize_llsd_xml_with_bytes(bytes.to_vec()),
|
||||||
|
InputFormat::Binary => OSDParser::deserialize_llsd_binary_with_bytes(bytes.to_vec()),
|
||||||
|
InputFormat::Notation => OSDParser::deserialize_llsd_notation_with_string(
|
||||||
|
String::from_utf8(bytes.to_vec()).map_err(|_| libremetaverse::Error::Argument)?,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_hint(path: &Path, bytes: &[u8]) -> InputFormat {
|
||||||
|
match path
|
||||||
|
.extension()
|
||||||
|
.and_then(|extension| extension.to_str())
|
||||||
|
.map(str::to_ascii_lowercase)
|
||||||
|
.as_deref()
|
||||||
|
{
|
||||||
|
Some("json") => InputFormat::Json,
|
||||||
|
Some("xml") => InputFormat::Xml,
|
||||||
|
Some("bin" | "binary") => InputFormat::Binary,
|
||||||
|
Some("notation" | "nt") => InputFormat::Notation,
|
||||||
|
_ => sniff_format(bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sniff_format(bytes: &[u8]) -> InputFormat {
|
||||||
|
let text = String::from_utf8_lossy(&bytes[..bytes.len().min(64)]);
|
||||||
|
let trimmed = text.trim_start_matches('\u{feff}').trim_start();
|
||||||
|
if trimmed.starts_with("<? llsd/binary ?>") || trimmed.starts_with("<?llsd/binary?>") {
|
||||||
|
InputFormat::Binary
|
||||||
|
} else if trimmed.starts_with('<') {
|
||||||
|
InputFormat::Xml
|
||||||
|
} else if matches!(
|
||||||
|
trimmed.as_bytes().first(),
|
||||||
|
Some(b'{' | b'[' | b'"' | b'-' | b'0'..=b'9')
|
||||||
|
) || trimmed.starts_with("true")
|
||||||
|
|| trimmed.starts_with("false")
|
||||||
|
|| trimmed.starts_with("null")
|
||||||
|
{
|
||||||
|
InputFormat::Json
|
||||||
|
} else {
|
||||||
|
InputFormat::Notation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize(osd: OSD, format: OutputFormat) -> Result<Vec<u8>, InspectorError> {
|
||||||
|
let result = match format {
|
||||||
|
OutputFormat::Json => {
|
||||||
|
OSDParser::serialize_json_string(osd, Some(true)).map(String::into_bytes)
|
||||||
|
}
|
||||||
|
OutputFormat::Xml => OSDParser::serialize_llsd_xml_bytes(osd),
|
||||||
|
OutputFormat::Binary => OSDParser::serialize_llsd_binary_with_osd(osd),
|
||||||
|
OutputFormat::Notation => OSDParser::serialize_llsd_notation(osd).map(String::into_bytes),
|
||||||
|
};
|
||||||
|
result.map_err(|_| InspectorError::Serialization { format })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_osd(osd: &OSD, indent: usize, output: &mut dyn Write) -> Result<(), InspectorError> {
|
||||||
|
let prefix = " ".repeat(indent);
|
||||||
|
match osd {
|
||||||
|
OSD::Map(values) => {
|
||||||
|
writeln!(output, "{prefix}Map ({} keys)", values.len()).map_err(stdout_error)?;
|
||||||
|
let mut entries = values.iter().collect::<Vec<_>>();
|
||||||
|
entries.sort_unstable_by_key(|(key, _)| *key);
|
||||||
|
for (key, value) in entries {
|
||||||
|
write!(output, "{prefix} {key}: ").map_err(stdout_error)?;
|
||||||
|
if matches!(value, OSD::Map(_) | OSD::Array(_)) {
|
||||||
|
writeln!(output).map_err(stdout_error)?;
|
||||||
|
display_osd(value, indent + 2, output)?;
|
||||||
|
} else {
|
||||||
|
display_osd(value, 0, output)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OSD::Array(values) => {
|
||||||
|
writeln!(output, "{prefix}Array ({} elements)", values.len()).map_err(stdout_error)?;
|
||||||
|
for (index, value) in values.iter().enumerate() {
|
||||||
|
write!(output, "{prefix} [{index}]: ").map_err(stdout_error)?;
|
||||||
|
if matches!(value, OSD::Map(_) | OSD::Array(_)) {
|
||||||
|
writeln!(output).map_err(stdout_error)?;
|
||||||
|
display_osd(value, indent + 2, output)?;
|
||||||
|
} else {
|
||||||
|
display_osd(value, 0, output)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OSD::String(value) => writeln!(output, "String: {value:?}").map_err(stdout_error)?,
|
||||||
|
OSD::Integer(value) => writeln!(output, "Integer: {value}").map_err(stdout_error)?,
|
||||||
|
OSD::Real(value) => writeln!(output, "Real: {value}").map_err(stdout_error)?,
|
||||||
|
OSD::Boolean(value) => writeln!(output, "Boolean: {value}").map_err(stdout_error)?,
|
||||||
|
OSD::UUID(value) => writeln!(output, "UUID: {value}").map_err(stdout_error)?,
|
||||||
|
OSD::Date(_) => writeln!(output, "Date: {}", osd.as_string().unwrap_or_default())
|
||||||
|
.map_err(stdout_error)?,
|
||||||
|
OSD::Uri(value) => writeln!(output, "URI: {}", value.0).map_err(stdout_error)?,
|
||||||
|
OSD::Binary(value) => {
|
||||||
|
writeln!(output, "Binary: {} bytes", value.len()).map_err(stdout_error)?;
|
||||||
|
}
|
||||||
|
OSD::LlsdXml(value) => writeln!(output, "LLSD XML: {value:?}").map_err(stdout_error)?,
|
||||||
|
OSD::Undefined => writeln!(output, "Unknown (Undefined)").map_err(stdout_error)?,
|
||||||
|
_ => writeln!(output, "Unknown ({:?})", osd.type_()).map_err(stdout_error)?,
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn type_name(value: OSDType) -> &'static str {
|
||||||
|
match value {
|
||||||
|
OSDType::Unknown => "Unknown",
|
||||||
|
OSDType::Boolean => "Boolean",
|
||||||
|
OSDType::Integer => "Integer",
|
||||||
|
OSDType::Real => "Real",
|
||||||
|
OSDType::String => "String",
|
||||||
|
OSDType::UUID => "UUID",
|
||||||
|
OSDType::Date => "Date",
|
||||||
|
OSDType::URI => "URI",
|
||||||
|
OSDType::Binary => "Binary",
|
||||||
|
OSDType::Map => "Map",
|
||||||
|
OSDType::Array => "Array",
|
||||||
|
OSDType::LlsdXml => "LlsdXml",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn format_name(format: OutputFormat) -> &'static str {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Json => "json",
|
||||||
|
OutputFormat::Xml => "xml",
|
||||||
|
OutputFormat::Binary => "binary",
|
||||||
|
OutputFormat::Notation => "notation",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_standard_stream(path: &Path) -> bool {
|
||||||
|
path.as_os_str() == "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_path(path: &Path) -> String {
|
||||||
|
if is_standard_stream(path) {
|
||||||
|
"<stdin>".into()
|
||||||
|
} else {
|
||||||
|
path.display().to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdout_error(source: io::Error) -> InspectorError {
|
||||||
|
InspectorError::Io {
|
||||||
|
action: "writing standard output".into(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
416
programs/tests/osd_inspector_cli.rs
Normal file
416
programs/tests/osd_inspector_cli.rs
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
use libremetaverse::Primitive;
|
||||||
|
use libremetaverse::structured_data::{OSD, OSDParser};
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Output, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
const JSON_FIXTURE: &[u8] =
|
||||||
|
include_bytes!("../../tests/fixtures/structured_data/json_reference.json");
|
||||||
|
const EXIT_USAGE: i32 = 2;
|
||||||
|
const EXIT_IO: i32 = 3;
|
||||||
|
const EXIT_INVALID_OSD: i32 = 4;
|
||||||
|
const EXIT_INVALID_PRIMITIVE: i32 = 5;
|
||||||
|
|
||||||
|
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
struct TestDir(PathBuf);
|
||||||
|
|
||||||
|
impl TestDir {
|
||||||
|
fn new(test_name: &str) -> Self {
|
||||||
|
let id = TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"metacrate-osd-inspector-{test_name}-{}-{id}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).expect("create isolated test directory");
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self, name: &str) -> PathBuf {
|
||||||
|
self.0.join(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[&str], stdin: Option<&[u8]>) -> Output {
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_osd-inspector"));
|
||||||
|
command
|
||||||
|
.args(args)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
if stdin.is_some() {
|
||||||
|
command.stdin(Stdio::piped());
|
||||||
|
} else {
|
||||||
|
command.stdin(Stdio::null());
|
||||||
|
}
|
||||||
|
let mut child = command.spawn().expect("spawn osd-inspector");
|
||||||
|
if let Some(bytes) = stdin {
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.take()
|
||||||
|
.expect("piped stdin")
|
||||||
|
.write_all(bytes)
|
||||||
|
.expect("write command stdin");
|
||||||
|
}
|
||||||
|
child.wait_with_output().expect("collect command output")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn utf8(bytes: &[u8]) -> &str {
|
||||||
|
std::str::from_utf8(bytes).expect("command output is UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_exit(output: &Output, expected: i32) {
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(expected),
|
||||||
|
"stdout:\n{}\nstderr:\n{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_fixture(directory: &TestDir) -> PathBuf {
|
||||||
|
let path = directory.path("reference.json");
|
||||||
|
fs::write(&path, JSON_FIXTURE).expect("write JSON fixture");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_text(path: &Path) -> &str {
|
||||||
|
path.to_str().expect("test path is UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_usage_and_upstream_aliases_are_stable() {
|
||||||
|
let no_args = run(&[], None);
|
||||||
|
assert_exit(&no_args, EXIT_USAGE);
|
||||||
|
assert!(utf8(&no_args.stderr).contains("Usage: osd-inspector"));
|
||||||
|
|
||||||
|
let help = run(&["--help"], None);
|
||||||
|
assert!(help.status.success());
|
||||||
|
let help = utf8(&help.stdout);
|
||||||
|
for command in [
|
||||||
|
"inspect",
|
||||||
|
"convert",
|
||||||
|
"validate",
|
||||||
|
"prim-to-osd",
|
||||||
|
"osd-to-prim",
|
||||||
|
] {
|
||||||
|
assert!(help.contains(command), "help omitted {command}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = TestDir::new("aliases");
|
||||||
|
let input = write_fixture(&directory);
|
||||||
|
let input = path_text(&input);
|
||||||
|
for (command, expected) in [
|
||||||
|
("i", "Structure:"),
|
||||||
|
("inspect", "Map (4 keys)"),
|
||||||
|
("v", "Valid OSD file"),
|
||||||
|
("validate", "Keys: 4"),
|
||||||
|
] {
|
||||||
|
let output = run(&[command, input], None);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{command}: {}",
|
||||||
|
utf8(&output.stderr)
|
||||||
|
);
|
||||||
|
assert!(utf8(&output.stdout).contains(expected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspect_is_deterministic_and_validate_accepts_standard_input() {
|
||||||
|
let directory = TestDir::new("inspect");
|
||||||
|
let input = write_fixture(&directory);
|
||||||
|
let output = run(&["inspect", path_text(&input)], None);
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
let output = utf8(&output.stdout);
|
||||||
|
assert!(output.contains("Type: Map"));
|
||||||
|
assert!(output.contains(&format!("Size: {} bytes", JSON_FIXTURE.len())));
|
||||||
|
let array = output.find(" array:").expect("array key");
|
||||||
|
let false_value = output.find(" false:").expect("false key");
|
||||||
|
let true_value = output.find(" true:").expect("true key");
|
||||||
|
let zero = output.find(" zero:").expect("zero key");
|
||||||
|
assert!(array < false_value && false_value < true_value && true_value < zero);
|
||||||
|
|
||||||
|
let output = run(&["validate", "-"], Some(JSON_FIXTURE));
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
assert_eq!(
|
||||||
|
utf8(&output.stdout),
|
||||||
|
"Valid OSD file\n Type: Map\n Keys: 4\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_emits_library_golden_bytes_for_every_format() {
|
||||||
|
let directory = TestDir::new("convert");
|
||||||
|
let input = write_fixture(&directory);
|
||||||
|
let expected = OSDParser::deserialize_json_with_string(
|
||||||
|
std::str::from_utf8(JSON_FIXTURE)
|
||||||
|
.expect("compatibility fixture is UTF-8")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.expect("parse checked-in compatibility fixture");
|
||||||
|
let cases: [(&str, &str, Vec<u8>); 4] = [
|
||||||
|
(
|
||||||
|
"json",
|
||||||
|
"converted.json",
|
||||||
|
OSDParser::serialize_json_string(expected.clone(), Some(true))
|
||||||
|
.expect("serialize golden JSON")
|
||||||
|
.into_bytes(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"xml",
|
||||||
|
"converted.xml",
|
||||||
|
OSDParser::serialize_llsd_xml_bytes(expected.clone()).expect("serialize golden XML"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"binary",
|
||||||
|
"converted.bin",
|
||||||
|
OSDParser::serialize_llsd_binary_with_osd(expected.clone())
|
||||||
|
.expect("serialize golden binary"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"notation",
|
||||||
|
"converted.notation",
|
||||||
|
OSDParser::serialize_llsd_notation(expected)
|
||||||
|
.expect("serialize golden notation")
|
||||||
|
.into_bytes(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (index, (format, filename, golden)) in cases.into_iter().enumerate() {
|
||||||
|
let destination = directory.path(filename);
|
||||||
|
let command = if index == 0 { "convert" } else { "c" };
|
||||||
|
let output = run(
|
||||||
|
&[command, path_text(&input), format, path_text(&destination)],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"{format}: {}",
|
||||||
|
utf8(&output.stderr)
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(&destination).expect("read converted file"), golden);
|
||||||
|
assert!(utf8(&output.stdout).contains(&format!("to {format}:")));
|
||||||
|
|
||||||
|
let output = run(&["validate", path_text(&destination)], None);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"could not read back {format}: {}",
|
||||||
|
utf8(&output.stderr)
|
||||||
|
);
|
||||||
|
assert!(utf8(&output.stdout).contains("Type: Map"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_documented_output_format_alias_is_accepted() {
|
||||||
|
let directory = TestDir::new("format-aliases");
|
||||||
|
let input = write_fixture(&directory);
|
||||||
|
let expected = OSDParser::deserialize_json_with_string(
|
||||||
|
std::str::from_utf8(JSON_FIXTURE)
|
||||||
|
.expect("compatibility fixture is UTF-8")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.expect("parse checked-in compatibility fixture");
|
||||||
|
let aliases = [
|
||||||
|
(
|
||||||
|
"j",
|
||||||
|
OSDParser::serialize_json_string(expected.clone(), Some(true))
|
||||||
|
.expect("serialize JSON")
|
||||||
|
.into_bytes(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"x",
|
||||||
|
OSDParser::serialize_llsd_xml_bytes(expected.clone()).expect("serialize XML"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"bin",
|
||||||
|
OSDParser::serialize_llsd_binary_with_osd(expected.clone()).expect("serialize binary"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"b",
|
||||||
|
OSDParser::serialize_llsd_binary_with_osd(expected.clone()).expect("serialize binary"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"llsd",
|
||||||
|
OSDParser::serialize_llsd_notation(expected.clone())
|
||||||
|
.expect("serialize notation")
|
||||||
|
.into_bytes(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"n",
|
||||||
|
OSDParser::serialize_llsd_notation(expected)
|
||||||
|
.expect("serialize notation")
|
||||||
|
.into_bytes(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (alias, golden) in aliases {
|
||||||
|
let output = run(&["c", path_text(&input), alias, "-"], None);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"format alias {alias}: {}",
|
||||||
|
utf8(&output.stderr)
|
||||||
|
);
|
||||||
|
assert_eq!(output.stdout, golden, "format alias {alias}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conversion_supports_binary_standard_output_and_format_detection() {
|
||||||
|
let expected = OSDParser::deserialize_json_with_string(
|
||||||
|
std::str::from_utf8(JSON_FIXTURE)
|
||||||
|
.expect("compatibility fixture is UTF-8")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.expect("parse checked-in compatibility fixture");
|
||||||
|
let output = run(&["convert", "-", "binary", "-"], Some(JSON_FIXTURE));
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
assert!(output.stderr.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
OSDParser::deserialize_llsd_binary_with_bytes(output.stdout)
|
||||||
|
.expect("parse binary standard output"),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
|
||||||
|
let binary = OSDParser::serialize_llsd_binary_with_osd(expected.clone())
|
||||||
|
.expect("serialize binary fixture");
|
||||||
|
let output = run(&["validate", "-"], Some(&binary));
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
assert!(utf8(&output.stdout).contains("Type: Map"));
|
||||||
|
|
||||||
|
let notation =
|
||||||
|
OSDParser::serialize_llsd_notation(expected).expect("serialize notation fixture");
|
||||||
|
let output = run(&["validate", "-"], Some(notation.as_bytes()));
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
assert!(utf8(&output.stdout).contains("Type: Map"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[allow(clippy::float_cmp)] // Exact integral coordinates are part of the golden sample.
|
||||||
|
fn sample_primitive_round_trips_through_the_public_native_api() {
|
||||||
|
let output = run(&["prim-to-osd"], None);
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
let osd = OSDParser::deserialize_json_with_string(
|
||||||
|
String::from_utf8(output.stdout.clone()).expect("sample command emits UTF-8"),
|
||||||
|
)
|
||||||
|
.expect("sample command emits JSON");
|
||||||
|
let primitive = Primitive::from_osd(osd).expect("sample is native Primitive OSD");
|
||||||
|
assert_eq!(primitive.local_id, 12_345);
|
||||||
|
assert_eq!(primitive.position.x, 128.0);
|
||||||
|
assert_eq!(primitive.position.y, 128.0);
|
||||||
|
assert_eq!(primitive.position.z, 25.0);
|
||||||
|
assert_eq!(
|
||||||
|
primitive
|
||||||
|
.properties
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.name.as_str()),
|
||||||
|
Some("Example Cube")
|
||||||
|
);
|
||||||
|
|
||||||
|
let directory = TestDir::new("primitive");
|
||||||
|
let path = directory.path("cube.json");
|
||||||
|
fs::write(&path, output.stdout).expect("write primitive JSON");
|
||||||
|
let output = run(&["osd-to-prim", path_text(&path)], None);
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
let summary = utf8(&output.stdout);
|
||||||
|
for expected in [
|
||||||
|
"Successfully parsed Primitive:",
|
||||||
|
"Name: Example Cube",
|
||||||
|
"Position: <128, 128, 25>",
|
||||||
|
"Scale: <1, 1, 1>",
|
||||||
|
"Material: Wood",
|
||||||
|
"PCode: Prim",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
summary.contains(expected),
|
||||||
|
"summary omitted {expected}:\n{summary}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_use_stderr_and_documented_exit_codes() {
|
||||||
|
let directory = TestDir::new("failures");
|
||||||
|
let malformed = directory.path("malformed.json");
|
||||||
|
fs::write(&malformed, b"{\"unterminated\": [1, 2}").expect("write malformed fixture");
|
||||||
|
let output = run(&["validate", path_text(&malformed)], None);
|
||||||
|
assert_exit(&output, EXIT_INVALID_OSD);
|
||||||
|
assert!(output.stdout.is_empty());
|
||||||
|
assert!(utf8(&output.stderr).contains("invalid OSD input"));
|
||||||
|
|
||||||
|
let primitive = directory.path("not-a-primitive.json");
|
||||||
|
fs::write(&primitive, b"[1,2,3]").expect("write non-primitive fixture");
|
||||||
|
let output = run(&["osd-to-prim", path_text(&primitive)], None);
|
||||||
|
assert_exit(&output, EXIT_INVALID_PRIMITIVE);
|
||||||
|
assert!(utf8(&output.stderr).contains("not a Primitive map"));
|
||||||
|
|
||||||
|
let missing = directory.path("missing.json");
|
||||||
|
let output = run(&["inspect", path_text(&missing)], None);
|
||||||
|
assert_exit(&output, EXIT_IO);
|
||||||
|
assert!(utf8(&output.stderr).contains("opening"));
|
||||||
|
|
||||||
|
let input = write_fixture(&directory);
|
||||||
|
let output = run(
|
||||||
|
&["--max-input-bytes", "4", "validate", path_text(&input)],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_exit(&output, EXIT_INVALID_OSD);
|
||||||
|
assert!(utf8(&output.stderr).contains("4-byte limit"));
|
||||||
|
|
||||||
|
let output = run(
|
||||||
|
&[
|
||||||
|
"convert",
|
||||||
|
path_text(&input),
|
||||||
|
"json",
|
||||||
|
path_text(&directory.0),
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_exit(&output, EXIT_IO);
|
||||||
|
assert!(utf8(&output.stderr).contains("creating"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalar_json_is_not_confused_with_llsd_notation() {
|
||||||
|
for (input, expected_type) in [
|
||||||
|
(b"true".as_slice(), "Boolean"),
|
||||||
|
(b"42", "Integer"),
|
||||||
|
(br#""text""#, "String"),
|
||||||
|
(b"null", "Unknown"),
|
||||||
|
] {
|
||||||
|
let output = run(&["validate", "-"], Some(input));
|
||||||
|
assert!(output.status.success(), "{}", utf8(&output.stderr));
|
||||||
|
assert!(utf8(&output.stdout).contains(&format!("Type: {expected_type}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
for invalid in [b"".as_slice(), b" \t\r\n"] {
|
||||||
|
let output = run(&["validate", "-"], Some(invalid));
|
||||||
|
assert_exit(&output, EXIT_INVALID_OSD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspected_fixture_retains_expected_value_types() {
|
||||||
|
let expected = OSDParser::deserialize_json_with_string(
|
||||||
|
std::str::from_utf8(JSON_FIXTURE)
|
||||||
|
.expect("compatibility fixture is UTF-8")
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.expect("parse checked-in compatibility fixture");
|
||||||
|
let OSD::Map(values) = expected else {
|
||||||
|
panic!("compatibility fixture must remain a map");
|
||||||
|
};
|
||||||
|
assert!(matches!(values.get("array"), Some(OSD::Array(_))));
|
||||||
|
assert!(matches!(values.get("false"), Some(OSD::Boolean(false))));
|
||||||
|
assert!(matches!(values.get("true"), Some(OSD::Boolean(true))));
|
||||||
|
assert!(matches!(values.get("zero"), Some(OSD::Integer(0))));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user