Skip to main content

ai_memory/cli/
shell.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `cmd_shell` REPL migration. The line-handling logic is extracted into
5//! `handle_command(parts, conn, out)` so unit tests can drive command
6//! parsing/dispatch without spawning a subprocess. The outer stdin loop
7//! is intentionally minimal and is **not** covered by unit tests — its
8//! `read_line` blocking call would deadlock a buffer-driven test fixture.
9
10use crate::cli::CliOutput;
11use crate::cli::helpers::human_age;
12use crate::models::field_names;
13use crate::{color, db, models, validate};
14use anyhow::Result;
15use rusqlite::Connection;
16use std::path::Path;
17
18/// Returned by `handle_command` to signal whether the REPL should keep
19/// reading more lines.
20#[derive(Debug, PartialEq, Eq)]
21pub enum ShellAction {
22    /// Continue reading the next prompt line.
23    Continue,
24    /// Exit the REPL cleanly.
25    Quit,
26}
27
28/// REPL command dispatcher. Splits its input into a command + tail and
29/// emits all output through `out`. Returns `Quit` on `quit/exit/q`,
30/// `Continue` otherwise.
31#[allow(clippy::too_many_lines)]
32pub fn handle_command(parts: &[&str], conn: &Connection, out: &mut CliOutput<'_>) -> ShellAction {
33    if parts.is_empty() {
34        return ShellAction::Continue;
35    }
36    match parts[0] {
37        "quit" | "exit" | "q" => return ShellAction::Quit,
38        "help" | "h" => {
39            let _ = writeln!(out.stdout, "  recall <context>    — fuzzy recall");
40            let _ = writeln!(out.stdout, "  search <query>      — keyword search");
41            let _ = writeln!(out.stdout, "  list [namespace]    — list memories");
42            let _ = writeln!(out.stdout, "  get <id>            — show memory details");
43            let _ = writeln!(out.stdout, "  update <id> <field>=<value> [field=value]…");
44            let _ = writeln!(
45                out.stdout,
46                "                       — mutate one or more fields (issue #653: full-profile parity)"
47            );
48            let _ = writeln!(out.stdout, "  stats               — show statistics");
49            let _ = writeln!(out.stdout, "  namespaces          — list namespaces");
50            let _ = writeln!(out.stdout, "  delete <id>         — delete a memory");
51            let _ = writeln!(out.stdout, "  quit                — exit shell");
52        }
53        "recall" | "r" => {
54            let ctx = parts[1..].join(" ");
55            if ctx.is_empty() {
56                let _ = writeln!(out.stderr, "usage: recall <context>");
57                return ShellAction::Continue;
58            }
59            match db::recall(
60                conn,
61                &ctx,
62                None,
63                10,
64                None,
65                None,
66                None,
67                models::SHORT_TTL_EXTEND_SECS,
68                models::MID_TTL_EXTEND_SECS,
69                None,
70                None,
71                false,
72                None,
73            ) {
74                Ok((results, _outcome)) => {
75                    for (mem, score) in &results {
76                        let _ = writeln!(
77                            out.stdout,
78                            "  [{}] {} {} score={:.2}",
79                            color::tier_color(mem.tier.as_str(), mem.tier.as_str()),
80                            color::bold(&mem.title),
81                            color::priority_bar(mem.priority),
82                            score
83                        );
84                        let preview: String = mem.content.chars().take(100).collect();
85                        let _ = writeln!(out.stdout, "    {}", color::dim(&preview));
86                    }
87                    let _ = writeln!(out.stdout, "  {} result(s)", results.len());
88                }
89                Err(e) => {
90                    let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
91                }
92            }
93        }
94        "search" | "s" => {
95            let q = parts[1..].join(" ");
96            if q.is_empty() {
97                let _ = writeln!(out.stderr, "usage: search <query>");
98                return ShellAction::Continue;
99            }
100            match db::search(
101                conn, &q, None, None, 20, None, None, None, None, None, None, false,
102            ) {
103                Ok(results) => {
104                    for mem in &results {
105                        let _ = writeln!(
106                            out.stdout,
107                            "  [{}] {} (p={})",
108                            color::tier_color(mem.tier.as_str(), mem.tier.as_str()),
109                            mem.title,
110                            mem.priority
111                        );
112                    }
113                    let _ = writeln!(out.stdout, "  {} result(s)", results.len());
114                }
115                Err(e) => {
116                    let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
117                }
118            }
119        }
120        "list" | "ls" => {
121            let ns = parts.get(1).copied();
122            match db::list(conn, ns, None, 20, 0, None, None, None, None, None) {
123                Ok(results) => {
124                    for mem in &results {
125                        let age = human_age(&mem.updated_at);
126                        let _ = writeln!(
127                            out.stdout,
128                            "  [{}] {} (ns={}, {})",
129                            color::tier_color(mem.tier.as_str(), mem.tier.as_str()),
130                            mem.title,
131                            mem.namespace,
132                            color::dim(&age)
133                        );
134                    }
135                    let _ = writeln!(out.stdout, "  {} memory(ies)", results.len());
136                }
137                Err(e) => {
138                    let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
139                }
140            }
141        }
142        "get" => {
143            let id = parts.get(1).copied().unwrap_or("");
144            if id.is_empty() {
145                let _ = writeln!(out.stderr, "usage: get <id>");
146                return ShellAction::Continue;
147            }
148            if let Err(e) = validate::validate_id(id) {
149                let _ = writeln!(out.stderr, "{}", crate::errors::msg::invalid("id", e));
150                return ShellAction::Continue;
151            }
152            match db::get(conn, id) {
153                Ok(Some(mem)) => {
154                    let _ = writeln!(
155                        out.stdout,
156                        "{}",
157                        serde_json::to_string_pretty(&mem).unwrap_or_default()
158                    );
159                }
160                Ok(None) => {
161                    let _ = writeln!(out.stderr, "not found");
162                }
163                Err(e) => {
164                    let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
165                }
166            }
167        }
168        "update" | "u" => {
169            // Issue #653 — REPL parity with the `--profile full` MCP
170            // `memory_update` surface. Parses `update <id> field=value
171            // [field=value]…` where field ∈ {title, content, tier,
172            // namespace, tags, priority, confidence, expires_at}.
173            // Honors the same validators the CLI `update` subcommand
174            // uses (`crate::validate::*`). Empty `expires_at=` clears
175            // the expiry; comma-separated `tags=` splits + trims.
176            if parts.len() < 3 {
177                let _ = writeln!(
178                    out.stderr,
179                    "usage: update <id> field=value [field=value]…  (fields: title, content, tier, namespace, tags, priority, confidence, expires_at)"
180                );
181                return ShellAction::Continue;
182            }
183            let raw_id = parts[1];
184            if let Err(e) = validate::validate_id(raw_id) {
185                let _ = writeln!(out.stderr, "{}", crate::errors::msg::invalid("id", e));
186                return ShellAction::Continue;
187            }
188            let resolved_id = match db::get(conn, raw_id) {
189                Ok(Some(_)) => raw_id.to_string(),
190                Ok(None) => match db::get_by_prefix(conn, raw_id) {
191                    Ok(Some(mem)) => mem.id,
192                    Ok(None) => {
193                        let _ = writeln!(out.stderr, "{}", crate::errors::msg::not_found(raw_id));
194                        return ShellAction::Continue;
195                    }
196                    Err(e) => {
197                        let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
198                        return ShellAction::Continue;
199                    }
200                },
201                Err(e) => {
202                    let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
203                    return ShellAction::Continue;
204                }
205            };
206            let mut title: Option<String> = None;
207            let mut content: Option<String> = None;
208            let mut tier: Option<models::Tier> = None;
209            let mut namespace: Option<String> = None;
210            let mut tags: Option<Vec<String>> = None;
211            let mut priority: Option<i32> = None;
212            let mut confidence: Option<f64> = None;
213            let mut expires_at: Option<String> = None;
214            let mut parse_err: Option<String> = None;
215            for kv in &parts[2..] {
216                let Some((k, v)) = kv.split_once('=') else {
217                    parse_err = Some(format!(
218                        "expected key=value, got '{kv}' (e.g. namespace=work)"
219                    ));
220                    break;
221                };
222                match k {
223                    "title" => title = Some(v.to_string()),
224                    "content" => content = Some(v.to_string()),
225                    "tier" => match models::Tier::from_str(v) {
226                        Some(t) => tier = Some(t),
227                        None => {
228                            parse_err =
229                                Some(format!("invalid tier '{v}' (expected short/mid/long)"));
230                            break;
231                        }
232                    },
233                    "namespace" | "ns" => namespace = Some(v.to_string()),
234                    "tags" => {
235                        tags = Some(
236                            v.split(',')
237                                .map(|s| s.trim().to_string())
238                                .filter(|s| !s.is_empty())
239                                .collect(),
240                        );
241                    }
242                    "priority" => match v.parse::<i32>() {
243                        Ok(p) => priority = Some(p),
244                        Err(_) => {
245                            parse_err = Some(format!("invalid priority '{v}' (i32 expected)"));
246                            break;
247                        }
248                    },
249                    field_names::CONFIDENCE => match v.parse::<f64>() {
250                        Ok(c) => confidence = Some(c),
251                        Err(_) => {
252                            parse_err = Some(format!("invalid confidence '{v}' (0.0..=1.0)"));
253                            break;
254                        }
255                    },
256                    field_names::EXPIRES_AT => expires_at = Some(v.to_string()),
257                    unknown => {
258                        parse_err = Some(format!(
259                            "unknown field '{unknown}' (one of: title, content, tier, namespace, tags, priority, confidence, expires_at)"
260                        ));
261                        break;
262                    }
263                }
264            }
265            if let Some(e) = parse_err {
266                let _ = writeln!(out.stderr, "{e}");
267                return ShellAction::Continue;
268            }
269            if let Some(ref t) = title
270                && let Err(e) = validate::validate_title(t)
271            {
272                let _ = writeln!(out.stderr, "invalid title: {e}");
273                return ShellAction::Continue;
274            }
275            if let Some(ref c) = content
276                && let Err(e) = validate::validate_content(c)
277            {
278                let _ = writeln!(out.stderr, "invalid content: {e}");
279                return ShellAction::Continue;
280            }
281            if let Some(ref ns) = namespace
282                && let Err(e) = validate::validate_namespace(ns)
283            {
284                let _ = writeln!(out.stderr, "invalid namespace: {e}");
285                return ShellAction::Continue;
286            }
287            if let Some(ref tg) = tags
288                && let Err(e) = validate::validate_tags(tg)
289            {
290                let _ = writeln!(out.stderr, "invalid tags: {e}");
291                return ShellAction::Continue;
292            }
293            if let Some(p) = priority
294                && let Err(e) = validate::validate_priority(p)
295            {
296                let _ = writeln!(out.stderr, "invalid priority: {e}");
297                return ShellAction::Continue;
298            }
299            if let Some(c) = confidence
300                && let Err(e) = validate::validate_confidence(c)
301            {
302                let _ = writeln!(out.stderr, "invalid confidence: {e}");
303                return ShellAction::Continue;
304            }
305            if let Some(ref ts) = expires_at
306                && !ts.is_empty()
307                && let Err(e) = validate::validate_expires_at_format(ts)
308            {
309                let _ = writeln!(out.stderr, "invalid expires_at: {e}");
310                return ShellAction::Continue;
311            }
312            match db::update(
313                conn,
314                &resolved_id,
315                title.as_deref(),
316                content.as_deref(),
317                tier.as_ref(),
318                namespace.as_deref(),
319                tags.as_ref(),
320                priority,
321                confidence,
322                expires_at.as_deref(),
323                None,
324            ) {
325                Ok((true, _)) => {
326                    let _ = writeln!(out.stdout, "  updated: {}", color::cyan(&resolved_id));
327                }
328                Ok((false, _)) => {
329                    let _ = writeln!(out.stderr, "  not found");
330                }
331                Err(e) => {
332                    let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
333                }
334            }
335        }
336        "stats" => match db::stats(conn, Path::new(":memory:")) {
337            Ok(s) => {
338                let _ = writeln!(out.stdout, "  total: {}, links: {}", s.total, s.links_count);
339                for t in &s.by_tier {
340                    let _ = writeln!(
341                        out.stdout,
342                        "    {}: {}",
343                        color::tier_color(&t.tier, &t.tier),
344                        t.count
345                    );
346                }
347            }
348            Err(e) => {
349                let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
350            }
351        },
352        field_names::NAMESPACES | "ns" => match db::list_namespaces(conn) {
353            Ok(ns) => {
354                for n in &ns {
355                    let _ = writeln!(out.stdout, "  {}: {}", color::cyan(&n.namespace), n.count);
356                }
357            }
358            Err(e) => {
359                let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
360            }
361        },
362        "delete" | "del" | "rm" => {
363            let id = parts.get(1).copied().unwrap_or("");
364            if id.is_empty() {
365                let _ = writeln!(out.stderr, "usage: delete <id>");
366                return ShellAction::Continue;
367            }
368            if let Err(e) = validate::validate_id(id) {
369                let _ = writeln!(out.stderr, "{}", crate::errors::msg::invalid("id", e));
370                return ShellAction::Continue;
371            }
372            match db::delete(conn, id) {
373                Ok(true) => {
374                    let _ = writeln!(out.stdout, "  deleted");
375                }
376                Ok(false) => {
377                    let _ = writeln!(out.stderr, "  not found");
378                }
379                Err(e) => {
380                    let _ = writeln!(out.stderr, "{}", crate::errors::msg::error_line(&e));
381                }
382            }
383        }
384        unknown => {
385            let _ = writeln!(
386                out.stderr,
387                "unknown command: {unknown}. Type 'help' for commands."
388            );
389        }
390    }
391    ShellAction::Continue
392}
393
394/// `shell` handler. Outer stdin loop. Not unit-tested — the blocking
395/// `read_line` call would deadlock a `Vec<u8>` test fixture; the line
396/// handler logic lives in `handle_command`, which is exhaustively tested.
397pub fn run(db_path: &Path) -> Result<()> {
398    let conn = db::open(db_path)?;
399    println!(
400        "{}",
401        color::bold("ai-memory shell — type 'help' for commands, 'quit' to exit")
402    );
403    let stdin = std::io::stdin();
404    let stdout_handle = std::io::stdout();
405    let stderr_handle = std::io::stderr();
406    loop {
407        eprint!("{} ", color::cyan("memory>"));
408        let mut line = String::new();
409        if stdin.read_line(&mut line)? == 0 {
410            break;
411        }
412        let parts: Vec<&str> = line.split_whitespace().collect();
413        let mut so = stdout_handle.lock();
414        let mut se = stderr_handle.lock();
415        let mut out = CliOutput::from_std(&mut so, &mut se);
416        let action = handle_command(&parts, &conn, &mut out);
417        drop(out);
418        if action == ShellAction::Quit {
419            break;
420        }
421    }
422    println!("goodbye");
423    Ok(())
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::cli::test_utils::{TestEnv, seed_memory};
430
431    fn fresh_conn(env: &TestEnv) -> Connection {
432        // Seed at least once so the schema is materialised, then reopen.
433        seed_memory(&env.db_path, "shell-ns", "seed", "seed-content");
434        db::open(&env.db_path).unwrap()
435    }
436
437    #[test]
438    fn test_shell_quit_command_returns_quit() {
439        let env = TestEnv::fresh();
440        let conn = fresh_conn(&env);
441        let mut stdout = Vec::new();
442        let mut stderr = Vec::new();
443        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
444        let action = handle_command(&["quit"], &conn, &mut out);
445        assert_eq!(action, ShellAction::Quit);
446        let action = handle_command(&["exit"], &conn, &mut out);
447        assert_eq!(action, ShellAction::Quit);
448        let action = handle_command(&["q"], &conn, &mut out);
449        assert_eq!(action, ShellAction::Quit);
450    }
451
452    #[test]
453    fn test_shell_recall_runs_recall() {
454        let env = TestEnv::fresh();
455        let conn = fresh_conn(&env);
456        let mut stdout = Vec::new();
457        let mut stderr = Vec::new();
458        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
459        let action = handle_command(&["recall", "seed"], &conn, &mut out);
460        assert_eq!(action, ShellAction::Continue);
461        let stdout_str = String::from_utf8(stdout).unwrap();
462        assert!(stdout_str.contains("result(s)"));
463    }
464
465    #[test]
466    fn test_shell_recall_empty_args_writes_usage() {
467        let env = TestEnv::fresh();
468        let conn = fresh_conn(&env);
469        let mut stdout = Vec::new();
470        let mut stderr = Vec::new();
471        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
472        handle_command(&["recall"], &conn, &mut out);
473        let stderr_str = String::from_utf8(stderr).unwrap();
474        assert!(stderr_str.contains("usage: recall"));
475    }
476
477    #[test]
478    fn test_shell_search_runs_search() {
479        let env = TestEnv::fresh();
480        let conn = fresh_conn(&env);
481        let mut stdout = Vec::new();
482        let mut stderr = Vec::new();
483        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
484        let action = handle_command(&["search", "seed"], &conn, &mut out);
485        assert_eq!(action, ShellAction::Continue);
486        let stdout_str = String::from_utf8(stdout).unwrap();
487        assert!(stdout_str.contains("result(s)"));
488    }
489
490    #[test]
491    fn test_shell_help_writes_help_text() {
492        let env = TestEnv::fresh();
493        let conn = fresh_conn(&env);
494        let mut stdout = Vec::new();
495        let mut stderr = Vec::new();
496        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
497        handle_command(&["help"], &conn, &mut out);
498        let stdout_str = String::from_utf8(stdout).unwrap();
499        assert!(stdout_str.contains("recall"));
500        assert!(stdout_str.contains("search"));
501        assert!(stdout_str.contains("quit"));
502    }
503
504    #[test]
505    fn test_shell_unknown_command_writes_error() {
506        let env = TestEnv::fresh();
507        let conn = fresh_conn(&env);
508        let mut stdout = Vec::new();
509        let mut stderr = Vec::new();
510        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
511        let action = handle_command(&["frobnicate"], &conn, &mut out);
512        assert_eq!(action, ShellAction::Continue);
513        let stderr_str = String::from_utf8(stderr).unwrap();
514        assert!(stderr_str.contains("unknown command"));
515    }
516
517    #[test]
518    fn test_shell_empty_parts_continues() {
519        let env = TestEnv::fresh();
520        let conn = fresh_conn(&env);
521        let mut stdout = Vec::new();
522        let mut stderr = Vec::new();
523        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
524        let action = handle_command(&[], &conn, &mut out);
525        assert_eq!(action, ShellAction::Continue);
526    }
527
528    #[test]
529    fn test_shell_list_runs_list() {
530        let env = TestEnv::fresh();
531        let conn = fresh_conn(&env);
532        let mut stdout = Vec::new();
533        let mut stderr = Vec::new();
534        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
535        let action = handle_command(&["list"], &conn, &mut out);
536        assert_eq!(action, ShellAction::Continue);
537        let stdout_str = String::from_utf8(stdout).unwrap();
538        assert!(stdout_str.contains("memory(ies)"));
539    }
540
541    #[test]
542    fn test_shell_namespaces_runs() {
543        let env = TestEnv::fresh();
544        let conn = fresh_conn(&env);
545        let mut stdout = Vec::new();
546        let mut stderr = Vec::new();
547        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
548        let action = handle_command(&["namespaces"], &conn, &mut out);
549        assert_eq!(action, ShellAction::Continue);
550        let stdout_str = String::from_utf8(stdout).unwrap();
551        assert!(stdout_str.contains("shell-ns"));
552    }
553
554    #[test]
555    fn test_shell_get_invalid_id_writes_error() {
556        let env = TestEnv::fresh();
557        let conn = fresh_conn(&env);
558        let mut stdout = Vec::new();
559        let mut stderr = Vec::new();
560        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
561        // Trigger "id contains invalid characters" via a control character.
562        handle_command(&["get", "bad\x07id"], &conn, &mut out);
563        let stderr_str = String::from_utf8(stderr).unwrap();
564        assert!(stderr_str.contains("invalid id"), "stderr: {stderr_str}");
565    }
566
567    #[test]
568    fn test_shell_get_missing_arg_writes_usage() {
569        let env = TestEnv::fresh();
570        let conn = fresh_conn(&env);
571        let mut stdout = Vec::new();
572        let mut stderr = Vec::new();
573        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
574        handle_command(&["get"], &conn, &mut out);
575        let stderr_str = String::from_utf8(stderr).unwrap();
576        assert!(stderr_str.contains("usage: get"));
577    }
578
579    #[test]
580    fn test_shell_delete_missing_arg() {
581        let env = TestEnv::fresh();
582        let conn = fresh_conn(&env);
583        let mut stdout = Vec::new();
584        let mut stderr = Vec::new();
585        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
586        handle_command(&["delete"], &conn, &mut out);
587        let stderr_str = String::from_utf8(stderr).unwrap();
588        assert!(stderr_str.contains("usage: delete"));
589    }
590
591    // ----------------------------------------------------------------
592    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
593    // ----------------------------------------------------------------
594
595    /// Look up a seeded memory id directly so we can drive the
596    /// `get`/`delete` happy paths without guessing UUIDs.
597    fn lookup_seeded_id(env: &TestEnv) -> String {
598        let conn = db::open(&env.db_path).unwrap();
599        let all = db::export_all(&conn).unwrap();
600        all.first()
601            .expect("seed should have inserted one row")
602            .id
603            .clone()
604    }
605
606    #[test]
607    fn shell_recall_emits_result_row_with_score() {
608        // Drives the recall result-printing branch (lines 67-79). The
609        // seed memory's title matches "seed" so we get a hit.
610        let env = TestEnv::fresh();
611        let conn = fresh_conn(&env);
612        let mut stdout = Vec::new();
613        let mut stderr = Vec::new();
614        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
615        let action = handle_command(&["recall", "seed"], &conn, &mut out);
616        assert_eq!(action, ShellAction::Continue);
617        let stdout_str = String::from_utf8(stdout).unwrap();
618        assert!(stdout_str.contains("score="), "got: {stdout_str}");
619        // Result count line at the end.
620        assert!(stdout_str.contains("result(s)"));
621    }
622
623    #[test]
624    fn shell_recall_r_alias_works() {
625        let env = TestEnv::fresh();
626        let conn = fresh_conn(&env);
627        let mut stdout = Vec::new();
628        let mut stderr = Vec::new();
629        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
630        let action = handle_command(&["r", "seed"], &conn, &mut out);
631        assert_eq!(action, ShellAction::Continue);
632        let stdout_str = String::from_utf8(stdout).unwrap();
633        assert!(stdout_str.contains("result(s)"));
634    }
635
636    #[test]
637    fn shell_search_emits_result_row() {
638        // Drives the search result-printing branch (lines 94-103).
639        let env = TestEnv::fresh();
640        let conn = fresh_conn(&env);
641        let mut stdout = Vec::new();
642        let mut stderr = Vec::new();
643        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
644        let action = handle_command(&["search", "seed"], &conn, &mut out);
645        assert_eq!(action, ShellAction::Continue);
646        let stdout_str = String::from_utf8(stdout).unwrap();
647        assert!(stdout_str.contains("p="), "got: {stdout_str}");
648        assert!(stdout_str.contains("result(s)"));
649    }
650
651    #[test]
652    fn shell_search_empty_args_writes_usage() {
653        let env = TestEnv::fresh();
654        let conn = fresh_conn(&env);
655        let mut stdout = Vec::new();
656        let mut stderr = Vec::new();
657        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
658        let action = handle_command(&["search"], &conn, &mut out);
659        assert_eq!(action, ShellAction::Continue);
660        let stderr_str = String::from_utf8(stderr).unwrap();
661        assert!(stderr_str.contains("usage: search"));
662    }
663
664    #[test]
665    fn shell_list_emits_result_row() {
666        // Drives the list result-printing branch (lines 114-125).
667        let env = TestEnv::fresh();
668        let conn = fresh_conn(&env);
669        let mut stdout = Vec::new();
670        let mut stderr = Vec::new();
671        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
672        let action = handle_command(&["list"], &conn, &mut out);
673        assert_eq!(action, ShellAction::Continue);
674        let stdout_str = String::from_utf8(stdout).unwrap();
675        // Each row carries "ns=" and the trailing count line.
676        assert!(stdout_str.contains("ns="), "got: {stdout_str}");
677        assert!(stdout_str.contains("memory(ies)"));
678    }
679
680    #[test]
681    fn shell_list_namespace_filter() {
682        // Drives the `parts.get(1)` namespace argument path.
683        let env = TestEnv::fresh();
684        let conn = fresh_conn(&env);
685        let mut stdout = Vec::new();
686        let mut stderr = Vec::new();
687        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
688        handle_command(&["list", "shell-ns"], &conn, &mut out);
689        let stdout_str = String::from_utf8(stdout).unwrap();
690        assert!(stdout_str.contains("shell-ns"));
691    }
692
693    #[test]
694    fn shell_list_ls_alias_works() {
695        let env = TestEnv::fresh();
696        let conn = fresh_conn(&env);
697        let mut stdout = Vec::new();
698        let mut stderr = Vec::new();
699        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
700        handle_command(&["ls"], &conn, &mut out);
701        let stdout_str = String::from_utf8(stdout).unwrap();
702        assert!(stdout_str.contains("memory(ies)"));
703    }
704
705    #[test]
706    fn shell_get_returns_memory_details() {
707        // Drives the get(success) JSON-pretty-print branch (lines 143-148).
708        let env = TestEnv::fresh();
709        let conn = fresh_conn(&env);
710        let id = lookup_seeded_id(&env);
711        let mut stdout = Vec::new();
712        let mut stderr = Vec::new();
713        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
714        handle_command(&["get", &id], &conn, &mut out);
715        let stdout_str = String::from_utf8(stdout).unwrap();
716        // JSON pretty includes the title field literal.
717        assert!(stdout_str.contains("\"title\""), "got: {stdout_str}");
718        assert!(stdout_str.contains("seed"), "got: {stdout_str}");
719    }
720
721    #[test]
722    fn shell_get_not_found_writes_stderr() {
723        // Drives the get(Ok(None)) branch (line 151).
724        let env = TestEnv::fresh();
725        let conn = fresh_conn(&env);
726        let mut stdout = Vec::new();
727        let mut stderr = Vec::new();
728        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
729        // A syntactically-valid id that does not exist.
730        handle_command(
731            &["get", "00000000-0000-0000-0000-000000000000"],
732            &conn,
733            &mut out,
734        );
735        let stderr_str = String::from_utf8(stderr).unwrap();
736        assert!(stderr_str.contains("not found"));
737    }
738
739    #[test]
740    fn shell_stats_runs() {
741        // Drives the stats success branch (lines 159-168).
742        let env = TestEnv::fresh();
743        let conn = fresh_conn(&env);
744        let mut stdout = Vec::new();
745        let mut stderr = Vec::new();
746        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
747        let action = handle_command(&["stats"], &conn, &mut out);
748        assert_eq!(action, ShellAction::Continue);
749        let stdout_str = String::from_utf8(stdout).unwrap();
750        assert!(stdout_str.contains("total:"));
751    }
752
753    #[test]
754    fn shell_delete_success() {
755        // Drives the delete(Ok(true)) branch (line 195-197).
756        let env = TestEnv::fresh();
757        let conn = fresh_conn(&env);
758        let id = lookup_seeded_id(&env);
759        let mut stdout = Vec::new();
760        let mut stderr = Vec::new();
761        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
762        handle_command(&["delete", &id], &conn, &mut out);
763        let stdout_str = String::from_utf8(stdout).unwrap();
764        assert!(stdout_str.contains("deleted"));
765    }
766
767    #[test]
768    fn shell_delete_not_found_writes_stderr() {
769        // Drives the delete(Ok(false)) branch (line 198-200).
770        let env = TestEnv::fresh();
771        let conn = fresh_conn(&env);
772        let mut stdout = Vec::new();
773        let mut stderr = Vec::new();
774        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
775        handle_command(
776            &["delete", "00000000-0000-0000-0000-000000000000"],
777            &conn,
778            &mut out,
779        );
780        let stderr_str = String::from_utf8(stderr).unwrap();
781        assert!(stderr_str.contains("not found"));
782    }
783
784    #[test]
785    fn shell_delete_invalid_id() {
786        // Drives the validate_id-error branch on delete (line 191-192).
787        let env = TestEnv::fresh();
788        let conn = fresh_conn(&env);
789        let mut stdout = Vec::new();
790        let mut stderr = Vec::new();
791        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
792        handle_command(&["delete", "bad\x07id"], &conn, &mut out);
793        let stderr_str = String::from_utf8(stderr).unwrap();
794        assert!(stderr_str.contains("invalid id"));
795    }
796
797    #[test]
798    fn shell_help_h_alias() {
799        let env = TestEnv::fresh();
800        let conn = fresh_conn(&env);
801        let mut stdout = Vec::new();
802        let mut stderr = Vec::new();
803        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
804        handle_command(&["h"], &conn, &mut out);
805        let stdout_str = String::from_utf8(stdout).unwrap();
806        assert!(stdout_str.contains("recall"));
807    }
808
809    #[test]
810    fn shell_namespaces_ns_alias() {
811        let env = TestEnv::fresh();
812        let conn = fresh_conn(&env);
813        let mut stdout = Vec::new();
814        let mut stderr = Vec::new();
815        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
816        handle_command(&["ns"], &conn, &mut out);
817        let stdout_str = String::from_utf8(stdout).unwrap();
818        assert!(stdout_str.contains("shell-ns"));
819    }
820
821    /// Pipe-driven stdin redirect for unit-testing `run()`. Writes
822    /// `lines` to a pipe, dup2s the pipe over fd 0 for the duration
823    /// of `f`, then restores the original stdin. Unix-only (the
824    /// playbook explicitly forbids modifying the CLI surface, so we
825    /// stretch the test harness instead).
826    #[cfg(unix)]
827    fn with_stdin_lines<R>(lines: &str, f: impl FnOnce() -> R) -> R {
828        use std::os::unix::io::AsRawFd;
829        use std::sync::Mutex;
830        static STDIN_LOCK: Mutex<()> = Mutex::new(());
831        let _g = STDIN_LOCK.lock().unwrap_or_else(|e| e.into_inner());
832
833        // Build a pipe; write the lines into the write end then close it
834        // so the read end yields EOF after the buffered content is drained.
835        let mut fds: [libc::c_int; 2] = [0; 2];
836        unsafe {
837            assert_eq!(libc::pipe(fds.as_mut_ptr()), 0, "pipe()");
838        }
839        let read_fd = fds[0];
840        let write_fd = fds[1];
841        unsafe {
842            let bytes = lines.as_bytes();
843            let written = libc::write(write_fd, bytes.as_ptr().cast(), bytes.len());
844            assert_eq!(written, bytes.len() as isize, "write to pipe");
845            libc::close(write_fd);
846        }
847
848        // Snapshot stdin's current fd and dup2 the read end over fd 0.
849        let stdin = std::io::stdin();
850        let stdin_fd = stdin.as_raw_fd();
851        let saved = unsafe { libc::dup(stdin_fd) };
852        assert!(saved >= 0, "save stdin fd");
853        unsafe {
854            assert_eq!(libc::dup2(read_fd, stdin_fd), stdin_fd, "dup2");
855            libc::close(read_fd);
856        }
857
858        let r = f();
859
860        // Restore stdin.
861        unsafe {
862            libc::dup2(saved, stdin_fd);
863            libc::close(saved);
864        }
865        r
866    }
867
868    #[cfg(unix)]
869    #[test]
870    fn shell_run_with_quit_line_returns_cleanly() {
871        // Feeds a single "quit\n" line through stdin, then EOF. The
872        // REPL must call handle_command which returns ShellAction::Quit
873        // and break.
874        let env = TestEnv::fresh();
875        seed_memory(&env.db_path, "shell-run-ns", "seed", "content");
876        let db = env.db_path.clone();
877        let r = with_stdin_lines("quit\n", || run(&db));
878        assert!(r.is_ok());
879    }
880
881    #[cfg(unix)]
882    #[test]
883    fn shell_run_with_help_then_quit() {
884        // Two-line input drives both `read_line` (twice) and
885        // handle_command (twice).
886        let env = TestEnv::fresh();
887        seed_memory(&env.db_path, "shell-run-ns", "seed", "content");
888        let db = env.db_path.clone();
889        let r = with_stdin_lines("help\nquit\n", || run(&db));
890        assert!(r.is_ok());
891    }
892
893    #[test]
894    fn shell_run_with_eof_stdin_returns_cleanly() {
895        // The outer REPL `run()` reads from process stdin. Under
896        // `cargo test`, stdin is connected to `/dev/null` (which yields
897        // EOF on first read) on every host this codebase is tested on
898        // (macOS, Linux CI), so the read_line loop short-circuits to
899        // `Ok(())` without ever blocking.
900        //
901        // This is the only viable unit-test path for `run()`: the
902        // function's I/O contract is hard-wired to `std::io::stdin()`
903        // / `std::io::stdout()` / `std::io::stderr()` and we are not
904        // allowed to refactor it for testability (see playbook §1
905        // "do not modify CLI clap definitions").
906        //
907        // If a future CI introduces a stdin that does not yield EOF
908        // (e.g. an interactive harness) this test will hang. The fix
909        // is to gate it behind `#[cfg(target_family = "unix")]` and
910        // pipe `/dev/null` to stdin explicitly via `nix::dup2`.
911        let env = TestEnv::fresh();
912        // Seed first so db::open finds an existing schema.
913        seed_memory(&env.db_path, "shell-run-ns", "seed", "content");
914        // We can't capture stdout/stderr from `println!`/`eprint!` here,
915        // and `run()` blocks if stdin doesn't EOF. Under cargo test,
916        // stdin is /dev/null which yields EOF immediately. If this
917        // test hangs in CI, mark it with `#[ignore]` and exercise the
918        // REPL via an integration test that spawns the binary.
919        let r = run(&env.db_path);
920        assert!(r.is_ok());
921    }
922
923    #[test]
924    fn shell_delete_aliases() {
925        let env = TestEnv::fresh();
926        let conn = fresh_conn(&env);
927        let id = lookup_seeded_id(&env);
928        // `del` alias.
929        {
930            let mut stdout = Vec::new();
931            let mut stderr = Vec::new();
932            let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
933            handle_command(&["del", &id], &conn, &mut out);
934            assert!(String::from_utf8(stdout).unwrap().contains("deleted"));
935        }
936        // Re-seed for the second alias.
937        seed_memory(&env.db_path, "shell-ns", "seed2", "seed-content-2");
938        let conn2 = db::open(&env.db_path).unwrap();
939        let id2 = {
940            let all = db::export_all(&conn2).unwrap();
941            all.iter().find(|m| m.title == "seed2").unwrap().id.clone()
942        };
943        let mut stdout = Vec::new();
944        let mut stderr = Vec::new();
945        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
946        handle_command(&["rm", &id2], &conn2, &mut out);
947        assert!(String::from_utf8(stdout).unwrap().contains("deleted"));
948    }
949
950    // ----------------------------------------------------------------
951    // Issue #653 — REPL `update` parity with `--profile full`
952    // `memory_update`. The CLI subcommand always existed; the REPL
953    // didn't, forcing operators into raw MCP JSON-RPC. These tests
954    // pin the parsing surface + the dispatch path against db::update.
955    // ----------------------------------------------------------------
956
957    #[test]
958    fn shell_update_changes_namespace() {
959        // Headline use case from the issue: "switch a memory's
960        // namespace … via the REPL or the CLI".
961        let env = TestEnv::fresh();
962        let conn = fresh_conn(&env);
963        let id = lookup_seeded_id(&env);
964        let mut stdout = Vec::new();
965        let mut stderr = Vec::new();
966        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
967        let action = handle_command(&["update", &id, "namespace=migrated"], &conn, &mut out);
968        assert_eq!(action, ShellAction::Continue);
969        let stdout_str = String::from_utf8(stdout).unwrap();
970        assert!(stdout_str.contains("updated:"), "stdout: {stdout_str}");
971        let mem = db::get(&conn, &id).unwrap().unwrap();
972        assert_eq!(mem.namespace, "migrated");
973    }
974
975    #[test]
976    fn shell_update_multiple_fields_one_call() {
977        let env = TestEnv::fresh();
978        let conn = fresh_conn(&env);
979        let id = lookup_seeded_id(&env);
980        let mut stdout = Vec::new();
981        let mut stderr = Vec::new();
982        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
983        let action = handle_command(
984            &[
985                "update",
986                &id,
987                "title=renamed",
988                "priority=9",
989                "confidence=0.9",
990            ],
991            &conn,
992            &mut out,
993        );
994        assert_eq!(action, ShellAction::Continue);
995        let stdout_str = String::from_utf8(stdout).unwrap();
996        assert!(stdout_str.contains("updated:"), "stdout: {stdout_str}");
997        let mem = db::get(&conn, &id).unwrap().unwrap();
998        assert_eq!(mem.title, "renamed");
999        assert_eq!(mem.priority, 9);
1000        assert!((mem.confidence - 0.9).abs() < f64::EPSILON);
1001    }
1002
1003    #[test]
1004    fn shell_update_short_alias_u_works() {
1005        let env = TestEnv::fresh();
1006        let conn = fresh_conn(&env);
1007        let id = lookup_seeded_id(&env);
1008        let mut stdout = Vec::new();
1009        let mut stderr = Vec::new();
1010        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1011        handle_command(&["u", &id, "namespace=via-alias"], &conn, &mut out);
1012        let mem = db::get(&conn, &id).unwrap().unwrap();
1013        assert_eq!(mem.namespace, "via-alias");
1014    }
1015
1016    #[test]
1017    fn shell_update_missing_args_writes_usage() {
1018        let env = TestEnv::fresh();
1019        let conn = fresh_conn(&env);
1020        let mut stdout = Vec::new();
1021        let mut stderr = Vec::new();
1022        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1023        handle_command(&["update"], &conn, &mut out);
1024        let stderr_str = String::from_utf8(stderr).unwrap();
1025        assert!(stderr_str.contains("usage: update"));
1026    }
1027
1028    #[test]
1029    fn shell_update_missing_kv_writes_usage() {
1030        let env = TestEnv::fresh();
1031        let conn = fresh_conn(&env);
1032        let id = lookup_seeded_id(&env);
1033        let mut stdout = Vec::new();
1034        let mut stderr = Vec::new();
1035        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1036        handle_command(&["update", &id], &conn, &mut out);
1037        let stderr_str = String::from_utf8(stderr).unwrap();
1038        assert!(stderr_str.contains("usage: update"));
1039    }
1040
1041    #[test]
1042    fn shell_update_unknown_field_errors() {
1043        let env = TestEnv::fresh();
1044        let conn = fresh_conn(&env);
1045        let id = lookup_seeded_id(&env);
1046        let mut stdout = Vec::new();
1047        let mut stderr = Vec::new();
1048        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1049        handle_command(&["update", &id, "frobnitz=value"], &conn, &mut out);
1050        let stderr_str = String::from_utf8(stderr).unwrap();
1051        assert!(stderr_str.contains("unknown field"), "stderr: {stderr_str}");
1052    }
1053
1054    #[test]
1055    fn shell_update_malformed_kv_errors() {
1056        let env = TestEnv::fresh();
1057        let conn = fresh_conn(&env);
1058        let id = lookup_seeded_id(&env);
1059        let mut stdout = Vec::new();
1060        let mut stderr = Vec::new();
1061        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1062        handle_command(&["update", &id, "no-equals-sign"], &conn, &mut out);
1063        let stderr_str = String::from_utf8(stderr).unwrap();
1064        assert!(
1065            stderr_str.contains("expected key=value"),
1066            "stderr: {stderr_str}"
1067        );
1068    }
1069
1070    #[test]
1071    fn shell_update_invalid_tier_errors() {
1072        let env = TestEnv::fresh();
1073        let conn = fresh_conn(&env);
1074        let id = lookup_seeded_id(&env);
1075        let mut stdout = Vec::new();
1076        let mut stderr = Vec::new();
1077        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1078        handle_command(&["update", &id, "tier=archived"], &conn, &mut out);
1079        let stderr_str = String::from_utf8(stderr).unwrap();
1080        assert!(stderr_str.contains("invalid tier"), "stderr: {stderr_str}");
1081    }
1082
1083    #[test]
1084    fn shell_update_invalid_id_errors() {
1085        let env = TestEnv::fresh();
1086        let conn = fresh_conn(&env);
1087        let mut stdout = Vec::new();
1088        let mut stderr = Vec::new();
1089        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1090        handle_command(&["update", "bad\x07id", "namespace=foo"], &conn, &mut out);
1091        let stderr_str = String::from_utf8(stderr).unwrap();
1092        assert!(stderr_str.contains("invalid id"), "stderr: {stderr_str}");
1093    }
1094
1095    #[test]
1096    fn shell_update_nonexistent_id_writes_not_found() {
1097        let env = TestEnv::fresh();
1098        let conn = fresh_conn(&env);
1099        let mut stdout = Vec::new();
1100        let mut stderr = Vec::new();
1101        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1102        // Plausible UUID format that won't resolve.
1103        let fake = "deadbeef-dead-beef-dead-beefdeadbeef";
1104        handle_command(&["update", fake, "namespace=foo"], &conn, &mut out);
1105        let stderr_str = String::from_utf8(stderr).unwrap();
1106        assert!(stderr_str.contains("not found"), "stderr: {stderr_str}");
1107    }
1108
1109    #[test]
1110    fn shell_help_lists_update_command() {
1111        // Pin the help text for issue #653 so future help-text edits
1112        // don't silently drop the update entry.
1113        let env = TestEnv::fresh();
1114        let conn = fresh_conn(&env);
1115        let mut stdout = Vec::new();
1116        let mut stderr = Vec::new();
1117        let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
1118        handle_command(&["help"], &conn, &mut out);
1119        let stdout_str = String::from_utf8(stdout).unwrap();
1120        assert!(stdout_str.contains("update <id>"), "help: {stdout_str}");
1121        assert!(stdout_str.contains("#653"), "help: {stdout_str}");
1122    }
1123}