Skip to main content

plugmem_cli/
lib.rs

1//! The crate README is included below as module documentation, which makes
2//! every Rust example in it a doctest: a README that drifts from the API stops
3//! compiling instead of quietly lying.
4#![doc = include_str!("../README.md")]
5//! `plugmem` — the command-line surface over the
6//! [temporal-memory engine](https://docs.rs/plugmem-core/latest), a thin wrapper around
7//! [`plugmem_host::Database`]. Parse the arguments, call one
8//! engine verb, render the result — human text by default, `--json` for
9//! tooling and agents. No memory logic lives here; that is the engine's.
10//!
11//! Exit codes: `0` success; `1` a soft miss (the target fact does not
12//! exist, or the database is locked by another process); `2` a usage or
13//! runtime error. This makes the binary scriptable as a gate.
14//!
15//! The logic is in this library (not `main.rs`) so it is unit-testable:
16//! [`run`] wires argv and the database, and `execute` runs one command
17//! against an open [`Database`] into any writer.
18
19mod cli;
20mod config;
21mod workspace;
22
23use std::collections::BTreeMap;
24use std::io::{self, BufRead, Write};
25use std::path::{Path, PathBuf};
26use std::process::ExitCode;
27use std::time::{SystemTime, UNIX_EPOCH};
28
29use clap::Parser;
30use plugmem_host::{
31    Database, ExportedFact, FactId, HostError, LinkInput, MaintenanceMode, MaintenanceOptions,
32    ReadOnlyDatabase, RecallQuery, RecallResult, RememberInput, RememberOutcome, Settings, Stats,
33    UnlinkInput, VALID_TO_OPEN,
34};
35use serde_json::json;
36
37use crate::cli::{Cli, Command, HelpTopic, MaintainMode};
38use crate::config::read_batch_size;
39
40/// Environment variable naming the database file (below the `--db` flag).
41pub(crate) const ENV_DB: &str = "PLUGMEM_DB";
42/// Last-resort relative database name if the platform data directory is unavailable.
43pub(crate) const DEFAULT_DB: &str = "plugmem.db";
44
45/// A failure before or during a command: a runtime engine/host error, or a
46/// usage error (a malformed argument the parser could not catch).
47#[derive(Debug)]
48pub(crate) enum CliError {
49    Host(HostError),
50    Usage(String),
51}
52
53impl From<HostError> for CliError {
54    fn from(e: HostError) -> Self {
55        CliError::Host(e)
56    }
57}
58
59/// Wall-clock now in unix milliseconds (the engine keeps no clock).
60pub(crate) fn now_ms() -> u64 {
61    SystemTime::now()
62        .duration_since(UNIX_EPOCH)
63        .map(|d| d.as_millis() as u64)
64        .unwrap_or(0)
65}
66
67/// Parses argv and runs one command, mapping the result to a process exit
68/// code. The binary's `main` is a one-liner over this; the wiring itself is
69/// `run_parsed`, which is unit-testable (only `Cli::parse` is not).
70pub fn run() -> ExitCode {
71    let stdout = io::stdout();
72    ExitCode::from(run_parsed(Cli::parse(), &mut stdout.lock()))
73}
74
75/// The testable core of [`run`]: resolve settings and the database path,
76/// open the right handle, run the command into `out`, return the exit code
77/// (`0` ok, `1` soft miss / locked, `2` error). Errors go to stderr.
78fn run_parsed(cli: Cli, out: &mut impl Write) -> u8 {
79    if let Command::Help { topic } = &cli.command {
80        return execute_help(topic, cli.json, out);
81    }
82
83    // Read config.toml once: the shared loader builds engine/embedder/
84    // maintenance settings; the CLI reads its own `[maintenance].batch_size`
85    // from the same table (used by `import` below).
86    let table = match plugmem_host::read_config(cli.config.as_deref()) {
87        Ok(t) => t,
88        Err(e) => return report_err(&e.into()),
89    };
90    let cfg_batch_size = read_batch_size(table.as_ref());
91    let mut settings = match Settings::from_table(table.as_ref()) {
92        Ok(s) => s,
93        Err(e) => return report_err(&e.into()),
94    };
95    // A workspace is opt-in: with no flag, no environment variable and no
96    // `[workspace].dir`, `root` is `None` and everything below behaves exactly
97    // as it did before workspaces existed — `--db` is a path, and the
98    // `workspace` group says there is nothing to manage.
99    let root = workspace::resolve_root(cli.workspace.as_deref(), &settings);
100    if let Command::Workspace { command } = &cli.command {
101        return match workspace::execute(command, root, settings, cli.json, out) {
102            Ok(code) => code,
103            Err(e) => {
104                let _ = out.flush();
105                report_err(&e)
106            }
107        };
108    }
109    let path = resolve_db_path(
110        cli.db.as_deref(),
111        settings.database_path.as_deref(),
112        root.as_ref(),
113    );
114    if let Err(e) = workspace::ensure_dir(&path, root.as_ref()) {
115        return report_err(&e);
116    }
117
118    // `recover` is a standalone salvage on file paths — it opens the source
119    // itself (under an exclusive lock) and writes a fresh destination, so it
120    // runs before the normal open. `scrub` is a byte-level container check over
121    // a read-only (shared-lock) open, which requires a checkpointed database.
122    match &cli.command {
123        Command::Recover { dst } => return do_recover(&path, dst, &settings, cli.json, out),
124        Command::Scrub => return do_scrub(&path, &settings, cli.json, out),
125        // The interactive session opens one handle and reads commands from
126        // stdin, so it is dispatched before the per-command open below. The
127        // read-only variant observes another process's writer over a shared
128        // mmap; the default variant opens the single writer handle.
129        Command::Repl { read_only: true } => {
130            return run_repl_ro(&path, settings, cli.json, io::stdin().lock(), out);
131        }
132        Command::Repl { read_only: false } => {
133            return run_repl(&path, settings, cli.json, io::stdin().lock(), out);
134        }
135        _ => {}
136    }
137
138    // Read-only commands open the snapshot zero-copy (mmap, shared lock) and
139    // coexist with a live writer process (Variant 2 MVCC) — they never take the
140    // writer lock. `verify` is a pure content check, so it belongs here too.
141    // `recall` embeds its text query *before* the open (mirroring the host's
142    // "embed outside the lock" rule) so it can search by vector on the read-only
143    // path, which carries no embedder. A dirty (un-checkpointed) journal forbids
144    // a read-only open, so those fall through to the read-write path.
145    let readonly_ok = matches!(
146        &cli.command,
147        Command::Show { .. }
148            | Command::Stats
149            | Command::Export
150            | Command::Verify
151            | Command::Recall { .. }
152    );
153    if readonly_ok {
154        let recall_vector = match embed_recall_query(&mut settings, &cli.command) {
155            Ok(v) => v,
156            Err(e) => return report_err(&e),
157        };
158        match Database::open_readonly(&path, settings.config.clone()) {
159            Ok(ro) => {
160                return execute_ro(&ro, &cli.command, recall_vector.as_deref(), cli.json, out);
161            }
162            Err(HostError::Locked { path }) => return report_locked(&path),
163            // Any other failure — a missing snapshot (fresh db), a dirty
164            // journal (NeedsCheckpoint), or a corrupt image — is handled by
165            // the read-write path: it creates/checkpoints, or surfaces the
166            // same corruption as a typed error.
167            Err(_) => {}
168        }
169    }
170
171    // `cfg_batch_size` was read from the config table above (before `open`
172    // consumes `settings`); the `--batch` flag still wins over it.
173    let db = match settings.open(&path) {
174        Ok(db) => db,
175        Err(HostError::Locked { path }) => return report_locked(&path),
176        Err(e) => return report_err(&CliError::Host(e)),
177    };
178    // Import is dispatched here, not in `execute`: its batch size comes from the
179    // `--batch` flag or `[maintenance].batch_size` (flag > config > default).
180    if let Command::Import { file, batch } = &cli.command {
181        let batch_size = batch
182            .or(cfg_batch_size.map(|n| n as usize))
183            .unwrap_or(DEFAULT_IMPORT_BATCH)
184            .max(1);
185        return match do_import(&db, now_ms(), file, batch_size, out) {
186            Ok(report) => {
187                if cli.json {
188                    writeln!(
189                        out,
190                        "{}",
191                        json!({ "imported": report.facts, "edges": report.edges })
192                    )
193                    .ok();
194                } else if report.edges == 0 {
195                    writeln!(out, "imported {} facts", report.facts).ok();
196                } else {
197                    writeln!(
198                        out,
199                        "imported {} facts and {} edges",
200                        report.facts, report.edges
201                    )
202                    .ok();
203                }
204                0
205            }
206            Err(e) => {
207                let _ = out.flush();
208                report_err(&e)
209            }
210        };
211    }
212    match execute(&db, &cli.command, cli.json, now_ms(), out) {
213        Ok(code) => code,
214        Err(e) => {
215            let _ = out.flush();
216            report_err(&e)
217        }
218    }
219}
220
221/// Default facts-per-batch for `import` when neither `--batch` nor
222/// `[maintenance].batch_size` is set — safe for provider batch limits.
223const DEFAULT_IMPORT_BATCH: usize = 128;
224
225/// Writes one error, plus whatever follow-up it carries.
226///
227/// Every path that shows a failure goes through here — the one-shot commands
228/// and both repls — so a message cannot say one thing in one of them and
229/// something else in another. The follow-up matters for a pool ceiling in
230/// particular: on its own that error is a bare byte count.
231fn write_err(out: &mut impl Write, e: &CliError) {
232    let _ = match e {
233        CliError::Usage(msg) => writeln!(out, "plugmem: {msg}"),
234        CliError::Host(err) => {
235            writeln!(out, "plugmem: {err}").and_then(|()| match err.capacity_hint() {
236                Some(hint) => writeln!(out, "plugmem: {hint}"),
237                None => Ok(()),
238            })
239        }
240    };
241}
242
243/// Prints an error to stderr and returns its exit code (`2`).
244fn report_err(e: &CliError) -> u8 {
245    write_err(&mut std::io::stderr(), e);
246    2
247}
248
249/// Prints the locked message and returns its exit code (`1`).
250fn report_locked(path: &std::path::Path) -> u8 {
251    eprintln!(
252        "plugmem: database is locked by another process: {}",
253        path.display()
254    );
255    1
256}
257
258/// Database path precedence: `--db` flag > `$PLUGMEM_DB` >
259/// `[database].path` > the platform default.
260///
261/// With a workspace configured, the first two may name a memory instead of a
262/// path — see [`workspace::resolve_target`]. `[database].path` and the platform
263/// default are always paths: they are file settings, not names.
264fn resolve_db_path(
265    flag: Option<&str>,
266    config_path: Option<&std::path::Path>,
267    root: Option<&PathBuf>,
268) -> PathBuf {
269    flag.map(|value| workspace::resolve_target(value, root))
270        .or_else(|| {
271            std::env::var_os(ENV_DB).map(|v| workspace::resolve_target(&v.to_string_lossy(), root))
272        })
273        .or_else(|| config_path.map(PathBuf::from))
274        .or_else(plugmem_host::default_database_path)
275        .unwrap_or_else(|| PathBuf::from(DEFAULT_DB))
276}
277
278/// Render the opt-in detailed help topics without reading a config file or
279/// opening a database.
280fn execute_help(topic: &HelpTopic, json_output: bool, out: &mut impl Write) -> u8 {
281    match topic {
282        HelpTopic::Settings => {
283            if json_output {
284                let help = plugmem_host::settings_help();
285                let settings: Vec<_> = help
286                    .docs()
287                    .iter()
288                    .map(|doc| {
289                        json!({
290                            "section": doc.section,
291                            "key": doc.key,
292                            "type": doc.value_type,
293                            "default": doc.default,
294                            "description": doc.description,
295                            "scope": doc.scope.as_str(),
296                        })
297                    })
298                    .collect();
299                let value = json!({
300                    "topic": "settings",
301                    "config_path_precedence": help.config_path_precedence(),
302                    "default_config_path": plugmem_host::default_config_path()
303                        .map(|path| path.display().to_string()),
304                    "settings": settings,
305                });
306                writeln!(out, "{value}").ok();
307            } else {
308                write!(out, "{}", plugmem_host::settings_help().render_human()).ok();
309            }
310            0
311        }
312    }
313}
314
315/// Runs a read-only command over a zero-copy [`ReadOnlyDatabase`] (mmap,
316/// shared lock). Only the commands `run_parsed` routes here appear.
317fn execute_ro(
318    ro: &ReadOnlyDatabase,
319    cmd: &Command,
320    recall_vector: Option<&[f32]>,
321    json: bool,
322    out: &mut impl Write,
323) -> u8 {
324    match cmd {
325        Command::Recall { .. } => {
326            match with_recall_query(cmd, now_ms(), recall_vector, |q| ro.recall(q)) {
327                Ok(res) => {
328                    render_recall(&res, json, out);
329                    0
330                }
331                Err(e) => report_err(&CliError::Host(e)),
332            }
333        }
334        Command::Show { id } => render_show(ro.get(FactId(*id)), *id, json, out),
335        Command::Stats => {
336            render_stats(&ro.stats(), json, out);
337            0
338        }
339        Command::Export => {
340            ro.export_each(|f| write_export_line(out, &f));
341            ro.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
342            0
343        }
344        // A clean image returns Ok; corruption is a typed error mapped to exit 2.
345        Command::Verify => match ro.verify() {
346            Ok(()) => {
347                if json {
348                    writeln!(out, "{}", json!({ "ok": true })).ok();
349                } else {
350                    writeln!(out, "integrity ok").ok();
351                }
352                0
353            }
354            Err(e) => report_err(&CliError::Host(e)),
355        },
356        _ => unreachable!("execute_ro only receives read-only commands"),
357    }
358}
359
360/// Runs one command against an open database, writing the result to `out`.
361/// Returns the process exit code (`0` ok, `1` soft miss). Split from
362/// [`run`] so tests drive it directly against a temp database.
363fn execute(
364    db: &Database,
365    cmd: &Command,
366    json: bool,
367    now: u64,
368    out: &mut impl Write,
369) -> Result<u8, CliError> {
370    match cmd {
371        Command::Remember {
372            text,
373            entity,
374            tags,
375            links,
376            meta,
377            valid_from,
378            vector,
379        } => {
380            let outcome = do_remember(
381                db,
382                now,
383                text,
384                entity,
385                tags,
386                links,
387                meta,
388                *valid_from,
389                vector,
390                None,
391            )?;
392            render_remember(&outcome, json, out);
393            Ok(0)
394        }
395        Command::Revise {
396            id,
397            text,
398            entity,
399            tags,
400            links,
401            meta,
402            valid_from,
403            vector,
404        } => {
405            let outcome = do_remember(
406                db,
407                now,
408                text,
409                entity,
410                tags,
411                links,
412                meta,
413                *valid_from,
414                vector,
415                Some(FactId(*id)),
416            )?;
417            render_remember(&outcome, json, out);
418            Ok(0)
419        }
420        Command::Recall { .. } => {
421            let res = with_recall_query(cmd, now, None, |q| db.recall(q))?;
422            render_recall(&res, json, out);
423            Ok(0)
424        }
425        Command::Forget { id } => {
426            let fresh = db.forget(now, FactId(*id))?;
427            if json {
428                writeln!(out, "{}", json!({ "id": id, "forgotten": fresh })).ok();
429            } else if fresh {
430                writeln!(out, "forgot fact {id}").ok();
431            } else {
432                writeln!(out, "fact {id} was already gone").ok();
433            }
434            Ok(0)
435        }
436        Command::Link {
437            src,
438            rel,
439            dst,
440            provenance,
441        } => {
442            db.link(LinkInput {
443                now,
444                src,
445                rel,
446                dst,
447                provenance: provenance.map(FactId),
448            })?;
449            if json {
450                writeln!(out, "{}", json!({ "src": src, "rel": rel, "dst": dst })).ok();
451            } else {
452                writeln!(out, "linked {src} -{rel}-> {dst}").ok();
453            }
454            Ok(0)
455        }
456        Command::Unlink { src, rel, dst } => {
457            let fresh = db.unlink(UnlinkInput { now, src, rel, dst })?;
458            if json {
459                writeln!(
460                    out,
461                    "{}",
462                    json!({ "src": src, "rel": rel, "dst": dst, "unlinked": fresh })
463                )
464                .ok();
465            } else if fresh {
466                writeln!(out, "unlinked {src} -{rel}-> {dst}").ok();
467            } else {
468                writeln!(out, "edge {src} -{rel}-> {dst} was already absent").ok();
469            }
470            Ok(0)
471        }
472        Command::Show { id } => Ok(render_show(db.get(FactId(*id)), *id, json, out)),
473        Command::Stats => {
474            render_stats(&db.stats(), json, out);
475            Ok(0)
476        }
477        Command::Export => {
478            db.export_each(|f| write_export_line(out, &f));
479            db.export_edges_each(|src, rel, dst, fact| write_export_edge(out, src, rel, dst, fact));
480            Ok(0)
481        }
482        Command::Maintain { mode } => {
483            let report = db.maintain_with_options(now, maintenance_options(*mode))?;
484            if json {
485                writeln!(
486                    out,
487                    "{}",
488                    json!({
489                        "purged": report.purged,
490                        "bytes_before": report.bytes_before,
491                        "bytes_after": report.bytes_after,
492                        "no_op": report.no_op,
493                        "tombstones_before": report.tombstones_before,
494                        "facts_before": report.facts_before,
495                        "facts_after": report.facts_after,
496                        "vectors_before": report.vectors_before,
497                        "vectors_after": report.vectors_after,
498                        "hnsw_indexed_before": report.hnsw_indexed_before,
499                        "hnsw_indexed_after": report.hnsw_indexed_after,
500                        "structural_compacted": report.structural_compacted,
501                        "bm25_compacted": report.bm25_compacted,
502                        "bm25_reindexed": report.bm25_reindexed,
503                        "hnsw_rebuilt": report.hnsw_rebuilt,
504                        "hnsw_remapped": report.hnsw_remapped,
505                        "hnsw_inserted": report.hnsw_inserted,
506                        "edges_compacted": report.edges_compacted,
507                        "edges_before": report.edges_before,
508                        "edge_versions_before": report.edge_versions_before,
509                    })
510                )
511                .ok();
512            } else {
513                writeln!(
514                    out,
515                    "maintained: purged {}, {} -> {} bytes, hnsw +{}, bm25 {}{}{}",
516                    report.purged,
517                    report.bytes_before,
518                    report.bytes_after,
519                    report.hnsw_inserted,
520                    if report.bm25_reindexed {
521                        "reindexed"
522                    } else if report.bm25_compacted {
523                        "compacted"
524                    } else {
525                        "unchanged"
526                    },
527                    if report.edges_compacted {
528                        ", edges repacked"
529                    } else {
530                        ""
531                    },
532                    if report.no_op { " (no-op)" } else { "" }
533                )
534                .ok();
535            }
536            Ok(0)
537        }
538        Command::Checkpoint => {
539            db.checkpoint(now)?;
540            if json {
541                writeln!(out, "{}", json!({ "ok": true })).ok();
542            } else {
543                writeln!(out, "checkpointed: journal flushed to snapshot").ok();
544            }
545            Ok(0)
546        }
547        Command::Verify => {
548            // A clean image returns Ok; corruption is a typed error the caller
549            // maps to exit 2.
550            db.verify()?;
551            if json {
552                writeln!(out, "{}", json!({ "ok": true })).ok();
553            } else {
554                writeln!(out, "integrity ok").ok();
555            }
556            Ok(0)
557        }
558        // Handled in `run_parsed` (Import needs `settings` for its batch size).
559        Command::Scrub
560        | Command::Recover { .. }
561        | Command::Repl { .. }
562        | Command::Import { .. }
563        | Command::Workspace { .. }
564        | Command::Help { .. } => {
565            unreachable!("this command is dispatched before execute")
566        }
567    }
568}
569
570/// Salvages `src` into a fresh `dst`: `Database::recover` opens
571/// the source under an exclusive lock, drops the content-corrupt facts, and
572/// writes a clean disk-first copy. The source is left untouched.
573fn do_recover(src: &Path, dst: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
574    match Database::recover(src, dst, settings.config.clone(), now_ms()) {
575        Ok(r) => {
576            if json {
577                writeln!(
578                    out,
579                    "{}",
580                    json!({
581                        "kept": r.kept,
582                        "dropped_text": r.dropped_text,
583                        "dropped_vector": r.dropped_vector,
584                        "dropped_metadata": r.dropped_metadata,
585                        "dst": dst.display().to_string(),
586                    })
587                )
588                .ok();
589            } else {
590                writeln!(
591                    out,
592                    "recovered to {}: kept {}, dropped {} text + {} vector + {} metadata",
593                    dst.display(),
594                    r.kept,
595                    r.dropped_text,
596                    r.dropped_vector,
597                    r.dropped_metadata
598                )
599                .ok();
600            }
601            0
602        }
603        Err(HostError::Locked { path }) => report_locked(&path),
604        Err(e) => report_err(&CliError::Host(e)),
605    }
606}
607
608/// Runs a byte-level container scrub over a read-only (shared-lock) open. A
609/// clean image exits 0; the first damaged section is a typed error (exit 2). A
610/// dirty journal forbids the read-only open — the reported `NeedsCheckpoint`
611/// tells the caller to run `maintain` first.
612fn do_scrub(path: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
613    let ro = match Database::open_readonly(path, settings.config.clone()) {
614        Ok(ro) => ro,
615        Err(HostError::Locked { path }) => return report_locked(&path),
616        Err(e) => return report_err(&CliError::Host(e)),
617    };
618    let scrub = match ro.scrub() {
619        Ok(s) => s,
620        Err(e) => return report_err(&CliError::Host(e)),
621    };
622    let mut done = 0u64;
623    let mut total = 0u64;
624    for step in scrub {
625        match step {
626            Ok(p) => {
627                done = p.done_bytes;
628                total = p.total_bytes;
629            }
630            Err(e) => return report_err(&CliError::Host(e)),
631        }
632    }
633    if json {
634        writeln!(out, "{}", json!({ "ok": true, "bytes": done })).ok();
635    } else {
636        writeln!(out, "scrub ok: {done}/{total} bytes verified").ok();
637    }
638    0
639}
640
641/// Parses a single REPL line: the subcommand grammar, with no leading binary
642/// name (the line is `recall tokio`, not `plugmem recall tokio`).
643#[derive(Parser)]
644#[command(
645    no_binary_name = true,
646    name = "plugmem",
647    disable_help_subcommand = true
648)]
649struct ReplLine {
650    #[command(subcommand)]
651    command: Command,
652}
653
654/// Splits a REPL line into tokens, honoring single/double quotes so
655/// `remember "two words"` is one argument. No escape handling — a quote runs to
656/// its match or the end of the line.
657fn split_line(line: &str) -> Vec<String> {
658    let mut tokens = Vec::new();
659    let mut cur = String::new();
660    let mut quote: Option<char> = None;
661    let mut has = false;
662    for c in line.chars() {
663        match quote {
664            Some(q) => {
665                if c == q {
666                    quote = None;
667                } else {
668                    cur.push(c);
669                }
670            }
671            None if c == '"' || c == '\'' => {
672                quote = Some(c);
673                has = true;
674            }
675            None if c.is_whitespace() => {
676                if has {
677                    tokens.push(std::mem::take(&mut cur));
678                    has = false;
679                }
680            }
681            None => {
682                cur.push(c);
683                has = true;
684            }
685        }
686    }
687    if has {
688        tokens.push(cur);
689    }
690    tokens
691}
692
693/// Runs the interactive session over one open writer handle: read a line, parse
694/// it as a subcommand, run it against the in-memory engine, repeat. The engine
695/// stays resident, so each command is host-speed (no per-command reload). The
696/// session checkpoints on exit, leaving a read-ready file. Prompts and the
697/// banner go to stderr so stdout carries only command output.
698fn run_repl(
699    path: &Path,
700    settings: Settings,
701    json: bool,
702    input: impl BufRead,
703    out: &mut impl Write,
704) -> u8 {
705    let db = match settings.open(path) {
706        Ok(db) => db,
707        Err(HostError::Locked { path }) => return report_locked(&path),
708        Err(e) => return report_err(&CliError::Host(e)),
709    };
710    eprintln!("plugmem repl — one open handle, host speed. `help` for verbs, `exit` to quit.");
711    eprint!("plugmem> ");
712    for line in input.lines() {
713        let Ok(line) = line else { break };
714        let line = line.trim();
715        if line.is_empty() {
716            eprint!("plugmem> ");
717            continue;
718        }
719        if line == "exit" || line == "quit" {
720            break;
721        } else if line == "help" {
722            writeln!(
723                out,
724                "verbs: remember recall revise forget link unlink show stats maintain checkpoint \
725                 verify export import  (scrub/recover stay one-shot)  exit"
726            )
727            .ok();
728        } else {
729            run_repl_line(&db, line, json, out);
730        }
731        eprint!("plugmem> ");
732    }
733    eprintln!();
734    // Leave the database checkpointed (read-ready) for the next opener.
735    match db.checkpoint(now_ms()) {
736        Ok(()) => 0,
737        Err(e) => report_err(&CliError::Host(e)),
738    }
739}
740
741/// Parses and runs one non-meta REPL line, reporting errors to `out` without
742/// ending the session.
743fn run_repl_line(db: &Database, line: &str, json: bool, out: &mut impl Write) {
744    let cmd = match ReplLine::try_parse_from(split_line(line)) {
745        Ok(r) => r.command,
746        // clap's message (usage / unknown command / `--help`) — print, continue.
747        Err(e) => {
748            let _ = writeln!(out, "{e}");
749            return;
750        }
751    };
752    match &cmd {
753        Command::Repl { .. } => {
754            let _ = writeln!(out, "already in a repl session");
755        }
756        Command::Scrub | Command::Recover { .. } => {
757            let _ = writeln!(
758                out,
759                "scrub/recover are one-shot commands; run them outside the repl"
760            );
761        }
762        _ => {
763            if let Err(e) = execute(db, &cmd, json, now_ms(), out) {
764                write_err(out, &e);
765            }
766        }
767    }
768}
769
770/// Runs the interactive session **read-only** over one open
771/// [`ReadOnlyDatabase`] (a shared, zero-copy mmap): it observes another
772/// process's writer at the generation it opened on. Only the read verbs run;
773/// writes and one-shot commands are refused. Two extra meta-verbs make the
774/// cross-process freshness observable by hand — `generation` prints the pinned
775/// snapshot number, and `refresh` advances to the writer's latest published
776/// checkpoint (see [`ReadOnlyDatabase::refresh`](plugmem_host::ReadOnlyDatabase::refresh)).
777///
778/// These two verbs exist **only** in this mode. A normal (writer) `repl` and
779/// any one-shot command already see the freshest data — read-your-writes over
780/// the overlay, or a fresh open per command — so there is nothing to refresh
781/// there. This session never writes: it does not checkpoint on exit.
782fn run_repl_ro(
783    path: &Path,
784    mut settings: Settings,
785    json: bool,
786    input: impl BufRead,
787    out: &mut impl Write,
788) -> u8 {
789    let mut ro = match Database::open_readonly(path, settings.config.clone()) {
790        Ok(ro) => ro,
791        Err(HostError::Locked { path }) => return report_locked(&path),
792        // A dirty (un-checkpointed) journal, a fresh database with no published
793        // generation, or a corrupt image — surfaced as a typed error.
794        Err(e) => return report_err(&CliError::Host(e)),
795    };
796    eprintln!(
797        "plugmem repl --read-only — observing generation {} of another process's writer. \
798         `help` for verbs, `refresh`/`generation` for cross-process freshness, `exit` to quit.",
799        ro.generation()
800    );
801    eprint!("plugmem(ro)> ");
802    for line in input.lines() {
803        let Ok(line) = line else { break };
804        let line = line.trim();
805        if line.is_empty() {
806            eprint!("plugmem(ro)> ");
807            continue;
808        }
809        match line {
810            "exit" | "quit" => break,
811            "help" => {
812                writeln!(
813                    out,
814                    "read verbs: recall show stats export verify  \
815                     freshness: generation refresh  exit  \
816                     (writes and scrub/recover are refused in a read-only session)"
817                )
818                .ok();
819            }
820            // Freshness meta-verbs — only meaningful for a read-only observer of
821            // another process's writer (a writer repl sees its own writes at once).
822            "generation" => {
823                let g = ro.generation();
824                if json {
825                    writeln!(out, "{}", json!({ "generation": g })).ok();
826                } else {
827                    writeln!(out, "generation {g}").ok();
828                }
829            }
830            "refresh" => match ro.refresh() {
831                Ok(advanced) => {
832                    let g = ro.generation();
833                    if json {
834                        writeln!(out, "{}", json!({ "advanced": advanced, "generation": g })).ok();
835                    } else if advanced {
836                        writeln!(out, "refreshed → generation {g}").ok();
837                    } else {
838                        writeln!(out, "already current → generation {g}").ok();
839                    }
840                }
841                Err(e) => write_err(out, &CliError::Host(e)),
842            },
843            _ => run_repl_ro_line(&ro, &mut settings, line, json, out),
844        }
845        eprint!("plugmem(ro)> ");
846    }
847    eprintln!();
848    // Read-only: nothing to checkpoint, the writer owns the file.
849    0
850}
851
852/// Parses and runs one non-meta line of a read-only repl, refusing anything but
853/// the read verbs (writes/one-shot are not available without the writer lock).
854fn run_repl_ro_line(
855    ro: &ReadOnlyDatabase,
856    settings: &mut Settings,
857    line: &str,
858    json: bool,
859    out: &mut impl Write,
860) {
861    let cmd = match ReplLine::try_parse_from(split_line(line)) {
862        Ok(r) => r.command,
863        Err(e) => {
864            let _ = writeln!(out, "{e}");
865            return;
866        }
867    };
868    let readable = matches!(
869        &cmd,
870        Command::Show { .. }
871            | Command::Stats
872            | Command::Export
873            | Command::Verify
874            | Command::Recall { .. }
875    );
876    if !readable {
877        let _ = writeln!(
878            out,
879            "read-only session: only recall/show/stats/export/verify run \
880             (plus refresh/generation); writes and one-shot commands need a writer handle"
881        );
882        return;
883    }
884    // Embed a text recall query up front, exactly like the one-shot read-only
885    // path — the read-only handle carries no embedder of its own.
886    let recall_vector = match embed_recall_query(settings, &cmd) {
887        Ok(v) => v,
888        Err(e) => {
889            write_err(out, &e);
890            return;
891        }
892    };
893    let _ = execute_ro(ro, &cmd, recall_vector.as_deref(), json, out);
894}
895
896/// Embeds a `recall` command's text query into a vector using the configured
897/// embedder, so the read-only path (which carries no embedder) can still search
898/// by meaning while a writer process holds the database. Returns `None` when the
899/// command is not `recall`, carries no query text, or no embedder is configured
900/// — recall then falls back to lexical/structural sources. Mirrors the host's
901/// "embed before the lock" rule; the embed happens before the open
902/// so a locked database only costs the embed on the rare read-write fallback.
903fn embed_recall_query(
904    settings: &mut Settings,
905    cmd: &Command,
906) -> Result<Option<Vec<f32>>, CliError> {
907    let Command::Recall {
908        query: Some(text),
909        vector,
910        ..
911    } = cmd
912    else {
913        return Ok(None);
914    };
915    // An explicit `--vector` replaces the embedder, so there is nothing to
916    // embed and no call to make.
917    if !vector.is_empty() {
918        return Ok(None);
919    }
920    let Some(embedder) = settings.embedder.as_ref() else {
921        return Ok(None);
922    };
923    let mut vectors = embedder.embed(&[text.as_str()]).map_err(CliError::Host)?;
924    Ok(vectors.pop())
925}
926
927/// Builds the [`RecallQuery`] for a `recall` command and passes it to `f`.
928/// A closure (not a return) because the query borrows temporary tag/entity
929/// slices that must outlive the call. Used by both the read-write and
930/// read-only paths.
931fn with_recall_query<R>(
932    cmd: &Command,
933    now: u64,
934    override_vector: Option<&[f32]>,
935    f: impl FnOnce(RecallQuery<'_>) -> R,
936) -> R {
937    let Command::Recall {
938        query,
939        tags,
940        entities,
941        as_of,
942        range,
943        k,
944        closed,
945        token_budget,
946        ef,
947        vector,
948    } = cmd
949    else {
950        unreachable!("with_recall_query called on a non-recall command");
951    };
952    let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
953    let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
954    let range_pair = range.as_ref().map(|v| (v[0], v[1]));
955    // Precedence, matching the host: an explicit `--vector` wins outright and
956    // nothing is sent to the embedder. Otherwise `override_vector` carries the
957    // text the CLI embedded for the read-only path; on the read-write path both
958    // are `None` and the host embeds inside `recall`.
959    let explicit = (!vector.is_empty()).then_some(vector.as_slice());
960    let q = RecallQuery {
961        now,
962        text: query.as_deref(),
963        vector: explicit.or(override_vector),
964        tags: &tag_refs,
965        entities: &ent_refs,
966        as_of: *as_of,
967        range: range_pair,
968        k: *k,
969        token_budget: *token_budget,
970        include_closed: *closed,
971        ef: *ef,
972    };
973    f(q)
974}
975
976/// Renders a recall result — the engine's block (human) or facts + block
977/// (JSON).
978fn render_recall(res: &RecallResult, json: bool, out: &mut impl Write) {
979    if json {
980        let facts: Vec<_> = res
981            .facts
982            .iter()
983            .map(|f| {
984                json!({
985                    "id": f.id.0,
986                    "score": f.score,
987                    "sources": f.sources,
988                    "recorded_at": f.recorded_at,
989                    "valid_from": f.valid_from,
990                    "valid_to": open_or(f.valid_to),
991                })
992            })
993            .collect();
994        // The edges the graph source walked. The MCP server and the napi
995        // binding have always returned these; the CLI built its JSON by hand
996        // and dropped them, which made `--provenance` write-only from here:
997        // recordable, then unreadable.
998        let edges: Vec<_> = res
999            .edges
1000            .iter()
1001            .map(|e| {
1002                json!({
1003                    "src": e.src.0,
1004                    "rel": e.rel.0,
1005                    "dst": e.dst.0,
1006                    "provenance": (e.provenance != FactId::NONE).then_some(e.provenance.0),
1007                })
1008            })
1009            .collect();
1010        writeln!(
1011            out,
1012            "{}",
1013            json!({
1014                "facts": facts,
1015                "edges": edges,
1016                "rendered": res.rendered,
1017                "truncated": res.truncated,
1018            })
1019        )
1020        .ok();
1021    } else if res.rendered.is_empty() {
1022        writeln!(out, "(nothing recalled)").ok();
1023    } else {
1024        writeln!(out, "{}", res.rendered).ok();
1025    }
1026}
1027
1028/// Renders one fact's card. Returns the exit code (`0` found, `1` missing).
1029fn render_show(
1030    fact: Option<plugmem_host::FactSnapshot>,
1031    id: u32,
1032    json: bool,
1033    out: &mut impl Write,
1034) -> u8 {
1035    let Some(fact) = fact else {
1036        if json {
1037            writeln!(out, "{}", json!({ "id": id, "found": false })).ok();
1038        } else {
1039            writeln!(out, "fact {id} not found").ok();
1040        }
1041        return 1;
1042    };
1043    let r = &fact.record;
1044    if json {
1045        writeln!(
1046            out,
1047            "{}",
1048            json!({
1049                "id": r.id.0,
1050                "text": fact.text,
1051                "recorded_at": r.recorded_at,
1052                "valid_from": r.valid_from,
1053                "valid_to": open_or(r.valid_to),
1054                "closed": r.is_closed(),
1055                "tombstone": r.is_tombstone(),
1056                "revises": (r.revises != FactId::NONE).then_some(r.revises.0),
1057                "metadata": fact.metadata,
1058            })
1059        )
1060        .ok();
1061    } else {
1062        writeln!(out, "fact {}", r.id.0).ok();
1063        writeln!(out, "  text        {}", fact.text).ok();
1064        writeln!(out, "  recorded_at {}", r.recorded_at).ok();
1065        write!(out, "  valid       [{}, ", r.valid_from).ok();
1066        match r.valid_to {
1067            VALID_TO_OPEN => writeln!(out, "open)").ok(),
1068            to => writeln!(out, "{to})").ok(),
1069        };
1070        if r.revises != FactId::NONE {
1071            writeln!(out, "  revises     fact {}", r.revises.0).ok();
1072        }
1073        if !fact.metadata.is_empty() {
1074            let rendered = fact
1075                .metadata
1076                .iter()
1077                .map(|(k, v)| format!("{k}={v}"))
1078                .collect::<Vec<_>>()
1079                .join(", ");
1080            writeln!(out, "  metadata    {rendered}").ok();
1081        }
1082        if r.is_tombstone() {
1083            writeln!(out, "  state       tombstoned").ok();
1084        }
1085    }
1086    0
1087}
1088
1089/// Translates the command-line mode into engine options.
1090///
1091/// `auto` keeps the bounded HNSW budget that makes it safe to run often;
1092/// every explicit mode takes the budget the engine defines for it.
1093fn maintenance_options(mode: MaintainMode) -> MaintenanceOptions {
1094    match MaintenanceMode::from(mode) {
1095        MaintenanceMode::Auto => MaintenanceOptions::auto(),
1096        MaintenanceMode::Full => MaintenanceOptions::full(),
1097        mode => MaintenanceOptions {
1098            mode,
1099            ..MaintenanceOptions::auto()
1100        },
1101    }
1102}
1103
1104/// Renders engine size counters.
1105fn render_stats(s: &Stats, json: bool, out: &mut impl Write) {
1106    if json {
1107        writeln!(
1108            out,
1109            "{}",
1110            json!({
1111                "facts": s.facts,
1112                "entities": s.entities,
1113                "terms": s.terms,
1114                "edges": s.edges,
1115                "edge_versions": s.edge_versions,
1116                "vectors": s.vectors,
1117                "hnsw_indexed": s.hnsw_indexed,
1118                "next_fact": s.next_fact,
1119                "next_entity": s.next_entity,
1120                "next_edge": s.next_edge,
1121                "pool_bytes": s.pool_bytes,
1122                "shards": {
1123                    "facts": s.shards.facts,
1124                    "entities": s.shards.entities,
1125                    "edges": s.shards.edges,
1126                    "temporal": s.shards.temporal,
1127                    "postings": s.shards.postings,
1128                },
1129            })
1130        )
1131        .ok();
1132    } else {
1133        writeln!(out, "facts       {}", s.facts).ok();
1134        writeln!(out, "entities    {}", s.entities).ok();
1135        writeln!(out, "terms       {}", s.terms).ok();
1136        writeln!(out, "edges       {}", s.edges).ok();
1137        writeln!(out, "edge_vers   {}", s.edge_versions).ok();
1138        writeln!(out, "vectors     {}", s.vectors).ok();
1139        writeln!(out, "hnsw_idx    {}", s.hnsw_indexed).ok();
1140        writeln!(out, "next_fact   {}", s.next_fact).ok();
1141        writeln!(out, "next_edge   {}", s.next_edge).ok();
1142        writeln!(out, "pool_bytes  {}", s.pool_bytes).ok();
1143        // The engine picks these from what it holds and moves them during
1144        // `maintain`; they are state to read, not a setting to choose.
1145        writeln!(
1146            out,
1147            "shards      facts {} entities {} edges {} temporal {} postings {}",
1148            s.shards.facts, s.shards.entities, s.shards.edges, s.shards.temporal, s.shards.postings,
1149        )
1150        .ok();
1151    }
1152}
1153
1154/// Writes one exported fact as a JSONL line. The unit of the streaming export
1155/// — the same shape with or without `--json` (JSONL is already machine-readable).
1156///
1157/// `kind` is what makes the format extensible: a reader dispatches on it, and a
1158/// line without one is a fact, which is exactly how files written before edges
1159/// existed still load.
1160fn write_export_line(out: &mut impl Write, f: &ExportedFact) {
1161    writeln!(
1162        out,
1163        "{}",
1164        json!({
1165            "kind": "fact",
1166            "id": f.id,
1167            "text": f.text,
1168            "entity": f.entity,
1169            "tags": f.tags,
1170            "metadata": f.metadata,
1171            "recorded_at": f.recorded_at,
1172            "valid_from": f.valid_from,
1173        })
1174    )
1175    .ok();
1176}
1177
1178/// Writes one edge line. Emitted **after** every fact line, so an importer
1179/// reading forward has already seen the fact a `provenance` names and can
1180/// translate its id without buffering or a second pass.
1181fn write_export_edge(out: &mut impl Write, src: &str, rel: &str, dst: &str, provenance: FactId) {
1182    writeln!(
1183        out,
1184        "{}",
1185        json!({
1186            "kind": "edge",
1187            "src": src,
1188            "rel": rel,
1189            "dst": dst,
1190            "provenance": (provenance != FactId::NONE).then_some(provenance.0),
1191        })
1192    )
1193    .ok();
1194}
1195
1196/// Renders a whole slice of exported facts as JSONL (test helper — the runtime
1197/// path streams via [`write_export_line`]).
1198#[cfg(test)]
1199fn render_export(facts: &[ExportedFact], _json: bool, out: &mut impl Write) {
1200    for f in facts {
1201        write_export_line(out, f);
1202    }
1203}
1204
1205/// Loads facts from a JSONL file (as written by `export`) in **streamed
1206/// batches** of `batch_size`: the file is read line-by-line (memory bounded to
1207/// a batch, not the whole file), and each full batch is one
1208/// [`remember_many`](Database::remember_many) — one embedder round-trip and one
1209/// journal fsync, instead of per fact. Returns the count imported. A malformed
1210/// line is a usage error naming its 1-based number.
1211fn do_import(
1212    db: &Database,
1213    now: u64,
1214    file: &std::path::Path,
1215    batch_size: usize,
1216    _out: &mut impl Write,
1217) -> Result<ImportReport, CliError> {
1218    let f = std::fs::File::open(file)
1219        .map_err(|e| CliError::Usage(format!("reading {}: {e}", file.display())))?;
1220    let reader = io::BufReader::new(f);
1221    let mut count = 0usize;
1222    let mut batch: Vec<ParsedFact> = Vec::with_capacity(batch_size);
1223    // Old fact id -> the id this database gave it. Sparse (an exporting
1224    // database has burned ids), so a map rather than a dense vector: a file
1225    // whose edges carry no provenance never grows it at all.
1226    let mut remap: BTreeMap<u32, FactId> = BTreeMap::new();
1227    let mut edges = 0usize;
1228
1229    for (i, line) in reader.lines().enumerate() {
1230        let line = line.map_err(|e| CliError::Usage(format!("line {}: {e}", i + 1)))?;
1231        let line = line.trim();
1232        if line.is_empty() {
1233            continue;
1234        }
1235        match parse_import_line(line, i + 1)? {
1236            ImportLine::Fact(fact) => {
1237                batch.push(fact);
1238                if batch.len() >= batch_size {
1239                    count += flush_import_batch(db, now, &batch, &mut remap)?;
1240                    batch.clear();
1241                }
1242            }
1243            ImportLine::Edge(edge) => {
1244                // Edges follow every fact in a file this CLI wrote, so the
1245                // pending batch has to land before an edge can name a fact
1246                // from it. Flushing here costs one extra batch write per file,
1247                // not per edge.
1248                if !batch.is_empty() {
1249                    count += flush_import_batch(db, now, &batch, &mut remap)?;
1250                    batch.clear();
1251                }
1252                db.link(LinkInput {
1253                    now,
1254                    src: &edge.src,
1255                    rel: &edge.rel,
1256                    dst: &edge.dst,
1257                    // A provenance naming a fact this file did not carry (it
1258                    // was closed, or forgotten before the export) links without
1259                    // one rather than pointing at an unrelated id.
1260                    provenance: edge.provenance.and_then(|old| remap.get(&old).copied()),
1261                })?;
1262                edges += 1;
1263            }
1264        }
1265    }
1266    count += flush_import_batch(db, now, &batch, &mut remap)?;
1267    Ok(ImportReport {
1268        facts: count,
1269        edges,
1270    })
1271}
1272
1273/// What an import wrote. Edges are counted separately because a file may carry
1274/// only facts (anything written before edges were in the format) and reporting
1275/// "0 edges" for those would read as a loss rather than as their absence.
1276#[derive(Debug, PartialEq, Eq)]
1277struct ImportReport {
1278    facts: usize,
1279    edges: usize,
1280}
1281
1282/// One line of an import file: the two shapes `export` writes.
1283enum ImportLine {
1284    Fact(ParsedFact),
1285    Edge(ParsedEdge),
1286}
1287
1288/// One parsed edge line. Its `provenance` is the fact id **in the exporting
1289/// database**; the importer translates it through the ids it just assigned.
1290struct ParsedEdge {
1291    src: String,
1292    rel: String,
1293    dst: String,
1294    provenance: Option<u32>,
1295}
1296
1297/// One parsed JSONL fact, owned so a whole batch can be buffered before its
1298/// `remember_many`. `id` is the exporting database's id, kept only so edges in
1299/// the same file can be pointed at the fact once it has a new one.
1300struct ParsedFact {
1301    id: Option<u32>,
1302    text: String,
1303    entity: Option<String>,
1304    tags: Vec<String>,
1305    metadata: Vec<(String, String)>,
1306    valid_from: Option<u64>,
1307}
1308
1309/// Parses one JSONL line. Dispatches on `kind`; a line without one is a fact,
1310/// which is how files written before edges existed still load. Bad JSON, an
1311/// unknown `kind`, or a fact missing its `text` is a usage error naming the
1312/// 1-based line.
1313fn parse_import_line(line: &str, lineno: usize) -> Result<ImportLine, CliError> {
1314    let v: serde_json::Value =
1315        serde_json::from_str(line).map_err(|e| CliError::Usage(format!("line {lineno}: {e}")))?;
1316    match v["kind"].as_str() {
1317        None | Some("fact") => parse_import_fact(&v, lineno).map(ImportLine::Fact),
1318        Some("edge") => parse_import_edge(&v, lineno).map(ImportLine::Edge),
1319        Some(other) => Err(CliError::Usage(format!(
1320            "line {lineno}: unknown kind \"{other}\" (expected \"fact\" or \"edge\")"
1321        ))),
1322    }
1323}
1324
1325/// Parses an edge line. All three endpoints are required — an edge missing one
1326/// is not a partial edge, it is a broken file.
1327fn parse_import_edge(v: &serde_json::Value, lineno: usize) -> Result<ParsedEdge, CliError> {
1328    let field = |key: &str| -> Result<String, CliError> {
1329        v[key]
1330            .as_str()
1331            .map(String::from)
1332            .ok_or_else(|| CliError::Usage(format!("line {lineno}: edge missing string \"{key}\"")))
1333    };
1334    Ok(ParsedEdge {
1335        src: field("src")?,
1336        rel: field("rel")?,
1337        dst: field("dst")?,
1338        provenance: v["provenance"].as_u64().and_then(|n| u32::try_from(n).ok()),
1339    })
1340}
1341
1342/// Parses a fact line into an owned fact.
1343fn parse_import_fact(v: &serde_json::Value, lineno: usize) -> Result<ParsedFact, CliError> {
1344    let text = v["text"]
1345        .as_str()
1346        .ok_or_else(|| CliError::Usage(format!("line {lineno}: missing string \"text\"")))?
1347        .to_string();
1348    let entity = v["entity"].as_str().map(String::from);
1349    let tags = v["tags"]
1350        .as_array()
1351        .map(|a| {
1352            a.iter()
1353                .filter_map(|t| t.as_str().map(String::from))
1354                .collect()
1355        })
1356        .unwrap_or_default();
1357    // Metadata: an object of string values. Keys are sorted (via `BTreeMap`) so
1358    // the imported pairs are canonical; non-string values are skipped.
1359    let metadata = v["metadata"]
1360        .as_object()
1361        .map(|m| {
1362            m.iter()
1363                .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
1364                .collect::<BTreeMap<_, _>>()
1365                .into_iter()
1366                .collect()
1367        })
1368        .unwrap_or_default();
1369    let valid_from = v["valid_from"].as_u64();
1370    Ok(ParsedFact {
1371        id: v["id"].as_u64().and_then(|n| u32::try_from(n).ok()),
1372        text,
1373        entity,
1374        tags,
1375        metadata,
1376        valid_from,
1377    })
1378}
1379
1380/// Writes one batch of parsed facts via `remember_many` (one embed round-trip,
1381/// one fsync). Returns how many were written; an empty batch is a no-op.
1382fn flush_import_batch(
1383    db: &Database,
1384    now: u64,
1385    batch: &[ParsedFact],
1386    remap: &mut BTreeMap<u32, FactId>,
1387) -> Result<usize, CliError> {
1388    if batch.is_empty() {
1389        return Ok(0);
1390    }
1391    // Per-fact `&[&str]` tag slices and `&[(&str,&str)]` metadata pairs must
1392    // outlive the `remember_many` call.
1393    let tag_refs: Vec<Vec<&str>> = batch
1394        .iter()
1395        .map(|p| p.tags.iter().map(String::as_str).collect())
1396        .collect();
1397    let meta_refs: Vec<Vec<(&str, &str)>> = batch
1398        .iter()
1399        .map(|p| {
1400            p.metadata
1401                .iter()
1402                .map(|(k, v)| (k.as_str(), v.as_str()))
1403                .collect()
1404        })
1405        .collect();
1406    let inputs: Vec<RememberInput> = batch
1407        .iter()
1408        .zip(&tag_refs)
1409        .zip(&meta_refs)
1410        .map(|((p, tags), meta)| RememberInput {
1411            entity: p.entity.as_deref(),
1412            tags,
1413            metadata: (!meta.is_empty()).then_some(meta.as_slice()),
1414            valid_from: p.valid_from,
1415            ..RememberInput::text(now, &p.text)
1416        })
1417        .collect();
1418    // `remember_many` returns outcomes in input order, so each parsed fact
1419    // learns the id this database gave it — the only thing an edge needs.
1420    let outcomes = db.remember_many(inputs)?;
1421    for (parsed, outcome) in batch.iter().zip(&outcomes) {
1422        if let Some(old) = parsed.id {
1423            remap.insert(old, outcome.id);
1424        }
1425    }
1426    Ok(batch.len())
1427}
1428
1429/// Shared `remember`/`revise` body: build the input and dispatch.
1430#[allow(clippy::too_many_arguments)]
1431fn do_remember(
1432    db: &Database,
1433    now: u64,
1434    text: &str,
1435    entity: &Option<String>,
1436    tags: &[String],
1437    links: &[String],
1438    meta: &[String],
1439    valid_from: Option<u64>,
1440    vector: &[f32],
1441    revise: Option<FactId>,
1442) -> Result<RememberOutcome, CliError> {
1443    let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1444    let link_pairs = parse_links(links)?;
1445    let link_refs: Vec<(&str, &str)> = link_pairs
1446        .iter()
1447        .map(|(r, e)| (r.as_str(), e.as_str()))
1448        .collect();
1449    // A `BTreeMap` dedups keys (last `--meta` for a key wins) and sorts them;
1450    // the engine re-canonicalizes regardless, but this keeps the borrowed pairs
1451    // clean and dup-free.
1452    let meta_map = parse_meta(meta)?;
1453    let meta_refs: Vec<(&str, &str)> = meta_map
1454        .iter()
1455        .map(|(k, v)| (k.as_str(), v.as_str()))
1456        .collect();
1457    let input = RememberInput {
1458        entity: entity.as_deref(),
1459        tags: &tag_refs,
1460        links: &link_refs,
1461        metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1462        valid_from,
1463        // An explicit `--vector` is authoritative: the host embeds only when
1464        // this is `None`, so passing one skips the provider entirely. The
1465        // engine checks the length against `dim`.
1466        vector: (!vector.is_empty()).then_some(vector),
1467        ..RememberInput::text(now, text)
1468    };
1469    match revise {
1470        Some(target) => Ok(db.revise(target, input)?),
1471        None => Ok(db.remember(input)?),
1472    }
1473}
1474
1475/// Parses `--meta KEY=VALUE` strings into a sorted, deduped map (last value per
1476/// key wins).
1477fn parse_meta(meta: &[String]) -> Result<BTreeMap<String, String>, CliError> {
1478    let mut map = BTreeMap::new();
1479    for s in meta {
1480        let (k, v) = s
1481            .split_once('=')
1482            .filter(|(k, _)| !k.is_empty())
1483            .ok_or_else(|| CliError::Usage(format!("bad --meta `{s}` — expected KEY=VALUE")))?;
1484        map.insert(k.to_string(), v.to_string());
1485    }
1486    Ok(map)
1487}
1488
1489/// Parses `--link REL:ENTITY` strings into `(rel, entity)` pairs.
1490fn parse_links(links: &[String]) -> Result<Vec<(String, String)>, CliError> {
1491    links
1492        .iter()
1493        .map(|s| {
1494            s.split_once(':')
1495                .filter(|(r, e)| !r.is_empty() && !e.is_empty())
1496                .map(|(r, e)| (r.to_string(), e.to_string()))
1497                .ok_or_else(|| CliError::Usage(format!("bad --link `{s}` — expected REL:ENTITY")))
1498        })
1499        .collect()
1500}
1501
1502/// Renders a `remember`/`revise` outcome (shared shape).
1503fn render_remember(outcome: &RememberOutcome, json: bool, out: &mut impl Write) {
1504    if json {
1505        let similar: Vec<_> = outcome
1506            .similar
1507            .iter()
1508            .map(|s| json!({ "id": s.id.0, "score": s.score, "reason": format!("{:?}", s.reason) }))
1509            .collect();
1510        writeln!(
1511            out,
1512            "{}",
1513            json!({
1514                "id": outcome.id.0,
1515                "entity": outcome.entity.map(|e| e.0),
1516                "similar": similar,
1517            })
1518        )
1519        .ok();
1520    } else {
1521        writeln!(out, "remembered fact {}", outcome.id.0).ok();
1522        for s in &outcome.similar {
1523            writeln!(
1524                out,
1525                "  ~ similar to fact {} ({:?}, {:.2})",
1526                s.id.0, s.reason, s.score
1527            )
1528            .ok();
1529        }
1530    }
1531}
1532
1533/// `VALID_TO_OPEN` → JSON `null`, a real bound → the number.
1534fn open_or(valid_to: u64) -> Option<u64> {
1535    (valid_to != VALID_TO_OPEN).then_some(valid_to)
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540    use plugmem_host::Config;
1541
1542    use super::*;
1543
1544    /// A stub embedder returning a fixed vector per input — no network.
1545    struct StubEmbedder;
1546    impl plugmem_host::Embedder for StubEmbedder {
1547        fn dim(&self) -> usize {
1548            3
1549        }
1550        fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1551            Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1552        }
1553    }
1554
1555    fn recall_cmd(query: Option<&str>) -> Command {
1556        Command::Recall {
1557            query: query.map(str::to_owned),
1558            tags: vec![],
1559            entities: vec![],
1560            as_of: None,
1561            range: None,
1562            k: 0,
1563            closed: false,
1564            token_budget: None,
1565            ef: None,
1566            vector: Vec::new(),
1567        }
1568    }
1569
1570    fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1571        Settings {
1572            database_path: None,
1573            config: Config::default(),
1574            embedder,
1575            snapshot_every_ops: None,
1576            snapshot_journal_bytes: None,
1577            maintain_every_forgets: None,
1578            fsync: None,
1579            workspace: plugmem_host::WorkspaceSettings {
1580                dir: None,
1581                limits: plugmem_host::WorkspaceLimits::default(),
1582            },
1583        }
1584    }
1585
1586    #[test]
1587    fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1588        // recall text + embedder → a vector.
1589        let mut with = settings_with(Some(Box::new(StubEmbedder)));
1590        assert_eq!(
1591            embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1592            Some(vec![0.1, 0.2, 0.3])
1593        );
1594
1595        // no embedder → None (recall falls back to lexical/structural sources).
1596        let mut without = settings_with(None);
1597        assert_eq!(
1598            embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1599            None
1600        );
1601
1602        // recall with no query text → None (nothing to embed).
1603        let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1604        assert_eq!(
1605            embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1606            None
1607        );
1608
1609        // a non-recall command → None even with an embedder configured.
1610        let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1611        assert_eq!(
1612            embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1613            None
1614        );
1615    }
1616
1617    #[test]
1618    fn split_line_honors_quotes_and_whitespace() {
1619        assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1620        assert_eq!(
1621            split_line(r#"remember "two words" --tag x"#),
1622            ["remember", "two words", "--tag", "x"]
1623        );
1624        assert_eq!(split_line("  recall   'a b'  "), ["recall", "a b"]);
1625        assert_eq!(split_line(""), Vec::<String>::new());
1626        // An empty quoted string is a real (empty) argument.
1627        assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1628    }
1629
1630    #[test]
1631    fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1632        let (db, tmp) = TempDb::open();
1633        let path = tmp.0.join("m.plugmem");
1634        drop(db); // release the writer lock so run_repl can open it
1635
1636        let settings = settings_with(None);
1637        // Multi-word text is quoted, same grammar as the one-shot CLI.
1638        let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1639        let mut out = Vec::new();
1640        let code = run_repl(&path, settings, false, &script[..], &mut out);
1641        let text = String::from_utf8(out).unwrap();
1642
1643        assert_eq!(code, 0);
1644        assert!(text.contains("remembered fact 0"), "{text}");
1645        assert!(text.contains("tokio"), "{text}");
1646        // A bad line is reported but does not end the session (revise ran after).
1647        assert!(text.contains("unrecognized subcommand"), "{text}");
1648
1649        // Checkpointed on exit → a fresh read-only open sees the data with a
1650        // clean journal. The revise chain leaves two facts: the closed original
1651        // and its active successor.
1652        let ro = Database::open_readonly(&path, Config::default()).unwrap();
1653        assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1654    }
1655
1656    #[test]
1657    fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1658        let (db, tmp) = TempDb::open();
1659        let path = tmp.0.join("m.plugmem");
1660        // Seed and publish generation 1, then keep the writer open and live —
1661        // the read-only repl observes it cross-process (Variant 2 MVCC).
1662        let mut sink = Vec::new();
1663        execute(
1664            &db,
1665            &remember("seed fact tokio", None, &[]),
1666            false,
1667            1_000,
1668            &mut sink,
1669        )
1670        .unwrap();
1671        db.checkpoint(1_001).unwrap();
1672
1673        let settings = settings_with(None);
1674        // A read verb, both freshness verbs, and a write (must be refused).
1675        let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1676        let mut out = Vec::new();
1677        let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1678        let text = String::from_utf8(out).unwrap();
1679
1680        assert_eq!(code, 0);
1681        assert!(text.contains("generation 1"), "generation verb: {text}");
1682        assert!(text.contains("fact"), "stats ran: {text}");
1683        // The writer published nothing after the reader opened, so refresh is a
1684        // no-op that stays on generation 1.
1685        assert!(
1686            text.contains("already current → generation 1"),
1687            "refresh no-op: {text}"
1688        );
1689        // A write verb is refused without ending the session (exit still ran).
1690        assert!(text.contains("read-only session"), "write refused: {text}");
1691
1692        // The read-only session never wrote: the writer is still on generation 1
1693        // with its single seeded fact, untouched by the repl.
1694        assert_eq!(db.stats().facts, 1);
1695    }
1696
1697    #[test]
1698    fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1699        let (db, tmp) = TempDb::open();
1700        let path = tmp.0.join("m.plugmem");
1701        let mut sink = Vec::new();
1702        execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1703        db.checkpoint(1_001).unwrap();
1704
1705        // A reader hook that publishes a *new* generation the first time the repl
1706        // pulls a line, so the subsequent `refresh` deterministically advances —
1707        // exercising the "refreshed" branch without a background thread.
1708        struct HookOnFirstRead<'a> {
1709            script: std::io::Cursor<&'a [u8]>,
1710            db: &'a Database,
1711            fired: bool,
1712        }
1713        impl std::io::Read for HookOnFirstRead<'_> {
1714            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1715                if !self.fired {
1716                    self.fired = true;
1717                    // Publish generation 2 before the first command is read, so
1718                    // the reader (opened on gen 1) sees something newer.
1719                    let mut s = Vec::new();
1720                    execute(
1721                        self.db,
1722                        &remember("second", None, &[]),
1723                        false,
1724                        2_000,
1725                        &mut s,
1726                    )
1727                    .unwrap();
1728                    self.db.checkpoint(2_001).unwrap();
1729                }
1730                self.script.read(buf)
1731            }
1732        }
1733        let reader = std::io::BufReader::new(HookOnFirstRead {
1734            script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
1735            db: &db,
1736            fired: false,
1737        });
1738
1739        let mut out = Vec::new();
1740        let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
1741        let text = String::from_utf8(out).unwrap();
1742
1743        assert_eq!(code, 0);
1744        // Opened on gen 1, the writer published gen 2, refresh advanced onto it.
1745        assert!(text.contains("refreshed → generation 2"), "advance: {text}");
1746        // And the advanced reader now sees the writer's second fact.
1747        assert!(text.contains("fact"), "stats after refresh: {text}");
1748        assert_eq!(db.stats().facts, 2);
1749    }
1750
1751    #[test]
1752    fn read_only_repl_freshness_verbs_emit_json() {
1753        let (db, tmp) = TempDb::open();
1754        let path = tmp.0.join("m.plugmem");
1755        let mut sink = Vec::new();
1756        execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
1757        db.checkpoint(1_001).unwrap();
1758
1759        let script = b"generation\nrefresh\nexit\n";
1760        let mut out = Vec::new();
1761        let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
1762        let text = String::from_utf8(out).unwrap();
1763
1764        assert_eq!(code, 0);
1765        assert!(
1766            text.contains(r#""generation":1"#),
1767            "generation json: {text}"
1768        );
1769        assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
1770    }
1771
1772    /// A throwaway database on a unique temp path; removed on drop.
1773    struct TempDb(PathBuf);
1774    impl TempDb {
1775        fn open() -> (Database, Self) {
1776            let dir = std::env::temp_dir().join(format!(
1777                "plugmem-cli-{}-{}",
1778                std::process::id(),
1779                now_ms_unique()
1780            ));
1781            std::fs::create_dir_all(&dir).unwrap();
1782            let path = dir.join("m.plugmem");
1783            let (db, _) = Database::open(&path, Config::default()).unwrap();
1784            (db, TempDb(dir))
1785        }
1786    }
1787    impl Drop for TempDb {
1788        fn drop(&mut self) {
1789            let _ = std::fs::remove_dir_all(&self.0);
1790        }
1791    }
1792
1793    /// A strictly-increasing counter so temp dirs never collide within a run
1794    /// (the wall clock alone can repeat at millisecond resolution).
1795    fn now_ms_unique() -> String {
1796        use std::sync::atomic::{AtomicU64, Ordering};
1797        static N: AtomicU64 = AtomicU64::new(0);
1798        format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
1799    }
1800
1801    fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
1802        let mut buf = Vec::new();
1803        let code = execute(db, cmd, json, now, &mut buf).expect("execute");
1804        (code, String::from_utf8(buf).unwrap())
1805    }
1806
1807    fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
1808        Command::Remember {
1809            text: text.into(),
1810            entity: entity.map(Into::into),
1811            tags: tags.iter().map(|t| (*t).into()).collect(),
1812            links: Vec::new(),
1813            meta: Vec::new(),
1814            valid_from: None,
1815            vector: Vec::new(),
1816        }
1817    }
1818
1819    fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
1820        Command::Remember {
1821            text: text.into(),
1822            entity: None,
1823            tags: Vec::new(),
1824            links: Vec::new(),
1825            meta: meta.iter().map(|m| (*m).into()).collect(),
1826            valid_from: None,
1827            vector: Vec::new(),
1828        }
1829    }
1830
1831    #[test]
1832    fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
1833        let (db, _t) = TempDb::open();
1834        // Keys given out of order; last value for a repeated key wins.
1835        let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
1836        assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
1837
1838        // show (human): sorted `key=value`, last-write-wins on `page`.
1839        let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1840        assert!(
1841            human.contains("metadata    page=3, uri=s3://b/x"),
1842            "{human}"
1843        );
1844        // show (json): a metadata object.
1845        let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1846        let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
1847        assert_eq!(v["metadata"]["page"], "3");
1848        assert_eq!(v["metadata"]["uri"], "s3://b/x");
1849
1850        // export: the JSONL line carries the same object.
1851        let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
1852        let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
1853        assert_eq!(line["metadata"]["uri"], "s3://b/x");
1854
1855        // A `--meta` without `=` is a usage error.
1856        assert!(matches!(
1857            parse_meta(&["noequals".to_string()]),
1858            Err(CliError::Usage(_))
1859        ));
1860        assert!(parse_meta(&["=noKey".to_string()]).is_err());
1861    }
1862
1863    #[test]
1864    fn remember_then_recall_human_and_json() {
1865        let (db, _t) = TempDb::open();
1866        let (code, out) = run_cmd(
1867            &db,
1868            &remember("prefers tokio", Some("user"), &["pref"]),
1869            false,
1870            1_000,
1871        );
1872        assert_eq!(code, 0);
1873        assert!(out.starts_with("remembered fact 0"), "{out}");
1874
1875        // human recall
1876        let recall = Command::Recall {
1877            query: Some("tokio".into()),
1878            tags: Vec::new(),
1879            entities: Vec::new(),
1880            as_of: None,
1881            range: None,
1882            k: 0,
1883            closed: false,
1884            token_budget: None,
1885            ef: None,
1886            vector: Vec::new(),
1887        };
1888        let (code, out) = run_cmd(&db, &recall, false, 2_000);
1889        assert_eq!(code, 0);
1890        assert!(out.contains("tokio"), "{out}");
1891
1892        // json recall
1893        let (code, out) = run_cmd(&db, &recall, true, 2_000);
1894        assert_eq!(code, 0);
1895        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1896        assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
1897    }
1898
1899    #[test]
1900    fn recall_empty_is_ok_with_a_note() {
1901        let (db, _t) = TempDb::open();
1902        let recall = Command::Recall {
1903            query: Some("nothing here".into()),
1904            tags: Vec::new(),
1905            entities: Vec::new(),
1906            as_of: None,
1907            range: None,
1908            k: 0,
1909            closed: false,
1910            token_budget: None,
1911            ef: None,
1912            vector: Vec::new(),
1913        };
1914        let (code, out) = run_cmd(&db, &recall, false, 1_000);
1915        assert_eq!(code, 0);
1916        assert!(out.contains("nothing recalled"), "{out}");
1917    }
1918
1919    #[test]
1920    fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
1921        let (db, _t) = TempDb::open();
1922        run_cmd(
1923            &db,
1924            &remember("lives in Moscow", Some("user"), &[]),
1925            false,
1926            1_000,
1927        );
1928        // a near-duplicate surfaces a similar hint
1929        let (_, out) = run_cmd(
1930            &db,
1931            &remember("lives in Moscow now", Some("user"), &[]),
1932            false,
1933            1_500,
1934        );
1935        assert!(out.contains("similar to fact"), "{out}");
1936
1937        let revise = Command::Revise {
1938            id: 0,
1939            text: "lives in Berlin".into(),
1940            entity: Some("user".into()),
1941            tags: Vec::new(),
1942            links: Vec::new(),
1943            meta: Vec::new(),
1944            valid_from: None,
1945            vector: Vec::new(),
1946        };
1947        let (code, out) = run_cmd(&db, &revise, false, 2_000);
1948        assert_eq!(code, 0);
1949        assert!(out.starts_with("remembered fact"), "{out}");
1950    }
1951
1952    #[test]
1953    fn show_found_and_missing() {
1954        let (db, _t) = TempDb::open();
1955        run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
1956
1957        let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1958        assert_eq!(code, 0);
1959        assert!(
1960            out.contains("a note") && out.contains("recorded_at 1000"),
1961            "{out}"
1962        );
1963
1964        let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
1965        assert_eq!(code, 1, "missing id is a soft miss");
1966        assert!(out.contains("not found"), "{out}");
1967
1968        // json card
1969        let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1970        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1971        assert_eq!(v["text"], "a note");
1972        assert_eq!(v["valid_to"], serde_json::Value::Null); // open interval
1973    }
1974
1975    #[test]
1976    fn forget_then_maintain_purges() {
1977        let (db, _t) = TempDb::open();
1978        run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
1979
1980        let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
1981        assert_eq!(code, 0);
1982        assert!(out.contains("forgot fact 0"), "{out}");
1983        // second forget is idempotent
1984        let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
1985        assert!(out.contains("already gone"), "{out}");
1986
1987        let (code, out) = run_cmd(
1988            &db,
1989            &Command::Maintain {
1990                mode: MaintainMode::Auto,
1991            },
1992            false,
1993            3_000,
1994        );
1995        assert_eq!(code, 0);
1996        assert!(out.contains("purged 1"), "{out}");
1997    }
1998
1999    #[test]
2000    fn link_and_stats_and_json() {
2001        let (db, _t) = TempDb::open();
2002        run_cmd(
2003            &db,
2004            &remember("uses tokio", Some("plugmem"), &[]),
2005            false,
2006            1_000,
2007        );
2008        let link = Command::Link {
2009            src: "plugmem".into(),
2010            rel: "depends_on".into(),
2011            dst: "tokio".into(),
2012            provenance: None,
2013        };
2014        let (code, out) = run_cmd(&db, &link, false, 2_000);
2015        assert_eq!(code, 0);
2016        assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
2017        let unlink = Command::Unlink {
2018            src: "plugmem".into(),
2019            rel: "depends_on".into(),
2020            dst: "tokio".into(),
2021        };
2022        let (code, out) = run_cmd(&db, &unlink, false, 2_500);
2023        assert_eq!(code, 0);
2024        assert!(
2025            out.contains("unlinked plugmem -depends_on-> tokio"),
2026            "{out}"
2027        );
2028
2029        let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
2030        assert_eq!(code, 0);
2031        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2032        assert_eq!(v["facts"], 1);
2033        assert_eq!(v["edges"], 0);
2034        assert_eq!(v["edge_versions"], 1);
2035    }
2036
2037    #[test]
2038    fn bad_link_is_a_usage_error() {
2039        let (db, _t) = TempDb::open();
2040        let cmd = Command::Remember {
2041            text: "x".into(),
2042            entity: Some("user".into()),
2043            tags: Vec::new(),
2044            links: vec!["not-a-pair".into()],
2045            meta: Vec::new(),
2046            valid_from: None,
2047            vector: Vec::new(),
2048        };
2049        let mut buf = Vec::new();
2050        let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
2051        assert!(matches!(err, CliError::Usage(_)));
2052    }
2053
2054    #[test]
2055    fn as_of_time_travel_via_recall() {
2056        let (db, _t) = TempDb::open();
2057        run_cmd(
2058            &db,
2059            &remember("lives in Moscow", Some("user"), &[]),
2060            false,
2061            1_000,
2062        );
2063        let revise = Command::Revise {
2064            id: 0,
2065            text: "lives in Berlin".into(),
2066            entity: Some("user".into()),
2067            tags: Vec::new(),
2068            links: Vec::new(),
2069            meta: Vec::new(),
2070            valid_from: None,
2071            vector: Vec::new(),
2072        };
2073        run_cmd(&db, &revise, false, 2_000);
2074
2075        let as_of = Command::Recall {
2076            query: Some("lives".into()),
2077            tags: Vec::new(),
2078            entities: vec!["user".into()],
2079            as_of: Some(1_500),
2080            range: None,
2081            k: 0,
2082            closed: false,
2083            token_budget: None,
2084            ef: None,
2085            vector: Vec::new(),
2086        };
2087        let (_, out) = run_cmd(&db, &as_of, false, 3_000);
2088        assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
2089    }
2090
2091    #[test]
2092    fn every_command_has_a_json_shape() {
2093        let (db, _t) = TempDb::open();
2094        // remember --json: id + similar array
2095        let (_, out) = run_cmd(
2096            &db,
2097            &remember("uses tokio", Some("plugmem"), &["pref"]),
2098            true,
2099            1_000,
2100        );
2101        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2102        assert_eq!(v["id"], 0);
2103        assert!(v["similar"].is_array());
2104
2105        // revise --json
2106        let revise = Command::Revise {
2107            id: 0,
2108            text: "uses tokio now".into(),
2109            entity: Some("plugmem".into()),
2110            tags: Vec::new(),
2111            links: Vec::new(),
2112            meta: Vec::new(),
2113            valid_from: None,
2114            vector: Vec::new(),
2115        };
2116        let (_, out) = run_cmd(&db, &revise, true, 1_500);
2117        assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2118
2119        // link --json
2120        let link = Command::Link {
2121            src: "plugmem".into(),
2122            rel: "depends_on".into(),
2123            dst: "tokio".into(),
2124            provenance: None,
2125        };
2126        let (_, out) = run_cmd(&db, &link, true, 2_000);
2127        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2128        assert_eq!(v["rel"], "depends_on");
2129        let unlink = Command::Unlink {
2130            src: "plugmem".into(),
2131            rel: "depends_on".into(),
2132            dst: "tokio".into(),
2133        };
2134        let (_, out) = run_cmd(&db, &unlink, true, 2_100);
2135        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2136        assert_eq!(v["unlinked"], true);
2137
2138        // forget --json then maintain --json
2139        let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
2140        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2141        assert_eq!(v["forgotten"], true);
2142        let (_, out) = run_cmd(
2143            &db,
2144            &Command::Maintain {
2145                mode: MaintainMode::Auto,
2146            },
2147            true,
2148            3_000,
2149        );
2150        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2151        assert!(v["purged"].as_u64().unwrap() >= 1);
2152
2153        // show --json of a missing id
2154        let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
2155        assert_eq!(code, 1);
2156        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2157        assert_eq!(v["found"], false);
2158
2159        // recall --json with a range window (covers the range/closed paths)
2160        let recall = Command::Recall {
2161            query: None,
2162            tags: Vec::new(),
2163            entities: vec!["plugmem".into()],
2164            as_of: None,
2165            range: Some(vec![0, 10_000]),
2166            k: 4,
2167            closed: true,
2168            token_budget: None,
2169            ef: None,
2170            vector: Vec::new(),
2171        };
2172        let (_, out) = run_cmd(&db, &recall, true, 4_000);
2173        assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2174    }
2175
2176    #[test]
2177    fn stats_human_lists_the_counters() {
2178        let (db, _t) = TempDb::open();
2179        run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2180        let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
2181        assert_eq!(code, 0);
2182        assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
2183    }
2184
2185    #[test]
2186    fn verify_command_renders_human_and_json() {
2187        let (db, _t) = TempDb::open();
2188        run_cmd(&db, &remember("clean", None, &[]), false, 1_000);
2189
2190        let (code, out) = run_cmd(&db, &Command::Verify, false, 2_000);
2191        assert_eq!(code, 0);
2192        assert_eq!(out.trim(), "integrity ok");
2193
2194        let (code, out) = run_cmd(&db, &Command::Verify, true, 2_100);
2195        assert_eq!(code, 0);
2196        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2197        assert_eq!(v["ok"], true);
2198    }
2199
2200    #[test]
2201    fn show_json_of_a_revised_predecessor_is_closed() {
2202        let (db, _t) = TempDb::open();
2203        run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
2204        let revise = Command::Revise {
2205            id: 0,
2206            text: "v2".into(),
2207            entity: Some("e".into()),
2208            tags: Vec::new(),
2209            links: Vec::new(),
2210            meta: Vec::new(),
2211            valid_from: None,
2212            vector: Vec::new(),
2213        };
2214        run_cmd(&db, &revise, false, 2_000);
2215        // the successor records `revises`; its card names the predecessor
2216        let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
2217        assert!(out.contains("revises     fact 0"), "{out}");
2218    }
2219
2220    #[test]
2221    fn resolve_db_path_prefers_the_flag() {
2222        let p = "/tmp/explicit.plugmem";
2223        assert_eq!(resolve_db_path(Some(p), None, None), PathBuf::from(p));
2224        let configured = std::path::Path::new("/tmp/configured.plugmem");
2225        assert_eq!(
2226            resolve_db_path(None, Some(configured), None),
2227            PathBuf::from(configured)
2228        );
2229        // With no flag/config it falls back to $PLUGMEM_DB or the platform default — we
2230        // only assert the code path runs and yields some path.
2231        let _ = resolve_db_path(None, None, None);
2232    }
2233
2234    #[test]
2235    fn a_bare_name_is_a_memory_only_when_a_workspace_is_configured() {
2236        let root = PathBuf::from("/srv/bot");
2237
2238        // Without a workspace, everything is a path — this is the guard on the
2239        // default: the old behaviour of `--db` is not allowed to shift.
2240        assert_eq!(
2241            resolve_db_path(Some("work"), None, None),
2242            PathBuf::from("work")
2243        );
2244
2245        // With one, a bare name resolves inside it...
2246        assert_eq!(
2247            resolve_db_path(Some("work"), None, Some(&root)),
2248            PathBuf::from("/srv/bot/db/work.plugmem")
2249        );
2250        // ...and anything that is not a name stays a path, so an explicit file
2251        // is still reachable from inside a workspace.
2252        for path in ["./work", "work.plugmem", "/srv/other.plugmem", "../up"] {
2253            assert_eq!(
2254                resolve_db_path(Some(path), None, Some(&root)),
2255                PathBuf::from(path),
2256                "{path}"
2257            );
2258        }
2259
2260        // `[database].path` is a file setting, never a name.
2261        let configured = std::path::Path::new("work");
2262        assert_eq!(
2263            resolve_db_path(None, Some(configured), Some(&root)),
2264            PathBuf::from("work")
2265        );
2266    }
2267
2268    #[test]
2269    fn settings_help_runs_without_opening_a_database() {
2270        let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
2271        let mut output = Vec::new();
2272        assert_eq!(run_parsed(cli, &mut output), 0);
2273        let output = String::from_utf8(output).unwrap();
2274        assert!(output.contains("plugmem settings"));
2275        assert!(output.contains("[database]"));
2276        assert!(output.contains("path (path string"));
2277
2278        let cli = Cli::try_parse_from(["plugmem-cli", "--json", "help", "settings"]).unwrap();
2279        let mut output = Vec::new();
2280        assert_eq!(run_parsed(cli, &mut output), 0);
2281        let output: serde_json::Value = serde_json::from_slice(&output).unwrap();
2282        assert_eq!(output["topic"], "settings");
2283        assert!(output["config_path_precedence"].is_array());
2284        assert!(output["settings"].as_array().unwrap().len() > 10);
2285    }
2286
2287    #[test]
2288    fn run_parsed_opens_runs_and_reports() {
2289        let dir = std::env::temp_dir().join(format!(
2290            "plugmem-run-{}-{}",
2291            std::process::id(),
2292            now_ms_unique()
2293        ));
2294        std::fs::create_dir_all(&dir).unwrap();
2295        let path = dir.join("m.plugmem");
2296        let cli = Cli {
2297            db: Some(path.display().to_string()),
2298            workspace: None,
2299            config: None,
2300            json: false,
2301            command: Command::Stats,
2302        };
2303        let mut buf = Vec::new();
2304        let code = run_parsed(cli, &mut buf);
2305        assert_eq!(code, 0);
2306        assert!(String::from_utf8(buf).unwrap().contains("facts"));
2307        let _ = std::fs::remove_dir_all(&dir);
2308    }
2309
2310    #[test]
2311    fn recover_and_scrub_render_json_and_human_shapes() {
2312        let (db, tmp) = TempDb::open();
2313        let path = tmp.0.join("m.plugmem");
2314        run_cmd(&db, &remember("recoverable fact", None, &[]), false, 1_000);
2315        run_cmd(&db, &Command::Checkpoint, false, 2_000);
2316        drop(db);
2317
2318        let settings = settings_with(None);
2319        let mut out = Vec::new();
2320        assert_eq!(do_scrub(&path, &settings, true, &mut out), 0);
2321        let scrub: serde_json::Value = serde_json::from_slice(&out).unwrap();
2322        assert_eq!(scrub["ok"], true);
2323        assert!(scrub["bytes"].as_u64().unwrap() > 0);
2324        let mut out = Vec::new();
2325        assert_eq!(do_scrub(&path, &settings, false, &mut out), 0);
2326        let out = String::from_utf8(out).unwrap();
2327        assert!(out.contains("scrub ok:"), "{out}");
2328
2329        let json_dst = tmp.0.join("copy-json.plugmem");
2330        let mut out = Vec::new();
2331        assert_eq!(do_recover(&path, &json_dst, &settings, true, &mut out), 0);
2332        let recover: serde_json::Value = serde_json::from_slice(&out).unwrap();
2333        assert_eq!(recover["kept"], 1);
2334        assert_eq!(recover["dropped_text"], 0);
2335        assert_eq!(recover["dst"], json_dst.display().to_string());
2336
2337        let human_dst = tmp.0.join("copy-human.plugmem");
2338        let mut out = Vec::new();
2339        assert_eq!(do_recover(&path, &human_dst, &settings, false, &mut out), 0);
2340        let out = String::from_utf8(out).unwrap();
2341        assert!(out.contains("recovered to"), "{out}");
2342        assert!(out.contains("kept 1"), "{out}");
2343    }
2344
2345    #[test]
2346    fn readonly_dispatcher_renders_every_read_shape() {
2347        let (db, tmp) = TempDb::open();
2348        let path = tmp.0.join("m.plugmem");
2349        run_cmd(
2350            &db,
2351            &remember("readonly tokio fact", Some("plugmem"), &["pref"]),
2352            false,
2353            1_000,
2354        );
2355        run_cmd(&db, &Command::Checkpoint, false, 2_000);
2356        let ro = Database::open_readonly(&path, Config::default()).unwrap();
2357
2358        let mut out = Vec::new();
2359        assert_eq!(execute_ro(&ro, &Command::Stats, None, true, &mut out), 0);
2360        let stats: serde_json::Value = serde_json::from_slice(&out).unwrap();
2361        assert_eq!(stats["facts"], 1);
2362
2363        let mut out = Vec::new();
2364        assert_eq!(
2365            execute_ro(&ro, &Command::Show { id: 0 }, None, false, &mut out),
2366            0
2367        );
2368        let text = String::from_utf8(out).unwrap();
2369        assert!(text.contains("readonly tokio fact"), "{text}");
2370
2371        let mut out = Vec::new();
2372        assert_eq!(execute_ro(&ro, &Command::Export, None, false, &mut out), 0);
2373        let exported: serde_json::Value =
2374            serde_json::from_str(String::from_utf8(out).unwrap().lines().next().unwrap()).unwrap();
2375        assert_eq!(exported["text"], "readonly tokio fact");
2376
2377        let mut out = Vec::new();
2378        let recall = Command::Recall {
2379            query: Some("tokio".into()),
2380            tags: vec!["pref".into()],
2381            entities: vec!["plugmem".into()],
2382            as_of: None,
2383            range: None,
2384            k: 1,
2385            closed: false,
2386            token_budget: None,
2387            ef: None,
2388            vector: Vec::new(),
2389        };
2390        assert_eq!(execute_ro(&ro, &recall, None, false, &mut out), 0);
2391        let text = String::from_utf8(out).unwrap();
2392        assert!(text.contains("tokio"), "{text}");
2393
2394        let mut out = Vec::new();
2395        assert_eq!(execute_ro(&ro, &Command::Verify, None, true, &mut out), 0);
2396        let verify: serde_json::Value = serde_json::from_slice(&out).unwrap();
2397        assert_eq!(verify["ok"], true);
2398    }
2399
2400    #[test]
2401    fn run_parsed_on_a_locked_database_returns_one() {
2402        let (_held, dir) = {
2403            let dir = std::env::temp_dir().join(format!(
2404                "plugmem-lock-{}-{}",
2405                std::process::id(),
2406                now_ms_unique()
2407            ));
2408            std::fs::create_dir_all(&dir).unwrap();
2409            let path = dir.join("m.plugmem");
2410            (Database::open(&path, Config::default()).unwrap(), dir)
2411        };
2412        let cli = Cli {
2413            db: Some(dir.join("m.plugmem").display().to_string()),
2414            workspace: None,
2415            config: None,
2416            json: false,
2417            command: Command::Stats,
2418        };
2419        let mut buf = Vec::new();
2420        assert_eq!(run_parsed(cli, &mut buf), 1);
2421        let _ = std::fs::remove_dir_all(&dir);
2422    }
2423
2424    #[test]
2425    fn run_parsed_propagates_a_usage_error_as_two() {
2426        let dir = std::env::temp_dir().join(format!(
2427            "plugmem-usage-{}-{}",
2428            std::process::id(),
2429            now_ms_unique()
2430        ));
2431        std::fs::create_dir_all(&dir).unwrap();
2432        let cli = Cli {
2433            db: Some(dir.join("m.plugmem").display().to_string()),
2434            workspace: None,
2435            config: None,
2436            json: false,
2437            command: Command::Remember {
2438                text: "x".into(),
2439                entity: None,
2440                tags: Vec::new(),
2441                links: vec!["bad".into()],
2442                meta: Vec::new(),
2443                valid_from: None,
2444                vector: Vec::new(),
2445            },
2446        };
2447        let mut buf = Vec::new();
2448        assert_eq!(run_parsed(cli, &mut buf), 2);
2449        let _ = std::fs::remove_dir_all(&dir);
2450    }
2451
2452    /// A scratch directory (no db) for config/checkpoint tests; removed on drop.
2453    struct Scratch(PathBuf);
2454    impl Scratch {
2455        fn new(tag: &str) -> Self {
2456            let dir = std::env::temp_dir().join(format!(
2457                "plugmem-cli-{tag}-{}-{}",
2458                std::process::id(),
2459                now_ms_unique()
2460            ));
2461            std::fs::create_dir_all(&dir).unwrap();
2462            Scratch(dir)
2463        }
2464    }
2465    impl Drop for Scratch {
2466        fn drop(&mut self) {
2467            let _ = std::fs::remove_dir_all(&self.0);
2468        }
2469    }
2470
2471    #[test]
2472    fn export_import_roundtrip_preserves_open_facts() {
2473        // A deliberately nested scenario: entities, multi-tag facts, a
2474        // revision (closes its predecessor), a forget (tombstone), and an
2475        // explicit valid_from — export must dump exactly the open facts, and
2476        // import must reconstruct that set faithfully.
2477        let (a, _ta) = TempDb::open();
2478        run_cmd(
2479            &a,
2480            &Command::Remember {
2481                text: "prefers tokio".into(),
2482                entity: Some("user".into()),
2483                tags: vec!["pref".into(), "lang".into()],
2484                links: Vec::new(),
2485                meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
2486                valid_from: Some(500),
2487                vector: Vec::new(),
2488            },
2489            false,
2490            1_000,
2491        );
2492        run_cmd(
2493            &a,
2494            &remember("lives in Moscow", Some("user"), &[]),
2495            false,
2496            1_100,
2497        ); // id 1
2498        run_cmd(
2499            &a,
2500            &Command::Revise {
2501                id: 1,
2502                text: "lives in Berlin".into(),
2503                entity: Some("user".into()),
2504                tags: vec!["geo".into()],
2505                links: Vec::new(),
2506                meta: Vec::new(),
2507                valid_from: None,
2508                vector: Vec::new(),
2509            },
2510            false,
2511            1_200,
2512        ); // id 2 open, id 1 closed
2513        run_cmd(&a, &remember("junk", None, &[]), false, 1_300); // id 3
2514        run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); // tombstone id 3
2515        run_cmd(
2516            &a,
2517            &remember("uses rust", Some("plugmem"), &["lang"]),
2518            false,
2519            1_500,
2520        ); // id 4
2521
2522        // Export A into a JSONL file.
2523        let mut dump = Vec::new();
2524        render_export(&a.export(), false, &mut dump);
2525        let scratch = Scratch::new("roundtrip");
2526        let file = scratch.0.join("dump.jsonl");
2527        std::fs::write(&file, &dump).unwrap();
2528
2529        // Import into a fresh B.
2530        let (b, _tb) = TempDb::open();
2531        let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
2532
2533        // Both sides, compared as sets keyed by the preserved fields.
2534        let key = |f: &ExportedFact| {
2535            let mut tags = f.tags.clone();
2536            tags.sort();
2537            (f.text.clone(), f.entity.clone(), tags, f.valid_from)
2538        };
2539        let mut ak: Vec<_> = a.export().iter().map(key).collect();
2540        let mut bk: Vec<_> = b.export().iter().map(key).collect();
2541        ak.sort();
2542        bk.sort();
2543        assert_eq!(n.facts, ak.len());
2544        assert_eq!(
2545            ak, bk,
2546            "roundtrip must preserve text/entity/tags/valid_from"
2547        );
2548
2549        // Spot-checks: the open facts survive with their metadata; the closed
2550        // revision and the tombstone do not.
2551        let b_open = b.export();
2552        assert!(b_open.iter().any(|f| f.text == "prefers tokio"
2553            && f.valid_from == 500
2554            && f.entity.as_deref() == Some("user")
2555            && f.tags == vec!["pref".to_string(), "lang".to_string()]
2556            && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
2557            && f.metadata.get("src").map(String::as_str) == Some("chat")));
2558        assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
2559        assert!(b_open.iter().any(|f| f.text == "uses rust"));
2560        assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
2561        assert!(!b_open.iter().any(|f| f.text == "junk"));
2562    }
2563
2564    #[test]
2565    fn export_command_emits_jsonl_regardless_of_json_flag() {
2566        let (db, _t) = TempDb::open();
2567        run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
2568        for json in [false, true] {
2569            let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
2570            assert_eq!(code, 0);
2571            let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2572            assert_eq!(v["text"], "a fact");
2573            assert_eq!(v["entity"], "e");
2574            assert_eq!(v["tags"][0], "t");
2575        }
2576    }
2577
2578    #[test]
2579    fn import_command_counts_and_rejects_bad_lines() {
2580        let (db, _t) = TempDb::open();
2581        let scratch = Scratch::new("import");
2582        let good = scratch.0.join("in.jsonl");
2583        std::fs::write(
2584            &good,
2585            "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
2586        )
2587        .unwrap();
2588        // A tiny batch size exercises the streaming/chunking path (two batches).
2589        let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
2590        assert_eq!(n.facts, 2, "both facts imported, blank line skipped");
2591
2592        let bad = scratch.0.join("bad.jsonl");
2593        std::fs::write(&bad, "not json at all\n").unwrap();
2594        let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
2595        assert!(matches!(err, CliError::Usage(_)));
2596    }
2597
2598    #[test]
2599    fn import_batch_size_does_not_change_the_result() {
2600        // The chunk size is a performance knob only: importing the same file with
2601        // batch 1 and batch 100 yields the identical fact set.
2602        let scratch = Scratch::new("import-batch");
2603        let file = scratch.0.join("facts.jsonl");
2604        let mut jsonl = String::new();
2605        for i in 0..5 {
2606            jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
2607        }
2608        std::fs::write(&file, &jsonl).unwrap();
2609
2610        let (a, _ta) = TempDb::open();
2611        let (b, _tb) = TempDb::open();
2612        let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
2613        let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
2614
2615        assert_eq!(na.facts, 5);
2616        assert_eq!(nb.facts, 5);
2617        let texts = |db: &Database| {
2618            let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
2619            t.sort();
2620            t
2621        };
2622        assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
2623    }
2624
2625    #[test]
2626    fn config_table_feeds_settings_and_the_cli_batch_size() {
2627        // The CLI reads config.toml once (host `read_config`), builds the
2628        // shared `Settings`, and pulls its own `batch_size` from the same
2629        // table — the exact flow of `run_parsed`.
2630        let scratch = Scratch::new("settings");
2631        let cfgfile = scratch.0.join("config.toml");
2632        std::fs::write(
2633            &cfgfile,
2634            "[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n\
2635             [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
2636        )
2637        .unwrap();
2638        let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
2639        let s = Settings::from_table(table.as_ref()).unwrap();
2640        assert_eq!(s.config.dim, 512);
2641        assert!(s.embedder.is_none());
2642        assert_eq!(s.snapshot_every_ops, Some(64));
2643        assert_eq!(read_batch_size(table.as_ref()), Some(200));
2644
2645        // An explicit --config that does not exist is a usage error.
2646        assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
2647    }
2648
2649    #[test]
2650    fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
2651        let scratch = Scratch::new("checkpoint-cmd");
2652        let path = scratch.0.join("m.plugmem");
2653
2654        // A remember through the read-write path leaves a dirty journal.
2655        let remember = Cli {
2656            db: Some(path.display().to_string()),
2657            workspace: None,
2658            config: None,
2659            json: false,
2660            command: Command::Remember {
2661                text: "hello tokio".into(),
2662                entity: None,
2663                tags: Vec::new(),
2664                links: Vec::new(),
2665                meta: Vec::new(),
2666                valid_from: None,
2667                vector: Vec::new(),
2668            },
2669        };
2670        assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
2671
2672        // The new command: human shape.
2673        let checkpoint = |json| Cli {
2674            db: Some(path.display().to_string()),
2675            workspace: None,
2676            config: None,
2677            json,
2678            command: Command::Checkpoint,
2679        };
2680        let mut buf = Vec::new();
2681        assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
2682        assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
2683
2684        // json shape.
2685        let mut buf = Vec::new();
2686        assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
2687        let v: serde_json::Value =
2688            serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
2689        assert_eq!(v["ok"], true);
2690
2691        // The journal is now clean, so scrub (a shared-lock, read-only open)
2692        // succeeds — it would fail `NeedsCheckpoint` on a dirty journal.
2693        let scrub = Cli {
2694            db: Some(path.display().to_string()),
2695            workspace: None,
2696            config: None,
2697            json: false,
2698            command: Command::Scrub,
2699        };
2700        let mut buf = Vec::new();
2701        assert_eq!(run_parsed(scrub, &mut buf), 0);
2702        assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
2703    }
2704
2705    #[test]
2706    fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
2707        let scratch = Scratch::new("ro-route");
2708        let path = scratch.0.join("m.plugmem");
2709        {
2710            let (db, _) = Database::open(&path, Config::default()).unwrap();
2711            db.remember(RememberInput::text(1_000, "hello tokio"))
2712                .unwrap();
2713            db.checkpoint(2_000).unwrap(); // empty journal → open_readonly succeeds
2714        }
2715        // stats routes through open_readonly (mmap, shared)
2716        let cli = Cli {
2717            db: Some(path.display().to_string()),
2718            workspace: None,
2719            config: None,
2720            json: false,
2721            command: Command::Stats,
2722        };
2723        let mut buf = Vec::new();
2724        assert_eq!(run_parsed(cli, &mut buf), 0);
2725        assert!(String::from_utf8(buf).unwrap().contains("facts"));
2726
2727        // recall with no embedder also uses the read-only path
2728        let cli = Cli {
2729            db: Some(path.display().to_string()),
2730            workspace: None,
2731            config: None,
2732            json: false,
2733            command: Command::Recall {
2734                query: Some("tokio".into()),
2735                tags: Vec::new(),
2736                entities: Vec::new(),
2737                as_of: None,
2738                range: None,
2739                k: 0,
2740                closed: false,
2741                token_budget: None,
2742                ef: None,
2743                vector: Vec::new(),
2744            },
2745        };
2746        let mut buf = Vec::new();
2747        assert_eq!(run_parsed(cli, &mut buf), 0);
2748        assert!(String::from_utf8(buf).unwrap().contains("tokio"));
2749    }
2750}