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