use clap::Parser;
use colored::Colorize;
use graph_d::gql::{Gql, QueryResult, QueryValue};
use graph_d::Graph;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::cell::RefCell;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "graph_d")]
#[command(version, about, long_about = None)]
#[command(author = "Graph DB Team")]
struct Args {
#[arg(default_value = ":memory:")]
database: String,
#[arg(short = 'c', long = "cmd")]
command: Option<String>,
#[arg(short = 'f', long = "file")]
script_file: Option<PathBuf>,
#[arg(short = 'o', long = "output", default_value = "table")]
output_format: OutputFormat,
#[arg(short = 'q', long = "quiet")]
quiet: bool,
#[arg(short = 'v', long = "verbose")]
verbose: bool,
}
#[derive(Debug, Clone, Copy, Default)]
enum OutputFormat {
#[default]
Table,
Json,
Csv,
}
impl std::str::FromStr for OutputFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"table" => Ok(OutputFormat::Table),
"json" => Ok(OutputFormat::Json),
"csv" => Ok(OutputFormat::Csv),
_ => Err(format!(
"Unknown output format '{}'. Use: table, json, csv",
s
)),
}
}
}
fn main() {
if let Err(e) = run() {
eprintln!("{}: {}", "Error".red().bold(), e);
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let graph = if args.database == ":memory:" {
if args.verbose {
eprintln!("{} in-memory database", "Created".green());
}
Graph::new()?
} else {
let path = validate_path(&args.database)?;
if args.verbose {
if path.exists() {
eprintln!("{} database: {}", "Opened".green(), path.display());
} else {
eprintln!("{} database: {}", "Created".green(), path.display());
}
}
Graph::open(&path)?
};
let graph = RefCell::new(graph);
let gql = Gql::new(&graph);
if let Some(cmd) = &args.command {
execute_query(&gql, cmd, args.output_format, args.quiet)?;
} else if let Some(script_path) = &args.script_file {
let validated_script_path = validate_path(script_path.to_string_lossy().as_ref())?;
let script = std::fs::read_to_string(&validated_script_path)?;
for line in script.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with("--") && !line.starts_with("//") {
execute_query(&gql, line, args.output_format, args.quiet)?;
}
}
} else {
interactive_mode(&gql, args.output_format, args.quiet, args.verbose)?;
}
Ok(())
}
fn validate_path(path_str: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let path = PathBuf::from(path_str);
let path_string = path_str.replace('\\', "/");
if path_string.contains("../") || path_string.contains("/..") || path_string == ".." {
return Err(format!("Invalid path '{}': path traversal not allowed", path_str).into());
}
if path.exists() {
return path
.canonicalize()
.map_err(|e| format!("Cannot access '{}': {}", path_str, e).into());
}
let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
let parent_canonical = if parent.as_os_str().is_empty() || parent == std::path::Path::new(".") {
std::env::current_dir()?
} else if parent.exists() {
parent.canonicalize().map_err(|e| {
format!(
"Cannot access parent directory '{}': {}",
parent.display(),
e
)
})?
} else {
return Err(format!("Parent directory '{}' does not exist", parent.display()).into());
};
let file_name = path
.file_name()
.ok_or_else(|| format!("Invalid path '{}': no file name", path_str))?;
Ok(parent_canonical.join(file_name))
}
fn interactive_mode(
gql: &Gql,
output_format: OutputFormat,
quiet: bool,
verbose: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let mut rl = DefaultEditor::new()?;
let history_path = dirs_home().map(|h| h.join(".graph_d_history"));
if let Some(ref path) = history_path {
let _ = rl.load_history(path);
}
if !quiet {
println!(
"{} {} - Type {} for help, {} to exit",
"Graph_D".cyan().bold(),
graph_d::VERSION,
".help".yellow(),
".exit".yellow()
);
println!();
}
let mut multiline_buffer = String::new();
loop {
let prompt = if multiline_buffer.is_empty() {
"graph_d> ".to_string()
} else {
" ...> ".to_string()
};
match rl.readline(&prompt) {
Ok(line) => {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with('.') && multiline_buffer.is_empty() {
if handle_meta_command(line, verbose) {
break;
}
continue;
}
multiline_buffer.push_str(line);
multiline_buffer.push(' ');
if !line.ends_with(';') {
continue;
}
let query = multiline_buffer.trim().trim_end_matches(';');
let _ = rl.add_history_entry(query);
match gql.execute(query) {
Ok(result) => {
print_result(&result, output_format);
}
Err(e) => {
eprintln!("{}: {}", "Query error".red(), e);
}
}
multiline_buffer.clear();
}
Err(ReadlineError::Interrupted) => {
if !multiline_buffer.is_empty() {
multiline_buffer.clear();
println!("^C");
} else {
println!("Use {} or {} to exit", ".exit".yellow(), "Ctrl-D".yellow());
}
}
Err(ReadlineError::Eof) => {
if !quiet {
println!("Goodbye!");
}
break;
}
Err(e) => {
eprintln!("{}: {}", "Input error".red(), e);
break;
}
}
}
if let Some(ref path) = history_path {
let _ = rl.save_history(path);
}
Ok(())
}
fn handle_meta_command(cmd: &str, verbose: bool) -> bool {
let parts: Vec<&str> = cmd.split_whitespace().collect();
let command = parts.first().map(|s| s.to_lowercase()).unwrap_or_default();
match command.as_str() {
".exit" | ".quit" | ".q" => {
println!("Goodbye!");
return true;
}
".help" | ".h" | ".?" => {
print_help();
}
".tables" => {
println!(
"Graph databases don't have tables. Use {} to see nodes.",
"MATCH (n) RETURN n".cyan()
);
}
".schema" => {
println!(
"Schema-less database. Nodes and relationships can have arbitrary JSON properties."
);
}
".stats" => {
println!("Database statistics: (use MATCH queries to explore data)");
}
".mode" => {
if parts.len() > 1 {
println!("Output mode: {} (change with -o flag)", parts[1]);
} else {
println!("Output modes: table, json, csv");
}
}
".verbose" => {
println!("Verbose mode: {}", if verbose { "on" } else { "off" });
}
_ => {
eprintln!(
"{}: Unknown command '{}'. Type {} for help.",
"Error".red(),
command.yellow(),
".help".cyan()
);
}
}
false
}
fn print_help() {
println!("{}", "Graph_D Shell Commands".cyan().bold());
println!();
println!(" {} Show this help message", ".help".yellow());
println!(" {} Exit the shell", ".exit".yellow());
println!(" {} Show available output modes", ".mode".yellow());
println!(" {} Database statistics", ".stats".yellow());
println!();
println!("{}", "GQL Query Examples".cyan().bold());
println!();
println!(
" {} Create a labeled node",
"CREATE (n:Person {name: 'Alice'})".green()
);
println!(
" {} Return all nodes",
"MATCH (n) RETURN n".green()
);
println!(
" {} Filter by property",
"MATCH (n:Person) WHERE n.age > 25 RETURN n".green()
);
println!(
" {} Find relationships",
"MATCH (a)-[r:KNOWS]->(b) RETURN a, r, b".green()
);
println!();
println!("{}", "Tips".cyan().bold());
println!();
println!(" • Queries can span multiple lines (end with ;)");
println!(" • Use {} to interrupt current input", "Ctrl-C".yellow());
println!(
" • Use {} or {} to exit",
"Ctrl-D".yellow(),
".exit".yellow()
);
println!();
}
fn execute_query(
gql: &Gql,
query: &str,
output_format: OutputFormat,
quiet: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let query = query.trim().trim_end_matches(';');
match gql.execute(query) {
Ok(result) => {
print_result(&result, output_format);
if !quiet {
let row_count = result.rows.len();
eprintln!(
"{} row{} returned",
row_count,
if row_count == 1 { "" } else { "s" }
);
}
Ok(())
}
Err(e) => Err(format!("Query failed: {}", e).into()),
}
}
fn print_result(result: &QueryResult, format: OutputFormat) {
if result.rows.is_empty() {
return;
}
match format {
OutputFormat::Table => print_table(result),
OutputFormat::Json => print_json(result),
OutputFormat::Csv => print_csv(result),
}
}
fn print_table(result: &QueryResult) {
let mut table_data: Vec<Vec<String>> = Vec::new();
table_data.push(result.columns.clone());
for row in &result.rows {
let string_row: Vec<String> = row.iter().map(format_value).collect();
table_data.push(string_row);
}
if !table_data.is_empty() {
let col_widths: Vec<usize> = (0..table_data[0].len())
.map(|col| {
table_data
.iter()
.map(|row| row.get(col).map(|s| s.len()).unwrap_or(0))
.max()
.unwrap_or(0)
})
.collect();
let header: Vec<String> = table_data[0]
.iter()
.enumerate()
.map(|(i, h)| format!("{:width$}", h, width = col_widths[i]))
.collect();
println!("{}", header.join(" | ").cyan());
let separator: Vec<String> = col_widths.iter().map(|w| "-".repeat(*w)).collect();
println!("{}", separator.join("-+-"));
for row in table_data.iter().skip(1) {
let formatted: Vec<String> = row
.iter()
.enumerate()
.map(|(i, v)| format!("{:width$}", v, width = col_widths[i]))
.collect();
println!("{}", formatted.join(" | "));
}
}
}
fn print_json(result: &QueryResult) {
let json_rows: Vec<serde_json::Value> = result
.rows
.iter()
.map(|row| {
let obj: serde_json::Map<String, serde_json::Value> = result
.columns
.iter()
.zip(row.iter())
.map(|(col, val)| (col.clone(), value_to_json(val)))
.collect();
serde_json::Value::Object(obj)
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json_rows).unwrap_or_default()
);
}
fn print_csv(result: &QueryResult) {
println!("{}", result.columns.join(","));
for row in &result.rows {
let values: Vec<String> = row.iter().map(|v| escape_csv(&format_value(v))).collect();
println!("{}", values.join(","));
}
}
fn format_value(value: &QueryValue) -> String {
match value {
QueryValue::Null => "NULL".to_string(),
QueryValue::Boolean(b) => b.to_string(),
QueryValue::Integer(i) => i.to_string(),
QueryValue::Float(f) => f.to_string(),
QueryValue::String(s) => s.clone(),
QueryValue::Node {
id,
labels,
properties,
} => {
let labels_str = if labels.is_empty() {
String::new()
} else {
format!(":{}", labels.join(":"))
};
let props_str = if properties.is_empty() {
String::new()
} else {
format!(" {}", serde_json::to_string(properties).unwrap_or_default())
};
format!("({}{}{})", id, labels_str, props_str)
}
QueryValue::Relationship {
id,
from_id,
to_id,
rel_type,
properties,
} => {
let props_str = if properties.is_empty() {
String::new()
} else {
format!(" {}", serde_json::to_string(properties).unwrap_or_default())
};
format!(
"[{}:{}]-({} -> {}){}",
id, rel_type, from_id, to_id, props_str
)
}
QueryValue::Path(values) => {
let path_str: Vec<String> = values.iter().map(format_value).collect();
path_str.join(" -> ")
}
QueryValue::List(values) => {
let list_str: Vec<String> = values.iter().map(format_value).collect();
format!("[{}]", list_str.join(", "))
}
}
}
fn value_to_json(value: &QueryValue) -> serde_json::Value {
match value {
QueryValue::Null => serde_json::Value::Null,
QueryValue::Boolean(b) => serde_json::Value::Bool(*b),
QueryValue::Integer(i) => serde_json::Value::Number((*i).into()),
QueryValue::Float(f) => serde_json::json!(*f),
QueryValue::String(s) => serde_json::Value::String(s.clone()),
QueryValue::Node {
id,
labels,
properties,
} => {
serde_json::json!({
"_type": "node",
"id": id,
"labels": labels,
"properties": properties
})
}
QueryValue::Relationship {
id,
from_id,
to_id,
rel_type,
properties,
} => {
serde_json::json!({
"_type": "relationship",
"id": id,
"from": from_id,
"to": to_id,
"type": rel_type,
"properties": properties
})
}
QueryValue::Path(values) => {
serde_json::Value::Array(values.iter().map(value_to_json).collect())
}
QueryValue::List(values) => {
serde_json::Value::Array(values.iter().map(value_to_json).collect())
}
}
}
fn escape_csv(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
fn dirs_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_validate_path_simple_filename() {
let result = validate_path("test.db");
assert!(result.is_ok(), "Simple filename should be valid");
let path = result.unwrap();
assert!(path.is_absolute(), "Result should be absolute path");
assert!(path.ends_with("test.db"), "Should preserve filename");
}
#[test]
fn test_validate_path_rejects_dotdot_slash() {
let result = validate_path("../etc/passwd");
assert!(result.is_err(), "Path with ../ should be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("path traversal"),
"Error should mention traversal"
);
}
#[test]
fn test_validate_path_rejects_slash_dotdot() {
let result = validate_path("/tmp/foo/..");
assert!(result.is_err(), "Path with /.. should be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("path traversal"),
"Error should mention traversal"
);
}
#[test]
fn test_validate_path_rejects_standalone_dotdot() {
let result = validate_path("..");
assert!(result.is_err(), "Standalone .. should be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("path traversal"),
"Error should mention traversal"
);
}
#[test]
fn test_validate_path_rejects_backslash_traversal() {
let result = validate_path("..\\etc\\passwd");
assert!(result.is_err(), "Backslash traversal should be rejected");
}
#[test]
fn test_validate_path_rejects_nonexistent_parent() {
let result = validate_path("/nonexistent_dir_12345/test.db");
assert!(result.is_err(), "Non-existent parent should be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("does not exist") || err.contains("Cannot access"),
"Error should indicate missing directory"
);
}
#[test]
fn test_validate_path_canonicalizes_existing() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("existing.db");
fs::write(&test_file, "test").unwrap();
let result = validate_path(test_file.to_str().unwrap());
assert!(result.is_ok(), "Existing file should be valid");
let path = result.unwrap();
assert!(path.is_absolute(), "Result should be absolute");
}
#[test]
fn test_validate_path_new_file_existing_dir() {
let temp_dir = TempDir::new().unwrap();
let new_file = temp_dir.path().join("new.db");
let result = validate_path(new_file.to_str().unwrap());
assert!(result.is_ok(), "New file in existing dir should be valid");
let path = result.unwrap();
assert!(path.is_absolute(), "Result should be absolute");
assert!(path.ends_with("new.db"), "Should preserve filename");
}
#[test]
fn test_validate_path_hidden_traversal() {
assert!(validate_path("foo/../bar").is_err());
assert!(validate_path("./foo/../../../etc/passwd").is_err());
assert!(validate_path("a/b/c/../../../x").is_err());
}
#[test]
fn test_validate_path_allows_valid_dots() {
let result = validate_path("./test.db");
if let Err(e) = &result {
assert!(
!e.to_string().contains("path traversal"),
"Single dot should not be flagged as traversal"
);
}
let result = validate_path("test.backup.db");
assert!(result.is_ok(), "Dots in filename should be allowed");
let result = validate_path(".hidden.db");
assert!(result.is_ok(), "Hidden files should be allowed");
}
}