Implement native PrimInspector
All checks were successful
Native Rust workspace compile / compile (push) Successful in 21m40s

This commit is contained in:
2026-08-11 08:32:22 +00:00
parent f0420f6c8b
commit 4504abcc64
6 changed files with 1203 additions and 4 deletions

View File

@@ -1,3 +1,3 @@
fn main() -> std::process::ExitCode {
libremetaverse_programs::pending_program("PrimInspector")
libremetaverse_programs::prim_inspector::main_entry()
}

View File

@@ -3,6 +3,7 @@
pub mod commands;
pub mod osd_inspector;
pub mod packet_dump;
pub mod prim_inspector;
pub mod simple_bot;
pub use libremetaverse::shim::pending_program;

View File

@@ -0,0 +1,893 @@
//! Native primitive discovery and inspection for live and deterministic fake grids.
use clap::Parser;
use clap::error::ErrorKind;
use libremetaverse::structured_data::{OSD, OSDParser};
use libremetaverse::types::compat::{CancellationTokenSource, Subscription};
use libremetaverse::types::{SaleType, UUID, Vector3};
use libremetaverse::{
DisconnectedEventArgs, GridClient, LoginProgressEventArgs, NetworkManager,
ObjectPropertiesEventArgs, Primitive, PrimitiveObjectProperties, SimChangedEventArgs,
Simulator,
};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_INPUT: u8 = 3;
pub const EXIT_CLIENT: u8 = 4;
pub const EXIT_OUTPUT: u8 = 5;
const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 30;
const DEFAULT_DISCOVERY_DELAY_MS: u64 = 2_000;
const DEFAULT_PROPERTY_TIMEOUT_MS: u64 = 3_000;
const DEFAULT_MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
const MAX_FIXTURE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_FIXTURE_ALLOC_BYTES: usize = 8 * 1024 * 1024;
const MAX_OBJECTS: usize = 100_000;
const MAX_PROPERTY_REQUESTS: usize = 1_024;
const EVENT_QUEUE_CAPACITY: usize = 2_048;
#[derive(Parser)]
#[command(
name = "prim-inspector",
version,
about = "Discover and inspect the nearest primitives on a native LibreMetaverse grid",
long_about = None,
arg_required_else_help = true
)]
struct Cli {
/// Avatar first name. May also be supplied as `GRID_FIRST_NAME`.
#[arg(value_name = "FIRSTNAME")]
first_name: Option<String>,
/// Avatar last name. May also be supplied as `GRID_LAST_NAME`.
#[arg(value_name = "LASTNAME")]
last_name: Option<String>,
/// Avatar password. May also be supplied as `GRID_PASSWORD`.
#[arg(value_name = "PASSWORD")]
password: Option<String>,
/// Case-insensitive object-name substring.
#[arg(value_name = "SEARCH_TERM")]
search_term: Option<String>,
/// Case-insensitive object-name substring for `--fake-grid` mode.
#[arg(long = "search", value_name = "SEARCH_TERM")]
fake_search: Option<String>,
/// Inspect a bounded LLSD JSON fixture without accessing a live grid.
#[arg(long, value_name = "FILE")]
fake_grid: Option<PathBuf>,
/// Maximum number of nearest matching objects to print.
#[arg(long, default_value_t = 10, value_name = "COUNT")]
limit: usize,
/// Override the login endpoint. `GRID_LOGIN_URL` is used when absent.
#[arg(long, value_name = "URL")]
login_uri: Option<String>,
/// Maximum time allowed for login.
#[arg(long, default_value_t = DEFAULT_LOGIN_TIMEOUT_SECONDS, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
login_timeout_seconds: u64,
/// Time to collect object updates after login.
#[arg(long, default_value_t = DEFAULT_DISCOVERY_DELAY_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(0..=300_000))]
discovery_delay_ms: u64,
/// Shared deadline for outstanding object-property requests.
#[arg(long, default_value_t = DEFAULT_PROPERTY_TIMEOUT_MS, value_name = "MILLISECONDS", value_parser = clap::value_parser!(u64).range(1..=300_000))]
property_timeout_ms: u64,
/// Refuse to emit more than this many bytes.
#[arg(long, default_value_t = DEFAULT_MAX_OUTPUT_BYTES, value_name = "BYTES")]
max_output_bytes: usize,
}
struct LiveArguments {
first_name: String,
last_name: String,
password: String,
search_term: Option<String>,
login_uri: Option<String>,
login_timeout: Duration,
}
#[derive(Debug)]
enum ProgramError {
Usage(String),
Input { action: String, source: io::Error },
InvalidFixture(&'static str),
Client(&'static str),
LoginFailed,
LoginTimedOut,
Cancelled,
SimulatorChanged,
Disconnected,
Output(io::Error),
OutputLimit,
}
impl ProgramError {
const fn exit_code(&self) -> u8 {
match self {
Self::Usage(_) => EXIT_USAGE,
Self::Input { .. } | Self::InvalidFixture(_) => EXIT_INPUT,
Self::Client(_)
| Self::LoginFailed
| Self::LoginTimedOut
| Self::Cancelled
| Self::SimulatorChanged
| Self::Disconnected => EXIT_CLIENT,
Self::Output(_) | Self::OutputLimit => EXIT_OUTPUT,
}
}
}
impl fmt::Display for ProgramError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage(message) => formatter.write_str(message),
Self::Input { action, source } => write!(formatter, "{action}: {source}"),
Self::InvalidFixture(reason) => {
write!(formatter, "invalid fake-grid fixture: {reason}")
}
Self::Client(operation) => {
write!(formatter, "native client operation failed: {operation}")
}
Self::LoginFailed => formatter.write_str("login failed"),
Self::LoginTimedOut => formatter.write_str("login timed out"),
Self::Cancelled => formatter.write_str("inspection cancelled"),
Self::SimulatorChanged => formatter.write_str("simulator changed during inspection"),
Self::Disconnected => formatter.write_str("grid disconnected during inspection"),
Self::Output(source) => write!(formatter, "writing standard output: {source}"),
Self::OutputLimit => formatter.write_str("inspection reached the output byte limit"),
}
}
}
#[derive(Default)]
struct BoundedOutput {
bytes: Vec<u8>,
maximum: usize,
}
impl BoundedOutput {
fn new(maximum: usize) -> Self {
Self {
bytes: Vec::new(),
maximum,
}
}
fn line(&mut self, value: impl AsRef<str>) -> Result<(), ProgramError> {
let value = sanitize(value.as_ref());
let required = value
.len()
.checked_add(1)
.ok_or(ProgramError::OutputLimit)?;
if self
.bytes
.len()
.checked_add(required)
.is_none_or(|size| size > self.maximum)
{
return Err(ProgramError::OutputLimit);
}
self.bytes.extend_from_slice(value.as_bytes());
self.bytes.push(b'\n');
Ok(())
}
fn flush(self) -> Result<(), ProgramError> {
let mut output = io::stdout().lock();
output
.write_all(&self.bytes)
.map_err(ProgramError::Output)?;
output.flush().map_err(ProgramError::Output)
}
}
enum LiveEvent {
Status(String),
Properties(Box<PrimitiveObjectProperties>),
Disconnected,
SimulatorChanged { had_previous: bool },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PropertyState {
Ready,
Delayed,
Missing,
}
struct FixtureObject {
primitive: Primitive,
pending_properties: Option<PrimitiveObjectProperties>,
state: PropertyState,
}
struct Fixture {
region: String,
agent_position: Vector3,
objects: Vec<FixtureObject>,
}
#[must_use]
pub fn main_entry() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("prim-inspector: {error}");
ExitCode::from(error.exit_code())
}
}
}
fn run() -> Result<(), ProgramError> {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(error)
if matches!(
error.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) =>
{
error.print().map_err(ProgramError::Output)?;
return Ok(());
}
Err(error) => return Err(ProgramError::Usage(error.to_string())),
};
if !(1..=100).contains(&cli.limit) {
return Err(ProgramError::Usage(
"--limit must be between 1 and 100".into(),
));
}
if cli.max_output_bytes == 0 {
return Err(ProgramError::Usage(
"--max-output-bytes must be greater than zero".into(),
));
}
validate_search(cli.search_term.as_deref())?;
validate_search(cli.fake_search.as_deref())?;
let mut output = BoundedOutput::new(cli.max_output_bytes);
let runtime =
tokio::runtime::Runtime::new().map_err(|_| ProgramError::Client("start runtime"))?;
if let Some(path) = &cli.fake_grid {
if cli.first_name.is_some()
|| cli.last_name.is_some()
|| cli.password.is_some()
|| cli.login_uri.is_some()
{
return Err(ProgramError::Usage(
"live credentials and --login-uri cannot be combined with --fake-grid".into(),
));
}
if cli.search_term.is_some() && cli.fake_search.is_some() {
return Err(ProgramError::Usage(
"positional SEARCH_TERM and --search cannot be combined".into(),
));
}
run_fixture(
path,
cli.fake_search.as_deref().or(cli.search_term.as_deref()),
cli.limit,
cli.property_timeout_ms,
&mut output,
)?;
} else {
let live = resolve_live_arguments(&cli)?;
runtime.block_on(run_live(
live,
cli.limit,
Duration::from_millis(cli.discovery_delay_ms),
Duration::from_millis(cli.property_timeout_ms),
&mut output,
))?;
}
output.flush()
}
fn validate_search(search: Option<&str>) -> Result<(), ProgramError> {
if search.is_some_and(|value| value.chars().count() > 256) {
return Err(ProgramError::Usage(
"SEARCH_TERM must contain at most 256 characters".into(),
));
}
Ok(())
}
fn resolve_live_arguments(cli: &Cli) -> Result<LiveArguments, ProgramError> {
fn required(
value: Option<&String>,
variable: &str,
label: &str,
) -> Result<String, ProgramError> {
value
.cloned()
.or_else(|| std::env::var(variable).ok())
.filter(|value| !value.is_empty())
.ok_or_else(|| ProgramError::Usage(format!("{label} is required (or set {variable})")))
}
Ok(LiveArguments {
first_name: required(cli.first_name.as_ref(), "GRID_FIRST_NAME", "FIRSTNAME")?,
last_name: required(cli.last_name.as_ref(), "GRID_LAST_NAME", "LASTNAME")?,
password: required(cli.password.as_ref(), "GRID_PASSWORD", "PASSWORD")?,
search_term: cli.search_term.clone(),
login_uri: cli
.login_uri
.clone()
.or_else(|| std::env::var("GRID_LOGIN_URL").ok())
.filter(|value| !value.is_empty()),
login_timeout: Duration::from_secs(cli.login_timeout_seconds),
})
}
async fn run_live(
mut arguments: LiveArguments,
limit: usize,
discovery_delay: Duration,
property_timeout: Duration,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let mut client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?;
client.settings().agent_settings_mut().multiple_sims = false;
let network = client.network();
let objects = client.objects();
let mut login = network
.default_login_params(
std::mem::take(&mut arguments.first_name),
std::mem::take(&mut arguments.last_name),
std::mem::take(&mut arguments.password),
"PrimInspector".into(),
env!("CARGO_PKG_VERSION").into(),
)
.map_err(|_| ProgramError::Client("build login parameters"))?;
if let Some(uri) = arguments.login_uri.take() {
login.uri = uri;
}
let (sender, mut receiver) = mpsc::channel(EVENT_QUEUE_CAPACITY);
let subscriptions = install_subscriptions(&network, &objects, &sender);
if let Err(error) = output.line("Logging in...") {
shutdown(&client, &network, subscriptions);
return Err(error);
}
let cancellation = CancellationTokenSource::new();
let login_result = tokio::select! {
result = network.login_with_login_params_cancellation_token(login, Some(cancellation.token())) => {
result.map_err(|_| ProgramError::LoginFailed)
}
() = tokio::time::sleep(arguments.login_timeout) => {
cancellation.cancel();
let _ = network.abort_login();
Err(ProgramError::LoginTimedOut)
}
signal = tokio::signal::ctrl_c() => {
cancellation.cancel();
let _ = network.abort_login();
signal.map_err(|_| ProgramError::Cancelled)?;
Err(ProgramError::Cancelled)
}
};
let result = async {
if !login_result? {
return Err(ProgramError::LoginFailed);
}
let simulator = network
.current_sim()
.ok_or(ProgramError::Client("resolve current simulator"))?;
drain_login_events(&mut receiver, output)?;
let position = client.self_().sim_position();
output.line(format!("Region: {}", simulator.name))?;
output.line(format!("Agent position: {}", vector3(position)))?;
output.line(format!(
"Collecting object updates for {} ms...",
discovery_delay.as_millis()
))?;
wait_duration(discovery_delay, &mut receiver, output).await?;
let mut primitives = snapshot(&simulator)?;
output.line(format!("Found {} primitives", primitives.len()))?;
hydrate_live_properties(
&objects,
&simulator,
&mut primitives,
property_timeout,
&mut receiver,
output,
)
.await?;
inspect(
primitives,
position,
arguments.search_term.as_deref(),
limit,
output,
)
}
.await;
shutdown(&client, &network, subscriptions);
result
}
fn drain_login_events(
receiver: &mut mpsc::Receiver<LiveEvent>,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
while let Ok(event) = receiver.try_recv() {
match event {
LiveEvent::Status(status) => output.line(status)?,
LiveEvent::Properties(_)
| LiveEvent::SimulatorChanged {
had_previous: false,
} => {}
LiveEvent::SimulatorChanged { had_previous: true } => {
return Err(ProgramError::SimulatorChanged);
}
LiveEvent::Disconnected => return Err(ProgramError::Disconnected),
}
}
Ok(())
}
fn install_subscriptions(
network: &NetworkManager,
objects: &libremetaverse::ObjectManager,
sender: &mpsc::Sender<LiveEvent>,
) -> Vec<Subscription> {
let status = sender.clone();
let properties = sender.clone();
let disconnected = sender.clone();
let changed = sender.clone();
vec![
network.subscribe_login_progress(Arc::new(move |event: LoginProgressEventArgs| {
let _ = status.try_send(LiveEvent::Status(format!(
"Login {:?}: {}",
event.status(),
event.message()
)));
})),
objects.subscribe_object_properties(Arc::new(move |event: ObjectPropertiesEventArgs| {
let _ = properties.try_send(LiveEvent::Properties(Box::new(event.properties())));
})),
network.subscribe_disconnected(Arc::new(move |_event: DisconnectedEventArgs| {
let _ = disconnected.try_send(LiveEvent::Disconnected);
})),
network.subscribe_sim_changed(Arc::new(move |event: SimChangedEventArgs| {
let _ = changed.try_send(LiveEvent::SimulatorChanged {
had_previous: event.previous_simulator().is_some(),
});
})),
]
}
fn shutdown(client: &GridClient, network: &NetworkManager, subscriptions: Vec<Subscription>) {
drop(subscriptions);
let _ = network.logout_with_method();
let _ = client.dispose_with_method();
}
fn snapshot(simulator: &Simulator) -> Result<Vec<Primitive>, ProgramError> {
let values = simulator
.objects_primitives
.read()
.map_err(|_| ProgramError::Client("lock simulator primitives"))?;
if values.len() > MAX_OBJECTS {
return Err(ProgramError::Client(
"primitive snapshot exceeds safety limit",
));
}
Ok(values.values().cloned().collect())
}
async fn wait_duration(
duration: Duration,
receiver: &mut mpsc::Receiver<LiveEvent>,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let deadline = tokio::time::Instant::now() + duration;
loop {
tokio::select! {
() = tokio::time::sleep_until(deadline) => return Ok(()),
signal = tokio::signal::ctrl_c() => {
signal.map_err(|_| ProgramError::Cancelled)?;
return Err(ProgramError::Cancelled);
}
event = receiver.recv() => match event {
Some(LiveEvent::Status(status)) => output.line(status)?,
Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged),
Some(LiveEvent::SimulatorChanged { had_previous: false } | LiveEvent::Properties(_)) => {},
None => return Err(ProgramError::Client("event queue closed")),
}
}
}
}
async fn hydrate_live_properties(
objects: &libremetaverse::ObjectManager,
simulator: &Simulator,
primitives: &mut [Primitive],
timeout: Duration,
receiver: &mut mpsc::Receiver<LiveEvent>,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let mut pending = HashMap::new();
for primitive in primitives
.iter()
.filter(|primitive| primitive.properties.is_none())
.take(MAX_PROPERTY_REQUESTS)
{
objects
.select_object_with_simulator_u_int32(simulator.clone(), primitive.local_id)
.map_err(|_| ProgramError::Client("request object properties"))?;
pending.insert(primitive.id, primitive.local_id);
}
if pending.is_empty() {
return Ok(());
}
output.line(format!(
"Requested properties for {} primitives",
pending.len()
))?;
let deadline = tokio::time::Instant::now() + timeout;
while !pending.is_empty() {
tokio::select! {
() = tokio::time::sleep_until(deadline) => break,
signal = tokio::signal::ctrl_c() => {
signal.map_err(|_| ProgramError::Cancelled)?;
return Err(ProgramError::Cancelled);
}
event = receiver.recv() => match event {
Some(LiveEvent::Properties(properties)) => {
if pending.remove(&properties.object_id).is_some()
&& let Some(primitive) = primitives.iter_mut().find(|primitive| primitive.id == properties.object_id)
{ primitive.properties = Some(*properties); }
}
Some(LiveEvent::Status(status)) => output.line(status)?,
Some(LiveEvent::Disconnected) => return Err(ProgramError::Disconnected),
Some(LiveEvent::SimulatorChanged { had_previous: true }) => return Err(ProgramError::SimulatorChanged),
Some(LiveEvent::SimulatorChanged { had_previous: false }) => {},
None => return Err(ProgramError::Client("event queue closed")),
}
}
}
if !pending.is_empty() {
output.line(format!(
"Property timeout: {} primitives remain unknown",
pending.len()
))?;
}
Ok(())
}
fn run_fixture(
path: &Path,
search: Option<&str>,
limit: usize,
property_timeout_ms: u64,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let fixture = read_fixture(path)?;
output.line("Fake grid connected")?;
output.line(format!("Region: {}", fixture.region))?;
output.line(format!(
"Agent position: {}",
vector3(fixture.agent_position)
))?;
output.line(format!("Found {} primitives", fixture.objects.len()))?;
let mut primitives = Vec::with_capacity(fixture.objects.len());
let mut missing = 0;
for mut object in fixture.objects {
match object.state {
PropertyState::Ready => {}
PropertyState::Delayed => {
output.line(format!(
"CALL select-object local_id={}",
object.primitive.local_id
))?;
object.primitive.properties = object.pending_properties.take();
output.line(format!(
"PROPERTY received local_id={}",
object.primitive.local_id
))?;
}
PropertyState::Missing => {
output.line(format!(
"CALL select-object local_id={}",
object.primitive.local_id
))?;
missing += 1;
}
}
primitives.push(object.primitive);
}
if missing != 0 {
output.line(format!(
"Property timeout after {property_timeout_ms} ms: {missing} primitives remain unknown"
))?;
}
inspect(primitives, fixture.agent_position, search, limit, output)?;
output.line("Fake grid logout complete; active_tasks=0 open_sockets=0")
}
fn read_fixture(path: &Path) -> Result<Fixture, ProgramError> {
let metadata = fs::metadata(path).map_err(|source| ProgramError::Input {
action: format!("reading fixture metadata {}", path.display()),
source,
})?;
if metadata.len() > MAX_FIXTURE_BYTES {
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
}
let bytes = fs::read(path).map_err(|source| ProgramError::Input {
action: format!("reading fixture {}", path.display()),
source,
})?;
if bytes.len() > MAX_FIXTURE_ALLOC_BYTES {
return Err(ProgramError::InvalidFixture("file exceeds 8 MiB limit"));
}
let text =
String::from_utf8(bytes).map_err(|_| ProgramError::InvalidFixture("JSON must be UTF-8"))?;
let root = OSDParser::deserialize_json_with_string(text)
.map_err(|_| ProgramError::InvalidFixture("could not parse LLSD JSON"))?;
root.validate_limits(32, 1_000_000, MAX_FIXTURE_ALLOC_BYTES)
.map_err(|_| ProgramError::InvalidFixture("LLSD limits exceeded"))?;
let OSD::Map(root) = root else {
return Err(ProgramError::InvalidFixture("root must be a map"));
};
let region = value_string(&root, "region").unwrap_or_else(|| "Fake Region".into());
let agent_position = root
.get("agent_position")
.and_then(|value| value.as_vector3().ok())
.ok_or(ProgramError::InvalidFixture(
"agent_position must be a three-number array",
))?;
let Some(OSD::Array(values)) = root.get("objects") else {
return Err(ProgramError::InvalidFixture("objects must be an array"));
};
if values.len() > MAX_OBJECTS {
return Err(ProgramError::InvalidFixture("too many objects"));
}
let mut objects = Vec::with_capacity(values.len());
for value in values {
let OSD::Map(map) = value else {
return Err(ProgramError::InvalidFixture("each object must be a map"));
};
let mut primitive = Primitive::from_osd(value.clone())
.map_err(|_| ProgramError::InvalidFixture("invalid primitive data"))?;
if primitive.id == UUID::zero() || primitive.local_id == 0 {
return Err(ProgramError::InvalidFixture(
"each object needs non-zero id and localid",
));
}
primitive.text = value_string(map, "hover_text").unwrap_or_default();
let mut properties = primitive.properties.take().unwrap_or_default();
properties.object_id = primitive.id;
properties.owner_id = value_uuid(map, "owner").unwrap_or_default();
properties.creator_id = value_uuid(map, "creator").unwrap_or_default();
properties.sale_type = parse_sale_type(value_string(map, "sale_type").as_deref())?;
properties.sale_price = map
.get("sale_price")
.and_then(|value| value.as_integer().ok())
.unwrap_or_default();
let state = match value_string(map, "property_state")
.as_deref()
.unwrap_or("ready")
{
"ready" => PropertyState::Ready,
"delayed" => PropertyState::Delayed,
"missing" => PropertyState::Missing,
_ => {
return Err(ProgramError::InvalidFixture(
"property_state must be ready, delayed, or missing",
));
}
};
let (current, pending) = match state {
PropertyState::Ready => (Some(properties), None),
PropertyState::Delayed => (None, Some(properties)),
PropertyState::Missing => (None, None),
};
primitive.properties = current;
objects.push(FixtureObject {
primitive,
pending_properties: pending,
state,
});
}
Ok(Fixture {
region,
agent_position,
objects,
})
}
fn value_string(map: &HashMap<String, OSD>, key: &str) -> Option<String> {
map.get(key).and_then(|value| value.as_string().ok())
}
fn value_uuid(map: &HashMap<String, OSD>, key: &str) -> Option<UUID> {
map.get(key)
.and_then(|value| value.as_uuid().ok())
.filter(|value| *value != UUID::zero())
}
fn parse_sale_type(value: Option<&str>) -> Result<SaleType, ProgramError> {
match value.unwrap_or("not").to_ascii_lowercase().as_str() {
"not" | "none" => Ok(SaleType::Not),
"original" => Ok(SaleType::Original),
"copy" => Ok(SaleType::Copy),
"contents" => Ok(SaleType::Contents),
_ => Err(ProgramError::InvalidFixture("unknown sale_type")),
}
}
fn inspect(
primitives: Vec<Primitive>,
agent_position: Vector3,
search: Option<&str>,
limit: usize,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let needle = search.map(str::to_lowercase);
let mut matches = primitives
.into_iter()
.filter_map(|primitive| {
let name = primitive
.properties
.as_ref()
.map_or("Unknown", |properties| properties.name.as_str());
if needle
.as_ref()
.is_some_and(|needle| !name.to_lowercase().contains(needle))
{
return None;
}
let distance = Vector3::distance(primitive.position, agent_position).ok()?;
Some((distance, primitive))
})
.collect::<Vec<_>>();
matches.sort_by(|(left_distance, left), (right_distance, right)| {
left_distance
.total_cmp(right_distance)
.then_with(|| left.local_id.cmp(&right.local_id))
.then_with(|| left.id.to_string().cmp(&right.id.to_string()))
});
if matches.is_empty() {
if let Some(search) = search {
output.line(format!("No objects matching '{search}'"))?;
} else {
output.line("No primitives found")?;
}
return Ok(());
}
output.line(format!(
"Inspecting {} nearest matching primitives",
matches.len().min(limit)
))?;
for (distance, primitive) in matches.into_iter().take(limit) {
format_primitive(&primitive, distance, output)?;
}
Ok(())
}
fn format_primitive(
primitive: &Primitive,
distance: f32,
output: &mut BoundedOutput,
) -> Result<(), ProgramError> {
let name = primitive
.properties
.as_ref()
.map(|properties| properties.name.as_str())
.filter(|name| !name.is_empty())
.unwrap_or("Unknown");
output.line("")?;
output.line(format!("Object: {name} (ID: {})", primitive.id))?;
output.line(format!(" Local ID: {}", primitive.local_id))?;
output.line(format!(" Type: {:?}", primitive.type_()))?;
output.line(format!(" Distance: {distance:.2} m"))?;
output.line(format!(" Position: {}", vector3(primitive.position)))?;
output.line(format!(
" Rotation: <{:.3}, {:.3}, {:.3}, {:.3}>",
primitive.rotation.x, primitive.rotation.y, primitive.rotation.z, primitive.rotation.w
))?;
output.line(format!(" Scale: {}", vector3(primitive.scale)))?;
if primitive.parent_id != 0 {
output.line(format!(" Parent ID: {}", primitive.parent_id))?;
}
output.line(format!(" Flags: {:?}", primitive.flags))?;
output.line(format!(" Material: {:?}", primitive.prim_data.material))?;
output.line(format!(" PCode: {:?}", primitive.prim_data.p_code))?;
output.line(format!(
" Profile: {:?}",
primitive.prim_data.profile_curve_with_property()
))?;
output.line(format!(" Path: {:?}", primitive.prim_data.path_curve))?;
if let Some(sculpt) = &primitive.sculpt {
output.line(format!(" Sculpt Type: {:?}", sculpt.type_()))?;
output.line(format!(" Sculpt Texture: {}", sculpt.sculpt_texture))?;
}
if let Some(light) = &primitive.light {
output.line(format!(
" Light Color: <{:.3}, {:.3}, {:.3}, {:.3}>",
light.color.r, light.color.g, light.color.b, light.color.a
))?;
output.line(format!(" Light Intensity: {:.3}", light.intensity))?;
output.line(format!(" Light Radius: {:.3}", light.radius))?;
}
if let Some(flexible) = &primitive.flexible {
output.line(format!(" Flexible Softness: {}", flexible.softness))?;
output.line(format!(" Flexible Gravity: {:.3}", flexible.gravity))?;
}
if let Some(properties) = &primitive.properties {
output.line(format!(" Owner: {}", properties.owner_id))?;
output.line(format!(" Creator: {}", properties.creator_id))?;
output.line(format!(" Description: {}", properties.description))?;
if properties.sale_type != SaleType::Not {
output.line(format!(
" For Sale: {:?} for L${}",
properties.sale_type, properties.sale_price
))?;
}
} else {
output.line(" Properties: unavailable")?;
}
if !primitive.text.is_empty() {
output.line(format!(" Hover Text: {}", primitive.text))?;
}
Ok(())
}
fn sanitize(value: &str) -> String {
let value = value.replace(['\r', '\n', '\t'], " ");
value
.split_whitespace()
.map(|word| {
let lower = word.to_ascii_lowercase();
if lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.contains("password=")
|| lower.contains("token=")
{
"[redacted]"
} else {
word
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn vector3(value: Vector3) -> String {
format!("<{:.3}, {:.3}, {:.3}>", value.x, value.y, value.z)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_removes_urls_and_secret_parameters() {
assert_eq!(
sanitize("see https://grid.invalid/?token=secret now"),
"see [redacted] now"
);
}
#[test]
fn sale_type_parser_accepts_documented_values() {
assert_eq!(parse_sale_type(Some("COPY")).unwrap(), SaleType::Copy);
assert!(parse_sale_type(Some("auction")).is_err());
}
}