Skip to main content

vectorizer_sdk/client/
vectors.rs

1//! Vector-level surface: get, batch-insert texts, embed.
2//!
3//! Single-vector retrieval, batch text insertion, and on-server
4//! embedding generation. Search lives in [`super::search`];
5//! collection-level CRUD in [`super::collections`].
6
7use super::VectorizerClient;
8use crate::error::{Result, VectorizerError};
9use crate::models::*;
10
11impl VectorizerClient {
12    /// Fetch one vector by id.
13    ///
14    /// **Server caveat (observed on `hivehub/vectorizer:3.0.x`):** the
15    /// `GET /collections/{c}/vectors/{id}` endpoint currently returns
16    /// HTTP 200 with a synthetic uniform-vector payload
17    /// (`[0.1, 0.1, …]`) even for ids that don't exist. Callers that
18    /// need real miss detection should probe via
19    /// [`VectorizerClient::list_vectors`] or search and not trust an
20    /// `Ok(Vector)` as proof of existence until the server fix ships.
21    pub async fn get_vector(&self, collection: &str, vector_id: &str) -> Result<Vector> {
22        let response = self
23            .make_request(
24                "GET",
25                &format!("/collections/{collection}/vectors/{vector_id}"),
26                None,
27            )
28            .await?;
29        let vector: Vector = serde_json::from_str(&response).map_err(|e| {
30            VectorizerError::server(format!("Failed to parse get vector response: {e}"))
31        })?;
32        Ok(vector)
33    }
34
35    /// Insert a batch of texts into a collection. The server embeds
36    /// each entry with the collection's configured provider (BM25 by
37    /// default; FastEmbed ONNX when selected in `config.yml`).
38    ///
39    /// Wire contract: the server's `POST /insert_texts` handler
40    /// expects `{ "collection": "<name>", "texts": [...] }` — the
41    /// collection is a top-level field in the JSON body, not a path
42    /// segment. The earlier `POST /collections/{c}/documents` path
43    /// this method used was never served (the 3.0.x server returns
44    /// 404 for it) and has been removed.
45    ///
46    /// Per-entry `id` field: the server **reassigns** every inserted
47    /// vector a server-generated UUID regardless of what the caller
48    /// sent. The original client id is stashed as `client_id` on the
49    /// response entry. Callers that need idempotency by client id
50    /// should key off the `client_id` round-trip, not the
51    /// server-assigned UUID.
52    pub async fn insert_texts(
53        &self,
54        collection: &str,
55        texts: Vec<BatchTextRequest>,
56    ) -> Result<BatchResponse> {
57        let payload = serde_json::json!({
58            "collection": collection,
59            "texts": texts,
60        });
61        let response = self
62            .make_request("POST", "/insert_texts", Some(payload))
63            .await?;
64        let mut batch_response: BatchResponse = serde_json::from_str(&response).map_err(|e| {
65            VectorizerError::server(format!("Failed to parse insert texts response: {e}"))
66        })?;
67        // v3 omits the pre-v3 `success` field and instead emits
68        // `inserted` / `failed` counts (aliased onto
69        // `successful_operations` / `failed_operations`). The struct
70        // doc-comment tells callers to derive the flag themselves; do
71        // that here once so existing consumers (and the SDK integration
72        // suite) keep working across the shape change.
73        if !batch_response.success
74            && batch_response.failed_operations == 0
75            && batch_response.successful_operations > 0
76        {
77            batch_response.success = true;
78        }
79        // v3 also drops the pre-v3 `operation` tag. The call site here
80        // unambiguously *is* an insert, so fill it in if the server
81        // didn't — callers that assert on the tag keep working.
82        if batch_response.operation.is_empty() {
83            batch_response.operation = "insert".to_string();
84        }
85        Ok(batch_response)
86    }
87
88    /// Delete a single vector by id from a collection.
89    ///
90    /// Calls `DELETE /collections/{collection}/vectors/{vector_id}`.
91    /// Returns `Ok(())` on 2xx; the server treats "not found" as a
92    /// 4xx that surfaces as a `VectorizerError::NotFound`-class error
93    /// via the shared error mapper.
94    ///
95    /// Companion to [`Self::delete_vectors`] (batch) and
96    /// [`Self::move_to_collection`] (cross-collection move). See
97    /// issue #265 for the tier-demotion use case.
98    pub async fn delete_vector(&self, collection: &str, vector_id: &str) -> Result<()> {
99        self.make_request(
100            "DELETE",
101            &format!("/collections/{collection}/vectors/{vector_id}"),
102            None,
103        )
104        .await?;
105        Ok(())
106    }
107
108    /// Delete a batch of vectors from a single collection. Per-id
109    /// failures (e.g. not-found) are captured in
110    /// [`DeleteReport::results`] without aborting the batch.
111    ///
112    /// Calls `POST /batch_delete` with `{"collection": ..., "ids": [...]}`.
113    pub async fn delete_vectors(&self, collection: &str, ids: &[String]) -> Result<DeleteReport> {
114        let payload = serde_json::json!({
115            "collection": collection,
116            "ids": ids,
117        });
118        let response = self
119            .make_request("POST", "/batch_delete", Some(payload))
120            .await?;
121        let report: DeleteReport = serde_json::from_str(&response).map_err(|e| {
122            VectorizerError::server(format!("Failed to parse delete_vectors response: {e}"))
123        })?;
124        Ok(report)
125    }
126
127    /// Move vectors from `src` to `dst` without re-embedding (issue #265).
128    ///
129    /// Calls `POST /collections/{src}/vectors/move` with
130    /// `{"destination": dst, "ids": [...]}`. Server invariant: the
131    /// destination insert lands BEFORE the source delete, so a
132    /// mid-batch crash leaves a recoverable duplicate (never data
133    /// loss). Per-id outcomes (`ok`, `missing_in_src`,
134    /// `dst_insert_failed`, `src_delete_failed`) populate
135    /// [`MoveReport::results`] without aborting the batch.
136    ///
137    /// Typical use: tier-demotion pruner that walks a hot collection
138    /// and relocates aged vectors to a warm/cold collection.
139    pub async fn move_to_collection(
140        &self,
141        src: &str,
142        dst: &str,
143        ids: &[String],
144    ) -> Result<MoveReport> {
145        let payload = serde_json::json!({
146            "destination": dst,
147            "ids": ids,
148        });
149        let response = self
150            .make_request(
151                "POST",
152                &format!("/collections/{src}/vectors/move"),
153                Some(payload),
154            )
155            .await?;
156        let report: MoveReport = serde_json::from_str(&response).map_err(|e| {
157            VectorizerError::server(format!("Failed to parse move_to_collection response: {e}"))
158        })?;
159        Ok(report)
160    }
161
162    /// Update a vector's metadata in-place.
163    ///
164    /// Calls `POST /update` with `{collection, id, ...metadata}`.
165    /// Returns the server's confirmation as a synthetic [`Vector`].
166    ///
167    /// Server contract (`PUT /vectors`): the handler accepts `id` and
168    /// `collection` from the JSON body, invalidates the cache, and
169    /// returns `{message}`. The SDK synthesises a minimal `Vector`
170    /// from the request parameters because the server does not echo
171    /// back the full vector payload.
172    pub async fn update_vector(
173        &self,
174        collection: &str,
175        id: &str,
176        request: UpdateVectorRequest,
177    ) -> Result<Vector> {
178        let mut payload = serde_json::Map::new();
179        payload.insert(
180            "collection".into(),
181            serde_json::Value::String(collection.to_string()),
182        );
183        payload.insert("id".into(), serde_json::Value::String(id.to_string()));
184        if let Some(meta) = request.metadata {
185            payload.insert("metadata".into(), meta);
186        }
187        self.make_request("POST", "/update", Some(serde_json::Value::Object(payload)))
188            .await?;
189        Ok(Vector {
190            id: id.to_string(),
191            data: vec![],
192            metadata: None,
193            public_key: None,
194        })
195    }
196
197    /// Insert a single text document into a collection (auto-chunking when
198    /// the text is long).
199    ///
200    /// Calls `POST /insert` with `{collection, id?, text, metadata?}`.
201    /// Returns the first vector id created as a synthetic `Vector`.
202    ///
203    /// Server response: `{message, vectors_created, vector_ids, collection, chunked}`.
204    pub async fn insert_text(
205        &self,
206        collection: &str,
207        id: &str,
208        text: &str,
209        metadata: Option<serde_json::Value>,
210    ) -> Result<Vector> {
211        let mut payload = serde_json::Map::new();
212        payload.insert(
213            "collection".into(),
214            serde_json::Value::String(collection.to_string()),
215        );
216        payload.insert("id".into(), serde_json::Value::String(id.to_string()));
217        payload.insert("text".into(), serde_json::Value::String(text.to_string()));
218        if let Some(meta) = metadata {
219            payload.insert("metadata".into(), meta);
220        }
221        let response = self
222            .make_request("POST", "/insert", Some(serde_json::Value::Object(payload)))
223            .await?;
224        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
225            VectorizerError::server(format!("Failed to parse insert_text response: {e}"))
226        })?;
227        let assigned_id = val
228            .get("vector_ids")
229            .and_then(|a| a.as_array())
230            .and_then(|a| a.first())
231            .and_then(|v| v.as_str())
232            .unwrap_or(id)
233            .to_string();
234        Ok(Vector {
235            id: assigned_id,
236            data: vec![],
237            metadata: None,
238            public_key: None,
239        })
240    }
241
242    /// List vectors in a collection with pagination.
243    ///
244    /// Calls `GET /collections/{name}/vectors?page=&limit=`.
245    ///
246    /// Note: the server handler uses `offset` (not `page`) as the query
247    /// parameter. `page` is translated to `offset = page * limit` by the
248    /// SDK. Pass `page=None` and `limit=None` for the server defaults
249    /// (limit=10, offset=0).
250    pub async fn list_vectors(
251        &self,
252        collection: &str,
253        page: Option<u32>,
254        limit: Option<u32>,
255    ) -> Result<VectorPage> {
256        let limit_val = limit.unwrap_or(10);
257        let offset_val = page.unwrap_or(0) * limit_val;
258        let endpoint =
259            format!("/collections/{collection}/vectors?limit={limit_val}&offset={offset_val}");
260        let response = self.make_request("GET", &endpoint, None).await?;
261        serde_json::from_str(&response).map_err(|e| {
262            VectorizerError::server(format!("Failed to parse list_vectors response: {e}"))
263        })
264    }
265
266    /// Fetch a single vector by id via the path-based `GET` endpoint.
267    ///
268    /// Calls `GET /collections/{name}/vectors/{id}`.
269    ///
270    /// This is distinct from `get_vector` which uses the older `POST /vector`
271    /// shape. The path-based handler currently returns a synthetic
272    /// uniform-vector payload — see the server handler doc for the caveat.
273    pub async fn get_vector_by_path(&self, collection: &str, id: &str) -> Result<Vector> {
274        let response = self
275            .make_request(
276                "GET",
277                &format!("/collections/{collection}/vectors/{id}"),
278                None,
279            )
280            .await?;
281        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
282            VectorizerError::server(format!("Failed to parse get_vector_by_path response: {e}"))
283        })?;
284        let vec_id = val
285            .get("id")
286            .and_then(|v| v.as_str())
287            .unwrap_or(id)
288            .to_string();
289        let data: Vec<f32> = val
290            .get("vector")
291            .and_then(|v| v.as_array())
292            .map(|arr| {
293                arr.iter()
294                    .filter_map(|x| x.as_f64().map(|f| f as f32))
295                    .collect()
296            })
297            .unwrap_or_default();
298        let metadata: Option<std::collections::HashMap<String, serde_json::Value>> = val
299            .get("payload")
300            .and_then(|p| serde_json::from_value(p.clone()).ok());
301        Ok(Vector {
302            id: vec_id,
303            data,
304            metadata,
305            public_key: None,
306        })
307    }
308
309    /// Batch-insert multiple text documents into a collection.
310    ///
311    /// Calls `POST /batch_insert` with `{collection, texts: [...]}`.
312    /// Returns aggregate insert counts in [`BatchInsertReport`].
313    pub async fn batch_insert_texts(
314        &self,
315        collection: &str,
316        items: Vec<BatchInsertItem>,
317    ) -> Result<BatchInsertReport> {
318        let texts: Vec<serde_json::Value> = items
319            .into_iter()
320            .map(|item| {
321                let mut obj = serde_json::Map::new();
322                obj.insert("text".into(), serde_json::Value::String(item.text));
323                if let Some(id) = item.id {
324                    obj.insert("id".into(), serde_json::Value::String(id));
325                }
326                if let Some(meta) = item.metadata {
327                    obj.insert("metadata".into(), meta);
328                }
329                serde_json::Value::Object(obj)
330            })
331            .collect();
332        let payload = serde_json::json!({ "collection": collection, "texts": texts });
333        let response = self
334            .make_request("POST", "/batch_insert", Some(payload))
335            .await?;
336        serde_json::from_str(&response).map_err(|e| {
337            VectorizerError::server(format!("Failed to parse batch_insert_texts response: {e}"))
338        })
339    }
340
341    /// Bulk-insert pre-computed embeddings.
342    ///
343    /// Calls `POST /insert_vectors` with `{collection, vectors: [...]}`.
344    /// Skips the server-side embedding pipeline entirely; the caller
345    /// supplies raw `Vec<f32>` embeddings.
346    pub async fn insert_vectors(
347        &self,
348        collection: &str,
349        vectors: Vec<RawVectorInsert>,
350    ) -> Result<BatchInsertReport> {
351        let payload = serde_json::json!({ "collection": collection, "vectors": vectors });
352        let response = self
353            .make_request("POST", "/insert_vectors", Some(payload))
354            .await?;
355        serde_json::from_str(&response).map_err(|e| {
356            VectorizerError::server(format!("Failed to parse insert_vectors response: {e}"))
357        })
358    }
359
360    /// Run multiple search queries against one collection in a single
361    /// round-trip.
362    ///
363    /// Calls `POST /batch_search` with `{collection, queries: [...]}`.
364    /// Each query may carry either a text `query` (embedded server-side)
365    /// or a raw `vector`. Returns one [`SearchResponse`] per query.
366    pub async fn batch_search(
367        &self,
368        collection: &str,
369        requests: Vec<BatchSearchQuery>,
370    ) -> Result<Vec<SearchResponse>> {
371        let payload = serde_json::json!({ "collection": collection, "queries": requests });
372        let response = self
373            .make_request("POST", "/batch_search", Some(payload))
374            .await?;
375        let val: serde_json::Value = serde_json::from_str(&response).map_err(|e| {
376            VectorizerError::server(format!("Failed to parse batch_search response: {e}"))
377        })?;
378        // Server returns {collection, count, succeeded, failed, results: [...]}
379        // where each element is a per-query result object.
380        let results_arr = val
381            .get("results")
382            .and_then(|r| r.as_array())
383            .cloned()
384            .unwrap_or_default();
385        let mut out = Vec::with_capacity(results_arr.len());
386        for entry in results_arr {
387            let sr: SearchResponse = serde_json::from_value(entry).map_err(|e| {
388                VectorizerError::server(format!("Failed to parse batch_search entry: {e}"))
389            })?;
390            out.push(sr);
391        }
392        Ok(out)
393    }
394
395    /// Batch-update vector payloads (and optionally dense vectors).
396    ///
397    /// Calls `POST /batch_update` with `{collection, updates: [...]}`.
398    pub async fn batch_update_vectors(
399        &self,
400        collection: &str,
401        updates: Vec<VectorUpdate>,
402    ) -> Result<BatchUpdateReport> {
403        let payload = serde_json::json!({ "collection": collection, "updates": updates });
404        let response = self
405            .make_request("POST", "/batch_update", Some(payload))
406            .await?;
407        serde_json::from_str(&response).map_err(|e| {
408            VectorizerError::server(format!(
409                "Failed to parse batch_update_vectors response: {e}"
410            ))
411        })
412    }
413
414    /// Delete every vector in a collection that matches a Qdrant-style
415    /// metadata filter (phase13).
416    ///
417    /// Calls `POST /collections/{name}/vectors/delete_by_filter` with
418    /// `{"filter": <filter>}`. An empty filter is rejected by the server
419    /// with 400 to prevent accidental full-collection wipes.
420    ///
421    /// Response: `{scanned, matched, deleted, results}`.
422    ///
423    /// # Using the typed filter builder (recommended)
424    ///
425    /// Use [`crate::models::QdrantFilter`] to build the filter with compile-time
426    /// guidance. The server requires a Qdrant-style shape and returns
427    /// `400 parse_error` for wrong shapes (e.g. flat `{key:value}` or
428    /// Qdrant-client-style `{must:[{key,match:{value}}]}`).
429    ///
430    /// ```rust,no_run
431    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
432    /// use vectorizer_sdk::models::{QdrantCondition, QdrantFilter};
433    /// # let client: vectorizer_sdk::client::VectorizerClient = unimplemented!();
434    ///
435    /// let filter = QdrantFilter::must(vec![
436    ///     QdrantCondition::match_string("topic", "index"),
437    /// ]);
438    /// client.delete_by_filter("my_col", serde_json::to_value(filter)?).await?;
439    /// # Ok(()) }
440    /// ```
441    ///
442    /// See `docs/users/api/API_REFERENCE.md § Filter shape` for the full
443    /// wire contract and a list of common mistakes.
444    pub async fn delete_by_filter(
445        &self,
446        collection: &str,
447        filter: serde_json::Value,
448    ) -> Result<DeleteByFilterReport> {
449        let payload = serde_json::json!({ "filter": filter });
450        let response = self
451            .make_request(
452                "POST",
453                &format!("/collections/{collection}/vectors/delete_by_filter"),
454                Some(payload),
455            )
456            .await?;
457        serde_json::from_str(&response).map_err(|e| {
458            VectorizerError::server(format!("Failed to parse delete_by_filter response: {e}"))
459        })
460    }
461
462    /// Apply a JSON-merge-patch to the payload of every vector matching a
463    /// filter (phase13).
464    ///
465    /// Calls `POST /collections/{name}/vectors/bulk_update_metadata` with
466    /// `{"filter": <filter>, "patch": <patch>}`. Patch is applied with
467    /// RFC 7396 semantics: keys in `patch` overwrite existing payload values;
468    /// `null` values remove keys.
469    ///
470    /// Response: `{scanned, matched, updated, results}`.
471    ///
472    /// # Using the typed filter builder (recommended)
473    ///
474    /// Use [`crate::models::QdrantFilter`] to build the filter with compile-time
475    /// guidance. The server requires a Qdrant-style shape and returns
476    /// `400 parse_error` for wrong shapes.
477    ///
478    /// ```rust,no_run
479    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
480    /// use vectorizer_sdk::models::{QdrantCondition, QdrantFilter};
481    /// # let client: vectorizer_sdk::client::VectorizerClient = unimplemented!();
482    ///
483    /// let filter = QdrantFilter::must(vec![
484    ///     QdrantCondition::match_string("status", "draft"),
485    /// ]);
486    /// let patch = serde_json::json!({ "status": "published" });
487    /// client.bulk_update_metadata("my_col", serde_json::to_value(filter)?, patch).await?;
488    /// # Ok(()) }
489    /// ```
490    ///
491    /// See `docs/users/api/API_REFERENCE.md § Filter shape` for the full
492    /// wire contract and a list of common mistakes.
493    pub async fn bulk_update_metadata(
494        &self,
495        collection: &str,
496        filter: serde_json::Value,
497        patch: serde_json::Value,
498    ) -> Result<BulkUpdateReport> {
499        let payload = serde_json::json!({ "filter": filter, "patch": patch });
500        let response = self
501            .make_request(
502                "POST",
503                &format!("/collections/{collection}/vectors/bulk_update_metadata"),
504                Some(payload),
505            )
506            .await?;
507        serde_json::from_str(&response).map_err(|e| {
508            VectorizerError::server(format!(
509                "Failed to parse bulk_update_metadata response: {e}"
510            ))
511        })
512    }
513
514    /// Copy vectors from `src` to `dst` without re-embedding (phase13).
515    ///
516    /// Unlike `move_to_collection`, the source vectors are NOT deleted.
517    /// Calls `POST /collections/{src}/vectors/copy` with
518    /// `{"destination": dst, "ids": [...]}`.
519    ///
520    /// Per-id status: `ok | missing_in_src | dst_insert_failed`.
521    /// Response: `{src, dst, requested, copied, failed, results}`.
522    pub async fn copy_vectors(&self, src: &str, dst: &str, ids: &[String]) -> Result<CopyReport> {
523        let payload = serde_json::json!({ "destination": dst, "ids": ids });
524        let response = self
525            .make_request(
526                "POST",
527                &format!("/collections/{src}/vectors/copy"),
528                Some(payload),
529            )
530            .await?;
531        serde_json::from_str(&response).map_err(|e| {
532            VectorizerError::server(format!("Failed to parse copy_vectors response: {e}"))
533        })
534    }
535
536    /// Set or clear a per-vector expiry timestamp (phase13).
537    ///
538    /// Calls `PATCH /collections/{name}/vectors/{id}/expiry` with
539    /// `{"expires_at": <unix_ms>}`. Pass `None` to clear an existing expiry.
540    /// The timestamp is stored as `__expires_at` inside the vector payload and
541    /// is read by the per-collection TTL reaper.
542    pub async fn set_vector_expiry(
543        &self,
544        collection: &str,
545        vector_id: &str,
546        expires_at: Option<i64>,
547    ) -> Result<()> {
548        let payload = serde_json::json!({ "expires_at": expires_at });
549        self.make_request(
550            "PATCH",
551            &format!("/collections/{collection}/vectors/{vector_id}/expiry"),
552            Some(payload),
553        )
554        .await?;
555        Ok(())
556    }
557
558    /// Generate an embedding for `text` using either the supplied
559    /// `model` name or the server default.
560    pub async fn embed_text(&self, text: &str, model: Option<&str>) -> Result<EmbeddingResponse> {
561        let mut payload = serde_json::Map::new();
562        payload.insert(
563            "text".to_string(),
564            serde_json::Value::String(text.to_string()),
565        );
566        if let Some(model) = model {
567            payload.insert(
568                "model".to_string(),
569                serde_json::Value::String(model.to_string()),
570            );
571        }
572        let response = self
573            .make_request("POST", "/embed", Some(serde_json::Value::Object(payload)))
574            .await?;
575        let embedding_response: EmbeddingResponse =
576            serde_json::from_str(&response).map_err(|e| {
577                VectorizerError::server(format!("Failed to parse embedding response: {e}"))
578            })?;
579        Ok(embedding_response)
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use serde_json::json;
586
587    use crate::models::{
588        BulkUpdateReport, CopyReport, DeleteByFilterReport, ReencodeJob, VectorOpResult,
589    };
590
591    #[test]
592    fn delete_by_filter_report_deserializes_server_contract() {
593        let raw = json!({
594            "scanned": 100,
595            "matched": 3,
596            "deleted": 2,
597            "results": [
598                {"id": "vec-1", "status": "deleted"},
599                {"id": "vec-2", "status": "deleted"},
600                {"id": "vec-3", "status": "error", "error": "not found"},
601            ],
602        });
603        let report: DeleteByFilterReport = serde_json::from_value(raw).unwrap();
604        assert_eq!(report.scanned, 100);
605        assert_eq!(report.matched, 3);
606        assert_eq!(report.deleted, 2);
607        assert_eq!(report.results.len(), 3);
608    }
609
610    #[test]
611    fn bulk_update_report_deserializes_server_contract() {
612        let raw = json!({
613            "scanned": 50,
614            "matched": 5,
615            "updated": 5,
616            "results": [
617                {"id": "vec-1", "status": "updated"},
618            ],
619        });
620        let report: BulkUpdateReport = serde_json::from_value(raw).unwrap();
621        assert_eq!(report.scanned, 50);
622        assert_eq!(report.matched, 5);
623        assert_eq!(report.updated, 5);
624    }
625
626    #[test]
627    fn copy_report_deserializes_server_contract() {
628        let raw = json!({
629            "src": "hot",
630            "dst": "cold",
631            "requested": 3,
632            "copied": 2,
633            "failed": 1,
634            "results": [
635                {"id": "v1", "status": "ok"},
636                {"id": "v2", "status": "ok"},
637                {"id": "v3", "status": "missing_in_src", "error": "not found"},
638            ],
639        });
640        let report: CopyReport = serde_json::from_value(raw).unwrap();
641        assert_eq!(report.src, "hot");
642        assert_eq!(report.dst, "cold");
643        assert_eq!(report.copied, 2);
644        assert_eq!(report.failed, 1);
645        let statuses: Vec<&str> = report.results.iter().map(|r| r.status.as_str()).collect();
646        assert_eq!(statuses, vec!["ok", "ok", "missing_in_src"]);
647    }
648
649    #[test]
650    fn copy_report_round_trips_through_serde() {
651        let report = CopyReport {
652            src: "src".into(),
653            dst: "dst".into(),
654            requested: 1,
655            copied: 1,
656            failed: 0,
657            results: vec![VectorOpResult {
658                id: Some("v1".into()),
659                status: "ok".into(),
660                error: None,
661                index: None,
662            }],
663        };
664        let serialized = serde_json::to_value(&report).unwrap();
665        let parsed: CopyReport = serde_json::from_value(serialized).unwrap();
666        assert_eq!(parsed, report);
667    }
668
669    #[test]
670    fn reencode_job_deserializes_server_contract() {
671        let raw = json!({
672            "job_id": "reencode-mycol-1234567890",
673            "collection": "mycol",
674            "state": "completed",
675            "target_encoding": "sq8",
676            "progress": 1.0,
677        });
678        let job: ReencodeJob = serde_json::from_value(raw).unwrap();
679        assert_eq!(job.collection, "mycol");
680        assert_eq!(job.state, "completed");
681        assert_eq!(job.target_encoding, "sq8");
682        assert!((job.progress - 1.0).abs() < f64::EPSILON);
683    }
684}