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