Skip to main content

net/ffi/
aggregator.rs

1//! C FFI bindings for the aggregator-registry RPC client +
2//! fold-query client + channel-visibility setter
3//! (`SDK_AGGREGATOR_SUBNET_PLAN.md` stages 5 + 4-fold-query).
4//!
5//! Boundary conventions mirror `ffi::mesh`: opaque handles
6//! freed via dedicated `_free`, scalar ids as `u64`, JSON
7//! strings via `CString::into_raw` freed by the caller via
8//! `net_free_string`. Caller safety contract is identical to
9//! `ffi::mesh` / `ffi::cortex`; `clippy::missing_safety_doc`
10//! suppressed at the module level for the same rationale.
11#![allow(clippy::missing_safety_doc)]
12#![expect(
13    clippy::undocumented_unsafe_blocks,
14    reason = "module-wide FFI safety contract documented in ffi::mod.rs preamble"
15)]
16
17use std::ffi::{c_char, c_int, CStr, CString};
18use std::sync::Arc;
19use std::time::Duration;
20
21use parking_lot::{Mutex as ParkingMutex, RwLock as ParkingRwLock};
22
23use crate::adapter::net::behavior::aggregator::{
24    FoldQueryClient, FoldQueryClientError, FoldQueryError, RegistryClient, RegistryClientError,
25    RegistryGroupSummary, RegistryRpcError, SummaryAnnouncement, DEFAULT_QUERY_DEADLINE,
26    DEFAULT_REGISTRY_DEADLINE,
27};
28use crate::adapter::net::{ChannelConfig, ChannelId, ChannelName, Visibility};
29
30use super::mesh::MeshNodeHandle;
31
32// ─── Error-kind discriminants (locked across SDKs) ───
33
34/// Server handler rejected: no summarizer registered under the
35/// requested fold kind. Only emitted by
36/// `net_fold_query_client_*` ops.
37pub const NET_REGISTRY_ERR_UNKNOWN_KIND: i32 = 7;
38
39/// `net_registry_client_*` op succeeded.
40pub const NET_REGISTRY_OK: i32 = 0;
41/// Transport-level failure (no route, timeout, server returned
42/// a non-Ok status before invoking the handler).
43pub const NET_REGISTRY_ERR_TRANSPORT: i32 = 1;
44/// Request serialization or response deserialization failed.
45pub const NET_REGISTRY_ERR_CODEC: i32 = 2;
46/// Server handler rejected: no template by that name.
47pub const NET_REGISTRY_ERR_UNKNOWN_TEMPLATE: i32 = 3;
48/// Server handler rejected: a group by that name is already
49/// registered.
50pub const NET_REGISTRY_ERR_DUPLICATE_GROUP_NAME: i32 = 4;
51/// Server handler rejected for a daemon-defined reason
52/// (config validation, replica spawn failed, etc.).
53pub const NET_REGISTRY_ERR_SPAWN_REJECTED: i32 = 5;
54/// Server doesn't accept dynamic spawn (read-only daemon).
55pub const NET_REGISTRY_ERR_SPAWN_NOT_SUPPORTED: i32 = 6;
56/// Server handler rejected `Scale`: no group by that name is
57/// registered on the target.
58pub const NET_REGISTRY_ERR_UNKNOWN_GROUP: i32 = 8;
59/// Server handler rejected `Scale` for a daemon-defined reason
60/// (template mismatch, replica spawn/stop failure, etc.).
61pub const NET_REGISTRY_ERR_SCALE_REJECTED: i32 = 9;
62/// Server doesn't accept dynamic scale (no scale handler
63/// installed).
64pub const NET_REGISTRY_ERR_SCALE_NOT_SUPPORTED: i32 = 10;
65/// Caller-side error: a string argument wasn't valid UTF-8 or
66/// a pointer was null where one was required.
67pub const NET_REGISTRY_ERR_INVALID_ARGS: i32 = 99;
68
69// ─── Visibility discriminants ───
70
71/// Wire-equivalent of [`Visibility`]. Values are
72/// representation-stable across SDK releases — operator code
73/// referring to them by literal value (not just by name) stays
74/// correct. Mirrors every substrate variant 1-to-1; mirror order
75/// is sorted by tier-broadness for operator readability.
76#[repr(i32)]
77#[derive(Copy, Clone)]
78pub enum NetVisibility {
79    /// Mirrors [`Visibility::Global`] — visible everywhere.
80    Global = 0,
81    /// Mirrors [`Visibility::ParentVisible`].
82    ParentVisible = 1,
83    /// Mirrors [`Visibility::Exported`] — explicit per-subnet export list.
84    Exported = 2,
85    /// Mirrors [`Visibility::SubnetLocal`] — packets never leave the subnet.
86    SubnetLocal = 3,
87}
88
89impl NetVisibility {
90    fn from_raw(raw: i32) -> Option<Visibility> {
91        match raw {
92            0 => Some(Visibility::Global),
93            1 => Some(Visibility::ParentVisible),
94            2 => Some(Visibility::Exported),
95            3 => Some(Visibility::SubnetLocal),
96            _ => None,
97        }
98    }
99
100    /// Compile-time exhaustiveness check in the *opposite*
101    /// direction — every substrate [`Visibility`] variant must
102    /// have a wire-stable C ABI counterpart. If the substrate
103    /// gains a variant, this `match` stops compiling, forcing
104    /// the FFI maintainer to either add the discriminant + bump
105    /// the wire contract or explicitly accept the omission with
106    /// `_ => None`. Without this, [`from_raw`] would silently
107    /// reject the new variant and operator code referring to it
108    /// by literal value would see a NULL handle / ERR_INVALID
109    /// instead of a typed wire error.
110    #[allow(dead_code)] // existence is the check
111    fn to_raw(v: Visibility) -> NetVisibility {
112        match v {
113            Visibility::Global => NetVisibility::Global,
114            Visibility::ParentVisible => NetVisibility::ParentVisible,
115            Visibility::Exported => NetVisibility::Exported,
116            Visibility::SubnetLocal => NetVisibility::SubnetLocal,
117        }
118    }
119}
120
121// ─── Handle ───
122
123/// FFI handle for a [`RegistryClient`].
124///
125/// The inner client is wrapped in a `RwLock` so concurrent ops
126/// (entry points are called from many threads in async runtimes)
127/// can share read access while a `set_deadline` writer
128/// serializes. `last_error_detail` lives behind a separate
129/// `parking_lot::Mutex`; the lifetime contract for pointers
130/// returned by [`net_registry_last_error_detail`] is "valid
131/// until the next op on this handle or until free".
132pub struct RegistryClientHandle {
133    client: ParkingRwLock<RegistryClient>,
134    last_error_detail: ParkingMutex<Option<CString>>,
135}
136
137// ─── Constructor / free / builder ───
138
139/// Construct a `RegistryClient` against an existing
140/// [`MeshNodeHandle`]. Returns a handle the caller frees via
141/// [`net_registry_client_free`]. Returns NULL on null input.
142#[unsafe(no_mangle)]
143pub unsafe extern "C" fn net_registry_client_new(
144    mesh_handle: *mut MeshNodeHandle,
145) -> *mut RegistryClientHandle {
146    if mesh_handle.is_null() {
147        return std::ptr::null_mut();
148    }
149    let mesh_arc = unsafe { super::mesh::mesh_node_arc(&*mesh_handle) };
150    let boxed = Box::new(RegistryClientHandle {
151        client: ParkingRwLock::new(RegistryClient::new(mesh_arc)),
152        last_error_detail: ParkingMutex::new(None),
153    });
154    Box::into_raw(boxed)
155}
156
157/// Free a `RegistryClient` handle produced by
158/// [`net_registry_client_new`]. Idempotent on NULL.
159#[unsafe(no_mangle)]
160pub unsafe extern "C" fn net_registry_client_free(handle: *mut RegistryClientHandle) {
161    if handle.is_null() {
162        return;
163    }
164    drop(unsafe { Box::from_raw(handle) });
165}
166
167/// Override the per-call deadline in milliseconds. `millis == 0`
168/// resets to the substrate default. Safe to call concurrently
169/// with in-flight ops; the writer takes the inner lock briefly
170/// and any concurrent reader either observes the old or the new
171/// deadline (no torn read).
172#[unsafe(no_mangle)]
173pub unsafe extern "C" fn net_registry_client_set_deadline(
174    handle: *mut RegistryClientHandle,
175    millis: u64,
176) {
177    if handle.is_null() {
178        return;
179    }
180    let h: &RegistryClientHandle = unsafe { &*handle };
181    let deadline = if millis == 0 {
182        DEFAULT_REGISTRY_DEADLINE
183    } else {
184        Duration::from_millis(millis)
185    };
186    h.client.write().set_deadline_mut(deadline);
187}
188
189// ─── Op-handler internals ───
190//
191// Every public `net_registry_client_*` op shares the same six
192// steps: null-check, parse CStr args, snapshot the client under
193// the read lock, await the substrate call, classify+store-detail
194// on error, write the out param. The `dispatch_*` + `write_*`
195// helpers below capture each step once.
196
197/// Set `*out` if non-null and return the JSON pointer + status.
198/// Op handlers funnel every success / failure path through this
199/// so the null-check on `out_error_kind` is centralized.
200#[inline]
201unsafe fn write_kind(out: *mut c_int, kind: c_int) {
202    if !out.is_null() {
203        unsafe { *out = kind };
204    }
205}
206
207/// Read a NUL-terminated UTF-8 string argument and return an
208/// owned `String`, or set the out-param to `INVALID_ARGS` +
209/// return `None` if the pointer is null or the bytes aren't
210/// valid UTF-8.
211#[inline]
212unsafe fn cstr_arg(ptr: *const c_char, out: *mut c_int) -> Option<String> {
213    if ptr.is_null() {
214        unsafe { write_kind(out, NET_REGISTRY_ERR_INVALID_ARGS) };
215        return None;
216    }
217    match unsafe { CStr::from_ptr(ptr).to_str() } {
218        Ok(s) => Some(s.to_owned()),
219        Err(_) => {
220            unsafe { write_kind(out, NET_REGISTRY_ERR_INVALID_ARGS) };
221            None
222        }
223    }
224}
225
226/// Convert a JSON string into a heap-allocated `*mut c_char` the
227/// caller frees with `net_free_string`. Returns NULL + sets the
228/// out-param to `CODEC` if the string contains an embedded NUL.
229#[inline]
230unsafe fn json_to_raw(json: String, out: *mut c_int) -> *mut c_char {
231    match CString::new(json) {
232        Ok(s) => {
233            unsafe { write_kind(out, NET_REGISTRY_OK) };
234            s.into_raw()
235        }
236        Err(_) => {
237            unsafe { write_kind(out, NET_REGISTRY_ERR_CODEC) };
238            std::ptr::null_mut()
239        }
240    }
241}
242
243/// Funnel for any registry op that returns a JSON string.
244/// Takes a closure that produces `Result<String, RegistryClientError>`
245/// (the JSON-encoding step is the caller's responsibility because
246/// the substrate type varies per op).
247unsafe fn registry_op_json<F>(
248    handle: *mut RegistryClientHandle,
249    out_error_kind: *mut c_int,
250    op: F,
251) -> *mut c_char
252where
253    F: FnOnce(RegistryClient) -> Result<String, RegistryClientError>,
254{
255    if handle.is_null() {
256        unsafe { write_kind(out_error_kind, NET_REGISTRY_ERR_INVALID_ARGS) };
257        return std::ptr::null_mut();
258    }
259    let h: &RegistryClientHandle = unsafe { &*handle };
260    let client = h.client.read().clone();
261    match op(client) {
262        Ok(json) => unsafe { json_to_raw(json, out_error_kind) },
263        Err(e) => {
264            let (kind, detail) = classify(&e);
265            store_error_detail(h, detail);
266            unsafe { write_kind(out_error_kind, kind) };
267            std::ptr::null_mut()
268        }
269    }
270}
271
272// ─── Operations ───
273
274/// Enumerate groups on `target_node_id`. Returns a JSON-encoded
275/// `[RegistryGroupSummaryJson]` string the caller frees via
276/// `net_free_string`. On error, writes the error kind to
277/// `*out_error_kind` and returns NULL.
278#[unsafe(no_mangle)]
279pub unsafe extern "C" fn net_registry_client_list(
280    handle: *mut RegistryClientHandle,
281    target_node_id: u64,
282    out_error_kind: *mut c_int,
283) -> *mut c_char {
284    if out_error_kind.is_null() {
285        return std::ptr::null_mut();
286    }
287    unsafe {
288        registry_op_json(handle, out_error_kind, |client| {
289            block_on(client.list(target_node_id)).map(|groups| groups_to_json(&groups))
290        })
291    }
292}
293
294/// Spawn a new group by referencing a daemon-side template.
295/// `template_name` + `group_name` are NUL-terminated UTF-8.
296#[unsafe(no_mangle)]
297pub unsafe extern "C" fn net_registry_client_spawn(
298    handle: *mut RegistryClientHandle,
299    target_node_id: u64,
300    template_name: *const c_char,
301    group_name: *const c_char,
302    replica_count: u8,
303    out_error_kind: *mut c_int,
304) -> *mut c_char {
305    let Some(template) = (unsafe { cstr_arg(template_name, out_error_kind) }) else {
306        return std::ptr::null_mut();
307    };
308    let Some(group) = (unsafe { cstr_arg(group_name, out_error_kind) }) else {
309        return std::ptr::null_mut();
310    };
311    unsafe {
312        registry_op_json(handle, out_error_kind, |client| {
313            block_on(client.spawn(target_node_id, template, group, replica_count))
314                .map(|summary| group_to_json(&summary))
315        })
316    }
317}
318
319/// Tear down a registered group by name. Returns `1` when the
320/// group existed and was stopped, `0` when no such group was
321/// registered, `-1` on transport / codec / invalid-args
322/// failure (consult `out_error_kind`).
323#[unsafe(no_mangle)]
324pub unsafe extern "C" fn net_registry_client_unregister(
325    handle: *mut RegistryClientHandle,
326    target_node_id: u64,
327    group_name: *const c_char,
328    out_error_kind: *mut c_int,
329) -> c_int {
330    if handle.is_null() {
331        unsafe { write_kind(out_error_kind, NET_REGISTRY_ERR_INVALID_ARGS) };
332        return -1;
333    }
334    let Some(group) = (unsafe { cstr_arg(group_name, out_error_kind) }) else {
335        return -1;
336    };
337    let h: &RegistryClientHandle = unsafe { &*handle };
338    let client = h.client.read().clone();
339    match block_on(client.unregister(target_node_id, group)) {
340        Ok(existed) => {
341            unsafe { write_kind(out_error_kind, NET_REGISTRY_OK) };
342            if existed {
343                1
344            } else {
345                0
346            }
347        }
348        Err(e) => {
349            let (kind, detail) = classify(&e);
350            store_error_detail(h, detail);
351            unsafe { write_kind(out_error_kind, kind) };
352            -1
353        }
354    }
355}
356
357/// Get the operator-facing detail string for the most recent
358/// non-OK op on this handle. Returns a NUL-terminated C string
359/// owned by the handle — the pointer is valid until the next
360/// op (which may overwrite it) or until the handle is freed.
361/// Returns NULL when no error has been recorded.
362///
363/// Callers wanting to hold the string across other ops should
364/// copy it before doing anything else with the handle.
365#[unsafe(no_mangle)]
366pub unsafe extern "C" fn net_registry_last_error_detail(
367    handle: *mut RegistryClientHandle,
368) -> *const c_char {
369    if handle.is_null() {
370        return std::ptr::null();
371    }
372    let h: &RegistryClientHandle = unsafe { &*handle };
373    let guard = h.last_error_detail.lock();
374    match guard.as_ref() {
375        Some(c) => c.as_ptr(),
376        None => std::ptr::null(),
377    }
378}
379
380// ─── Visibility setter ───
381
382/// Register a channel with a specific [`Visibility`] tier.
383/// Mirrors `Mesh::register_channel` from the Rust SDK at the C
384/// boundary. `visibility` is an [`i32`] matching the
385/// [`NetVisibility`] discriminants.
386///
387/// Returns `NET_REGISTRY_OK` on success or a typed error code.
388/// Operator-facing detail (e.g. "invalid channel name") is
389/// written to a side-channel: the substrate logs via `tracing`
390/// — no per-call detail string is allocated at this layer.
391#[unsafe(no_mangle)]
392pub unsafe extern "C" fn net_register_channel(
393    mesh_handle: *mut MeshNodeHandle,
394    name: *const c_char,
395    visibility: c_int,
396) -> c_int {
397    if mesh_handle.is_null() || name.is_null() {
398        return NET_REGISTRY_ERR_INVALID_ARGS;
399    }
400    let vis = match NetVisibility::from_raw(visibility) {
401        Some(v) => v,
402        None => return NET_REGISTRY_ERR_INVALID_ARGS,
403    };
404    let name_str = match unsafe { CStr::from_ptr(name).to_str() } {
405        Ok(s) => s,
406        Err(_) => return NET_REGISTRY_ERR_INVALID_ARGS,
407    };
408    let channel = match ChannelName::new(name_str) {
409        Ok(c) => c,
410        Err(_) => return NET_REGISTRY_ERR_INVALID_ARGS,
411    };
412    // Use the mesh's installed ChannelConfigRegistry. The
413    // mesh-FFI's net_mesh_new always installs one, so this is
414    // safe; if it ever changes, the registry being `None` is
415    // surfaced as NET_REGISTRY_ERR_INVALID_ARGS.
416    let mesh_arc: Arc<crate::adapter::net::MeshNode> =
417        unsafe { super::mesh::mesh_node_arc(&*mesh_handle) };
418    let Some(configs) = mesh_arc.channel_configs() else {
419        return NET_REGISTRY_ERR_INVALID_ARGS;
420    };
421    let cfg = ChannelConfig::new(ChannelId::new(channel)).with_visibility(vis);
422    configs.insert(cfg);
423    NET_REGISTRY_OK
424}
425
426// ─── FoldQueryClient handle ───
427
428/// FFI handle for a [`FoldQueryClient`]. Same sync model as
429/// [`RegistryClientHandle`]: the inner client lives behind a
430/// `RwLock` so `set_ttl` / `set_deadline` writers serialize with
431/// in-flight ops, and the cache (held by the inner client's
432/// `Arc<RwLock<HashMap<...>>>`) survives deadline / TTL changes.
433pub struct FoldQueryClientHandle {
434    client: ParkingRwLock<FoldQueryClient>,
435    last_error_detail: ParkingMutex<Option<CString>>,
436}
437
438/// Construct a `FoldQueryClient` against an existing
439/// [`MeshNodeHandle`]. Returns a handle the caller frees via
440/// [`net_fold_query_client_free`]. Returns NULL on null input.
441#[unsafe(no_mangle)]
442pub unsafe extern "C" fn net_fold_query_client_new(
443    mesh_handle: *mut MeshNodeHandle,
444) -> *mut FoldQueryClientHandle {
445    if mesh_handle.is_null() {
446        return std::ptr::null_mut();
447    }
448    let mesh_arc = unsafe { super::mesh::mesh_node_arc(&*mesh_handle) };
449    let boxed = Box::new(FoldQueryClientHandle {
450        client: ParkingRwLock::new(FoldQueryClient::new(mesh_arc)),
451        last_error_detail: ParkingMutex::new(None),
452    });
453    Box::into_raw(boxed)
454}
455
456/// Free a `FoldQueryClient` handle. Idempotent on NULL.
457#[unsafe(no_mangle)]
458pub unsafe extern "C" fn net_fold_query_client_free(handle: *mut FoldQueryClientHandle) {
459    if handle.is_null() {
460        return;
461    }
462    drop(unsafe { Box::from_raw(handle) });
463}
464
465/// Override the cache TTL in milliseconds. `millis == 0` disables
466/// the cache entirely. Mutates in place — the warmed cache
467/// survives the adjustment.
468#[unsafe(no_mangle)]
469pub unsafe extern "C" fn net_fold_query_client_set_ttl(
470    handle: *mut FoldQueryClientHandle,
471    millis: u64,
472) {
473    if handle.is_null() {
474        return;
475    }
476    let h: &FoldQueryClientHandle = unsafe { &*handle };
477    h.client.write().set_ttl_mut(Duration::from_millis(millis));
478}
479
480/// Override the per-call deadline in milliseconds. `millis == 0`
481/// resets to the substrate default. Mutates in place.
482#[unsafe(no_mangle)]
483pub unsafe extern "C" fn net_fold_query_client_set_deadline(
484    handle: *mut FoldQueryClientHandle,
485    millis: u64,
486) {
487    if handle.is_null() {
488        return;
489    }
490    let h: &FoldQueryClientHandle = unsafe { &*handle };
491    let deadline = if millis == 0 {
492        DEFAULT_QUERY_DEADLINE
493    } else {
494        Duration::from_millis(millis)
495    };
496    h.client.write().set_deadline_mut(deadline);
497}
498
499/// Query the aggregator's latest cached summaries. Cache hit
500/// returns immediately; miss issues a wire RPC, caches the
501/// response, and returns. Returns a JSON-encoded
502/// `[SummaryAnnouncementJson]` string the caller frees via
503/// `net_free_string`.
504#[unsafe(no_mangle)]
505pub unsafe extern "C" fn net_fold_query_client_query_latest(
506    handle: *mut FoldQueryClientHandle,
507    target_node_id: u64,
508    kind: u16,
509    out_error_kind: *mut c_int,
510) -> *mut c_char {
511    if out_error_kind.is_null() {
512        return std::ptr::null_mut();
513    }
514    unsafe {
515        fold_query_op_json(handle, out_error_kind, |client| {
516            block_on(client.query_latest(target_node_id, kind))
517                .map(|summaries| summaries_to_json(&summaries))
518        })
519    }
520}
521
522/// Force a fresh `SummarizeNow` query — never cached.
523#[unsafe(no_mangle)]
524pub unsafe extern "C" fn net_fold_query_client_query_summarize_now(
525    handle: *mut FoldQueryClientHandle,
526    target_node_id: u64,
527    kind: u16,
528    out_error_kind: *mut c_int,
529) -> *mut c_char {
530    if out_error_kind.is_null() {
531        return std::ptr::null_mut();
532    }
533    unsafe {
534        fold_query_op_json(handle, out_error_kind, |client| {
535            block_on(client.query_summarize_now(target_node_id, kind))
536                .map(|summaries| summaries_to_json(&summaries))
537        })
538    }
539}
540
541/// Drop every cached entry.
542#[unsafe(no_mangle)]
543pub unsafe extern "C" fn net_fold_query_client_invalidate_cache(
544    handle: *mut FoldQueryClientHandle,
545) {
546    if handle.is_null() {
547        return;
548    }
549    let h: &FoldQueryClientHandle = unsafe { &*handle };
550    h.client.read().invalidate_cache();
551}
552
553/// Drop only cache entries matching `target_node_id`.
554#[unsafe(no_mangle)]
555pub unsafe extern "C" fn net_fold_query_client_invalidate_target(
556    handle: *mut FoldQueryClientHandle,
557    target_node_id: u64,
558) {
559    if handle.is_null() {
560        return;
561    }
562    let h: &FoldQueryClientHandle = unsafe { &*handle };
563    h.client.read().invalidate_target(target_node_id);
564}
565
566/// Operator-facing detail string for the most recent non-OK
567/// fold-query op. Same valid-until contract as
568/// [`net_registry_last_error_detail`].
569#[unsafe(no_mangle)]
570pub unsafe extern "C" fn net_fold_query_last_error_detail(
571    handle: *mut FoldQueryClientHandle,
572) -> *const c_char {
573    if handle.is_null() {
574        return std::ptr::null();
575    }
576    let h: &FoldQueryClientHandle = unsafe { &*handle };
577    let guard = h.last_error_detail.lock();
578    match guard.as_ref() {
579        Some(c) => c.as_ptr(),
580        None => std::ptr::null(),
581    }
582}
583
584// ─── Internals ───
585
586/// Run a future to completion on the shared mesh-FFI tokio
587/// runtime. Same as `ffi::mesh::block_on` — re-uses that
588/// runtime so we don't fragment scheduling.
589fn block_on<F: std::future::Future>(future: F) -> F::Output {
590    super::mesh::block_on(future)
591}
592
593/// Funnel for any fold-query op that returns a JSON string.
594/// Mirror of [`registry_op_json`].
595unsafe fn fold_query_op_json<F>(
596    handle: *mut FoldQueryClientHandle,
597    out_error_kind: *mut c_int,
598    op: F,
599) -> *mut c_char
600where
601    F: FnOnce(FoldQueryClient) -> Result<String, FoldQueryClientError>,
602{
603    if handle.is_null() {
604        unsafe { write_kind(out_error_kind, NET_REGISTRY_ERR_INVALID_ARGS) };
605        return std::ptr::null_mut();
606    }
607    let h: &FoldQueryClientHandle = unsafe { &*handle };
608    let client = h.client.read().clone();
609    match op(client) {
610        Ok(json) => unsafe { json_to_raw(json, out_error_kind) },
611        Err(e) => {
612            let (kind, detail) = classify_fold_query(&e);
613            store_fold_query_error_detail(h, detail);
614            unsafe { write_kind(out_error_kind, kind) };
615            std::ptr::null_mut()
616        }
617    }
618}
619
620fn classify_fold_query(err: &FoldQueryClientError) -> (i32, String) {
621    match err {
622        FoldQueryClientError::Transport(e) => (NET_REGISTRY_ERR_TRANSPORT, format!("{e}")),
623        FoldQueryClientError::Codec(c) => (NET_REGISTRY_ERR_CODEC, c.clone()),
624        FoldQueryClientError::Server(FoldQueryError::UnknownKind { kind }) => (
625            NET_REGISTRY_ERR_UNKNOWN_KIND,
626            format!("unknown fold kind: 0x{kind:04x}"),
627        ),
628        FoldQueryClientError::Server(FoldQueryError::DecodeFailed(s)) => {
629            (NET_REGISTRY_ERR_CODEC, format!("server decode: {s}"))
630        }
631    }
632}
633
634fn store_fold_query_error_detail(h: &FoldQueryClientHandle, detail: String) {
635    let c = match CString::new(detail) {
636        Ok(c) => c,
637        Err(_) => CString::new("invalid utf-8 in error detail").unwrap_or_default(),
638    };
639    *h.last_error_detail.lock() = Some(c);
640}
641
642fn summaries_to_json(summaries: &[SummaryAnnouncement]) -> String {
643    let wire: Vec<SummaryWire<'_>> = summaries.iter().map(SummaryWire::from).collect();
644    // `to_string` only fails on serializer-side issues — none of
645    // our wire types have non-string map keys or Float NaN — so
646    // the unwrap is unreachable. Defensive `to_string`-on-error
647    // keeps the FFI surface infallible.
648    serde_json::to_string(&wire).unwrap_or_else(|_| "[]".to_string())
649}
650
651#[cfg(test)]
652fn summary_to_json(s: &SummaryAnnouncement) -> String {
653    serde_json::to_string(&SummaryWire::from(s)).unwrap_or_else(|_| "{}".to_string())
654}
655
656#[derive(serde::Serialize)]
657struct SummaryWire<'a> {
658    fold_kind: u16,
659    source_subnet: String,
660    generation: u64,
661    buckets: Vec<BucketWire<'a>>,
662}
663
664#[derive(serde::Serialize)]
665struct BucketWire<'a> {
666    name: &'a str,
667    count: u64,
668}
669
670impl<'a> From<&'a SummaryAnnouncement> for SummaryWire<'a> {
671    fn from(s: &'a SummaryAnnouncement) -> Self {
672        Self {
673            fold_kind: s.fold_kind,
674            source_subnet: format!("{}", s.source_subnet),
675            generation: s.generation,
676            buckets: s
677                .buckets
678                .iter()
679                .map(|(n, c)| BucketWire {
680                    name: n.as_str(),
681                    count: *c,
682                })
683                .collect(),
684        }
685    }
686}
687
688/// Map a `RegistryClientError` to `(error_kind, detail_string)`.
689fn classify(err: &RegistryClientError) -> (i32, String) {
690    match err {
691        RegistryClientError::Transport(e) => (NET_REGISTRY_ERR_TRANSPORT, format!("{e}")),
692        RegistryClientError::Codec(c) => (NET_REGISTRY_ERR_CODEC, c.clone()),
693        RegistryClientError::Server(RegistryRpcError::DecodeFailed(s)) => {
694            (NET_REGISTRY_ERR_CODEC, format!("server decode: {s}"))
695        }
696        RegistryClientError::Server(RegistryRpcError::UnknownTemplate(t)) => (
697            NET_REGISTRY_ERR_UNKNOWN_TEMPLATE,
698            format!("unknown template: {t}"),
699        ),
700        RegistryClientError::Server(RegistryRpcError::DuplicateGroupName(n)) => (
701            NET_REGISTRY_ERR_DUPLICATE_GROUP_NAME,
702            format!("duplicate group name: {n}"),
703        ),
704        RegistryClientError::Server(RegistryRpcError::SpawnRejected(d)) => (
705            NET_REGISTRY_ERR_SPAWN_REJECTED,
706            format!("spawn rejected: {d}"),
707        ),
708        RegistryClientError::Server(RegistryRpcError::SpawnNotSupported) => (
709            NET_REGISTRY_ERR_SPAWN_NOT_SUPPORTED,
710            "daemon is read-only (no spawn handler installed)".to_string(),
711        ),
712        RegistryClientError::Server(RegistryRpcError::UnknownGroup(g)) => (
713            NET_REGISTRY_ERR_UNKNOWN_GROUP,
714            format!("unknown group: {g}"),
715        ),
716        RegistryClientError::Server(RegistryRpcError::ScaleRejected(d)) => (
717            NET_REGISTRY_ERR_SCALE_REJECTED,
718            format!("scale rejected: {d}"),
719        ),
720        RegistryClientError::Server(RegistryRpcError::ScaleNotSupported) => (
721            NET_REGISTRY_ERR_SCALE_NOT_SUPPORTED,
722            "daemon doesn't accept dynamic scale (no scaler installed)".to_string(),
723        ),
724    }
725}
726
727fn store_error_detail(h: &RegistryClientHandle, detail: String) {
728    let c = match CString::new(detail) {
729        Ok(c) => c,
730        Err(_) => CString::new("invalid utf-8 in error detail").unwrap_or_default(),
731    };
732    *h.last_error_detail.lock() = Some(c);
733}
734
735/// Encode the wire-contract JSON for a slice of registry-group
736/// summaries via `serde_json`. The substrate type
737/// `RegistryGroupSummary` derives `Serialize` but its
738/// `group_seed: [u8; 32]` field serializes as an array of u8 —
739/// the wire contract calls for `group_seed_hex: "abab…"` (64
740/// lowercase hex chars). The proxy wire-types below handle the
741/// rename + hex encoding.
742fn groups_to_json(groups: &[RegistryGroupSummary]) -> String {
743    let wire: Vec<GroupWire<'_>> = groups.iter().map(GroupWire::from).collect();
744    serde_json::to_string(&wire).unwrap_or_else(|_| "[]".to_string())
745}
746
747fn group_to_json(g: &RegistryGroupSummary) -> String {
748    serde_json::to_string(&GroupWire::from(g)).unwrap_or_else(|_| "{}".to_string())
749}
750
751#[derive(serde::Serialize)]
752struct GroupWire<'a> {
753    name: &'a str,
754    group_seed_hex: String,
755    replicas: Vec<ReplicaWire<'a>>,
756}
757
758#[derive(serde::Serialize)]
759struct ReplicaWire<'a> {
760    generation: u64,
761    healthy: bool,
762    diagnostic: Option<&'a str>,
763    placement_node_id: Option<u64>,
764}
765
766impl<'a> From<&'a RegistryGroupSummary> for GroupWire<'a> {
767    fn from(g: &'a RegistryGroupSummary) -> Self {
768        Self {
769            name: g.name.as_str(),
770            group_seed_hex: hex::encode(g.group_seed),
771            replicas: g
772                .replicas
773                .iter()
774                .map(|r| ReplicaWire {
775                    generation: r.generation,
776                    healthy: r.healthy,
777                    diagnostic: r.diagnostic.as_deref(),
778                    placement_node_id: r.placement_node_id,
779                })
780                .collect(),
781        }
782    }
783}
784
785#[cfg(test)]
786mod tests {
787    use super::*;
788
789    #[test]
790    fn visibility_round_trips_through_raw() {
791        for (raw, expected) in [
792            (0, Visibility::Global),
793            (1, Visibility::ParentVisible),
794            (2, Visibility::Exported),
795            (3, Visibility::SubnetLocal),
796        ] {
797            let back = NetVisibility::from_raw(raw).expect("known discriminant");
798            assert_eq!(format!("{back:?}"), format!("{expected:?}"));
799        }
800        assert!(NetVisibility::from_raw(99).is_none());
801        assert!(NetVisibility::from_raw(-1).is_none());
802    }
803
804    #[test]
805    fn group_to_json_includes_every_documented_field() {
806        let g = RegistryGroupSummary {
807            name: "alpha".into(),
808            group_seed: [0xABu8; 32],
809            source_subnet: crate::adapter::net::subnet::SubnetId::GLOBAL,
810            fold_kinds: vec![0x0001],
811            replicas: vec![
812                crate::adapter::net::behavior::aggregator::RegistryReplicaSummary {
813                    generation: 42,
814                    healthy: true,
815                    diagnostic: None,
816                    placement_node_id: Some(0xBEEF),
817                },
818                crate::adapter::net::behavior::aggregator::RegistryReplicaSummary {
819                    generation: 0,
820                    healthy: false,
821                    diagnostic: Some("stuck".into()),
822                    placement_node_id: None,
823                },
824            ],
825        };
826        let json = group_to_json(&g);
827        assert!(json.contains("\"name\":\"alpha\""));
828        // Each byte 0xAB → "ab"; 32 of them = 64 hex chars
829        // alternating "ab".
830        assert!(json.contains("\"group_seed_hex\":\"abababababababababababababababababababababababababababababababab\""));
831        assert!(json.contains("\"generation\":42"));
832        assert!(json.contains("\"healthy\":true"));
833        assert!(json.contains("\"diagnostic\":null"));
834        assert!(json.contains("\"placement_node_id\":48879"));
835        assert!(json.contains("\"healthy\":false"));
836        assert!(json.contains("\"diagnostic\":\"stuck\""));
837        assert!(json.contains("\"placement_node_id\":null"));
838    }
839
840    #[test]
841    fn summary_to_json_includes_every_documented_field() {
842        let s = SummaryAnnouncement {
843            fold_kind: 0x42,
844            source_subnet: crate::adapter::net::subnet::SubnetId::GLOBAL,
845            generation: 7,
846            buckets: vec![("alpha".into(), 1), ("beta".into(), 2)],
847        };
848        let json = summary_to_json(&s);
849        assert!(json.contains("\"fold_kind\":66"));
850        assert!(json.contains("\"source_subnet\":\"global\""));
851        assert!(json.contains("\"generation\":7"));
852        assert!(json.contains("\"name\":\"alpha\""));
853        assert!(json.contains("\"count\":1"));
854        assert!(json.contains("\"name\":\"beta\""));
855        assert!(json.contains("\"count\":2"));
856    }
857
858    #[test]
859    fn classify_fold_query_maps_every_variant() {
860        use crate::adapter::net::mesh_rpc::RpcError;
861        // Transport — anything carrying an RpcError lands on
862        // NET_REGISTRY_ERR_TRANSPORT regardless of the inner kind.
863        let transport = FoldQueryClientError::Transport(RpcError::NoRoute {
864            target: 0,
865            reason: String::new(),
866        });
867        assert_eq!(
868            classify_fold_query(&transport).0,
869            NET_REGISTRY_ERR_TRANSPORT
870        );
871
872        let codec = FoldQueryClientError::Codec("bad".into());
873        assert_eq!(classify_fold_query(&codec).0, NET_REGISTRY_ERR_CODEC);
874
875        let unknown_kind = FoldQueryClientError::Server(FoldQueryError::UnknownKind { kind: 0x42 });
876        let (kind_code, detail) = classify_fold_query(&unknown_kind);
877        assert_eq!(kind_code, NET_REGISTRY_ERR_UNKNOWN_KIND);
878        assert!(detail.contains("0x0042"));
879
880        let decode_failed =
881            FoldQueryClientError::Server(FoldQueryError::DecodeFailed("boom".into()));
882        assert_eq!(
883            classify_fold_query(&decode_failed).0,
884            NET_REGISTRY_ERR_CODEC,
885        );
886    }
887}