//! `Resolver` async trait — pluggable backend for tag-name lookup. //! //! The .NET reference's `GalaxyRepositoryTagResolver` is a single concrete //! class with a SQL backend. The Rust port splits the surface into a trait //! plus one provided implementation (`tiberius`-backed, gated by the //! `galaxy-resolver` Cargo feature) so consumers can plug in any other //! backing — an in-memory cache for tests, a JSON snapshot from //! `wwtools/grdb/`, a future REST client, etc. //! //! Both [`Resolver::resolve`] and [`Resolver::browse`] mirror the .NET //! `ResolveAsync` and `BrowseAsync` signatures (`GalaxyRepositoryTagResolver.cs:88,117`). use crate::metadata::GalaxyTagMetadata; use crate::parser::ParseError; use async_trait::async_trait; use thiserror::Error; /// Errors raised by [`Resolver`] implementations. Mirrors the /// `InvalidOperationException` raised by .NET when a tag is not found /// (`GalaxyRepositoryTagResolver.cs:114`) plus parser failures from /// [`crate::parser::ParsedTagReference::parse_candidates`]. #[derive(Debug, Error)] #[non_exhaustive] pub enum ResolverError { /// The tag-reference string itself failed to parse. #[error("invalid tag reference: {0}")] InvalidTagReference(#[from] ParseError), /// No metadata row matched any of the parsed candidates. Mirrors /// `cs:114`. #[error( "Galaxy tag reference '{tag_reference}' was not found in the deployed repository metadata" )] NotFound { tag_reference: String }, /// Backend-specific failure (SQL connect / query error, transport /// error, etc.). Carries an opaque message so backends don't have to /// expose their concrete error types in the trait. #[error("Galaxy resolver backend error: {message}")] Backend { message: String }, } /// Pluggable async tag-name → metadata resolver. /// /// Implementations should be thread-safe (`Send + Sync`) so the Rust /// `NmxClient` can hold one in an `Arc` shared across /// multiple write/advise calls. #[async_trait] pub trait Resolver: Send + Sync { /// Resolve a single tag reference (`Object.Attribute`, /// `Object.Primitive.Attribute`, optionally `.property(buffer)`- /// suffixed) to its metadata. Mirrors `ResolveAsync` /// (`GalaxyRepositoryTagResolver.cs:88-115`). /// /// # Errors /// [`ResolverError::InvalidTagReference`], [`ResolverError::NotFound`], /// or [`ResolverError::Backend`]. async fn resolve(&self, tag_reference: &str) -> Result; /// Browse multiple matching attributes. The default implementation /// returns [`ResolverError::Backend`] with `"browse not implemented"` /// — backends that support browsing override this. Mirrors /// `BrowseAsync` (`cs:117-147`). /// /// `object_tag_like` and `attribute_like` use SQL `LIKE` semantics /// (`%` for any-sequence, `_` for any-single-char). /// /// # Errors /// As for [`Self::resolve`]. async fn browse( &self, object_tag_like: &str, attribute_like: &str, max_rows: usize, ) -> Result, ResolverError> { let _ = (object_tag_like, attribute_like, max_rows); Err(ResolverError::Backend { message: "browse not implemented for this resolver".to_string(), }) } } #[cfg(test)] #[allow( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::panic )] mod tests { use super::*; use std::collections::HashMap; use std::sync::Mutex; /// Tiny in-memory resolver for tests. Demonstrates that the trait is /// implementable without any SQL machinery, validating the /// "pluggable backend" design. struct InMemoryResolver { rows: HashMap, browse_calls: Mutex>, } impl InMemoryResolver { fn new() -> Self { let mut rows = HashMap::new(); rows.insert( "TestObject.TestAttr".to_string(), GalaxyTagMetadata { object_tag_name: "TestObject".to_string(), attribute_name: "TestAttr".to_string(), primitive_name: None, platform_id: 1, engine_id: 2, object_id: 3, primitive_id: 0, attribute_id: 7, property_id: 10, mx_data_type: 4, is_array: false, security_classification: 0, attribute_source: "dynamic".to_string(), }, ); Self { rows, browse_calls: Mutex::new(Vec::new()), } } } #[async_trait] impl Resolver for InMemoryResolver { async fn resolve(&self, tag_reference: &str) -> Result { self.rows .get(tag_reference) .cloned() .ok_or_else(|| ResolverError::NotFound { tag_reference: tag_reference.to_string(), }) } async fn browse( &self, object_tag_like: &str, attribute_like: &str, max_rows: usize, ) -> Result, ResolverError> { self.browse_calls.lock().unwrap().push(( object_tag_like.to_string(), attribute_like.to_string(), max_rows, )); Ok(self.rows.values().take(max_rows).cloned().collect()) } } #[tokio::test(flavor = "current_thread")] async fn in_memory_resolver_round_trip() { let r = InMemoryResolver::new(); let m = r.resolve("TestObject.TestAttr").await.unwrap(); assert_eq!(m.object_tag_name, "TestObject"); assert_eq!(m.attribute_name, "TestAttr"); } #[tokio::test(flavor = "current_thread")] async fn in_memory_resolver_not_found() { let r = InMemoryResolver::new(); let err = r.resolve("DoesNotExist.X").await.unwrap_err(); assert!(matches!(err, ResolverError::NotFound { .. })); } #[tokio::test(flavor = "current_thread")] async fn default_browse_returns_backend_error() { // Concrete impl that doesn't override browse picks up the default. struct NoBrowse; #[async_trait] impl Resolver for NoBrowse { async fn resolve(&self, _: &str) -> Result { unimplemented!() } } let err = NoBrowse.browse("%", "%", 10).await.unwrap_err(); match err { ResolverError::Backend { message } => { assert!(message.contains("browse not implemented")); } other => panic!("expected Backend, got {other:?}"), } } #[test] fn parse_error_converts_into_resolver_error() { // ResolverError::from(ParseError::Empty) via #[from]. let e: ResolverError = ParseError::Empty.into(); assert!(matches!(e, ResolverError::InvalidTagReference(_))); } }