use crate::types::CodeChunk;
use anyhow::{Context, Result};
use futures_util::stream::{FuturesOrdered, StreamExt};
use serde::{Deserialize, Serialize};
use std::time::Duration;
const CHUNK_PAGE_LIMIT: usize = 1000;
const CHUNK_PAGE_CONCURRENCY: usize = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexSummary {
pub id: String,
#[serde(default)]
pub root_path: Option<String>,
}
#[derive(Clone)]
pub struct TrustySearchClient {
base_url: String,
http: reqwest::Client,
}
impl TrustySearchClient {
pub fn new(base_url: impl Into<String>) -> Self {
let mut base = base_url.into();
if base.ends_with('/') {
base.pop();
}
let http = reqwest::ClientBuilder::new()
.tcp_keepalive(Duration::from_secs(60))
.pool_max_idle_per_host(CHUNK_PAGE_CONCURRENCY)
.http2_prior_knowledge() .timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(5))
.build()
.expect("failed to build HTTP client");
Self {
base_url: base,
http,
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub async fn health(&self) -> Result<bool> {
let url = format!("{}/health", self.base_url);
let resp = self.http.get(&url).send().await.context("GET /health")?;
Ok(resp.status().is_success())
}
pub async fn list_indexes(&self) -> Result<Vec<IndexSummary>> {
#[derive(Deserialize)]
struct Listing {
indexes: Vec<String>,
}
let url = format!("{}/indexes", self.base_url);
let body: Listing = self
.http
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("non-2xx from {url}"))?
.json()
.await
.with_context(|| format!("decode {url}"))?;
Ok(body
.indexes
.into_iter()
.map(|id| IndexSummary {
id,
root_path: None,
})
.collect())
}
pub async fn index_details(&self) -> Result<Vec<IndexSummary>> {
#[derive(Deserialize)]
struct Listing {
indexes: Vec<IndexSummary>,
}
let url = format!("{}/indexes?details=true", self.base_url);
let body: Listing = self
.http
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("non-2xx from {url}"))?
.json()
.await
.with_context(|| format!("decode {url}"))?;
Ok(body.indexes)
}
pub async fn index_status_root_path(&self, index_id: &str) -> Result<Option<String>> {
#[derive(Deserialize)]
struct StatusBody {
#[serde(default)]
root_path: Option<String>,
}
let url = format!("{}/indexes/{}/status", self.base_url, index_id);
let body: StatusBody = self
.http
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("non-2xx from {url}"))?
.json()
.await
.with_context(|| format!("decode {url}"))?;
Ok(body.root_path)
}
pub async fn get_chunks(&self, index_id: &str) -> Result<Vec<CodeChunk>> {
let base = format!("{}/indexes/{}/chunks", self.base_url, index_id);
let mut all_chunks: Vec<CodeChunk> = Vec::new();
let mut next_offset: usize = 0;
let mut exhausted = false;
while !exhausted {
let mut window: FuturesOrdered<_> = (0..CHUNK_PAGE_CONCURRENCY)
.map(|i| {
let offset = next_offset + i * CHUNK_PAGE_LIMIT;
fetch_chunk_page(&self.http, &base, offset, CHUNK_PAGE_LIMIT)
})
.collect();
let mut window_consumed: usize = 0;
while let Some(page) = window.next().await {
let page = page?;
let received = page.len();
all_chunks.extend(page);
window_consumed += 1;
if received < CHUNK_PAGE_LIMIT {
exhausted = true;
while let Some(extra) = window.next().await {
all_chunks.extend(extra?);
}
break;
}
}
if !exhausted {
next_offset += window_consumed * CHUNK_PAGE_LIMIT;
}
}
Ok(all_chunks)
}
}
async fn fetch_chunk_page(
http: &reqwest::Client,
base_url: &str,
offset: usize,
limit: usize,
) -> Result<Vec<CodeChunk>> {
#[derive(Deserialize)]
struct ChunksBody {
chunks: Vec<CodeChunk>,
}
let url = format!("{base_url}?offset={offset}&limit={limit}");
let body: ChunksBody = http
.get(&url)
.send()
.await
.with_context(|| format!("GET {url}"))?
.error_for_status()
.with_context(|| format!("non-2xx from {url}"))?
.json()
.await
.with_context(|| format!("decode {url}"))?;
Ok(body.chunks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_summary_deserializes_with_and_without_root_path() {
let json = r#"{"id":"abc","root_path":"/home/user/proj"}"#;
let s: IndexSummary = serde_json::from_str(json).expect("deserialize with root_path");
assert_eq!(s.id, "abc");
assert_eq!(s.root_path.as_deref(), Some("/home/user/proj"));
let json2 = r#"{"id":"xyz"}"#;
let s2: IndexSummary = serde_json::from_str(json2).expect("deserialize without root_path");
assert_eq!(s2.id, "xyz");
assert!(s2.root_path.is_none());
}
#[test]
fn index_details_deserializes_root_path() {
let json = r#"{"indexes":[{"id":"idx1","root_path":"/src/myapp"},{"id":"idx2"}]}"#;
#[derive(serde::Deserialize)]
struct Listing {
indexes: Vec<IndexSummary>,
}
let listing: Listing = serde_json::from_str(json).expect("parse listing");
assert_eq!(listing.indexes.len(), 2);
assert_eq!(listing.indexes[0].id, "idx1");
assert_eq!(listing.indexes[0].root_path.as_deref(), Some("/src/myapp"));
assert_eq!(listing.indexes[1].id, "idx2");
assert!(listing.indexes[1].root_path.is_none());
}
#[test]
fn index_status_deserializes_root_path() {
#[derive(serde::Deserialize)]
struct StatusBody {
#[serde(default)]
root_path: Option<String>,
}
let json_with = r#"{"index_id":"myproj","root_path":"/home/user/myproj","chunk_count":42}"#;
let s: StatusBody = serde_json::from_str(json_with).expect("parse status with root_path");
assert_eq!(s.root_path.as_deref(), Some("/home/user/myproj"));
let json_without = r#"{"index_id":"myproj","chunk_count":0}"#;
let s2: StatusBody =
serde_json::from_str(json_without).expect("parse status without root_path");
assert!(s2.root_path.is_none());
}
}