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, HostError, LinkInput, MaintenanceMode, MaintenanceOptions,
29 ReadOnlyDatabase, RecallQuery, RecallResult, RememberInput, RememberOutcome, Settings, Stats,
30 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::Export
153 | Command::Verify
154 | Command::Recall { .. }
155 );
156 if readonly_ok {
157 let recall_vector = match embed_recall_query(&mut settings, &cli.command) {
158 Ok(v) => v,
159 Err(e) => return report_err(&e),
160 };
161 match Database::open_readonly(&path, settings.config.clone()) {
162 Ok(ro) => {
163 return execute_ro(&ro, &cli.command, recall_vector.as_deref(), cli.json, out);
164 }
165 Err(HostError::Locked { path }) => return report_locked(&path),
166 Err(_) => {}
171 }
172 }
173
174 let db = match settings.open(&path) {
177 Ok(db) => db,
178 Err(HostError::Locked { path }) => return report_locked(&path),
179 Err(e) => return report_err(&CliError::Host(e)),
180 };
181 if let Command::Import { file, batch } = &cli.command {
184 let batch_size = batch
185 .or(cfg_batch_size.map(|n| n as usize))
186 .unwrap_or(DEFAULT_IMPORT_BATCH)
187 .max(1);
188 return match do_import(&db, now_ms(), file, batch_size, out) {
189 Ok(report) => {
190 if cli.json {
191 writeln!(
192 out,
193 "{}",
194 json!({ "imported": report.facts, "edges": report.edges })
195 )
196 .ok();
197 } else if report.edges == 0 {
198 writeln!(out, "imported {} facts", report.facts).ok();
199 } else {
200 writeln!(
201 out,
202 "imported {} facts and {} edges",
203 report.facts, report.edges
204 )
205 .ok();
206 }
207 0
208 }
209 Err(e) => {
210 let _ = out.flush();
211 report_err(&e)
212 }
213 };
214 }
215 match execute(&db, &cli.command, cli.json, now_ms(), out) {
216 Ok(code) => code,
217 Err(e) => {
218 let _ = out.flush();
219 report_err(&e)
220 }
221 }
222}
223
224const DEFAULT_IMPORT_BATCH: usize = 128;
227
228fn write_err(out: &mut impl Write, e: &CliError) {
235 let _ = match e {
236 CliError::Usage(msg) => writeln!(out, "plugmem: {msg}"),
237 CliError::Host(err) => {
238 writeln!(out, "plugmem: {err}").and_then(|()| match err.capacity_hint() {
239 Some(hint) => writeln!(out, "plugmem: {hint}"),
240 None => Ok(()),
241 })
242 }
243 };
244}
245
246fn report_err(e: &CliError) -> u8 {
248 write_err(&mut std::io::stderr(), e);
249 2
250}
251
252fn report_locked(path: &std::path::Path) -> u8 {
254 eprintln!(
255 "plugmem: database is locked by another process: {}",
256 path.display()
257 );
258 1
259}
260
261fn resolve_db_path(
268 flag: Option<&str>,
269 config_path: Option<&std::path::Path>,
270 root: Option<&PathBuf>,
271) -> PathBuf {
272 flag.map(|value| workspace::resolve_target(value, root))
273 .or_else(|| {
274 std::env::var_os(ENV_DB).map(|v| workspace::resolve_target(&v.to_string_lossy(), root))
275 })
276 .or_else(|| config_path.map(PathBuf::from))
277 .or_else(plugmem_host::default_database_path)
278 .unwrap_or_else(|| PathBuf::from(DEFAULT_DB))
279}
280
281fn execute_help(topic: &HelpTopic, json_output: bool, out: &mut impl Write) -> u8 {
284 match topic {
285 HelpTopic::Settings => {
286 if json_output {
287 let help = plugmem_host::settings_help();
288 let settings: Vec<_> = help
289 .docs()
290 .iter()
291 .map(|doc| {
292 json!({
293 "section": doc.section,
294 "key": doc.key,
295 "type": doc.value_type,
296 "default": doc.default,
297 "description": doc.description,
298 "scope": doc.scope.as_str(),
299 })
300 })
301 .collect();
302 let value = json!({
303 "topic": "settings",
304 "config_path_precedence": help.config_path_precedence(),
305 "default_config_path": plugmem_host::default_config_path()
306 .map(|path| path.display().to_string()),
307 "settings": settings,
308 });
309 writeln!(out, "{value}").ok();
310 } else {
311 write!(out, "{}", plugmem_host::settings_help().render_human()).ok();
312 }
313 0
314 }
315 }
316}
317
318fn execute_ro(
321 ro: &ReadOnlyDatabase,
322 cmd: &Command,
323 recall_vector: Option<&[f32]>,
324 json: bool,
325 out: &mut impl Write,
326) -> u8 {
327 match cmd {
328 Command::Recall { .. } => {
329 match with_recall_query(cmd, now_ms(), recall_vector, |q| ro.recall(q)) {
330 Ok(res) => {
331 render_recall(&res, json, out);
332 0
333 }
334 Err(e) => report_err(&CliError::Host(e)),
335 }
336 }
337 Command::Show { id } => render_show(ro.get(FactId(*id)), *id, json, out),
338 Command::Stats => {
339 render_stats(&ro.stats(), json, out);
340 0
341 }
342 Command::Export => {
343 ro.export_each(|f| write_export_line(out, &f));
344 ro.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
345 0
346 }
347 Command::Verify => match ro.verify() {
349 Ok(()) => {
350 if json {
351 writeln!(out, "{}", json!({ "ok": true })).ok();
352 } else {
353 writeln!(out, "integrity ok").ok();
354 }
355 0
356 }
357 Err(e) => report_err(&CliError::Host(e)),
358 },
359 _ => unreachable!("execute_ro only receives read-only commands"),
360 }
361}
362
363fn execute(
367 db: &Database,
368 cmd: &Command,
369 json: bool,
370 now: u64,
371 out: &mut impl Write,
372) -> Result<u8, CliError> {
373 match cmd {
374 Command::Remember {
375 text,
376 entity,
377 tags,
378 links,
379 meta,
380 valid_from,
381 vector,
382 } => {
383 let outcome = do_remember(
384 db,
385 now,
386 text,
387 entity,
388 tags,
389 links,
390 meta,
391 *valid_from,
392 vector,
393 None,
394 )?;
395 render_remember(&outcome, json, out);
396 Ok(0)
397 }
398 Command::Revise {
399 id,
400 text,
401 entity,
402 tags,
403 links,
404 meta,
405 valid_from,
406 vector,
407 } => {
408 let outcome = do_remember(
409 db,
410 now,
411 text,
412 entity,
413 tags,
414 links,
415 meta,
416 *valid_from,
417 vector,
418 Some(FactId(*id)),
419 )?;
420 render_remember(&outcome, json, out);
421 Ok(0)
422 }
423 Command::Recall { .. } => {
424 let res = with_recall_query(cmd, now, None, |q| db.recall(q))?;
425 render_recall(&res, json, out);
426 Ok(0)
427 }
428 Command::Forget { id } => {
429 let fresh = db.forget(now, FactId(*id))?;
430 if json {
431 writeln!(out, "{}", json!({ "id": id, "forgotten": fresh })).ok();
432 } else if fresh {
433 writeln!(out, "forgot fact {id}").ok();
434 } else {
435 writeln!(out, "fact {id} was already gone").ok();
436 }
437 Ok(0)
438 }
439 Command::Link {
440 src,
441 rel,
442 dst,
443 provenance,
444 } => {
445 db.link(LinkInput {
446 now,
447 src,
448 rel,
449 dst,
450 provenance: provenance.map(FactId),
451 })?;
452 if json {
453 writeln!(out, "{}", json!({ "src": src, "rel": rel, "dst": dst })).ok();
454 } else {
455 writeln!(out, "linked {src} -{rel}-> {dst}").ok();
456 }
457 Ok(0)
458 }
459 Command::Unlink { src, rel, dst } => {
460 let fresh = db.unlink(UnlinkInput { now, src, rel, dst })?;
461 if json {
462 writeln!(
463 out,
464 "{}",
465 json!({ "src": src, "rel": rel, "dst": dst, "unlinked": fresh })
466 )
467 .ok();
468 } else if fresh {
469 writeln!(out, "unlinked {src} -{rel}-> {dst}").ok();
470 } else {
471 writeln!(out, "edge {src} -{rel}-> {dst} was already absent").ok();
472 }
473 Ok(0)
474 }
475 Command::Show { id } => Ok(render_show(db.get(FactId(*id)), *id, json, out)),
476 Command::Stats => {
477 render_stats(&db.stats(), json, out);
478 Ok(0)
479 }
480 Command::Export => {
481 db.export_each(|f| write_export_line(out, &f));
482 db.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
483 Ok(0)
484 }
485 Command::Maintain { mode } => {
486 let report = db.maintain_with_options(now, maintenance_options(*mode))?;
487 if json {
488 writeln!(
489 out,
490 "{}",
491 json!({
492 "purged": report.purged,
493 "bytes_before": report.bytes_before,
494 "bytes_after": report.bytes_after,
495 "no_op": report.no_op,
496 "tombstones_before": report.tombstones_before,
497 "facts_before": report.facts_before,
498 "facts_after": report.facts_after,
499 "vectors_before": report.vectors_before,
500 "vectors_after": report.vectors_after,
501 "hnsw_indexed_before": report.hnsw_indexed_before,
502 "hnsw_indexed_after": report.hnsw_indexed_after,
503 "structural_compacted": report.structural_compacted,
504 "bm25_compacted": report.bm25_compacted,
505 "bm25_reindexed": report.bm25_reindexed,
506 "hnsw_rebuilt": report.hnsw_rebuilt,
507 "hnsw_remapped": report.hnsw_remapped,
508 "hnsw_inserted": report.hnsw_inserted,
509 "edges_compacted": report.edges_compacted,
510 "edges_before": report.edges_before,
511 "edge_versions_before": report.edge_versions_before,
512 })
513 )
514 .ok();
515 } else {
516 writeln!(
517 out,
518 "maintained: purged {}, {} -> {} bytes, hnsw +{}, bm25 {}{}{}",
519 report.purged,
520 report.bytes_before,
521 report.bytes_after,
522 report.hnsw_inserted,
523 if report.bm25_reindexed {
524 "reindexed"
525 } else if report.bm25_compacted {
526 "compacted"
527 } else {
528 "unchanged"
529 },
530 if report.edges_compacted {
531 ", edges repacked"
532 } else {
533 ""
534 },
535 if report.no_op { " (no-op)" } else { "" }
536 )
537 .ok();
538 }
539 Ok(0)
540 }
541 Command::Checkpoint => {
542 db.checkpoint(now)?;
543 if json {
544 writeln!(out, "{}", json!({ "ok": true })).ok();
545 } else {
546 writeln!(out, "checkpointed: journal flushed to snapshot").ok();
547 }
548 Ok(0)
549 }
550 Command::Verify => {
551 db.verify()?;
554 if json {
555 writeln!(out, "{}", json!({ "ok": true })).ok();
556 } else {
557 writeln!(out, "integrity ok").ok();
558 }
559 Ok(0)
560 }
561 Command::Scrub
563 | Command::Recover { .. }
564 | Command::Repl { .. }
565 | Command::Import { .. }
566 | Command::Workspace { .. }
567 | Command::Help { .. } => {
568 unreachable!("this command is dispatched before execute")
569 }
570 }
571}
572
573fn do_recover(src: &Path, dst: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
577 match Database::recover(src, dst, settings.config.clone(), now_ms()) {
578 Ok(r) => {
579 if json {
580 writeln!(
581 out,
582 "{}",
583 json!({
584 "kept": r.kept,
585 "dropped_text": r.dropped_text,
586 "dropped_vector": r.dropped_vector,
587 "dropped_metadata": r.dropped_metadata,
588 "dst": dst.display().to_string(),
589 })
590 )
591 .ok();
592 } else {
593 writeln!(
594 out,
595 "recovered to {}: kept {}, dropped {} text + {} vector + {} metadata",
596 dst.display(),
597 r.kept,
598 r.dropped_text,
599 r.dropped_vector,
600 r.dropped_metadata
601 )
602 .ok();
603 }
604 0
605 }
606 Err(HostError::Locked { path }) => report_locked(&path),
607 Err(e) => report_err(&CliError::Host(e)),
608 }
609}
610
611fn do_scrub(path: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
616 let ro = match Database::open_readonly(path, settings.config.clone()) {
617 Ok(ro) => ro,
618 Err(HostError::Locked { path }) => return report_locked(&path),
619 Err(e) => return report_err(&CliError::Host(e)),
620 };
621 let scrub = match ro.scrub() {
622 Ok(s) => s,
623 Err(e) => return report_err(&CliError::Host(e)),
624 };
625 let mut done = 0u64;
626 let mut total = 0u64;
627 for step in scrub {
628 match step {
629 Ok(p) => {
630 done = p.done_bytes;
631 total = p.total_bytes;
632 }
633 Err(e) => return report_err(&CliError::Host(e)),
634 }
635 }
636 if json {
637 writeln!(out, "{}", json!({ "ok": true, "bytes": done })).ok();
638 } else {
639 writeln!(out, "scrub ok: {done}/{total} bytes verified").ok();
640 }
641 0
642}
643
644#[derive(Parser)]
647#[command(
648 no_binary_name = true,
649 name = "plugmem",
650 disable_help_subcommand = true
651)]
652struct ReplLine {
653 #[command(subcommand)]
654 command: Command,
655}
656
657fn split_line(line: &str) -> Vec<String> {
661 let mut tokens = Vec::new();
662 let mut cur = String::new();
663 let mut quote: Option<char> = None;
664 let mut has = false;
665 for c in line.chars() {
666 match quote {
667 Some(q) => {
668 if c == q {
669 quote = None;
670 } else {
671 cur.push(c);
672 }
673 }
674 None if c == '"' || c == '\'' => {
675 quote = Some(c);
676 has = true;
677 }
678 None if c.is_whitespace() => {
679 if has {
680 tokens.push(std::mem::take(&mut cur));
681 has = false;
682 }
683 }
684 None => {
685 cur.push(c);
686 has = true;
687 }
688 }
689 }
690 if has {
691 tokens.push(cur);
692 }
693 tokens
694}
695
696fn run_repl(
702 path: &Path,
703 settings: Settings,
704 json: bool,
705 input: impl BufRead,
706 out: &mut impl Write,
707) -> u8 {
708 let db = match settings.open(path) {
709 Ok(db) => db,
710 Err(HostError::Locked { path }) => return report_locked(&path),
711 Err(e) => return report_err(&CliError::Host(e)),
712 };
713 eprintln!("plugmem repl — one open handle, host speed. `help` for verbs, `exit` to quit.");
714 eprint!("plugmem> ");
715 for line in input.lines() {
716 let Ok(line) = line else { break };
717 let line = line.trim();
718 if line.is_empty() {
719 eprint!("plugmem> ");
720 continue;
721 }
722 if line == "exit" || line == "quit" {
723 break;
724 } else if line == "help" {
725 writeln!(
726 out,
727 "verbs: remember recall revise forget link unlink show stats maintain checkpoint \
728 verify export import (scrub/recover stay one-shot) exit"
729 )
730 .ok();
731 } else {
732 run_repl_line(&db, line, json, out);
733 }
734 eprint!("plugmem> ");
735 }
736 eprintln!();
737 match db.checkpoint(now_ms()) {
739 Ok(()) => 0,
740 Err(e) => report_err(&CliError::Host(e)),
741 }
742}
743
744fn run_repl_line(db: &Database, line: &str, json: bool, out: &mut impl Write) {
747 let cmd = match ReplLine::try_parse_from(split_line(line)) {
748 Ok(r) => r.command,
749 Err(e) => {
751 let _ = writeln!(out, "{e}");
752 return;
753 }
754 };
755 match &cmd {
756 Command::Repl { .. } => {
757 let _ = writeln!(out, "already in a repl session");
758 }
759 Command::Scrub | Command::Recover { .. } => {
760 let _ = writeln!(
761 out,
762 "scrub/recover are one-shot commands; run them outside the repl"
763 );
764 }
765 _ => {
766 if let Err(e) = execute(db, &cmd, json, now_ms(), out) {
767 write_err(out, &e);
768 }
769 }
770 }
771}
772
773fn run_repl_ro(
786 path: &Path,
787 mut settings: Settings,
788 json: bool,
789 input: impl BufRead,
790 out: &mut impl Write,
791) -> u8 {
792 let mut ro = match Database::open_readonly(path, settings.config.clone()) {
793 Ok(ro) => ro,
794 Err(HostError::Locked { path }) => return report_locked(&path),
795 Err(e) => return report_err(&CliError::Host(e)),
798 };
799 eprintln!(
800 "plugmem repl --read-only — observing generation {} of another process's writer. \
801 `help` for verbs, `refresh`/`generation` for cross-process freshness, `exit` to quit.",
802 ro.generation()
803 );
804 eprint!("plugmem(ro)> ");
805 for line in input.lines() {
806 let Ok(line) = line else { break };
807 let line = line.trim();
808 if line.is_empty() {
809 eprint!("plugmem(ro)> ");
810 continue;
811 }
812 match line {
813 "exit" | "quit" => break,
814 "help" => {
815 writeln!(
816 out,
817 "read verbs: recall show stats export verify \
818 freshness: generation refresh exit \
819 (writes and scrub/recover are refused in a read-only session)"
820 )
821 .ok();
822 }
823 "generation" => {
826 let g = ro.generation();
827 if json {
828 writeln!(out, "{}", json!({ "generation": g })).ok();
829 } else {
830 writeln!(out, "generation {g}").ok();
831 }
832 }
833 "refresh" => match ro.refresh() {
834 Ok(advanced) => {
835 let g = ro.generation();
836 if json {
837 writeln!(out, "{}", json!({ "advanced": advanced, "generation": g })).ok();
838 } else if advanced {
839 writeln!(out, "refreshed → generation {g}").ok();
840 } else {
841 writeln!(out, "already current → generation {g}").ok();
842 }
843 }
844 Err(e) => write_err(out, &CliError::Host(e)),
845 },
846 _ => run_repl_ro_line(&ro, &mut settings, line, json, out),
847 }
848 eprint!("plugmem(ro)> ");
849 }
850 eprintln!();
851 0
853}
854
855fn run_repl_ro_line(
858 ro: &ReadOnlyDatabase,
859 settings: &mut Settings,
860 line: &str,
861 json: bool,
862 out: &mut impl Write,
863) {
864 let cmd = match ReplLine::try_parse_from(split_line(line)) {
865 Ok(r) => r.command,
866 Err(e) => {
867 let _ = writeln!(out, "{e}");
868 return;
869 }
870 };
871 let readable = matches!(
872 &cmd,
873 Command::Show { .. }
874 | Command::Stats
875 | Command::Export
876 | Command::Verify
877 | Command::Recall { .. }
878 );
879 if !readable {
880 let _ = writeln!(
881 out,
882 "read-only session: only recall/show/stats/export/verify run \
883 (plus refresh/generation); writes and one-shot commands need a writer handle"
884 );
885 return;
886 }
887 let recall_vector = match embed_recall_query(settings, &cmd) {
890 Ok(v) => v,
891 Err(e) => {
892 write_err(out, &e);
893 return;
894 }
895 };
896 let _ = execute_ro(ro, &cmd, recall_vector.as_deref(), json, out);
897}
898
899fn embed_recall_query(
907 settings: &mut Settings,
908 cmd: &Command,
909) -> Result<Option<Vec<f32>>, CliError> {
910 let Command::Recall {
911 query: Some(text),
912 vector,
913 ..
914 } = cmd
915 else {
916 return Ok(None);
917 };
918 if !vector.is_empty() {
921 return Ok(None);
922 }
923 let Some(embedder) = settings.embedder.as_ref() else {
924 return Ok(None);
925 };
926 let mut vectors = embedder.embed(&[text.as_str()]).map_err(CliError::Host)?;
927 Ok(vectors.pop())
928}
929
930fn with_recall_query<R>(
935 cmd: &Command,
936 now: u64,
937 override_vector: Option<&[f32]>,
938 f: impl FnOnce(RecallQuery<'_>) -> R,
939) -> R {
940 let Command::Recall {
941 query,
942 tags,
943 entities,
944 as_of,
945 range,
946 k,
947 closed,
948 token_budget,
949 ef,
950 graph_depth,
951 vector,
952 } = cmd
953 else {
954 unreachable!("with_recall_query called on a non-recall command");
955 };
956 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
957 let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
958 let range_pair = range.as_ref().map(|v| (v[0], v[1]));
959 let explicit = (!vector.is_empty()).then_some(vector.as_slice());
964 let q = RecallQuery {
965 now,
966 text: query.as_deref(),
967 vector: explicit.or(override_vector),
968 tags: &tag_refs,
969 entities: &ent_refs,
970 as_of: *as_of,
971 range: range_pair,
972 k: *k,
973 token_budget: *token_budget,
974 include_closed: *closed,
975 ef: *ef,
976 graph_depth: *graph_depth,
977 };
978 f(q)
979}
980
981fn render_recall(res: &RecallResult, json: bool, out: &mut impl Write) {
984 if json {
985 let facts: Vec<_> = res
986 .facts
987 .iter()
988 .map(|f| {
989 json!({
990 "id": f.id.0,
991 "score": f.score,
992 "sources": f.sources,
993 "recorded_at": f.recorded_at,
994 "valid_from": f.valid_from,
995 "valid_to": open_or(f.valid_to),
996 })
997 })
998 .collect();
999 let edges: Vec<_> = res
1004 .edges
1005 .iter()
1006 .map(|e| {
1007 json!({
1008 "src": e.src.0,
1009 "rel": e.rel.0,
1010 "dst": e.dst.0,
1011 "provenance": (e.provenance != FactId::NONE).then_some(e.provenance.0),
1012 })
1013 })
1014 .collect();
1015 writeln!(
1016 out,
1017 "{}",
1018 json!({
1019 "facts": facts,
1020 "edges": edges,
1021 "rendered": res.rendered,
1022 "truncated": res.truncated,
1023 })
1024 )
1025 .ok();
1026 } else if res.rendered.is_empty() {
1027 writeln!(out, "(nothing recalled)").ok();
1028 } else {
1029 writeln!(out, "{}", res.rendered).ok();
1030 }
1031}
1032
1033fn render_show(
1035 fact: Option<plugmem_host::FactSnapshot>,
1036 id: u32,
1037 json: bool,
1038 out: &mut impl Write,
1039) -> u8 {
1040 let Some(fact) = fact else {
1041 if json {
1042 writeln!(out, "{}", json!({ "id": id, "found": false })).ok();
1043 } else {
1044 writeln!(out, "fact {id} not found").ok();
1045 }
1046 return 1;
1047 };
1048 let r = &fact.record;
1049 if json {
1050 writeln!(
1051 out,
1052 "{}",
1053 json!({
1054 "id": r.id.0,
1055 "text": fact.text,
1056 "recorded_at": r.recorded_at,
1057 "valid_from": r.valid_from,
1058 "valid_to": open_or(r.valid_to),
1059 "closed": r.is_closed(),
1060 "tombstone": r.is_tombstone(),
1061 "revises": (r.revises != FactId::NONE).then_some(r.revises.0),
1062 "metadata": fact.metadata,
1063 })
1064 )
1065 .ok();
1066 } else {
1067 writeln!(out, "fact {}", r.id.0).ok();
1068 writeln!(out, " text {}", fact.text).ok();
1069 writeln!(out, " recorded_at {}", r.recorded_at).ok();
1070 write!(out, " valid [{}, ", r.valid_from).ok();
1071 match r.valid_to {
1072 VALID_TO_OPEN => writeln!(out, "open)").ok(),
1073 to => writeln!(out, "{to})").ok(),
1074 };
1075 if r.revises != FactId::NONE {
1076 writeln!(out, " revises fact {}", r.revises.0).ok();
1077 }
1078 if !fact.metadata.is_empty() {
1079 let rendered = fact
1080 .metadata
1081 .iter()
1082 .map(|(k, v)| format!("{k}={v}"))
1083 .collect::<Vec<_>>()
1084 .join(", ");
1085 writeln!(out, " metadata {rendered}").ok();
1086 }
1087 if r.is_tombstone() {
1088 writeln!(out, " state tombstoned").ok();
1089 }
1090 }
1091 0
1092}
1093
1094fn maintenance_options(mode: MaintainMode) -> MaintenanceOptions {
1099 match MaintenanceMode::from(mode) {
1100 MaintenanceMode::Auto => MaintenanceOptions::auto(),
1101 MaintenanceMode::Full => MaintenanceOptions::full(),
1102 mode => MaintenanceOptions {
1103 mode,
1104 ..MaintenanceOptions::auto()
1105 },
1106 }
1107}
1108
1109fn render_stats(s: &Stats, json: bool, out: &mut impl Write) {
1111 if json {
1112 writeln!(
1113 out,
1114 "{}",
1115 json!({
1116 "facts": s.facts,
1117 "entities": s.entities,
1118 "terms": s.terms,
1119 "edges": s.edges,
1120 "edge_versions": s.edge_versions,
1121 "vectors": s.vectors,
1122 "hnsw_indexed": s.hnsw_indexed,
1123 "next_fact": s.next_fact,
1124 "next_entity": s.next_entity,
1125 "next_edge": s.next_edge,
1126 "pool_bytes": s.pool_bytes,
1127 "shards": {
1128 "facts": s.shards.facts,
1129 "entities": s.shards.entities,
1130 "edges": s.shards.edges,
1131 "temporal": s.shards.temporal,
1132 "postings": s.shards.postings,
1133 },
1134 })
1135 )
1136 .ok();
1137 } else {
1138 writeln!(out, "facts {}", s.facts).ok();
1139 writeln!(out, "entities {}", s.entities).ok();
1140 writeln!(out, "terms {}", s.terms).ok();
1141 writeln!(out, "edges {}", s.edges).ok();
1142 writeln!(out, "edge_vers {}", s.edge_versions).ok();
1143 writeln!(out, "vectors {}", s.vectors).ok();
1144 writeln!(out, "hnsw_idx {}", s.hnsw_indexed).ok();
1145 writeln!(out, "next_fact {}", s.next_fact).ok();
1146 writeln!(out, "next_edge {}", s.next_edge).ok();
1147 writeln!(out, "pool_bytes {}", s.pool_bytes).ok();
1148 writeln!(
1151 out,
1152 "shards facts {} entities {} edges {} temporal {} postings {}",
1153 s.shards.facts, s.shards.entities, s.shards.edges, s.shards.temporal, s.shards.postings,
1154 )
1155 .ok();
1156 }
1157}
1158
1159fn write_export_line(out: &mut impl Write, f: &ExportedFact) {
1166 writeln!(
1167 out,
1168 "{}",
1169 json!({
1170 "kind": "fact",
1171 "id": f.id,
1172 "text": f.text,
1173 "entity": f.entity,
1174 "tags": f.tags,
1175 "metadata": f.metadata,
1176 "recorded_at": f.recorded_at,
1177 "valid_from": f.valid_from,
1178 })
1179 )
1180 .ok();
1181}
1182
1183fn write_export_edge(out: &mut impl Write, src: &str, rel: &str, dst: &str, provenance: FactId) {
1187 writeln!(
1188 out,
1189 "{}",
1190 json!({
1191 "kind": "edge",
1192 "src": src,
1193 "rel": rel,
1194 "dst": dst,
1195 "provenance": (provenance != FactId::NONE).then_some(provenance.0),
1196 })
1197 )
1198 .ok();
1199}
1200
1201#[cfg(test)]
1204fn render_export(facts: &[ExportedFact], _json: bool, out: &mut impl Write) {
1205 for f in facts {
1206 write_export_line(out, f);
1207 }
1208}
1209
1210fn do_import(
1217 db: &Database,
1218 now: u64,
1219 file: &std::path::Path,
1220 batch_size: usize,
1221 _out: &mut impl Write,
1222) -> Result<ImportReport, CliError> {
1223 let f = std::fs::File::open(file)
1224 .map_err(|e| CliError::Usage(format!("reading {}: {e}", file.display())))?;
1225 let reader = io::BufReader::new(f);
1226 let mut count = 0usize;
1227 let mut batch: Vec<ParsedFact> = Vec::with_capacity(batch_size);
1228 let mut remap: BTreeMap<u32, FactId> = BTreeMap::new();
1232 let mut edges = 0usize;
1233
1234 for (i, line) in reader.lines().enumerate() {
1235 let line = line.map_err(|e| CliError::Usage(format!("line {}: {e}", i + 1)))?;
1236 let line = line.trim();
1237 if line.is_empty() {
1238 continue;
1239 }
1240 match parse_import_line(line, i + 1)? {
1241 ImportLine::Fact(fact) => {
1242 batch.push(fact);
1243 if batch.len() >= batch_size {
1244 count += flush_import_batch(db, now, &batch, &mut remap)?;
1245 batch.clear();
1246 }
1247 }
1248 ImportLine::Edge(edge) => {
1249 if !batch.is_empty() {
1254 count += flush_import_batch(db, now, &batch, &mut remap)?;
1255 batch.clear();
1256 }
1257 db.link(LinkInput {
1258 now,
1259 src: &edge.src,
1260 rel: &edge.rel,
1261 dst: &edge.dst,
1262 provenance: edge.provenance.and_then(|old| remap.get(&old).copied()),
1266 })?;
1267 edges += 1;
1268 }
1269 }
1270 }
1271 count += flush_import_batch(db, now, &batch, &mut remap)?;
1272 Ok(ImportReport {
1273 facts: count,
1274 edges,
1275 })
1276}
1277
1278#[derive(Debug, PartialEq, Eq)]
1282struct ImportReport {
1283 facts: usize,
1284 edges: usize,
1285}
1286
1287enum ImportLine {
1289 Fact(ParsedFact),
1290 Edge(ParsedEdge),
1291}
1292
1293struct ParsedEdge {
1296 src: String,
1297 rel: String,
1298 dst: String,
1299 provenance: Option<u32>,
1300}
1301
1302struct ParsedFact {
1306 id: Option<u32>,
1307 text: String,
1308 entity: Option<String>,
1309 tags: Vec<String>,
1310 metadata: Vec<(String, String)>,
1311 valid_from: Option<u64>,
1312}
1313
1314fn parse_import_line(line: &str, lineno: usize) -> Result<ImportLine, CliError> {
1319 let v: serde_json::Value =
1320 serde_json::from_str(line).map_err(|e| CliError::Usage(format!("line {lineno}: {e}")))?;
1321 match v["kind"].as_str() {
1322 None | Some("fact") => parse_import_fact(&v, lineno).map(ImportLine::Fact),
1323 Some("edge") => parse_import_edge(&v, lineno).map(ImportLine::Edge),
1324 Some(other) => Err(CliError::Usage(format!(
1325 "line {lineno}: unknown kind \"{other}\" (expected \"fact\" or \"edge\")"
1326 ))),
1327 }
1328}
1329
1330fn parse_import_edge(v: &serde_json::Value, lineno: usize) -> Result<ParsedEdge, CliError> {
1333 let field = |key: &str| -> Result<String, CliError> {
1334 v[key]
1335 .as_str()
1336 .map(String::from)
1337 .ok_or_else(|| CliError::Usage(format!("line {lineno}: edge missing string \"{key}\"")))
1338 };
1339 Ok(ParsedEdge {
1340 src: field("src")?,
1341 rel: field("rel")?,
1342 dst: field("dst")?,
1343 provenance: v["provenance"].as_u64().and_then(|n| u32::try_from(n).ok()),
1344 })
1345}
1346
1347fn parse_import_fact(v: &serde_json::Value, lineno: usize) -> Result<ParsedFact, CliError> {
1349 let text = v["text"]
1350 .as_str()
1351 .ok_or_else(|| CliError::Usage(format!("line {lineno}: missing string \"text\"")))?
1352 .to_string();
1353 let entity = v["entity"].as_str().map(String::from);
1354 let tags = v["tags"]
1355 .as_array()
1356 .map(|a| {
1357 a.iter()
1358 .filter_map(|t| t.as_str().map(String::from))
1359 .collect()
1360 })
1361 .unwrap_or_default();
1362 let metadata = v["metadata"]
1365 .as_object()
1366 .map(|m| {
1367 m.iter()
1368 .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
1369 .collect::<BTreeMap<_, _>>()
1370 .into_iter()
1371 .collect()
1372 })
1373 .unwrap_or_default();
1374 let valid_from = v["valid_from"].as_u64();
1375 Ok(ParsedFact {
1376 id: v["id"].as_u64().and_then(|n| u32::try_from(n).ok()),
1377 text,
1378 entity,
1379 tags,
1380 metadata,
1381 valid_from,
1382 })
1383}
1384
1385fn flush_import_batch(
1388 db: &Database,
1389 now: u64,
1390 batch: &[ParsedFact],
1391 remap: &mut BTreeMap<u32, FactId>,
1392) -> Result<usize, CliError> {
1393 if batch.is_empty() {
1394 return Ok(0);
1395 }
1396 let tag_refs: Vec<Vec<&str>> = batch
1399 .iter()
1400 .map(|p| p.tags.iter().map(String::as_str).collect())
1401 .collect();
1402 let meta_refs: Vec<Vec<(&str, &str)>> = batch
1403 .iter()
1404 .map(|p| {
1405 p.metadata
1406 .iter()
1407 .map(|(k, v)| (k.as_str(), v.as_str()))
1408 .collect()
1409 })
1410 .collect();
1411 let inputs: Vec<RememberInput> = batch
1412 .iter()
1413 .zip(&tag_refs)
1414 .zip(&meta_refs)
1415 .map(|((p, tags), meta)| RememberInput {
1416 entity: p.entity.as_deref(),
1417 tags,
1418 metadata: (!meta.is_empty()).then_some(meta.as_slice()),
1419 valid_from: p.valid_from,
1420 ..RememberInput::text(now, &p.text)
1421 })
1422 .collect();
1423 let outcomes = db.remember_many(inputs)?;
1426 for (parsed, outcome) in batch.iter().zip(&outcomes) {
1427 if let Some(old) = parsed.id {
1428 remap.insert(old, outcome.id);
1429 }
1430 }
1431 Ok(batch.len())
1432}
1433
1434#[allow(clippy::too_many_arguments)]
1436fn do_remember(
1437 db: &Database,
1438 now: u64,
1439 text: &str,
1440 entity: &Option<String>,
1441 tags: &[String],
1442 links: &[String],
1443 meta: &[String],
1444 valid_from: Option<u64>,
1445 vector: &[f32],
1446 revise: Option<FactId>,
1447) -> Result<RememberOutcome, CliError> {
1448 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1449 let link_pairs = parse_links(links)?;
1450 let link_refs: Vec<(&str, &str)> = link_pairs
1451 .iter()
1452 .map(|(r, e)| (r.as_str(), e.as_str()))
1453 .collect();
1454 let meta_map = parse_meta(meta)?;
1458 let meta_refs: Vec<(&str, &str)> = meta_map
1459 .iter()
1460 .map(|(k, v)| (k.as_str(), v.as_str()))
1461 .collect();
1462 let input = RememberInput {
1463 entity: entity.as_deref(),
1464 tags: &tag_refs,
1465 links: &link_refs,
1466 metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1467 valid_from,
1468 vector: (!vector.is_empty()).then_some(vector),
1472 ..RememberInput::text(now, text)
1473 };
1474 match revise {
1475 Some(target) => Ok(db.revise(target, input)?),
1476 None => Ok(db.remember(input)?),
1477 }
1478}
1479
1480fn parse_meta(meta: &[String]) -> Result<BTreeMap<String, String>, CliError> {
1483 let mut map = BTreeMap::new();
1484 for s in meta {
1485 let (k, v) = s
1486 .split_once('=')
1487 .filter(|(k, _)| !k.is_empty())
1488 .ok_or_else(|| CliError::Usage(format!("bad --meta `{s}` — expected KEY=VALUE")))?;
1489 map.insert(k.to_string(), v.to_string());
1490 }
1491 Ok(map)
1492}
1493
1494fn parse_links(links: &[String]) -> Result<Vec<(String, String)>, CliError> {
1496 links
1497 .iter()
1498 .map(|s| {
1499 s.split_once(':')
1500 .filter(|(r, e)| !r.is_empty() && !e.is_empty())
1501 .map(|(r, e)| (r.to_string(), e.to_string()))
1502 .ok_or_else(|| CliError::Usage(format!("bad --link `{s}` — expected REL:ENTITY")))
1503 })
1504 .collect()
1505}
1506
1507fn render_remember(outcome: &RememberOutcome, json: bool, out: &mut impl Write) {
1509 if json {
1510 let similar: Vec<_> = outcome
1511 .similar
1512 .iter()
1513 .map(|s| json!({ "id": s.id.0, "score": s.score, "reason": format!("{:?}", s.reason) }))
1514 .collect();
1515 writeln!(
1516 out,
1517 "{}",
1518 json!({
1519 "id": outcome.id.0,
1520 "entity": outcome.entity.map(|e| e.0),
1521 "similar": similar,
1522 })
1523 )
1524 .ok();
1525 } else {
1526 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1527 for s in &outcome.similar {
1528 writeln!(
1529 out,
1530 " ~ similar to fact {} ({:?}, {:.2})",
1531 s.id.0, s.reason, s.score
1532 )
1533 .ok();
1534 }
1535 }
1536}
1537
1538fn open_or(valid_to: u64) -> Option<u64> {
1540 (valid_to != VALID_TO_OPEN).then_some(valid_to)
1541}
1542
1543#[cfg(test)]
1544mod tests {
1545 use plugmem_host::Config;
1546
1547 use super::*;
1548
1549 struct StubEmbedder;
1551 impl plugmem_host::Embedder for StubEmbedder {
1552 fn dim(&self) -> usize {
1553 3
1554 }
1555 fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1556 Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1557 }
1558 }
1559
1560 fn recall_cmd(query: Option<&str>) -> Command {
1561 Command::Recall {
1562 query: query.map(str::to_owned),
1563 tags: vec![],
1564 entities: vec![],
1565 as_of: None,
1566 range: None,
1567 k: 0,
1568 closed: false,
1569 token_budget: None,
1570 ef: None,
1571 graph_depth: None,
1572 vector: Vec::new(),
1573 }
1574 }
1575
1576 fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1577 Settings {
1578 database_path: None,
1579 config: Config::default(),
1580 embedder,
1581 snapshot_every_ops: None,
1582 snapshot_journal_bytes: None,
1583 maintain_every_forgets: None,
1584 fsync: None,
1585 workspace: plugmem_host::WorkspaceSettings {
1586 dir: None,
1587 limits: plugmem_host::WorkspaceLimits::default(),
1588 },
1589 warnings: Vec::new(),
1590 }
1591 }
1592
1593 #[test]
1594 fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1595 let mut with = settings_with(Some(Box::new(StubEmbedder)));
1597 assert_eq!(
1598 embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1599 Some(vec![0.1, 0.2, 0.3])
1600 );
1601
1602 let mut without = settings_with(None);
1604 assert_eq!(
1605 embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1606 None
1607 );
1608
1609 let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1611 assert_eq!(
1612 embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1613 None
1614 );
1615
1616 let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1618 assert_eq!(
1619 embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1620 None
1621 );
1622 }
1623
1624 #[test]
1625 fn split_line_honors_quotes_and_whitespace() {
1626 assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1627 assert_eq!(
1628 split_line(r#"remember "two words" --tag x"#),
1629 ["remember", "two words", "--tag", "x"]
1630 );
1631 assert_eq!(split_line(" recall 'a b' "), ["recall", "a b"]);
1632 assert_eq!(split_line(""), Vec::<String>::new());
1633 assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1635 }
1636
1637 #[test]
1638 fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1639 let (db, tmp) = TempDb::open();
1640 let path = tmp.0.join("m.plugmem");
1641 drop(db); let settings = settings_with(None);
1644 let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1646 let mut out = Vec::new();
1647 let code = run_repl(&path, settings, false, &script[..], &mut out);
1648 let text = String::from_utf8(out).unwrap();
1649
1650 assert_eq!(code, 0);
1651 assert!(text.contains("remembered fact 0"), "{text}");
1652 assert!(text.contains("tokio"), "{text}");
1653 assert!(text.contains("unrecognized subcommand"), "{text}");
1655
1656 let ro = Database::open_readonly(&path, Config::default()).unwrap();
1660 assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1661 }
1662
1663 #[test]
1664 fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1665 let (db, tmp) = TempDb::open();
1666 let path = tmp.0.join("m.plugmem");
1667 let mut sink = Vec::new();
1670 execute(
1671 &db,
1672 &remember("seed fact tokio", None, &[]),
1673 false,
1674 1_000,
1675 &mut sink,
1676 )
1677 .unwrap();
1678 db.checkpoint(1_001).unwrap();
1679
1680 let settings = settings_with(None);
1681 let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1683 let mut out = Vec::new();
1684 let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1685 let text = String::from_utf8(out).unwrap();
1686
1687 assert_eq!(code, 0);
1688 assert!(text.contains("generation 1"), "generation verb: {text}");
1689 assert!(text.contains("fact"), "stats ran: {text}");
1690 assert!(
1693 text.contains("already current → generation 1"),
1694 "refresh no-op: {text}"
1695 );
1696 assert!(text.contains("read-only session"), "write refused: {text}");
1698
1699 assert_eq!(db.stats().facts, 1);
1702 }
1703
1704 #[test]
1705 fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1706 let (db, tmp) = TempDb::open();
1707 let path = tmp.0.join("m.plugmem");
1708 let mut sink = Vec::new();
1709 execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1710 db.checkpoint(1_001).unwrap();
1711
1712 struct HookOnFirstRead<'a> {
1716 script: std::io::Cursor<&'a [u8]>,
1717 db: &'a Database,
1718 fired: bool,
1719 }
1720 impl std::io::Read for HookOnFirstRead<'_> {
1721 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1722 if !self.fired {
1723 self.fired = true;
1724 let mut s = Vec::new();
1727 execute(
1728 self.db,
1729 &remember("second", None, &[]),
1730 false,
1731 2_000,
1732 &mut s,
1733 )
1734 .unwrap();
1735 self.db.checkpoint(2_001).unwrap();
1736 }
1737 self.script.read(buf)
1738 }
1739 }
1740 let reader = std::io::BufReader::new(HookOnFirstRead {
1741 script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
1742 db: &db,
1743 fired: false,
1744 });
1745
1746 let mut out = Vec::new();
1747 let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
1748 let text = String::from_utf8(out).unwrap();
1749
1750 assert_eq!(code, 0);
1751 assert!(text.contains("refreshed → generation 2"), "advance: {text}");
1753 assert!(text.contains("fact"), "stats after refresh: {text}");
1755 assert_eq!(db.stats().facts, 2);
1756 }
1757
1758 #[test]
1759 fn read_only_repl_freshness_verbs_emit_json() {
1760 let (db, tmp) = TempDb::open();
1761 let path = tmp.0.join("m.plugmem");
1762 let mut sink = Vec::new();
1763 execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
1764 db.checkpoint(1_001).unwrap();
1765
1766 let script = b"generation\nrefresh\nexit\n";
1767 let mut out = Vec::new();
1768 let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
1769 let text = String::from_utf8(out).unwrap();
1770
1771 assert_eq!(code, 0);
1772 assert!(
1773 text.contains(r#""generation":1"#),
1774 "generation json: {text}"
1775 );
1776 assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
1777 }
1778
1779 struct TempDb(PathBuf);
1781 impl TempDb {
1782 fn open() -> (Database, Self) {
1783 let dir = std::env::temp_dir().join(format!(
1784 "plugmem-cli-{}-{}",
1785 std::process::id(),
1786 now_ms_unique()
1787 ));
1788 std::fs::create_dir_all(&dir).unwrap();
1789 let path = dir.join("m.plugmem");
1790 let (db, _) = Database::open(&path, Config::default()).unwrap();
1791 (db, TempDb(dir))
1792 }
1793 }
1794 impl Drop for TempDb {
1795 fn drop(&mut self) {
1796 let _ = std::fs::remove_dir_all(&self.0);
1797 }
1798 }
1799
1800 fn now_ms_unique() -> String {
1803 use std::sync::atomic::{AtomicU64, Ordering};
1804 static N: AtomicU64 = AtomicU64::new(0);
1805 format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
1806 }
1807
1808 fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
1809 let mut buf = Vec::new();
1810 let code = execute(db, cmd, json, now, &mut buf).expect("execute");
1811 (code, String::from_utf8(buf).unwrap())
1812 }
1813
1814 fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
1815 Command::Remember {
1816 text: text.into(),
1817 entity: entity.map(Into::into),
1818 tags: tags.iter().map(|t| (*t).into()).collect(),
1819 links: Vec::new(),
1820 meta: Vec::new(),
1821 valid_from: None,
1822 vector: Vec::new(),
1823 }
1824 }
1825
1826 fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
1827 Command::Remember {
1828 text: text.into(),
1829 entity: None,
1830 tags: Vec::new(),
1831 links: Vec::new(),
1832 meta: meta.iter().map(|m| (*m).into()).collect(),
1833 valid_from: None,
1834 vector: Vec::new(),
1835 }
1836 }
1837
1838 #[test]
1839 fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
1840 let (db, _t) = TempDb::open();
1841 let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
1843 assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
1844
1845 let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1847 assert!(
1848 human.contains("metadata page=3, uri=s3://b/x"),
1849 "{human}"
1850 );
1851 let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1853 let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
1854 assert_eq!(v["metadata"]["page"], "3");
1855 assert_eq!(v["metadata"]["uri"], "s3://b/x");
1856
1857 let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
1859 let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
1860 assert_eq!(line["metadata"]["uri"], "s3://b/x");
1861
1862 assert!(matches!(
1864 parse_meta(&["noequals".to_string()]),
1865 Err(CliError::Usage(_))
1866 ));
1867 assert!(parse_meta(&["=noKey".to_string()]).is_err());
1868 }
1869
1870 #[test]
1871 fn remember_then_recall_human_and_json() {
1872 let (db, _t) = TempDb::open();
1873 let (code, out) = run_cmd(
1874 &db,
1875 &remember("prefers tokio", Some("user"), &["pref"]),
1876 false,
1877 1_000,
1878 );
1879 assert_eq!(code, 0);
1880 assert!(out.starts_with("remembered fact 0"), "{out}");
1881
1882 let recall = Command::Recall {
1884 query: Some("tokio".into()),
1885 tags: Vec::new(),
1886 entities: Vec::new(),
1887 as_of: None,
1888 range: None,
1889 k: 0,
1890 closed: false,
1891 token_budget: None,
1892 ef: None,
1893 graph_depth: None,
1894 vector: Vec::new(),
1895 };
1896 let (code, out) = run_cmd(&db, &recall, false, 2_000);
1897 assert_eq!(code, 0);
1898 assert!(out.contains("tokio"), "{out}");
1899
1900 let (code, out) = run_cmd(&db, &recall, true, 2_000);
1902 assert_eq!(code, 0);
1903 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1904 assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
1905 }
1906
1907 #[test]
1908 fn recall_empty_is_ok_with_a_note() {
1909 let (db, _t) = TempDb::open();
1910 let recall = Command::Recall {
1911 query: Some("nothing here".into()),
1912 tags: Vec::new(),
1913 entities: Vec::new(),
1914 as_of: None,
1915 range: None,
1916 k: 0,
1917 closed: false,
1918 token_budget: None,
1919 ef: None,
1920 graph_depth: None,
1921 vector: Vec::new(),
1922 };
1923 let (code, out) = run_cmd(&db, &recall, false, 1_000);
1924 assert_eq!(code, 0);
1925 assert!(out.contains("nothing recalled"), "{out}");
1926 }
1927
1928 #[test]
1929 fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
1930 let (db, _t) = TempDb::open();
1931 run_cmd(
1932 &db,
1933 &remember("lives in Moscow", Some("user"), &[]),
1934 false,
1935 1_000,
1936 );
1937 let (_, out) = run_cmd(
1939 &db,
1940 &remember("lives in Moscow now", Some("user"), &[]),
1941 false,
1942 1_500,
1943 );
1944 assert!(out.contains("similar to fact"), "{out}");
1945
1946 let revise = Command::Revise {
1947 id: 0,
1948 text: "lives in Berlin".into(),
1949 entity: Some("user".into()),
1950 tags: Vec::new(),
1951 links: Vec::new(),
1952 meta: Vec::new(),
1953 valid_from: None,
1954 vector: Vec::new(),
1955 };
1956 let (code, out) = run_cmd(&db, &revise, false, 2_000);
1957 assert_eq!(code, 0);
1958 assert!(out.starts_with("remembered fact"), "{out}");
1959 }
1960
1961 #[test]
1962 fn show_found_and_missing() {
1963 let (db, _t) = TempDb::open();
1964 run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
1965
1966 let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1967 assert_eq!(code, 0);
1968 assert!(
1969 out.contains("a note") && out.contains("recorded_at 1000"),
1970 "{out}"
1971 );
1972
1973 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
1974 assert_eq!(code, 1, "missing id is a soft miss");
1975 assert!(out.contains("not found"), "{out}");
1976
1977 let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1979 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1980 assert_eq!(v["text"], "a note");
1981 assert_eq!(v["valid_to"], serde_json::Value::Null); }
1983
1984 #[test]
1985 fn forget_then_maintain_purges() {
1986 let (db, _t) = TempDb::open();
1987 run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
1988
1989 let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
1990 assert_eq!(code, 0);
1991 assert!(out.contains("forgot fact 0"), "{out}");
1992 let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
1994 assert!(out.contains("already gone"), "{out}");
1995
1996 let (code, out) = run_cmd(
1997 &db,
1998 &Command::Maintain {
1999 mode: MaintainMode::Auto,
2000 },
2001 false,
2002 3_000,
2003 );
2004 assert_eq!(code, 0);
2005 assert!(out.contains("purged 1"), "{out}");
2006 }
2007
2008 #[test]
2009 fn link_and_stats_and_json() {
2010 let (db, _t) = TempDb::open();
2011 run_cmd(
2012 &db,
2013 &remember("uses tokio", Some("plugmem"), &[]),
2014 false,
2015 1_000,
2016 );
2017 let link = Command::Link {
2018 src: "plugmem".into(),
2019 rel: "depends_on".into(),
2020 dst: "tokio".into(),
2021 provenance: None,
2022 };
2023 let (code, out) = run_cmd(&db, &link, false, 2_000);
2024 assert_eq!(code, 0);
2025 assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
2026 let unlink = Command::Unlink {
2027 src: "plugmem".into(),
2028 rel: "depends_on".into(),
2029 dst: "tokio".into(),
2030 };
2031 let (code, out) = run_cmd(&db, &unlink, false, 2_500);
2032 assert_eq!(code, 0);
2033 assert!(
2034 out.contains("unlinked plugmem -depends_on-> tokio"),
2035 "{out}"
2036 );
2037
2038 let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
2039 assert_eq!(code, 0);
2040 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2041 assert_eq!(v["facts"], 1);
2042 assert_eq!(v["edges"], 0);
2043 assert_eq!(v["edge_versions"], 1);
2044 }
2045
2046 #[test]
2047 fn bad_link_is_a_usage_error() {
2048 let (db, _t) = TempDb::open();
2049 let cmd = Command::Remember {
2050 text: "x".into(),
2051 entity: Some("user".into()),
2052 tags: Vec::new(),
2053 links: vec!["not-a-pair".into()],
2054 meta: Vec::new(),
2055 valid_from: None,
2056 vector: Vec::new(),
2057 };
2058 let mut buf = Vec::new();
2059 let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
2060 assert!(matches!(err, CliError::Usage(_)));
2061 }
2062
2063 #[test]
2064 fn as_of_time_travel_via_recall() {
2065 let (db, _t) = TempDb::open();
2066 run_cmd(
2067 &db,
2068 &remember("lives in Moscow", Some("user"), &[]),
2069 false,
2070 1_000,
2071 );
2072 let revise = Command::Revise {
2073 id: 0,
2074 text: "lives in Berlin".into(),
2075 entity: Some("user".into()),
2076 tags: Vec::new(),
2077 links: Vec::new(),
2078 meta: Vec::new(),
2079 valid_from: None,
2080 vector: Vec::new(),
2081 };
2082 run_cmd(&db, &revise, false, 2_000);
2083
2084 let as_of = Command::Recall {
2085 query: Some("lives".into()),
2086 tags: Vec::new(),
2087 entities: vec!["user".into()],
2088 as_of: Some(1_500),
2089 range: None,
2090 k: 0,
2091 closed: false,
2092 token_budget: None,
2093 ef: None,
2094 graph_depth: None,
2095 vector: Vec::new(),
2096 };
2097 let (_, out) = run_cmd(&db, &as_of, false, 3_000);
2098 assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
2099 }
2100
2101 #[test]
2102 fn every_command_has_a_json_shape() {
2103 let (db, _t) = TempDb::open();
2104 let (_, out) = run_cmd(
2106 &db,
2107 &remember("uses tokio", Some("plugmem"), &["pref"]),
2108 true,
2109 1_000,
2110 );
2111 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2112 assert_eq!(v["id"], 0);
2113 assert!(v["similar"].is_array());
2114
2115 let revise = Command::Revise {
2117 id: 0,
2118 text: "uses tokio now".into(),
2119 entity: Some("plugmem".into()),
2120 tags: Vec::new(),
2121 links: Vec::new(),
2122 meta: Vec::new(),
2123 valid_from: None,
2124 vector: Vec::new(),
2125 };
2126 let (_, out) = run_cmd(&db, &revise, true, 1_500);
2127 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2128
2129 let link = Command::Link {
2131 src: "plugmem".into(),
2132 rel: "depends_on".into(),
2133 dst: "tokio".into(),
2134 provenance: None,
2135 };
2136 let (_, out) = run_cmd(&db, &link, true, 2_000);
2137 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2138 assert_eq!(v["rel"], "depends_on");
2139 let unlink = Command::Unlink {
2140 src: "plugmem".into(),
2141 rel: "depends_on".into(),
2142 dst: "tokio".into(),
2143 };
2144 let (_, out) = run_cmd(&db, &unlink, true, 2_100);
2145 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2146 assert_eq!(v["unlinked"], true);
2147
2148 let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
2150 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2151 assert_eq!(v["forgotten"], true);
2152 let (_, out) = run_cmd(
2153 &db,
2154 &Command::Maintain {
2155 mode: MaintainMode::Auto,
2156 },
2157 true,
2158 3_000,
2159 );
2160 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2161 assert!(v["purged"].as_u64().unwrap() >= 1);
2162
2163 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
2165 assert_eq!(code, 1);
2166 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2167 assert_eq!(v["found"], false);
2168
2169 let recall = Command::Recall {
2171 query: None,
2172 tags: Vec::new(),
2173 entities: vec!["plugmem".into()],
2174 as_of: None,
2175 range: Some(vec![0, 10_000]),
2176 k: 4,
2177 closed: true,
2178 token_budget: None,
2179 ef: None,
2180 graph_depth: None,
2181 vector: Vec::new(),
2182 };
2183 let (_, out) = run_cmd(&db, &recall, true, 4_000);
2184 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2185 }
2186
2187 #[test]
2188 fn graph_depth_is_a_per_call_flag_over_the_configured_default() {
2189 let (db, _t) = TempDb::open();
2192 for (i, (entity, next)) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
2193 .iter()
2194 .enumerate()
2195 {
2196 let cmd = Command::Remember {
2197 text: format!("fact on {entity}"),
2198 entity: Some((*entity).into()),
2199 tags: Vec::new(),
2200 links: vec![format!("leads_to:{next}")],
2201 meta: Vec::new(),
2202 valid_from: None,
2203 vector: Vec::new(),
2204 };
2205 run_cmd(&db, &cmd, false, 1_000 + i as u64);
2206 }
2207
2208 let reached = |depth: Option<u32>| {
2209 let recall = Command::Recall {
2210 query: None,
2211 tags: Vec::new(),
2212 entities: vec!["a".into()],
2213 as_of: None,
2214 range: None,
2215 k: 64,
2216 closed: false,
2217 token_budget: Some(4096),
2218 ef: None,
2219 graph_depth: depth,
2220 vector: Vec::new(),
2221 };
2222 let (_, out) = run_cmd(&db, &recall, false, 5_000);
2223 out.lines().filter(|l| l.starts_with("- [f")).count()
2224 };
2225
2226 assert_eq!(reached(None), 3, "the configured default is 2 hops");
2227 assert_eq!(reached(Some(0)), 1, "no expansion: the anchor's own fact");
2228 assert_eq!(reached(Some(1)), 2);
2229 assert_eq!(reached(Some(3)), 4);
2230 assert_eq!(reached(Some(99)), 4);
2233 assert_eq!(reached(Some(u32::MAX)), 4);
2234 }
2235
2236 #[test]
2237 fn stats_human_lists_the_counters() {
2238 let (db, _t) = TempDb::open();
2239 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2240 let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
2241 assert_eq!(code, 0);
2242 assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
2243 }
2244
2245 #[test]
2246 fn verify_command_renders_human_and_json() {
2247 let (db, _t) = TempDb::open();
2248 run_cmd(&db, &remember("clean", None, &[]), false, 1_000);
2249
2250 let (code, out) = run_cmd(&db, &Command::Verify, false, 2_000);
2251 assert_eq!(code, 0);
2252 assert_eq!(out.trim(), "integrity ok");
2253
2254 let (code, out) = run_cmd(&db, &Command::Verify, true, 2_100);
2255 assert_eq!(code, 0);
2256 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2257 assert_eq!(v["ok"], true);
2258 }
2259
2260 #[test]
2261 fn show_json_of_a_revised_predecessor_is_closed() {
2262 let (db, _t) = TempDb::open();
2263 run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
2264 let revise = Command::Revise {
2265 id: 0,
2266 text: "v2".into(),
2267 entity: Some("e".into()),
2268 tags: Vec::new(),
2269 links: Vec::new(),
2270 meta: Vec::new(),
2271 valid_from: None,
2272 vector: Vec::new(),
2273 };
2274 run_cmd(&db, &revise, false, 2_000);
2275 let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
2277 assert!(out.contains("revises fact 0"), "{out}");
2278 }
2279
2280 #[test]
2281 fn resolve_db_path_prefers_the_flag() {
2282 let p = "/tmp/explicit.plugmem";
2283 assert_eq!(resolve_db_path(Some(p), None, None), PathBuf::from(p));
2284 let configured = std::path::Path::new("/tmp/configured.plugmem");
2285 assert_eq!(
2286 resolve_db_path(None, Some(configured), None),
2287 PathBuf::from(configured)
2288 );
2289 let _ = resolve_db_path(None, None, None);
2292 }
2293
2294 #[test]
2295 fn a_bare_name_is_a_memory_only_when_a_workspace_is_configured() {
2296 let root = PathBuf::from("/srv/bot");
2297
2298 assert_eq!(
2301 resolve_db_path(Some("work"), None, None),
2302 PathBuf::from("work")
2303 );
2304
2305 assert_eq!(
2307 resolve_db_path(Some("work"), None, Some(&root)),
2308 PathBuf::from("/srv/bot/db/work.plugmem")
2309 );
2310 for path in ["./work", "work.plugmem", "/srv/other.plugmem", "../up"] {
2313 assert_eq!(
2314 resolve_db_path(Some(path), None, Some(&root)),
2315 PathBuf::from(path),
2316 "{path}"
2317 );
2318 }
2319
2320 let configured = std::path::Path::new("work");
2322 assert_eq!(
2323 resolve_db_path(None, Some(configured), Some(&root)),
2324 PathBuf::from("work")
2325 );
2326 }
2327
2328 #[test]
2329 fn settings_help_runs_without_opening_a_database() {
2330 let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
2331 let mut output = Vec::new();
2332 assert_eq!(run_parsed(cli, &mut output), 0);
2333 let output = String::from_utf8(output).unwrap();
2334 assert!(output.contains("plugmem settings"));
2335 assert!(output.contains("[database]"));
2336 assert!(output.contains("path (path string"));
2337
2338 let cli = Cli::try_parse_from(["plugmem-cli", "--json", "help", "settings"]).unwrap();
2339 let mut output = Vec::new();
2340 assert_eq!(run_parsed(cli, &mut output), 0);
2341 let output: serde_json::Value = serde_json::from_slice(&output).unwrap();
2342 assert_eq!(output["topic"], "settings");
2343 assert!(output["config_path_precedence"].is_array());
2344 assert!(output["settings"].as_array().unwrap().len() > 10);
2345 }
2346
2347 #[test]
2348 fn run_parsed_opens_runs_and_reports() {
2349 let dir = std::env::temp_dir().join(format!(
2350 "plugmem-run-{}-{}",
2351 std::process::id(),
2352 now_ms_unique()
2353 ));
2354 std::fs::create_dir_all(&dir).unwrap();
2355 let path = dir.join("m.plugmem");
2356 let cli = Cli {
2357 db: Some(path.display().to_string()),
2358 workspace: None,
2359 config: None,
2360 json: false,
2361 command: Command::Stats,
2362 };
2363 let mut buf = Vec::new();
2364 let code = run_parsed(cli, &mut buf);
2365 assert_eq!(code, 0);
2366 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2367 let _ = std::fs::remove_dir_all(&dir);
2368 }
2369
2370 #[test]
2371 fn recover_and_scrub_render_json_and_human_shapes() {
2372 let (db, tmp) = TempDb::open();
2373 let path = tmp.0.join("m.plugmem");
2374 run_cmd(&db, &remember("recoverable fact", None, &[]), false, 1_000);
2375 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2376 drop(db);
2377
2378 let settings = settings_with(None);
2379 let mut out = Vec::new();
2380 assert_eq!(do_scrub(&path, &settings, true, &mut out), 0);
2381 let scrub: serde_json::Value = serde_json::from_slice(&out).unwrap();
2382 assert_eq!(scrub["ok"], true);
2383 assert!(scrub["bytes"].as_u64().unwrap() > 0);
2384 let mut out = Vec::new();
2385 assert_eq!(do_scrub(&path, &settings, false, &mut out), 0);
2386 let out = String::from_utf8(out).unwrap();
2387 assert!(out.contains("scrub ok:"), "{out}");
2388
2389 let json_dst = tmp.0.join("copy-json.plugmem");
2390 let mut out = Vec::new();
2391 assert_eq!(do_recover(&path, &json_dst, &settings, true, &mut out), 0);
2392 let recover: serde_json::Value = serde_json::from_slice(&out).unwrap();
2393 assert_eq!(recover["kept"], 1);
2394 assert_eq!(recover["dropped_text"], 0);
2395 assert_eq!(recover["dst"], json_dst.display().to_string());
2396
2397 let human_dst = tmp.0.join("copy-human.plugmem");
2398 let mut out = Vec::new();
2399 assert_eq!(do_recover(&path, &human_dst, &settings, false, &mut out), 0);
2400 let out = String::from_utf8(out).unwrap();
2401 assert!(out.contains("recovered to"), "{out}");
2402 assert!(out.contains("kept 1"), "{out}");
2403 }
2404
2405 #[test]
2406 fn readonly_dispatcher_renders_every_read_shape() {
2407 let (db, tmp) = TempDb::open();
2408 let path = tmp.0.join("m.plugmem");
2409 run_cmd(
2410 &db,
2411 &remember("readonly tokio fact", Some("plugmem"), &["pref"]),
2412 false,
2413 1_000,
2414 );
2415 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2416 let ro = Database::open_readonly(&path, Config::default()).unwrap();
2417
2418 let mut out = Vec::new();
2419 assert_eq!(execute_ro(&ro, &Command::Stats, None, true, &mut out), 0);
2420 let stats: serde_json::Value = serde_json::from_slice(&out).unwrap();
2421 assert_eq!(stats["facts"], 1);
2422
2423 let mut out = Vec::new();
2424 assert_eq!(
2425 execute_ro(&ro, &Command::Show { id: 0 }, None, false, &mut out),
2426 0
2427 );
2428 let text = String::from_utf8(out).unwrap();
2429 assert!(text.contains("readonly tokio fact"), "{text}");
2430
2431 let mut out = Vec::new();
2432 assert_eq!(execute_ro(&ro, &Command::Export, None, false, &mut out), 0);
2433 let exported: serde_json::Value =
2434 serde_json::from_str(String::from_utf8(out).unwrap().lines().next().unwrap()).unwrap();
2435 assert_eq!(exported["text"], "readonly tokio fact");
2436
2437 let mut out = Vec::new();
2438 let recall = Command::Recall {
2439 query: Some("tokio".into()),
2440 tags: vec!["pref".into()],
2441 entities: vec!["plugmem".into()],
2442 as_of: None,
2443 range: None,
2444 k: 1,
2445 closed: false,
2446 token_budget: None,
2447 ef: None,
2448 graph_depth: None,
2449 vector: Vec::new(),
2450 };
2451 assert_eq!(execute_ro(&ro, &recall, None, false, &mut out), 0);
2452 let text = String::from_utf8(out).unwrap();
2453 assert!(text.contains("tokio"), "{text}");
2454
2455 let mut out = Vec::new();
2456 assert_eq!(execute_ro(&ro, &Command::Verify, None, true, &mut out), 0);
2457 let verify: serde_json::Value = serde_json::from_slice(&out).unwrap();
2458 assert_eq!(verify["ok"], true);
2459 }
2460
2461 #[test]
2462 fn run_parsed_on_a_locked_database_returns_one() {
2463 let (_held, dir) = {
2464 let dir = std::env::temp_dir().join(format!(
2465 "plugmem-lock-{}-{}",
2466 std::process::id(),
2467 now_ms_unique()
2468 ));
2469 std::fs::create_dir_all(&dir).unwrap();
2470 let path = dir.join("m.plugmem");
2471 (Database::open(&path, Config::default()).unwrap(), dir)
2472 };
2473 let cli = Cli {
2474 db: Some(dir.join("m.plugmem").display().to_string()),
2475 workspace: None,
2476 config: None,
2477 json: false,
2478 command: Command::Stats,
2479 };
2480 let mut buf = Vec::new();
2481 assert_eq!(run_parsed(cli, &mut buf), 1);
2482 let _ = std::fs::remove_dir_all(&dir);
2483 }
2484
2485 #[test]
2486 fn run_parsed_propagates_a_usage_error_as_two() {
2487 let dir = std::env::temp_dir().join(format!(
2488 "plugmem-usage-{}-{}",
2489 std::process::id(),
2490 now_ms_unique()
2491 ));
2492 std::fs::create_dir_all(&dir).unwrap();
2493 let cli = Cli {
2494 db: Some(dir.join("m.plugmem").display().to_string()),
2495 workspace: None,
2496 config: None,
2497 json: false,
2498 command: Command::Remember {
2499 text: "x".into(),
2500 entity: None,
2501 tags: Vec::new(),
2502 links: vec!["bad".into()],
2503 meta: Vec::new(),
2504 valid_from: None,
2505 vector: Vec::new(),
2506 },
2507 };
2508 let mut buf = Vec::new();
2509 assert_eq!(run_parsed(cli, &mut buf), 2);
2510 let _ = std::fs::remove_dir_all(&dir);
2511 }
2512
2513 struct Scratch(PathBuf);
2515 impl Scratch {
2516 fn new(tag: &str) -> Self {
2517 let dir = std::env::temp_dir().join(format!(
2518 "plugmem-cli-{tag}-{}-{}",
2519 std::process::id(),
2520 now_ms_unique()
2521 ));
2522 std::fs::create_dir_all(&dir).unwrap();
2523 Scratch(dir)
2524 }
2525 }
2526 impl Drop for Scratch {
2527 fn drop(&mut self) {
2528 let _ = std::fs::remove_dir_all(&self.0);
2529 }
2530 }
2531
2532 #[test]
2533 fn export_import_roundtrip_preserves_open_facts() {
2534 let (a, _ta) = TempDb::open();
2539 run_cmd(
2540 &a,
2541 &Command::Remember {
2542 text: "prefers tokio".into(),
2543 entity: Some("user".into()),
2544 tags: vec!["pref".into(), "lang".into()],
2545 links: Vec::new(),
2546 meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
2547 valid_from: Some(500),
2548 vector: Vec::new(),
2549 },
2550 false,
2551 1_000,
2552 );
2553 run_cmd(
2554 &a,
2555 &remember("lives in Moscow", Some("user"), &[]),
2556 false,
2557 1_100,
2558 ); run_cmd(
2560 &a,
2561 &Command::Revise {
2562 id: 1,
2563 text: "lives in Berlin".into(),
2564 entity: Some("user".into()),
2565 tags: vec!["geo".into()],
2566 links: Vec::new(),
2567 meta: Vec::new(),
2568 valid_from: None,
2569 vector: Vec::new(),
2570 },
2571 false,
2572 1_200,
2573 ); run_cmd(&a, &remember("junk", None, &[]), false, 1_300); run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); run_cmd(
2577 &a,
2578 &remember("uses rust", Some("plugmem"), &["lang"]),
2579 false,
2580 1_500,
2581 ); let mut dump = Vec::new();
2585 render_export(&a.export(), false, &mut dump);
2586 let scratch = Scratch::new("roundtrip");
2587 let file = scratch.0.join("dump.jsonl");
2588 std::fs::write(&file, &dump).unwrap();
2589
2590 let (b, _tb) = TempDb::open();
2592 let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
2593
2594 let key = |f: &ExportedFact| {
2596 let mut tags = f.tags.clone();
2597 tags.sort();
2598 (f.text.clone(), f.entity.clone(), tags, f.valid_from)
2599 };
2600 let mut ak: Vec<_> = a.export().iter().map(key).collect();
2601 let mut bk: Vec<_> = b.export().iter().map(key).collect();
2602 ak.sort();
2603 bk.sort();
2604 assert_eq!(n.facts, ak.len());
2605 assert_eq!(
2606 ak, bk,
2607 "roundtrip must preserve text/entity/tags/valid_from"
2608 );
2609
2610 let b_open = b.export();
2613 assert!(b_open.iter().any(|f| f.text == "prefers tokio"
2614 && f.valid_from == 500
2615 && f.entity.as_deref() == Some("user")
2616 && f.tags == vec!["pref".to_string(), "lang".to_string()]
2617 && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
2618 && f.metadata.get("src").map(String::as_str) == Some("chat")));
2619 assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
2620 assert!(b_open.iter().any(|f| f.text == "uses rust"));
2621 assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
2622 assert!(!b_open.iter().any(|f| f.text == "junk"));
2623 }
2624
2625 #[test]
2626 fn export_command_emits_jsonl_regardless_of_json_flag() {
2627 let (db, _t) = TempDb::open();
2628 run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
2629 for json in [false, true] {
2630 let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
2631 assert_eq!(code, 0);
2632 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2633 assert_eq!(v["text"], "a fact");
2634 assert_eq!(v["entity"], "e");
2635 assert_eq!(v["tags"][0], "t");
2636 }
2637 }
2638
2639 #[test]
2640 fn import_command_counts_and_rejects_bad_lines() {
2641 let (db, _t) = TempDb::open();
2642 let scratch = Scratch::new("import");
2643 let good = scratch.0.join("in.jsonl");
2644 std::fs::write(
2645 &good,
2646 "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
2647 )
2648 .unwrap();
2649 let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
2651 assert_eq!(n.facts, 2, "both facts imported, blank line skipped");
2652
2653 let bad = scratch.0.join("bad.jsonl");
2654 std::fs::write(&bad, "not json at all\n").unwrap();
2655 let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
2656 assert!(matches!(err, CliError::Usage(_)));
2657 }
2658
2659 #[test]
2660 fn import_batch_size_does_not_change_the_result() {
2661 let scratch = Scratch::new("import-batch");
2664 let file = scratch.0.join("facts.jsonl");
2665 let mut jsonl = String::new();
2666 for i in 0..5 {
2667 jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
2668 }
2669 std::fs::write(&file, &jsonl).unwrap();
2670
2671 let (a, _ta) = TempDb::open();
2672 let (b, _tb) = TempDb::open();
2673 let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
2674 let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
2675
2676 assert_eq!(na.facts, 5);
2677 assert_eq!(nb.facts, 5);
2678 let texts = |db: &Database| {
2679 let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
2680 t.sort();
2681 t
2682 };
2683 assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
2684 }
2685
2686 #[test]
2687 fn config_table_feeds_settings_and_the_cli_batch_size() {
2688 let scratch = Scratch::new("settings");
2692 let cfgfile = scratch.0.join("config.toml");
2693 std::fs::write(
2694 &cfgfile,
2695 "[engine]\ndim = 512\n[embedder]\nenabled = false\n\
2696 [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
2697 )
2698 .unwrap();
2699 let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
2700 let s = Settings::from_table(table.as_ref()).unwrap();
2701 assert_eq!(s.config.dim, 512);
2702 assert!(s.embedder.is_none());
2703 assert_eq!(s.snapshot_every_ops, Some(64));
2704 assert_eq!(read_batch_size(table.as_ref()), Some(200));
2705
2706 assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
2708 }
2709
2710 #[test]
2711 fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
2712 let scratch = Scratch::new("checkpoint-cmd");
2713 let path = scratch.0.join("m.plugmem");
2714
2715 let remember = Cli {
2717 db: Some(path.display().to_string()),
2718 workspace: None,
2719 config: None,
2720 json: false,
2721 command: Command::Remember {
2722 text: "hello tokio".into(),
2723 entity: None,
2724 tags: Vec::new(),
2725 links: Vec::new(),
2726 meta: Vec::new(),
2727 valid_from: None,
2728 vector: Vec::new(),
2729 },
2730 };
2731 assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
2732
2733 let checkpoint = |json| Cli {
2735 db: Some(path.display().to_string()),
2736 workspace: None,
2737 config: None,
2738 json,
2739 command: Command::Checkpoint,
2740 };
2741 let mut buf = Vec::new();
2742 assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
2743 assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
2744
2745 let mut buf = Vec::new();
2747 assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
2748 let v: serde_json::Value =
2749 serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
2750 assert_eq!(v["ok"], true);
2751
2752 let scrub = Cli {
2755 db: Some(path.display().to_string()),
2756 workspace: None,
2757 config: None,
2758 json: false,
2759 command: Command::Scrub,
2760 };
2761 let mut buf = Vec::new();
2762 assert_eq!(run_parsed(scrub, &mut buf), 0);
2763 assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
2764 }
2765
2766 #[test]
2767 fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
2768 let scratch = Scratch::new("ro-route");
2769 let path = scratch.0.join("m.plugmem");
2770 {
2771 let (db, _) = Database::open(&path, Config::default()).unwrap();
2772 db.remember(RememberInput::text(1_000, "hello tokio"))
2773 .unwrap();
2774 db.checkpoint(2_000).unwrap(); }
2776 let cli = Cli {
2778 db: Some(path.display().to_string()),
2779 workspace: None,
2780 config: None,
2781 json: false,
2782 command: Command::Stats,
2783 };
2784 let mut buf = Vec::new();
2785 assert_eq!(run_parsed(cli, &mut buf), 0);
2786 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2787
2788 let cli = Cli {
2790 db: Some(path.display().to_string()),
2791 workspace: None,
2792 config: None,
2793 json: false,
2794 command: Command::Recall {
2795 query: Some("tokio".into()),
2796 tags: Vec::new(),
2797 entities: Vec::new(),
2798 as_of: None,
2799 range: None,
2800 k: 0,
2801 closed: false,
2802 token_budget: None,
2803 ef: None,
2804 graph_depth: None,
2805 vector: Vec::new(),
2806 },
2807 };
2808 let mut buf = Vec::new();
2809 assert_eq!(run_parsed(cli, &mut buf), 0);
2810 assert!(String::from_utf8(buf).unwrap().contains("tokio"));
2811 }
2812}