use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::database::Database;
use crate::error::{GrumpyError, Result};
use crate::naming::validate_name;
pub struct Client {
name: String,
path: PathBuf,
databases: HashMap<String, Database>,
}
impl Client {
pub fn open(path: &Path, name: &str) -> Result<Self> {
std::fs::create_dir_all(path)?;
let mut databases = HashMap::new();
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let ft = entry.file_type()?;
if ft.is_dir() {
let db_name = entry.file_name().to_string_lossy().to_string();
if db_name.starts_with('.') {
continue;
}
let db_path = entry.path();
if db_path.join("wal.log").exists() || has_collection_subdirs(&db_path) {
let db = Database::open(&db_path)?;
databases.insert(db_name, db);
}
}
}
}
Ok(Self {
name: name.to_string(),
path: path.to_path_buf(),
databases,
})
}
pub fn create_database(&mut self, name: &str) -> Result<()> {
validate_name(name)?;
if self.databases.contains_key(name) {
return Err(GrumpyError::DatabaseNotFound(format!(
"database '{name}' already exists"
)));
}
let db_path = self.path.join(name);
let db = Database::open(&db_path)?;
self.databases.insert(name.to_string(), db);
Ok(())
}
pub fn drop_database(&mut self, name: &str) -> Result<()> {
let db = self
.databases
.remove(name)
.ok_or_else(|| GrumpyError::DatabaseNotFound(name.into()))?;
let db_path = db.path().to_path_buf();
drop(db);
std::fs::remove_dir_all(&db_path)?;
Ok(())
}
pub fn database(&mut self, name: &str) -> Result<&mut Database> {
if !self.databases.contains_key(name) {
let db_path = self.path.join(name);
if db_path.exists() {
let db = Database::open(&db_path)?;
self.databases.insert(name.to_string(), db);
} else {
return Err(GrumpyError::DatabaseNotFound(name.into()));
}
}
self.databases
.get_mut(name)
.ok_or_else(|| GrumpyError::DatabaseNotFound(name.into()))
}
pub fn list_databases(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.databases.keys().map(|s| s.as_str()).collect();
names.sort();
names
}
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn close(mut self) -> Result<()> {
for (_, db) in self.databases.drain() {
db.close()?;
}
Ok(())
}
}
fn has_collection_subdirs(path: &Path) -> bool {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|ft| ft.is_dir())
&& entry.path().join("data.db").exists()
{
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, Client) {
let dir = TempDir::new().unwrap();
let client = Client::open(dir.path().join("alice").as_path(), "alice").unwrap();
(dir, client)
}
#[test]
fn test_client_open_creates_dir() {
let dir = TempDir::new().unwrap();
let client_path = dir.path().join("newclient");
let _client = Client::open(&client_path, "newclient").unwrap();
assert!(client_path.exists());
}
#[test]
fn test_client_name() {
let (_dir, client) = setup();
assert_eq!(client.name(), "alice");
}
#[test]
fn test_create_and_list_databases() {
let (_dir, mut client) = setup();
assert!(client.list_databases().is_empty());
client.create_database("myapp").unwrap();
client.create_database("staging").unwrap();
let dbs = client.list_databases();
assert_eq!(dbs, vec!["myapp", "staging"]);
}
#[test]
fn test_drop_database() {
let (_dir, mut client) = setup();
client.create_database("temp").unwrap();
let db = client.database("temp").unwrap();
db.create_collection("items").unwrap();
client.drop_database("temp").unwrap();
assert!(client.list_databases().is_empty());
assert!(client.database("temp").is_err());
}
#[test]
fn test_drop_nonexistent_database() {
let (_dir, mut client) = setup();
assert!(client.drop_database("nope").is_err());
}
#[test]
fn test_database_access() {
let (_dir, mut client) = setup();
client.create_database("mydb").unwrap();
let db = client.database("mydb").unwrap();
db.create_collection("users").unwrap();
assert_eq!(db.list_collections(), vec!["users"]);
}
#[test]
fn test_database_isolation() {
let (_dir, mut client) = setup();
client.create_database("db1").unwrap();
client.create_database("db2").unwrap();
{
let db1 = client.database("db1").unwrap();
db1.create_collection("users").unwrap();
}
{
let db2 = client.database("db2").unwrap();
db2.create_collection("tasks").unwrap();
}
assert_eq!(
client.database("db1").unwrap().list_collections(),
vec!["users"]
);
assert_eq!(
client.database("db2").unwrap().list_collections(),
vec!["tasks"]
);
}
#[test]
fn test_persistence_across_reopen() {
let dir = TempDir::new().unwrap();
let client_path = dir.path().join("persistent");
{
let mut client = Client::open(&client_path, "persistent").unwrap();
client.create_database("mydb").unwrap();
let db = client.database("mydb").unwrap();
db.create_collection("items").unwrap();
db.insert("items", uuid::Uuid::from_u128(1), crate::Value::Integer(42))
.unwrap();
client.close().unwrap();
}
{
let mut client = Client::open(&client_path, "persistent").unwrap();
assert_eq!(client.list_databases(), vec!["mydb"]);
let db = client.database("mydb").unwrap();
assert_eq!(db.list_collections(), vec!["items"]);
let val = db.get("items", &uuid::Uuid::from_u128(1)).unwrap();
assert_eq!(val, Some(crate::Value::Integer(42)));
}
}
#[test]
fn test_lazy_open() {
let dir = TempDir::new().unwrap();
let client_path = dir.path().join("lazy");
{
let mut client = Client::open(&client_path, "lazy").unwrap();
client.create_database("existing").unwrap();
client.close().unwrap();
}
let mut client = Client::open(&client_path, "lazy").unwrap();
assert!(client.database("existing").is_ok());
}
#[test]
fn test_invalid_database_name() {
let (_dir, mut client) = setup();
assert!(client.create_database("Bad-Name").is_err());
assert!(client.create_database("").is_err());
}
}