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