Skip to main content

rpi_extensions/
resources.rs

1//! B5b — `resources_discover` host-side dispatch.
2//!
3//! pi's `resources_discover` is the one extension event that **returns a
4//! result to the host** (all other `on()` handlers are fire-and-forget). The
5//! plugin hands back `{skillPaths?, promptPaths?, themePaths?}` — bare string
6//! arrays — which the host merges across handlers and feeds into Part A's
7//! loaders (the "三者同交付" coherence point: the plugin's discovered paths
8//! reuse the same skill/prompt loaders as the static Part-A dirs).
9//!
10//! ## The signature problem and its fix
11//!
12//! The SDK's [`EventHandlerFn`] (`i32` return, no `out`) cannot express this
13//! result return, so B5b adds a distinct [`ResourcesDiscoverFn`] with an owning
14//! `out: *mut StbString` (mirror of [`RuntimeActionFn`]'s out-param shape). The
15//! `out` StbString is **plugin-produced**, so the host reclaims it via the
16//! plugin's own `plugin_free_string`, which traveled alongside the handler at
17//! registration (stored in [`ResourcesDiscoverHandler`]). This ownership detail
18//! is the load-bearing thing the fire-and-forget event signature cannot express.
19//!
20//! ## Fan-out semantics (mirrors pi `runner.ts:1156-1192`)
21//!
22//! Each registered handler is called in registration order with `{type, cwd,
23//! reason}`. One handler's error (nonzero `rc`) or panic **does not abort the
24//! fan-out** — the host logs + skips it and continues to the next handler.
25//! Results are concatenated (bare strings; rpi does NOT track per-extension
26//! `extensionPath` — v1 documented divergence: paths are unattributed).
27//!
28//! [`EventHandlerFn`]: rpi_plugin_sdk::EventHandlerFn
29//! [`RuntimeActionFn`]: rpi_plugin_sdk::RuntimeActionFn
30
31use std::panic::{catch_unwind, AssertUnwindSafe};
32
33use rpi_plugin_sdk::{StbString, StbStringRef};
34
35use crate::registry::{assert_active, RegistrySnapshot};
36
37/// The merged `resources_discover` result across all handlers: bare string
38/// arrays for skills, prompt-templates, and themes. `theme_paths` is collected
39/// for parity but rpi has no theme system yet (accepted, ignored, documented).
40#[derive(Debug, Default, Clone, PartialEq, Eq)]
41pub struct DiscoveredResources {
42    pub skill_paths: Vec<String>,
43    pub prompt_paths: Vec<String>,
44    pub theme_paths: Vec<String>,
45}
46
47/// Fan the `resources_discover` event out to every registered handler in
48/// registration order and merge their returned paths.
49///
50/// `cwd` and `reason` (`"startup"` or `"reload"`) are passed to each handler.
51/// Returns the concatenated [`DiscoveredResources`]. A stale registry (swapped-
52/// out session) returns empty — the staleness guard is the same one event
53/// dispatch uses ([`assert_active`]).
54///
55/// Per-handler errors (nonzero `rc`) and panics are logged and skipped; they do
56/// NOT abort the fan-out (mirrors pi `runner.ts:1179-1188`). The `out`
57/// StbString each handler produces is plugin-owned and reclaimed via that
58/// handler's stored `plugin_free_string` before moving to the next handler.
59pub fn emit_resources_discover(
60    cwd: &str,
61    reason: &str,
62    snapshot: &RegistrySnapshot,
63) -> DiscoveredResources {
64    if !assert_active(snapshot.active_flag()) {
65        return DiscoveredResources::default();
66    }
67    let handlers = snapshot.resources_discover();
68    if handlers.is_empty() {
69        return DiscoveredResources::default();
70    }
71
72    // Borrowed inputs for every call: cwd + reason as StbStringRef (the plugin
73    // reads them during the call; we hold the &str on this stack). pi's event
74    // envelope also carries `type: "resources_discover"` but the handler
75    // signature passes cwd/reason directly (the type is implicit in the slot).
76    let cwd_ref = StbStringRef::from_str(cwd);
77    let reason_ref = StbStringRef::from_str(reason);
78
79    let mut merged = DiscoveredResources::default();
80    for h in handlers {
81        let outcome = catch_unwind(AssertUnwindSafe(|| {
82            call_one_handler(*h, cwd_ref, reason_ref)
83        }));
84        match outcome {
85            Ok(Ok(paths)) => {
86                merged.skill_paths.extend(paths.skill_paths);
87                merged.prompt_paths.extend(paths.prompt_paths);
88                merged.theme_paths.extend(paths.theme_paths);
89            }
90            Ok(Err(rc)) => {
91                tracing::warn!(
92                    rc,
93                    "resources_discover handler returned nonzero — skipped (fan-out continues)"
94                );
95            }
96            Err(_) => {
97                tracing::error!(
98                    "resources_discover handler panicked — skipped (fan-out continues)"
99                );
100            }
101        }
102    }
103    merged
104}
105
106/// Call one handler and reclaim its `out` StbString. Returns the parsed paths
107/// (`Ok(DiscoveredResources)`) on `rc==0`, or the nonzero `rc` on error. The
108/// `out` string is always freed (via the handler's `plugin_free_string`) before
109/// returning, whether or not parsing succeeded — a handler returning `0` with a
110/// malformed payload still has its allocation reclaimed.
111fn call_one_handler(
112    h: crate::registry::ResourcesDiscoverHandler,
113    cwd_ref: StbStringRef,
114    reason_ref: StbStringRef,
115) -> Result<DiscoveredResources, i32> {
116    // The uninitialized `out` slot the handler writes into on success. pi's
117    // contract: `rc==0` ⇒ `out` is a plugin-owned JSON string the host frees;
118    // `rc!=0` ⇒ `out` is left untouched (the handler wrote nothing). We zero it
119    // so a `rc==0` handler that forgets to write still yields an empty parse
120    // rather than UB.
121    let mut out = StbString::empty();
122    let rc = (h.handler)(cwd_ref, reason_ref, &mut out, h.user_data);
123    if rc != 0 {
124        // Handler reported an error and should NOT have written `out`. Defensively
125        // free a non-empty `out` anyway (a misbehaving handler that wrote then
126        // returned nonzero would otherwise leak). `free_with` is a no-op on empty.
127        out.free_with(Some(h.plugin_free_string));
128        return Err(rc);
129    }
130
131    // Copy the plugin-owned bytes into a safe String, THEN free the plugin
132    // allocation (the plugin owns the bytes; we must not keep a reference past
133    // the free). `to_string_lossy` copies without freeing.
134    let json = out.to_string_lossy();
135    out.free_with(Some(h.plugin_free_string));
136
137    let paths = parse_discover_payload(&json);
138    Ok(paths)
139}
140
141/// Parse a handler's `out` JSON payload `{skillPaths?, promptPaths?,
142/// themePaths?}` (all bare string arrays, all optional). Missing fields default
143/// to empty. A non-object or unparseable payload yields empty arrays (lenient —
144/// one handler returning garbage does NOT poison the merged result).
145fn parse_discover_payload(json: &str) -> DiscoveredResources {
146    let mut out = DiscoveredResources::default();
147    if json.trim().is_empty() {
148        return out;
149    }
150    let value: serde_json::Value = match serde_json::from_str(json) {
151        Ok(v) => v,
152        Err(e) => {
153            tracing::warn!(error = %e, "resources_discover payload not valid JSON — treating as empty");
154            return out;
155        }
156    };
157    let obj = match value.as_object() {
158        Some(o) => o,
159        None => {
160            tracing::warn!("resources_discover payload not a JSON object — treating as empty");
161            return out;
162        }
163    };
164    if let Some(arr) = obj.get("skillPaths").and_then(|v| v.as_array()) {
165        out.skill_paths
166            .extend(arr.iter().filter_map(|v| v.as_str()).map(str::to_string));
167    }
168    if let Some(arr) = obj.get("promptPaths").and_then(|v| v.as_array()) {
169        out.prompt_paths
170            .extend(arr.iter().filter_map(|v| v.as_str()).map(str::to_string));
171    }
172    if let Some(arr) = obj.get("themePaths").and_then(|v| v.as_array()) {
173        out.theme_paths
174            .extend(arr.iter().filter_map(|v| v.as_str()).map(str::to_string));
175    }
176    out
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::registry::ExtensionRegistry;
183    use rpi_plugin_sdk::{ResourcesDiscoverFn, StbString};
184    use std::sync::atomic::{AtomicUsize, Ordering};
185
186    /// Plugin-side `free_string` for the test handlers: reconstructs the
187    /// `Box<[u8]>` from ptr+len and drops it (matches `StbString::from_string`'s
188    /// allocation, same as the real `host_free_string` in lib.rs).
189    extern "C" fn test_free(s: StbString) {
190        if s.len == 0 || s.ptr.is_null() {
191            return;
192        }
193        // SAFETY: `from_string` allocated via `Box::into_raw(Box<[u8]>)`; we
194        // reconstruct the same layout. Mirrors `host_free_string` exactly.
195        unsafe {
196            let slice = core::slice::from_raw_parts_mut(s.ptr as *mut u8, s.len);
197            let _ = Box::from_raw(slice as *mut [u8]);
198        }
199    }
200
201    /// Per-test call counter threaded through `user_data` (a `*mut AtomicUsize`).
202    /// This keeps tests isolated (no shared global) so they can run in parallel.
203    fn bump(ud: *mut std::ffi::c_void) {
204        if ud.is_null() {
205            return;
206        }
207        // SAFETY: the test owns the `AtomicUsize` it passed in and it outlives the
208        // call (it's on the test's stack, pinned by the snapshot's copy of the
209        // pointer — the registry copies the raw pointer value, not the pointee).
210        unsafe {
211            (*(ud as *mut AtomicUsize)).fetch_add(1, Ordering::SeqCst);
212        }
213    }
214
215    /// A handler that returns two skill paths + one prompt path + one theme path.
216    extern "C" fn two_skill_handler(
217        _cwd: StbStringRef,
218        _reason: StbStringRef,
219        out: *mut StbString,
220        ud: *mut std::ffi::c_void,
221    ) -> i32 {
222        bump(ud);
223        let json = serde_json::json!({
224            "skillPaths": ["/a/SKILL.md", "/b/SKILL.md"],
225            "promptPaths": ["/p/greet.md"],
226            "themePaths": ["/t/dark.json"],
227        })
228        .to_string();
229        unsafe {
230            *out = StbString::from_string(json);
231        }
232        0
233    }
234
235    /// A handler that returns only skill paths (prompt/theme omitted — lenient).
236    extern "C" fn skill_only_handler(
237        _cwd: StbStringRef,
238        _reason: StbStringRef,
239        out: *mut StbString,
240        ud: *mut std::ffi::c_void,
241    ) -> i32 {
242        bump(ud);
243        let json = r#"{"skillPaths":["/c/SKILL.md"]}"#.to_string();
244        unsafe {
245            *out = StbString::from_string(json);
246        }
247        0
248    }
249
250    /// A handler that reports an error (nonzero) — must be skipped, fan-out
251    /// continues, and its `out` (untouched/empty) is defensively freed.
252    extern "C" fn error_handler(
253        _cwd: StbStringRef,
254        _reason: StbStringRef,
255        _out: *mut StbString,
256        ud: *mut std::ffi::c_void,
257    ) -> i32 {
258        bump(ud);
259        42
260    }
261
262    /// A handler that returns 0 but writes garbage JSON — lenient parse yields
263    /// empty arrays (does NOT poison the merged result), and the allocation is
264    /// still freed.
265    extern "C" fn garbage_handler(
266        _cwd: StbStringRef,
267        _reason: StbStringRef,
268        out: *mut StbString,
269        ud: *mut std::ffi::c_void,
270    ) -> i32 {
271        bump(ud);
272        unsafe {
273            *out = StbString::from_string("not json {{{".to_string());
274        }
275        0
276    }
277
278    /// Build a snapshot whose handlers all count into `counter` via `user_data`.
279    fn reg_with(handlers: &[ResourcesDiscoverFn], counter: &AtomicUsize) -> RegistrySnapshot {
280        counter.store(0, Ordering::SeqCst);
281        let mut reg = ExtensionRegistry::new();
282        let ud = counter as *const AtomicUsize as *mut std::ffi::c_void;
283        for h in handlers {
284            reg.register_resources_discover(*h, test_free, ud);
285        }
286        reg.snapshot()
287    }
288
289    #[test]
290    fn no_handlers_returns_empty() {
291        let counter = AtomicUsize::new(0);
292        let snap = reg_with(&[], &counter);
293        let r = emit_resources_discover("/cwd", "startup", &snap);
294        assert!(r.skill_paths.is_empty());
295        assert!(r.prompt_paths.is_empty());
296        assert!(r.theme_paths.is_empty());
297    }
298
299    #[test]
300    fn one_handler_merges_all_three_arrays() {
301        let counter = AtomicUsize::new(0);
302        let snap = reg_with(&[two_skill_handler], &counter);
303        let r = emit_resources_discover("/cwd", "startup", &snap);
304        assert_eq!(r.skill_paths, ["/a/SKILL.md", "/b/SKILL.md"]);
305        assert_eq!(r.prompt_paths, ["/p/greet.md"]);
306        assert_eq!(r.theme_paths, ["/t/dark.json"]);
307        assert_eq!(counter.load(Ordering::SeqCst), 1);
308    }
309
310    #[test]
311    fn multiple_handlers_concatenate_in_registration_order() {
312        let counter = AtomicUsize::new(0);
313        let snap = reg_with(&[two_skill_handler, skill_only_handler], &counter);
314        let r = emit_resources_discover("/cwd", "reload", &snap);
315        assert_eq!(r.skill_paths, ["/a/SKILL.md", "/b/SKILL.md", "/c/SKILL.md"]);
316        // The second handler returned no prompt/theme → only the first's.
317        assert_eq!(r.prompt_paths, ["/p/greet.md"]);
318        assert_eq!(r.theme_paths, ["/t/dark.json"]);
319        assert_eq!(counter.load(Ordering::SeqCst), 2);
320    }
321
322    #[test]
323    fn error_handler_skipped_fan_out_continues() {
324        let counter = AtomicUsize::new(0);
325        let snap = reg_with(
326            &[error_handler, two_skill_handler, garbage_handler],
327            &counter,
328        );
329        let r = emit_resources_discover("/cwd", "startup", &snap);
330        // error_handler skipped; two_skill_handler contributed;
331        // garbage_handler produced empty (lenient). All three ran.
332        assert_eq!(counter.load(Ordering::SeqCst), 3);
333        assert_eq!(r.skill_paths, ["/a/SKILL.md", "/b/SKILL.md"]);
334        assert_eq!(r.prompt_paths, ["/p/greet.md"]);
335    }
336
337    #[test]
338    fn stale_registry_returns_empty() {
339        let counter = AtomicUsize::new(0);
340        let snap = reg_with(&[two_skill_handler], &counter);
341        snap.active_flag().store(false, Ordering::SeqCst);
342        let r = emit_resources_discover("/cwd", "startup", &snap);
343        assert!(r.skill_paths.is_empty());
344        assert_eq!(
345            counter.load(Ordering::SeqCst),
346            0,
347            "stale registry must not invoke handlers"
348        );
349    }
350
351    #[test]
352    fn parse_payload_lenient_defaults() {
353        assert_eq!(parse_discover_payload(""), DiscoveredResources::default());
354        assert_eq!(parse_discover_payload("{}"), DiscoveredResources::default());
355        assert_eq!(
356            parse_discover_payload(r#"{"skillPaths":["/x"]}"#).skill_paths,
357            ["/x"]
358        );
359        // Non-object payloads collapse to empty, never panic.
360        assert_eq!(
361            parse_discover_payload("[1,2,3]"),
362            DiscoveredResources::default()
363        );
364        assert_eq!(
365            parse_discover_payload("null"),
366            DiscoveredResources::default()
367        );
368    }
369}