Skip to main content

vectorizer_sdk/rpc/
commands.rs

1//! Typed wrappers around the v1 RPC command catalog.
2//!
3//! Each method in this module corresponds to one entry in the wire
4//! spec's command catalog (§ 6). The wrapper:
5//!
6//! 1. Builds the positional `args` array per the spec.
7//! 2. Calls [`RpcClient::call`].
8//! 3. Decodes the [`VectorizerValue`] response into a typed Rust
9//!    value with explicit field handling (no `serde_json::from_value`
10//!    detour — the wire is MessagePack, not JSON).
11//!
12//! Adding a new typed wrapper for a v1 command landed on the server
13//! is mechanical: a new method on `RpcClient` here, an entry in the
14//! README, and (ideally) a test in `tests/rpc_integration.rs`.
15
16use super::client::{Result, RpcClient, RpcClientError};
17use super::types::VectorizerValue;
18
19// ── Shared helpers ────────────────────────────────────────────────────────────
20
21/// Decode a `VectorizerValue::Map` field as a `String`, returning
22/// `RpcClientError::Server` when the field is absent or non-string.
23fn need_str(v: &VectorizerValue, cmd: &str, key: &str) -> Result<String> {
24    v.map_get(key)
25        .and_then(|x| x.as_str().map(str::to_owned))
26        .ok_or_else(|| RpcClientError::Server(format!("{cmd}: missing string field '{key}'")))
27}
28
29/// Decode a `VectorizerValue::Map` field as `i64`.
30fn need_int(v: &VectorizerValue, cmd: &str, key: &str) -> Result<i64> {
31    v.map_get(key)
32        .and_then(|x| x.as_int())
33        .ok_or_else(|| RpcClientError::Server(format!("{cmd}: missing int field '{key}'")))
34}
35
36/// Decode a `VectorizerValue::Map` field as `bool`.
37fn need_bool(v: &VectorizerValue, cmd: &str, key: &str) -> Result<bool> {
38    v.map_get(key)
39        .and_then(|x| x.as_bool())
40        .ok_or_else(|| RpcClientError::Server(format!("{cmd}: missing bool field '{key}'")))
41}
42
43/// Decode a top-level `Array` response into a `Vec<String>`.
44fn decode_string_array(v: VectorizerValue, cmd: &str) -> Result<Vec<String>> {
45    let arr = v
46        .as_array()
47        .ok_or_else(|| RpcClientError::Server(format!("{cmd}: expected Array")))?;
48    Ok(arr
49        .iter()
50        .filter_map(|x| x.as_str().map(str::to_owned))
51        .collect())
52}
53
54// ── Return types ──────────────────────────────────────────────────────────────
55
56/// Collection metadata returned by `collections.get_info`.
57#[derive(Debug, Clone)]
58pub struct CollectionInfo {
59    /// Collection name as registered on the server.
60    pub name: String,
61    /// Number of vectors currently stored.
62    pub vector_count: i64,
63    /// Number of source documents represented by those vectors.
64    pub document_count: i64,
65    /// Vector dimension.
66    pub dimension: i64,
67    /// Distance metric the collection's index uses (e.g. `"Cosine"`).
68    pub metric: String,
69    /// ISO-8601 timestamp of when the collection was created.
70    pub created_at: String,
71    /// ISO-8601 timestamp of the last mutation.
72    pub updated_at: String,
73}
74
75/// One result from `search.basic` or `search.by_text`.
76#[derive(Debug, Clone)]
77pub struct SearchHit {
78    /// Vector ID inside the collection.
79    pub id: String,
80    /// Similarity score in `[0.0, 1.0]` for cosine; backend-defined otherwise.
81    pub score: f64,
82    /// Optional payload serialised as a JSON string. Decode with
83    /// `serde_json::from_str` for structured access.
84    pub payload: Option<String>,
85}
86
87/// Response from `collections.create`.
88#[derive(Debug, Clone)]
89pub struct CreateCollectionResult {
90    pub name: String,
91    pub dimension: i64,
92    pub metric: String,
93    pub success: bool,
94}
95
96/// Response from `collections.cleanup_empty` (admin-gated on server).
97#[derive(Debug, Clone)]
98pub struct CleanupEmptyResult {
99    pub removed: i64,
100    pub dry_run: bool,
101}
102
103/// Response from `vectors.insert` / `vectors.insert_text` / `vectors.update`.
104#[derive(Debug, Clone)]
105pub struct VectorWriteResult {
106    pub id: String,
107    pub success: bool,
108}
109
110/// Per-item result inside batch responses.
111#[derive(Debug, Clone)]
112pub struct BatchItemResult {
113    pub index: i64,
114    pub id: Option<String>,
115    pub status: String,
116    pub error: Option<String>,
117}
118
119/// Response from `vectors.batch_insert` / `vectors.batch_insert_texts`.
120#[derive(Debug, Clone)]
121pub struct BatchInsertResult {
122    pub inserted: i64,
123    pub failed: i64,
124    pub results: Vec<BatchItemResult>,
125}
126
127/// Response from `vectors.batch_update`.
128#[derive(Debug, Clone)]
129pub struct BatchUpdateResult {
130    pub updated: i64,
131    pub failed: i64,
132    pub results: Vec<BatchItemResult>,
133}
134
135/// Response from `vectors.batch_delete`.
136#[derive(Debug, Clone)]
137pub struct BatchDeleteResult {
138    pub deleted: i64,
139    pub failed: i64,
140    pub results: Vec<BatchItemResult>,
141}
142
143/// One per-query result from `vectors.batch_search`.
144#[derive(Debug, Clone)]
145pub struct BatchSearchResult {
146    pub index: i64,
147    pub status: String,
148    pub results: Vec<SearchHit>,
149    pub error: Option<String>,
150}
151
152/// Response from `vectors.move`.
153#[derive(Debug, Clone)]
154pub struct MoveRpcResult {
155    pub src: String,
156    pub dst: String,
157    pub moved: i64,
158    pub failed: i64,
159}
160
161/// Response from `vectors.copy`.
162#[derive(Debug, Clone)]
163pub struct CopyRpcResult {
164    pub src: String,
165    pub dst: String,
166    pub copied: i64,
167    pub failed: i64,
168}
169
170/// Response from `vectors.delete_by_filter`.
171#[derive(Debug, Clone)]
172pub struct DeleteByFilterRpcResult {
173    pub scanned: i64,
174    pub matched: i64,
175    pub deleted: i64,
176}
177
178/// Response from `vectors.bulk_update_metadata`.
179#[derive(Debug, Clone)]
180pub struct BulkUpdateMetadataRpcResult {
181    pub scanned: i64,
182    pub matched: i64,
183    pub updated: i64,
184}
185
186/// Response from `vectors.set_expiry`.
187#[derive(Debug, Clone)]
188pub struct SetExpiryResult {
189    pub id: String,
190    pub expires_at: i64,
191    pub success: bool,
192}
193
194/// Response from `vectors.embed`.
195#[derive(Debug, Clone)]
196pub struct EmbedResult {
197    pub embedding: Vec<f64>,
198    pub model: String,
199    pub dimension: i64,
200}
201
202/// Response from `vectors.list`.
203#[derive(Debug, Clone)]
204pub struct VectorListResult {
205    pub items: Vec<VectorizerValue>,
206    pub total: i64,
207    pub page: i64,
208    pub limit: i64,
209}
210
211/// Paginated list of vector IDs and data from `vectors.list`.
212/// Items are raw `VectorizerValue::Map` entries containing `id`, `data`, etc.
213
214/// Response from `search.explain`.
215#[derive(Debug, Clone)]
216pub struct SearchExplainResult {
217    pub hits: Vec<SearchHit>,
218    pub collection: String,
219    pub k: i64,
220    pub trace: SearchTrace,
221}
222
223/// HNSW traversal trace from `search.explain`.
224#[derive(Debug, Clone)]
225pub struct SearchTrace {
226    pub visited_nodes: i64,
227    pub ef_search: i64,
228    pub hnsw_search_ms: f64,
229    pub total_ms: f64,
230}
231
232/// Summary response from `discovery.discover`.
233#[derive(Debug, Clone)]
234pub struct DiscoverResult {
235    pub answer_prompt: String,
236    pub sections: i64,
237    pub bullets: i64,
238    pub chunks: i64,
239}
240
241/// One scored collection from `discovery.score_collections`.
242#[derive(Debug, Clone)]
243pub struct ScoredCollection {
244    pub name: String,
245    pub score: f64,
246    pub vector_count: i64,
247}
248
249/// Response from `discovery.expand_queries`.
250#[derive(Debug, Clone)]
251pub struct ExpandQueriesResult {
252    pub original_query: String,
253    pub expanded_queries: Vec<String>,
254    pub count: i64,
255}
256
257/// One chunk from `discovery.broad_discovery` / `discovery.semantic_focus`.
258#[derive(Debug, Clone)]
259pub struct DiscoveryChunk {
260    pub collection: String,
261    pub score: f64,
262    pub content_preview: String,
263}
264
265/// Response from `discovery.compress_evidence`.
266#[derive(Debug, Clone)]
267pub struct CompressBullet {
268    pub text: String,
269    pub source_id: String,
270    pub score: f64,
271}
272
273/// Response from `discovery.build_answer_plan`.
274#[derive(Debug, Clone)]
275pub struct AnswerPlanResult {
276    pub sections: Vec<AnswerPlanSection>,
277    pub total_bullets: i64,
278}
279
280/// One section inside an answer plan.
281#[derive(Debug, Clone)]
282pub struct AnswerPlanSection {
283    pub title: String,
284    pub bullets_count: i64,
285}
286
287/// Response from `discovery.render_llm_prompt`.
288#[derive(Debug, Clone)]
289pub struct RenderPromptResult {
290    pub prompt: String,
291    pub length: i64,
292    pub estimated_tokens: i64,
293}
294
295/// Response from graph discovery stats (`graph.discovery_status`).
296#[derive(Debug, Clone)]
297pub struct GraphDiscoveryStatus {
298    pub total_nodes: i64,
299    pub nodes_with_edges: i64,
300    pub total_edges: i64,
301    pub progress_percentage: f64,
302}
303
304/// Response from `graph.discover_edges`.
305#[derive(Debug, Clone)]
306pub struct DiscoverEdgesResult {
307    pub success: bool,
308    pub total_nodes: i64,
309    pub nodes_processed: i64,
310    pub nodes_with_edges: i64,
311    pub total_edges_created: i64,
312}
313
314/// Response from `graph.discover_edges_for_node`.
315#[derive(Debug, Clone)]
316pub struct DiscoverEdgesForNodeResult {
317    pub success: bool,
318    pub node_id: String,
319    pub edges_created: i64,
320}
321
322/// Admin stats response from `admin.stats`.
323#[derive(Debug, Clone)]
324pub struct AdminStats {
325    pub collections_count: i64,
326    pub total_vectors: i64,
327    pub version: String,
328}
329
330/// Admin status response from `admin.status`.
331#[derive(Debug, Clone)]
332pub struct AdminStatus {
333    pub ready: bool,
334    pub collections_count: i64,
335    pub version: String,
336}
337
338/// Slow query config from `admin.slow_queries_config`.
339#[derive(Debug, Clone)]
340pub struct SlowQueryConfigResult {
341    pub threshold_ms: i64,
342    pub capacity: i64,
343    pub status: String,
344}
345
346/// Response from `auth.me`.
347#[derive(Debug, Clone)]
348pub struct AuthMeResult {
349    pub username: String,
350    pub authenticated: bool,
351}
352
353/// Response from `auth.refresh_token`.
354#[derive(Debug, Clone)]
355pub struct RefreshTokenResult {
356    pub access_token: String,
357    pub token_type: String,
358}
359
360/// Response from `auth.validate_password`.
361#[derive(Debug, Clone)]
362pub struct ValidatePasswordResult {
363    pub valid: bool,
364    pub errors: Vec<String>,
365}
366
367/// Response from `auth.api_keys_create` / `auth.api_keys_create_scoped`.
368#[derive(Debug, Clone)]
369pub struct ApiKeyCreated {
370    pub api_key: String,
371    pub id: String,
372    pub name: String,
373}
374
375/// Response from `auth.api_keys_rotate`.
376#[derive(Debug, Clone)]
377pub struct RotatedApiKey {
378    pub old_key_id: String,
379    pub new_key_id: String,
380    pub new_token: String,
381    pub grace_until: Option<String>,
382}
383
384/// Response from `replication.configure`.
385#[derive(Debug, Clone)]
386pub struct ReplicationConfigureResult {
387    pub success: bool,
388    pub role: String,
389    pub message: String,
390}
391
392/// Response from `cluster.rebalance_status` — may be idle or active.
393#[derive(Debug, Clone)]
394pub struct RebalanceStatus {
395    /// `"idle"` when no rebalance is running.
396    pub status: Option<String>,
397    pub message: Option<String>,
398}
399
400// ── Helper: decode batch item results ────────────────────────────────────────
401
402fn decode_batch_items(arr: &[VectorizerValue]) -> Vec<BatchItemResult> {
403    arr.iter()
404        .map(|entry| {
405            let index = entry.map_get("index").and_then(|v| v.as_int()).unwrap_or(0);
406            let id = entry
407                .map_get("id")
408                .and_then(|v| v.as_str())
409                .map(str::to_owned);
410            let status = entry
411                .map_get("status")
412                .and_then(|v| v.as_str())
413                .unwrap_or("unknown")
414                .to_owned();
415            let error = entry
416                .map_get("error")
417                .and_then(|v| v.as_str())
418                .map(str::to_owned);
419            BatchItemResult {
420                index,
421                id,
422                status,
423                error,
424            }
425        })
426        .collect()
427}
428
429fn decode_search_hits(arr: &[VectorizerValue]) -> Vec<SearchHit> {
430    arr.iter()
431        .filter_map(|entry| {
432            let id = entry.map_get("id")?.as_str().map(str::to_owned)?;
433            let score = entry
434                .map_get("score")
435                .and_then(|v| v.as_float())
436                .unwrap_or(0.0);
437            let payload = entry
438                .map_get("payload")
439                .and_then(|v| v.as_str())
440                .map(str::to_owned);
441            Some(SearchHit { id, score, payload })
442        })
443        .collect()
444}
445
446// ═════════════════════════════════════════════════════════════════════════════
447// Collections
448// ═════════════════════════════════════════════════════════════════════════════
449
450impl RpcClient {
451    /// `collections.list` — return every collection name visible to
452    /// the authenticated principal.
453    pub async fn list_collections(&self) -> Result<Vec<String>> {
454        let v = self.call("collections.list", vec![]).await?;
455        decode_string_array(v, "collections.list")
456    }
457
458    /// `collections.get_info` — return metadata for one collection.
459    pub async fn get_collection_info(&self, name: &str) -> Result<CollectionInfo> {
460        let v = self
461            .call(
462                "collections.get_info",
463                vec![VectorizerValue::Str(name.to_owned())],
464            )
465            .await?;
466        Ok(CollectionInfo {
467            name: need_str(&v, "collections.get_info", "name")?,
468            vector_count: need_int(&v, "collections.get_info", "vector_count")?,
469            document_count: need_int(&v, "collections.get_info", "document_count")?,
470            dimension: need_int(&v, "collections.get_info", "dimension")?,
471            metric: need_str(&v, "collections.get_info", "metric")?,
472            created_at: need_str(&v, "collections.get_info", "created_at")?,
473            updated_at: need_str(&v, "collections.get_info", "updated_at")?,
474        })
475    }
476
477    /// `collections.create` — create a new collection.
478    ///
479    /// `config` is a `Map` with optional keys `dimension` (`Int`) and
480    /// `metric` (`Str`: `"cosine"` | `"euclidean"` | `"dot"`).
481    pub async fn create_collection(
482        &self,
483        name: &str,
484        config: VectorizerValue,
485    ) -> Result<CreateCollectionResult> {
486        let v = self
487            .call(
488                "collections.create",
489                vec![VectorizerValue::Str(name.to_owned()), config],
490            )
491            .await?;
492        Ok(CreateCollectionResult {
493            name: need_str(&v, "collections.create", "name")?,
494            dimension: need_int(&v, "collections.create", "dimension")?,
495            metric: need_str(&v, "collections.create", "metric")?,
496            success: need_bool(&v, "collections.create", "success")?,
497        })
498    }
499
500    /// `collections.delete` — delete a collection (admin-gated on server).
501    pub async fn delete_collection(&self, name: &str) -> Result<bool> {
502        let v = self
503            .call(
504                "collections.delete",
505                vec![VectorizerValue::Str(name.to_owned())],
506            )
507            .await?;
508        need_bool(&v, "collections.delete", "success")
509    }
510
511    /// `collections.list_empty` — list collections that contain zero vectors.
512    pub async fn list_empty_collections(&self) -> Result<Vec<String>> {
513        let v = self.call("collections.list_empty", vec![]).await?;
514        decode_string_array(v, "collections.list_empty")
515    }
516
517    /// `collections.cleanup_empty` — remove empty collections.
518    ///
519    /// Pass `dry_run: true` to preview which collections would be removed
520    /// without actually deleting them.
521    pub async fn cleanup_empty_collections(&self, dry_run: bool) -> Result<CleanupEmptyResult> {
522        let config = VectorizerValue::Map(vec![(
523            VectorizerValue::Str("dry_run".into()),
524            VectorizerValue::Bool(dry_run),
525        )]);
526        let v = self.call("collections.cleanup_empty", vec![config]).await?;
527        Ok(CleanupEmptyResult {
528            removed: need_int(&v, "collections.cleanup_empty", "removed")?,
529            dry_run: need_bool(&v, "collections.cleanup_empty", "dry_run")?,
530        })
531    }
532
533    /// `collections.force_save` — flush a collection's in-memory state to disk.
534    pub async fn force_save_collection(&self, name: &str) -> Result<bool> {
535        let v = self
536            .call(
537                "collections.force_save",
538                vec![VectorizerValue::Str(name.to_owned())],
539            )
540            .await?;
541        need_bool(&v, "collections.force_save", "success")
542    }
543}
544
545// ═════════════════════════════════════════════════════════════════════════════
546// Vectors
547// ═════════════════════════════════════════════════════════════════════════════
548
549impl RpcClient {
550    /// `vectors.get` — fetch one vector by id. Returns the raw
551    /// `VectorizerValue::Map` so callers can read whichever fields
552    /// they care about (`id`, `data`, `payload`, `document_id`).
553    pub async fn get_vector(&self, collection: &str, vector_id: &str) -> Result<VectorizerValue> {
554        self.call(
555            "vectors.get",
556            vec![
557                VectorizerValue::Str(collection.to_owned()),
558                VectorizerValue::Str(vector_id.to_owned()),
559            ],
560        )
561        .await
562    }
563
564    /// `vectors.insert` — insert one pre-computed vector.
565    ///
566    /// `data` must match the collection's configured dimension.
567    /// `payload` is an optional `VectorizerValue::Map` of metadata.
568    pub async fn insert_vector(
569        &self,
570        collection: &str,
571        id: Option<&str>,
572        data: Vec<f32>,
573        payload: Option<VectorizerValue>,
574    ) -> Result<VectorWriteResult> {
575        let id_val = id
576            .map(|s| VectorizerValue::Str(s.to_owned()))
577            .unwrap_or(VectorizerValue::Null);
578        let data_val = VectorizerValue::Array(
579            data.into_iter()
580                .map(|f| VectorizerValue::Float(f as f64))
581                .collect(),
582        );
583        let mut args = vec![
584            VectorizerValue::Str(collection.to_owned()),
585            id_val,
586            data_val,
587        ];
588        if let Some(p) = payload {
589            args.push(p);
590        }
591        let v = self.call("vectors.insert", args).await?;
592        Ok(VectorWriteResult {
593            id: need_str(&v, "vectors.insert", "id")?,
594            success: need_bool(&v, "vectors.insert", "success")?,
595        })
596    }
597
598    /// `vectors.insert_text` — embed `text` server-side and insert.
599    ///
600    /// The server auto-creates the collection if it does not exist.
601    pub async fn insert_text_vector(
602        &self,
603        collection: &str,
604        id: Option<&str>,
605        text: &str,
606        payload: Option<VectorizerValue>,
607    ) -> Result<VectorWriteResult> {
608        let id_val = id
609            .map(|s| VectorizerValue::Str(s.to_owned()))
610            .unwrap_or(VectorizerValue::Null);
611        let mut args = vec![
612            VectorizerValue::Str(collection.to_owned()),
613            id_val,
614            VectorizerValue::Str(text.to_owned()),
615        ];
616        if let Some(p) = payload {
617            args.push(p);
618        }
619        let v = self.call("vectors.insert_text", args).await?;
620        Ok(VectorWriteResult {
621            id: need_str(&v, "vectors.insert_text", "id")?,
622            success: need_bool(&v, "vectors.insert_text", "success")?,
623        })
624    }
625
626    /// `vectors.update` — replace a vector's data and/or payload.
627    pub async fn update_vector(
628        &self,
629        collection: &str,
630        id: &str,
631        data: Vec<f32>,
632        payload: Option<VectorizerValue>,
633    ) -> Result<VectorWriteResult> {
634        let data_val = VectorizerValue::Array(
635            data.into_iter()
636                .map(|f| VectorizerValue::Float(f as f64))
637                .collect(),
638        );
639        let mut args = vec![
640            VectorizerValue::Str(collection.to_owned()),
641            VectorizerValue::Str(id.to_owned()),
642            data_val,
643        ];
644        if let Some(p) = payload {
645            args.push(p);
646        }
647        let v = self.call("vectors.update", args).await?;
648        Ok(VectorWriteResult {
649            id: need_str(&v, "vectors.update", "id")?,
650            success: need_bool(&v, "vectors.update", "success")?,
651        })
652    }
653
654    /// `vectors.delete` — delete one vector by id.
655    pub async fn delete_vector_rpc(&self, collection: &str, id: &str) -> Result<bool> {
656        let v = self
657            .call(
658                "vectors.delete",
659                vec![
660                    VectorizerValue::Str(collection.to_owned()),
661                    VectorizerValue::Str(id.to_owned()),
662                ],
663            )
664            .await?;
665        need_bool(&v, "vectors.delete", "success")
666    }
667
668    /// `vectors.list` — page through vectors in a collection.
669    ///
670    /// `page` is zero-based; `limit` is capped at 50 by the server.
671    pub async fn list_vectors(
672        &self,
673        collection: &str,
674        page: i64,
675        limit: i64,
676    ) -> Result<VectorListResult> {
677        let v = self
678            .call(
679                "vectors.list",
680                vec![
681                    VectorizerValue::Str(collection.to_owned()),
682                    VectorizerValue::Int(page),
683                    VectorizerValue::Int(limit),
684                ],
685            )
686            .await?;
687        let items = v
688            .map_get("items")
689            .and_then(|x| x.as_array())
690            .map(|a| a.to_vec())
691            .unwrap_or_default();
692        Ok(VectorListResult {
693            items,
694            total: need_int(&v, "vectors.list", "total")?,
695            page: need_int(&v, "vectors.list", "page")?,
696            limit: need_int(&v, "vectors.list", "limit")?,
697        })
698    }
699
700    /// `vectors.embed` — embed `text` server-side and return the embedding.
701    pub async fn embed_text(&self, text: &str, model: Option<&str>) -> Result<EmbedResult> {
702        let mut args = vec![VectorizerValue::Str(text.to_owned())];
703        if let Some(m) = model {
704            args.push(VectorizerValue::Str(m.to_owned()));
705        }
706        let v = self.call("vectors.embed", args).await?;
707        let embedding = v
708            .map_get("embedding")
709            .and_then(|x| x.as_array())
710            .map(|arr| arr.iter().filter_map(|x| x.as_float()).collect())
711            .unwrap_or_default();
712        Ok(EmbedResult {
713            embedding,
714            model: v
715                .map_get("model")
716                .and_then(|x| x.as_str())
717                .unwrap_or("bm25")
718                .to_owned(),
719            dimension: v.map_get("dimension").and_then(|x| x.as_int()).unwrap_or(0),
720        })
721    }
722
723    /// `vectors.batch_insert` — insert multiple pre-computed vectors.
724    ///
725    /// Each item in `items` is a `VectorizerValue::Map` with at least
726    /// `data` (`Array<Float>`) and optionally `id` (`Str`) and `payload`
727    /// (`Map`).
728    pub async fn batch_insert_vectors(
729        &self,
730        collection: &str,
731        items: Vec<VectorizerValue>,
732    ) -> Result<BatchInsertResult> {
733        let v = self
734            .call(
735                "vectors.batch_insert",
736                vec![
737                    VectorizerValue::Str(collection.to_owned()),
738                    VectorizerValue::Array(items),
739                ],
740            )
741            .await?;
742        let results = v
743            .map_get("results")
744            .and_then(|x| x.as_array())
745            .map(|a| decode_batch_items(a))
746            .unwrap_or_default();
747        Ok(BatchInsertResult {
748            inserted: v.map_get("inserted").and_then(|x| x.as_int()).unwrap_or(0),
749            failed: v.map_get("failed").and_then(|x| x.as_int()).unwrap_or(0),
750            results,
751        })
752    }
753
754    /// `vectors.batch_insert_texts` — embed and insert multiple text items.
755    ///
756    /// Each item in `items` is a `VectorizerValue::Map` with at least
757    /// `text` (`Str`) and optionally `id` (`Str`) and `payload` (`Map`).
758    pub async fn batch_insert_texts(
759        &self,
760        collection: &str,
761        items: Vec<VectorizerValue>,
762    ) -> Result<BatchInsertResult> {
763        let v = self
764            .call(
765                "vectors.batch_insert_texts",
766                vec![
767                    VectorizerValue::Str(collection.to_owned()),
768                    VectorizerValue::Array(items),
769                ],
770            )
771            .await?;
772        let results = v
773            .map_get("results")
774            .and_then(|x| x.as_array())
775            .map(|a| decode_batch_items(a))
776            .unwrap_or_default();
777        Ok(BatchInsertResult {
778            inserted: v.map_get("inserted").and_then(|x| x.as_int()).unwrap_or(0),
779            failed: v.map_get("failed").and_then(|x| x.as_int()).unwrap_or(0),
780            results,
781        })
782    }
783
784    /// `vectors.batch_search` — run multiple searches in one round-trip.
785    ///
786    /// Each request in `requests` is a `VectorizerValue::Map` with
787    /// `collection` (`Str`), `query` (`Str`), and optional `limit` (`Int`).
788    pub async fn batch_search(
789        &self,
790        requests: Vec<VectorizerValue>,
791    ) -> Result<Vec<BatchSearchResult>> {
792        let v = self
793            .call(
794                "vectors.batch_search",
795                vec![VectorizerValue::Array(requests)],
796            )
797            .await?;
798        let arr = v
799            .as_array()
800            .ok_or_else(|| RpcClientError::Server("vectors.batch_search: expected Array".into()))?;
801        Ok(arr
802            .iter()
803            .map(|entry| {
804                let index = entry.map_get("index").and_then(|x| x.as_int()).unwrap_or(0);
805                let status = entry
806                    .map_get("status")
807                    .and_then(|x| x.as_str())
808                    .unwrap_or("unknown")
809                    .to_owned();
810                let error = entry
811                    .map_get("error")
812                    .and_then(|x| x.as_str())
813                    .map(str::to_owned);
814                let results = entry
815                    .map_get("results")
816                    .and_then(|x| x.as_array())
817                    .map(|a| decode_search_hits(a))
818                    .unwrap_or_default();
819                BatchSearchResult {
820                    index,
821                    status,
822                    results,
823                    error,
824                }
825            })
826            .collect())
827    }
828
829    /// `vectors.batch_update` — update multiple vectors' data and/or payload.
830    ///
831    /// Each item in `updates` is a `VectorizerValue::Map` with `id` (`Str`)
832    /// and optionally `data` (`Array<Float>`) and `payload` (`Map`).
833    pub async fn batch_update_vectors(
834        &self,
835        collection: &str,
836        updates: Vec<VectorizerValue>,
837    ) -> Result<BatchUpdateResult> {
838        let v = self
839            .call(
840                "vectors.batch_update",
841                vec![
842                    VectorizerValue::Str(collection.to_owned()),
843                    VectorizerValue::Array(updates),
844                ],
845            )
846            .await?;
847        let results = v
848            .map_get("results")
849            .and_then(|x| x.as_array())
850            .map(|a| decode_batch_items(a))
851            .unwrap_or_default();
852        Ok(BatchUpdateResult {
853            updated: v.map_get("updated").and_then(|x| x.as_int()).unwrap_or(0),
854            failed: v.map_get("failed").and_then(|x| x.as_int()).unwrap_or(0),
855            results,
856        })
857    }
858
859    /// `vectors.batch_delete` — delete multiple vectors by id.
860    pub async fn batch_delete_vectors(
861        &self,
862        collection: &str,
863        ids: Vec<String>,
864    ) -> Result<BatchDeleteResult> {
865        let ids_val = VectorizerValue::Array(ids.into_iter().map(VectorizerValue::Str).collect());
866        let v = self
867            .call(
868                "vectors.batch_delete",
869                vec![VectorizerValue::Str(collection.to_owned()), ids_val],
870            )
871            .await?;
872        let results = v
873            .map_get("results")
874            .and_then(|x| x.as_array())
875            .map(|a| decode_batch_items(a))
876            .unwrap_or_default();
877        Ok(BatchDeleteResult {
878            deleted: v.map_get("deleted").and_then(|x| x.as_int()).unwrap_or(0),
879            failed: v.map_get("failed").and_then(|x| x.as_int()).unwrap_or(0),
880            results,
881        })
882    }
883
884    /// `vectors.move` — move vectors from `src` to `dst` collection.
885    ///
886    /// Named `move_vectors_rpc` to avoid collision with the REST SDK's
887    /// `move_to_collection`.
888    pub async fn move_vectors_rpc(
889        &self,
890        src: &str,
891        dst: &str,
892        ids: Vec<String>,
893    ) -> Result<MoveRpcResult> {
894        let ids_val = VectorizerValue::Array(ids.into_iter().map(VectorizerValue::Str).collect());
895        let v = self
896            .call(
897                "vectors.move",
898                vec![
899                    VectorizerValue::Str(src.to_owned()),
900                    VectorizerValue::Str(dst.to_owned()),
901                    ids_val,
902                ],
903            )
904            .await?;
905        Ok(MoveRpcResult {
906            src: need_str(&v, "vectors.move", "src")?,
907            dst: need_str(&v, "vectors.move", "dst")?,
908            moved: v.map_get("moved").and_then(|x| x.as_int()).unwrap_or(0),
909            failed: v.map_get("failed").and_then(|x| x.as_int()).unwrap_or(0),
910        })
911    }
912
913    /// `vectors.copy` — copy vectors from `src` to `dst` without deleting.
914    pub async fn copy_vectors_rpc(
915        &self,
916        src: &str,
917        dst: &str,
918        ids: Vec<String>,
919    ) -> Result<CopyRpcResult> {
920        let ids_val = VectorizerValue::Array(ids.into_iter().map(VectorizerValue::Str).collect());
921        let v = self
922            .call(
923                "vectors.copy",
924                vec![
925                    VectorizerValue::Str(src.to_owned()),
926                    VectorizerValue::Str(dst.to_owned()),
927                    ids_val,
928                ],
929            )
930            .await?;
931        Ok(CopyRpcResult {
932            src: need_str(&v, "vectors.copy", "src")?,
933            dst: need_str(&v, "vectors.copy", "dst")?,
934            copied: v.map_get("copied").and_then(|x| x.as_int()).unwrap_or(0),
935            failed: v.map_get("failed").and_then(|x| x.as_int()).unwrap_or(0),
936        })
937    }
938
939    /// `vectors.delete_by_filter` — delete all vectors matching a Qdrant-style
940    /// filter predicate.
941    ///
942    /// `filter` is a `VectorizerValue::Map` matching the Qdrant filter schema.
943    pub async fn delete_by_filter_rpc(
944        &self,
945        collection: &str,
946        filter: VectorizerValue,
947    ) -> Result<DeleteByFilterRpcResult> {
948        let v = self
949            .call(
950                "vectors.delete_by_filter",
951                vec![VectorizerValue::Str(collection.to_owned()), filter],
952            )
953            .await?;
954        Ok(DeleteByFilterRpcResult {
955            scanned: v.map_get("scanned").and_then(|x| x.as_int()).unwrap_or(0),
956            matched: v.map_get("matched").and_then(|x| x.as_int()).unwrap_or(0),
957            deleted: v.map_get("deleted").and_then(|x| x.as_int()).unwrap_or(0),
958        })
959    }
960
961    /// `vectors.bulk_update_metadata` — apply a JSON-merge-patch to all
962    /// vectors matching `filter`.
963    ///
964    /// `filter` selects the target vectors; `patch` is applied via RFC 7396
965    /// merge-patch.
966    pub async fn bulk_update_metadata_rpc(
967        &self,
968        collection: &str,
969        filter: VectorizerValue,
970        patch: VectorizerValue,
971    ) -> Result<BulkUpdateMetadataRpcResult> {
972        let v = self
973            .call(
974                "vectors.bulk_update_metadata",
975                vec![VectorizerValue::Str(collection.to_owned()), filter, patch],
976            )
977            .await?;
978        Ok(BulkUpdateMetadataRpcResult {
979            scanned: v.map_get("scanned").and_then(|x| x.as_int()).unwrap_or(0),
980            matched: v.map_get("matched").and_then(|x| x.as_int()).unwrap_or(0),
981            updated: v.map_get("updated").and_then(|x| x.as_int()).unwrap_or(0),
982        })
983    }
984
985    /// `vectors.set_expiry` — attach a TTL to one vector.
986    ///
987    /// `expires_at` may be a Unix millisecond timestamp or an RFC3339 string.
988    pub async fn set_vector_expiry(
989        &self,
990        collection: &str,
991        id: &str,
992        expires_at: &str,
993    ) -> Result<SetExpiryResult> {
994        let v = self
995            .call(
996                "vectors.set_expiry",
997                vec![
998                    VectorizerValue::Str(collection.to_owned()),
999                    VectorizerValue::Str(id.to_owned()),
1000                    VectorizerValue::Str(expires_at.to_owned()),
1001                ],
1002            )
1003            .await?;
1004        Ok(SetExpiryResult {
1005            id: need_str(&v, "vectors.set_expiry", "id")?,
1006            expires_at: need_int(&v, "vectors.set_expiry", "expires_at")?,
1007            success: need_bool(&v, "vectors.set_expiry", "success")?,
1008        })
1009    }
1010}
1011
1012// ═════════════════════════════════════════════════════════════════════════════
1013// Search
1014// ═════════════════════════════════════════════════════════════════════════════
1015
1016impl RpcClient {
1017    /// `search.basic` — search `collection` for `query` and return up
1018    /// to `limit` hits sorted by descending similarity.
1019    pub async fn search_basic(
1020        &self,
1021        collection: &str,
1022        query: &str,
1023        limit: usize,
1024    ) -> Result<Vec<SearchHit>> {
1025        let args = vec![
1026            VectorizerValue::Str(collection.to_owned()),
1027            VectorizerValue::Str(query.to_owned()),
1028            VectorizerValue::Int(limit as i64),
1029        ];
1030        let v = self.call("search.basic", args).await?;
1031        let arr = v
1032            .as_array()
1033            .ok_or_else(|| RpcClientError::Server("search.basic: expected Array".into()))?;
1034        Ok(decode_search_hits(arr))
1035    }
1036
1037    /// `search.intelligent` — multi-collection intelligent search.
1038    ///
1039    /// `request` is a `VectorizerValue::Map` with at minimum `query` (`Str`)
1040    /// and optionally `collections` (`Array<Str>`), `max_results` (`Int`),
1041    /// `domain_expansion` (`Bool`).
1042    pub async fn search_intelligent(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1043        self.call("search.intelligent", vec![request]).await
1044    }
1045
1046    /// `search.by_text` — search one collection by text query.
1047    pub async fn search_by_text(
1048        &self,
1049        collection: &str,
1050        query: &str,
1051        limit: usize,
1052    ) -> Result<Vec<SearchHit>> {
1053        let v = self
1054            .call(
1055                "search.by_text",
1056                vec![
1057                    VectorizerValue::Str(collection.to_owned()),
1058                    VectorizerValue::Str(query.to_owned()),
1059                    VectorizerValue::Int(limit as i64),
1060                ],
1061            )
1062            .await?;
1063        let arr = v
1064            .map_get("results")
1065            .and_then(|x| x.as_array())
1066            .ok_or_else(|| {
1067                RpcClientError::Server("search.by_text: missing results array".into())
1068            })?;
1069        Ok(decode_search_hits(arr))
1070    }
1071
1072    /// `search.by_file` — file-content-based search.
1073    ///
1074    /// `request` is a `VectorizerValue::Map` describing the file query.
1075    /// The server currently returns an empty result set (stub surface);
1076    /// this method is provided for forward-compatibility.
1077    pub async fn search_by_file(
1078        &self,
1079        collection: &str,
1080        request: VectorizerValue,
1081    ) -> Result<Vec<SearchHit>> {
1082        let v = self
1083            .call(
1084                "search.by_file",
1085                vec![VectorizerValue::Str(collection.to_owned()), request],
1086            )
1087            .await?;
1088        let arr = v
1089            .map_get("results")
1090            .and_then(|x| x.as_array())
1091            .ok_or_else(|| {
1092                RpcClientError::Server("search.by_file: missing results array".into())
1093            })?;
1094        Ok(decode_search_hits(arr))
1095    }
1096
1097    /// `search.hybrid` — RRF / weighted-combination hybrid dense+sparse search.
1098    ///
1099    /// `request` is a `VectorizerValue::Map` with at minimum `query` (`Str`)
1100    /// and optional keys `alpha`, `dense_k`, `sparse_k`, `final_k`,
1101    /// `algorithm` (`"rrf"` | `"weighted"` | `"alpha"`).
1102    pub async fn search_hybrid(
1103        &self,
1104        collection: &str,
1105        request: VectorizerValue,
1106    ) -> Result<Vec<SearchHit>> {
1107        let v = self
1108            .call(
1109                "search.hybrid",
1110                vec![VectorizerValue::Str(collection.to_owned()), request],
1111            )
1112            .await?;
1113        let arr = v
1114            .map_get("results")
1115            .and_then(|x| x.as_array())
1116            .ok_or_else(|| RpcClientError::Server("search.hybrid: missing results array".into()))?;
1117        Ok(decode_search_hits(arr))
1118    }
1119
1120    /// `search.semantic` — semantic re-ranking search.
1121    ///
1122    /// `request` is a `VectorizerValue::Map` with `query` (`Str`),
1123    /// `collection` (`Str`), and optional `max_results`, `semantic_reranking`,
1124    /// `cross_encoder_reranking`, `similarity_threshold`.
1125    pub async fn search_semantic(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1126        self.call("search.semantic", vec![request]).await
1127    }
1128
1129    /// `search.contextual` — context-filtered semantic search.
1130    ///
1131    /// `request` is a `VectorizerValue::Map` with `query` (`Str`),
1132    /// `collection` (`Str`), and optional `context_filters` (`Map`),
1133    /// `max_results`, `context_weight`, `context_reranking`.
1134    pub async fn search_contextual(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1135        self.call("search.contextual", vec![request]).await
1136    }
1137
1138    /// `search.multi_collection` — fan-out search across multiple collections.
1139    ///
1140    /// `request` is a `VectorizerValue::Map` with `query` (`Str`),
1141    /// `collections` (`Array<Str>`), and optional `max_per_collection`,
1142    /// `max_total_results`, `cross_collection_reranking`.
1143    pub async fn search_multi_collection(
1144        &self,
1145        request: VectorizerValue,
1146    ) -> Result<VectorizerValue> {
1147        self.call("search.multi_collection", vec![request]).await
1148    }
1149
1150    /// `search.explain` — run a vector search and return HNSW traversal trace.
1151    ///
1152    /// `collection` is the target collection; `request` must contain
1153    /// `vector` (`Array<Float>`) and optionally `k` (`Int`).
1154    pub async fn search_explain(
1155        &self,
1156        collection: &str,
1157        request: VectorizerValue,
1158    ) -> Result<SearchExplainResult> {
1159        let v = self
1160            .call(
1161                "search.explain",
1162                vec![VectorizerValue::Str(collection.to_owned()), request],
1163            )
1164            .await?;
1165        let hits = v
1166            .map_get("hits")
1167            .and_then(|x| x.as_array())
1168            .map(|a| decode_search_hits(a))
1169            .unwrap_or_default();
1170        let trace_val = v.map_get("trace").cloned().unwrap_or(VectorizerValue::Null);
1171        let trace = SearchTrace {
1172            visited_nodes: trace_val
1173                .map_get("visited_nodes")
1174                .and_then(|x| x.as_int())
1175                .unwrap_or(0),
1176            ef_search: trace_val
1177                .map_get("ef_search")
1178                .and_then(|x| x.as_int())
1179                .unwrap_or(0),
1180            hnsw_search_ms: trace_val
1181                .map_get("hnsw_search_ms")
1182                .and_then(|x| x.as_float())
1183                .unwrap_or(0.0),
1184            total_ms: trace_val
1185                .map_get("total_ms")
1186                .and_then(|x| x.as_float())
1187                .unwrap_or(0.0),
1188        };
1189        Ok(SearchExplainResult {
1190            hits,
1191            collection: v
1192                .map_get("collection")
1193                .and_then(|x| x.as_str())
1194                .unwrap_or("")
1195                .to_owned(),
1196            k: v.map_get("k").and_then(|x| x.as_int()).unwrap_or(0),
1197            trace,
1198        })
1199    }
1200}
1201
1202// ═════════════════════════════════════════════════════════════════════════════
1203// Discovery
1204// ═════════════════════════════════════════════════════════════════════════════
1205
1206impl RpcClient {
1207    /// `discovery.discover` — full discovery pipeline: embed → search →
1208    /// compress → build plan → render prompt.
1209    ///
1210    /// `request` must contain `query` (`Str`) and optionally
1211    /// `include_collections`, `exclude_collections` (`Array<Str>`),
1212    /// `max_bullets` (`Int`).
1213    pub async fn discover(&self, request: VectorizerValue) -> Result<DiscoverResult> {
1214        let v = self.call("discovery.discover", vec![request]).await?;
1215        Ok(DiscoverResult {
1216            answer_prompt: need_str(&v, "discovery.discover", "answer_prompt")?,
1217            sections: v.map_get("sections").and_then(|x| x.as_int()).unwrap_or(0),
1218            bullets: v.map_get("bullets").and_then(|x| x.as_int()).unwrap_or(0),
1219            chunks: v.map_get("chunks").and_then(|x| x.as_int()).unwrap_or(0),
1220        })
1221    }
1222
1223    /// `discovery.filter_collections` — filter collection list by query
1224    /// relevance.
1225    ///
1226    /// `request` must contain `query` (`Str`) and optionally `include` /
1227    /// `exclude` (`Array<Str>`).
1228    pub async fn filter_collections(&self, request: VectorizerValue) -> Result<Vec<String>> {
1229        let v = self
1230            .call("discovery.filter_collections", vec![request])
1231            .await?;
1232        let arr = v
1233            .map_get("filtered_collections")
1234            .and_then(|x| x.as_array())
1235            .ok_or_else(|| {
1236                RpcClientError::Server(
1237                    "discovery.filter_collections: missing filtered_collections".into(),
1238                )
1239            })?;
1240        Ok(arr
1241            .iter()
1242            .filter_map(|entry| entry.map_get("name")?.as_str().map(str::to_owned))
1243            .collect())
1244    }
1245
1246    /// `discovery.score_collections` — score all collections for a query.
1247    ///
1248    /// `request` must contain `query` (`Str`).
1249    pub async fn score_collections(
1250        &self,
1251        request: VectorizerValue,
1252    ) -> Result<Vec<ScoredCollection>> {
1253        let v = self
1254            .call("discovery.score_collections", vec![request])
1255            .await?;
1256        let arr = v
1257            .map_get("scored_collections")
1258            .and_then(|x| x.as_array())
1259            .ok_or_else(|| {
1260                RpcClientError::Server(
1261                    "discovery.score_collections: missing scored_collections".into(),
1262                )
1263            })?;
1264        Ok(arr
1265            .iter()
1266            .map(|entry| ScoredCollection {
1267                name: entry
1268                    .map_get("name")
1269                    .and_then(|x| x.as_str())
1270                    .unwrap_or("")
1271                    .to_owned(),
1272                score: entry
1273                    .map_get("score")
1274                    .and_then(|x| x.as_float())
1275                    .unwrap_or(0.0),
1276                vector_count: entry
1277                    .map_get("vector_count")
1278                    .and_then(|x| x.as_int())
1279                    .unwrap_or(0),
1280            })
1281            .collect())
1282    }
1283
1284    /// `discovery.expand_queries` — generate query variants via baseline expansion.
1285    ///
1286    /// `request` must contain `query` (`Str`) and optionally
1287    /// `max_expansions` (`Int`), `include_definition`, `include_features`,
1288    /// `include_architecture` (`Bool`).
1289    pub async fn expand_queries(&self, request: VectorizerValue) -> Result<ExpandQueriesResult> {
1290        let v = self.call("discovery.expand_queries", vec![request]).await?;
1291        let expanded = v
1292            .map_get("expanded_queries")
1293            .and_then(|x| x.as_array())
1294            .map(|arr| {
1295                arr.iter()
1296                    .filter_map(|x| x.as_str().map(str::to_owned))
1297                    .collect()
1298            })
1299            .unwrap_or_default();
1300        Ok(ExpandQueriesResult {
1301            original_query: need_str(&v, "discovery.expand_queries", "original_query")?,
1302            count: v.map_get("count").and_then(|x| x.as_int()).unwrap_or(0),
1303            expanded_queries: expanded,
1304        })
1305    }
1306
1307    /// `discovery.broad_discovery` — multi-query broad search across all
1308    /// collections.
1309    ///
1310    /// `request` must contain `queries` (`Array<Str>`) and optionally
1311    /// `k` (`Int`).
1312    pub async fn broad_discovery(&self, request: VectorizerValue) -> Result<Vec<DiscoveryChunk>> {
1313        let v = self
1314            .call("discovery.broad_discovery", vec![request])
1315            .await?;
1316        let arr = v
1317            .map_get("chunks")
1318            .and_then(|x| x.as_array())
1319            .ok_or_else(|| {
1320                RpcClientError::Server("discovery.broad_discovery: missing chunks".into())
1321            })?;
1322        Ok(arr
1323            .iter()
1324            .map(|entry| DiscoveryChunk {
1325                collection: entry
1326                    .map_get("collection")
1327                    .and_then(|x| x.as_str())
1328                    .unwrap_or("")
1329                    .to_owned(),
1330                score: entry
1331                    .map_get("score")
1332                    .and_then(|x| x.as_float())
1333                    .unwrap_or(0.0),
1334                content_preview: entry
1335                    .map_get("content_preview")
1336                    .and_then(|x| x.as_str())
1337                    .unwrap_or("")
1338                    .to_owned(),
1339            })
1340            .collect())
1341    }
1342
1343    /// `discovery.semantic_focus` — deep semantic search within one collection.
1344    ///
1345    /// `request` must contain `collection` (`Str`), `queries`
1346    /// (`Array<Str>`), and optionally `k` (`Int`).
1347    pub async fn semantic_focus(&self, request: VectorizerValue) -> Result<Vec<DiscoveryChunk>> {
1348        let v = self.call("discovery.semantic_focus", vec![request]).await?;
1349        let arr = v
1350            .map_get("chunks")
1351            .and_then(|x| x.as_array())
1352            .ok_or_else(|| {
1353                RpcClientError::Server("discovery.semantic_focus: missing chunks".into())
1354            })?;
1355        Ok(arr
1356            .iter()
1357            .map(|entry| DiscoveryChunk {
1358                collection: entry
1359                    .map_get("collection")
1360                    .and_then(|x| x.as_str())
1361                    .unwrap_or("")
1362                    .to_owned(),
1363                score: entry
1364                    .map_get("score")
1365                    .and_then(|x| x.as_float())
1366                    .unwrap_or(0.0),
1367                content_preview: entry
1368                    .map_get("content_preview")
1369                    .and_then(|x| x.as_str())
1370                    .unwrap_or("")
1371                    .to_owned(),
1372            })
1373            .collect())
1374    }
1375
1376    /// `discovery.promote_readme` — promote README chunks to the top of a
1377    /// chunk set.
1378    ///
1379    /// `request` must contain `chunks` (`Array<Map>`) where each map has
1380    /// `collection`, `doc_id`, `content`, `score`, `file_path`,
1381    /// `chunk_index`, `file_extension`.
1382    pub async fn promote_readme(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1383        self.call("discovery.promote_readme", vec![request]).await
1384    }
1385
1386    /// `discovery.compress_evidence` — compress a chunk set into ranked bullets.
1387    ///
1388    /// `request` must contain `chunks` (`Array<Map>`) and optionally
1389    /// `max_bullets` (`Int`), `max_per_doc` (`Int`).
1390    pub async fn compress_evidence(&self, request: VectorizerValue) -> Result<Vec<CompressBullet>> {
1391        let v = self
1392            .call("discovery.compress_evidence", vec![request])
1393            .await?;
1394        let arr = v
1395            .map_get("bullets")
1396            .and_then(|x| x.as_array())
1397            .ok_or_else(|| {
1398                RpcClientError::Server("discovery.compress_evidence: missing bullets".into())
1399            })?;
1400        Ok(arr
1401            .iter()
1402            .map(|entry| CompressBullet {
1403                text: entry
1404                    .map_get("text")
1405                    .and_then(|x| x.as_str())
1406                    .unwrap_or("")
1407                    .to_owned(),
1408                source_id: entry
1409                    .map_get("source_id")
1410                    .and_then(|x| x.as_str())
1411                    .unwrap_or("")
1412                    .to_owned(),
1413                score: entry
1414                    .map_get("score")
1415                    .and_then(|x| x.as_float())
1416                    .unwrap_or(0.0),
1417            })
1418            .collect())
1419    }
1420
1421    /// `discovery.build_answer_plan` — organise bullets into a structured
1422    /// answer plan.
1423    ///
1424    /// `request` must contain `bullets` (`Array<Map>`), each with `text`,
1425    /// `source_id`, `score`, `category`.
1426    pub async fn build_answer_plan(&self, request: VectorizerValue) -> Result<AnswerPlanResult> {
1427        let v = self
1428            .call("discovery.build_answer_plan", vec![request])
1429            .await?;
1430        let sections = v
1431            .map_get("sections")
1432            .and_then(|x| x.as_array())
1433            .map(|arr| {
1434                arr.iter()
1435                    .map(|entry| AnswerPlanSection {
1436                        title: entry
1437                            .map_get("title")
1438                            .and_then(|x| x.as_str())
1439                            .unwrap_or("")
1440                            .to_owned(),
1441                        bullets_count: entry
1442                            .map_get("bullets_count")
1443                            .and_then(|x| x.as_int())
1444                            .unwrap_or(0),
1445                    })
1446                    .collect()
1447            })
1448            .unwrap_or_default();
1449        Ok(AnswerPlanResult {
1450            sections,
1451            total_bullets: v
1452                .map_get("total_bullets")
1453                .and_then(|x| x.as_int())
1454                .unwrap_or(0),
1455        })
1456    }
1457
1458    /// `discovery.render_llm_prompt` — render an answer plan into an LLM
1459    /// prompt string.
1460    ///
1461    /// `request` must contain `plan` (`Map`) with `sections` (`Array<Map>`)
1462    /// and optionally `total_bullets`, `sources`.
1463    pub async fn render_llm_prompt(&self, request: VectorizerValue) -> Result<RenderPromptResult> {
1464        let v = self
1465            .call("discovery.render_llm_prompt", vec![request])
1466            .await?;
1467        Ok(RenderPromptResult {
1468            prompt: need_str(&v, "discovery.render_llm_prompt", "prompt")?,
1469            length: v.map_get("length").and_then(|x| x.as_int()).unwrap_or(0),
1470            estimated_tokens: v
1471                .map_get("estimated_tokens")
1472                .and_then(|x| x.as_int())
1473                .unwrap_or(0),
1474        })
1475    }
1476}
1477
1478// ═════════════════════════════════════════════════════════════════════════════
1479// File ops
1480// ═════════════════════════════════════════════════════════════════════════════
1481
1482impl RpcClient {
1483    /// `file.content` — retrieve raw file content stored in a collection.
1484    ///
1485    /// `request` must contain `collection` (`Str`) and `file_path` (`Str`),
1486    /// and optionally `max_size_kb` (`Int`).
1487    pub async fn file_content(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1488        self.call("file.content", vec![request]).await
1489    }
1490
1491    /// `file.list` — list files indexed in a collection.
1492    ///
1493    /// `request` must contain `collection` (`Str`) and optionally
1494    /// `filter_by_type` (`Array<Str>`), `min_chunks` (`Int`),
1495    /// `max_results` (`Int`), `sort_by` (`Str`).
1496    pub async fn file_list(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1497        self.call("file.list", vec![request]).await
1498    }
1499
1500    /// `file.summary` — extractive or structural summary of one file.
1501    ///
1502    /// `request` must contain `collection` (`Str`), `file_path` (`Str`), and
1503    /// optionally `summary_type` (`"extractive"` | `"structural"` | `"both"`),
1504    /// `max_sentences` (`Int`).
1505    pub async fn file_summary(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1506        self.call("file.summary", vec![request]).await
1507    }
1508
1509    /// `file.chunks` — retrieve ordered chunks for one file.
1510    ///
1511    /// `request` must contain `collection` (`Str`), `file_path` (`Str`), and
1512    /// optionally `start_chunk` (`Int`), `limit` (`Int`),
1513    /// `include_context` (`Bool`).
1514    pub async fn file_chunks(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1515        self.call("file.chunks", vec![request]).await
1516    }
1517
1518    /// `file.outline` — directory-tree outline of a collection's files.
1519    ///
1520    /// `request` must contain `collection` (`Str`) and optionally
1521    /// `max_depth` (`Int`), `include_summaries` (`Bool`),
1522    /// `highlight_key_files` (`Bool`).
1523    pub async fn file_outline(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1524        self.call("file.outline", vec![request]).await
1525    }
1526
1527    /// `file.related` — find files semantically related to a given file.
1528    ///
1529    /// `request` must contain `collection` (`Str`), `file_path` (`Str`), and
1530    /// optionally `limit` (`Int`), `similarity_threshold` (`Float`),
1531    /// `include_reason` (`Bool`).
1532    pub async fn file_related(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1533        self.call("file.related", vec![request]).await
1534    }
1535
1536    /// `file.search_by_type` — search within files of specific extension types.
1537    ///
1538    /// `request` must contain `collection` (`Str`), `query` (`Str`),
1539    /// `file_types` (`Array<Str>`), and optionally `limit` (`Int`),
1540    /// `return_full_files` (`Bool`).
1541    pub async fn file_search_by_type(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1542        self.call("file.search_by_type", vec![request]).await
1543    }
1544}
1545
1546// ═════════════════════════════════════════════════════════════════════════════
1547// Graph
1548// ═════════════════════════════════════════════════════════════════════════════
1549
1550impl RpcClient {
1551    /// `graph.list_nodes` — list all graph nodes in a collection.
1552    pub async fn graph_list_nodes(&self, collection: &str) -> Result<VectorizerValue> {
1553        self.call(
1554            "graph.list_nodes",
1555            vec![VectorizerValue::Str(collection.to_owned())],
1556        )
1557        .await
1558    }
1559
1560    /// `graph.neighbors` — fetch direct neighbors of a graph node.
1561    pub async fn graph_neighbors(
1562        &self,
1563        collection: &str,
1564        node_id: &str,
1565    ) -> Result<VectorizerValue> {
1566        self.call(
1567            "graph.neighbors",
1568            vec![
1569                VectorizerValue::Str(collection.to_owned()),
1570                VectorizerValue::Str(node_id.to_owned()),
1571            ],
1572        )
1573        .await
1574    }
1575
1576    /// `graph.find_related` — find nodes reachable within `max_hops` of a node.
1577    pub async fn graph_find_related(
1578        &self,
1579        collection: &str,
1580        node_id: &str,
1581        max_hops: i64,
1582    ) -> Result<VectorizerValue> {
1583        self.call(
1584            "graph.find_related",
1585            vec![
1586                VectorizerValue::Str(collection.to_owned()),
1587                VectorizerValue::Str(node_id.to_owned()),
1588                VectorizerValue::Int(max_hops),
1589            ],
1590        )
1591        .await
1592    }
1593
1594    /// `graph.find_path` — shortest path between two graph nodes.
1595    pub async fn graph_find_path(
1596        &self,
1597        collection: &str,
1598        from: &str,
1599        to: &str,
1600    ) -> Result<VectorizerValue> {
1601        self.call(
1602            "graph.find_path",
1603            vec![
1604                VectorizerValue::Str(collection.to_owned()),
1605                VectorizerValue::Str(from.to_owned()),
1606                VectorizerValue::Str(to.to_owned()),
1607            ],
1608        )
1609        .await
1610    }
1611
1612    /// `graph.create_edge` — create a directed edge between two nodes.
1613    ///
1614    /// `edge` is a `VectorizerValue::Map` with `source` (`Str`), `target`
1615    /// (`Str`), `relationship_type` (`Str`), and optionally `weight`
1616    /// (`Float`).
1617    pub async fn graph_create_edge(
1618        &self,
1619        collection: &str,
1620        edge: VectorizerValue,
1621    ) -> Result<VectorizerValue> {
1622        self.call(
1623            "graph.create_edge",
1624            vec![VectorizerValue::Str(collection.to_owned()), edge],
1625        )
1626        .await
1627    }
1628
1629    /// `graph.delete_edge` — remove an edge by its id.
1630    pub async fn graph_delete_edge(
1631        &self,
1632        collection: &str,
1633        edge_id: &str,
1634    ) -> Result<VectorizerValue> {
1635        self.call(
1636            "graph.delete_edge",
1637            vec![
1638                VectorizerValue::Str(collection.to_owned()),
1639                VectorizerValue::Str(edge_id.to_owned()),
1640            ],
1641        )
1642        .await
1643    }
1644
1645    /// `graph.list_edges` — list all edges in a collection's graph.
1646    pub async fn graph_list_edges(&self, collection: &str) -> Result<VectorizerValue> {
1647        self.call(
1648            "graph.list_edges",
1649            vec![VectorizerValue::Str(collection.to_owned())],
1650        )
1651        .await
1652    }
1653
1654    /// `graph.discover_edges` — auto-discover edges by vector similarity
1655    /// across the whole collection.
1656    ///
1657    /// `request` is an optional `VectorizerValue::Map` with
1658    /// `similarity_threshold` (`Float`) and `max_per_node` (`Int`).
1659    pub async fn graph_discover_edges(
1660        &self,
1661        collection: &str,
1662        request: VectorizerValue,
1663    ) -> Result<DiscoverEdgesResult> {
1664        let v = self
1665            .call(
1666                "graph.discover_edges",
1667                vec![VectorizerValue::Str(collection.to_owned()), request],
1668            )
1669            .await?;
1670        Ok(DiscoverEdgesResult {
1671            success: v
1672                .map_get("success")
1673                .and_then(|x| x.as_bool())
1674                .unwrap_or(false),
1675            total_nodes: v
1676                .map_get("total_nodes")
1677                .and_then(|x| x.as_int())
1678                .unwrap_or(0),
1679            nodes_processed: v
1680                .map_get("nodes_processed")
1681                .and_then(|x| x.as_int())
1682                .unwrap_or(0),
1683            nodes_with_edges: v
1684                .map_get("nodes_with_edges")
1685                .and_then(|x| x.as_int())
1686                .unwrap_or(0),
1687            total_edges_created: v
1688                .map_get("total_edges_created")
1689                .and_then(|x| x.as_int())
1690                .unwrap_or(0),
1691        })
1692    }
1693
1694    /// `graph.discover_edges_for_node` — auto-discover edges for one node.
1695    ///
1696    /// `request` is an optional `VectorizerValue::Map` with
1697    /// `similarity_threshold` (`Float`) and `max_per_node` (`Int`).
1698    pub async fn graph_discover_edges_for_node(
1699        &self,
1700        collection: &str,
1701        node_id: &str,
1702        request: VectorizerValue,
1703    ) -> Result<DiscoverEdgesForNodeResult> {
1704        let v = self
1705            .call(
1706                "graph.discover_edges_for_node",
1707                vec![
1708                    VectorizerValue::Str(collection.to_owned()),
1709                    VectorizerValue::Str(node_id.to_owned()),
1710                    request,
1711                ],
1712            )
1713            .await?;
1714        Ok(DiscoverEdgesForNodeResult {
1715            success: v
1716                .map_get("success")
1717                .and_then(|x| x.as_bool())
1718                .unwrap_or(false),
1719            node_id: v
1720                .map_get("node_id")
1721                .and_then(|x| x.as_str())
1722                .unwrap_or(node_id)
1723                .to_owned(),
1724            edges_created: v
1725                .map_get("edges_created")
1726                .and_then(|x| x.as_int())
1727                .unwrap_or(0),
1728        })
1729    }
1730
1731    /// `graph.discovery_status` — percentage of nodes that have edges.
1732    pub async fn graph_discovery_status(&self, collection: &str) -> Result<GraphDiscoveryStatus> {
1733        let v = self
1734            .call(
1735                "graph.discovery_status",
1736                vec![VectorizerValue::Str(collection.to_owned())],
1737            )
1738            .await?;
1739        Ok(GraphDiscoveryStatus {
1740            total_nodes: v
1741                .map_get("total_nodes")
1742                .and_then(|x| x.as_int())
1743                .unwrap_or(0),
1744            nodes_with_edges: v
1745                .map_get("nodes_with_edges")
1746                .and_then(|x| x.as_int())
1747                .unwrap_or(0),
1748            total_edges: v
1749                .map_get("total_edges")
1750                .and_then(|x| x.as_int())
1751                .unwrap_or(0),
1752            progress_percentage: v
1753                .map_get("progress_percentage")
1754                .and_then(|x| x.as_float())
1755                .unwrap_or(0.0),
1756        })
1757    }
1758}
1759
1760// ═════════════════════════════════════════════════════════════════════════════
1761// Admin
1762// ═════════════════════════════════════════════════════════════════════════════
1763
1764impl RpcClient {
1765    /// `admin.stats` — aggregate vector/collection counts.
1766    pub async fn admin_stats(&self) -> Result<AdminStats> {
1767        let v = self.call("admin.stats", vec![]).await?;
1768        Ok(AdminStats {
1769            collections_count: v
1770                .map_get("collections_count")
1771                .and_then(|x| x.as_int())
1772                .unwrap_or(0),
1773            total_vectors: v
1774                .map_get("total_vectors")
1775                .and_then(|x| x.as_int())
1776                .unwrap_or(0),
1777            version: v
1778                .map_get("version")
1779                .and_then(|x| x.as_str())
1780                .unwrap_or("")
1781                .to_owned(),
1782        })
1783    }
1784
1785    /// `admin.status` — readiness probe and basic counts.
1786    pub async fn admin_status(&self) -> Result<AdminStatus> {
1787        let v = self.call("admin.status", vec![]).await?;
1788        Ok(AdminStatus {
1789            ready: v
1790                .map_get("ready")
1791                .and_then(|x| x.as_bool())
1792                .unwrap_or(false),
1793            collections_count: v
1794                .map_get("collections_count")
1795                .and_then(|x| x.as_int())
1796                .unwrap_or(0),
1797            version: v
1798                .map_get("version")
1799                .and_then(|x| x.as_str())
1800                .unwrap_or("")
1801                .to_owned(),
1802        })
1803    }
1804
1805    /// `admin.logs` — in-process log entries (the server currently returns
1806    /// empty; use the REST `/logs` endpoint for live streaming).
1807    pub async fn admin_logs(&self, request: Option<VectorizerValue>) -> Result<VectorizerValue> {
1808        let args = request.map(|r| vec![r]).unwrap_or_default();
1809        self.call("admin.logs", args).await
1810    }
1811
1812    /// `admin.indexing_progress` — how many collections have been indexed.
1813    pub async fn admin_indexing_progress(&self) -> Result<VectorizerValue> {
1814        self.call("admin.indexing_progress", vec![]).await
1815    }
1816
1817    /// `admin.config_get` — read the server's `config.yml`.
1818    pub async fn admin_config_get(&self) -> Result<VectorizerValue> {
1819        self.call("admin.config_get", vec![]).await
1820    }
1821
1822    /// `admin.config_update` — write a patch map to `config.yml` (admin).
1823    ///
1824    /// `patch` is a `VectorizerValue::Map` of config keys to new values.
1825    pub async fn admin_config_update(&self, patch: VectorizerValue) -> Result<bool> {
1826        let v = self.call("admin.config_update", vec![patch]).await?;
1827        need_bool(&v, "admin.config_update", "success")
1828    }
1829
1830    /// `admin.backups_list` — list available backup files.
1831    pub async fn admin_backups_list(&self) -> Result<VectorizerValue> {
1832        self.call("admin.backups_list", vec![]).await
1833    }
1834
1835    /// `admin.backups_create` — create a backup (admin).
1836    ///
1837    /// `request` must contain `name` (`Str`) and optionally `collections`
1838    /// (`Array<Str>`).
1839    pub async fn admin_backups_create(&self, request: VectorizerValue) -> Result<String> {
1840        let v = self.call("admin.backups_create", vec![request]).await?;
1841        need_str(&v, "admin.backups_create", "backup_id")
1842    }
1843
1844    /// `admin.backups_restore` — restore a backup by id (admin).
1845    ///
1846    /// `request` must contain `backup_id` (`Str`).
1847    pub async fn admin_backups_restore(&self, request: VectorizerValue) -> Result<bool> {
1848        let v = self.call("admin.backups_restore", vec![request]).await?;
1849        need_bool(&v, "admin.backups_restore", "success")
1850    }
1851
1852    /// `admin.workspaces_list` — list configured workspaces.
1853    pub async fn admin_workspaces_list(&self) -> Result<VectorizerValue> {
1854        self.call("admin.workspaces_list", vec![]).await
1855    }
1856
1857    /// `admin.workspace_get` — read `workspace.yml`.
1858    pub async fn admin_workspace_get(&self) -> Result<VectorizerValue> {
1859        self.call("admin.workspace_get", vec![]).await
1860    }
1861
1862    /// `admin.workspace_add` — register a new workspace directory (admin).
1863    ///
1864    /// `request` must contain `path` (`Str`) and `collection_name` (`Str`).
1865    pub async fn admin_workspace_add(&self, request: VectorizerValue) -> Result<VectorizerValue> {
1866        self.call("admin.workspace_add", vec![request]).await
1867    }
1868
1869    /// `admin.workspace_remove` — remove a workspace by name (admin).
1870    pub async fn admin_workspace_remove(&self, name: &str) -> Result<bool> {
1871        let v = self
1872            .call(
1873                "admin.workspace_remove",
1874                vec![VectorizerValue::Str(name.to_owned())],
1875            )
1876            .await?;
1877        need_bool(&v, "admin.workspace_remove", "success")
1878    }
1879
1880    /// `admin.restart` — schedule a server restart (admin).
1881    pub async fn admin_restart(&self) -> Result<bool> {
1882        let v = self.call("admin.restart", vec![]).await?;
1883        need_bool(&v, "admin.restart", "success")
1884    }
1885
1886    /// `admin.slow_queries_list` — retrieve the slow-query ring buffer.
1887    pub async fn admin_slow_queries_list(&self) -> Result<VectorizerValue> {
1888        self.call("admin.slow_queries_list", vec![]).await
1889    }
1890
1891    /// `admin.slow_queries_config` — configure slow-query threshold and
1892    /// ring-buffer capacity.
1893    ///
1894    /// `config` must contain `threshold_ms` (`Int`) and optionally
1895    /// `capacity` (`Int`).
1896    pub async fn admin_slow_queries_config(
1897        &self,
1898        config: VectorizerValue,
1899    ) -> Result<SlowQueryConfigResult> {
1900        let v = self.call("admin.slow_queries_config", vec![config]).await?;
1901        Ok(SlowQueryConfigResult {
1902            threshold_ms: v
1903                .map_get("threshold_ms")
1904                .and_then(|x| x.as_int())
1905                .unwrap_or(0),
1906            capacity: v.map_get("capacity").and_then(|x| x.as_int()).unwrap_or(0),
1907            status: v
1908                .map_get("status")
1909                .and_then(|x| x.as_str())
1910                .unwrap_or("ok")
1911                .to_owned(),
1912        })
1913    }
1914}
1915
1916// ═════════════════════════════════════════════════════════════════════════════
1917// Auth / RBAC
1918// ═════════════════════════════════════════════════════════════════════════════
1919
1920impl RpcClient {
1921    /// `auth.me` — return the authenticated principal's identity.
1922    pub async fn auth_me(&self) -> Result<AuthMeResult> {
1923        let v = self.call("auth.me", vec![]).await?;
1924        Ok(AuthMeResult {
1925            username: v
1926                .map_get("username")
1927                .and_then(|x| x.as_str())
1928                .unwrap_or("unknown")
1929                .to_owned(),
1930            authenticated: v
1931                .map_get("authenticated")
1932                .and_then(|x| x.as_bool())
1933                .unwrap_or(false),
1934        })
1935    }
1936
1937    /// `auth.logout` — blacklist the supplied JWT so it cannot be reused.
1938    pub async fn auth_logout(&self, token: &str) -> Result<VectorizerValue> {
1939        self.call("auth.logout", vec![VectorizerValue::Str(token.to_owned())])
1940            .await
1941    }
1942
1943    /// `auth.refresh_token` — exchange a valid JWT for a fresh one.
1944    pub async fn auth_refresh_token(&self, token: &str) -> Result<RefreshTokenResult> {
1945        let v = self
1946            .call(
1947                "auth.refresh_token",
1948                vec![VectorizerValue::Str(token.to_owned())],
1949            )
1950            .await?;
1951        Ok(RefreshTokenResult {
1952            access_token: need_str(&v, "auth.refresh_token", "access_token")?,
1953            token_type: v
1954                .map_get("token_type")
1955                .and_then(|x| x.as_str())
1956                .unwrap_or("Bearer")
1957                .to_owned(),
1958        })
1959    }
1960
1961    /// `auth.validate_password` — check a plaintext password against the
1962    /// server's password policy.
1963    pub async fn auth_validate_password(&self, password: &str) -> Result<ValidatePasswordResult> {
1964        let v = self
1965            .call(
1966                "auth.validate_password",
1967                vec![VectorizerValue::Str(password.to_owned())],
1968            )
1969            .await?;
1970        let errors = v
1971            .map_get("errors")
1972            .and_then(|x| x.as_array())
1973            .map(|arr| {
1974                arr.iter()
1975                    .filter_map(|x| x.as_str().map(str::to_owned))
1976                    .collect()
1977            })
1978            .unwrap_or_default();
1979        Ok(ValidatePasswordResult {
1980            valid: v
1981                .map_get("valid")
1982                .and_then(|x| x.as_bool())
1983                .unwrap_or(false),
1984            errors,
1985        })
1986    }
1987
1988    /// `auth.api_keys_create` — create a new API key.
1989    ///
1990    /// `request` must contain `name` (`Str`) and optionally `expires_in`
1991    /// (`Int`, seconds) and `permissions` (`Array<Str>`).
1992    pub async fn auth_api_keys_create(&self, request: VectorizerValue) -> Result<ApiKeyCreated> {
1993        let v = self.call("auth.api_keys_create", vec![request]).await?;
1994        Ok(ApiKeyCreated {
1995            api_key: need_str(&v, "auth.api_keys_create", "api_key")?,
1996            id: need_str(&v, "auth.api_keys_create", "id")?,
1997            name: need_str(&v, "auth.api_keys_create", "name")?,
1998        })
1999    }
2000
2001    /// `auth.api_keys_list` — list API keys for the current principal.
2002    pub async fn auth_api_keys_list(&self) -> Result<VectorizerValue> {
2003        self.call("auth.api_keys_list", vec![]).await
2004    }
2005
2006    /// `auth.api_keys_revoke` — permanently revoke an API key by id.
2007    pub async fn auth_api_keys_revoke(&self, key_id: &str) -> Result<bool> {
2008        let v = self
2009            .call(
2010                "auth.api_keys_revoke",
2011                vec![VectorizerValue::Str(key_id.to_owned())],
2012            )
2013            .await?;
2014        need_bool(&v, "auth.api_keys_revoke", "success")
2015    }
2016
2017    /// `auth.api_keys_rotate` — rotate an API key (5-minute grace period).
2018    ///
2019    /// Named `rotate_api_key_rpc` to avoid collision with the REST SDK's
2020    /// `rotate_api_key`.
2021    pub async fn rotate_api_key_rpc(&self, key_id: &str) -> Result<RotatedApiKey> {
2022        let v = self
2023            .call(
2024                "auth.api_keys_rotate",
2025                vec![VectorizerValue::Str(key_id.to_owned())],
2026            )
2027            .await?;
2028        Ok(RotatedApiKey {
2029            old_key_id: need_str(&v, "auth.api_keys_rotate", "old_key_id")?,
2030            new_key_id: need_str(&v, "auth.api_keys_rotate", "new_key_id")?,
2031            new_token: need_str(&v, "auth.api_keys_rotate", "new_token")?,
2032            grace_until: v
2033                .map_get("grace_until")
2034                .and_then(|x| x.as_str())
2035                .map(str::to_owned),
2036        })
2037    }
2038
2039    /// `auth.api_keys_create_scoped` — create a collection-scoped API key.
2040    ///
2041    /// `request` must contain `name` (`Str`) and optionally `expires_in`
2042    /// (`Int`), `permissions` (`Array<Str>`), `scopes` (`Array<Map>`).
2043    pub async fn auth_api_keys_create_scoped(
2044        &self,
2045        request: VectorizerValue,
2046    ) -> Result<ApiKeyCreated> {
2047        let v = self
2048            .call("auth.api_keys_create_scoped", vec![request])
2049            .await?;
2050        Ok(ApiKeyCreated {
2051            api_key: need_str(&v, "auth.api_keys_create_scoped", "api_key")?,
2052            id: need_str(&v, "auth.api_keys_create_scoped", "id")?,
2053            name: need_str(&v, "auth.api_keys_create_scoped", "name")?,
2054        })
2055    }
2056
2057    /// `auth.users_create` — create a user (admin; returns server error in
2058    /// v1 — RpcState does not carry AuthHandlerState; use the REST endpoint).
2059    pub async fn auth_users_create(&self, request: VectorizerValue) -> Result<VectorizerValue> {
2060        self.call("auth.users_create", vec![request]).await
2061    }
2062
2063    /// `auth.users_list` — list users (admin; returns server error in v1).
2064    pub async fn auth_users_list(&self) -> Result<VectorizerValue> {
2065        self.call("auth.users_list", vec![]).await
2066    }
2067
2068    /// `auth.users_delete` — delete a user (admin; returns server error in v1).
2069    pub async fn auth_users_delete(&self, request: VectorizerValue) -> Result<VectorizerValue> {
2070        self.call("auth.users_delete", vec![request]).await
2071    }
2072
2073    /// `auth.users_change_password` — change a user's password (returns
2074    /// server error in v1 — use REST).
2075    pub async fn auth_users_change_password(
2076        &self,
2077        request: VectorizerValue,
2078    ) -> Result<VectorizerValue> {
2079        self.call("auth.users_change_password", vec![request]).await
2080    }
2081
2082    /// `auth.introspect` — inspect a token's claims and blacklist status.
2083    pub async fn auth_introspect(&self, token: &str) -> Result<VectorizerValue> {
2084        self.call(
2085            "auth.introspect",
2086            vec![VectorizerValue::Str(token.to_owned())],
2087        )
2088        .await
2089    }
2090
2091    /// `auth.audit` — query the auth audit log.
2092    ///
2093    /// `request` is an optional `VectorizerValue::Map` with `from` (`Str`),
2094    /// `to` (`Str`), `actor` (`Str`), `action` (`Str`), `limit` (`Int`).
2095    pub async fn auth_audit(&self, request: VectorizerValue) -> Result<VectorizerValue> {
2096        self.call("auth.audit", vec![request]).await
2097    }
2098}
2099
2100// ═════════════════════════════════════════════════════════════════════════════
2101// Replication
2102// ═════════════════════════════════════════════════════════════════════════════
2103
2104impl RpcClient {
2105    /// `replication.status` — current replication role and replica list.
2106    pub async fn replication_status(&self) -> Result<VectorizerValue> {
2107        self.call("replication.status", vec![]).await
2108    }
2109
2110    /// `replication.configure` — set the replication role for this node.
2111    ///
2112    /// `config` must contain `role` (`Str`: `"master"` | `"replica"` |
2113    /// `"standalone"`) and optionally `bind_address`, `master_address`
2114    /// (`Str`). A server restart is required for the change to take effect.
2115    pub async fn replication_configure(
2116        &self,
2117        config: VectorizerValue,
2118    ) -> Result<ReplicationConfigureResult> {
2119        let v = self.call("replication.configure", vec![config]).await?;
2120        Ok(ReplicationConfigureResult {
2121            success: need_bool(&v, "replication.configure", "success")?,
2122            role: need_str(&v, "replication.configure", "role")?,
2123            message: v
2124                .map_get("message")
2125                .and_then(|x| x.as_str())
2126                .unwrap_or("")
2127                .to_owned(),
2128        })
2129    }
2130
2131    /// `replication.stats` — replication throughput and lag statistics.
2132    pub async fn replication_stats(&self) -> Result<VectorizerValue> {
2133        self.call("replication.stats", vec![]).await
2134    }
2135
2136    /// `replication.replicas_list` — list connected replicas (master only).
2137    pub async fn replication_replicas_list(&self) -> Result<VectorizerValue> {
2138        self.call("replication.replicas_list", vec![]).await
2139    }
2140}
2141
2142// ═════════════════════════════════════════════════════════════════════════════
2143// Cluster
2144// ═════════════════════════════════════════════════════════════════════════════
2145
2146impl RpcClient {
2147    /// `cluster.failover` — promote a replica to master (admin).
2148    pub async fn cluster_failover(&self, replica_id: &str) -> Result<VectorizerValue> {
2149        self.call(
2150            "cluster.failover",
2151            vec![VectorizerValue::Str(replica_id.to_owned())],
2152        )
2153        .await
2154    }
2155
2156    /// `cluster.replica_resync` — force a replica to resync from master (admin).
2157    pub async fn cluster_replica_resync(&self, replica_id: &str) -> Result<VectorizerValue> {
2158        self.call(
2159            "cluster.replica_resync",
2160            vec![VectorizerValue::Str(replica_id.to_owned())],
2161        )
2162        .await
2163    }
2164
2165    /// `cluster.peer_add` — add a new peer to the cluster (admin).
2166    ///
2167    /// `request` must contain `address` (`Str`) and optionally `role`
2168    /// (`Str`: `"member"` | `"observer"`).
2169    pub async fn cluster_peer_add(&self, request: VectorizerValue) -> Result<VectorizerValue> {
2170        self.call("cluster.peer_add", vec![request]).await
2171    }
2172
2173    /// `cluster.rebalance` — trigger a shard rebalance across peers (admin).
2174    pub async fn cluster_rebalance(&self) -> Result<VectorizerValue> {
2175        self.call("cluster.rebalance", vec![]).await
2176    }
2177
2178    /// `cluster.rebalance_status` — check the status of an in-progress
2179    /// rebalance (or confirm idle).
2180    pub async fn cluster_rebalance_status(&self) -> Result<RebalanceStatus> {
2181        let v = self.call("cluster.rebalance_status", vec![]).await?;
2182        Ok(RebalanceStatus {
2183            status: v
2184                .map_get("status")
2185                .and_then(|x| x.as_str())
2186                .map(str::to_owned),
2187            message: v
2188                .map_get("message")
2189                .and_then(|x| x.as_str())
2190                .map(str::to_owned),
2191        })
2192    }
2193}
2194
2195// ═════════════════════════════════════════════════════════════════════════════
2196// Tests
2197// ═════════════════════════════════════════════════════════════════════════════
2198
2199#[cfg(test)]
2200mod tests {
2201    use super::*;
2202
2203    // ── Collections ──────────────────────────────────────────────────────────
2204
2205    #[test]
2206    fn collection_info_fields_present() {
2207        let map = VectorizerValue::Map(vec![
2208            (
2209                VectorizerValue::Str("name".into()),
2210                VectorizerValue::Str("test".into()),
2211            ),
2212            (
2213                VectorizerValue::Str("vector_count".into()),
2214                VectorizerValue::Int(42),
2215            ),
2216            (
2217                VectorizerValue::Str("document_count".into()),
2218                VectorizerValue::Int(10),
2219            ),
2220            (
2221                VectorizerValue::Str("dimension".into()),
2222                VectorizerValue::Int(512),
2223            ),
2224            (
2225                VectorizerValue::Str("metric".into()),
2226                VectorizerValue::Str("Cosine".into()),
2227            ),
2228            (
2229                VectorizerValue::Str("created_at".into()),
2230                VectorizerValue::Str("2024-01-01T00:00:00Z".into()),
2231            ),
2232            (
2233                VectorizerValue::Str("updated_at".into()),
2234                VectorizerValue::Str("2024-01-02T00:00:00Z".into()),
2235            ),
2236        ]);
2237        let info = CollectionInfo {
2238            name: need_str(&map, "test", "name").unwrap(),
2239            vector_count: need_int(&map, "test", "vector_count").unwrap(),
2240            document_count: need_int(&map, "test", "document_count").unwrap(),
2241            dimension: need_int(&map, "test", "dimension").unwrap(),
2242            metric: need_str(&map, "test", "metric").unwrap(),
2243            created_at: need_str(&map, "test", "created_at").unwrap(),
2244            updated_at: need_str(&map, "test", "updated_at").unwrap(),
2245        };
2246        assert_eq!(info.name, "test");
2247        assert_eq!(info.vector_count, 42);
2248        assert_eq!(info.dimension, 512);
2249    }
2250
2251    #[test]
2252    fn create_collection_result_decodes() {
2253        let map = VectorizerValue::Map(vec![
2254            (
2255                VectorizerValue::Str("name".into()),
2256                VectorizerValue::Str("myc".into()),
2257            ),
2258            (
2259                VectorizerValue::Str("dimension".into()),
2260                VectorizerValue::Int(128),
2261            ),
2262            (
2263                VectorizerValue::Str("metric".into()),
2264                VectorizerValue::Str("cosine".into()),
2265            ),
2266            (
2267                VectorizerValue::Str("success".into()),
2268                VectorizerValue::Bool(true),
2269            ),
2270        ]);
2271        let r = CreateCollectionResult {
2272            name: need_str(&map, "c", "name").unwrap(),
2273            dimension: need_int(&map, "c", "dimension").unwrap(),
2274            metric: need_str(&map, "c", "metric").unwrap(),
2275            success: need_bool(&map, "c", "success").unwrap(),
2276        };
2277        assert!(r.success);
2278        assert_eq!(r.dimension, 128);
2279    }
2280
2281    #[test]
2282    fn cleanup_empty_result_decodes() {
2283        let map = VectorizerValue::Map(vec![
2284            (
2285                VectorizerValue::Str("removed".into()),
2286                VectorizerValue::Int(3),
2287            ),
2288            (
2289                VectorizerValue::Str("dry_run".into()),
2290                VectorizerValue::Bool(false),
2291            ),
2292        ]);
2293        let r = CleanupEmptyResult {
2294            removed: need_int(&map, "c", "removed").unwrap(),
2295            dry_run: need_bool(&map, "c", "dry_run").unwrap(),
2296        };
2297        assert_eq!(r.removed, 3);
2298        assert!(!r.dry_run);
2299    }
2300
2301    // ── Vectors ──────────────────────────────────────────────────────────────
2302
2303    #[test]
2304    fn vector_write_result_decodes() {
2305        let map = VectorizerValue::Map(vec![
2306            (
2307                VectorizerValue::Str("id".into()),
2308                VectorizerValue::Str("abc-123".into()),
2309            ),
2310            (
2311                VectorizerValue::Str("success".into()),
2312                VectorizerValue::Bool(true),
2313            ),
2314        ]);
2315        let r = VectorWriteResult {
2316            id: need_str(&map, "v", "id").unwrap(),
2317            success: need_bool(&map, "v", "success").unwrap(),
2318        };
2319        assert_eq!(r.id, "abc-123");
2320        assert!(r.success);
2321    }
2322
2323    #[test]
2324    fn batch_insert_result_decodes() {
2325        let item = VectorizerValue::Map(vec![
2326            (
2327                VectorizerValue::Str("index".into()),
2328                VectorizerValue::Int(0),
2329            ),
2330            (
2331                VectorizerValue::Str("id".into()),
2332                VectorizerValue::Str("x".into()),
2333            ),
2334            (
2335                VectorizerValue::Str("status".into()),
2336                VectorizerValue::Str("ok".into()),
2337            ),
2338        ]);
2339        let items = &[item];
2340        let results = decode_batch_items(items);
2341        assert_eq!(results.len(), 1);
2342        assert_eq!(results[0].status, "ok");
2343        assert_eq!(results[0].id.as_deref(), Some("x"));
2344    }
2345
2346    #[test]
2347    fn batch_search_result_decodes() {
2348        let hit = VectorizerValue::Map(vec![
2349            (
2350                VectorizerValue::Str("id".into()),
2351                VectorizerValue::Str("v1".into()),
2352            ),
2353            (
2354                VectorizerValue::Str("score".into()),
2355                VectorizerValue::Float(0.95),
2356            ),
2357        ]);
2358        let hits = &[hit];
2359        let decoded = decode_search_hits(hits);
2360        assert_eq!(decoded.len(), 1);
2361        assert_eq!(decoded[0].id, "v1");
2362        assert!((decoded[0].score - 0.95).abs() < 1e-6);
2363    }
2364
2365    #[test]
2366    fn move_rpc_result_decodes() {
2367        let map = VectorizerValue::Map(vec![
2368            (
2369                VectorizerValue::Str("src".into()),
2370                VectorizerValue::Str("col_a".into()),
2371            ),
2372            (
2373                VectorizerValue::Str("dst".into()),
2374                VectorizerValue::Str("col_b".into()),
2375            ),
2376            (
2377                VectorizerValue::Str("moved".into()),
2378                VectorizerValue::Int(5),
2379            ),
2380            (
2381                VectorizerValue::Str("failed".into()),
2382                VectorizerValue::Int(1),
2383            ),
2384        ]);
2385        let r = MoveRpcResult {
2386            src: need_str(&map, "m", "src").unwrap(),
2387            dst: need_str(&map, "m", "dst").unwrap(),
2388            moved: map.map_get("moved").and_then(|x| x.as_int()).unwrap_or(0),
2389            failed: map.map_get("failed").and_then(|x| x.as_int()).unwrap_or(0),
2390        };
2391        assert_eq!(r.src, "col_a");
2392        assert_eq!(r.moved, 5);
2393    }
2394
2395    #[test]
2396    fn set_expiry_result_decodes() {
2397        let map = VectorizerValue::Map(vec![
2398            (
2399                VectorizerValue::Str("id".into()),
2400                VectorizerValue::Str("v99".into()),
2401            ),
2402            (
2403                VectorizerValue::Str("expires_at".into()),
2404                VectorizerValue::Int(9_999_999),
2405            ),
2406            (
2407                VectorizerValue::Str("success".into()),
2408                VectorizerValue::Bool(true),
2409            ),
2410        ]);
2411        let r = SetExpiryResult {
2412            id: need_str(&map, "se", "id").unwrap(),
2413            expires_at: need_int(&map, "se", "expires_at").unwrap(),
2414            success: need_bool(&map, "se", "success").unwrap(),
2415        };
2416        assert_eq!(r.expires_at, 9_999_999);
2417    }
2418
2419    // ── Search ───────────────────────────────────────────────────────────────
2420
2421    #[test]
2422    fn search_explain_trace_decodes() {
2423        let trace_val = VectorizerValue::Map(vec![
2424            (
2425                VectorizerValue::Str("visited_nodes".into()),
2426                VectorizerValue::Int(50),
2427            ),
2428            (
2429                VectorizerValue::Str("ef_search".into()),
2430                VectorizerValue::Int(100),
2431            ),
2432            (
2433                VectorizerValue::Str("hnsw_search_ms".into()),
2434                VectorizerValue::Float(1.5),
2435            ),
2436            (
2437                VectorizerValue::Str("total_ms".into()),
2438                VectorizerValue::Float(2.0),
2439            ),
2440        ]);
2441        let trace = SearchTrace {
2442            visited_nodes: trace_val
2443                .map_get("visited_nodes")
2444                .and_then(|x| x.as_int())
2445                .unwrap_or(0),
2446            ef_search: trace_val
2447                .map_get("ef_search")
2448                .and_then(|x| x.as_int())
2449                .unwrap_or(0),
2450            hnsw_search_ms: trace_val
2451                .map_get("hnsw_search_ms")
2452                .and_then(|x| x.as_float())
2453                .unwrap_or(0.0),
2454            total_ms: trace_val
2455                .map_get("total_ms")
2456                .and_then(|x| x.as_float())
2457                .unwrap_or(0.0),
2458        };
2459        assert_eq!(trace.visited_nodes, 50);
2460        assert!((trace.hnsw_search_ms - 1.5).abs() < 1e-6);
2461    }
2462
2463    // ── Discovery ────────────────────────────────────────────────────────────
2464
2465    #[test]
2466    fn discover_result_decodes() {
2467        let map = VectorizerValue::Map(vec![
2468            (
2469                VectorizerValue::Str("answer_prompt".into()),
2470                VectorizerValue::Str("Here is ...".into()),
2471            ),
2472            (
2473                VectorizerValue::Str("sections".into()),
2474                VectorizerValue::Int(3),
2475            ),
2476            (
2477                VectorizerValue::Str("bullets".into()),
2478                VectorizerValue::Int(12),
2479            ),
2480            (
2481                VectorizerValue::Str("chunks".into()),
2482                VectorizerValue::Int(8),
2483            ),
2484        ]);
2485        let r = DiscoverResult {
2486            answer_prompt: need_str(&map, "d", "answer_prompt").unwrap(),
2487            sections: map
2488                .map_get("sections")
2489                .and_then(|x| x.as_int())
2490                .unwrap_or(0),
2491            bullets: map.map_get("bullets").and_then(|x| x.as_int()).unwrap_or(0),
2492            chunks: map.map_get("chunks").and_then(|x| x.as_int()).unwrap_or(0),
2493        };
2494        assert_eq!(r.bullets, 12);
2495    }
2496
2497    #[test]
2498    fn expand_queries_result_decodes() {
2499        let map = VectorizerValue::Map(vec![
2500            (
2501                VectorizerValue::Str("original_query".into()),
2502                VectorizerValue::Str("rust".into()),
2503            ),
2504            (
2505                VectorizerValue::Str("expanded_queries".into()),
2506                VectorizerValue::Array(vec![
2507                    VectorizerValue::Str("rust programming".into()),
2508                    VectorizerValue::Str("rust language".into()),
2509                ]),
2510            ),
2511            (
2512                VectorizerValue::Str("count".into()),
2513                VectorizerValue::Int(2),
2514            ),
2515        ]);
2516        let expanded: Vec<String> = map
2517            .map_get("expanded_queries")
2518            .and_then(|x| x.as_array())
2519            .map(|arr| {
2520                arr.iter()
2521                    .filter_map(|x| x.as_str().map(str::to_owned))
2522                    .collect()
2523            })
2524            .unwrap_or_default();
2525        assert_eq!(expanded.len(), 2);
2526    }
2527
2528    // ── Graph ────────────────────────────────────────────────────────────────
2529
2530    #[test]
2531    fn graph_discovery_status_decodes() {
2532        let map = VectorizerValue::Map(vec![
2533            (
2534                VectorizerValue::Str("total_nodes".into()),
2535                VectorizerValue::Int(100),
2536            ),
2537            (
2538                VectorizerValue::Str("nodes_with_edges".into()),
2539                VectorizerValue::Int(75),
2540            ),
2541            (
2542                VectorizerValue::Str("total_edges".into()),
2543                VectorizerValue::Int(200),
2544            ),
2545            (
2546                VectorizerValue::Str("progress_percentage".into()),
2547                VectorizerValue::Float(75.0),
2548            ),
2549        ]);
2550        let r = GraphDiscoveryStatus {
2551            total_nodes: map
2552                .map_get("total_nodes")
2553                .and_then(|x| x.as_int())
2554                .unwrap_or(0),
2555            nodes_with_edges: map
2556                .map_get("nodes_with_edges")
2557                .and_then(|x| x.as_int())
2558                .unwrap_or(0),
2559            total_edges: map
2560                .map_get("total_edges")
2561                .and_then(|x| x.as_int())
2562                .unwrap_or(0),
2563            progress_percentage: map
2564                .map_get("progress_percentage")
2565                .and_then(|x| x.as_float())
2566                .unwrap_or(0.0),
2567        };
2568        assert_eq!(r.total_nodes, 100);
2569        assert!((r.progress_percentage - 75.0).abs() < 1e-6);
2570    }
2571
2572    #[test]
2573    fn discover_edges_result_decodes() {
2574        let map = VectorizerValue::Map(vec![
2575            (
2576                VectorizerValue::Str("success".into()),
2577                VectorizerValue::Bool(true),
2578            ),
2579            (
2580                VectorizerValue::Str("total_nodes".into()),
2581                VectorizerValue::Int(50),
2582            ),
2583            (
2584                VectorizerValue::Str("nodes_processed".into()),
2585                VectorizerValue::Int(50),
2586            ),
2587            (
2588                VectorizerValue::Str("nodes_with_edges".into()),
2589                VectorizerValue::Int(40),
2590            ),
2591            (
2592                VectorizerValue::Str("total_edges_created".into()),
2593                VectorizerValue::Int(120),
2594            ),
2595        ]);
2596        let r = DiscoverEdgesResult {
2597            success: map
2598                .map_get("success")
2599                .and_then(|x| x.as_bool())
2600                .unwrap_or(false),
2601            total_nodes: map
2602                .map_get("total_nodes")
2603                .and_then(|x| x.as_int())
2604                .unwrap_or(0),
2605            nodes_processed: map
2606                .map_get("nodes_processed")
2607                .and_then(|x| x.as_int())
2608                .unwrap_or(0),
2609            nodes_with_edges: map
2610                .map_get("nodes_with_edges")
2611                .and_then(|x| x.as_int())
2612                .unwrap_or(0),
2613            total_edges_created: map
2614                .map_get("total_edges_created")
2615                .and_then(|x| x.as_int())
2616                .unwrap_or(0),
2617        };
2618        assert!(r.success);
2619        assert_eq!(r.total_edges_created, 120);
2620    }
2621
2622    // ── Admin ────────────────────────────────────────────────────────────────
2623
2624    #[test]
2625    fn admin_stats_decodes() {
2626        let map = VectorizerValue::Map(vec![
2627            (
2628                VectorizerValue::Str("collections_count".into()),
2629                VectorizerValue::Int(5),
2630            ),
2631            (
2632                VectorizerValue::Str("total_vectors".into()),
2633                VectorizerValue::Int(1000),
2634            ),
2635            (
2636                VectorizerValue::Str("version".into()),
2637                VectorizerValue::Str("3.8.0".into()),
2638            ),
2639        ]);
2640        let r = AdminStats {
2641            collections_count: map
2642                .map_get("collections_count")
2643                .and_then(|x| x.as_int())
2644                .unwrap_or(0),
2645            total_vectors: map
2646                .map_get("total_vectors")
2647                .and_then(|x| x.as_int())
2648                .unwrap_or(0),
2649            version: map
2650                .map_get("version")
2651                .and_then(|x| x.as_str())
2652                .unwrap_or("")
2653                .to_owned(),
2654        };
2655        assert_eq!(r.collections_count, 5);
2656        assert_eq!(r.version, "3.8.0");
2657    }
2658
2659    #[test]
2660    fn slow_query_config_decodes() {
2661        let map = VectorizerValue::Map(vec![
2662            (
2663                VectorizerValue::Str("threshold_ms".into()),
2664                VectorizerValue::Int(100),
2665            ),
2666            (
2667                VectorizerValue::Str("capacity".into()),
2668                VectorizerValue::Int(500),
2669            ),
2670            (
2671                VectorizerValue::Str("status".into()),
2672                VectorizerValue::Str("ok".into()),
2673            ),
2674        ]);
2675        let r = SlowQueryConfigResult {
2676            threshold_ms: map
2677                .map_get("threshold_ms")
2678                .and_then(|x| x.as_int())
2679                .unwrap_or(0),
2680            capacity: map
2681                .map_get("capacity")
2682                .and_then(|x| x.as_int())
2683                .unwrap_or(0),
2684            status: map
2685                .map_get("status")
2686                .and_then(|x| x.as_str())
2687                .unwrap_or("")
2688                .to_owned(),
2689        };
2690        assert_eq!(r.threshold_ms, 100);
2691        assert_eq!(r.status, "ok");
2692    }
2693
2694    // ── Auth ─────────────────────────────────────────────────────────────────
2695
2696    #[test]
2697    fn api_key_created_decodes() {
2698        let map = VectorizerValue::Map(vec![
2699            (
2700                VectorizerValue::Str("api_key".into()),
2701                VectorizerValue::Str("vz_abc123".into()),
2702            ),
2703            (
2704                VectorizerValue::Str("id".into()),
2705                VectorizerValue::Str("key-id-1".into()),
2706            ),
2707            (
2708                VectorizerValue::Str("name".into()),
2709                VectorizerValue::Str("ci-key".into()),
2710            ),
2711        ]);
2712        let r = ApiKeyCreated {
2713            api_key: need_str(&map, "a", "api_key").unwrap(),
2714            id: need_str(&map, "a", "id").unwrap(),
2715            name: need_str(&map, "a", "name").unwrap(),
2716        };
2717        assert_eq!(r.api_key, "vz_abc123");
2718        assert_eq!(r.name, "ci-key");
2719    }
2720
2721    #[test]
2722    fn rotated_api_key_decodes() {
2723        let map = VectorizerValue::Map(vec![
2724            (
2725                VectorizerValue::Str("old_key_id".into()),
2726                VectorizerValue::Str("old".into()),
2727            ),
2728            (
2729                VectorizerValue::Str("new_key_id".into()),
2730                VectorizerValue::Str("new".into()),
2731            ),
2732            (
2733                VectorizerValue::Str("new_token".into()),
2734                VectorizerValue::Str("vz_new_xxx".into()),
2735            ),
2736        ]);
2737        let r = RotatedApiKey {
2738            old_key_id: need_str(&map, "r", "old_key_id").unwrap(),
2739            new_key_id: need_str(&map, "r", "new_key_id").unwrap(),
2740            new_token: need_str(&map, "r", "new_token").unwrap(),
2741            grace_until: map
2742                .map_get("grace_until")
2743                .and_then(|x| x.as_str())
2744                .map(str::to_owned),
2745        };
2746        assert_eq!(r.old_key_id, "old");
2747        assert_eq!(r.new_token, "vz_new_xxx");
2748        assert!(r.grace_until.is_none());
2749    }
2750
2751    #[test]
2752    fn validate_password_result_decodes() {
2753        let map = VectorizerValue::Map(vec![
2754            (
2755                VectorizerValue::Str("valid".into()),
2756                VectorizerValue::Bool(false),
2757            ),
2758            (
2759                VectorizerValue::Str("errors".into()),
2760                VectorizerValue::Array(vec![VectorizerValue::Str("too short".into())]),
2761            ),
2762        ]);
2763        let errors: Vec<String> = map
2764            .map_get("errors")
2765            .and_then(|x| x.as_array())
2766            .map(|arr| {
2767                arr.iter()
2768                    .filter_map(|x| x.as_str().map(str::to_owned))
2769                    .collect()
2770            })
2771            .unwrap_or_default();
2772        assert_eq!(errors.len(), 1);
2773        assert_eq!(errors[0], "too short");
2774    }
2775
2776    // ── Replication / Cluster ─────────────────────────────────────────────────
2777
2778    #[test]
2779    fn replication_configure_result_decodes() {
2780        let map = VectorizerValue::Map(vec![
2781            (
2782                VectorizerValue::Str("success".into()),
2783                VectorizerValue::Bool(true),
2784            ),
2785            (
2786                VectorizerValue::Str("role".into()),
2787                VectorizerValue::Str("master".into()),
2788            ),
2789            (
2790                VectorizerValue::Str("message".into()),
2791                VectorizerValue::Str("restart required".into()),
2792            ),
2793        ]);
2794        let r = ReplicationConfigureResult {
2795            success: need_bool(&map, "rc", "success").unwrap(),
2796            role: need_str(&map, "rc", "role").unwrap(),
2797            message: map
2798                .map_get("message")
2799                .and_then(|x| x.as_str())
2800                .unwrap_or("")
2801                .to_owned(),
2802        };
2803        assert!(r.success);
2804        assert_eq!(r.role, "master");
2805    }
2806
2807    #[test]
2808    fn rebalance_status_idle_decodes() {
2809        let map = VectorizerValue::Map(vec![
2810            (
2811                VectorizerValue::Str("status".into()),
2812                VectorizerValue::Str("idle".into()),
2813            ),
2814            (
2815                VectorizerValue::Str("message".into()),
2816                VectorizerValue::Str("No rebalance".into()),
2817            ),
2818        ]);
2819        let r = RebalanceStatus {
2820            status: map
2821                .map_get("status")
2822                .and_then(|x| x.as_str())
2823                .map(str::to_owned),
2824            message: map
2825                .map_get("message")
2826                .and_then(|x| x.as_str())
2827                .map(str::to_owned),
2828        };
2829        assert_eq!(r.status.as_deref(), Some("idle"));
2830    }
2831
2832    // ── Need-* helpers error on missing fields ────────────────────────────────
2833
2834    #[test]
2835    fn need_str_errors_on_missing() {
2836        let map = VectorizerValue::Map(vec![]);
2837        assert!(need_str(&map, "cmd", "missing_field").is_err());
2838    }
2839
2840    #[test]
2841    fn need_int_errors_on_missing() {
2842        let map = VectorizerValue::Map(vec![]);
2843        assert!(need_int(&map, "cmd", "missing_field").is_err());
2844    }
2845
2846    #[test]
2847    fn decode_string_array_errors_on_non_array() {
2848        let v = VectorizerValue::Str("not_an_array".into());
2849        assert!(decode_string_array(v, "cmd").is_err());
2850    }
2851}