use std::panic::{catch_unwind, AssertUnwindSafe};
use rpi_plugin_sdk::{StbString, StbStringRef};
use crate::registry::{assert_active, RegistrySnapshot};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DiscoveredResources {
pub skill_paths: Vec<String>,
pub prompt_paths: Vec<String>,
pub theme_paths: Vec<String>,
}
pub fn emit_resources_discover(
cwd: &str,
reason: &str,
snapshot: &RegistrySnapshot,
) -> DiscoveredResources {
if !assert_active(snapshot.active_flag()) {
return DiscoveredResources::default();
}
let handlers = snapshot.resources_discover();
if handlers.is_empty() {
return DiscoveredResources::default();
}
let cwd_ref = StbStringRef::from_str(cwd);
let reason_ref = StbStringRef::from_str(reason);
let mut merged = DiscoveredResources::default();
for h in handlers {
let outcome = catch_unwind(AssertUnwindSafe(|| {
call_one_handler(*h, cwd_ref, reason_ref)
}));
match outcome {
Ok(Ok(paths)) => {
merged.skill_paths.extend(paths.skill_paths);
merged.prompt_paths.extend(paths.prompt_paths);
merged.theme_paths.extend(paths.theme_paths);
}
Ok(Err(rc)) => {
tracing::warn!(
rc,
"resources_discover handler returned nonzero — skipped (fan-out continues)"
);
}
Err(_) => {
tracing::error!(
"resources_discover handler panicked — skipped (fan-out continues)"
);
}
}
}
merged
}
fn call_one_handler(
h: crate::registry::ResourcesDiscoverHandler,
cwd_ref: StbStringRef,
reason_ref: StbStringRef,
) -> Result<DiscoveredResources, i32> {
let mut out = StbString::empty();
let rc = (h.handler)(cwd_ref, reason_ref, &mut out, h.user_data);
if rc != 0 {
out.free_with(Some(h.plugin_free_string));
return Err(rc);
}
let json = out.to_string_lossy();
out.free_with(Some(h.plugin_free_string));
let paths = parse_discover_payload(&json);
Ok(paths)
}
fn parse_discover_payload(json: &str) -> DiscoveredResources {
let mut out = DiscoveredResources::default();
if json.trim().is_empty() {
return out;
}
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "resources_discover payload not valid JSON — treating as empty");
return out;
}
};
let obj = match value.as_object() {
Some(o) => o,
None => {
tracing::warn!("resources_discover payload not a JSON object — treating as empty");
return out;
}
};
if let Some(arr) = obj.get("skillPaths").and_then(|v| v.as_array()) {
out.skill_paths
.extend(arr.iter().filter_map(|v| v.as_str()).map(str::to_string));
}
if let Some(arr) = obj.get("promptPaths").and_then(|v| v.as_array()) {
out.prompt_paths
.extend(arr.iter().filter_map(|v| v.as_str()).map(str::to_string));
}
if let Some(arr) = obj.get("themePaths").and_then(|v| v.as_array()) {
out.theme_paths
.extend(arr.iter().filter_map(|v| v.as_str()).map(str::to_string));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::ExtensionRegistry;
use rpi_plugin_sdk::{ResourcesDiscoverFn, StbString};
use std::sync::atomic::{AtomicUsize, Ordering};
extern "C" fn test_free(s: StbString) {
if s.len == 0 || s.ptr.is_null() {
return;
}
unsafe {
let slice = core::slice::from_raw_parts_mut(s.ptr as *mut u8, s.len);
let _ = Box::from_raw(slice as *mut [u8]);
}
}
fn bump(ud: *mut std::ffi::c_void) {
if ud.is_null() {
return;
}
unsafe {
(*(ud as *mut AtomicUsize)).fetch_add(1, Ordering::SeqCst);
}
}
extern "C" fn two_skill_handler(
_cwd: StbStringRef,
_reason: StbStringRef,
out: *mut StbString,
ud: *mut std::ffi::c_void,
) -> i32 {
bump(ud);
let json = serde_json::json!({
"skillPaths": ["/a/SKILL.md", "/b/SKILL.md"],
"promptPaths": ["/p/greet.md"],
"themePaths": ["/t/dark.json"],
})
.to_string();
unsafe {
*out = StbString::from_string(json);
}
0
}
extern "C" fn skill_only_handler(
_cwd: StbStringRef,
_reason: StbStringRef,
out: *mut StbString,
ud: *mut std::ffi::c_void,
) -> i32 {
bump(ud);
let json = r#"{"skillPaths":["/c/SKILL.md"]}"#.to_string();
unsafe {
*out = StbString::from_string(json);
}
0
}
extern "C" fn error_handler(
_cwd: StbStringRef,
_reason: StbStringRef,
_out: *mut StbString,
ud: *mut std::ffi::c_void,
) -> i32 {
bump(ud);
42
}
extern "C" fn garbage_handler(
_cwd: StbStringRef,
_reason: StbStringRef,
out: *mut StbString,
ud: *mut std::ffi::c_void,
) -> i32 {
bump(ud);
unsafe {
*out = StbString::from_string("not json {{{".to_string());
}
0
}
fn reg_with(handlers: &[ResourcesDiscoverFn], counter: &AtomicUsize) -> RegistrySnapshot {
counter.store(0, Ordering::SeqCst);
let mut reg = ExtensionRegistry::new();
let ud = counter as *const AtomicUsize as *mut std::ffi::c_void;
for h in handlers {
reg.register_resources_discover(*h, test_free, ud);
}
reg.snapshot()
}
#[test]
fn no_handlers_returns_empty() {
let counter = AtomicUsize::new(0);
let snap = reg_with(&[], &counter);
let r = emit_resources_discover("/cwd", "startup", &snap);
assert!(r.skill_paths.is_empty());
assert!(r.prompt_paths.is_empty());
assert!(r.theme_paths.is_empty());
}
#[test]
fn one_handler_merges_all_three_arrays() {
let counter = AtomicUsize::new(0);
let snap = reg_with(&[two_skill_handler], &counter);
let r = emit_resources_discover("/cwd", "startup", &snap);
assert_eq!(r.skill_paths, ["/a/SKILL.md", "/b/SKILL.md"]);
assert_eq!(r.prompt_paths, ["/p/greet.md"]);
assert_eq!(r.theme_paths, ["/t/dark.json"]);
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
#[test]
fn multiple_handlers_concatenate_in_registration_order() {
let counter = AtomicUsize::new(0);
let snap = reg_with(&[two_skill_handler, skill_only_handler], &counter);
let r = emit_resources_discover("/cwd", "reload", &snap);
assert_eq!(r.skill_paths, ["/a/SKILL.md", "/b/SKILL.md", "/c/SKILL.md"]);
assert_eq!(r.prompt_paths, ["/p/greet.md"]);
assert_eq!(r.theme_paths, ["/t/dark.json"]);
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
#[test]
fn error_handler_skipped_fan_out_continues() {
let counter = AtomicUsize::new(0);
let snap = reg_with(
&[error_handler, two_skill_handler, garbage_handler],
&counter,
);
let r = emit_resources_discover("/cwd", "startup", &snap);
assert_eq!(counter.load(Ordering::SeqCst), 3);
assert_eq!(r.skill_paths, ["/a/SKILL.md", "/b/SKILL.md"]);
assert_eq!(r.prompt_paths, ["/p/greet.md"]);
}
#[test]
fn stale_registry_returns_empty() {
let counter = AtomicUsize::new(0);
let snap = reg_with(&[two_skill_handler], &counter);
snap.active_flag().store(false, Ordering::SeqCst);
let r = emit_resources_discover("/cwd", "startup", &snap);
assert!(r.skill_paths.is_empty());
assert_eq!(
counter.load(Ordering::SeqCst),
0,
"stale registry must not invoke handlers"
);
}
#[test]
fn parse_payload_lenient_defaults() {
assert_eq!(parse_discover_payload(""), DiscoveredResources::default());
assert_eq!(parse_discover_payload("{}"), DiscoveredResources::default());
assert_eq!(
parse_discover_payload(r#"{"skillPaths":["/x"]}"#).skill_paths,
["/x"]
);
assert_eq!(
parse_discover_payload("[1,2,3]"),
DiscoveredResources::default()
);
assert_eq!(
parse_discover_payload("null"),
DiscoveredResources::default()
);
}
}