1use std::panic::{catch_unwind, AssertUnwindSafe};
32
33use rpi_plugin_sdk::{StbString, StbStringRef};
34
35use crate::registry::{assert_active, RegistrySnapshot};
36
37#[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
47pub 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 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
106fn call_one_handler(
112 h: crate::registry::ResourcesDiscoverHandler,
113 cwd_ref: StbStringRef,
114 reason_ref: StbStringRef,
115) -> Result<DiscoveredResources, i32> {
116 let mut out = StbString::empty();
122 let rc = (h.handler)(cwd_ref, reason_ref, &mut out, h.user_data);
123 if rc != 0 {
124 out.free_with(Some(h.plugin_free_string));
128 return Err(rc);
129 }
130
131 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
141fn 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 extern "C" fn test_free(s: StbString) {
190 if s.len == 0 || s.ptr.is_null() {
191 return;
192 }
193 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 fn bump(ud: *mut std::ffi::c_void) {
204 if ud.is_null() {
205 return;
206 }
207 unsafe {
211 (*(ud as *mut AtomicUsize)).fetch_add(1, Ordering::SeqCst);
212 }
213 }
214
215 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 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 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 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 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 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 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 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}