From d5c318d28015dcfbdf24056bea10885467bb7c8a Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 06:41:29 +0000 Subject: [PATCH] Implement native AIS inventory reconciliation (#63) --- .gitea/workflows/codegen.yml | 11 + .gitea/workflows/rust-workspace.yml | 1 + README.md | 8 + api/SHIM-COVERAGE.md | 2 +- crates/libremetaverse/src/caps_http.rs | 56 +- crates/libremetaverse/src/client_core.rs | 30 + crates/libremetaverse/src/generated.rs | 240 ++-- crates/libremetaverse/src/inventory.rs | 184 +++ crates/libremetaverse/src/inventory_ais.rs | 1060 +++++++++++++++++ .../src/inventory_ais_internal_semantics.rs | 215 ++++ .../libremetaverse/src/inventory_manager.rs | 24 + crates/libremetaverse/src/lib.rs | 16 +- docs/inventory.md | 24 + tools/check_milestone_09_issue_63.py | 104 ++ tools/generate_api_shims.py | 81 ++ tools/generate_rust_mapping.py | 1 + 16 files changed, 1896 insertions(+), 161 deletions(-) create mode 100644 crates/libremetaverse/src/inventory_ais.rs create mode 100644 crates/libremetaverse/src/inventory_ais_internal_semantics.rs create mode 100644 tools/check_milestone_09_issue_63.py diff --git a/.gitea/workflows/codegen.yml b/.gitea/workflows/codegen.yml index 3f6f02a..27ad591 100644 --- a/.gitea/workflows/codegen.yml +++ b/.gitea/workflows/codegen.yml @@ -17,9 +17,12 @@ on: - "tools/check_milestone_09_issue_60.py" - "tools/check_milestone_09_issue_61.py" - "tools/check_milestone_09_issue_62.py" + - "tools/check_milestone_09_issue_63.py" - "crates/libremetaverse/src/agent_movement.rs" - "crates/libremetaverse/src/inventory.rs" - "crates/libremetaverse/src/inventory_manager.rs" + - "crates/libremetaverse/src/inventory_ais.rs" + - "crates/libremetaverse/src/inventory_ais_internal_semantics.rs" - "docs/agent-movement.md" - "docs/inventory.md" - "crates/libremetaverse/Cargo.toml" @@ -35,6 +38,7 @@ on: - "tests/compat/tests/appearance_visual_semantics.rs" - "tests/compat/tests/inventory_store_semantics.rs" - "tests/compat/tests/inventory_manager_semantics.rs" + - "tests/compat/tests/inventory_ais_semantics.rs" - "tests/compat/tests/task_inventory_semantics.rs" - "tests/compat/tests/misclassified_link_semantics.rs" - "tests/upstream-tests.json" @@ -54,9 +58,12 @@ on: - "tools/check_milestone_09_issue_60.py" - "tools/check_milestone_09_issue_61.py" - "tools/check_milestone_09_issue_62.py" + - "tools/check_milestone_09_issue_63.py" - "crates/libremetaverse/src/agent_movement.rs" - "crates/libremetaverse/src/inventory.rs" - "crates/libremetaverse/src/inventory_manager.rs" + - "crates/libremetaverse/src/inventory_ais.rs" + - "crates/libremetaverse/src/inventory_ais_internal_semantics.rs" - "docs/agent-movement.md" - "docs/inventory.md" - "crates/libremetaverse/Cargo.toml" @@ -72,6 +79,7 @@ on: - "tests/compat/tests/appearance_visual_semantics.rs" - "tests/compat/tests/inventory_store_semantics.rs" - "tests/compat/tests/inventory_manager_semantics.rs" + - "tests/compat/tests/inventory_ais_semantics.rs" - "tests/compat/tests/task_inventory_semantics.rs" - "tests/compat/tests/misclassified_link_semantics.rs" - "tests/upstream-tests.json" @@ -99,6 +107,7 @@ jobs: python3 tools/check_milestone_09_issue_60.py python3 tools/check_milestone_09_issue_61.py python3 tools/check_milestone_09_issue_62.py + python3 tools/check_milestone_09_issue_63.py cargo check --manifest-path tests/api-compile/Cargo.toml --locked - name: Test generated wire codecs and translated parity cases run: | @@ -108,6 +117,8 @@ jobs: cargo test -p libremetaverse-compat-tests --test wire_semantics cargo test -p libremetaverse-compat-tests --test inventory_store_semantics cargo test -p libremetaverse-compat-tests --test inventory_manager_semantics + cargo test -p libremetaverse-compat-tests --test inventory_ais_semantics + cargo test -p libremetaverse --lib inventory_ais_internal_semantics cargo test -p libremetaverse-compat-tests --test task_inventory_semantics cargo test -p libremetaverse-compat-tests --test misclassified_link_semantics - name: Test generated visual and foliage catalogs diff --git a/.gitea/workflows/rust-workspace.yml b/.gitea/workflows/rust-workspace.yml index a8ddacc..649c52f 100644 --- a/.gitea/workflows/rust-workspace.yml +++ b/.gitea/workflows/rust-workspace.yml @@ -31,5 +31,6 @@ jobs: python3 tools/check_milestone_09_issue_60.py python3 tools/check_milestone_09_issue_61.py python3 tools/check_milestone_09_issue_62.py + python3 tools/check_milestone_09_issue_63.py - name: Compile every workspace target with bounded memory run: cargo check --workspace --all-targets --locked -j 1 diff --git a/README.md b/README.md index e1d9b95..f7fb118 100644 --- a/README.md +++ b/README.md @@ -367,3 +367,11 @@ before store reconciliation, and legacy task inventory uses a 16 MiB-bounded `ReplyTaskInventory`/`RequestXfer` state machine. Focused translated and native fake-packet tests cover correlation, stale replies, offers, bulk completion, capability task inventory, and malformed transfer sizes. + +The client-owned `InventoryAISClient` implements Inventory/Library API v3 +category, child, link, item, outfit, orphan, and trash resources with exact +LLSD XML request shapes and `COPY` destination headers. Parsed objects, +side-effect removals, and folder versions are validated and reconciled through +one atomic store transaction; cancellation and malformed responses cannot +partially mutate the cache. Focused compatibility and injected-HTTP tests cover +link correction, endpoint recording, error isolation, and metadata updates. diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 4bcd446..62d9808 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | Assembly | Types | Members | Status | |---|---:|---:|---| -| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 164 types / 14,877 members; remaining surface is callable failure-only shims | +| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 166 types / 14,923 members; remaining surface is callable failure-only shims | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | diff --git a/crates/libremetaverse/src/caps_http.rs b/crates/libremetaverse/src/caps_http.rs index 0a20375..3510e1f 100644 --- a/crates/libremetaverse/src/caps_http.rs +++ b/crates/libremetaverse/src/caps_http.rs @@ -699,6 +699,31 @@ impl HttpCapsClient { .await } + /// Sends a capability request whose verb or headers are not covered by the + /// fixed public compatibility overloads (`AISv3` uses `COPY` and + /// `Destination`). The request still passes through all normal limits, + /// cancellation, rate limiting, and injected-handler recording. + pub(crate) async fn send_custom( + &self, + method: &str, + uri: Uri, + headers: std::collections::BTreeMap, + content_type: Option, + payload: Vec, + cancellation_token: CancellationToken, + ) -> Result<(HttpResponse, Vec), Error> { + self.send_with_headers( + method, + uri, + headers, + content_type, + payload, + cancellation_token, + None, + ) + .await + } + pub async fn get_request( &self, uri: Uri, @@ -1026,6 +1051,28 @@ impl HttpCapsClient { payload: Vec, cancellation_token: CancellationToken, progress: Option>>, + ) -> Result<(HttpResponse, Vec), Error> { + self.send_with_headers( + method, + uri, + std::collections::BTreeMap::new(), + content_type, + payload, + cancellation_token, + progress, + ) + .await + } + + async fn send_with_headers( + &self, + method: &str, + uri: Uri, + headers: std::collections::BTreeMap, + content_type: Option, + payload: Vec, + cancellation_token: CancellationToken, + progress: Option>>, ) -> Result<(HttpResponse, Vec), Error> { if self.0.disposed.load(Ordering::Acquire) { return Err(Error::InvalidOperation); @@ -1057,6 +1104,7 @@ impl HttpCapsClient { handler, method, uri, + headers, content_type, payload, cancellation_token, @@ -1069,6 +1117,7 @@ impl HttpCapsClient { client, method, parsed, + headers, content_type, payload, cancellation_token, @@ -1086,6 +1135,7 @@ impl HttpCapsClient { handler: &HttpMessageHandler, method: &str, uri: Uri, + headers: std::collections::BTreeMap, content_type: Option, payload: Vec, cancellation_token: CancellationToken, @@ -1095,7 +1145,7 @@ impl HttpCapsClient { let request = HttpRequest { method: method.to_owned(), uri, - headers: std::collections::BTreeMap::new(), + headers, content_type, body: payload, }; @@ -1112,6 +1162,7 @@ impl HttpCapsClient { client: &reqwest::Client, method: &str, uri: reqwest::Url, + headers: std::collections::BTreeMap, content_type: Option, payload: Vec, cancellation_token: CancellationToken, @@ -1119,6 +1170,9 @@ impl HttpCapsClient { ) -> Result<(HttpResponse, Vec), Error> { let method = reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| Error::Argument)?; let mut request = client.request(method, uri); + for (name, value) in headers { + request = request.header(name, value); + } if let Some(content_type) = content_type { request = request.header(reqwest::header::CONTENT_TYPE, content_type); } diff --git a/crates/libremetaverse/src/client_core.rs b/crates/libremetaverse/src/client_core.rs index 6c3fc94..02c9d09 100644 --- a/crates/libremetaverse/src/client_core.rs +++ b/crates/libremetaverse/src/client_core.rs @@ -119,6 +119,7 @@ struct ClientRuntime { network_manager: Mutex>, agent_manager: Mutex>, inventory_manager: Mutex>>, + inventory_ais_client: Mutex>, shutdown_complete: Condvar, shutdown_wait: Mutex<()>, } @@ -148,6 +149,7 @@ impl ClientRuntime { network_manager: Mutex::new(std::sync::Weak::new()), agent_manager: Mutex::new(std::sync::Weak::new()), inventory_manager: Mutex::new(None), + inventory_ais_client: Mutex::new(None), shutdown_complete: Condvar::new(), shutdown_wait: Mutex::new(()), } @@ -224,6 +226,10 @@ impl ClientRuntime { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .take(); + self.inventory_ais_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); self.http_caps_client .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -441,6 +447,30 @@ impl GridClient { Ok(manager) } + pub(crate) fn native_ais_client(&self) -> Result { + let mut cached = self + .runtime + .inventory_ais_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(client) = cached.as_ref() { + return Ok(client.clone()); + } + let client = + crate::inventory_ais::InventoryAISClient::native_new(Some(Arc::new(self.clone())))?; + *cached = Some(client.clone()); + Ok(client) + } + + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn native_set_ais_client(&mut self, value: crate::InventoryAISClient) { + *self + .runtime + .inventory_ais_client + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value); + } + #[allow(clippy::needless_pass_by_value)] // The mapped C# property setter owns its value. pub(crate) fn native_set_inventory(&mut self, value: crate::InventoryManager) { *self diff --git a/crates/libremetaverse/src/generated.rs b/crates/libremetaverse/src/generated.rs index 06b6a59..ee32fa0 100644 --- a/crates/libremetaverse/src/generated.rs +++ b/crates/libremetaverse/src/generated.rs @@ -6,52 +6,14 @@ #![allow(non_snake_case)] /// C# type: `T:LibreMetaverse.AISResponseMeta`. -pub struct AISResponseMeta; -impl AISResponseMeta { - /// C# member: `F:LibreMetaverse.AISResponseMeta.Empty`. - pub fn empty() -> libremetaverse::AISResponseMeta { - libremetaverse_types::unimplemented_api!("F:LibreMetaverse.AISResponseMeta.Empty") - } - /// C# member: `M:LibreMetaverse.AISResponseMeta.#ctor(System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyDictionary{LibreMetaverse.UUID,System.Int32})`. - pub fn new( - broken_links: Vec, - items: Vec, - categories: Vec, - versions: std::collections::HashMap, - ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AISResponseMeta.#ctor(System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyDictionary{LibreMetaverse.UUID,System.Int32})", - ) - } - /// C# member: `P:LibreMetaverse.AISResponseMeta.BrokenLinksRemoved`. - pub fn broken_links_removed(&self) -> Vec { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.AISResponseMeta.BrokenLinksRemoved" - ) - } - /// C# member: `P:LibreMetaverse.AISResponseMeta.CategoriesRemoved`. - pub fn categories_removed(&self) -> Vec { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.AISResponseMeta.CategoriesRemoved" - ) - } - /// C# member: `P:LibreMetaverse.AISResponseMeta.CategoryVersionUpdates`. - pub fn category_version_updates( - &self, - ) -> std::collections::HashMap { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.AISResponseMeta.CategoryVersionUpdates" - ) - } - /// C# member: `P:LibreMetaverse.AISResponseMeta.HasAnyData`. - pub fn has_any_data(&self) -> bool { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.AISResponseMeta.HasAnyData") - } - /// C# member: `P:LibreMetaverse.AISResponseMeta.ItemsRemoved`. - pub fn items_removed(&self) -> Vec { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.AISResponseMeta.ItemsRemoved") - } -} +/// C# member: `F:LibreMetaverse.AISResponseMeta.Empty`. +/// C# member: `M:LibreMetaverse.AISResponseMeta.#ctor(System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyList{LibreMetaverse.UUID},System.Collections.Generic.IReadOnlyDictionary{LibreMetaverse.UUID,System.Int32})`. +/// C# member: `P:LibreMetaverse.AISResponseMeta.BrokenLinksRemoved`. +/// C# member: `P:LibreMetaverse.AISResponseMeta.CategoriesRemoved`. +/// C# member: `P:LibreMetaverse.AISResponseMeta.CategoryVersionUpdates`. +/// C# member: `P:LibreMetaverse.AISResponseMeta.HasAnyData`. +/// C# member: `P:LibreMetaverse.AISResponseMeta.ItemsRemoved`. +pub use crate::inventory_ais::AISResponseMeta; /// C# type: `T:LibreMetaverse.AccessDeniedReason`. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] @@ -9265,11 +9227,14 @@ impl GridClient { } /// C# member: `P:LibreMetaverse.GridClient.AisClient`. pub fn ais_client(&self) -> libremetaverse::InventoryAISClient { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.AisClient") + /* native client-core implementation */ + self.native_ais_client() + .unwrap_or_else(|_| panic!("failed to construct InventoryAISClient")) } /// Setter for C# member: `P:LibreMetaverse.GridClient.AisClient`. pub fn set_ais_client(&mut self, value: libremetaverse::InventoryAISClient) { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.AisClient") + /* native client-core implementation */ + self.native_set_ais_client(value) } /// C# member: `P:LibreMetaverse.GridClient.Animesh`. pub fn animesh(&self) -> libremetaverse::animesh::AnimeshManager { @@ -11794,7 +11759,7 @@ impl InterestListManager { pub use crate::inventory::Inventory; /// C# type: `T:LibreMetaverse.InventoryAISClient`. -pub struct InventoryAISClient; +pub use crate::inventory_ais::InventoryAISClient; impl InventoryAISClient { /// C# member: `F:LibreMetaverse.InventoryAISClient.INVENTORY_CAP_NAME`. pub const INVENTORY_CAP_NAME: &'static str = "InventoryAPIv3"; @@ -11804,9 +11769,7 @@ impl InventoryAISClient { pub fn new( client: Option>, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.#ctor(LibreMetaverse.GridClient)", - ) + crate::inventory_ais::InventoryAISClient::native_new(client) } /// C# member: `M:LibreMetaverse.InventoryAISClient.CopyCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Boolean,System.Threading.CancellationToken)`. pub async fn copy_category( @@ -11816,9 +11779,8 @@ impl InventoryAISClient { simulate: Option, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.CopyCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Boolean,System.Threading.CancellationToken)", - ) + self.native_copy_category(source_uuid, dest_uuid, simulate, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.CopyCategoryChildrenAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn copy_category_children( @@ -11827,9 +11789,8 @@ impl InventoryAISClient { dest_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.CopyCategoryChildrenAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_copy_category_children(category, dest_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.CopyCategoryLinksAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn copy_category_links( @@ -11838,9 +11799,8 @@ impl InventoryAISClient { dest_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.CopyCategoryLinksAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_copy_category_links(category, dest_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.CopyItemAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn copy_item( @@ -11849,9 +11809,8 @@ impl InventoryAISClient { dest_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.CopyItemAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_copy_item(item_uuid, dest_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.CopyLibraryCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Boolean,System.Threading.CancellationToken)`. pub async fn copy_library_category( @@ -11861,9 +11820,13 @@ impl InventoryAISClient { copy_subfolders: bool, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.CopyLibraryCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Boolean,System.Threading.CancellationToken)", + self.native_copy_library_category( + source_uuid, + dest_uuid, + copy_subfolders, + cancellation_token, ) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.CreateInventoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Boolean,System.Threading.CancellationToken)`. pub async fn create_inventory( @@ -11873,9 +11836,8 @@ impl InventoryAISClient { create_link: bool, cancellation_token: Option, ) -> Result<(bool, Option), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.CreateInventoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Boolean,System.Threading.CancellationToken)", - ) + self.native_create_inventory(parent_uuid, new_inventory, create_link, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.DeleteCategoryChildrenAsync(System.String,System.Threading.CancellationToken)`. pub async fn delete_category_children( @@ -11883,9 +11845,8 @@ impl InventoryAISClient { category: String, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.DeleteCategoryChildrenAsync(System.String,System.Threading.CancellationToken)", - ) + self.native_delete_category_children(category, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.DeleteCategoryLinksAsync(System.String,System.Threading.CancellationToken)`. pub async fn delete_category_links( @@ -11893,27 +11854,22 @@ impl InventoryAISClient { category: String, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.DeleteCategoryLinksAsync(System.String,System.Threading.CancellationToken)", - ) + self.native_delete_category_links(category, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.EmptyTrashAsync(System.Threading.CancellationToken)`. pub async fn empty_trash( &self, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.EmptyTrashAsync(System.Threading.CancellationToken)", - ) + self.native_empty_trash(cancellation_token).await } /// C# member: `M:LibreMetaverse.InventoryAISClient.FetchCOFAsync(System.Threading.CancellationToken)`. pub async fn fetch_cof( &self, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.FetchCOFAsync(System.Threading.CancellationToken)", - ) + self.native_fetch_cof(cancellation_token).await } /// C# member: `M:LibreMetaverse.InventoryAISClient.FetchCategoryCategoriesAsync(LibreMetaverse.UUID,System.Boolean,System.Boolean,System.Int32,System.Threading.CancellationToken)`. pub async fn fetch_category_categories( @@ -11932,9 +11888,14 @@ impl InventoryAISClient { ), crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.FetchCategoryCategoriesAsync(LibreMetaverse.UUID,System.Boolean,System.Boolean,System.Int32,System.Threading.CancellationToken)", + self.native_fetch_category_categories( + cat_id, + use_inventory_cap, + recursive, + depth, + cancellation_token, ) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.FetchCategorySubsetAsync(LibreMetaverse.UUID,System.Collections.Generic.IEnumerable{LibreMetaverse.UUID},System.Boolean,System.Boolean,System.Int32,System.Threading.CancellationToken)`. pub async fn fetch_category_subset( @@ -11954,9 +11915,15 @@ impl InventoryAISClient { ), crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.FetchCategorySubsetAsync(LibreMetaverse.UUID,System.Collections.Generic.IEnumerable{LibreMetaverse.UUID},System.Boolean,System.Boolean,System.Int32,System.Threading.CancellationToken)", + self.native_fetch_category_subset( + cat_id, + specific_children, + use_inventory_cap, + recursive, + depth, + cancellation_token, ) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.FetchItemAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn fetch_item( @@ -11972,18 +11939,14 @@ impl InventoryAISClient { ), crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.FetchItemAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_fetch_item(item_uuid, cancellation_token).await } /// C# member: `M:LibreMetaverse.InventoryAISClient.FetchOrphansAsync(System.Threading.CancellationToken)`. pub async fn fetch_orphans( &self, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.FetchOrphansAsync(System.Threading.CancellationToken)", - ) + self.native_fetch_orphans(cancellation_token).await } /// C# member: `M:LibreMetaverse.InventoryAISClient.GetCategoryAsync(System.String,System.Threading.CancellationToken)`. pub async fn get_category( @@ -11999,9 +11962,7 @@ impl InventoryAISClient { ), crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.GetCategoryAsync(System.String,System.Threading.CancellationToken)", - ) + self.native_get_category(category, cancellation_token).await } /// C# member: `M:LibreMetaverse.InventoryAISClient.GetCategoryChildrenAsync(System.String,System.Int32,System.Boolean,System.Threading.CancellationToken)`. pub async fn get_category_children( @@ -12019,9 +11980,8 @@ impl InventoryAISClient { ), crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.GetCategoryChildrenAsync(System.String,System.Int32,System.Boolean,System.Threading.CancellationToken)", - ) + self.native_get_category_children(category, depth, recursive, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.GetCategoryLinksAsync(System.String,System.Threading.CancellationToken)`. pub async fn get_category_links( @@ -12037,9 +11997,8 @@ impl InventoryAISClient { ), crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.GetCategoryLinksAsync(System.String,System.Threading.CancellationToken)", - ) + self.native_get_category_links(category, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.MoveCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn move_category( @@ -12048,9 +12007,8 @@ impl InventoryAISClient { dest_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.MoveCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_move_category(source_uuid, dest_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.MoveCategoryChildrenAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn move_category_children( @@ -12059,9 +12017,8 @@ impl InventoryAISClient { dest_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.MoveCategoryChildrenAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_move_category_children(category, dest_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.MoveCategoryLinksAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn move_category_links( @@ -12070,9 +12027,8 @@ impl InventoryAISClient { dest_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.MoveCategoryLinksAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_move_category_links(category, dest_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.MoveItemAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn move_item( @@ -12081,17 +12037,14 @@ impl InventoryAISClient { dest_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.MoveItemAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_move_item(item_uuid, dest_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.ParseAISResponseMeta(LibreMetaverse.StructuredData.OSDMap)`. pub fn parse_ais_response_meta( response: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.ParseAISResponseMeta(LibreMetaverse.StructuredData.OSDMap)", - ) + crate::inventory_ais::InventoryAISClient::native_parse_ais_response_meta(response) } /// C# member: `M:LibreMetaverse.InventoryAISClient.ParseEmbedded(LibreMetaverse.StructuredData.OSDMap,System.Collections.Generic.List{LibreMetaverse.InventoryFolder}@,System.Collections.Generic.List{LibreMetaverse.InventoryItem}@,System.Collections.Generic.List{LibreMetaverse.InventoryItem}@)`. pub fn parse_embedded( @@ -12101,36 +12054,28 @@ impl InventoryAISClient { items: &mut Option>, links: &mut Option>, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.ParseEmbedded(LibreMetaverse.StructuredData.OSDMap,System.Collections.Generic.List{LibreMetaverse.InventoryFolder}@,System.Collections.Generic.List{LibreMetaverse.InventoryItem}@,System.Collections.Generic.List{LibreMetaverse.InventoryItem}@)", - ) + self.native_parse_embedded(response, folders, items, links) } /// C# member: `M:LibreMetaverse.InventoryAISClient.ParseFoldersFromEmbedded(LibreMetaverse.StructuredData.OSDMap)`. pub fn parse_folders_from_embedded( &self, response: libremetaverse_structured_data::OSDMap, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.ParseFoldersFromEmbedded(LibreMetaverse.StructuredData.OSDMap)", - ) + self.native_parse_folders_from_embedded(response) } /// C# member: `M:LibreMetaverse.InventoryAISClient.ParseItemsFromEmbedded(LibreMetaverse.StructuredData.OSDMap)`. pub fn parse_items_from_embedded( &self, response: Option, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.ParseItemsFromEmbedded(LibreMetaverse.StructuredData.OSDMap)", - ) + self.native_parse_items_from_embedded(response) } /// C# member: `M:LibreMetaverse.InventoryAISClient.ParseLinksFromEmbedded(LibreMetaverse.StructuredData.OSDMap)`. pub fn parse_links_from_embedded( &self, response: Option, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.ParseLinksFromEmbedded(LibreMetaverse.StructuredData.OSDMap)", - ) + self.native_parse_links_from_embedded(response) } /// C# member: `M:LibreMetaverse.InventoryAISClient.PurgeDescendentsAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn purge_descendents( @@ -12138,9 +12083,8 @@ impl InventoryAISClient { category_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.PurgeDescendentsAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_purge_descendents(category_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.PutCategoryChildrenAsync(System.String,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)`. pub async fn put_category_children( @@ -12149,9 +12093,8 @@ impl InventoryAISClient { children_payload: libremetaverse_structured_data::OSD, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.PutCategoryChildrenAsync(System.String,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)", - ) + self.native_put_category_children(category, children_payload, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.PutCategoryLinksAsync(System.String,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)`. pub async fn put_category_links( @@ -12160,9 +12103,8 @@ impl InventoryAISClient { links_payload: libremetaverse_structured_data::OSD, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.PutCategoryLinksAsync(System.String,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)", - ) + self.native_put_category_links(category, links_payload, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.RemoveCategoryAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn remove_category( @@ -12170,9 +12112,8 @@ impl InventoryAISClient { category_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.RemoveCategoryAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_remove_category(category_uuid, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.RemoveItemAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn remove_item( @@ -12180,9 +12121,7 @@ impl InventoryAISClient { item_uuid: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.RemoveItemAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_remove_item(item_uuid, cancellation_token).await } /// C# member: `M:LibreMetaverse.InventoryAISClient.SlamFolderAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)`. pub async fn slam_folder( @@ -12191,9 +12130,8 @@ impl InventoryAISClient { new_inventory: libremetaverse_structured_data::OSD, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.SlamFolderAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)", - ) + self.native_slam_folder(folder_uuid, new_inventory, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.UpdateCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)`. pub async fn update_category( @@ -12202,9 +12140,8 @@ impl InventoryAISClient { updates: libremetaverse_structured_data::OSD, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.UpdateCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)", - ) + self.native_update_category(category_uuid, updates, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.InventoryAISClient.UpdateItemAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)`. pub async fn update_item( @@ -12213,13 +12150,12 @@ impl InventoryAISClient { updates: libremetaverse_structured_data::OSD, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.UpdateItemAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)", - ) + self.native_update_item(item_uuid, updates, cancellation_token) + .await } /// C# member: `P:LibreMetaverse.InventoryAISClient.IsAvailable`. pub fn is_available(&self) -> bool { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.InventoryAISClient.IsAvailable") + self.native_is_available() } } diff --git a/crates/libremetaverse/src/inventory.rs b/crates/libremetaverse/src/inventory.rs index c912baa..2a0888f 100644 --- a/crates/libremetaverse/src/inventory.rs +++ b/crates/libremetaverse/src/inventory.rs @@ -855,6 +855,35 @@ enum InventoryValue { } impl InventoryValue { + fn from_item_typed(item: InventoryItem) -> Self { + macro_rules! typed { + ($kind:ty, $variant:ident) => {{ + let mut value = <$kind>::new(item.base.uuid) + .expect("known inventory item constructor cannot fail"); + value.base = item; + Self::$variant(value) + }}; + } + match item.inventory_type { + InventoryType::TEXTURE => typed!(InventoryTexture, Texture), + InventoryType::SOUND => typed!(InventorySound, Sound), + InventoryType::CALLING_CARD => typed!(InventoryCallingCard, CallingCard), + InventoryType::LANDMARK => typed!(InventoryLandmark, Landmark), + InventoryType::OBJECT => typed!(InventoryObject, Object), + InventoryType::NOTECARD => typed!(InventoryNotecard, Notecard), + InventoryType::CATEGORY => typed!(InventoryCategory, Category), + InventoryType::LSL => typed!(InventoryLSL, Lsl), + InventoryType::SNAPSHOT => typed!(InventorySnapshot, Snapshot), + InventoryType::ATTACHMENT => typed!(InventoryAttachment, Attachment), + InventoryType::WEARABLE => typed!(InventoryWearable, Wearable), + InventoryType::ANIMATION => typed!(InventoryAnimation, Animation), + InventoryType::GESTURE => typed!(InventoryGesture, Gesture), + InventoryType::SETTINGS => typed!(InventorySettings, Settings), + InventoryType::MATERIAL => typed!(InventoryMaterial, Material), + _ => Self::Item(item), + } + } + fn from_class(value: &dyn InventoryObjectClass) -> Option { macro_rules! downcast { ($type:ty, $variant:ident) => { @@ -1472,6 +1501,161 @@ impl Inventory { .and_then(|value| value.item().cloned()) } + /// Atomically applies one fully parsed AIS response. A detached candidate + /// graph is built first, so validation or rebuilding failures cannot expose + /// a partially reconciled inventory to readers. + #[allow(clippy::too_many_lines)] // One lock-free candidate build followed by one state swap. + pub(crate) fn native_apply_ais_batch( + &self, + folders: Vec, + items: Vec, + removed_items: &[UUID], + removed_categories: &[UUID], + broken_links: &[UUID], + versions: &HashMap, + ) -> Result<(), Error> { + let mut incoming = HashSet::new(); + for uuid in folders + .iter() + .map(|folder| folder.base.uuid) + .chain(items.iter().map(|item| item.base.uuid)) + { + if uuid == UUID::zero() || !incoming.insert(uuid) { + return Err(Error::Argument); + } + } + if incoming.len() > MAX_CACHE_RECORDS || versions.keys().any(|uuid| *uuid == UUID::zero()) { + return Err(Error::Argument); + } + let mut touched = incoming.clone(); + touched.extend(versions.keys().copied()); + + let (mut candidate, old_values) = { + let state = read(&self.inner.state); + let old_values: HashMap<_, _> = state + .items + .iter() + .filter_map(|(uuid, node)| node.value().map(|value| (*uuid, value))) + .collect(); + let mut candidate = InventoryState { + root: state.root, + library_root: state.library_root, + ..InventoryState::default() + }; + for (uuid, value) in &old_values { + candidate + .items + .insert(*uuid, InventoryNode::from_value(value.clone())); + } + (candidate, old_values) + }; + + for folder in folders { + candidate.items.insert( + folder.base.uuid, + InventoryNode::from_value(InventoryValue::Folder(folder)), + ); + } + for item in items { + let uuid = item.base.uuid; + let value = InventoryValue::from_item_typed(item); + candidate + .items + .insert(uuid, InventoryNode::from_value(value)); + } + for (uuid, version) in versions { + if let Some(node) = candidate.items.get(uuid) { + let Some(mut value) = node.value() else { + return Err(Error::InvalidOperation); + }; + let Some(folder) = value.folder_mut() else { + return Err(Error::InvalidOperation); + }; + folder.version = *version; + node.set_value(value); + } + } + + let direct_removals: HashSet<_> = removed_items + .iter() + .chain(broken_links) + .copied() + .filter(|uuid| *uuid != UUID::zero()) + .collect(); + for uuid in direct_removals { + candidate.items.remove(&uuid); + } + rebuild_indexes_and_counts(&mut candidate); + for category in removed_categories + .iter() + .copied() + .filter(|uuid| *uuid != UUID::zero()) + { + let mut pending = vec![category]; + let mut descendants = HashSet::new(); + while let Some(uuid) = pending.pop() { + if descendants.len() >= MAX_CACHE_RECORDS || !descendants.insert(uuid) { + continue; + } + if let Some(node) = candidate.items.get(&uuid) { + pending.extend(read(&node.inner).children.keys().copied()); + } + } + for uuid in descendants { + candidate.items.remove(&uuid); + } + if candidate.root == Some(category) { + candidate.root = None; + } + if candidate.library_root == Some(category) { + candidate.library_root = None; + } + } + if candidate.items.len() > MAX_CACHE_RECORDS { + return Err(Error::InvalidOperation); + } + rebuild_indexes_and_counts(&mut candidate); + candidate.root_node = candidate + .root + .and_then(|uuid| candidate.items.get(&uuid).cloned()); + candidate.library_root_node = candidate + .library_root + .and_then(|uuid| candidate.items.get(&uuid).cloned()); + + let new_values: HashMap<_, _> = candidate + .items + .iter() + .filter_map(|(uuid, node)| node.value().map(|value| (*uuid, value))) + .collect(); + *write(&self.inner.state) = candidate; + + for (uuid, old) in &old_values { + match new_values.get(uuid) { + None => self + .inner + .removed + .emit(InventoryObjectRemovedEventArgs::from_value(old.clone())), + Some(new) if touched.contains(uuid) => { + self.inner + .updated + .emit(InventoryObjectUpdatedEventArgs::from_values( + old.clone(), + new.clone(), + )); + } + Some(_) => {} + } + } + for (uuid, new) in new_values { + if !old_values.contains_key(&uuid) { + self.inner + .added + .emit(InventoryObjectAddedEventArgs::from_value(new)); + } + } + Ok(()) + } + pub fn try_get_value_with_uuid_inventory_base( &self, uuid: UUID, diff --git a/crates/libremetaverse/src/inventory_ais.rs b/crates/libremetaverse/src/inventory_ais.rs new file mode 100644 index 0000000..3a720e6 --- /dev/null +++ b/crates/libremetaverse/src/inventory_ais.rs @@ -0,0 +1,1060 @@ +//! Native Inventory API v3 capability client and cache reconciliation. + +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::too_many_arguments)] + +use crate::client_core::ClientWeakHandle; +use crate::{Error, GridClient, InventoryFolder, InventoryItem}; +use libremetaverse_structured_data::{OSD, OSDMap, OSDParser}; +use libremetaverse_types::compat::{CancellationToken, HttpResponse, Uri}; +use libremetaverse_types::{AssetType, UUID}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::sync::Arc; + +const MAX_FOLDER_DEPTH_REQUEST: i32 = 50; +const MAX_AIS_OBJECTS: usize = 100_000; +const LLSD_XML: &str = "application/llsd+xml"; + +type InventoryResult = ( + bool, + Vec, + Vec, + Vec, +); +type ParsedInventory = (Vec, Vec, Vec); +type CollectionSections<'a> = (Option<&'a HashMap>, Option<&'a OSD>); + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct AISResponseMeta { + broken_links_removed: Vec, + items_removed: Vec, + categories_removed: Vec, + category_version_updates: HashMap, +} + +impl AISResponseMeta { + pub fn new( + broken_links: Vec, + items: Vec, + categories: Vec, + versions: HashMap, + ) -> Result { + Ok(Self { + broken_links_removed: broken_links, + items_removed: items, + categories_removed: categories, + category_version_updates: versions, + }) + } + + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + #[must_use] + pub fn broken_links_removed(&self) -> Vec { + self.broken_links_removed.clone() + } + + #[must_use] + pub fn items_removed(&self) -> Vec { + self.items_removed.clone() + } + + #[must_use] + pub fn categories_removed(&self) -> Vec { + self.categories_removed.clone() + } + + #[must_use] + pub fn category_version_updates(&self) -> HashMap { + self.category_version_updates.clone() + } + + #[must_use] + pub fn has_any_data(&self) -> bool { + !self.broken_links_removed.is_empty() + || !self.items_removed.is_empty() + || !self.categories_removed.is_empty() + || !self.category_version_updates.is_empty() + } +} + +#[derive(Clone)] +pub struct InventoryAISClient { + client: ClientWeakHandle, +} + +impl InventoryAISClient { + pub(crate) fn native_new(client: Option>) -> Result { + let client = client.ok_or(Error::ArgumentNull)?; + Ok(Self { + client: client.native_weak_handle(), + }) + } + + fn client(&self) -> Result { + self.client.upgrade().ok_or(Error::InvalidOperation) + } + + fn capability(&self, name: &str) -> Result, Error> { + let client = self.client()?; + let Some(simulator) = client.native_network()?.current_sim() else { + return Ok(None); + }; + let Some(caps) = simulator.native_caps() else { + return Ok(None); + }; + caps.capability_uri(name.to_owned()) + } + + pub(crate) fn native_is_available(&self) -> bool { + self.capability(Self::INVENTORY_CAP_NAME) + .ok() + .flatten() + .is_some() + } + + fn endpoint(capability: &Uri, path: &str) -> Uri { + Uri(format!("{}/{}", capability.0.trim_end_matches('/'), path)) + } + + fn valid_category(category: &str) -> bool { + !category.is_empty() + && category + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + } + + fn token(token: Option) -> CancellationToken { + token.unwrap_or_default() + } + + fn status_ok(response: &HttpResponse) -> bool { + response.is_success_status_code() || response.status_code == 304 + } + + async fn request( + &self, + cap_name: &str, + method: &str, + path: String, + headers: BTreeMap, + payload: Option, + cancellation_token: Option, + ) -> Result)>, Error> { + let token = Self::token(cancellation_token); + token.throw_if_cancellation_requested()?; + let Some(capability) = self.capability(cap_name)? else { + return Ok(None); + }; + let bytes = match payload { + Some(payload) => OSDParser::serialize_llsd_xml_bytes(payload)?, + None => Vec::new(), + }; + let content_type = (!bytes.is_empty()).then(|| LLSD_XML.to_owned()); + self.client()? + .native_http_caps_client() + .send_custom( + method, + Self::endpoint(&capability, &path), + headers, + content_type, + bytes, + token, + ) + .await + .map(Some) + } + + fn decode_map(bytes: Vec) -> Result>, Error> { + if bytes.is_empty() { + return Ok(None); + } + match OSDParser::deserialize_with_bytes(bytes)? { + OSD::Map(map) => Ok(Some(map)), + _ => Err(Error::Argument), + } + } + + fn parse_all_map(&self, map: &HashMap) -> Result { + let response = OSD::Map(map.clone()); + let folders = self.parse_folders_osd(&response)?; + let items = self.parse_items_osd(&response)?; + let links = self.parse_links_osd(&response)?; + if folders.len() + items.len() + links.len() > MAX_AIS_OBJECTS { + return Err(Error::InvalidOperation); + } + Ok((folders, items, links)) + } + + fn reconcile( + &self, + map: &HashMap, + folders: &[InventoryFolder], + items: &[InventoryItem], + links: &[InventoryItem], + ) -> Result<(), Error> { + let meta = Self::parse_meta_map(map)?; + if folders.is_empty() && items.is_empty() && links.is_empty() && !meta.has_any_data() { + return Ok(()); + } + self.client()?.native_inventory()?.native_reconcile_ais( + folders.to_vec(), + items.iter().chain(links).cloned().collect(), + &meta, + ) + } + + async fn mutation( + &self, + cap_name: &str, + method: &str, + path: String, + headers: BTreeMap, + payload: Option, + gone_is_success: bool, + cancellation_token: Option, + ) -> Result< + ( + bool, + Vec, + Vec, + Vec, + ), + Error, + > { + let Some((response, bytes)) = self + .request(cap_name, method, path, headers, payload, cancellation_token) + .await? + else { + return Ok((false, Vec::new(), Vec::new(), Vec::new())); + }; + let success = + Self::status_ok(&response) || (gone_is_success && response.status_code == 410); + if !success { + return Ok((false, Vec::new(), Vec::new(), Vec::new())); + } + let Some(map) = Self::decode_map(bytes)? else { + return Ok((true, Vec::new(), Vec::new(), Vec::new())); + }; + let (folders, items, links) = self.parse_all_map(&map)?; + self.reconcile(&map, &folders, &items, &links)?; + Ok((true, folders, items, links)) + } + + async fn mutation_bool( + &self, + cap_name: &str, + method: &str, + path: String, + headers: BTreeMap, + payload: Option, + gone_is_success: bool, + cancellation_token: Option, + ) -> Result { + match self + .mutation( + cap_name, + method, + path, + headers, + payload, + gone_is_success, + cancellation_token, + ) + .await + { + Ok((success, _, _, _)) => Ok(success), + Err(Error::Cancelled) => Err(Error::Cancelled), + Err(_error) => { + #[cfg(test)] + eprintln!("AIS mutation failed: {_error:?}"); + Ok(false) + } + } + } + + async fn get_parsed( + &self, + cap_name: &str, + path: String, + cancellation_token: Option, + ) -> Result { + let result = async { + let Some((response, bytes)) = self + .request( + cap_name, + "GET", + path, + BTreeMap::new(), + None, + cancellation_token, + ) + .await? + else { + return Ok((false, Vec::new(), Vec::new(), Vec::new())); + }; + if !Self::status_ok(&response) { + return Ok((false, Vec::new(), Vec::new(), Vec::new())); + } + let Some(map) = Self::decode_map(bytes)? else { + return Ok((true, Vec::new(), Vec::new(), Vec::new())); + }; + let parsed = self.parse_all_map(&map)?; + self.reconcile(&map, &parsed.0, &parsed.1, &parsed.2)?; + Ok((true, parsed.0, parsed.1, parsed.2)) + } + .await; + match result { + Err(Error::Cancelled) => Err(Error::Cancelled), + Err(_) => Ok((false, Vec::new(), Vec::new(), Vec::new())), + value => value, + } + } + + fn tid() -> Result { + UUID::random() + } + + fn destination(uuid: UUID) -> BTreeMap { + BTreeMap::from([("Destination".to_owned(), uuid.to_string())]) + } + + pub(crate) async fn native_create_inventory( + &self, + parent_uuid: UUID, + new_inventory: OSD, + create_link: bool, + cancellation_token: Option, + ) -> Result<(bool, Option), Error> { + let path = format!("category/{parent_uuid}?tid={}", Self::tid()?); + match self + .mutation( + Self::INVENTORY_CAP_NAME, + "POST", + path, + BTreeMap::new(), + Some(new_inventory), + false, + cancellation_token, + ) + .await + { + Ok((success, _, items, links)) => Ok(( + success, + if create_link { links } else { items }.into_iter().next(), + )), + Err(Error::Cancelled) => Err(Error::Cancelled), + Err(_) => Ok((false, None)), + } + } + + #[allow(dead_code)] // Internal mapped helper used by InventoryManager batch links. + pub(crate) async fn native_create_inventory_links( + &self, + parent_uuid: UUID, + new_inventory: OSD, + cancellation_token: Option, + ) -> Result, Error> { + let path = format!("category/{parent_uuid}?tid={}", Self::tid()?); + let (_, _, _, links) = self + .mutation( + Self::INVENTORY_CAP_NAME, + "POST", + path, + BTreeMap::new(), + Some(new_inventory), + false, + cancellation_token, + ) + .await?; + Ok(links) + } + + pub(crate) async fn native_slam_folder( + &self, + folder: UUID, + payload: OSD, + token: Option, + ) -> Result { + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "PUT", + format!("category/{folder}/links?tid={}", Self::tid()?), + BTreeMap::new(), + Some(payload), + false, + token, + ) + .await + } + pub(crate) async fn native_remove_category( + &self, + id: UUID, + token: Option, + ) -> Result { + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "DELETE", + format!("category/{id}"), + BTreeMap::new(), + None, + true, + token, + ) + .await + } + pub(crate) async fn native_remove_item( + &self, + id: UUID, + token: Option, + ) -> Result { + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "DELETE", + format!("item/{id}"), + BTreeMap::new(), + None, + true, + token, + ) + .await + } + pub(crate) async fn native_copy_library_category( + &self, + source: UUID, + destination: UUID, + subfolders: bool, + token: Option, + ) -> Result { + let suffix = if subfolders { + String::new() + } else { + ",depth=0".to_owned() + }; + self.mutation_bool( + Self::LIBRARY_CAP_NAME, + "COPY", + format!("category/{source}?tid={}{}", Self::tid()?, suffix), + Self::destination(destination), + None, + false, + token, + ) + .await + } + pub(crate) async fn native_purge_descendents( + &self, + id: UUID, + token: Option, + ) -> Result { + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "DELETE", + format!("category/{id}/children"), + BTreeMap::new(), + None, + false, + token, + ) + .await + } + pub(crate) async fn native_update_category( + &self, + id: UUID, + payload: OSD, + token: Option, + ) -> Result { + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "PATCH", + format!("category/{id}"), + BTreeMap::new(), + Some(payload), + false, + token, + ) + .await + } + pub(crate) async fn native_update_item( + &self, + id: UUID, + payload: OSD, + token: Option, + ) -> Result { + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "PATCH", + format!("item/{id}"), + BTreeMap::new(), + Some(payload), + false, + token, + ) + .await + } + pub(crate) async fn native_copy_category( + &self, + source: UUID, + destination: UUID, + simulate: Option, + token: Option, + ) -> Result { + let simulate = if simulate.unwrap_or(false) { + "&simulate=1" + } else { + "" + }; + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "COPY", + format!("category/{source}?tid={}{}", Self::tid()?, simulate), + Self::destination(destination), + None, + false, + token, + ) + .await + } + pub(crate) async fn native_move_category( + &self, + source: UUID, + destination: UUID, + token: Option, + ) -> Result { + self.native_update_category( + source, + OSD::Map(HashMap::from([( + "parent_id".into(), + OSD::UUID(destination), + )])), + token, + ) + .await + } + pub(crate) async fn native_put_category_children( + &self, + category: String, + payload: OSD, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok(false); + } + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "PUT", + format!("category/{category}/children?tid={}", Self::tid()?), + BTreeMap::new(), + Some(payload), + false, + token, + ) + .await + } + pub(crate) async fn native_copy_category_children( + &self, + category: String, + destination: UUID, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok(false); + } + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "COPY", + format!("category/{category}/children?tid={}", Self::tid()?), + Self::destination(destination), + None, + false, + token, + ) + .await + } + #[allow(clippy::unused_async)] // Fixed Task API must return an immediately ready future. + pub(crate) async fn native_move_category_children( + &self, + _category: String, + _destination: UUID, + _token: Option, + ) -> Result { + Ok(false) + } + pub(crate) async fn native_delete_category_children( + &self, + category: String, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok(false); + } + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "DELETE", + format!("category/{category}/children"), + BTreeMap::new(), + None, + false, + token, + ) + .await + } + pub(crate) async fn native_put_category_links( + &self, + category: String, + payload: OSD, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok(false); + } + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "PUT", + format!("category/{category}/links?tid={}", Self::tid()?), + BTreeMap::new(), + Some(payload), + false, + token, + ) + .await + } + pub(crate) async fn native_copy_category_links( + &self, + category: String, + destination: UUID, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok(false); + } + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "COPY", + format!("category/{category}/links?tid={}", Self::tid()?), + Self::destination(destination), + None, + false, + token, + ) + .await + } + #[allow(clippy::unused_async)] // Fixed Task API must return an immediately ready future. + pub(crate) async fn native_move_category_links( + &self, + _category: String, + _destination: UUID, + _token: Option, + ) -> Result { + Ok(false) + } + pub(crate) async fn native_delete_category_links( + &self, + category: String, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok(false); + } + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "DELETE", + format!("category/{category}/links"), + BTreeMap::new(), + None, + false, + token, + ) + .await + } + pub(crate) async fn native_copy_item( + &self, + item: UUID, + destination: UUID, + token: Option, + ) -> Result { + self.mutation_bool( + Self::INVENTORY_CAP_NAME, + "COPY", + format!("item/{item}?tid={}", Self::tid()?), + Self::destination(destination), + None, + false, + token, + ) + .await + } + pub(crate) async fn native_move_item( + &self, + item: UUID, + destination: UUID, + token: Option, + ) -> Result { + self.native_update_item( + item, + OSD::Map(HashMap::from([( + "parent_id".into(), + OSD::UUID(destination), + )])), + token, + ) + .await + } + + pub(crate) async fn native_get_category( + &self, + category: String, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok((false, vec![], vec![], vec![])); + } + self.get_parsed( + Self::INVENTORY_CAP_NAME, + format!("category/{category}"), + token, + ) + .await + } + pub(crate) async fn native_get_category_children( + &self, + category: String, + depth: i32, + recursive: bool, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok((false, vec![], vec![], vec![])); + } + let depth = if recursive { + MAX_FOLDER_DEPTH_REQUEST + } else { + depth.min(MAX_FOLDER_DEPTH_REQUEST) + }; + self.get_parsed( + Self::INVENTORY_CAP_NAME, + format!("category/{category}/children?depth={depth}"), + token, + ) + .await + } + pub(crate) async fn native_fetch_category_categories( + &self, + id: UUID, + inventory: bool, + recursive: bool, + depth: Option, + token: Option, + ) -> Result { + let depth = if recursive { + MAX_FOLDER_DEPTH_REQUEST + } else { + depth.unwrap_or(0).min(MAX_FOLDER_DEPTH_REQUEST) + }; + let cap = if inventory { + Self::INVENTORY_CAP_NAME + } else { + Self::LIBRARY_CAP_NAME + }; + self.get_parsed( + cap, + format!("category/{id}/categories?depth={depth}"), + token, + ) + .await + } + pub(crate) async fn native_fetch_category_subset( + &self, + id: UUID, + children: Box>, + inventory: bool, + recursive: bool, + depth: Option, + token: Option, + ) -> Result { + let children: Vec<_> = children.take(MAX_AIS_OBJECTS).collect(); + if children.is_empty() { + return Ok((false, vec![], vec![], vec![])); + } + let depth = if recursive { + MAX_FOLDER_DEPTH_REQUEST + } else { + depth.unwrap_or(0).min(MAX_FOLDER_DEPTH_REQUEST) + }; + let list = children + .iter() + .map(ToString::to_string) + .collect::>() + .join(","); + let cap = if inventory { + Self::INVENTORY_CAP_NAME + } else { + Self::LIBRARY_CAP_NAME + }; + self.get_parsed( + cap, + format!("category/{id}/children?depth={depth}&children={list}"), + token, + ) + .await + } + pub(crate) async fn native_get_category_links( + &self, + category: String, + token: Option, + ) -> Result { + if !Self::valid_category(&category) { + return Ok((false, vec![], vec![], vec![])); + } + self.get_parsed( + Self::INVENTORY_CAP_NAME, + format!("category/{category}/links"), + token, + ) + .await + } + pub(crate) async fn native_fetch_item( + &self, + id: UUID, + token: Option, + ) -> Result { + let result = self + .get_parsed( + Self::INVENTORY_CAP_NAME, + format!("item/{id}"), + token.clone(), + ) + .await?; + if result.0 && result.1.is_empty() && result.2.is_empty() && result.3.is_empty() { + // `get_parsed` handles embedded shapes; retrying is intentionally avoided. + } + Ok(result) + } + pub(crate) async fn native_fetch_cof( + &self, + token: Option, + ) -> Result { + self.native_get_category_links("current".into(), token) + .await + .map(|v| v.0) + } + pub(crate) async fn native_fetch_orphans( + &self, + token: Option, + ) -> Result { + self.get_parsed(Self::INVENTORY_CAP_NAME, "orphans".into(), token) + .await + .map(|v| v.0) + } + pub(crate) async fn native_empty_trash( + &self, + token: Option, + ) -> Result { + self.native_delete_category_children("trash".into(), token) + .await + } + + pub(crate) fn native_parse_items_from_embedded( + &self, + response: Option, + ) -> Result, Error> { + response.map_or(Ok(Vec::new()), |response| self.parse_items_osd(&response)) + } + pub(crate) fn native_parse_folders_from_embedded( + &self, + response: OSDMap, + ) -> Result, Error> { + self.parse_folders_osd(&OSD::Map(response.snapshot())) + } + pub(crate) fn native_parse_links_from_embedded( + &self, + response: Option, + ) -> Result, Error> { + response.map_or(Ok(Vec::new()), |response| self.parse_links_osd(&response)) + } + pub(crate) fn native_parse_embedded( + &self, + response: OSD, + folders: &mut Option>, + items: &mut Option>, + links: &mut Option>, + ) -> Result<(), Error> { + let parsed = match response { + OSD::Map(map) => self.parse_all_map(&map)?, + _ => return Err(Error::Argument), + }; + *folders = Some(parsed.0); + *items = Some(parsed.1); + *links = Some(parsed.2); + Ok(()) + } + + fn collection_map<'a>( + response: &'a OSD, + plural: &str, + singular: &str, + ) -> Option> { + let OSD::Map(root) = response else { + return None; + }; + let container = match root.get("_embedded") { + Some(OSD::Map(map)) => map, + _ => root, + }; + let collection = match container.get(plural) { + Some(OSD::Map(map)) => Some(map), + _ => None, + }; + Some((collection, container.get(singular))) + } + + #[allow(clippy::unused_self)] // Parser belongs to the fixed instance API. + fn parse_items_osd(&self, response: &OSD) -> Result, Error> { + let Some((collection, singular)) = Self::collection_map(response, "items", "item") else { + return Ok(Vec::new()); + }; + let mut result = Vec::new(); + if let Some(collection) = collection { + for value in collection.values() { + if let OSD::Map(map) = value { + result.push(InventoryItem::from_osd(OSD::Map(map.clone()))?); + } + } + } + if let Some(OSD::Map(map)) = singular + && map.contains_key("item_id") + { + result.push(InventoryItem::from_osd(OSD::Map(map.clone()))?); + } + // GET /item/{id} returns the item itself at the top level. + if let OSD::Map(map) = response + && map.contains_key("item_id") + { + result.push(InventoryItem::from_osd(response.clone())?); + } + Self::dedup_items(result) + } + + #[allow(clippy::unused_self)] // Parser belongs to the fixed instance API. + fn parse_folders_osd(&self, response: &OSD) -> Result, Error> { + let Some((collection, singular)) = Self::collection_map(response, "categories", "category") + else { + return Ok(Vec::new()); + }; + let mut result = Vec::new(); + if let Some(collection) = collection { + for value in collection.values() { + if let OSD::Map(map) = value { + result.push(InventoryFolder::from_osd(OSD::Map(map.clone()))?); + } + } + } + if let Some(OSD::Map(map)) = singular + && map.contains_key("category_id") + { + result.push(InventoryFolder::from_osd(OSD::Map(map.clone()))?); + } + Self::dedup_folders(result) + } + + #[allow(clippy::unused_self)] // Parser belongs to the fixed instance API. + fn parse_links_osd(&self, response: &OSD) -> Result, Error> { + let Some((collection, _)) = Self::collection_map(response, "links", "link") else { + return Ok(Vec::new()); + }; + let mut result = Vec::new(); + if let Some(collection) = collection { + for value in collection.values() { + let OSD::Map(map) = value else { + return Err(Error::Argument); + }; + let mut item = InventoryItem::from_osd(OSD::Map(map.clone()))?; + item.set_asset_type(AssetType::Link); + if let Some(agent) = map.get("agent_id") { + let id = agent.as_uuid()?; + item.base.set_owner_id(id); + item.set_creator_id(id); + item.set_last_owner_id(id); + } + result.push(item); + } + } + Self::dedup_items(result) + } + + fn dedup_items(items: Vec) -> Result, Error> { + let mut ids = HashSet::new(); + if items + .iter() + .any(|item| item.base.uuid() == UUID::zero() || !ids.insert(item.base.uuid())) + { + return Err(Error::Argument); + } + Ok(items) + } + fn dedup_folders(folders: Vec) -> Result, Error> { + let mut ids = HashSet::new(); + if folders + .iter() + .any(|folder| folder.base.uuid() == UUID::zero() || !ids.insert(folder.base.uuid())) + { + return Err(Error::Argument); + } + Ok(folders) + } + + pub(crate) fn native_parse_ais_response_meta( + response: Option, + ) -> Result { + response.map_or_else( + || Ok(AISResponseMeta::empty()), + |map| Self::parse_meta_map(&map.snapshot()), + ) + } + fn parse_meta_map(map: &HashMap) -> Result { + let broken = Self::uuid_list(map.get("_broken_links_removed"))?; + let mut removed = Self::uuid_list(map.get("_removed_items"))?; + removed.extend(Self::uuid_list(map.get("_category_items_removed"))?); + let categories = Self::uuid_list(map.get("_categories_removed"))?; + let mut versions = HashMap::new(); + if let Some(OSD::Map(values)) = map.get("_updated_category_versions") { + for (key, value) in values { + if let Ok(uuid) = UUID::parse(key.clone()) + && uuid != UUID::zero() + { + versions.insert(uuid, value.as_integer()?); + } + } + } + AISResponseMeta::new(broken, removed, categories, versions) + } + fn uuid_list(value: Option<&OSD>) -> Result, Error> { + let mut values = Vec::new(); + match value { + None => {} + Some(OSD::Array(array)) => { + for value in array { + let uuid = value.as_uuid()?; + if uuid != UUID::zero() { + values.push(uuid); + } + } + } + Some(OSD::Map(map)) => { + for key in map.keys() { + if let Ok(uuid) = UUID::parse(key.clone()) + && uuid != UUID::zero() + { + values.push(uuid); + } + } + } + Some(_) => return Err(Error::Argument), + } + Ok(values) + } +} diff --git a/crates/libremetaverse/src/inventory_ais_internal_semantics.rs b/crates/libremetaverse/src/inventory_ais_internal_semantics.rs new file mode 100644 index 0000000..7782667 --- /dev/null +++ b/crates/libremetaverse/src/inventory_ais_internal_semantics.rs @@ -0,0 +1,215 @@ +use super::*; +use libremetaverse_structured_data::{OSD, OSDParser}; +use libremetaverse_types::compat::{ + CancellationTokenSource, HttpMessageHandler, HttpRequest, HttpResponse, Uri, +}; +use libremetaverse_types::{InventoryType, UUID}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex}; + +const CAP: &str = "http://inventory.test/ais"; +const LIBRARY_CAP: &str = "http://library.test/ais"; +const SEED: &str = "http://inventory.test/seed"; + +fn block_on(future: F) -> F::Output { + use std::task::{Context, Poll, Wake, Waker}; + struct ThreadWake(std::thread::Thread); + impl Wake for ThreadWake { + fn wake(self: Arc) { + self.0.unpark(); + } + } + let waker = Waker::from(Arc::new(ThreadWake(std::thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::park(), + } + } +} + +fn client_with_response(body: Vec) -> (GridClient, Arc>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&requests); + let seed = format!( + "InventoryAPIv3{CAP}LibraryAPIv3{LIBRARY_CAP}" + ); + let handler = HttpMessageHandler::new(move |request, _| { + let response = if request.uri.0 == SEED { + HttpResponse { + status_code: 200, + headers: BTreeMap::new(), + content_type: Some("application/llsd+xml".into()), + body: seed.as_bytes().to_vec(), + } + } else { + recorded.lock().unwrap().push(request); + HttpResponse { + status_code: 200, + headers: BTreeMap::new(), + content_type: Some("application/llsd+xml".into()), + body: body.clone(), + } + }; + async move { response } + }); + let mut client = GridClient::new().unwrap(); + client.set_http_caps_client(HttpCapsClient::new(handler.clone()).unwrap()); + let mut simulator_client = GridClient::new().unwrap(); + simulator_client.set_http_caps_client(HttpCapsClient::new(handler).unwrap()); + let simulator = Simulator::new( + simulator_client, + "127.0.0.1:0".parse().unwrap(), + 0, + None, + None, + ) + .unwrap(); + simulator + .set_seed_caps(Some(Uri(SEED.into())), Some(true)) + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while simulator + .native_caps() + .and_then(|caps| caps.capability_uri("InventoryAPIv3".into()).ok().flatten()) + .is_none() + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let mut network = client.network(); + network.set_current_sim(Some(simulator)); + client.set_network(network); + (client, requests) +} + +#[test] +fn records_exact_ais_verbs_paths_headers_and_llsd_payloads() { + let (client, requests) = client_with_response(Vec::new()); + let ais = client.ais_client(); + assert!(ais.is_available(), "seed capability should be visible"); + let item = UUID::random().unwrap(); + let destination = UUID::random().unwrap(); + let payload = OSD::Map(HashMap::from([( + "name".into(), + OSD::String("renamed".into()), + )])); + + assert!(block_on(ais.update_item(item, payload.clone(), None)).unwrap()); + assert!(block_on(ais.copy_item(item, destination, None)).unwrap()); + assert!( + block_on(ais.get_category_children("current".into(), 0, true, None)) + .unwrap() + .0 + ); + + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].method, "PATCH"); + assert_eq!(requests[0].uri.0, format!("{CAP}/item/{item}")); + assert_eq!( + requests[0].content_type.as_deref(), + Some("application/llsd+xml") + ); + assert_eq!( + OSDParser::deserialize_with_bytes(requests[0].body.clone()).unwrap(), + payload + ); + assert_eq!(requests[1].method, "COPY"); + assert!( + requests[1] + .uri + .0 + .starts_with(&format!("{CAP}/item/{item}?tid=")) + ); + assert_eq!( + requests[1].headers.get("Destination"), + Some(&destination.to_string()) + ); + assert!(requests[1].body.is_empty()); + assert_eq!(requests[2].method, "GET"); + assert_eq!( + requests[2].uri.0, + format!("{CAP}/category/current/children?depth=50") + ); +} + +#[test] +fn malformed_and_cancelled_responses_leave_store_unchanged() { + let (client, requests) = client_with_response(b"not llsd".to_vec()); + let owner = UUID::random().unwrap(); + let item_id = UUID::random().unwrap(); + let store = Inventory::new_with_grid_client_uuid(Arc::new(client.clone()), owner).unwrap(); + let item = InventoryItem::new_with_inventory_type_uuid(InventoryType::OBJECT, item_id).unwrap(); + store.update_node_for(&item).unwrap(); + client.inventory().native_set_store_for_tests(store.clone()); + + let result = block_on(client.ais_client().fetch_item(item_id, None)).unwrap(); + assert!(!result.0); + assert!(store.contains_with_uuid(item_id).unwrap()); + assert_eq!(store.count(), 1); + + let cancellation = CancellationTokenSource::new(); + cancellation.cancel(); + let result = block_on( + client + .ais_client() + .fetch_item(item_id, Some(cancellation.token())), + ); + assert!(matches!(result, Err(Error::Cancelled))); + assert_eq!(requests.lock().unwrap().len(), 1); + assert_eq!(store.count(), 1); +} + +#[test] +fn partially_valid_response_is_rejected_before_any_reconciliation() { + let existing = UUID::random().unwrap(); + let new_folder = UUID::random().unwrap(); + let malformed_item = UUID::random().unwrap(); + let zero = UUID::zero(); + let body = format!( + "_removed_items{existing}_embeddedcategories{new_folder}category_id{new_folder}parent_id{zero}namevalid folderitems{malformed_item}item_idnot-a-uuid" + ); + let (client, _) = client_with_response(body.into_bytes()); + let store = + Inventory::new_with_grid_client_uuid(Arc::new(client.clone()), UUID::random().unwrap()) + .unwrap(); + let item = + InventoryItem::new_with_inventory_type_uuid(InventoryType::OBJECT, existing).unwrap(); + store.update_node_for(&item).unwrap(); + client.inventory().native_set_store_for_tests(store.clone()); + + let result = block_on(client.ais_client().get_category("current".into(), None)).unwrap(); + assert!(!result.0); + assert!(store.contains_with_uuid(existing).unwrap()); + assert!(!store.contains_with_uuid(new_folder).unwrap()); + assert_eq!(store.count(), 1); +} + +#[test] +fn response_metadata_reconciles_removals_and_folder_versions_together() { + let folder_id = UUID::random().unwrap(); + let item_id = UUID::random().unwrap(); + let body = format!( + "_removed_items{item_id}_updated_category_versions{folder_id}17" + ); + let (client, _) = client_with_response(body.into_bytes()); + let owner = UUID::random().unwrap(); + let store = Inventory::new_with_grid_client_uuid(Arc::new(client.clone()), owner).unwrap(); + let folder = InventoryFolder::new(folder_id).unwrap(); + let mut item = + InventoryItem::new_with_inventory_type_uuid(InventoryType::OBJECT, item_id).unwrap(); + item.base.set_parent_uuid(folder_id); + store.update_node_for(&folder).unwrap(); + store.update_node_for(&item).unwrap(); + client.inventory().native_set_store_for_tests(store.clone()); + + assert!(block_on(client.ais_client().remove_item(item_id, None)).unwrap()); + assert!(!store.contains_with_uuid(item_id).unwrap()); + let updated: Option = store + .get_value_or_default_with_uuid_a7c63fbe(folder_id) + .unwrap(); + assert_eq!(updated.unwrap().version(), 17); +} diff --git a/crates/libremetaverse/src/inventory_manager.rs b/crates/libremetaverse/src/inventory_manager.rs index 499048a..230cf94 100644 --- a/crates/libremetaverse/src/inventory_manager.rs +++ b/crates/libremetaverse/src/inventory_manager.rs @@ -1215,6 +1215,30 @@ impl InventoryManager { read(&self.inner.store).clone() } + pub(crate) fn native_reconcile_ais( + &self, + folders: Vec, + items: Vec, + meta: &crate::inventory_ais::AISResponseMeta, + ) -> Result<(), Error> { + let Some(store) = self.store_clone() else { + return Ok(()); + }; + store.native_apply_ais_batch( + folders, + items, + &meta.items_removed(), + &meta.categories_removed(), + &meta.broken_links_removed(), + &meta.category_version_updates(), + ) + } + + #[cfg(test)] + pub(crate) fn native_set_store_for_tests(&self, store: Inventory) { + *write(&self.inner.store) = Some(store); + } + fn cache_server_item(&self, item: &InventoryItem) -> Result<(), Error> { if item.base.uuid() == UUID::zero() || item.inventory_type() == InventoryType::FOLDER { return Err(Error::Argument); diff --git a/crates/libremetaverse/src/lib.rs b/crates/libremetaverse/src/lib.rs index 5d45fe5..c19b7be 100644 --- a/crates/libremetaverse/src/lib.rs +++ b/crates/libremetaverse/src/lib.rs @@ -15,6 +15,7 @@ mod download_manager; mod event_queue; mod gesture; mod inventory; +mod inventory_ais; mod inventory_manager; #[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator. mod foliage_catalog; @@ -47,6 +48,8 @@ mod avatar_animesh_semantics; #[cfg(test)] mod batch_link_internal_semantics; #[cfg(test)] +mod inventory_ais_internal_semantics; +#[cfg(test)] mod world_internal_semantics; type Terrain = Vec>; @@ -146,17 +149,16 @@ impl appearance::CurrentOutfitFolder { } } -#[allow(clippy::unused_async, dead_code)] +#[allow(dead_code)] // Internal C# surface consumed by InventoryManager workflows. impl InventoryAISClient { async fn create_inventory_links( &self, - _parent_uuid: libremetaverse_types::UUID, - _new_inventory: libremetaverse_structured_data::OSD, - _cancellation_token: Option, + parent_uuid: libremetaverse_types::UUID, + new_inventory: libremetaverse_structured_data::OSD, + cancellation_token: Option, ) -> Result, Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.InventoryAISClient.CreateInventoryLinksAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)", - ) + self.native_create_inventory_links(parent_uuid, new_inventory, cancellation_token) + .await } } diff --git a/docs/inventory.md b/docs/inventory.md index 718d87d..161cdda 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -69,3 +69,27 @@ snapshot subscribers before invoking them. Focused coverage lives in `inventory_manager_semantics`, `task_inventory_semantics`, and the native `inventory_manager::tests` fake-packet cases for callback isolation, bulk-copy completion, stale folder versions, offers, capability events, and bounded Xfer. + +## Inventory API v3 + +`InventoryAISClient` implements the `InventoryAPIv3` and `LibraryAPIv3` +capability resources for categories, children, links, individual items, the +current outfit folder, orphans, and trash. Requests preserve the AIS verbs and +resource shapes, including `COPY` with its `Destination` header, transaction +IDs, depth/subset queries, and LLSD XML request content type. The client uses +the injected capability HTTP transport, so tests and applications can record +requests deterministically without replacing inventory logic. + +Successful response bodies are fully decoded and validated before any local +state changes. Embedded folders, concrete items, and links are parsed together; +object or mesh links with the legacy texture inventory type are corrected to +attachments. Side-effect removals and category version updates are combined +with returned objects and installed atomically through a detached candidate +inventory graph. Malformed, oversized, cancelled, or timed-out responses leave +the live store unchanged, and no store lock crosses an await or callback. + +AIS remains capability-optional. Missing capabilities and HTTP failures return +the fixed false/empty compatibility results, while cancellation is propagated. +The existing `InventoryManager` UDP fallback remains the contract-correct path +for operations whose capability is absent; AIS reconciliation shares that +manager's transport-independent store and observer ordering. diff --git a/tools/check_milestone_09_issue_63.py b/tools/check_milestone_09_issue_63.py new file mode 100644 index 0000000..45491f5 --- /dev/null +++ b/tools/check_milestone_09_issue_63.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Audit issue 63's native AISv3 capability and reconciliation boundary.""" + +from __future__ import annotations + +from pathlib import Path +import re + +import generate_api_shims + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "crates" / "libremetaverse" / "src" / "inventory_ais.rs" +HTTP = ROOT / "crates" / "libremetaverse" / "src" / "caps_http.rs" +INVENTORY = ROOT / "crates" / "libremetaverse" / "src" / "inventory.rs" +NATIVE_TESTS = ROOT / "crates" / "libremetaverse" / "src" / "inventory_ais_internal_semantics.rs" +COMPAT_TESTS = ROOT / "tests" / "compat" / "tests" / "inventory_ais_semantics.rs" +DOC = ROOT / "docs" / "inventory.md" +MAPPING = ROOT / "api" / "RUST-MAPPING.tsv" +STUB_RE = re.compile(r"\b(?:not_implemented|unimplemented_api)\b|\b(?:todo|unimplemented)!\s*\(") + + +def require_markers(path: Path, markers: tuple[str, ...]) -> None: + text = path.read_text() + missing = [marker for marker in markers if marker not in text] + if missing: + raise SystemExit(f"{path.name}: audit evidence missing: " + ", ".join(missing)) + + +def main() -> None: + native_types = generate_api_shims.NATIVE_TYPES | generate_api_shims.NATIVE_DECLARATIONS + for type_id in ("T:LibreMetaverse.AISResponseMeta", "T:LibreMetaverse.InventoryAISClient"): + if type_id not in native_types: + raise SystemExit(f"issue 63 native type missing: {type_id}") + + mapped = generate_api_shims.NATIVE_MEMBER_BODIES + required = set() + for row in MAPPING.read_text().splitlines()[1:]: + doc_id = row.split("\t", 1)[0] + if doc_id.startswith(("M:LibreMetaverse.InventoryAISClient.", "P:LibreMetaverse.InventoryAISClient.")): + required.add(doc_id) + missing = sorted(required - mapped.keys()) + if missing: + raise SystemExit("issue 63 native members missing: " + ", ".join(missing)) + for member in ( + "P:LibreMetaverse.GridClient.AisClient", + "P:LibreMetaverse.GridClient.AisClient#set", + ): + if member not in mapped: + raise SystemExit(f"issue 63 GridClient member missing: {member}") + if STUB_RE.search(SOURCE.read_text()): + raise SystemExit("issue 63 owned Rust stubs remain in inventory_ais.rs") + + require_markers( + SOURCE, + ( + '"COPY"', + '"Destination"', + '"application/llsd+xml"', + "MAX_FOLDER_DEPTH_REQUEST", + "MAX_AIS_OBJECTS", + "Error::Cancelled", + "parse_meta_map", + "native_reconcile_ais", + ), + ) + require_markers(HTTP, ("send_custom", "send_with_headers")) + require_markers(INVENTORY, ("native_apply_ais_batch", "detached candidate")) + require_markers( + NATIVE_TESTS, + ( + "records_exact_ais_verbs_paths_headers_and_llsd_payloads", + "malformed_and_cancelled_responses_leave_store_unchanged", + "partially_valid_response_is_rejected_before_any_reconciliation", + "response_metadata_reconciles_removals_and_folder_versions_together", + ), + ) + require_markers( + COMPAT_TESTS, + ( + "parse_links_object_asset_results_in_attachment_type_and_parses_fields", + "parse_embedded_combines_items_and_links", + "move_category_links_returns_completed_future", + ), + ) + require_markers( + DOC, + ( + "Inventory API v3", + "InventoryAPIv3", + "LibraryAPIv3", + "Destination", + "atomically", + "UDP fallback", + ), + ) + print( + "issue 63 audit: AIS endpoints, LLSD parsing, COPY headers, bounded atomic " + "reconciliation, documentation, and focused tests are present" + ) + + +if __name__ == "__main__": + main() diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index ee24eb3..acc06bf 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -38,6 +38,7 @@ TARGETS = { # implementations. The generated module keeps catalog markers and re-exports # the hand-written type so coverage remains deterministic. NATIVE_TYPES = { + "T:LibreMetaverse.AISResponseMeta": "crate::inventory_ais::AISResponseMeta", "T:LibreMetaverse.AccountLevelBenefits": "crate::login::AccountLevelBenefits", "T:LibreMetaverse.AgentDisplayName": "crate::message_decoder::AgentDisplayName", "T:LibreMetaverse.AgentDataReplyEventArgs": "crate::agent_messages::AgentDataReplyEventArgs", @@ -262,6 +263,7 @@ NATIVE_DECLARATIONS = { "T:LibreMetaverse.AgentManager": "crate::agent_manager::AgentManager", "T:LibreMetaverse.GridClient": "crate::client_core::GridClient", "T:LibreMetaverse.InventoryManager": "crate::inventory_manager::InventoryManager", + "T:LibreMetaverse.InventoryAISClient": "crate::inventory_ais::InventoryAISClient", "T:LibreMetaverse.NetworkManager": "crate::network_manager::NetworkManager", "T:LibreMetaverse.Settings": "crate::client_core::Settings", "T:LibreMetaverse.Simulator": "crate::network_manager::Simulator", @@ -337,6 +339,10 @@ NATIVE_MEMBER_BODIES = { "self.native_inventory().unwrap_or_else(|_| panic!(\"failed to construct InventoryManager\"))", "P:LibreMetaverse.GridClient.Inventory#set": "self.native_set_inventory(value)", + "P:LibreMetaverse.GridClient.AisClient": + "self.native_ais_client().unwrap_or_else(|_| panic!(\"failed to construct InventoryAISClient\"))", + "P:LibreMetaverse.GridClient.AisClient#set": + "self.native_set_ais_client(value)", "P:LibreMetaverse.GridClient.Network": "self.native_network().unwrap_or_else(|_| panic!(\"failed to construct NetworkManager\"))", "P:LibreMetaverse.GridClient.Network#set": @@ -363,6 +369,81 @@ NATIVE_MEMBER_BODIES = { "crate::inventory_manager::InventoryManager::native_magic_id()", "M:LibreMetaverse.InventoryManager.#ctor(LibreMetaverse.GridClient)": "crate::inventory_manager::InventoryManager::native_new(client)", + "F:LibreMetaverse.InventoryAISClient.INVENTORY_CAP_NAME": + "crate::inventory_ais::InventoryAISClient::INVENTORY_CAP_NAME", + "F:LibreMetaverse.InventoryAISClient.LIBRARY_CAP_NAME": + "crate::inventory_ais::InventoryAISClient::LIBRARY_CAP_NAME", + "M:LibreMetaverse.InventoryAISClient.#ctor(LibreMetaverse.GridClient)": + "crate::inventory_ais::InventoryAISClient::native_new(client)", + "P:LibreMetaverse.InventoryAISClient.IsAvailable": "self.native_is_available()", + "M:LibreMetaverse.InventoryAISClient.CreateInventoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Boolean,System.Threading.CancellationToken)": + "self.native_create_inventory(parent_uuid, new_inventory, create_link, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.SlamFolderAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)": + "self.native_slam_folder(folder_uuid, new_inventory, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.RemoveCategoryAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_remove_category(category_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.RemoveItemAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_remove_item(item_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.CopyLibraryCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Boolean,System.Threading.CancellationToken)": + "self.native_copy_library_category(source_uuid, dest_uuid, copy_subfolders, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.PurgeDescendentsAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_purge_descendents(category_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.UpdateCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)": + "self.native_update_category(category_uuid, updates, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.UpdateItemAsync(LibreMetaverse.UUID,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)": + "self.native_update_item(item_uuid, updates, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.CopyCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Boolean,System.Threading.CancellationToken)": + "self.native_copy_category(source_uuid, dest_uuid, simulate, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.MoveCategoryAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_move_category(source_uuid, dest_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.PutCategoryChildrenAsync(System.String,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)": + "self.native_put_category_children(category, children_payload, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.CopyCategoryChildrenAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_copy_category_children(category, dest_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.MoveCategoryChildrenAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_move_category_children(category, dest_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.DeleteCategoryChildrenAsync(System.String,System.Threading.CancellationToken)": + "self.native_delete_category_children(category, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.PutCategoryLinksAsync(System.String,LibreMetaverse.StructuredData.OSD,System.Threading.CancellationToken)": + "self.native_put_category_links(category, links_payload, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.CopyCategoryLinksAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_copy_category_links(category, dest_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.MoveCategoryLinksAsync(System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_move_category_links(category, dest_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.DeleteCategoryLinksAsync(System.String,System.Threading.CancellationToken)": + "self.native_delete_category_links(category, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.CopyItemAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_copy_item(item_uuid, dest_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.MoveItemAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_move_item(item_uuid, dest_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.GetCategoryAsync(System.String,System.Threading.CancellationToken)": + "self.native_get_category(category, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.GetCategoryChildrenAsync(System.String,System.Int32,System.Boolean,System.Threading.CancellationToken)": + "self.native_get_category_children(category, depth, recursive, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.FetchCategoryCategoriesAsync(LibreMetaverse.UUID,System.Boolean,System.Boolean,System.Int32,System.Threading.CancellationToken)": + "self.native_fetch_category_categories(cat_id, use_inventory_cap, recursive, depth, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.FetchCategorySubsetAsync(LibreMetaverse.UUID,System.Collections.Generic.IEnumerable{LibreMetaverse.UUID},System.Boolean,System.Boolean,System.Int32,System.Threading.CancellationToken)": + "self.native_fetch_category_subset(cat_id, specific_children, use_inventory_cap, recursive, depth, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.GetCategoryLinksAsync(System.String,System.Threading.CancellationToken)": + "self.native_get_category_links(category, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.FetchItemAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_fetch_item(item_uuid, cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.FetchCOFAsync(System.Threading.CancellationToken)": + "self.native_fetch_cof(cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.FetchOrphansAsync(System.Threading.CancellationToken)": + "self.native_fetch_orphans(cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.EmptyTrashAsync(System.Threading.CancellationToken)": + "self.native_empty_trash(cancellation_token).await", + "M:LibreMetaverse.InventoryAISClient.ParseItemsFromEmbedded(LibreMetaverse.StructuredData.OSDMap)": + "self.native_parse_items_from_embedded(response)", + "M:LibreMetaverse.InventoryAISClient.ParseFoldersFromEmbedded(LibreMetaverse.StructuredData.OSDMap)": + "self.native_parse_folders_from_embedded(response)", + "M:LibreMetaverse.InventoryAISClient.ParseLinksFromEmbedded(LibreMetaverse.StructuredData.OSDMap)": + "self.native_parse_links_from_embedded(response)", + "M:LibreMetaverse.InventoryAISClient.ParseEmbedded(LibreMetaverse.StructuredData.OSDMap,System.Collections.Generic.List{LibreMetaverse.InventoryFolder}@,System.Collections.Generic.List{LibreMetaverse.InventoryItem}@,System.Collections.Generic.List{LibreMetaverse.InventoryItem}@)": + "self.native_parse_embedded(response, folders, items, links)", + "M:LibreMetaverse.InventoryAISClient.ParseAISResponseMeta(LibreMetaverse.StructuredData.OSDMap)": + "crate::inventory_ais::InventoryAISClient::native_parse_ais_response_meta(response)", "M:LibreMetaverse.InventoryManager.CreateInventoryItem(LibreMetaverse.InventoryType,LibreMetaverse.UUID)": "crate::inventory_manager::InventoryManager::native_create_inventory_item(type_, id)", "M:LibreMetaverse.InventoryManager.CopyItemAsync(LibreMetaverse.UUID,LibreMetaverse.UUID,System.String,LibreMetaverse.UUID,System.Threading.CancellationToken)": diff --git a/tools/generate_rust_mapping.py b/tools/generate_rust_mapping.py index 6546ad7..3540282 100644 --- a/tools/generate_rust_mapping.py +++ b/tools/generate_rust_mapping.py @@ -994,6 +994,7 @@ def validate_generated_shims() -> None: "crate::gesture::", "crate::json_codec::", "crate::inventory::", + "crate::inventory_ais::", "crate::inventory_manager::", "crate::message_codec::", "crate::message_decoder::",