Skip to main content

dakera_client/
client.rs

1//! Dakera client implementation
2
3use reqwest::{Client, StatusCode};
4use std::time::Duration;
5use tracing::{debug, instrument};
6
7use crate::error::{ClientError, Result};
8use crate::types::*;
9
10/// Default timeout for requests
11const DEFAULT_TIMEOUT_SECS: u64 = 30;
12
13/// Dakeraclient for interacting with the vector database
14#[derive(Debug, Clone)]
15pub struct DakeraClient {
16    /// HTTP client
17    pub(crate) client: Client,
18    /// Base URL of the Dakera server
19    pub(crate) base_url: String,
20}
21
22impl DakeraClient {
23    /// Create a new client with the given base URL
24    ///
25    /// # Example
26    ///
27    /// ```rust,no_run
28    /// use dakera_client::DakeraClient;
29    ///
30    /// let client = DakeraClient::new("http://localhost:3000").unwrap();
31    /// ```
32    pub fn new(base_url: impl Into<String>) -> Result<Self> {
33        DakeraClientBuilder::new(base_url).build()
34    }
35
36    /// Create a new client builder for more configuration options
37    pub fn builder(base_url: impl Into<String>) -> DakeraClientBuilder {
38        DakeraClientBuilder::new(base_url)
39    }
40
41    // ========================================================================
42    // Health & Status
43    // ========================================================================
44
45    /// Check server health
46    #[instrument(skip(self))]
47    pub async fn health(&self) -> Result<HealthResponse> {
48        let url = format!("{}/health", self.base_url);
49        let response = self.client.get(&url).send().await?;
50
51        if response.status().is_success() {
52            Ok(response.json().await?)
53        } else {
54            // Health endpoint might return simple OK
55            Ok(HealthResponse {
56                healthy: true,
57                version: None,
58                uptime_seconds: None,
59            })
60        }
61    }
62
63    /// Check if server is ready
64    #[instrument(skip(self))]
65    pub async fn ready(&self) -> Result<ReadinessResponse> {
66        let url = format!("{}/health/ready", self.base_url);
67        let response = self.client.get(&url).send().await?;
68
69        if response.status().is_success() {
70            Ok(response.json().await?)
71        } else {
72            Ok(ReadinessResponse {
73                ready: false,
74                components: None,
75            })
76        }
77    }
78
79    /// Check if server is live
80    #[instrument(skip(self))]
81    pub async fn live(&self) -> Result<bool> {
82        let url = format!("{}/health/live", self.base_url);
83        let response = self.client.get(&url).send().await?;
84        Ok(response.status().is_success())
85    }
86
87    // ========================================================================
88    // Namespace Operations
89    // ========================================================================
90
91    /// List all namespaces
92    #[instrument(skip(self))]
93    pub async fn list_namespaces(&self) -> Result<Vec<String>> {
94        let url = format!("{}/v1/namespaces", self.base_url);
95        let response = self.client.get(&url).send().await?;
96        self.handle_response::<ListNamespacesResponse>(response)
97            .await
98            .map(|r| r.namespaces)
99    }
100
101    /// Get namespace information
102    #[instrument(skip(self))]
103    pub async fn get_namespace(&self, namespace: &str) -> Result<NamespaceInfo> {
104        let url = format!("{}/v1/namespaces/{}", self.base_url, namespace);
105        let response = self.client.get(&url).send().await?;
106        self.handle_response(response).await
107    }
108
109    // ========================================================================
110    // Vector Operations
111    // ========================================================================
112
113    /// Upsert vectors into a namespace
114    #[instrument(skip(self, request), fields(vector_count = request.vectors.len()))]
115    pub async fn upsert(&self, namespace: &str, request: UpsertRequest) -> Result<UpsertResponse> {
116        let url = format!("{}/v1/namespaces/{}/vectors", self.base_url, namespace);
117        debug!(
118            "Upserting {} vectors to {}",
119            request.vectors.len(),
120            namespace
121        );
122
123        let response = self.client.post(&url).json(&request).send().await?;
124        self.handle_response(response).await
125    }
126
127    /// Upsert a single vector (convenience method)
128    #[instrument(skip(self, vector))]
129    pub async fn upsert_one(&self, namespace: &str, vector: Vector) -> Result<UpsertResponse> {
130        self.upsert(namespace, UpsertRequest::single(vector)).await
131    }
132
133    /// Upsert vectors in column format (Turbopuffer-inspired)
134    ///
135    /// This format is more efficient for bulk upserts as it avoids repeating
136    /// field names for each vector. All arrays must have equal length.
137    ///
138    /// # Example
139    ///
140    /// ```rust,no_run
141    /// use dakera_client::{DakeraClient, ColumnUpsertRequest};
142    ///
143    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
144    /// let client = DakeraClient::new("http://localhost:3000")?;
145    ///
146    /// let request = ColumnUpsertRequest::new(
147    ///     vec!["id1".to_string(), "id2".to_string(), "id3".to_string()],
148    ///     vec![
149    ///         vec![0.1, 0.2, 0.3],
150    ///         vec![0.4, 0.5, 0.6],
151    ///         vec![0.7, 0.8, 0.9],
152    ///     ],
153    /// )
154    /// .with_attribute("category", vec![
155    ///     serde_json::json!("A"),
156    ///     serde_json::json!("B"),
157    ///     serde_json::json!("A"),
158    /// ]);
159    ///
160    /// let response = client.upsert_columns("my-namespace", request).await?;
161    /// println!("Upserted {} vectors", response.upserted_count);
162    /// # Ok(())
163    /// # }
164    /// ```
165    #[instrument(skip(self, request), fields(namespace = %namespace, count = request.ids.len()))]
166    pub async fn upsert_columns(
167        &self,
168        namespace: &str,
169        request: ColumnUpsertRequest,
170    ) -> Result<UpsertResponse> {
171        let url = format!(
172            "{}/v1/namespaces/{}/upsert-columns",
173            self.base_url, namespace
174        );
175        debug!(
176            "Upserting {} vectors in column format to {}",
177            request.ids.len(),
178            namespace
179        );
180
181        let response = self.client.post(&url).json(&request).send().await?;
182        self.handle_response(response).await
183    }
184
185    /// Query for similar vectors
186    #[instrument(skip(self, request), fields(top_k = request.top_k))]
187    pub async fn query(&self, namespace: &str, request: QueryRequest) -> Result<QueryResponse> {
188        let url = format!("{}/v1/namespaces/{}/query", self.base_url, namespace);
189        debug!(
190            "Querying namespace {} for top {} results",
191            namespace, request.top_k
192        );
193
194        let response = self.client.post(&url).json(&request).send().await?;
195        self.handle_response(response).await
196    }
197
198    /// Simple query with just a vector and top_k (convenience method)
199    #[instrument(skip(self, vector))]
200    pub async fn query_simple(
201        &self,
202        namespace: &str,
203        vector: Vec<f32>,
204        top_k: u32,
205    ) -> Result<QueryResponse> {
206        self.query(namespace, QueryRequest::new(vector, top_k))
207            .await
208    }
209
210    /// Execute multiple queries in a single request
211    ///
212    /// This allows executing multiple vector similarity queries in parallel,
213    /// which is more efficient than making separate requests.
214    ///
215    /// # Example
216    ///
217    /// ```rust,no_run
218    /// use dakera_client::{DakeraClient, BatchQueryRequest, BatchQueryItem};
219    ///
220    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
221    /// let client = DakeraClient::new("http://localhost:3000")?;
222    ///
223    /// let request = BatchQueryRequest::new(vec![
224    ///     BatchQueryItem::new(vec![0.1, 0.2, 0.3], 5).with_id("query1"),
225    ///     BatchQueryItem::new(vec![0.4, 0.5, 0.6], 10).with_id("query2"),
226    /// ]);
227    ///
228    /// let response = client.batch_query("my-namespace", request).await?;
229    /// println!("Executed {} queries in {}ms", response.query_count, response.total_latency_ms);
230    /// # Ok(())
231    /// # }
232    /// ```
233    #[instrument(skip(self, request), fields(namespace = %namespace, query_count = request.queries.len()))]
234    pub async fn batch_query(
235        &self,
236        namespace: &str,
237        request: BatchQueryRequest,
238    ) -> Result<BatchQueryResponse> {
239        let url = format!("{}/v1/namespaces/{}/batch-query", self.base_url, namespace);
240        debug!(
241            "Batch querying namespace {} with {} queries",
242            namespace,
243            request.queries.len()
244        );
245
246        let response = self.client.post(&url).json(&request).send().await?;
247        self.handle_response(response).await
248    }
249
250    /// Delete vectors by ID
251    #[instrument(skip(self, request), fields(id_count = request.ids.len()))]
252    pub async fn delete(&self, namespace: &str, request: DeleteRequest) -> Result<DeleteResponse> {
253        let url = format!(
254            "{}/v1/namespaces/{}/vectors/delete",
255            self.base_url, namespace
256        );
257        debug!("Deleting {} vectors from {}", request.ids.len(), namespace);
258
259        let response = self.client.post(&url).json(&request).send().await?;
260        self.handle_response(response).await
261    }
262
263    /// Delete a single vector by ID (convenience method)
264    #[instrument(skip(self))]
265    pub async fn delete_one(&self, namespace: &str, id: &str) -> Result<DeleteResponse> {
266        self.delete(namespace, DeleteRequest::single(id)).await
267    }
268
269    // ========================================================================
270    // Full-Text Search Operations
271    // ========================================================================
272
273    /// Index documents for full-text search
274    #[instrument(skip(self, request), fields(doc_count = request.documents.len()))]
275    pub async fn index_documents(
276        &self,
277        namespace: &str,
278        request: IndexDocumentsRequest,
279    ) -> Result<IndexDocumentsResponse> {
280        let url = format!(
281            "{}/v1/namespaces/{}/fulltext/index",
282            self.base_url, namespace
283        );
284        debug!(
285            "Indexing {} documents in {}",
286            request.documents.len(),
287            namespace
288        );
289
290        let response = self.client.post(&url).json(&request).send().await?;
291        self.handle_response(response).await
292    }
293
294    /// Index a single document (convenience method)
295    #[instrument(skip(self, document))]
296    pub async fn index_document(
297        &self,
298        namespace: &str,
299        document: Document,
300    ) -> Result<IndexDocumentsResponse> {
301        self.index_documents(
302            namespace,
303            IndexDocumentsRequest {
304                documents: vec![document],
305            },
306        )
307        .await
308    }
309
310    /// Perform full-text search
311    #[instrument(skip(self, request))]
312    pub async fn fulltext_search(
313        &self,
314        namespace: &str,
315        request: FullTextSearchRequest,
316    ) -> Result<FullTextSearchResponse> {
317        let url = format!(
318            "{}/v1/namespaces/{}/fulltext/search",
319            self.base_url, namespace
320        );
321        debug!("Full-text search in {} for: {}", namespace, request.query);
322
323        let response = self.client.post(&url).json(&request).send().await?;
324        self.handle_response(response).await
325    }
326
327    /// Simple full-text search (convenience method)
328    #[instrument(skip(self))]
329    pub async fn search_text(
330        &self,
331        namespace: &str,
332        query: &str,
333        top_k: u32,
334    ) -> Result<FullTextSearchResponse> {
335        self.fulltext_search(namespace, FullTextSearchRequest::new(query, top_k))
336            .await
337    }
338
339    /// Get full-text index statistics
340    #[instrument(skip(self))]
341    pub async fn fulltext_stats(&self, namespace: &str) -> Result<FullTextStats> {
342        let url = format!(
343            "{}/v1/namespaces/{}/fulltext/stats",
344            self.base_url, namespace
345        );
346        let response = self.client.get(&url).send().await?;
347        self.handle_response(response).await
348    }
349
350    /// Delete documents from full-text index
351    #[instrument(skip(self, request))]
352    pub async fn fulltext_delete(
353        &self,
354        namespace: &str,
355        request: DeleteRequest,
356    ) -> Result<DeleteResponse> {
357        let url = format!(
358            "{}/v1/namespaces/{}/fulltext/delete",
359            self.base_url, namespace
360        );
361        let response = self.client.post(&url).json(&request).send().await?;
362        self.handle_response(response).await
363    }
364
365    // ========================================================================
366    // Hybrid Search Operations
367    // ========================================================================
368
369    /// Perform hybrid search (vector + full-text)
370    #[instrument(skip(self, request), fields(top_k = request.top_k))]
371    pub async fn hybrid_search(
372        &self,
373        namespace: &str,
374        request: HybridSearchRequest,
375    ) -> Result<HybridSearchResponse> {
376        let url = format!("{}/v1/namespaces/{}/hybrid", self.base_url, namespace);
377        debug!(
378            "Hybrid search in {} with vector_weight={}",
379            namespace, request.vector_weight
380        );
381
382        let response = self.client.post(&url).json(&request).send().await?;
383        self.handle_response(response).await
384    }
385
386    // ========================================================================
387    // Multi-Vector Search Operations
388    // ========================================================================
389
390    /// Multi-vector search with positive/negative vectors and MMR
391    ///
392    /// This performs semantic search using multiple positive vectors (to search towards)
393    /// and optional negative vectors (to search away from). Supports MMR (Maximal Marginal
394    /// Relevance) for result diversity.
395    ///
396    /// # Example
397    ///
398    /// ```rust,no_run
399    /// use dakera_client::{DakeraClient, MultiVectorSearchRequest};
400    ///
401    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
402    /// let client = DakeraClient::new("http://localhost:3000")?;
403    ///
404    /// // Search towards multiple concepts, away from others
405    /// let request = MultiVectorSearchRequest::new(vec![
406    ///     vec![0.1, 0.2, 0.3],  // positive vector 1
407    ///     vec![0.4, 0.5, 0.6],  // positive vector 2
408    /// ])
409    /// .with_negative_vectors(vec![
410    ///     vec![0.7, 0.8, 0.9],  // negative vector
411    /// ])
412    /// .with_top_k(10)
413    /// .with_mmr(0.7);  // Enable MMR with lambda=0.7
414    ///
415    /// let response = client.multi_vector_search("my-namespace", request).await?;
416    /// for result in response.results {
417    ///     println!("ID: {}, Score: {}", result.id, result.score);
418    /// }
419    /// # Ok(())
420    /// # }
421    /// ```
422    #[instrument(skip(self, request), fields(namespace = %namespace))]
423    pub async fn multi_vector_search(
424        &self,
425        namespace: &str,
426        request: MultiVectorSearchRequest,
427    ) -> Result<MultiVectorSearchResponse> {
428        let url = format!("{}/v1/namespaces/{}/multi-vector", self.base_url, namespace);
429        debug!(
430            "Multi-vector search in {} with {} positive vectors",
431            namespace,
432            request.positive_vectors.len()
433        );
434
435        let response = self.client.post(&url).json(&request).send().await?;
436        self.handle_response(response).await
437    }
438
439    // ========================================================================
440    // Aggregation Operations
441    // ========================================================================
442
443    /// Aggregate vectors with grouping (Turbopuffer-inspired)
444    ///
445    /// This performs aggregation queries on vector metadata, supporting
446    /// count, sum, avg, min, and max operations with optional grouping.
447    ///
448    /// # Example
449    ///
450    /// ```rust,no_run
451    /// use dakera_client::{DakeraClient, AggregationRequest};
452    ///
453    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
454    /// let client = DakeraClient::new("http://localhost:3000")?;
455    ///
456    /// // Count all vectors and sum scores, grouped by category
457    /// let request = AggregationRequest::new()
458    ///     .with_count("total_count")
459    ///     .with_sum("total_score", "score")
460    ///     .with_avg("avg_score", "score")
461    ///     .with_group_by("category");
462    ///
463    /// let response = client.aggregate("my-namespace", request).await?;
464    /// if let Some(groups) = response.aggregation_groups {
465    ///     for group in groups {
466    ///         println!("Group: {:?}", group.group_key);
467    ///     }
468    /// }
469    /// # Ok(())
470    /// # }
471    /// ```
472    #[instrument(skip(self, request), fields(namespace = %namespace))]
473    pub async fn aggregate(
474        &self,
475        namespace: &str,
476        request: AggregationRequest,
477    ) -> Result<AggregationResponse> {
478        let url = format!("{}/v1/namespaces/{}/aggregate", self.base_url, namespace);
479        debug!(
480            "Aggregating in namespace {} with {} aggregations",
481            namespace,
482            request.aggregate_by.len()
483        );
484
485        let response = self.client.post(&url).json(&request).send().await?;
486        self.handle_response(response).await
487    }
488
489    // ========================================================================
490    // Unified Query Operations
491    // ========================================================================
492
493    /// Unified query with flexible ranking options (Turbopuffer-inspired)
494    ///
495    /// This provides a unified API for vector search (ANN/kNN), full-text search (BM25),
496    /// and attribute ordering. Supports combining multiple ranking functions with
497    /// Sum, Max, and Product operators.
498    ///
499    /// # Example
500    ///
501    /// ```rust,no_run
502    /// use dakera_client::{DakeraClient, UnifiedQueryRequest, SortDirection};
503    ///
504    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
505    /// let client = DakeraClient::new("http://localhost:3000")?;
506    ///
507    /// // Vector ANN search
508    /// let request = UnifiedQueryRequest::vector_search(vec![0.1, 0.2, 0.3], 10);
509    /// let response = client.unified_query("my-namespace", request).await?;
510    ///
511    /// // Full-text BM25 search
512    /// let request = UnifiedQueryRequest::fulltext_search("content", "hello world", 10);
513    /// let response = client.unified_query("my-namespace", request).await?;
514    ///
515    /// // Attribute ordering with filter
516    /// let request = UnifiedQueryRequest::attribute_order("timestamp", SortDirection::Desc, 10)
517    ///     .with_filter(serde_json::json!({"category": {"$eq": "science"}}));
518    /// let response = client.unified_query("my-namespace", request).await?;
519    ///
520    /// for result in response.results {
521    ///     println!("ID: {}, Score: {:?}", result.id, result.dist);
522    /// }
523    /// # Ok(())
524    /// # }
525    /// ```
526    #[instrument(skip(self, request), fields(namespace = %namespace))]
527    pub async fn unified_query(
528        &self,
529        namespace: &str,
530        request: UnifiedQueryRequest,
531    ) -> Result<UnifiedQueryResponse> {
532        let url = format!(
533            "{}/v1/namespaces/{}/unified-query",
534            self.base_url, namespace
535        );
536        debug!(
537            "Unified query in namespace {} with top_k={}",
538            namespace, request.top_k
539        );
540
541        let response = self.client.post(&url).json(&request).send().await?;
542        self.handle_response(response).await
543    }
544
545    /// Simple vector search using the unified query API (convenience method)
546    ///
547    /// This is a shortcut for `unified_query` with a vector ANN search.
548    #[instrument(skip(self, vector))]
549    pub async fn unified_vector_search(
550        &self,
551        namespace: &str,
552        vector: Vec<f32>,
553        top_k: usize,
554    ) -> Result<UnifiedQueryResponse> {
555        self.unified_query(namespace, UnifiedQueryRequest::vector_search(vector, top_k))
556            .await
557    }
558
559    /// Simple full-text search using the unified query API (convenience method)
560    ///
561    /// This is a shortcut for `unified_query` with a BM25 full-text search.
562    #[instrument(skip(self))]
563    pub async fn unified_text_search(
564        &self,
565        namespace: &str,
566        field: &str,
567        query: &str,
568        top_k: usize,
569    ) -> Result<UnifiedQueryResponse> {
570        self.unified_query(
571            namespace,
572            UnifiedQueryRequest::fulltext_search(field, query, top_k),
573        )
574        .await
575    }
576
577    // ========================================================================
578    // Query Explain Operations
579    // ========================================================================
580
581    /// Explain query execution plan (similar to SQL EXPLAIN)
582    ///
583    /// This provides detailed information about how a query will be executed,
584    /// including index selection, execution stages, cost estimates, and
585    /// performance recommendations.
586    ///
587    /// # Example
588    ///
589    /// ```rust,no_run
590    /// use dakera_client::{DakeraClient, QueryExplainRequest};
591    ///
592    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
593    /// let client = DakeraClient::new("http://localhost:3000")?;
594    ///
595    /// // Explain a vector search query
596    /// let request = QueryExplainRequest::vector_search(vec![0.1, 0.2, 0.3], 10)
597    ///     .with_verbose();
598    /// let plan = client.explain_query("my-namespace", request).await?;
599    ///
600    /// println!("Query plan: {}", plan.summary);
601    /// println!("Estimated time: {}ms", plan.cost_estimate.estimated_time_ms);
602    ///
603    /// for stage in &plan.stages {
604    ///     println!("Stage {}: {} - {}", stage.order, stage.name, stage.description);
605    /// }
606    ///
607    /// for rec in &plan.recommendations {
608    ///     println!("Recommendation ({}): {}", rec.priority, rec.description);
609    /// }
610    /// # Ok(())
611    /// # }
612    /// ```
613    #[instrument(skip(self, request), fields(namespace = %namespace))]
614    pub async fn explain_query(
615        &self,
616        namespace: &str,
617        request: QueryExplainRequest,
618    ) -> Result<QueryExplainResponse> {
619        let url = format!("{}/v1/namespaces/{}/explain", self.base_url, namespace);
620        debug!(
621            "Explaining query in namespace {} (query_type={:?}, top_k={})",
622            namespace, request.query_type, request.top_k
623        );
624
625        let response = self.client.post(&url).json(&request).send().await?;
626        self.handle_response(response).await
627    }
628
629    // ========================================================================
630    // Cache Warming Operations
631    // ========================================================================
632
633    /// Warm cache for vectors in a namespace
634    ///
635    /// This pre-loads vectors into cache tiers for faster subsequent access.
636    /// Supports priority levels and can run in the background.
637    ///
638    /// # Example
639    ///
640    /// ```rust,no_run
641    /// use dakera_client::{DakeraClient, WarmCacheRequest, WarmingPriority};
642    ///
643    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
644    /// let client = DakeraClient::new("http://localhost:3000")?;
645    ///
646    /// // Warm entire namespace with high priority
647    /// let response = client.warm_cache(
648    ///     WarmCacheRequest::new("my-namespace")
649    ///         .with_priority(WarmingPriority::High)
650    /// ).await?;
651    ///
652    /// println!("Warmed {} entries", response.entries_warmed);
653    /// # Ok(())
654    /// # }
655    /// ```
656    #[instrument(skip(self, request), fields(namespace = %request.namespace, priority = ?request.priority))]
657    pub async fn warm_cache(&self, request: WarmCacheRequest) -> Result<WarmCacheResponse> {
658        let url = format!(
659            "{}/v1/namespaces/{}/cache/warm",
660            self.base_url, request.namespace
661        );
662        debug!(
663            "Warming cache for namespace {} with priority {:?}",
664            request.namespace, request.priority
665        );
666
667        let response = self.client.post(&url).json(&request).send().await?;
668        self.handle_response(response).await
669    }
670
671    /// Warm specific vectors by ID (convenience method)
672    #[instrument(skip(self, vector_ids))]
673    pub async fn warm_vectors(
674        &self,
675        namespace: &str,
676        vector_ids: Vec<String>,
677    ) -> Result<WarmCacheResponse> {
678        self.warm_cache(WarmCacheRequest::new(namespace).with_vector_ids(vector_ids))
679            .await
680    }
681
682    // ========================================================================
683    // Export Operations
684    // ========================================================================
685
686    /// Export vectors from a namespace with pagination
687    ///
688    /// This exports all vectors from a namespace, supporting pagination for
689    /// large datasets. Use the `next_cursor` from the response to fetch
690    /// subsequent pages.
691    ///
692    /// # Example
693    ///
694    /// ```rust,no_run
695    /// use dakera_client::{DakeraClient, ExportRequest};
696    ///
697    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
698    /// let client = DakeraClient::new("http://localhost:3000")?;
699    ///
700    /// // Export first page of vectors
701    /// let mut request = ExportRequest::new().with_top_k(1000);
702    /// let response = client.export("my-namespace", request).await?;
703    ///
704    /// println!("Exported {} vectors", response.returned_count);
705    ///
706    /// // Fetch next page if available
707    /// if let Some(cursor) = response.next_cursor {
708    ///     let next_request = ExportRequest::new().with_cursor(cursor);
709    ///     let next_response = client.export("my-namespace", next_request).await?;
710    /// }
711    /// # Ok(())
712    /// # }
713    /// ```
714    #[instrument(skip(self, request), fields(namespace = %namespace))]
715    pub async fn export(&self, namespace: &str, request: ExportRequest) -> Result<ExportResponse> {
716        let url = format!("{}/v1/namespaces/{}/export", self.base_url, namespace);
717        debug!(
718            "Exporting vectors from namespace {} (top_k={}, cursor={:?})",
719            namespace, request.top_k, request.cursor
720        );
721
722        let response = self.client.post(&url).json(&request).send().await?;
723        self.handle_response(response).await
724    }
725
726    /// Export all vectors from a namespace (convenience method)
727    ///
728    /// This is a simple wrapper that exports with default settings.
729    #[instrument(skip(self))]
730    pub async fn export_all(&self, namespace: &str) -> Result<ExportResponse> {
731        self.export(namespace, ExportRequest::new()).await
732    }
733
734    // ========================================================================
735    // Operations
736    // ========================================================================
737
738    /// Get system diagnostics
739    #[instrument(skip(self))]
740    pub async fn diagnostics(&self) -> Result<SystemDiagnostics> {
741        let url = format!("{}/ops/diagnostics", self.base_url);
742        let response = self.client.get(&url).send().await?;
743        self.handle_response(response).await
744    }
745
746    /// List background jobs
747    #[instrument(skip(self))]
748    pub async fn list_jobs(&self) -> Result<Vec<JobInfo>> {
749        let url = format!("{}/ops/jobs", self.base_url);
750        let response = self.client.get(&url).send().await?;
751        self.handle_response(response).await
752    }
753
754    /// Get a specific job status
755    #[instrument(skip(self))]
756    pub async fn get_job(&self, job_id: &str) -> Result<Option<JobInfo>> {
757        let url = format!("{}/ops/jobs/{}", self.base_url, job_id);
758        let response = self.client.get(&url).send().await?;
759
760        if response.status() == StatusCode::NOT_FOUND {
761            return Ok(None);
762        }
763
764        self.handle_response(response).await.map(Some)
765    }
766
767    /// Trigger index compaction
768    #[instrument(skip(self, request))]
769    pub async fn compact(&self, request: CompactionRequest) -> Result<CompactionResponse> {
770        let url = format!("{}/ops/compact", self.base_url);
771        let response = self.client.post(&url).json(&request).send().await?;
772        self.handle_response(response).await
773    }
774
775    /// Request graceful shutdown
776    #[instrument(skip(self))]
777    pub async fn shutdown(&self) -> Result<()> {
778        let url = format!("{}/ops/shutdown", self.base_url);
779        let response = self.client.post(&url).send().await?;
780
781        if response.status().is_success() {
782            Ok(())
783        } else {
784            let status = response.status().as_u16();
785            let text = response.text().await.unwrap_or_default();
786            Err(ClientError::Server {
787                status,
788                message: text,
789            })
790        }
791    }
792
793    // ========================================================================
794    // Private Helpers
795    // ========================================================================
796
797    /// Handle response and deserialize JSON
798    pub(crate) async fn handle_response<T: serde::de::DeserializeOwned>(
799        &self,
800        response: reqwest::Response,
801    ) -> Result<T> {
802        let status = response.status();
803
804        if status.is_success() {
805            Ok(response.json().await?)
806        } else {
807            let status_code = status.as_u16();
808            let text = response.text().await.unwrap_or_default();
809
810            // Try to parse error message from JSON
811            let message = if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
812                json.get("error")
813                    .and_then(|e| e.as_str())
814                    .unwrap_or(&text)
815                    .to_string()
816            } else {
817                text
818            };
819
820            Err(ClientError::Server {
821                status: status_code,
822                message,
823            })
824        }
825    }
826}
827
828/// Builder for DakeraClient
829#[derive(Debug)]
830pub struct DakeraClientBuilder {
831    base_url: String,
832    timeout: Duration,
833    user_agent: Option<String>,
834}
835
836impl DakeraClientBuilder {
837    /// Create a new builder
838    pub fn new(base_url: impl Into<String>) -> Self {
839        Self {
840            base_url: base_url.into(),
841            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
842            user_agent: None,
843        }
844    }
845
846    /// Set the request timeout
847    pub fn timeout(mut self, timeout: Duration) -> Self {
848        self.timeout = timeout;
849        self
850    }
851
852    /// Set the request timeout in seconds
853    pub fn timeout_secs(mut self, secs: u64) -> Self {
854        self.timeout = Duration::from_secs(secs);
855        self
856    }
857
858    /// Set a custom user agent
859    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
860        self.user_agent = Some(user_agent.into());
861        self
862    }
863
864    /// Build the client
865    pub fn build(self) -> Result<DakeraClient> {
866        // Normalize base URL (remove trailing slash)
867        let base_url = self.base_url.trim_end_matches('/').to_string();
868
869        // Validate URL
870        if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
871            return Err(ClientError::InvalidUrl(
872                "URL must start with http:// or https://".to_string(),
873            ));
874        }
875
876        let user_agent = self
877            .user_agent
878            .unwrap_or_else(|| format!("dakera-client/{}", env!("CARGO_PKG_VERSION")));
879
880        let client = Client::builder()
881            .timeout(self.timeout)
882            .user_agent(user_agent)
883            .build()
884            .map_err(|e| ClientError::Config(e.to_string()))?;
885
886        Ok(DakeraClient { client, base_url })
887    }
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    #[test]
895    fn test_client_builder() {
896        let client = DakeraClient::new("http://localhost:3000");
897        assert!(client.is_ok());
898    }
899
900    #[test]
901    fn test_client_builder_with_options() {
902        let client = DakeraClient::builder("http://localhost:3000")
903            .timeout_secs(60)
904            .user_agent("test-client/1.0")
905            .build();
906        assert!(client.is_ok());
907    }
908
909    #[test]
910    fn test_client_builder_invalid_url() {
911        let client = DakeraClient::new("invalid-url");
912        assert!(client.is_err());
913    }
914
915    #[test]
916    fn test_client_builder_trailing_slash() {
917        let client = DakeraClient::new("http://localhost:3000/").unwrap();
918        assert!(!client.base_url.ends_with('/'));
919    }
920
921    #[test]
922    fn test_vector_creation() {
923        let v = Vector::new("test", vec![0.1, 0.2, 0.3]);
924        assert_eq!(v.id, "test");
925        assert_eq!(v.values.len(), 3);
926        assert!(v.metadata.is_none());
927    }
928
929    #[test]
930    fn test_query_request_builder() {
931        let req = QueryRequest::new(vec![0.1, 0.2], 10)
932            .with_filter(serde_json::json!({"category": "test"}))
933            .include_metadata(false);
934
935        assert_eq!(req.top_k, 10);
936        assert!(req.filter.is_some());
937        assert!(!req.include_metadata);
938    }
939
940    #[test]
941    fn test_hybrid_search_request() {
942        let req = HybridSearchRequest::new(vec![0.1], "test query", 5).with_vector_weight(0.7);
943
944        assert_eq!(req.vector_weight, 0.7);
945        assert_eq!(req.text, "test query");
946    }
947
948    #[test]
949    fn test_hybrid_search_weight_clamping() {
950        let req = HybridSearchRequest::new(vec![0.1], "test", 5).with_vector_weight(1.5); // Should be clamped to 1.0
951
952        assert_eq!(req.vector_weight, 1.0);
953    }
954}