#[cfg(feature = "repl")]
fn main() -> Result<(), Box<dyn std::error::Error>> {
use minigdb::query_capturing;
use rustyline::{error::ReadlineError, DefaultEditor};
env_logger::init();
#[cfg(feature = "server")]
{
let args: Vec<String> = std::env::args().skip(1).collect();
match args.first().map(|s| s.as_str()) {
Some("serve") => return serve_main(&args[1..]),
Some("adduser") => return adduser_main(&args[1..]),
Some("passwd") => return passwd_main(&args[1..]),
Some("users") => return users_main(),
_ => {}
}
}
let data_root = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("minigdb");
let graphs_dir = data_root.join("graphs");
std::fs::create_dir_all(&graphs_dir)?;
let mut current_graph_name = String::from("default");
let (mut storage, mut graph) = open_graph(&graphs_dir, ¤t_graph_name)
.map_err(|e| format!("Failed to open default graph: {e}"))?;
let mut next_txn_id: u64 = 0;
println!("minigdb v{}", env!("CARGO_PKG_VERSION"));
println!("Data: {}", data_root.display());
println!("Type GQL queries (end with ';'), or :quit / :exit to exit.");
println!("Transaction commands: BEGIN, COMMIT, ROLLBACK");
println!("Graph commands: :graphs :create <name> :use <name> :drop <name> :clear");
println!();
let mut rl = DefaultEditor::new()?;
let history_path = data_root.join(".minigdb_history");
let history_path = history_path.to_str().unwrap_or(".minigdb_history");
let _ = rl.load_history(history_path);
let mut pending = String::new();
loop {
let prompt = match (graph.is_in_transaction(), pending.is_empty()) {
(true, true) => format!("txn({})> ", current_graph_name),
(true, false) => format!("txn({})-> ", current_graph_name),
(false, true) => format!("{}> ", current_graph_name),
(false, false) => format!("{}-> ", current_graph_name),
};
let readline = rl.readline(&prompt);
match readline {
Ok(line) => {
let trimmed = line.trim();
if trimmed.eq_ignore_ascii_case(":quit")
|| trimmed.eq_ignore_ascii_case(":q")
|| trimmed.eq_ignore_ascii_case(":exit")
|| trimmed.eq_ignore_ascii_case("exit")
|| trimmed.eq_ignore_ascii_case("quit")
{
println!("Goodbye.");
break;
}
if trimmed.eq_ignore_ascii_case(":checkpoint") {
match storage.checkpoint(&graph) {
Ok(_) => println!("Checkpoint written."),
Err(e) => eprintln!("Error: {e}"),
}
rl.add_history_entry(trimmed)?;
continue;
}
let is_graph_cmd = trimmed.starts_with(':')
&& !trimmed.eq_ignore_ascii_case(":quit")
&& !trimmed.eq_ignore_ascii_case(":q")
&& !trimmed.eq_ignore_ascii_case(":exit")
&& !trimmed.eq_ignore_ascii_case("exit")
&& !trimmed.eq_ignore_ascii_case("quit")
&& !trimmed.eq_ignore_ascii_case(":checkpoint");
if is_graph_cmd {
let cmd = trimmed.trim_end_matches(';').trim();
if graph.is_in_transaction() {
eprintln!("Error: graph management commands are not allowed inside a transaction (COMMIT or ROLLBACK first)");
rl.add_history_entry(trimmed)?;
continue;
}
if cmd.eq_ignore_ascii_case(":graphs") {
let names = list_graph_names(&graphs_dir);
if names.is_empty() {
println!("(no graphs)");
} else {
for name in &names {
if name == ¤t_graph_name {
println!(" {} (active)", name);
} else {
println!(" {}", name);
}
}
}
} else if let Some(rest) = cmd.strip_prefix(":create ").or_else(|| cmd.strip_prefix(":CREATE ")) {
let name = rest.trim();
if !validate_graph_name(name) {
eprintln!("Error: invalid graph name '{}' (use alphanumeric, '_', '-', max 64 chars)", name);
} else {
if let Err(e) = storage.checkpoint(&graph) {
eprintln!("Warning: could not checkpoint current graph: {e}");
}
match open_graph(&graphs_dir, name) {
Ok((new_storage, new_graph)) => {
storage = new_storage;
graph = new_graph;
current_graph_name = name.to_string();
println!("Created and switched to graph '{}'.", name);
}
Err(e) => eprintln!("Error creating graph '{}': {}", name, e),
}
}
} else if let Some(rest) = cmd.strip_prefix(":use ").or_else(|| cmd.strip_prefix(":USE ")) {
let name = rest.trim();
if !validate_graph_name(name) {
eprintln!("Error: invalid graph name '{}'", name);
} else {
let graph_path = graphs_dir.join(name);
if !graph_path.exists() {
eprintln!("Error: graph '{}' does not exist (use :create to create it)", name);
} else {
if let Err(e) = storage.checkpoint(&graph) {
eprintln!("Warning: could not checkpoint current graph: {e}");
}
match open_graph(&graphs_dir, name) {
Ok((new_storage, new_graph)) => {
storage = new_storage;
graph = new_graph;
current_graph_name = name.to_string();
println!("Switched to graph '{}'.", name);
}
Err(e) => eprintln!("Error opening graph '{}': {}", name, e),
}
}
}
} else if let Some(rest) = cmd.strip_prefix(":drop ").or_else(|| cmd.strip_prefix(":DROP ")) {
let name = rest.trim();
if !validate_graph_name(name) {
eprintln!("Error: invalid graph name '{}'", name);
} else if name == current_graph_name {
eprintln!("Error: cannot drop the active graph (switch to another graph first)");
} else {
let graph_path = graphs_dir.join(name);
if !graph_path.exists() {
eprintln!("Error: graph '{}' does not exist", name);
} else {
match std::fs::remove_dir_all(&graph_path) {
Ok(_) => println!("Dropped graph '{}'.", name),
Err(e) => eprintln!("Error dropping graph '{}': {}", name, e),
}
}
}
} else if cmd.eq_ignore_ascii_case(":clear") {
if graph.is_in_transaction() {
eprintln!("Error: cannot :clear inside a transaction.");
} else {
match graph.clear() {
Ok(_) => println!("Graph cleared."),
Err(e) => eprintln!("Error: {e}"),
}
}
} else {
eprintln!("Unknown command: '{}'. Type :graphs, :create <name>, :use <name>, :drop <name>, :clear.", cmd);
}
rl.add_history_entry(trimmed)?;
continue;
}
let upper = trimmed.to_uppercase();
let bare = upper.trim_end_matches(';').trim();
if bare == "BEGIN" {
match graph.begin_transaction() {
Ok(_) => println!("Transaction started."),
Err(e) => eprintln!("Error: {e}"),
}
rl.add_history_entry(trimmed)?;
continue;
}
if bare == "COMMIT" {
match graph.commit_transaction() {
Ok(_) => println!("Transaction committed."),
Err(e) => eprintln!("Error: {e}"),
}
rl.add_history_entry(trimmed)?;
continue;
}
if bare == "ROLLBACK" {
match graph.rollback_transaction() {
Ok(_) => println!("Transaction rolled back."),
Err(e) => eprintln!("Error: {e}"),
}
rl.add_history_entry(trimmed)?;
continue;
}
if !trimmed.is_empty() {
rl.add_history_entry(trimmed)?;
}
pending.push_str(trimmed);
pending.push(' ');
let ready = pending.trim().ends_with(';')
|| (!pending.trim().is_empty()
&& !pending.trim().ends_with(',')
&& is_complete_statement(pending.trim()));
if ready {
let input = pending.trim().to_string();
pending.clear();
match query_capturing(&input, &mut graph, &mut next_txn_id) {
Ok((rows, _ops)) => {
if rows.is_empty() {
println!("(no results)");
} else if rows.len() == 1
&& rows[0].len() == 1
&& rows[0].contains_key("result")
{
if let Some(minigdb::Value::String(msg)) = rows[0].get("result") {
println!("{msg}");
}
} else {
print_rows(&rows);
}
}
Err(e) => eprintln!("Error: {e}"),
}
}
}
Err(ReadlineError::Interrupted) => {
pending.clear();
println!("(interrupted)");
}
Err(ReadlineError::Eof) => {
println!("Goodbye.");
break;
}
Err(e) => {
eprintln!("Readline error: {e}");
break;
}
}
}
let _ = rl.save_history(history_path);
if let Err(e) = storage.checkpoint(&graph) {
eprintln!("Warning: could not write final checkpoint: {e}");
}
Ok(())
}
#[cfg(feature = "repl")]
fn open_graph(
graphs_dir: &std::path::Path,
name: &str,
) -> Result<(minigdb::StorageManager, minigdb::Graph), minigdb::DbError> {
let graph_path = graphs_dir.join(name);
std::fs::create_dir_all(&graph_path)
.map_err(minigdb::DbError::Storage)?;
minigdb::StorageManager::open(&graph_path)
}
#[cfg(any(feature = "repl", test))]
fn list_graph_names(graphs_dir: &std::path::Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(graphs_dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.filter_map(|e| {
let e = e.ok()?;
if e.file_type().ok()?.is_dir() {
let name = e.file_name().into_string().ok()?;
if name.starts_with('_') { None } else { Some(name) }
} else {
None
}
})
.collect();
names.sort();
names
}
#[cfg(any(feature = "repl", test))]
fn validate_graph_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& !name.starts_with('_') && name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
fn is_complete_statement(s: &str) -> bool {
if s.starts_with(':') {
return true;
}
let upper = s.to_uppercase();
if upper.contains("RETURN") {
return true;
}
if upper.starts_with("INSERT") || upper.starts_with("DELETE") || upper.starts_with("DETACH") {
return true;
}
if upper.starts_with("UNWIND") {
return upper.contains("RETURN") || upper.contains("INSERT");
}
if upper.starts_with("OPTIONAL") {
return upper.contains("RETURN");
}
if upper.starts_with("SHOW") {
return true;
}
if upper.starts_with("CREATE INDEX") || upper.starts_with("DROP INDEX") {
return upper.contains(')');
}
if upper.starts_with("CREATE CONSTRAINT") || upper.starts_with("DROP CONSTRAINT") {
return upper.contains(')');
}
if upper.starts_with("SHOW CONSTRAINTS") {
return true;
}
if upper.starts_with("TRUNCATE") {
return true;
}
if upper.starts_with("LOAD") {
return true;
}
if upper.starts_with("CALL") {
return upper.contains(')');
}
if upper.starts_with("MATCH") {
return upper.contains(" SET ") || upper.contains(" REMOVE ")
|| upper.contains(" DELETE ") || upper.contains(" DETACH ")
|| upper.contains(" INSERT ");
}
false
}
fn print_rows(rows: &[minigdb::Row]) {
if rows.is_empty() {
return;
}
let mut cols: Vec<String> = rows[0].keys().cloned().collect();
cols.sort();
let col_widths: Vec<usize> = cols
.iter()
.map(|c| {
let header_w = c.len();
let max_val_w = rows
.iter()
.map(|r| r.get(c).map(|v| format!("{v}").len()).unwrap_or(4))
.max()
.unwrap_or(4);
header_w.max(max_val_w)
})
.collect();
print_separator(&col_widths);
print!("| ");
for (i, col) in cols.iter().enumerate() {
print!("{:width$} | ", col, width = col_widths[i]);
}
println!();
print_separator(&col_widths);
for row in rows {
print!("| ");
for (i, col) in cols.iter().enumerate() {
let val = row
.get(col)
.map(|v| format!("{v}"))
.unwrap_or_else(|| "null".to_string());
print!("{:width$} | ", val, width = col_widths[i]);
}
println!();
}
print_separator(&col_widths);
println!("{} row(s)", rows.len());
}
fn print_separator(widths: &[usize]) {
print!("+");
for w in widths {
print!("{}", "-".repeat(w + 2));
print!("+");
}
println!();
}
#[cfg(all(feature = "repl", feature = "server"))]
fn serve_main(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let mut host = String::from("127.0.0.1");
let mut port: u16 = 7474;
let mut no_auth = false;
let mut gui_port: Option<u16> = Some(7475);
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--host" => {
i += 1;
host = args.get(i).cloned().ok_or("--host requires a value")?;
}
"--port" | "-p" => {
i += 1;
port = args
.get(i)
.and_then(|s| s.parse().ok())
.ok_or("--port requires a numeric value")?;
}
"--no-auth" => no_auth = true,
"--no-gui" => gui_port = None,
"--gui-port" => {
i += 1;
gui_port = Some(
args.get(i)
.and_then(|s| s.parse().ok())
.ok_or("--gui-port requires a numeric value")?,
);
}
"--help" | "-h" => {
eprintln!(
"Usage: minigdb serve [--host <addr>] [--port <port>] [--no-auth] [--gui-port <port>] [--no-gui]\n\
\n\
Options:\n\
--host Bind address (default: 127.0.0.1)\n\
--port, -p TCP port (default: 7474)\n\
--no-auth Disable authentication\n\
--gui-port HTTP GUI port (default: 7475)\n\
--no-gui Disable the web GUI\n\
\n\
User management:\n\
minigdb adduser <name> Add a new user\n\
minigdb passwd <name> Change a user's password\n\
minigdb users List all users\n\
\n\
Protocol v2: newline-delimited JSON over TCP.\n\
Server sends hello on connect, then client may auth, then queries.\n\
Request: {{\"id\":1,\"query\":\"MATCH (n) RETURN n\"}}\n\
Response: {{\"id\":1,\"rows\":[...],\"elapsed_ms\":0.3}}"
);
return Ok(());
}
other => {
return Err(format!("Unknown argument: {other} (try --help)").into());
}
}
i += 1;
}
let data_root = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("minigdb");
let graphs_dir = data_root.join("graphs");
std::fs::create_dir_all(&graphs_dir)?;
let mut config = minigdb::server::auth::ServerConfig::load(&data_root);
if no_auth {
config.server.auth_required = false;
}
let addr: std::net::SocketAddr = format!("{host}:{port}").parse()?;
let gui_addr: Option<std::net::SocketAddr> = gui_port
.map(|p| format!("{host}:{p}").parse())
.transpose()?;
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(minigdb::server::serve(config, graphs_dir, addr, gui_addr))?;
Ok(())
}
#[cfg(all(feature = "repl", feature = "server"))]
fn adduser_main(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let name = args.first().ok_or("Usage: minigdb adduser <name>")?;
if !validate_graph_name(name) {
return Err(format!("Invalid username '{name}' (alphanumeric, '_', '-', max 64 chars)").into());
}
let data_root = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("minigdb");
std::fs::create_dir_all(&data_root)?;
let mut config = minigdb::server::auth::ServerConfig::load(&data_root);
if config.find_user(name).is_some() {
return Err(format!("User '{name}' already exists (use 'passwd' to change password)").into());
}
let password = rpassword::prompt_password(format!("Password for '{name}': "))?;
let confirm = rpassword::prompt_password("Confirm password: ")?;
if password != confirm {
return Err("Passwords do not match.".into());
}
print!("Allowed graphs (comma-separated, or * for all) [*]: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut graphs_input = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut graphs_input)?;
let graphs_input = graphs_input.trim();
let graphs: Vec<String> = if graphs_input.is_empty() || graphs_input == "*" {
vec!["*".to_string()]
} else {
graphs_input.split(',').map(|s| s.trim().to_string()).collect()
};
config.users.push(minigdb::server::auth::UserEntry {
name: name.clone(),
password_hash: minigdb::server::auth::hash_password(&password),
graphs,
});
config.save(&data_root)?;
println!("User '{name}' added.");
Ok(())
}
#[cfg(all(feature = "repl", feature = "server"))]
fn passwd_main(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let name = args.first().ok_or("Usage: minigdb passwd <name>")?;
let data_root = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("minigdb");
let mut config = minigdb::server::auth::ServerConfig::load(&data_root);
if config.find_user(name).is_none() {
return Err(format!("User '{name}' does not exist.").into());
}
let password = rpassword::prompt_password(format!("New password for '{name}': "))?;
let confirm = rpassword::prompt_password("Confirm new password: ")?;
if password != confirm {
return Err("Passwords do not match.".into());
}
if let Some(entry) = config.find_user_mut(name) {
entry.password_hash = minigdb::server::auth::hash_password(&password);
}
config.save(&data_root)?;
println!("Password updated for '{name}'.");
Ok(())
}
#[cfg(all(feature = "repl", feature = "server"))]
fn users_main() -> Result<(), Box<dyn std::error::Error>> {
let data_root = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("minigdb");
let config = minigdb::server::auth::ServerConfig::load(&data_root);
if config.users.is_empty() {
println!("No users configured.");
println!("Run 'minigdb adduser <name>' to add one.");
} else {
println!("{:<24} {}", "User", "Graphs");
println!("{}", "-".repeat(48));
for u in &config.users {
println!("{:<24} {}", u.name, u.graphs.join(", "));
}
}
Ok(())
}
#[cfg(not(feature = "repl"))]
fn main() {
eprintln!("Built without 'repl' feature.");
std::process::exit(1);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn validate_graph_name_accepts_simple_names() {
assert!(validate_graph_name("default"));
assert!(validate_graph_name("myproject"));
assert!(validate_graph_name("my-project"));
assert!(validate_graph_name("my_project"));
assert!(validate_graph_name("Graph1"));
assert!(validate_graph_name("a"));
}
#[test]
fn validate_graph_name_rejects_empty() {
assert!(!validate_graph_name(""));
}
#[test]
fn validate_graph_name_rejects_too_long() {
let long = "a".repeat(65);
assert!(!validate_graph_name(&long));
let exact = "a".repeat(64);
assert!(validate_graph_name(&exact));
}
#[test]
fn validate_graph_name_rejects_bad_chars() {
assert!(!validate_graph_name("my graph")); assert!(!validate_graph_name("my/graph")); assert!(!validate_graph_name("my.graph")); assert!(!validate_graph_name("my:graph")); assert!(!validate_graph_name("../evil")); }
#[test]
fn list_graph_names_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let names = list_graph_names(dir.path());
assert!(names.is_empty());
}
#[test]
fn list_graph_names_lists_subdirs_sorted() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("zebra")).unwrap();
fs::create_dir(dir.path().join("alpha")).unwrap();
fs::create_dir(dir.path().join("beta")).unwrap();
fs::write(dir.path().join("notadir.txt"), b"").unwrap();
let names = list_graph_names(dir.path());
assert_eq!(names, vec!["alpha", "beta", "zebra"]);
}
#[test]
fn list_graph_names_ignores_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("myfile"), b"").unwrap();
let names = list_graph_names(dir.path());
assert!(names.is_empty());
}
#[test]
fn validate_graph_name_rejects_underscore_prefix() {
assert!(!validate_graph_name("_meta"));
assert!(!validate_graph_name("_internal"));
assert!(!validate_graph_name("_"));
assert!(validate_graph_name("my_graph"));
assert!(validate_graph_name("a_b_c"));
}
#[test]
fn list_graph_names_filters_system_graphs() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("default")).unwrap();
fs::create_dir(dir.path().join("analytics")).unwrap();
fs::create_dir(dir.path().join("_meta")).unwrap();
fs::create_dir(dir.path().join("_internal")).unwrap();
let names = list_graph_names(dir.path());
assert_eq!(names, vec!["analytics", "default"]);
assert!(!names.iter().any(|n| n.starts_with('_')));
}
#[test]
fn is_complete_for_colon_commands() {
assert!(is_complete_statement(":graphs"));
assert!(is_complete_statement(":create myproject"));
assert!(is_complete_statement(":use default"));
assert!(is_complete_statement(":drop analytics"));
assert!(is_complete_statement(":checkpoint"));
assert!(is_complete_statement(":quit"));
}
#[test]
fn is_complete_for_load_csv() {
assert!(is_complete_statement("LOAD CSV NODES FROM 'people.csv'"));
assert!(is_complete_statement("LOAD CSV NODES FROM 'people.csv' LABEL Person"));
assert!(is_complete_statement("LOAD CSV EDGES FROM 'knows.csv' LABEL KNOWS"));
assert!(!is_complete_statement("MATCH (n:Person)"));
}
}