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