Skip to main content

plugmem_cli/
lib.rs

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