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 } => {
362 let outcome = do_remember(db, now, text, entity, tags, links, meta, *valid_from, None)?;
363 render_remember(&outcome, json, out);
364 Ok(0)
365 }
366 Command::Revise {
367 id,
368 text,
369 entity,
370 tags,
371 links,
372 meta,
373 valid_from,
374 } => {
375 let outcome = do_remember(
376 db,
377 now,
378 text,
379 entity,
380 tags,
381 links,
382 meta,
383 *valid_from,
384 Some(FactId(*id)),
385 )?;
386 render_remember(&outcome, json, out);
387 Ok(0)
388 }
389 Command::Recall { .. } => {
390 let res = with_recall_query(cmd, now, None, |q| db.recall(q))?;
391 render_recall(&res, json, out);
392 Ok(0)
393 }
394 Command::Forget { id } => {
395 let fresh = db.forget(now, FactId(*id))?;
396 if json {
397 writeln!(out, "{}", json!({ "id": id, "forgotten": fresh })).ok();
398 } else if fresh {
399 writeln!(out, "forgot fact {id}").ok();
400 } else {
401 writeln!(out, "fact {id} was already gone").ok();
402 }
403 Ok(0)
404 }
405 Command::Link { src, rel, dst } => {
406 db.link(LinkInput {
407 now,
408 src,
409 rel,
410 dst,
411 provenance: None,
412 })?;
413 if json {
414 writeln!(out, "{}", json!({ "src": src, "rel": rel, "dst": dst })).ok();
415 } else {
416 writeln!(out, "linked {src} -{rel}-> {dst}").ok();
417 }
418 Ok(0)
419 }
420 Command::Unlink { src, rel, dst } => {
421 let fresh = db.unlink(UnlinkInput { now, src, rel, dst })?;
422 if json {
423 writeln!(
424 out,
425 "{}",
426 json!({ "src": src, "rel": rel, "dst": dst, "unlinked": fresh })
427 )
428 .ok();
429 } else if fresh {
430 writeln!(out, "unlinked {src} -{rel}-> {dst}").ok();
431 } else {
432 writeln!(out, "edge {src} -{rel}-> {dst} was already absent").ok();
433 }
434 Ok(0)
435 }
436 Command::Show { id } => Ok(render_show(db.get(FactId(*id)), *id, json, out)),
437 Command::Stats => {
438 render_stats(&db.stats(), json, out);
439 Ok(0)
440 }
441 Command::Export => {
442 db.export_each(|f| write_export_line(out, &f));
443 Ok(0)
444 }
445 Command::Maintain { mode } => {
446 let report = db.maintain_with_options(now, maintenance_options(*mode))?;
447 if json {
448 writeln!(
449 out,
450 "{}",
451 json!({
452 "purged": report.purged,
453 "bytes_before": report.bytes_before,
454 "bytes_after": report.bytes_after,
455 "no_op": report.no_op,
456 "tombstones_before": report.tombstones_before,
457 "facts_before": report.facts_before,
458 "facts_after": report.facts_after,
459 "vectors_before": report.vectors_before,
460 "vectors_after": report.vectors_after,
461 "hnsw_indexed_before": report.hnsw_indexed_before,
462 "hnsw_indexed_after": report.hnsw_indexed_after,
463 "structural_compacted": report.structural_compacted,
464 "bm25_compacted": report.bm25_compacted,
465 "bm25_reindexed": report.bm25_reindexed,
466 "hnsw_rebuilt": report.hnsw_rebuilt,
467 "hnsw_remapped": report.hnsw_remapped,
468 "hnsw_inserted": report.hnsw_inserted,
469 "edges_compacted": report.edges_compacted,
470 "edges_before": report.edges_before,
471 "edge_versions_before": report.edge_versions_before,
472 })
473 )
474 .ok();
475 } else {
476 writeln!(
477 out,
478 "maintained: purged {}, {} -> {} bytes, hnsw +{}, bm25 {}{}{}",
479 report.purged,
480 report.bytes_before,
481 report.bytes_after,
482 report.hnsw_inserted,
483 if report.bm25_reindexed {
484 "reindexed"
485 } else if report.bm25_compacted {
486 "compacted"
487 } else {
488 "unchanged"
489 },
490 if report.edges_compacted {
491 ", edges repacked"
492 } else {
493 ""
494 },
495 if report.no_op { " (no-op)" } else { "" }
496 )
497 .ok();
498 }
499 Ok(0)
500 }
501 Command::Checkpoint => {
502 db.checkpoint(now)?;
503 if json {
504 writeln!(out, "{}", json!({ "ok": true })).ok();
505 } else {
506 writeln!(out, "checkpointed: journal flushed to snapshot").ok();
507 }
508 Ok(0)
509 }
510 Command::Verify => {
511 db.verify()?;
514 if json {
515 writeln!(out, "{}", json!({ "ok": true })).ok();
516 } else {
517 writeln!(out, "integrity ok").ok();
518 }
519 Ok(0)
520 }
521 Command::Scrub
523 | Command::Recover { .. }
524 | Command::Repl { .. }
525 | Command::Import { .. }
526 | Command::Workspace { .. }
527 | Command::Help { .. } => {
528 unreachable!("this command is dispatched before execute")
529 }
530 }
531}
532
533fn do_recover(src: &Path, dst: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
537 match Database::recover(src, dst, settings.config.clone(), now_ms()) {
538 Ok(r) => {
539 if json {
540 writeln!(
541 out,
542 "{}",
543 json!({
544 "kept": r.kept,
545 "dropped_text": r.dropped_text,
546 "dropped_vector": r.dropped_vector,
547 "dropped_metadata": r.dropped_metadata,
548 "dst": dst.display().to_string(),
549 })
550 )
551 .ok();
552 } else {
553 writeln!(
554 out,
555 "recovered to {}: kept {}, dropped {} text + {} vector + {} metadata",
556 dst.display(),
557 r.kept,
558 r.dropped_text,
559 r.dropped_vector,
560 r.dropped_metadata
561 )
562 .ok();
563 }
564 0
565 }
566 Err(HostError::Locked { path }) => report_locked(&path),
567 Err(e) => report_err(&CliError::Host(e)),
568 }
569}
570
571fn do_scrub(path: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
576 let ro = match Database::open_readonly(path, settings.config.clone()) {
577 Ok(ro) => ro,
578 Err(HostError::Locked { path }) => return report_locked(&path),
579 Err(e) => return report_err(&CliError::Host(e)),
580 };
581 let scrub = match ro.scrub() {
582 Ok(s) => s,
583 Err(e) => return report_err(&CliError::Host(e)),
584 };
585 let mut done = 0u64;
586 let mut total = 0u64;
587 for step in scrub {
588 match step {
589 Ok(p) => {
590 done = p.done_bytes;
591 total = p.total_bytes;
592 }
593 Err(e) => return report_err(&CliError::Host(e)),
594 }
595 }
596 if json {
597 writeln!(out, "{}", json!({ "ok": true, "bytes": done })).ok();
598 } else {
599 writeln!(out, "scrub ok: {done}/{total} bytes verified").ok();
600 }
601 0
602}
603
604#[derive(Parser)]
607#[command(
608 no_binary_name = true,
609 name = "plugmem",
610 disable_help_subcommand = true
611)]
612struct ReplLine {
613 #[command(subcommand)]
614 command: Command,
615}
616
617fn split_line(line: &str) -> Vec<String> {
621 let mut tokens = Vec::new();
622 let mut cur = String::new();
623 let mut quote: Option<char> = None;
624 let mut has = false;
625 for c in line.chars() {
626 match quote {
627 Some(q) => {
628 if c == q {
629 quote = None;
630 } else {
631 cur.push(c);
632 }
633 }
634 None if c == '"' || c == '\'' => {
635 quote = Some(c);
636 has = true;
637 }
638 None if c.is_whitespace() => {
639 if has {
640 tokens.push(std::mem::take(&mut cur));
641 has = false;
642 }
643 }
644 None => {
645 cur.push(c);
646 has = true;
647 }
648 }
649 }
650 if has {
651 tokens.push(cur);
652 }
653 tokens
654}
655
656fn run_repl(
662 path: &Path,
663 settings: Settings,
664 json: bool,
665 input: impl BufRead,
666 out: &mut impl Write,
667) -> u8 {
668 let db = match settings.open(path) {
669 Ok(db) => db,
670 Err(HostError::Locked { path }) => return report_locked(&path),
671 Err(e) => return report_err(&CliError::Host(e)),
672 };
673 eprintln!("plugmem repl — one open handle, host speed. `help` for verbs, `exit` to quit.");
674 eprint!("plugmem> ");
675 for line in input.lines() {
676 let Ok(line) = line else { break };
677 let line = line.trim();
678 if line.is_empty() {
679 eprint!("plugmem> ");
680 continue;
681 }
682 if line == "exit" || line == "quit" {
683 break;
684 } else if line == "help" {
685 writeln!(
686 out,
687 "verbs: remember recall revise forget link unlink show stats maintain checkpoint \
688 verify export import (scrub/recover stay one-shot) exit"
689 )
690 .ok();
691 } else {
692 run_repl_line(&db, line, json, out);
693 }
694 eprint!("plugmem> ");
695 }
696 eprintln!();
697 match db.checkpoint(now_ms()) {
699 Ok(()) => 0,
700 Err(e) => report_err(&CliError::Host(e)),
701 }
702}
703
704fn run_repl_line(db: &Database, line: &str, json: bool, out: &mut impl Write) {
707 let cmd = match ReplLine::try_parse_from(split_line(line)) {
708 Ok(r) => r.command,
709 Err(e) => {
711 let _ = writeln!(out, "{e}");
712 return;
713 }
714 };
715 match &cmd {
716 Command::Repl { .. } => {
717 let _ = writeln!(out, "already in a repl session");
718 }
719 Command::Scrub | Command::Recover { .. } => {
720 let _ = writeln!(
721 out,
722 "scrub/recover are one-shot commands; run them outside the repl"
723 );
724 }
725 _ => {
726 if let Err(e) = execute(db, &cmd, json, now_ms(), out) {
727 write_err(out, &e);
728 }
729 }
730 }
731}
732
733fn run_repl_ro(
746 path: &Path,
747 mut settings: Settings,
748 json: bool,
749 input: impl BufRead,
750 out: &mut impl Write,
751) -> u8 {
752 let mut ro = match Database::open_readonly(path, settings.config.clone()) {
753 Ok(ro) => ro,
754 Err(HostError::Locked { path }) => return report_locked(&path),
755 Err(e) => return report_err(&CliError::Host(e)),
758 };
759 eprintln!(
760 "plugmem repl --read-only — observing generation {} of another process's writer. \
761 `help` for verbs, `refresh`/`generation` for cross-process freshness, `exit` to quit.",
762 ro.generation()
763 );
764 eprint!("plugmem(ro)> ");
765 for line in input.lines() {
766 let Ok(line) = line else { break };
767 let line = line.trim();
768 if line.is_empty() {
769 eprint!("plugmem(ro)> ");
770 continue;
771 }
772 match line {
773 "exit" | "quit" => break,
774 "help" => {
775 writeln!(
776 out,
777 "read verbs: recall show stats export verify \
778 freshness: generation refresh exit \
779 (writes and scrub/recover are refused in a read-only session)"
780 )
781 .ok();
782 }
783 "generation" => {
786 let g = ro.generation();
787 if json {
788 writeln!(out, "{}", json!({ "generation": g })).ok();
789 } else {
790 writeln!(out, "generation {g}").ok();
791 }
792 }
793 "refresh" => match ro.refresh() {
794 Ok(advanced) => {
795 let g = ro.generation();
796 if json {
797 writeln!(out, "{}", json!({ "advanced": advanced, "generation": g })).ok();
798 } else if advanced {
799 writeln!(out, "refreshed → generation {g}").ok();
800 } else {
801 writeln!(out, "already current → generation {g}").ok();
802 }
803 }
804 Err(e) => write_err(out, &CliError::Host(e)),
805 },
806 _ => run_repl_ro_line(&ro, &mut settings, line, json, out),
807 }
808 eprint!("plugmem(ro)> ");
809 }
810 eprintln!();
811 0
813}
814
815fn run_repl_ro_line(
818 ro: &ReadOnlyDatabase,
819 settings: &mut Settings,
820 line: &str,
821 json: bool,
822 out: &mut impl Write,
823) {
824 let cmd = match ReplLine::try_parse_from(split_line(line)) {
825 Ok(r) => r.command,
826 Err(e) => {
827 let _ = writeln!(out, "{e}");
828 return;
829 }
830 };
831 let readable = matches!(
832 &cmd,
833 Command::Show { .. }
834 | Command::Stats
835 | Command::Export
836 | Command::Verify
837 | Command::Recall { .. }
838 );
839 if !readable {
840 let _ = writeln!(
841 out,
842 "read-only session: only recall/show/stats/export/verify run \
843 (plus refresh/generation); writes and one-shot commands need a writer handle"
844 );
845 return;
846 }
847 let recall_vector = match embed_recall_query(settings, &cmd) {
850 Ok(v) => v,
851 Err(e) => {
852 write_err(out, &e);
853 return;
854 }
855 };
856 let _ = execute_ro(ro, &cmd, recall_vector.as_deref(), json, out);
857}
858
859fn embed_recall_query(
867 settings: &mut Settings,
868 cmd: &Command,
869) -> Result<Option<Vec<f32>>, CliError> {
870 let Command::Recall {
871 query: Some(text), ..
872 } = cmd
873 else {
874 return Ok(None);
875 };
876 let Some(embedder) = settings.embedder.as_mut() else {
877 return Ok(None);
878 };
879 let mut vectors = embedder.embed(&[text.as_str()]).map_err(CliError::Host)?;
880 Ok(vectors.pop())
881}
882
883fn with_recall_query<R>(
888 cmd: &Command,
889 now: u64,
890 override_vector: Option<&[f32]>,
891 f: impl FnOnce(RecallQuery<'_>) -> R,
892) -> R {
893 let Command::Recall {
894 query,
895 tags,
896 entities,
897 as_of,
898 range,
899 k,
900 closed,
901 } = cmd
902 else {
903 unreachable!("with_recall_query called on a non-recall command");
904 };
905 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
906 let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
907 let range_pair = range.as_ref().map(|v| (v[0], v[1]));
908 let q = RecallQuery {
912 now,
913 text: query.as_deref(),
914 vector: override_vector,
915 tags: &tag_refs,
916 entities: &ent_refs,
917 as_of: *as_of,
918 range: range_pair,
919 k: *k,
920 token_budget: None,
921 include_closed: *closed,
922 ef: None,
923 };
924 f(q)
925}
926
927fn render_recall(res: &RecallResult, json: bool, out: &mut impl Write) {
930 if json {
931 let facts: Vec<_> = res
932 .facts
933 .iter()
934 .map(|f| {
935 json!({
936 "id": f.id.0,
937 "score": f.score,
938 "sources": f.sources,
939 "recorded_at": f.recorded_at,
940 "valid_from": f.valid_from,
941 "valid_to": open_or(f.valid_to),
942 })
943 })
944 .collect();
945 writeln!(
946 out,
947 "{}",
948 json!({ "facts": facts, "rendered": res.rendered, "truncated": res.truncated })
949 )
950 .ok();
951 } else if res.rendered.is_empty() {
952 writeln!(out, "(nothing recalled)").ok();
953 } else {
954 writeln!(out, "{}", res.rendered).ok();
955 }
956}
957
958fn render_show(
960 fact: Option<plugmem_host::FactSnapshot>,
961 id: u32,
962 json: bool,
963 out: &mut impl Write,
964) -> u8 {
965 let Some(fact) = fact else {
966 if json {
967 writeln!(out, "{}", json!({ "id": id, "found": false })).ok();
968 } else {
969 writeln!(out, "fact {id} not found").ok();
970 }
971 return 1;
972 };
973 let r = &fact.record;
974 if json {
975 writeln!(
976 out,
977 "{}",
978 json!({
979 "id": r.id.0,
980 "text": fact.text,
981 "recorded_at": r.recorded_at,
982 "valid_from": r.valid_from,
983 "valid_to": open_or(r.valid_to),
984 "closed": r.is_closed(),
985 "tombstone": r.is_tombstone(),
986 "revises": (r.revises != FactId::NONE).then_some(r.revises.0),
987 "metadata": fact.metadata,
988 })
989 )
990 .ok();
991 } else {
992 writeln!(out, "fact {}", r.id.0).ok();
993 writeln!(out, " text {}", fact.text).ok();
994 writeln!(out, " recorded_at {}", r.recorded_at).ok();
995 write!(out, " valid [{}, ", r.valid_from).ok();
996 match r.valid_to {
997 VALID_TO_OPEN => writeln!(out, "open)").ok(),
998 to => writeln!(out, "{to})").ok(),
999 };
1000 if r.revises != FactId::NONE {
1001 writeln!(out, " revises fact {}", r.revises.0).ok();
1002 }
1003 if !fact.metadata.is_empty() {
1004 let rendered = fact
1005 .metadata
1006 .iter()
1007 .map(|(k, v)| format!("{k}={v}"))
1008 .collect::<Vec<_>>()
1009 .join(", ");
1010 writeln!(out, " metadata {rendered}").ok();
1011 }
1012 if r.is_tombstone() {
1013 writeln!(out, " state tombstoned").ok();
1014 }
1015 }
1016 0
1017}
1018
1019fn maintenance_options(mode: MaintainMode) -> MaintenanceOptions {
1024 match MaintenanceMode::from(mode) {
1025 MaintenanceMode::Auto => MaintenanceOptions::auto(),
1026 MaintenanceMode::Full => MaintenanceOptions::full(),
1027 mode => MaintenanceOptions {
1028 mode,
1029 ..MaintenanceOptions::auto()
1030 },
1031 }
1032}
1033
1034fn render_stats(s: &Stats, json: bool, out: &mut impl Write) {
1036 if json {
1037 writeln!(
1038 out,
1039 "{}",
1040 json!({
1041 "facts": s.facts,
1042 "entities": s.entities,
1043 "terms": s.terms,
1044 "edges": s.edges,
1045 "edge_versions": s.edge_versions,
1046 "vectors": s.vectors,
1047 "hnsw_indexed": s.hnsw_indexed,
1048 "next_fact": s.next_fact,
1049 "next_entity": s.next_entity,
1050 "next_edge": s.next_edge,
1051 "pool_bytes": s.pool_bytes,
1052 "shards": {
1053 "facts": s.shards.facts,
1054 "entities": s.shards.entities,
1055 "edges": s.shards.edges,
1056 "temporal": s.shards.temporal,
1057 "postings": s.shards.postings,
1058 },
1059 })
1060 )
1061 .ok();
1062 } else {
1063 writeln!(out, "facts {}", s.facts).ok();
1064 writeln!(out, "entities {}", s.entities).ok();
1065 writeln!(out, "terms {}", s.terms).ok();
1066 writeln!(out, "edges {}", s.edges).ok();
1067 writeln!(out, "edge_vers {}", s.edge_versions).ok();
1068 writeln!(out, "vectors {}", s.vectors).ok();
1069 writeln!(out, "hnsw_idx {}", s.hnsw_indexed).ok();
1070 writeln!(out, "next_fact {}", s.next_fact).ok();
1071 writeln!(out, "next_edge {}", s.next_edge).ok();
1072 writeln!(out, "pool_bytes {}", s.pool_bytes).ok();
1073 writeln!(
1076 out,
1077 "shards facts {} entities {} edges {} temporal {} postings {}",
1078 s.shards.facts, s.shards.entities, s.shards.edges, s.shards.temporal, s.shards.postings,
1079 )
1080 .ok();
1081 }
1082}
1083
1084fn write_export_line(out: &mut impl Write, f: &ExportedFact) {
1087 writeln!(
1088 out,
1089 "{}",
1090 json!({
1091 "text": f.text,
1092 "entity": f.entity,
1093 "tags": f.tags,
1094 "metadata": f.metadata,
1095 "recorded_at": f.recorded_at,
1096 "valid_from": f.valid_from,
1097 })
1098 )
1099 .ok();
1100}
1101
1102#[cfg(test)]
1105fn render_export(facts: &[ExportedFact], _json: bool, out: &mut impl Write) {
1106 for f in facts {
1107 write_export_line(out, f);
1108 }
1109}
1110
1111fn do_import(
1118 db: &Database,
1119 now: u64,
1120 file: &std::path::Path,
1121 batch_size: usize,
1122 _out: &mut impl Write,
1123) -> Result<usize, CliError> {
1124 let f = std::fs::File::open(file)
1125 .map_err(|e| CliError::Usage(format!("reading {}: {e}", file.display())))?;
1126 let reader = io::BufReader::new(f);
1127 let mut count = 0usize;
1128 let mut batch: Vec<ParsedFact> = Vec::with_capacity(batch_size);
1129 for (i, line) in reader.lines().enumerate() {
1130 let line = line.map_err(|e| CliError::Usage(format!("line {}: {e}", i + 1)))?;
1131 let line = line.trim();
1132 if line.is_empty() {
1133 continue;
1134 }
1135 batch.push(parse_import_line(line, i + 1)?);
1136 if batch.len() >= batch_size {
1137 count += flush_import_batch(db, now, &batch)?;
1138 batch.clear();
1139 }
1140 }
1141 count += flush_import_batch(db, now, &batch)?;
1142 Ok(count)
1143}
1144
1145struct ParsedFact {
1148 text: String,
1149 entity: Option<String>,
1150 tags: Vec<String>,
1151 metadata: Vec<(String, String)>,
1152 valid_from: Option<u64>,
1153}
1154
1155fn parse_import_line(line: &str, lineno: usize) -> Result<ParsedFact, CliError> {
1158 let v: serde_json::Value =
1159 serde_json::from_str(line).map_err(|e| CliError::Usage(format!("line {lineno}: {e}")))?;
1160 let text = v["text"]
1161 .as_str()
1162 .ok_or_else(|| CliError::Usage(format!("line {lineno}: missing string \"text\"")))?
1163 .to_string();
1164 let entity = v["entity"].as_str().map(String::from);
1165 let tags = v["tags"]
1166 .as_array()
1167 .map(|a| {
1168 a.iter()
1169 .filter_map(|t| t.as_str().map(String::from))
1170 .collect()
1171 })
1172 .unwrap_or_default();
1173 let metadata = v["metadata"]
1176 .as_object()
1177 .map(|m| {
1178 m.iter()
1179 .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
1180 .collect::<BTreeMap<_, _>>()
1181 .into_iter()
1182 .collect()
1183 })
1184 .unwrap_or_default();
1185 let valid_from = v["valid_from"].as_u64();
1186 Ok(ParsedFact {
1187 text,
1188 entity,
1189 tags,
1190 metadata,
1191 valid_from,
1192 })
1193}
1194
1195fn flush_import_batch(db: &Database, now: u64, batch: &[ParsedFact]) -> Result<usize, CliError> {
1198 if batch.is_empty() {
1199 return Ok(0);
1200 }
1201 let tag_refs: Vec<Vec<&str>> = batch
1204 .iter()
1205 .map(|p| p.tags.iter().map(String::as_str).collect())
1206 .collect();
1207 let meta_refs: Vec<Vec<(&str, &str)>> = batch
1208 .iter()
1209 .map(|p| {
1210 p.metadata
1211 .iter()
1212 .map(|(k, v)| (k.as_str(), v.as_str()))
1213 .collect()
1214 })
1215 .collect();
1216 let inputs: Vec<RememberInput> = batch
1217 .iter()
1218 .zip(&tag_refs)
1219 .zip(&meta_refs)
1220 .map(|((p, tags), meta)| RememberInput {
1221 entity: p.entity.as_deref(),
1222 tags,
1223 metadata: (!meta.is_empty()).then_some(meta.as_slice()),
1224 valid_from: p.valid_from,
1225 ..RememberInput::text(now, &p.text)
1226 })
1227 .collect();
1228 db.remember_many(inputs)?;
1229 Ok(batch.len())
1230}
1231
1232#[allow(clippy::too_many_arguments)]
1234fn do_remember(
1235 db: &Database,
1236 now: u64,
1237 text: &str,
1238 entity: &Option<String>,
1239 tags: &[String],
1240 links: &[String],
1241 meta: &[String],
1242 valid_from: Option<u64>,
1243 revise: Option<FactId>,
1244) -> Result<RememberOutcome, CliError> {
1245 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1246 let link_pairs = parse_links(links)?;
1247 let link_refs: Vec<(&str, &str)> = link_pairs
1248 .iter()
1249 .map(|(r, e)| (r.as_str(), e.as_str()))
1250 .collect();
1251 let meta_map = parse_meta(meta)?;
1255 let meta_refs: Vec<(&str, &str)> = meta_map
1256 .iter()
1257 .map(|(k, v)| (k.as_str(), v.as_str()))
1258 .collect();
1259 let input = RememberInput {
1260 entity: entity.as_deref(),
1261 tags: &tag_refs,
1262 links: &link_refs,
1263 metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1264 valid_from,
1265 ..RememberInput::text(now, text)
1266 };
1267 match revise {
1268 Some(target) => Ok(db.revise(target, input)?),
1269 None => Ok(db.remember(input)?),
1270 }
1271}
1272
1273fn parse_meta(meta: &[String]) -> Result<BTreeMap<String, String>, CliError> {
1276 let mut map = BTreeMap::new();
1277 for s in meta {
1278 let (k, v) = s
1279 .split_once('=')
1280 .filter(|(k, _)| !k.is_empty())
1281 .ok_or_else(|| CliError::Usage(format!("bad --meta `{s}` — expected KEY=VALUE")))?;
1282 map.insert(k.to_string(), v.to_string());
1283 }
1284 Ok(map)
1285}
1286
1287fn parse_links(links: &[String]) -> Result<Vec<(String, String)>, CliError> {
1289 links
1290 .iter()
1291 .map(|s| {
1292 s.split_once(':')
1293 .filter(|(r, e)| !r.is_empty() && !e.is_empty())
1294 .map(|(r, e)| (r.to_string(), e.to_string()))
1295 .ok_or_else(|| CliError::Usage(format!("bad --link `{s}` — expected REL:ENTITY")))
1296 })
1297 .collect()
1298}
1299
1300fn render_remember(outcome: &RememberOutcome, json: bool, out: &mut impl Write) {
1302 if json {
1303 let similar: Vec<_> = outcome
1304 .similar
1305 .iter()
1306 .map(|s| json!({ "id": s.id.0, "score": s.score, "reason": format!("{:?}", s.reason) }))
1307 .collect();
1308 writeln!(
1309 out,
1310 "{}",
1311 json!({
1312 "id": outcome.id.0,
1313 "entity": outcome.entity.map(|e| e.0),
1314 "similar": similar,
1315 })
1316 )
1317 .ok();
1318 } else {
1319 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1320 for s in &outcome.similar {
1321 writeln!(
1322 out,
1323 " ~ similar to fact {} ({:?}, {:.2})",
1324 s.id.0, s.reason, s.score
1325 )
1326 .ok();
1327 }
1328 }
1329}
1330
1331fn open_or(valid_to: u64) -> Option<u64> {
1333 (valid_to != VALID_TO_OPEN).then_some(valid_to)
1334}
1335
1336#[cfg(test)]
1337mod tests {
1338 use plugmem_host::Config;
1339
1340 use super::*;
1341
1342 struct StubEmbedder;
1344 impl plugmem_host::Embedder for StubEmbedder {
1345 fn dim(&self) -> usize {
1346 3
1347 }
1348 fn embed(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1349 Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1350 }
1351 }
1352
1353 fn recall_cmd(query: Option<&str>) -> Command {
1354 Command::Recall {
1355 query: query.map(str::to_owned),
1356 tags: vec![],
1357 entities: vec![],
1358 as_of: None,
1359 range: None,
1360 k: 0,
1361 closed: false,
1362 }
1363 }
1364
1365 fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1366 Settings {
1367 database_path: None,
1368 config: Config::default(),
1369 embedder,
1370 snapshot_every_ops: None,
1371 snapshot_journal_bytes: None,
1372 maintain_every_forgets: None,
1373 workspace: plugmem_host::WorkspaceSettings {
1374 dir: None,
1375 limits: plugmem_host::WorkspaceLimits::default(),
1376 },
1377 }
1378 }
1379
1380 #[test]
1381 fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1382 let mut with = settings_with(Some(Box::new(StubEmbedder)));
1384 assert_eq!(
1385 embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1386 Some(vec![0.1, 0.2, 0.3])
1387 );
1388
1389 let mut without = settings_with(None);
1391 assert_eq!(
1392 embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1393 None
1394 );
1395
1396 let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1398 assert_eq!(
1399 embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1400 None
1401 );
1402
1403 let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1405 assert_eq!(
1406 embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1407 None
1408 );
1409 }
1410
1411 #[test]
1412 fn split_line_honors_quotes_and_whitespace() {
1413 assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1414 assert_eq!(
1415 split_line(r#"remember "two words" --tag x"#),
1416 ["remember", "two words", "--tag", "x"]
1417 );
1418 assert_eq!(split_line(" recall 'a b' "), ["recall", "a b"]);
1419 assert_eq!(split_line(""), Vec::<String>::new());
1420 assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1422 }
1423
1424 #[test]
1425 fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1426 let (db, tmp) = TempDb::open();
1427 let path = tmp.0.join("m.plugmem");
1428 drop(db); let settings = settings_with(None);
1431 let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1433 let mut out = Vec::new();
1434 let code = run_repl(&path, settings, false, &script[..], &mut out);
1435 let text = String::from_utf8(out).unwrap();
1436
1437 assert_eq!(code, 0);
1438 assert!(text.contains("remembered fact 0"), "{text}");
1439 assert!(text.contains("tokio"), "{text}");
1440 assert!(text.contains("unrecognized subcommand"), "{text}");
1442
1443 let ro = Database::open_readonly(&path, Config::default()).unwrap();
1447 assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1448 }
1449
1450 #[test]
1451 fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1452 let (db, tmp) = TempDb::open();
1453 let path = tmp.0.join("m.plugmem");
1454 let mut sink = Vec::new();
1457 execute(
1458 &db,
1459 &remember("seed fact tokio", None, &[]),
1460 false,
1461 1_000,
1462 &mut sink,
1463 )
1464 .unwrap();
1465 db.checkpoint(1_001).unwrap();
1466
1467 let settings = settings_with(None);
1468 let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1470 let mut out = Vec::new();
1471 let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1472 let text = String::from_utf8(out).unwrap();
1473
1474 assert_eq!(code, 0);
1475 assert!(text.contains("generation 1"), "generation verb: {text}");
1476 assert!(text.contains("fact"), "stats ran: {text}");
1477 assert!(
1480 text.contains("already current → generation 1"),
1481 "refresh no-op: {text}"
1482 );
1483 assert!(text.contains("read-only session"), "write refused: {text}");
1485
1486 assert_eq!(db.stats().facts, 1);
1489 }
1490
1491 #[test]
1492 fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1493 let (db, tmp) = TempDb::open();
1494 let path = tmp.0.join("m.plugmem");
1495 let mut sink = Vec::new();
1496 execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1497 db.checkpoint(1_001).unwrap();
1498
1499 struct HookOnFirstRead<'a> {
1503 script: std::io::Cursor<&'a [u8]>,
1504 db: &'a Database,
1505 fired: bool,
1506 }
1507 impl std::io::Read for HookOnFirstRead<'_> {
1508 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1509 if !self.fired {
1510 self.fired = true;
1511 let mut s = Vec::new();
1514 execute(
1515 self.db,
1516 &remember("second", None, &[]),
1517 false,
1518 2_000,
1519 &mut s,
1520 )
1521 .unwrap();
1522 self.db.checkpoint(2_001).unwrap();
1523 }
1524 self.script.read(buf)
1525 }
1526 }
1527 let reader = std::io::BufReader::new(HookOnFirstRead {
1528 script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
1529 db: &db,
1530 fired: false,
1531 });
1532
1533 let mut out = Vec::new();
1534 let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
1535 let text = String::from_utf8(out).unwrap();
1536
1537 assert_eq!(code, 0);
1538 assert!(text.contains("refreshed → generation 2"), "advance: {text}");
1540 assert!(text.contains("fact"), "stats after refresh: {text}");
1542 assert_eq!(db.stats().facts, 2);
1543 }
1544
1545 #[test]
1546 fn read_only_repl_freshness_verbs_emit_json() {
1547 let (db, tmp) = TempDb::open();
1548 let path = tmp.0.join("m.plugmem");
1549 let mut sink = Vec::new();
1550 execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
1551 db.checkpoint(1_001).unwrap();
1552
1553 let script = b"generation\nrefresh\nexit\n";
1554 let mut out = Vec::new();
1555 let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
1556 let text = String::from_utf8(out).unwrap();
1557
1558 assert_eq!(code, 0);
1559 assert!(
1560 text.contains(r#""generation":1"#),
1561 "generation json: {text}"
1562 );
1563 assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
1564 }
1565
1566 struct TempDb(PathBuf);
1568 impl TempDb {
1569 fn open() -> (Database, Self) {
1570 let dir = std::env::temp_dir().join(format!(
1571 "plugmem-cli-{}-{}",
1572 std::process::id(),
1573 now_ms_unique()
1574 ));
1575 std::fs::create_dir_all(&dir).unwrap();
1576 let path = dir.join("m.plugmem");
1577 let (db, _) = Database::open(&path, Config::default()).unwrap();
1578 (db, TempDb(dir))
1579 }
1580 }
1581 impl Drop for TempDb {
1582 fn drop(&mut self) {
1583 let _ = std::fs::remove_dir_all(&self.0);
1584 }
1585 }
1586
1587 fn now_ms_unique() -> String {
1590 use std::sync::atomic::{AtomicU64, Ordering};
1591 static N: AtomicU64 = AtomicU64::new(0);
1592 format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
1593 }
1594
1595 fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
1596 let mut buf = Vec::new();
1597 let code = execute(db, cmd, json, now, &mut buf).expect("execute");
1598 (code, String::from_utf8(buf).unwrap())
1599 }
1600
1601 fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
1602 Command::Remember {
1603 text: text.into(),
1604 entity: entity.map(Into::into),
1605 tags: tags.iter().map(|t| (*t).into()).collect(),
1606 links: Vec::new(),
1607 meta: Vec::new(),
1608 valid_from: None,
1609 }
1610 }
1611
1612 fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
1613 Command::Remember {
1614 text: text.into(),
1615 entity: None,
1616 tags: Vec::new(),
1617 links: Vec::new(),
1618 meta: meta.iter().map(|m| (*m).into()).collect(),
1619 valid_from: None,
1620 }
1621 }
1622
1623 #[test]
1624 fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
1625 let (db, _t) = TempDb::open();
1626 let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
1628 assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
1629
1630 let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1632 assert!(
1633 human.contains("metadata page=3, uri=s3://b/x"),
1634 "{human}"
1635 );
1636 let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1638 let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
1639 assert_eq!(v["metadata"]["page"], "3");
1640 assert_eq!(v["metadata"]["uri"], "s3://b/x");
1641
1642 let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
1644 let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
1645 assert_eq!(line["metadata"]["uri"], "s3://b/x");
1646
1647 assert!(matches!(
1649 parse_meta(&["noequals".to_string()]),
1650 Err(CliError::Usage(_))
1651 ));
1652 assert!(parse_meta(&["=noKey".to_string()]).is_err());
1653 }
1654
1655 #[test]
1656 fn remember_then_recall_human_and_json() {
1657 let (db, _t) = TempDb::open();
1658 let (code, out) = run_cmd(
1659 &db,
1660 &remember("prefers tokio", Some("user"), &["pref"]),
1661 false,
1662 1_000,
1663 );
1664 assert_eq!(code, 0);
1665 assert!(out.starts_with("remembered fact 0"), "{out}");
1666
1667 let recall = Command::Recall {
1669 query: Some("tokio".into()),
1670 tags: Vec::new(),
1671 entities: Vec::new(),
1672 as_of: None,
1673 range: None,
1674 k: 0,
1675 closed: false,
1676 };
1677 let (code, out) = run_cmd(&db, &recall, false, 2_000);
1678 assert_eq!(code, 0);
1679 assert!(out.contains("tokio"), "{out}");
1680
1681 let (code, out) = run_cmd(&db, &recall, true, 2_000);
1683 assert_eq!(code, 0);
1684 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1685 assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
1686 }
1687
1688 #[test]
1689 fn recall_empty_is_ok_with_a_note() {
1690 let (db, _t) = TempDb::open();
1691 let recall = Command::Recall {
1692 query: Some("nothing here".into()),
1693 tags: Vec::new(),
1694 entities: Vec::new(),
1695 as_of: None,
1696 range: None,
1697 k: 0,
1698 closed: false,
1699 };
1700 let (code, out) = run_cmd(&db, &recall, false, 1_000);
1701 assert_eq!(code, 0);
1702 assert!(out.contains("nothing recalled"), "{out}");
1703 }
1704
1705 #[test]
1706 fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
1707 let (db, _t) = TempDb::open();
1708 run_cmd(
1709 &db,
1710 &remember("lives in Moscow", Some("user"), &[]),
1711 false,
1712 1_000,
1713 );
1714 let (_, out) = run_cmd(
1716 &db,
1717 &remember("lives in Moscow now", Some("user"), &[]),
1718 false,
1719 1_500,
1720 );
1721 assert!(out.contains("similar to fact"), "{out}");
1722
1723 let revise = Command::Revise {
1724 id: 0,
1725 text: "lives in Berlin".into(),
1726 entity: Some("user".into()),
1727 tags: Vec::new(),
1728 links: Vec::new(),
1729 meta: Vec::new(),
1730 valid_from: None,
1731 };
1732 let (code, out) = run_cmd(&db, &revise, false, 2_000);
1733 assert_eq!(code, 0);
1734 assert!(out.starts_with("remembered fact"), "{out}");
1735 }
1736
1737 #[test]
1738 fn show_found_and_missing() {
1739 let (db, _t) = TempDb::open();
1740 run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
1741
1742 let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1743 assert_eq!(code, 0);
1744 assert!(
1745 out.contains("a note") && out.contains("recorded_at 1000"),
1746 "{out}"
1747 );
1748
1749 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
1750 assert_eq!(code, 1, "missing id is a soft miss");
1751 assert!(out.contains("not found"), "{out}");
1752
1753 let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1755 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1756 assert_eq!(v["text"], "a note");
1757 assert_eq!(v["valid_to"], serde_json::Value::Null); }
1759
1760 #[test]
1761 fn forget_then_maintain_purges() {
1762 let (db, _t) = TempDb::open();
1763 run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
1764
1765 let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
1766 assert_eq!(code, 0);
1767 assert!(out.contains("forgot fact 0"), "{out}");
1768 let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
1770 assert!(out.contains("already gone"), "{out}");
1771
1772 let (code, out) = run_cmd(
1773 &db,
1774 &Command::Maintain {
1775 mode: MaintainMode::Auto,
1776 },
1777 false,
1778 3_000,
1779 );
1780 assert_eq!(code, 0);
1781 assert!(out.contains("purged 1"), "{out}");
1782 }
1783
1784 #[test]
1785 fn link_and_stats_and_json() {
1786 let (db, _t) = TempDb::open();
1787 run_cmd(
1788 &db,
1789 &remember("uses tokio", Some("plugmem"), &[]),
1790 false,
1791 1_000,
1792 );
1793 let link = Command::Link {
1794 src: "plugmem".into(),
1795 rel: "depends_on".into(),
1796 dst: "tokio".into(),
1797 };
1798 let (code, out) = run_cmd(&db, &link, false, 2_000);
1799 assert_eq!(code, 0);
1800 assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
1801 let unlink = Command::Unlink {
1802 src: "plugmem".into(),
1803 rel: "depends_on".into(),
1804 dst: "tokio".into(),
1805 };
1806 let (code, out) = run_cmd(&db, &unlink, false, 2_500);
1807 assert_eq!(code, 0);
1808 assert!(
1809 out.contains("unlinked plugmem -depends_on-> tokio"),
1810 "{out}"
1811 );
1812
1813 let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
1814 assert_eq!(code, 0);
1815 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1816 assert_eq!(v["facts"], 1);
1817 assert_eq!(v["edges"], 0);
1818 assert_eq!(v["edge_versions"], 1);
1819 }
1820
1821 #[test]
1822 fn bad_link_is_a_usage_error() {
1823 let (db, _t) = TempDb::open();
1824 let cmd = Command::Remember {
1825 text: "x".into(),
1826 entity: Some("user".into()),
1827 tags: Vec::new(),
1828 links: vec!["not-a-pair".into()],
1829 meta: Vec::new(),
1830 valid_from: None,
1831 };
1832 let mut buf = Vec::new();
1833 let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
1834 assert!(matches!(err, CliError::Usage(_)));
1835 }
1836
1837 #[test]
1838 fn as_of_time_travel_via_recall() {
1839 let (db, _t) = TempDb::open();
1840 run_cmd(
1841 &db,
1842 &remember("lives in Moscow", Some("user"), &[]),
1843 false,
1844 1_000,
1845 );
1846 let revise = Command::Revise {
1847 id: 0,
1848 text: "lives in Berlin".into(),
1849 entity: Some("user".into()),
1850 tags: Vec::new(),
1851 links: Vec::new(),
1852 meta: Vec::new(),
1853 valid_from: None,
1854 };
1855 run_cmd(&db, &revise, false, 2_000);
1856
1857 let as_of = Command::Recall {
1858 query: Some("lives".into()),
1859 tags: Vec::new(),
1860 entities: vec!["user".into()],
1861 as_of: Some(1_500),
1862 range: None,
1863 k: 0,
1864 closed: false,
1865 };
1866 let (_, out) = run_cmd(&db, &as_of, false, 3_000);
1867 assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
1868 }
1869
1870 #[test]
1871 fn every_command_has_a_json_shape() {
1872 let (db, _t) = TempDb::open();
1873 let (_, out) = run_cmd(
1875 &db,
1876 &remember("uses tokio", Some("plugmem"), &["pref"]),
1877 true,
1878 1_000,
1879 );
1880 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1881 assert_eq!(v["id"], 0);
1882 assert!(v["similar"].is_array());
1883
1884 let revise = Command::Revise {
1886 id: 0,
1887 text: "uses tokio now".into(),
1888 entity: Some("plugmem".into()),
1889 tags: Vec::new(),
1890 links: Vec::new(),
1891 meta: Vec::new(),
1892 valid_from: None,
1893 };
1894 let (_, out) = run_cmd(&db, &revise, true, 1_500);
1895 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
1896
1897 let link = Command::Link {
1899 src: "plugmem".into(),
1900 rel: "depends_on".into(),
1901 dst: "tokio".into(),
1902 };
1903 let (_, out) = run_cmd(&db, &link, true, 2_000);
1904 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1905 assert_eq!(v["rel"], "depends_on");
1906 let unlink = Command::Unlink {
1907 src: "plugmem".into(),
1908 rel: "depends_on".into(),
1909 dst: "tokio".into(),
1910 };
1911 let (_, out) = run_cmd(&db, &unlink, true, 2_100);
1912 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1913 assert_eq!(v["unlinked"], true);
1914
1915 let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
1917 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1918 assert_eq!(v["forgotten"], true);
1919 let (_, out) = run_cmd(
1920 &db,
1921 &Command::Maintain {
1922 mode: MaintainMode::Auto,
1923 },
1924 true,
1925 3_000,
1926 );
1927 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1928 assert!(v["purged"].as_u64().unwrap() >= 1);
1929
1930 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
1932 assert_eq!(code, 1);
1933 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1934 assert_eq!(v["found"], false);
1935
1936 let recall = Command::Recall {
1938 query: None,
1939 tags: Vec::new(),
1940 entities: vec!["plugmem".into()],
1941 as_of: None,
1942 range: Some(vec![0, 10_000]),
1943 k: 4,
1944 closed: true,
1945 };
1946 let (_, out) = run_cmd(&db, &recall, true, 4_000);
1947 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
1948 }
1949
1950 #[test]
1951 fn stats_human_lists_the_counters() {
1952 let (db, _t) = TempDb::open();
1953 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
1954 let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
1955 assert_eq!(code, 0);
1956 assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
1957 }
1958
1959 #[test]
1960 fn verify_command_renders_human_and_json() {
1961 let (db, _t) = TempDb::open();
1962 run_cmd(&db, &remember("clean", None, &[]), false, 1_000);
1963
1964 let (code, out) = run_cmd(&db, &Command::Verify, false, 2_000);
1965 assert_eq!(code, 0);
1966 assert_eq!(out.trim(), "integrity ok");
1967
1968 let (code, out) = run_cmd(&db, &Command::Verify, true, 2_100);
1969 assert_eq!(code, 0);
1970 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1971 assert_eq!(v["ok"], true);
1972 }
1973
1974 #[test]
1975 fn show_json_of_a_revised_predecessor_is_closed() {
1976 let (db, _t) = TempDb::open();
1977 run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
1978 let revise = Command::Revise {
1979 id: 0,
1980 text: "v2".into(),
1981 entity: Some("e".into()),
1982 tags: Vec::new(),
1983 links: Vec::new(),
1984 meta: Vec::new(),
1985 valid_from: None,
1986 };
1987 run_cmd(&db, &revise, false, 2_000);
1988 let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
1990 assert!(out.contains("revises fact 0"), "{out}");
1991 }
1992
1993 #[test]
1994 fn resolve_db_path_prefers_the_flag() {
1995 let p = "/tmp/explicit.plugmem";
1996 assert_eq!(resolve_db_path(Some(p), None, None), PathBuf::from(p));
1997 let configured = std::path::Path::new("/tmp/configured.plugmem");
1998 assert_eq!(
1999 resolve_db_path(None, Some(configured), None),
2000 PathBuf::from(configured)
2001 );
2002 let _ = resolve_db_path(None, None, None);
2005 }
2006
2007 #[test]
2008 fn a_bare_name_is_a_memory_only_when_a_workspace_is_configured() {
2009 let root = PathBuf::from("/srv/bot");
2010
2011 assert_eq!(
2014 resolve_db_path(Some("work"), None, None),
2015 PathBuf::from("work")
2016 );
2017
2018 assert_eq!(
2020 resolve_db_path(Some("work"), None, Some(&root)),
2021 PathBuf::from("/srv/bot/db/work.plugmem")
2022 );
2023 for path in ["./work", "work.plugmem", "/srv/other.plugmem", "../up"] {
2026 assert_eq!(
2027 resolve_db_path(Some(path), None, Some(&root)),
2028 PathBuf::from(path),
2029 "{path}"
2030 );
2031 }
2032
2033 let configured = std::path::Path::new("work");
2035 assert_eq!(
2036 resolve_db_path(None, Some(configured), Some(&root)),
2037 PathBuf::from("work")
2038 );
2039 }
2040
2041 #[test]
2042 fn settings_help_runs_without_opening_a_database() {
2043 let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
2044 let mut output = Vec::new();
2045 assert_eq!(run_parsed(cli, &mut output), 0);
2046 let output = String::from_utf8(output).unwrap();
2047 assert!(output.contains("plugmem settings"));
2048 assert!(output.contains("[database]"));
2049 assert!(output.contains("path (path string"));
2050
2051 let cli = Cli::try_parse_from(["plugmem-cli", "--json", "help", "settings"]).unwrap();
2052 let mut output = Vec::new();
2053 assert_eq!(run_parsed(cli, &mut output), 0);
2054 let output: serde_json::Value = serde_json::from_slice(&output).unwrap();
2055 assert_eq!(output["topic"], "settings");
2056 assert!(output["config_path_precedence"].is_array());
2057 assert!(output["settings"].as_array().unwrap().len() > 10);
2058 }
2059
2060 #[test]
2061 fn run_parsed_opens_runs_and_reports() {
2062 let dir = std::env::temp_dir().join(format!(
2063 "plugmem-run-{}-{}",
2064 std::process::id(),
2065 now_ms_unique()
2066 ));
2067 std::fs::create_dir_all(&dir).unwrap();
2068 let path = dir.join("m.plugmem");
2069 let cli = Cli {
2070 db: Some(path.display().to_string()),
2071 workspace: None,
2072 config: None,
2073 json: false,
2074 command: Command::Stats,
2075 };
2076 let mut buf = Vec::new();
2077 let code = run_parsed(cli, &mut buf);
2078 assert_eq!(code, 0);
2079 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2080 let _ = std::fs::remove_dir_all(&dir);
2081 }
2082
2083 #[test]
2084 fn recover_and_scrub_render_json_and_human_shapes() {
2085 let (db, tmp) = TempDb::open();
2086 let path = tmp.0.join("m.plugmem");
2087 run_cmd(&db, &remember("recoverable fact", None, &[]), false, 1_000);
2088 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2089 drop(db);
2090
2091 let settings = settings_with(None);
2092 let mut out = Vec::new();
2093 assert_eq!(do_scrub(&path, &settings, true, &mut out), 0);
2094 let scrub: serde_json::Value = serde_json::from_slice(&out).unwrap();
2095 assert_eq!(scrub["ok"], true);
2096 assert!(scrub["bytes"].as_u64().unwrap() > 0);
2097 let mut out = Vec::new();
2098 assert_eq!(do_scrub(&path, &settings, false, &mut out), 0);
2099 let out = String::from_utf8(out).unwrap();
2100 assert!(out.contains("scrub ok:"), "{out}");
2101
2102 let json_dst = tmp.0.join("copy-json.plugmem");
2103 let mut out = Vec::new();
2104 assert_eq!(do_recover(&path, &json_dst, &settings, true, &mut out), 0);
2105 let recover: serde_json::Value = serde_json::from_slice(&out).unwrap();
2106 assert_eq!(recover["kept"], 1);
2107 assert_eq!(recover["dropped_text"], 0);
2108 assert_eq!(recover["dst"], json_dst.display().to_string());
2109
2110 let human_dst = tmp.0.join("copy-human.plugmem");
2111 let mut out = Vec::new();
2112 assert_eq!(do_recover(&path, &human_dst, &settings, false, &mut out), 0);
2113 let out = String::from_utf8(out).unwrap();
2114 assert!(out.contains("recovered to"), "{out}");
2115 assert!(out.contains("kept 1"), "{out}");
2116 }
2117
2118 #[test]
2119 fn readonly_dispatcher_renders_every_read_shape() {
2120 let (db, tmp) = TempDb::open();
2121 let path = tmp.0.join("m.plugmem");
2122 run_cmd(
2123 &db,
2124 &remember("readonly tokio fact", Some("plugmem"), &["pref"]),
2125 false,
2126 1_000,
2127 );
2128 run_cmd(&db, &Command::Checkpoint, false, 2_000);
2129 let ro = Database::open_readonly(&path, Config::default()).unwrap();
2130
2131 let mut out = Vec::new();
2132 assert_eq!(execute_ro(&ro, &Command::Stats, None, true, &mut out), 0);
2133 let stats: serde_json::Value = serde_json::from_slice(&out).unwrap();
2134 assert_eq!(stats["facts"], 1);
2135
2136 let mut out = Vec::new();
2137 assert_eq!(
2138 execute_ro(&ro, &Command::Show { id: 0 }, None, false, &mut out),
2139 0
2140 );
2141 let text = String::from_utf8(out).unwrap();
2142 assert!(text.contains("readonly tokio fact"), "{text}");
2143
2144 let mut out = Vec::new();
2145 assert_eq!(execute_ro(&ro, &Command::Export, None, false, &mut out), 0);
2146 let exported: serde_json::Value =
2147 serde_json::from_str(String::from_utf8(out).unwrap().lines().next().unwrap()).unwrap();
2148 assert_eq!(exported["text"], "readonly tokio fact");
2149
2150 let mut out = Vec::new();
2151 let recall = Command::Recall {
2152 query: Some("tokio".into()),
2153 tags: vec!["pref".into()],
2154 entities: vec!["plugmem".into()],
2155 as_of: None,
2156 range: None,
2157 k: 1,
2158 closed: false,
2159 };
2160 assert_eq!(execute_ro(&ro, &recall, None, false, &mut out), 0);
2161 let text = String::from_utf8(out).unwrap();
2162 assert!(text.contains("tokio"), "{text}");
2163
2164 let mut out = Vec::new();
2165 assert_eq!(execute_ro(&ro, &Command::Verify, None, true, &mut out), 0);
2166 let verify: serde_json::Value = serde_json::from_slice(&out).unwrap();
2167 assert_eq!(verify["ok"], true);
2168 }
2169
2170 #[test]
2171 fn run_parsed_on_a_locked_database_returns_one() {
2172 let (_held, dir) = {
2173 let dir = std::env::temp_dir().join(format!(
2174 "plugmem-lock-{}-{}",
2175 std::process::id(),
2176 now_ms_unique()
2177 ));
2178 std::fs::create_dir_all(&dir).unwrap();
2179 let path = dir.join("m.plugmem");
2180 (Database::open(&path, Config::default()).unwrap(), dir)
2181 };
2182 let cli = Cli {
2183 db: Some(dir.join("m.plugmem").display().to_string()),
2184 workspace: None,
2185 config: None,
2186 json: false,
2187 command: Command::Stats,
2188 };
2189 let mut buf = Vec::new();
2190 assert_eq!(run_parsed(cli, &mut buf), 1);
2191 let _ = std::fs::remove_dir_all(&dir);
2192 }
2193
2194 #[test]
2195 fn run_parsed_propagates_a_usage_error_as_two() {
2196 let dir = std::env::temp_dir().join(format!(
2197 "plugmem-usage-{}-{}",
2198 std::process::id(),
2199 now_ms_unique()
2200 ));
2201 std::fs::create_dir_all(&dir).unwrap();
2202 let cli = Cli {
2203 db: Some(dir.join("m.plugmem").display().to_string()),
2204 workspace: None,
2205 config: None,
2206 json: false,
2207 command: Command::Remember {
2208 text: "x".into(),
2209 entity: None,
2210 tags: Vec::new(),
2211 links: vec!["bad".into()],
2212 meta: Vec::new(),
2213 valid_from: None,
2214 },
2215 };
2216 let mut buf = Vec::new();
2217 assert_eq!(run_parsed(cli, &mut buf), 2);
2218 let _ = std::fs::remove_dir_all(&dir);
2219 }
2220
2221 struct Scratch(PathBuf);
2223 impl Scratch {
2224 fn new(tag: &str) -> Self {
2225 let dir = std::env::temp_dir().join(format!(
2226 "plugmem-cli-{tag}-{}-{}",
2227 std::process::id(),
2228 now_ms_unique()
2229 ));
2230 std::fs::create_dir_all(&dir).unwrap();
2231 Scratch(dir)
2232 }
2233 }
2234 impl Drop for Scratch {
2235 fn drop(&mut self) {
2236 let _ = std::fs::remove_dir_all(&self.0);
2237 }
2238 }
2239
2240 #[test]
2241 fn export_import_roundtrip_preserves_open_facts() {
2242 let (a, _ta) = TempDb::open();
2247 run_cmd(
2248 &a,
2249 &Command::Remember {
2250 text: "prefers tokio".into(),
2251 entity: Some("user".into()),
2252 tags: vec!["pref".into(), "lang".into()],
2253 links: Vec::new(),
2254 meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
2255 valid_from: Some(500),
2256 },
2257 false,
2258 1_000,
2259 );
2260 run_cmd(
2261 &a,
2262 &remember("lives in Moscow", Some("user"), &[]),
2263 false,
2264 1_100,
2265 ); run_cmd(
2267 &a,
2268 &Command::Revise {
2269 id: 1,
2270 text: "lives in Berlin".into(),
2271 entity: Some("user".into()),
2272 tags: vec!["geo".into()],
2273 links: Vec::new(),
2274 meta: Vec::new(),
2275 valid_from: None,
2276 },
2277 false,
2278 1_200,
2279 ); run_cmd(&a, &remember("junk", None, &[]), false, 1_300); run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); run_cmd(
2283 &a,
2284 &remember("uses rust", Some("plugmem"), &["lang"]),
2285 false,
2286 1_500,
2287 ); let mut dump = Vec::new();
2291 render_export(&a.export(), false, &mut dump);
2292 let scratch = Scratch::new("roundtrip");
2293 let file = scratch.0.join("dump.jsonl");
2294 std::fs::write(&file, &dump).unwrap();
2295
2296 let (b, _tb) = TempDb::open();
2298 let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
2299
2300 let key = |f: &ExportedFact| {
2302 let mut tags = f.tags.clone();
2303 tags.sort();
2304 (f.text.clone(), f.entity.clone(), tags, f.valid_from)
2305 };
2306 let mut ak: Vec<_> = a.export().iter().map(key).collect();
2307 let mut bk: Vec<_> = b.export().iter().map(key).collect();
2308 ak.sort();
2309 bk.sort();
2310 assert_eq!(n, ak.len());
2311 assert_eq!(
2312 ak, bk,
2313 "roundtrip must preserve text/entity/tags/valid_from"
2314 );
2315
2316 let b_open = b.export();
2319 assert!(b_open.iter().any(|f| f.text == "prefers tokio"
2320 && f.valid_from == 500
2321 && f.entity.as_deref() == Some("user")
2322 && f.tags == vec!["pref".to_string(), "lang".to_string()]
2323 && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
2324 && f.metadata.get("src").map(String::as_str) == Some("chat")));
2325 assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
2326 assert!(b_open.iter().any(|f| f.text == "uses rust"));
2327 assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
2328 assert!(!b_open.iter().any(|f| f.text == "junk"));
2329 }
2330
2331 #[test]
2332 fn export_command_emits_jsonl_regardless_of_json_flag() {
2333 let (db, _t) = TempDb::open();
2334 run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
2335 for json in [false, true] {
2336 let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
2337 assert_eq!(code, 0);
2338 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2339 assert_eq!(v["text"], "a fact");
2340 assert_eq!(v["entity"], "e");
2341 assert_eq!(v["tags"][0], "t");
2342 }
2343 }
2344
2345 #[test]
2346 fn import_command_counts_and_rejects_bad_lines() {
2347 let (db, _t) = TempDb::open();
2348 let scratch = Scratch::new("import");
2349 let good = scratch.0.join("in.jsonl");
2350 std::fs::write(
2351 &good,
2352 "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
2353 )
2354 .unwrap();
2355 let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
2357 assert_eq!(n, 2, "both facts imported, blank line skipped");
2358
2359 let bad = scratch.0.join("bad.jsonl");
2360 std::fs::write(&bad, "not json at all\n").unwrap();
2361 let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
2362 assert!(matches!(err, CliError::Usage(_)));
2363 }
2364
2365 #[test]
2366 fn import_batch_size_does_not_change_the_result() {
2367 let scratch = Scratch::new("import-batch");
2370 let file = scratch.0.join("facts.jsonl");
2371 let mut jsonl = String::new();
2372 for i in 0..5 {
2373 jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
2374 }
2375 std::fs::write(&file, &jsonl).unwrap();
2376
2377 let (a, _ta) = TempDb::open();
2378 let (b, _tb) = TempDb::open();
2379 let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
2380 let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
2381
2382 assert_eq!(na, 5);
2383 assert_eq!(nb, 5);
2384 let texts = |db: &Database| {
2385 let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
2386 t.sort();
2387 t
2388 };
2389 assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
2390 }
2391
2392 #[test]
2393 fn config_table_feeds_settings_and_the_cli_batch_size() {
2394 let scratch = Scratch::new("settings");
2398 let cfgfile = scratch.0.join("config.toml");
2399 std::fs::write(
2400 &cfgfile,
2401 "[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n\
2402 [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
2403 )
2404 .unwrap();
2405 let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
2406 let s = Settings::from_table(table.as_ref()).unwrap();
2407 assert_eq!(s.config.dim, 512);
2408 assert!(s.embedder.is_none());
2409 assert_eq!(s.snapshot_every_ops, Some(64));
2410 assert_eq!(read_batch_size(table.as_ref()), Some(200));
2411
2412 assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
2414 }
2415
2416 #[test]
2417 fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
2418 let scratch = Scratch::new("checkpoint-cmd");
2419 let path = scratch.0.join("m.plugmem");
2420
2421 let remember = Cli {
2423 db: Some(path.display().to_string()),
2424 workspace: None,
2425 config: None,
2426 json: false,
2427 command: Command::Remember {
2428 text: "hello tokio".into(),
2429 entity: None,
2430 tags: Vec::new(),
2431 links: Vec::new(),
2432 meta: Vec::new(),
2433 valid_from: None,
2434 },
2435 };
2436 assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
2437
2438 let checkpoint = |json| Cli {
2440 db: Some(path.display().to_string()),
2441 workspace: None,
2442 config: None,
2443 json,
2444 command: Command::Checkpoint,
2445 };
2446 let mut buf = Vec::new();
2447 assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
2448 assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
2449
2450 let mut buf = Vec::new();
2452 assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
2453 let v: serde_json::Value =
2454 serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
2455 assert_eq!(v["ok"], true);
2456
2457 let scrub = Cli {
2460 db: Some(path.display().to_string()),
2461 workspace: None,
2462 config: None,
2463 json: false,
2464 command: Command::Scrub,
2465 };
2466 let mut buf = Vec::new();
2467 assert_eq!(run_parsed(scrub, &mut buf), 0);
2468 assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
2469 }
2470
2471 #[test]
2472 fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
2473 let scratch = Scratch::new("ro-route");
2474 let path = scratch.0.join("m.plugmem");
2475 {
2476 let (db, _) = Database::open(&path, Config::default()).unwrap();
2477 db.remember(RememberInput::text(1_000, "hello tokio"))
2478 .unwrap();
2479 db.checkpoint(2_000).unwrap(); }
2481 let cli = Cli {
2483 db: Some(path.display().to_string()),
2484 workspace: None,
2485 config: None,
2486 json: false,
2487 command: Command::Stats,
2488 };
2489 let mut buf = Vec::new();
2490 assert_eq!(run_parsed(cli, &mut buf), 0);
2491 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2492
2493 let cli = Cli {
2495 db: Some(path.display().to_string()),
2496 workspace: None,
2497 config: None,
2498 json: false,
2499 command: Command::Recall {
2500 query: Some("tokio".into()),
2501 tags: Vec::new(),
2502 entities: Vec::new(),
2503 as_of: None,
2504 range: None,
2505 k: 0,
2506 closed: false,
2507 },
2508 };
2509 let mut buf = Vec::new();
2510 assert_eq!(run_parsed(cli, &mut buf), 0);
2511 assert!(String::from_utf8(buf).unwrap().contains("tokio"));
2512 }
2513}