Skip to main content

plugmem_cli/
lib.rs

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