1mod cli;
16mod config;
17
18use std::collections::BTreeMap;
19use std::io::{self, BufRead, Write};
20use std::path::{Path, PathBuf};
21use std::process::ExitCode;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use clap::Parser;
25use plugmem_host::{
26 Database, ExportedFact, FactId, HostError, LinkInput, ReadOnlyDatabase, RecallQuery,
27 RecallResult, RememberInput, RememberOutcome, Settings, Stats, VALID_TO_OPEN,
28};
29use serde_json::json;
30
31use crate::cli::{Cli, Command, HelpTopic};
32use crate::config::read_batch_size;
33
34pub(crate) const ENV_DB: &str = "PLUGMEM_DB";
36pub(crate) const DEFAULT_DB: &str = "plugmem.db";
38
39#[derive(Debug)]
42pub(crate) enum CliError {
43 Host(HostError),
44 Usage(String),
45}
46
47impl From<HostError> for CliError {
48 fn from(e: HostError) -> Self {
49 CliError::Host(e)
50 }
51}
52
53pub(crate) fn now_ms() -> u64 {
55 SystemTime::now()
56 .duration_since(UNIX_EPOCH)
57 .map(|d| d.as_millis() as u64)
58 .unwrap_or(0)
59}
60
61pub fn run() -> ExitCode {
65 let stdout = io::stdout();
66 ExitCode::from(run_parsed(Cli::parse(), &mut stdout.lock()))
67}
68
69fn run_parsed(cli: Cli, out: &mut impl Write) -> u8 {
73 if let Command::Help { topic } = &cli.command {
74 return execute_help(topic, cli.json, out);
75 }
76
77 let table = match plugmem_host::read_config(cli.config.as_deref()) {
81 Ok(t) => t,
82 Err(e) => return report_err(&e.into()),
83 };
84 let cfg_batch_size = read_batch_size(table.as_ref());
85 let mut settings = match Settings::from_table(table.as_ref()) {
86 Ok(s) => s,
87 Err(e) => return report_err(&e.into()),
88 };
89 let path = resolve_db_path(cli.db.as_deref(), settings.database_path.as_deref());
90
91 match &cli.command {
96 Command::Recover { dst } => return do_recover(&path, dst, &settings, cli.json, out),
97 Command::Scrub => return do_scrub(&path, &settings, cli.json, out),
98 Command::Repl { read_only: true } => {
103 return run_repl_ro(&path, settings, cli.json, io::stdin().lock(), out);
104 }
105 Command::Repl { read_only: false } => {
106 return run_repl(&path, settings, cli.json, io::stdin().lock(), out);
107 }
108 _ => {}
109 }
110
111 let readonly_ok = matches!(
119 &cli.command,
120 Command::Show { .. }
121 | Command::Stats
122 | Command::Export
123 | Command::Verify
124 | Command::Recall { .. }
125 );
126 if readonly_ok {
127 let recall_vector = match embed_recall_query(&mut settings, &cli.command) {
128 Ok(v) => v,
129 Err(e) => return report_err(&e),
130 };
131 match Database::open_readonly(&path, settings.config.clone()) {
132 Ok(ro) => {
133 return execute_ro(&ro, &cli.command, recall_vector.as_deref(), cli.json, out);
134 }
135 Err(HostError::Locked { path }) => return report_locked(&path),
136 Err(_) => {}
141 }
142 }
143
144 let db = match settings.open(&path) {
147 Ok(db) => db,
148 Err(HostError::Locked { path }) => return report_locked(&path),
149 Err(e) => return report_err(&CliError::Host(e)),
150 };
151 if let Command::Import { file, batch } = &cli.command {
154 let batch_size = batch
155 .or(cfg_batch_size.map(|n| n as usize))
156 .unwrap_or(DEFAULT_IMPORT_BATCH)
157 .max(1);
158 return match do_import(&db, now_ms(), file, batch_size, out) {
159 Ok(n) => {
160 if cli.json {
161 writeln!(out, "{}", json!({ "imported": n })).ok();
162 } else {
163 writeln!(out, "imported {n} facts").ok();
164 }
165 0
166 }
167 Err(e) => {
168 let _ = out.flush();
169 report_err(&e)
170 }
171 };
172 }
173 match execute(&db, &cli.command, cli.json, now_ms(), out) {
174 Ok(code) => code,
175 Err(e) => {
176 let _ = out.flush();
177 report_err(&e)
178 }
179 }
180}
181
182const DEFAULT_IMPORT_BATCH: usize = 128;
185
186fn report_err(e: &CliError) -> u8 {
188 match e {
189 CliError::Usage(msg) => eprintln!("plugmem: {msg}"),
190 CliError::Host(err) => eprintln!("plugmem: {err}"),
191 }
192 2
193}
194
195fn report_locked(path: &std::path::Path) -> u8 {
197 eprintln!(
198 "plugmem: database is locked by another process: {}",
199 path.display()
200 );
201 1
202}
203
204fn resolve_db_path(
207 flag: Option<&std::path::Path>,
208 config_path: Option<&std::path::Path>,
209) -> PathBuf {
210 flag.map(PathBuf::from)
211 .or_else(|| std::env::var_os(ENV_DB).map(PathBuf::from))
212 .or_else(|| config_path.map(PathBuf::from))
213 .or_else(plugmem_host::default_database_path)
214 .unwrap_or_else(|| PathBuf::from(DEFAULT_DB))
215}
216
217fn execute_help(topic: &HelpTopic, json_output: bool, out: &mut impl Write) -> u8 {
220 match topic {
221 HelpTopic::Settings => {
222 if json_output {
223 let help = plugmem_host::settings_help();
224 let settings: Vec<_> = help
225 .docs()
226 .iter()
227 .map(|doc| {
228 json!({
229 "section": doc.section,
230 "key": doc.key,
231 "type": doc.value_type,
232 "default": doc.default,
233 "description": doc.description,
234 "scope": doc.scope.as_str(),
235 })
236 })
237 .collect();
238 let value = json!({
239 "topic": "settings",
240 "config_path_precedence": help.config_path_precedence(),
241 "default_config_path": plugmem_host::default_config_path()
242 .map(|path| path.display().to_string()),
243 "settings": settings,
244 });
245 writeln!(out, "{value}").ok();
246 } else {
247 write!(out, "{}", plugmem_host::settings_help().render_human()).ok();
248 }
249 0
250 }
251 }
252}
253
254fn execute_ro(
257 ro: &ReadOnlyDatabase,
258 cmd: &Command,
259 recall_vector: Option<&[f32]>,
260 json: bool,
261 out: &mut impl Write,
262) -> u8 {
263 match cmd {
264 Command::Recall { .. } => {
265 match with_recall_query(cmd, now_ms(), recall_vector, |q| ro.recall(q)) {
266 Ok(res) => {
267 render_recall(&res, json, out);
268 0
269 }
270 Err(e) => report_err(&CliError::Host(e)),
271 }
272 }
273 Command::Show { id } => render_show(ro.get(FactId(*id)), *id, json, out),
274 Command::Stats => {
275 render_stats(&ro.stats(), json, out);
276 0
277 }
278 Command::Export => {
279 ro.export_each(|f| write_export_line(out, &f));
280 0
281 }
282 Command::Verify => match ro.verify() {
284 Ok(()) => {
285 if json {
286 writeln!(out, "{}", json!({ "ok": true })).ok();
287 } else {
288 writeln!(out, "integrity ok").ok();
289 }
290 0
291 }
292 Err(e) => report_err(&CliError::Host(e)),
293 },
294 _ => unreachable!("execute_ro only receives read-only commands"),
295 }
296}
297
298fn execute(
302 db: &Database,
303 cmd: &Command,
304 json: bool,
305 now: u64,
306 out: &mut impl Write,
307) -> Result<u8, CliError> {
308 match cmd {
309 Command::Remember {
310 text,
311 entity,
312 tags,
313 links,
314 meta,
315 valid_from,
316 } => {
317 let outcome = do_remember(db, now, text, entity, tags, links, meta, *valid_from, None)?;
318 render_remember(&outcome, json, out);
319 Ok(0)
320 }
321 Command::Revise {
322 id,
323 text,
324 entity,
325 tags,
326 links,
327 meta,
328 valid_from,
329 } => {
330 let outcome = do_remember(
331 db,
332 now,
333 text,
334 entity,
335 tags,
336 links,
337 meta,
338 *valid_from,
339 Some(FactId(*id)),
340 )?;
341 render_remember(&outcome, json, out);
342 Ok(0)
343 }
344 Command::Recall { .. } => {
345 let res = with_recall_query(cmd, now, None, |q| db.recall(q))?;
346 render_recall(&res, json, out);
347 Ok(0)
348 }
349 Command::Forget { id } => {
350 let fresh = db.forget(now, FactId(*id))?;
351 if json {
352 writeln!(out, "{}", json!({ "id": id, "forgotten": fresh })).ok();
353 } else if fresh {
354 writeln!(out, "forgot fact {id}").ok();
355 } else {
356 writeln!(out, "fact {id} was already gone").ok();
357 }
358 Ok(0)
359 }
360 Command::Link { src, rel, dst } => {
361 db.link(LinkInput {
362 now,
363 src,
364 rel,
365 dst,
366 provenance: None,
367 })?;
368 if json {
369 writeln!(out, "{}", json!({ "src": src, "rel": rel, "dst": dst })).ok();
370 } else {
371 writeln!(out, "linked {src} -{rel}-> {dst}").ok();
372 }
373 Ok(0)
374 }
375 Command::Show { id } => Ok(render_show(db.get(FactId(*id)), *id, json, out)),
376 Command::Stats => {
377 render_stats(&db.stats(), json, out);
378 Ok(0)
379 }
380 Command::Export => {
381 db.export_each(|f| write_export_line(out, &f));
382 Ok(0)
383 }
384 Command::Maintain => {
385 let report = db.maintain(now)?;
386 if json {
387 writeln!(
388 out,
389 "{}",
390 json!({
391 "purged": report.purged,
392 "bytes_before": report.bytes_before,
393 "bytes_after": report.bytes_after,
394 })
395 )
396 .ok();
397 } else {
398 writeln!(
399 out,
400 "maintained: purged {}, {} -> {} bytes",
401 report.purged, report.bytes_before, report.bytes_after
402 )
403 .ok();
404 }
405 Ok(0)
406 }
407 Command::Checkpoint => {
408 db.checkpoint(now)?;
409 if json {
410 writeln!(out, "{}", json!({ "ok": true })).ok();
411 } else {
412 writeln!(out, "checkpointed: journal flushed to snapshot").ok();
413 }
414 Ok(0)
415 }
416 Command::Verify => {
417 db.verify()?;
420 if json {
421 writeln!(out, "{}", json!({ "ok": true })).ok();
422 } else {
423 writeln!(out, "integrity ok").ok();
424 }
425 Ok(0)
426 }
427 Command::Scrub
429 | Command::Recover { .. }
430 | Command::Repl { .. }
431 | Command::Import { .. }
432 | Command::Help { .. } => {
433 unreachable!("this command is dispatched before execute")
434 }
435 }
436}
437
438fn do_recover(src: &Path, dst: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
442 match Database::recover(src, dst, settings.config.clone(), now_ms()) {
443 Ok(r) => {
444 if json {
445 writeln!(
446 out,
447 "{}",
448 json!({
449 "kept": r.kept,
450 "dropped_text": r.dropped_text,
451 "dropped_vector": r.dropped_vector,
452 "dropped_metadata": r.dropped_metadata,
453 "dst": dst.display().to_string(),
454 })
455 )
456 .ok();
457 } else {
458 writeln!(
459 out,
460 "recovered to {}: kept {}, dropped {} text + {} vector + {} metadata",
461 dst.display(),
462 r.kept,
463 r.dropped_text,
464 r.dropped_vector,
465 r.dropped_metadata
466 )
467 .ok();
468 }
469 0
470 }
471 Err(HostError::Locked { path }) => report_locked(&path),
472 Err(e) => report_err(&CliError::Host(e)),
473 }
474}
475
476fn do_scrub(path: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
481 let ro = match Database::open_readonly(path, settings.config.clone()) {
482 Ok(ro) => ro,
483 Err(HostError::Locked { path }) => return report_locked(&path),
484 Err(e) => return report_err(&CliError::Host(e)),
485 };
486 let scrub = match ro.scrub() {
487 Ok(s) => s,
488 Err(e) => return report_err(&CliError::Host(e)),
489 };
490 let mut done = 0u64;
491 let mut total = 0u64;
492 for step in scrub {
493 match step {
494 Ok(p) => {
495 done = p.done_bytes;
496 total = p.total_bytes;
497 }
498 Err(e) => return report_err(&CliError::Host(e)),
499 }
500 }
501 if json {
502 writeln!(out, "{}", json!({ "ok": true, "bytes": done })).ok();
503 } else {
504 writeln!(out, "scrub ok: {done}/{total} bytes verified").ok();
505 }
506 0
507}
508
509#[derive(Parser)]
512#[command(
513 no_binary_name = true,
514 name = "plugmem",
515 disable_help_subcommand = true
516)]
517struct ReplLine {
518 #[command(subcommand)]
519 command: Command,
520}
521
522fn split_line(line: &str) -> Vec<String> {
526 let mut tokens = Vec::new();
527 let mut cur = String::new();
528 let mut quote: Option<char> = None;
529 let mut has = false;
530 for c in line.chars() {
531 match quote {
532 Some(q) => {
533 if c == q {
534 quote = None;
535 } else {
536 cur.push(c);
537 }
538 }
539 None if c == '"' || c == '\'' => {
540 quote = Some(c);
541 has = true;
542 }
543 None if c.is_whitespace() => {
544 if has {
545 tokens.push(std::mem::take(&mut cur));
546 has = false;
547 }
548 }
549 None => {
550 cur.push(c);
551 has = true;
552 }
553 }
554 }
555 if has {
556 tokens.push(cur);
557 }
558 tokens
559}
560
561fn run_repl(
567 path: &Path,
568 settings: Settings,
569 json: bool,
570 input: impl BufRead,
571 out: &mut impl Write,
572) -> u8 {
573 let db = match settings.open(path) {
574 Ok(db) => db,
575 Err(HostError::Locked { path }) => return report_locked(&path),
576 Err(e) => return report_err(&CliError::Host(e)),
577 };
578 eprintln!("plugmem repl — one open handle, host speed. `help` for verbs, `exit` to quit.");
579 eprint!("plugmem> ");
580 for line in input.lines() {
581 let Ok(line) = line else { break };
582 let line = line.trim();
583 if line.is_empty() {
584 eprint!("plugmem> ");
585 continue;
586 }
587 if line == "exit" || line == "quit" {
588 break;
589 } else if line == "help" {
590 writeln!(
591 out,
592 "verbs: remember recall revise forget link show stats maintain checkpoint \
593 verify export import (scrub/recover stay one-shot) exit"
594 )
595 .ok();
596 } else {
597 run_repl_line(&db, line, json, out);
598 }
599 eprint!("plugmem> ");
600 }
601 eprintln!();
602 match db.checkpoint(now_ms()) {
604 Ok(()) => 0,
605 Err(e) => report_err(&CliError::Host(e)),
606 }
607}
608
609fn run_repl_line(db: &Database, line: &str, json: bool, out: &mut impl Write) {
612 let cmd = match ReplLine::try_parse_from(split_line(line)) {
613 Ok(r) => r.command,
614 Err(e) => {
616 let _ = writeln!(out, "{e}");
617 return;
618 }
619 };
620 match &cmd {
621 Command::Repl { .. } => {
622 let _ = writeln!(out, "already in a repl session");
623 }
624 Command::Scrub | Command::Recover { .. } => {
625 let _ = writeln!(
626 out,
627 "scrub/recover are one-shot commands; run them outside the repl"
628 );
629 }
630 _ => {
631 if let Err(e) = execute(db, &cmd, json, now_ms(), out) {
632 let _ = match &e {
633 CliError::Usage(m) => writeln!(out, "plugmem: {m}"),
634 CliError::Host(h) => writeln!(out, "plugmem: {h}"),
635 };
636 }
637 }
638 }
639}
640
641fn run_repl_ro(
654 path: &Path,
655 mut settings: Settings,
656 json: bool,
657 input: impl BufRead,
658 out: &mut impl Write,
659) -> u8 {
660 let mut ro = match Database::open_readonly(path, settings.config.clone()) {
661 Ok(ro) => ro,
662 Err(HostError::Locked { path }) => return report_locked(&path),
663 Err(e) => return report_err(&CliError::Host(e)),
666 };
667 eprintln!(
668 "plugmem repl --read-only — observing generation {} of another process's writer. \
669 `help` for verbs, `refresh`/`generation` for cross-process freshness, `exit` to quit.",
670 ro.generation()
671 );
672 eprint!("plugmem(ro)> ");
673 for line in input.lines() {
674 let Ok(line) = line else { break };
675 let line = line.trim();
676 if line.is_empty() {
677 eprint!("plugmem(ro)> ");
678 continue;
679 }
680 match line {
681 "exit" | "quit" => break,
682 "help" => {
683 writeln!(
684 out,
685 "read verbs: recall show stats export verify \
686 freshness: generation refresh exit \
687 (writes and scrub/recover are refused in a read-only session)"
688 )
689 .ok();
690 }
691 "generation" => {
694 let g = ro.generation();
695 if json {
696 writeln!(out, "{}", json!({ "generation": g })).ok();
697 } else {
698 writeln!(out, "generation {g}").ok();
699 }
700 }
701 "refresh" => match ro.refresh() {
702 Ok(advanced) => {
703 let g = ro.generation();
704 if json {
705 writeln!(out, "{}", json!({ "advanced": advanced, "generation": g })).ok();
706 } else if advanced {
707 writeln!(out, "refreshed → generation {g}").ok();
708 } else {
709 writeln!(out, "already current → generation {g}").ok();
710 }
711 }
712 Err(e) => {
713 writeln!(out, "plugmem: {e}").ok();
714 }
715 },
716 _ => run_repl_ro_line(&ro, &mut settings, line, json, out),
717 }
718 eprint!("plugmem(ro)> ");
719 }
720 eprintln!();
721 0
723}
724
725fn run_repl_ro_line(
728 ro: &ReadOnlyDatabase,
729 settings: &mut Settings,
730 line: &str,
731 json: bool,
732 out: &mut impl Write,
733) {
734 let cmd = match ReplLine::try_parse_from(split_line(line)) {
735 Ok(r) => r.command,
736 Err(e) => {
737 let _ = writeln!(out, "{e}");
738 return;
739 }
740 };
741 let readable = matches!(
742 &cmd,
743 Command::Show { .. }
744 | Command::Stats
745 | Command::Export
746 | Command::Verify
747 | Command::Recall { .. }
748 );
749 if !readable {
750 let _ = writeln!(
751 out,
752 "read-only session: only recall/show/stats/export/verify run \
753 (plus refresh/generation); writes and one-shot commands need a writer handle"
754 );
755 return;
756 }
757 let recall_vector = match embed_recall_query(settings, &cmd) {
760 Ok(v) => v,
761 Err(e) => {
762 let _ = match &e {
763 CliError::Usage(m) => writeln!(out, "plugmem: {m}"),
764 CliError::Host(h) => writeln!(out, "plugmem: {h}"),
765 };
766 return;
767 }
768 };
769 let _ = execute_ro(ro, &cmd, recall_vector.as_deref(), json, out);
770}
771
772fn embed_recall_query(
780 settings: &mut Settings,
781 cmd: &Command,
782) -> Result<Option<Vec<f32>>, CliError> {
783 let Command::Recall {
784 query: Some(text), ..
785 } = cmd
786 else {
787 return Ok(None);
788 };
789 let Some(embedder) = settings.embedder.as_mut() else {
790 return Ok(None);
791 };
792 let mut vectors = embedder.embed(&[text.as_str()]).map_err(CliError::Host)?;
793 Ok(vectors.pop())
794}
795
796fn with_recall_query<R>(
801 cmd: &Command,
802 now: u64,
803 override_vector: Option<&[f32]>,
804 f: impl FnOnce(RecallQuery<'_>) -> R,
805) -> R {
806 let Command::Recall {
807 query,
808 tags,
809 entities,
810 as_of,
811 range,
812 k,
813 closed,
814 } = cmd
815 else {
816 unreachable!("with_recall_query called on a non-recall command");
817 };
818 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
819 let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
820 let range_pair = range.as_ref().map(|v| (v[0], v[1]));
821 let q = RecallQuery {
825 now,
826 text: query.as_deref(),
827 vector: override_vector,
828 tags: &tag_refs,
829 entities: &ent_refs,
830 as_of: *as_of,
831 range: range_pair,
832 k: *k,
833 token_budget: None,
834 include_closed: *closed,
835 ef: None,
836 };
837 f(q)
838}
839
840fn render_recall(res: &RecallResult, json: bool, out: &mut impl Write) {
843 if json {
844 let facts: Vec<_> = res
845 .facts
846 .iter()
847 .map(|f| {
848 json!({
849 "id": f.id.0,
850 "score": f.score,
851 "sources": f.sources,
852 "recorded_at": f.recorded_at,
853 "valid_from": f.valid_from,
854 "valid_to": open_or(f.valid_to),
855 })
856 })
857 .collect();
858 writeln!(
859 out,
860 "{}",
861 json!({ "facts": facts, "rendered": res.rendered, "truncated": res.truncated })
862 )
863 .ok();
864 } else if res.rendered.is_empty() {
865 writeln!(out, "(nothing recalled)").ok();
866 } else {
867 writeln!(out, "{}", res.rendered).ok();
868 }
869}
870
871fn render_show(
873 fact: Option<plugmem_host::FactSnapshot>,
874 id: u32,
875 json: bool,
876 out: &mut impl Write,
877) -> u8 {
878 let Some(fact) = fact else {
879 if json {
880 writeln!(out, "{}", json!({ "id": id, "found": false })).ok();
881 } else {
882 writeln!(out, "fact {id} not found").ok();
883 }
884 return 1;
885 };
886 let r = &fact.record;
887 if json {
888 writeln!(
889 out,
890 "{}",
891 json!({
892 "id": r.id.0,
893 "text": fact.text,
894 "recorded_at": r.recorded_at,
895 "valid_from": r.valid_from,
896 "valid_to": open_or(r.valid_to),
897 "closed": r.is_closed(),
898 "tombstone": r.is_tombstone(),
899 "revises": (r.revises != FactId::NONE).then_some(r.revises.0),
900 "metadata": fact.metadata,
901 })
902 )
903 .ok();
904 } else {
905 writeln!(out, "fact {}", r.id.0).ok();
906 writeln!(out, " text {}", fact.text).ok();
907 writeln!(out, " recorded_at {}", r.recorded_at).ok();
908 write!(out, " valid [{}, ", r.valid_from).ok();
909 match r.valid_to {
910 VALID_TO_OPEN => writeln!(out, "open)").ok(),
911 to => writeln!(out, "{to})").ok(),
912 };
913 if r.revises != FactId::NONE {
914 writeln!(out, " revises fact {}", r.revises.0).ok();
915 }
916 if !fact.metadata.is_empty() {
917 let rendered = fact
918 .metadata
919 .iter()
920 .map(|(k, v)| format!("{k}={v}"))
921 .collect::<Vec<_>>()
922 .join(", ");
923 writeln!(out, " metadata {rendered}").ok();
924 }
925 if r.is_tombstone() {
926 writeln!(out, " state tombstoned").ok();
927 }
928 }
929 0
930}
931
932fn render_stats(s: &Stats, json: bool, out: &mut impl Write) {
934 if json {
935 writeln!(
936 out,
937 "{}",
938 json!({
939 "facts": s.facts,
940 "entities": s.entities,
941 "terms": s.terms,
942 "edges": s.edges,
943 "vectors": s.vectors,
944 "next_fact": s.next_fact,
945 "next_entity": s.next_entity,
946 "pool_bytes": s.pool_bytes,
947 })
948 )
949 .ok();
950 } else {
951 writeln!(out, "facts {}", s.facts).ok();
952 writeln!(out, "entities {}", s.entities).ok();
953 writeln!(out, "terms {}", s.terms).ok();
954 writeln!(out, "edges {}", s.edges).ok();
955 writeln!(out, "vectors {}", s.vectors).ok();
956 writeln!(out, "next_fact {}", s.next_fact).ok();
957 writeln!(out, "pool_bytes {}", s.pool_bytes).ok();
958 }
959}
960
961fn write_export_line(out: &mut impl Write, f: &ExportedFact) {
964 writeln!(
965 out,
966 "{}",
967 json!({
968 "text": f.text,
969 "entity": f.entity,
970 "tags": f.tags,
971 "metadata": f.metadata,
972 "recorded_at": f.recorded_at,
973 "valid_from": f.valid_from,
974 })
975 )
976 .ok();
977}
978
979#[cfg(test)]
982fn render_export(facts: &[ExportedFact], _json: bool, out: &mut impl Write) {
983 for f in facts {
984 write_export_line(out, f);
985 }
986}
987
988fn do_import(
995 db: &Database,
996 now: u64,
997 file: &std::path::Path,
998 batch_size: usize,
999 _out: &mut impl Write,
1000) -> Result<usize, CliError> {
1001 let f = std::fs::File::open(file)
1002 .map_err(|e| CliError::Usage(format!("reading {}: {e}", file.display())))?;
1003 let reader = io::BufReader::new(f);
1004 let mut count = 0usize;
1005 let mut batch: Vec<ParsedFact> = Vec::with_capacity(batch_size);
1006 for (i, line) in reader.lines().enumerate() {
1007 let line = line.map_err(|e| CliError::Usage(format!("line {}: {e}", i + 1)))?;
1008 let line = line.trim();
1009 if line.is_empty() {
1010 continue;
1011 }
1012 batch.push(parse_import_line(line, i + 1)?);
1013 if batch.len() >= batch_size {
1014 count += flush_import_batch(db, now, &batch)?;
1015 batch.clear();
1016 }
1017 }
1018 count += flush_import_batch(db, now, &batch)?;
1019 Ok(count)
1020}
1021
1022struct ParsedFact {
1025 text: String,
1026 entity: Option<String>,
1027 tags: Vec<String>,
1028 metadata: Vec<(String, String)>,
1029 valid_from: Option<u64>,
1030}
1031
1032fn parse_import_line(line: &str, lineno: usize) -> Result<ParsedFact, CliError> {
1035 let v: serde_json::Value =
1036 serde_json::from_str(line).map_err(|e| CliError::Usage(format!("line {lineno}: {e}")))?;
1037 let text = v["text"]
1038 .as_str()
1039 .ok_or_else(|| CliError::Usage(format!("line {lineno}: missing string \"text\"")))?
1040 .to_string();
1041 let entity = v["entity"].as_str().map(String::from);
1042 let tags = v["tags"]
1043 .as_array()
1044 .map(|a| {
1045 a.iter()
1046 .filter_map(|t| t.as_str().map(String::from))
1047 .collect()
1048 })
1049 .unwrap_or_default();
1050 let metadata = v["metadata"]
1053 .as_object()
1054 .map(|m| {
1055 m.iter()
1056 .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
1057 .collect::<BTreeMap<_, _>>()
1058 .into_iter()
1059 .collect()
1060 })
1061 .unwrap_or_default();
1062 let valid_from = v["valid_from"].as_u64();
1063 Ok(ParsedFact {
1064 text,
1065 entity,
1066 tags,
1067 metadata,
1068 valid_from,
1069 })
1070}
1071
1072fn flush_import_batch(db: &Database, now: u64, batch: &[ParsedFact]) -> Result<usize, CliError> {
1075 if batch.is_empty() {
1076 return Ok(0);
1077 }
1078 let tag_refs: Vec<Vec<&str>> = batch
1081 .iter()
1082 .map(|p| p.tags.iter().map(String::as_str).collect())
1083 .collect();
1084 let meta_refs: Vec<Vec<(&str, &str)>> = batch
1085 .iter()
1086 .map(|p| {
1087 p.metadata
1088 .iter()
1089 .map(|(k, v)| (k.as_str(), v.as_str()))
1090 .collect()
1091 })
1092 .collect();
1093 let inputs: Vec<RememberInput> = batch
1094 .iter()
1095 .zip(&tag_refs)
1096 .zip(&meta_refs)
1097 .map(|((p, tags), meta)| RememberInput {
1098 entity: p.entity.as_deref(),
1099 tags,
1100 metadata: (!meta.is_empty()).then_some(meta.as_slice()),
1101 valid_from: p.valid_from,
1102 ..RememberInput::text(now, &p.text)
1103 })
1104 .collect();
1105 db.remember_many(inputs)?;
1106 Ok(batch.len())
1107}
1108
1109#[allow(clippy::too_many_arguments)]
1111fn do_remember(
1112 db: &Database,
1113 now: u64,
1114 text: &str,
1115 entity: &Option<String>,
1116 tags: &[String],
1117 links: &[String],
1118 meta: &[String],
1119 valid_from: Option<u64>,
1120 revise: Option<FactId>,
1121) -> Result<RememberOutcome, CliError> {
1122 let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1123 let link_pairs = parse_links(links)?;
1124 let link_refs: Vec<(&str, &str)> = link_pairs
1125 .iter()
1126 .map(|(r, e)| (r.as_str(), e.as_str()))
1127 .collect();
1128 let meta_map = parse_meta(meta)?;
1132 let meta_refs: Vec<(&str, &str)> = meta_map
1133 .iter()
1134 .map(|(k, v)| (k.as_str(), v.as_str()))
1135 .collect();
1136 let input = RememberInput {
1137 entity: entity.as_deref(),
1138 tags: &tag_refs,
1139 links: &link_refs,
1140 metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1141 valid_from,
1142 ..RememberInput::text(now, text)
1143 };
1144 match revise {
1145 Some(target) => Ok(db.revise(target, input)?),
1146 None => Ok(db.remember(input)?),
1147 }
1148}
1149
1150fn parse_meta(meta: &[String]) -> Result<BTreeMap<String, String>, CliError> {
1153 let mut map = BTreeMap::new();
1154 for s in meta {
1155 let (k, v) = s
1156 .split_once('=')
1157 .filter(|(k, _)| !k.is_empty())
1158 .ok_or_else(|| CliError::Usage(format!("bad --meta `{s}` — expected KEY=VALUE")))?;
1159 map.insert(k.to_string(), v.to_string());
1160 }
1161 Ok(map)
1162}
1163
1164fn parse_links(links: &[String]) -> Result<Vec<(String, String)>, CliError> {
1166 links
1167 .iter()
1168 .map(|s| {
1169 s.split_once(':')
1170 .filter(|(r, e)| !r.is_empty() && !e.is_empty())
1171 .map(|(r, e)| (r.to_string(), e.to_string()))
1172 .ok_or_else(|| CliError::Usage(format!("bad --link `{s}` — expected REL:ENTITY")))
1173 })
1174 .collect()
1175}
1176
1177fn render_remember(outcome: &RememberOutcome, json: bool, out: &mut impl Write) {
1179 if json {
1180 let similar: Vec<_> = outcome
1181 .similar
1182 .iter()
1183 .map(|s| json!({ "id": s.id.0, "score": s.score, "reason": format!("{:?}", s.reason) }))
1184 .collect();
1185 writeln!(
1186 out,
1187 "{}",
1188 json!({
1189 "id": outcome.id.0,
1190 "entity": outcome.entity.map(|e| e.0),
1191 "similar": similar,
1192 })
1193 )
1194 .ok();
1195 } else {
1196 writeln!(out, "remembered fact {}", outcome.id.0).ok();
1197 for s in &outcome.similar {
1198 writeln!(
1199 out,
1200 " ~ similar to fact {} ({:?}, {:.2})",
1201 s.id.0, s.reason, s.score
1202 )
1203 .ok();
1204 }
1205 }
1206}
1207
1208fn open_or(valid_to: u64) -> Option<u64> {
1210 (valid_to != VALID_TO_OPEN).then_some(valid_to)
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use plugmem_host::Config;
1216
1217 use super::*;
1218
1219 struct StubEmbedder;
1221 impl plugmem_host::Embedder for StubEmbedder {
1222 fn dim(&self) -> usize {
1223 3
1224 }
1225 fn embed(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1226 Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1227 }
1228 }
1229
1230 fn recall_cmd(query: Option<&str>) -> Command {
1231 Command::Recall {
1232 query: query.map(str::to_owned),
1233 tags: vec![],
1234 entities: vec![],
1235 as_of: None,
1236 range: None,
1237 k: 0,
1238 closed: false,
1239 }
1240 }
1241
1242 fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1243 Settings {
1244 database_path: None,
1245 config: Config::default(),
1246 embedder,
1247 snapshot_every_ops: None,
1248 snapshot_journal_bytes: None,
1249 maintain_every_forgets: None,
1250 }
1251 }
1252
1253 #[test]
1254 fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1255 let mut with = settings_with(Some(Box::new(StubEmbedder)));
1257 assert_eq!(
1258 embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1259 Some(vec![0.1, 0.2, 0.3])
1260 );
1261
1262 let mut without = settings_with(None);
1264 assert_eq!(
1265 embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1266 None
1267 );
1268
1269 let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1271 assert_eq!(
1272 embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1273 None
1274 );
1275
1276 let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1278 assert_eq!(
1279 embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1280 None
1281 );
1282 }
1283
1284 #[test]
1285 fn split_line_honors_quotes_and_whitespace() {
1286 assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1287 assert_eq!(
1288 split_line(r#"remember "two words" --tag x"#),
1289 ["remember", "two words", "--tag", "x"]
1290 );
1291 assert_eq!(split_line(" recall 'a b' "), ["recall", "a b"]);
1292 assert_eq!(split_line(""), Vec::<String>::new());
1293 assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1295 }
1296
1297 #[test]
1298 fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1299 let (db, tmp) = TempDb::open();
1300 let path = tmp.0.join("m.plugmem");
1301 drop(db); let settings = settings_with(None);
1304 let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1306 let mut out = Vec::new();
1307 let code = run_repl(&path, settings, false, &script[..], &mut out);
1308 let text = String::from_utf8(out).unwrap();
1309
1310 assert_eq!(code, 0);
1311 assert!(text.contains("remembered fact 0"), "{text}");
1312 assert!(text.contains("tokio"), "{text}");
1313 assert!(text.contains("unrecognized subcommand"), "{text}");
1315
1316 let ro = Database::open_readonly(&path, Config::default()).unwrap();
1320 assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1321 }
1322
1323 #[test]
1324 fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1325 let (db, tmp) = TempDb::open();
1326 let path = tmp.0.join("m.plugmem");
1327 let mut sink = Vec::new();
1330 execute(
1331 &db,
1332 &remember("seed fact tokio", None, &[]),
1333 false,
1334 1_000,
1335 &mut sink,
1336 )
1337 .unwrap();
1338 db.checkpoint(1_001).unwrap();
1339
1340 let settings = settings_with(None);
1341 let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1343 let mut out = Vec::new();
1344 let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1345 let text = String::from_utf8(out).unwrap();
1346
1347 assert_eq!(code, 0);
1348 assert!(text.contains("generation 1"), "generation verb: {text}");
1349 assert!(text.contains("fact"), "stats ran: {text}");
1350 assert!(
1353 text.contains("already current → generation 1"),
1354 "refresh no-op: {text}"
1355 );
1356 assert!(text.contains("read-only session"), "write refused: {text}");
1358
1359 assert_eq!(db.stats().facts, 1);
1362 }
1363
1364 #[test]
1365 fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1366 let (db, tmp) = TempDb::open();
1367 let path = tmp.0.join("m.plugmem");
1368 let mut sink = Vec::new();
1369 execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1370 db.checkpoint(1_001).unwrap();
1371
1372 struct HookOnFirstRead<'a> {
1376 script: std::io::Cursor<&'a [u8]>,
1377 db: &'a Database,
1378 fired: bool,
1379 }
1380 impl std::io::Read for HookOnFirstRead<'_> {
1381 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1382 if !self.fired {
1383 self.fired = true;
1384 let mut s = Vec::new();
1387 execute(
1388 self.db,
1389 &remember("second", None, &[]),
1390 false,
1391 2_000,
1392 &mut s,
1393 )
1394 .unwrap();
1395 self.db.checkpoint(2_001).unwrap();
1396 }
1397 self.script.read(buf)
1398 }
1399 }
1400 let reader = std::io::BufReader::new(HookOnFirstRead {
1401 script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
1402 db: &db,
1403 fired: false,
1404 });
1405
1406 let mut out = Vec::new();
1407 let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
1408 let text = String::from_utf8(out).unwrap();
1409
1410 assert_eq!(code, 0);
1411 assert!(text.contains("refreshed → generation 2"), "advance: {text}");
1413 assert!(text.contains("fact"), "stats after refresh: {text}");
1415 assert_eq!(db.stats().facts, 2);
1416 }
1417
1418 #[test]
1419 fn read_only_repl_freshness_verbs_emit_json() {
1420 let (db, tmp) = TempDb::open();
1421 let path = tmp.0.join("m.plugmem");
1422 let mut sink = Vec::new();
1423 execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
1424 db.checkpoint(1_001).unwrap();
1425
1426 let script = b"generation\nrefresh\nexit\n";
1427 let mut out = Vec::new();
1428 let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
1429 let text = String::from_utf8(out).unwrap();
1430
1431 assert_eq!(code, 0);
1432 assert!(
1433 text.contains(r#""generation":1"#),
1434 "generation json: {text}"
1435 );
1436 assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
1437 }
1438
1439 struct TempDb(PathBuf);
1441 impl TempDb {
1442 fn open() -> (Database, Self) {
1443 let dir = std::env::temp_dir().join(format!(
1444 "plugmem-cli-{}-{}",
1445 std::process::id(),
1446 now_ms_unique()
1447 ));
1448 std::fs::create_dir_all(&dir).unwrap();
1449 let path = dir.join("m.plugmem");
1450 let (db, _) = Database::open(&path, Config::default()).unwrap();
1451 (db, TempDb(dir))
1452 }
1453 }
1454 impl Drop for TempDb {
1455 fn drop(&mut self) {
1456 let _ = std::fs::remove_dir_all(&self.0);
1457 }
1458 }
1459
1460 fn now_ms_unique() -> String {
1463 use std::sync::atomic::{AtomicU64, Ordering};
1464 static N: AtomicU64 = AtomicU64::new(0);
1465 format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
1466 }
1467
1468 fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
1469 let mut buf = Vec::new();
1470 let code = execute(db, cmd, json, now, &mut buf).expect("execute");
1471 (code, String::from_utf8(buf).unwrap())
1472 }
1473
1474 fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
1475 Command::Remember {
1476 text: text.into(),
1477 entity: entity.map(Into::into),
1478 tags: tags.iter().map(|t| (*t).into()).collect(),
1479 links: Vec::new(),
1480 meta: Vec::new(),
1481 valid_from: None,
1482 }
1483 }
1484
1485 fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
1486 Command::Remember {
1487 text: text.into(),
1488 entity: None,
1489 tags: Vec::new(),
1490 links: Vec::new(),
1491 meta: meta.iter().map(|m| (*m).into()).collect(),
1492 valid_from: None,
1493 }
1494 }
1495
1496 #[test]
1497 fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
1498 let (db, _t) = TempDb::open();
1499 let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
1501 assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
1502
1503 let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1505 assert!(
1506 human.contains("metadata page=3, uri=s3://b/x"),
1507 "{human}"
1508 );
1509 let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1511 let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
1512 assert_eq!(v["metadata"]["page"], "3");
1513 assert_eq!(v["metadata"]["uri"], "s3://b/x");
1514
1515 let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
1517 let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
1518 assert_eq!(line["metadata"]["uri"], "s3://b/x");
1519
1520 assert!(matches!(
1522 parse_meta(&["noequals".to_string()]),
1523 Err(CliError::Usage(_))
1524 ));
1525 assert!(parse_meta(&["=noKey".to_string()]).is_err());
1526 }
1527
1528 #[test]
1529 fn remember_then_recall_human_and_json() {
1530 let (db, _t) = TempDb::open();
1531 let (code, out) = run_cmd(
1532 &db,
1533 &remember("prefers tokio", Some("user"), &["pref"]),
1534 false,
1535 1_000,
1536 );
1537 assert_eq!(code, 0);
1538 assert!(out.starts_with("remembered fact 0"), "{out}");
1539
1540 let recall = Command::Recall {
1542 query: Some("tokio".into()),
1543 tags: Vec::new(),
1544 entities: Vec::new(),
1545 as_of: None,
1546 range: None,
1547 k: 0,
1548 closed: false,
1549 };
1550 let (code, out) = run_cmd(&db, &recall, false, 2_000);
1551 assert_eq!(code, 0);
1552 assert!(out.contains("tokio"), "{out}");
1553
1554 let (code, out) = run_cmd(&db, &recall, true, 2_000);
1556 assert_eq!(code, 0);
1557 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1558 assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
1559 }
1560
1561 #[test]
1562 fn recall_empty_is_ok_with_a_note() {
1563 let (db, _t) = TempDb::open();
1564 let recall = Command::Recall {
1565 query: Some("nothing here".into()),
1566 tags: Vec::new(),
1567 entities: Vec::new(),
1568 as_of: None,
1569 range: None,
1570 k: 0,
1571 closed: false,
1572 };
1573 let (code, out) = run_cmd(&db, &recall, false, 1_000);
1574 assert_eq!(code, 0);
1575 assert!(out.contains("nothing recalled"), "{out}");
1576 }
1577
1578 #[test]
1579 fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
1580 let (db, _t) = TempDb::open();
1581 run_cmd(
1582 &db,
1583 &remember("lives in Moscow", Some("user"), &[]),
1584 false,
1585 1_000,
1586 );
1587 let (_, out) = run_cmd(
1589 &db,
1590 &remember("lives in Moscow now", Some("user"), &[]),
1591 false,
1592 1_500,
1593 );
1594 assert!(out.contains("similar to fact"), "{out}");
1595
1596 let revise = Command::Revise {
1597 id: 0,
1598 text: "lives in Berlin".into(),
1599 entity: Some("user".into()),
1600 tags: Vec::new(),
1601 links: Vec::new(),
1602 meta: Vec::new(),
1603 valid_from: None,
1604 };
1605 let (code, out) = run_cmd(&db, &revise, false, 2_000);
1606 assert_eq!(code, 0);
1607 assert!(out.starts_with("remembered fact"), "{out}");
1608 }
1609
1610 #[test]
1611 fn show_found_and_missing() {
1612 let (db, _t) = TempDb::open();
1613 run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
1614
1615 let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1616 assert_eq!(code, 0);
1617 assert!(
1618 out.contains("a note") && out.contains("recorded_at 1000"),
1619 "{out}"
1620 );
1621
1622 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
1623 assert_eq!(code, 1, "missing id is a soft miss");
1624 assert!(out.contains("not found"), "{out}");
1625
1626 let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1628 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1629 assert_eq!(v["text"], "a note");
1630 assert_eq!(v["valid_to"], serde_json::Value::Null); }
1632
1633 #[test]
1634 fn forget_then_maintain_purges() {
1635 let (db, _t) = TempDb::open();
1636 run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
1637
1638 let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
1639 assert_eq!(code, 0);
1640 assert!(out.contains("forgot fact 0"), "{out}");
1641 let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
1643 assert!(out.contains("already gone"), "{out}");
1644
1645 let (code, out) = run_cmd(&db, &Command::Maintain, false, 3_000);
1646 assert_eq!(code, 0);
1647 assert!(out.contains("purged 1"), "{out}");
1648 }
1649
1650 #[test]
1651 fn link_and_stats_and_json() {
1652 let (db, _t) = TempDb::open();
1653 run_cmd(
1654 &db,
1655 &remember("uses tokio", Some("plugmem"), &[]),
1656 false,
1657 1_000,
1658 );
1659 let link = Command::Link {
1660 src: "plugmem".into(),
1661 rel: "depends_on".into(),
1662 dst: "tokio".into(),
1663 };
1664 let (code, out) = run_cmd(&db, &link, false, 2_000);
1665 assert_eq!(code, 0);
1666 assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
1667
1668 let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
1669 assert_eq!(code, 0);
1670 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1671 assert_eq!(v["facts"], 1);
1672 assert!(v["edges"].as_u64().unwrap() >= 1);
1673 }
1674
1675 #[test]
1676 fn bad_link_is_a_usage_error() {
1677 let (db, _t) = TempDb::open();
1678 let cmd = Command::Remember {
1679 text: "x".into(),
1680 entity: Some("user".into()),
1681 tags: Vec::new(),
1682 links: vec!["not-a-pair".into()],
1683 meta: Vec::new(),
1684 valid_from: None,
1685 };
1686 let mut buf = Vec::new();
1687 let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
1688 assert!(matches!(err, CliError::Usage(_)));
1689 }
1690
1691 #[test]
1692 fn as_of_time_travel_via_recall() {
1693 let (db, _t) = TempDb::open();
1694 run_cmd(
1695 &db,
1696 &remember("lives in Moscow", Some("user"), &[]),
1697 false,
1698 1_000,
1699 );
1700 let revise = Command::Revise {
1701 id: 0,
1702 text: "lives in Berlin".into(),
1703 entity: Some("user".into()),
1704 tags: Vec::new(),
1705 links: Vec::new(),
1706 meta: Vec::new(),
1707 valid_from: None,
1708 };
1709 run_cmd(&db, &revise, false, 2_000);
1710
1711 let as_of = Command::Recall {
1712 query: Some("lives".into()),
1713 tags: Vec::new(),
1714 entities: vec!["user".into()],
1715 as_of: Some(1_500),
1716 range: None,
1717 k: 0,
1718 closed: false,
1719 };
1720 let (_, out) = run_cmd(&db, &as_of, false, 3_000);
1721 assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
1722 }
1723
1724 #[test]
1725 fn every_command_has_a_json_shape() {
1726 let (db, _t) = TempDb::open();
1727 let (_, out) = run_cmd(
1729 &db,
1730 &remember("uses tokio", Some("plugmem"), &["pref"]),
1731 true,
1732 1_000,
1733 );
1734 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1735 assert_eq!(v["id"], 0);
1736 assert!(v["similar"].is_array());
1737
1738 let revise = Command::Revise {
1740 id: 0,
1741 text: "uses tokio now".into(),
1742 entity: Some("plugmem".into()),
1743 tags: Vec::new(),
1744 links: Vec::new(),
1745 meta: Vec::new(),
1746 valid_from: None,
1747 };
1748 let (_, out) = run_cmd(&db, &revise, true, 1_500);
1749 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
1750
1751 let link = Command::Link {
1753 src: "plugmem".into(),
1754 rel: "depends_on".into(),
1755 dst: "tokio".into(),
1756 };
1757 let (_, out) = run_cmd(&db, &link, true, 2_000);
1758 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1759 assert_eq!(v["rel"], "depends_on");
1760
1761 let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
1763 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1764 assert_eq!(v["forgotten"], true);
1765 let (_, out) = run_cmd(&db, &Command::Maintain, true, 3_000);
1766 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1767 assert!(v["purged"].as_u64().unwrap() >= 1);
1768
1769 let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
1771 assert_eq!(code, 1);
1772 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1773 assert_eq!(v["found"], false);
1774
1775 let recall = Command::Recall {
1777 query: None,
1778 tags: Vec::new(),
1779 entities: vec!["plugmem".into()],
1780 as_of: None,
1781 range: Some(vec![0, 10_000]),
1782 k: 4,
1783 closed: true,
1784 };
1785 let (_, out) = run_cmd(&db, &recall, true, 4_000);
1786 assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
1787 }
1788
1789 #[test]
1790 fn stats_human_lists_the_counters() {
1791 let (db, _t) = TempDb::open();
1792 run_cmd(&db, &remember("a", None, &[]), false, 1_000);
1793 let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
1794 assert_eq!(code, 0);
1795 assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
1796 }
1797
1798 #[test]
1799 fn show_json_of_a_revised_predecessor_is_closed() {
1800 let (db, _t) = TempDb::open();
1801 run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
1802 let revise = Command::Revise {
1803 id: 0,
1804 text: "v2".into(),
1805 entity: Some("e".into()),
1806 tags: Vec::new(),
1807 links: Vec::new(),
1808 meta: Vec::new(),
1809 valid_from: None,
1810 };
1811 run_cmd(&db, &revise, false, 2_000);
1812 let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
1814 assert!(out.contains("revises fact 0"), "{out}");
1815 }
1816
1817 #[test]
1818 fn resolve_db_path_prefers_the_flag() {
1819 let p = std::path::Path::new("/tmp/explicit.plugmem");
1820 assert_eq!(resolve_db_path(Some(p), None), PathBuf::from(p));
1821 let configured = std::path::Path::new("/tmp/configured.plugmem");
1822 assert_eq!(
1823 resolve_db_path(None, Some(configured)),
1824 PathBuf::from(configured)
1825 );
1826 let _ = resolve_db_path(None, None);
1829 }
1830
1831 #[test]
1832 fn settings_help_runs_without_opening_a_database() {
1833 let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
1834 let mut output = Vec::new();
1835 assert_eq!(run_parsed(cli, &mut output), 0);
1836 let output = String::from_utf8(output).unwrap();
1837 assert!(output.contains("plugmem settings"));
1838 assert!(output.contains("[database]"));
1839 assert!(output.contains("path (path string"));
1840 }
1841
1842 #[test]
1843 fn run_parsed_opens_runs_and_reports() {
1844 let dir = std::env::temp_dir().join(format!(
1845 "plugmem-run-{}-{}",
1846 std::process::id(),
1847 now_ms_unique()
1848 ));
1849 std::fs::create_dir_all(&dir).unwrap();
1850 let path = dir.join("m.plugmem");
1851 let cli = Cli {
1852 db: Some(path.clone()),
1853 config: None,
1854 json: false,
1855 command: Command::Stats,
1856 };
1857 let mut buf = Vec::new();
1858 let code = run_parsed(cli, &mut buf);
1859 assert_eq!(code, 0);
1860 assert!(String::from_utf8(buf).unwrap().contains("facts"));
1861 let _ = std::fs::remove_dir_all(&dir);
1862 }
1863
1864 #[test]
1865 fn run_parsed_on_a_locked_database_returns_one() {
1866 let (_held, dir) = {
1867 let dir = std::env::temp_dir().join(format!(
1868 "plugmem-lock-{}-{}",
1869 std::process::id(),
1870 now_ms_unique()
1871 ));
1872 std::fs::create_dir_all(&dir).unwrap();
1873 let path = dir.join("m.plugmem");
1874 (Database::open(&path, Config::default()).unwrap(), dir)
1875 };
1876 let cli = Cli {
1877 db: Some(dir.join("m.plugmem")),
1878 config: None,
1879 json: false,
1880 command: Command::Stats,
1881 };
1882 let mut buf = Vec::new();
1883 assert_eq!(run_parsed(cli, &mut buf), 1);
1884 let _ = std::fs::remove_dir_all(&dir);
1885 }
1886
1887 #[test]
1888 fn run_parsed_propagates_a_usage_error_as_two() {
1889 let dir = std::env::temp_dir().join(format!(
1890 "plugmem-usage-{}-{}",
1891 std::process::id(),
1892 now_ms_unique()
1893 ));
1894 std::fs::create_dir_all(&dir).unwrap();
1895 let cli = Cli {
1896 db: Some(dir.join("m.plugmem")),
1897 config: None,
1898 json: false,
1899 command: Command::Remember {
1900 text: "x".into(),
1901 entity: None,
1902 tags: Vec::new(),
1903 links: vec!["bad".into()],
1904 meta: Vec::new(),
1905 valid_from: None,
1906 },
1907 };
1908 let mut buf = Vec::new();
1909 assert_eq!(run_parsed(cli, &mut buf), 2);
1910 let _ = std::fs::remove_dir_all(&dir);
1911 }
1912
1913 struct Scratch(PathBuf);
1915 impl Scratch {
1916 fn new(tag: &str) -> Self {
1917 let dir = std::env::temp_dir().join(format!(
1918 "plugmem-cli-{tag}-{}-{}",
1919 std::process::id(),
1920 now_ms_unique()
1921 ));
1922 std::fs::create_dir_all(&dir).unwrap();
1923 Scratch(dir)
1924 }
1925 }
1926 impl Drop for Scratch {
1927 fn drop(&mut self) {
1928 let _ = std::fs::remove_dir_all(&self.0);
1929 }
1930 }
1931
1932 #[test]
1933 fn export_import_roundtrip_preserves_open_facts() {
1934 let (a, _ta) = TempDb::open();
1939 run_cmd(
1940 &a,
1941 &Command::Remember {
1942 text: "prefers tokio".into(),
1943 entity: Some("user".into()),
1944 tags: vec!["pref".into(), "lang".into()],
1945 links: Vec::new(),
1946 meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
1947 valid_from: Some(500),
1948 },
1949 false,
1950 1_000,
1951 );
1952 run_cmd(
1953 &a,
1954 &remember("lives in Moscow", Some("user"), &[]),
1955 false,
1956 1_100,
1957 ); run_cmd(
1959 &a,
1960 &Command::Revise {
1961 id: 1,
1962 text: "lives in Berlin".into(),
1963 entity: Some("user".into()),
1964 tags: vec!["geo".into()],
1965 links: Vec::new(),
1966 meta: Vec::new(),
1967 valid_from: None,
1968 },
1969 false,
1970 1_200,
1971 ); run_cmd(&a, &remember("junk", None, &[]), false, 1_300); run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); run_cmd(
1975 &a,
1976 &remember("uses rust", Some("plugmem"), &["lang"]),
1977 false,
1978 1_500,
1979 ); let mut dump = Vec::new();
1983 render_export(&a.export(), false, &mut dump);
1984 let scratch = Scratch::new("roundtrip");
1985 let file = scratch.0.join("dump.jsonl");
1986 std::fs::write(&file, &dump).unwrap();
1987
1988 let (b, _tb) = TempDb::open();
1990 let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
1991
1992 let key = |f: &ExportedFact| {
1994 let mut tags = f.tags.clone();
1995 tags.sort();
1996 (f.text.clone(), f.entity.clone(), tags, f.valid_from)
1997 };
1998 let mut ak: Vec<_> = a.export().iter().map(key).collect();
1999 let mut bk: Vec<_> = b.export().iter().map(key).collect();
2000 ak.sort();
2001 bk.sort();
2002 assert_eq!(n, ak.len());
2003 assert_eq!(
2004 ak, bk,
2005 "roundtrip must preserve text/entity/tags/valid_from"
2006 );
2007
2008 let b_open = b.export();
2011 assert!(b_open.iter().any(|f| f.text == "prefers tokio"
2012 && f.valid_from == 500
2013 && f.entity.as_deref() == Some("user")
2014 && f.tags == vec!["pref".to_string(), "lang".to_string()]
2015 && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
2016 && f.metadata.get("src").map(String::as_str) == Some("chat")));
2017 assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
2018 assert!(b_open.iter().any(|f| f.text == "uses rust"));
2019 assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
2020 assert!(!b_open.iter().any(|f| f.text == "junk"));
2021 }
2022
2023 #[test]
2024 fn export_command_emits_jsonl_regardless_of_json_flag() {
2025 let (db, _t) = TempDb::open();
2026 run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
2027 for json in [false, true] {
2028 let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
2029 assert_eq!(code, 0);
2030 let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2031 assert_eq!(v["text"], "a fact");
2032 assert_eq!(v["entity"], "e");
2033 assert_eq!(v["tags"][0], "t");
2034 }
2035 }
2036
2037 #[test]
2038 fn import_command_counts_and_rejects_bad_lines() {
2039 let (db, _t) = TempDb::open();
2040 let scratch = Scratch::new("import");
2041 let good = scratch.0.join("in.jsonl");
2042 std::fs::write(
2043 &good,
2044 "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
2045 )
2046 .unwrap();
2047 let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
2049 assert_eq!(n, 2, "both facts imported, blank line skipped");
2050
2051 let bad = scratch.0.join("bad.jsonl");
2052 std::fs::write(&bad, "not json at all\n").unwrap();
2053 let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
2054 assert!(matches!(err, CliError::Usage(_)));
2055 }
2056
2057 #[test]
2058 fn import_batch_size_does_not_change_the_result() {
2059 let scratch = Scratch::new("import-batch");
2062 let file = scratch.0.join("facts.jsonl");
2063 let mut jsonl = String::new();
2064 for i in 0..5 {
2065 jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
2066 }
2067 std::fs::write(&file, &jsonl).unwrap();
2068
2069 let (a, _ta) = TempDb::open();
2070 let (b, _tb) = TempDb::open();
2071 let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
2072 let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
2073
2074 assert_eq!(na, 5);
2075 assert_eq!(nb, 5);
2076 let texts = |db: &Database| {
2077 let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
2078 t.sort();
2079 t
2080 };
2081 assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
2082 }
2083
2084 #[test]
2085 fn config_table_feeds_settings_and_the_cli_batch_size() {
2086 let scratch = Scratch::new("settings");
2090 let cfgfile = scratch.0.join("config.toml");
2091 std::fs::write(
2092 &cfgfile,
2093 "[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n\
2094 [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
2095 )
2096 .unwrap();
2097 let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
2098 let s = Settings::from_table(table.as_ref()).unwrap();
2099 assert_eq!(s.config.dim, 512);
2100 assert!(s.embedder.is_none());
2101 assert_eq!(s.snapshot_every_ops, Some(64));
2102 assert_eq!(read_batch_size(table.as_ref()), Some(200));
2103
2104 assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
2106 }
2107
2108 #[test]
2109 fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
2110 let scratch = Scratch::new("checkpoint-cmd");
2111 let path = scratch.0.join("m.plugmem");
2112
2113 let remember = Cli {
2115 db: Some(path.clone()),
2116 config: None,
2117 json: false,
2118 command: Command::Remember {
2119 text: "hello tokio".into(),
2120 entity: None,
2121 tags: Vec::new(),
2122 links: Vec::new(),
2123 meta: Vec::new(),
2124 valid_from: None,
2125 },
2126 };
2127 assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
2128
2129 let checkpoint = |json| Cli {
2131 db: Some(path.clone()),
2132 config: None,
2133 json,
2134 command: Command::Checkpoint,
2135 };
2136 let mut buf = Vec::new();
2137 assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
2138 assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
2139
2140 let mut buf = Vec::new();
2142 assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
2143 let v: serde_json::Value =
2144 serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
2145 assert_eq!(v["ok"], true);
2146
2147 let scrub = Cli {
2150 db: Some(path),
2151 config: None,
2152 json: false,
2153 command: Command::Scrub,
2154 };
2155 let mut buf = Vec::new();
2156 assert_eq!(run_parsed(scrub, &mut buf), 0);
2157 assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
2158 }
2159
2160 #[test]
2161 fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
2162 let scratch = Scratch::new("ro-route");
2163 let path = scratch.0.join("m.plugmem");
2164 {
2165 let (db, _) = Database::open(&path, Config::default()).unwrap();
2166 db.remember(RememberInput::text(1_000, "hello tokio"))
2167 .unwrap();
2168 db.checkpoint(2_000).unwrap(); }
2170 let cli = Cli {
2172 db: Some(path.clone()),
2173 config: None,
2174 json: false,
2175 command: Command::Stats,
2176 };
2177 let mut buf = Vec::new();
2178 assert_eq!(run_parsed(cli, &mut buf), 0);
2179 assert!(String::from_utf8(buf).unwrap().contains("facts"));
2180
2181 let cli = Cli {
2183 db: Some(path),
2184 config: None,
2185 json: false,
2186 command: Command::Recall {
2187 query: Some("tokio".into()),
2188 tags: Vec::new(),
2189 entities: Vec::new(),
2190 as_of: None,
2191 range: None,
2192 k: 0,
2193 closed: false,
2194 },
2195 };
2196 let mut buf = Vec::new();
2197 assert_eq!(run_parsed(cli, &mut buf), 0);
2198 assert!(String::from_utf8(buf).unwrap().contains("tokio"));
2199 }
2200}