use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
fn parse_shell(value: &str) -> Result<Shell, String> {
match value {
"bash" => Ok(Shell::Bash),
"zsh" => Ok(Shell::Zsh),
"fish" => Ok(Shell::Fish),
"pwsh" | "powershell" => Ok(Shell::PowerShell),
_ => Err(format!(
"Unsupported shell: {}. Use bash, zsh, fish, or pwsh.",
value
)),
}
}
#[derive(Parser, Debug)]
#[command(name = "alopex")]
#[command(version, about, long_about = None)]
pub struct Cli {
#[arg(long)]
pub data_dir: Option<String>,
#[arg(long)]
pub profile: Option<String>,
#[arg(long, conflicts_with = "data_dir")]
pub in_memory: bool,
#[arg(long, value_enum)]
pub output: Option<OutputFormat>,
#[arg(long)]
pub limit: Option<usize>,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub verbose: bool,
#[arg(long)]
pub insecure: bool,
#[arg(long, value_enum, default_value = "multi")]
pub thread_mode: ThreadMode,
#[arg(long, short = 'b')]
pub batch: bool,
#[arg(long)]
pub yes: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
Jsonl,
Csv,
Tsv,
}
impl OutputFormat {
#[allow(dead_code)]
pub fn supports_streaming(&self) -> bool {
matches!(self, Self::Json | Self::Jsonl | Self::Csv | Self::Tsv)
}
}
impl Cli {
pub fn output_format(&self) -> OutputFormat {
self.output.unwrap_or(OutputFormat::Table)
}
pub fn output_is_explicit(&self) -> bool {
self.output.is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ThreadMode {
Multi,
Single,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Profile {
#[command(subcommand)]
command: Option<ProfileCommand>,
},
Kv {
#[command(subcommand)]
command: Option<KvCommand>,
},
Sql(SqlCommand),
Vector {
#[command(subcommand)]
command: Option<VectorCommand>,
},
Hnsw {
#[command(subcommand)]
command: Option<HnswCommand>,
},
Columnar {
#[command(subcommand)]
command: Option<ColumnarCommand>,
},
Server {
#[command(subcommand)]
command: Option<ServerCommand>,
},
Lifecycle {
#[command(subcommand)]
command: Option<LifecycleCommand>,
},
Version,
Completions {
#[arg(value_parser = parse_shell, value_name = "SHELL")]
shell: Shell,
},
}
#[derive(Subcommand, Debug, Clone)]
pub enum ProfileCommand {
Create {
name: String,
#[arg(long)]
data_dir: String,
},
List,
Show {
name: String,
},
Delete {
name: String,
},
SetDefault {
name: String,
},
}
#[derive(Subcommand, Debug)]
pub enum KvCommand {
Get {
key: String,
},
Put {
key: String,
value: String,
},
Delete {
key: String,
},
List {
#[arg(long)]
prefix: Option<String>,
},
#[command(subcommand)]
Txn(KvTxnCommand),
}
#[derive(Subcommand, Debug)]
pub enum KvTxnCommand {
Begin {
#[arg(long)]
timeout_secs: Option<u64>,
},
Get {
key: String,
#[arg(long)]
txn_id: String,
},
Put {
key: String,
value: String,
#[arg(long)]
txn_id: String,
},
Delete {
key: String,
#[arg(long)]
txn_id: String,
},
Commit {
#[arg(long)]
txn_id: String,
},
Rollback {
#[arg(long)]
txn_id: String,
},
}
#[derive(Parser, Debug)]
pub struct SqlCommand {
#[arg(conflicts_with = "file")]
pub query: Option<String>,
#[arg(long, short = 'f')]
pub file: Option<String>,
#[arg(long)]
pub fetch_size: Option<usize>,
#[arg(long)]
pub max_rows: Option<usize>,
#[arg(long)]
pub deadline: Option<String>,
#[arg(long)]
pub tui: bool,
}
#[derive(Subcommand, Debug)]
pub enum VectorCommand {
Search {
#[arg(long)]
index: String,
#[arg(long)]
query: String,
#[arg(long, short = 'k', default_value = "10")]
k: usize,
#[arg(long)]
progress: bool,
},
Upsert {
#[arg(long)]
index: String,
#[arg(long)]
key: String,
#[arg(long)]
vector: String,
},
Delete {
#[arg(long)]
index: String,
#[arg(long)]
key: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum DistanceMetric {
#[default]
Cosine,
L2,
Ip,
}
#[derive(Subcommand, Debug)]
pub enum HnswCommand {
Create {
name: String,
#[arg(long)]
dim: usize,
#[arg(long, value_enum, default_value = "cosine")]
metric: DistanceMetric,
},
Stats {
name: String,
},
Drop {
name: String,
},
}
#[derive(Subcommand, Debug)]
pub enum ColumnarCommand {
Scan {
#[arg(long)]
segment: String,
#[arg(long)]
progress: bool,
},
Stats {
#[arg(long)]
segment: String,
},
List,
Ingest {
#[arg(long)]
file: PathBuf,
#[arg(long)]
table: String,
#[arg(long, default_value = ",", value_parser = clap::value_parser!(char))]
delimiter: char,
#[arg(
long,
default_value = "true",
value_parser = clap::value_parser!(bool),
action = clap::ArgAction::Set
)]
header: bool,
#[arg(long, default_value = "zstd")]
compression: String,
#[arg(long)]
row_group_size: Option<usize>,
},
#[command(subcommand)]
Index(IndexCommand),
}
#[derive(Subcommand, Debug)]
pub enum IndexCommand {
Create {
#[arg(long)]
segment: String,
#[arg(long)]
column: String,
#[arg(long = "type")]
index_type: String,
},
List {
#[arg(long)]
segment: String,
},
Drop {
#[arg(long)]
segment: String,
#[arg(long)]
column: String,
},
}
#[derive(Subcommand, Debug)]
pub enum ServerCommand {
Status,
Metrics,
Health,
Compaction {
#[command(subcommand)]
command: CompactionCommand,
},
}
#[derive(Subcommand, Debug)]
pub enum LifecycleCommand {
Archive,
Restore {
#[arg(long)]
source: Option<String>,
#[command(subcommand)]
command: Option<LifecycleRestoreCommand>,
},
Backup {
#[command(subcommand)]
command: Option<LifecycleBackupCommand>,
},
Export,
}
#[derive(Subcommand, Debug)]
pub enum LifecycleBackupCommand {
Status {
#[arg(long)]
handle: String,
},
}
#[derive(Subcommand, Debug)]
pub enum LifecycleRestoreCommand {
Status {
#[arg(long)]
handle: String,
},
}
#[derive(Subcommand, Debug)]
pub enum CompactionCommand {
Trigger,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_in_memory_kv_get() {
let args = vec!["alopex", "--in-memory", "kv", "get", "mykey"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(cli.in_memory);
assert!(cli.data_dir.is_none());
assert_eq!(cli.output_format(), OutputFormat::Table);
assert!(matches!(
cli.command,
Some(Command::Kv {
command: Some(KvCommand::Get { key })
}) if key == "mykey"
));
}
#[test]
fn test_parse_data_dir_sql() {
let args = vec![
"alopex",
"--data-dir",
"/path/to/db",
"sql",
"SELECT * FROM users",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(!cli.in_memory);
assert_eq!(cli.data_dir, Some("/path/to/db".to_string()));
assert!(matches!(
cli.command,
Some(Command::Sql(SqlCommand { query: Some(q), file: None, .. })) if q == "SELECT * FROM users"
));
}
#[test]
fn test_parse_output_format() {
let args = vec!["alopex", "--in-memory", "--output", "jsonl", "kv", "list"];
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.output_format(), OutputFormat::Jsonl);
assert!(cli.output_is_explicit());
}
#[test]
fn test_parse_limit() {
let args = vec!["alopex", "--in-memory", "--limit", "100", "kv", "list"];
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.limit, Some(100));
}
#[test]
fn test_parse_sql_streaming_options() {
let args = vec![
"alopex",
"sql",
"--fetch-size",
"500",
"--max-rows",
"250",
"--deadline",
"30s",
"SELECT 1",
];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Some(Command::Sql(cmd)) => {
assert_eq!(cmd.fetch_size, Some(500));
assert_eq!(cmd.max_rows, Some(250));
assert_eq!(cmd.deadline.as_deref(), Some("30s"));
assert!(!cmd.tui);
}
_ => panic!("expected sql command"),
}
}
#[test]
fn test_parse_sql_tui_flag() {
let args = vec!["alopex", "sql", "--tui", "SELECT 1"];
let cli = Cli::try_parse_from(args).unwrap();
match cli.command {
Some(Command::Sql(cmd)) => {
assert!(cmd.tui);
assert_eq!(cmd.query.as_deref(), Some("SELECT 1"));
}
_ => panic!("expected sql command"),
}
}
#[test]
fn test_parse_server_status() {
let args = vec!["alopex", "server", "status"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Server {
command: Some(ServerCommand::Status)
})
));
}
#[test]
fn test_parse_server_compaction_trigger() {
let args = vec!["alopex", "server", "compaction", "trigger"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Server {
command: Some(ServerCommand::Compaction {
command: CompactionCommand::Trigger
})
})
));
}
#[test]
fn test_parse_verbose_quiet() {
let args = vec!["alopex", "--in-memory", "--verbose", "kv", "list"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(cli.verbose);
assert!(!cli.quiet);
}
#[test]
fn test_parse_thread_mode() {
let args = vec![
"alopex",
"--in-memory",
"--thread-mode",
"single",
"kv",
"list",
];
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.thread_mode, ThreadMode::Single);
}
#[test]
fn test_parse_profile_option_batch_yes() {
let args = vec![
"alopex",
"--profile",
"dev",
"--batch",
"--yes",
"--in-memory",
"kv",
"list",
];
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.profile.as_deref(), Some("dev"));
assert!(cli.batch);
assert!(cli.yes);
}
#[test]
fn test_parse_batch_short_flag() {
let args = vec!["alopex", "-b", "--in-memory", "kv", "list"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(cli.batch);
}
#[test]
fn test_parse_profile_create_subcommand() {
let args = vec![
"alopex",
"profile",
"create",
"dev",
"--data-dir",
"/path/to/db",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Profile {
command: Some(ProfileCommand::Create { name, data_dir })
})
if name == "dev" && data_dir == "/path/to/db"
));
}
#[test]
fn test_parse_completions_bash() {
let args = vec!["alopex", "completions", "bash"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Completions { shell }) if shell == Shell::Bash
));
}
#[test]
fn test_parse_completions_pwsh() {
let args = vec!["alopex", "completions", "pwsh"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Completions { shell }) if shell == Shell::PowerShell
));
}
#[test]
fn test_parse_kv_put() {
let args = vec!["alopex", "--in-memory", "kv", "put", "mykey", "myvalue"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Kv {
command: Some(KvCommand::Put { key, value })
}) if key == "mykey" && value == "myvalue"
));
}
#[test]
fn test_parse_kv_delete() {
let args = vec!["alopex", "--in-memory", "kv", "delete", "mykey"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Kv {
command: Some(KvCommand::Delete { key })
}) if key == "mykey"
));
}
#[test]
fn test_parse_kv_txn_begin() {
let args = vec!["alopex", "kv", "txn", "begin", "--timeout-secs", "30"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Kv {
command: Some(KvCommand::Txn(KvTxnCommand::Begin {
timeout_secs: Some(30)
}))
})
));
}
#[test]
fn test_parse_kv_txn_get_requires_txn_id() {
let args = vec!["alopex", "kv", "txn", "get", "mykey"];
assert!(Cli::try_parse_from(args).is_err());
}
#[test]
fn test_parse_kv_txn_get() {
let args = vec!["alopex", "kv", "txn", "get", "mykey", "--txn-id", "txn123"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Kv {
command: Some(KvCommand::Txn(KvTxnCommand::Get { key, txn_id }))
}) if key == "mykey" && txn_id == "txn123"
));
}
#[test]
fn test_parse_kv_list_with_prefix() {
let args = vec!["alopex", "--in-memory", "kv", "list", "--prefix", "user:"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Kv {
command: Some(KvCommand::List { prefix: Some(p) })
}) if p == "user:"
));
}
#[test]
fn test_parse_sql_from_file() {
let args = vec!["alopex", "--in-memory", "sql", "-f", "query.sql"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Sql(SqlCommand { query: None, file: Some(f), .. })) if f == "query.sql"
));
}
#[test]
fn test_parse_vector_search() {
let args = vec![
"alopex",
"--in-memory",
"vector",
"search",
"--index",
"my_index",
"--query",
"[1.0,2.0,3.0]",
"-k",
"5",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Vector {
command: Some(VectorCommand::Search { index, query, k, progress })
}) if index == "my_index" && query == "[1.0,2.0,3.0]" && k == 5 && !progress
));
}
#[test]
fn test_parse_vector_upsert() {
let args = vec![
"alopex",
"--in-memory",
"vector",
"upsert",
"--index",
"my_index",
"--key",
"vec1",
"--vector",
"[1.0,2.0,3.0]",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Vector {
command: Some(VectorCommand::Upsert { index, key, vector })
}) if index == "my_index" && key == "vec1" && vector == "[1.0,2.0,3.0]"
));
}
#[test]
fn test_parse_vector_delete() {
let args = vec![
"alopex",
"--in-memory",
"vector",
"delete",
"--index",
"my_index",
"--key",
"vec1",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Vector {
command: Some(VectorCommand::Delete { index, key })
}) if index == "my_index" && key == "vec1"
));
}
#[test]
fn test_parse_hnsw_create() {
let args = vec![
"alopex",
"--in-memory",
"hnsw",
"create",
"my_index",
"--dim",
"128",
"--metric",
"l2",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Hnsw {
command: Some(HnswCommand::Create { name, dim, metric })
}) if name == "my_index" && dim == 128 && metric == DistanceMetric::L2
));
}
#[test]
fn test_parse_hnsw_create_default_metric() {
let args = vec![
"alopex",
"--in-memory",
"hnsw",
"create",
"my_index",
"--dim",
"128",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Hnsw {
command: Some(HnswCommand::Create { name, dim, metric })
}) if name == "my_index" && dim == 128 && metric == DistanceMetric::Cosine
));
}
#[test]
fn test_parse_columnar_scan() {
let args = vec![
"alopex",
"--in-memory",
"columnar",
"scan",
"--segment",
"seg_001",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Columnar {
command: Some(ColumnarCommand::Scan { segment, progress })
}) if segment == "seg_001" && !progress
));
}
#[test]
fn test_parse_columnar_stats() {
let args = vec![
"alopex",
"--in-memory",
"columnar",
"stats",
"--segment",
"seg_001",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Columnar {
command: Some(ColumnarCommand::Stats { segment })
}) if segment == "seg_001"
));
}
#[test]
fn test_parse_columnar_list() {
let args = vec!["alopex", "--in-memory", "columnar", "list"];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Columnar {
command: Some(ColumnarCommand::List)
})
));
}
#[test]
fn test_parse_columnar_ingest_defaults() {
let args = vec![
"alopex",
"--in-memory",
"columnar",
"ingest",
"--file",
"data.csv",
"--table",
"events",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Columnar {
command: Some(ColumnarCommand::Ingest {
file,
table,
delimiter,
header,
compression,
row_group_size,
})
}) if file == std::path::Path::new("data.csv")
&& table == "events"
&& delimiter == ','
&& header
&& compression == "zstd"
&& row_group_size.is_none()
));
}
#[test]
fn test_parse_columnar_ingest_custom_options() {
let args = vec![
"alopex",
"--in-memory",
"columnar",
"ingest",
"--file",
"data.csv",
"--table",
"events",
"--delimiter",
";",
"--header",
"false",
"--compression",
"zstd",
"--row-group-size",
"500",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Columnar {
command: Some(ColumnarCommand::Ingest {
file,
table,
delimiter,
header,
compression,
row_group_size,
})
}) if file == std::path::Path::new("data.csv")
&& table == "events"
&& delimiter == ';'
&& !header
&& compression == "zstd"
&& row_group_size == Some(500)
));
}
#[test]
fn test_parse_columnar_index_create() {
let args = vec![
"alopex",
"--in-memory",
"columnar",
"index",
"create",
"--segment",
"123:1",
"--column",
"col1",
"--type",
"bloom",
];
let cli = Cli::try_parse_from(args).unwrap();
assert!(matches!(
cli.command,
Some(Command::Columnar {
command: Some(ColumnarCommand::Index(IndexCommand::Create {
segment,
column,
index_type,
}))
}) if segment == "123:1"
&& column == "col1"
&& index_type == "bloom"
));
}
#[test]
fn test_output_format_supports_streaming() {
assert!(!OutputFormat::Table.supports_streaming());
assert!(OutputFormat::Json.supports_streaming());
assert!(OutputFormat::Jsonl.supports_streaming());
assert!(OutputFormat::Csv.supports_streaming());
assert!(OutputFormat::Tsv.supports_streaming());
}
#[test]
fn test_default_values() {
let args = vec!["alopex", "--in-memory", "kv", "list"];
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.output_format(), OutputFormat::Table);
assert!(!cli.output_is_explicit());
assert_eq!(cli.thread_mode, ThreadMode::Multi);
assert!(cli.limit.is_none());
assert!(!cli.quiet);
assert!(!cli.verbose);
}
#[test]
fn test_s3_data_dir() {
let args = vec![
"alopex",
"--data-dir",
"s3://my-bucket/prefix",
"kv",
"list",
];
let cli = Cli::try_parse_from(args).unwrap();
assert_eq!(cli.data_dir, Some("s3://my-bucket/prefix".to_string()));
}
}