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