Skip to main content

agent_engine/tools/
catalog.rs

1//! `ToolCatalog` — all locally known capabilities (spec §7.1, Task 14).
2//!
3//! The catalog is a passive inventory integrated into [`super::ToolRegistry`]:
4//! every registry construction and mutation path (initial construction,
5//! subagent variants, dynamic registration, replacement, extension merge,
6//! disable) keeps exactly one truthful catalog alongside the exposed schema
7//! without changing what is exposed or executable. The registry remains the
8//! active behavior projection until later tasks introduce `DiscoveryIndex`,
9//! `SessionToolSet`, and `ExecutionGate`.
10//!
11//! Insertion invariants (spec §4.2):
12//! - no implementation is constructed (factories are stored, never invoked);
13//! - no process is started and no network is touched;
14//! - no extension handler or MCP connection method is called;
15//! - no schema is exposed to the model;
16//! - no execution grant is issued.
17//!
18//! Mutation invariants:
19//! - every successful mutation advances the generation by checked (never
20//!   saturating) arithmetic — a mutation cannot succeed without a new
21//!   generation, so stale activation grants can never keep validating;
22//! - duplicate and no-op failures do not advance the generation.
23
24use crate::tools::{Tool, ToolOrigin};
25use agent_core::BoundedText;
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28use std::collections::BTreeMap;
29use std::sync::Arc;
30use thiserror::Error;
31
32pub use agent_core::orchestration::capability::{
33    CatalogGeneration, CatalogGenerationOverflow, SchemaDigest, SessionActivationGrant, ToolId,
34    ToolIdError,
35};
36
37/// Deferred implementation constructor. Stored at insertion, invoked only by
38/// explicit later acquisition (execution-gate territory, not catalog).
39pub type ToolFactory = Arc<dyn Fn() -> Arc<dyn Tool> + Send + Sync>;
40
41/// Byte budget for raw identity fragments retained inside
42/// [`CapabilitySource`] (extension/plugin/server ids and tool names).
43const SOURCE_IDENTITY_MAX_BYTES: usize = 256;
44
45/// Hex characters of SHA-256 appended to a truncated identity fragment
46/// (160 bits, matching the `ToolId` digest-segment strength).
47const SOURCE_TRUNCATION_DIGEST_HEX_LEN: usize = 40;
48
49/// Bound a trust-relevant identity fragment without letting two distinct
50/// oversized identities collapse into one displayed value: when truncation
51/// occurs, an explicit `…#sha-<hex>` marker of the FULL raw bytes is
52/// appended, so the result both records that it was cut and stays distinct
53/// per original identity (up to SHA-256 collision). The source-aware
54/// [`ToolId`] is always derived from the raw identity, never this bounded
55/// form, so id collision resistance is unaffected.
56fn bounded_identity(raw: &str) -> String {
57    let bounded = BoundedText::new(raw, SOURCE_IDENTITY_MAX_BYTES);
58    if !bounded.truncated {
59        return bounded.text;
60    }
61    let digest = Sha256::digest(raw.as_bytes());
62    let mut hex = String::with_capacity(SOURCE_TRUNCATION_DIGEST_HEX_LEN);
63    for byte in digest.iter().take(SOURCE_TRUNCATION_DIGEST_HEX_LEN / 2) {
64        use std::fmt::Write as _;
65        let _ = write!(hex, "{byte:02x}");
66    }
67    let marker = format!("\u{2026}#sha-{hex}");
68    let keep = SOURCE_IDENTITY_MAX_BYTES.saturating_sub(marker.len());
69    format!("{}{marker}", BoundedText::new(raw, keep).text)
70}
71
72/// Where a capability comes from, carrying the exact runtime identities
73/// (extension/plugin/server ids and per-source tool names). Drives the ID
74/// namespace and, later, per-source permission policy.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum CapabilitySource {
77    Builtin,
78    Extension {
79        extension_id: String,
80        tool_name: String,
81    },
82    Mcp {
83        server_id: String,
84        server_tool_name: String,
85    },
86    Plugin {
87        plugin_id: String,
88        tool_name: String,
89    },
90    /// Dynamically registered with no declared origin. Kept explicit and
91    /// conservative — never classified as builtin.
92    Unknown {
93        runtime_name: String,
94    },
95}
96
97/// Permission/trust provenance recorded at catalog time. Evaluated (not
98/// merely trusted) by the future execution gate.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub enum TrustProvenance {
101    /// Compiled into this runtime; trusted as the binary itself.
102    BuiltinRuntime,
103    /// Declared by a locally installed extension manifest.
104    ExtensionManifest { extension_id: String },
105    /// Declared by local MCP server configuration.
106    McpConfig { server_id: String },
107    /// Declared by a plugin definition.
108    PluginConfig { plugin_id: String },
109    /// No verifiable provenance. Conservative fail-closed default for
110    /// unclassified dynamic registrations.
111    Unverified,
112}
113
114/// Conservative tool effect classes (Task 24, spec §8.2). Declared by the
115/// implementation, recorded in the catalog at registration, and consumed
116/// by the stream scheduler: only `ReadOnly` (or proven non-conflicting
117/// keyed writes) run concurrently; everything else serializes. Unknown/
118/// dynamic tools default to `NonIdempotent` — the safest class.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum ToolEffect {
121    /// No observable side effects; safe to run concurrently.
122    ReadOnly,
123    /// Repeatable write with a stable target; conflicts are detected via
124    /// the concurrency key (same key => model-order serial execution).
125    IdempotentWrite,
126    /// Arbitrary side effects; serialized by default.
127    NonIdempotent,
128}
129
130/// Where the full JSON schema for a capability lives. The catalog may hold
131/// the schema locally, but never exposes it to the model.
132#[derive(Clone, Debug)]
133pub enum SchemaLocator {
134    /// Full schema already known in-process (builtins, loaded extensions).
135    Inline(Value),
136}
137
138impl SchemaLocator {
139    fn schema(&self) -> &Value {
140        match self {
141            Self::Inline(schema) => schema,
142        }
143    }
144}
145
146/// One locally known capability. Construction is pure: it computes the
147/// deterministic schema digest and bounds the compact descriptor, but never
148/// invokes the implementation factory.
149#[derive(Clone)]
150pub struct CapabilityRecord {
151    id: ToolId,
152    source: CapabilitySource,
153    summary: String,
154    tags: Vec<String>,
155    schema: SchemaLocator,
156    schema_digest: SchemaDigest,
157    factory: ToolFactory,
158    provenance: TrustProvenance,
159    effect: ToolEffect,
160}
161
162impl CapabilityRecord {
163    /// Byte budget for the compact summary (descriptor, not full schema).
164    pub const SUMMARY_MAX_BYTES: usize = 256;
165    /// Maximum number of retained tags.
166    pub const MAX_TAGS: usize = 8;
167    /// Byte budget per retained tag.
168    pub const TAG_MAX_BYTES: usize = 32;
169
170    pub fn new(
171        id: ToolId,
172        source: CapabilitySource,
173        summary: &str,
174        tags: Vec<String>,
175        schema: SchemaLocator,
176        factory: ToolFactory,
177        provenance: TrustProvenance,
178    ) -> Self {
179        let schema_digest = SchemaDigest::of_schema(schema.schema());
180        let summary = BoundedText::new(summary, Self::SUMMARY_MAX_BYTES).text;
181        let tags = tags
182            .into_iter()
183            .take(Self::MAX_TAGS)
184            .map(|tag| BoundedText::new(&tag, Self::TAG_MAX_BYTES).text)
185            .collect();
186        Self {
187            id,
188            source,
189            summary,
190            tags,
191            schema,
192            schema_digest,
193            factory,
194            provenance,
195            effect: ToolEffect::NonIdempotent,
196        }
197    }
198
199    /// Catalog an already-constructed registry tool passively from its
200    /// declared [`ToolOrigin`]. Reads only in-process metadata (`name`,
201    /// `description`, `parameters`, `origin`): no execution, no extension
202    /// handler call, no MCP connection/process/network activity, no grant.
203    ///
204    /// Runtime identities are encoded through the source-aware [`ToolId`]
205    /// constructors, so existing uppercase/Unicode/colon-bearing names are
206    /// represented exactly rather than rejected, and undeclared origins are
207    /// cataloged conservatively as unknown — never invented as builtin.
208    pub fn for_registered_tool(tool: &Arc<dyn Tool>) -> Self {
209        let (id, source, provenance) = identity_for_tool(tool.as_ref());
210        let implementation = Arc::clone(tool);
211        let factory: ToolFactory = Arc::new(move || Arc::clone(&implementation));
212        let mut record = Self::new(
213            id,
214            source,
215            tool.description(),
216            Vec::new(),
217            SchemaLocator::Inline(tool.parameters()),
218            factory,
219            provenance,
220        );
221        // Task 24: record the implementation-declared effect class; the
222        // trait default keeps unknown/dynamic tools NonIdempotent.
223        record.effect = tool.effect();
224        record
225    }
226
227    pub fn id(&self) -> &ToolId {
228        &self.id
229    }
230
231    pub fn source(&self) -> &CapabilitySource {
232        &self.source
233    }
234
235    pub fn summary(&self) -> &str {
236        &self.summary
237    }
238
239    pub fn tags(&self) -> &[String] {
240        &self.tags
241    }
242
243    pub fn schema_locator(&self) -> &SchemaLocator {
244        &self.schema
245    }
246
247    pub fn schema_digest(&self) -> &SchemaDigest {
248        &self.schema_digest
249    }
250
251    pub fn provenance(&self) -> &TrustProvenance {
252        &self.provenance
253    }
254
255    /// The recorded conservative effect class (Task 24, spec §8.2).
256    pub fn effect(&self) -> ToolEffect {
257        self.effect
258    }
259
260    /// Construct the implementation. This is the ONLY place the stored
261    /// factory is invoked; the catalog itself never calls it.
262    pub fn implementation(&self) -> Arc<dyn Tool> {
263        (self.factory)()
264    }
265}
266
267impl std::fmt::Debug for CapabilityRecord {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        f.debug_struct("CapabilityRecord")
270            .field("id", &self.id)
271            .field("source", &self.source)
272            .field("summary", &self.summary)
273            .field("tags", &self.tags)
274            .field("schema_digest", &self.schema_digest)
275            .field("provenance", &self.provenance)
276            .field("effect", &self.effect)
277            .finish_non_exhaustive()
278    }
279}
280
281/// Catalog identity of a registered tool: its exact runtime name for
282/// extension tools is `<extension_id>:<tool_name>`, so the extension prefix
283/// is stripped before encoding to keep the id keyed on (extension, tool).
284pub(crate) fn tool_id_for(tool: &dyn Tool) -> ToolId {
285    identity_for_tool(tool).0
286}
287
288fn identity_for_tool(tool: &dyn Tool) -> (ToolId, CapabilitySource, TrustProvenance) {
289    match tool.origin() {
290        ToolOrigin::Builtin => (
291            ToolId::builtin(tool.name()),
292            CapabilitySource::Builtin,
293            TrustProvenance::BuiltinRuntime,
294        ),
295        ToolOrigin::Extension { extension_id } => {
296            let runtime_name = tool.name();
297            let tool_name = runtime_name
298                .strip_prefix(&format!("{extension_id}:"))
299                .unwrap_or(runtime_name);
300            (
301                ToolId::extension(&extension_id, tool_name),
302                CapabilitySource::Extension {
303                    extension_id: bounded_identity(&extension_id),
304                    tool_name: bounded_identity(tool_name),
305                },
306                TrustProvenance::ExtensionManifest {
307                    extension_id: bounded_identity(&extension_id),
308                },
309            )
310        }
311        ToolOrigin::Mcp {
312            server_id,
313            server_tool_name,
314        } => (
315            ToolId::mcp(&server_id, &server_tool_name),
316            CapabilitySource::Mcp {
317                server_id: bounded_identity(&server_id),
318                server_tool_name: bounded_identity(&server_tool_name),
319            },
320            TrustProvenance::McpConfig {
321                server_id: bounded_identity(&server_id),
322            },
323        ),
324        ToolOrigin::Plugin {
325            plugin_id,
326            tool_name,
327        } => (
328            ToolId::plugin(&plugin_id, &tool_name),
329            CapabilitySource::Plugin {
330                plugin_id: bounded_identity(&plugin_id),
331                tool_name: bounded_identity(&tool_name),
332            },
333            TrustProvenance::PluginConfig {
334                plugin_id: bounded_identity(&plugin_id),
335            },
336        ),
337        ToolOrigin::Unknown => (
338            ToolId::unclassified(tool.name()),
339            CapabilitySource::Unknown {
340                runtime_name: bounded_identity(tool.name()),
341            },
342            TrustProvenance::Unverified,
343        ),
344    }
345}
346
347/// Typed catalog mutation failures. All fail closed without advancing the
348/// catalog generation or partially applying the mutation.
349#[derive(Clone, Debug, Error, PartialEq, Eq)]
350pub enum CatalogError {
351    #[error("duplicate tool id in catalog: {0}")]
352    DuplicateToolId(ToolId),
353    /// A batch registration collided on a RUNTIME name — against a tool
354    /// already live in the registry or another batch member. Boot batches
355    /// are strictly additive: replacement is never permitted through them.
356    /// The echoed name is bounded, never raw hostile input.
357    #[error("duplicate runtime tool name in additive batch: {0}")]
358    DuplicateRuntimeName(String),
359    #[error("invalid tool id: {0}")]
360    InvalidToolId(#[from] ToolIdError),
361    #[error("catalog mutation rejected: {0}")]
362    GenerationExhausted(#[from] CatalogGenerationOverflow),
363}
364
365/// Inventory of all locally known capabilities, keyed by stable [`ToolId`]
366/// in deterministic order, with a generation that advances on every mutation.
367#[derive(Clone, Debug)]
368pub struct ToolCatalog {
369    entries: BTreeMap<ToolId, CapabilityRecord>,
370    generation: CatalogGeneration,
371}
372
373impl ToolCatalog {
374    pub fn empty() -> Self {
375        Self {
376            entries: BTreeMap::new(),
377            generation: CatalogGeneration::initial(),
378        }
379    }
380
381    /// Boundary-test support: an empty catalog resumed at an explicit
382    /// generation. Not used by production paths.
383    ///
384    /// Deliberately NOT gated behind `#[cfg(any(test, feature = "testing"))]`:
385    /// this crate's integration tests (e.g. `tests/registry_catalog.rs`)
386    /// compile the library without `cfg(test)` and are run without the
387    /// `testing` feature (it is enabled only through `synaps-tui`/workspace
388    /// wiring), so cfg-gating would break their imports. `#[doc(hidden)]`
389    /// keeps it out of the public API surface instead.
390    #[doc(hidden)]
391    pub fn resume_at_generation_for_tests(generation: CatalogGeneration) -> Self {
392        Self {
393            entries: BTreeMap::new(),
394            generation,
395        }
396    }
397
398    /// Boundary-test support: overwrite the generation counter in place.
399    /// Grants nothing, exposes nothing, and mutates no entries; only the
400    /// counter used by fail-closed advancement checks is changed. Doc-hidden
401    /// instead of cfg-gated for the same reason as
402    /// [`Self::resume_at_generation_for_tests`] (plain integration tests
403    /// must keep compiling without the `testing` feature).
404    #[doc(hidden)]
405    pub fn set_generation_for_tests(&mut self, generation: CatalogGeneration) {
406        self.generation = generation;
407    }
408
409    /// Read-only snapshot of the catalog integrated into a live registry.
410    ///
411    /// Kept as a compatibility shim over [`super::ToolRegistry::catalog`];
412    /// the registry maintains its catalog through every construction and
413    /// mutation path, so this is a pure read with no process start, network
414    /// access, schema exposure, or execution grant.
415    pub fn from_registry(registry: &super::ToolRegistry) -> Result<Self, CatalogError> {
416        Ok(registry.catalog().clone())
417    }
418
419    pub fn generation(&self) -> CatalogGeneration {
420        self.generation
421    }
422
423    pub fn len(&self) -> usize {
424        self.entries.len()
425    }
426
427    pub fn is_empty(&self) -> bool {
428        self.entries.is_empty()
429    }
430
431    pub fn get(&self, id: &ToolId) -> Option<&CapabilityRecord> {
432        self.entries.get(id)
433    }
434
435    /// Iterate records in deterministic `ToolId` order.
436    pub fn iter(&self) -> impl Iterator<Item = &CapabilityRecord> {
437        self.entries.values()
438    }
439
440    /// Insert one capability. Duplicate IDs fail closed (no silent
441    /// replacement, no generation advance); generation exhaustion fails
442    /// closed (no entry change). On success the catalog generation advances
443    /// by exactly one. The record's factory is stored, never invoked.
444    pub fn insert(&mut self, record: CapabilityRecord) -> Result<(), CatalogError> {
445        if self.entries.contains_key(&record.id) {
446            return Err(CatalogError::DuplicateToolId(record.id.clone()));
447        }
448        let next = self.generation.checked_next()?;
449        self.entries.insert(record.id.clone(), record);
450        self.generation = next;
451        Ok(())
452    }
453
454    /// Insert-or-replace one capability as a single mutation, optionally
455    /// dropping the entry it shadows (registry replacement by runtime name
456    /// can change the capability identity; the stale identity must not
457    /// linger and keep validating old grants).
458    ///
459    /// Occupied-identity safety: if the incoming id is already cataloged and
460    /// it is NOT the unchanged identity of the same-runtime-name tool being
461    /// replaced (`replaced == Some(record.id)`), the mutation fails typed
462    /// with [`CatalogError::DuplicateToolId`] before any entry or generation
463    /// change — two distinct runtime names must never silently share one
464    /// capability identity, or live tools and catalog would diverge. Fails
465    /// closed without touching entries when no new generation is available.
466    pub fn upsert(
467        &mut self,
468        replaced: Option<&ToolId>,
469        record: CapabilityRecord,
470    ) -> Result<(), CatalogError> {
471        let same_identity_replacement = replaced == Some(&record.id);
472        if !same_identity_replacement && self.entries.contains_key(&record.id) {
473            return Err(CatalogError::DuplicateToolId(record.id.clone()));
474        }
475        let next = self.generation.checked_next()?;
476        if let Some(stale) = replaced {
477            if stale != &record.id {
478                self.entries.remove(stale);
479            }
480        }
481        self.entries.insert(record.id.clone(), record);
482        self.generation = next;
483        Ok(())
484    }
485
486    /// Advance strictly past both this catalog's generation and a prior
487    /// registry generation. Used when a mutation rebuilds the catalog from
488    /// scratch (e.g. `disable`): the rebuilt catalog must not reuse
489    /// generation values already observed, or stale grants could survive.
490    pub fn rebase_past(
491        &mut self,
492        prior: CatalogGeneration,
493    ) -> Result<(), CatalogGenerationOverflow> {
494        self.generation = self.generation.max(prior).checked_next()?;
495        Ok(())
496    }
497}
498
499// ── DiscoveryIndex (Task 15, spec §7.1) ─────────────────────────────────────
500
501/// Maximum UTF-8 byte length of a discovery query.
502pub const QUERY_MAX_BYTES: usize = 256;
503/// Hard cap on the per-search result count budget.
504pub const SEARCH_MAX_RESULTS_CAP: usize = 64;
505/// Hard cap on the per-search serialized byte budget (64 KiB).
506pub const SEARCH_MAX_RESULT_BYTES_CAP: usize = 64 * 1024;
507/// Minimum per-search serialized byte budget: the empty serialized result
508/// collection (`[]`) is 2 bytes, so no smaller budget can be honest.
509pub const SEARCH_MIN_RESULT_BYTES: usize = 2;
510
511/// Typed failure for boundary parsing of a [`DiscoveryQuery`].
512#[derive(Clone, Debug, Error, PartialEq, Eq)]
513pub enum DiscoveryQueryError {
514    #[error("discovery query is empty")]
515    Empty,
516    #[error("discovery query is oversized: {actual} bytes exceeds limit {limit}")]
517    Oversized { actual: usize, limit: usize },
518    #[error("discovery query contains control characters")]
519    ControlCharacters,
520}
521
522/// A validated, bounded, case-folded discovery query. Parse-at-boundary:
523/// empty (or whitespace-only), oversized, and control-character-bearing
524/// input fails typed before any search runs.
525#[derive(Clone, Debug, PartialEq, Eq)]
526pub struct DiscoveryQuery {
527    needle: String,
528}
529
530impl DiscoveryQuery {
531    pub fn parse(raw: &str) -> Result<Self, DiscoveryQueryError> {
532        if raw.len() > QUERY_MAX_BYTES {
533            return Err(DiscoveryQueryError::Oversized {
534                actual: raw.len(),
535                limit: QUERY_MAX_BYTES,
536            });
537        }
538        let trimmed = raw.trim();
539        if trimmed.is_empty() {
540            return Err(DiscoveryQueryError::Empty);
541        }
542        // Reject C0/C1/DEL control values (newline, ESC/ANSI, NUL, …) in the
543        // retained needle: the search haystack joins id/summary/tag fields
544        // with `\n`, so an embedded newline could match across field
545        // boundaries, and control bytes must never survive into logs.
546        if trimmed.chars().any(char::is_control) {
547            return Err(DiscoveryQueryError::ControlCharacters);
548        }
549        Ok(Self {
550            needle: trimmed.to_lowercase(),
551        })
552    }
553
554    /// The case-folded needle actually matched against descriptors.
555    pub fn needle(&self) -> &str {
556        &self.needle
557    }
558}
559
560/// Typed failure for constructing [`SearchLimits`]. Zero budgets would make
561/// every search vacuous; sub-minimum byte budgets could not even hold the
562/// empty serialized result collection; over-cap budgets would defeat the
563/// bound.
564#[derive(Clone, Debug, Error, PartialEq, Eq)]
565pub enum SearchLimitsError {
566    #[error("search result count budget must be positive")]
567    ZeroResults,
568    #[error("search byte budget must be positive")]
569    ZeroBytes,
570    #[error(
571        "search byte budget {actual} is below the serialized empty result collection size {min}"
572    )]
573    BytesBelowMinimum { actual: usize, min: usize },
574    #[error("search result count budget {actual} exceeds cap {cap}")]
575    ResultsOverCap { actual: usize, cap: usize },
576    #[error("search byte budget {actual} exceeds cap {cap}")]
577    BytesOverCap { actual: usize, cap: usize },
578}
579
580/// Validated positive, capped search budgets. The byte budget bounds the
581/// exact compact-JSON serialization of the returned hit collection as an
582/// array (entries plus `[`/`]` and comma overhead), so a caller can rely on
583/// `serde_json::to_vec(results.hits())` never exceeding it.
584#[derive(Clone, Copy, Debug, PartialEq, Eq)]
585pub struct SearchLimits {
586    max_results: usize,
587    max_result_bytes: usize,
588}
589
590impl SearchLimits {
591    pub fn new(max_results: usize, max_result_bytes: usize) -> Result<Self, SearchLimitsError> {
592        if max_results == 0 {
593            return Err(SearchLimitsError::ZeroResults);
594        }
595        if max_result_bytes == 0 {
596            return Err(SearchLimitsError::ZeroBytes);
597        }
598        if max_result_bytes < SEARCH_MIN_RESULT_BYTES {
599            return Err(SearchLimitsError::BytesBelowMinimum {
600                actual: max_result_bytes,
601                min: SEARCH_MIN_RESULT_BYTES,
602            });
603        }
604        if max_results > SEARCH_MAX_RESULTS_CAP {
605            return Err(SearchLimitsError::ResultsOverCap {
606                actual: max_results,
607                cap: SEARCH_MAX_RESULTS_CAP,
608            });
609        }
610        if max_result_bytes > SEARCH_MAX_RESULT_BYTES_CAP {
611            return Err(SearchLimitsError::BytesOverCap {
612                actual: max_result_bytes,
613                cap: SEARCH_MAX_RESULT_BYTES_CAP,
614            });
615        }
616        Ok(Self {
617            max_results,
618            max_result_bytes,
619        })
620    }
621
622    pub fn max_results(&self) -> usize {
623        self.max_results
624    }
625
626    pub fn max_result_bytes(&self) -> usize {
627        self.max_result_bytes
628    }
629}
630
631/// Serializable source-class label. A label only — no raw runtime identity
632/// strings, handlers, or process data.
633#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
634#[serde(rename_all = "snake_case")]
635pub enum SourceClass {
636    Builtin,
637    Extension,
638    Mcp,
639    Plugin,
640    Unknown,
641}
642
643impl SourceClass {
644    fn of(source: &CapabilitySource) -> Self {
645        match source {
646            CapabilitySource::Builtin => Self::Builtin,
647            CapabilitySource::Extension { .. } => Self::Extension,
648            CapabilitySource::Mcp { .. } => Self::Mcp,
649            CapabilitySource::Plugin { .. } => Self::Plugin,
650            CapabilitySource::Unknown { .. } => Self::Unknown,
651        }
652    }
653}
654
655/// One compact, serializable discovery descriptor. By construction it holds
656/// no schema locator, no factory, no extension/MCP handler or process data,
657/// and no raw unbounded source strings — only the stable id, bounded
658/// summary/tags (already bounded at catalog time), a source-class label, and
659/// digest metadata.
660#[derive(Clone, Debug, serde::Serialize)]
661pub struct DiscoveryEntry {
662    id: ToolId,
663    source_class: SourceClass,
664    summary: String,
665    tags: Vec<String>,
666    schema_digest: SchemaDigest,
667}
668
669impl DiscoveryEntry {
670    pub fn id(&self) -> &ToolId {
671        &self.id
672    }
673
674    pub fn source_class(&self) -> SourceClass {
675        self.source_class
676    }
677
678    pub fn summary(&self) -> &str {
679        &self.summary
680    }
681
682    pub fn tags(&self) -> &[String] {
683        &self.tags
684    }
685
686    pub fn schema_digest(&self) -> &SchemaDigest {
687        &self.schema_digest
688    }
689}
690
691/// Typed failure for building a [`DiscoveryIndex`].
692#[derive(Clone, Debug, Error, PartialEq, Eq)]
693pub enum DiscoveryIndexError {
694    #[error("failed to serialize compact descriptor for {id}: {detail}")]
695    DescriptorSerialization { id: ToolId, detail: String },
696}
697
698/// Prebuilt search row: the compact entry, its exact serialized byte cost
699/// (computed once at build so budget accounting includes JSON metadata
700/// overhead), and its case-folded haystack.
701#[derive(Clone, Debug)]
702struct IndexRow {
703    entry: DiscoveryEntry,
704    serialized_bytes: usize,
705    haystack: String,
706}
707
708/// Deterministic search outcome. `truncated` is true whenever any matching
709/// entry was withheld by the count or byte budget, so callers never mistake
710/// a bounded result for the complete one.
711#[derive(Debug)]
712pub struct SearchResults<'a> {
713    generation: CatalogGeneration,
714    hits: Vec<&'a DiscoveryEntry>,
715    serialized_bytes: usize,
716    truncated: bool,
717}
718
719impl<'a> SearchResults<'a> {
720    /// The catalog generation this index snapshot was built from.
721    pub fn generation(&self) -> CatalogGeneration {
722        self.generation
723    }
724
725    pub fn hits(&self) -> &[&'a DiscoveryEntry] {
726        &self.hits
727    }
728
729    /// Exact compact-JSON byte size of the returned hit collection serialized
730    /// as an array — equal to `serde_json::to_vec(self.hits()).len()`,
731    /// including the `[`/`]` container (2 bytes when empty) and separating
732    /// commas. Always ≤ the byte budget the search ran with.
733    pub fn serialized_bytes(&self) -> usize {
734        self.serialized_bytes
735    }
736
737    pub fn truncated(&self) -> bool {
738        self.truncated
739    }
740}
741
742/// Bounded searchable snapshot of catalog compact descriptors (spec §7.1).
743///
744/// Building and searching are local and pure: no implementation acquisition,
745/// no execution, no process start, no network, no full-schema read beyond
746/// the digest already computed at catalog time, and no registry exposure
747/// change. The snapshot pins the catalog generation; callers detect drift
748/// with [`DiscoveryIndex::is_stale`] and rebuild instead of silently serving
749/// stale records.
750#[derive(Clone, Debug)]
751pub struct DiscoveryIndex {
752    generation: CatalogGeneration,
753    rows: Vec<IndexRow>,
754}
755
756impl DiscoveryIndex {
757    /// Build the index from catalog compact descriptors only. Reads id,
758    /// source class, bounded summary/tags, and schema digest — never the
759    /// schema locator or the factory.
760    pub fn build(catalog: &ToolCatalog) -> Result<Self, DiscoveryIndexError> {
761        let mut rows = Vec::with_capacity(catalog.len());
762        // `catalog.iter()` is deterministic ToolId order; rows inherit it.
763        for record in catalog.iter() {
764            let entry = DiscoveryEntry {
765                id: record.id().clone(),
766                source_class: SourceClass::of(record.source()),
767                summary: record.summary().to_string(),
768                tags: record.tags().to_vec(),
769                schema_digest: record.schema_digest().clone(),
770            };
771            let serialized_bytes = serde_json::to_vec(&entry)
772                .map_err(|err| DiscoveryIndexError::DescriptorSerialization {
773                    id: entry.id.clone(),
774                    detail: err.to_string(),
775                })?
776                .len();
777            let mut haystack = entry.id.as_str().to_lowercase();
778            haystack.push('\n');
779            haystack.push_str(&entry.summary.to_lowercase());
780            for tag in &entry.tags {
781                haystack.push('\n');
782                haystack.push_str(&tag.to_lowercase());
783            }
784            rows.push(IndexRow {
785                entry,
786                serialized_bytes,
787                haystack,
788            });
789        }
790        Ok(Self {
791            generation: catalog.generation(),
792            rows,
793        })
794    }
795
796    /// The catalog generation this snapshot was built from.
797    pub fn generation(&self) -> CatalogGeneration {
798        self.generation
799    }
800
801    /// True when the catalog has mutated since this snapshot was built. A
802    /// stale index must be rebuilt, not silently served.
803    pub fn is_stale(&self, catalog: &ToolCatalog) -> bool {
804        self.generation != catalog.generation()
805    }
806
807    /// Deterministic, bounded, pure substring search over compact
808    /// descriptors. Entries are visited in ToolId order; a matching entry is
809    /// returned only if it fits both the remaining count budget and the
810    /// remaining serialized byte budget. The byte budget accounts the exact
811    /// compact-JSON array serialization of the returned hits — container
812    /// brackets (2 bytes even when empty) plus one comma per additional
813    /// entry — so `serde_json::to_vec` of the hit collection can never
814    /// exceed it. The first match that does not fit stops the scan
815    /// (documented bounded behavior; `truncated` reports it).
816    pub fn search(&self, query: &DiscoveryQuery, limits: &SearchLimits) -> SearchResults<'_> {
817        let mut hits = Vec::new();
818        // Serialized cost so far of `hits` as a compact JSON array: `[]` is
819        // 2 bytes; each entry adds its own bytes plus a comma when it is not
820        // the first.
821        let mut used_bytes = SEARCH_MIN_RESULT_BYTES;
822        let mut truncated = false;
823        for row in &self.rows {
824            if !row.haystack.contains(query.needle()) {
825                continue;
826            }
827            let separator = usize::from(!hits.is_empty());
828            let next_bytes = used_bytes
829                .checked_add(separator)
830                .and_then(|bytes| bytes.checked_add(row.serialized_bytes));
831            let fits = match next_bytes {
832                Some(bytes) => bytes <= limits.max_result_bytes(),
833                // Overflow can only mean "far beyond any budget".
834                None => false,
835            };
836            if hits.len() == limits.max_results() || !fits {
837                truncated = true;
838                break;
839            }
840            used_bytes = next_bytes.expect("fits implies no overflow");
841            hits.push(&row.entry);
842        }
843        SearchResults {
844            generation: self.generation,
845            hits,
846            serialized_bytes: used_bytes,
847            truncated,
848        }
849    }
850}