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, checked } => {
1731 if json {
1732 writeln!(
1733 out,
1734 "{}",
1735 json!({
1736 "status": "stored",
1737 "checked": checked,
1738 "outcome": {
1739 "id": outcome.id.0,
1740 "entity": outcome.entity.map(|entity| entity.0),
1741 "similar": [],
1742 }
1743 })
1744 )
1745 .ok();
1746 } else {
1747 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1748 if !checked {
1751 writeln!(
1752 out,
1753 " ! stored WITHOUT a duplicate check: the check is scoped to \
1754 --entity, and none was given"
1755 )
1756 .ok();
1757 }
1758 }
1759 }
1760 GuardedRememberOutcome::Blocked { similar } => {
1761 if json {
1762 let similar: Vec<_> = similar
1763 .iter()
1764 .map(|item| {
1765 json!({
1766 "id": item.id.0,
1767 "score": item.score,
1768 "reason": format!("{:?}", item.reason),
1769 })
1770 })
1771 .collect();
1772 writeln!(
1773 out,
1774 "{}",
1775 json!({ "status": "blocked", "similar": similar })
1776 )
1777 .ok();
1778 } else {
1779 writeln!(out, "not remembered: similar facts require a decision").ok();
1780 for item in similar {
1781 writeln!(
1782 out,
1783 " ~ fact {} ({:?}, {:.2})",
1784 item.id.0, item.reason, item.score
1785 )
1786 .ok();
1787 }
1788 }
1789 }
1790 }
1791}
1792
1793fn open_or(valid_to: u64) -> Option<u64> {
1795 (valid_to != VALID_TO_OPEN).then_some(valid_to)
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800 use plugmem_host::Config;
1801
1802 use super::*;
1803
1804 struct StubEmbedder;
1806 impl plugmem_host::Embedder for StubEmbedder {
1807 fn space_id(&self) -> &str {
1808 "cli-stub"
1809 }
1810
1811 fn dim(&self) -> usize {
1812 3
1813 }
1814 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1815 Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1816 }
1817 }
1818
1819 fn recall_cmd(query: Option<&str>) -> Command {
1820 Command::Recall {
1821 query: query.map(str::to_owned),
1822 tags: vec![],
1823 entities: vec![],
1824 as_of: None,
1825 range: None,
1826 k: 0,
1827 closed: false,
1828 token_budget: None,
1829 ef: None,
1830 graph_depth: None,
1831 vector: Vec::new(),
1832 }
1833 }
1834
1835 fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1836 Settings {
1837 database_path: None,
1838 config: Config::default(),
1839 embedder,
1840 snapshot_every_ops: None,
1841 snapshot_journal_bytes: None,
1842 maintain_every_forgets: None,
1843 fsync: None,
1844 workspace: plugmem_host::WorkspaceSettings {
1845 dir: None,
1846 limits: plugmem_host::WorkspaceLimits::default(),
1847 },
1848 warnings: Vec::new(),
1849 }
1850 }
1851
1852 #[test]
1853 fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1854 let mut with = settings_with(Some(Box::new(StubEmbedder)));
1856 assert_eq!(
1857 embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1858 Some(vec![0.1, 0.2, 0.3])
1859 );
1860
1861 let mut without = settings_with(None);
1863 assert_eq!(
1864 embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1865 None
1866 );
1867
1868 let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1870 assert_eq!(
1871 embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1872 None
1873 );
1874
1875 let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1877 assert_eq!(
1878 embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1879 None
1880 );
1881 }
1882
1883 #[test]
1884 fn split_line_honors_quotes_and_whitespace() {
1885 assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1886 assert_eq!(
1887 split_line(r#"remember "two words" --tag x"#),
1888 ["remember", "two words", "--tag", "x"]
1889 );
1890 assert_eq!(split_line(" recall 'a b' "), ["recall", "a b"]);
1891 assert_eq!(split_line(""), Vec::<String>::new());
1892 assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1894 }
1895
1896 #[test]
1897 fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1898 let (db, tmp) = TempDb::open();
1899 let path = tmp.0.join("m.plugmem");
1900 drop(db); let settings = settings_with(None);
1903 let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1905 let mut out = Vec::new();
1906 let code = run_repl(&path, settings, false, &script[..], &mut out);
1907 let text = String::from_utf8(out).unwrap();
1908
1909 assert_eq!(code, 0);
1910 assert!(text.contains("remembered fact 0"), "{text}");
1911 assert!(text.contains("tokio"), "{text}");
1912 assert!(text.contains("unrecognized subcommand"), "{text}");
1914
1915 let ro = Database::open_readonly(&path, Config::default()).unwrap();
1919 assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1920 }
1921
1922 #[test]
1923 fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1924 let (db, tmp) = TempDb::open();
1925 let path = tmp.0.join("m.plugmem");
1926 let mut sink = Vec::new();
1929 execute(
1930 &db,
1931 &remember("seed fact tokio", None, &[]),
1932 false,
1933 1_000,
1934 &mut sink,
1935 )
1936 .unwrap();
1937 db.checkpoint(1_001).unwrap();
1938
1939 let settings = settings_with(None);
1940 let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1942 let mut out = Vec::new();
1943 let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1944 let text = String::from_utf8(out).unwrap();
1945
1946 assert_eq!(code, 0);
1947 assert!(text.contains("generation 1"), "generation verb: {text}");
1948 assert!(text.contains("fact"), "stats ran: {text}");
1949 assert!(
1952 text.contains("already current → generation 1"),
1953 "refresh no-op: {text}"
1954 );
1955 assert!(text.contains("read-only session"), "write refused: {text}");
1957
1958 assert_eq!(db.stats().facts, 1);
1961 }
1962
1963 #[test]
1964 fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1965 let (db, tmp) = TempDb::open();
1966 let path = tmp.0.join("m.plugmem");
1967 let mut sink = Vec::new();
1968 execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1969 db.checkpoint(1_001).unwrap();
1970
1971 struct HookOnFirstRead<'a> {
1975 script: std::io::Cursor<&'a [u8]>,
1976 db: &'a Database,
1977 fired: bool,
1978 }
1979 impl std::io::Read for HookOnFirstRead<'_> {
1980 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1981 if !self.fired {
1982 self.fired = true;
1983 let mut s = Vec::new();
1986 execute(
1987 self.db,
1988 &remember("second", None, &[]),
1989 false,
1990 2_000,
1991 &mut s,
1992 )
1993 .unwrap();
1994 self.db.checkpoint(2_001).unwrap();
1995 }
1996 self.script.read(buf)
1997 }
1998 }
1999 let reader = std::io::BufReader::new(HookOnFirstRead {
2000 script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
2001 db: &db,
2002 fired: false,
2003 });
2004
2005 let mut out = Vec::new();
2006 let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
2007 let text = String::from_utf8(out).unwrap();
2008
2009 assert_eq!(code, 0);
2010 assert!(text.contains("refreshed → generation 2"), "advance: {text}");
2012 assert!(text.contains("fact"), "stats after refresh: {text}");
2014 assert_eq!(db.stats().facts, 2);
2015 }
2016
2017 #[test]
2018 fn read_only_repl_freshness_verbs_emit_json() {
2019 let (db, tmp) = TempDb::open();
2020 let path = tmp.0.join("m.plugmem");
2021 let mut sink = Vec::new();
2022 execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
2023 db.checkpoint(1_001).unwrap();
2024
2025 let script = b"generation\nrefresh\nexit\n";
2026 let mut out = Vec::new();
2027 let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
2028 let text = String::from_utf8(out).unwrap();
2029
2030 assert_eq!(code, 0);
2031 assert!(
2032 text.contains(r#""generation":1"#),
2033 "generation json: {text}"
2034 );
2035 assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
2036 }
2037
2038 struct TempDb(PathBuf);
2040 impl TempDb {
2041 fn open() -> (Database, Self) {
2042 let dir = std::env::temp_dir().join(format!(
2043 "plugmem-cli-{}-{}",
2044 std::process::id(),
2045 now_ms_unique()
2046 ));
2047 std::fs::create_dir_all(&dir).unwrap();
2048 let path = dir.join("m.plugmem");
2049 let (db, _) = Database::open(&path, Config::default()).unwrap();
2050 (db, TempDb(dir))
2051 }
2052 }
2053 impl Drop for TempDb {
2054 fn drop(&mut self) {
2055 let _ = std::fs::remove_dir_all(&self.0);
2056 }
2057 }
2058
2059 fn now_ms_unique() -> String {
2062 use std::sync::atomic::{AtomicU64, Ordering};
2063 static N: AtomicU64 = AtomicU64::new(0);
2064 format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
2065 }
2066
2067 fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
2068 let mut buf = Vec::new();
2069 let code = execute(db, cmd, json, now, &mut buf).expect("execute");
2070 (code, String::from_utf8(buf).unwrap())
2071 }
2072
2073 fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
2074 Command::Remember {
2075 text: text.into(),
2076 entity: entity.map(Into::into),
2077 tags: tags.iter().map(|t| (*t).into()).collect(),
2078 links: Vec::new(),
2079 meta: Vec::new(),
2080 valid_from: None,
2081 vector: Vec::new(),
2082 guarded: false,
2083 }
2084 }
2085
2086 fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
2087 Command::Remember {
2088 text: text.into(),
2089 entity: None,
2090 tags: Vec::new(),
2091 links: Vec::new(),
2092 meta: meta.iter().map(|m| (*m).into()).collect(),
2093 valid_from: None,
2094 vector: Vec::new(),
2095 guarded: false,
2096 }
2097 }
2098
2099 #[test]
2100 fn guarded_remember_flag_reports_blocked_without_a_write() {
2101 let (db, _temp) = TempDb::open();
2102 let mut first = remember("likes green tea every morning", Some("user"), &[]);
2103 let Command::Remember { guarded, .. } = &mut first else {
2104 unreachable!()
2105 };
2106 *guarded = true;
2107 let (_, stored) = run_cmd(&db, &first, true, 1_000);
2108 let stored: serde_json::Value = serde_json::from_str(&stored).unwrap();
2109 assert_eq!(stored["status"], "stored");
2110 assert_eq!(stored["outcome"]["id"], 0);
2111 assert_eq!(stored["checked"], true, "an entity was named");
2112
2113 let mut second = remember("likes green tea each morning", Some("user"), &[]);
2114 let Command::Remember { guarded, .. } = &mut second else {
2115 unreachable!()
2116 };
2117 *guarded = true;
2118 let (_, blocked) = run_cmd(&db, &second, true, 2_000);
2119 let blocked: serde_json::Value = serde_json::from_str(&blocked).unwrap();
2120 assert_eq!(blocked["status"], "blocked");
2121 assert_eq!(blocked["similar"][0]["id"], 0);
2122 assert_eq!(db.stats().facts, 1);
2123 }
2124
2125 #[test]
2133 fn guarded_without_an_entity_says_it_stored_unchecked() {
2134 let (db, _temp) = TempDb::open();
2135 let text = "the cache is disabled because it raced with the warmup task";
2136 for at in [1_000, 2_000, 3_000] {
2137 let mut cmd = remember(text, None, &[]);
2138 let Command::Remember { guarded, .. } = &mut cmd else {
2139 unreachable!()
2140 };
2141 *guarded = true;
2142 let (_, plain) = run_cmd(&db, &cmd, false, at);
2143 assert!(plain.contains("remembered fact"), "{plain}");
2144 assert!(plain.contains("WITHOUT a duplicate check"), "{plain}");
2145 }
2146 assert_eq!(db.stats().facts, 3, "identical facts, as remember would");
2147
2148 let mut cmd = remember(text, None, &[]);
2149 let Command::Remember { guarded, .. } = &mut cmd else {
2150 unreachable!()
2151 };
2152 *guarded = true;
2153 let (_, json) = run_cmd(&db, &cmd, true, 4_000);
2154 let json: serde_json::Value = serde_json::from_str(&json).unwrap();
2155 assert_eq!(json["status"], "stored");
2156 assert_eq!(json["checked"], false);
2157 }
2158
2159 #[test]
2160 fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
2161 let (db, _t) = TempDb::open();
2162 let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
2164 assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
2165
2166 let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2168 assert!(
2169 human.contains("metadata page=3, uri=s3://b/x"),
2170 "{human}"
2171 );
2172 let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2174 let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
2175 assert_eq!(v["metadata"]["page"], "3");
2176 assert_eq!(v["metadata"]["uri"], "s3://b/x");
2177
2178 let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
2180 let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
2181 assert_eq!(line["metadata"]["uri"], "s3://b/x");
2182
2183 assert!(matches!(
2185 parse_meta(&["noequals".to_string()]),
2186 Err(CliError::Usage(_))
2187 ));
2188 assert!(parse_meta(&["=noKey".to_string()]).is_err());
2189 }
2190
2191 #[test]
2192 fn remember_then_recall_human_and_json() {
2193 let (db, _t) = TempDb::open();
2194 let (code, out) = run_cmd(
2195 &db,
2196 &remember("prefers tokio", Some("user"), &["pref"]),
2197 false,
2198 1_000,
2199 );
2200 assert_eq!(code, 0);
2201 assert!(out.starts_with("remembered fact 0"), "{out}");
2202
2203 let recall = Command::Recall {
2205 query: Some("tokio".into()),
2206 tags: Vec::new(),
2207 entities: Vec::new(),
2208 as_of: None,
2209 range: None,
2210 k: 0,
2211 closed: false,
2212 token_budget: None,
2213 ef: None,
2214 graph_depth: None,
2215 vector: Vec::new(),
2216 };
2217 let (code, out) = run_cmd(&db, &recall, false, 2_000);
2218 assert_eq!(code, 0);
2219 assert!(out.contains("tokio"), "{out}");
2220
2221 let (code, out) = run_cmd(&db, &recall, true, 2_000);
2223 assert_eq!(code, 0);
2224 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2225 assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
2226 }
2227
2228 #[test]
2229 fn recall_empty_is_ok_with_a_note() {
2230 let (db, _t) = TempDb::open();
2231 let recall = Command::Recall {
2232 query: Some("nothing here".into()),
2233 tags: Vec::new(),
2234 entities: Vec::new(),
2235 as_of: None,
2236 range: None,
2237 k: 0,
2238 closed: false,
2239 token_budget: None,
2240 ef: None,
2241 graph_depth: None,
2242 vector: Vec::new(),
2243 };
2244 let (code, out) = run_cmd(&db, &recall, false, 1_000);
2245 assert_eq!(code, 0);
2246 assert!(out.contains("nothing recalled"), "{out}");
2247 }
2248
2249 #[test]
2250 fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
2251 let (db, _t) = TempDb::open();
2252 run_cmd(
2253 &db,
2254 &remember("lives in Moscow", Some("user"), &[]),
2255 false,
2256 1_000,
2257 );
2258 let (_, out) = run_cmd(
2260 &db,
2261 &remember("lives in Moscow now", Some("user"), &[]),
2262 false,
2263 1_500,
2264 );
2265 assert!(out.contains("similar to fact"), "{out}");
2266
2267 let revise = Command::Revise {
2268 id: 0,
2269 text: "lives in Berlin".into(),
2270 entity: Some("user".into()),
2271 tags: Vec::new(),
2272 links: Vec::new(),
2273 meta: Vec::new(),
2274 valid_from: None,
2275 vector: Vec::new(),
2276 };
2277 let (code, out) = run_cmd(&db, &revise, false, 2_000);
2278 assert_eq!(code, 0);
2279 assert!(out.starts_with("remembered fact"), "{out}");
2280 }
2281
2282 #[test]
2283 fn show_found_and_missing() {
2284 let (db, _t) = TempDb::open();
2285 run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
2286
2287 let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2288 assert_eq!(code, 0);
2289 assert!(
2290 out.contains("a note") && out.contains("recorded_at 1000"),
2291 "{out}"
2292 );
2293
2294 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
2295 assert_eq!(code, 1, "missing id is a soft miss");
2296 assert!(out.contains("not found"), "{out}");
2297
2298 let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2300 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2301 assert_eq!(v["text"], "a note");
2302 assert_eq!(v["valid_to"], serde_json::Value::Null); }
2304
2305 #[test]
2306 fn forget_then_maintain_purges() {
2307 let (db, _t) = TempDb::open();
2308 run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
2309
2310 let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
2311 assert_eq!(code, 0);
2312 assert!(out.contains("forgot fact 0"), "{out}");
2313 let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
2315 assert!(out.contains("already gone"), "{out}");
2316
2317 let (code, out) = run_cmd(
2318 &db,
2319 &Command::Maintain {
2320 mode: MaintainMode::Auto,
2321 reembed: false,
2322 batch_size: None,
2323 },
2324 false,
2325 3_000,
2326 );
2327 assert_eq!(code, 0);
2328 assert!(out.contains("purged 1"), "{out}");
2329 }
2330
2331 #[test]
2332 fn explicit_reembed_has_human_and_json_reports() {
2333 let (plain, tmp) = TempDb::open();
2334 let path = tmp.0.join("m.plugmem");
2335 drop(plain);
2336 let mut config = Config::default();
2337 config.dim = 3;
2338 let (db, _) = Database::builder(config)
2339 .embedder(Box::new(StubEmbedder))
2340 .open(path)
2341 .unwrap();
2342 run_cmd(&db, &remember("vector fact", None, &["kept"]), false, 1);
2343
2344 let command = Command::Maintain {
2345 mode: MaintainMode::Auto,
2346 reembed: true,
2347 batch_size: Some(1),
2348 };
2349 let (code, human) = run_cmd(&db, &command, false, 2);
2350 assert_eq!(code, 0);
2351 assert!(human.contains("reembedded: 1 facts"));
2352 let (code, json) = run_cmd(&db, &command, true, 3);
2353 assert_eq!(code, 0);
2354 let report: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
2355 assert_eq!(report["new_space"], "cli-stub");
2356 assert_eq!(report["embedded"], 1);
2357 }
2358
2359 #[test]
2360 fn readonly_cli_refuses_a_same_dimension_different_space() {
2361 let (plain, tmp) = TempDb::open();
2362 let path = tmp.0.join("m.plugmem");
2363 drop(plain);
2364 let mut config = Config::default();
2365 config.dim = 3;
2366 let (db, _) = Database::builder(config.clone())
2367 .embedder(Box::new(StubEmbedder))
2368 .open(&path)
2369 .unwrap();
2370 db.remember(RememberInput::text(1, "stored vector"))
2371 .unwrap();
2372 db.checkpoint(2).unwrap();
2373 let ro = Database::open_readonly(&path, config).unwrap();
2374 let cmd = recall_cmd(Some("stored"));
2375 let mut out = Vec::new();
2376 let code = execute_ro(
2377 &ro,
2378 &cmd,
2379 Some(&[0.1, 0.2, 0.3]),
2380 Some("other-model"),
2381 false,
2382 &mut out,
2383 );
2384 assert_eq!(code, 2);
2385 }
2386
2387 #[test]
2388 fn tags_pages_and_remove_tag_have_human_and_json_shapes() {
2389 let (db, _t) = TempDb::open();
2390 run_cmd(&db, &remember("one", None, &["drop", "keep"]), false, 1_000);
2391 run_cmd(&db, &remember("two", None, &["drop"]), false, 1_100);
2392
2393 let (code, out) = run_cmd(
2394 &db,
2395 &Command::Tags {
2396 prefix: None,
2397 cursor: None,
2398 limit: 1,
2399 },
2400 false,
2401 2_000,
2402 );
2403 assert_eq!(code, 0);
2404 assert!(out.starts_with("2\tdrop\n"), "{out}");
2405 assert!(out.contains("next_cursor\t"), "{out}");
2406
2407 let (_, out) = run_cmd(&db, &Command::RemoveTag { tag: "drop".into() }, true, 3_000);
2408 let removed: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2409 assert_eq!(removed["affected"], 2);
2410
2411 let (_, out) = run_cmd(
2412 &db,
2413 &Command::Tags {
2414 prefix: None,
2415 cursor: None,
2416 limit: 0,
2417 },
2418 true,
2419 4_000,
2420 );
2421 let page: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2422 assert_eq!(page["items"][0]["name"], "keep");
2423 assert_eq!(db.export().len(), 2, "facts remain current after retagging");
2424 }
2425
2426 #[test]
2427 fn link_and_stats_and_json() {
2428 let (db, _t) = TempDb::open();
2429 run_cmd(
2430 &db,
2431 &remember("uses tokio", Some("plugmem"), &[]),
2432 false,
2433 1_000,
2434 );
2435 let link = Command::Link {
2436 src: "plugmem".into(),
2437 rel: "depends_on".into(),
2438 dst: "tokio".into(),
2439 provenance: None,
2440 };
2441 let (code, out) = run_cmd(&db, &link, false, 2_000);
2442 assert_eq!(code, 0);
2443 assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
2444 let unlink = Command::Unlink {
2445 src: "plugmem".into(),
2446 rel: "depends_on".into(),
2447 dst: "tokio".into(),
2448 };
2449 let (code, out) = run_cmd(&db, &unlink, false, 2_500);
2450 assert_eq!(code, 0);
2451 assert!(
2452 out.contains("unlinked plugmem -depends_on-> tokio"),
2453 "{out}"
2454 );
2455
2456 let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
2457 assert_eq!(code, 0);
2458 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2459 assert_eq!(v["facts"], 1);
2460 assert_eq!(v["edges"], 0);
2461 assert_eq!(v["edge_versions"], 1);
2462 }
2463
2464 #[test]
2465 fn bad_link_is_a_usage_error() {
2466 let (db, _t) = TempDb::open();
2467 let cmd = Command::Remember {
2468 text: "x".into(),
2469 entity: Some("user".into()),
2470 tags: Vec::new(),
2471 links: vec!["not-a-pair".into()],
2472 meta: Vec::new(),
2473 valid_from: None,
2474 vector: Vec::new(),
2475 guarded: false,
2476 };
2477 let mut buf = Vec::new();
2478 let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
2479 assert!(matches!(err, CliError::Usage(_)));
2480 }
2481
2482 #[test]
2483 fn as_of_time_travel_via_recall() {
2484 let (db, _t) = TempDb::open();
2485 run_cmd(
2486 &db,
2487 &remember("lives in Moscow", Some("user"), &[]),
2488 false,
2489 1_000,
2490 );
2491 let revise = Command::Revise {
2492 id: 0,
2493 text: "lives in Berlin".into(),
2494 entity: Some("user".into()),
2495 tags: Vec::new(),
2496 links: Vec::new(),
2497 meta: Vec::new(),
2498 valid_from: None,
2499 vector: Vec::new(),
2500 };
2501 run_cmd(&db, &revise, false, 2_000);
2502
2503 let as_of = Command::Recall {
2504 query: Some("lives".into()),
2505 tags: Vec::new(),
2506 entities: vec!["user".into()],
2507 as_of: Some(1_500),
2508 range: None,
2509 k: 0,
2510 closed: false,
2511 token_budget: None,
2512 ef: None,
2513 graph_depth: None,
2514 vector: Vec::new(),
2515 };
2516 let (_, out) = run_cmd(&db, &as_of, false, 3_000);
2517 assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
2518 }
2519
2520 #[test]
2521 fn every_command_has_a_json_shape() {
2522 let (db, _t) = TempDb::open();
2523 let (_, out) = run_cmd(
2525 &db,
2526 &remember("uses tokio", Some("plugmem"), &["pref"]),
2527 true,
2528 1_000,
2529 );
2530 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2531 assert_eq!(v["id"], 0);
2532 assert!(v["similar"].is_array());
2533
2534 let revise = Command::Revise {
2536 id: 0,
2537 text: "uses tokio now".into(),
2538 entity: Some("plugmem".into()),
2539 tags: Vec::new(),
2540 links: Vec::new(),
2541 meta: Vec::new(),
2542 valid_from: None,
2543 vector: Vec::new(),
2544 };
2545 let (_, out) = run_cmd(&db, &revise, true, 1_500);
2546 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2547
2548 let link = Command::Link {
2550 src: "plugmem".into(),
2551 rel: "depends_on".into(),
2552 dst: "tokio".into(),
2553 provenance: None,
2554 };
2555 let (_, out) = run_cmd(&db, &link, true, 2_000);
2556 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2557 assert_eq!(v["rel"], "depends_on");
2558 let unlink = Command::Unlink {
2559 src: "plugmem".into(),
2560 rel: "depends_on".into(),
2561 dst: "tokio".into(),
2562 };
2563 let (_, out) = run_cmd(&db, &unlink, true, 2_100);
2564 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2565 assert_eq!(v["unlinked"], true);
2566
2567 let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
2569 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2570 assert_eq!(v["forgotten"], true);
2571 let (_, out) = run_cmd(
2572 &db,
2573 &Command::Maintain {
2574 mode: MaintainMode::Auto,
2575 reembed: false,
2576 batch_size: None,
2577 },
2578 true,
2579 3_000,
2580 );
2581 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2582 assert!(v["purged"].as_u64().unwrap() >= 1);
2583
2584 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
2586 assert_eq!(code, 1);
2587 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2588 assert_eq!(v["found"], false);
2589
2590 let recall = Command::Recall {
2592 query: None,
2593 tags: Vec::new(),
2594 entities: vec!["plugmem".into()],
2595 as_of: None,
2596 range: Some(vec![0, 10_000]),
2597 k: 4,
2598 closed: true,
2599 token_budget: None,
2600 ef: None,
2601 graph_depth: None,
2602 vector: Vec::new(),
2603 };
2604 let (_, out) = run_cmd(&db, &recall, true, 4_000);
2605 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2606 }
2607
2608 #[test]
2609 fn graph_depth_is_a_per_call_flag_over_the_configured_default() {
2610 let (db, _t) = TempDb::open();
2613 for (i, (entity, next)) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
2614 .iter()
2615 .enumerate()
2616 {
2617 let cmd = Command::Remember {
2618 text: format!("fact on {entity}"),
2619 entity: Some((*entity).into()),
2620 tags: Vec::new(),
2621 links: vec![format!("leads_to:{next}")],
2622 meta: Vec::new(),
2623 valid_from: None,
2624 vector: Vec::new(),
2625 guarded: false,
2626 };
2627 run_cmd(&db, &cmd, false, 1_000 + i as u64);
2628 }
2629
2630 let reached = |depth: Option<u32>| {
2631 let recall = Command::Recall {
2632 query: None,
2633 tags: Vec::new(),
2634 entities: vec!["a".into()],
2635 as_of: None,
2636 range: None,
2637 k: 64,
2638 closed: false,
2639 token_budget: Some(4096),
2640 ef: None,
2641 graph_depth: depth,
2642 vector: Vec::new(),
2643 };
2644 let (_, out) = run_cmd(&db, &recall, false, 5_000);
2645 out.lines().filter(|l| l.starts_with("- [f")).count()
2646 };
2647
2648 assert_eq!(reached(None), 3, "the configured default is 2 hops");
2649 assert_eq!(reached(Some(0)), 1, "no expansion: the anchor's own fact");
2650 assert_eq!(reached(Some(1)), 2);
2651 assert_eq!(reached(Some(3)), 4);
2652 assert_eq!(reached(Some(99)), 4);
2655 assert_eq!(reached(Some(u32::MAX)), 4);
2656 }
2657
2658 #[test]
2659 fn stats_human_lists_the_counters() {
2660 let (db, _t) = TempDb::open();
2661 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2662 let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
2663 assert_eq!(code, 0);
2664 assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
2665 }
2666
2667 #[test]
2668 fn verify_command_renders_human_and_json() {
2669 let (db, _t) = TempDb::open();
2670 run_cmd(&db, &remember("clean", None, &[]), false, 1_000);
2671
2672 let (code, out) = run_cmd(&db, &Command::Verify, false, 2_000);
2673 assert_eq!(code, 0);
2674 assert_eq!(out.trim(), "integrity ok");
2675
2676 let (code, out) = run_cmd(&db, &Command::Verify, true, 2_100);
2677 assert_eq!(code, 0);
2678 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2679 assert_eq!(v["ok"], true);
2680 }
2681
2682 #[test]
2683 fn show_json_of_a_revised_predecessor_is_closed() {
2684 let (db, _t) = TempDb::open();
2685 run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
2686 let revise = Command::Revise {
2687 id: 0,
2688 text: "v2".into(),
2689 entity: Some("e".into()),
2690 tags: Vec::new(),
2691 links: Vec::new(),
2692 meta: Vec::new(),
2693 valid_from: None,
2694 vector: Vec::new(),
2695 };
2696 run_cmd(&db, &revise, false, 2_000);
2697 let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
2699 assert!(out.contains("revises fact 0"), "{out}");
2700 }
2701
2702 #[test]
2703 fn resolve_db_path_prefers_the_flag() {
2704 let p = "/tmp/explicit.plugmem";
2705 assert_eq!(resolve_db_path(Some(p), None, None), PathBuf::from(p));
2706 let configured = std::path::Path::new("/tmp/configured.plugmem");
2707 assert_eq!(
2708 resolve_db_path(None, Some(configured), None),
2709 PathBuf::from(configured)
2710 );
2711 let _ = resolve_db_path(None, None, None);
2714 }
2715
2716 #[test]
2717 fn a_bare_name_is_a_memory_only_when_a_workspace_is_configured() {
2718 let root = PathBuf::from("/srv/bot");
2719
2720 assert_eq!(
2723 resolve_db_path(Some("work"), None, None),
2724 PathBuf::from("work")
2725 );
2726
2727 assert_eq!(
2729 resolve_db_path(Some("work"), None, Some(&root)),
2730 PathBuf::from("/srv/bot/db/work.plugmem")
2731 );
2732 for path in ["./work", "work.plugmem", "/srv/other.plugmem", "../up"] {
2735 assert_eq!(
2736 resolve_db_path(Some(path), None, Some(&root)),
2737 PathBuf::from(path),
2738 "{path}"
2739 );
2740 }
2741
2742 let configured = std::path::Path::new("work");
2744 assert_eq!(
2745 resolve_db_path(None, Some(configured), Some(&root)),
2746 PathBuf::from("work")
2747 );
2748 }
2749
2750 #[test]
2751 fn settings_help_runs_without_opening_a_database() {
2752 let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
2753 let mut output = Vec::new();
2754 assert_eq!(run_parsed(cli, &mut output), 0);
2755 let output = String::from_utf8(output).unwrap();
2756 assert!(output.contains("plugmem settings"));
2757 assert!(output.contains("[database]"));
2758 assert!(output.contains("path (path string"));
2759
2760 let cli = Cli::try_parse_from(["plugmem-cli", "--json", "help", "settings"]).unwrap();
2761 let mut output = Vec::new();
2762 assert_eq!(run_parsed(cli, &mut output), 0);
2763 let output: serde_json::Value = serde_json::from_slice(&output).unwrap();
2764 assert_eq!(output["topic"], "settings");
2765 assert!(output["config_path_precedence"].is_array());
2766 assert!(output["settings"].as_array().unwrap().len() > 10);
2767 }
2768
2769 #[test]
2770 fn run_parsed_opens_runs_and_reports() {
2771 let dir = std::env::temp_dir().join(format!(
2772 "plugmem-run-{}-{}",
2773 std::process::id(),
2774 now_ms_unique()
2775 ));
2776 std::fs::create_dir_all(&dir).unwrap();
2777 let path = dir.join("m.plugmem");
2778 let cli = Cli {
2779 db: Some(path.display().to_string()),
2780 workspace: None,
2781 config: None,
2782 json: false,
2783 command: Command::Stats,
2784 };
2785 let mut buf = Vec::new();
2786 let code = run_parsed(cli, &mut buf);
2787 assert_eq!(code, 0);
2788 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2789 let _ = std::fs::remove_dir_all(&dir);
2790 }
2791
2792 #[test]
2793 fn recover_and_scrub_render_json_and_human_shapes() {
2794 let (db, tmp) = TempDb::open();
2795 let path = tmp.0.join("m.plugmem");
2796 run_cmd(&db, &remember("recoverable fact", None, &[]), false, 1_000);
2797 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2798 drop(db);
2799
2800 let settings = settings_with(None);
2801 let mut out = Vec::new();
2802 assert_eq!(do_scrub(&path, &settings, true, &mut out), 0);
2803 let scrub: serde_json::Value = serde_json::from_slice(&out).unwrap();
2804 assert_eq!(scrub["ok"], true);
2805 assert!(scrub["bytes"].as_u64().unwrap() > 0);
2806 let mut out = Vec::new();
2807 assert_eq!(do_scrub(&path, &settings, false, &mut out), 0);
2808 let out = String::from_utf8(out).unwrap();
2809 assert!(out.contains("scrub ok:"), "{out}");
2810
2811 let json_dst = tmp.0.join("copy-json.plugmem");
2812 let mut out = Vec::new();
2813 assert_eq!(do_recover(&path, &json_dst, &settings, true, &mut out), 0);
2814 let recover: serde_json::Value = serde_json::from_slice(&out).unwrap();
2815 assert_eq!(recover["kept"], 1);
2816 assert_eq!(recover["dropped_text"], 0);
2817 assert_eq!(recover["dst"], json_dst.display().to_string());
2818
2819 let human_dst = tmp.0.join("copy-human.plugmem");
2820 let mut out = Vec::new();
2821 assert_eq!(do_recover(&path, &human_dst, &settings, false, &mut out), 0);
2822 let out = String::from_utf8(out).unwrap();
2823 assert!(out.contains("recovered to"), "{out}");
2824 assert!(out.contains("kept 1"), "{out}");
2825 }
2826
2827 #[test]
2828 fn readonly_dispatcher_renders_every_read_shape() {
2829 let (db, tmp) = TempDb::open();
2830 let path = tmp.0.join("m.plugmem");
2831 run_cmd(
2832 &db,
2833 &remember("readonly tokio fact", Some("plugmem"), &["pref"]),
2834 false,
2835 1_000,
2836 );
2837 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2838 let ro = Database::open_readonly(&path, Config::default()).unwrap();
2839
2840 let mut out = Vec::new();
2841 assert_eq!(
2842 execute_ro(&ro, &Command::Stats, None, None, true, &mut out),
2843 0
2844 );
2845 let stats: serde_json::Value = serde_json::from_slice(&out).unwrap();
2846 assert_eq!(stats["facts"], 1);
2847
2848 let mut out = Vec::new();
2849 assert_eq!(
2850 execute_ro(&ro, &Command::Show { id: 0 }, None, None, false, &mut out),
2851 0
2852 );
2853 let text = String::from_utf8(out).unwrap();
2854 assert!(text.contains("readonly tokio fact"), "{text}");
2855
2856 let mut out = Vec::new();
2857 assert_eq!(
2858 execute_ro(&ro, &Command::Export, None, None, false, &mut out),
2859 0
2860 );
2861 let exported: serde_json::Value =
2862 serde_json::from_str(String::from_utf8(out).unwrap().lines().next().unwrap()).unwrap();
2863 assert_eq!(exported["text"], "readonly tokio fact");
2864
2865 let mut out = Vec::new();
2866 let recall = Command::Recall {
2867 query: Some("tokio".into()),
2868 tags: vec!["pref".into()],
2869 entities: vec!["plugmem".into()],
2870 as_of: None,
2871 range: None,
2872 k: 1,
2873 closed: false,
2874 token_budget: None,
2875 ef: None,
2876 graph_depth: None,
2877 vector: Vec::new(),
2878 };
2879 assert_eq!(execute_ro(&ro, &recall, None, None, false, &mut out), 0);
2880 let text = String::from_utf8(out).unwrap();
2881 assert!(text.contains("tokio"), "{text}");
2882
2883 let mut out = Vec::new();
2884 assert_eq!(
2885 execute_ro(&ro, &Command::Verify, None, None, true, &mut out),
2886 0
2887 );
2888 let verify: serde_json::Value = serde_json::from_slice(&out).unwrap();
2889 assert_eq!(verify["ok"], true);
2890 }
2891
2892 #[test]
2893 fn run_parsed_on_a_locked_database_returns_one() {
2894 let (_held, dir) = {
2895 let dir = std::env::temp_dir().join(format!(
2896 "plugmem-lock-{}-{}",
2897 std::process::id(),
2898 now_ms_unique()
2899 ));
2900 std::fs::create_dir_all(&dir).unwrap();
2901 let path = dir.join("m.plugmem");
2902 (Database::open(&path, Config::default()).unwrap(), dir)
2903 };
2904 let cli = Cli {
2905 db: Some(dir.join("m.plugmem").display().to_string()),
2906 workspace: None,
2907 config: None,
2908 json: false,
2909 command: Command::Stats,
2910 };
2911 let mut buf = Vec::new();
2912 assert_eq!(run_parsed(cli, &mut buf), 1);
2913 let _ = std::fs::remove_dir_all(&dir);
2914 }
2915
2916 #[test]
2917 fn run_parsed_propagates_a_usage_error_as_two() {
2918 let dir = std::env::temp_dir().join(format!(
2919 "plugmem-usage-{}-{}",
2920 std::process::id(),
2921 now_ms_unique()
2922 ));
2923 std::fs::create_dir_all(&dir).unwrap();
2924 let cli = Cli {
2925 db: Some(dir.join("m.plugmem").display().to_string()),
2926 workspace: None,
2927 config: None,
2928 json: false,
2929 command: Command::Remember {
2930 text: "x".into(),
2931 entity: None,
2932 tags: Vec::new(),
2933 links: vec!["bad".into()],
2934 meta: Vec::new(),
2935 valid_from: None,
2936 vector: Vec::new(),
2937 guarded: false,
2938 },
2939 };
2940 let mut buf = Vec::new();
2941 assert_eq!(run_parsed(cli, &mut buf), 2);
2942 let _ = std::fs::remove_dir_all(&dir);
2943 }
2944
2945 struct Scratch(PathBuf);
2947 impl Scratch {
2948 fn new(tag: &str) -> Self {
2949 let dir = std::env::temp_dir().join(format!(
2950 "plugmem-cli-{tag}-{}-{}",
2951 std::process::id(),
2952 now_ms_unique()
2953 ));
2954 std::fs::create_dir_all(&dir).unwrap();
2955 Scratch(dir)
2956 }
2957 }
2958 impl Drop for Scratch {
2959 fn drop(&mut self) {
2960 let _ = std::fs::remove_dir_all(&self.0);
2961 }
2962 }
2963
2964 #[test]
2965 fn export_import_roundtrip_preserves_open_facts() {
2966 let (a, _ta) = TempDb::open();
2971 run_cmd(
2972 &a,
2973 &Command::Remember {
2974 text: "prefers tokio".into(),
2975 entity: Some("user".into()),
2976 tags: vec!["pref".into(), "lang".into()],
2977 links: Vec::new(),
2978 meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
2979 valid_from: Some(500),
2980 vector: Vec::new(),
2981 guarded: false,
2982 },
2983 false,
2984 1_000,
2985 );
2986 run_cmd(
2987 &a,
2988 &remember("lives in Moscow", Some("user"), &[]),
2989 false,
2990 1_100,
2991 ); run_cmd(
2993 &a,
2994 &Command::Revise {
2995 id: 1,
2996 text: "lives in Berlin".into(),
2997 entity: Some("user".into()),
2998 tags: vec!["geo".into()],
2999 links: Vec::new(),
3000 meta: Vec::new(),
3001 valid_from: None,
3002 vector: Vec::new(),
3003 },
3004 false,
3005 1_200,
3006 ); run_cmd(&a, &remember("junk", None, &[]), false, 1_300); run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); run_cmd(
3010 &a,
3011 &remember("uses rust", Some("plugmem"), &["lang"]),
3012 false,
3013 1_500,
3014 ); let mut dump = Vec::new();
3018 render_export(&a.export(), false, &mut dump);
3019 let scratch = Scratch::new("roundtrip");
3020 let file = scratch.0.join("dump.jsonl");
3021 std::fs::write(&file, &dump).unwrap();
3022
3023 let (b, _tb) = TempDb::open();
3025 let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
3026
3027 let key = |f: &ExportedFact| {
3029 let mut tags = f.tags.clone();
3030 tags.sort();
3031 (f.text.clone(), f.entity.clone(), tags, f.valid_from)
3032 };
3033 let mut ak: Vec<_> = a.export().iter().map(key).collect();
3034 let mut bk: Vec<_> = b.export().iter().map(key).collect();
3035 ak.sort();
3036 bk.sort();
3037 assert_eq!(n.facts, ak.len());
3038 assert_eq!(
3039 ak, bk,
3040 "roundtrip must preserve text/entity/tags/valid_from"
3041 );
3042
3043 let b_open = b.export();
3046 assert!(b_open.iter().any(|f| f.text == "prefers tokio"
3047 && f.valid_from == 500
3048 && f.entity.as_deref() == Some("user")
3049 && f.tags == vec!["pref".to_string(), "lang".to_string()]
3050 && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
3051 && f.metadata.get("src").map(String::as_str) == Some("chat")));
3052 assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
3053 assert!(b_open.iter().any(|f| f.text == "uses rust"));
3054 assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
3055 assert!(!b_open.iter().any(|f| f.text == "junk"));
3056 }
3057
3058 #[test]
3059 fn export_command_emits_jsonl_regardless_of_json_flag() {
3060 let (db, _t) = TempDb::open();
3061 run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
3062 for json in [false, true] {
3063 let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
3064 assert_eq!(code, 0);
3065 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
3066 assert_eq!(v["text"], "a fact");
3067 assert_eq!(v["entity"], "e");
3068 assert_eq!(v["tags"][0], "t");
3069 }
3070 }
3071
3072 #[test]
3073 fn import_command_counts_and_rejects_bad_lines() {
3074 let (db, _t) = TempDb::open();
3075 let scratch = Scratch::new("import");
3076 let good = scratch.0.join("in.jsonl");
3077 std::fs::write(
3078 &good,
3079 "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
3080 )
3081 .unwrap();
3082 let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
3084 assert_eq!(n.facts, 2, "both facts imported, blank line skipped");
3085
3086 let bad = scratch.0.join("bad.jsonl");
3087 std::fs::write(&bad, "not json at all\n").unwrap();
3088 let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
3089 assert!(matches!(err, CliError::Usage(_)));
3090 }
3091
3092 #[test]
3093 fn import_batch_size_does_not_change_the_result() {
3094 let scratch = Scratch::new("import-batch");
3097 let file = scratch.0.join("facts.jsonl");
3098 let mut jsonl = String::new();
3099 for i in 0..5 {
3100 jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
3101 }
3102 std::fs::write(&file, &jsonl).unwrap();
3103
3104 let (a, _ta) = TempDb::open();
3105 let (b, _tb) = TempDb::open();
3106 let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
3107 let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
3108
3109 assert_eq!(na.facts, 5);
3110 assert_eq!(nb.facts, 5);
3111 let texts = |db: &Database| {
3112 let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
3113 t.sort();
3114 t
3115 };
3116 assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
3117 }
3118
3119 #[test]
3120 fn config_table_feeds_settings_and_the_cli_batch_size() {
3121 let scratch = Scratch::new("settings");
3125 let cfgfile = scratch.0.join("config.toml");
3126 std::fs::write(
3127 &cfgfile,
3128 "[engine]\ndim = 512\n[embedder]\nenabled = false\n\
3129 [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
3130 )
3131 .unwrap();
3132 let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
3133 let s = Settings::from_table(table.as_ref()).unwrap();
3134 assert_eq!(s.config.dim, 512);
3135 assert!(s.embedder.is_none());
3136 assert_eq!(s.snapshot_every_ops, Some(64));
3137 assert_eq!(read_batch_size(table.as_ref()), Some(200));
3138
3139 assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
3141 }
3142
3143 #[test]
3144 fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
3145 let scratch = Scratch::new("checkpoint-cmd");
3146 let path = scratch.0.join("m.plugmem");
3147
3148 let remember = Cli {
3150 db: Some(path.display().to_string()),
3151 workspace: None,
3152 config: None,
3153 json: false,
3154 command: Command::Remember {
3155 text: "hello tokio".into(),
3156 entity: None,
3157 tags: Vec::new(),
3158 links: Vec::new(),
3159 meta: Vec::new(),
3160 valid_from: None,
3161 vector: Vec::new(),
3162 guarded: false,
3163 },
3164 };
3165 assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
3166
3167 let checkpoint = |json| Cli {
3169 db: Some(path.display().to_string()),
3170 workspace: None,
3171 config: None,
3172 json,
3173 command: Command::Checkpoint,
3174 };
3175 let mut buf = Vec::new();
3176 assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
3177 assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
3178
3179 let mut buf = Vec::new();
3181 assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
3182 let v: serde_json::Value =
3183 serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
3184 assert_eq!(v["ok"], true);
3185
3186 let scrub = Cli {
3189 db: Some(path.display().to_string()),
3190 workspace: None,
3191 config: None,
3192 json: false,
3193 command: Command::Scrub,
3194 };
3195 let mut buf = Vec::new();
3196 assert_eq!(run_parsed(scrub, &mut buf), 0);
3197 assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
3198 }
3199
3200 #[test]
3201 fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
3202 let scratch = Scratch::new("ro-route");
3203 let path = scratch.0.join("m.plugmem");
3204 {
3205 let (db, _) = Database::open(&path, Config::default()).unwrap();
3206 db.remember(RememberInput::text(1_000, "hello tokio"))
3207 .unwrap();
3208 db.checkpoint(2_000).unwrap(); }
3210 let cli = Cli {
3212 db: Some(path.display().to_string()),
3213 workspace: None,
3214 config: None,
3215 json: false,
3216 command: Command::Stats,
3217 };
3218 let mut buf = Vec::new();
3219 assert_eq!(run_parsed(cli, &mut buf), 0);
3220 assert!(String::from_utf8(buf).unwrap().contains("facts"));
3221
3222 let cli = Cli {
3224 db: Some(path.display().to_string()),
3225 workspace: None,
3226 config: None,
3227 json: false,
3228 command: Command::Recall {
3229 query: Some("tokio".into()),
3230 tags: Vec::new(),
3231 entities: Vec::new(),
3232 as_of: None,
3233 range: None,
3234 k: 0,
3235 closed: false,
3236 token_budget: None,
3237 ef: None,
3238 graph_depth: None,
3239 vector: Vec::new(),
3240 },
3241 };
3242 let mut buf = Vec::new();
3243 assert_eq!(run_parsed(cli, &mut buf), 0);
3244 assert!(String::from_utf8(buf).unwrap().contains("tokio"));
3245 }
3246}