mod commands;
mod config;
mod embedding;
mod errors;
mod hook;
mod memory;
pub mod memory_types; mod output;
mod project;
mod rrf;
mod sqlite;
mod temporal;
use clap::Parser;
use commands::Commands;
use errors::Error;
use memory::MemoryStore;
use output::{ErrorResponse, print_json};
use project::detect_project;
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "vipune", about = "Minimal memory layer for AI agents", long_about = None)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[arg(long, short = 'p', global = true)]
project: Option<String>,
#[arg(long, global = true)]
db_path: Option<String>,
#[command(subcommand)]
command: Commands,
}
const USAGE_ERROR_EXIT_CODE: i32 = 64;
fn main() -> ExitCode {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(error) => {
if error.use_stderr() {
eprint!("{error}");
std::process::exit(USAGE_ERROR_EXIT_CODE);
} else {
print!("{error}");
std::process::exit(0);
}
}
};
match run(&cli) {
Ok(exit_code) => exit_code,
Err(error) => {
let exit_code = if matches!(error, Error::ContentTooLong { .. }) {
ExitCode::from(3)
} else {
ExitCode::from(1)
};
if cli.json {
print_json(&ErrorResponse {
error: error.to_string(),
});
} else {
eprintln!("Error: {}", error);
}
exit_code
}
}
}
#[cfg(feature = "mcp")]
fn to_lib_config(config: &config::Config) -> vipune::Config {
vipune::Config {
database_path: config.database_path.clone(),
embedding_model: config.embedding_model.clone(),
similarity_threshold: config.similarity_threshold,
recency_weight: config.recency_weight,
hybrid: config.hybrid,
decay_refresh_days: config.decay_refresh_days,
promotion_threshold: config.promotion_threshold,
prune_retrieval_limit: config.prune_retrieval_limit,
prune_min_age_days: config.prune_min_age_days,
}
}
fn hook_event_from_command(command: &Commands) -> Option<crate::hook::HookEvent> {
match command {
Commands::Hook {
command: commands::HookCommands::SessionStart,
} => Some(crate::hook::HookEvent::SessionStart),
Commands::Hook {
command: commands::HookCommands::UserPromptSubmit,
} => Some(crate::hook::HookEvent::UserPromptSubmit),
Commands::Hook {
command: commands::HookCommands::PreToolUse,
} => Some(crate::hook::HookEvent::PreToolUse),
Commands::Hook {
command: commands::HookCommands::PostToolUse,
} => Some(crate::hook::HookEvent::PostToolUse),
Commands::Hook {
command: commands::HookCommands::PreCompact,
} => Some(crate::hook::HookEvent::PreCompact),
_ => None,
}
}
fn run(cli: &Cli) -> Result<ExitCode, Error> {
let mut config = config::Config::load()?;
config.ensure_directories()?;
if let Some(db_path) = &cli.db_path {
config.database_path = db_path.clone().into();
}
let project_id = detect_project(cli.project.as_deref());
if let Some(event) = hook_event_from_command(&cli.command) {
return commands::hook_run::handle_hook_event(&config, cli.json, event);
}
#[cfg(feature = "mcp")]
if matches!(cli.command, Commands::Mcp) {
vipune::mcp::server::run_mcp(to_lib_config(&config), &project_id)
.map_err(|e| Error::Config(e.to_string()))?;
return Ok(ExitCode::SUCCESS);
}
let mut store = MemoryStore::new(
&config.database_path,
&config.embedding_model,
config.clone(),
)?;
commands::execute(&cli.command, &mut store, project_id, &config, cli.json)
}
#[cfg(test)]
mod issue_178_tests {
use crate::commands::{SearchContext, handle_get, handle_list, handle_search};
use crate::config::Config;
use crate::memory::crud::test_fake_embedder;
use crate::memory::{MemoryStore, SearchOptions};
use crate::output::{GetResponse, ListResponse, SearchResponse};
use crate::sqlite::Database;
#[test]
fn test_get_json_response_includes_memory_type_and_status() {
let dir = tempfile::TempDir::new().expect("temp dir for issue 178 test");
let db_path = dir.path().join(format!("178_{}.db", uuid::Uuid::new_v4()));
let db = Database::open(&db_path).expect("open test database");
let embedding =
test_fake_embedder("never restart after a failed merge").expect("fake embedder");
let id = db
.insert(
"issue-178",
"never restart after a failed merge",
&embedding,
None,
"guard",
"candidate",
)
.expect("insert row");
let mut store = MemoryStore::from_db_with_test_embedder(db);
let exit = handle_get(&mut store, &id, "issue-178", true, false).expect("handle_get ok");
assert_eq!(exit, std::process::ExitCode::SUCCESS);
let memory = store.get(&id, "issue-178").unwrap().expect("memory found");
let response = GetResponse {
id: memory.id.clone(),
content: memory.content.clone(),
project_id: memory.project_id,
metadata: memory.metadata,
created_at: memory.created_at,
updated_at: memory.updated_at,
retrieval_count: memory.retrieval_count,
last_retrieved_at: memory.last_retrieved_at,
memory_type: memory.memory_type.clone(),
status: memory.status.clone(),
importance: memory.importance.clone(),
};
assert_eq!(memory.memory_type, "guard");
assert_eq!(memory.status, "candidate");
let json = serde_json::to_string_pretty(&response).expect("serialize get response");
assert!(
json.contains("\"memory_type\": \"guard\""),
"get JSON must carry memory_type: {json}"
);
assert!(
json.contains("\"status\": \"candidate\""),
"get JSON must carry status: {json}"
);
}
#[test]
fn test_search_list_json_response_includes_memory_type_and_status() {
let dir = tempfile::TempDir::new().expect("temp dir for issue 178 test");
let db_path = dir.path().join(format!("178_{}.db", uuid::Uuid::new_v4()));
let db = Database::open(&db_path).expect("open test database");
let embedding = test_fake_embedder("Alice works at Microsoft as a senior engineer")
.expect("fake embedder");
let _ = db
.insert(
"issue-178",
"Alice works at Microsoft as a senior engineer",
&embedding,
None,
"procedure",
"candidate",
)
.expect("insert row");
let mut store = MemoryStore::from_db_with_test_embedder(db);
let exit = handle_list(&mut store, "issue-178", 10, None, None, true, true)
.expect("handle_list ok");
assert_eq!(exit, std::process::ExitCode::SUCCESS);
let exit = handle_search(
&mut store,
"issue-178",
&SearchContext {
query: "senior engineer".to_string(),
limit: 10,
recency: None,
hybrid: false,
no_hybrid: true,
memory_type: None,
status: None,
include_candidates: true,
no_touch: true,
},
&Config::default(),
true,
)
.expect("handle_search ok");
assert_eq!(exit, std::process::ExitCode::SUCCESS);
let memories = store
.list("issue-178", 10, Some(&["procedure"]), Some(&["candidate"]))
.expect("list rows")
.into_iter()
.map(|m| crate::output::ListItem {
id: m.id,
content: m.content,
created_at: m.created_at,
retrieval_count: m.retrieval_count,
last_retrieved_at: m.last_retrieved_at,
memory_type: m.memory_type,
status: m.status,
importance: m.importance,
})
.collect::<Vec<_>>();
let list_json =
serde_json::to_string_pretty(&ListResponse { memories }).expect("serialize list");
assert!(
list_json.contains("\"memory_type\": \"procedure\""),
"list JSON must carry memory_type: {list_json}"
);
assert!(
list_json.contains("\"status\": \"candidate\""),
"list JSON must carry status: {list_json}"
);
let results = store
.search(
"issue-178",
"senior engineer",
10,
0.0,
SearchOptions {
memory_types: Some(vec!["procedure"]),
statuses: Some(vec!["candidate"]),
},
)
.expect("search rows")
.into_iter()
.map(|m| crate::output::SearchResultItem {
id: m.id,
content: m.content,
similarity: m.similarity.unwrap_or(0.0),
created_at: m.created_at,
retrieval_count: m.retrieval_count,
last_retrieved_at: m.last_retrieved_at,
memory_type: m.memory_type,
status: m.status,
importance: m.importance,
})
.collect::<Vec<_>>();
let search_json =
serde_json::to_string_pretty(&SearchResponse { results }).expect("serialize search");
assert!(
search_json.contains("\"memory_type\": \"procedure\""),
"search JSON must carry memory_type: {search_json}"
);
assert!(
search_json.contains("\"status\": \"candidate\""),
"search JSON must carry status: {search_json}"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use memory_types::{BatchIngestItemResult, IngestPolicy};
#[cfg(feature = "mcp")]
use std::path::PathBuf;
#[cfg(feature = "mcp")]
#[test]
fn test_to_lib_config_maps_all_fields_non_default() {
let local_config = config::Config {
database_path: PathBuf::from("/nondefault/db/path.sqlite"),
embedding_model: "nondefault/embedding-model".to_string(),
similarity_threshold: 0.42,
recency_weight: 0.77,
hybrid: true,
decay_refresh_days: 14.0,
promotion_threshold: 7,
prune_retrieval_limit: 3,
prune_min_age_days: 9.5,
};
let lib_config = to_lib_config(&local_config);
assert_eq!(
lib_config.database_path,
PathBuf::from("/nondefault/db/path.sqlite")
);
assert_eq!(lib_config.embedding_model, "nondefault/embedding-model");
assert_eq!(lib_config.similarity_threshold, 0.42);
assert_eq!(lib_config.recency_weight, 0.77);
assert!(lib_config.hybrid);
assert_eq!(lib_config.decay_refresh_days, 14.0);
assert_eq!(lib_config.promotion_threshold, 7);
assert_eq!(lib_config.prune_retrieval_limit, 3);
assert_eq!(lib_config.prune_min_age_days, 9.5);
}
#[test]
fn test_cli_parse_add() {
let cli = Cli::parse_from(["vipune", "add", "test content"]);
assert!(!cli.json);
assert!(cli.project.is_none());
assert!(cli.db_path.is_none());
matches!(cli.command, Commands::Add { .. });
}
#[test]
fn test_batch_types_exist() {
let _policy_force = IngestPolicy::Force;
let _policy_conflict = IngestPolicy::ConflictAware;
let _added = BatchIngestItemResult::Added {
id: "test-id".to_string(),
};
let _conflicts = BatchIngestItemResult::Conflicts {
proposed: "test".to_string(),
conflicts: vec![],
};
let _error = BatchIngestItemResult::Error {
message: "error".to_string(),
};
assert!(IngestPolicy::Force == IngestPolicy::Force);
}
#[test]
fn test_cli_parse_with_json() {
let cli = Cli::parse_from(["vipune", "--json", "add", "test"]);
assert!(cli.json);
}
#[test]
fn test_cli_parse_with_project() {
let cli = Cli::parse_from(["vipune", "-p", "my-project", "add", "test"]);
assert_eq!(cli.project, Some("my-project".to_string()));
}
#[test]
fn test_cli_parse_search() {
let cli = Cli::parse_from(["vipune", "search", "query", "--limit", "10"]);
matches!(
cli.command,
Commands::Search {
query,
limit: 10,
..
} if query == "query"
);
}
#[test]
fn test_cli_parse_get() {
let cli = Cli::parse_from(["vipune", "get", "memory-id"]);
matches!(cli.command, Commands::Get { id, no_touch: _ } if id == "memory-id");
}
#[test]
fn test_cli_parse_list() {
let cli = Cli::parse_from(["vipune", "list"]);
matches!(cli.command, Commands::List { .. });
}
#[test]
fn test_cli_parse_delete() {
let cli = Cli::parse_from(["vipune", "delete", "memory-id"]);
matches!(cli.command, Commands::Delete { id } if id == "memory-id");
}
#[test]
fn test_cli_parse_update() {
let cli = Cli::parse_from(["vipune", "update", "memory-id", "--text", "new content"]);
matches!(
cli.command,
Commands::Update { id, text, metadata, memory_type, status, importance }
if id == "memory-id" && text == Some("new content".to_string()) && metadata.is_none() && memory_type.is_none() && status.is_none() && importance.is_none()
);
let cli = Cli::parse_from(["vipune", "update", "memory-id", "-m", r#"{"tag": "new"}"#]);
matches!(
cli.command,
Commands::Update { id, text, metadata, memory_type, status, importance }
if id == "memory-id" && text.is_none() && metadata == Some(r#"{"tag": "new"}"#.to_string()) && memory_type.is_none() && status.is_none() && importance.is_none()
);
let cli = Cli::parse_from([
"vipune",
"update",
"memory-id",
"-t",
"new",
"-m",
r#"{"key":"val"}"#,
]);
matches!(
cli.command,
Commands::Update { id, text, metadata, memory_type, status, importance }
if id == "memory-id" && text == Some("new".to_string()) && metadata == Some(r#"{"key":"val"}"#.to_string()) && memory_type.is_none() && status.is_none() && importance.is_none()
);
}
#[test]
fn test_cli_parse_version() {
let cli = Cli::parse_from(["vipune", "version"]);
matches!(cli.command, Commands::Version);
}
#[test]
fn test_cli_parse_validate() {
let cli = Cli::parse_from(["vipune", "validate", "test text"]);
matches!(
cli.command,
Commands::Validate { text } if text == "test text"
);
}
#[test]
fn test_cli_parse_with_db_path() {
let cli = Cli::parse_from(["vipune", "--db-path", "/custom/path.db", "add", "test"]);
assert_eq!(cli.db_path, Some("/custom/path.db".to_string()));
}
#[test]
fn test_cli_parse_search_with_recency() {
let cli = Cli::parse_from(["vipune", "search", "query", "--recency", "0.5"]);
matches!(
cli.command,
Commands::Search {
query,
recency: Some(0.5),
..
} if query == "query"
);
}
#[test]
fn test_cli_parse_search_without_recency() {
let cli = Cli::parse_from(["vipune", "search", "query"]);
matches!(
cli.command,
Commands::Search {
query,
recency: None,
..
} if query == "query"
);
}
#[test]
fn test_cli_parse_search_with_hybrid() {
let cli = Cli::parse_from(["vipune", "search", "query", "--hybrid"]);
matches!(
cli.command,
Commands::Search {
query,
hybrid: true,
..
} if query == "query"
);
}
#[test]
fn test_cli_parse_search_without_hybrid() {
let cli = Cli::parse_from(["vipune", "search", "query"]);
matches!(
cli.command,
Commands::Search {
query,
hybrid: false,
..
} if query == "query"
);
}
#[test]
fn test_cli_parse_search_with_hybrid_and_recency() {
let cli = Cli::parse_from(["vipune", "search", "query", "--hybrid", "--recency", "0.5"]);
matches!(
cli.command,
Commands::Search {
query,
hybrid: true,
recency: Some(0.5),
..
} if query == "query"
);
}
#[test]
fn test_batch_ingest_integration_compiles() {
let mut store = MemoryStore::test_store();
let result = store.batch_ingest("test-project", vec![], IngestPolicy::Force);
assert!(result.is_ok());
assert_eq!(result.unwrap().results.len(), 0);
}
#[test]
fn test_cli_parse_project_merge() {
let cli = Cli::parse_from(["vipune", "project", "merge", "old-id", "new-id"]);
if let Commands::Project { command } = cli.command {
let commands::ProjectCommands::Merge { from, to } = command;
assert_eq!(from, "old-id");
assert_eq!(to, "new-id");
} else {
panic!("Expected Project subcommand");
}
}
#[test]
fn test_cli_parse_project_merge_with_json() {
let cli = Cli::parse_from(["vipune", "--json", "project", "merge", "a", "b"]);
assert!(cli.json);
matches!(cli.command, Commands::Project { .. });
}
#[test]
fn test_cli_parse_project_merge_with_db_path() {
let cli = Cli::parse_from([
"vipune",
"--db-path",
"/tmp/test.db",
"project",
"merge",
"x",
"y",
]);
assert_eq!(cli.db_path, Some("/tmp/test.db".to_string()));
matches!(cli.command, Commands::Project { .. });
}
#[test]
fn test_cli_parse_project_subcommand_missing_fails() {
let result = Cli::try_parse_from(["vipune", "project"]);
assert!(result.is_err());
}
#[test]
fn test_cli_parse_project_merge_missing_args_fails() {
let result = Cli::try_parse_from(["vipune", "project", "merge", "only-from"]);
assert!(result.is_err());
}
#[test]
fn test_clap_usage_errors_fail_parse_to_stderr() {
let Err(error) = Cli::try_parse_from(["vipune", "add", "x", "--memory-typo"]) else {
panic!("typo'd flag should be a parse error");
};
assert!(
error.use_stderr(),
"typo'd flag should be routed to stderr (and exit with EX_USAGE in main)"
);
let Err(error) = Cli::try_parse_from(["vipune"]) else {
panic!("missing subcommand should be a parse error");
};
assert!(
error.use_stderr(),
"missing subcommand should be routed to stderr (and exit with EX_USAGE in main)"
);
}
#[test]
fn test_clap_help_is_stdout_path() {
let Err(error) = Cli::try_parse_from(["vipune", "--help"]) else {
panic!("--help should short-circuit as a display-help error");
};
assert!(
!error.use_stderr(),
"--help must be routed to stdout (and exit 0 in main), not stderr"
);
}
#[test]
fn test_cli_parse_doctor_projects() {
let cli = Cli::parse_from(["vipune", "doctor", "--projects"]);
if let Commands::Doctor {
embeddings: false,
projects: true,
fts: false,
project: None,
repair: false,
} = cli.command
{
} else {
panic!("Expected Doctor with --projects flag");
}
}
#[test]
fn test_cli_parse_doctor_embeddings() {
let cli = Cli::parse_from(["vipune", "doctor", "--embeddings"]);
if let Commands::Doctor {
embeddings: true,
projects: false,
fts: false,
project: None,
repair: false,
} = cli.command
{
} else {
panic!("Expected Doctor with --embeddings flag");
}
}
#[test]
fn test_cli_parse_doctor_fts() {
let cli = Cli::parse_from(["vipune", "doctor", "--fts"]);
if let Commands::Doctor {
embeddings: false,
projects: false,
fts: true,
project: None,
repair: false,
} = cli.command
{
} else {
panic!("Expected Doctor with --fts flag");
}
}
#[test]
fn test_cli_parse_doctor_fts_with_p() {
let cli = Cli::parse_from(["vipune", "doctor", "--fts", "-p", "my-proj"]);
if let Commands::Doctor {
embeddings: _,
projects: _,
fts: true,
project: Some(ref p),
repair: false,
} = cli.command
{
assert_eq!(p, "my-proj");
} else {
panic!("Expected Doctor with --fts and -p flags");
}
}
#[test]
fn test_cli_parse_doctor_fts_and_embeddings_errors() {
let result = Cli::try_parse_from(["vipune", "doctor", "--fts", "--embeddings"]);
assert!(
result.is_err(),
"doctor --fts --embeddings should fail at parse time"
);
}
#[test]
fn test_cli_parse_doctor_fts_and_projects_errors() {
let result = Cli::try_parse_from(["vipune", "doctor", "--fts", "--projects"]);
assert!(
result.is_err(),
"doctor --fts --projects should fail at parse time"
);
}
#[test]
fn test_cli_parse_doctor_repair_alone_errors() {
let result = Cli::try_parse_from(["vipune", "doctor", "--repair"]);
assert!(
result.is_err(),
"doctor --repair alone should fail at parse time (no doctor-mode flag)"
)
}
#[test]
fn test_cli_parse_doctor_fts_with_repair_parses() {
let cli = Cli::parse_from(["vipune", "doctor", "--fts", "--repair"]);
if let Commands::Doctor {
embeddings: false,
projects: false,
fts: true,
project: None,
repair: true,
} = cli.command
{
} else {
panic!("Expected Doctor with --fts --repair");
}
}
#[test]
fn test_cli_parse_doctor_projects_with_p() {
let cli = Cli::parse_from(["vipune", "doctor", "--projects", "-p", "my-proj"]);
if let Commands::Doctor {
embeddings: _,
projects: true,
fts: _,
project: Some(ref p),
repair: _,
} = cli.command
{
assert_eq!(p, "my-proj");
} else {
panic!("Expected Doctor with --projects and -p flags");
}
}
#[test]
fn test_cli_parse_doctor_both_flags_errors() {
let result = Cli::try_parse_from(["vipune", "doctor", "--embeddings", "--projects"]);
assert!(
result.is_err(),
"doctor --embeddings --projects should fail at parse or execute time"
);
}
#[test]
fn test_cli_parse_doctor_neither_flag_errors() {
let result = Cli::try_parse_from(["vipune", "doctor"]);
assert!(
result.is_err(),
"doctor without a doctor-mode flag should fail at parse time"
);
}
#[test]
fn test_cli_parse_backup() {
let cli = Cli::parse_from(["vipune", "backup"]);
matches!(cli.command, Commands::Backup { output: None });
}
#[test]
fn test_cli_parse_backup_with_output() {
let cli = Cli::parse_from(["vipune", "backup", "--output", "/tmp/backup.db"]);
matches!(
cli.command,
Commands::Backup {
output: Some(p)
} if p.to_string_lossy() == "/tmp/backup.db"
);
}
#[test]
fn test_cli_parse_backup_with_json() {
let cli = Cli::parse_from(["vipune", "--json", "backup"]);
assert!(cli.json);
matches!(cli.command, Commands::Backup { .. });
}
#[test]
fn test_cli_parse_backup_with_db_path() {
let cli = Cli::parse_from(["vipune", "--db-path", "/tmp/seeded.db", "backup"]);
assert_eq!(cli.db_path, Some("/tmp/seeded.db".to_string()));
matches!(cli.command, Commands::Backup { .. });
}
#[test]
fn test_cli_parse_doctor_projects_with_json() {
let cli = Cli::parse_from(["vipune", "--json", "doctor", "--projects"]);
assert!(cli.json);
matches!(cli.command, Commands::Doctor { .. });
}
#[test]
fn test_cli_parse_export() {
let cli = Cli::parse_from(["vipune", "export", "/tmp/out.jsonl"]);
matches!(
cli.command,
Commands::Export {
ref output_path
} if output_path == "/tmp/out.jsonl"
);
}
#[test]
fn test_cli_parse_export_with_json() {
let cli = Cli::parse_from(["vipune", "--json", "export", "out.jsonl"]);
assert!(cli.json);
matches!(cli.command, Commands::Export { .. });
}
#[test]
fn test_cli_parse_export_with_db_path() {
let cli = Cli::parse_from([
"vipune",
"--db-path",
"/tmp/seeded.db",
"export",
"out.jsonl",
]);
assert_eq!(cli.db_path, Some("/tmp/seeded.db".to_string()));
matches!(cli.command, Commands::Export { .. });
}
#[test]
fn test_cli_parse_export_with_stray_project_parses_but_is_ignored() {
let cli = Cli::parse_from(["vipune", "-p", "my-proj", "export", "out.jsonl"]);
assert_eq!(cli.project, Some("my-proj".to_string()));
matches!(cli.command, Commands::Export { .. });
}
#[test]
fn test_cli_parse_export_missing_output_path_fails() {
let result = Cli::try_parse_from(["vipune", "export"]);
assert!(result.is_err());
}
#[test]
fn test_cli_parse_import_with_source() {
let cli = Cli::parse_from(["vipune", "import", "/tmp/export.jsonl"]);
if let Commands::Import { source } = cli.command {
assert_eq!(source, Some("/tmp/export.jsonl".to_string()));
} else {
panic!("Expected Import subcommand");
}
}
#[test]
fn test_cli_parse_import_stdin() {
let cli = Cli::parse_from(["vipune", "import", "-"]);
if let Commands::Import { source } = cli.command {
assert_eq!(source, Some("-".to_string()));
} else {
panic!("Expected Import subcommand");
}
}
#[test]
fn test_cli_parse_import_no_source_defaults_to_stdin() {
let cli = Cli::parse_from(["vipune", "import"]);
if let Commands::Import { source } = cli.command {
assert!(source.is_none(), "no source should default to stdin");
} else {
panic!("Expected Import subcommand");
}
}
#[test]
fn test_cli_parse_import_with_json() {
let cli = Cli::parse_from(["vipune", "--json", "import", "-"]);
assert!(cli.json);
matches!(cli.command, Commands::Import { .. });
}
#[test]
fn test_cli_parse_import_with_db_path() {
let cli = Cli::parse_from(["vipune", "--db-path", "/tmp/test.db", "import", "-"]);
assert_eq!(cli.db_path, Some("/tmp/test.db".to_string()));
matches!(cli.command, Commands::Import { .. });
}
#[test]
fn test_cli_parse_import_with_project_flag_parses_but_is_ignored() {
let cli = Cli::parse_from(["vipune", "--project", "my-proj", "import", "-"]);
assert_eq!(cli.project, Some("my-proj".to_string()));
matches!(cli.command, Commands::Import { .. });
}
#[test]
fn test_cli_parse_hook_install() {
let cli = Cli::parse_from(["vipune", "hook", "install"]);
if let Commands::Hook { command } = cli.command {
matches!(command, commands::HookCommands::Install);
} else {
panic!("Expected Hook subcommand");
}
}
#[test]
fn test_cli_parse_hook_uninstall() {
let cli = Cli::parse_from(["vipune", "hook", "uninstall"]);
if let Commands::Hook { command } = cli.command {
matches!(command, commands::HookCommands::Uninstall);
} else {
panic!("Expected Hook subcommand");
}
}
#[test]
fn test_cli_parse_hook_session_start() {
let cli = Cli::parse_from(["vipune", "hook", "session-start"]);
if let Commands::Hook { command } = cli.command {
matches!(command, commands::HookCommands::SessionStart);
} else {
panic!("Expected Hook subcommand");
}
}
#[test]
fn test_cli_parse_hook_user_prompt_submit() {
let cli = Cli::parse_from(["vipune", "hook", "user-prompt-submit"]);
if let Commands::Hook { command } = cli.command {
matches!(command, commands::HookCommands::UserPromptSubmit);
} else {
panic!("Expected Hook subcommand");
}
}
#[test]
fn test_cli_parse_hook_pre_tool_use() {
let cli = Cli::parse_from(["vipune", "hook", "pre-tool-use"]);
if let Commands::Hook { command } = cli.command {
matches!(command, commands::HookCommands::PreToolUse);
} else {
panic!("Expected Hook subcommand");
}
}
#[test]
fn test_cli_parse_hook_post_tool_use() {
let cli = Cli::parse_from(["vipune", "hook", "post-tool-use"]);
if let Commands::Hook { command } = cli.command {
matches!(command, commands::HookCommands::PostToolUse);
} else {
panic!("Expected Hook subcommand");
}
}
#[test]
fn test_cli_parse_hook_pre_compact() {
let cli = Cli::parse_from(["vipune", "hook", "pre-compact"]);
if let Commands::Hook { command } = cli.command {
matches!(command, commands::HookCommands::PreCompact);
} else {
panic!("Expected Hook subcommand");
}
}
#[test]
fn test_cli_parse_hook_missing_subcommand_fails() {
let result = Cli::try_parse_from(["vipune", "hook"]);
assert!(
result.is_err(),
"hook without subcommand should fail at parse time"
);
}
#[test]
fn test_cli_parse_hook_with_db_path() {
let cli = Cli::parse_from(["vipune", "--db-path", "/tmp/test.db", "hook", "install"]);
assert_eq!(cli.db_path, Some("/tmp/test.db".to_string()));
matches!(cli.command, Commands::Hook { .. });
}
}