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}