1#![doc = include_str!("../README.md")]
2mod cli;
17mod config;
18mod workspace;
19
20use std::collections::BTreeMap;
21use std::io::{self, BufRead, Write};
22use std::path::{Path, PathBuf};
23use std::process::ExitCode;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use clap::Parser;
27use plugmem_host::{
28 Database, ExportedFact, FactId, GuardedRememberOutcome, HostError, LinkInput, MaintenanceMode,
29 MaintenanceOptions, ReadOnlyDatabase, RecallQuery, RecallResult, RememberInput,
30 RememberOutcome, Settings, Stats, TagPage, TagQuery, UnlinkInput, VALID_TO_OPEN,
31};
32use serde_json::json;
33
34use crate::cli::{Cli, Command, HelpTopic, MaintainMode};
35use crate::config::read_batch_size;
36
37pub(crate) const ENV_DB: &str = "PLUGMEM_DB";
39pub(crate) const DEFAULT_DB: &str = "plugmem.db";
41
42#[derive(Debug)]
45pub(crate) enum CliError {
46 Host(HostError),
47 Usage(String),
48}
49
50impl From<HostError> for CliError {
51 fn from(e: HostError) -> Self {
52 CliError::Host(e)
53 }
54}
55
56pub(crate) fn now_ms() -> u64 {
58 SystemTime::now()
59 .duration_since(UNIX_EPOCH)
60 .map(|d| d.as_millis() as u64)
61 .unwrap_or(0)
62}
63
64pub fn run() -> ExitCode {
68 let stdout = io::stdout();
69 ExitCode::from(run_parsed(Cli::parse(), &mut stdout.lock()))
70}
71
72fn run_parsed(cli: Cli, out: &mut impl Write) -> u8 {
76 if let Command::Help { topic } = &cli.command {
77 return execute_help(topic, cli.json, out);
78 }
79
80 let table = match plugmem_host::read_config(cli.config.as_deref()) {
84 Ok(t) => t,
85 Err(e) => return report_err(&e.into()),
86 };
87 let cfg_batch_size = read_batch_size(table.as_ref());
88 let mut settings = match Settings::from_table(table.as_ref()) {
89 Ok(s) => s,
90 Err(e) => return report_err(&e.into()),
91 };
92 for warning in &settings.warnings {
96 eprintln!("plugmem: {warning}");
97 }
98 let root = workspace::resolve_root(cli.workspace.as_deref(), &settings);
103 if let Command::Workspace { command } = &cli.command {
104 return match workspace::execute(command, root, settings, cli.json, out) {
105 Ok(code) => code,
106 Err(e) => {
107 let _ = out.flush();
108 report_err(&e)
109 }
110 };
111 }
112 let path = resolve_db_path(
113 cli.db.as_deref(),
114 settings.database_path.as_deref(),
115 root.as_ref(),
116 );
117 if let Err(e) = workspace::ensure_dir(&path, root.as_ref()) {
118 return report_err(&e);
119 }
120
121 match &cli.command {
126 Command::Recover { dst } => return do_recover(&path, dst, &settings, cli.json, out),
127 Command::Scrub => return do_scrub(&path, &settings, cli.json, out),
128 Command::Repl { read_only: true } => {
133 return run_repl_ro(&path, settings, cli.json, io::stdin().lock(), out);
134 }
135 Command::Repl { read_only: false } => {
136 return run_repl(&path, settings, cli.json, io::stdin().lock(), out);
137 }
138 _ => {}
139 }
140
141 let readonly_ok = matches!(
149 &cli.command,
150 Command::Show { .. }
151 | Command::Stats
152 | Command::Tags { .. }
153 | Command::Export
154 | Command::Verify
155 | Command::Recall { .. }
156 );
157 if readonly_ok {
158 let recall_vector = match embed_recall_query(&mut settings, &cli.command) {
159 Ok(v) => v,
160 Err(e) => return report_err(&e),
161 };
162 match Database::open_readonly(&path, settings.config.clone()) {
163 Ok(ro) => {
164 let recall_space = recall_vector.as_ref().and_then(|_| {
165 settings
166 .embedder
167 .as_ref()
168 .map(|embedder| embedder.space_id())
169 });
170 return execute_ro(
171 &ro,
172 &cli.command,
173 recall_vector.as_deref(),
174 recall_space,
175 cli.json,
176 out,
177 );
178 }
179 Err(HostError::Locked { path }) => return report_locked(&path),
180 Err(_) => {}
185 }
186 }
187
188 let db = match settings.open(&path) {
191 Ok(db) => db,
192 Err(HostError::Locked { path }) => return report_locked(&path),
193 Err(e) => return report_err(&CliError::Host(e)),
194 };
195 if let Command::Import { file, batch } = &cli.command {
198 let batch_size = batch
199 .or(cfg_batch_size.map(|n| n as usize))
200 .unwrap_or(DEFAULT_IMPORT_BATCH)
201 .max(1);
202 return match do_import(&db, now_ms(), file, batch_size, out) {
203 Ok(report) => {
204 if cli.json {
205 writeln!(
206 out,
207 "{}",
208 json!({ "imported": report.facts, "edges": report.edges })
209 )
210 .ok();
211 } else if report.edges == 0 {
212 writeln!(out, "imported {} facts", report.facts).ok();
213 } else {
214 writeln!(
215 out,
216 "imported {} facts and {} edges",
217 report.facts, report.edges
218 )
219 .ok();
220 }
221 0
222 }
223 Err(e) => {
224 let _ = out.flush();
225 report_err(&e)
226 }
227 };
228 }
229 match execute(&db, &cli.command, cli.json, now_ms(), out) {
230 Ok(code) => code,
231 Err(e) => {
232 let _ = out.flush();
233 report_err(&e)
234 }
235 }
236}
237
238const DEFAULT_IMPORT_BATCH: usize = 128;
241
242fn write_err(out: &mut impl Write, e: &CliError) {
249 let _ = match e {
250 CliError::Usage(msg) => writeln!(out, "plugmem: {msg}"),
251 CliError::Host(err) => {
252 writeln!(out, "plugmem: {err}").and_then(|()| match err.capacity_hint() {
253 Some(hint) => writeln!(out, "plugmem: {hint}"),
254 None => Ok(()),
255 })
256 }
257 };
258}
259
260fn report_err(e: &CliError) -> u8 {
262 write_err(&mut std::io::stderr(), e);
263 2
264}
265
266fn report_locked(path: &std::path::Path) -> u8 {
268 eprintln!(
269 "plugmem: database is locked by another process: {}",
270 path.display()
271 );
272 1
273}
274
275fn resolve_db_path(
282 flag: Option<&str>,
283 config_path: Option<&std::path::Path>,
284 root: Option<&PathBuf>,
285) -> PathBuf {
286 flag.map(|value| workspace::resolve_target(value, root))
287 .or_else(|| {
288 std::env::var_os(ENV_DB).map(|v| workspace::resolve_target(&v.to_string_lossy(), root))
289 })
290 .or_else(|| config_path.map(PathBuf::from))
291 .or_else(plugmem_host::default_database_path)
292 .unwrap_or_else(|| PathBuf::from(DEFAULT_DB))
293}
294
295fn execute_help(topic: &HelpTopic, json_output: bool, out: &mut impl Write) -> u8 {
298 match topic {
299 HelpTopic::Settings => {
300 if json_output {
301 let help = plugmem_host::settings_help();
302 let settings: Vec<_> = help
303 .docs()
304 .iter()
305 .map(|doc| {
306 json!({
307 "section": doc.section,
308 "key": doc.key,
309 "type": doc.value_type,
310 "default": doc.default,
311 "description": doc.description,
312 "scope": doc.scope.as_str(),
313 })
314 })
315 .collect();
316 let value = json!({
317 "topic": "settings",
318 "config_path_precedence": help.config_path_precedence(),
319 "default_config_path": plugmem_host::default_config_path()
320 .map(|path| path.display().to_string()),
321 "settings": settings,
322 });
323 writeln!(out, "{value}").ok();
324 } else {
325 write!(out, "{}", plugmem_host::settings_help().render_human()).ok();
326 }
327 0
328 }
329 }
330}
331
332fn execute_ro(
335 ro: &ReadOnlyDatabase,
336 cmd: &Command,
337 recall_vector: Option<&[f32]>,
338 recall_space: Option<&str>,
339 json: bool,
340 out: &mut impl Write,
341) -> u8 {
342 match cmd {
343 Command::Recall { .. } => {
344 match with_recall_query(cmd, now_ms(), recall_vector, |q| match recall_space {
345 Some(space) => ro.recall_in_space(q, space),
346 None => ro.recall(q),
347 }) {
348 Ok(res) => {
349 render_recall(&res, json, out);
350 0
351 }
352 Err(e) => report_err(&CliError::Host(e)),
353 }
354 }
355 Command::Show { id } => render_show(ro.get(FactId(*id)), *id, json, out),
356 Command::Stats => {
357 render_stats(&ro.stats(), json, out);
358 0
359 }
360 Command::Tags {
361 prefix,
362 cursor,
363 limit,
364 } => match ro.list_tags(TagQuery {
365 prefix: prefix.as_deref(),
366 cursor: cursor.as_deref(),
367 limit: *limit,
368 }) {
369 Ok(page) => {
370 render_tags(&page, json, out);
371 0
372 }
373 Err(e) => report_err(&CliError::Host(e)),
374 },
375 Command::Export => {
376 ro.export_each(|f| write_export_line(out, &f));
377 ro.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
378 0
379 }
380 Command::Verify => match ro.verify() {
382 Ok(()) => {
383 if json {
384 writeln!(out, "{}", json!({ "ok": true })).ok();
385 } else {
386 writeln!(out, "integrity ok").ok();
387 }
388 0
389 }
390 Err(e) => report_err(&CliError::Host(e)),
391 },
392 _ => unreachable!("execute_ro only receives read-only commands"),
393 }
394}
395
396fn execute(
400 db: &Database,
401 cmd: &Command,
402 json: bool,
403 now: u64,
404 out: &mut impl Write,
405) -> Result<u8, CliError> {
406 match cmd {
407 Command::Remember {
408 text,
409 entity,
410 tags,
411 links,
412 meta,
413 valid_from,
414 vector,
415 guarded,
416 } => {
417 if *guarded {
418 let outcome = do_guarded_remember(
419 db,
420 now,
421 text,
422 entity,
423 tags,
424 links,
425 meta,
426 *valid_from,
427 vector,
428 )?;
429 render_guarded_remember(&outcome, json, out);
430 } else {
431 let outcome = do_remember(
432 db,
433 now,
434 text,
435 entity,
436 tags,
437 links,
438 meta,
439 *valid_from,
440 vector,
441 None,
442 )?;
443 render_remember(&outcome, json, out);
444 }
445 Ok(0)
446 }
447 Command::Revise {
448 id,
449 text,
450 entity,
451 tags,
452 links,
453 meta,
454 valid_from,
455 vector,
456 } => {
457 let outcome = do_remember(
458 db,
459 now,
460 text,
461 entity,
462 tags,
463 links,
464 meta,
465 *valid_from,
466 vector,
467 Some(FactId(*id)),
468 )?;
469 render_remember(&outcome, json, out);
470 Ok(0)
471 }
472 Command::Recall { .. } => {
473 let res = with_recall_query(cmd, now, None, |q| db.recall(q))?;
474 render_recall(&res, json, out);
475 Ok(0)
476 }
477 Command::Forget { id } => {
478 let fresh = db.forget(now, FactId(*id))?;
479 if json {
480 writeln!(out, "{}", json!({ "id": id, "forgotten": fresh })).ok();
481 } else if fresh {
482 writeln!(out, "forgot fact {id}").ok();
483 } else {
484 writeln!(out, "fact {id} was already gone").ok();
485 }
486 Ok(0)
487 }
488 Command::Tags {
489 prefix,
490 cursor,
491 limit,
492 } => {
493 let page = db.list_tags(TagQuery {
494 prefix: prefix.as_deref(),
495 cursor: cursor.as_deref(),
496 limit: *limit,
497 })?;
498 render_tags(&page, json, out);
499 Ok(0)
500 }
501 Command::RemoveTag { tag } => {
502 let report = db.remove_tag(now, tag)?;
503 if json {
504 writeln!(
505 out,
506 "{}",
507 json!({ "tag": tag, "affected": report.affected })
508 )
509 .ok();
510 } else {
511 writeln!(
512 out,
513 "removed tag {tag:?} from {} current facts",
514 report.affected
515 )
516 .ok();
517 }
518 Ok(0)
519 }
520 Command::Link {
521 src,
522 rel,
523 dst,
524 provenance,
525 } => {
526 db.link(LinkInput {
527 now,
528 src,
529 rel,
530 dst,
531 provenance: provenance.map(FactId),
532 })?;
533 if json {
534 writeln!(out, "{}", json!({ "src": src, "rel": rel, "dst": dst })).ok();
535 } else {
536 writeln!(out, "linked {src} -{rel}-> {dst}").ok();
537 }
538 Ok(0)
539 }
540 Command::Unlink { src, rel, dst } => {
541 let fresh = db.unlink(UnlinkInput { now, src, rel, dst })?;
542 if json {
543 writeln!(
544 out,
545 "{}",
546 json!({ "src": src, "rel": rel, "dst": dst, "unlinked": fresh })
547 )
548 .ok();
549 } else if fresh {
550 writeln!(out, "unlinked {src} -{rel}-> {dst}").ok();
551 } else {
552 writeln!(out, "edge {src} -{rel}-> {dst} was already absent").ok();
553 }
554 Ok(0)
555 }
556 Command::Show { id } => Ok(render_show(db.get(FactId(*id)), *id, json, out)),
557 Command::Stats => {
558 render_stats(&db.stats(), json, out);
559 Ok(0)
560 }
561 Command::Export => {
562 db.export_each(|f| write_export_line(out, &f));
563 db.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
564 Ok(0)
565 }
566 Command::Maintain {
567 mode,
568 reembed,
569 batch_size,
570 } => {
571 if *reembed {
572 let report = db.reembed_with_batch(
573 now,
574 batch_size.unwrap_or(plugmem_host::DEFAULT_REEMBED_BATCH_SIZE),
575 )?;
576 if json {
577 writeln!(
578 out,
579 "{}",
580 json!({
581 "embedded": report.embedded,
582 "previous_dim": report.previous_dim,
583 "new_dim": report.new_dim,
584 "previous_space": report.previous_space,
585 "new_space": report.new_space,
586 "vector_bytes": report.vector_bytes,
587 "hnsw_indexed": report.hnsw_indexed,
588 })
589 )
590 .ok();
591 } else {
592 writeln!(
593 out,
594 "reembedded: {} facts, {} -> {} dimensions, {:?} -> {:?}, {} vector bytes, hnsw {}",
595 report.embedded,
596 report.previous_dim,
597 report.new_dim,
598 report.previous_space,
599 report.new_space,
600 report.vector_bytes,
601 report.hnsw_indexed,
602 )
603 .ok();
604 }
605 return Ok(0);
606 }
607 let report = db.maintain_with_options(now, maintenance_options(*mode))?;
608 if json {
609 writeln!(
610 out,
611 "{}",
612 json!({
613 "purged": report.purged,
614 "bytes_before": report.bytes_before,
615 "bytes_after": report.bytes_after,
616 "no_op": report.no_op,
617 "tombstones_before": report.tombstones_before,
618 "facts_before": report.facts_before,
619 "facts_after": report.facts_after,
620 "vectors_before": report.vectors_before,
621 "vectors_after": report.vectors_after,
622 "hnsw_indexed_before": report.hnsw_indexed_before,
623 "hnsw_indexed_after": report.hnsw_indexed_after,
624 "structural_compacted": report.structural_compacted,
625 "bm25_compacted": report.bm25_compacted,
626 "bm25_reindexed": report.bm25_reindexed,
627 "hnsw_rebuilt": report.hnsw_rebuilt,
628 "hnsw_remapped": report.hnsw_remapped,
629 "hnsw_inserted": report.hnsw_inserted,
630 "edges_compacted": report.edges_compacted,
631 "edges_before": report.edges_before,
632 "edge_versions_before": report.edge_versions_before,
633 })
634 )
635 .ok();
636 } else {
637 writeln!(
638 out,
639 "maintained: purged {}, {} -> {} bytes, hnsw +{}, bm25 {}{}{}",
640 report.purged,
641 report.bytes_before,
642 report.bytes_after,
643 report.hnsw_inserted,
644 if report.bm25_reindexed {
645 "reindexed"
646 } else if report.bm25_compacted {
647 "compacted"
648 } else {
649 "unchanged"
650 },
651 if report.edges_compacted {
652 ", edges repacked"
653 } else {
654 ""
655 },
656 if report.no_op { " (no-op)" } else { "" }
657 )
658 .ok();
659 }
660 Ok(0)
661 }
662 Command::Checkpoint => {
663 db.checkpoint(now)?;
664 if json {
665 writeln!(out, "{}", json!({ "ok": true })).ok();
666 } else {
667 writeln!(out, "checkpointed: journal flushed to snapshot").ok();
668 }
669 Ok(0)
670 }
671 Command::Verify => {
672 db.verify()?;
675 if json {
676 writeln!(out, "{}", json!({ "ok": true })).ok();
677 } else {
678 writeln!(out, "integrity ok").ok();
679 }
680 Ok(0)
681 }
682 Command::Scrub
684 | Command::Recover { .. }
685 | Command::Repl { .. }
686 | Command::Import { .. }
687 | Command::Workspace { .. }
688 | Command::Help { .. } => {
689 unreachable!("this command is dispatched before execute")
690 }
691 }
692}
693
694fn do_recover(src: &Path, dst: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
698 match Database::recover(src, dst, settings.config.clone(), now_ms()) {
699 Ok(r) => {
700 if json {
701 writeln!(
702 out,
703 "{}",
704 json!({
705 "kept": r.kept,
706 "dropped_text": r.dropped_text,
707 "dropped_vector": r.dropped_vector,
708 "dropped_metadata": r.dropped_metadata,
709 "dst": dst.display().to_string(),
710 })
711 )
712 .ok();
713 } else {
714 writeln!(
715 out,
716 "recovered to {}: kept {}, dropped {} text + {} vector + {} metadata",
717 dst.display(),
718 r.kept,
719 r.dropped_text,
720 r.dropped_vector,
721 r.dropped_metadata
722 )
723 .ok();
724 }
725 0
726 }
727 Err(HostError::Locked { path }) => report_locked(&path),
728 Err(e) => report_err(&CliError::Host(e)),
729 }
730}
731
732fn do_scrub(path: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
737 let ro = match Database::open_readonly(path, settings.config.clone()) {
738 Ok(ro) => ro,
739 Err(HostError::Locked { path }) => return report_locked(&path),
740 Err(e) => return report_err(&CliError::Host(e)),
741 };
742 let scrub = match ro.scrub() {
743 Ok(s) => s,
744 Err(e) => return report_err(&CliError::Host(e)),
745 };
746 let mut done = 0u64;
747 let mut total = 0u64;
748 for step in scrub {
749 match step {
750 Ok(p) => {
751 done = p.done_bytes;
752 total = p.total_bytes;
753 }
754 Err(e) => return report_err(&CliError::Host(e)),
755 }
756 }
757 if json {
758 writeln!(out, "{}", json!({ "ok": true, "bytes": done })).ok();
759 } else {
760 writeln!(out, "scrub ok: {done}/{total} bytes verified").ok();
761 }
762 0
763}
764
765#[derive(Parser)]
768#[command(
769 no_binary_name = true,
770 name = "plugmem",
771 disable_help_subcommand = true
772)]
773struct ReplLine {
774 #[command(subcommand)]
775 command: Command,
776}
777
778fn split_line(line: &str) -> Vec<String> {
782 let mut tokens = Vec::new();
783 let mut cur = String::new();
784 let mut quote: Option<char> = None;
785 let mut has = false;
786 for c in line.chars() {
787 match quote {
788 Some(q) => {
789 if c == q {
790 quote = None;
791 } else {
792 cur.push(c);
793 }
794 }
795 None if c == '"' || c == '\'' => {
796 quote = Some(c);
797 has = true;
798 }
799 None if c.is_whitespace() => {
800 if has {
801 tokens.push(std::mem::take(&mut cur));
802 has = false;
803 }
804 }
805 None => {
806 cur.push(c);
807 has = true;
808 }
809 }
810 }
811 if has {
812 tokens.push(cur);
813 }
814 tokens
815}
816
817fn run_repl(
823 path: &Path,
824 settings: Settings,
825 json: bool,
826 input: impl BufRead,
827 out: &mut impl Write,
828) -> u8 {
829 let db = match settings.open(path) {
830 Ok(db) => db,
831 Err(HostError::Locked { path }) => return report_locked(&path),
832 Err(e) => return report_err(&CliError::Host(e)),
833 };
834 eprintln!("plugmem repl — one open handle, host speed. `help` for verbs, `exit` to quit.");
835 eprint!("plugmem> ");
836 for line in input.lines() {
837 let Ok(line) = line else { break };
838 let line = line.trim();
839 if line.is_empty() {
840 eprint!("plugmem> ");
841 continue;
842 }
843 if line == "exit" || line == "quit" {
844 break;
845 } else if line == "help" {
846 writeln!(
847 out,
848 "verbs: remember recall revise forget tags remove-tag link unlink show stats maintain checkpoint \
849 verify export import (scrub/recover stay one-shot) exit"
850 )
851 .ok();
852 } else {
853 run_repl_line(&db, line, json, out);
854 }
855 eprint!("plugmem> ");
856 }
857 eprintln!();
858 match db.checkpoint(now_ms()) {
860 Ok(()) => 0,
861 Err(e) => report_err(&CliError::Host(e)),
862 }
863}
864
865fn run_repl_line(db: &Database, line: &str, json: bool, out: &mut impl Write) {
868 let cmd = match ReplLine::try_parse_from(split_line(line)) {
869 Ok(r) => r.command,
870 Err(e) => {
872 let _ = writeln!(out, "{e}");
873 return;
874 }
875 };
876 match &cmd {
877 Command::Repl { .. } => {
878 let _ = writeln!(out, "already in a repl session");
879 }
880 Command::Scrub | Command::Recover { .. } => {
881 let _ = writeln!(
882 out,
883 "scrub/recover are one-shot commands; run them outside the repl"
884 );
885 }
886 _ => {
887 if let Err(e) = execute(db, &cmd, json, now_ms(), out) {
888 write_err(out, &e);
889 }
890 }
891 }
892}
893
894fn run_repl_ro(
907 path: &Path,
908 mut settings: Settings,
909 json: bool,
910 input: impl BufRead,
911 out: &mut impl Write,
912) -> u8 {
913 let mut ro = match Database::open_readonly(path, settings.config.clone()) {
914 Ok(ro) => ro,
915 Err(HostError::Locked { path }) => return report_locked(&path),
916 Err(e) => return report_err(&CliError::Host(e)),
919 };
920 eprintln!(
921 "plugmem repl --read-only — observing generation {} of another process's writer. \
922 `help` for verbs, `refresh`/`generation` for cross-process freshness, `exit` to quit.",
923 ro.generation()
924 );
925 eprint!("plugmem(ro)> ");
926 for line in input.lines() {
927 let Ok(line) = line else { break };
928 let line = line.trim();
929 if line.is_empty() {
930 eprint!("plugmem(ro)> ");
931 continue;
932 }
933 match line {
934 "exit" | "quit" => break,
935 "help" => {
936 writeln!(
937 out,
938 "read verbs: recall show stats export verify \
939 freshness: generation refresh exit \
940 (writes and scrub/recover are refused in a read-only session)"
941 )
942 .ok();
943 }
944 "generation" => {
947 let g = ro.generation();
948 if json {
949 writeln!(out, "{}", json!({ "generation": g })).ok();
950 } else {
951 writeln!(out, "generation {g}").ok();
952 }
953 }
954 "refresh" => match ro.refresh() {
955 Ok(advanced) => {
956 let g = ro.generation();
957 if json {
958 writeln!(out, "{}", json!({ "advanced": advanced, "generation": g })).ok();
959 } else if advanced {
960 writeln!(out, "refreshed → generation {g}").ok();
961 } else {
962 writeln!(out, "already current → generation {g}").ok();
963 }
964 }
965 Err(e) => write_err(out, &CliError::Host(e)),
966 },
967 _ => run_repl_ro_line(&ro, &mut settings, line, json, out),
968 }
969 eprint!("plugmem(ro)> ");
970 }
971 eprintln!();
972 0
974}
975
976fn run_repl_ro_line(
979 ro: &ReadOnlyDatabase,
980 settings: &mut Settings,
981 line: &str,
982 json: bool,
983 out: &mut impl Write,
984) {
985 let cmd = match ReplLine::try_parse_from(split_line(line)) {
986 Ok(r) => r.command,
987 Err(e) => {
988 let _ = writeln!(out, "{e}");
989 return;
990 }
991 };
992 let readable = matches!(
993 &cmd,
994 Command::Show { .. }
995 | Command::Stats
996 | Command::Tags { .. }
997 | Command::Export
998 | Command::Verify
999 | Command::Recall { .. }
1000 );
1001 if !readable {
1002 let _ = writeln!(
1003 out,
1004 "read-only session: only recall/show/stats/tags/export/verify run \
1005 (plus refresh/generation); writes and one-shot commands need a writer handle"
1006 );
1007 return;
1008 }
1009 let recall_vector = match embed_recall_query(settings, &cmd) {
1012 Ok(v) => v,
1013 Err(e) => {
1014 write_err(out, &e);
1015 return;
1016 }
1017 };
1018 let recall_space = recall_vector.as_ref().and_then(|_| {
1019 settings
1020 .embedder
1021 .as_ref()
1022 .map(|embedder| embedder.space_id())
1023 });
1024 let _ = execute_ro(ro, &cmd, recall_vector.as_deref(), recall_space, json, out);
1025}
1026
1027fn embed_recall_query(
1035 settings: &mut Settings,
1036 cmd: &Command,
1037) -> Result<Option<Vec<f32>>, CliError> {
1038 let Command::Recall {
1039 query: Some(text),
1040 vector,
1041 ..
1042 } = cmd
1043 else {
1044 return Ok(None);
1045 };
1046 if !vector.is_empty() {
1049 return Ok(None);
1050 }
1051 let Some(embedder) = settings.embedder.as_ref() else {
1052 return Ok(None);
1053 };
1054 let mut vectors = embedder.embed(&[text.as_str()]).map_err(CliError::Host)?;
1055 Ok(vectors.pop())
1056}
1057
1058fn with_recall_query<R>(
1063 cmd: &Command,
1064 now: u64,
1065 override_vector: Option<&[f32]>,
1066 f: impl FnOnce(RecallQuery<'_>) -> R,
1067) -> R {
1068 let Command::Recall {
1069 query,
1070 tags,
1071 entities,
1072 as_of,
1073 range,
1074 k,
1075 closed,
1076 token_budget,
1077 ef,
1078 graph_depth,
1079 vector,
1080 } = cmd
1081 else {
1082 unreachable!("with_recall_query called on a non-recall command");
1083 };
1084 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1085 let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
1086 let range_pair = range.as_ref().map(|v| (v[0], v[1]));
1087 let explicit = (!vector.is_empty()).then_some(vector.as_slice());
1092 let q = RecallQuery {
1093 now,
1094 text: query.as_deref(),
1095 vector: explicit.or(override_vector),
1096 tags: &tag_refs,
1097 entities: &ent_refs,
1098 as_of: *as_of,
1099 range: range_pair,
1100 k: *k,
1101 token_budget: *token_budget,
1102 include_closed: *closed,
1103 ef: *ef,
1104 graph_depth: *graph_depth,
1105 };
1106 f(q)
1107}
1108
1109fn render_recall(res: &RecallResult, json: bool, out: &mut impl Write) {
1112 if json {
1113 let facts: Vec<_> = res
1114 .facts
1115 .iter()
1116 .map(|f| {
1117 json!({
1118 "id": f.id.0,
1119 "score": f.score,
1120 "sources": f.sources,
1121 "recorded_at": f.recorded_at,
1122 "valid_from": f.valid_from,
1123 "valid_to": open_or(f.valid_to),
1124 })
1125 })
1126 .collect();
1127 let edges: Vec<_> = res
1132 .edges
1133 .iter()
1134 .map(|e| {
1135 json!({
1136 "src": e.src.0,
1137 "rel": e.rel.0,
1138 "dst": e.dst.0,
1139 "provenance": (e.provenance != FactId::NONE).then_some(e.provenance.0),
1140 })
1141 })
1142 .collect();
1143 writeln!(
1144 out,
1145 "{}",
1146 json!({
1147 "facts": facts,
1148 "edges": edges,
1149 "rendered": res.rendered,
1150 "truncated": res.truncated,
1151 })
1152 )
1153 .ok();
1154 } else if res.rendered.is_empty() {
1155 writeln!(out, "(nothing recalled)").ok();
1156 } else {
1157 writeln!(out, "{}", res.rendered).ok();
1158 }
1159}
1160
1161fn render_show(
1163 fact: Option<plugmem_host::FactSnapshot>,
1164 id: u32,
1165 json: bool,
1166 out: &mut impl Write,
1167) -> u8 {
1168 let Some(fact) = fact else {
1169 if json {
1170 writeln!(out, "{}", json!({ "id": id, "found": false })).ok();
1171 } else {
1172 writeln!(out, "fact {id} not found").ok();
1173 }
1174 return 1;
1175 };
1176 let r = &fact.record;
1177 if json {
1178 writeln!(
1179 out,
1180 "{}",
1181 json!({
1182 "id": r.id.0,
1183 "text": fact.text,
1184 "recorded_at": r.recorded_at,
1185 "valid_from": r.valid_from,
1186 "valid_to": open_or(r.valid_to),
1187 "closed": r.is_closed(),
1188 "tombstone": r.is_tombstone(),
1189 "revises": (r.revises != FactId::NONE).then_some(r.revises.0),
1190 "metadata": fact.metadata,
1191 })
1192 )
1193 .ok();
1194 } else {
1195 writeln!(out, "fact {}", r.id.0).ok();
1196 writeln!(out, " text {}", fact.text).ok();
1197 writeln!(out, " recorded_at {}", r.recorded_at).ok();
1198 write!(out, " valid [{}, ", r.valid_from).ok();
1199 match r.valid_to {
1200 VALID_TO_OPEN => writeln!(out, "open)").ok(),
1201 to => writeln!(out, "{to})").ok(),
1202 };
1203 if r.revises != FactId::NONE {
1204 writeln!(out, " revises fact {}", r.revises.0).ok();
1205 }
1206 if !fact.metadata.is_empty() {
1207 let rendered = fact
1208 .metadata
1209 .iter()
1210 .map(|(k, v)| format!("{k}={v}"))
1211 .collect::<Vec<_>>()
1212 .join(", ");
1213 writeln!(out, " metadata {rendered}").ok();
1214 }
1215 if r.is_tombstone() {
1216 writeln!(out, " state tombstoned").ok();
1217 }
1218 }
1219 0
1220}
1221
1222fn maintenance_options(mode: MaintainMode) -> MaintenanceOptions {
1227 match MaintenanceMode::from(mode) {
1228 MaintenanceMode::Auto => MaintenanceOptions::auto(),
1229 MaintenanceMode::Full => MaintenanceOptions::full(),
1230 mode => MaintenanceOptions {
1231 mode,
1232 ..MaintenanceOptions::auto()
1233 },
1234 }
1235}
1236
1237fn render_stats(s: &Stats, json: bool, out: &mut impl Write) {
1239 if json {
1240 writeln!(
1241 out,
1242 "{}",
1243 json!({
1244 "facts": s.facts,
1245 "entities": s.entities,
1246 "terms": s.terms,
1247 "edges": s.edges,
1248 "edge_versions": s.edge_versions,
1249 "vectors": s.vectors,
1250 "hnsw_indexed": s.hnsw_indexed,
1251 "next_fact": s.next_fact,
1252 "next_entity": s.next_entity,
1253 "next_edge": s.next_edge,
1254 "pool_bytes": s.pool_bytes,
1255 "shards": {
1256 "facts": s.shards.facts,
1257 "entities": s.shards.entities,
1258 "edges": s.shards.edges,
1259 "temporal": s.shards.temporal,
1260 "postings": s.shards.postings,
1261 },
1262 })
1263 )
1264 .ok();
1265 } else {
1266 writeln!(out, "facts {}", s.facts).ok();
1267 writeln!(out, "entities {}", s.entities).ok();
1268 writeln!(out, "terms {}", s.terms).ok();
1269 writeln!(out, "edges {}", s.edges).ok();
1270 writeln!(out, "edge_vers {}", s.edge_versions).ok();
1271 writeln!(out, "vectors {}", s.vectors).ok();
1272 writeln!(out, "hnsw_idx {}", s.hnsw_indexed).ok();
1273 writeln!(out, "next_fact {}", s.next_fact).ok();
1274 writeln!(out, "next_edge {}", s.next_edge).ok();
1275 writeln!(out, "pool_bytes {}", s.pool_bytes).ok();
1276 writeln!(
1279 out,
1280 "shards facts {} entities {} edges {} temporal {} postings {}",
1281 s.shards.facts, s.shards.entities, s.shards.edges, s.shards.temporal, s.shards.postings,
1282 )
1283 .ok();
1284 }
1285}
1286
1287fn render_tags(page: &TagPage, json_output: bool, out: &mut impl Write) {
1290 if json_output {
1291 writeln!(
1292 out,
1293 "{}",
1294 json!({
1295 "items": page.items.iter().map(|item| json!({
1296 "name": item.name,
1297 "count": item.count,
1298 })).collect::<Vec<_>>(),
1299 "next_cursor": page.next_cursor,
1300 })
1301 )
1302 .ok();
1303 return;
1304 }
1305 for item in &page.items {
1306 writeln!(out, "{}\t{}", item.count, item.name).ok();
1307 }
1308 if let Some(cursor) = &page.next_cursor {
1309 writeln!(out, "next_cursor\t{cursor}").ok();
1310 }
1311}
1312
1313fn write_export_line(out: &mut impl Write, f: &ExportedFact) {
1320 writeln!(
1321 out,
1322 "{}",
1323 json!({
1324 "kind": "fact",
1325 "id": f.id,
1326 "text": f.text,
1327 "entity": f.entity,
1328 "tags": f.tags,
1329 "metadata": f.metadata,
1330 "recorded_at": f.recorded_at,
1331 "valid_from": f.valid_from,
1332 })
1333 )
1334 .ok();
1335}
1336
1337fn write_export_edge(out: &mut impl Write, src: &str, rel: &str, dst: &str, provenance: FactId) {
1341 writeln!(
1342 out,
1343 "{}",
1344 json!({
1345 "kind": "edge",
1346 "src": src,
1347 "rel": rel,
1348 "dst": dst,
1349 "provenance": (provenance != FactId::NONE).then_some(provenance.0),
1350 })
1351 )
1352 .ok();
1353}
1354
1355#[cfg(test)]
1358fn render_export(facts: &[ExportedFact], _json: bool, out: &mut impl Write) {
1359 for f in facts {
1360 write_export_line(out, f);
1361 }
1362}
1363
1364fn do_import(
1371 db: &Database,
1372 now: u64,
1373 file: &std::path::Path,
1374 batch_size: usize,
1375 _out: &mut impl Write,
1376) -> Result<ImportReport, CliError> {
1377 let f = std::fs::File::open(file)
1378 .map_err(|e| CliError::Usage(format!("reading {}: {e}", file.display())))?;
1379 let reader = io::BufReader::new(f);
1380 let mut count = 0usize;
1381 let mut batch: Vec<ParsedFact> = Vec::with_capacity(batch_size);
1382 let mut remap: BTreeMap<u32, FactId> = BTreeMap::new();
1386 let mut edges = 0usize;
1387
1388 for (i, line) in reader.lines().enumerate() {
1389 let line = line.map_err(|e| CliError::Usage(format!("line {}: {e}", i + 1)))?;
1390 let line = line.trim();
1391 if line.is_empty() {
1392 continue;
1393 }
1394 match parse_import_line(line, i + 1)? {
1395 ImportLine::Fact(fact) => {
1396 batch.push(fact);
1397 if batch.len() >= batch_size {
1398 count += flush_import_batch(db, now, &batch, &mut remap)?;
1399 batch.clear();
1400 }
1401 }
1402 ImportLine::Edge(edge) => {
1403 if !batch.is_empty() {
1408 count += flush_import_batch(db, now, &batch, &mut remap)?;
1409 batch.clear();
1410 }
1411 db.link(LinkInput {
1412 now,
1413 src: &edge.src,
1414 rel: &edge.rel,
1415 dst: &edge.dst,
1416 provenance: edge.provenance.and_then(|old| remap.get(&old).copied()),
1420 })?;
1421 edges += 1;
1422 }
1423 }
1424 }
1425 count += flush_import_batch(db, now, &batch, &mut remap)?;
1426 Ok(ImportReport {
1427 facts: count,
1428 edges,
1429 })
1430}
1431
1432#[derive(Debug, PartialEq, Eq)]
1436struct ImportReport {
1437 facts: usize,
1438 edges: usize,
1439}
1440
1441enum ImportLine {
1443 Fact(ParsedFact),
1444 Edge(ParsedEdge),
1445}
1446
1447struct ParsedEdge {
1450 src: String,
1451 rel: String,
1452 dst: String,
1453 provenance: Option<u32>,
1454}
1455
1456struct ParsedFact {
1460 id: Option<u32>,
1461 text: String,
1462 entity: Option<String>,
1463 tags: Vec<String>,
1464 metadata: Vec<(String, String)>,
1465 valid_from: Option<u64>,
1466}
1467
1468fn parse_import_line(line: &str, lineno: usize) -> Result<ImportLine, CliError> {
1473 let v: serde_json::Value =
1474 serde_json::from_str(line).map_err(|e| CliError::Usage(format!("line {lineno}: {e}")))?;
1475 match v["kind"].as_str() {
1476 None | Some("fact") => parse_import_fact(&v, lineno).map(ImportLine::Fact),
1477 Some("edge") => parse_import_edge(&v, lineno).map(ImportLine::Edge),
1478 Some(other) => Err(CliError::Usage(format!(
1479 "line {lineno}: unknown kind \"{other}\" (expected \"fact\" or \"edge\")"
1480 ))),
1481 }
1482}
1483
1484fn parse_import_edge(v: &serde_json::Value, lineno: usize) -> Result<ParsedEdge, CliError> {
1487 let field = |key: &str| -> Result<String, CliError> {
1488 v[key]
1489 .as_str()
1490 .map(String::from)
1491 .ok_or_else(|| CliError::Usage(format!("line {lineno}: edge missing string \"{key}\"")))
1492 };
1493 Ok(ParsedEdge {
1494 src: field("src")?,
1495 rel: field("rel")?,
1496 dst: field("dst")?,
1497 provenance: v["provenance"].as_u64().and_then(|n| u32::try_from(n).ok()),
1498 })
1499}
1500
1501fn parse_import_fact(v: &serde_json::Value, lineno: usize) -> Result<ParsedFact, CliError> {
1503 let text = v["text"]
1504 .as_str()
1505 .ok_or_else(|| CliError::Usage(format!("line {lineno}: missing string \"text\"")))?
1506 .to_string();
1507 let entity = v["entity"].as_str().map(String::from);
1508 let tags = v["tags"]
1509 .as_array()
1510 .map(|a| {
1511 a.iter()
1512 .filter_map(|t| t.as_str().map(String::from))
1513 .collect()
1514 })
1515 .unwrap_or_default();
1516 let metadata = v["metadata"]
1519 .as_object()
1520 .map(|m| {
1521 m.iter()
1522 .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
1523 .collect::<BTreeMap<_, _>>()
1524 .into_iter()
1525 .collect()
1526 })
1527 .unwrap_or_default();
1528 let valid_from = v["valid_from"].as_u64();
1529 Ok(ParsedFact {
1530 id: v["id"].as_u64().and_then(|n| u32::try_from(n).ok()),
1531 text,
1532 entity,
1533 tags,
1534 metadata,
1535 valid_from,
1536 })
1537}
1538
1539fn flush_import_batch(
1542 db: &Database,
1543 now: u64,
1544 batch: &[ParsedFact],
1545 remap: &mut BTreeMap<u32, FactId>,
1546) -> Result<usize, CliError> {
1547 if batch.is_empty() {
1548 return Ok(0);
1549 }
1550 let tag_refs: Vec<Vec<&str>> = batch
1553 .iter()
1554 .map(|p| p.tags.iter().map(String::as_str).collect())
1555 .collect();
1556 let meta_refs: Vec<Vec<(&str, &str)>> = batch
1557 .iter()
1558 .map(|p| {
1559 p.metadata
1560 .iter()
1561 .map(|(k, v)| (k.as_str(), v.as_str()))
1562 .collect()
1563 })
1564 .collect();
1565 let inputs: Vec<RememberInput> = batch
1566 .iter()
1567 .zip(&tag_refs)
1568 .zip(&meta_refs)
1569 .map(|((p, tags), meta)| RememberInput {
1570 entity: p.entity.as_deref(),
1571 tags,
1572 metadata: (!meta.is_empty()).then_some(meta.as_slice()),
1573 valid_from: p.valid_from,
1574 ..RememberInput::text(now, &p.text)
1575 })
1576 .collect();
1577 let outcomes = db.remember_many(inputs)?;
1580 for (parsed, outcome) in batch.iter().zip(&outcomes) {
1581 if let Some(old) = parsed.id {
1582 remap.insert(old, outcome.id);
1583 }
1584 }
1585 Ok(batch.len())
1586}
1587
1588#[allow(clippy::too_many_arguments)]
1590fn do_remember(
1591 db: &Database,
1592 now: u64,
1593 text: &str,
1594 entity: &Option<String>,
1595 tags: &[String],
1596 links: &[String],
1597 meta: &[String],
1598 valid_from: Option<u64>,
1599 vector: &[f32],
1600 revise: Option<FactId>,
1601) -> Result<RememberOutcome, CliError> {
1602 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1603 let link_pairs = parse_links(links)?;
1604 let link_refs: Vec<(&str, &str)> = link_pairs
1605 .iter()
1606 .map(|(r, e)| (r.as_str(), e.as_str()))
1607 .collect();
1608 let meta_map = parse_meta(meta)?;
1612 let meta_refs: Vec<(&str, &str)> = meta_map
1613 .iter()
1614 .map(|(k, v)| (k.as_str(), v.as_str()))
1615 .collect();
1616 let input = RememberInput {
1617 entity: entity.as_deref(),
1618 tags: &tag_refs,
1619 links: &link_refs,
1620 metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1621 valid_from,
1622 vector: (!vector.is_empty()).then_some(vector),
1626 ..RememberInput::text(now, text)
1627 };
1628 match revise {
1629 Some(target) => Ok(db.revise(target, input)?),
1630 None => Ok(db.remember(input)?),
1631 }
1632}
1633
1634#[allow(clippy::too_many_arguments)]
1637fn do_guarded_remember(
1638 db: &Database,
1639 now: u64,
1640 text: &str,
1641 entity: &Option<String>,
1642 tags: &[String],
1643 links: &[String],
1644 meta: &[String],
1645 valid_from: Option<u64>,
1646 vector: &[f32],
1647) -> Result<GuardedRememberOutcome, CliError> {
1648 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1649 let link_pairs = parse_links(links)?;
1650 let link_refs: Vec<(&str, &str)> = link_pairs
1651 .iter()
1652 .map(|(relation, target)| (relation.as_str(), target.as_str()))
1653 .collect();
1654 let meta_map = parse_meta(meta)?;
1655 let meta_refs: Vec<(&str, &str)> = meta_map
1656 .iter()
1657 .map(|(key, value)| (key.as_str(), value.as_str()))
1658 .collect();
1659 Ok(db.remember_guarded(RememberInput {
1660 entity: entity.as_deref(),
1661 tags: &tag_refs,
1662 links: &link_refs,
1663 metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1664 valid_from,
1665 vector: (!vector.is_empty()).then_some(vector),
1666 ..RememberInput::text(now, text)
1667 })?)
1668}
1669
1670fn parse_meta(meta: &[String]) -> Result<BTreeMap<String, String>, CliError> {
1673 let mut map = BTreeMap::new();
1674 for s in meta {
1675 let (k, v) = s
1676 .split_once('=')
1677 .filter(|(k, _)| !k.is_empty())
1678 .ok_or_else(|| CliError::Usage(format!("bad --meta `{s}` — expected KEY=VALUE")))?;
1679 map.insert(k.to_string(), v.to_string());
1680 }
1681 Ok(map)
1682}
1683
1684fn parse_links(links: &[String]) -> Result<Vec<(String, String)>, CliError> {
1686 links
1687 .iter()
1688 .map(|s| {
1689 s.split_once(':')
1690 .filter(|(r, e)| !r.is_empty() && !e.is_empty())
1691 .map(|(r, e)| (r.to_string(), e.to_string()))
1692 .ok_or_else(|| CliError::Usage(format!("bad --link `{s}` — expected REL:ENTITY")))
1693 })
1694 .collect()
1695}
1696
1697fn render_remember(outcome: &RememberOutcome, json: bool, out: &mut impl Write) {
1699 if json {
1700 let similar: Vec<_> = outcome
1701 .similar
1702 .iter()
1703 .map(|s| json!({ "id": s.id.0, "score": s.score, "reason": format!("{:?}", s.reason) }))
1704 .collect();
1705 writeln!(
1706 out,
1707 "{}",
1708 json!({
1709 "id": outcome.id.0,
1710 "entity": outcome.entity.map(|e| e.0),
1711 "similar": similar,
1712 })
1713 )
1714 .ok();
1715 } else {
1716 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1717 for s in &outcome.similar {
1718 writeln!(
1719 out,
1720 " ~ similar to fact {} ({:?}, {:.2})",
1721 s.id.0, s.reason, s.score
1722 )
1723 .ok();
1724 }
1725 }
1726}
1727
1728fn render_guarded_remember(outcome: &GuardedRememberOutcome, json: bool, out: &mut impl Write) {
1729 match outcome {
1730 GuardedRememberOutcome::Stored { outcome } => {
1731 if json {
1732 writeln!(
1733 out,
1734 "{}",
1735 json!({
1736 "status": "stored",
1737 "outcome": {
1738 "id": outcome.id.0,
1739 "entity": outcome.entity.map(|entity| entity.0),
1740 "similar": [],
1741 }
1742 })
1743 )
1744 .ok();
1745 } else {
1746 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1747 }
1748 }
1749 GuardedRememberOutcome::Blocked { similar } => {
1750 if json {
1751 let similar: Vec<_> = similar
1752 .iter()
1753 .map(|item| {
1754 json!({
1755 "id": item.id.0,
1756 "score": item.score,
1757 "reason": format!("{:?}", item.reason),
1758 })
1759 })
1760 .collect();
1761 writeln!(
1762 out,
1763 "{}",
1764 json!({ "status": "blocked", "similar": similar })
1765 )
1766 .ok();
1767 } else {
1768 writeln!(out, "not remembered: similar facts require a decision").ok();
1769 for item in similar {
1770 writeln!(
1771 out,
1772 " ~ fact {} ({:?}, {:.2})",
1773 item.id.0, item.reason, item.score
1774 )
1775 .ok();
1776 }
1777 }
1778 }
1779 }
1780}
1781
1782fn open_or(valid_to: u64) -> Option<u64> {
1784 (valid_to != VALID_TO_OPEN).then_some(valid_to)
1785}
1786
1787#[cfg(test)]
1788mod tests {
1789 use plugmem_host::Config;
1790
1791 use super::*;
1792
1793 struct StubEmbedder;
1795 impl plugmem_host::Embedder for StubEmbedder {
1796 fn space_id(&self) -> &str {
1797 "cli-stub"
1798 }
1799
1800 fn dim(&self) -> usize {
1801 3
1802 }
1803 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1804 Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1805 }
1806 }
1807
1808 fn recall_cmd(query: Option<&str>) -> Command {
1809 Command::Recall {
1810 query: query.map(str::to_owned),
1811 tags: vec![],
1812 entities: vec![],
1813 as_of: None,
1814 range: None,
1815 k: 0,
1816 closed: false,
1817 token_budget: None,
1818 ef: None,
1819 graph_depth: None,
1820 vector: Vec::new(),
1821 }
1822 }
1823
1824 fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1825 Settings {
1826 database_path: None,
1827 config: Config::default(),
1828 embedder,
1829 snapshot_every_ops: None,
1830 snapshot_journal_bytes: None,
1831 maintain_every_forgets: None,
1832 fsync: None,
1833 workspace: plugmem_host::WorkspaceSettings {
1834 dir: None,
1835 limits: plugmem_host::WorkspaceLimits::default(),
1836 },
1837 warnings: Vec::new(),
1838 }
1839 }
1840
1841 #[test]
1842 fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1843 let mut with = settings_with(Some(Box::new(StubEmbedder)));
1845 assert_eq!(
1846 embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1847 Some(vec![0.1, 0.2, 0.3])
1848 );
1849
1850 let mut without = settings_with(None);
1852 assert_eq!(
1853 embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1854 None
1855 );
1856
1857 let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1859 assert_eq!(
1860 embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1861 None
1862 );
1863
1864 let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1866 assert_eq!(
1867 embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1868 None
1869 );
1870 }
1871
1872 #[test]
1873 fn split_line_honors_quotes_and_whitespace() {
1874 assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1875 assert_eq!(
1876 split_line(r#"remember "two words" --tag x"#),
1877 ["remember", "two words", "--tag", "x"]
1878 );
1879 assert_eq!(split_line(" recall 'a b' "), ["recall", "a b"]);
1880 assert_eq!(split_line(""), Vec::<String>::new());
1881 assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1883 }
1884
1885 #[test]
1886 fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1887 let (db, tmp) = TempDb::open();
1888 let path = tmp.0.join("m.plugmem");
1889 drop(db); let settings = settings_with(None);
1892 let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1894 let mut out = Vec::new();
1895 let code = run_repl(&path, settings, false, &script[..], &mut out);
1896 let text = String::from_utf8(out).unwrap();
1897
1898 assert_eq!(code, 0);
1899 assert!(text.contains("remembered fact 0"), "{text}");
1900 assert!(text.contains("tokio"), "{text}");
1901 assert!(text.contains("unrecognized subcommand"), "{text}");
1903
1904 let ro = Database::open_readonly(&path, Config::default()).unwrap();
1908 assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1909 }
1910
1911 #[test]
1912 fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1913 let (db, tmp) = TempDb::open();
1914 let path = tmp.0.join("m.plugmem");
1915 let mut sink = Vec::new();
1918 execute(
1919 &db,
1920 &remember("seed fact tokio", None, &[]),
1921 false,
1922 1_000,
1923 &mut sink,
1924 )
1925 .unwrap();
1926 db.checkpoint(1_001).unwrap();
1927
1928 let settings = settings_with(None);
1929 let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1931 let mut out = Vec::new();
1932 let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1933 let text = String::from_utf8(out).unwrap();
1934
1935 assert_eq!(code, 0);
1936 assert!(text.contains("generation 1"), "generation verb: {text}");
1937 assert!(text.contains("fact"), "stats ran: {text}");
1938 assert!(
1941 text.contains("already current → generation 1"),
1942 "refresh no-op: {text}"
1943 );
1944 assert!(text.contains("read-only session"), "write refused: {text}");
1946
1947 assert_eq!(db.stats().facts, 1);
1950 }
1951
1952 #[test]
1953 fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1954 let (db, tmp) = TempDb::open();
1955 let path = tmp.0.join("m.plugmem");
1956 let mut sink = Vec::new();
1957 execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1958 db.checkpoint(1_001).unwrap();
1959
1960 struct HookOnFirstRead<'a> {
1964 script: std::io::Cursor<&'a [u8]>,
1965 db: &'a Database,
1966 fired: bool,
1967 }
1968 impl std::io::Read for HookOnFirstRead<'_> {
1969 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1970 if !self.fired {
1971 self.fired = true;
1972 let mut s = Vec::new();
1975 execute(
1976 self.db,
1977 &remember("second", None, &[]),
1978 false,
1979 2_000,
1980 &mut s,
1981 )
1982 .unwrap();
1983 self.db.checkpoint(2_001).unwrap();
1984 }
1985 self.script.read(buf)
1986 }
1987 }
1988 let reader = std::io::BufReader::new(HookOnFirstRead {
1989 script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
1990 db: &db,
1991 fired: false,
1992 });
1993
1994 let mut out = Vec::new();
1995 let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
1996 let text = String::from_utf8(out).unwrap();
1997
1998 assert_eq!(code, 0);
1999 assert!(text.contains("refreshed → generation 2"), "advance: {text}");
2001 assert!(text.contains("fact"), "stats after refresh: {text}");
2003 assert_eq!(db.stats().facts, 2);
2004 }
2005
2006 #[test]
2007 fn read_only_repl_freshness_verbs_emit_json() {
2008 let (db, tmp) = TempDb::open();
2009 let path = tmp.0.join("m.plugmem");
2010 let mut sink = Vec::new();
2011 execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
2012 db.checkpoint(1_001).unwrap();
2013
2014 let script = b"generation\nrefresh\nexit\n";
2015 let mut out = Vec::new();
2016 let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
2017 let text = String::from_utf8(out).unwrap();
2018
2019 assert_eq!(code, 0);
2020 assert!(
2021 text.contains(r#""generation":1"#),
2022 "generation json: {text}"
2023 );
2024 assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
2025 }
2026
2027 struct TempDb(PathBuf);
2029 impl TempDb {
2030 fn open() -> (Database, Self) {
2031 let dir = std::env::temp_dir().join(format!(
2032 "plugmem-cli-{}-{}",
2033 std::process::id(),
2034 now_ms_unique()
2035 ));
2036 std::fs::create_dir_all(&dir).unwrap();
2037 let path = dir.join("m.plugmem");
2038 let (db, _) = Database::open(&path, Config::default()).unwrap();
2039 (db, TempDb(dir))
2040 }
2041 }
2042 impl Drop for TempDb {
2043 fn drop(&mut self) {
2044 let _ = std::fs::remove_dir_all(&self.0);
2045 }
2046 }
2047
2048 fn now_ms_unique() -> String {
2051 use std::sync::atomic::{AtomicU64, Ordering};
2052 static N: AtomicU64 = AtomicU64::new(0);
2053 format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
2054 }
2055
2056 fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
2057 let mut buf = Vec::new();
2058 let code = execute(db, cmd, json, now, &mut buf).expect("execute");
2059 (code, String::from_utf8(buf).unwrap())
2060 }
2061
2062 fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
2063 Command::Remember {
2064 text: text.into(),
2065 entity: entity.map(Into::into),
2066 tags: tags.iter().map(|t| (*t).into()).collect(),
2067 links: Vec::new(),
2068 meta: Vec::new(),
2069 valid_from: None,
2070 vector: Vec::new(),
2071 guarded: false,
2072 }
2073 }
2074
2075 fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
2076 Command::Remember {
2077 text: text.into(),
2078 entity: None,
2079 tags: Vec::new(),
2080 links: Vec::new(),
2081 meta: meta.iter().map(|m| (*m).into()).collect(),
2082 valid_from: None,
2083 vector: Vec::new(),
2084 guarded: false,
2085 }
2086 }
2087
2088 #[test]
2089 fn guarded_remember_flag_reports_blocked_without_a_write() {
2090 let (db, _temp) = TempDb::open();
2091 let mut first = remember("likes green tea every morning", Some("user"), &[]);
2092 let Command::Remember { guarded, .. } = &mut first else {
2093 unreachable!()
2094 };
2095 *guarded = true;
2096 let (_, stored) = run_cmd(&db, &first, true, 1_000);
2097 let stored: serde_json::Value = serde_json::from_str(&stored).unwrap();
2098 assert_eq!(stored["status"], "stored");
2099 assert_eq!(stored["outcome"]["id"], 0);
2100
2101 let mut second = remember("likes green tea each morning", Some("user"), &[]);
2102 let Command::Remember { guarded, .. } = &mut second else {
2103 unreachable!()
2104 };
2105 *guarded = true;
2106 let (_, blocked) = run_cmd(&db, &second, true, 2_000);
2107 let blocked: serde_json::Value = serde_json::from_str(&blocked).unwrap();
2108 assert_eq!(blocked["status"], "blocked");
2109 assert_eq!(blocked["similar"][0]["id"], 0);
2110 assert_eq!(db.stats().facts, 1);
2111 }
2112
2113 #[test]
2114 fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
2115 let (db, _t) = TempDb::open();
2116 let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
2118 assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
2119
2120 let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2122 assert!(
2123 human.contains("metadata page=3, uri=s3://b/x"),
2124 "{human}"
2125 );
2126 let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2128 let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
2129 assert_eq!(v["metadata"]["page"], "3");
2130 assert_eq!(v["metadata"]["uri"], "s3://b/x");
2131
2132 let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
2134 let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
2135 assert_eq!(line["metadata"]["uri"], "s3://b/x");
2136
2137 assert!(matches!(
2139 parse_meta(&["noequals".to_string()]),
2140 Err(CliError::Usage(_))
2141 ));
2142 assert!(parse_meta(&["=noKey".to_string()]).is_err());
2143 }
2144
2145 #[test]
2146 fn remember_then_recall_human_and_json() {
2147 let (db, _t) = TempDb::open();
2148 let (code, out) = run_cmd(
2149 &db,
2150 &remember("prefers tokio", Some("user"), &["pref"]),
2151 false,
2152 1_000,
2153 );
2154 assert_eq!(code, 0);
2155 assert!(out.starts_with("remembered fact 0"), "{out}");
2156
2157 let recall = Command::Recall {
2159 query: Some("tokio".into()),
2160 tags: Vec::new(),
2161 entities: Vec::new(),
2162 as_of: None,
2163 range: None,
2164 k: 0,
2165 closed: false,
2166 token_budget: None,
2167 ef: None,
2168 graph_depth: None,
2169 vector: Vec::new(),
2170 };
2171 let (code, out) = run_cmd(&db, &recall, false, 2_000);
2172 assert_eq!(code, 0);
2173 assert!(out.contains("tokio"), "{out}");
2174
2175 let (code, out) = run_cmd(&db, &recall, true, 2_000);
2177 assert_eq!(code, 0);
2178 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2179 assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
2180 }
2181
2182 #[test]
2183 fn recall_empty_is_ok_with_a_note() {
2184 let (db, _t) = TempDb::open();
2185 let recall = Command::Recall {
2186 query: Some("nothing here".into()),
2187 tags: Vec::new(),
2188 entities: Vec::new(),
2189 as_of: None,
2190 range: None,
2191 k: 0,
2192 closed: false,
2193 token_budget: None,
2194 ef: None,
2195 graph_depth: None,
2196 vector: Vec::new(),
2197 };
2198 let (code, out) = run_cmd(&db, &recall, false, 1_000);
2199 assert_eq!(code, 0);
2200 assert!(out.contains("nothing recalled"), "{out}");
2201 }
2202
2203 #[test]
2204 fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
2205 let (db, _t) = TempDb::open();
2206 run_cmd(
2207 &db,
2208 &remember("lives in Moscow", Some("user"), &[]),
2209 false,
2210 1_000,
2211 );
2212 let (_, out) = run_cmd(
2214 &db,
2215 &remember("lives in Moscow now", Some("user"), &[]),
2216 false,
2217 1_500,
2218 );
2219 assert!(out.contains("similar to fact"), "{out}");
2220
2221 let revise = Command::Revise {
2222 id: 0,
2223 text: "lives in Berlin".into(),
2224 entity: Some("user".into()),
2225 tags: Vec::new(),
2226 links: Vec::new(),
2227 meta: Vec::new(),
2228 valid_from: None,
2229 vector: Vec::new(),
2230 };
2231 let (code, out) = run_cmd(&db, &revise, false, 2_000);
2232 assert_eq!(code, 0);
2233 assert!(out.starts_with("remembered fact"), "{out}");
2234 }
2235
2236 #[test]
2237 fn show_found_and_missing() {
2238 let (db, _t) = TempDb::open();
2239 run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
2240
2241 let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2242 assert_eq!(code, 0);
2243 assert!(
2244 out.contains("a note") && out.contains("recorded_at 1000"),
2245 "{out}"
2246 );
2247
2248 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
2249 assert_eq!(code, 1, "missing id is a soft miss");
2250 assert!(out.contains("not found"), "{out}");
2251
2252 let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2254 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2255 assert_eq!(v["text"], "a note");
2256 assert_eq!(v["valid_to"], serde_json::Value::Null); }
2258
2259 #[test]
2260 fn forget_then_maintain_purges() {
2261 let (db, _t) = TempDb::open();
2262 run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
2263
2264 let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
2265 assert_eq!(code, 0);
2266 assert!(out.contains("forgot fact 0"), "{out}");
2267 let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
2269 assert!(out.contains("already gone"), "{out}");
2270
2271 let (code, out) = run_cmd(
2272 &db,
2273 &Command::Maintain {
2274 mode: MaintainMode::Auto,
2275 reembed: false,
2276 batch_size: None,
2277 },
2278 false,
2279 3_000,
2280 );
2281 assert_eq!(code, 0);
2282 assert!(out.contains("purged 1"), "{out}");
2283 }
2284
2285 #[test]
2286 fn explicit_reembed_has_human_and_json_reports() {
2287 let (plain, tmp) = TempDb::open();
2288 let path = tmp.0.join("m.plugmem");
2289 drop(plain);
2290 let mut config = Config::default();
2291 config.dim = 3;
2292 let (db, _) = Database::builder(config)
2293 .embedder(Box::new(StubEmbedder))
2294 .open(path)
2295 .unwrap();
2296 run_cmd(&db, &remember("vector fact", None, &["kept"]), false, 1);
2297
2298 let command = Command::Maintain {
2299 mode: MaintainMode::Auto,
2300 reembed: true,
2301 batch_size: Some(1),
2302 };
2303 let (code, human) = run_cmd(&db, &command, false, 2);
2304 assert_eq!(code, 0);
2305 assert!(human.contains("reembedded: 1 facts"));
2306 let (code, json) = run_cmd(&db, &command, true, 3);
2307 assert_eq!(code, 0);
2308 let report: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
2309 assert_eq!(report["new_space"], "cli-stub");
2310 assert_eq!(report["embedded"], 1);
2311 }
2312
2313 #[test]
2314 fn readonly_cli_refuses_a_same_dimension_different_space() {
2315 let (plain, tmp) = TempDb::open();
2316 let path = tmp.0.join("m.plugmem");
2317 drop(plain);
2318 let mut config = Config::default();
2319 config.dim = 3;
2320 let (db, _) = Database::builder(config.clone())
2321 .embedder(Box::new(StubEmbedder))
2322 .open(&path)
2323 .unwrap();
2324 db.remember(RememberInput::text(1, "stored vector"))
2325 .unwrap();
2326 db.checkpoint(2).unwrap();
2327 let ro = Database::open_readonly(&path, config).unwrap();
2328 let cmd = recall_cmd(Some("stored"));
2329 let mut out = Vec::new();
2330 let code = execute_ro(
2331 &ro,
2332 &cmd,
2333 Some(&[0.1, 0.2, 0.3]),
2334 Some("other-model"),
2335 false,
2336 &mut out,
2337 );
2338 assert_eq!(code, 2);
2339 }
2340
2341 #[test]
2342 fn tags_pages_and_remove_tag_have_human_and_json_shapes() {
2343 let (db, _t) = TempDb::open();
2344 run_cmd(&db, &remember("one", None, &["drop", "keep"]), false, 1_000);
2345 run_cmd(&db, &remember("two", None, &["drop"]), false, 1_100);
2346
2347 let (code, out) = run_cmd(
2348 &db,
2349 &Command::Tags {
2350 prefix: None,
2351 cursor: None,
2352 limit: 1,
2353 },
2354 false,
2355 2_000,
2356 );
2357 assert_eq!(code, 0);
2358 assert!(out.starts_with("2\tdrop\n"), "{out}");
2359 assert!(out.contains("next_cursor\t"), "{out}");
2360
2361 let (_, out) = run_cmd(&db, &Command::RemoveTag { tag: "drop".into() }, true, 3_000);
2362 let removed: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2363 assert_eq!(removed["affected"], 2);
2364
2365 let (_, out) = run_cmd(
2366 &db,
2367 &Command::Tags {
2368 prefix: None,
2369 cursor: None,
2370 limit: 0,
2371 },
2372 true,
2373 4_000,
2374 );
2375 let page: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2376 assert_eq!(page["items"][0]["name"], "keep");
2377 assert_eq!(db.export().len(), 2, "facts remain current after retagging");
2378 }
2379
2380 #[test]
2381 fn link_and_stats_and_json() {
2382 let (db, _t) = TempDb::open();
2383 run_cmd(
2384 &db,
2385 &remember("uses tokio", Some("plugmem"), &[]),
2386 false,
2387 1_000,
2388 );
2389 let link = Command::Link {
2390 src: "plugmem".into(),
2391 rel: "depends_on".into(),
2392 dst: "tokio".into(),
2393 provenance: None,
2394 };
2395 let (code, out) = run_cmd(&db, &link, false, 2_000);
2396 assert_eq!(code, 0);
2397 assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
2398 let unlink = Command::Unlink {
2399 src: "plugmem".into(),
2400 rel: "depends_on".into(),
2401 dst: "tokio".into(),
2402 };
2403 let (code, out) = run_cmd(&db, &unlink, false, 2_500);
2404 assert_eq!(code, 0);
2405 assert!(
2406 out.contains("unlinked plugmem -depends_on-> tokio"),
2407 "{out}"
2408 );
2409
2410 let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
2411 assert_eq!(code, 0);
2412 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2413 assert_eq!(v["facts"], 1);
2414 assert_eq!(v["edges"], 0);
2415 assert_eq!(v["edge_versions"], 1);
2416 }
2417
2418 #[test]
2419 fn bad_link_is_a_usage_error() {
2420 let (db, _t) = TempDb::open();
2421 let cmd = Command::Remember {
2422 text: "x".into(),
2423 entity: Some("user".into()),
2424 tags: Vec::new(),
2425 links: vec!["not-a-pair".into()],
2426 meta: Vec::new(),
2427 valid_from: None,
2428 vector: Vec::new(),
2429 guarded: false,
2430 };
2431 let mut buf = Vec::new();
2432 let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
2433 assert!(matches!(err, CliError::Usage(_)));
2434 }
2435
2436 #[test]
2437 fn as_of_time_travel_via_recall() {
2438 let (db, _t) = TempDb::open();
2439 run_cmd(
2440 &db,
2441 &remember("lives in Moscow", Some("user"), &[]),
2442 false,
2443 1_000,
2444 );
2445 let revise = Command::Revise {
2446 id: 0,
2447 text: "lives in Berlin".into(),
2448 entity: Some("user".into()),
2449 tags: Vec::new(),
2450 links: Vec::new(),
2451 meta: Vec::new(),
2452 valid_from: None,
2453 vector: Vec::new(),
2454 };
2455 run_cmd(&db, &revise, false, 2_000);
2456
2457 let as_of = Command::Recall {
2458 query: Some("lives".into()),
2459 tags: Vec::new(),
2460 entities: vec!["user".into()],
2461 as_of: Some(1_500),
2462 range: None,
2463 k: 0,
2464 closed: false,
2465 token_budget: None,
2466 ef: None,
2467 graph_depth: None,
2468 vector: Vec::new(),
2469 };
2470 let (_, out) = run_cmd(&db, &as_of, false, 3_000);
2471 assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
2472 }
2473
2474 #[test]
2475 fn every_command_has_a_json_shape() {
2476 let (db, _t) = TempDb::open();
2477 let (_, out) = run_cmd(
2479 &db,
2480 &remember("uses tokio", Some("plugmem"), &["pref"]),
2481 true,
2482 1_000,
2483 );
2484 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2485 assert_eq!(v["id"], 0);
2486 assert!(v["similar"].is_array());
2487
2488 let revise = Command::Revise {
2490 id: 0,
2491 text: "uses tokio now".into(),
2492 entity: Some("plugmem".into()),
2493 tags: Vec::new(),
2494 links: Vec::new(),
2495 meta: Vec::new(),
2496 valid_from: None,
2497 vector: Vec::new(),
2498 };
2499 let (_, out) = run_cmd(&db, &revise, true, 1_500);
2500 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2501
2502 let link = Command::Link {
2504 src: "plugmem".into(),
2505 rel: "depends_on".into(),
2506 dst: "tokio".into(),
2507 provenance: None,
2508 };
2509 let (_, out) = run_cmd(&db, &link, true, 2_000);
2510 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2511 assert_eq!(v["rel"], "depends_on");
2512 let unlink = Command::Unlink {
2513 src: "plugmem".into(),
2514 rel: "depends_on".into(),
2515 dst: "tokio".into(),
2516 };
2517 let (_, out) = run_cmd(&db, &unlink, true, 2_100);
2518 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2519 assert_eq!(v["unlinked"], true);
2520
2521 let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
2523 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2524 assert_eq!(v["forgotten"], true);
2525 let (_, out) = run_cmd(
2526 &db,
2527 &Command::Maintain {
2528 mode: MaintainMode::Auto,
2529 reembed: false,
2530 batch_size: None,
2531 },
2532 true,
2533 3_000,
2534 );
2535 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2536 assert!(v["purged"].as_u64().unwrap() >= 1);
2537
2538 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
2540 assert_eq!(code, 1);
2541 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2542 assert_eq!(v["found"], false);
2543
2544 let recall = Command::Recall {
2546 query: None,
2547 tags: Vec::new(),
2548 entities: vec!["plugmem".into()],
2549 as_of: None,
2550 range: Some(vec![0, 10_000]),
2551 k: 4,
2552 closed: true,
2553 token_budget: None,
2554 ef: None,
2555 graph_depth: None,
2556 vector: Vec::new(),
2557 };
2558 let (_, out) = run_cmd(&db, &recall, true, 4_000);
2559 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2560 }
2561
2562 #[test]
2563 fn graph_depth_is_a_per_call_flag_over_the_configured_default() {
2564 let (db, _t) = TempDb::open();
2567 for (i, (entity, next)) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
2568 .iter()
2569 .enumerate()
2570 {
2571 let cmd = Command::Remember {
2572 text: format!("fact on {entity}"),
2573 entity: Some((*entity).into()),
2574 tags: Vec::new(),
2575 links: vec![format!("leads_to:{next}")],
2576 meta: Vec::new(),
2577 valid_from: None,
2578 vector: Vec::new(),
2579 guarded: false,
2580 };
2581 run_cmd(&db, &cmd, false, 1_000 + i as u64);
2582 }
2583
2584 let reached = |depth: Option<u32>| {
2585 let recall = Command::Recall {
2586 query: None,
2587 tags: Vec::new(),
2588 entities: vec!["a".into()],
2589 as_of: None,
2590 range: None,
2591 k: 64,
2592 closed: false,
2593 token_budget: Some(4096),
2594 ef: None,
2595 graph_depth: depth,
2596 vector: Vec::new(),
2597 };
2598 let (_, out) = run_cmd(&db, &recall, false, 5_000);
2599 out.lines().filter(|l| l.starts_with("- [f")).count()
2600 };
2601
2602 assert_eq!(reached(None), 3, "the configured default is 2 hops");
2603 assert_eq!(reached(Some(0)), 1, "no expansion: the anchor's own fact");
2604 assert_eq!(reached(Some(1)), 2);
2605 assert_eq!(reached(Some(3)), 4);
2606 assert_eq!(reached(Some(99)), 4);
2609 assert_eq!(reached(Some(u32::MAX)), 4);
2610 }
2611
2612 #[test]
2613 fn stats_human_lists_the_counters() {
2614 let (db, _t) = TempDb::open();
2615 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2616 let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
2617 assert_eq!(code, 0);
2618 assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
2619 }
2620
2621 #[test]
2622 fn verify_command_renders_human_and_json() {
2623 let (db, _t) = TempDb::open();
2624 run_cmd(&db, &remember("clean", None, &[]), false, 1_000);
2625
2626 let (code, out) = run_cmd(&db, &Command::Verify, false, 2_000);
2627 assert_eq!(code, 0);
2628 assert_eq!(out.trim(), "integrity ok");
2629
2630 let (code, out) = run_cmd(&db, &Command::Verify, true, 2_100);
2631 assert_eq!(code, 0);
2632 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2633 assert_eq!(v["ok"], true);
2634 }
2635
2636 #[test]
2637 fn show_json_of_a_revised_predecessor_is_closed() {
2638 let (db, _t) = TempDb::open();
2639 run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
2640 let revise = Command::Revise {
2641 id: 0,
2642 text: "v2".into(),
2643 entity: Some("e".into()),
2644 tags: Vec::new(),
2645 links: Vec::new(),
2646 meta: Vec::new(),
2647 valid_from: None,
2648 vector: Vec::new(),
2649 };
2650 run_cmd(&db, &revise, false, 2_000);
2651 let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
2653 assert!(out.contains("revises fact 0"), "{out}");
2654 }
2655
2656 #[test]
2657 fn resolve_db_path_prefers_the_flag() {
2658 let p = "/tmp/explicit.plugmem";
2659 assert_eq!(resolve_db_path(Some(p), None, None), PathBuf::from(p));
2660 let configured = std::path::Path::new("/tmp/configured.plugmem");
2661 assert_eq!(
2662 resolve_db_path(None, Some(configured), None),
2663 PathBuf::from(configured)
2664 );
2665 let _ = resolve_db_path(None, None, None);
2668 }
2669
2670 #[test]
2671 fn a_bare_name_is_a_memory_only_when_a_workspace_is_configured() {
2672 let root = PathBuf::from("/srv/bot");
2673
2674 assert_eq!(
2677 resolve_db_path(Some("work"), None, None),
2678 PathBuf::from("work")
2679 );
2680
2681 assert_eq!(
2683 resolve_db_path(Some("work"), None, Some(&root)),
2684 PathBuf::from("/srv/bot/db/work.plugmem")
2685 );
2686 for path in ["./work", "work.plugmem", "/srv/other.plugmem", "../up"] {
2689 assert_eq!(
2690 resolve_db_path(Some(path), None, Some(&root)),
2691 PathBuf::from(path),
2692 "{path}"
2693 );
2694 }
2695
2696 let configured = std::path::Path::new("work");
2698 assert_eq!(
2699 resolve_db_path(None, Some(configured), Some(&root)),
2700 PathBuf::from("work")
2701 );
2702 }
2703
2704 #[test]
2705 fn settings_help_runs_without_opening_a_database() {
2706 let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
2707 let mut output = Vec::new();
2708 assert_eq!(run_parsed(cli, &mut output), 0);
2709 let output = String::from_utf8(output).unwrap();
2710 assert!(output.contains("plugmem settings"));
2711 assert!(output.contains("[database]"));
2712 assert!(output.contains("path (path string"));
2713
2714 let cli = Cli::try_parse_from(["plugmem-cli", "--json", "help", "settings"]).unwrap();
2715 let mut output = Vec::new();
2716 assert_eq!(run_parsed(cli, &mut output), 0);
2717 let output: serde_json::Value = serde_json::from_slice(&output).unwrap();
2718 assert_eq!(output["topic"], "settings");
2719 assert!(output["config_path_precedence"].is_array());
2720 assert!(output["settings"].as_array().unwrap().len() > 10);
2721 }
2722
2723 #[test]
2724 fn run_parsed_opens_runs_and_reports() {
2725 let dir = std::env::temp_dir().join(format!(
2726 "plugmem-run-{}-{}",
2727 std::process::id(),
2728 now_ms_unique()
2729 ));
2730 std::fs::create_dir_all(&dir).unwrap();
2731 let path = dir.join("m.plugmem");
2732 let cli = Cli {
2733 db: Some(path.display().to_string()),
2734 workspace: None,
2735 config: None,
2736 json: false,
2737 command: Command::Stats,
2738 };
2739 let mut buf = Vec::new();
2740 let code = run_parsed(cli, &mut buf);
2741 assert_eq!(code, 0);
2742 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2743 let _ = std::fs::remove_dir_all(&dir);
2744 }
2745
2746 #[test]
2747 fn recover_and_scrub_render_json_and_human_shapes() {
2748 let (db, tmp) = TempDb::open();
2749 let path = tmp.0.join("m.plugmem");
2750 run_cmd(&db, &remember("recoverable fact", None, &[]), false, 1_000);
2751 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2752 drop(db);
2753
2754 let settings = settings_with(None);
2755 let mut out = Vec::new();
2756 assert_eq!(do_scrub(&path, &settings, true, &mut out), 0);
2757 let scrub: serde_json::Value = serde_json::from_slice(&out).unwrap();
2758 assert_eq!(scrub["ok"], true);
2759 assert!(scrub["bytes"].as_u64().unwrap() > 0);
2760 let mut out = Vec::new();
2761 assert_eq!(do_scrub(&path, &settings, false, &mut out), 0);
2762 let out = String::from_utf8(out).unwrap();
2763 assert!(out.contains("scrub ok:"), "{out}");
2764
2765 let json_dst = tmp.0.join("copy-json.plugmem");
2766 let mut out = Vec::new();
2767 assert_eq!(do_recover(&path, &json_dst, &settings, true, &mut out), 0);
2768 let recover: serde_json::Value = serde_json::from_slice(&out).unwrap();
2769 assert_eq!(recover["kept"], 1);
2770 assert_eq!(recover["dropped_text"], 0);
2771 assert_eq!(recover["dst"], json_dst.display().to_string());
2772
2773 let human_dst = tmp.0.join("copy-human.plugmem");
2774 let mut out = Vec::new();
2775 assert_eq!(do_recover(&path, &human_dst, &settings, false, &mut out), 0);
2776 let out = String::from_utf8(out).unwrap();
2777 assert!(out.contains("recovered to"), "{out}");
2778 assert!(out.contains("kept 1"), "{out}");
2779 }
2780
2781 #[test]
2782 fn readonly_dispatcher_renders_every_read_shape() {
2783 let (db, tmp) = TempDb::open();
2784 let path = tmp.0.join("m.plugmem");
2785 run_cmd(
2786 &db,
2787 &remember("readonly tokio fact", Some("plugmem"), &["pref"]),
2788 false,
2789 1_000,
2790 );
2791 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2792 let ro = Database::open_readonly(&path, Config::default()).unwrap();
2793
2794 let mut out = Vec::new();
2795 assert_eq!(
2796 execute_ro(&ro, &Command::Stats, None, None, true, &mut out),
2797 0
2798 );
2799 let stats: serde_json::Value = serde_json::from_slice(&out).unwrap();
2800 assert_eq!(stats["facts"], 1);
2801
2802 let mut out = Vec::new();
2803 assert_eq!(
2804 execute_ro(&ro, &Command::Show { id: 0 }, None, None, false, &mut out),
2805 0
2806 );
2807 let text = String::from_utf8(out).unwrap();
2808 assert!(text.contains("readonly tokio fact"), "{text}");
2809
2810 let mut out = Vec::new();
2811 assert_eq!(
2812 execute_ro(&ro, &Command::Export, None, None, false, &mut out),
2813 0
2814 );
2815 let exported: serde_json::Value =
2816 serde_json::from_str(String::from_utf8(out).unwrap().lines().next().unwrap()).unwrap();
2817 assert_eq!(exported["text"], "readonly tokio fact");
2818
2819 let mut out = Vec::new();
2820 let recall = Command::Recall {
2821 query: Some("tokio".into()),
2822 tags: vec!["pref".into()],
2823 entities: vec!["plugmem".into()],
2824 as_of: None,
2825 range: None,
2826 k: 1,
2827 closed: false,
2828 token_budget: None,
2829 ef: None,
2830 graph_depth: None,
2831 vector: Vec::new(),
2832 };
2833 assert_eq!(execute_ro(&ro, &recall, None, None, false, &mut out), 0);
2834 let text = String::from_utf8(out).unwrap();
2835 assert!(text.contains("tokio"), "{text}");
2836
2837 let mut out = Vec::new();
2838 assert_eq!(
2839 execute_ro(&ro, &Command::Verify, None, None, true, &mut out),
2840 0
2841 );
2842 let verify: serde_json::Value = serde_json::from_slice(&out).unwrap();
2843 assert_eq!(verify["ok"], true);
2844 }
2845
2846 #[test]
2847 fn run_parsed_on_a_locked_database_returns_one() {
2848 let (_held, dir) = {
2849 let dir = std::env::temp_dir().join(format!(
2850 "plugmem-lock-{}-{}",
2851 std::process::id(),
2852 now_ms_unique()
2853 ));
2854 std::fs::create_dir_all(&dir).unwrap();
2855 let path = dir.join("m.plugmem");
2856 (Database::open(&path, Config::default()).unwrap(), dir)
2857 };
2858 let cli = Cli {
2859 db: Some(dir.join("m.plugmem").display().to_string()),
2860 workspace: None,
2861 config: None,
2862 json: false,
2863 command: Command::Stats,
2864 };
2865 let mut buf = Vec::new();
2866 assert_eq!(run_parsed(cli, &mut buf), 1);
2867 let _ = std::fs::remove_dir_all(&dir);
2868 }
2869
2870 #[test]
2871 fn run_parsed_propagates_a_usage_error_as_two() {
2872 let dir = std::env::temp_dir().join(format!(
2873 "plugmem-usage-{}-{}",
2874 std::process::id(),
2875 now_ms_unique()
2876 ));
2877 std::fs::create_dir_all(&dir).unwrap();
2878 let cli = Cli {
2879 db: Some(dir.join("m.plugmem").display().to_string()),
2880 workspace: None,
2881 config: None,
2882 json: false,
2883 command: Command::Remember {
2884 text: "x".into(),
2885 entity: None,
2886 tags: Vec::new(),
2887 links: vec!["bad".into()],
2888 meta: Vec::new(),
2889 valid_from: None,
2890 vector: Vec::new(),
2891 guarded: false,
2892 },
2893 };
2894 let mut buf = Vec::new();
2895 assert_eq!(run_parsed(cli, &mut buf), 2);
2896 let _ = std::fs::remove_dir_all(&dir);
2897 }
2898
2899 struct Scratch(PathBuf);
2901 impl Scratch {
2902 fn new(tag: &str) -> Self {
2903 let dir = std::env::temp_dir().join(format!(
2904 "plugmem-cli-{tag}-{}-{}",
2905 std::process::id(),
2906 now_ms_unique()
2907 ));
2908 std::fs::create_dir_all(&dir).unwrap();
2909 Scratch(dir)
2910 }
2911 }
2912 impl Drop for Scratch {
2913 fn drop(&mut self) {
2914 let _ = std::fs::remove_dir_all(&self.0);
2915 }
2916 }
2917
2918 #[test]
2919 fn export_import_roundtrip_preserves_open_facts() {
2920 let (a, _ta) = TempDb::open();
2925 run_cmd(
2926 &a,
2927 &Command::Remember {
2928 text: "prefers tokio".into(),
2929 entity: Some("user".into()),
2930 tags: vec!["pref".into(), "lang".into()],
2931 links: Vec::new(),
2932 meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
2933 valid_from: Some(500),
2934 vector: Vec::new(),
2935 guarded: false,
2936 },
2937 false,
2938 1_000,
2939 );
2940 run_cmd(
2941 &a,
2942 &remember("lives in Moscow", Some("user"), &[]),
2943 false,
2944 1_100,
2945 ); run_cmd(
2947 &a,
2948 &Command::Revise {
2949 id: 1,
2950 text: "lives in Berlin".into(),
2951 entity: Some("user".into()),
2952 tags: vec!["geo".into()],
2953 links: Vec::new(),
2954 meta: Vec::new(),
2955 valid_from: None,
2956 vector: Vec::new(),
2957 },
2958 false,
2959 1_200,
2960 ); run_cmd(&a, &remember("junk", None, &[]), false, 1_300); run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); run_cmd(
2964 &a,
2965 &remember("uses rust", Some("plugmem"), &["lang"]),
2966 false,
2967 1_500,
2968 ); let mut dump = Vec::new();
2972 render_export(&a.export(), false, &mut dump);
2973 let scratch = Scratch::new("roundtrip");
2974 let file = scratch.0.join("dump.jsonl");
2975 std::fs::write(&file, &dump).unwrap();
2976
2977 let (b, _tb) = TempDb::open();
2979 let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
2980
2981 let key = |f: &ExportedFact| {
2983 let mut tags = f.tags.clone();
2984 tags.sort();
2985 (f.text.clone(), f.entity.clone(), tags, f.valid_from)
2986 };
2987 let mut ak: Vec<_> = a.export().iter().map(key).collect();
2988 let mut bk: Vec<_> = b.export().iter().map(key).collect();
2989 ak.sort();
2990 bk.sort();
2991 assert_eq!(n.facts, ak.len());
2992 assert_eq!(
2993 ak, bk,
2994 "roundtrip must preserve text/entity/tags/valid_from"
2995 );
2996
2997 let b_open = b.export();
3000 assert!(b_open.iter().any(|f| f.text == "prefers tokio"
3001 && f.valid_from == 500
3002 && f.entity.as_deref() == Some("user")
3003 && f.tags == vec!["pref".to_string(), "lang".to_string()]
3004 && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
3005 && f.metadata.get("src").map(String::as_str) == Some("chat")));
3006 assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
3007 assert!(b_open.iter().any(|f| f.text == "uses rust"));
3008 assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
3009 assert!(!b_open.iter().any(|f| f.text == "junk"));
3010 }
3011
3012 #[test]
3013 fn export_command_emits_jsonl_regardless_of_json_flag() {
3014 let (db, _t) = TempDb::open();
3015 run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
3016 for json in [false, true] {
3017 let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
3018 assert_eq!(code, 0);
3019 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
3020 assert_eq!(v["text"], "a fact");
3021 assert_eq!(v["entity"], "e");
3022 assert_eq!(v["tags"][0], "t");
3023 }
3024 }
3025
3026 #[test]
3027 fn import_command_counts_and_rejects_bad_lines() {
3028 let (db, _t) = TempDb::open();
3029 let scratch = Scratch::new("import");
3030 let good = scratch.0.join("in.jsonl");
3031 std::fs::write(
3032 &good,
3033 "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
3034 )
3035 .unwrap();
3036 let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
3038 assert_eq!(n.facts, 2, "both facts imported, blank line skipped");
3039
3040 let bad = scratch.0.join("bad.jsonl");
3041 std::fs::write(&bad, "not json at all\n").unwrap();
3042 let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
3043 assert!(matches!(err, CliError::Usage(_)));
3044 }
3045
3046 #[test]
3047 fn import_batch_size_does_not_change_the_result() {
3048 let scratch = Scratch::new("import-batch");
3051 let file = scratch.0.join("facts.jsonl");
3052 let mut jsonl = String::new();
3053 for i in 0..5 {
3054 jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
3055 }
3056 std::fs::write(&file, &jsonl).unwrap();
3057
3058 let (a, _ta) = TempDb::open();
3059 let (b, _tb) = TempDb::open();
3060 let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
3061 let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
3062
3063 assert_eq!(na.facts, 5);
3064 assert_eq!(nb.facts, 5);
3065 let texts = |db: &Database| {
3066 let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
3067 t.sort();
3068 t
3069 };
3070 assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
3071 }
3072
3073 #[test]
3074 fn config_table_feeds_settings_and_the_cli_batch_size() {
3075 let scratch = Scratch::new("settings");
3079 let cfgfile = scratch.0.join("config.toml");
3080 std::fs::write(
3081 &cfgfile,
3082 "[engine]\ndim = 512\n[embedder]\nenabled = false\n\
3083 [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
3084 )
3085 .unwrap();
3086 let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
3087 let s = Settings::from_table(table.as_ref()).unwrap();
3088 assert_eq!(s.config.dim, 512);
3089 assert!(s.embedder.is_none());
3090 assert_eq!(s.snapshot_every_ops, Some(64));
3091 assert_eq!(read_batch_size(table.as_ref()), Some(200));
3092
3093 assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
3095 }
3096
3097 #[test]
3098 fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
3099 let scratch = Scratch::new("checkpoint-cmd");
3100 let path = scratch.0.join("m.plugmem");
3101
3102 let remember = Cli {
3104 db: Some(path.display().to_string()),
3105 workspace: None,
3106 config: None,
3107 json: false,
3108 command: Command::Remember {
3109 text: "hello tokio".into(),
3110 entity: None,
3111 tags: Vec::new(),
3112 links: Vec::new(),
3113 meta: Vec::new(),
3114 valid_from: None,
3115 vector: Vec::new(),
3116 guarded: false,
3117 },
3118 };
3119 assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
3120
3121 let checkpoint = |json| Cli {
3123 db: Some(path.display().to_string()),
3124 workspace: None,
3125 config: None,
3126 json,
3127 command: Command::Checkpoint,
3128 };
3129 let mut buf = Vec::new();
3130 assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
3131 assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
3132
3133 let mut buf = Vec::new();
3135 assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
3136 let v: serde_json::Value =
3137 serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
3138 assert_eq!(v["ok"], true);
3139
3140 let scrub = Cli {
3143 db: Some(path.display().to_string()),
3144 workspace: None,
3145 config: None,
3146 json: false,
3147 command: Command::Scrub,
3148 };
3149 let mut buf = Vec::new();
3150 assert_eq!(run_parsed(scrub, &mut buf), 0);
3151 assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
3152 }
3153
3154 #[test]
3155 fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
3156 let scratch = Scratch::new("ro-route");
3157 let path = scratch.0.join("m.plugmem");
3158 {
3159 let (db, _) = Database::open(&path, Config::default()).unwrap();
3160 db.remember(RememberInput::text(1_000, "hello tokio"))
3161 .unwrap();
3162 db.checkpoint(2_000).unwrap(); }
3164 let cli = Cli {
3166 db: Some(path.display().to_string()),
3167 workspace: None,
3168 config: None,
3169 json: false,
3170 command: Command::Stats,
3171 };
3172 let mut buf = Vec::new();
3173 assert_eq!(run_parsed(cli, &mut buf), 0);
3174 assert!(String::from_utf8(buf).unwrap().contains("facts"));
3175
3176 let cli = Cli {
3178 db: Some(path.display().to_string()),
3179 workspace: None,
3180 config: None,
3181 json: false,
3182 command: Command::Recall {
3183 query: Some("tokio".into()),
3184 tags: Vec::new(),
3185 entities: Vec::new(),
3186 as_of: None,
3187 range: None,
3188 k: 0,
3189 closed: false,
3190 token_budget: None,
3191 ef: None,
3192 graph_depth: None,
3193 vector: Vec::new(),
3194 },
3195 };
3196 let mut buf = Vec::new();
3197 assert_eq!(run_parsed(cli, &mut buf), 0);
3198 assert!(String::from_utf8(buf).unwrap().contains("tokio"));
3199 }
3200}