use crate::cli::CliOutput;
use crate::cli::helpers::human_age;
use crate::models::field_names;
use crate::{color, db, models, validate};
use anyhow::Result;
use rusqlite::Connection;
use std::path::Path;
#[derive(Debug, PartialEq, Eq)]
pub enum ShellAction {
Continue,
Quit,
}
#[allow(clippy::too_many_lines)]
pub fn handle_command(parts: &[&str], conn: &Connection, out: &mut CliOutput<'_>) -> ShellAction {
if parts.is_empty() {
return ShellAction::Continue;
}
match parts[0] {
"quit" | "exit" | "q" => return ShellAction::Quit,
"help" | "h" => {
let _ = writeln!(out.stdout, " recall <context> — fuzzy recall");
let _ = writeln!(out.stdout, " search <query> — keyword search");
let _ = writeln!(out.stdout, " list [namespace] — list memories");
let _ = writeln!(out.stdout, " get <id> — show memory details");
let _ = writeln!(out.stdout, " update <id> <field>=<value> [field=value]…");
let _ = writeln!(
out.stdout,
" — mutate one or more fields (issue #653: full-profile parity)"
);
let _ = writeln!(out.stdout, " stats — show statistics");
let _ = writeln!(out.stdout, " namespaces — list namespaces");
let _ = writeln!(out.stdout, " delete <id> — delete a memory");
let _ = writeln!(out.stdout, " quit — exit shell");
}
"recall" | "r" => {
let ctx = parts[1..].join(" ");
if ctx.is_empty() {
let _ = writeln!(out.stderr, "usage: recall <context>");
return ShellAction::Continue;
}
match db::recall(
conn,
&ctx,
None,
10,
None,
None,
None,
models::SHORT_TTL_EXTEND_SECS,
models::MID_TTL_EXTEND_SECS,
None,
None,
false,
None,
) {
Ok((results, _outcome)) => {
for (mem, score) in &results {
let _ = writeln!(
out.stdout,
" [{}] {} {} score={:.2}",
color::tier_color(mem.tier.as_str(), mem.tier.as_str()),
color::bold(&mem.title),
color::priority_bar(mem.priority),
score
);
let preview: String = mem.content.chars().take(100).collect();
let _ = writeln!(out.stdout, " {}", color::dim(&preview));
}
let _ = writeln!(out.stdout, " {} result(s)", results.len());
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
}
}
"search" | "s" => {
let q = parts[1..].join(" ");
if q.is_empty() {
let _ = writeln!(out.stderr, "usage: search <query>");
return ShellAction::Continue;
}
match db::search(
conn, &q, None, None, 20, None, None, None, None, None, None, false,
) {
Ok(results) => {
for mem in &results {
let _ = writeln!(
out.stdout,
" [{}] {} (p={})",
color::tier_color(mem.tier.as_str(), mem.tier.as_str()),
mem.title,
mem.priority
);
}
let _ = writeln!(out.stdout, " {} result(s)", results.len());
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
}
}
"list" | "ls" => {
let ns = parts.get(1).copied();
match db::list(conn, ns, None, 20, 0, None, None, None, None, None) {
Ok(results) => {
for mem in &results {
let age = human_age(&mem.updated_at);
let _ = writeln!(
out.stdout,
" [{}] {} (ns={}, {})",
color::tier_color(mem.tier.as_str(), mem.tier.as_str()),
mem.title,
mem.namespace,
color::dim(&age)
);
}
let _ = writeln!(out.stdout, " {} memory(ies)", results.len());
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
}
}
"get" => {
let id = parts.get(1).copied().unwrap_or("");
if id.is_empty() {
let _ = writeln!(out.stderr, "usage: get <id>");
return ShellAction::Continue;
}
if let Err(e) = validate::validate_id(id) {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::invalid("id", e));
return ShellAction::Continue;
}
match db::get(conn, id) {
Ok(Some(mem)) => {
let _ = writeln!(
out.stdout,
"{}",
serde_json::to_string_pretty(&mem).unwrap_or_default()
);
}
Ok(None) => {
let _ = writeln!(out.stderr, "not found");
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
}
}
"update" | "u" => {
if parts.len() < 3 {
let _ = writeln!(
out.stderr,
"usage: update <id> field=value [field=value]… (fields: title, content, tier, namespace, tags, priority, confidence, expires_at)"
);
return ShellAction::Continue;
}
let raw_id = parts[1];
if let Err(e) = validate::validate_id(raw_id) {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::invalid("id", e));
return ShellAction::Continue;
}
let resolved_id = match db::get(conn, raw_id) {
Ok(Some(_)) => raw_id.to_string(),
Ok(None) => match db::get_by_prefix(conn, raw_id) {
Ok(Some(mem)) => mem.id,
Ok(None) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::not_found(raw_id));
return ShellAction::Continue;
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
return ShellAction::Continue;
}
},
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
return ShellAction::Continue;
}
};
let mut title: Option<String> = None;
let mut content: Option<String> = None;
let mut tier: Option<models::Tier> = None;
let mut namespace: Option<String> = None;
let mut tags: Option<Vec<String>> = None;
let mut priority: Option<i32> = None;
let mut confidence: Option<f64> = None;
let mut expires_at: Option<String> = None;
let mut parse_err: Option<String> = None;
for kv in &parts[2..] {
let Some((k, v)) = kv.split_once('=') else {
parse_err = Some(format!(
"expected key=value, got '{kv}' (e.g. namespace=work)"
));
break;
};
match k {
"title" => title = Some(v.to_string()),
"content" => content = Some(v.to_string()),
"tier" => match models::Tier::from_str(v) {
Some(t) => tier = Some(t),
None => {
parse_err =
Some(format!("invalid tier '{v}' (expected short/mid/long)"));
break;
}
},
"namespace" | "ns" => namespace = Some(v.to_string()),
"tags" => {
tags = Some(
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
);
}
"priority" => match v.parse::<i32>() {
Ok(p) => priority = Some(p),
Err(_) => {
parse_err = Some(format!("invalid priority '{v}' (i32 expected)"));
break;
}
},
field_names::CONFIDENCE => match v.parse::<f64>() {
Ok(c) => confidence = Some(c),
Err(_) => {
parse_err = Some(format!("invalid confidence '{v}' (0.0..=1.0)"));
break;
}
},
field_names::EXPIRES_AT => expires_at = Some(v.to_string()),
unknown => {
parse_err = Some(format!(
"unknown field '{unknown}' (one of: title, content, tier, namespace, tags, priority, confidence, expires_at)"
));
break;
}
}
}
if let Some(e) = parse_err {
let _ = writeln!(out.stderr, "{e}");
return ShellAction::Continue;
}
if let Some(ref t) = title
&& let Err(e) = validate::validate_title(t)
{
let _ = writeln!(out.stderr, "invalid title: {e}");
return ShellAction::Continue;
}
if let Some(ref c) = content
&& let Err(e) = validate::validate_content(c)
{
let _ = writeln!(out.stderr, "invalid content: {e}");
return ShellAction::Continue;
}
if let Some(ref ns) = namespace
&& let Err(e) = validate::validate_namespace(ns)
{
let _ = writeln!(out.stderr, "invalid namespace: {e}");
return ShellAction::Continue;
}
if let Some(ref tg) = tags
&& let Err(e) = validate::validate_tags(tg)
{
let _ = writeln!(out.stderr, "invalid tags: {e}");
return ShellAction::Continue;
}
if let Some(p) = priority
&& let Err(e) = validate::validate_priority(p)
{
let _ = writeln!(out.stderr, "invalid priority: {e}");
return ShellAction::Continue;
}
if let Some(c) = confidence
&& let Err(e) = validate::validate_confidence(c)
{
let _ = writeln!(out.stderr, "invalid confidence: {e}");
return ShellAction::Continue;
}
if let Some(ref ts) = expires_at
&& !ts.is_empty()
&& let Err(e) = validate::validate_expires_at_format(ts)
{
let _ = writeln!(out.stderr, "invalid expires_at: {e}");
return ShellAction::Continue;
}
match db::update(
conn,
&resolved_id,
title.as_deref(),
content.as_deref(),
tier.as_ref(),
namespace.as_deref(),
tags.as_ref(),
priority,
confidence,
expires_at.as_deref(),
None,
) {
Ok((true, _)) => {
let _ = writeln!(out.stdout, " updated: {}", color::cyan(&resolved_id));
}
Ok((false, _)) => {
let _ = writeln!(out.stderr, " not found");
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
}
}
"stats" => match db::stats(conn, Path::new(":memory:")) {
Ok(s) => {
let _ = writeln!(out.stdout, " total: {}, links: {}", s.total, s.links_count);
for t in &s.by_tier {
let _ = writeln!(
out.stdout,
" {}: {}",
color::tier_color(&t.tier, &t.tier),
t.count
);
}
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
},
field_names::NAMESPACES | "ns" => match db::list_namespaces(conn) {
Ok(ns) => {
for n in &ns {
let _ = writeln!(out.stdout, " {}: {}", color::cyan(&n.namespace), n.count);
}
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
},
"delete" | "del" | "rm" => {
let id = parts.get(1).copied().unwrap_or("");
if id.is_empty() {
let _ = writeln!(out.stderr, "usage: delete <id>");
return ShellAction::Continue;
}
if let Err(e) = validate::validate_id(id) {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::invalid("id", e));
return ShellAction::Continue;
}
match db::delete(conn, id) {
Ok(true) => {
let _ = writeln!(out.stdout, " deleted");
}
Ok(false) => {
let _ = writeln!(out.stderr, " not found");
}
Err(e) => {
let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
}
}
}
unknown => {
let _ = writeln!(
out.stderr,
"unknown command: {unknown}. Type 'help' for commands."
);
}
}
ShellAction::Continue
}
pub fn run(db_path: &Path) -> Result<()> {
let conn = db::open(db_path)?;
println!(
"{}",
color::bold("ai-memory shell — type 'help' for commands, 'quit' to exit")
);
let stdin = std::io::stdin();
let stdout_handle = std::io::stdout();
let stderr_handle = std::io::stderr();
loop {
eprint!("{} ", color::cyan("memory>"));
let mut line = String::new();
if stdin.read_line(&mut line)? == 0 {
break;
}
let parts: Vec<&str> = line.split_whitespace().collect();
let mut so = stdout_handle.lock();
let mut se = stderr_handle.lock();
let mut out = CliOutput::from_std(&mut so, &mut se);
let action = handle_command(&parts, &conn, &mut out);
drop(out);
if action == ShellAction::Quit {
break;
}
}
println!("goodbye");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::test_utils::{TestEnv, seed_memory};
fn fresh_conn(env: &TestEnv) -> Connection {
seed_memory(&env.db_path, "shell-ns", "seed", "seed-content");
db::open(&env.db_path).unwrap()
}
#[test]
fn test_shell_quit_command_returns_quit() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["quit"], &conn, &mut out);
assert_eq!(action, ShellAction::Quit);
let action = handle_command(&["exit"], &conn, &mut out);
assert_eq!(action, ShellAction::Quit);
let action = handle_command(&["q"], &conn, &mut out);
assert_eq!(action, ShellAction::Quit);
}
#[test]
fn test_shell_recall_runs_recall() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["recall", "seed"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("result(s)"));
}
#[test]
fn test_shell_recall_empty_args_writes_usage() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["recall"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("usage: recall"));
}
#[test]
fn test_shell_search_runs_search() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["search", "seed"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("result(s)"));
}
#[test]
fn test_shell_help_writes_help_text() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["help"], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("recall"));
assert!(stdout_str.contains("search"));
assert!(stdout_str.contains("quit"));
}
#[test]
fn test_shell_unknown_command_writes_error() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["frobnicate"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("unknown command"));
}
#[test]
fn test_shell_empty_parts_continues() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&[], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
}
#[test]
fn test_shell_list_runs_list() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["list"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("memory(ies)"));
}
#[test]
fn test_shell_namespaces_runs() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["namespaces"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("shell-ns"));
}
#[test]
fn test_shell_get_invalid_id_writes_error() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["get", "bad\x07id"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("invalid id"), "stderr: {stderr_str}");
}
#[test]
fn test_shell_get_missing_arg_writes_usage() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["get"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("usage: get"));
}
#[test]
fn test_shell_delete_missing_arg() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["delete"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("usage: delete"));
}
fn lookup_seeded_id(env: &TestEnv) -> String {
let conn = db::open(&env.db_path).unwrap();
let all = db::export_all(&conn).unwrap();
all.first()
.expect("seed should have inserted one row")
.id
.clone()
}
#[test]
fn shell_recall_emits_result_row_with_score() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["recall", "seed"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("score="), "got: {stdout_str}");
assert!(stdout_str.contains("result(s)"));
}
#[test]
fn shell_recall_r_alias_works() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["r", "seed"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("result(s)"));
}
#[test]
fn shell_search_emits_result_row() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["search", "seed"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("p="), "got: {stdout_str}");
assert!(stdout_str.contains("result(s)"));
}
#[test]
fn shell_search_empty_args_writes_usage() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["search"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("usage: search"));
}
#[test]
fn shell_list_emits_result_row() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["list"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("ns="), "got: {stdout_str}");
assert!(stdout_str.contains("memory(ies)"));
}
#[test]
fn shell_list_namespace_filter() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["list", "shell-ns"], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("shell-ns"));
}
#[test]
fn shell_list_ls_alias_works() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["ls"], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("memory(ies)"));
}
#[test]
fn shell_get_returns_memory_details() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["get", &id], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("\"title\""), "got: {stdout_str}");
assert!(stdout_str.contains("seed"), "got: {stdout_str}");
}
#[test]
fn shell_get_not_found_writes_stderr() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(
&["get", "00000000-0000-0000-0000-000000000000"],
&conn,
&mut out,
);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("not found"));
}
#[test]
fn shell_stats_runs() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["stats"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("total:"));
}
#[test]
fn shell_delete_success() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["delete", &id], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("deleted"));
}
#[test]
fn shell_delete_not_found_writes_stderr() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(
&["delete", "00000000-0000-0000-0000-000000000000"],
&conn,
&mut out,
);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("not found"));
}
#[test]
fn shell_delete_invalid_id() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["delete", "bad\x07id"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("invalid id"));
}
#[test]
fn shell_help_h_alias() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["h"], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("recall"));
}
#[test]
fn shell_namespaces_ns_alias() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["ns"], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("shell-ns"));
}
#[cfg(unix)]
fn with_stdin_lines<R>(lines: &str, f: impl FnOnce() -> R) -> R {
use std::os::unix::io::AsRawFd;
use std::sync::Mutex;
static STDIN_LOCK: Mutex<()> = Mutex::new(());
let _g = STDIN_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut fds: [libc::c_int; 2] = [0; 2];
unsafe {
assert_eq!(libc::pipe(fds.as_mut_ptr()), 0, "pipe()");
}
let read_fd = fds[0];
let write_fd = fds[1];
unsafe {
let bytes = lines.as_bytes();
let written = libc::write(write_fd, bytes.as_ptr().cast(), bytes.len());
assert_eq!(written, bytes.len() as isize, "write to pipe");
libc::close(write_fd);
}
let stdin = std::io::stdin();
let stdin_fd = stdin.as_raw_fd();
let saved = unsafe { libc::dup(stdin_fd) };
assert!(saved >= 0, "save stdin fd");
unsafe {
assert_eq!(libc::dup2(read_fd, stdin_fd), stdin_fd, "dup2");
libc::close(read_fd);
}
let r = f();
unsafe {
libc::dup2(saved, stdin_fd);
libc::close(saved);
}
r
}
#[cfg(unix)]
#[test]
fn shell_run_with_quit_line_returns_cleanly() {
let env = TestEnv::fresh();
seed_memory(&env.db_path, "shell-run-ns", "seed", "content");
let db = env.db_path.clone();
let r = with_stdin_lines("quit\n", || run(&db));
assert!(r.is_ok());
}
#[cfg(unix)]
#[test]
fn shell_run_with_help_then_quit() {
let env = TestEnv::fresh();
seed_memory(&env.db_path, "shell-run-ns", "seed", "content");
let db = env.db_path.clone();
let r = with_stdin_lines("help\nquit\n", || run(&db));
assert!(r.is_ok());
}
#[test]
fn shell_run_with_eof_stdin_returns_cleanly() {
let env = TestEnv::fresh();
seed_memory(&env.db_path, "shell-run-ns", "seed", "content");
let r = run(&env.db_path);
assert!(r.is_ok());
}
#[test]
fn shell_delete_aliases() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
{
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["del", &id], &conn, &mut out);
assert!(String::from_utf8(stdout).unwrap().contains("deleted"));
}
seed_memory(&env.db_path, "shell-ns", "seed2", "seed-content-2");
let conn2 = db::open(&env.db_path).unwrap();
let id2 = {
let all = db::export_all(&conn2).unwrap();
all.iter().find(|m| m.title == "seed2").unwrap().id.clone()
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["rm", &id2], &conn2, &mut out);
assert!(String::from_utf8(stdout).unwrap().contains("deleted"));
}
#[test]
fn shell_update_changes_namespace() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(&["update", &id, "namespace=migrated"], &conn, &mut out);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("updated:"), "stdout: {stdout_str}");
let mem = db::get(&conn, &id).unwrap().unwrap();
assert_eq!(mem.namespace, "migrated");
}
#[test]
fn shell_update_multiple_fields_one_call() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let action = handle_command(
&[
"update",
&id,
"title=renamed",
"priority=9",
"confidence=0.9",
],
&conn,
&mut out,
);
assert_eq!(action, ShellAction::Continue);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("updated:"), "stdout: {stdout_str}");
let mem = db::get(&conn, &id).unwrap().unwrap();
assert_eq!(mem.title, "renamed");
assert_eq!(mem.priority, 9);
assert!((mem.confidence - 0.9).abs() < f64::EPSILON);
}
#[test]
fn shell_update_short_alias_u_works() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["u", &id, "namespace=via-alias"], &conn, &mut out);
let mem = db::get(&conn, &id).unwrap().unwrap();
assert_eq!(mem.namespace, "via-alias");
}
#[test]
fn shell_update_missing_args_writes_usage() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["update"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("usage: update"));
}
#[test]
fn shell_update_missing_kv_writes_usage() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["update", &id], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("usage: update"));
}
#[test]
fn shell_update_unknown_field_errors() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["update", &id, "frobnitz=value"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("unknown field"), "stderr: {stderr_str}");
}
#[test]
fn shell_update_malformed_kv_errors() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["update", &id, "no-equals-sign"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(
stderr_str.contains("expected key=value"),
"stderr: {stderr_str}"
);
}
#[test]
fn shell_update_invalid_tier_errors() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let id = lookup_seeded_id(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["update", &id, "tier=archived"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("invalid tier"), "stderr: {stderr_str}");
}
#[test]
fn shell_update_invalid_id_errors() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["update", "bad\x07id", "namespace=foo"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("invalid id"), "stderr: {stderr_str}");
}
#[test]
fn shell_update_nonexistent_id_writes_not_found() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
let fake = "deadbeef-dead-beef-dead-beefdeadbeef";
handle_command(&["update", fake, "namespace=foo"], &conn, &mut out);
let stderr_str = String::from_utf8(stderr).unwrap();
assert!(stderr_str.contains("not found"), "stderr: {stderr_str}");
}
#[test]
fn shell_help_lists_update_command() {
let env = TestEnv::fresh();
let conn = fresh_conn(&env);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
handle_command(&["help"], &conn, &mut out);
let stdout_str = String::from_utf8(stdout).unwrap();
assert!(stdout_str.contains("update <id>"), "help: {stdout_str}");
assert!(stdout_str.contains("#653"), "help: {stdout_str}");
}
}