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);
let out = unsafe {
rpi_plugin_sdk::callbacks::OwnedString::from_raw(out, Some(h.plugin_free_string))
};
if rc != 0 {
return Err(rc);
}
let json = out.to_string_lossy();
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::callbacks::free_string as test_free;
use rpi_plugin_sdk::ResourcesDiscoverFn;
use std::cell::Cell;
use std::sync::atomic::Ordering;
thread_local! { static CALLS: Cell<usize> = const { Cell::new(0) }; }
fn bump() {
CALLS.with(|count| count.set(count.get() + 1));
}
rpi_plugin_sdk::string_handler! {
fn two_skill_handler((_cwd, _reason), out, _ud) {
bump();
let json = serde_json::json!({
"skillPaths": ["/a/SKILL.md", "/b/SKILL.md"],
"promptPaths": ["/p/greet.md"],
"themePaths": ["/t/dark.json"],
})
.to_string();
i32::from(!out.write(json))
} }
rpi_plugin_sdk::string_handler! {
fn skill_only_handler((_cwd, _reason), out, _ud) {
bump();
let json = r#"{"skillPaths":["/c/SKILL.md"]}"#.to_string();
i32::from(!out.write(json))
} }
rpi_plugin_sdk::string_handler! {
fn error_handler((_cwd, _reason), _out, _ud) {
bump();
42
} }
rpi_plugin_sdk::string_handler! {
fn garbage_handler((_cwd, _reason), out, _ud) {
bump();
i32::from(!out.write("not json {{{".to_string()))
} }
fn reg_with(handlers: &[ResourcesDiscoverFn]) -> RegistrySnapshot {
CALLS.with(|count| count.set(0));
let mut reg = ExtensionRegistry::new();
for h in handlers {
reg.register_resources_discover(*h, test_free, std::ptr::null_mut());
}
reg.snapshot()
}
#[test]
fn no_handlers_returns_empty() {
let snap = reg_with(&[]);
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 snap = reg_with(&[two_skill_handler]);
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!(CALLS.with(Cell::get), 1);
}
#[test]
fn multiple_handlers_concatenate_in_registration_order() {
let snap = reg_with(&[two_skill_handler, skill_only_handler]);
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!(CALLS.with(Cell::get), 2);
}
#[test]
fn error_handler_skipped_fan_out_continues() {
let snap = reg_with(&[error_handler, two_skill_handler, garbage_handler]);
let r = emit_resources_discover("/cwd", "startup", &snap);
assert_eq!(CALLS.with(Cell::get), 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 snap = reg_with(&[two_skill_handler]);
snap.active_flag().store(false, Ordering::SeqCst);
let r = emit_resources_discover("/cwd", "startup", &snap);
assert!(r.skill_paths.is_empty());
assert_eq!(
CALLS.with(Cell::get),
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()
);
}
}