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