Skip to main content

plugmem_cli/
lib.rs

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