mod commands;
mod config;
mod embedding;
mod errors;
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,
}
fn main() -> ExitCode {
let cli = Cli::parse();
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
}
}
}
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());
#[cfg(feature = "mcp")]
if matches!(cli.command, Commands::Mcp) {
vipune::mcp::server::run_mcp(
config.embedding_model.clone(),
&project_id,
config.database_path.clone(),
)
.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 tests {
use super::*;
use memory_types::{BatchIngestItemResult, IngestPolicy};
#[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 }
if id == "memory-id" && text == Some("new content".to_string()) && metadata.is_none() && memory_type.is_none() && status.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 }
if id == "memory-id" && text.is_none() && metadata == Some(r#"{"tag": "new"}"#.to_string()) && memory_type.is_none() && status.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 }
if id == "memory-id" && text == Some("new".to_string()) && metadata == Some(r#"{"key":"val"}"#.to_string()) && memory_type.is_none() && status.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_cli_parse_doctor_projects() {
let cli = Cli::parse_from(["vipune", "doctor", "--projects"]);
if let Commands::Doctor {
embeddings: false,
projects: true,
project: None,
} = 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,
project: None,
} = cli.command
{
} else {
panic!("Expected Doctor with --embeddings flag");
}
}
#[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,
project: Some(ref p),
} = 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 --embeddings or --projects should fail at parse time"
);
}
#[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 { .. });
}
}