1use 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#[derive(Debug, PartialEq, Eq)]
21pub enum ShellAction {
22 Continue,
24 Quit,
26}
27
28#[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 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
394pub 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_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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 let env = TestEnv::fresh();
912 seed_memory(&env.db_path, "shell-run-ns", "seed", "content");
914 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 {
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 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 #[test]
958 fn shell_update_changes_namespace() {
959 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 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 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}