Skip to main content

rpi_extensions/
registry.rs

1//! The extension registry — accumulates registrations during a plugin's
2//! `rpi_plugin_register` call, then snapshots for the host session.
3//!
4//! `HostApi` (in [`crate`]) holds an `ExtensionRegistry` behind a `Mutex` while
5//! the plugin's register trampolines run; after register completes the host
6//! [`take`]s it and builds a [`RegistrySnapshot`] the session keeps for its
7//! lifetime. Staleness across a session swap / `/reload` is guarded by a shared
8//! `Arc<AtomicBool>` "active" flag ([`assert_active`]).
9//!
10//! [`take`]: crate::HostApi::take_registry
11
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::Arc;
14
15use rpi_agent::error::AgentError;
16use rpi_ai::types::Tool;
17use rpi_plugin_sdk::{
18    BeforeAgentStartFn, CommandHandlerFn, EventHandlerFn, EventTag, FreeStringFn, OAuthRequestFn,
19    ProviderRequestFn, ProviderStreamRequestFn, RenderFn, ResourcesDiscoverFn, EVENT_TAG_COUNT,
20};
21
22use crate::tool::PluginToolHandle;
23
24/// A provider package's catalog and authentication policy, supplied through
25/// `declare({"providers":[...]})`. Credentials are always resolved by the host.
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "camelCase", deny_unknown_fields)]
28pub struct ProviderManifest {
29    pub id: String,
30    pub models: Vec<rpi_ai::Model>,
31    #[serde(default)]
32    pub api_key_env: Option<String>,
33    #[serde(default = "requires_auth_default")]
34    pub requires_auth: bool,
35}
36
37fn requires_auth_default() -> bool {
38    true
39}
40
41// ---------------------------------------------------------------------------
42// Registered-item records
43// ---------------------------------------------------------------------------
44
45/// A tool an extension registered: the provider-facing [`Tool`] schema + the
46/// plugin's 4-function handle ([`PluginToolHandle`]) the host drives via
47/// [`PluginToolAdapter`](crate::PluginToolAdapter).
48///
49/// `tool` is public so the host can merge schemas; `handle` is crate-private
50/// (only [`PluginToolAdapter`](crate::PluginToolAdapter) drives it). `Clone`
51/// derives because [`Tool`] is `Clone` and [`PluginToolHandle`] is `Copy`.
52#[derive(Clone)]
53pub struct ExtensionTool {
54    /// The provider-facing tool definition (name/description/parameters).
55    pub tool: Tool,
56    pub(crate) handle: PluginToolHandle,
57}
58
59impl ExtensionTool {
60    pub fn new(tool: Tool, handle: PluginToolHandle) -> Self {
61        Self { tool, handle }
62    }
63
64    /// The plugin-side function handles backing this tool. `Copy` (fn pointers
65    /// + a `FreeStringFn`), so handing it out is free. Public so the host
66    /// (e.g. rpi-cli's session builder) can wrap a registered tool in a
67    /// [`PluginToolAdapter`](crate::PluginToolAdapter) outside this crate.
68    pub fn handle(&self) -> PluginToolHandle {
69        self.handle
70    }
71}
72
73/// A registered slash command and its plugin callback.
74/// The host supplies an opaque owner-scoped runtime handle, not plugin memory.
75/// The plugin makes its callback safe to invoke from any host thread.
76#[derive(Clone)]
77pub struct RegisteredCommand {
78    pub name: String,
79    pub description: String,
80    pub handler: CommandHandlerFn,
81    pub user_data: *mut std::ffi::c_void,
82}
83
84/// A keyboard shortcut declared by a native extension (e.g. `"space"` for
85/// push-to-talk). The host records it so the TUI can route keys to it; the
86/// owning plugin receives the key via its `Input` event handler.
87///
88/// `key` is a normalized key name (`"space"`, `"enter"`, `"c"`, `"f1"`)
89/// matching the editor's keybinding vocabulary.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct RegisteredShortcut {
92    pub key: String,
93    pub description: String,
94    /// The owning extension's display name (host-side only, for diagnostics).
95    pub plugin: String,
96}
97
98// SAFETY: user_data is an opaque host registry handle (never dereferenced),
99// and plugin callbacks support invocation from any host thread.
100unsafe impl Send for RegisteredCommand {}
101unsafe impl Sync for RegisteredCommand {}
102
103/// A CLI flag declared by a native extension. Values are supplied by the
104/// host's parsed `Args::unknown_flags` map and read by the plugin through the
105/// `RuntimeActionId::GetCliFlag` action.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct RegisteredFlag {
108    pub name: String,
109    pub description: String,
110}
111
112/// A registered `on(tag)` event handler. `user_data` is the plugin's opaque
113/// context, passed back unchanged on every dispatch. `plugin` is the display
114/// name of the owning extension (host-side only — stamped from the cdylib path
115/// at registration; not part of the ABI), used for lifecycle veto diagnostics
116/// ("extension `foo` aborted `before_tui_start`").
117///
118/// `priority`/`platforms` are the owning plugin's **declaration** (P2): the
119/// snapshot sorts handlers by `priority` (smaller first, stable), and dispatch
120/// skips a handler whose non-empty `platforms` excludes the host platform.
121/// Both default to `100` / empty (all platforms); a plugin sets them via the
122/// unified C ABI `declare` callback.
123///
124/// SAFETY contract: the plugin guarantees `handler` is safe to call from any
125/// thread (the host dispatches from the async emitter thread) and `user_data`
126/// is valid for the registry's lifetime. The host never frees `user_data`
127/// (plugin-owned).
128#[derive(Clone)]
129pub struct RegisteredHandler {
130    /// Display name of the owning extension (from the cdylib file stem).
131    pub plugin: String,
132    /// Dispatch order (smaller runs first). Default `100`.
133    pub priority: i32,
134    /// Platforms this handler's plugin supports (empty = all). Matched against
135    /// [`current_platform`].
136    pub platforms: Vec<String>,
137    pub handler: EventHandlerFn,
138    pub user_data: *mut std::ffi::c_void,
139}
140// SAFETY: fn pointers are Send+Sync; `user_data` is an opaque plugin pointer the
141// plugin warrants is thread-safe to pass to `handler` from any thread. The host
142// only reads it through `handler`.
143unsafe impl Send for RegisteredHandler {}
144unsafe impl Sync for RegisteredHandler {}
145
146/// A registered `resources_discover` handler (B5b). The `out` [`StbString`] the
147/// handler produces is **plugin-owned**, so the host reclaims it via the
148/// plugin's own `plugin_free_string` traveled alongside. `user_data` is the
149/// plugin's opaque context. SAFETY: same as [`RegisteredHandler`] — the plugin
150/// warrants `handler` is callable from any thread and `user_data` is valid for
151/// the registry's lifetime; the host never frees `user_data`.
152#[derive(Clone, Copy)]
153pub struct ResourcesDiscoverHandler {
154    pub handler: ResourcesDiscoverFn,
155    pub plugin_free_string: FreeStringFn,
156    pub user_data: *mut std::ffi::c_void,
157}
158// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
159// thread-safe; the host only reads `user_data` through `handler`.
160unsafe impl Send for ResourcesDiscoverHandler {}
161unsafe impl Sync for ResourcesDiscoverHandler {}
162
163/// A registered `before_agent_start` handler (system-prompt transform). Same
164/// ownership shape as [`ResourcesDiscoverHandler`]: the handler produces a
165/// **plugin-owned** `out` [`StbString`] the host reclaims via the plugin's own
166/// `plugin_free_string`, which therefore travels alongside. `user_data` is the
167/// plugin's opaque context.
168///
169/// SAFETY: same as [`RegisteredHandler`] — the plugin warrants `handler` is
170/// callable from any thread and `user_data` is valid for the registry's
171/// lifetime; the host never frees `user_data`.
172#[derive(Clone, Copy)]
173pub struct BeforeAgentStartHandler {
174    pub handler: BeforeAgentStartFn,
175    pub plugin_free_string: FreeStringFn,
176    pub user_data: *mut std::ffi::c_void,
177}
178// SAFETY: fn pointers + an opaque plugin pointer the plugin warrants is
179// thread-safe; the host only reads `user_data` through `handler`.
180unsafe impl Send for BeforeAgentStartHandler {}
181unsafe impl Sync for BeforeAgentStartHandler {}
182
183/// A registered custom provider (B5c). The host wraps `request_fn` in a
184/// [`PluggableProvider`](crate::PluggableProvider) impl of `rpi_ai::Provider`;
185/// its `stream_simple` drives `request_fn` on `spawn_blocking` (the sync fn
186/// can't own a chunked stream), reads the **plugin-owned** `out` JSON (a full
187/// assistant message), reclaims it via `plugin_free_string`, parses it to an
188/// [`AssistantMessage`](rpi_ai::types::AssistantMessage), and emits it as one
189/// terminal `Done` chunk (v1 one-shot, documented divergence from pi's async
190/// streaming). `provider_id`/`base_url`/`api_style` carry the provider's
191/// identity (copied from the borrowed `StbStringRef`s at registration); the fn
192/// pointers + `user_data` live as long as the plugin (keepalive-mapped).
193/// `user_data` is the plugin's opaque context, passed back on every
194/// `request_fn` call.
195///
196/// `stream_fn` is the **streaming** capability ([`ProviderStreamRequestFn`],
197/// registered through `register_streaming_provider`). When present the host
198/// drives it instead of `request_fn`: the plugin emits per-chunk envelopes that
199/// the host converts into `AssistantMessageEvent`s *as they arrive*, then the
200/// same plugin-owned `out` message still supplies the terminal event. `None`
201/// keeps the v1 one-shot behavior, so an existing plugin is unaffected.
202///
203/// That single signature also carries the cancellation protocol: the host always
204/// passes a [`ProviderStreamControl`](rpi_plugin_sdk::ProviderStreamControl), and
205/// whether the plugin polls it is the plugin's choice. There is deliberately no
206/// separate "cancellable" registration — one method, and the caller decides.
207///
208/// SAFETY: the plugin warrants `request_fn`/`stream_fn` are callable from any
209/// thread (the host calls them from a `spawn_blocking` pool thread) and
210/// `user_data` is valid for the plugin's lifetime; the host never frees
211/// `user_data`.
212#[derive(Clone)]
213pub struct RegisteredProvider {
214    pub provider_id: String,
215    pub base_url: String,
216    pub api_style: String,
217    pub request_fn: ProviderRequestFn,
218    /// Streaming request fn (streaming providers only).
219    pub stream_fn: Option<ProviderStreamRequestFn>,
220    pub plugin_free_string: FreeStringFn,
221    pub user_data: *mut std::ffi::c_void,
222}
223
224/// A provider-owned OAuth implementation. `manifest_json` is validated JSON
225/// copied from the plugin; the callback receives action envelopes such as
226/// `{\"action\":\"begin\"}` and returns plugin-owned JSON.
227#[derive(Clone)]
228pub struct RegisteredOAuthProvider {
229    /// The owning extension display name, used to scope credentials and
230    /// diagnostics. This is host-owned metadata and is not supplied by the
231    /// plugin.
232    pub plugin: String,
233    pub manifest_json: String,
234    pub request_fn: OAuthRequestFn,
235    pub plugin_free_string: FreeStringFn,
236    pub user_data: *mut std::ffi::c_void,
237}
238// SAFETY: as for RegisteredProvider, the plugin must keep its opaque context
239// live and make request_fn safe to call from any host thread.
240unsafe impl Send for RegisteredOAuthProvider {}
241unsafe impl Sync for RegisteredOAuthProvider {}
242// SAFETY: fn pointers + owned `String`s + an opaque plugin pointer the plugin
243// warrants is thread-safe; the host never frees `user_data`.
244unsafe impl Send for RegisteredProvider {}
245unsafe impl Sync for RegisteredProvider {}
246
247/// A registered message/markdown/entry renderer (B5c). The interactive TUI
248/// consumes all three kinds through the JSON component adapter. `render_fn` produces a
249/// **plugin-owned** `out` [`StbString`] the host reclaims via
250/// `plugin_free_string`; `user_data` is passed back on every render call. `name`
251/// is copied from the borrowed `StbStringRef` at registration.
252///
253/// SAFETY: same as [`RegisteredProvider`].
254#[derive(Clone)]
255pub struct RegisteredRenderer {
256    pub name: String,
257    pub kind: RegisteredRendererKind,
258    pub render_fn: RenderFn,
259    pub plugin_free_string: FreeStringFn,
260    pub user_data: *mut std::ffi::c_void,
261}
262
263/// Which render path this renderer targets — mirrors the three distinct
264/// `register_*` slots (`register_message_renderer` /
265/// `register_markdown_transformer` / `register_entry_renderer`).
266#[derive(Clone, Copy, PartialEq, Eq)]
267pub enum RegisteredRendererKind {
268    Message,
269    Markdown,
270    Entry,
271}
272// SAFETY: same as [`RegisteredProvider`] — fn pointers + owned `String` + an
273// opaque plugin pointer the plugin warrants is thread-safe.
274unsafe impl Send for RegisteredRenderer {}
275unsafe impl Sync for RegisteredRenderer {}
276
277/// One flat registration record, for iteration/diagnostics. Built on demand
278/// from the typed vecs in [`RegistrySnapshot`].
279#[derive(Clone)]
280pub enum RegistryEntry {
281    Tool(ExtensionTool),
282    Command(RegisteredCommand),
283    Flag(RegisteredFlag),
284}
285
286// ---------------------------------------------------------------------------
287// P2 — plugin declarations (priority + platforms)
288// ---------------------------------------------------------------------------
289
290/// Default dispatch priority for a plugin that declares none. Handlers run
291/// smallest-priority-first; ties break by registration order.
292pub const DEFAULT_PRIORITY: i32 = 100;
293
294/// The host platform, as matched against a plugin's declared `platforms`.
295/// Platform set used by extension capability declarations.
296pub fn current_platform() -> &'static str {
297    if cfg!(target_os = "android") {
298        "android"
299    } else if cfg!(target_os = "linux") {
300        // Termux reports `target_os = "android"` above; a plain Linux is here.
301        "linux"
302    } else if cfg!(target_os = "macos") {
303        "macos"
304    } else if cfg!(target_os = "windows") {
305        "windows"
306    } else {
307        "unknown"
308    }
309}
310
311/// Whether a handler with the given declared `platforms` should run on this
312/// host. Empty `platforms` = all platforms; a non-empty list requires the host
313/// platform to be present (`"all"` is accepted as a wildcard).
314pub fn platform_allows(platforms: &[String]) -> bool {
315    if platforms.is_empty() {
316        return true;
317    }
318    let here = current_platform();
319    platforms
320        .iter()
321        .any(|p| p.eq_ignore_ascii_case("all") || p.eq_ignore_ascii_case(here))
322}
323
324// ---------------------------------------------------------------------------
325// ExtensionRegistry — accumulated during register, then snapshotted
326// ---------------------------------------------------------------------------
327
328/// Accumulates registrations from one or more plugins' `rpi_plugin_register`
329/// calls. Held by [`HostApi`](crate::HostApi) during register; the host then
330/// [`take_registry`](crate::HostApi::take_registry) and builds a snapshot.
331///
332/// Merge semantics mirror pi: **first-registration-wins** on tool/command name
333/// collision (a later registration for an existing name is dropped, not an
334/// overwrite). Event handlers accumulate (multiple per tag — fan-out).
335pub struct ExtensionRegistry {
336    tools: Vec<ExtensionTool>,
337    commands: Vec<RegisteredCommand>,
338    flags: Vec<RegisteredFlag>,
339    /// Keyboard shortcuts declared by plugins (first-wins by `key`). Recorded
340    /// so the TUI can route a key press/release to the owning plugin's `Input`
341    /// event handler instead of consuming it as editor text.
342    shortcuts: Vec<RegisteredShortcut>,
343    /// `handlers[tag as usize]` — all handlers subscribed to that tag.
344    handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
345    /// `resources_discover` handlers (B5b). Fan-out on discovery, in registration
346    /// order. Unlike event handlers (which key off a tag in a fixed array), these
347    /// are a single flat list — `resources_discover` has its own out-param
348    /// signature and is never dispatched through the fire-and-forget event path.
349    resources_discover: Vec<ResourcesDiscoverHandler>,
350    /// `before_agent_start` system-prompt transformers. A flat list for the same
351    /// reason as `resources_discover`: this is an out-param handler (the plugin
352    /// returns the prompt to install), not a fire-and-forget event subscription,
353    /// so it is not indexed by a tag. Chained in registration order.
354    before_agent_start: Vec<BeforeAgentStartHandler>,
355    /// Registered custom providers (B5c). The host wraps each in a
356    /// [`PluggableProvider`](crate::PluggableProvider). First-registration-wins on
357    /// `provider_id`.
358    providers: Vec<RegisteredProvider>,
359    provider_manifests: std::collections::BTreeMap<String, ProviderManifest>,
360    /// Provider-owned OAuth implementations. First-registration-wins on the
361    /// manifest `id` field.
362    oauth_providers: Vec<RegisteredOAuthProvider>,
363    /// Registered renderers (B5c), split by kind at registration. First-wins on
364    /// `(kind, name)`. The host records these now; TUI consumption is B5e.
365    renderers: Vec<RegisteredRenderer>,
366    /// P2: the registering plugin's declared dispatch priority. Stamped onto
367    /// every handler it registers; a later `declare` also retro-updates them.
368    priority: i32,
369    /// P2: the registering plugin's declared platforms (empty = all).
370    platforms: Vec<String>,
371    /// A plugin's `{"ownsStdio":true}` claim: this host process's stdin/stdout
372    /// belong to the plugin (an ACP/MCP-style stdio server), so the host must
373    /// defer `read_piped_stdin` until after extension load and run the headless
374    /// `Rpc` lifecycle instead of Print/TUI. Monotonic across `absorb` (any
375    /// claiming plugin projects `true`). The scoped ActionBridge arbitrates
376    /// exclusivity before registries are merged. Default `false`.
377    owns_stdio: bool,
378    /// Shared staleness flag. `true` while the session owning this registry is
379    /// active; set `false` on swap/`/reload`. Tool/event dispatch checks it.
380    active: Arc<AtomicBool>,
381}
382
383impl Default for ExtensionRegistry {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389impl ExtensionRegistry {
390    /// Build an empty, active registry.
391    pub fn new() -> Self {
392        let handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT] =
393            std::array::from_fn(|_| Vec::new());
394        Self {
395            tools: Vec::new(),
396            commands: Vec::new(),
397            flags: Vec::new(),
398            shortcuts: Vec::new(),
399            handlers,
400            resources_discover: Vec::new(),
401            before_agent_start: Vec::new(),
402            providers: Vec::new(),
403            provider_manifests: std::collections::BTreeMap::new(),
404            oauth_providers: Vec::new(),
405            renderers: Vec::new(),
406            priority: DEFAULT_PRIORITY,
407            platforms: Vec::new(),
408            owns_stdio: false,
409            active: Arc::new(AtomicBool::new(true)),
410        }
411    }
412
413    /// Register a tool. Returns `true` if a **prior** tool of the same name was
414    /// kept (first-wins: the new one is dropped). Returns `false` on insert.
415    pub fn register_tool(&mut self, tool: Tool, handle: PluginToolHandle) -> bool {
416        if self.tools.iter().any(|t| t.tool.name == tool.name) {
417            // First-wins: keep the prior, drop the new handle. We must destroy
418            // the dropped handle's allocation? No — `handle` is just fn pointers
419            // + a FreeStringFn (all Copy, no allocation). Dropping it is a no-op.
420            // A real per-tool plugin allocation only exists after `execute`
421            // produces a StepHandle; we never call execute for a dropped
422            // registration, so nothing to free.
423            return true;
424        }
425        self.tools.push(ExtensionTool::new(tool, handle));
426        false
427    }
428
429    /// Register a slash command (first-wins by name). `true` if a prior was kept.
430    pub fn register_command(
431        &mut self,
432        name: String,
433        description: String,
434        handler: CommandHandlerFn,
435        user_data: *mut std::ffi::c_void,
436    ) -> bool {
437        if self.commands.iter().any(|c| c.name == name) {
438            return true;
439        }
440        self.commands.push(RegisteredCommand {
441            name,
442            description,
443            handler,
444            user_data,
445        });
446        false
447    }
448
449    /// Register a CLI flag (first-wins by name). Returns `true` when an
450    /// earlier extension already declared the same flag.
451    pub fn register_flag(&mut self, name: String, description: String) -> bool {
452        if self.flags.iter().any(|flag| flag.name == name) {
453            return true;
454        }
455        self.flags.push(RegisteredFlag { name, description });
456        false
457    }
458
459    /// Declare a keyboard shortcut (first-wins by `key`). Returns `true` when an
460    /// earlier extension already claimed the same key.
461    ///
462    /// Recording a shortcut does **not** bind the key to a callback: the owning
463    /// plugin receives presses/releases through its `Input` event handler and
464    /// decides what to do. The host uses this list only to know which keys are
465    /// claimed, so it can route them to extensions instead of the editor.
466    pub fn register_shortcut(&mut self, plugin: String, key: String, description: String) -> bool {
467        if self.shortcuts.iter().any(|s| s.key == key) {
468            return true;
469        }
470        self.shortcuts.push(RegisteredShortcut {
471            key,
472            description,
473            plugin,
474        });
475        false
476    }
477
478    /// Subscribe a handler to `tag`. Multiple handlers per tag are kept
479    /// (fan-out on dispatch). Always inserts; returns `false`. `plugin` is the
480    /// owning extension's display name (host-side only, for diagnostics).
481    pub fn register_event_handler(
482        &mut self,
483        plugin: String,
484        tag: EventTag,
485        handler: EventHandlerFn,
486        user_data: *mut std::ffi::c_void,
487    ) -> bool {
488        let idx = tag as usize;
489        if idx < EVENT_TAG_COUNT {
490            // Stamp the registering plugin's current declaration (P2). A plugin
491            // typically declares BEFORE registering handlers; `apply_declaration`
492            // also retro-updates any handler registered before the declare call.
493            self.handlers[idx].push(RegisteredHandler {
494                plugin,
495                priority: self.priority,
496                platforms: self.platforms.clone(),
497                handler,
498                user_data,
499            });
500        }
501        false
502    }
503
504    /// Apply a plugin's declaration parsed from the unified C ABI `declare` JSON
505    /// (`{"priority":60,"platforms":["linux","macos"]}`). Unknown keys are
506    /// ignored; malformed values leave the corresponding field unchanged.
507    /// Retro-updates any handlers already registered (so declare order doesn't
508    /// matter). Returns the number of fields applied.
509    pub fn apply_declaration(
510        &mut self,
511        priority: Option<i32>,
512        platforms: Option<Vec<String>>,
513    ) -> usize {
514        let mut applied = 0;
515        if let Some(priority) = priority {
516            self.priority = priority;
517            for tag_handlers in self.handlers.iter_mut() {
518                for h in tag_handlers.iter_mut() {
519                    h.priority = priority;
520                }
521            }
522            applied += 1;
523        }
524        if let Some(platforms) = platforms {
525            self.platforms = platforms.clone();
526            for tag_handlers in self.handlers.iter_mut() {
527                for h in tag_handlers.iter_mut() {
528                    h.platforms = platforms.clone();
529                }
530            }
531            applied += 1;
532        }
533        applied
534    }
535
536    /// Parse a v3 `declare` JSON payload and apply it (P2). Recognised keys:
537    /// `priority` (integer), `platforms` (array of strings) and `ownsStdio`
538    /// (boolean; see [`ExtensionRegistry::declared_owns_stdio`]). Returns `true`
539    /// if the payload parsed as JSON (even with no recognised keys), `false` on
540    /// a parse error.
541    pub fn apply_declaration_json(&mut self, json: &str) -> bool {
542        let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
543            return false;
544        };
545        if let Some(providers) = value.get("providers") {
546            let Ok(manifests) = serde_json::from_value::<Vec<ProviderManifest>>(providers.clone())
547            else {
548                return false;
549            };
550            if manifests.iter().any(|manifest| {
551                manifest.id.trim().is_empty()
552                    || manifest
553                        .models
554                        .iter()
555                        .any(|model| model.id.trim().is_empty() || model.provider != manifest.id)
556            }) {
557                return false;
558            }
559            for manifest in manifests {
560                self.provider_manifests
561                    .insert(manifest.id.clone(), manifest);
562            }
563        }
564        let priority = value
565            .get("priority")
566            .and_then(|v| v.as_i64())
567            .map(|v| v as i32);
568        let platforms = value
569            .get("platforms")
570            .and_then(|v| v.as_array())
571            .map(|arr| {
572                arr.iter()
573                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
574                    .collect::<Vec<String>>()
575            });
576        if let Some(owns) = value.get("ownsStdio").and_then(|v| v.as_bool()) {
577            // A claim, not a toggle: once a plugin owns stdio it stays claimed
578            // (and `absorb` ORs it across plugins).
579            self.owns_stdio = self.owns_stdio || owns;
580        }
581        self.apply_declaration(priority, platforms);
582        true
583    }
584
585    /// Register a `resources_discover` handler (B5b). Multiple handlers are kept
586    /// (fan-out on discovery, registration order). Always inserts; returns `false`.
587    pub fn register_resources_discover(
588        &mut self,
589        handler: ResourcesDiscoverFn,
590        plugin_free_string: FreeStringFn,
591        user_data: *mut std::ffi::c_void,
592    ) -> bool {
593        self.resources_discover.push(ResourcesDiscoverHandler {
594            handler,
595            plugin_free_string,
596            user_data,
597        });
598        false
599    }
600
601    /// Register a `before_agent_start` system-prompt transformer. Multiple
602    /// handlers are kept (chained in registration order: each sees the prompt the
603    /// previous one returned, mirroring pi `runner.ts:1329-1347`). Always
604    /// inserts; returns `false`.
605    pub fn register_before_agent_start(
606        &mut self,
607        handler: BeforeAgentStartFn,
608        plugin_free_string: FreeStringFn,
609        user_data: *mut std::ffi::c_void,
610    ) -> bool {
611        self.before_agent_start.push(BeforeAgentStartHandler {
612            handler,
613            plugin_free_string,
614            user_data,
615        });
616        false
617    }
618
619    /// Register a custom provider (B5c). First-registration-wins on `provider_id`
620    /// (a later registration for an existing id is dropped, mirroring tool/command
621    /// first-wins). Returns `true` if a prior provider of the same id was kept.
622    pub fn register_provider(&mut self, provider: RegisteredProvider) -> bool {
623        if self
624            .providers
625            .iter()
626            .any(|p| p.provider_id == provider.provider_id)
627        {
628            return true;
629        }
630        self.providers.push(provider);
631        false
632    }
633
634    /// Register the **streaming** form of a custom provider (see
635    /// [`RegisteredProvider::stream_fn`]).
636    ///
637    /// Differs from [`register_provider`](Self::register_provider) in one way:
638    /// upgrading an id that already has a **one-shot** registration replaces it
639    /// instead of being dropped, because streaming is a strict superset of the
640    /// one-shot contract — losing the upgrade to first-wins would silently keep
641    /// the plugin on the non-streaming path. A registration that arrives when the
642    /// id already has a **streaming** fn is still dropped (first streaming
643    /// registration wins). Returns `true` when an existing streaming provider
644    /// was kept.
645    pub fn register_streaming_provider(&mut self, provider: RegisteredProvider) -> bool {
646        debug_assert!(
647            provider.stream_fn.is_some(),
648            "register_streaming_provider requires stream_fn; use register_provider otherwise"
649        );
650        if let Some(existing) = self
651            .providers
652            .iter_mut()
653            .find(|p| p.provider_id == provider.provider_id)
654        {
655            if existing.stream_fn.is_some() {
656                // Two streaming registrations for one id: first wins, like every
657                // other registrar in this registry.
658                return true;
659            }
660            // One-shot → streaming upgrade. Keep the record's slot (and its
661            // position) but adopt the richer capability + any identity the
662            // plugin re-sent.
663            existing.stream_fn = provider.stream_fn;
664            existing.request_fn = provider.request_fn;
665            existing.base_url = provider.base_url;
666            existing.api_style = provider.api_style;
667            existing.plugin_free_string = provider.plugin_free_string;
668            existing.user_data = provider.user_data;
669            return false;
670        }
671        self.providers.push(provider);
672        false
673    }
674
675    /// Register a provider-owned OAuth implementation. The manifest must be a
676    /// JSON object with a non-empty string `id`; malformed manifests are
677    /// rejected. Returns `true` when an existing provider won.
678    pub fn register_oauth_provider(&mut self, provider: RegisteredOAuthProvider) -> bool {
679        let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
680        else {
681            return true;
682        };
683        let Some(id) = manifest.get("id").and_then(serde_json::Value::as_str) else {
684            return true;
685        };
686        if id.trim().is_empty()
687            || self.oauth_providers.iter().any(|p| {
688                serde_json::from_str::<serde_json::Value>(&p.manifest_json)
689                    .ok()
690                    .and_then(|v| {
691                        v.get("id")
692                            .and_then(serde_json::Value::as_str)
693                            .map(str::to_owned)
694                    })
695                    .as_deref()
696                    == Some(id)
697            })
698        {
699            return true;
700        }
701        self.oauth_providers.push(provider);
702        false
703    }
704
705    /// Register a renderer (B5c — message/markdown/entry). First-wins on
706    /// `(kind, name)`. Returns `true` if a prior renderer of the same kind+name
707    /// was kept.
708    pub fn register_renderer(&mut self, renderer: RegisteredRenderer) -> bool {
709        if self
710            .renderers
711            .iter()
712            .any(|r| r.kind == renderer.kind && r.name == renderer.name)
713        {
714            return true;
715        }
716        self.renderers.push(renderer);
717        false
718    }
719
720    /// Build a snapshot the host session keeps. The registry's `active` flag is
721    /// shared (Arc) so a later `invalidate` on the registry also invalidates the
722    /// snapshot — important for cross-session staleness.
723    pub fn snapshot(&self) -> RegistrySnapshot {
724        // P2: dispatch order is priority-first (smaller first). `sort_by_key` is
725        // stable, so equal priorities keep registration order (the prior
726        // contract). Sorting per tag here means every snapshot — including the
727        // merged session snapshot built via `absorb` — is pre-ordered.
728        let mut handlers = self.handlers.clone();
729        for tag_handlers in handlers.iter_mut() {
730            tag_handlers.sort_by_key(|h| h.priority);
731        }
732        RegistrySnapshot {
733            tools: self
734                .tools
735                .iter()
736                .map(|t| ExtensionTool {
737                    tool: t.tool.clone(),
738                    handle: t.handle,
739                })
740                .collect(),
741            commands: self.commands.clone(),
742            flags: self.flags.clone(),
743            shortcuts: self.shortcuts.clone(),
744            handlers,
745            resources_discover: self.resources_discover.clone(),
746            before_agent_start: self.before_agent_start.clone(),
747            providers: self.providers.clone(),
748            provider_manifests: self.provider_manifests.clone(),
749            oauth_providers: self.oauth_providers.clone(),
750            renderers: self.renderers.clone(),
751            owns_stdio: self.owns_stdio,
752            active: Arc::clone(&self.active),
753        }
754    }
755
756    /// Mark this registry (and any snapshot sharing its flag) as stale.
757    pub fn invalidate(&self) {
758        self.active.store(false, Ordering::SeqCst);
759    }
760
761    /// Whether this registry is still active (not invalidated).
762    pub fn is_active(&self) -> bool {
763        self.active.load(Ordering::SeqCst)
764    }
765
766    /// P2: the plugin's declared dispatch priority (default [`DEFAULT_PRIORITY`]).
767    pub fn declared_priority(&self) -> i32 {
768        self.priority
769    }
770
771    /// P2: the plugin's declared platforms (empty = all).
772    pub fn declared_platforms(&self) -> &[String] {
773        &self.platforms
774    }
775
776    /// Whether this plugin claimed ownership of the host process's stdio
777    /// (`declare {"ownsStdio":true}`). Default `false`.
778    pub fn declared_owns_stdio(&self) -> bool {
779        self.owns_stdio
780    }
781
782    /// Absorb another registry's registrations into this one, first-wins on
783    /// name (tools/commands) and appending (event handlers). Used by
784    /// [`load_dir`](crate::loader::load_dir)/`merge_registries` to fold
785    /// per-plugin registries into one session registry in load order. Consumes
786    /// `other` (its `active` flag is discarded — the session registry's flag
787    /// wins, since it owns session lifetime).
788    pub(crate) fn absorb(&mut self, mut other: ExtensionRegistry) {
789        for t in other.tools.drain(..) {
790            if self.tools.iter().any(|x| x.tool.name == t.tool.name) {
791                // first-wins: keep the prior, discard the new. Nothing to free
792                // (handle is fn pointers). Emit no diagnostic here — the loader
793                // may surface collisions if desired; v1 silent first-wins.
794                continue;
795            }
796            self.tools.push(t);
797        }
798        for c in other.commands.drain(..) {
799            if self.commands.iter().any(|x| x.name == c.name) {
800                continue;
801            }
802            self.commands.push(c);
803        }
804        for flag in other.flags.drain(..) {
805            if self.flags.iter().any(|existing| existing.name == flag.name) {
806                continue;
807            }
808            self.flags.push(flag);
809        }
810        for shortcut in other.shortcuts.drain(..) {
811            if self
812                .shortcuts
813                .iter()
814                .any(|existing| existing.key == shortcut.key)
815            {
816                continue;
817            }
818            self.shortcuts.push(shortcut);
819        }
820        for (tag_idx, handlers) in other.handlers.iter_mut().enumerate() {
821            self.handlers[tag_idx].append(handlers);
822        }
823        self.resources_discover
824            .append(&mut other.resources_discover);
825        self.before_agent_start
826            .append(&mut other.before_agent_start);
827        for p in other.providers.drain(..) {
828            if self
829                .providers
830                .iter()
831                .any(|x| x.provider_id == p.provider_id)
832            {
833                continue;
834            }
835            if let Some(manifest) = other.provider_manifests.remove(&p.provider_id) {
836                self.provider_manifests
837                    .insert(p.provider_id.clone(), manifest);
838            }
839            self.providers.push(p);
840        }
841        for oauth in other.oauth_providers.drain(..) {
842            if self.oauth_providers.iter().any(|existing| {
843                let existing_id =
844                    serde_json::from_str::<serde_json::Value>(&existing.manifest_json)
845                        .ok()
846                        .and_then(|value| {
847                            value
848                                .get("id")
849                                .and_then(serde_json::Value::as_str)
850                                .map(str::to_owned)
851                        });
852                let oauth_id = serde_json::from_str::<serde_json::Value>(&oauth.manifest_json)
853                    .ok()
854                    .and_then(|value| {
855                        value
856                            .get("id")
857                            .and_then(serde_json::Value::as_str)
858                            .map(str::to_owned)
859                    });
860                existing_id.is_some() && existing_id == oauth_id
861            }) {
862                continue;
863            }
864            self.oauth_providers.push(oauth);
865        }
866        for r in other.renderers.drain(..) {
867            if self
868                .renderers
869                .iter()
870                .any(|x| x.kind == r.kind && x.name == r.name)
871            {
872                continue;
873            }
874            self.renderers.push(r);
875        }
876        // stdio ownership is a claim: any claiming plugin makes the merged
877        // session own stdio. The live bridge has already checked the exclusive
878        // owner; this flag projects that claim into the immutable snapshot.
879        self.owns_stdio |= other.owns_stdio;
880    }
881}
882
883// ---------------------------------------------------------------------------
884// RegistrySnapshot — the host session's immutable view
885// ---------------------------------------------------------------------------
886
887/// An immutable snapshot of an [`ExtensionRegistry`] the host session keeps for
888/// its lifetime. Tools are wrapped in [`ExtensionTool`] so the host can build
889/// [`PluginToolAdapter`](crate::PluginToolAdapter)s; event handlers are grouped
890/// by tag for the [`ExtensionEmitter`](crate::ExtensionEmitter) to fan out.
891///
892/// The `active` flag is shared with the source registry, so invalidating the
893/// registry (on session swap) invalidates this snapshot too.
894pub struct RegistrySnapshot {
895    tools: Vec<ExtensionTool>,
896    commands: Vec<RegisteredCommand>,
897    flags: Vec<RegisteredFlag>,
898    shortcuts: Vec<RegisteredShortcut>,
899    handlers: [Vec<RegisteredHandler>; EVENT_TAG_COUNT],
900    resources_discover: Vec<ResourcesDiscoverHandler>,
901    before_agent_start: Vec<BeforeAgentStartHandler>,
902    providers: Vec<RegisteredProvider>,
903    provider_manifests: std::collections::BTreeMap<String, ProviderManifest>,
904    oauth_providers: Vec<RegisteredOAuthProvider>,
905    renderers: Vec<RegisteredRenderer>,
906    /// A plugin's `{"ownsStdio":true}` claim (merged OR across plugins). The
907    /// host reads this after `load_session` to defer `read_piped_stdin` and run
908    /// the headless `Rpc` lifecycle. See [`ExtensionRegistry::declared_owns_stdio`].
909    owns_stdio: bool,
910    active: Arc<AtomicBool>,
911}
912
913impl RegistrySnapshot {
914    /// The extension tools (schema + handle), insertion order.
915    pub fn tools(&self) -> &[ExtensionTool] {
916        &self.tools
917    }
918
919    /// Consume the snapshot into the owned tool list (for building adapters).
920    pub fn into_tools(self) -> Vec<ExtensionTool> {
921        self.tools
922    }
923
924    /// The registered slash commands.
925    pub fn commands(&self) -> &[RegisteredCommand] {
926        &self.commands
927    }
928
929    /// The registered CLI flags, insertion order.
930    pub fn flags(&self) -> &[RegisteredFlag] {
931        &self.flags
932    }
933
934    /// Whether any loaded plugin claimed ownership of the host process's stdio
935    /// (`declare {"ownsStdio":true}`). The host uses this to defer
936    /// `read_piped_stdin` and run the headless `Rpc` lifecycle.
937    pub fn owns_stdio(&self) -> bool {
938        self.owns_stdio
939    }
940
941    /// Keyboard shortcuts declared by extensions, insertion order.
942    pub fn shortcuts(&self) -> &[RegisteredShortcut] {
943        &self.shortcuts
944    }
945
946    /// Whether any extension has claimed `key` as a shortcut. Used by the TUI
947    /// to route that key to extension `Input` events instead of the editor.
948    pub fn has_shortcut(&self, key: &str) -> bool {
949        self.shortcuts.iter().any(|s| s.key == key)
950    }
951
952    /// Handlers subscribed to `tag` (empty slice if none).
953    pub fn handlers_for(&self, tag: EventTag) -> &[RegisteredHandler] {
954        let idx = tag as usize;
955        if idx < EVENT_TAG_COUNT {
956            &self.handlers[idx]
957        } else {
958            &[]
959        }
960    }
961
962    /// The `resources_discover` handlers, registration order (B5b). Empty when
963    /// no plugin registered a discovery handler.
964    pub fn resources_discover(&self) -> &[ResourcesDiscoverHandler] {
965        &self.resources_discover
966    }
967
968    /// The `before_agent_start` system-prompt transformers, registration order.
969    /// Empty when no plugin asked to transform the prompt — the common case, and
970    /// the one callers treat as "leave the prompt alone".
971    pub fn before_agent_start(&self) -> &[BeforeAgentStartHandler] {
972        &self.before_agent_start
973    }
974
975    /// The registered custom providers (B5c), registration order. The host wraps
976    /// each in a [`PluggableProvider`](crate::PluggableProvider). Empty when no
977    /// plugin registered a provider.
978    pub fn providers(&self) -> &[RegisteredProvider] {
979        &self.providers
980    }
981
982    /// Only declarations backed by a successfully registered provider are used.
983    pub fn provider_manifest(&self, id: &str) -> Option<&ProviderManifest> {
984        self.providers
985            .iter()
986            .any(|provider| provider.provider_id == id)
987            .then(|| self.provider_manifests.get(id))
988            .flatten()
989    }
990
991    /// All registered renderers (B5c), registration order, across all three
992    /// kinds.
993    pub fn renderers(&self) -> &[RegisteredRenderer] {
994        &self.renderers
995    }
996
997    /// OAuth providers registered by extensions, in registration order.
998    pub fn oauth_providers(&self) -> &[RegisteredOAuthProvider] {
999        &self.oauth_providers
1000    }
1001
1002    /// Find an OAuth provider by its manifest `id`.
1003    pub fn oauth_provider(&self, provider_id: &str) -> Option<&RegisteredOAuthProvider> {
1004        self.oauth_providers.iter().find(|provider| {
1005            serde_json::from_str::<serde_json::Value>(&provider.manifest_json)
1006                .ok()
1007                .and_then(|value| {
1008                    value
1009                        .get("id")
1010                        .and_then(serde_json::Value::as_str)
1011                        .map(|id| id == provider_id)
1012                })
1013                .unwrap_or(false)
1014        })
1015    }
1016
1017    /// The registered renderers of a specific kind (B5c). The TUI dispatches
1018    /// message and entry renderers through the same JSON adapter used by the
1019    /// markdown transformer.
1020    pub fn renderers_of(&self, kind: RegisteredRendererKind) -> Vec<RegisteredRenderer> {
1021        self.renderers
1022            .iter()
1023            .filter(|r| r.kind == kind)
1024            .cloned()
1025            .collect()
1026    }
1027
1028    /// A flat iterator of all registrations (tools + commands), for diagnostics.
1029    pub fn entries(&self) -> Vec<RegistryEntry> {
1030        let mut v: Vec<RegistryEntry> = Vec::new();
1031        for t in &self.tools {
1032            v.push(RegistryEntry::Tool(ExtensionTool {
1033                tool: t.tool.clone(),
1034                handle: t.handle,
1035            }));
1036        }
1037        for c in &self.commands {
1038            v.push(RegistryEntry::Command(c.clone()));
1039        }
1040        for flag in &self.flags {
1041            v.push(RegistryEntry::Flag(flag.clone()));
1042        }
1043        v
1044    }
1045
1046    /// Whether this snapshot's session is still active.
1047    pub fn is_active(&self) -> bool {
1048        self.active.load(Ordering::SeqCst)
1049    }
1050
1051    /// The shared active flag (for [`assert_active`]).
1052    pub fn active_flag(&self) -> &Arc<AtomicBool> {
1053        &self.active
1054    }
1055}
1056
1057/// Staleness guard: `true` while the owning session is still active. Called
1058/// before dispatching an extension event or driving an extension tool so a
1059/// stale registry (from a swapped-out session) can't act. Mirrors pi's
1060/// `ExtensionRuntimeState` active check.
1061pub fn assert_active(active: &Arc<AtomicBool>) -> bool {
1062    active.load(Ordering::SeqCst)
1063}
1064
1065/// Build an `AgentError` for a stale-registry access.
1066#[allow(dead_code)]
1067pub(crate) fn stale_error() -> AgentError {
1068    AgentError::State("extensions registry is stale (session swapped/reloaded)".into())
1069}
1070
1071// Suppress unused-fn warning until B3 wires dispatch through the snapshot.
1072#[allow(dead_code)]
1073fn _ensure_handler_accessor_used(snap: &RegistrySnapshot) {
1074    let _ = snap.handlers_for(EventTag::MessageEnd);
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080
1081    #[test]
1082    fn shortcuts_first_wins_and_merge() {
1083        let mut first = ExtensionRegistry::new();
1084        assert!(!first.register_shortcut("voice".into(), "space".into(), "talk".into()));
1085        // Second claim on the same key is rejected (first-wins).
1086        assert!(first.register_shortcut("other".into(), "space".into(), "also".into()));
1087        assert!(!first.register_shortcut("voice".into(), "enter".into(), "send".into()));
1088
1089        let mut second = ExtensionRegistry::new();
1090        assert!(!second.register_shortcut("other".into(), "space".into(), "dup".into()));
1091        assert!(!second.register_shortcut("other".into(), "f1".into(), "help".into()));
1092
1093        first.absorb(second);
1094        let snap = first.snapshot();
1095        assert_eq!(snap.shortcuts().len(), 3);
1096        assert_eq!(snap.shortcuts()[0].key, "space");
1097        assert_eq!(snap.shortcuts()[0].plugin, "voice");
1098        assert_eq!(snap.shortcuts()[1].key, "enter");
1099        assert_eq!(snap.shortcuts()[2].key, "f1");
1100        // The claimed-key query the TUI routes on.
1101        assert!(snap.has_shortcut("space"));
1102        assert!(!snap.has_shortcut("tab"));
1103    }
1104
1105    #[test]
1106    fn cli_flags_snapshot_and_merge_first_wins() {
1107        let mut first = ExtensionRegistry::new();
1108        assert!(!first.register_flag("server".into(), "Start server".into()));
1109        assert!(first.register_flag("server".into(), "Different description".into()));
1110
1111        let mut second = ExtensionRegistry::new();
1112        assert!(!second.register_flag("server".into(), "Second server".into()));
1113        assert!(!second.register_flag("port".into(), "Listen port".into()));
1114
1115        first.absorb(second);
1116        let snapshot = first.snapshot();
1117        let flags = snapshot.flags();
1118        assert_eq!(flags.len(), 2);
1119        assert_eq!(flags[0].name, "server");
1120        assert_eq!(flags[0].description, "Start server");
1121        assert_eq!(flags[1].name, "port");
1122    }
1123
1124    extern "C" fn noop_handler(
1125        _ev: rpi_plugin_sdk::StablePluginEvent,
1126        _ud: *mut std::ffi::c_void,
1127    ) -> i32 {
1128        0
1129    }
1130
1131    fn handler_of<'a>(
1132        snap: &'a RegistrySnapshot,
1133        tag: EventTag,
1134        i: usize,
1135    ) -> &'a RegisteredHandler {
1136        &snap.handlers_for(tag)[i]
1137    }
1138
1139    /// Two plugins declaring different priorities: the merged snapshot orders
1140    /// the lower-priority plugin's handler first (smaller runs first).
1141    #[test]
1142    fn priority_orders_handlers_after_merge() {
1143        let tag = EventTag::SessionStart;
1144
1145        let mut late = ExtensionRegistry::new();
1146        late.apply_declaration(Some(200), None);
1147        late.register_event_handler("late".into(), tag, noop_handler, std::ptr::null_mut());
1148
1149        let mut early = ExtensionRegistry::new();
1150        early.apply_declaration(Some(50), None);
1151        early.register_event_handler("early".into(), tag, noop_handler, std::ptr::null_mut());
1152
1153        // Absorb in the "wrong" order — snapshot must still sort by priority.
1154        let mut session = ExtensionRegistry::new();
1155        session.absorb(late);
1156        session.absorb(early);
1157        let snap = session.snapshot();
1158        assert_eq!(handler_of(&snap, tag, 0).plugin, "early");
1159        assert_eq!(handler_of(&snap, tag, 1).plugin, "late");
1160    }
1161
1162    /// Equal priorities keep registration order (stable sort).
1163    #[test]
1164    fn equal_priority_is_stable() {
1165        let tag = EventTag::TurnEnd;
1166        let mut reg = ExtensionRegistry::new();
1167        reg.register_event_handler("a".into(), tag, noop_handler, std::ptr::null_mut());
1168        reg.register_event_handler("b".into(), tag, noop_handler, std::ptr::null_mut());
1169        let snap = reg.snapshot();
1170        assert_eq!(handler_of(&snap, tag, 0).plugin, "a");
1171        assert_eq!(handler_of(&snap, tag, 1).plugin, "b");
1172    }
1173
1174    /// A declaration applied AFTER handlers exist retro-updates them (declare
1175    /// order does not matter).
1176    #[test]
1177    fn declaration_retro_updates_existing_handlers() {
1178        let tag = EventTag::SessionStart;
1179        let mut reg = ExtensionRegistry::new();
1180        reg.register_event_handler("p".into(), tag, noop_handler, std::ptr::null_mut());
1181        assert_eq!(
1182            handler_of(&reg.snapshot(), tag, 0).priority,
1183            DEFAULT_PRIORITY
1184        );
1185
1186        assert!(reg.apply_declaration_json(r#"{"priority":7,"platforms":["linux"]}"#));
1187        let snap = reg.snapshot();
1188        assert_eq!(handler_of(&snap, tag, 0).priority, 7);
1189        assert_eq!(
1190            handler_of(&snap, tag, 0).platforms,
1191            vec!["linux".to_string()]
1192        );
1193    }
1194
1195    /// Malformed declaration JSON is rejected (returns `false`); recognised keys
1196    /// apply.
1197    #[test]
1198    fn apply_declaration_json_parses_and_rejects() {
1199        let mut reg = ExtensionRegistry::new();
1200        assert!(!reg.apply_declaration_json("not json"));
1201        assert!(reg.apply_declaration_json("{}"));
1202        assert!(reg.apply_declaration_json(r#"{"priority":3}"#));
1203        assert_eq!(reg.apply_declaration(Some(3), None), 1);
1204    }
1205
1206    /// `declare {"ownsStdio":true}` claims the host process's stdio; it is
1207    /// monotonic (a later `false` does not un-claim) and merges by OR.
1208    #[test]
1209    fn declaration_owns_stdio_is_monotonic_and_merges() {
1210        let mut reg = ExtensionRegistry::new();
1211        assert!(!reg.declared_owns_stdio());
1212        assert!(!reg.snapshot().owns_stdio());
1213
1214        assert!(reg.apply_declaration_json(r#"{"ownsStdio":true}"#));
1215        assert!(reg.declared_owns_stdio());
1216        assert!(reg.snapshot().owns_stdio());
1217
1218        // A later `false` must not release an established claim.
1219        assert!(reg.apply_declaration_json(r#"{"ownsStdio":false}"#));
1220        assert!(reg.declared_owns_stdio());
1221
1222        // `absorb` ORs ownership across plugins (any claiming plugin wins).
1223        let mut plain = ExtensionRegistry::new();
1224        let mut claiming = ExtensionRegistry::new();
1225        assert!(claiming.apply_declaration_json(r#"{"ownsStdio":true}"#));
1226        plain.absorb(claiming);
1227        assert!(plain.declared_owns_stdio());
1228        assert!(plain.snapshot().owns_stdio());
1229    }
1230
1231    #[test]
1232    fn platform_allows_matches_host_and_wildcard() {
1233        assert!(platform_allows(&[]));
1234        assert!(platform_allows(&["all".to_string()]));
1235        assert!(platform_allows(&["ALL".to_string()]));
1236        assert!(platform_allows(&[current_platform().to_string()]));
1237        assert!(!platform_allows(&["nonexistent-os".to_string()]));
1238        // Empty host subset is fine; a non-matching list is not.
1239        assert!(!platform_allows(&["definitely-not-this-host".to_string()]));
1240    }
1241
1242    extern "C" fn noop_oauth(
1243        _request: rpi_plugin_sdk::StbStringRef,
1244        _out: *mut rpi_plugin_sdk::StbString,
1245        _user_data: *mut std::ffi::c_void,
1246    ) -> i32 {
1247        0
1248    }
1249
1250    extern "C" fn noop_free(_value: rpi_plugin_sdk::StbString) {}
1251
1252    #[test]
1253    fn oauth_manifest_registers_and_deduplicates() {
1254        let mut reg = ExtensionRegistry::new();
1255        let make = |manifest: &str| RegisteredOAuthProvider {
1256            plugin: "test-plugin".to_string(),
1257            manifest_json: manifest.to_string(),
1258            request_fn: noop_oauth,
1259            plugin_free_string: noop_free,
1260            user_data: std::ptr::null_mut(),
1261        };
1262        assert!(!reg.register_oauth_provider(make(r#"{"id":"acme","displayName":"Acme"}"#)));
1263        assert!(reg.register_oauth_provider(make(r#"{"id":"acme"}"#)));
1264        assert!(reg.register_oauth_provider(make(r#"{"displayName":"missing-id"}"#)));
1265        assert_eq!(reg.snapshot().oauth_providers().len(), 1);
1266    }
1267}
1268
1269#[cfg(test)]
1270mod provider_manifest_tests {
1271    use super::*;
1272    use rpi_plugin_sdk::{StbString, StbStringRef};
1273    extern "C" fn request(_: StbStringRef, _: *mut StbString, _: *mut std::ffi::c_void) -> i32 {
1274        1
1275    }
1276    fn record() -> RegisteredProvider {
1277        RegisteredProvider {
1278            provider_id: "package".into(),
1279            base_url: "http://localhost".into(),
1280            api_style: "openai-completions".into(),
1281            request_fn: request,
1282            stream_fn: None,
1283            plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
1284            user_data: std::ptr::null_mut(),
1285        }
1286    }
1287    fn declaration(id: &str, model_id: &str) -> String {
1288        serde_json::json!({"providers":[{"id":id,"models":[rpi_ai::Model::new(model_id, model_id,
1289            rpi_ai::Api::OpenaiCompletions,"package","http://localhost")]}]})
1290        .to_string()
1291    }
1292    #[test]
1293    fn catalog_requires_registration_and_stays_with_the_winning_provider() {
1294        let mut first = ExtensionRegistry::new();
1295        assert!(first.apply_declaration_json(&declaration("package", "first")));
1296        assert!(first.snapshot().provider_manifest("package").is_none());
1297        first.register_provider(record());
1298        let manifest = first
1299            .snapshot()
1300            .provider_manifest("package")
1301            .unwrap()
1302            .clone();
1303        assert!(
1304            manifest.requires_auth,
1305            "packages require authentication by default"
1306        );
1307        assert_eq!(manifest.models[0].id, "first");
1308        let mut second = ExtensionRegistry::new();
1309        second.register_provider(record());
1310        assert!(second.apply_declaration_json(&declaration("package", "second")));
1311        first.absorb(second);
1312        assert_eq!(
1313            first
1314                .snapshot()
1315                .provider_manifest("package")
1316                .unwrap()
1317                .models[0]
1318                .id,
1319            "first"
1320        );
1321    }
1322    #[test]
1323    fn a_package_cannot_attach_a_catalog_to_another_providers_identity() {
1324        let mut registry = ExtensionRegistry::new();
1325        registry.register_provider(record());
1326        assert!(!registry.apply_declaration_json(&declaration("other", "wrong")));
1327        assert!(registry.snapshot().provider_manifest("package").is_none());
1328    }
1329}