use std::io::Write;
use std::path::PathBuf;
use alopex_embedded::{Database, TxnMode};
use serde::{Deserialize, Serialize};
use crate::batch::BatchMode;
use crate::cli::{OutputFormat, VectorCommand};
use crate::client::http::{ClientError, HttpClient};
use crate::error::{CliError, Result};
use crate::models::{Column, DataType, Row, Value};
use crate::output::formatter::Formatter;
use crate::output::RowCollector;
use crate::progress::ProgressIndicator;
use crate::streaming::{StreamingWriter, WriteStatus};
use crate::tui::admin::{AdminBackend, AdminContext, AdminTarget, AuthCapabilities};
use crate::tui::renderer::render_output;
#[derive(Debug, Serialize)]
struct RemoteVectorSearchRequest {
index: String,
query: Vec<f32>,
k: usize,
}
#[derive(Debug, Serialize)]
struct RemoteVectorUpsertRequest {
index: String,
key: Vec<u8>,
vector: Vec<f32>,
}
#[derive(Debug, Serialize)]
struct RemoteVectorDeleteRequest {
index: String,
key: Vec<u8>,
}
#[derive(Debug, Deserialize)]
struct RemoteVectorSearchResult {
key: Vec<u8>,
distance: f32,
#[allow(dead_code)]
metadata: Vec<u8>,
}
#[derive(Debug, Deserialize)]
struct RemoteVectorSearchResponse {
results: Vec<RemoteVectorSearchResult>,
}
#[derive(Debug, Deserialize)]
struct RemoteVectorStatusResponse {
success: bool,
}
pub fn execute<W: Write>(
db: &Database,
cmd: VectorCommand,
batch_mode: &BatchMode,
writer: &mut StreamingWriter<W>,
) -> Result<()> {
match cmd {
VectorCommand::Search {
index,
query,
k,
progress,
} => execute_search(db, &index, &query, k, progress, batch_mode, writer),
VectorCommand::Upsert { index, key, vector } => {
execute_upsert(db, &index, &key, &vector, writer)
}
VectorCommand::Delete { index, key } => execute_delete(db, &index, &key, writer),
}
}
#[allow(clippy::too_many_arguments)]
pub fn execute_tui(
db: &Database,
cmd: VectorCommand,
batch_mode: &BatchMode,
output_format: OutputFormat,
columns: Vec<Column>,
limit: Option<usize>,
quiet: bool,
connection_label: impl Into<String>,
data_dir: Option<PathBuf>,
) -> Result<()> {
let connection_label = connection_label.into();
let context_message = Some(vector_command_context(&cmd));
let admin_label = connection_label.clone();
let admin_data_dir = data_dir.clone();
let admin_launcher: Option<Box<dyn FnMut() -> Result<()> + '_>> = Some(Box::new(move || {
let connection_label = admin_label.clone();
let data_dir = admin_data_dir.clone();
crate::tui::admin::run_admin_ui(AdminContext {
connection_label,
auth: AuthCapabilities::full(),
backend: AdminBackend::Local {
db,
batch_mode,
output_format,
limit,
quiet,
data_dir,
},
initial_target: Some(AdminTarget::Vector),
})
}));
let collector = RowCollector::new();
let formatter = Box::new(collector.formatter());
let mut sink = std::io::sink();
let mut writer =
StreamingWriter::new(&mut sink, formatter, columns.clone(), limit).with_quiet(quiet);
execute(db, cmd, batch_mode, &mut writer)?;
let warning = collector.truncation_warning();
render_output(
columns,
collector.rows(),
connection_label,
context_message,
true,
warning,
output_format,
admin_launcher,
)
}
pub async fn execute_remote_with_formatter<W: Write>(
client: &HttpClient,
cmd: &VectorCommand,
batch_mode: &BatchMode,
writer: &mut W,
formatter: Box<dyn Formatter>,
limit: Option<usize>,
quiet: bool,
) -> Result<()> {
match cmd {
VectorCommand::Search {
index,
query,
k,
progress,
} => {
execute_remote_search(
client, index, query, *k, *progress, batch_mode, writer, formatter, limit, quiet,
)
.await
}
VectorCommand::Upsert { index, key, vector } => {
execute_remote_upsert(client, index, key, vector, writer, formatter, limit, quiet).await
}
VectorCommand::Delete { index, key } => {
execute_remote_delete(client, index, key, writer, formatter, limit, quiet).await
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn execute_remote_tui<'a>(
client: &HttpClient,
cmd: &VectorCommand,
batch_mode: &BatchMode,
columns: Vec<Column>,
output_format: OutputFormat,
limit: Option<usize>,
quiet: bool,
connection_label: impl Into<String>,
admin_launcher: Option<Box<dyn FnMut() -> Result<()> + 'a>>,
) -> Result<()> {
let collector = RowCollector::new();
let formatter = Box::new(collector.formatter());
let mut sink = std::io::sink();
execute_remote_with_formatter(client, cmd, batch_mode, &mut sink, formatter, limit, quiet)
.await?;
let warning = collector.truncation_warning();
render_output(
columns,
collector.rows(),
connection_label,
Some(vector_command_context(cmd)),
true,
warning,
output_format,
admin_launcher,
)
}
#[allow(clippy::too_many_arguments)]
async fn execute_remote_search<W: Write>(
client: &HttpClient,
index: &str,
query_json: &str,
k: usize,
progress: bool,
batch_mode: &BatchMode,
writer: &mut W,
formatter: Box<dyn Formatter>,
limit: Option<usize>,
quiet: bool,
) -> Result<()> {
let query_vector: Vec<f32> = serde_json::from_str(query_json)
.map_err(|e| CliError::InvalidArgument(format!("Invalid vector JSON: {}", e)))?;
let mut progress_indicator = ProgressIndicator::new(
batch_mode,
progress,
quiet,
format!("Searching index '{}' for {} nearest neighbors...", index, k),
);
let request = RemoteVectorSearchRequest {
index: index.to_string(),
query: query_vector,
k,
};
let response: RemoteVectorSearchResponse = client
.post_json("hnsw/search", &request)
.await
.map_err(map_client_error)?;
progress_indicator.finish_with_message(format!("found {} results.", response.results.len()));
let columns = vector_search_columns();
let mut streaming_writer =
StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
streaming_writer.prepare(Some(response.results.len()))?;
for result in response.results {
let key_display = match std::str::from_utf8(&result.key) {
Ok(s) => Value::Text(s.to_string()),
Err(_) => Value::Bytes(result.key),
};
let row = Row::new(vec![key_display, Value::Float(result.distance as f64)]);
match streaming_writer.write_row(row)? {
WriteStatus::LimitReached => break,
WriteStatus::Continue => {}
}
}
streaming_writer.finish()
}
#[allow(clippy::too_many_arguments)]
async fn execute_remote_upsert<W: Write>(
client: &HttpClient,
index: &str,
key: &str,
vector_json: &str,
writer: &mut W,
formatter: Box<dyn Formatter>,
limit: Option<usize>,
quiet: bool,
) -> Result<()> {
let vector: Vec<f32> = serde_json::from_str(vector_json)
.map_err(|e| CliError::InvalidArgument(format!("Invalid vector JSON: {}", e)))?;
let request = RemoteVectorUpsertRequest {
index: index.to_string(),
key: key.as_bytes().to_vec(),
vector,
};
let response: RemoteVectorStatusResponse = client
.post_json("hnsw/upsert", &request)
.await
.map_err(map_client_error)?;
if response.success {
if quiet {
return Ok(());
}
let columns = vector_status_columns();
let mut streaming_writer =
StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
streaming_writer.prepare(Some(1))?;
let row = Row::new(vec![
Value::Text("OK".to_string()),
Value::Text(format!("Vector '{}' upserted", key)),
]);
streaming_writer.write_row(row)?;
streaming_writer.finish()
} else {
Err(CliError::InvalidArgument(
"Failed to upsert vector".to_string(),
))
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_remote_delete<W: Write>(
client: &HttpClient,
index: &str,
key: &str,
writer: &mut W,
formatter: Box<dyn Formatter>,
limit: Option<usize>,
quiet: bool,
) -> Result<()> {
let request = RemoteVectorDeleteRequest {
index: index.to_string(),
key: key.as_bytes().to_vec(),
};
let response: RemoteVectorStatusResponse = client
.post_json("hnsw/delete", &request)
.await
.map_err(map_client_error)?;
if response.success {
if quiet {
return Ok(());
}
let columns = vector_status_columns();
let mut streaming_writer =
StreamingWriter::new(writer, formatter, columns, limit).with_quiet(quiet);
streaming_writer.prepare(Some(1))?;
let row = Row::new(vec![
Value::Text("OK".to_string()),
Value::Text(format!("Vector '{}' deleted", key)),
]);
streaming_writer.write_row(row)?;
streaming_writer.finish()
} else {
Err(CliError::InvalidArgument(
"Failed to delete vector".to_string(),
))
}
}
fn map_client_error(err: ClientError) -> CliError {
match err {
ClientError::Request { source, .. } => {
CliError::ServerConnection(format!("request failed: {source}"))
}
ClientError::InvalidUrl(message) => CliError::InvalidArgument(message),
ClientError::Build(message) => CliError::InvalidArgument(message),
ClientError::Auth(err) => CliError::InvalidArgument(err.to_string()),
ClientError::HttpStatus { status, body } => {
CliError::InvalidArgument(format!("Server error: HTTP {} - {}", status.as_u16(), body))
}
}
}
fn vector_command_context(cmd: &VectorCommand) -> String {
match cmd {
VectorCommand::Search { index, k, .. } => format!("vector search --index {index} -k {k}"),
VectorCommand::Upsert { index, key, .. } => {
format!("vector upsert --index {index} --key {key}")
}
VectorCommand::Delete { index, key } => {
format!("vector delete --index {index} --key {key}")
}
}
}
fn execute_search<W: Write>(
db: &Database,
index: &str,
query_json: &str,
k: usize,
progress: bool,
batch_mode: &BatchMode,
writer: &mut StreamingWriter<W>,
) -> Result<()> {
let query_vector: Vec<f32> = serde_json::from_str(query_json)
.map_err(|e| CliError::InvalidArgument(format!("Invalid vector JSON: {}", e)))?;
let mut progress_indicator = ProgressIndicator::new(
batch_mode,
progress,
writer.is_quiet(),
format!("Searching index '{}' for {} nearest neighbors...", index, k),
);
let (results, _stats) = db.search_hnsw(index, &query_vector, k, None)?;
progress_indicator.finish_with_message(format!("found {} results.", results.len()));
writer.prepare(Some(results.len()))?;
for result in results {
let key_display = match std::str::from_utf8(&result.key) {
Ok(s) => Value::Text(s.to_string()),
Err(_) => Value::Bytes(result.key),
};
let row = Row::new(vec![key_display, Value::Float(result.distance as f64)]);
match writer.write_row(row)? {
WriteStatus::LimitReached => break,
WriteStatus::Continue => {}
}
}
writer.finish()?;
Ok(())
}
fn execute_upsert<W: Write>(
db: &Database,
index: &str,
key: &str,
vector_json: &str,
writer: &mut StreamingWriter<W>,
) -> Result<()> {
let vector: Vec<f32> = serde_json::from_str(vector_json)
.map_err(|e| CliError::InvalidArgument(format!("Invalid vector JSON: {}", e)))?;
let mut txn = db.begin(TxnMode::ReadWrite)?;
txn.upsert_to_hnsw(index, key.as_bytes(), &vector, b"")?;
txn.commit()?;
if !writer.is_quiet() {
writer.prepare(Some(1))?;
let row = Row::new(vec![
Value::Text("OK".to_string()),
Value::Text(format!("Vector '{}' upserted", key)),
]);
writer.write_row(row)?;
writer.finish()?;
}
Ok(())
}
fn execute_delete<W: Write>(
db: &Database,
index: &str,
key: &str,
writer: &mut StreamingWriter<W>,
) -> Result<()> {
let mut txn = db.begin(TxnMode::ReadWrite)?;
txn.delete_from_hnsw(index, key.as_bytes())?;
txn.commit()?;
if !writer.is_quiet() {
writer.prepare(Some(1))?;
let row = Row::new(vec![
Value::Text("OK".to_string()),
Value::Text(format!("Vector '{}' deleted", key)),
]);
writer.write_row(row)?;
writer.finish()?;
}
Ok(())
}
pub fn vector_search_columns() -> Vec<Column> {
vec![
Column::new("id", DataType::Text),
Column::new("distance", DataType::Float),
]
}
pub fn vector_status_columns() -> Vec<Column> {
vec![
Column::new("status", DataType::Text),
Column::new("message", DataType::Text),
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::batch::BatchModeSource;
use crate::output::jsonl::JsonlFormatter;
use alopex_embedded::HnswConfig;
fn create_test_db() -> Database {
Database::new()
}
fn create_search_writer(output: &mut Vec<u8>) -> StreamingWriter<&mut Vec<u8>> {
let formatter = Box::new(JsonlFormatter::new());
let columns = vector_search_columns();
StreamingWriter::new(output, formatter, columns, None)
}
fn default_batch_mode() -> BatchMode {
BatchMode {
is_batch: false,
is_tty: true,
source: BatchModeSource::Default,
}
}
fn create_status_writer(output: &mut Vec<u8>) -> StreamingWriter<&mut Vec<u8>> {
let formatter = Box::new(JsonlFormatter::new());
let columns = vector_status_columns();
StreamingWriter::new(output, formatter, columns, None)
}
fn setup_hnsw_index(db: &Database, name: &str) {
let config = HnswConfig::default()
.with_dimension(3)
.with_metric(alopex_embedded::Metric::L2)
.with_m(8)
.with_ef_construction(32);
db.create_hnsw_index(name, config).unwrap();
}
#[test]
fn test_upsert_single_vector() {
let db = create_test_db();
setup_hnsw_index(&db, "test_index");
let mut output = Vec::new();
{
let mut writer = create_status_writer(&mut output);
execute_upsert(&db, "test_index", "v1", "[1.0, 0.0, 0.0]", &mut writer).unwrap();
}
let result = String::from_utf8(output).unwrap();
assert!(result.contains("OK"));
assert!(result.contains("Vector 'v1' upserted"));
}
#[test]
fn test_search_vectors() {
use alopex_embedded::TxnMode;
let db = create_test_db();
setup_hnsw_index(&db, "search_test");
{
let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
txn.upsert_to_hnsw("search_test", b"v1", &[1.0_f32, 0.0, 0.0], b"")
.unwrap();
txn.upsert_to_hnsw("search_test", b"v2", &[0.0_f32, 1.0, 0.0], b"")
.unwrap();
txn.upsert_to_hnsw("search_test", b"v3", &[0.0_f32, 0.0, 1.0], b"")
.unwrap();
txn.commit().unwrap();
}
let query = "[1.0, 0.0, 0.0]";
let mut output = Vec::new();
{
let mut writer = create_search_writer(&mut output);
execute_search(
&db,
"search_test",
query,
2,
false,
&default_batch_mode(),
&mut writer,
)
.unwrap();
}
let result = String::from_utf8(output).unwrap();
assert!(result.contains("v1")); }
#[test]
fn test_delete_single_vector() {
use alopex_embedded::TxnMode;
let db = create_test_db();
setup_hnsw_index(&db, "delete_test");
{
let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
txn.upsert_to_hnsw("delete_test", b"v1", &[1.0_f32, 0.0, 0.0], b"")
.unwrap();
txn.upsert_to_hnsw("delete_test", b"v2", &[0.0_f32, 1.0, 0.0], b"")
.unwrap();
txn.commit().unwrap();
}
let mut output = Vec::new();
{
let mut writer = create_status_writer(&mut output);
execute_delete(&db, "delete_test", "v1", &mut writer).unwrap();
}
let result = String::from_utf8(output).unwrap();
assert!(result.contains("OK"));
assert!(result.contains("Vector 'v1' deleted"));
}
#[test]
fn test_invalid_vector_json() {
let db = create_test_db();
setup_hnsw_index(&db, "invalid_test");
let mut output = Vec::new();
let mut writer = create_status_writer(&mut output);
let result = execute_upsert(&db, "invalid_test", "v1", "not valid json", &mut writer);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), CliError::InvalidArgument(_)));
}
#[test]
fn test_direct_multi_txn_hnsw() {
use alopex_embedded::TxnMode;
let db = Database::new();
let config = HnswConfig::default()
.with_dimension(2)
.with_metric(alopex_embedded::Metric::L2)
.with_m(8)
.with_ef_construction(32);
db.create_hnsw_index("direct_test", config).unwrap();
let mut txn = db.begin(TxnMode::ReadWrite).unwrap();
txn.upsert_to_hnsw("direct_test", b"a", &[0.0_f32, 0.0], b"ma")
.unwrap();
txn.upsert_to_hnsw("direct_test", b"b", &[1.0_f32, 0.0], b"mb")
.unwrap();
txn.commit().unwrap();
let (results, _) = db
.search_hnsw("direct_test", &[0.1_f32, 0.0], 1, None)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].key, b"a");
}
}