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, checked } => {
1731            if json {
1732                writeln!(
1733                    out,
1734                    "{}",
1735                    json!({
1736                        "status": "stored",
1737                        "checked": checked,
1738                        "outcome": {
1739                            "id": outcome.id.0,
1740                            "entity": outcome.entity.map(|entity| entity.0),
1741                            "similar": [],
1742                        }
1743                    })
1744                )
1745                .ok();
1746            } else {
1747                writeln!(out, "remembered fact {}", outcome.id.0).ok();
1748                // Said plainly, because the alternative is a caller who asked
1749                // for a duplicate check, did not get one, and cannot tell.
1750                if !checked {
1751                    writeln!(
1752                        out,
1753                        "  ! stored WITHOUT a duplicate check: the check is scoped to \
1754                         --entity, and none was given"
1755                    )
1756                    .ok();
1757                }
1758            }
1759        }
1760        GuardedRememberOutcome::Blocked { similar } => {
1761            if json {
1762                let similar: Vec<_> = similar
1763                    .iter()
1764                    .map(|item| {
1765                        json!({
1766                            "id": item.id.0,
1767                            "score": item.score,
1768                            "reason": format!("{:?}", item.reason),
1769                        })
1770                    })
1771                    .collect();
1772                writeln!(
1773                    out,
1774                    "{}",
1775                    json!({ "status": "blocked", "similar": similar })
1776                )
1777                .ok();
1778            } else {
1779                writeln!(out, "not remembered: similar facts require a decision").ok();
1780                for item in similar {
1781                    writeln!(
1782                        out,
1783                        "  ~ fact {} ({:?}, {:.2})",
1784                        item.id.0, item.reason, item.score
1785                    )
1786                    .ok();
1787                }
1788            }
1789        }
1790    }
1791}
1792
1793/// `VALID_TO_OPEN` → JSON `null`, a real bound → the number.
1794fn open_or(valid_to: u64) -> Option<u64> {
1795    (valid_to != VALID_TO_OPEN).then_some(valid_to)
1796}
1797
1798#[cfg(test)]
1799mod tests {
1800    use plugmem_host::Config;
1801
1802    use super::*;
1803
1804    /// A stub embedder returning a fixed vector per input — no network.
1805    struct StubEmbedder;
1806    impl plugmem_host::Embedder for StubEmbedder {
1807        fn space_id(&self) -> &str {
1808            "cli-stub"
1809        }
1810
1811        fn dim(&self) -> usize {
1812            3
1813        }
1814        fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, HostError> {
1815            Ok(texts.iter().map(|_| vec![0.1, 0.2, 0.3]).collect())
1816        }
1817    }
1818
1819    fn recall_cmd(query: Option<&str>) -> Command {
1820        Command::Recall {
1821            query: query.map(str::to_owned),
1822            tags: vec![],
1823            entities: vec![],
1824            as_of: None,
1825            range: None,
1826            k: 0,
1827            closed: false,
1828            token_budget: None,
1829            ef: None,
1830            graph_depth: None,
1831            vector: Vec::new(),
1832        }
1833    }
1834
1835    fn settings_with(embedder: Option<Box<dyn plugmem_host::Embedder>>) -> Settings {
1836        Settings {
1837            database_path: None,
1838            config: Config::default(),
1839            embedder,
1840            snapshot_every_ops: None,
1841            snapshot_journal_bytes: None,
1842            maintain_every_forgets: None,
1843            fsync: None,
1844            workspace: plugmem_host::WorkspaceSettings {
1845                dir: None,
1846                limits: plugmem_host::WorkspaceLimits::default(),
1847            },
1848            warnings: Vec::new(),
1849        }
1850    }
1851
1852    #[test]
1853    fn embed_recall_query_embeds_recall_text_only_when_an_embedder_is_set() {
1854        // recall text + embedder → a vector.
1855        let mut with = settings_with(Some(Box::new(StubEmbedder)));
1856        assert_eq!(
1857            embed_recall_query(&mut with, &recall_cmd(Some("tokio"))).unwrap(),
1858            Some(vec![0.1, 0.2, 0.3])
1859        );
1860
1861        // no embedder → None (recall falls back to lexical/structural sources).
1862        let mut without = settings_with(None);
1863        assert_eq!(
1864            embed_recall_query(&mut without, &recall_cmd(Some("tokio"))).unwrap(),
1865            None
1866        );
1867
1868        // recall with no query text → None (nothing to embed).
1869        let mut with_empty = settings_with(Some(Box::new(StubEmbedder)));
1870        assert_eq!(
1871            embed_recall_query(&mut with_empty, &recall_cmd(None)).unwrap(),
1872            None
1873        );
1874
1875        // a non-recall command → None even with an embedder configured.
1876        let mut with_stats = settings_with(Some(Box::new(StubEmbedder)));
1877        assert_eq!(
1878            embed_recall_query(&mut with_stats, &Command::Stats).unwrap(),
1879            None
1880        );
1881    }
1882
1883    #[test]
1884    fn split_line_honors_quotes_and_whitespace() {
1885        assert_eq!(split_line("remember hello"), ["remember", "hello"]);
1886        assert_eq!(
1887            split_line(r#"remember "two words" --tag x"#),
1888            ["remember", "two words", "--tag", "x"]
1889        );
1890        assert_eq!(split_line("  recall   'a b'  "), ["recall", "a b"]);
1891        assert_eq!(split_line(""), Vec::<String>::new());
1892        // An empty quoted string is a real (empty) argument.
1893        assert_eq!(split_line(r#"remember """#), ["remember", ""]);
1894    }
1895
1896    #[test]
1897    fn repl_runs_over_one_handle_and_checkpoints_on_exit() {
1898        let (db, tmp) = TempDb::open();
1899        let path = tmp.0.join("m.plugmem");
1900        drop(db); // release the writer lock so run_repl can open it
1901
1902        let settings = settings_with(None);
1903        // Multi-word text is quoted, same grammar as the one-shot CLI.
1904        let script = b"remember \"hello tokio world\"\nrecall tokio\nrevise 0 \"goodbye tokio\"\nbadcmd\nexit\n";
1905        let mut out = Vec::new();
1906        let code = run_repl(&path, settings, false, &script[..], &mut out);
1907        let text = String::from_utf8(out).unwrap();
1908
1909        assert_eq!(code, 0);
1910        assert!(text.contains("remembered fact 0"), "{text}");
1911        assert!(text.contains("tokio"), "{text}");
1912        // A bad line is reported but does not end the session (revise ran after).
1913        assert!(text.contains("unrecognized subcommand"), "{text}");
1914
1915        // Checkpointed on exit → a fresh read-only open sees the data with a
1916        // clean journal. The revise chain leaves two facts: the closed original
1917        // and its active successor.
1918        let ro = Database::open_readonly(&path, Config::default()).unwrap();
1919        assert_eq!(ro.stats().facts, 2, "original + successor after the revise");
1920    }
1921
1922    #[test]
1923    fn read_only_repl_observes_a_writer_reports_freshness_and_refuses_writes() {
1924        let (db, tmp) = TempDb::open();
1925        let path = tmp.0.join("m.plugmem");
1926        // Seed and publish generation 1, then keep the writer open and live —
1927        // the read-only repl observes it cross-process (Variant 2 MVCC).
1928        let mut sink = Vec::new();
1929        execute(
1930            &db,
1931            &remember("seed fact tokio", None, &[]),
1932            false,
1933            1_000,
1934            &mut sink,
1935        )
1936        .unwrap();
1937        db.checkpoint(1_001).unwrap();
1938
1939        let settings = settings_with(None);
1940        // A read verb, both freshness verbs, and a write (must be refused).
1941        let script = b"generation\nstats\nrefresh\nremember \"nope\"\nexit\n";
1942        let mut out = Vec::new();
1943        let code = run_repl_ro(&path, settings, false, &script[..], &mut out);
1944        let text = String::from_utf8(out).unwrap();
1945
1946        assert_eq!(code, 0);
1947        assert!(text.contains("generation 1"), "generation verb: {text}");
1948        assert!(text.contains("fact"), "stats ran: {text}");
1949        // The writer published nothing after the reader opened, so refresh is a
1950        // no-op that stays on generation 1.
1951        assert!(
1952            text.contains("already current → generation 1"),
1953            "refresh no-op: {text}"
1954        );
1955        // A write verb is refused without ending the session (exit still ran).
1956        assert!(text.contains("read-only session"), "write refused: {text}");
1957
1958        // The read-only session never wrote: the writer is still on generation 1
1959        // with its single seeded fact, untouched by the repl.
1960        assert_eq!(db.stats().facts, 1);
1961    }
1962
1963    #[test]
1964    fn read_only_repl_refresh_advances_after_the_writer_checkpoints() {
1965        let (db, tmp) = TempDb::open();
1966        let path = tmp.0.join("m.plugmem");
1967        let mut sink = Vec::new();
1968        execute(&db, &remember("first", None, &[]), false, 1_000, &mut sink).unwrap();
1969        db.checkpoint(1_001).unwrap();
1970
1971        // A reader hook that publishes a *new* generation the first time the repl
1972        // pulls a line, so the subsequent `refresh` deterministically advances —
1973        // exercising the "refreshed" branch without a background thread.
1974        struct HookOnFirstRead<'a> {
1975            script: std::io::Cursor<&'a [u8]>,
1976            db: &'a Database,
1977            fired: bool,
1978        }
1979        impl std::io::Read for HookOnFirstRead<'_> {
1980            fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1981                if !self.fired {
1982                    self.fired = true;
1983                    // Publish generation 2 before the first command is read, so
1984                    // the reader (opened on gen 1) sees something newer.
1985                    let mut s = Vec::new();
1986                    execute(
1987                        self.db,
1988                        &remember("second", None, &[]),
1989                        false,
1990                        2_000,
1991                        &mut s,
1992                    )
1993                    .unwrap();
1994                    self.db.checkpoint(2_001).unwrap();
1995                }
1996                self.script.read(buf)
1997            }
1998        }
1999        let reader = std::io::BufReader::new(HookOnFirstRead {
2000            script: std::io::Cursor::new(b"refresh\nstats\nexit\n" as &[u8]),
2001            db: &db,
2002            fired: false,
2003        });
2004
2005        let mut out = Vec::new();
2006        let code = run_repl_ro(&path, settings_with(None), false, reader, &mut out);
2007        let text = String::from_utf8(out).unwrap();
2008
2009        assert_eq!(code, 0);
2010        // Opened on gen 1, the writer published gen 2, refresh advanced onto it.
2011        assert!(text.contains("refreshed → generation 2"), "advance: {text}");
2012        // And the advanced reader now sees the writer's second fact.
2013        assert!(text.contains("fact"), "stats after refresh: {text}");
2014        assert_eq!(db.stats().facts, 2);
2015    }
2016
2017    #[test]
2018    fn read_only_repl_freshness_verbs_emit_json() {
2019        let (db, tmp) = TempDb::open();
2020        let path = tmp.0.join("m.plugmem");
2021        let mut sink = Vec::new();
2022        execute(&db, &remember("j", None, &[]), false, 1_000, &mut sink).unwrap();
2023        db.checkpoint(1_001).unwrap();
2024
2025        let script = b"generation\nrefresh\nexit\n";
2026        let mut out = Vec::new();
2027        let code = run_repl_ro(&path, settings_with(None), true, &script[..], &mut out);
2028        let text = String::from_utf8(out).unwrap();
2029
2030        assert_eq!(code, 0);
2031        assert!(
2032            text.contains(r#""generation":1"#),
2033            "generation json: {text}"
2034        );
2035        assert!(text.contains(r#""advanced":false"#), "refresh json: {text}");
2036    }
2037
2038    /// A throwaway database on a unique temp path; removed on drop.
2039    struct TempDb(PathBuf);
2040    impl TempDb {
2041        fn open() -> (Database, Self) {
2042            let dir = std::env::temp_dir().join(format!(
2043                "plugmem-cli-{}-{}",
2044                std::process::id(),
2045                now_ms_unique()
2046            ));
2047            std::fs::create_dir_all(&dir).unwrap();
2048            let path = dir.join("m.plugmem");
2049            let (db, _) = Database::open(&path, Config::default()).unwrap();
2050            (db, TempDb(dir))
2051        }
2052    }
2053    impl Drop for TempDb {
2054        fn drop(&mut self) {
2055            let _ = std::fs::remove_dir_all(&self.0);
2056        }
2057    }
2058
2059    /// A strictly-increasing counter so temp dirs never collide within a run
2060    /// (the wall clock alone can repeat at millisecond resolution).
2061    fn now_ms_unique() -> String {
2062        use std::sync::atomic::{AtomicU64, Ordering};
2063        static N: AtomicU64 = AtomicU64::new(0);
2064        format!("{}-{}", now_ms(), N.fetch_add(1, Ordering::Relaxed))
2065    }
2066
2067    fn run_cmd(db: &Database, cmd: &Command, json: bool, now: u64) -> (u8, String) {
2068        let mut buf = Vec::new();
2069        let code = execute(db, cmd, json, now, &mut buf).expect("execute");
2070        (code, String::from_utf8(buf).unwrap())
2071    }
2072
2073    fn remember(text: &str, entity: Option<&str>, tags: &[&str]) -> Command {
2074        Command::Remember {
2075            text: text.into(),
2076            entity: entity.map(Into::into),
2077            tags: tags.iter().map(|t| (*t).into()).collect(),
2078            links: Vec::new(),
2079            meta: Vec::new(),
2080            valid_from: None,
2081            vector: Vec::new(),
2082            guarded: false,
2083        }
2084    }
2085
2086    fn remember_with_meta(text: &str, meta: &[&str]) -> Command {
2087        Command::Remember {
2088            text: text.into(),
2089            entity: None,
2090            tags: Vec::new(),
2091            links: Vec::new(),
2092            meta: meta.iter().map(|m| (*m).into()).collect(),
2093            valid_from: None,
2094            vector: Vec::new(),
2095            guarded: false,
2096        }
2097    }
2098
2099    #[test]
2100    fn guarded_remember_flag_reports_blocked_without_a_write() {
2101        let (db, _temp) = TempDb::open();
2102        let mut first = remember("likes green tea every morning", Some("user"), &[]);
2103        let Command::Remember { guarded, .. } = &mut first else {
2104            unreachable!()
2105        };
2106        *guarded = true;
2107        let (_, stored) = run_cmd(&db, &first, true, 1_000);
2108        let stored: serde_json::Value = serde_json::from_str(&stored).unwrap();
2109        assert_eq!(stored["status"], "stored");
2110        assert_eq!(stored["outcome"]["id"], 0);
2111        assert_eq!(stored["checked"], true, "an entity was named");
2112
2113        let mut second = remember("likes green tea each morning", Some("user"), &[]);
2114        let Command::Remember { guarded, .. } = &mut second else {
2115            unreachable!()
2116        };
2117        *guarded = true;
2118        let (_, blocked) = run_cmd(&db, &second, true, 2_000);
2119        let blocked: serde_json::Value = serde_json::from_str(&blocked).unwrap();
2120        assert_eq!(blocked["status"], "blocked");
2121        assert_eq!(blocked["similar"][0]["id"], 0);
2122        assert_eq!(db.stats().facts, 1);
2123    }
2124
2125    /// `--guarded` with no `--entity` stores unguarded, and prints that it did.
2126    ///
2127    /// The detector is scoped to the fact's entity, so there is no candidate
2128    /// set to compare against and nothing can be blocked - now or after any
2129    /// number of later writes. Without the notice the caller sees a plain
2130    /// "remembered fact N" and has no way to learn the check they asked for
2131    /// never ran.
2132    #[test]
2133    fn guarded_without_an_entity_says_it_stored_unchecked() {
2134        let (db, _temp) = TempDb::open();
2135        let text = "the cache is disabled because it raced with the warmup task";
2136        for at in [1_000, 2_000, 3_000] {
2137            let mut cmd = remember(text, None, &[]);
2138            let Command::Remember { guarded, .. } = &mut cmd else {
2139                unreachable!()
2140            };
2141            *guarded = true;
2142            let (_, plain) = run_cmd(&db, &cmd, false, at);
2143            assert!(plain.contains("remembered fact"), "{plain}");
2144            assert!(plain.contains("WITHOUT a duplicate check"), "{plain}");
2145        }
2146        assert_eq!(db.stats().facts, 3, "identical facts, as remember would");
2147
2148        let mut cmd = remember(text, None, &[]);
2149        let Command::Remember { guarded, .. } = &mut cmd else {
2150            unreachable!()
2151        };
2152        *guarded = true;
2153        let (_, json) = run_cmd(&db, &cmd, true, 4_000);
2154        let json: serde_json::Value = serde_json::from_str(&json).unwrap();
2155        assert_eq!(json["status"], "stored");
2156        assert_eq!(json["checked"], false);
2157    }
2158
2159    #[test]
2160    fn meta_flag_renders_sorted_in_show_and_export_and_rejects_bad_input() {
2161        let (db, _t) = TempDb::open();
2162        // Keys given out of order; last value for a repeated key wins.
2163        let cmd = remember_with_meta("a scan", &["uri=s3://b/x", "page=2", "page=3"]);
2164        assert_eq!(run_cmd(&db, &cmd, false, 1_000).0, 0);
2165
2166        // show (human): sorted `key=value`, last-write-wins on `page`.
2167        let (_, human) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2168        assert!(
2169            human.contains("metadata    page=3, uri=s3://b/x"),
2170            "{human}"
2171        );
2172        // show (json): a metadata object.
2173        let (_, jshow) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2174        let v: serde_json::Value = serde_json::from_str(&jshow).unwrap();
2175        assert_eq!(v["metadata"]["page"], "3");
2176        assert_eq!(v["metadata"]["uri"], "s3://b/x");
2177
2178        // export: the JSONL line carries the same object.
2179        let (_, exp) = run_cmd(&db, &Command::Export, false, 2_000);
2180        let line: serde_json::Value = serde_json::from_str(exp.lines().next().unwrap()).unwrap();
2181        assert_eq!(line["metadata"]["uri"], "s3://b/x");
2182
2183        // A `--meta` without `=` is a usage error.
2184        assert!(matches!(
2185            parse_meta(&["noequals".to_string()]),
2186            Err(CliError::Usage(_))
2187        ));
2188        assert!(parse_meta(&["=noKey".to_string()]).is_err());
2189    }
2190
2191    #[test]
2192    fn remember_then_recall_human_and_json() {
2193        let (db, _t) = TempDb::open();
2194        let (code, out) = run_cmd(
2195            &db,
2196            &remember("prefers tokio", Some("user"), &["pref"]),
2197            false,
2198            1_000,
2199        );
2200        assert_eq!(code, 0);
2201        assert!(out.starts_with("remembered fact 0"), "{out}");
2202
2203        // human recall
2204        let recall = Command::Recall {
2205            query: Some("tokio".into()),
2206            tags: Vec::new(),
2207            entities: Vec::new(),
2208            as_of: None,
2209            range: None,
2210            k: 0,
2211            closed: false,
2212            token_budget: None,
2213            ef: None,
2214            graph_depth: None,
2215            vector: Vec::new(),
2216        };
2217        let (code, out) = run_cmd(&db, &recall, false, 2_000);
2218        assert_eq!(code, 0);
2219        assert!(out.contains("tokio"), "{out}");
2220
2221        // json recall
2222        let (code, out) = run_cmd(&db, &recall, true, 2_000);
2223        assert_eq!(code, 0);
2224        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2225        assert!(!v["facts"].as_array().unwrap().is_empty(), "{out}");
2226    }
2227
2228    #[test]
2229    fn recall_empty_is_ok_with_a_note() {
2230        let (db, _t) = TempDb::open();
2231        let recall = Command::Recall {
2232            query: Some("nothing here".into()),
2233            tags: Vec::new(),
2234            entities: Vec::new(),
2235            as_of: None,
2236            range: None,
2237            k: 0,
2238            closed: false,
2239            token_budget: None,
2240            ef: None,
2241            graph_depth: None,
2242            vector: Vec::new(),
2243        };
2244        let (code, out) = run_cmd(&db, &recall, false, 1_000);
2245        assert_eq!(code, 0);
2246        assert!(out.contains("nothing recalled"), "{out}");
2247    }
2248
2249    #[test]
2250    fn revise_closes_the_predecessor_and_conflict_is_surfaced() {
2251        let (db, _t) = TempDb::open();
2252        run_cmd(
2253            &db,
2254            &remember("lives in Moscow", Some("user"), &[]),
2255            false,
2256            1_000,
2257        );
2258        // a near-duplicate surfaces a similar hint
2259        let (_, out) = run_cmd(
2260            &db,
2261            &remember("lives in Moscow now", Some("user"), &[]),
2262            false,
2263            1_500,
2264        );
2265        assert!(out.contains("similar to fact"), "{out}");
2266
2267        let revise = Command::Revise {
2268            id: 0,
2269            text: "lives in Berlin".into(),
2270            entity: Some("user".into()),
2271            tags: Vec::new(),
2272            links: Vec::new(),
2273            meta: Vec::new(),
2274            valid_from: None,
2275            vector: Vec::new(),
2276        };
2277        let (code, out) = run_cmd(&db, &revise, false, 2_000);
2278        assert_eq!(code, 0);
2279        assert!(out.starts_with("remembered fact"), "{out}");
2280    }
2281
2282    #[test]
2283    fn show_found_and_missing() {
2284        let (db, _t) = TempDb::open();
2285        run_cmd(&db, &remember("a note", None, &[]), false, 1_000);
2286
2287        let (code, out) = run_cmd(&db, &Command::Show { id: 0 }, false, 2_000);
2288        assert_eq!(code, 0);
2289        assert!(
2290            out.contains("a note") && out.contains("recorded_at 1000"),
2291            "{out}"
2292        );
2293
2294        let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, false, 2_000);
2295        assert_eq!(code, 1, "missing id is a soft miss");
2296        assert!(out.contains("not found"), "{out}");
2297
2298        // json card
2299        let (_, out) = run_cmd(&db, &Command::Show { id: 0 }, true, 2_000);
2300        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2301        assert_eq!(v["text"], "a note");
2302        assert_eq!(v["valid_to"], serde_json::Value::Null); // open interval
2303    }
2304
2305    #[test]
2306    fn forget_then_maintain_purges() {
2307        let (db, _t) = TempDb::open();
2308        run_cmd(&db, &remember("temp", None, &[]), false, 1_000);
2309
2310        let (code, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_000);
2311        assert_eq!(code, 0);
2312        assert!(out.contains("forgot fact 0"), "{out}");
2313        // second forget is idempotent
2314        let (_, out) = run_cmd(&db, &Command::Forget { id: 0 }, false, 2_100);
2315        assert!(out.contains("already gone"), "{out}");
2316
2317        let (code, out) = run_cmd(
2318            &db,
2319            &Command::Maintain {
2320                mode: MaintainMode::Auto,
2321                reembed: false,
2322                batch_size: None,
2323            },
2324            false,
2325            3_000,
2326        );
2327        assert_eq!(code, 0);
2328        assert!(out.contains("purged 1"), "{out}");
2329    }
2330
2331    #[test]
2332    fn explicit_reembed_has_human_and_json_reports() {
2333        let (plain, tmp) = TempDb::open();
2334        let path = tmp.0.join("m.plugmem");
2335        drop(plain);
2336        let mut config = Config::default();
2337        config.dim = 3;
2338        let (db, _) = Database::builder(config)
2339            .embedder(Box::new(StubEmbedder))
2340            .open(path)
2341            .unwrap();
2342        run_cmd(&db, &remember("vector fact", None, &["kept"]), false, 1);
2343
2344        let command = Command::Maintain {
2345            mode: MaintainMode::Auto,
2346            reembed: true,
2347            batch_size: Some(1),
2348        };
2349        let (code, human) = run_cmd(&db, &command, false, 2);
2350        assert_eq!(code, 0);
2351        assert!(human.contains("reembedded: 1 facts"));
2352        let (code, json) = run_cmd(&db, &command, true, 3);
2353        assert_eq!(code, 0);
2354        let report: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
2355        assert_eq!(report["new_space"], "cli-stub");
2356        assert_eq!(report["embedded"], 1);
2357    }
2358
2359    #[test]
2360    fn readonly_cli_refuses_a_same_dimension_different_space() {
2361        let (plain, tmp) = TempDb::open();
2362        let path = tmp.0.join("m.plugmem");
2363        drop(plain);
2364        let mut config = Config::default();
2365        config.dim = 3;
2366        let (db, _) = Database::builder(config.clone())
2367            .embedder(Box::new(StubEmbedder))
2368            .open(&path)
2369            .unwrap();
2370        db.remember(RememberInput::text(1, "stored vector"))
2371            .unwrap();
2372        db.checkpoint(2).unwrap();
2373        let ro = Database::open_readonly(&path, config).unwrap();
2374        let cmd = recall_cmd(Some("stored"));
2375        let mut out = Vec::new();
2376        let code = execute_ro(
2377            &ro,
2378            &cmd,
2379            Some(&[0.1, 0.2, 0.3]),
2380            Some("other-model"),
2381            false,
2382            &mut out,
2383        );
2384        assert_eq!(code, 2);
2385    }
2386
2387    #[test]
2388    fn tags_pages_and_remove_tag_have_human_and_json_shapes() {
2389        let (db, _t) = TempDb::open();
2390        run_cmd(&db, &remember("one", None, &["drop", "keep"]), false, 1_000);
2391        run_cmd(&db, &remember("two", None, &["drop"]), false, 1_100);
2392
2393        let (code, out) = run_cmd(
2394            &db,
2395            &Command::Tags {
2396                prefix: None,
2397                cursor: None,
2398                limit: 1,
2399            },
2400            false,
2401            2_000,
2402        );
2403        assert_eq!(code, 0);
2404        assert!(out.starts_with("2\tdrop\n"), "{out}");
2405        assert!(out.contains("next_cursor\t"), "{out}");
2406
2407        let (_, out) = run_cmd(&db, &Command::RemoveTag { tag: "drop".into() }, true, 3_000);
2408        let removed: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2409        assert_eq!(removed["affected"], 2);
2410
2411        let (_, out) = run_cmd(
2412            &db,
2413            &Command::Tags {
2414                prefix: None,
2415                cursor: None,
2416                limit: 0,
2417            },
2418            true,
2419            4_000,
2420        );
2421        let page: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2422        assert_eq!(page["items"][0]["name"], "keep");
2423        assert_eq!(db.export().len(), 2, "facts remain current after retagging");
2424    }
2425
2426    #[test]
2427    fn link_and_stats_and_json() {
2428        let (db, _t) = TempDb::open();
2429        run_cmd(
2430            &db,
2431            &remember("uses tokio", Some("plugmem"), &[]),
2432            false,
2433            1_000,
2434        );
2435        let link = Command::Link {
2436            src: "plugmem".into(),
2437            rel: "depends_on".into(),
2438            dst: "tokio".into(),
2439            provenance: None,
2440        };
2441        let (code, out) = run_cmd(&db, &link, false, 2_000);
2442        assert_eq!(code, 0);
2443        assert!(out.contains("plugmem -depends_on-> tokio"), "{out}");
2444        let unlink = Command::Unlink {
2445            src: "plugmem".into(),
2446            rel: "depends_on".into(),
2447            dst: "tokio".into(),
2448        };
2449        let (code, out) = run_cmd(&db, &unlink, false, 2_500);
2450        assert_eq!(code, 0);
2451        assert!(
2452            out.contains("unlinked plugmem -depends_on-> tokio"),
2453            "{out}"
2454        );
2455
2456        let (code, out) = run_cmd(&db, &Command::Stats, true, 3_000);
2457        assert_eq!(code, 0);
2458        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2459        assert_eq!(v["facts"], 1);
2460        assert_eq!(v["edges"], 0);
2461        assert_eq!(v["edge_versions"], 1);
2462    }
2463
2464    #[test]
2465    fn bad_link_is_a_usage_error() {
2466        let (db, _t) = TempDb::open();
2467        let cmd = Command::Remember {
2468            text: "x".into(),
2469            entity: Some("user".into()),
2470            tags: Vec::new(),
2471            links: vec!["not-a-pair".into()],
2472            meta: Vec::new(),
2473            valid_from: None,
2474            vector: Vec::new(),
2475            guarded: false,
2476        };
2477        let mut buf = Vec::new();
2478        let err = execute(&db, &cmd, false, 1_000, &mut buf).unwrap_err();
2479        assert!(matches!(err, CliError::Usage(_)));
2480    }
2481
2482    #[test]
2483    fn as_of_time_travel_via_recall() {
2484        let (db, _t) = TempDb::open();
2485        run_cmd(
2486            &db,
2487            &remember("lives in Moscow", Some("user"), &[]),
2488            false,
2489            1_000,
2490        );
2491        let revise = Command::Revise {
2492            id: 0,
2493            text: "lives in Berlin".into(),
2494            entity: Some("user".into()),
2495            tags: Vec::new(),
2496            links: Vec::new(),
2497            meta: Vec::new(),
2498            valid_from: None,
2499            vector: Vec::new(),
2500        };
2501        run_cmd(&db, &revise, false, 2_000);
2502
2503        let as_of = Command::Recall {
2504            query: Some("lives".into()),
2505            tags: Vec::new(),
2506            entities: vec!["user".into()],
2507            as_of: Some(1_500),
2508            range: None,
2509            k: 0,
2510            closed: false,
2511            token_budget: None,
2512            ef: None,
2513            graph_depth: None,
2514            vector: Vec::new(),
2515        };
2516        let (_, out) = run_cmd(&db, &as_of, false, 3_000);
2517        assert!(out.contains("Moscow"), "as-of 1500 → Moscow: {out}");
2518    }
2519
2520    #[test]
2521    fn every_command_has_a_json_shape() {
2522        let (db, _t) = TempDb::open();
2523        // remember --json: id + similar array
2524        let (_, out) = run_cmd(
2525            &db,
2526            &remember("uses tokio", Some("plugmem"), &["pref"]),
2527            true,
2528            1_000,
2529        );
2530        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2531        assert_eq!(v["id"], 0);
2532        assert!(v["similar"].is_array());
2533
2534        // revise --json
2535        let revise = Command::Revise {
2536            id: 0,
2537            text: "uses tokio now".into(),
2538            entity: Some("plugmem".into()),
2539            tags: Vec::new(),
2540            links: Vec::new(),
2541            meta: Vec::new(),
2542            valid_from: None,
2543            vector: Vec::new(),
2544        };
2545        let (_, out) = run_cmd(&db, &revise, true, 1_500);
2546        assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2547
2548        // link --json
2549        let link = Command::Link {
2550            src: "plugmem".into(),
2551            rel: "depends_on".into(),
2552            dst: "tokio".into(),
2553            provenance: None,
2554        };
2555        let (_, out) = run_cmd(&db, &link, true, 2_000);
2556        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2557        assert_eq!(v["rel"], "depends_on");
2558        let unlink = Command::Unlink {
2559            src: "plugmem".into(),
2560            rel: "depends_on".into(),
2561            dst: "tokio".into(),
2562        };
2563        let (_, out) = run_cmd(&db, &unlink, true, 2_100);
2564        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2565        assert_eq!(v["unlinked"], true);
2566
2567        // forget --json then maintain --json
2568        let (_, out) = run_cmd(&db, &Command::Forget { id: 1 }, true, 2_500);
2569        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2570        assert_eq!(v["forgotten"], true);
2571        let (_, out) = run_cmd(
2572            &db,
2573            &Command::Maintain {
2574                mode: MaintainMode::Auto,
2575                reembed: false,
2576                batch_size: None,
2577            },
2578            true,
2579            3_000,
2580        );
2581        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2582        assert!(v["purged"].as_u64().unwrap() >= 1);
2583
2584        // show --json of a missing id
2585        let (code, out) = run_cmd(&db, &Command::Show { id: 999 }, true, 3_500);
2586        assert_eq!(code, 1);
2587        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2588        assert_eq!(v["found"], false);
2589
2590        // recall --json with a range window (covers the range/closed paths)
2591        let recall = Command::Recall {
2592            query: None,
2593            tags: Vec::new(),
2594            entities: vec!["plugmem".into()],
2595            as_of: None,
2596            range: Some(vec![0, 10_000]),
2597            k: 4,
2598            closed: true,
2599            token_budget: None,
2600            ef: None,
2601            graph_depth: None,
2602            vector: Vec::new(),
2603        };
2604        let (_, out) = run_cmd(&db, &recall, true, 4_000);
2605        assert!(serde_json::from_str::<serde_json::Value>(out.trim()).is_ok());
2606    }
2607
2608    #[test]
2609    fn graph_depth_is_a_per_call_flag_over_the_configured_default() {
2610        // A chain a -> b -> c -> d with one fact each, so the number of facts
2611        // recalled *is* the number of hops taken.
2612        let (db, _t) = TempDb::open();
2613        for (i, (entity, next)) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
2614            .iter()
2615            .enumerate()
2616        {
2617            let cmd = Command::Remember {
2618                text: format!("fact on {entity}"),
2619                entity: Some((*entity).into()),
2620                tags: Vec::new(),
2621                links: vec![format!("leads_to:{next}")],
2622                meta: Vec::new(),
2623                valid_from: None,
2624                vector: Vec::new(),
2625                guarded: false,
2626            };
2627            run_cmd(&db, &cmd, false, 1_000 + i as u64);
2628        }
2629
2630        let reached = |depth: Option<u32>| {
2631            let recall = Command::Recall {
2632                query: None,
2633                tags: Vec::new(),
2634                entities: vec!["a".into()],
2635                as_of: None,
2636                range: None,
2637                k: 64,
2638                closed: false,
2639                token_budget: Some(4096),
2640                ef: None,
2641                graph_depth: depth,
2642                vector: Vec::new(),
2643            };
2644            let (_, out) = run_cmd(&db, &recall, false, 5_000);
2645            out.lines().filter(|l| l.starts_with("- [f")).count()
2646        };
2647
2648        assert_eq!(reached(None), 3, "the configured default is 2 hops");
2649        assert_eq!(reached(Some(0)), 1, "no expansion: the anchor's own fact");
2650        assert_eq!(reached(Some(1)), 2);
2651        assert_eq!(reached(Some(3)), 4);
2652        // No hop ceiling, and an absurd depth terminates: the walk ends when a
2653        // pass adds no entity, not when a counter runs out.
2654        assert_eq!(reached(Some(99)), 4);
2655        assert_eq!(reached(Some(u32::MAX)), 4);
2656    }
2657
2658    #[test]
2659    fn stats_human_lists_the_counters() {
2660        let (db, _t) = TempDb::open();
2661        run_cmd(&db, &remember("a", None, &[]), false, 1_000);
2662        let (code, out) = run_cmd(&db, &Command::Stats, false, 2_000);
2663        assert_eq!(code, 0);
2664        assert!(out.contains("facts") && out.contains("pool_bytes"), "{out}");
2665    }
2666
2667    #[test]
2668    fn verify_command_renders_human_and_json() {
2669        let (db, _t) = TempDb::open();
2670        run_cmd(&db, &remember("clean", None, &[]), false, 1_000);
2671
2672        let (code, out) = run_cmd(&db, &Command::Verify, false, 2_000);
2673        assert_eq!(code, 0);
2674        assert_eq!(out.trim(), "integrity ok");
2675
2676        let (code, out) = run_cmd(&db, &Command::Verify, true, 2_100);
2677        assert_eq!(code, 0);
2678        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
2679        assert_eq!(v["ok"], true);
2680    }
2681
2682    #[test]
2683    fn show_json_of_a_revised_predecessor_is_closed() {
2684        let (db, _t) = TempDb::open();
2685        run_cmd(&db, &remember("v1", Some("e"), &[]), false, 1_000);
2686        let revise = Command::Revise {
2687            id: 0,
2688            text: "v2".into(),
2689            entity: Some("e".into()),
2690            tags: Vec::new(),
2691            links: Vec::new(),
2692            meta: Vec::new(),
2693            valid_from: None,
2694            vector: Vec::new(),
2695        };
2696        run_cmd(&db, &revise, false, 2_000);
2697        // the successor records `revises`; its card names the predecessor
2698        let (_, out) = run_cmd(&db, &Command::Show { id: 1 }, false, 3_000);
2699        assert!(out.contains("revises     fact 0"), "{out}");
2700    }
2701
2702    #[test]
2703    fn resolve_db_path_prefers_the_flag() {
2704        let p = "/tmp/explicit.plugmem";
2705        assert_eq!(resolve_db_path(Some(p), None, None), PathBuf::from(p));
2706        let configured = std::path::Path::new("/tmp/configured.plugmem");
2707        assert_eq!(
2708            resolve_db_path(None, Some(configured), None),
2709            PathBuf::from(configured)
2710        );
2711        // With no flag/config it falls back to $PLUGMEM_DB or the platform default — we
2712        // only assert the code path runs and yields some path.
2713        let _ = resolve_db_path(None, None, None);
2714    }
2715
2716    #[test]
2717    fn a_bare_name_is_a_memory_only_when_a_workspace_is_configured() {
2718        let root = PathBuf::from("/srv/bot");
2719
2720        // Without a workspace, everything is a path — this is the guard on the
2721        // default: the old behaviour of `--db` is not allowed to shift.
2722        assert_eq!(
2723            resolve_db_path(Some("work"), None, None),
2724            PathBuf::from("work")
2725        );
2726
2727        // With one, a bare name resolves inside it...
2728        assert_eq!(
2729            resolve_db_path(Some("work"), None, Some(&root)),
2730            PathBuf::from("/srv/bot/db/work.plugmem")
2731        );
2732        // ...and anything that is not a name stays a path, so an explicit file
2733        // is still reachable from inside a workspace.
2734        for path in ["./work", "work.plugmem", "/srv/other.plugmem", "../up"] {
2735            assert_eq!(
2736                resolve_db_path(Some(path), None, Some(&root)),
2737                PathBuf::from(path),
2738                "{path}"
2739            );
2740        }
2741
2742        // `[database].path` is a file setting, never a name.
2743        let configured = std::path::Path::new("work");
2744        assert_eq!(
2745            resolve_db_path(None, Some(configured), Some(&root)),
2746            PathBuf::from("work")
2747        );
2748    }
2749
2750    #[test]
2751    fn settings_help_runs_without_opening_a_database() {
2752        let cli = Cli::try_parse_from(["plugmem-cli", "help", "settings"]).unwrap();
2753        let mut output = Vec::new();
2754        assert_eq!(run_parsed(cli, &mut output), 0);
2755        let output = String::from_utf8(output).unwrap();
2756        assert!(output.contains("plugmem settings"));
2757        assert!(output.contains("[database]"));
2758        assert!(output.contains("path (path string"));
2759
2760        let cli = Cli::try_parse_from(["plugmem-cli", "--json", "help", "settings"]).unwrap();
2761        let mut output = Vec::new();
2762        assert_eq!(run_parsed(cli, &mut output), 0);
2763        let output: serde_json::Value = serde_json::from_slice(&output).unwrap();
2764        assert_eq!(output["topic"], "settings");
2765        assert!(output["config_path_precedence"].is_array());
2766        assert!(output["settings"].as_array().unwrap().len() > 10);
2767    }
2768
2769    #[test]
2770    fn run_parsed_opens_runs_and_reports() {
2771        let dir = std::env::temp_dir().join(format!(
2772            "plugmem-run-{}-{}",
2773            std::process::id(),
2774            now_ms_unique()
2775        ));
2776        std::fs::create_dir_all(&dir).unwrap();
2777        let path = dir.join("m.plugmem");
2778        let cli = Cli {
2779            db: Some(path.display().to_string()),
2780            workspace: None,
2781            config: None,
2782            json: false,
2783            command: Command::Stats,
2784        };
2785        let mut buf = Vec::new();
2786        let code = run_parsed(cli, &mut buf);
2787        assert_eq!(code, 0);
2788        assert!(String::from_utf8(buf).unwrap().contains("facts"));
2789        let _ = std::fs::remove_dir_all(&dir);
2790    }
2791
2792    #[test]
2793    fn recover_and_scrub_render_json_and_human_shapes() {
2794        let (db, tmp) = TempDb::open();
2795        let path = tmp.0.join("m.plugmem");
2796        run_cmd(&db, &remember("recoverable fact", None, &[]), false, 1_000);
2797        run_cmd(&db, &Command::Checkpoint, false, 2_000);
2798        drop(db);
2799
2800        let settings = settings_with(None);
2801        let mut out = Vec::new();
2802        assert_eq!(do_scrub(&path, &settings, true, &mut out), 0);
2803        let scrub: serde_json::Value = serde_json::from_slice(&out).unwrap();
2804        assert_eq!(scrub["ok"], true);
2805        assert!(scrub["bytes"].as_u64().unwrap() > 0);
2806        let mut out = Vec::new();
2807        assert_eq!(do_scrub(&path, &settings, false, &mut out), 0);
2808        let out = String::from_utf8(out).unwrap();
2809        assert!(out.contains("scrub ok:"), "{out}");
2810
2811        let json_dst = tmp.0.join("copy-json.plugmem");
2812        let mut out = Vec::new();
2813        assert_eq!(do_recover(&path, &json_dst, &settings, true, &mut out), 0);
2814        let recover: serde_json::Value = serde_json::from_slice(&out).unwrap();
2815        assert_eq!(recover["kept"], 1);
2816        assert_eq!(recover["dropped_text"], 0);
2817        assert_eq!(recover["dst"], json_dst.display().to_string());
2818
2819        let human_dst = tmp.0.join("copy-human.plugmem");
2820        let mut out = Vec::new();
2821        assert_eq!(do_recover(&path, &human_dst, &settings, false, &mut out), 0);
2822        let out = String::from_utf8(out).unwrap();
2823        assert!(out.contains("recovered to"), "{out}");
2824        assert!(out.contains("kept 1"), "{out}");
2825    }
2826
2827    #[test]
2828    fn readonly_dispatcher_renders_every_read_shape() {
2829        let (db, tmp) = TempDb::open();
2830        let path = tmp.0.join("m.plugmem");
2831        run_cmd(
2832            &db,
2833            &remember("readonly tokio fact", Some("plugmem"), &["pref"]),
2834            false,
2835            1_000,
2836        );
2837        run_cmd(&db, &Command::Checkpoint, false, 2_000);
2838        let ro = Database::open_readonly(&path, Config::default()).unwrap();
2839
2840        let mut out = Vec::new();
2841        assert_eq!(
2842            execute_ro(&ro, &Command::Stats, None, None, true, &mut out),
2843            0
2844        );
2845        let stats: serde_json::Value = serde_json::from_slice(&out).unwrap();
2846        assert_eq!(stats["facts"], 1);
2847
2848        let mut out = Vec::new();
2849        assert_eq!(
2850            execute_ro(&ro, &Command::Show { id: 0 }, None, None, false, &mut out),
2851            0
2852        );
2853        let text = String::from_utf8(out).unwrap();
2854        assert!(text.contains("readonly tokio fact"), "{text}");
2855
2856        let mut out = Vec::new();
2857        assert_eq!(
2858            execute_ro(&ro, &Command::Export, None, None, false, &mut out),
2859            0
2860        );
2861        let exported: serde_json::Value =
2862            serde_json::from_str(String::from_utf8(out).unwrap().lines().next().unwrap()).unwrap();
2863        assert_eq!(exported["text"], "readonly tokio fact");
2864
2865        let mut out = Vec::new();
2866        let recall = Command::Recall {
2867            query: Some("tokio".into()),
2868            tags: vec!["pref".into()],
2869            entities: vec!["plugmem".into()],
2870            as_of: None,
2871            range: None,
2872            k: 1,
2873            closed: false,
2874            token_budget: None,
2875            ef: None,
2876            graph_depth: None,
2877            vector: Vec::new(),
2878        };
2879        assert_eq!(execute_ro(&ro, &recall, None, None, false, &mut out), 0);
2880        let text = String::from_utf8(out).unwrap();
2881        assert!(text.contains("tokio"), "{text}");
2882
2883        let mut out = Vec::new();
2884        assert_eq!(
2885            execute_ro(&ro, &Command::Verify, None, None, true, &mut out),
2886            0
2887        );
2888        let verify: serde_json::Value = serde_json::from_slice(&out).unwrap();
2889        assert_eq!(verify["ok"], true);
2890    }
2891
2892    #[test]
2893    fn run_parsed_on_a_locked_database_returns_one() {
2894        let (_held, dir) = {
2895            let dir = std::env::temp_dir().join(format!(
2896                "plugmem-lock-{}-{}",
2897                std::process::id(),
2898                now_ms_unique()
2899            ));
2900            std::fs::create_dir_all(&dir).unwrap();
2901            let path = dir.join("m.plugmem");
2902            (Database::open(&path, Config::default()).unwrap(), dir)
2903        };
2904        let cli = Cli {
2905            db: Some(dir.join("m.plugmem").display().to_string()),
2906            workspace: None,
2907            config: None,
2908            json: false,
2909            command: Command::Stats,
2910        };
2911        let mut buf = Vec::new();
2912        assert_eq!(run_parsed(cli, &mut buf), 1);
2913        let _ = std::fs::remove_dir_all(&dir);
2914    }
2915
2916    #[test]
2917    fn run_parsed_propagates_a_usage_error_as_two() {
2918        let dir = std::env::temp_dir().join(format!(
2919            "plugmem-usage-{}-{}",
2920            std::process::id(),
2921            now_ms_unique()
2922        ));
2923        std::fs::create_dir_all(&dir).unwrap();
2924        let cli = Cli {
2925            db: Some(dir.join("m.plugmem").display().to_string()),
2926            workspace: None,
2927            config: None,
2928            json: false,
2929            command: Command::Remember {
2930                text: "x".into(),
2931                entity: None,
2932                tags: Vec::new(),
2933                links: vec!["bad".into()],
2934                meta: Vec::new(),
2935                valid_from: None,
2936                vector: Vec::new(),
2937                guarded: false,
2938            },
2939        };
2940        let mut buf = Vec::new();
2941        assert_eq!(run_parsed(cli, &mut buf), 2);
2942        let _ = std::fs::remove_dir_all(&dir);
2943    }
2944
2945    /// A scratch directory (no db) for config/checkpoint tests; removed on drop.
2946    struct Scratch(PathBuf);
2947    impl Scratch {
2948        fn new(tag: &str) -> Self {
2949            let dir = std::env::temp_dir().join(format!(
2950                "plugmem-cli-{tag}-{}-{}",
2951                std::process::id(),
2952                now_ms_unique()
2953            ));
2954            std::fs::create_dir_all(&dir).unwrap();
2955            Scratch(dir)
2956        }
2957    }
2958    impl Drop for Scratch {
2959        fn drop(&mut self) {
2960            let _ = std::fs::remove_dir_all(&self.0);
2961        }
2962    }
2963
2964    #[test]
2965    fn export_import_roundtrip_preserves_open_facts() {
2966        // A deliberately nested scenario: entities, multi-tag facts, a
2967        // revision (closes its predecessor), a forget (tombstone), and an
2968        // explicit valid_from — export must dump exactly the open facts, and
2969        // import must reconstruct that set faithfully.
2970        let (a, _ta) = TempDb::open();
2971        run_cmd(
2972            &a,
2973            &Command::Remember {
2974                text: "prefers tokio".into(),
2975                entity: Some("user".into()),
2976                tags: vec!["pref".into(), "lang".into()],
2977                links: Vec::new(),
2978                meta: vec!["uri=s3://b/x".into(), "src=chat".into()],
2979                valid_from: Some(500),
2980                vector: Vec::new(),
2981                guarded: false,
2982            },
2983            false,
2984            1_000,
2985        );
2986        run_cmd(
2987            &a,
2988            &remember("lives in Moscow", Some("user"), &[]),
2989            false,
2990            1_100,
2991        ); // id 1
2992        run_cmd(
2993            &a,
2994            &Command::Revise {
2995                id: 1,
2996                text: "lives in Berlin".into(),
2997                entity: Some("user".into()),
2998                tags: vec!["geo".into()],
2999                links: Vec::new(),
3000                meta: Vec::new(),
3001                valid_from: None,
3002                vector: Vec::new(),
3003            },
3004            false,
3005            1_200,
3006        ); // id 2 open, id 1 closed
3007        run_cmd(&a, &remember("junk", None, &[]), false, 1_300); // id 3
3008        run_cmd(&a, &Command::Forget { id: 3 }, false, 1_400); // tombstone id 3
3009        run_cmd(
3010            &a,
3011            &remember("uses rust", Some("plugmem"), &["lang"]),
3012            false,
3013            1_500,
3014        ); // id 4
3015
3016        // Export A into a JSONL file.
3017        let mut dump = Vec::new();
3018        render_export(&a.export(), false, &mut dump);
3019        let scratch = Scratch::new("roundtrip");
3020        let file = scratch.0.join("dump.jsonl");
3021        std::fs::write(&file, &dump).unwrap();
3022
3023        // Import into a fresh B.
3024        let (b, _tb) = TempDb::open();
3025        let n = do_import(&b, 9_000, &file, 128, &mut Vec::new()).unwrap();
3026
3027        // Both sides, compared as sets keyed by the preserved fields.
3028        let key = |f: &ExportedFact| {
3029            let mut tags = f.tags.clone();
3030            tags.sort();
3031            (f.text.clone(), f.entity.clone(), tags, f.valid_from)
3032        };
3033        let mut ak: Vec<_> = a.export().iter().map(key).collect();
3034        let mut bk: Vec<_> = b.export().iter().map(key).collect();
3035        ak.sort();
3036        bk.sort();
3037        assert_eq!(n.facts, ak.len());
3038        assert_eq!(
3039            ak, bk,
3040            "roundtrip must preserve text/entity/tags/valid_from"
3041        );
3042
3043        // Spot-checks: the open facts survive with their metadata; the closed
3044        // revision and the tombstone do not.
3045        let b_open = b.export();
3046        assert!(b_open.iter().any(|f| f.text == "prefers tokio"
3047            && f.valid_from == 500
3048            && f.entity.as_deref() == Some("user")
3049            && f.tags == vec!["pref".to_string(), "lang".to_string()]
3050            && f.metadata.get("uri").map(String::as_str) == Some("s3://b/x")
3051            && f.metadata.get("src").map(String::as_str) == Some("chat")));
3052        assert!(b_open.iter().any(|f| f.text == "lives in Berlin"));
3053        assert!(b_open.iter().any(|f| f.text == "uses rust"));
3054        assert!(!b_open.iter().any(|f| f.text.contains("Moscow")));
3055        assert!(!b_open.iter().any(|f| f.text == "junk"));
3056    }
3057
3058    #[test]
3059    fn export_command_emits_jsonl_regardless_of_json_flag() {
3060        let (db, _t) = TempDb::open();
3061        run_cmd(&db, &remember("a fact", Some("e"), &["t"]), false, 1_000);
3062        for json in [false, true] {
3063            let (code, out) = run_cmd(&db, &Command::Export, json, 2_000);
3064            assert_eq!(code, 0);
3065            let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
3066            assert_eq!(v["text"], "a fact");
3067            assert_eq!(v["entity"], "e");
3068            assert_eq!(v["tags"][0], "t");
3069        }
3070    }
3071
3072    #[test]
3073    fn import_command_counts_and_rejects_bad_lines() {
3074        let (db, _t) = TempDb::open();
3075        let scratch = Scratch::new("import");
3076        let good = scratch.0.join("in.jsonl");
3077        std::fs::write(
3078            &good,
3079            "{\"text\":\"from jsonl\",\"entity\":\"user\",\"tags\":[\"x\"],\"valid_from\":42}\n\n{\"text\":\"second\"}\n",
3080        )
3081        .unwrap();
3082        // A tiny batch size exercises the streaming/chunking path (two batches).
3083        let n = do_import(&db, 9_000, &good, 1, &mut Vec::new()).unwrap();
3084        assert_eq!(n.facts, 2, "both facts imported, blank line skipped");
3085
3086        let bad = scratch.0.join("bad.jsonl");
3087        std::fs::write(&bad, "not json at all\n").unwrap();
3088        let err = do_import(&db, 9_000, &bad, 128, &mut Vec::new()).unwrap_err();
3089        assert!(matches!(err, CliError::Usage(_)));
3090    }
3091
3092    #[test]
3093    fn import_batch_size_does_not_change_the_result() {
3094        // The chunk size is a performance knob only: importing the same file with
3095        // batch 1 and batch 100 yields the identical fact set.
3096        let scratch = Scratch::new("import-batch");
3097        let file = scratch.0.join("facts.jsonl");
3098        let mut jsonl = String::new();
3099        for i in 0..5 {
3100            jsonl.push_str(&format!("{{\"text\":\"fact number {i}\"}}\n"));
3101        }
3102        std::fs::write(&file, &jsonl).unwrap();
3103
3104        let (a, _ta) = TempDb::open();
3105        let (b, _tb) = TempDb::open();
3106        let na = do_import(&a, 9_000, &file, 1, &mut Vec::new()).unwrap();
3107        let nb = do_import(&b, 9_000, &file, 100, &mut Vec::new()).unwrap();
3108
3109        assert_eq!(na.facts, 5);
3110        assert_eq!(nb.facts, 5);
3111        let texts = |db: &Database| {
3112            let mut t: Vec<_> = db.export().into_iter().map(|f| f.text).collect();
3113            t.sort();
3114            t
3115        };
3116        assert_eq!(texts(&a), texts(&b), "batch size must not change the facts");
3117    }
3118
3119    #[test]
3120    fn config_table_feeds_settings_and_the_cli_batch_size() {
3121        // The CLI reads config.toml once (host `read_config`), builds the
3122        // shared `Settings`, and pulls its own `batch_size` from the same
3123        // table — the exact flow of `run_parsed`.
3124        let scratch = Scratch::new("settings");
3125        let cfgfile = scratch.0.join("config.toml");
3126        std::fs::write(
3127            &cfgfile,
3128            "[engine]\ndim = 512\n[embedder]\nenabled = false\n\
3129             [maintenance]\nsnapshot_every_ops = 64\nbatch_size = 200\n",
3130        )
3131        .unwrap();
3132        let table = plugmem_host::read_config(Some(&cfgfile)).unwrap();
3133        let s = Settings::from_table(table.as_ref()).unwrap();
3134        assert_eq!(s.config.dim, 512);
3135        assert!(s.embedder.is_none());
3136        assert_eq!(s.snapshot_every_ops, Some(64));
3137        assert_eq!(read_batch_size(table.as_ref()), Some(200));
3138
3139        // An explicit --config that does not exist is a usage error.
3140        assert!(plugmem_host::read_config(Some(&scratch.0.join("nope.toml"))).is_err());
3141    }
3142
3143    #[test]
3144    fn checkpoint_command_flushes_the_journal_and_enables_the_readonly_path() {
3145        let scratch = Scratch::new("checkpoint-cmd");
3146        let path = scratch.0.join("m.plugmem");
3147
3148        // A remember through the read-write path leaves a dirty journal.
3149        let remember = Cli {
3150            db: Some(path.display().to_string()),
3151            workspace: None,
3152            config: None,
3153            json: false,
3154            command: Command::Remember {
3155                text: "hello tokio".into(),
3156                entity: None,
3157                tags: Vec::new(),
3158                links: Vec::new(),
3159                meta: Vec::new(),
3160                valid_from: None,
3161                vector: Vec::new(),
3162                guarded: false,
3163            },
3164        };
3165        assert_eq!(run_parsed(remember, &mut Vec::new()), 0);
3166
3167        // The new command: human shape.
3168        let checkpoint = |json| Cli {
3169            db: Some(path.display().to_string()),
3170            workspace: None,
3171            config: None,
3172            json,
3173            command: Command::Checkpoint,
3174        };
3175        let mut buf = Vec::new();
3176        assert_eq!(run_parsed(checkpoint(false), &mut buf), 0);
3177        assert!(String::from_utf8(buf).unwrap().contains("checkpointed"));
3178
3179        // json shape.
3180        let mut buf = Vec::new();
3181        assert_eq!(run_parsed(checkpoint(true), &mut buf), 0);
3182        let v: serde_json::Value =
3183            serde_json::from_str(String::from_utf8(buf).unwrap().trim()).unwrap();
3184        assert_eq!(v["ok"], true);
3185
3186        // The journal is now clean, so scrub (a shared-lock, read-only open)
3187        // succeeds — it would fail `NeedsCheckpoint` on a dirty journal.
3188        let scrub = Cli {
3189            db: Some(path.display().to_string()),
3190            workspace: None,
3191            config: None,
3192            json: false,
3193            command: Command::Scrub,
3194        };
3195        let mut buf = Vec::new();
3196        assert_eq!(run_parsed(scrub, &mut buf), 0);
3197        assert!(String::from_utf8(buf).unwrap().contains("scrub ok"));
3198    }
3199
3200    #[test]
3201    fn run_parsed_uses_the_readonly_path_after_a_checkpoint() {
3202        let scratch = Scratch::new("ro-route");
3203        let path = scratch.0.join("m.plugmem");
3204        {
3205            let (db, _) = Database::open(&path, Config::default()).unwrap();
3206            db.remember(RememberInput::text(1_000, "hello tokio"))
3207                .unwrap();
3208            db.checkpoint(2_000).unwrap(); // empty journal → open_readonly succeeds
3209        }
3210        // stats routes through open_readonly (mmap, shared)
3211        let cli = Cli {
3212            db: Some(path.display().to_string()),
3213            workspace: None,
3214            config: None,
3215            json: false,
3216            command: Command::Stats,
3217        };
3218        let mut buf = Vec::new();
3219        assert_eq!(run_parsed(cli, &mut buf), 0);
3220        assert!(String::from_utf8(buf).unwrap().contains("facts"));
3221
3222        // recall with no embedder also uses the read-only path
3223        let cli = Cli {
3224            db: Some(path.display().to_string()),
3225            workspace: None,
3226            config: None,
3227            json: false,
3228            command: Command::Recall {
3229                query: Some("tokio".into()),
3230                tags: Vec::new(),
3231                entities: Vec::new(),
3232                as_of: None,
3233                range: None,
3234                k: 0,
3235                closed: false,
3236                token_budget: None,
3237                ef: None,
3238                graph_depth: None,
3239                vector: Vec::new(),
3240            },
3241        };
3242        let mut buf = Vec::new();
3243        assert_eq!(run_parsed(cli, &mut buf), 0);
3244        assert!(String::from_utf8(buf).unwrap().contains("tokio"));
3245    }
3246}