Skip to main content

plugmem_cli/
lib.rs

1//! `plugmem` — the command-line surface over the
2//! [temporal-memory engine](plugmem_core), a thin wrapper around
3//! [`plugmem_host::Database`]. Parse the arguments, call one
4//! engine verb, render the result — human text by default, `--json` for
5//! tooling and agents. No memory logic lives here; that is the engine's.
6//!
7//! Exit codes: `0` success; `1` a soft miss (the target fact does not
8//! exist, or the database is locked by another process); `2` a usage or
9//! runtime error. This makes the binary scriptable as a gate.
10//!
11//! The logic is in this library (not `main.rs`) so it is unit-testable:
12//! [`run`] wires argv and the database, and `execute` runs one command
13//! against an open [`Database`] into any writer.
14
15mod cli;
16mod config;
17
18use std::collections::BTreeMap;
19use std::io::{self, BufRead, Write};
20use std::path::{Path, PathBuf};
21use std::process::ExitCode;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use clap::Parser;
25use plugmem_host::{
26    Database, ExportedFact, FactId, HostError, LinkInput, ReadOnlyDatabase, RecallQuery,
27    RecallResult, RememberInput, RememberOutcome, Settings, Stats, VALID_TO_OPEN,
28};
29use serde_json::json;
30
31use crate::cli::{Cli, Command};
32use crate::config::read_batch_size;
33
34/// Environment variable naming the database file (below the `--db` flag).
35pub(crate) const ENV_DB: &str = "PLUGMEM_DB";
36/// Default database file when neither flag nor env is given.
37pub(crate) const DEFAULT_DB: &str = "plugmem.db";
38
39/// A failure before or during a command: a runtime engine/host error, or a
40/// usage error (a malformed argument the parser could not catch).
41#[derive(Debug)]
42pub(crate) enum CliError {
43    Host(HostError),
44    Usage(String),
45}
46
47impl From<HostError> for CliError {
48    fn from(e: HostError) -> Self {
49        CliError::Host(e)
50    }
51}
52
53/// Wall-clock now in unix milliseconds (the engine keeps no clock).
54pub(crate) fn now_ms() -> u64 {
55    SystemTime::now()
56        .duration_since(UNIX_EPOCH)
57        .map(|d| d.as_millis() as u64)
58        .unwrap_or(0)
59}
60
61/// Parses argv and runs one command, mapping the result to a process exit
62/// code. The binary's `main` is a one-liner over this; the wiring itself is
63/// [`run_parsed`], which is unit-testable (only `Cli::parse` is not).
64pub fn run() -> ExitCode {
65    let stdout = io::stdout();
66    ExitCode::from(run_parsed(Cli::parse(), &mut stdout.lock()))
67}
68
69/// The testable core of [`run`]: resolve settings and the database path,
70/// open the right handle, run the command into `out`, return the exit code
71/// (`0` ok, `1` soft miss / locked, `2` error). Errors go to stderr.
72fn run_parsed(cli: Cli, out: &mut impl Write) -> u8 {
73    let path = resolve_db_path(cli.db.as_deref());
74    // Read config.toml once: the shared loader builds engine/embedder/
75    // maintenance settings; the CLI reads its own `[maintenance].batch_size`
76    // from the same table (used by `import` below).
77    let table = match plugmem_host::read_config(cli.config.as_deref()) {
78        Ok(t) => t,
79        Err(e) => return report_err(&e.into()),
80    };
81    let cfg_batch_size = read_batch_size(table.as_ref());
82    let mut settings = match Settings::from_table(table.as_ref()) {
83        Ok(s) => s,
84        Err(e) => return report_err(&e.into()),
85    };
86
87    // `recover` is a standalone salvage on file paths — it opens the source
88    // itself (under an exclusive lock) and writes a fresh destination, so it
89    // runs before the normal open. `scrub` is a byte-level container check over
90    // a read-only (shared-lock) open, which requires a checkpointed database.
91    match &cli.command {
92        Command::Recover { dst } => return do_recover(&path, dst, &settings, cli.json, out),
93        Command::Scrub => return do_scrub(&path, &settings, cli.json, out),
94        // The interactive session opens one handle and reads commands from
95        // stdin, so it is dispatched before the per-command open below. The
96        // read-only variant observes another process's writer over a shared
97        // mmap; the default variant opens the single writer handle.
98        Command::Repl { read_only: true } => {
99            return run_repl_ro(&path, settings, cli.json, io::stdin().lock(), out);
100        }
101        Command::Repl { read_only: false } => {
102            return run_repl(&path, settings, cli.json, io::stdin().lock(), out);
103        }
104        _ => {}
105    }
106
107    // Read-only commands open the snapshot zero-copy (mmap, shared lock) and
108    // coexist with a live writer process (Variant 2 MVCC) — they never take the
109    // writer lock. `verify` is a pure content check, so it belongs here too.
110    // `recall` embeds its text query *before* the open (mirroring the host's
111    // "embed outside the lock" rule) so it can search by vector on the read-only
112    // path, which carries no embedder. A dirty (un-checkpointed) journal forbids
113    // a read-only open, so those fall through to the read-write path.
114    let readonly_ok = matches!(
115        &cli.command,
116        Command::Show { .. }
117            | Command::Stats
118            | Command::Export
119            | Command::Verify
120            | Command::Recall { .. }
121    );
122    if readonly_ok {
123        let recall_vector = match embed_recall_query(&mut settings, &cli.command) {
124            Ok(v) => v,
125            Err(e) => return report_err(&e),
126        };
127        match Database::open_readonly(&path, settings.config.clone()) {
128            Ok(ro) => {
129                return execute_ro(&ro, &cli.command, recall_vector.as_deref(), cli.json, out);
130            }
131            Err(HostError::Locked { path }) => return report_locked(&path),
132            // Any other failure — a missing snapshot (fresh db), a dirty
133            // journal (NeedsCheckpoint), or a corrupt image — is handled by
134            // the read-write path: it creates/checkpoints, or surfaces the
135            // same corruption as a typed error.
136            Err(_) => {}
137        }
138    }
139
140    // `cfg_batch_size` was read from the config table above (before `open`
141    // consumes `settings`); the `--batch` flag still wins over it.
142    let db = match settings.open(&path) {
143        Ok(db) => db,
144        Err(HostError::Locked { path }) => return report_locked(&path),
145        Err(e) => return report_err(&CliError::Host(e)),
146    };
147    // Import is dispatched here, not in `execute`: its batch size comes from the
148    // `--batch` flag or `[maintenance].batch_size` (flag > config > default).
149    if let Command::Import { file, batch } = &cli.command {
150        let batch_size = batch
151            .or(cfg_batch_size.map(|n| n as usize))
152            .unwrap_or(DEFAULT_IMPORT_BATCH)
153            .max(1);
154        return match do_import(&db, now_ms(), file, batch_size, out) {
155            Ok(n) => {
156                if cli.json {
157                    writeln!(out, "{}", json!({ "imported": n })).ok();
158                } else {
159                    writeln!(out, "imported {n} facts").ok();
160                }
161                0
162            }
163            Err(e) => {
164                let _ = out.flush();
165                report_err(&e)
166            }
167        };
168    }
169    match execute(&db, &cli.command, cli.json, now_ms(), out) {
170        Ok(code) => code,
171        Err(e) => {
172            let _ = out.flush();
173            report_err(&e)
174        }
175    }
176}
177
178/// Default facts-per-batch for `import` when neither `--batch` nor
179/// `[maintenance].batch_size` is set — safe for provider batch limits.
180const DEFAULT_IMPORT_BATCH: usize = 128;
181
182/// Prints an error to stderr and returns its exit code (`2`).
183fn report_err(e: &CliError) -> u8 {
184    match e {
185        CliError::Usage(msg) => eprintln!("plugmem: {msg}"),
186        CliError::Host(err) => eprintln!("plugmem: {err}"),
187    }
188    2
189}
190
191/// Prints the locked message and returns its exit code (`1`).
192fn report_locked(path: &std::path::Path) -> u8 {
193    eprintln!(
194        "plugmem: database is locked by another process: {}",
195        path.display()
196    );
197    1
198}
199
200/// Database path precedence: `--db` flag > `$PLUGMEM_DB` >
201/// `./plugmem.db`.
202fn resolve_db_path(flag: Option<&std::path::Path>) -> PathBuf {
203    flag.map(PathBuf::from)
204        .or_else(|| std::env::var_os(ENV_DB).map(PathBuf::from))
205        .unwrap_or_else(|| PathBuf::from(DEFAULT_DB))
206}
207
208/// Runs a read-only command over a zero-copy [`ReadOnlyDatabase`] (mmap,
209/// shared lock). Only the commands `run_parsed` routes here appear.
210fn execute_ro(
211    ro: &ReadOnlyDatabase,
212    cmd: &Command,
213    recall_vector: Option<&[f32]>,
214    json: bool,
215    out: &mut impl Write,
216) -> u8 {
217    match cmd {
218        Command::Recall { .. } => {
219            match with_recall_query(cmd, now_ms(), recall_vector, |q| ro.recall(q)) {
220                Ok(res) => {
221                    render_recall(&res, json, out);
222                    0
223                }
224                Err(e) => report_err(&CliError::Host(e)),
225            }
226        }
227        Command::Show { id } => render_show(ro.get(FactId(*id)), *id, json, out),
228        Command::Stats => {
229            render_stats(&ro.stats(), json, out);
230            0
231        }
232        Command::Export => {
233            ro.export_each(|f| write_export_line(out, &f));
234            0
235        }
236        // A clean image returns Ok; corruption is a typed error mapped to exit 2.
237        Command::Verify => match ro.verify() {
238            Ok(()) => {
239                if json {
240                    writeln!(out, "{}", json!({ "ok": true })).ok();
241                } else {
242                    writeln!(out, "integrity ok").ok();
243                }
244                0
245            }
246            Err(e) => report_err(&CliError::Host(e)),
247        },
248        _ => unreachable!("execute_ro only receives read-only commands"),
249    }
250}
251
252/// Runs one command against an open database, writing the result to `out`.
253/// Returns the process exit code (`0` ok, `1` soft miss). Split from
254/// [`run`] so tests drive it directly against a temp database.
255fn execute(
256    db: &Database,
257    cmd: &Command,
258    json: bool,
259    now: u64,
260    out: &mut impl Write,
261) -> Result<u8, CliError> {
262    match cmd {
263        Command::Remember {
264            text,
265            entity,
266            tags,
267            links,
268            meta,
269            valid_from,
270        } => {
271            let outcome = do_remember(db, now, text, entity, tags, links, meta, *valid_from, None)?;
272            render_remember(&outcome, json, out);
273            Ok(0)
274        }
275        Command::Revise {
276            id,
277            text,
278            entity,
279            tags,
280            links,
281            meta,
282            valid_from,
283        } => {
284            let outcome = do_remember(
285                db,
286                now,
287                text,
288                entity,
289                tags,
290                links,
291                meta,
292                *valid_from,
293                Some(FactId(*id)),
294            )?;
295            render_remember(&outcome, json, out);
296            Ok(0)
297        }
298        Command::Recall { .. } => {
299            let res = with_recall_query(cmd, now, None, |q| db.recall(q))?;
300            render_recall(&res, json, out);
301            Ok(0)
302        }
303        Command::Forget { id } => {
304            let fresh = db.forget(now, FactId(*id))?;
305            if json {
306                writeln!(out, "{}", json!({ "id": id, "forgotten": fresh })).ok();
307            } else if fresh {
308                writeln!(out, "forgot fact {id}").ok();
309            } else {
310                writeln!(out, "fact {id} was already gone").ok();
311            }
312            Ok(0)
313        }
314        Command::Link { src, rel, dst } => {
315            db.link(LinkInput {
316                now,
317                src,
318                rel,
319                dst,
320                provenance: None,
321            })?;
322            if json {
323                writeln!(out, "{}", json!({ "src": src, "rel": rel, "dst": dst })).ok();
324            } else {
325                writeln!(out, "linked {src} -{rel}-> {dst}").ok();
326            }
327            Ok(0)
328        }
329        Command::Show { id } => Ok(render_show(db.get(FactId(*id)), *id, json, out)),
330        Command::Stats => {
331            render_stats(&db.stats(), json, out);
332            Ok(0)
333        }
334        Command::Export => {
335            db.export_each(|f| write_export_line(out, &f));
336            Ok(0)
337        }
338        Command::Maintain => {
339            let report = db.maintain(now)?;
340            if json {
341                writeln!(
342                    out,
343                    "{}",
344                    json!({
345                        "purged": report.purged,
346                        "bytes_before": report.bytes_before,
347                        "bytes_after": report.bytes_after,
348                    })
349                )
350                .ok();
351            } else {
352                writeln!(
353                    out,
354                    "maintained: purged {}, {} -> {} bytes",
355                    report.purged, report.bytes_before, report.bytes_after
356                )
357                .ok();
358            }
359            Ok(0)
360        }
361        Command::Checkpoint => {
362            db.checkpoint(now)?;
363            if json {
364                writeln!(out, "{}", json!({ "ok": true })).ok();
365            } else {
366                writeln!(out, "checkpointed: journal flushed to snapshot").ok();
367            }
368            Ok(0)
369        }
370        Command::Verify => {
371            // A clean image returns Ok; corruption is a typed error the caller
372            // maps to exit 2.
373            db.verify()?;
374            if json {
375                writeln!(out, "{}", json!({ "ok": true })).ok();
376            } else {
377                writeln!(out, "integrity ok").ok();
378            }
379            Ok(0)
380        }
381        // Handled in `run_parsed` (Import needs `settings` for its batch size).
382        Command::Scrub
383        | Command::Recover { .. }
384        | Command::Repl { .. }
385        | Command::Import { .. } => {
386            unreachable!("scrub/recover/repl/import are dispatched before execute")
387        }
388    }
389}
390
391/// Salvages `src` into a fresh `dst`: `Database::recover` opens
392/// the source under an exclusive lock, drops the content-corrupt facts, and
393/// writes a clean disk-first copy. The source is left untouched.
394fn do_recover(src: &Path, dst: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
395    match Database::recover(src, dst, settings.config.clone(), now_ms()) {
396        Ok(r) => {
397            if json {
398                writeln!(
399                    out,
400                    "{}",
401                    json!({
402                        "kept": r.kept,
403                        "dropped_text": r.dropped_text,
404                        "dropped_vector": r.dropped_vector,
405                        "dropped_metadata": r.dropped_metadata,
406                        "dst": dst.display().to_string(),
407                    })
408                )
409                .ok();
410            } else {
411                writeln!(
412                    out,
413                    "recovered to {}: kept {}, dropped {} text + {} vector + {} metadata",
414                    dst.display(),
415                    r.kept,
416                    r.dropped_text,
417                    r.dropped_vector,
418                    r.dropped_metadata
419                )
420                .ok();
421            }
422            0
423        }
424        Err(HostError::Locked { path }) => report_locked(&path),
425        Err(e) => report_err(&CliError::Host(e)),
426    }
427}
428
429/// Runs a byte-level container scrub over a read-only (shared-lock) open. A
430/// clean image exits 0; the first damaged section is a typed error (exit 2). A
431/// dirty journal forbids the read-only open — the reported `NeedsCheckpoint`
432/// tells the caller to run `maintain` first.
433fn do_scrub(path: &Path, settings: &Settings, json: bool, out: &mut impl Write) -> u8 {
434    let ro = match Database::open_readonly(path, settings.config.clone()) {
435        Ok(ro) => ro,
436        Err(HostError::Locked { path }) => return report_locked(&path),
437        Err(e) => return report_err(&CliError::Host(e)),
438    };
439    let scrub = match ro.scrub() {
440        Ok(s) => s,
441        Err(e) => return report_err(&CliError::Host(e)),
442    };
443    let mut done = 0u64;
444    let mut total = 0u64;
445    for step in scrub {
446        match step {
447            Ok(p) => {
448                done = p.done_bytes;
449                total = p.total_bytes;
450            }
451            Err(e) => return report_err(&CliError::Host(e)),
452        }
453    }
454    if json {
455        writeln!(out, "{}", json!({ "ok": true, "bytes": done })).ok();
456    } else {
457        writeln!(out, "scrub ok: {done}/{total} bytes verified").ok();
458    }
459    0
460}
461
462/// Parses a single REPL line: the subcommand grammar, with no leading binary
463/// name (the line is `recall tokio`, not `plugmem recall tokio`).
464#[derive(Parser)]
465#[command(no_binary_name = true, name = "plugmem")]
466struct ReplLine {
467    #[command(subcommand)]
468    command: Command,
469}
470
471/// Splits a REPL line into tokens, honoring single/double quotes so
472/// `remember "two words"` is one argument. No escape handling — a quote runs to
473/// its match or the end of the line.
474fn split_line(line: &str) -> Vec<String> {
475    let mut tokens = Vec::new();
476    let mut cur = String::new();
477    let mut quote: Option<char> = None;
478    let mut has = false;
479    for c in line.chars() {
480        match quote {
481            Some(q) => {
482                if c == q {
483                    quote = None;
484                } else {
485                    cur.push(c);
486                }
487            }
488            None if c == '"' || c == '\'' => {
489                quote = Some(c);
490                has = true;
491            }
492            None if c.is_whitespace() => {
493                if has {
494                    tokens.push(std::mem::take(&mut cur));
495                    has = false;
496                }
497            }
498            None => {
499                cur.push(c);
500                has = true;
501            }
502        }
503    }
504    if has {
505        tokens.push(cur);
506    }
507    tokens
508}
509
510/// Runs the interactive session over one open writer handle: read a line, parse
511/// it as a subcommand, run it against the in-memory engine, repeat. The engine
512/// stays resident, so each command is host-speed (no per-command reload). The
513/// session checkpoints on exit, leaving a read-ready file. Prompts and the
514/// banner go to stderr so stdout carries only command output.
515fn run_repl(
516    path: &Path,
517    settings: Settings,
518    json: bool,
519    input: impl BufRead,
520    out: &mut impl Write,
521) -> u8 {
522    let db = match settings.open(path) {
523        Ok(db) => db,
524        Err(HostError::Locked { path }) => return report_locked(&path),
525        Err(e) => return report_err(&CliError::Host(e)),
526    };
527    eprintln!("plugmem repl — one open handle, host speed. `help` for verbs, `exit` to quit.");
528    eprint!("plugmem> ");
529    for line in input.lines() {
530        let Ok(line) = line else { break };
531        let line = line.trim();
532        if line.is_empty() {
533            eprint!("plugmem> ");
534            continue;
535        }
536        if line == "exit" || line == "quit" {
537            break;
538        } else if line == "help" {
539            writeln!(
540                out,
541                "verbs: remember recall revise forget link show stats maintain checkpoint \
542                 verify export import  (scrub/recover stay one-shot)  exit"
543            )
544            .ok();
545        } else {
546            run_repl_line(&db, line, json, out);
547        }
548        eprint!("plugmem> ");
549    }
550    eprintln!();
551    // Leave the database checkpointed (read-ready) for the next opener.
552    match db.checkpoint(now_ms()) {
553        Ok(()) => 0,
554        Err(e) => report_err(&CliError::Host(e)),
555    }
556}
557
558/// Parses and runs one non-meta REPL line, reporting errors to `out` without
559/// ending the session.
560fn run_repl_line(db: &Database, line: &str, json: bool, out: &mut impl Write) {
561    let cmd = match ReplLine::try_parse_from(split_line(line)) {
562        Ok(r) => r.command,
563        // clap's message (usage / unknown command / `--help`) — print, continue.
564        Err(e) => {
565            let _ = writeln!(out, "{e}");
566            return;
567        }
568    };
569    match &cmd {
570        Command::Repl { .. } => {
571            let _ = writeln!(out, "already in a repl session");
572        }
573        Command::Scrub | Command::Recover { .. } => {
574            let _ = writeln!(
575                out,
576                "scrub/recover are one-shot commands; run them outside the repl"
577            );
578        }
579        _ => {
580            if let Err(e) = execute(db, &cmd, json, now_ms(), out) {
581                let _ = match &e {
582                    CliError::Usage(m) => writeln!(out, "plugmem: {m}"),
583                    CliError::Host(h) => writeln!(out, "plugmem: {h}"),
584                };
585            }
586        }
587    }
588}
589
590/// Runs the interactive session **read-only** over one open
591/// [`ReadOnlyDatabase`] (a shared, zero-copy mmap): it observes another
592/// process's writer at the generation it opened on. Only the read verbs run;
593/// writes and one-shot commands are refused. Two extra meta-verbs make the
594/// cross-process freshness observable by hand — `generation` prints the pinned
595/// snapshot number, and `refresh` advances to the writer's latest published
596/// checkpoint (see [`ReadOnlyDatabase::refresh`](plugmem_host::ReadOnlyDatabase::refresh)).
597///
598/// These two verbs exist **only** in this mode. A normal (writer) `repl` and
599/// any one-shot command already see the freshest data — read-your-writes over
600/// the overlay, or a fresh open per command — so there is nothing to refresh
601/// there. This session never writes: it does not checkpoint on exit.
602fn run_repl_ro(
603    path: &Path,
604    mut settings: Settings,
605    json: bool,
606    input: impl BufRead,
607    out: &mut impl Write,
608) -> u8 {
609    let mut ro = match Database::open_readonly(path, settings.config.clone()) {
610        Ok(ro) => ro,
611        Err(HostError::Locked { path }) => return report_locked(&path),
612        // A dirty (un-checkpointed) journal, a fresh database with no published
613        // generation, or a corrupt image — surfaced as a typed error.
614        Err(e) => return report_err(&CliError::Host(e)),
615    };
616    eprintln!(
617        "plugmem repl --read-only — observing generation {} of another process's writer. \
618         `help` for verbs, `refresh`/`generation` for cross-process freshness, `exit` to quit.",
619        ro.generation()
620    );
621    eprint!("plugmem(ro)> ");
622    for line in input.lines() {
623        let Ok(line) = line else { break };
624        let line = line.trim();
625        if line.is_empty() {
626            eprint!("plugmem(ro)> ");
627            continue;
628        }
629        match line {
630            "exit" | "quit" => break,
631            "help" => {
632                writeln!(
633                    out,
634                    "read verbs: recall show stats export verify  \
635                     freshness: generation refresh  exit  \
636                     (writes and scrub/recover are refused in a read-only session)"
637                )
638                .ok();
639            }
640            // Freshness meta-verbs — only meaningful for a read-only observer of
641            // another process's writer (a writer repl sees its own writes at once).
642            "generation" => {
643                let g = ro.generation();
644                if json {
645                    writeln!(out, "{}", json!({ "generation": g })).ok();
646                } else {
647                    writeln!(out, "generation {g}").ok();
648                }
649            }
650            "refresh" => match ro.refresh() {
651                Ok(advanced) => {
652                    let g = ro.generation();
653                    if json {
654                        writeln!(out, "{}", json!({ "advanced": advanced, "generation": g })).ok();
655                    } else if advanced {
656                        writeln!(out, "refreshed → generation {g}").ok();
657                    } else {
658                        writeln!(out, "already current → generation {g}").ok();
659                    }
660                }
661                Err(e) => {
662                    writeln!(out, "plugmem: {e}").ok();
663                }
664            },
665            _ => run_repl_ro_line(&ro, &mut settings, line, json, out),
666        }
667        eprint!("plugmem(ro)> ");
668    }
669    eprintln!();
670    // Read-only: nothing to checkpoint, the writer owns the file.
671    0
672}
673
674/// Parses and runs one non-meta line of a read-only repl, refusing anything but
675/// the read verbs (writes/one-shot are not available without the writer lock).
676fn run_repl_ro_line(
677    ro: &ReadOnlyDatabase,
678    settings: &mut Settings,
679    line: &str,
680    json: bool,
681    out: &mut impl Write,
682) {
683    let cmd = match ReplLine::try_parse_from(split_line(line)) {
684        Ok(r) => r.command,
685        Err(e) => {
686            let _ = writeln!(out, "{e}");
687            return;
688        }
689    };
690    let readable = matches!(
691        &cmd,
692        Command::Show { .. }
693            | Command::Stats
694            | Command::Export
695            | Command::Verify
696            | Command::Recall { .. }
697    );
698    if !readable {
699        let _ = writeln!(
700            out,
701            "read-only session: only recall/show/stats/export/verify run \
702             (plus refresh/generation); writes and one-shot commands need a writer handle"
703        );
704        return;
705    }
706    // Embed a text recall query up front, exactly like the one-shot read-only
707    // path — the read-only handle carries no embedder of its own.
708    let recall_vector = match embed_recall_query(settings, &cmd) {
709        Ok(v) => v,
710        Err(e) => {
711            let _ = match &e {
712                CliError::Usage(m) => writeln!(out, "plugmem: {m}"),
713                CliError::Host(h) => writeln!(out, "plugmem: {h}"),
714            };
715            return;
716        }
717    };
718    let _ = execute_ro(ro, &cmd, recall_vector.as_deref(), json, out);
719}
720
721/// Embeds a `recall` command's text query into a vector using the configured
722/// embedder, so the read-only path (which carries no embedder) can still search
723/// by meaning while a writer process holds the database. Returns `None` when the
724/// command is not `recall`, carries no query text, or no embedder is configured
725/// — recall then falls back to lexical/structural sources. Mirrors the host's
726/// "embed before the lock" rule; the embed happens before the open
727/// so a locked database only costs the embed on the rare read-write fallback.
728fn embed_recall_query(
729    settings: &mut Settings,
730    cmd: &Command,
731) -> Result<Option<Vec<f32>>, CliError> {
732    let Command::Recall {
733        query: Some(text), ..
734    } = cmd
735    else {
736        return Ok(None);
737    };
738    let Some(embedder) = settings.embedder.as_mut() else {
739        return Ok(None);
740    };
741    let mut vectors = embedder.embed(&[text.as_str()]).map_err(CliError::Host)?;
742    Ok(vectors.pop())
743}
744
745/// Builds the [`RecallQuery`] for a `recall` command and passes it to `f`.
746/// A closure (not a return) because the query borrows temporary tag/entity
747/// slices that must outlive the call. Used by both the read-write and
748/// read-only paths.
749fn with_recall_query<R>(
750    cmd: &Command,
751    now: u64,
752    override_vector: Option<&[f32]>,
753    f: impl FnOnce(RecallQuery<'_>) -> R,
754) -> R {
755    let Command::Recall {
756        query,
757        tags,
758        entities,
759        as_of,
760        range,
761        k,
762        closed,
763    } = cmd
764    else {
765        unreachable!("with_recall_query called on a non-recall command");
766    };
767    let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
768    let ent_refs: Vec<&str> = entities.iter().map(String::as_str).collect();
769    let range_pair = range.as_ref().map(|v| (v[0], v[1]));
770    // `override_vector` is set only on the read-only path, where the CLI has
771    // already embedded the text query; the read-write path leaves it `None` and
772    // the host embeds inside `recall`.
773    let q = RecallQuery {
774        now,
775        text: query.as_deref(),
776        vector: override_vector,
777        tags: &tag_refs,
778        entities: &ent_refs,
779        as_of: *as_of,
780        range: range_pair,
781        k: *k,
782        token_budget: None,
783        include_closed: *closed,
784        ef: None,
785    };
786    f(q)
787}
788
789/// Renders a recall result — the engine's block (human) or facts + block
790/// (JSON).
791fn render_recall(res: &RecallResult, json: bool, out: &mut impl Write) {
792    if json {
793        let facts: Vec<_> = res
794            .facts
795            .iter()
796            .map(|f| {
797                json!({
798                    "id": f.id.0,
799                    "score": f.score,
800                    "sources": f.sources,
801                    "recorded_at": f.recorded_at,
802                    "valid_from": f.valid_from,
803                    "valid_to": open_or(f.valid_to),
804                })
805            })
806            .collect();
807        writeln!(
808            out,
809            "{}",
810            json!({ "facts": facts, "rendered": res.rendered, "truncated": res.truncated })
811        )
812        .ok();
813    } else if res.rendered.is_empty() {
814        writeln!(out, "(nothing recalled)").ok();
815    } else {
816        writeln!(out, "{}", res.rendered).ok();
817    }
818}
819
820/// Renders one fact's card. Returns the exit code (`0` found, `1` missing).
821fn render_show(
822    fact: Option<plugmem_host::FactSnapshot>,
823    id: u32,
824    json: bool,
825    out: &mut impl Write,
826) -> u8 {
827    let Some(fact) = fact else {
828        if json {
829            writeln!(out, "{}", json!({ "id": id, "found": false })).ok();
830        } else {
831            writeln!(out, "fact {id} not found").ok();
832        }
833        return 1;
834    };
835    let r = &fact.record;
836    if json {
837        writeln!(
838            out,
839            "{}",
840            json!({
841                "id": r.id.0,
842                "text": fact.text,
843                "recorded_at": r.recorded_at,
844                "valid_from": r.valid_from,
845                "valid_to": open_or(r.valid_to),
846                "closed": r.is_closed(),
847                "tombstone": r.is_tombstone(),
848                "revises": (r.revises != FactId::NONE).then_some(r.revises.0),
849                "metadata": fact.metadata,
850            })
851        )
852        .ok();
853    } else {
854        writeln!(out, "fact {}", r.id.0).ok();
855        writeln!(out, "  text        {}", fact.text).ok();
856        writeln!(out, "  recorded_at {}", r.recorded_at).ok();
857        write!(out, "  valid       [{}, ", r.valid_from).ok();
858        match r.valid_to {
859            VALID_TO_OPEN => writeln!(out, "open)").ok(),
860            to => writeln!(out, "{to})").ok(),
861        };
862        if r.revises != FactId::NONE {
863            writeln!(out, "  revises     fact {}", r.revises.0).ok();
864        }
865        if !fact.metadata.is_empty() {
866            let rendered = fact
867                .metadata
868                .iter()
869                .map(|(k, v)| format!("{k}={v}"))
870                .collect::<Vec<_>>()
871                .join(", ");
872            writeln!(out, "  metadata    {rendered}").ok();
873        }
874        if r.is_tombstone() {
875            writeln!(out, "  state       tombstoned").ok();
876        }
877    }
878    0
879}
880
881/// Renders engine size counters.
882fn render_stats(s: &Stats, json: bool, out: &mut impl Write) {
883    if json {
884        writeln!(
885            out,
886            "{}",
887            json!({
888                "facts": s.facts,
889                "entities": s.entities,
890                "terms": s.terms,
891                "edges": s.edges,
892                "vectors": s.vectors,
893                "next_fact": s.next_fact,
894                "next_entity": s.next_entity,
895                "pool_bytes": s.pool_bytes,
896            })
897        )
898        .ok();
899    } else {
900        writeln!(out, "facts       {}", s.facts).ok();
901        writeln!(out, "entities    {}", s.entities).ok();
902        writeln!(out, "terms       {}", s.terms).ok();
903        writeln!(out, "edges       {}", s.edges).ok();
904        writeln!(out, "vectors     {}", s.vectors).ok();
905        writeln!(out, "next_fact   {}", s.next_fact).ok();
906        writeln!(out, "pool_bytes  {}", s.pool_bytes).ok();
907    }
908}
909
910/// Writes one exported fact as a JSONL line. The unit of the streaming export
911/// — the same shape with or without `--json` (JSONL is already machine-readable).
912fn write_export_line(out: &mut impl Write, f: &ExportedFact) {
913    writeln!(
914        out,
915        "{}",
916        json!({
917            "text": f.text,
918            "entity": f.entity,
919            "tags": f.tags,
920            "metadata": f.metadata,
921            "recorded_at": f.recorded_at,
922            "valid_from": f.valid_from,
923        })
924    )
925    .ok();
926}
927
928/// Renders a whole slice of exported facts as JSONL (test helper — the runtime
929/// path streams via [`write_export_line`]).
930#[cfg(test)]
931fn render_export(facts: &[ExportedFact], _json: bool, out: &mut impl Write) {
932    for f in facts {
933        write_export_line(out, f);
934    }
935}
936
937/// Loads facts from a JSONL file (as written by `export`) in **streamed
938/// batches** of `batch_size`: the file is read line-by-line (memory bounded to
939/// a batch, not the whole file), and each full batch is one
940/// [`remember_many`](Database::remember_many) — one embedder round-trip and one
941/// journal fsync, instead of per fact. Returns the count imported. A malformed
942/// line is a usage error naming its 1-based number.
943fn do_import(
944    db: &Database,
945    now: u64,
946    file: &std::path::Path,
947    batch_size: usize,
948    _out: &mut impl Write,
949) -> Result<usize, CliError> {
950    let f = std::fs::File::open(file)
951        .map_err(|e| CliError::Usage(format!("reading {}: {e}", file.display())))?;
952    let reader = io::BufReader::new(f);
953    let mut count = 0usize;
954    let mut batch: Vec<ParsedFact> = Vec::with_capacity(batch_size);
955    for (i, line) in reader.lines().enumerate() {
956        let line = line.map_err(|e| CliError::Usage(format!("line {}: {e}", i + 1)))?;
957        let line = line.trim();
958        if line.is_empty() {
959            continue;
960        }
961        batch.push(parse_import_line(line, i + 1)?);
962        if batch.len() >= batch_size {
963            count += flush_import_batch(db, now, &batch)?;
964            batch.clear();
965        }
966    }
967    count += flush_import_batch(db, now, &batch)?;
968    Ok(count)
969}
970
971/// One parsed JSONL fact, owned so a whole batch can be buffered before its
972/// `remember_many`.
973struct ParsedFact {
974    text: String,
975    entity: Option<String>,
976    tags: Vec<String>,
977    metadata: Vec<(String, String)>,
978    valid_from: Option<u64>,
979}
980
981/// Parses one JSONL line into an owned fact. Bad JSON, or a missing/non-string
982/// `text`, is a usage error naming the 1-based line.
983fn parse_import_line(line: &str, lineno: usize) -> Result<ParsedFact, CliError> {
984    let v: serde_json::Value =
985        serde_json::from_str(line).map_err(|e| CliError::Usage(format!("line {lineno}: {e}")))?;
986    let text = v["text"]
987        .as_str()
988        .ok_or_else(|| CliError::Usage(format!("line {lineno}: missing string \"text\"")))?
989        .to_string();
990    let entity = v["entity"].as_str().map(String::from);
991    let tags = v["tags"]
992        .as_array()
993        .map(|a| {
994            a.iter()
995                .filter_map(|t| t.as_str().map(String::from))
996                .collect()
997        })
998        .unwrap_or_default();
999    // Metadata: an object of string values. Keys are sorted (via `BTreeMap`) so
1000    // the imported pairs are canonical; non-string values are skipped.
1001    let metadata = v["metadata"]
1002        .as_object()
1003        .map(|m| {
1004            m.iter()
1005                .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
1006                .collect::<BTreeMap<_, _>>()
1007                .into_iter()
1008                .collect()
1009        })
1010        .unwrap_or_default();
1011    let valid_from = v["valid_from"].as_u64();
1012    Ok(ParsedFact {
1013        text,
1014        entity,
1015        tags,
1016        metadata,
1017        valid_from,
1018    })
1019}
1020
1021/// Writes one batch of parsed facts via `remember_many` (one embed round-trip,
1022/// one fsync). Returns how many were written; an empty batch is a no-op.
1023fn flush_import_batch(db: &Database, now: u64, batch: &[ParsedFact]) -> Result<usize, CliError> {
1024    if batch.is_empty() {
1025        return Ok(0);
1026    }
1027    // Per-fact `&[&str]` tag slices and `&[(&str,&str)]` metadata pairs must
1028    // outlive the `remember_many` call.
1029    let tag_refs: Vec<Vec<&str>> = batch
1030        .iter()
1031        .map(|p| p.tags.iter().map(String::as_str).collect())
1032        .collect();
1033    let meta_refs: Vec<Vec<(&str, &str)>> = batch
1034        .iter()
1035        .map(|p| {
1036            p.metadata
1037                .iter()
1038                .map(|(k, v)| (k.as_str(), v.as_str()))
1039                .collect()
1040        })
1041        .collect();
1042    let inputs: Vec<RememberInput> = batch
1043        .iter()
1044        .zip(&tag_refs)
1045        .zip(&meta_refs)
1046        .map(|((p, tags), meta)| RememberInput {
1047            entity: p.entity.as_deref(),
1048            tags,
1049            metadata: (!meta.is_empty()).then_some(meta.as_slice()),
1050            valid_from: p.valid_from,
1051            ..RememberInput::text(now, &p.text)
1052        })
1053        .collect();
1054    db.remember_many(inputs)?;
1055    Ok(batch.len())
1056}
1057
1058/// Shared `remember`/`revise` body: build the input and dispatch.
1059#[allow(clippy::too_many_arguments)]
1060fn do_remember(
1061    db: &Database,
1062    now: u64,
1063    text: &str,
1064    entity: &Option<String>,
1065    tags: &[String],
1066    links: &[String],
1067    meta: &[String],
1068    valid_from: Option<u64>,
1069    revise: Option<FactId>,
1070) -> Result<RememberOutcome, CliError> {
1071    let tag_refs: Vec<&str> = tags.iter().map(String::as_str).collect();
1072    let link_pairs = parse_links(links)?;
1073    let link_refs: Vec<(&str, &str)> = link_pairs
1074        .iter()
1075        .map(|(r, e)| (r.as_str(), e.as_str()))
1076        .collect();
1077    // A `BTreeMap` dedups keys (last `--meta` for a key wins) and sorts them;
1078    // the engine re-canonicalizes regardless, but this keeps the borrowed pairs
1079    // clean and dup-free.
1080    let meta_map = parse_meta(meta)?;
1081    let meta_refs: Vec<(&str, &str)> = meta_map
1082        .iter()
1083        .map(|(k, v)| (k.as_str(), v.as_str()))
1084        .collect();
1085    let input = RememberInput {
1086        entity: entity.as_deref(),
1087        tags: &tag_refs,
1088        links: &link_refs,
1089        metadata: (!meta_refs.is_empty()).then_some(meta_refs.as_slice()),
1090        valid_from,
1091        ..RememberInput::text(now, text)
1092    };
1093    match revise {
1094        Some(target) => Ok(db.revise(target, input)?),
1095        None => Ok(db.remember(input)?),
1096    }
1097}
1098
1099/// Parses `--meta KEY=VALUE` strings into a sorted, deduped map (last value per
1100/// key wins).
1101fn parse_meta(meta: &[String]) -> Result<BTreeMap<String, String>, CliError> {
1102    let mut map = BTreeMap::new();
1103    for s in meta {
1104        let (k, v) = s
1105            .split_once('=')
1106            .filter(|(k, _)| !k.is_empty())
1107            .ok_or_else(|| CliError::Usage(format!("bad --meta `{s}` — expected KEY=VALUE")))?;
1108        map.insert(k.to_string(), v.to_string());
1109    }
1110    Ok(map)
1111}
1112
1113/// Parses `--link REL:ENTITY` strings into `(rel, entity)` pairs.
1114fn parse_links(links: &[String]) -> Result<Vec<(String, String)>, CliError> {
1115    links
1116        .iter()
1117        .map(|s| {
1118            s.split_once(':')
1119                .filter(|(r, e)| !r.is_empty() && !e.is_empty())
1120                .map(|(r, e)| (r.to_string(), e.to_string()))
1121                .ok_or_else(|| CliError::Usage(format!("bad --link `{s}` — expected REL:ENTITY")))
1122        })
1123        .collect()
1124}
1125
1126/// Renders a `remember`/`revise` outcome (shared shape).
1127fn render_remember(outcome: &RememberOutcome, json: bool, out: &mut impl Write) {
1128    if json {
1129        let similar: Vec<_> = outcome
1130            .similar
1131            .iter()
1132            .map(|s| json!({ "id": s.id.0, "score": s.score, "reason": format!("{:?}", s.reason) }))
1133            .collect();
1134        writeln!(
1135            out,
1136            "{}",
1137            json!({
1138                "id": outcome.id.0,
1139                "entity": outcome.entity.map(|e| e.0),
1140                "similar": similar,
1141            })
1142        )
1143        .ok();
1144    } else {
1145        writeln!(out, "remembered fact {}", outcome.id.0).ok();
1146        for s in &outcome.similar {
1147            writeln!(
1148                out,
1149                "  ~ similar to fact {} ({:?}, {:.2})",
1150                s.id.0, s.reason, s.score
1151            )
1152            .ok();
1153        }
1154    }
1155}
1156
1157/// `VALID_TO_OPEN` → JSON `null`, a real bound → the number.
1158fn open_or(valid_to: u64) -> Option<u64> {
1159    (valid_to != VALID_TO_OPEN).then_some(valid_to)
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use plugmem_host::Config;
1165
1166    use super::*;
1167
1168    /// A stub embedder returning a fixed vector per input — no network.
1169    struct StubEmbedder;
1170    impl plugmem_host::Embedder for StubEmbedder {
1171        fn dim(&self) -> usize {
1172            3
1173        }
1174        fn embed(&mut self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1175            Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1176        }
1177    }
1178
1179    fn recall_cmd(query: Option<&str>) -> Command {
1180        Command::Recall {
1181            query: query.map(str::to_owned),
1182            tags: vec![],
1183            entities: vec![],
1184            as_of: None,
1185            range: None,
1186            k: 0,
1187            closed: false,
1188        }
1189    }
1190
1191    fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1192        Settings {
1193            config: Config::default(),
1194            embedder,
1195            snapshot_every_ops: None,
1196            snapshot_journal_bytes: None,
1197            maintain_every_forgets: None,
1198        }
1199    }
1200
1201    #[test]
1202    fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1203        // recall text + embedder → a vector.
1204        let mut with = settings_with(Some(Box::new(StubEmbedder)));
1205        assert_eq!(
1206            embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1207            Some(vec![0.1, 0.2, 0.3])
1208        );
1209
1210        // no embedder → None (recall falls back to lexical/structural sources).
1211        let mut without = settings_with(None);
1212        assert_eq!(
1213            embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1214            None
1215        );
1216
1217        // recall with no query text → None (nothing to embed).
1218        let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1219        assert_eq!(
1220            embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1221            None
1222        );
1223
1224        // a non-recall command → None even with an embedder configured.
1225        let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1226        assert_eq!(
1227            embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1228            None
1229        );
1230    }
1231
1232    #[test]
1233    fn split_line_honors_quotes_and_whitespace() {
1234        assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1235        assert_eq!(
1236            split_line(r#"remember "two words" --tag x"#),
1237            ["remember", "two words", "--tag", "x"]
1238        );
1239        assert_eq!(split_line("  recall   'a b'  "), ["recall", "a b"]);
1240        assert_eq!(split_line(""), Vec::<String>::new());
1241        // An empty quoted string is a real (empty) argument.
1242        assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1243    }
1244
1245    #[test]
1246    fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1247        let (db, tmp) = TempDb::open();
1248        let path = tmp.0.join("m.plugmem");
1249        drop(db); // release the writer lock so run_repl can open it
1250
1251        let settings = settings_with(None);
1252        // Multi-word text is quoted, same grammar as the one-shot CLI.
1253        let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1254        let mut out = Vec::new();
1255        let code = run_repl(&path, settings, false, &script[..], &mut out);
1256        let text = String::from_utf8(out).unwrap();
1257
1258        assert_eq!(code, 0);
1259        assert!(text.contains("remembered fact 0"), "{text}");
1260        assert!(text.contains("tokio"), "{text}");
1261        // A bad line is reported but does not end the session (revise ran after).
1262        assert!(text.contains("unrecognized subcommand"), "{text}");
1263
1264        // Checkpointed on exit → a fresh read-only open sees the data with a
1265        // clean journal. The revise chain leaves two facts: the closed original
1266        // and its active successor.
1267        let ro = Database::open_readonly(&path, Config::default()).unwrap();
1268        assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1269    }
1270
1271    #[test]
1272    fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1273        let (db, tmp) = TempDb::open();
1274        let path = tmp.0.join("m.plugmem");
1275        // Seed and publish generation 1, then keep the writer open and live —
1276        // the read-only repl observes it cross-process (Variant 2 MVCC).
1277        let mut sink = Vec::new();
1278        execute(
1279            &db,
1280            &remember("seed fact tokio", None, &[]),
1281            false,
1282            1_000,
1283            &mut sink,
1284        )
1285        .unwrap();
1286        db.checkpoint(1_001).unwrap();
1287
1288        let settings = settings_with(None);
1289        // A read verb, both freshness verbs, and a write (must be refused).
1290        let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1291        let mut out = Vec::new();
1292        let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1293        let text = String::from_utf8(out).unwrap();
1294
1295        assert_eq!(code, 0);
1296        assert!(text.contains("generation 1"), "generation verb: {text}");
1297        assert!(text.contains("fact"), "stats ran: {text}");
1298        // The writer published nothing after the reader opened, so refresh is a
1299        // no-op that stays on generation 1.
1300        assert!(
1301            text.contains("already current → generation 1"),
1302            "refresh no-op: {text}"
1303        );
1304        // A write verb is refused without ending the session (exit still ran).
1305        assert!(text.contains("read-only session"), "write refused: {text}");
1306
1307        // The read-only session never wrote: the writer is still on generation 1
1308        // with its single seeded fact, untouched by the repl.
1309        assert_eq!(db.stats().facts, 1);
1310    }
1311
1312    #[test]
1313    fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1314        let (db, tmp) = TempDb::open();
1315        let path = tmp.0.join("m.plugmem");
1316        let mut sink = Vec::new();
1317        execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1318        db.checkpoint(1_001).unwrap();
1319
1320        // A reader hook that publishes a *new* generation the first time the repl
1321        // pulls a line, so the subsequent `refresh` deterministically advances —
1322        // exercising the "refreshed" branch without a background thread.
1323        struct HookOnFirstRead<'a> {
1324            script: std::io::Cursor<&'a [u8]>,
1325            db: &'a Database,
1326            fired: bool,
1327        }
1328        impl std::io::Read for HookOnFirstRead<'_> {
1329            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1330                if !self.fired {
1331                    self.fired = true;
1332                    // Publish generation 2 before the first command is read, so
1333                    // the reader (opened on gen 1) sees something newer.
1334                    let mut s = Vec::new();
1335                    execute(
1336                        self.db,
1337                        &remember("second", None, &[]),
1338                        false,
1339                        2_000,
1340                        &mut s,
1341                    )
1342                    .unwrap();
1343                    self.db.checkpoint(2_001).unwrap();
1344                }
1345                self.script.read(buf)
1346            }
1347        }
1348        let reader = std::io::BufReader::new(HookOnFirstRead {
1349            script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
1350            db: &db,
1351            fired: false,
1352        });
1353
1354        let mut out = Vec::new();
1355        let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
1356        let text = String::from_utf8(out).unwrap();
1357
1358        assert_eq!(code, 0);
1359        // Opened on gen 1, the writer published gen 2, refresh advanced onto it.
1360        assert!(text.contains("refreshed → generation 2"), "advance: {text}");
1361        // And the advanced reader now sees the writer's second fact.
1362        assert!(text.contains("fact"), "stats after refresh: {text}");
1363        assert_eq!(db.stats().facts, 2);
1364    }
1365
1366    #[test]
1367    fn read_only_repl_freshness_verbs_emit_json() {
1368        let (db, tmp) = TempDb::open();
1369        let path = tmp.0.join("m.plugmem");
1370        let mut sink = Vec::new();
1371        execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
1372        db.checkpoint(1_001).unwrap();
1373
1374        let script = b"generation\nrefresh\nexit\n";
1375        let mut out = Vec::new();
1376        let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
1377        let text = String::from_utf8(out).unwrap();
1378
1379        assert_eq!(code, 0);
1380        assert!(
1381            text.contains(r#""generation":1"#),
1382            "generation json: {text}"
1383        );
1384        assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
1385    }
1386
1387    /// A throwaway database on a unique temp path; removed on drop.
1388    struct TempDb(PathBuf);
1389    impl TempDb {
1390        fn open() -> (Database, Self) {
1391            let dir = std::env::temp_dir().join(format!(
1392                "plugmem-cli-{}-{}",
1393                std::process::id(),
1394                now_ms_unique()
1395            ));
1396            std::fs::create_dir_all(&dir).unwrap();
1397            let path = dir.join("m.plugmem");
1398            let (db, _) = Database::open(&path, Config::default()).unwrap();
1399            (db, TempDb(dir))
1400        }
1401    }
1402    impl Drop for TempDb {
1403        fn drop(&mut self) {
1404            let _ = std::fs::remove_dir_all(&self.0);
1405        }
1406    }
1407
1408    /// A strictly-increasing counter so temp dirs never collide within a run
1409    /// (the wall clock alone can repeat at millisecond resolution).
1410    fn now_ms_unique() -> String {
1411        use std::sync::atomic::{AtomicU64, Ordering};
1412        static N: AtomicU64 = AtomicU64::new(0);
1413        format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
1414    }
1415
1416    fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
1417        let mut buf = Vec::new();
1418        let code = execute(db, cmd, json, now, &mut buf).expect("execute");
1419        (code, String::from_utf8(buf).unwrap())
1420    }
1421
1422    fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
1423        Command::Remember {
1424            text: text.into(),
1425            entity: entity.map(Into::into),
1426            tags: tags.iter().map(|t| (*t).into()).collect(),
1427            links: Vec::new(),
1428            meta: Vec::new(),
1429            valid_from: None,
1430        }
1431    }
1432
1433    fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
1434        Command::Remember {
1435            text: text.into(),
1436            entity: None,
1437            tags: Vec::new(),
1438            links: Vec::new(),
1439            meta: meta.iter().map(|m| (*m).into()).collect(),
1440            valid_from: None,
1441        }
1442    }
1443
1444    #[test]
1445    fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
1446        let (db, _t) = TempDb::open();
1447        // Keys given out of order; last value for a repeated key wins.
1448        let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
1449        assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
1450
1451        // show (human): sorted `key=value`, last-write-wins on `page`.
1452        let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1453        assert!(
1454            human.contains("metadata    page=3, uri=s3://b/x"),
1455            "{human}"
1456        );
1457        // show (json): a metadata object.
1458        let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1459        let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
1460        assert_eq!(v["metadata"]["page"], "3");
1461        assert_eq!(v["metadata"]["uri"], "s3://b/x");
1462
1463        // export: the JSONL line carries the same object.
1464        let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
1465        let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
1466        assert_eq!(line["metadata"]["uri"], "s3://b/x");
1467
1468        // A `--meta` without `=` is a usage error.
1469        assert!(matches!(
1470            parse_meta(&["noequals".to_string()]),
1471            Err(CliError::Usage(_))
1472        ));
1473        assert!(parse_meta(&["=noKey".to_string()]).is_err());
1474    }
1475
1476    #[test]
1477    fn remember_then_recall_human_and_json() {
1478        let (db, _t) = TempDb::open();
1479        let (code, out) = run_cmd(
1480            &db,
1481            &remember("prefers tokio", Some("user"), &["pref"]),
1482            false,
1483            1_000,
1484        );
1485        assert_eq!(code, 0);
1486        assert!(out.starts_with("remembered fact 0"), "{out}");
1487
1488        // human recall
1489        let recall = Command::Recall {
1490            query: Some("tokio".into()),
1491            tags: Vec::new(),
1492            entities: Vec::new(),
1493            as_of: None,
1494            range: None,
1495            k: 0,
1496            closed: false,
1497        };
1498        let (code, out) = run_cmd(&db, &recall, false, 2_000);
1499        assert_eq!(code, 0);
1500        assert!(out.contains("tokio"), "{out}");
1501
1502        // json recall
1503        let (code, out) = run_cmd(&db, &recall, true, 2_000);
1504        assert_eq!(code, 0);
1505        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1506        assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
1507    }
1508
1509    #[test]
1510    fn recall_empty_is_ok_with_a_note() {
1511        let (db, _t) = TempDb::open();
1512        let recall = Command::Recall {
1513            query: Some("nothing here".into()),
1514            tags: Vec::new(),
1515            entities: Vec::new(),
1516            as_of: None,
1517            range: None,
1518            k: 0,
1519            closed: false,
1520        };
1521        let (code, out) = run_cmd(&db, &recall, false, 1_000);
1522        assert_eq!(code, 0);
1523        assert!(out.contains("nothing recalled"), "{out}");
1524    }
1525
1526    #[test]
1527    fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
1528        let (db, _t) = TempDb::open();
1529        run_cmd(
1530            &db,
1531            &remember("lives in Moscow", Some("user"), &[]),
1532            false,
1533            1_000,
1534        );
1535        // a near-duplicate surfaces a similar hint
1536        let (_, out) = run_cmd(
1537            &db,
1538            &remember("lives in Moscow now", Some("user"), &[]),
1539            false,
1540            1_500,
1541        );
1542        assert!(out.contains("similar to fact"), "{out}");
1543
1544        let revise = Command::Revise {
1545            id: 0,
1546            text: "lives in Berlin".into(),
1547            entity: Some("user".into()),
1548            tags: Vec::new(),
1549            links: Vec::new(),
1550            meta: Vec::new(),
1551            valid_from: None,
1552        };
1553        let (code, out) = run_cmd(&db, &revise, false, 2_000);
1554        assert_eq!(code, 0);
1555        assert!(out.starts_with("remembered fact"), "{out}");
1556    }
1557
1558    #[test]
1559    fn show_found_and_missing() {
1560        let (db, _t) = TempDb::open();
1561        run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
1562
1563        let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
1564        assert_eq!(code, 0);
1565        assert!(
1566            out.contains("a note") && out.contains("recorded_at 1000"),
1567            "{out}"
1568        );
1569
1570        let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
1571        assert_eq!(code, 1, "missing id is a soft miss");
1572        assert!(out.contains("not found"), "{out}");
1573
1574        // json card
1575        let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
1576        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1577        assert_eq!(v["text"], "a note");
1578        assert_eq!(v["valid_to"], serde_json::Value::Null); // open interval
1579    }
1580
1581    #[test]
1582    fn forget_then_maintain_purges() {
1583        let (db, _t) = TempDb::open();
1584        run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
1585
1586        let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
1587        assert_eq!(code, 0);
1588        assert!(out.contains("forgot fact 0"), "{out}");
1589        // second forget is idempotent
1590        let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
1591        assert!(out.contains("already gone"), "{out}");
1592
1593        let (code, out) = run_cmd(&db, &Command::Maintain, false, 3_000);
1594        assert_eq!(code, 0);
1595        assert!(out.contains("purged 1"), "{out}");
1596    }
1597
1598    #[test]
1599    fn link_and_stats_and_json() {
1600        let (db, _t) = TempDb::open();
1601        run_cmd(
1602            &db,
1603            &remember("uses tokio", Some("plugmem"), &[]),
1604            false,
1605            1_000,
1606        );
1607        let link = Command::Link {
1608            src: "plugmem".into(),
1609            rel: "depends_on".into(),
1610            dst: "tokio".into(),
1611        };
1612        let (code, out) = run_cmd(&db, &link, false, 2_000);
1613        assert_eq!(code, 0);
1614        assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
1615
1616        let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
1617        assert_eq!(code, 0);
1618        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1619        assert_eq!(v["facts"], 1);
1620        assert!(v["edges"].as_u64().unwrap() >= 1);
1621    }
1622
1623    #[test]
1624    fn bad_link_is_a_usage_error() {
1625        let (db, _t) = TempDb::open();
1626        let cmd = Command::Remember {
1627            text: "x".into(),
1628            entity: Some("user".into()),
1629            tags: Vec::new(),
1630            links: vec!["not-a-pair".into()],
1631            meta: Vec::new(),
1632            valid_from: None,
1633        };
1634        let mut buf = Vec::new();
1635        let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
1636        assert!(matches!(err, CliError::Usage(_)));
1637    }
1638
1639    #[test]
1640    fn as_of_time_travel_via_recall() {
1641        let (db, _t) = TempDb::open();
1642        run_cmd(
1643            &db,
1644            &remember("lives in Moscow", Some("user"), &[]),
1645            false,
1646            1_000,
1647        );
1648        let revise = Command::Revise {
1649            id: 0,
1650            text: "lives in Berlin".into(),
1651            entity: Some("user".into()),
1652            tags: Vec::new(),
1653            links: Vec::new(),
1654            meta: Vec::new(),
1655            valid_from: None,
1656        };
1657        run_cmd(&db, &revise, false, 2_000);
1658
1659        let as_of = Command::Recall {
1660            query: Some("lives".into()),
1661            tags: Vec::new(),
1662            entities: vec!["user".into()],
1663            as_of: Some(1_500),
1664            range: None,
1665            k: 0,
1666            closed: false,
1667        };
1668        let (_, out) = run_cmd(&db, &as_of, false, 3_000);
1669        assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
1670    }
1671
1672    #[test]
1673    fn every_command_has_a_json_shape() {
1674        let (db, _t) = TempDb::open();
1675        // remember --json: id + similar array
1676        let (_, out) = run_cmd(
1677            &db,
1678            &remember("uses tokio", Some("plugmem"), &["pref"]),
1679            true,
1680            1_000,
1681        );
1682        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1683        assert_eq!(v["id"], 0);
1684        assert!(v["similar"].is_array());
1685
1686        // revise --json
1687        let revise = Command::Revise {
1688            id: 0,
1689            text: "uses tokio now".into(),
1690            entity: Some("plugmem".into()),
1691            tags: Vec::new(),
1692            links: Vec::new(),
1693            meta: Vec::new(),
1694            valid_from: None,
1695        };
1696        let (_, out) = run_cmd(&db, &revise, true, 1_500);
1697        assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
1698
1699        // link --json
1700        let link = Command::Link {
1701            src: "plugmem".into(),
1702            rel: "depends_on".into(),
1703            dst: "tokio".into(),
1704        };
1705        let (_, out) = run_cmd(&db, &link, true, 2_000);
1706        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1707        assert_eq!(v["rel"], "depends_on");
1708
1709        // forget --json then maintain --json
1710        let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
1711        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1712        assert_eq!(v["forgotten"], true);
1713        let (_, out) = run_cmd(&db, &Command::Maintain, true, 3_000);
1714        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1715        assert!(v["purged"].as_u64().unwrap() >= 1);
1716
1717        // show --json of a missing id
1718        let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
1719        assert_eq!(code, 1);
1720        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1721        assert_eq!(v["found"], false);
1722
1723        // recall --json with a range window (covers the range/closed paths)
1724        let recall = Command::Recall {
1725            query: None,
1726            tags: Vec::new(),
1727            entities: vec!["plugmem".into()],
1728            as_of: None,
1729            range: Some(vec![0, 10_000]),
1730            k: 4,
1731            closed: true,
1732        };
1733        let (_, out) = run_cmd(&db, &recall, true, 4_000);
1734        assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
1735    }
1736
1737    #[test]
1738    fn stats_human_lists_the_counters() {
1739        let (db, _t) = TempDb::open();
1740        run_cmd(&db, &remember("a", None, &[]), false, 1_000);
1741        let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
1742        assert_eq!(code, 0);
1743        assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
1744    }
1745
1746    #[test]
1747    fn show_json_of_a_revised_predecessor_is_closed() {
1748        let (db, _t) = TempDb::open();
1749        run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
1750        let revise = Command::Revise {
1751            id: 0,
1752            text: "v2".into(),
1753            entity: Some("e".into()),
1754            tags: Vec::new(),
1755            links: Vec::new(),
1756            meta: Vec::new(),
1757            valid_from: None,
1758        };
1759        run_cmd(&db, &revise, false, 2_000);
1760        // the successor records `revises`; its card names the predecessor
1761        let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
1762        assert!(out.contains("revises     fact 0"), "{out}");
1763    }
1764
1765    #[test]
1766    fn resolve_db_path_prefers_the_flag() {
1767        let p = std::path::Path::new("/tmp/explicit.plugmem");
1768        assert_eq!(resolve_db_path(Some(p)), PathBuf::from(p));
1769        // With no flag it falls back (to $PLUGMEM_DB or the default) — we
1770        // only assert the code path runs and yields some path.
1771        let _ = resolve_db_path(None);
1772    }
1773
1774    #[test]
1775    fn run_parsed_opens_runs_and_reports() {
1776        let dir = std::env::temp_dir().join(format!(
1777            "plugmem-run-{}-{}",
1778            std::process::id(),
1779            now_ms_unique()
1780        ));
1781        std::fs::create_dir_all(&dir).unwrap();
1782        let path = dir.join("m.plugmem");
1783        let cli = Cli {
1784            db: Some(path.clone()),
1785            config: None,
1786            json: false,
1787            command: Command::Stats,
1788        };
1789        let mut buf = Vec::new();
1790        let code = run_parsed(cli, &mut buf);
1791        assert_eq!(code, 0);
1792        assert!(String::from_utf8(buf).unwrap().contains("facts"));
1793        let _ = std::fs::remove_dir_all(&dir);
1794    }
1795
1796    #[test]
1797    fn run_parsed_on_a_locked_database_returns_one() {
1798        let (_held, dir) = {
1799            let dir = std::env::temp_dir().join(format!(
1800                "plugmem-lock-{}-{}",
1801                std::process::id(),
1802                now_ms_unique()
1803            ));
1804            std::fs::create_dir_all(&dir).unwrap();
1805            let path = dir.join("m.plugmem");
1806            (Database::open(&path, Config::default()).unwrap(), dir)
1807        };
1808        let cli = Cli {
1809            db: Some(dir.join("m.plugmem")),
1810            config: None,
1811            json: false,
1812            command: Command::Stats,
1813        };
1814        let mut buf = Vec::new();
1815        assert_eq!(run_parsed(cli, &mut buf), 1);
1816        let _ = std::fs::remove_dir_all(&dir);
1817    }
1818
1819    #[test]
1820    fn run_parsed_propagates_a_usage_error_as_two() {
1821        let dir = std::env::temp_dir().join(format!(
1822            "plugmem-usage-{}-{}",
1823            std::process::id(),
1824            now_ms_unique()
1825        ));
1826        std::fs::create_dir_all(&dir).unwrap();
1827        let cli = Cli {
1828            db: Some(dir.join("m.plugmem")),
1829            config: None,
1830            json: false,
1831            command: Command::Remember {
1832                text: "x".into(),
1833                entity: None,
1834                tags: Vec::new(),
1835                links: vec!["bad".into()],
1836                meta: Vec::new(),
1837                valid_from: None,
1838            },
1839        };
1840        let mut buf = Vec::new();
1841        assert_eq!(run_parsed(cli, &mut buf), 2);
1842        let _ = std::fs::remove_dir_all(&dir);
1843    }
1844
1845    /// A scratch directory (no db) for config/checkpoint tests; removed on drop.
1846    struct Scratch(PathBuf);
1847    impl Scratch {
1848        fn new(tag: &str) -> Self {
1849            let dir = std::env::temp_dir().join(format!(
1850                "plugmem-cli-{tag}-{}-{}",
1851                std::process::id(),
1852                now_ms_unique()
1853            ));
1854            std::fs::create_dir_all(&dir).unwrap();
1855            Scratch(dir)
1856        }
1857    }
1858    impl Drop for Scratch {
1859        fn drop(&mut self) {
1860            let _ = std::fs::remove_dir_all(&self.0);
1861        }
1862    }
1863
1864    #[test]
1865    fn export_import_roundtrip_preserves_open_facts() {
1866        // A deliberately nested scenario: entities, multi-tag facts, a
1867        // revision (closes its predecessor), a forget (tombstone), and an
1868        // explicit valid_from — export must dump exactly the open facts, and
1869        // import must reconstruct that set faithfully.
1870        let (a, _ta) = TempDb::open();
1871        run_cmd(
1872            &a,
1873            &Command::Remember {
1874                text: "prefers tokio".into(),
1875                entity: Some("user".into()),
1876                tags: vec!["pref".into(), "lang".into()],
1877                links: Vec::new(),
1878                meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
1879                valid_from: Some(500),
1880            },
1881            false,
1882            1_000,
1883        );
1884        run_cmd(
1885            &a,
1886            &remember("lives in Moscow", Some("user"), &[]),
1887            false,
1888            1_100,
1889        ); // id 1
1890        run_cmd(
1891            &a,
1892            &Command::Revise {
1893                id: 1,
1894                text: "lives in Berlin".into(),
1895                entity: Some("user".into()),
1896                tags: vec!["geo".into()],
1897                links: Vec::new(),
1898                meta: Vec::new(),
1899                valid_from: None,
1900            },
1901            false,
1902            1_200,
1903        ); // id 2 open, id 1 closed
1904        run_cmd(&a, &remember("junk", None, &[]), false, 1_300); // id 3
1905        run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); // tombstone id 3
1906        run_cmd(
1907            &a,
1908            &remember("uses rust", Some("plugmem"), &["lang"]),
1909            false,
1910            1_500,
1911        ); // id 4
1912
1913        // Export A into a JSONL file.
1914        let mut dump = Vec::new();
1915        render_export(&a.export(), false, &mut dump);
1916        let scratch = Scratch::new("roundtrip");
1917        let file = scratch.0.join("dump.jsonl");
1918        std::fs::write(&file, &dump).unwrap();
1919
1920        // Import into a fresh B.
1921        let (b, _tb) = TempDb::open();
1922        let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
1923
1924        // Both sides, compared as sets keyed by the preserved fields.
1925        let key = |f: &ExportedFact| {
1926            let mut tags = f.tags.clone();
1927            tags.sort();
1928            (f.text.clone(), f.entity.clone(), tags, f.valid_from)
1929        };
1930        let mut ak: Vec<_> = a.export().iter().map(key).collect();
1931        let mut bk: Vec<_> = b.export().iter().map(key).collect();
1932        ak.sort();
1933        bk.sort();
1934        assert_eq!(n, ak.len());
1935        assert_eq!(
1936            ak, bk,
1937            "roundtrip must preserve text/entity/tags/valid_from"
1938        );
1939
1940        // Spot-checks: the open facts survive with their metadata; the closed
1941        // revision and the tombstone do not.
1942        let b_open = b.export();
1943        assert!(b_open.iter().any(|f| f.text == "prefers tokio"
1944            && f.valid_from == 500
1945            && f.entity.as_deref() == Some("user")
1946            && f.tags == vec!["pref".to_string(), "lang".to_string()]
1947            && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
1948            && f.metadata.get("src").map(String::as_str) == Some("chat")));
1949        assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
1950        assert!(b_open.iter().any(|f| f.text == "uses rust"));
1951        assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
1952        assert!(!b_open.iter().any(|f| f.text == "junk"));
1953    }
1954
1955    #[test]
1956    fn export_command_emits_jsonl_regardless_of_json_flag() {
1957        let (db, _t) = TempDb::open();
1958        run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
1959        for json in [false, true] {
1960            let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
1961            assert_eq!(code, 0);
1962            let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
1963            assert_eq!(v["text"], "a fact");
1964            assert_eq!(v["entity"], "e");
1965            assert_eq!(v["tags"][0], "t");
1966        }
1967    }
1968
1969    #[test]
1970    fn import_command_counts_and_rejects_bad_lines() {
1971        let (db, _t) = TempDb::open();
1972        let scratch = Scratch::new("import");
1973        let good = scratch.0.join("in.jsonl");
1974        std::fs::write(
1975            &good,
1976            "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
1977        )
1978        .unwrap();
1979        // A tiny batch size exercises the streaming/chunking path (two batches).
1980        let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
1981        assert_eq!(n, 2, "both facts imported, blank line skipped");
1982
1983        let bad = scratch.0.join("bad.jsonl");
1984        std::fs::write(&bad, "not json at all\n").unwrap();
1985        let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
1986        assert!(matches!(err, CliError::Usage(_)));
1987    }
1988
1989    #[test]
1990    fn import_batch_size_does_not_change_the_result() {
1991        // The chunk size is a performance knob only: importing the same file with
1992        // batch 1 and batch 100 yields the identical fact set.
1993        let scratch = Scratch::new("import-batch");
1994        let file = scratch.0.join("facts.jsonl");
1995        let mut jsonl = String::new();
1996        for i in 0..5 {
1997            jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
1998        }
1999        std::fs::write(&file, &jsonl).unwrap();
2000
2001        let (a, _ta) = TempDb::open();
2002        let (b, _tb) = TempDb::open();
2003        let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
2004        let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
2005
2006        assert_eq!(na, 5);
2007        assert_eq!(nb, 5);
2008        let texts = |db: &Database| {
2009            let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
2010            t.sort();
2011            t
2012        };
2013        assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
2014    }
2015
2016    #[test]
2017    fn config_table_feeds_settings_and_the_cli_batch_size() {
2018        // The CLI reads config.toml once (host `read_config`), builds the
2019        // shared `Settings`, and pulls its own `batch_size` from the same
2020        // table — the exact flow of `run_parsed`.
2021        let scratch = Scratch::new("settings");
2022        let cfgfile = scratch.0.join("config.toml");
2023        std::fs::write(
2024            &cfgfile,
2025            "[engine]\ndim = 512\n[embedder]\nkind = \"none\"\n\
2026             [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
2027        )
2028        .unwrap();
2029        let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
2030        let s = Settings::from_table(table.as_ref()).unwrap();
2031        assert_eq!(s.config.dim, 512);
2032        assert!(s.embedder.is_none());
2033        assert_eq!(s.snapshot_every_ops, Some(64));
2034        assert_eq!(read_batch_size(table.as_ref()), Some(200));
2035
2036        // An explicit --config that does not exist is a usage error.
2037        assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
2038    }
2039
2040    #[test]
2041    fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
2042        let scratch = Scratch::new("checkpoint-cmd");
2043        let path = scratch.0.join("m.plugmem");
2044
2045        // A remember through the read-write path leaves a dirty journal.
2046        let remember = Cli {
2047            db: Some(path.clone()),
2048            config: None,
2049            json: false,
2050            command: Command::Remember {
2051                text: "hello tokio".into(),
2052                entity: None,
2053                tags: Vec::new(),
2054                links: Vec::new(),
2055                meta: Vec::new(),
2056                valid_from: None,
2057            },
2058        };
2059        assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
2060
2061        // The new command: human shape.
2062        let checkpoint = |json| Cli {
2063            db: Some(path.clone()),
2064            config: None,
2065            json,
2066            command: Command::Checkpoint,
2067        };
2068        let mut buf = Vec::new();
2069        assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
2070        assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
2071
2072        // json shape.
2073        let mut buf = Vec::new();
2074        assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
2075        let v: serde_json::Value =
2076            serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
2077        assert_eq!(v["ok"], true);
2078
2079        // The journal is now clean, so scrub (a shared-lock, read-only open)
2080        // succeeds — it would fail `NeedsCheckpoint` on a dirty journal.
2081        let scrub = Cli {
2082            db: Some(path),
2083            config: None,
2084            json: false,
2085            command: Command::Scrub,
2086        };
2087        let mut buf = Vec::new();
2088        assert_eq!(run_parsed(scrub, &mut buf), 0);
2089        assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
2090    }
2091
2092    #[test]
2093    fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
2094        let scratch = Scratch::new("ro-route");
2095        let path = scratch.0.join("m.plugmem");
2096        {
2097            let (db, _) = Database::open(&path, Config::default()).unwrap();
2098            db.remember(RememberInput::text(1_000, "hello tokio"))
2099                .unwrap();
2100            db.checkpoint(2_000).unwrap(); // empty journal → open_readonly succeeds
2101        }
2102        // stats routes through open_readonly (mmap, shared)
2103        let cli = Cli {
2104            db: Some(path.clone()),
2105            config: None,
2106            json: false,
2107            command: Command::Stats,
2108        };
2109        let mut buf = Vec::new();
2110        assert_eq!(run_parsed(cli, &mut buf), 0);
2111        assert!(String::from_utf8(buf).unwrap().contains("facts"));
2112
2113        // recall with no embedder also uses the read-only path
2114        let cli = Cli {
2115            db: Some(path),
2116            config: None,
2117            json: false,
2118            command: Command::Recall {
2119                query: Some("tokio".into()),
2120                tags: Vec::new(),
2121                entities: Vec::new(),
2122                as_of: None,
2123                range: None,
2124                k: 0,
2125                closed: false,
2126            },
2127        };
2128        let mut buf = Vec::new();
2129        assert_eq!(run_parsed(cli, &mut buf), 0);
2130        assert!(String::from_utf8(buf).unwrap().contains("tokio"));
2131    }
2132}