use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use anyhow::Result;
use indicatif::{ProgressBar, ProgressStyle};
use velesdb_core::{Database, Point, VectorCollection};
static CONFIG_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
pub fn set_config_path(path: Option<PathBuf>) {
let _ = CONFIG_PATH.set(path);
}
pub fn open_database(path: &Path) -> Result<Database> {
let config_path = CONFIG_PATH
.get()
.and_then(Option::as_ref)
.map(PathBuf::as_path);
open_database_with_config(path, config_path)
}
pub fn open_database_with_config(path: &Path, config_path: Option<&Path>) -> Result<Database> {
match config_path {
None => Ok(Database::open(path)?),
Some(cfg) => {
if !cfg.exists() {
anyhow::bail!("config file not found: {}", cfg.display());
}
let config = velesdb_core::config::VelesConfig::load_from_path_engine_only(cfg)
.map_err(|e| {
anyhow::anyhow!("failed to load VelesDB config from {}: {e}", cfg.display())
})?;
Ok(Database::open_with_config(path, config)?)
}
}
}
pub struct BatchImporter<'a> {
collection: &'a VectorCollection,
batch: Vec<Point>,
batch_size: usize,
pub stats: ImportAccumulator,
}
#[derive(Debug, Default)]
pub struct ImportAccumulator {
pub imported: usize,
pub errors: usize,
}
impl<'a> BatchImporter<'a> {
pub fn new(collection: &'a VectorCollection, batch_size: usize) -> Self {
Self {
collection,
batch: Vec::with_capacity(batch_size),
batch_size,
stats: ImportAccumulator::default(),
}
}
pub fn push(&mut self, point: Point) -> Result<()> {
self.batch.push(point);
self.stats.imported += 1;
if self.batch.len() >= self.batch_size {
self.collection.upsert_bulk(&self.batch)?;
self.batch.clear();
}
Ok(())
}
pub fn record_error(&mut self) {
self.stats.errors += 1;
}
pub fn flush(self) -> Result<ImportAccumulator> {
if !self.batch.is_empty() {
self.collection.upsert_bulk(&self.batch)?;
}
Ok(self.stats)
}
}
#[must_use]
pub fn create_progress_bar(total: usize, show: bool) -> ProgressBar {
if show {
let pb = ProgressBar::new(total as u64);
if let Ok(style) = ProgressStyle::default_bar().template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
) {
pb.set_style(style.progress_chars("#>-"));
}
pb
} else {
ProgressBar::hidden()
}
}
pub fn set_import_message(progress: &ProgressBar, total: usize, file_size: u64, show: bool) {
if show {
#[allow(clippy::cast_precision_loss)]
let size_mb = file_size as f64 / (1024.0 * 1024.0);
progress.set_message(format!("Importing {total} vectors ({size_mb:.1} MB)"));
}
}
pub fn point_payload_to_row(
id: u64,
payload: &Option<serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
let mut row = HashMap::new();
row.insert("id".to_string(), serde_json::json!(id));
if let Some(serde_json::Value::Object(map)) = payload {
for (k, v) in map {
row.insert(k.clone(), v.clone());
}
}
row
}
pub fn point_payload_to_browse_row(
id: u64,
payload: &Option<serde_json::Value>,
) -> HashMap<String, serde_json::Value> {
let mut row = HashMap::new();
row.insert("id".to_string(), serde_json::json!(id));
if let Some(serde_json::Value::Object(map)) = payload {
for (k, v) in map {
row.insert(k.clone(), truncate_display_value(v));
}
}
row
}
fn truncate_display_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::String(s) if s.len() > 50 => {
let truncated: String = s.chars().take(47).collect();
serde_json::json!(format!("{truncated}..."))
}
other => other.clone(),
}
}
pub fn print_json(data: &serde_json::Value) -> Result<()> {
println!("{}", serde_json::to_string_pretty(data)?);
Ok(())
}
pub fn point_to_export_record(
id: u64,
vector: Option<&[f32]>,
payload: &Option<serde_json::Value>,
) -> serde_json::Value {
let mut record = serde_json::Map::new();
record.insert("id".to_string(), serde_json::json!(id));
if let Some(v) = vector {
record.insert("vector".to_string(), serde_json::json!(v));
}
if let Some(p) = payload {
record.insert("payload".to_string(), p.clone());
}
serde_json::Value::Object(record)
}
pub fn write_export_file(records: &[serde_json::Value], filename: &str) -> Result<(), String> {
let json_str = serde_json::to_string_pretty(records)
.map_err(|e| format!("Failed to serialize records: {e}"))?;
std::fs::write(filename, json_str).map_err(|e| format!("Failed to write file: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_config_path_opens_with_core_defaults() {
let dir = tempfile::tempdir().expect("test: temp dir");
let db = open_database_with_config(dir.path(), None).expect("test: open without config");
assert_eq!(
db.config().limits.max_collections,
velesdb_core::config::LimitsConfig::default().max_collections
);
}
#[test]
fn test_custom_toml_limit_is_actually_enforced_not_just_parsed() {
let dir = tempfile::tempdir().expect("test: temp dir");
let toml_dir = tempfile::tempdir().expect("test: config dir");
let config_path = toml_dir.path().join("velesdb.toml");
std::fs::write(&config_path, "[limits]\nmax_collections = 1\n")
.expect("test: write config");
let db = open_database_with_config(dir.path(), Some(&config_path))
.expect("test: open with custom config");
assert_eq!(db.config().limits.max_collections, 1);
db.create_vector_collection_with_options(
"first",
4,
velesdb_core::DistanceMetric::Cosine,
velesdb_core::StorageMode::Full,
)
.expect("test: first collection under the limit should succeed");
let err = db
.create_vector_collection_with_options(
"second",
4,
velesdb_core::DistanceMetric::Cosine,
velesdb_core::StorageMode::Full,
)
.expect_err("test: second collection should be refused by the configured limit");
assert!(
err.to_string().contains("max_collections"),
"unexpected error: {err}"
);
}
#[test]
fn test_shell_owned_server_section_does_not_block_cli_open() {
let dir = tempfile::tempdir().expect("test: temp dir");
let toml_dir = tempfile::tempdir().expect("test: config dir");
let config_path = toml_dir.path().join("velesdb.toml");
std::fs::write(
&config_path,
"[server]\nport = 443\n\n[limits]\nmax_collections = 5\n",
)
.expect("test: write config");
let db = open_database_with_config(dir.path(), Some(&config_path))
.expect("a shell-owned [server] port=443 must not block CLI database open");
assert_eq!(db.config().limits.max_collections, 5);
}
#[test]
fn test_explicit_missing_config_path_fails_fast_no_silent_default() {
let dir = tempfile::tempdir().expect("test: temp dir");
let missing = std::path::Path::new("/nonexistent/velesdb-issue-1549.toml");
let err = match open_database_with_config(dir.path(), Some(missing)) {
Err(e) => e,
Ok(_) => panic!("test: missing explicit config path must error, not fall back"),
};
assert!(
err.to_string().contains("config file not found"),
"unexpected error: {err}"
);
}
#[test]
fn test_invalid_config_value_surfaces_typed_config_error_fail_fast() {
let dir = tempfile::tempdir().expect("test: temp dir");
let toml_dir = tempfile::tempdir().expect("test: config dir");
let config_path = toml_dir.path().join("velesdb.toml");
std::fs::write(&config_path, "[limits]\nmax_collections = 0\n")
.expect("test: write config");
let err = match open_database_with_config(dir.path(), Some(&config_path)) {
Err(e) => e,
Ok(_) => panic!("test: invalid value must fail fast, not silently default"),
};
assert!(
err.to_string().contains("limits.max_collections"),
"unexpected error: {err}"
);
}
#[test]
fn test_point_payload_to_row_with_payload() {
let payload = Some(serde_json::json!({
"title": "Hello",
"score": 0.95
}));
let row = point_payload_to_row(42, &payload);
assert_eq!(row.get("id"), Some(&serde_json::json!(42)));
assert_eq!(row.get("title"), Some(&serde_json::json!("Hello")));
assert_eq!(row.get("score"), Some(&serde_json::json!(0.95)));
assert_eq!(row.len(), 3);
}
#[test]
fn test_point_payload_to_row_without_payload() {
let row = point_payload_to_row(7, &None);
assert_eq!(row.get("id"), Some(&serde_json::json!(7)));
assert_eq!(row.len(), 1);
}
#[test]
fn test_point_payload_to_browse_row_truncates() {
let long_string = "a".repeat(80);
let payload = Some(serde_json::json!({
"content": long_string,
"short": "ok"
}));
let row = point_payload_to_browse_row(1, &payload);
assert_eq!(row.get("id"), Some(&serde_json::json!(1)));
assert_eq!(row.get("short"), Some(&serde_json::json!("ok")));
let content = row.get("content").unwrap().as_str().unwrap();
assert_eq!(content.len(), 50);
assert!(content.ends_with("..."));
}
#[test]
fn test_truncate_display_value_short_string() {
let val = serde_json::json!("short text");
let result = truncate_display_value(&val);
assert_eq!(result, serde_json::json!("short text"));
}
#[test]
fn test_truncate_display_value_long_string() {
let long = "x".repeat(100);
let result = truncate_display_value(&serde_json::json!(long));
let s = result.as_str().unwrap();
assert_eq!(s.len(), 50);
assert!(s.ends_with("..."));
assert!(s.starts_with("xxxxxxx"));
}
}