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