diff options
| author | main <main@swarm.moe> | 2026-03-20 20:14:50 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-20 20:14:50 -0400 |
| commit | bb92a05eb5446e07c6288e266bd06d7b5899eee5 (patch) | |
| tree | 56846c754fdf7c89c4e090d18d8269a2ce9f53f7 /crates | |
| parent | 84e898d9ba699451d5d13fe384e7bbe220564bc1 (diff) | |
| download | libmcp-bb92a05eb5446e07c6288e266bd06d7b5899eee5.zip | |
Add projection traits and doctrine testkit
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/libmcp-derive/Cargo.toml | 19 | ||||
| -rw-r--r-- | crates/libmcp-derive/src/lib.rs | 259 | ||||
| -rw-r--r-- | crates/libmcp-testkit/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/libmcp-testkit/src/lib.rs | 130 | ||||
| -rw-r--r-- | crates/libmcp/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/libmcp/src/lib.rs | 9 | ||||
| -rw-r--r-- | crates/libmcp/src/projection.rs | 308 |
7 files changed, 727 insertions, 0 deletions
diff --git a/crates/libmcp-derive/Cargo.toml b/crates/libmcp-derive/Cargo.toml new file mode 100644 index 0000000..7777392 --- /dev/null +++ b/crates/libmcp-derive/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "libmcp-derive" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +description.workspace = true +readme.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true + +[lints] +workspace = true diff --git a/crates/libmcp-derive/src/lib.rs b/crates/libmcp-derive/src/lib.rs new file mode 100644 index 0000000..bee9185 --- /dev/null +++ b/crates/libmcp-derive/src/lib.rs @@ -0,0 +1,259 @@ +//! Derive macros for `libmcp` projection traits. + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Field, Fields, Ident, LitStr, Result, parse_macro_input}; + +/// Derives `libmcp::StructuredProjection` and `libmcp::SurfacePolicy`. +#[proc_macro_derive(ToolProjection, attributes(libmcp))] +pub fn derive_tool_projection(input: TokenStream) -> TokenStream { + expand_tool_projection(parse_macro_input!(input as DeriveInput)) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Derives `libmcp::SelectorProjection`. +#[proc_macro_derive(SelectorProjection, attributes(libmcp))] +pub fn derive_selector_projection(input: TokenStream) -> TokenStream { + expand_selector_projection(parse_macro_input!(input as DeriveInput)) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +fn expand_tool_projection(input: DeriveInput) -> Result<proc_macro2::TokenStream> { + let ident = input.ident; + let data = match input.data { + Data::Struct(data) => data, + _ => { + return Err(syn::Error::new_spanned( + ident, + "ToolProjection only supports structs", + )); + } + }; + let fields = match data.fields { + Fields::Named(fields) => fields.named.into_iter().collect::<Vec<_>>(), + _ => { + return Err(syn::Error::new_spanned( + ident, + "ToolProjection requires named fields", + )); + } + }; + let container = parse_container_attrs(&input.attrs)?; + let kind = surface_kind_expr(&container.kind); + let reference_only = container.reference_only; + let forbid_opaque_ids = !container.allow_opaque_ids; + + let concise_entries = fields + .iter() + .filter(|field| include_in_concise(field)) + .map(project_field) + .collect::<Result<Vec<_>>>()?; + let full_entries = fields + .iter() + .filter(|field| include_in_full(field)) + .map(project_field) + .collect::<Result<Vec<_>>>()?; + + Ok(quote! { + impl ::libmcp::StructuredProjection for #ident { + fn concise_projection( + &self, + ) -> ::std::result::Result<::serde_json::Value, ::libmcp::ProjectionError> { + let mut object = ::serde_json::Map::new(); + #(#concise_entries)* + Ok(::serde_json::Value::Object(object)) + } + + fn full_projection( + &self, + ) -> ::std::result::Result<::serde_json::Value, ::libmcp::ProjectionError> { + let mut object = ::serde_json::Map::new(); + #(#full_entries)* + Ok(::serde_json::Value::Object(object)) + } + } + + impl ::libmcp::SurfacePolicy for #ident { + const KIND: ::libmcp::SurfaceKind = #kind; + const FORBID_OPAQUE_IDS: bool = #forbid_opaque_ids; + const REFERENCE_ONLY: bool = #reference_only; + } + }) +} + +fn expand_selector_projection(input: DeriveInput) -> Result<proc_macro2::TokenStream> { + let ident = input.ident; + let data = match input.data { + Data::Struct(data) => data, + _ => { + return Err(syn::Error::new_spanned( + ident, + "SelectorProjection only supports structs", + )); + } + }; + let fields = match data.fields { + Fields::Named(fields) => fields.named.into_iter().collect::<Vec<_>>(), + _ => { + return Err(syn::Error::new_spanned( + ident, + "SelectorProjection requires named fields", + )); + } + }; + + let slug_field = fields + .iter() + .find(|field| has_field_flag(field, "selector") || field_ident(field) == "slug") + .ok_or_else(|| syn::Error::new_spanned(&ident, "SelectorProjection needs a slug field"))?; + let title_field = fields + .iter() + .find(|field| has_field_flag(field, "title") || field_ident(field) == "title"); + + let slug_ident = slug_field.ident.as_ref().ok_or_else(|| { + syn::Error::new_spanned(slug_field, "SelectorProjection needs named fields") + })?; + let title_tokens = if let Some(field) = title_field { + let title_ident = field.ident.as_ref().ok_or_else(|| { + syn::Error::new_spanned(field, "SelectorProjection needs named fields") + })?; + quote! { title: ::std::option::Option::Some(self.#title_ident.clone().into()) } + } else { + quote! { title: ::std::option::Option::None } + }; + + Ok(quote! { + impl ::libmcp::SelectorProjection for #ident { + fn selector_ref(&self) -> ::libmcp::SelectorRef { + ::libmcp::SelectorRef { + slug: self.#slug_ident.clone(), + #title_tokens, + } + } + } + }) +} + +fn project_field(field: &Field) -> Result<proc_macro2::TokenStream> { + let ident = field + .ident + .as_ref() + .ok_or_else(|| syn::Error::new_spanned(field, "ToolProjection requires named fields"))?; + let key = LitStr::new(field_ident(field).as_str(), ident.span()); + if has_field_flag(field, "skip_none") { + Ok(quote! { + if let ::std::option::Option::Some(value) = &self.#ident { + object.insert( + #key.to_owned(), + ::serde_json::to_value(value).map_err(::libmcp::ProjectionError::from)?, + ); + } + }) + } else { + Ok(quote! { + object.insert( + #key.to_owned(), + ::serde_json::to_value(&self.#ident).map_err(::libmcp::ProjectionError::from)?, + ); + }) + } +} + +fn include_in_concise(field: &Field) -> bool { + !has_field_flag(field, "skip") + && !has_field_flag(field, "full_only") + && !has_field_flag(field, "full") +} + +fn include_in_full(field: &Field) -> bool { + !has_field_flag(field, "skip") && !has_field_flag(field, "concise_only") +} + +fn field_ident(field: &Field) -> String { + field + .ident + .as_ref() + .map_or_else(String::new, ToString::to_string) +} + +#[derive(Default)] +struct ContainerAttrs { + kind: Option<Ident>, + reference_only: bool, + allow_opaque_ids: bool, +} + +fn parse_container_attrs(attrs: &[syn::Attribute]) -> Result<ContainerAttrs> { + let mut parsed = ContainerAttrs::default(); + for attr in attrs.iter().filter(|attr| attr.path().is_ident("libmcp")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("kind") { + let value = meta.value()?; + let kind = value.parse::<LitStr>()?; + parsed.kind = Some(Ident::new( + normalize_surface_kind(kind.value()).as_str(), + kind.span(), + )); + return Ok(()); + } + if meta.path.is_ident("reference_only") { + parsed.reference_only = true; + return Ok(()); + } + if meta.path.is_ident("allow_opaque_ids") { + parsed.allow_opaque_ids = true; + return Ok(()); + } + Err(meta.error("unsupported libmcp container attribute")) + })?; + } + Ok(parsed) +} + +fn has_field_flag(field: &Field, flag: &str) -> bool { + field + .attrs + .iter() + .filter(|attr| attr.path().is_ident("libmcp")) + .any(|attr| attr_has_flag(attr, flag)) +} + +fn attr_has_flag(attr: &syn::Attribute, flag: &str) -> bool { + let mut found = false; + let _ = attr.parse_nested_meta(|meta| { + if meta.path.is_ident(flag) { + found = true; + return Ok(()); + } + Ok(()) + }); + found +} + +fn surface_kind_expr(kind: &Option<Ident>) -> proc_macro2::TokenStream { + let ident = kind + .clone() + .unwrap_or_else(|| Ident::new("Read", proc_macro2::Span::call_site())); + quote!(::libmcp::SurfaceKind::#ident) +} + +fn normalize_surface_kind(kind: String) -> String { + let normalized = kind.trim().replace('-', "_"); + let mut out = String::new(); + let mut uppercase_next = true; + for ch in normalized.chars() { + if ch == '_' { + uppercase_next = true; + continue; + } + if uppercase_next { + out.extend(ch.to_uppercase()); + uppercase_next = false; + } else { + out.push(ch); + } + } + out +} diff --git a/crates/libmcp-testkit/Cargo.toml b/crates/libmcp-testkit/Cargo.toml index b8e68b1..a958650 100644 --- a/crates/libmcp-testkit/Cargo.toml +++ b/crates/libmcp-testkit/Cargo.toml @@ -8,6 +8,7 @@ description.workspace = true readme.workspace = true [dependencies] +libmcp = { path = "../libmcp" } serde.workspace = true serde_json.workspace = true diff --git a/crates/libmcp-testkit/src/lib.rs b/crates/libmcp-testkit/src/lib.rs index 9f33643..3b61bd5 100644 --- a/crates/libmcp-testkit/src/lib.rs +++ b/crates/libmcp-testkit/src/lib.rs @@ -1,6 +1,8 @@ //! Shared test helpers for `libmcp` consumers. +use libmcp::{SurfaceKind, ToolProjection}; use serde::de::DeserializeOwned; +use serde_json::Value; use std::{ fs::File, io::{self, BufRead, BufReader}, @@ -30,3 +32,131 @@ where } Ok(records) } + +/// Assertion failure for projection doctrine checks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectionAssertion { + path: String, + message: String, +} + +impl ProjectionAssertion { + fn new(path: impl Into<String>, message: impl Into<String>) -> Self { + Self { + path: path.into(), + message: message.into(), + } + } +} + +impl std::fmt::Display for ProjectionAssertion { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.path, self.message) + } +} + +impl std::error::Error for ProjectionAssertion {} + +/// Asserts that a projection obeys the doctrine implied by its surface policy. +pub fn assert_projection_doctrine<T>(projection: &T) -> Result<(), ProjectionAssertion> +where + T: ToolProjection, +{ + let concise = projection + .concise_projection() + .map_err(|error| ProjectionAssertion::new("$concise", error.to_string()))?; + let full = projection + .full_projection() + .map_err(|error| ProjectionAssertion::new("$full", error.to_string()))?; + let policy = projection.projection_policy(); + if policy.forbid_opaque_ids { + assert_no_opaque_ids(&concise)?; + assert_no_opaque_ids(&full)?; + } + if policy.reference_only { + assert_reference_only(&concise)?; + assert_reference_only(&full)?; + } + if matches!(policy.kind, SurfaceKind::List) { + assert_list_shape(&concise)?; + assert_list_shape(&full)?; + } + Ok(()) +} + +/// Asserts that a JSON value does not leak opaque database identifiers. +pub fn assert_no_opaque_ids(value: &Value) -> Result<(), ProjectionAssertion> { + walk(value, "$", &mut |path, value| { + if let Value::Object(object) = value { + for key in object.keys() { + if key == "id" || key.ends_with("_id") { + return Err(ProjectionAssertion::new( + format!("{path}.{key}"), + "opaque identifier field leaked into model-facing projection", + )); + } + } + } + Ok(()) + }) +} + +/// Asserts that a list-like surface does not inline prose bodies. +pub fn assert_list_shape(value: &Value) -> Result<(), ProjectionAssertion> { + walk(value, "$", &mut |path, value| { + if let Value::Object(object) = value { + for key in object.keys() { + if matches!( + key.as_str(), + "body" | "payload_preview" | "analysis" | "rationale" + ) { + return Err(ProjectionAssertion::new( + format!("{path}.{key}"), + "list surface leaked body-like content", + )); + } + } + } + Ok(()) + }) +} + +/// Asserts that a reference-only surface does not inline large textual content. +pub fn assert_reference_only(value: &Value) -> Result<(), ProjectionAssertion> { + walk(value, "$", &mut |path, value| match value { + Value::Object(object) => { + for key in object.keys() { + if matches!(key.as_str(), "body" | "content" | "text" | "bytes") { + return Err(ProjectionAssertion::new( + format!("{path}.{key}"), + "reference-only surface inlined artifact content", + )); + } + } + Ok(()) + } + _ => Ok(()), + }) +} + +fn walk( + value: &Value, + path: &str, + visitor: &mut impl FnMut(&str, &Value) -> Result<(), ProjectionAssertion>, +) -> Result<(), ProjectionAssertion> { + visitor(path, value)?; + match value { + Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + walk(item, format!("{path}[{index}]").as_str(), visitor)?; + } + } + Value::Object(object) => { + for (key, child) in object { + walk(child, format!("{path}.{key}").as_str(), visitor)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + Ok(()) +} diff --git a/crates/libmcp/Cargo.toml b/crates/libmcp/Cargo.toml index 02ea9db..c087ab8 100644 --- a/crates/libmcp/Cargo.toml +++ b/crates/libmcp/Cargo.toml @@ -8,6 +8,7 @@ description.workspace = true readme.workspace = true [dependencies] +libmcp-derive = { path = "../libmcp-derive" } schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/libmcp/src/lib.rs b/crates/libmcp/src/lib.rs index 2f4e3b1..049cffe 100644 --- a/crates/libmcp/src/lib.rs +++ b/crates/libmcp/src/lib.rs @@ -1,10 +1,13 @@ //! `libmcp` is the shared operational spine for hardened MCP servers. +extern crate self as libmcp; + pub mod fault; pub mod health; pub mod host; pub mod jsonrpc; pub mod normalize; +pub mod projection; pub mod render; pub mod replay; pub mod telemetry; @@ -30,6 +33,10 @@ pub use normalize::{ NumericParseError, PathNormalizeError, normalize_ascii_token, normalize_local_path, parse_human_unsigned_u64, saturating_u64_to_usize, }; +pub use projection::{ + FallbackJsonProjection, ProjectionError, ProjectionPolicy, SelectorProjection, SelectorRef, + StructuredProjection, SurfaceKind, SurfacePolicy, ToolProjection, +}; pub use render::{ DetailLevel, JsonPorcelainConfig, PathStyle, RenderConfig, RenderMode, TruncatedText, collapse_inline_whitespace, render_json_porcelain, with_presentation_properties, @@ -37,3 +44,5 @@ pub use render::{ pub use replay::ReplayContract; pub use telemetry::{TelemetryLog, ToolErrorDetail, ToolOutcome}; pub use types::{Generation, InvariantViolation}; + +pub use libmcp_derive::{SelectorProjection, ToolProjection}; diff --git a/crates/libmcp/src/projection.rs b/crates/libmcp/src/projection.rs new file mode 100644 index 0000000..a6216db --- /dev/null +++ b/crates/libmcp/src/projection.rs @@ -0,0 +1,308 @@ +//! Model-facing projection traits. + +use crate::render::{DetailLevel, JsonPorcelainConfig, render_json_porcelain}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +const OVERVIEW_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 10, + max_inline_chars: 144, +}; +const OVERVIEW_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 28, + max_inline_chars: 240, +}; +const LIST_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 16, + max_inline_chars: 128, +}; +const LIST_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 32, + max_inline_chars: 176, +}; +const READ_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 18, + max_inline_chars: 176, +}; +const READ_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 40, + max_inline_chars: 320, +}; +const MUTATION_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 12, + max_inline_chars: 160, +}; +const MUTATION_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 24, + max_inline_chars: 256, +}; +const OPS_CONCISE_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 8, + max_inline_chars: 160, +}; +const OPS_FULL_CONFIG: JsonPorcelainConfig = JsonPorcelainConfig { + max_lines: 24, + max_inline_chars: 240, +}; + +/// Projection failure. +#[derive(Debug, Error)] +pub enum ProjectionError { + /// Serialization failed while materializing the projection. + #[error("failed to serialize projection: {0}")] + Serialize(#[from] serde_json::Error), +} + +/// Model-facing surface kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SurfaceKind { + /// One bounded snapshot answering “where are we now?” + Overview, + /// Enumeration surfaces such as lists and summaries. + List, + /// Focused one-object reads. + Read, + /// Mutation receipts. + Mutation, + /// Operational and health surfaces. + Ops, +} + +/// Projection policy derived from the surface kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProjectionPolicy { + /// Declared surface kind. + pub kind: SurfaceKind, + /// Whether opaque database identifiers are forbidden by doctrine. + pub forbid_opaque_ids: bool, + /// Whether the surface is reference-only and must not inline bodies. + pub reference_only: bool, + /// Concise porcelain bounds. + pub concise_porcelain: JsonPorcelainConfig, + /// Full porcelain bounds. + pub full_porcelain: JsonPorcelainConfig, +} + +impl ProjectionPolicy { + /// Builds a policy from the type-level doctrine. + #[must_use] + pub fn from_surface(kind: SurfaceKind, forbid_opaque_ids: bool, reference_only: bool) -> Self { + let (concise_porcelain, full_porcelain) = match kind { + SurfaceKind::Overview => (OVERVIEW_CONCISE_CONFIG, OVERVIEW_FULL_CONFIG), + SurfaceKind::List => (LIST_CONCISE_CONFIG, LIST_FULL_CONFIG), + SurfaceKind::Read => (READ_CONCISE_CONFIG, READ_FULL_CONFIG), + SurfaceKind::Mutation => (MUTATION_CONCISE_CONFIG, MUTATION_FULL_CONFIG), + SurfaceKind::Ops => (OPS_CONCISE_CONFIG, OPS_FULL_CONFIG), + }; + Self { + kind, + forbid_opaque_ids, + reference_only, + concise_porcelain, + full_porcelain, + } + } +} + +/// Slug-first selector projection for model-facing references. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct SelectorRef { + /// Stable human-facing selector. + pub slug: String, + /// Optional human-facing title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, +} + +/// Anything that can produce a selector reference. +pub trait SelectorProjection { + /// Builds the selector reference. + fn selector_ref(&self) -> SelectorRef; +} + +/// Type-level surface doctrine. +pub trait SurfacePolicy { + /// Declared surface kind. + const KIND: SurfaceKind; + /// Whether opaque database identifiers are forbidden by doctrine. + const FORBID_OPAQUE_IDS: bool = true; + /// Whether the surface is reference-only. + const REFERENCE_ONLY: bool = false; + + /// Materializes the policy. + #[must_use] + fn projection_policy(&self) -> ProjectionPolicy { + ProjectionPolicy::from_surface(Self::KIND, Self::FORBID_OPAQUE_IDS, Self::REFERENCE_ONLY) + } +} + +/// Structured concise/full projections. +pub trait StructuredProjection { + /// Concise structured projection. + fn concise_projection(&self) -> Result<Value, ProjectionError>; + /// Full structured projection. + fn full_projection(&self) -> Result<Value, ProjectionError>; +} + +/// Happy-path trait for model-facing tool outputs. +pub trait ToolProjection: StructuredProjection + SurfacePolicy { + /// Returns the structured projection for the chosen detail level. + fn structured_projection(&self, detail: DetailLevel) -> Result<Value, ProjectionError> { + match detail { + DetailLevel::Concise => self.concise_projection(), + DetailLevel::Full => self.full_projection(), + } + } + + /// Renders porcelain for the chosen detail level. + fn porcelain_projection(&self, detail: DetailLevel) -> Result<String, ProjectionError> { + let policy = self.projection_policy(); + let (value, config) = match detail { + DetailLevel::Concise => (self.concise_projection()?, policy.concise_porcelain), + DetailLevel::Full => (self.full_projection()?, policy.full_porcelain), + }; + Ok(render_json_porcelain(&value, config)) + } +} + +impl<T> ToolProjection for T where T: StructuredProjection + SurfacePolicy {} + +/// Explicit escape hatch for already-curated JSON projections. +#[derive(Debug, Clone)] +pub struct FallbackJsonProjection { + concise: Value, + full: Value, + kind: SurfaceKind, + forbid_opaque_ids: bool, + reference_only: bool, +} + +impl FallbackJsonProjection { + /// Builds a fallback projection from serialized values. + pub fn new( + concise: impl Serialize, + full: impl Serialize, + kind: SurfaceKind, + ) -> Result<Self, ProjectionError> { + Ok(Self { + concise: serde_json::to_value(concise)?, + full: serde_json::to_value(full)?, + kind, + forbid_opaque_ids: true, + reference_only: false, + }) + } + + /// Builds a fallback projection with explicit doctrine flags. + pub fn with_policy( + concise: impl Serialize, + full: impl Serialize, + kind: SurfaceKind, + forbid_opaque_ids: bool, + reference_only: bool, + ) -> Result<Self, ProjectionError> { + Ok(Self { + concise: serde_json::to_value(concise)?, + full: serde_json::to_value(full)?, + kind, + forbid_opaque_ids, + reference_only, + }) + } +} + +impl StructuredProjection for FallbackJsonProjection { + fn concise_projection(&self) -> Result<Value, ProjectionError> { + Ok(self.concise.clone()) + } + + fn full_projection(&self) -> Result<Value, ProjectionError> { + Ok(self.full.clone()) + } +} + +impl SurfacePolicy for FallbackJsonProjection { + const KIND: SurfaceKind = SurfaceKind::Read; + + fn projection_policy(&self) -> ProjectionPolicy { + ProjectionPolicy::from_surface(self.kind, self.forbid_opaque_ids, self.reference_only) + } +} + +#[cfg(test)] +mod tests { + use super::{StructuredProjection as _, SurfaceKind, SurfacePolicy as _}; + use crate::{DetailLevel, SelectorProjection, SelectorRef, ToolProjection}; + + #[derive(Clone, SelectorProjection)] + struct HypothesisSelector { + slug: String, + title: String, + } + + #[derive(ToolProjection)] + #[libmcp(kind = "read")] + struct ExperimentProjection { + slug: String, + title: String, + hypothesis: SelectorRef, + #[libmcp(skip_none)] + summary: Option<String>, + #[libmcp(full_only)] + analysis: String, + } + + #[test] + fn derived_projection_shapes_detail_levels() { + let owner = HypothesisSelector { + slug: "native-lp-sink".to_owned(), + title: "Native LP sink".to_owned(), + }; + let projection = ExperimentProjection { + slug: "matched-lp-site-traces".to_owned(), + title: "Matched LP site traces".to_owned(), + hypothesis: owner.selector_ref(), + summary: Some("Node LP work dominates traced native spend.".to_owned()), + analysis: "Native LP spends most traced wallclock in node reoptimization.".to_owned(), + }; + + assert_eq!(ExperimentProjection::KIND, SurfaceKind::Read); + + let concise = projection.concise_projection(); + assert!(concise.is_ok()); + let concise = match concise { + Ok(value) => value, + Err(_) => return, + }; + let full = projection.full_projection(); + assert!(full.is_ok()); + let full = match full { + Ok(value) => value, + Err(_) => return, + }; + + assert!(concise.get("analysis").is_none()); + assert_eq!( + concise + .get("hypothesis") + .and_then(|value| value.get("slug")) + .and_then(serde_json::Value::as_str), + Some("native-lp-sink") + ); + assert_eq!( + full.get("analysis").and_then(serde_json::Value::as_str), + Some("Native LP spends most traced wallclock in node reoptimization.") + ); + + let porcelain = projection.porcelain_projection(DetailLevel::Concise); + assert!(porcelain.is_ok()); + let porcelain = match porcelain { + Ok(value) => value, + Err(_) => return, + }; + assert!(porcelain.contains("slug: \"matched-lp-site-traces\"")); + } +} |