Skip to main content

cli/
lib.rs

1//! `mushroomdb` CLI library: hand-rolled arg parsing and the demo dataset builder.
2//!
3//! The binary in `main.rs` stays thin — it dispatches on [`parse_args`] and
4//! prints what the lib functions return.
5
6pub mod doctor;
7pub mod export;
8pub(crate) mod hook;
9pub mod ingest_git;
10pub mod install;
11pub mod recall;
12pub mod structure;
13
14#[cfg(test)]
15use core_api::restore::holds_a_store;
16/// Re-exported where it has always been named: `cli::RestoreOutcome`.
17pub use core_api::restore::RestoreOutcome;
18use core_api::schema::Schema;
19use core_api::{
20    default_max_edges, is_write_query, valid_namespace, AlgoDir, BackupReport, DegreeConfig,
21    Explanation, GraphDb, IngestOptions, LouvainConfig, PageRankConfig, Predicate, RealFs,
22    ResultSet, RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig,
23    WriteGuard, NS_MAX_LEN,
24};
25use export::ExportFormat;
26use std::collections::BTreeMap;
27use std::fmt::Write as _;
28use std::net::SocketAddr;
29use std::path::{Path, PathBuf};
30use std::time::Duration;
31
32/// What every snapshot mushroomdb takes *on its own* does with the WAL.
33///
34/// An ingest that ends past the WAL threshold, a `serve --snapshot-every`
35/// tick, a graceful shutdown, and a plain `mushroomdb snapshot` all archive the
36/// WAL to `wal.<N>.archive` rather than dropping it. `node_history`,
37/// `edge_history`, `was_linked` and `open_at` all read WAL frames and all
38/// consult archives, so the store opens fast and still remembers how it got
39/// here. A truncating snapshot ends that reach — it deletes the genesis marker
40/// as well as the tail — so it is never something mushroomdb decides for the
41/// user: only `mushroomdb snapshot --truncate` does it.
42pub const AUTOMATIC_SNAPSHOT: SnapshotOptions = SnapshotOptions {
43    keep_wal: false,
44    archive_wal: true,
45};
46
47/// How many WAL archives a snapshot mushroomdb takes *on its own* keeps.
48/// `None` = all of them. Retention is configured, never defaulted: deleting
49/// history nobody asked to delete is not a default worth having.
50///
51/// Archiving moves the WAL aside rather than deleting it, so without a bound
52/// every automatic snapshot leaves one more file behind and nothing ever
53/// reclaims them. On a dogfooded repository that is a new archive per
54/// [`SNAPSHOT_WAL_BYTES`] of churn, for as long as the store exists — and that
55/// growth is the argument for the default, not against it: the reach archives
56/// exist to preserve — `node_history`, `edge_history`, `was_linked` — is
57/// exactly what pruning costs, so an automatic snapshot never volunteers to
58/// pay it. `mushroomdb stats` and `mushroomdb doctor` both print where history
59/// currently starts, and `mushroomdb snapshot <db> --retention N` is how a
60/// caller bounds the directory once they have decided the trade is worth it.
61///
62/// One consequence is worth stating plainly for whoever does set a bound,
63/// because it is not proportional. The first prune breaks the genesis chain,
64/// and `open_at` refuses any commit it cannot reconstruct from a complete
65/// prefix — so from that point it answers for commits past the last snapshot
66/// and no further, even though the retained archives still answer
67/// `node_history` and `was_linked` over their own window. Time travel to a
68/// point-in-time state is therefore bounded by the last snapshot once a store
69/// has churned this far; the history reads are bounded by the retention.
70///
71/// Only the automatic path takes this default. `mushroomdb snapshot` is a
72/// thing the user asked for, and `--retention N` is theirs to set: an
73/// explicit snapshot with no `--retention` still keeps every archive.
74///
75/// [`SNAPSHOT_WAL_BYTES`]: crate::ingest_git::SNAPSHOT_WAL_BYTES
76pub const AUTO_SNAPSHOT_RETENTION: Option<u32> = None;
77
78/// Take [`AUTOMATIC_SNAPSHOT`] under a held write lock, keeping
79/// [`AUTO_SNAPSHOT_RETENTION`] archives.
80///
81/// The single place the automatic disposition and the automatic bound are
82/// applied together, so no caller can pick up one without the other.
83///
84/// # Errors
85///
86/// Whatever writing the snapshot returned.
87pub fn snapshot_automatically(db: &mut WriteGuard<'_>) -> Result<(), core_api::GraphError> {
88    db.set_wal_archive_retention(AUTO_SNAPSHOT_RETENTION);
89    db.snapshot_with(AUTOMATIC_SNAPSHOT)
90}
91
92/// How long a server-initiated snapshot waits for the store's cross-process
93/// write lock before giving up.
94///
95/// Short on purpose. A snapshot is an optimisation — it shortens the next
96/// open's replay — so skipping one costs nothing but a longer replay, whereas
97/// blocking the shutdown path or piling up timer ticks behind a busy peer
98/// costs the operator.
99pub const SNAPSHOT_LOCK_WAIT: Duration = Duration::from_millis(500);
100
101/// Take the snapshot `serve` takes — on a `--snapshot-every` tick, and once
102/// more on a graceful shutdown.
103///
104/// Lives here rather than in `main.rs` so the behaviour a running server has is
105/// the behaviour a test can call. `Busy` is the caller's to interpret: a tick
106/// skips it, since the next one is only a period away.
107///
108/// # Errors
109///
110/// Whatever taking the write lock or writing the snapshot returned.
111pub fn snapshot_shared(db: &SharedDb) -> Result<(), core_api::GraphError> {
112    snapshot_automatically(&mut db.write_with_wait(SNAPSHOT_LOCK_WAIT)?)
113}
114
115/// Deterministic demo: 10 Orgs, 20 Projects, 30 People.
116pub const N_ORGS: usize = 10;
117pub const N_PROJECTS: usize = 20;
118pub const N_PEOPLE: usize = 30;
119
120/// Sample query printed by `mushroomdb demo` and executed against the fresh store.
121///
122/// Scoped to one person so `ORDER BY score DESC` is visibly ranked (a global
123/// `LIMIT 5` would be five 1.0 home-project hits).
124pub const SAMPLE_QUERY: &str = "\
125MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
126RETURN p, proj, r.score AS score
127ORDER BY score DESC, proj";
128
129const SAMPLE_EXPLAIN_A: &str = "person-01";
130const SAMPLE_EXPLAIN_B: &str = "proj-01";
131
132/// Build version, printed by `mushroomdb --version`.
133pub const VERSION: &str = env!("CARGO_PKG_VERSION");
134
135/// The one line `--version` and the `version` subcommand print.
136#[must_use]
137pub fn version_string() -> String {
138    format!("mushroomdb {VERSION}")
139}
140
141/// Where `--auto` looks for a database, in order.
142///
143/// 1. `$CLAUDE_PROJECT_DIR/mushroom-memory` — the assistant tells a hook which
144///    project it is working in, and that is the most specific answer there is.
145/// 2. `<working-tree root>/mushroom-memory`, but only when the working
146///    directory is inside a git checkout. Without that guard a command run
147///    from a home directory would quietly create a store there.
148/// 3. `<home>/.mushroomdb/memory`, the user-scope default `install` writes.
149///
150/// The two project-scoped answers match [`install::default_db`],
151/// so a hook with `--auto` finds the store `install --project` created.
152#[must_use]
153pub fn resolve_auto_db(
154    env_project_dir: Option<&std::ffi::OsStr>,
155    cwd: &Path,
156    home: &Path,
157) -> PathBuf {
158    if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
159        return Path::new(dir).join("mushroom-memory");
160    }
161    if let Some(root) = worktree_root(cwd) {
162        return root.join("mushroom-memory");
163    }
164    home.join(".mushroomdb").join("memory")
165}
166
167/// The root of the working tree `dir` sits in: the nearest ancestor holding a
168/// `.git` entry, or `None` outside a checkout.
169///
170/// The answer is a *working tree* root, never the `.git` directory several
171/// worktrees share. A linked worktree keeps a `.git` **file** at its root
172/// (`gitdir: …/worktrees/<name>`) rather than a directory, and both spellings
173/// count here, so `git worktree add` produces a checkout that resolves to its
174/// own store. Two worktrees are two different sets of files, and a graph built
175/// from one answers questions about the other wrongly.
176///
177/// Walking up matters as much as the file/directory distinction: a hook fires
178/// with whatever working directory the tool call had, which is often a
179/// subdirectory, and only the root has the `.git` entry.
180#[must_use]
181pub fn worktree_root(dir: &Path) -> Option<&Path> {
182    dir.ancestors().find(|d| d.join(".git").exists())
183}
184
185/// How `serve` should mount a UI. Precedence: `--ui dir` > embedded > `--no-ui`.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub enum ServeUi {
188    Filesystem(PathBuf),
189    Embedded,
190    None,
191}
192
193/// Algorithm subcommand for `mushroomdb algo`.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum AlgoSubcmd {
196    Pagerank,
197    Wcc,
198    Degree,
199    Communities,
200}
201
202/// Parsed `mushroomdb` invocation.
203///
204/// No `Eq` derive: `Algo { min_weight: Option<f64>, .. }` carries a float.
205#[derive(Debug, Clone, PartialEq)]
206pub enum Command {
207    /// A hook body 0.6 installed and 0.7 retired: `touch`, `intercept`,
208    /// `impact-hook` and `enrich` in the assistant's settings, `sync` in the
209    /// git hooks. A machine that upgraded without re-running `install` still
210    /// calls them, so each is accepted with any arguments, opens nothing, and
211    /// prints one stderr line saying how to remove it. Never in [`usage`].
212    RetiredHook {
213        sub: &'static str,
214    },
215    Serve {
216        db_dir: PathBuf,
217        addr: SocketAddr,
218        ui: ServeUi,
219        /// If the db dir is missing or empty, run [`run_demo`] before serving.
220        /// Docker's default CMD uses this so a fresh volume is ready on first boot.
221        demo_if_empty: bool,
222        /// Bearer token for non-loopback binds. Loopback may omit it.
223        token: Option<String>,
224        /// Role-bound tokens from `--role-token TOKEN:ROLE` flags.
225        /// Merged with `MUSHROOMDB_ROLE_TOKENS` env var in main before serving.
226        role_tokens: Vec<(String, String)>,
227        /// Periodic snapshot cadence. `None` = off (default).
228        snapshot_every: Option<Duration>,
229        /// Seed an empty `db_dir` from the newest backup under this directory
230        /// before opening it. See [`restore_if_empty`]. `None` = off (default).
231        restore_from: Option<PathBuf>,
232        /// Path to PEM certificate for native TLS (`--tls-cert`). Requires `--tls-key`.
233        tls_cert: Option<PathBuf>,
234        /// Path to PEM private key for native TLS (`--tls-key`). Requires `--tls-cert`.
235        tls_key: Option<PathBuf>,
236    },
237    Mcp {
238        /// `None` with `auto` set: resolved by [`resolve_auto_db`] at run time.
239        db_dir: Option<PathBuf>,
240        auto: bool,
241        /// `--all-tools`: advertise every served tool in `tools/list`
242        /// rather than the association tools every store lists by default. The rest are
243        /// callable either way; the flag decides what is listed, and what every
244        /// session pays for before its first turn.
245        all_tools: bool,
246    },
247    Stats {
248        db_dir: PathBuf,
249    },
250    Demo {
251        db_dir: PathBuf,
252    },
253    /// Read-only view of the database at a past commit.
254    AsOf {
255        db_dir: PathBuf,
256        /// 0-based WAL commit index to replay up to (inclusive).
257        ///
258        /// Exactly one of `commit` and `at` is set — the parser refuses both and
259        /// refuses neither, because a precedence rule between them is how a
260        /// caller ends up believing it asked for a date while the engine
261        /// answered a commit.
262        commit: Option<u64>,
263        /// RFC 3339 date to replay up to, resolved against the store's recorded
264        /// commit times once it is open.
265        at: Option<String>,
266        /// Optional Cypher read query to execute against the as-of view.
267        query: Option<String>,
268        /// Read only this namespace, as it was at that commit.
269        namespace: Option<String>,
270    },
271    /// Profile the database and suggest linking rules with estimated edge counts.
272    Suggest {
273        db_dir: PathBuf,
274    },
275    /// Run a graph algorithm (pagerank / wcc / degree / communities).
276    Algo {
277        db_dir: PathBuf,
278        subcmd: AlgoSubcmd,
279        /// Print only the top N results (0 = all).
280        top: usize,
281        /// Edge direction for degree/pagerank (`out` / `in` / `both`).
282        /// Ignored by `wcc` and `communities`, which are always undirected.
283        dir: AlgoDir,
284        /// `communities` only: restrict to the union of these edge types
285        /// (`--edge-type T`, repeatable). Empty means all edge types.
286        edge_types: Vec<String>,
287        /// `communities` only: edge property to read as the edge weight.
288        weight_prop: Option<String>,
289        /// `communities` only: drop edges below this resolved weight.
290        min_weight: Option<f64>,
291    },
292    /// Run a Cypher query (read or write).
293    Query {
294        db_dir: PathBuf,
295        /// Positional after dir (remaining args joined), or `--query`.
296        cypher: String,
297        /// Answer as one of the store's roles: only the nodes it may see.
298        role: Option<String>,
299        /// Answer from one namespace only. Intersects `role` — never widens it.
300        namespace: Option<String>,
301    },
302    /// Write `snapshot.bin`. The WAL is archived unless told otherwise.
303    Snapshot {
304        db_dir: PathBuf,
305        wal: WalDisposition,
306        /// Keep the newest N archives; prune oldest at snapshot time.
307        /// None = unlimited. Applies only when the WAL is archived.
308        retention: Option<u32>,
309    },
310    /// Drive any outstanding vector-index build to completion.
311    BuildIndex {
312        db_dir: PathBuf,
313        /// Build only this rule; `None` builds every pending one.
314        rule: Option<String>,
315    },
316    /// Apply a JSON schema file idempotently (`schema apply <db-dir> <schema.json>`),
317    /// the built-in memory schema (`schema apply <db-dir> --memory-defaults`), or
318    /// the identity preset (`schema apply <db-dir> --memory-identity`).
319    SchemaApply {
320        db_dir: PathBuf,
321        /// `None` when a built-in was named instead.
322        schema_file: Option<PathBuf>,
323        memory_defaults: bool,
324        memory_identity: bool,
325    },
326    /// Migrate an old-format snapshot to the current version and keep `.bak`.
327    Migrate {
328        db_dir: PathBuf,
329    },
330    /// Validate CRC32 integrity of every section in the V8 snapshot.
331    Verify {
332        db_dir: PathBuf,
333    },
334    /// Create a consistent, verified copy of the database directory.
335    Backup {
336        db_dir: PathBuf,
337        dest: PathBuf,
338    },
339    /// Export all nodes, edges, and rules to a destination directory.
340    Export {
341        db_dir: PathBuf,
342        dest: PathBuf,
343        format: ExportFormat,
344    },
345    /// Build (or incrementally sync) a graph of a git repository.
346    IngestGit {
347        db_dir: PathBuf,
348        opts: ingest_git::IngestGitOpts,
349    },
350    /// Wire the /mushroom skill and MCP server into Claude Code / Cursor.
351    Install(install::InstallOpts),
352    /// Undo what `install` wrote (manifest-driven).
353    Uninstall(install::InstallOpts),
354    /// Turn an install off without removing it: strips the hooks, the MCP
355    /// entry and the git hook blocks; the skill, the store and the
356    /// `.gitignore` line stay.
357    Disable(install::ToggleOpts),
358    /// Turn a disabled install back on, re-deriving the dynamic parts (the
359    /// resolved command, the hooks) rather than replaying stale ones.
360    Enable(install::ToggleOpts),
361    /// Verify an install end to end: config, store, hooks, and a real MCP handshake.
362    Doctor(doctor::DoctorOpts),
363    /// Body of the Claude Code UserPromptSubmit hook: reads a prompt payload on
364    /// stdin, prints related graph facts on stdout.
365    Recall {
366        db_dir: Option<PathBuf>,
367        auto: bool,
368    },
369    /// Body of the Claude Code SessionStart hook: prints the repository in one
370    /// byte-stable block, which the host caches for the whole session.
371    Brief {
372        db_dir: Option<PathBuf>,
373        auto: bool,
374    },
375    /// What links two nodes, with the evidence behind each link.
376    Why {
377        db_dir: PathBuf,
378        a: String,
379        b: String,
380    },
381    Version,
382    Help,
383}
384
385/// Outcome of [`run_demo`]. Counts are deterministic.
386#[derive(Debug)]
387pub struct DemoOutcome {
388    pub auto_fk_rules: Vec<String>,
389    pub sample_query: String,
390    pub sample_result: ResultSet,
391    pub explanations: Vec<Explanation>,
392    pub stats: Stats,
393    /// First suggestion from the rule suggester (teaser only — not auto-applied).
394    pub suggestion: Option<RuleSuggestion>,
395}
396
397/// CLI-facing error. [`Display`] is the message printed to stderr.
398#[derive(Debug)]
399pub struct CliError(pub String);
400
401impl std::fmt::Display for CliError {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        f.write_str(&self.0)
404    }
405}
406
407impl std::error::Error for CliError {}
408
409impl From<core_api::GraphError> for CliError {
410    fn from(e: core_api::GraphError) -> Self {
411        CliError(e.to_string())
412    }
413}
414
415impl From<std::io::Error> for CliError {
416    fn from(e: std::io::Error) -> Self {
417        CliError(e.to_string())
418    }
419}
420
421/// Usage text for no-args / `--help` / `-h`.
422pub fn usage() -> &'static str {
423    "\
424mushroomdb — embedded graph database
425
426Usage:
427  mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
428                     [--command <path>] [--no-prewarm] [--delivery cli|mcp|both]
429                     [--always-load|--no-always-load]
430                     --delivery cli writes the skill and the hooks and registers no MCP
431                     server: the skill teaches `mushroomdb <command>` instead (claude-code
432                     only; cursor and codex are always registered as MCP servers)
433                     --always-load marks the registered MCP server alwaysLoad, so the host
434                     keeps its tools in context instead of deferring them; already the
435                     default when --db names a store and a server is registered
436                     (--delivery mcp|both) — --no-always-load opts out
437  mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
438  mushroomdb disable [--platform claude-code|cursor|codex|all] [--project|--user]
439                     turn an install off without removing it: hooks and MCP entry are
440                     removed; the skill, the store and .gitignore stay
441  mushroomdb enable [--platform claude-code|cursor|codex|all] [--project|--user]
442                     turn a disabled install back on
443  mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
444                     verify an install: config entry, store, hooks, and a real stdio
445                     handshake with the configured MCP command; exits 1 on any `fail`
446  mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--role-token TOKEN:ROLE] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>] [--restore-from <dir>] [--tls-cert <pem> --tls-key <pem>]
447                     --restore-from seeds an empty <db-dir> from the newest backup under <dir>
448                     (or from <dir> itself if it is one); a no-op when <db-dir> already holds a store
449                     --tls-cert and --tls-key go together and need a build with the `tls` feature
450  mushroomdb mcp <db-dir>|--auto [--all-tools]
451                     --all-tools lists every served tool; the default lists the association tools on every store,
452                     a store `ingest-git` built included (the rest stay callable, just unlisted)
453  mushroomdb stats <db-dir>
454  mushroomdb demo <db-dir>
455  mushroomdb recall <db-dir>|--auto   hook body: reads a prompt payload on stdin, prints related graph facts
456  mushroomdb brief <db-dir>|--auto    hook body: the store's schema in one block — labels,
457                                      edge types, history depth, roles and one worked call per
458                                      question kind; byte-stable, so a session host caches it once
459  mushroomdb why <db-dir> <a> <b>
460                                   every rule edge between two keys with the evidence that
461                                   derived it, or a note that there is none
462  mushroomdb suggest <db-dir>
463  mushroomdb asof <db-dir> --commit N|--at <date> [--query \"MATCH ...\"] [--namespace <ns>]
464                     --at takes an RFC 3339 date (2026-06-19, or 2026-06-19T12:00:00Z)
465                     and resolves to the last commit at or before it; exactly one of
466                     --commit and --at
467  mushroomdb query <db-dir> [--query \"MATCH ...\"] [--role <name>] [--namespace <ns>] <cypher…>
468                     --role answers as one of the store's roles and --namespace from one
469                     namespace; together they intersect, so neither ever widens the other
470                     and either one makes the query a read
471  mushroomdb snapshot <db-dir> [--keep-wal|--truncate] [--retention N]
472                     folds the WAL into snapshot.bin and archives it as wal.<N>.archive,
473                     so node_history, edge_history, was_linked and asof keep reaching it;
474                     --truncate discards it instead, --keep-wal leaves wal.bin whole
475  mushroomdb build-index <db-dir> [--rule <name>]
476                     drives a rule's vector index to completion a slice at a time, for an
477                     operator who wants the build finished before traffic arrives; a rule
478                     created over a large corpus derives no edges until its index is whole
479  mushroomdb migrate <db-dir>
480  mushroomdb verify <db-dir>       validate CRC32 integrity of every snapshot section
481  mushroomdb backup <db-dir> <dest>   process-local consistent copy of the database to <dest>
482                                      WARNING: unsafe against a concurrently running serve process;
483                                      use POST /backup on the HTTP server for live-serve backups
484  mushroomdb export <db-dir> <dest> --format jsonl|parquet|graphml   export all data
485                                      graphml writes one file: <dest>/graph.graphml if <dest> is an
486                                      existing directory, otherwise <dest> is the file path itself
487                                      (nodes + edges only; rules have no GraphML analogue)
488  mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
489                        [--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
490                                   graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
491                                   --recurse-submodules also walks each initialised submodule
492                                   --prs links merged pull requests via gh (skipped when gh is unavailable)
493                                   --no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
494                                   --no-docs skips Markdown bodies, headings and mentions
495                                   --ensure-gitignore adds the database directory to the repo's .gitignore
496                                   with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
497  mushroomdb schema apply <db-dir> <schema.json>
498  mushroomdb schema apply <db-dir> --memory-defaults
499                                   applies the built-in memory schema to an existing store;
500                                   full-text on a populated store rebuilds the index at every open from here on
501  mushroomdb schema apply <db-dir> --memory-identity
502                                   adds the identity preset: sixteen SAME_AS rules over each entity's aliases;
503                                   says what it will backfill, then backfills
504  mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
505  mushroomdb algo wcc <db-dir> [--top N]
506  mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
507  mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
508  mushroomdb --version
509  mushroomdb --help
510
511Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
512install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
513the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
514--command names one (a relative --command or --db is anchored to the current directory).
515--no-prewarm means no network and no resolution: neither the one-off package fetch nor locating the
516package's binary, so every hook keeps the slower `npx` form.
517install and uninstall take out the code-graph hooks and git hook blocks a 0.6 install wrote.
518uninstall resolves the same scope and falls back to the other one when the inferred scope has no
519manifest; undoing a Codex install needs --platform codex.
520A project install inside a git checkout writes --auto rather than a store path, so each `git
521worktree` gets its own store; outside a checkout, and with --db, the store is pinned to an absolute
522path instead.
523--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else mushroom-memory at the
524root of the working tree the current directory is in, else ~/.mushroomdb/memory.
525"
526}
527
528fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
529    let mut platform: Option<install::Platform> = None;
530    let mut scope: Option<install::Scope> = None;
531    let mut db: Option<PathBuf> = None;
532    let mut command: Option<PathBuf> = None;
533    let mut prewarm = true;
534    let mut delivery = install::Delivery::default();
535    // Tri-state on purpose: `None` is "the user said nothing", which is the
536    // only case the default below is allowed to decide.
537    let mut always_load: Option<bool> = None;
538    let mut i = 0;
539    while i < args.len() {
540        let a = args[i];
541        if a == "--delivery" {
542            let val = args
543                .get(i + 1)
544                .copied()
545                .ok_or_else(|| "missing value for --delivery".to_string())?;
546            delivery = install::Delivery::parse(val)?;
547            i += 2;
548        } else if let Some(val) = a.strip_prefix("--delivery=") {
549            delivery = install::Delivery::parse(val)?;
550            i += 1;
551        } else if a == "--platform" {
552            let val = args
553                .get(i + 1)
554                .copied()
555                .ok_or_else(|| "missing value for --platform".to_string())?;
556            platform = Some(install::Platform::parse(val)?);
557            i += 2;
558        } else if let Some(val) = a.strip_prefix("--platform=") {
559            platform = Some(install::Platform::parse(val)?);
560            i += 1;
561        } else if a == "--project" || a == "--user" {
562            let want = if a == "--project" {
563                install::Scope::Project
564            } else {
565                install::Scope::User
566            };
567            // Two scopes name two different installs; picking one silently
568            // would put files somewhere the user did not ask for.
569            if scope.is_some_and(|s| s != want) {
570                return Err("--project and --user are mutually exclusive".to_string());
571            }
572            scope = Some(want);
573            i += 1;
574        } else if a == "--no-git-hooks" {
575            // Accepted and ignored: 0.7 writes no git hooks, so "none" is
576            // already what every install gets. Refusing it would break every
577            // script that passed it to a 0.6 install for the same result.
578            i += 1;
579        } else if a == "--intercept-grep" || a == "--impact-before-edit" || a == "--enrich-grep" {
580            return Err(format!(
581                "{a} was removed in 0.7 along with the code graph it fed. \
582                 Nothing replaces it. Pin `mushroomdb@0.6.x` to keep it."
583            ));
584        } else if a == "--always-load" {
585            always_load = Some(true);
586            i += 1;
587        } else if a == "--no-always-load" {
588            always_load = Some(false);
589            i += 1;
590        } else if a == "--no-prewarm" {
591            prewarm = false;
592            i += 1;
593        } else if a == "--command" {
594            let val = args
595                .get(i + 1)
596                .copied()
597                .ok_or_else(|| "missing value for --command".to_string())?;
598            command = Some(PathBuf::from(val));
599            i += 2;
600        } else if let Some(val) = a.strip_prefix("--command=") {
601            command = Some(PathBuf::from(val));
602            i += 1;
603        } else if a == "--db" {
604            let val = args
605                .get(i + 1)
606                .copied()
607                .ok_or_else(|| "missing value for --db".to_string())?;
608            db = Some(PathBuf::from(val));
609            i += 2;
610        } else if let Some(val) = a.strip_prefix("--db=") {
611            db = Some(PathBuf::from(val));
612            i += 1;
613        } else if a.starts_with('-') {
614            return Err(format!("unexpected flag: {a}"));
615        } else {
616            return Err(format!("unexpected argument: {a}"));
617        }
618    }
619    // An install that named its store with `--db` and registers a server is
620    // an entity-store install: the session is being pointed at a graph it
621    // could not otherwise find, and the first association run showed what a
622    // deferred tool list costs it — turns spent searching for the tools
623    // before the first question. So `alwaysLoad` is the default there, and
624    // `--no-always-load` is the way out.
625    //
626    // An install with no `--db` takes whatever store the working directory
627    // resolves to, which is usually the code graph: three tools, a skill that
628    // teaches them, and no discovery problem worth pinning context for. That
629    // one stays opt-in through `--always-load`.
630    let always_load =
631        always_load.unwrap_or(db.is_some() && !matches!(delivery, install::Delivery::Cli));
632    Ok(install::InstallOpts {
633        platform,
634        scope,
635        db,
636        command,
637        prewarm,
638        delivery,
639        always_load,
640    })
641}
642
643fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
644    let mut platform: Option<install::Platform> = None;
645    let mut scope: Option<install::Scope> = None;
646    let mut i = 0;
647    while i < args.len() {
648        let a = args[i];
649        if a == "--platform" {
650            let val = args
651                .get(i + 1)
652                .copied()
653                .ok_or_else(|| "missing value for --platform".to_string())?;
654            platform = Some(install::Platform::parse(val)?);
655            i += 2;
656        } else if let Some(val) = a.strip_prefix("--platform=") {
657            platform = Some(install::Platform::parse(val)?);
658            i += 1;
659        } else if a == "--project" || a == "--user" {
660            let want = if a == "--project" {
661                install::Scope::Project
662            } else {
663                install::Scope::User
664            };
665            if scope.is_some_and(|s| s != want) {
666                return Err("--project and --user are mutually exclusive".to_string());
667            }
668            scope = Some(want);
669            i += 1;
670        } else if a.starts_with('-') {
671            return Err(format!("unexpected flag: {a}"));
672        } else {
673            return Err(format!("unexpected argument: {a}"));
674        }
675    }
676    Ok(doctor::DoctorOpts { platform, scope })
677}
678
679/// Shared by `mushroomdb enable` and `mushroomdb disable`: the same
680/// `--platform` / `--project` / `--user` flags `doctor` takes, and nothing
681/// else — neither command chooses a store or a binary, so there is no `--db`
682/// or `--command` to parse.
683fn parse_toggle_cmd(args: &[&str]) -> Result<install::ToggleOpts, String> {
684    let mut platform: Option<install::Platform> = None;
685    let mut scope: Option<install::Scope> = None;
686    let mut i = 0;
687    while i < args.len() {
688        let a = args[i];
689        if a == "--platform" {
690            let val = args
691                .get(i + 1)
692                .copied()
693                .ok_or_else(|| "missing value for --platform".to_string())?;
694            platform = Some(install::Platform::parse(val)?);
695            i += 2;
696        } else if let Some(val) = a.strip_prefix("--platform=") {
697            platform = Some(install::Platform::parse(val)?);
698            i += 1;
699        } else if a == "--project" || a == "--user" {
700            let want = if a == "--project" {
701                install::Scope::Project
702            } else {
703                install::Scope::User
704            };
705            if scope.is_some_and(|s| s != want) {
706                return Err("--project and --user are mutually exclusive".to_string());
707            }
708            scope = Some(want);
709            i += 1;
710        } else if a.starts_with('-') {
711            return Err(format!("unexpected flag: {a}"));
712        } else {
713            return Err(format!("unexpected argument: {a}"));
714        }
715    }
716    Ok(install::ToggleOpts { platform, scope })
717}
718
719fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
720    let mut positional = Vec::new();
721    let mut exclude = Vec::new();
722    let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
723    let mut recurse_submodules = false;
724    let mut prs = false;
725    let mut structure = true;
726    let mut docs = true;
727    let mut ensure_gitignore = false;
728    let mut i = 0;
729    while i < args.len() {
730        let a = args[i];
731        if a == "--recurse-submodules" {
732            recurse_submodules = true;
733            i += 1;
734        } else if a == "--prs" {
735            prs = true;
736            i += 1;
737        } else if a == "--no-structure" {
738            structure = false;
739            i += 1;
740        } else if a == "--no-docs" {
741            docs = false;
742            i += 1;
743        } else if a == "--ensure-gitignore" {
744            ensure_gitignore = true;
745            i += 1;
746        } else if a == "--exclude" {
747            exclude.push(
748                args.get(i + 1)
749                    .copied()
750                    .ok_or_else(|| "missing value for --exclude".to_string())?
751                    .to_string(),
752            );
753            i += 2;
754        } else if let Some(val) = a.strip_prefix("--exclude=") {
755            exclude.push(val.to_string());
756            i += 1;
757        } else if a == "--max-commits-per-file" {
758            let val = args
759                .get(i + 1)
760                .copied()
761                .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
762            max_commits_per_file = val
763                .parse()
764                .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
765            i += 2;
766        } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
767            max_commits_per_file = val
768                .parse()
769                .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
770            i += 1;
771        } else if a.starts_with('-') {
772            return Err(format!("unexpected flag: {a}"));
773        } else {
774            positional.push(a);
775            i += 1;
776        }
777    }
778    let [db_dir, repo] = positional.as_slice() else {
779        return Err("ingest-git requires <db-dir> <repo-dir>".into());
780    };
781    // Structure ingest reads the working tree, where a repository carries
782    // build output and vendored dependencies that its history does not. A user
783    // who states any pattern of their own is taken to mean exactly that set.
784    if exclude.is_empty() {
785        exclude = ingest_git::DEFAULT_EXCLUDES
786            .iter()
787            .map(|p| (*p).to_string())
788            .collect();
789    }
790    Ok(Command::IngestGit {
791        db_dir: PathBuf::from(db_dir),
792        opts: ingest_git::IngestGitOpts {
793            repo: PathBuf::from(repo),
794            exclude,
795            max_commits_per_file,
796            recurse_submodules,
797            prs,
798            structure,
799            docs,
800            ensure_gitignore,
801        },
802    })
803}
804
805/// Parse argv after the binary name. Hand-rolled — no clap.
806pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
807    let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
808    if args.is_empty() {
809        return Ok(Command::Help);
810    }
811    match args[0] {
812        "--help" | "-h" | "help" => Ok(Command::Help),
813        "--version" | "-V" | "version" => Ok(Command::Version),
814        "serve" => parse_serve(&args[1..]),
815        "mcp" => parse_mcp(&args[1..]),
816        "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
817        "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
818        "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
819        "asof" => parse_asof(&args[1..]),
820        "algo" => parse_algo(&args[1..]),
821        "query" => parse_query(&args[1..]),
822        "snapshot" => parse_snapshot(&args[1..]),
823        "build-index" => parse_build_index(&args[1..]),
824        "schema" => parse_schema(&args[1..]),
825        "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
826        "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
827        "backup" => parse_backup(&args[1..]),
828        "export" => parse_export(&args[1..]),
829        "recall" => parse_dir_or_auto("recall", &args[1..])
830            .map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
831        "brief" => parse_dir_or_auto("brief", &args[1..])
832            .map(|(db_dir, auto)| Command::Brief { db_dir, auto }),
833        "why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
834            db_dir,
835            a: rest[0].clone(),
836            b: rest[1].clone(),
837        }),
838        "ingest-git" => parse_ingest_git(&args[1..]),
839        "install" => parse_install_cmd(&args[1..]).map(Command::Install),
840        "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
841        "disable" => parse_toggle_cmd(&args[1..]).map(Command::Disable),
842        "enable" => parse_toggle_cmd(&args[1..]).map(Command::Enable),
843        "doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
844        // Arguments are not parsed: whatever a 0.6 install wrote after the
845        // subcommand, the answer is the same.
846        "touch" => Ok(Command::RetiredHook { sub: "touch" }),
847        "intercept" => Ok(Command::RetiredHook { sub: "intercept" }),
848        "impact-hook" => Ok(Command::RetiredHook { sub: "impact-hook" }),
849        "enrich" => Ok(Command::RetiredHook { sub: "enrich" }),
850        "sync" => Ok(Command::RetiredHook { sub: "sync" }),
851        other => Err(format!("unknown command: {other}")),
852    }
853}
854
855fn default_addr() -> SocketAddr {
856    SocketAddr::from(([127, 0, 0, 1], 8080))
857}
858
859fn parse_serve(args: &[&str]) -> Result<Command, String> {
860    let mut db_dir = None;
861    let mut addr = default_addr();
862    let mut ui = ServeUi::Embedded;
863    let mut saw_ui = false;
864    let mut saw_no_ui = false;
865    let mut demo_if_empty = false;
866    let mut token = None;
867    let mut role_tokens: Vec<(String, String)> = Vec::new();
868    let mut snapshot_every = None;
869    let mut restore_from: Option<PathBuf> = None;
870    let mut tls_cert: Option<PathBuf> = None;
871    let mut tls_key: Option<PathBuf> = None;
872    let mut i = 0;
873    while i < args.len() {
874        let a = args[i];
875        if a == "--addr" {
876            let val = args
877                .get(i + 1)
878                .copied()
879                .ok_or_else(|| "missing value for --addr".to_string())?;
880            addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
881            i += 2;
882        } else if let Some(val) = a.strip_prefix("--addr=") {
883            addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
884            i += 1;
885        } else if a == "--ui" {
886            let val = args
887                .get(i + 1)
888                .copied()
889                .ok_or_else(|| "missing value for --ui".to_string())?;
890            ui = ServeUi::Filesystem(PathBuf::from(val));
891            saw_ui = true;
892            i += 2;
893        } else if let Some(val) = a.strip_prefix("--ui=") {
894            ui = ServeUi::Filesystem(PathBuf::from(val));
895            saw_ui = true;
896            i += 1;
897        } else if a == "--no-ui" {
898            ui = ServeUi::None;
899            saw_no_ui = true;
900            i += 1;
901        } else if a == "--demo-if-empty" {
902            demo_if_empty = true;
903            i += 1;
904        } else if a == "--token" {
905            let val = args
906                .get(i + 1)
907                .copied()
908                .ok_or_else(|| "missing value for --token".to_string())?;
909            token = Some(val.to_string());
910            i += 2;
911        } else if let Some(val) = a.strip_prefix("--token=") {
912            token = Some(val.to_string());
913            i += 1;
914        } else if a == "--role-token" {
915            let val = args
916                .get(i + 1)
917                .copied()
918                .ok_or_else(|| "missing value for --role-token".to_string())?;
919            let (tok, role) = parse_role_token(val)?;
920            role_tokens.push((tok, role));
921            i += 2;
922        } else if let Some(val) = a.strip_prefix("--role-token=") {
923            let (tok, role) = parse_role_token(val)?;
924            role_tokens.push((tok, role));
925            i += 1;
926        } else if a == "--snapshot-every" {
927            let val = args
928                .get(i + 1)
929                .copied()
930                .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
931            snapshot_every = Some(parse_snapshot_every(val)?);
932            i += 2;
933        } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
934            snapshot_every = Some(parse_snapshot_every(val)?);
935            i += 1;
936        } else if a == "--restore-from" {
937            let val = args
938                .get(i + 1)
939                .copied()
940                .ok_or_else(|| "missing value for --restore-from".to_string())?;
941            restore_from = Some(PathBuf::from(val));
942            i += 2;
943        } else if let Some(val) = a.strip_prefix("--restore-from=") {
944            restore_from = Some(PathBuf::from(val));
945            i += 1;
946        } else if a == "--tls-cert" {
947            let val = args
948                .get(i + 1)
949                .copied()
950                .ok_or_else(|| "missing value for --tls-cert".to_string())?;
951            tls_cert = Some(PathBuf::from(val));
952            i += 2;
953        } else if let Some(val) = a.strip_prefix("--tls-cert=") {
954            tls_cert = Some(PathBuf::from(val));
955            i += 1;
956        } else if a == "--tls-key" {
957            let val = args
958                .get(i + 1)
959                .copied()
960                .ok_or_else(|| "missing value for --tls-key".to_string())?;
961            tls_key = Some(PathBuf::from(val));
962            i += 2;
963        } else if let Some(val) = a.strip_prefix("--tls-key=") {
964            tls_key = Some(PathBuf::from(val));
965            i += 1;
966        } else if a.starts_with('-') {
967            return Err(format!("unexpected flag: {a}"));
968        } else if db_dir.is_none() {
969            db_dir = Some(PathBuf::from(a));
970            i += 1;
971        } else {
972            return Err(format!("unexpected extra argument: {a}"));
973        }
974    }
975    if saw_ui && saw_no_ui {
976        return Err("cannot combine --ui and --no-ui".to_string());
977    }
978    match (&tls_cert, &tls_key) {
979        (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
980        (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
981        _ => {}
982    }
983    let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
984    Ok(Command::Serve {
985        db_dir,
986        addr,
987        ui,
988        demo_if_empty,
989        token,
990        role_tokens,
991        snapshot_every,
992        restore_from,
993        tls_cert,
994        tls_key,
995    })
996}
997
998fn parse_role_token(val: &str) -> Result<(String, String), String> {
999    let (tok, role) = val
1000        .split_once(':')
1001        .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
1002    if tok.is_empty() {
1003        return Err("--role-token: TOKEN must not be empty".to_string());
1004    }
1005    if role.is_empty() {
1006        return Err("--role-token: ROLE must not be empty".to_string());
1007    }
1008    Ok((tok.to_string(), role.to_string()))
1009}
1010
1011fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
1012    let secs: u64 = val
1013        .parse()
1014        .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
1015    if secs == 0 {
1016        return Err("--snapshot-every must be a positive number of seconds".into());
1017    }
1018    Ok(Duration::from_secs(secs))
1019}
1020
1021/// `--ui <dir>` must be a directory that contains `index.html`.
1022pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
1023    if !dir.is_dir() {
1024        return Err(format!("--ui directory does not exist: {}", dir.display()));
1025    }
1026    let index = dir.join("index.html");
1027    if !index.is_file() {
1028        return Err(format!(
1029            "--ui directory is missing index.html: {}",
1030            dir.display()
1031        ));
1032    }
1033    Ok(dir.to_path_buf())
1034}
1035
1036fn parse_asof(args: &[&str]) -> Result<Command, String> {
1037    let mut db_dir = None;
1038    let mut commit: Option<u64> = None;
1039    let mut at: Option<String> = None;
1040    let mut query: Option<String> = None;
1041    let mut namespace: Option<String> = None;
1042    let mut i = 0;
1043    while i < args.len() {
1044        let a = args[i];
1045        if a == "--at" {
1046            let val = args
1047                .get(i + 1)
1048                .copied()
1049                .ok_or_else(|| "missing value for --at".to_string())?;
1050            at = Some(val.to_string());
1051            i += 2;
1052        } else if let Some(val) = a.strip_prefix("--at=") {
1053            at = Some(val.to_string());
1054            i += 1;
1055        } else if a == "--commit" {
1056            let val = args
1057                .get(i + 1)
1058                .copied()
1059                .ok_or_else(|| "missing value for --commit".to_string())?;
1060            commit = Some(
1061                val.parse()
1062                    .map_err(|_| format!("invalid commit index: {val}"))?,
1063            );
1064            i += 2;
1065        } else if let Some(val) = a.strip_prefix("--commit=") {
1066            commit = Some(
1067                val.parse()
1068                    .map_err(|_| format!("invalid commit index: {val}"))?,
1069            );
1070            i += 1;
1071        } else if a == "--query" {
1072            let val = args
1073                .get(i + 1)
1074                .copied()
1075                .ok_or_else(|| "missing value for --query".to_string())?;
1076            query = Some(val.to_string());
1077            i += 2;
1078        } else if let Some(val) = a.strip_prefix("--query=") {
1079            query = Some(val.to_string());
1080            i += 1;
1081        } else if a == "--namespace" {
1082            let val = args
1083                .get(i + 1)
1084                .copied()
1085                .ok_or_else(|| "missing value for --namespace".to_string())?;
1086            namespace = Some(val.to_string());
1087            i += 2;
1088        } else if let Some(val) = a.strip_prefix("--namespace=") {
1089            namespace = Some(val.to_string());
1090            i += 1;
1091        } else if a.starts_with('-') {
1092            return Err(format!("unexpected flag: {a}"));
1093        } else if db_dir.is_none() {
1094            db_dir = Some(PathBuf::from(a));
1095            i += 1;
1096        } else {
1097            return Err(format!("unexpected extra argument: {a}"));
1098        }
1099    }
1100    let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
1101    match (commit.is_some(), at.is_some()) {
1102        (false, false) => {
1103            return Err("asof requires --commit N or --at <date>".to_string());
1104        }
1105        (true, true) => {
1106            return Err(
1107                "asof takes --commit or --at, not both: a precedence rule between a \
1108                 commit index and a date is how a caller ends up believing it asked \
1109                 for one and got the other"
1110                    .to_string(),
1111            );
1112        }
1113        _ => {}
1114    }
1115    Ok(Command::AsOf {
1116        db_dir,
1117        commit,
1118        at,
1119        query,
1120        namespace,
1121    })
1122}
1123
1124/// Execute an as-of query at the given commit and print results.
1125///
1126/// `namespace` restricts the read to one namespace as it was at that commit —
1127/// a namespace cannot change, so that is simply the nodes which existed then and
1128/// are in it. A name no node uses answers with nothing, never with everything.
1129pub fn run_asof(
1130    db_dir: &Path,
1131    commit: Option<u64>,
1132    at: Option<&str>,
1133    query: Option<&str>,
1134    namespace: Option<&str>,
1135) -> Result<String, CliError> {
1136    let namespace = check_namespace(namespace)?;
1137    // A date has to be resolved against the store's recorded commit times, and
1138    // `open_at` needs the commit before it opens — so a dated call costs one
1139    // ordinary open first. The parser guarantees exactly one of the two is set.
1140    let commit = match (commit, at) {
1141        (Some(c), _) => c,
1142        (None, Some(date)) => GraphDb::open(db_dir)?.resolve_date(date)?,
1143        (None, None) => {
1144            return Err(CliError(
1145                "asof requires --commit N or --at <date>".to_string(),
1146            ))
1147        }
1148    };
1149    // Counts come off the opened handle: it knows the archives the live WAL no
1150    // longer holds, and where history now starts.
1151    let db = GraphDb::open_at(db_dir, commit)?;
1152    let total = db.wal_total_commits()?;
1153    let floor = db.wal_horizon_floor();
1154    let mut out = String::new();
1155    if floor == 0 {
1156        let _ = writeln!(out, "as-of commit {} of {}", commit, total);
1157    } else {
1158        let _ = writeln!(
1159            out,
1160            "as-of commit {} of {} (history reaches back to commit {})",
1161            commit, total, floor
1162        );
1163    }
1164    if let Some(cypher) = query {
1165        let params = BTreeMap::new();
1166        let rs = match namespace {
1167            Some(ns) => db.query_masked(cypher, &params, &db.mask_for_namespace(ns))?,
1168            None => db.query(cypher, &params)?,
1169        };
1170        out.push_str(&format_result_set(&rs));
1171    }
1172    Ok(out)
1173}
1174
1175/// A `--namespace` value, refused here rather than resolved to an empty mask: a
1176/// typo that silently answers "nothing" reads like an empty store.
1177fn check_namespace(namespace: Option<&str>) -> Result<Option<&str>, CliError> {
1178    match namespace {
1179        Some(ns) if !valid_namespace(ns) => Err(CliError(format!(
1180            "namespace {ns:?} is not a valid namespace name — 1 to {NS_MAX_LEN} characters of \
1181             [A-Za-z0-9_.-]"
1182        ))),
1183        other => Ok(other),
1184    }
1185}
1186
1187fn parse_query(args: &[&str]) -> Result<Command, String> {
1188    let mut db_dir = None;
1189    let mut query_flag: Option<String> = None;
1190    let mut role: Option<String> = None;
1191    let mut namespace: Option<String> = None;
1192    let mut cypher_parts: Vec<&str> = Vec::new();
1193    let mut i = 0;
1194    while i < args.len() {
1195        let a = args[i];
1196        if a == "--query" {
1197            let val = args
1198                .get(i + 1)
1199                .copied()
1200                .ok_or_else(|| "missing value for --query".to_string())?;
1201            query_flag = Some(val.to_string());
1202            i += 2;
1203        } else if let Some(val) = a.strip_prefix("--query=") {
1204            query_flag = Some(val.to_string());
1205            i += 1;
1206        } else if a == "--role" {
1207            let val = args
1208                .get(i + 1)
1209                .copied()
1210                .ok_or_else(|| "missing value for --role".to_string())?;
1211            role = Some(val.to_string());
1212            i += 2;
1213        } else if let Some(val) = a.strip_prefix("--role=") {
1214            role = Some(val.to_string());
1215            i += 1;
1216        } else if a == "--namespace" {
1217            let val = args
1218                .get(i + 1)
1219                .copied()
1220                .ok_or_else(|| "missing value for --namespace".to_string())?;
1221            namespace = Some(val.to_string());
1222            i += 2;
1223        } else if let Some(val) = a.strip_prefix("--namespace=") {
1224            namespace = Some(val.to_string());
1225            i += 1;
1226        } else if a.starts_with('-') {
1227            return Err(format!("unexpected flag: {a}"));
1228        } else if db_dir.is_none() {
1229            db_dir = Some(PathBuf::from(a));
1230            i += 1;
1231        } else {
1232            cypher_parts.push(a);
1233            i += 1;
1234        }
1235    }
1236    let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
1237    let cypher = if let Some(q) = query_flag {
1238        if !cypher_parts.is_empty() {
1239            return Err(
1240                "query: pass Cypher as remaining arguments or --query, not both".to_string(),
1241            );
1242        }
1243        q
1244    } else {
1245        if cypher_parts.is_empty() {
1246            return Err("query requires a Cypher string".to_string());
1247        }
1248        cypher_parts.join(" ")
1249    };
1250    Ok(Command::Query {
1251        db_dir,
1252        cypher,
1253        role,
1254        namespace,
1255    })
1256}
1257
1258/// Run a Cypher read or write and print columns/rows like [`run_asof`].
1259///
1260/// `role` answers as one of the store's roles and `namespace` from one
1261/// namespace; together they **intersect**, so a role bound to one namespace
1262/// never sees another and naming a namespace outside its binding answers with
1263/// nothing. Either one makes the query a read: a restricted write is refused.
1264pub fn run_query(
1265    db_dir: &Path,
1266    cypher: &str,
1267    role: Option<&str>,
1268    namespace: Option<&str>,
1269) -> Result<String, CliError> {
1270    let namespace = check_namespace(namespace)?;
1271    let params = BTreeMap::new();
1272    if role.is_some() || namespace.is_some() {
1273        let db = GraphDb::open(db_dir)?;
1274        // The same never-widen composition every other surface uses: one mask
1275        // per leg, intersected.
1276        let mask = match (role, namespace) {
1277            (Some(role), Some(ns)) => db
1278                .mask_for_role(role)?
1279                .intersect(&db.mask_for_namespace(ns)),
1280            (Some(role), None) => db.mask_for_role(role)?,
1281            (None, Some(ns)) => db.mask_for_namespace(ns),
1282            (None, None) => unreachable!("one of the two is Some in this branch"),
1283        };
1284        return Ok(format_result_set(&db.query_masked(cypher, &params, &mask)?));
1285    }
1286    let is_write = is_write_query(cypher).map_err(CliError)?;
1287    let rs = if is_write {
1288        let mut db = GraphDb::open(db_dir)?;
1289        db.query_write(cypher, &params)?
1290    } else {
1291        let db = GraphDb::open(db_dir)?;
1292        db.query(cypher, &params)?
1293    };
1294    Ok(format_result_set(&rs))
1295}
1296
1297fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
1298    let mut db_dir = None;
1299    // Archiving is the default: a snapshot the user did not ask to be
1300    // destructive should not cost them their history.
1301    let mut wal = WalDisposition::Archive;
1302    let mut retention: Option<u32> = None;
1303    let mut i = 0;
1304    while i < args.len() {
1305        let a = args[i];
1306        if a == "--keep-wal" {
1307            wal = WalDisposition::Keep;
1308            i += 1;
1309        } else if a == "--truncate" {
1310            wal = WalDisposition::Truncate;
1311            i += 1;
1312        } else if a == "--archive-wal" {
1313            // Kept for the callers that spelled the default out.
1314            wal = WalDisposition::Archive;
1315            i += 1;
1316        } else if a.starts_with("--retention=") {
1317            let v = a.trim_start_matches("--retention=");
1318            retention = Some(
1319                v.parse::<u32>()
1320                    .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
1321            );
1322            i += 1;
1323        } else if a == "--retention" {
1324            i += 1;
1325            let v = args
1326                .get(i)
1327                .ok_or_else(|| "--retention requires a value".to_string())?;
1328            retention = Some(
1329                v.parse::<u32>()
1330                    .map_err(|e| format!("--retention value error: {e}"))?,
1331            );
1332            i += 1;
1333        } else if a.starts_with('-') {
1334            return Err(format!("unexpected flag: {a}"));
1335        } else if db_dir.is_none() {
1336            db_dir = Some(PathBuf::from(a));
1337            i += 1;
1338        } else {
1339            return Err(format!("unexpected extra argument: {a}"));
1340        }
1341    }
1342    let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
1343    Ok(Command::Snapshot {
1344        db_dir,
1345        wal,
1346        retention,
1347    })
1348}
1349
1350/// Migrate the snapshot at `db_dir` to the current format version.
1351///
1352/// - If the snapshot is already at the current version, prints
1353///   `already current (V<N>)`.
1354/// - If the snapshot is an older version, writes `snapshot.bin.bak` (atomic +
1355///   fsynced) then performs a truncating snapshot at the current version, and
1356///   prints `migrated V<from> -> V<current>`.
1357/// - WAL-only stores (no snapshot) are treated as needing a fresh snapshot.
1358pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
1359    let current = core_api::SNAPSHOT_VERSION;
1360    let from_ver = core_api::snapshot_version_at(db_dir)?;
1361
1362    // `>=`, not `==`: a store that has opted in to multiplicity writes V10, a
1363    // version above the default this binary writes. It is already current —
1364    // rewriting it would produce another V10 snapshot and report "V10 -> V9".
1365    if let Some(ver) = from_ver {
1366        if ver >= current {
1367            return Ok(format!("already current (V{ver})\n"));
1368        }
1369    }
1370
1371    // Copy the original snapshot to .bak at OS level — no in-memory buffer
1372    // required for a 2+ GiB file.  The original snapshot.bin is authoritative
1373    // until snapshot_with's write_atomic (tmp+rename) succeeds, so a torn .bak
1374    // on crash is acceptable.
1375    if from_ver.is_some() {
1376        std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
1377    }
1378
1379    // Open with auto_migrate=false to avoid double-migration, then write
1380    // the truncating snapshot (CLI migrate always truncates the WAL).
1381    let mut db = GraphDb::open_with_options(
1382        db_dir,
1383        core_api::OpenOptions {
1384            auto_migrate: false,
1385            ..Default::default()
1386        },
1387    )?;
1388    db.snapshot()?;
1389
1390    let msg = match from_ver {
1391        Some(ver) => format!("migrated V{ver} -> V{current}\n"),
1392        None => format!("migrated WAL-only -> V{current}\n"),
1393    };
1394    Ok(msg)
1395}
1396
1397/// Validate the CRC32 integrity of every section in a V8 snapshot.
1398///
1399/// Exits with a non-zero code if any section is corrupt.  This is the
1400/// explicit integrity audit path; mushroomdb does NOT CRC-check large
1401/// sections on the hot query path (see format-stability.md).
1402pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
1403    // A store that has only ever been written via the WAL has no snapshot yet;
1404    // give an actionable message instead of a raw "No such file" io error.
1405    if !db_dir.join("snapshot.bin").exists() {
1406        return Err(CliError(format!(
1407            "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
1408            db_dir.display(),
1409            db_dir.display()
1410        )));
1411    }
1412    let results = core_api::verify_snapshot(db_dir)
1413        .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
1414    let mut any_fail = false;
1415    let mut out = String::new();
1416    for (id, section_name, byte_len, result) in &results {
1417        match result {
1418            Ok(()) => {
1419                let _ = writeln!(
1420                    out,
1421                    "  section {:2} ({:<12}) {:>10} bytes  OK",
1422                    id, section_name, byte_len
1423                );
1424            }
1425            Err(msg) => {
1426                let _ = writeln!(
1427                    out,
1428                    "  section {:2} ({:<12}) {:>10} bytes  CORRUPT: {msg}",
1429                    id, section_name, byte_len
1430                );
1431                any_fail = true;
1432            }
1433        }
1434    }
1435    if any_fail {
1436        Err(CliError(format!("integrity check FAILED:\n{out}")))
1437    } else {
1438        Ok(format!(
1439            "integrity check OK ({} sections):\n{out}",
1440            results.len()
1441        ))
1442    }
1443}
1444
1445/// What a snapshot does with the WAL it folds in.
1446#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1447pub enum WalDisposition {
1448    /// Move it to `wal.<N>.archive`, where the history reads still find it.
1449    /// The default, and what every automatic snapshot does: a store should not
1450    /// forget how it got here in exchange for opening faster.
1451    #[default]
1452    Archive,
1453    /// Leave `wal.bin` whole. Every pre-snapshot commit stays in the live WAL,
1454    /// and every open replays all of it.
1455    Keep,
1456    /// Drop it. The smallest directory and the fastest open, at the price of
1457    /// every commit before this point: `node_history`, `edge_history`,
1458    /// `was_linked` and `open_at` stop reaching them, and any archives an
1459    /// earlier snapshot left become unreachable too.
1460    Truncate,
1461}
1462
1463impl WalDisposition {
1464    fn options(self) -> SnapshotOptions {
1465        match self {
1466            WalDisposition::Archive => AUTOMATIC_SNAPSHOT,
1467            WalDisposition::Keep => SnapshotOptions {
1468                keep_wal: true,
1469                archive_wal: false,
1470            },
1471            WalDisposition::Truncate => SnapshotOptions {
1472                keep_wal: false,
1473                archive_wal: false,
1474            },
1475        }
1476    }
1477}
1478
1479/// Open `dir` and write `snapshot.bin`, archiving the WAL by default.
1480pub fn run_snapshot(
1481    db_dir: &Path,
1482    wal: WalDisposition,
1483    retention: Option<u32>,
1484) -> Result<String, CliError> {
1485    let mut db = GraphDb::open(db_dir)?;
1486    if wal == WalDisposition::Archive {
1487        db.set_wal_archive_retention(retention);
1488    }
1489    db.snapshot_with(wal.options())?;
1490    Ok(format!(
1491        "snapshot written: {}\n",
1492        db_dir.join("snapshot.bin").display()
1493    ))
1494}
1495
1496fn parse_build_index(args: &[&str]) -> Result<Command, String> {
1497    let mut db_dir: Option<PathBuf> = None;
1498    let mut rule: Option<String> = None;
1499    let mut i = 0;
1500    while i < args.len() {
1501        let a = args[i];
1502        if let Some(v) = a.strip_prefix("--rule=") {
1503            if v.is_empty() {
1504                return Err("--rule requires a value".to_string());
1505            }
1506            rule = Some(v.to_string());
1507            i += 1;
1508        } else if a == "--rule" {
1509            i += 1;
1510            let v = args
1511                .get(i)
1512                .ok_or_else(|| "--rule requires a value".to_string())?;
1513            rule = Some((*v).to_string());
1514            i += 1;
1515        } else if a.starts_with('-') {
1516            return Err(format!("unexpected flag: {a}"));
1517        } else if db_dir.is_none() {
1518            db_dir = Some(PathBuf::from(a));
1519            i += 1;
1520        } else {
1521            return Err(format!("unexpected extra argument: {a}"));
1522        }
1523    }
1524    let db_dir = db_dir.ok_or_else(|| "build-index requires <db-dir>".to_string())?;
1525    Ok(Command::BuildIndex { db_dir, rule })
1526}
1527
1528/// Pump every outstanding vector-index build to completion, one slice per
1529/// call, printing a line per slice.
1530///
1531/// `rule` narrows the report to one rule; the pump itself always advances every
1532/// pending build, because they share one write lock and splitting them would
1533/// only mean holding it more often.
1534pub fn run_build_index(db_dir: &Path, rule: Option<&str>) -> Result<String, CliError> {
1535    let mut db = GraphDb::open(db_dir)?;
1536    build_index_on(&mut db, rule)
1537}
1538
1539/// [`run_build_index`] against an already-open handle.
1540///
1541/// Terminates: every pump advances each pending build's cursor by at least one
1542/// node (the slice size is never zero), so the outstanding list empties.
1543///
1544/// Exposed for tests that need a build this handle itself deferred. Whether a
1545/// build is outstanding is decided when the rule is created, so a test using a
1546/// reduced slice size has to keep the handle that created the rule; reopening
1547/// replays `CreateRule` at the production slice size. A build a *snapshot* cut
1548/// short needs no such care — the reopen recognises it, which is what
1549/// [`run_build_index`] relies on.
1550pub fn build_index_on(db: &mut GraphDb<RealFs>, rule: Option<&str>) -> Result<String, CliError> {
1551    let mut out = String::new();
1552    let interesting = |name: &str| rule.is_none_or(|r| r == name);
1553    // Read before pumping, so that "no such rule" and "that rule is already
1554    // built" can be told apart in the empty case below.
1555    let known_rules: Vec<String> = db.stats().rules.iter().map(|r| r.name.clone()).collect();
1556
1557    // Pump before looking. Open registers a build a snapshot cut short, but a
1558    // resumed build can still be registered and finished inside a single call,
1559    // so the outstanding list alone cannot show that anything happened.
1560    loop {
1561        let (finished, outstanding) = db.pump_index_build_reporting()?;
1562        for b in &outstanding {
1563            if interesting(&b.rule) {
1564                out.push_str(&format!("building {}: {}/{}\n", b.rule, b.indexed, b.total));
1565            }
1566        }
1567        // Reported from the pump rather than inferred from a shrinking
1568        // outstanding list: a resumed build is registered and finished inside a
1569        // single call, so it never appears in that list at all.
1570        for b in &finished {
1571            if interesting(&b.rule) {
1572                out.push_str(&format!("built {}: {} vectors\n", b.rule, b.total));
1573            }
1574        }
1575        if outstanding.is_empty() {
1576            break;
1577        }
1578    }
1579    if out.is_empty() {
1580        match rule {
1581            // Distinguish the two ways this can be empty, because they need
1582            // different things from the operator: a finished build is fine, a
1583            // name that is not a rule is a typo.
1584            Some(r) if !known_rules.iter().any(|k| k == r) => out.push_str(&format!(
1585                "no rule named {r:?} in this store; nothing to build\n"
1586            )),
1587            Some(r) => out.push_str(&format!("nothing to build for rule {r:?}\n")),
1588            None => out.push_str("nothing to build\n"),
1589        }
1590    }
1591    Ok(out)
1592}
1593
1594fn parse_schema(args: &[&str]) -> Result<Command, String> {
1595    if args.is_empty() {
1596        return Err("schema requires a subcommand: apply".to_string());
1597    }
1598    match args[0] {
1599        "apply" => parse_schema_apply(&args[1..]),
1600        other => Err(format!(
1601            "unknown schema subcommand: {other}; expected apply"
1602        )),
1603    }
1604}
1605
1606fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
1607    let mut db_dir = None;
1608    let mut schema_file = None;
1609    let mut memory_defaults = false;
1610    let mut memory_identity = false;
1611    for a in args {
1612        if *a == "--memory-defaults" {
1613            memory_defaults = true;
1614        } else if *a == "--memory-identity" {
1615            memory_identity = true;
1616        } else if a.starts_with('-') {
1617            return Err(format!("unexpected flag: {a}"));
1618        } else if db_dir.is_none() {
1619            db_dir = Some(PathBuf::from(*a));
1620        } else if schema_file.is_none() {
1621            schema_file = Some(PathBuf::from(*a));
1622        } else {
1623            return Err(format!("unexpected extra argument: {a}"));
1624        }
1625    }
1626    let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
1627    if memory_identity {
1628        if memory_defaults || schema_file.is_some() {
1629            return Err(
1630                "schema apply --memory-identity takes no schema file and no other preset"
1631                    .to_string(),
1632            );
1633        }
1634        return Ok(Command::SchemaApply {
1635            db_dir,
1636            schema_file: None,
1637            memory_defaults: false,
1638            memory_identity: true,
1639        });
1640    }
1641    if memory_defaults {
1642        if schema_file.is_some() {
1643            return Err("schema apply --memory-defaults takes no schema file argument".to_string());
1644        }
1645        return Ok(Command::SchemaApply {
1646            db_dir,
1647            schema_file: None,
1648            memory_defaults: true,
1649            memory_identity: false,
1650        });
1651    }
1652    let schema_file =
1653        schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
1654    Ok(Command::SchemaApply {
1655        db_dir,
1656        schema_file: Some(schema_file),
1657        memory_defaults: false,
1658        memory_identity: false,
1659    })
1660}
1661
1662/// Read `schema_file`, open `db_dir`, apply the schema, and return the diff
1663/// as one line per entry: `"created rule:x"`, `"updated view:y"`, etc.
1664pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
1665    let json = std::fs::read_to_string(schema_file)
1666        .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
1667    let schema: Schema = serde_json::from_str(&json).map_err(|e| {
1668        CliError(format!(
1669            "invalid schema JSON in {}: {e}",
1670            schema_file.display()
1671        ))
1672    })?;
1673    let mut db = GraphDb::open(db_dir)?;
1674    let diff = db.apply_schema(&schema)?;
1675    let mut out = String::new();
1676    for entry in &diff.created {
1677        let _ = writeln!(out, "created {entry}");
1678    }
1679    for entry in &diff.updated {
1680        let _ = writeln!(out, "updated {entry}");
1681    }
1682    for entry in &diff.unchanged {
1683        let _ = writeln!(out, "unchanged {entry}");
1684    }
1685    if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
1686        let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
1687    }
1688    Ok(out)
1689}
1690
1691/// `mushroomdb schema apply <db> --memory-defaults`
1692///
1693/// Applies `core_api::memory_schema::memory_defaults()`. Kept separate from
1694/// `run_schema_apply` because it takes no schema file, and separate from
1695/// `open` because declaring full-text on a populated store rebuilds the index
1696/// at open — the caller is told what it will cost before it happens.
1697pub fn run_schema_apply_memory_defaults(db_dir: &Path) -> Result<String, CliError> {
1698    let mut db = GraphDb::open(db_dir)?;
1699    let before = db.stats();
1700    let diff = db.apply_schema(&core_api::memory_schema::memory_defaults())?;
1701    let mut out = String::new();
1702    if diff.created.is_empty() && diff.updated.is_empty() {
1703        let _ = writeln!(out, "schema apply: already current, nothing to do");
1704        return Ok(out);
1705    }
1706    let _ = writeln!(
1707        out,
1708        "schema apply: {} created, {} updated, {} unchanged",
1709        diff.created.len(),
1710        diff.updated.len(),
1711        diff.unchanged.len()
1712    );
1713    for name in diff.created.iter().chain(diff.updated.iter()) {
1714        let _ = writeln!(out, "  {name}");
1715    }
1716    let _ = writeln!(
1717        out,
1718        "note: {} live nodes are now full-text indexed; the index rebuilds at \
1719         every open from here on.",
1720        before.nodes_live
1721    );
1722    Ok(out)
1723}
1724
1725/// `mushroomdb schema apply <db> --memory-identity`
1726///
1727/// Applies `core_api::memory_schema::memory_identity()` — opt-in, because a
1728/// store never gains a rule silently. What it will do is measured and stated
1729/// first, in the same output, before anything is written: how many entity
1730/// nodes the rules will compare, and how many need an `aliases` list written
1731/// before they can. Then the lists are written in one commit and the rules
1732/// created, each with its own backfill.
1733///
1734/// `aliases` is what a node's key and name imply. A node that already carries
1735/// items those do not imply — a user's own `aliases` property from before
1736/// 0.7 — keeps them: the same commit moves them to `alias_keys`, the list the
1737/// five claim rules read, and the output says how many nodes that touched.
1738/// Nothing is dropped. On a store that already carries the preset's
1739/// earlier eleven rules, this adds the five claim rules and leaves the rest
1740/// unchanged.
1741pub fn run_schema_apply_memory_identity(db_dir: &Path) -> Result<String, CliError> {
1742    use core_api::memory::identity::{
1743        aliases_to_backfill, entity_node_count, write_aliases, ALIASES_FIELD, ALIAS_KEYS_FIELD,
1744        SAME_AS_EDGE,
1745    };
1746    let mut db = GraphDb::open(db_dir)?;
1747    let preset = core_api::memory_schema::memory_identity();
1748    let entity_nodes = entity_node_count(&db);
1749    let backfill = aliases_to_backfill(&db)?;
1750    let mut out = String::new();
1751    let _ = writeln!(
1752        out,
1753        "identity preset: {} SAME_AS rules over each entity's aliases, global — they link \
1754         across namespaces",
1755        preset.rules.len()
1756    );
1757    let writes = |field: &str| backfill.iter().filter(|node| node.sets(field)).count();
1758    let _ = writeln!(
1759        out,
1760        "before applying: {entity_nodes} entity node(s); {} need an aliases list written \
1761         first, from the key and the name; each rule then compares aliases across its labels \
1762         once, and every later write to an entity is re-checked",
1763        writes(ALIASES_FIELD)
1764    );
1765    let moved = writes(ALIAS_KEYS_FIELD);
1766    if moved == 0 {
1767        let _ = writeln!(
1768            out,
1769            "alias_keys: nothing to move — it holds only declared aliases; an entity that \
1770             declares one equal to a provisional stub's key links that stub"
1771        );
1772    } else {
1773        let _ = writeln!(
1774            out,
1775            "alias_keys: {moved} node(s) carry aliases their key and name do not imply; those \
1776             are kept, moved to alias_keys as declared aliases — one equal to a provisional \
1777             stub's key links that stub"
1778        );
1779    }
1780    // Two commits: the aliases, then the rules. A failure between them leaves
1781    // the lists written and no rule; a re-run recovers, since the backfill is
1782    // idempotent and finds nothing left to write.
1783    write_aliases(&mut db, &backfill)?;
1784    let diff = db.apply_schema(&preset)?;
1785    let _ = writeln!(
1786        out,
1787        "schema apply: {} created, {} updated, {} unchanged",
1788        diff.created.len(),
1789        diff.updated.len(),
1790        diff.unchanged.len()
1791    );
1792    // Edges are directed and a same-label rule derives both directions, so
1793    // the pair count is the number of identities claimed.
1794    let edges = db.weighted_edges(SAME_AS_EDGE, None);
1795    let pairs: std::collections::BTreeSet<(&str, &str)> = edges
1796        .iter()
1797        .map(|(a, b, _)| {
1798            if a <= b {
1799                (a.as_str(), b.as_str())
1800            } else {
1801                (b.as_str(), a.as_str())
1802            }
1803        })
1804        .collect();
1805    let _ = writeln!(
1806        out,
1807        "SAME_AS edges now: {} ({} pair(s))",
1808        edges.len(),
1809        pairs.len()
1810    );
1811    Ok(out)
1812}
1813
1814fn parse_backup(args: &[&str]) -> Result<Command, String> {
1815    let mut db_dir = None;
1816    let mut dest = None;
1817    for a in args {
1818        if a.starts_with('-') {
1819            return Err(format!("unexpected flag: {a}"));
1820        }
1821        if db_dir.is_none() {
1822            db_dir = Some(PathBuf::from(*a));
1823        } else if dest.is_none() {
1824            dest = Some(PathBuf::from(*a));
1825        } else {
1826            return Err(format!("unexpected extra argument: {a}"));
1827        }
1828    }
1829    let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
1830    let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
1831    Ok(Command::Backup { db_dir, dest })
1832}
1833
1834fn parse_export(args: &[&str]) -> Result<Command, String> {
1835    let mut db_dir = None;
1836    let mut dest = None;
1837    let mut format = ExportFormat::Jsonl;
1838    let mut i = 0;
1839    while i < args.len() {
1840        let a = args[i];
1841        if a == "--format" {
1842            let val = args
1843                .get(i + 1)
1844                .copied()
1845                .ok_or_else(|| "missing value for --format".to_string())?;
1846            format = ExportFormat::parse(val).ok_or_else(|| {
1847                format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1848            })?;
1849            i += 2;
1850        } else if let Some(val) = a.strip_prefix("--format=") {
1851            format = ExportFormat::parse(val).ok_or_else(|| {
1852                format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1853            })?;
1854            i += 1;
1855        } else if a.starts_with('-') {
1856            return Err(format!("unexpected flag: {a}"));
1857        } else if db_dir.is_none() {
1858            db_dir = Some(PathBuf::from(a));
1859            i += 1;
1860        } else if dest.is_none() {
1861            dest = Some(PathBuf::from(a));
1862            i += 1;
1863        } else {
1864            return Err(format!("unexpected extra argument: {a}"));
1865        }
1866    }
1867    let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
1868    let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
1869    Ok(Command::Export {
1870        db_dir,
1871        dest,
1872        format,
1873    })
1874}
1875
1876/// Create a consistent, verified backup of `db_dir` to `dest`.
1877pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
1878    let db = GraphDb::open(db_dir)?;
1879    Ok(db.backup_to(dest)?)
1880}
1881
1882/// [`core_api::restore::restore_if_empty`], in the CLI's error type.
1883///
1884/// The implementation moved down into `core-api` in v0.6.10 so the Python
1885/// binding could reach it without taking a dependency on this crate; the
1886/// semantics, the messages and [`RestoreOutcome`] are unchanged. The engine
1887/// carries the message inside [`core_api::GraphError::Io`], whose own `Display`
1888/// adds a prefix — unwrapped here, so `serve` prints what it always printed.
1889pub fn restore_if_empty(db_dir: &Path, from: &Path) -> Result<RestoreOutcome, CliError> {
1890    core_api::restore::restore_if_empty(db_dir, from).map_err(|e| match e {
1891        core_api::GraphError::Io(io) => CliError(io.to_string()),
1892        other => CliError(other.to_string()),
1893    })
1894}
1895
1896/// Format a [`BackupReport`] for display.
1897pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
1898    let mut out = String::new();
1899    writeln!(out, "backup to: {}", dest.display()).unwrap();
1900    writeln!(out, "  files: {}", report.files.join(", ")).unwrap();
1901    writeln!(out, "  bytes: {}", report.bytes).unwrap();
1902    writeln!(out, "  verified: {}", report.verified).unwrap();
1903    out
1904}
1905
1906/// Export all data from `db_dir` to `dest` in `format`.
1907pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
1908    let db = GraphDb::open(db_dir)?;
1909    let nodes = db.all_nodes_for_export();
1910    let edges = db.all_edges_for_export();
1911    let mut rules = db.rules();
1912    rules.sort_by(|a, b| a.name.cmp(&b.name));
1913    let node_count = nodes.len();
1914    let edge_count = edges.len();
1915    let rule_count = rules.len();
1916    match format {
1917        ExportFormat::Jsonl => {
1918            export::write_jsonl(&nodes, &edges, &rules, dest)?;
1919            Ok(format!(
1920                "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1921                dest.display(),
1922                format.name(),
1923                node_count,
1924                edge_count,
1925                rule_count
1926            ))
1927        }
1928        ExportFormat::Parquet => {
1929            export::write_parquet(&nodes, &edges, &rules, dest)?;
1930            Ok(format!(
1931                "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1932                dest.display(),
1933                format.name(),
1934                node_count,
1935                edge_count,
1936                rule_count
1937            ))
1938        }
1939        // GraphML has no rule analogue: only nodes and edges are written.
1940        ExportFormat::Graphml => {
1941            let file_path = export::write_graphml(&nodes, &edges, dest)?;
1942            Ok(format!(
1943                "exported to {} (format={}): {} nodes, {} edges\n",
1944                file_path.display(),
1945                format.name(),
1946                node_count,
1947                edge_count,
1948            ))
1949        }
1950    }
1951}
1952
1953fn format_result_set(rs: &ResultSet) -> String {
1954    let mut out = String::new();
1955    let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
1956    for i in 0..rs.len() {
1957        let cells: Vec<String> = rs
1958            .columns()
1959            .iter()
1960            .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
1961            .collect();
1962        let _ = writeln!(out, "  {}", cells.join("  "));
1963    }
1964    out
1965}
1966
1967fn parse_algo(args: &[&str]) -> Result<Command, String> {
1968    if args.is_empty() {
1969        return Err(
1970            "algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
1971        );
1972    }
1973    let subcmd = match args[0] {
1974        "pagerank" => AlgoSubcmd::Pagerank,
1975        "wcc" => AlgoSubcmd::Wcc,
1976        "degree" => AlgoSubcmd::Degree,
1977        "communities" => AlgoSubcmd::Communities,
1978        other => {
1979            return Err(format!(
1980                "unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
1981            ))
1982        }
1983    };
1984    let rest = &args[1..];
1985    let mut db_dir = None;
1986    let mut top: usize = 20;
1987    let mut dir = AlgoDir::Both;
1988    let mut edge_types: Vec<String> = Vec::new();
1989    let mut weight_prop: Option<String> = None;
1990    let mut min_weight: Option<f64> = None;
1991    let mut i = 0;
1992    while i < rest.len() {
1993        let a = rest[i];
1994        if a == "--top" {
1995            let val = rest
1996                .get(i + 1)
1997                .copied()
1998                .ok_or_else(|| "missing value for --top".to_string())?;
1999            top = val
2000                .parse()
2001                .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
2002            i += 2;
2003        } else if let Some(val) = a.strip_prefix("--top=") {
2004            top = val
2005                .parse()
2006                .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
2007            i += 1;
2008        } else if a == "--dir" {
2009            let val = rest
2010                .get(i + 1)
2011                .copied()
2012                .ok_or_else(|| "missing value for --dir".to_string())?;
2013            dir = parse_algo_dir(val)?;
2014            i += 2;
2015        } else if let Some(val) = a.strip_prefix("--dir=") {
2016            dir = parse_algo_dir(val)?;
2017            i += 1;
2018        } else if a == "--edge-type" {
2019            let val = rest
2020                .get(i + 1)
2021                .copied()
2022                .ok_or_else(|| "missing value for --edge-type".to_string())?;
2023            edge_types.push(val.to_string());
2024            i += 2;
2025        } else if let Some(val) = a.strip_prefix("--edge-type=") {
2026            edge_types.push(val.to_string());
2027            i += 1;
2028        } else if a == "--weight-prop" {
2029            let val = rest
2030                .get(i + 1)
2031                .copied()
2032                .ok_or_else(|| "missing value for --weight-prop".to_string())?;
2033            weight_prop = Some(val.to_string());
2034            i += 2;
2035        } else if let Some(val) = a.strip_prefix("--weight-prop=") {
2036            weight_prop = Some(val.to_string());
2037            i += 1;
2038        } else if a == "--min-weight" {
2039            let val = rest
2040                .get(i + 1)
2041                .copied()
2042                .ok_or_else(|| "missing value for --min-weight".to_string())?;
2043            min_weight = Some(
2044                val.parse()
2045                    .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
2046            );
2047            i += 2;
2048        } else if let Some(val) = a.strip_prefix("--min-weight=") {
2049            min_weight = Some(
2050                val.parse()
2051                    .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
2052            );
2053            i += 1;
2054        } else if a.starts_with('-') {
2055            return Err(format!("unexpected flag: {a}"));
2056        } else if db_dir.is_none() {
2057            db_dir = Some(PathBuf::from(a));
2058            i += 1;
2059        } else {
2060            return Err(format!("unexpected extra argument: {a}"));
2061        }
2062    }
2063    let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
2064    Ok(Command::Algo {
2065        db_dir,
2066        subcmd,
2067        top,
2068        dir,
2069        edge_types,
2070        weight_prop,
2071        min_weight,
2072    })
2073}
2074
2075/// Parse the `--dir` value for `algo` into an [`AlgoDir`].
2076fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
2077    match val.to_ascii_lowercase().as_str() {
2078        "out" => Ok(AlgoDir::Out),
2079        "in" => Ok(AlgoDir::In),
2080        "both" => Ok(AlgoDir::Both),
2081        other => Err(format!("--dir must be one of out | in | both, got {other}")),
2082    }
2083}
2084
2085/// Body of `mushroomdb brief <db-dir>|--auto`, the `SessionStart` hook.
2086///
2087/// Byte-stable for a given store: the host caches this output for the whole
2088/// session, so two prompts of the same session must not disagree about what
2089/// the repository is. Opened read-only like every other question.
2090pub fn run_brief(db_dir: &Path) -> Result<String, CliError> {
2091    let db = open_for_reading(db_dir)?;
2092    let reach = reach_line(db_dir);
2093    let b = core_api::memory::brief::brief(&db, &core_api::memory::brief::BriefOptions::default());
2094    let mut text = core_api::memory::brief::render(&b, &reach);
2095    // An empty store's line names how to fill it, and `render` cannot know
2096    // which doors this install opened. A cli-only install gets the shell form.
2097    if text == core_api::memory::brief::EMPTY_BRIEF
2098        && matches!(install::delivery_for_store(db_dir), install::Delivery::Cli)
2099    {
2100        text = empty_brief_cli(db_dir);
2101    }
2102    // Said here because a brief is computed once per session and cached,
2103    // where the prompt hook fires every turn. A 0.6.x store upgraded to 0.7
2104    // has no text index until someone asks for one, and a user who never
2105    // calls the `recall` *tool* would otherwise never learn why the hook went
2106    // quiet.
2107    //
2108    // Placed above the reach line rather than after it: a brief ends with how
2109    // to reach the graph, and the tests holding that line read it as the last.
2110    // An empty store's brief has no reach line, so there it simply ends.
2111    //
2112    // The path is quoted and sanitized as the reach line's is: quoted so a
2113    // space or a quote in it stays one argument, sanitized because this line
2114    // is inserted after `render` sanitized everything else.
2115    //
2116    // A store with one text field can still be missing the memory defaults:
2117    // `remember` declares `Note.text` as it writes, so a store that never took
2118    // them stops having "no text index" at its first note, while the entities
2119    // already in it stay unsearchable by name. That store gets the second
2120    // notice, naming the fields. A store with no node under a memory label
2121    // gets neither: the defaults would index nothing there.
2122    let store = core_api::digest::sanitize(&install::sh_quote(&db_dir.to_string_lossy()));
2123    let notice = if db.fulltext_pairs().is_empty() {
2124        Some(format!(
2125            "no text index: recall cannot match anything here. \
2126             Run: mushroomdb schema apply {store} --memory-defaults\n"
2127        ))
2128    } else {
2129        let unindexed = core_api::memory::recall::unindexed_memory_fields(&db);
2130        (!unindexed.is_empty()).then(|| {
2131            format!(
2132                "text index incomplete: recall does not search {}. \
2133                 Run: mushroomdb schema apply {store} --memory-defaults\n",
2134                core_api::memory::recall::unindexed_fields_list(&unindexed)
2135            )
2136        })
2137    };
2138    if let Some(notice) = notice {
2139        let at = text
2140            .rfind("\nreach the graph: ")
2141            .map_or(text.len(), |i| i + 1);
2142        text.insert_str(at, &notice);
2143    }
2144    Ok(text)
2145}
2146
2147/// How a Cypher statement is typed at a shell, in the words every place that
2148/// prints a shell `query` uses: this brief's reach line, the CLI-only
2149/// empty-store line, and the skill's CLI table.
2150///
2151/// The statement is one double-quoted argument because the engine's Cypher
2152/// takes single-quoted strings only. A shell expands `$` and a backtick inside
2153/// double quotes, so a fact holding either would run a command on its way in;
2154/// the rule is in words rather than the characters themselves so it reads the
2155/// same in a Markdown table as in a plain line.
2156const SHELL_QUOTING_RULE: &str = "single-quote Cypher strings; inside the double quotes \
2157                                  backslash every dollar sign, double quote and backtick";
2158
2159/// The brief's last line: how to reach the graph from this session.
2160///
2161/// Two doors, because a session may have either one open — the MCP tool, and
2162/// the same question typed at a shell. The command names the binary the way
2163/// `install` would resolve it right now, which is the same resolution the
2164/// hooks themselves were written with, and it names the store, because both
2165/// tools take one.
2166///
2167/// **Every MCP name here has to be one `tools/list` actually lists.** A
2168/// session can only call what its client was shown, so naming a tool it
2169/// cannot see would be worse than naming none — which is also why a `cli`
2170/// install, registering no server at all, gets the shell form alone. Every
2171/// store is served [`server::ASSOCIATION_TOOLS`], one written by `ingest-git`
2172/// included, so every store is reached through `explain_association` and
2173/// `query`, which is where its entities are.
2174/// `the_reach_line_names_only_tools_its_surface_lists` holds this line and
2175/// that list in step.
2176///
2177/// The shell half names `query` alone. `mushroomdb why <db> <a> <b>` is
2178/// `explain_association`'s shell door, but it answers one pair, and `query`
2179/// answers that question and every other the brief's recipes ask, a Cypher
2180/// read away.
2181fn reach_line(db_dir: &Path) -> String {
2182    let bin = install::detect_mcp_command(None).shell();
2183    let db = install::sh_quote(&db_dir.to_string_lossy());
2184    let tools = format!(
2185        "explain_association <a> <b>{sep}query '<cypher>' (MCP tools; add role: <name> \
2186         or namespace: <ns> to narrow what it sees){sep}or:",
2187        sep = core_api::digest::SEP
2188    );
2189    let shell = format!("{bin} query {db} \"<cypher>\" ({SHELL_QUOTING_RULE})");
2190    match install::delivery_for_store(db_dir) {
2191        install::Delivery::Cli => shell,
2192        _ => format!("{tools} {shell}"),
2193    }
2194}
2195
2196/// An empty store's brief for an install that registered no MCP server.
2197///
2198/// [`core_api::memory::brief::EMPTY_BRIEF`] offers `remember`,
2199/// `upsert_entity` and `ingest_json`, which are MCP tools; a `--delivery cli`
2200/// session has a shell and nothing else. This is the same offer in the one
2201/// form that session can act on — a `query` with a `CREATE`, the write the
2202/// skill's CLI table names, with the `id:` property a `CREATE` needs.
2203///
2204/// The statement is in double quotes for the shell because the engine's
2205/// Cypher takes single-quoted strings only: the line has to run as a shell
2206/// would run it, and
2207/// `an_empty_store_brief_on_a_cli_delivery_install_names_no_mcp_tool` runs
2208/// it, on a store path with a space and a quote in it. The fact a session
2209/// fills in is arbitrary text inside those double quotes, so the line ends
2210/// with [`SHELL_QUOTING_RULE`].
2211///
2212/// The id and the text are placeholders, as the skill's `note:…` is. A
2213/// literal id would be the same key on every paste, and the key is the node.
2214fn empty_brief_cli(db_dir: &Path) -> String {
2215    // Sanitized as the reach line is: `render` returned a constant for this
2216    // store, so nothing downstream sanitizes what is built here.
2217    let bin = core_api::digest::sanitize(&install::detect_mcp_command(None).shell());
2218    let db = core_api::digest::sanitize(&install::sh_quote(&db_dir.to_string_lossy()));
2219    format!(
2220        "mushroomdb brief — empty store; offer to fill it: {bin} query {db} \
2221         \"CREATE (n:Note {{id: 'note:<fresh-id>', text: '<the fact>'}})\" writes a fact; \
2222         fill in both, with an id no note has yet; {SHELL_QUOTING_RULE}\n"
2223    )
2224}
2225
2226/// Open a store the way every question about it is asked: read-only, with both
2227/// write paths off, so asking never migrates a snapshot, rewrites a torn WAL
2228/// tail, or makes a writer wait on the cross-process lock.
2229///
2230/// A directory that is not there is an error rather than an empty store:
2231/// `RealFs::new` runs `create_dir_all`, so without this guard a `brief` hook
2232/// left behind by an uninstall — or any read of a mistyped path — would create
2233/// the very store it then reports as empty. The same guard `run_recall` opens
2234/// behind.
2235fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
2236    if !db_dir.exists() {
2237        return Err(CliError(format!("no store at {}", db_dir.display())));
2238    }
2239    Ok(GraphDb::open_with_options(
2240        db_dir,
2241        core_api::OpenOptions {
2242            auto_migrate: false,
2243            repair_wal: false,
2244            read_only: true,
2245        },
2246    )?)
2247}
2248
2249/// Body of `mushroomdb why <db> <a> <b>`: every rule edge between two keys
2250/// with the evidence that derived it, or an honest note that there is none.
2251/// A key the store does not hold is an error (`node key not found: <key>`),
2252/// not an empty answer.
2253///
2254/// The reply is what the `explain_association` tool sends, framing line
2255/// included: the rule names and matched values are graph content.
2256pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
2257    let db = open_for_reading(db_dir)?;
2258    let explained = core_api::explain_digest::explain_with_evidence(&db, a, b)?;
2259    Ok(format!(
2260        "{}{}",
2261        core_api::digest::UNTRUSTED_FRAMING,
2262        core_api::explain_digest::render_explain(a, b, &explained)
2263    ))
2264}
2265
2266/// Run a graph algorithm and return a formatted string.
2267///
2268/// `dir` selects the edge direction for `degree` and `pagerank`; `wcc` and
2269/// `communities` are always undirected and ignore it. `edge_types` /
2270/// `weight_prop` / `min_weight` are used by `communities` only.
2271#[allow(clippy::too_many_arguments)]
2272pub fn run_algo(
2273    db_dir: &Path,
2274    subcmd: &AlgoSubcmd,
2275    top: usize,
2276    dir: AlgoDir,
2277    edge_types: Vec<String>,
2278    weight_prop: Option<String>,
2279    min_weight: Option<f64>,
2280) -> Result<String, CliError> {
2281    let db = GraphDb::open(db_dir)?;
2282    match subcmd {
2283        AlgoSubcmd::Pagerank => {
2284            let config = PageRankConfig {
2285                direction: dir,
2286                ..PageRankConfig::default()
2287            };
2288            let report = db.pagerank(&config);
2289            Ok(format_pagerank(&report, top))
2290        }
2291        AlgoSubcmd::Wcc => {
2292            let config = WccConfig::default();
2293            let report = db.connected_components(&config);
2294            Ok(format_wcc(&report, top))
2295        }
2296        AlgoSubcmd::Degree => {
2297            let config = DegreeConfig {
2298                direction: dir,
2299                ..DegreeConfig::default()
2300            };
2301            let report = db.degree_centrality(&config);
2302            Ok(format_degree(&report, top))
2303        }
2304        AlgoSubcmd::Communities => {
2305            let config = LouvainConfig {
2306                edge_types,
2307                weight_prop,
2308                min_weight,
2309                ..LouvainConfig::default()
2310            };
2311            let report = db.communities(&config);
2312            Ok(format_communities(&report, top))
2313        }
2314    }
2315}
2316
2317fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
2318    let mut buf = String::new();
2319    let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
2320    let rows = if top == 0 {
2321        report.scores.as_slice()
2322    } else {
2323        &report.scores[..top.min(report.scores.len())]
2324    };
2325    for (i, (key, score)) in rows.iter().enumerate() {
2326        let _ = writeln!(buf, "  {:>4}  {:<40}  {:.6}", i + 1, key, score);
2327    }
2328    buf
2329}
2330
2331fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
2332    let mut buf = String::new();
2333    let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
2334    let rows = if top == 0 {
2335        report.components.as_slice()
2336    } else {
2337        &report.components[..top.min(report.components.len())]
2338    };
2339    for (key, comp_id) in rows {
2340        let _ = writeln!(buf, "  {:<40}  component={}", key, comp_id);
2341    }
2342    buf
2343}
2344
2345fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
2346    let mut buf = String::new();
2347    let _ = writeln!(
2348        buf,
2349        "== degree centrality (truncated={}) ==",
2350        report.truncated
2351    );
2352    let rows = if top == 0 {
2353        report.scores.as_slice()
2354    } else {
2355        &report.scores[..top.min(report.scores.len())]
2356    };
2357    for (i, (key, deg)) in rows.iter().enumerate() {
2358        let _ = writeln!(buf, "  {:>4}  {:<40}  degree={}", i + 1, key, deg);
2359    }
2360    buf
2361}
2362
2363/// One line per community: id, size, cohesion, first 3 members.
2364/// Prints `(truncated)` in the header when the time budget fired.
2365fn format_communities(report: &core_api::CommunityReport, top: usize) -> String {
2366    let mut buf = String::new();
2367    let trunc = if report.truncated { " (truncated)" } else { "" };
2368    let _ = writeln!(
2369        buf,
2370        "== communities (modularity={:.2}){trunc} ==",
2371        report.modularity
2372    );
2373    let rows = if top == 0 {
2374        report.communities.as_slice()
2375    } else {
2376        &report.communities[..top.min(report.communities.len())]
2377    };
2378    for c in rows {
2379        let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
2380        let _ = writeln!(
2381            buf,
2382            "  {:>4}  size={:<6} cohesion={:<6.2} members=[{}]",
2383            c.id,
2384            c.members.len(),
2385            c.cohesion,
2386            preview.join(", ")
2387        );
2388    }
2389    buf
2390}
2391
2392/// `<db-dir>` or `--auto`, for the commands a hook line invokes.
2393///
2394/// Exactly one of the two: `--auto` says "work it out from the environment",
2395/// which a stated path contradicts rather than refines.
2396fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
2397    let mut db_dir = None;
2398    let mut auto = false;
2399    for a in args {
2400        if *a == "--auto" {
2401            auto = true;
2402        } else if a.starts_with('-') {
2403            return Err(format!("unexpected flag: {a}"));
2404        } else if db_dir.is_some() {
2405            return Err(format!("unexpected extra argument: {a}"));
2406        } else {
2407            db_dir = Some(PathBuf::from(*a));
2408        }
2409    }
2410    match (&db_dir, auto) {
2411        (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
2412        (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
2413        _ => Ok((db_dir, auto)),
2414    }
2415}
2416
2417/// `mcp [<db-dir>|--auto] [--all-tools]`. Every other flag is
2418/// [`parse_dir_or_auto`]'s to reject, so `--all-tools` is stripped here and
2419/// the rest of the line parses exactly as `recall`'s does.
2420fn parse_mcp(args: &[&str]) -> Result<Command, String> {
2421    let all_tools = args.contains(&"--all-tools");
2422    let rest: Vec<&str> = args
2423        .iter()
2424        .copied()
2425        .filter(|a| *a != "--all-tools")
2426        .collect();
2427    parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
2428        db_dir,
2429        auto,
2430        all_tools,
2431    })
2432}
2433
2434/// `<db-dir>` followed by between `min` and `max` further arguments, none of
2435/// which may look like a flag.
2436///
2437/// The graph tools take keys — paths and symbol names — and a key beginning
2438/// with `-` is far more likely to be a typo'd flag than a file called `-x`, so
2439/// it is refused rather than looked up and reported missing.
2440fn parse_positional(
2441    cmd: &str,
2442    args: &[&str],
2443    min: usize,
2444    max: usize,
2445) -> Result<(PathBuf, Vec<String>), String> {
2446    let mut rest: Vec<String> = Vec::new();
2447    let mut db_dir: Option<PathBuf> = None;
2448    for a in args {
2449        if a.starts_with('-') {
2450            return Err(format!("unexpected flag: {a}"));
2451        }
2452        match db_dir {
2453            None => db_dir = Some(PathBuf::from(*a)),
2454            Some(_) => rest.push((*a).to_string()),
2455        }
2456    }
2457    let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
2458    if rest.len() < min {
2459        return Err(format!(
2460            "{cmd} requires <db-dir> and {min} more argument{}",
2461            if min == 1 { "" } else { "s" }
2462        ));
2463    }
2464    if rest.len() > max {
2465        return Err(format!("unexpected extra argument: {}", rest[max]));
2466    }
2467    Ok((db_dir, rest))
2468}
2469
2470fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
2471    let mut db_dir = None;
2472    for a in args {
2473        if a.starts_with('-') {
2474            return Err(format!("unexpected flag: {a}"));
2475        }
2476        if db_dir.is_some() {
2477            return Err(format!("unexpected extra argument: {a}"));
2478        }
2479        db_dir = Some(PathBuf::from(*a));
2480    }
2481    db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
2482}
2483
2484/// Pretty-print [`Stats`] for `mushroomdb stats` and the demo smoke test.
2485pub fn format_stats(stats: &Stats) -> String {
2486    let mut out = String::new();
2487    let _ = writeln!(
2488        out,
2489        "nodes: {} live, {} tombstoned",
2490        stats.nodes_live, stats.nodes_tombstoned
2491    );
2492    let _ = writeln!(out, "edges: {}", stats.edges);
2493    if stats.history_floor == 0 {
2494        let _ = writeln!(out, "history: complete (nothing pruned)");
2495    } else {
2496        let _ = writeln!(
2497            out,
2498            "history: reaches back to commit {}",
2499            stats.history_floor
2500        );
2501    }
2502    // A store that names no namespace is one implicit `default` namespace, and
2503    // saying so would be noise on every single-tenant store — so the line
2504    // appears only once there is more than one, which keeps existing output
2505    // byte-identical.
2506    if stats.namespaces.len() > 1 {
2507        let names: Vec<String> = stats
2508            .namespaces
2509            .iter()
2510            .map(|n| format!("{} ({})", n.name, n.nodes_live))
2511            .collect();
2512        let _ = writeln!(out, "namespaces: {}", names.join(", "));
2513    }
2514    let _ = writeln!(out, "rules: {}", stats.rules.len());
2515    for r in &stats.rules {
2516        let _ = writeln!(
2517            out,
2518            "  {:<28} edges={}  tripped={}",
2519            r.name, r.edges, r.tripped
2520        );
2521    }
2522    out
2523}
2524
2525/// Open `dir` and return live stats.
2526pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
2527    let db = SharedDb::open(dir)?;
2528    let stats = db.read().stats();
2529    Ok(stats)
2530}
2531
2532/// Build the deterministic demo dataset in an empty `dir`.
2533///
2534/// Refuses if `dir` already exists and is not empty. Ingests 10 Orgs, 20
2535/// Projects, 30 People via [`SharedDb`] / `ingest_json` (auto-FK on `*_id`)
2536/// then declares `skill_fit` plus the three Predicates II rules.
2537pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
2538    refuse_non_empty(dir)?;
2539
2540    let db = SharedDb::open(dir)?;
2541    let opts = IngestOptions::default();
2542    let mut auto_fk_rules = Vec::new();
2543
2544    {
2545        let mut w = db.write();
2546        // A demo store is the first store most readers open. Declare the
2547        // memory schema before the first write so `recall` can answer on it.
2548        w.apply_schema(&core_api::memory_schema::memory_defaults())?;
2549        for (label, json) in [
2550            ("Org", org_json()),
2551            ("Project", project_json()),
2552            ("Person", person_json()),
2553        ] {
2554            let report = w.ingest_json(label, &json, &opts)?;
2555            if !report.row_errors.is_empty() {
2556                return Err(CliError(format!(
2557                    "demo ingest of {label} had row errors: {:?}",
2558                    report.row_errors
2559                )));
2560            }
2561            auto_fk_rules.extend(report.rules_created);
2562        }
2563        let skill_fit = Predicate::Overlap {
2564            field: "skills".into(),
2565            min: 0.5,
2566        };
2567        let skill_fit_k = Some(default_max_edges(&skill_fit));
2568        w.create_rule(RuleDef {
2569            name: "skill_fit".into(),
2570            src_label: "Person".into(),
2571            dst_label: "Project".into(),
2572            predicate: skill_fit,
2573            edge_type: "FIT".into(),
2574            weight_prop: Some("score".into()),
2575            max_edges: skill_fit_k,
2576            approximate: false,
2577            via_label: None,
2578            via_edge: None,
2579            via_dir: None,
2580            namespace: None,
2581        })?;
2582        let founded_within = Predicate::NumericWithin {
2583            field: "founded_year".into(),
2584            tolerance: 2.0,
2585        };
2586        let founded_within_k = Some(default_max_edges(&founded_within));
2587        w.create_rule(RuleDef {
2588            name: "founded_within".into(),
2589            src_label: "Org".into(),
2590            dst_label: "Org".into(),
2591            predicate: founded_within,
2592            edge_type: "FOUNDED_WITHIN".into(),
2593            weight_prop: Some("score".into()),
2594            max_edges: founded_within_k,
2595            approximate: false,
2596            via_label: None,
2597            via_edge: None,
2598            via_dir: None,
2599            namespace: None,
2600        })?;
2601        let nearby_office = Predicate::GeoRadius {
2602            field: "office".into(),
2603            km: 50.0,
2604        };
2605        let nearby_office_k = Some(default_max_edges(&nearby_office));
2606        w.create_rule(RuleDef {
2607            name: "nearby_office".into(),
2608            src_label: "Org".into(),
2609            dst_label: "Org".into(),
2610            predicate: nearby_office,
2611            edge_type: "NEARBY_OFFICE".into(),
2612            weight_prop: Some("score".into()),
2613            max_edges: nearby_office_k,
2614            approximate: false,
2615            via_label: None,
2616            via_edge: None,
2617            via_dir: None,
2618            namespace: None,
2619        })?;
2620        let similar_interests = Predicate::VectorSimilar {
2621            field: "embedding".into(),
2622            min: 0.8,
2623        };
2624        let similar_interests_k = Some(default_max_edges(&similar_interests));
2625        w.create_rule(RuleDef {
2626            name: "similar_interests".into(),
2627            src_label: "Person".into(),
2628            dst_label: "Person".into(),
2629            predicate: similar_interests,
2630            edge_type: "SIMILAR".into(),
2631            weight_prop: Some("score".into()),
2632            max_edges: similar_interests_k,
2633            approximate: false,
2634            via_label: None,
2635            via_edge: None,
2636            via_dir: None,
2637            namespace: None,
2638        })?;
2639    }
2640
2641    let r = db.read();
2642    let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
2643    let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
2644    let stats = r.stats();
2645    // Rule suggestion teaser: first suggestion sorted by est_edges desc.
2646    let suggestion = r.suggest_rules().into_iter().next();
2647
2648    Ok(DemoOutcome {
2649        auto_fk_rules,
2650        sample_query: SAMPLE_QUERY.to_string(),
2651        sample_result,
2652        explanations,
2653        stats,
2654        suggestion,
2655    })
2656}
2657
2658fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
2659    if dir.is_file() {
2660        return Err(CliError(format!(
2661            "demo refuses a non-empty directory: {} is a file",
2662            dir.display()
2663        )));
2664    }
2665    if !dir.exists() {
2666        return Ok(true);
2667    }
2668    Ok(std::fs::read_dir(dir)?.next().is_none())
2669}
2670
2671fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
2672    if dir_is_empty_or_absent(dir)? {
2673        Ok(())
2674    } else {
2675        Err(CliError(format!(
2676            "demo refuses a non-empty directory: {} \
2677             (directory must be empty — including hidden files)",
2678            dir.display()
2679        )))
2680    }
2681}
2682
2683/// Run [`run_demo`] when `dir` is missing or empty; otherwise leave it alone.
2684pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
2685    if dir_is_empty_or_absent(dir)? {
2686        Ok(Some(run_demo(dir)?))
2687    } else {
2688        Ok(None)
2689    }
2690}
2691
2692fn json_array(rows: impl IntoIterator<Item = String>) -> String {
2693    let mut out = String::from("[");
2694    let mut first = true;
2695    for row in rows {
2696        if !first {
2697            out.push(',');
2698        }
2699        first = false;
2700        out.push_str(&row);
2701    }
2702    out.push(']');
2703    out
2704}
2705
2706/// Wrap a 1-based project index into `1..=N_PROJECTS`.
2707fn wrap_proj(i: usize) -> usize {
2708    (i - 1) % N_PROJECTS + 1
2709}
2710
2711/// Sliding window of `len` skill tokens starting at project `start`.
2712fn skill_window_json(start: usize, len: usize) -> String {
2713    let parts: Vec<String> = (0..len)
2714        .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
2715        .collect();
2716    format!("[{}]", parts.join(","))
2717}
2718
2719/// Real city [lat, lon] for org `i` (1-based). Four clusters sit inside 50 km:
2720/// NYC / Jersey City / Newark, SF / Oakland / Berkeley, London / Greenwich,
2721/// Paris / Versailles.
2722fn org_office(i: usize) -> (f64, f64) {
2723    match i {
2724        1 => (40.7128, -74.0060),  // New York
2725        2 => (48.8566, 2.3522),    // Paris
2726        3 => (51.5074, -0.1278),   // London
2727        4 => (37.7749, -122.4194), // San Francisco
2728        5 => (37.8044, -122.2711), // Oakland
2729        6 => (37.8715, -122.2730), // Berkeley
2730        7 => (40.7178, -74.0431),  // Jersey City
2731        8 => (51.4769, 0.0005),    // Greenwich
2732        9 => (48.8014, 2.1301),    // Versailles
2733        10 => (40.7357, -74.1724), // Newark
2734        _ => unreachable!("demo orgs are 1..=10"),
2735    }
2736}
2737
2738/// Dim-8 embedding for person `i`. Groups of three share a unit axis (cos = 1);
2739/// two extra groups are (0.8, 0.6, …) and (0.6, 0.8, …) so cos = 0.8 / 0.96
2740/// against the first two axes is hand-checkable.
2741fn person_embedding_json(i: usize) -> String {
2742    let mut v = [0.0_f64; 8];
2743    match i {
2744        9 | 19 | 29 => {
2745            v[0] = 0.8;
2746            v[1] = 0.6;
2747        }
2748        10 | 20 | 30 => {
2749            v[0] = 0.6;
2750            v[1] = 0.8;
2751        }
2752        _ => {
2753            let axis = (i - 1) % 10;
2754            debug_assert!(axis < 8);
2755            v[axis] = 1.0;
2756        }
2757    }
2758    let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
2759    format!("[{}]", parts.join(","))
2760}
2761
2762fn org_json() -> String {
2763    json_array((1..=N_ORGS).map(|i| {
2764        let year = 2010 + (i as i64 - 1);
2765        let (lat, lon) = org_office(i);
2766        format!(
2767            r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
2768            skill_window_json(i, 3)
2769        )
2770    }))
2771}
2772
2773fn project_json() -> String {
2774    json_array((1..=N_PROJECTS).map(|i| {
2775        let org = (i - 1) % N_ORGS + 1;
2776        format!(
2777            r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
2778            skill_window_json(i, 3)
2779        )
2780    }))
2781}
2782
2783fn person_json() -> String {
2784    json_array((1..=N_PEOPLE).map(|i| {
2785        let org = (i - 1) % N_ORGS + 1;
2786        let proj = (i - 1) % N_PROJECTS + 1;
2787        format!(
2788            r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
2789            person_embedding_json(i),
2790            skill_window_json(proj, 3)
2791        )
2792    }))
2793}
2794
2795/// Render a [`DemoOutcome`] the way `mushroomdb demo` prints it.
2796pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
2797    let mut buf = String::new();
2798    let _ = writeln!(buf, "== demo ==");
2799    let _ = writeln!(
2800        buf,
2801        "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
2802    );
2803    let _ = writeln!(
2804        buf,
2805        "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
2806    );
2807    let _ = writeln!(
2808        buf,
2809        "numeric rule: founded_within (Org.founded_year, tolerance 2)"
2810    );
2811    let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
2812    let _ = writeln!(
2813        buf,
2814        "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
2815    );
2816    let _ = writeln!(buf);
2817    let _ = writeln!(buf, "== auto-FK rules ==");
2818    let mut names = out.auto_fk_rules.clone();
2819    names.sort();
2820    for name in names {
2821        let _ = writeln!(buf, "  {name}");
2822    }
2823    let _ = writeln!(buf);
2824    let _ = writeln!(buf, "== query ==");
2825    let _ = writeln!(buf, "{}", out.sample_query);
2826    let _ = writeln!(buf);
2827    let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
2828    for i in 0..out.sample_result.len() {
2829        let cells: Vec<String> = out
2830            .sample_result
2831            .columns()
2832            .iter()
2833            .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
2834            .collect();
2835        let _ = writeln!(buf, "  {}", cells.join("  "));
2836    }
2837    let _ = writeln!(buf);
2838    let _ = writeln!(
2839        buf,
2840        "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
2841    );
2842    for e in &out.explanations {
2843        let weight = e
2844            .weight
2845            .map(|w| fmt_value(&Value::Float(w)))
2846            .unwrap_or_else(|| "none".into());
2847        let _ = writeln!(
2848            buf,
2849            "  rule={}  type={}  {}→{}  weight={}",
2850            e.rule, e.edge_type, e.src_key, e.dst_key, weight
2851        );
2852    }
2853    let _ = writeln!(buf);
2854    let _ = writeln!(buf, "== serve ==");
2855    let _ = writeln!(buf, "  mushroomdb serve {}", dir.display());
2856
2857    // Teaser: one suggestion from the rule suggester (not auto-applied).
2858    if let Some(s) = &out.suggestion {
2859        let _ = writeln!(buf);
2860        let _ = writeln!(buf, "== suggested rule (teaser) ==");
2861        let _ = writeln!(buf, "  {}", s.def.name);
2862        let _ = writeln!(
2863            buf,
2864            "  {} → {} via {:?}",
2865            s.def.src_label, s.def.dst_label, s.def.predicate
2866        );
2867        let _ = writeln!(buf, "  est_edges: ~{}", s.est_edges);
2868        let _ = writeln!(buf, "  {}", s.rationale);
2869        let _ = writeln!(
2870            buf,
2871            "  (run `mushroomdb suggest {}` for full analysis)",
2872            dir.display()
2873        );
2874    }
2875
2876    buf
2877}
2878
2879/// Profile the database at `dir` and return all rule suggestions.
2880pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
2881    let db = GraphDb::open(dir)?;
2882    Ok(db.suggest_rules())
2883}
2884
2885/// Pretty-print a list of [`RuleSuggestion`]s for `mushroomdb suggest`.
2886pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
2887    let mut buf = String::new();
2888    if suggestions.is_empty() {
2889        let _ = writeln!(
2890            buf,
2891            "no rule suggestions (database may be empty or rules already cover all patterns)"
2892        );
2893        return buf;
2894    }
2895    let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
2896    for (i, s) in suggestions.iter().enumerate() {
2897        let _ = writeln!(buf);
2898        let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
2899        let _ = writeln!(
2900            buf,
2901            "    {} → {}  via {:?}",
2902            s.def.src_label, s.def.dst_label, s.def.predicate
2903        );
2904        let _ = writeln!(buf, "    est_edges : ~{}", s.est_edges);
2905        let _ = writeln!(buf, "    rationale : {}", s.rationale);
2906        if !s.examples.is_empty() {
2907            let _ = writeln!(buf, "    examples  :");
2908            for (src, dst, score) in &s.examples {
2909                let _ = writeln!(buf, "      {src} → {dst}  score={score:.4}");
2910            }
2911        }
2912        let _ = writeln!(buf, "    predicate : {:?}", s.def.predicate);
2913        let _ = writeln!(
2914            buf,
2915            "    to apply  : POST /rules  or  db.create_rule(suggestion.def)"
2916        );
2917    }
2918    buf
2919}
2920
2921fn fmt_value(v: &Value) -> String {
2922    match v {
2923        Value::Int(i) => i.to_string(),
2924        Value::Float(f) => {
2925            let s = format!("{f}");
2926            if s.contains('.') || s.contains('e') || s.contains('E') {
2927                s
2928            } else {
2929                format!("{s}.0")
2930            }
2931        }
2932        Value::Str(s) => s.clone(),
2933        Value::Bool(b) => b.to_string(),
2934        Value::List(xs) => {
2935            let inner: Vec<String> = xs.iter().map(fmt_value).collect();
2936            format!("[{}]", inner.join(", "))
2937        }
2938        Value::Map(m) => {
2939            let inner: Vec<String> = m
2940                .iter()
2941                .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
2942                .collect();
2943            format!("{{{}}}", inner.join(", "))
2944        }
2945    }
2946}
2947
2948fn fmt_cell(cell: Option<&Value>) -> String {
2949    match cell {
2950        None => "null".into(),
2951        Some(v) => fmt_value(v),
2952    }
2953}
2954
2955#[cfg(test)]
2956mod tests {
2957    use super::*;
2958    use std::collections::BTreeSet;
2959    use std::net::SocketAddr;
2960    use std::path::PathBuf;
2961
2962    fn tmp(name: &str) -> PathBuf {
2963        let nanos = std::time::SystemTime::now()
2964            .duration_since(std::time::UNIX_EPOCH)
2965            .expect("clock")
2966            .as_nanos();
2967        let d = std::env::temp_dir().join(format!(
2968            "graphdb-cli-{}-{}-{}",
2969            name,
2970            std::process::id(),
2971            nanos
2972        ));
2973        let _ = std::fs::remove_dir_all(&d);
2974        d
2975    }
2976
2977    fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
2978        let g = db.read();
2979        let mut out = BTreeSet::new();
2980        for i in 1..=N_ORGS {
2981            let src = format!("org-{i:02}");
2982            if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2983                for dst in nbrs {
2984                    out.insert((src.clone(), dst));
2985                }
2986            }
2987        }
2988        for i in 1..=N_PEOPLE {
2989            let src = format!("person-{i:02}");
2990            if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2991                for dst in nbrs {
2992                    out.insert((src.clone(), dst));
2993                }
2994            }
2995        }
2996        out
2997    }
2998
2999    fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
3000        let hits: Vec<_> = db
3001            .read()
3002            .explain(a, b)
3003            .expect("explain")
3004            .into_iter()
3005            .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
3006            .collect();
3007        assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
3008        let got = hits[0].weight.expect("weighted");
3009        assert!(
3010            (got - want).abs() < 1e-12,
3011            "{rule} {a}→{b}: got {got} want {want}"
3012        );
3013    }
3014
3015    fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
3016        const R: f64 = 6371.0088;
3017        let phi1 = lat1.to_radians();
3018        let phi2 = lat2.to_radians();
3019        let dphi = (lat2 - lat1).to_radians();
3020        let dlam = (lon2 - lon1).to_radians();
3021        let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
3022            .clamp(0.0, 1.0);
3023        let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
3024        R * c
3025    }
3026
3027    fn default_bind() -> SocketAddr {
3028        SocketAddr::from(([127, 0, 0, 1], 8080))
3029    }
3030
3031    #[test]
3032    fn parse_args_table() {
3033        struct Case {
3034            args: &'static [&'static str],
3035            check: fn(Result<Command, String>),
3036        }
3037
3038        let cases = [
3039            Case {
3040                args: &[],
3041                check: |r| match r {
3042                    Ok(Command::Help) => {}
3043                    other => panic!("no-args → Help, got {other:?}"),
3044                },
3045            },
3046            Case {
3047                args: &["--help"],
3048                check: |r| match r {
3049                    Ok(Command::Help) => {}
3050                    other => panic!("--help → Help, got {other:?}"),
3051                },
3052            },
3053            Case {
3054                args: &["-h"],
3055                check: |r| match r {
3056                    Ok(Command::Help) => {}
3057                    other => panic!("-h → Help, got {other:?}"),
3058                },
3059            },
3060            Case {
3061                args: &["serve", "/tmp/demo-db"],
3062                check: |r| match r {
3063                    Ok(Command::Serve {
3064                        db_dir,
3065                        addr,
3066                        ui,
3067                        demo_if_empty,
3068                        token,
3069                        role_tokens,
3070                        snapshot_every,
3071                        restore_from,
3072                        tls_cert,
3073                        tls_key,
3074                    }) => {
3075                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3076                        assert_eq!(addr, default_bind());
3077                        assert_eq!(ui, super::ServeUi::Embedded);
3078                        assert!(!demo_if_empty);
3079                        assert_eq!(token, None);
3080                        assert!(role_tokens.is_empty());
3081                        assert_eq!(snapshot_every, None);
3082                        assert_eq!(restore_from, None);
3083                        assert_eq!(tls_cert, None);
3084                        assert_eq!(tls_key, None);
3085                    }
3086                    other => panic!("serve <dir> → Serve default addr, got {other:?}"),
3087                },
3088            },
3089            Case {
3090                args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
3091                check: |r| match r {
3092                    Ok(Command::Serve {
3093                        db_dir,
3094                        addr,
3095                        ui,
3096                        demo_if_empty,
3097                        token,
3098                        role_tokens,
3099                        snapshot_every,
3100                        restore_from,
3101                        tls_cert,
3102                        tls_key,
3103                    }) => {
3104                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3105                        assert_eq!(
3106                            addr,
3107                            "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
3108                        );
3109                        assert_eq!(ui, super::ServeUi::Embedded);
3110                        assert!(!demo_if_empty);
3111                        assert_eq!(token, None);
3112                        assert!(role_tokens.is_empty());
3113                        assert_eq!(snapshot_every, None);
3114                        assert_eq!(restore_from, None);
3115                        assert_eq!(tls_cert, None);
3116                        assert_eq!(tls_key, None);
3117                    }
3118                    other => panic!("serve --addr after dir, got {other:?}"),
3119                },
3120            },
3121            Case {
3122                args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
3123                check: |r| match r {
3124                    Ok(Command::Serve {
3125                        db_dir,
3126                        addr,
3127                        ui,
3128                        demo_if_empty,
3129                        token,
3130                        role_tokens,
3131                        snapshot_every,
3132                        restore_from,
3133                        tls_cert,
3134                        tls_key,
3135                    }) => {
3136                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3137                        assert_eq!(
3138                            addr,
3139                            "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
3140                        );
3141                        assert_eq!(ui, super::ServeUi::Embedded);
3142                        assert!(!demo_if_empty);
3143                        assert_eq!(token, None);
3144                        let _ = role_tokens; // empty, not asserted
3145                        assert_eq!(snapshot_every, None);
3146                        assert_eq!(restore_from, None);
3147                        assert_eq!(tls_cert, None);
3148                        assert_eq!(tls_key, None);
3149                    }
3150                    other => panic!("serve --addr=VALUE, got {other:?}"),
3151                },
3152            },
3153            Case {
3154                args: &["mcp", "/tmp/demo-db"],
3155                check: |r| match r {
3156                    Ok(Command::Mcp {
3157                        db_dir,
3158                        auto,
3159                        all_tools,
3160                    }) => {
3161                        assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
3162                        assert!(!auto);
3163                        assert!(!all_tools, "the short list is the default");
3164                    }
3165                    other => panic!("mcp <dir>, got {other:?}"),
3166                },
3167            },
3168            Case {
3169                args: &["stats", "/tmp/demo-db"],
3170                check: |r| match r {
3171                    Ok(Command::Stats { db_dir }) => {
3172                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3173                    }
3174                    other => panic!("stats <dir>, got {other:?}"),
3175                },
3176            },
3177            Case {
3178                args: &["demo", "/tmp/demo-db"],
3179                check: |r| match r {
3180                    Ok(Command::Demo { db_dir }) => {
3181                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3182                    }
3183                    other => panic!("demo <dir>, got {other:?}"),
3184                },
3185            },
3186            Case {
3187                args: &["serve"],
3188                check: |r| {
3189                    let e = r.expect_err("serve without dir");
3190                    assert!(
3191                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3192                        "missing-dir error should mention dir, got {e}"
3193                    );
3194                },
3195            },
3196            Case {
3197                args: &["mcp"],
3198                check: |r| {
3199                    let e = r.expect_err("mcp without dir");
3200                    assert!(
3201                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3202                        "missing-dir error should mention dir, got {e}"
3203                    );
3204                },
3205            },
3206            Case {
3207                args: &["stats"],
3208                check: |r| {
3209                    let e = r.expect_err("stats without dir");
3210                    assert!(
3211                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3212                        "missing-dir error should mention dir, got {e}"
3213                    );
3214                },
3215            },
3216            Case {
3217                args: &["demo"],
3218                check: |r| {
3219                    let e = r.expect_err("demo without dir");
3220                    assert!(
3221                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3222                        "missing-dir error should mention dir, got {e}"
3223                    );
3224                },
3225            },
3226            Case {
3227                args: &["serve", "/tmp/demo-db", "--addr"],
3228                check: |r| {
3229                    let e = r.expect_err("--addr missing value");
3230                    assert!(
3231                        e.to_lowercase().contains("addr"),
3232                        "--addr missing value should mention addr, got {e}"
3233                    );
3234                },
3235            },
3236            Case {
3237                args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
3238                check: |r| {
3239                    let e = r.expect_err("invalid addr");
3240                    assert!(
3241                        e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
3242                        "invalid addr should mention address, got {e}"
3243                    );
3244                },
3245            },
3246            Case {
3247                args: &["frobnicate", "/tmp/demo-db"],
3248                check: |r| {
3249                    let e = r.expect_err("unknown command");
3250                    assert!(
3251                        e.to_lowercase().contains("unknown")
3252                            || e.to_lowercase().contains("frobnicate"),
3253                        "unknown command should name it, got {e}"
3254                    );
3255                },
3256            },
3257            Case {
3258                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
3259                check: |r| match r {
3260                    Ok(Command::Serve { ui, .. }) => {
3261                        assert_eq!(
3262                            ui,
3263                            super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
3264                        );
3265                    }
3266                    other => panic!("serve --ui <dir>, got {other:?}"),
3267                },
3268            },
3269            Case {
3270                args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
3271                check: |r| match r {
3272                    Ok(Command::Serve { ui, .. }) => {
3273                        assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
3274                    }
3275                    other => panic!("serve --ui=VALUE, got {other:?}"),
3276                },
3277            },
3278            Case {
3279                args: &["serve", "/tmp/demo-db", "--ui"],
3280                check: |r| {
3281                    let e = r.expect_err("--ui missing value");
3282                    assert!(
3283                        e.to_lowercase().contains("ui"),
3284                        "--ui missing value should mention ui, got {e}"
3285                    );
3286                },
3287            },
3288            Case {
3289                args: &["serve", "/tmp/demo-db", "--no-ui"],
3290                check: |r| match r {
3291                    Ok(Command::Serve { ui, .. }) => {
3292                        assert_eq!(ui, super::ServeUi::None);
3293                    }
3294                    other => panic!("serve --no-ui, got {other:?}"),
3295                },
3296            },
3297            Case {
3298                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
3299                check: |r| {
3300                    let e = r.expect_err("combine --ui and --no-ui");
3301                    assert!(
3302                        e.contains("--ui") && e.contains("--no-ui"),
3303                        "conflict should name both flags, got {e}"
3304                    );
3305                },
3306            },
3307            Case {
3308                args: &["serve", "/tmp/demo-db", "extra"],
3309                check: |r| {
3310                    let e = r.expect_err("extra positional");
3311                    assert!(
3312                        e.to_lowercase().contains("unexpected")
3313                            || e.to_lowercase().contains("extra"),
3314                        "extra arg should be rejected, got {e}"
3315                    );
3316                },
3317            },
3318            Case {
3319                args: &[
3320                    "serve",
3321                    "/data",
3322                    "--addr",
3323                    "0.0.0.0:8080",
3324                    "--demo-if-empty",
3325                ],
3326                check: |r| match r {
3327                    Ok(Command::Serve {
3328                        db_dir,
3329                        addr,
3330                        demo_if_empty,
3331                        ui,
3332                        token,
3333                        snapshot_every,
3334                        ..
3335                    }) => {
3336                        assert_eq!(db_dir, PathBuf::from("/data"));
3337                        assert_eq!(
3338                            addr,
3339                            "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
3340                        );
3341                        assert!(demo_if_empty);
3342                        assert_eq!(ui, super::ServeUi::Embedded);
3343                        assert_eq!(token, None);
3344                        assert_eq!(snapshot_every, None);
3345                    }
3346                    other => panic!("serve --demo-if-empty docker default, got {other:?}"),
3347                },
3348            },
3349            Case {
3350                args: &["install", "--project", "--delivery", "cli"],
3351                check: |r| match r {
3352                    Ok(Command::Install(opts)) => {
3353                        assert_eq!(opts.scope, Some(install::Scope::Project));
3354                        assert_eq!(opts.delivery, install::Delivery::Cli);
3355                    }
3356                    other => panic!("install --delivery cli, got {other:?}"),
3357                },
3358            },
3359            Case {
3360                args: &["install", "--delivery=mcp"],
3361                check: |r| match r {
3362                    Ok(Command::Install(opts)) => {
3363                        assert_eq!(opts.delivery, install::Delivery::Mcp)
3364                    }
3365                    other => panic!("install --delivery=mcp, got {other:?}"),
3366                },
3367            },
3368            Case {
3369                // No flag is the default, and it is the one that opens both
3370                // doors: an upgrade must not quietly drop a user's server.
3371                args: &["install"],
3372                check: |r| match r {
3373                    Ok(Command::Install(opts)) => {
3374                        assert_eq!(opts.delivery, install::Delivery::Both)
3375                    }
3376                    other => panic!("install, got {other:?}"),
3377                },
3378            },
3379            Case {
3380                args: &["install", "--delivery", "sideways"],
3381                check: |r| match r {
3382                    Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
3383                    other => panic!("a bad --delivery must be refused, got {other:?}"),
3384                },
3385            },
3386            Case {
3387                args: &["install", "--always-load"],
3388                check: |r| match r {
3389                    Ok(Command::Install(opts)) => assert!(opts.always_load),
3390                    other => panic!("install --always-load, got {other:?}"),
3391                },
3392            },
3393            Case {
3394                // 0.7 writes no git hooks, so `--no-git-hooks` asks for what
3395                // every install already gets: accepted, not refused.
3396                args: &["install", "--no-git-hooks"],
3397                check: |r| match r {
3398                    Ok(Command::Install(_)) => {}
3399                    other => panic!("install --no-git-hooks, got {other:?}"),
3400                },
3401            },
3402            Case {
3403                // An install that names its store and registers a server is
3404                // an entity-store install: `alwaysLoad` without asking, so a
3405                // session meets the tools rather than searching for them.
3406                args: &["install", "--delivery", "mcp", "--db", "./mem"],
3407                check: |r| match r {
3408                    Ok(Command::Install(opts)) => assert!(opts.always_load),
3409                    other => panic!("install --delivery mcp --db, got {other:?}"),
3410                },
3411            },
3412            Case {
3413                // `both` registers a server too, so it defaults the same way.
3414                args: &["install", "--delivery", "both", "--db=./mem"],
3415                check: |r| match r {
3416                    Ok(Command::Install(opts)) => assert!(opts.always_load),
3417                    other => panic!("install --delivery both --db, got {other:?}"),
3418                },
3419            },
3420            Case {
3421                // …and `--no-always-load` is the way out of it.
3422                args: &[
3423                    "install",
3424                    "--delivery",
3425                    "mcp",
3426                    "--db",
3427                    "./mem",
3428                    "--no-always-load",
3429                ],
3430                check: |r| match r {
3431                    Ok(Command::Install(opts)) => assert!(!opts.always_load),
3432                    other => panic!("install --no-always-load, got {other:?}"),
3433                },
3434            },
3435            Case {
3436                // A `--delivery cli` install registers no server, so there is
3437                // no entry for the key to go on: naming a store cannot turn
3438                // it on.
3439                args: &["install", "--delivery", "cli", "--db", "./mem"],
3440                check: |r| match r {
3441                    Ok(Command::Install(opts)) => assert!(!opts.always_load),
3442                    other => panic!("install --delivery cli --db, got {other:?}"),
3443                },
3444            },
3445            Case {
3446                // An install with no `--db` resolves whatever store the
3447                // directory has — usually the code graph — and stays opt-in.
3448                // `--always-load` still forces it.
3449                args: &["install", "--always-load"],
3450                check: |r| match r {
3451                    Ok(Command::Install(opts)) => assert!(opts.always_load),
3452                    other => panic!("install --always-load, got {other:?}"),
3453                },
3454            },
3455            Case {
3456                // The one experiment left is off unless it is asked for.
3457                args: &["install"],
3458                check: |r| match r {
3459                    Ok(Command::Install(opts)) => assert!(!opts.always_load),
3460                    other => panic!("install, got {other:?}"),
3461                },
3462            },
3463        ];
3464
3465        for case in &cases {
3466            (case.check)(parse_args(case.args));
3467        }
3468    }
3469
3470    /// The five code-graph subcommands a person typed, left in 0.7. Each is an
3471    /// unknown command now — not a stub that prints a deprecation — and the
3472    /// help text names none of them. The other five were hook bodies; see
3473    /// [`the_retired_hook_bodies_parse_as_inert`].
3474    #[test]
3475    fn the_retired_code_graph_subcommands_are_unknown() {
3476        for sub in ["map", "explore", "context", "impact", "owners"] {
3477            match parse_args(&[sub, "/tmp/db"]) {
3478                Err(e) => assert_eq!(e, format!("unknown command: {sub}")),
3479                other => panic!("{sub} still parses: {other:?}"),
3480            }
3481            assert!(
3482                !usage().contains(&format!("mushroomdb {sub} ")),
3483                "the help still names {sub}"
3484            );
3485        }
3486    }
3487
3488    /// The five hook bodies 0.6 installed parse, with any arguments, to the
3489    /// inert [`Command::RetiredHook`], so an upgraded machine that has not
3490    /// re-run `install` is not shown a hook error on every tool call. The help
3491    /// still names none of them.
3492    #[test]
3493    fn the_retired_hook_bodies_parse_as_inert() {
3494        for sub in ["touch", "intercept", "impact-hook", "enrich", "sync"] {
3495            for args in [
3496                vec![sub],
3497                vec![sub, "--auto"],
3498                vec![sub, "/tmp/db", "--whatever", "x"],
3499            ] {
3500                assert_eq!(
3501                    parse_args(&args),
3502                    Ok(Command::RetiredHook { sub }),
3503                    "{args:?}"
3504                );
3505            }
3506            assert!(
3507                !usage().contains(&format!("mushroomdb {sub}")),
3508                "the help still names {sub}"
3509            );
3510        }
3511    }
3512
3513    #[test]
3514    fn serve_default_addr_is_loopback_8080() {
3515        match parse_args(&["serve", "/tmp/db"]).unwrap() {
3516            Command::Serve { addr, .. } => {
3517                assert_eq!(
3518                    addr,
3519                    "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
3520                );
3521            }
3522            other => panic!("{other:?}"),
3523        }
3524    }
3525
3526    #[test]
3527    fn serve_snapshot_every_parses_seconds() {
3528        match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
3529            Command::Serve { snapshot_every, .. } => {
3530                assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
3531            }
3532            other => panic!("{other:?}"),
3533        }
3534        match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
3535            Command::Serve { snapshot_every, .. } => {
3536                assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
3537            }
3538            other => panic!("{other:?}"),
3539        }
3540        match parse_args(&["serve", "/tmp/db"]).unwrap() {
3541            Command::Serve { snapshot_every, .. } => {
3542                assert_eq!(snapshot_every, None);
3543            }
3544            other => panic!("{other:?}"),
3545        }
3546        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
3547        assert!(
3548            err.contains("snapshot-every"),
3549            "missing value should name the flag, got {err}"
3550        );
3551        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
3552        assert!(
3553            err.contains("snapshot-every"),
3554            "zero should be rejected, got {err}"
3555        );
3556        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
3557        assert!(
3558            err.contains("snapshot-every"),
3559            "invalid value should name the flag, got {err}"
3560        );
3561    }
3562
3563    #[test]
3564    fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
3565        // parse succeeds; main() enforces the bind rule. Token is stored.
3566        match parse_args(&[
3567            "serve",
3568            "/tmp/db",
3569            "--addr",
3570            "0.0.0.0:8080",
3571            "--token",
3572            "s3cret",
3573        ])
3574        .unwrap()
3575        {
3576            Command::Serve { token, addr, .. } => {
3577                assert_eq!(token.as_deref(), Some("s3cret"));
3578                assert_eq!(addr.ip().to_string(), "0.0.0.0");
3579            }
3580            other => panic!("{other:?}"),
3581        }
3582    }
3583
3584    #[test]
3585    fn parse_build_index_both_forms_and_a_missing_value() {
3586        match parse_args(&["build-index", "/tmp/db"]).unwrap() {
3587            Command::BuildIndex { db_dir, rule } => {
3588                assert_eq!(db_dir, PathBuf::from("/tmp/db"));
3589                assert_eq!(rule, None);
3590            }
3591            other => panic!("{other:?}"),
3592        }
3593        for args in [
3594            vec!["build-index", "/tmp/db", "--rule", "sim"],
3595            vec!["build-index", "/tmp/db", "--rule=sim"],
3596        ] {
3597            match parse_args(&args).unwrap() {
3598                Command::BuildIndex { db_dir, rule } => {
3599                    assert_eq!(db_dir, PathBuf::from("/tmp/db"));
3600                    assert_eq!(rule.as_deref(), Some("sim"), "{args:?}");
3601                }
3602                other => panic!("{other:?}"),
3603            }
3604        }
3605        assert_eq!(
3606            parse_args(&["build-index", "/tmp/db", "--rule"]).unwrap_err(),
3607            "--rule requires a value"
3608        );
3609        assert_eq!(
3610            parse_args(&["build-index", "/tmp/db", "--rule="]).unwrap_err(),
3611            "--rule requires a value"
3612        );
3613        assert_eq!(
3614            parse_args(&["build-index"]).unwrap_err(),
3615            "build-index requires <db-dir>"
3616        );
3617        assert!(usage().contains("mushroomdb build-index <db-dir> [--rule <name>]"));
3618    }
3619
3620    #[test]
3621    fn parse_snapshot_and_query() {
3622        // Archiving is what a snapshot does unless the user says otherwise.
3623        for (args, want) in [
3624            (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
3625            (
3626                vec!["snapshot", "/tmp/db", "--archive-wal"],
3627                WalDisposition::Archive,
3628            ),
3629            (
3630                vec!["snapshot", "/tmp/db", "--keep-wal"],
3631                WalDisposition::Keep,
3632            ),
3633            (
3634                vec!["snapshot", "/tmp/db", "--truncate"],
3635                WalDisposition::Truncate,
3636            ),
3637        ] {
3638            match parse_args(&args).unwrap() {
3639                Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
3640                other => panic!("{other:?}"),
3641            }
3642        }
3643        match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
3644            Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
3645            other => panic!("{other:?}"),
3646        }
3647        match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
3648            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3649            other => panic!("{other:?}"),
3650        }
3651        match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
3652            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3653            other => panic!("{other:?}"),
3654        }
3655        let text = usage();
3656        assert!(
3657            text.contains("query"),
3658            "usage should mention query, got:\n{text}"
3659        );
3660        assert!(
3661            text.contains("snapshot"),
3662            "usage should mention snapshot, got:\n{text}"
3663        );
3664    }
3665
3666    /// Binding: the help describes the tree 0.7 ships. One tool surface, the
3667    /// association tools listed on every store, and one brief — the memory
3668    /// schema's. The code-graph listing and the repository brief are gone.
3669    #[test]
3670    fn usage_describes_one_surface_and_the_memory_brief() {
3671        let text = usage();
3672        assert!(
3673            text.contains(
3674                "--all-tools lists every served tool; the default lists the association tools on every store,\n\
3675                 \x20                    a store `ingest-git` built included"
3676            ),
3677            "mcp help line, got:\n{text}"
3678        );
3679        assert!(
3680            text.contains(
3681                "mushroomdb brief <db-dir>|--auto    hook body: the store's schema in one block — labels,\n\
3682                 \x20                                     edge types, history depth, roles and one worked call per\n\
3683                 \x20                                     question kind; byte-stable, so a session host caches it once"
3684            ),
3685            "brief help line, got:\n{text}"
3686        );
3687        for stale in [
3688            "28 tools",
3689            "explore, query, stats",
3690            "most called symbols",
3691            "synced sha",
3692        ] {
3693            assert!(!text.contains(stale), "usage still says {stale:?}");
3694        }
3695    }
3696
3697    #[test]
3698    fn usage_lists_every_subcommand() {
3699        let text = usage();
3700        for word in [
3701            "serve",
3702            "mcp",
3703            "stats",
3704            "demo",
3705            "query",
3706            "snapshot",
3707            "--keep-wal",
3708            "mushroomdb",
3709            "--ui",
3710            "--no-ui",
3711            "--demo-if-empty",
3712            "--token",
3713            "--role-token TOKEN:ROLE",
3714            "--tls-cert <pem> --tls-key <pem>",
3715            "a build with the `tls` feature",
3716            "--snapshot-every",
3717        ] {
3718            assert!(
3719                text.contains(word),
3720                "usage should mention {word}, got:\n{text}"
3721            );
3722        }
3723    }
3724
3725    #[test]
3726    fn validate_ui_dir_requires_index_html() {
3727        let missing = tmp("ui-missing");
3728        let err = super::validate_ui_dir(&missing).expect_err("missing dir");
3729        assert!(
3730            err.contains("does not exist"),
3731            "missing dir error, got {err}"
3732        );
3733
3734        let empty = tmp("ui-empty");
3735        std::fs::create_dir_all(&empty).unwrap();
3736        let err = super::validate_ui_dir(&empty).expect_err("no index");
3737        assert!(
3738            err.contains("index.html"),
3739            "missing index.html error, got {err}"
3740        );
3741
3742        let ok = tmp("ui-ok");
3743        std::fs::create_dir_all(&ok).unwrap();
3744        std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
3745        let got = super::validate_ui_dir(&ok).expect("valid ui dir");
3746        assert_eq!(got, ok);
3747    }
3748
3749    #[test]
3750    fn maybe_run_demo_if_empty_seeds_then_skips() {
3751        let dir = tmp("boot-empty");
3752        let first = super::maybe_run_demo_if_empty(&dir)
3753            .expect("empty dir demos")
3754            .expect("Some(DemoOutcome)");
3755        assert_eq!(first.stats.nodes_live, 60);
3756        let db = SharedDb::open(&dir).expect("reopen");
3757        assert!(db.read().has_node("person-01"));
3758        let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
3759        assert!(
3760            second.is_none(),
3761            "second boot must not re-demo a populated volume"
3762        );
3763
3764        let occupied = tmp("boot-occupied");
3765        std::fs::create_dir_all(&occupied).unwrap();
3766        std::fs::write(occupied.join("keep-me"), b"x").unwrap();
3767        let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
3768        assert!(skipped.is_none());
3769        assert_eq!(
3770            std::fs::read(occupied.join("keep-me")).unwrap(),
3771            b"x",
3772            "existing volume contents must be untouched"
3773        );
3774    }
3775
3776    #[test]
3777    fn demo_builder_is_deterministic_and_refuses_second_run() {
3778        let dir = tmp("demo");
3779        let out = run_demo(&dir).expect("first demo run");
3780
3781        assert_eq!(
3782            out.stats.nodes_live, 60,
3783            "10 orgs + 20 projects + 30 people"
3784        );
3785        assert_eq!(out.stats.nodes_tombstoned, 0);
3786        // Auto-FK: 20 project→org + 30 person→org + 30 person→project = 80.
3787        // FIT: each of 30 people matches home (Jaccard 1.0) and two adjacent
3788        // projects (3-skill window shifted ±1 → Jaccard 2/4 = 0.5) = 30*3 = 90.
3789        // founded_within: |year_i − year_j| ≤ 2 on 2010+(i-1) → 17 pairs × 2 = 34.
3790        // nearby_office: 4 city clusters (NYC/SF/London/Paris) → 8 pairs × 2 = 16.
3791        // similar_interests: dim-8 groups → 57 pairs × 2 = 114.
3792        // Total: 80 + 90 + 34 + 16 + 114 = 334.
3793        assert_eq!(out.stats.edges, 334);
3794        assert_eq!(
3795            out.stats.rules.len(),
3796            7,
3797            "3 auto-FK + overlap + numeric + geo + vector"
3798        );
3799        let fit = out
3800            .stats
3801            .rules
3802            .iter()
3803            .find(|r| r.name == "skill_fit")
3804            .expect("skill_fit");
3805        assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
3806        let founded = out
3807            .stats
3808            .rules
3809            .iter()
3810            .find(|r| r.name == "founded_within")
3811            .expect("founded_within");
3812        assert_eq!(founded.edges, 34);
3813        let nearby = out
3814            .stats
3815            .rules
3816            .iter()
3817            .find(|r| r.name == "nearby_office")
3818            .expect("nearby_office");
3819        assert_eq!(nearby.edges, 16);
3820        let similar = out
3821            .stats
3822            .rules
3823            .iter()
3824            .find(|r| r.name == "similar_interests")
3825            .expect("similar_interests");
3826        assert_eq!(similar.edges, 114);
3827
3828        let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
3829        names.sort_unstable();
3830        assert_eq!(
3831            names,
3832            vec![
3833                "auto_fk_person_org_id",
3834                "auto_fk_person_project_id",
3835                "auto_fk_project_org_id",
3836                "founded_within",
3837                "nearby_office",
3838                "similar_interests",
3839                "skill_fit",
3840            ]
3841        );
3842
3843        // `recall` needs a name index; the memory schema applied at store
3844        // creation adds it, plus every other memory-entity label — no nodes,
3845        // edges or rules.
3846        let db = SharedDb::open(&dir).expect("reopen demo");
3847        assert_eq!(
3848            db.read().fulltext_pairs(),
3849            vec![
3850                ("Concept".to_string(), "name".to_string()),
3851                ("Concept".to_string(), "summary".to_string()),
3852                ("Entity".to_string(), "name".to_string()),
3853                ("Event".to_string(), "name".to_string()),
3854                ("Note".to_string(), "text".to_string()),
3855                ("Org".to_string(), "name".to_string()),
3856                ("Person".to_string(), "name".to_string()),
3857                ("Project".to_string(), "name".to_string()),
3858            ]
3859        );
3860
3861        let mut auto = out.auto_fk_rules.clone();
3862        auto.sort();
3863        assert_eq!(
3864            auto,
3865            vec![
3866                "auto_fk_person_org_id".to_string(),
3867                "auto_fk_person_project_id".to_string(),
3868                "auto_fk_project_org_id".to_string(),
3869            ]
3870        );
3871
3872        assert!(
3873            !out.sample_result.is_empty(),
3874            "sample Cypher query must return rows"
3875        );
3876        assert!(
3877            out.sample_query.contains("ORDER BY score DESC"),
3878            "sample query must rank by score, got {}",
3879            out.sample_query
3880        );
3881        let scores: Vec<f64> = (0..out.sample_result.len())
3882            .map(|i| match out.sample_result.get(i, "score") {
3883                Some(Value::Float(f)) => *f,
3884                other => panic!("score col should be Float, got {other:?}"),
3885            })
3886            .collect();
3887        let distinct: std::collections::BTreeSet<u64> =
3888            scores.iter().map(|s| s.to_bits()).collect();
3889        assert!(
3890            distinct.len() >= 2,
3891            "sample results must be visibly ranked, got {scores:?}"
3892        );
3893        for w in scores.windows(2) {
3894            assert!(
3895                w[0] >= w[1],
3896                "scores must be non-increasing, got {scores:?}"
3897            );
3898        }
3899        assert!(
3900            !out.explanations.is_empty(),
3901            "explain(person-01, proj-01) must find the derived edges"
3902        );
3903
3904        let db = SharedDb::open(&dir).expect("reopen demo");
3905        assert_eq!(
3906            directed_pairs(&db, "FOUNDED_WITHIN"),
3907            [
3908                ("org-01", "org-02"),
3909                ("org-01", "org-03"),
3910                ("org-02", "org-01"),
3911                ("org-02", "org-03"),
3912                ("org-02", "org-04"),
3913                ("org-03", "org-01"),
3914                ("org-03", "org-02"),
3915                ("org-03", "org-04"),
3916                ("org-03", "org-05"),
3917                ("org-04", "org-02"),
3918                ("org-04", "org-03"),
3919                ("org-04", "org-05"),
3920                ("org-04", "org-06"),
3921                ("org-05", "org-03"),
3922                ("org-05", "org-04"),
3923                ("org-05", "org-06"),
3924                ("org-05", "org-07"),
3925                ("org-06", "org-04"),
3926                ("org-06", "org-05"),
3927                ("org-06", "org-07"),
3928                ("org-06", "org-08"),
3929                ("org-07", "org-05"),
3930                ("org-07", "org-06"),
3931                ("org-07", "org-08"),
3932                ("org-07", "org-09"),
3933                ("org-08", "org-06"),
3934                ("org-08", "org-07"),
3935                ("org-08", "org-09"),
3936                ("org-08", "org-10"),
3937                ("org-09", "org-07"),
3938                ("org-09", "org-08"),
3939                ("org-09", "org-10"),
3940                ("org-10", "org-08"),
3941                ("org-10", "org-09"),
3942            ]
3943            .into_iter()
3944            .map(|(a, b)| (a.to_string(), b.to_string()))
3945            .collect::<BTreeSet<_>>()
3946        );
3947        assert_eq!(
3948            directed_pairs(&db, "NEARBY_OFFICE"),
3949            [
3950                ("org-01", "org-07"),
3951                ("org-01", "org-10"),
3952                ("org-02", "org-09"),
3953                ("org-03", "org-08"),
3954                ("org-04", "org-05"),
3955                ("org-04", "org-06"),
3956                ("org-05", "org-04"),
3957                ("org-05", "org-06"),
3958                ("org-06", "org-04"),
3959                ("org-06", "org-05"),
3960                ("org-07", "org-01"),
3961                ("org-07", "org-10"),
3962                ("org-08", "org-03"),
3963                ("org-09", "org-02"),
3964                ("org-10", "org-01"),
3965                ("org-10", "org-07"),
3966            ]
3967            .into_iter()
3968            .map(|(a, b)| (a.to_string(), b.to_string()))
3969            .collect::<BTreeSet<_>>()
3970        );
3971        assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
3972        let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
3973        assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
3974        assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
3975        assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
3976
3977        let err = run_demo(&dir).expect_err("second run into the same dir");
3978        let msg = err.to_string().to_lowercase();
3979        assert!(
3980            msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
3981            "refuse message must mention non-empty dir, got {err}"
3982        );
3983        assert!(
3984            msg.contains("hidden"),
3985            "refuse message must mention hidden files, got {err}"
3986        );
3987
3988        let _ = std::fs::remove_dir_all(&dir);
3989    }
3990
3991    #[test]
3992    fn run_snapshot_writes_snapshot_bin() {
3993        let dir = tmp("snapshot-cli");
3994        {
3995            let mut db = GraphDb::open(&dir).expect("open");
3996            db.insert_node("Person", "alice", vec![]).expect("insert");
3997        }
3998        assert!(
3999            !dir.join("snapshot.bin").exists(),
4000            "GraphDb Drop must not snapshot"
4001        );
4002        let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
4003        assert!(
4004            dir.join("snapshot.bin").is_file(),
4005            "run_snapshot must write snapshot.bin"
4006        );
4007        assert!(
4008            out.contains("snapshot.bin"),
4009            "snapshot output should mention snapshot.bin, got {out}"
4010        );
4011        let db = GraphDb::open(&dir).expect("reopen");
4012        assert!(db.has_node("alice"), "reopen after snapshot must recover");
4013        let _ = std::fs::remove_dir_all(&dir);
4014    }
4015
4016    /// Every snapshot mushroomdb takes on its own — the ingest's, a
4017    /// `serve --snapshot-every` tick, the graceful-shutdown one — archives the
4018    /// WAL, so a store never loses its past to a write nobody asked for. Only
4019    /// an explicit `--truncate` ends that reach.
4020    #[test]
4021    fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
4022        let dir = tmp("snapshot-archive");
4023        {
4024            let mut db = GraphDb::open(&dir).expect("open");
4025            db.insert_node("Person", "alice", vec![]).expect("insert");
4026        }
4027        let before = core_api::wal_commit_count_at(&dir).expect("count");
4028        assert!(before > 0, "the insert is a commit");
4029
4030        // Exactly the call `serve` makes on a tick and on shutdown.
4031        {
4032            let shared = SharedDb::open(&dir).expect("open");
4033            snapshot_shared(&shared).expect("snapshot");
4034        }
4035
4036        let archives = || {
4037            std::fs::read_dir(&dir)
4038                .expect("read dir")
4039                .filter_map(Result::ok)
4040                .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
4041                .count()
4042        };
4043        assert_eq!(archives(), 1, "the WAL was archived, not dropped");
4044        assert!(
4045            dir.join("wal.genesis").is_file(),
4046            "the genesis marker is what lets asof reach an archived commit"
4047        );
4048        {
4049            let db = GraphDb::open(&dir).expect("reopen");
4050            assert!(db.has_node("alice"));
4051            assert!(
4052                !db.node_history("alice").expect("history").items.is_empty(),
4053                "the insert is still explainable"
4054            );
4055        }
4056        assert!(
4057            GraphDb::open_at(&dir, before - 1).is_ok(),
4058            "asof still reaches a commit the snapshot folded in"
4059        );
4060
4061        // A truncating snapshot is the destructive one, and only the user asks
4062        // for it.
4063        run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
4064        assert!(
4065            !dir.join("wal.genesis").exists(),
4066            "truncating ends asof's reach into the archives"
4067        );
4068        let db = GraphDb::open(&dir).expect("reopen");
4069        assert!(
4070            db.has_node("alice"),
4071            "the data survives; only the past goes"
4072        );
4073        let _ = std::fs::remove_dir_all(&dir);
4074    }
4075
4076    /// Automatic snapshots keep every archive: history is the thing archives
4077    /// exist for, and nothing deletes it unless a caller asks with
4078    /// `--retention`.
4079    #[test]
4080    fn automatic_snapshots_keep_every_archive() {
4081        let dir = tmp("snapshot-retention");
4082        let archives = |d: &Path| {
4083            std::fs::read_dir(d)
4084                .expect("read dir")
4085                .filter_map(Result::ok)
4086                .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
4087                .count()
4088        };
4089
4090        // Ten rounds of "commit something, then take the snapshot the ingest,
4091        // the sync hook and the server tick all take".
4092        let rounds = 10;
4093        for i in 0..rounds {
4094            {
4095                let mut db = GraphDb::open(&dir).expect("open");
4096                db.insert_node("Person", &format!("p{i}"), vec![])
4097                    .expect("insert");
4098            }
4099            let shared = SharedDb::open(&dir).expect("open shared");
4100            snapshot_shared(&shared).expect("snapshot");
4101        }
4102
4103        assert_eq!(
4104            archives(&dir),
4105            rounds,
4106            "an automatic snapshot must not delete an archive"
4107        );
4108
4109        let db = GraphDb::open(&dir).expect("reopen");
4110        assert_eq!(
4111            db.wal_horizon_floor(),
4112            0,
4113            "nothing was pruned, so the floor stays at 0"
4114        );
4115        for i in 0..rounds {
4116            assert!(db.has_node(&format!("p{i}")), "p{i} survived");
4117        }
4118        assert!(
4119            !db.node_history("p0").expect("history").items.is_empty(),
4120            "the oldest history is still there: that is the point of the default"
4121        );
4122        assert_eq!(db.node_history("p0").expect("history").horizon, 0);
4123        drop(db);
4124
4125        // The explicit command is the user's, and keeps everything unless the
4126        // user says otherwise.
4127        let manual = tmp("snapshot-retention-manual");
4128        for i in 0..3 {
4129            {
4130                let mut db = GraphDb::open(&manual).expect("open");
4131                db.insert_node("Person", &format!("p{i}"), vec![])
4132                    .expect("insert");
4133            }
4134            run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
4135        }
4136        assert_eq!(
4137            archives(&manual),
4138            3,
4139            "`mushroomdb snapshot` with no --retention keeps every archive"
4140        );
4141
4142        let _ = std::fs::remove_dir_all(&dir);
4143        let _ = std::fs::remove_dir_all(&manual);
4144    }
4145
4146    /// `--retention N` still bounds the archives when a caller asks for it —
4147    /// the automatic default changed, not the escape hatch.
4148    #[test]
4149    fn retention_is_still_available_when_configured() {
4150        let dir = tmp("retention-configured");
4151        for i in 0..5 {
4152            {
4153                let mut db = GraphDb::open(&dir).unwrap();
4154                db.insert_node("Person", &format!("p{i}"), vec![]).unwrap();
4155            }
4156            run_snapshot(&dir, WalDisposition::Archive, Some(2)).unwrap();
4157        }
4158        let archives = std::fs::read_dir(&dir)
4159            .expect("read dir")
4160            .filter_map(Result::ok)
4161            .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
4162            .count();
4163        assert_eq!(archives, 2, "--retention 2 still prunes to two");
4164        assert!(GraphDb::open(&dir).unwrap().wal_horizon_floor() > 0);
4165        let _ = std::fs::remove_dir_all(&dir);
4166    }
4167
4168    #[test]
4169    fn run_query_formats_like_asof() {
4170        let dir = tmp("query-cli");
4171        {
4172            let mut db = GraphDb::open(&dir).expect("open");
4173            db.insert_node(
4174                "Person",
4175                "alice",
4176                vec![("id".into(), Value::Str("alice".into()))],
4177            )
4178            .expect("insert");
4179        }
4180        let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id", None, None).expect("query");
4181        assert!(out.contains("columns:"), "got {out}");
4182        assert!(out.contains("id=alice"), "got {out}");
4183        let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})", None, None).expect("write");
4184        let db = GraphDb::open(&dir).expect("reopen");
4185        assert!(db.has_node("bob"), "query_write must persist CREATE");
4186        let _ = std::fs::remove_dir_all(&dir);
4187    }
4188
4189    /// The as-of header says how far back history reaches, and counts every
4190    /// commit the store still holds — including the archived ones the live
4191    /// WAL no longer carries.
4192    #[test]
4193    fn asof_header_names_the_horizon() {
4194        let dir = tmp("asof-horizon");
4195        // Ten rounds of "write, then snapshot with an explicit retention": the
4196        // bound prunes the oldest archives and the floor advances past 0. The
4197        // automatic path no longer prunes on its own, so the horizon here is
4198        // built with `--retention` rather than the automatic default.
4199        for i in 0..10 {
4200            {
4201                let mut db = GraphDb::open(&dir).expect("open");
4202                db.insert_node("Person", &format!("p{i}"), vec![])
4203                    .expect("insert");
4204            }
4205            run_snapshot(&dir, WalDisposition::Archive, Some(2)).expect("snapshot");
4206        }
4207        // One more write after the last snapshot: that commit lives in the live
4208        // WAL, which is what `asof` can still reconstruct on a pruned store.
4209        {
4210            let mut db = GraphDb::open(&dir).expect("open");
4211            db.insert_node("Person", "after", vec![]).expect("insert");
4212        }
4213        let (floor, total) = {
4214            let db = GraphDb::open(&dir).expect("reopen");
4215            (
4216                db.wal_horizon_floor(),
4217                db.wal_total_commits().expect("total"),
4218            )
4219        };
4220        assert!(floor > 0, "the retention must have pruned something");
4221
4222        let out = run_asof(&dir, Some(total - 1), None, None, None).expect("asof");
4223        assert_eq!(
4224            out.trim(),
4225            format!(
4226                "as-of commit {} of {total} (history reaches back to commit {floor})",
4227                total - 1
4228            ),
4229            "the header must name the horizon it can reach"
4230        );
4231        let _ = std::fs::remove_dir_all(&dir);
4232
4233        // A store that has pruned nothing reads exactly as it always did.
4234        let clean = tmp("asof-clean");
4235        {
4236            let mut db = GraphDb::open(&clean).expect("open");
4237            db.insert_node("Person", "a", vec![]).expect("insert");
4238        }
4239        assert_eq!(
4240            run_asof(&clean, Some(0), None, None, None)
4241                .expect("asof")
4242                .trim(),
4243            "as-of commit 0 of 1"
4244        );
4245        let _ = std::fs::remove_dir_all(&clean);
4246    }
4247
4248    /// `stats` says whether history is complete, and where it starts when it
4249    /// is not.
4250    #[test]
4251    fn format_stats_says_how_far_back_history_reaches() {
4252        let dir = tmp("stats-horizon");
4253        {
4254            let mut db = GraphDb::open(&dir).expect("open");
4255            db.insert_node("Person", "a", vec![]).expect("insert");
4256        }
4257        let text = format_stats(&read_stats(&dir).expect("stats"));
4258        assert!(
4259            text.contains("history: complete (nothing pruned)"),
4260            "an unpruned store says so, got:\n{text}"
4261        );
4262        let _ = std::fs::remove_dir_all(&dir);
4263
4264        let pruned = tmp("stats-horizon-pruned");
4265        for i in 0..10 {
4266            {
4267                let mut db = GraphDb::open(&pruned).expect("open");
4268                db.insert_node("Person", &format!("p{i}"), vec![])
4269                    .expect("insert");
4270            }
4271            run_snapshot(&pruned, WalDisposition::Archive, Some(2)).expect("snapshot");
4272        }
4273        let stats = read_stats(&pruned).expect("stats");
4274        assert!(stats.history_floor > 0, "the retention must have pruned");
4275        assert!(
4276            format_stats(&stats).contains(&format!(
4277                "history: reaches back to commit {}",
4278                stats.history_floor
4279            )),
4280            "a pruned store names its floor"
4281        );
4282        let _ = std::fs::remove_dir_all(&pruned);
4283
4284        // A truncating snapshot discards history too, and says so the same way.
4285        let truncated = tmp("stats-horizon-truncated");
4286        {
4287            let mut db = GraphDb::open(&truncated).expect("open");
4288            db.insert_node("Person", "a", vec![]).expect("insert");
4289            db.insert_node("Person", "b", vec![]).expect("insert");
4290        }
4291        run_snapshot(&truncated, WalDisposition::Truncate, None).expect("truncate");
4292        let stats = read_stats(&truncated).expect("stats");
4293        assert_eq!(stats.history_floor, 2, "two commits were discarded");
4294        assert!(
4295            format_stats(&stats).contains("history: reaches back to commit 2"),
4296            "a truncated store names its floor, got:\n{}",
4297            format_stats(&stats)
4298        );
4299        let _ = std::fs::remove_dir_all(&truncated);
4300    }
4301
4302    #[test]
4303    fn format_stats_contains_counts() {
4304        let dir = tmp("stats-smoke");
4305        let out = run_demo(&dir).expect("demo for stats smoke");
4306        let text = format_stats(&out.stats);
4307        assert!(
4308            text.contains("60"),
4309            "stats output should include live node count, got:\n{text}"
4310        );
4311        assert!(
4312            text.contains("334"),
4313            "stats output should include edge count, got:\n{text}"
4314        );
4315        assert!(
4316            text.to_lowercase().contains("node"),
4317            "stats output should mention nodes, got:\n{text}"
4318        );
4319        assert!(
4320            text.to_lowercase().contains("edge"),
4321            "stats output should mention edges, got:\n{text}"
4322        );
4323        let _ = std::fs::remove_dir_all(&dir);
4324    }
4325
4326    // ── backup CLI tests ──────────────────────────────────────────────────────
4327
4328    #[test]
4329    fn parse_backup_round_trip() {
4330        let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
4331        match r {
4332            Ok(Command::Backup { db_dir, dest }) => {
4333                assert_eq!(db_dir, PathBuf::from("/db/dir"));
4334                assert_eq!(dest, PathBuf::from("/backup/dest"));
4335            }
4336            other => panic!("backup parse, got {other:?}"),
4337        }
4338    }
4339
4340    #[test]
4341    fn parse_backup_missing_dest_errors() {
4342        let r = parse_args(&["backup", "/db/dir"]);
4343        assert!(r.is_err(), "backup without <dest> should error");
4344        let e = r.unwrap_err();
4345        assert!(
4346            e.to_lowercase().contains("dest"),
4347            "error should mention dest, got: {e}"
4348        );
4349    }
4350
4351    #[test]
4352    fn parse_export_defaults_to_jsonl() {
4353        let r = parse_args(&["export", "/db/dir", "/export/dest"]);
4354        match r {
4355            Ok(Command::Export { format, .. }) => {
4356                assert_eq!(format, ExportFormat::Jsonl);
4357            }
4358            other => panic!("export parse, got {other:?}"),
4359        }
4360    }
4361
4362    #[test]
4363    fn parse_export_parquet_flag() {
4364        let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
4365        match r {
4366            Ok(Command::Export { format, .. }) => {
4367                assert_eq!(format, ExportFormat::Parquet);
4368            }
4369            other => panic!("export --format parquet parse, got {other:?}"),
4370        }
4371    }
4372
4373    #[test]
4374    fn parse_export_parquet_flag_eq() {
4375        let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
4376        match r {
4377            Ok(Command::Export { format, .. }) => {
4378                assert_eq!(format, ExportFormat::Parquet);
4379            }
4380            other => panic!("export --format=parquet parse, got {other:?}"),
4381        }
4382    }
4383
4384    #[test]
4385    fn run_backup_cli_produces_verified_report() {
4386        let src = tmp("cli-backup-src");
4387        let dst = tmp("cli-backup-dst");
4388        let _ = run_demo(&src).expect("demo");
4389        let report = run_backup(&src, &dst).expect("run_backup");
4390        assert!(report.verified, "backup must be verified");
4391        assert!(!report.files.is_empty());
4392        assert!(report.bytes > 0);
4393        let _ = std::fs::remove_dir_all(&src);
4394        let _ = std::fs::remove_dir_all(&dst);
4395    }
4396
4397    /// Build a one-node store at `dir` and snapshot it, so `backup_to` has a
4398    /// `snapshot.bin` to copy.
4399    fn seed_store(dir: &Path, key: &str) {
4400        let mut db = GraphDb::open(dir).expect("open seed store");
4401        db.insert_node("N", key, vec![]).expect("insert seed node");
4402        db.snapshot().expect("snapshot seed store");
4403    }
4404
4405    #[test]
4406    fn restore_from_seeds_an_empty_dir() {
4407        let src = tmp("restore-src");
4408        seed_store(&src, "a");
4409        let vault = tmp("restore-vault");
4410        run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
4411
4412        let fresh = tmp("restore-fresh");
4413        match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
4414            RestoreOutcome::Restored { files, bytes, .. } => {
4415                assert!(
4416                    files.contains(&"snapshot.bin".to_string()),
4417                    "expected snapshot.bin among {files:?}"
4418                );
4419                assert!(bytes > 0, "expected a non-zero byte count");
4420            }
4421            other => panic!("expected Restored, got {other:?}"),
4422        }
4423        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4424
4425        let _ = std::fs::remove_dir_all(&src);
4426        let _ = std::fs::remove_dir_all(&vault);
4427        let _ = std::fs::remove_dir_all(&fresh);
4428    }
4429
4430    #[test]
4431    fn restore_from_a_backup_dir_itself() {
4432        let src = tmp("restore-direct-src");
4433        seed_store(&src, "a");
4434        let backup = tmp("restore-direct-backup");
4435        run_backup(&src, &backup).expect("backup");
4436
4437        let fresh = tmp("restore-direct-fresh");
4438        match restore_if_empty(&fresh, &backup).expect("restore_if_empty") {
4439            RestoreOutcome::Restored { from, .. } => assert_eq!(from, backup),
4440            other => panic!("expected Restored, got {other:?}"),
4441        }
4442        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4443
4444        let _ = std::fs::remove_dir_all(&src);
4445        let _ = std::fs::remove_dir_all(&backup);
4446        let _ = std::fs::remove_dir_all(&fresh);
4447    }
4448
4449    /// A store that has never snapshotted backs up as `wal.bin` and nothing
4450    /// else — `mushroomdb demo` then `mushroomdb backup` is exactly that — and
4451    /// it carries every commit. Ranking on `snapshot.bin` alone would skip it
4452    /// and start the server empty, which is the failure the flag exists to
4453    /// prevent.
4454    #[test]
4455    fn restore_from_a_wal_only_backup() {
4456        let src = tmp("restore-walonly-src");
4457        {
4458            let mut db = GraphDb::open(&src).expect("open src");
4459            db.insert_node("N", "a", vec![]).expect("insert");
4460        }
4461        assert!(
4462            !src.join("snapshot.bin").exists(),
4463            "test setup: src must not have snapshotted"
4464        );
4465        let vault = tmp("restore-walonly-vault");
4466        run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
4467
4468        let fresh = tmp("restore-walonly-fresh");
4469        match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
4470            RestoreOutcome::Restored { files, .. } => assert!(
4471                files.contains(&"wal.bin".to_string()),
4472                "expected wal.bin among {files:?}"
4473            ),
4474            other => panic!("expected Restored, got {other:?}"),
4475        }
4476        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4477
4478        let _ = std::fs::remove_dir_all(&src);
4479        let _ = std::fs::remove_dir_all(&vault);
4480        let _ = std::fs::remove_dir_all(&fresh);
4481    }
4482
4483    #[test]
4484    fn restore_from_picks_latest_then_newest() {
4485        let older_src = tmp("restore-rank-old-src");
4486        seed_store(&older_src, "old");
4487        let newer_src = tmp("restore-rank-new-src");
4488        seed_store(&newer_src, "new");
4489        let latest_src = tmp("restore-rank-latest-src");
4490        seed_store(&latest_src, "named-latest");
4491
4492        // Newest by mtime, with no `latest/` present.
4493        let vault = tmp("restore-rank-vault");
4494        run_backup(&older_src, &vault.join("2026-09-01")).expect("backup old");
4495        std::thread::sleep(std::time::Duration::from_millis(1100));
4496        run_backup(&newer_src, &vault.join("2026-09-02")).expect("backup new");
4497
4498        let fresh = tmp("restore-rank-fresh");
4499        match restore_if_empty(&fresh, &vault).expect("restore by mtime") {
4500            RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("2026-09-02")),
4501            other => panic!("expected Restored, got {other:?}"),
4502        }
4503        assert!(GraphDb::open(&fresh)
4504            .expect("open restored")
4505            .has_node("new"));
4506
4507        // `latest/` wins outright, even though it is older than 2026-09-02.
4508        run_backup(&latest_src, &vault.join("latest")).expect("backup latest");
4509        let older_than_latest = std::fs::metadata(vault.join("2026-09-02").join("snapshot.bin"))
4510            .expect("stat newest")
4511            .modified()
4512            .expect("mtime");
4513        let latest_mtime = std::fs::metadata(vault.join("latest").join("snapshot.bin"))
4514            .expect("stat latest")
4515            .modified()
4516            .expect("mtime");
4517        assert!(
4518            latest_mtime >= older_than_latest,
4519            "test setup: latest/ should not be older here"
4520        );
4521
4522        let fresh2 = tmp("restore-rank-fresh2");
4523        match restore_if_empty(&fresh2, &vault).expect("restore by name") {
4524            RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("latest")),
4525            other => panic!("expected Restored, got {other:?}"),
4526        }
4527        assert!(GraphDb::open(&fresh2)
4528            .expect("open restored")
4529            .has_node("named-latest"));
4530
4531        for d in [&older_src, &newer_src, &latest_src, &vault, &fresh, &fresh2] {
4532            let _ = std::fs::remove_dir_all(d);
4533        }
4534    }
4535
4536    #[test]
4537    fn restore_from_is_a_no_op_when_a_store_exists() {
4538        let src = tmp("restore-noop-src");
4539        seed_store(&src, "a");
4540        let vault = tmp("restore-noop-vault");
4541        run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
4542
4543        let existing = tmp("restore-noop-existing");
4544        seed_store(&existing, "b");
4545
4546        assert_eq!(
4547            restore_if_empty(&existing, &vault).expect("restore_if_empty"),
4548            RestoreOutcome::AlreadyPresent
4549        );
4550        let db = GraphDb::open(&existing).expect("open existing");
4551        assert!(db.has_node("b"), "the existing store must survive");
4552        assert!(!db.has_node("a"), "the backup must not have been copied in");
4553
4554        let _ = std::fs::remove_dir_all(&src);
4555        let _ = std::fs::remove_dir_all(&vault);
4556        let _ = std::fs::remove_dir_all(&existing);
4557    }
4558
4559    #[test]
4560    fn restore_from_an_empty_vault_is_not_an_error() {
4561        let vault = tmp("restore-empty-vault");
4562        std::fs::create_dir_all(&vault).expect("mkdir vault");
4563        let fresh = tmp("restore-empty-fresh");
4564        assert_eq!(
4565            restore_if_empty(&fresh, &vault).expect("restore_if_empty"),
4566            RestoreOutcome::Empty
4567        );
4568        // A missing `from` is the same warning: a first boot with no volume yet.
4569        assert_eq!(
4570            restore_if_empty(&fresh, &vault.join("nope")).expect("restore_if_empty"),
4571            RestoreOutcome::Empty
4572        );
4573
4574        let _ = std::fs::remove_dir_all(&vault);
4575        let _ = std::fs::remove_dir_all(&fresh);
4576    }
4577
4578    #[test]
4579    fn restore_from_a_corrupt_backup_fails_loudly() {
4580        let src = tmp("restore-corrupt-src");
4581        seed_store(&src, "a");
4582        let vault = tmp("restore-corrupt-vault");
4583        let backup = vault.join("2026-09-10T00-00Z");
4584        run_backup(&src, &backup).expect("backup");
4585
4586        // Truncate the copied snapshot: the CRC check on open must reject it.
4587        let snap = backup.join("snapshot.bin");
4588        let bytes = std::fs::read(&snap).expect("read snapshot");
4589        std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
4590
4591        let fresh = tmp("restore-corrupt-fresh");
4592        let err = restore_if_empty(&fresh, &vault).expect_err("expected a hard failure");
4593        let msg = err.to_string();
4594        assert!(
4595            msg.contains(&fresh.display().to_string()),
4596            "error must name the restored dir, got: {msg}"
4597        );
4598        assert!(
4599            msg.contains(&backup.display().to_string()),
4600            "error must name the backup, got: {msg}"
4601        );
4602
4603        let _ = std::fs::remove_dir_all(&src);
4604        let _ = std::fs::remove_dir_all(&vault);
4605        let _ = std::fs::remove_dir_all(&fresh);
4606    }
4607
4608    /// A failed restore is all-or-nothing: nothing of the bad copy reaches
4609    /// `db_dir`, so the next boot restores rather than reporting
4610    /// `AlreadyPresent` over a half-written store.
4611    #[test]
4612    fn a_failed_restore_leaves_the_db_dir_untouched() {
4613        let src = tmp("restore-atomic-src");
4614        seed_store(&src, "a");
4615
4616        let bad_vault = tmp("restore-atomic-bad-vault");
4617        let bad = bad_vault.join("2026-09-10T00-00Z");
4618        run_backup(&src, &bad).expect("backup the bad one");
4619        let snap = bad.join("snapshot.bin");
4620        let bytes = std::fs::read(&snap).expect("read snapshot");
4621        std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
4622
4623        let good_vault = tmp("restore-atomic-good-vault");
4624        run_backup(&src, &good_vault.join("2026-09-11T00-00Z")).expect("backup the good one");
4625
4626        let fresh = tmp("restore-atomic-fresh");
4627        let err = restore_if_empty(&fresh, &bad_vault).expect_err("expected a hard failure");
4628        let msg = err.to_string();
4629        assert!(
4630            msg.contains(&fresh.display().to_string()) && msg.contains(&bad.display().to_string()),
4631            "error must name both paths, got: {msg}"
4632        );
4633
4634        // Nothing was left behind — not the copied files, not the staging dir.
4635        let leftovers: Vec<String> = std::fs::read_dir(&fresh)
4636            .expect("read fresh")
4637            .flatten()
4638            .filter_map(|e| e.file_name().into_string().ok())
4639            .collect();
4640        assert!(
4641            leftovers.is_empty(),
4642            "a failed restore must leave nothing behind, found: {leftovers:?}"
4643        );
4644        assert!(!holds_a_store(&fresh), "the dir must not hold a store");
4645
4646        // So the retry against a good backup restores, rather than deciding a
4647        // store is already there.
4648        match restore_if_empty(&fresh, &good_vault).expect("retry must restore") {
4649            RestoreOutcome::Restored { from, .. } => {
4650                assert_eq!(from, good_vault.join("2026-09-11T00-00Z"))
4651            }
4652            other => panic!("expected Restored on retry, got {other:?}"),
4653        }
4654        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4655
4656        for d in [&src, &bad_vault, &good_vault, &fresh] {
4657            let _ = std::fs::remove_dir_all(d);
4658        }
4659    }
4660
4661    /// The staging directory never outlives a successful restore either — a
4662    /// served store directory holds the store and nothing else.
4663    #[test]
4664    fn a_successful_restore_leaves_no_staging_dir() {
4665        let src = tmp("restore-staging-src");
4666        seed_store(&src, "a");
4667        let vault = tmp("restore-staging-vault");
4668        run_backup(&src, &vault.join("2026-09-11T00-00Z")).expect("backup");
4669
4670        let fresh = tmp("restore-staging-fresh");
4671        restore_if_empty(&fresh, &vault).expect("restore");
4672
4673        let stray: Vec<String> = std::fs::read_dir(&fresh)
4674            .expect("read fresh")
4675            .flatten()
4676            .filter_map(|e| e.file_name().into_string().ok())
4677            .filter(|n| n.starts_with(".restore-"))
4678            .collect();
4679        assert!(
4680            stray.is_empty(),
4681            "staging dir must be gone, found: {stray:?}"
4682        );
4683
4684        for d in [&src, &vault, &fresh] {
4685            let _ = std::fs::remove_dir_all(d);
4686        }
4687    }
4688
4689    #[test]
4690    fn serve_parses_restore_from() {
4691        match parse_args(&["serve", "/tmp/db", "--restore-from", "/vol/backups"]) {
4692            Ok(Command::Serve { restore_from, .. }) => {
4693                assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
4694            }
4695            other => panic!("--restore-from parse, got {other:?}"),
4696        }
4697        match parse_args(&["serve", "/tmp/db", "--restore-from=/vol/backups"]) {
4698            Ok(Command::Serve { restore_from, .. }) => {
4699                assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
4700            }
4701            other => panic!("--restore-from= parse, got {other:?}"),
4702        }
4703        match parse_args(&["serve", "/tmp/db"]) {
4704            Ok(Command::Serve { restore_from, .. }) => assert_eq!(restore_from, None),
4705            other => panic!("default restore_from, got {other:?}"),
4706        }
4707        let err = parse_args(&["serve", "/tmp/db", "--restore-from"])
4708            .expect_err("missing value must be an error");
4709        assert!(
4710            err.contains("--restore-from"),
4711            "error must name the flag, got: {err}"
4712        );
4713    }
4714
4715    #[test]
4716    fn run_export_jsonl_two_runs_byte_identical() {
4717        let src = tmp("cli-export-src");
4718        let dst1 = tmp("cli-export-dst1");
4719        let dst2 = tmp("cli-export-dst2");
4720        let _ = run_demo(&src).expect("demo");
4721
4722        run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
4723        run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
4724
4725        for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
4726            let f1 = std::fs::read(dst1.join(filename)).expect("read first");
4727            let f2 = std::fs::read(dst2.join(filename)).expect("read second");
4728            assert_eq!(
4729                f1, f2,
4730                "{filename} must be byte-identical across two export runs"
4731            );
4732        }
4733        let _ = std::fs::remove_dir_all(&src);
4734        let _ = std::fs::remove_dir_all(&dst1);
4735        let _ = std::fs::remove_dir_all(&dst2);
4736    }
4737
4738    #[test]
4739    fn run_export_jsonl_nodes_are_sorted() {
4740        let src = tmp("cli-export-sorted");
4741        let dst = tmp("cli-export-sorted-dst");
4742        let _ = run_demo(&src).expect("demo");
4743        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
4744
4745        let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
4746        let keys: Vec<String> = content
4747            .lines()
4748            .filter(|l| !l.is_empty())
4749            .map(|l| {
4750                let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
4751                v["key"].as_str().unwrap_or("").to_string()
4752            })
4753            .collect();
4754        let mut sorted = keys.clone();
4755        sorted.sort();
4756        assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
4757        let _ = std::fs::remove_dir_all(&src);
4758        let _ = std::fs::remove_dir_all(&dst);
4759    }
4760
4761    #[test]
4762    fn run_export_jsonl_derived_edges_have_rule() {
4763        let src = tmp("cli-export-derived");
4764        let dst = tmp("cli-export-derived-dst");
4765        let _ = run_demo(&src).expect("demo");
4766        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
4767
4768        let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
4769        let derived_lines: Vec<serde_json::Value> = content
4770            .lines()
4771            .filter(|l| !l.is_empty())
4772            .map(|l| serde_json::from_str(l).expect("parse line"))
4773            .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
4774            .collect();
4775        assert!(
4776            !derived_lines.is_empty(),
4777            "demo store should have derived edges"
4778        );
4779        for edge in &derived_lines {
4780            assert!(
4781                !edge["rule"].is_null(),
4782                "derived edge must have non-null rule: {edge}"
4783            );
4784        }
4785        let _ = std::fs::remove_dir_all(&src);
4786        let _ = std::fs::remove_dir_all(&dst);
4787    }
4788
4789    #[test]
4790    fn run_export_parquet_produces_files() {
4791        let src = tmp("cli-export-parq-src");
4792        let dst = tmp("cli-export-parq-dst");
4793        let _ = run_demo(&src).expect("demo");
4794        run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
4795
4796        assert!(
4797            dst.join("nodes.parquet").exists(),
4798            "nodes.parquet must exist"
4799        );
4800        assert!(
4801            dst.join("edges.parquet").exists(),
4802            "edges.parquet must exist"
4803        );
4804        assert!(
4805            dst.join("rules.parquet").exists(),
4806            "rules.parquet must exist"
4807        );
4808        // All files must be non-empty.
4809        for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
4810            let meta = std::fs::metadata(dst.join(f)).expect("metadata");
4811            assert!(meta.len() > 0, "{f} must be non-empty");
4812        }
4813        let _ = std::fs::remove_dir_all(&src);
4814        let _ = std::fs::remove_dir_all(&dst);
4815    }
4816
4817    #[test]
4818    fn parse_export_graphml_flag() {
4819        let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
4820        match r {
4821            Ok(Command::Export { format, .. }) => {
4822                assert_eq!(format, ExportFormat::Graphml);
4823            }
4824            other => panic!("export --format graphml parse, got {other:?}"),
4825        }
4826    }
4827
4828    #[test]
4829    fn run_export_graphml_structure() {
4830        let src = tmp("cli-export-gml-src");
4831        let dst_dir = tmp("cli-export-gml-dst");
4832        let dst = dst_dir.join("graph.graphml");
4833        let _ = run_demo(&src).expect("demo");
4834        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4835
4836        let content = std::fs::read_to_string(&dst).expect("read graphml");
4837
4838        assert!(
4839            content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
4840            "must start with an XML declaration"
4841        );
4842        assert!(
4843            content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
4844            "must use the standard GraphML namespace"
4845        );
4846        assert!(
4847            content.contains(
4848                "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
4849            ),
4850            "must declare the node label key"
4851        );
4852        assert!(
4853            content.contains(
4854                "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
4855            ),
4856            "must declare the edge type key"
4857        );
4858        assert!(
4859            content.contains(
4860                "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
4861            ),
4862            "must declare the edge derived key"
4863        );
4864        assert!(
4865            content.contains(
4866                "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
4867            ),
4868            "must declare the edge rule key"
4869        );
4870        assert!(
4871            content.contains(
4872                "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
4873            ),
4874            "must declare the edge weight key"
4875        );
4876        // The demo store's Org nodes carry `founded_year` as a JSON integer
4877        // (`Value::Int`, a 64-bit i64). GraphML's informal convention treats
4878        // `attr.type="int"` as 32-bit, so this must declare `"long"`.
4879        assert!(
4880            content.contains(
4881                "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
4882            ),
4883            "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
4884        );
4885        assert!(
4886            content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
4887            "must declare a single directed graph element"
4888        );
4889        assert!(content.contains("<node id="), "must contain node elements");
4890        assert!(
4891            content.contains("<edge id=\"e0\" source=\""),
4892            "must contain a sequentially-numbered edge starting at e0"
4893        );
4894        assert!(
4895            content.trim_end().ends_with("</graphml>"),
4896            "must close the root element"
4897        );
4898
4899        // The demo store's `skill_fit` rule declares weight_prop "score"; its
4900        // derived FIT edges must carry both a rule and a weight in GraphML.
4901        assert!(
4902            content.contains("<data key=\"e_rule\">skill_fit</data>")
4903                || content.contains("<data key=\"e_rule\">founded_within</data>"),
4904            "at least one derived edge must carry its rule name"
4905        );
4906        assert!(
4907            content.contains(&format!(
4908                "<data key=\"{}\">",
4909                "e_weight" /* declared above */
4910            )),
4911            "at least one derived edge must carry a weight value"
4912        );
4913
4914        let _ = std::fs::remove_dir_all(&src);
4915        let _ = std::fs::remove_dir_all(&dst_dir);
4916    }
4917
4918    #[test]
4919    fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
4920        let src = tmp("cli-export-gml-dir-src");
4921        let dst_dir = tmp("cli-export-gml-dir-dst");
4922        std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
4923        let _ = run_demo(&src).expect("demo");
4924
4925        let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
4926
4927        assert!(
4928            dst_dir.join("graph.graphml").exists(),
4929            "an existing directory dest must produce dest/graph.graphml"
4930        );
4931        assert!(
4932            msg.contains("graph.graphml"),
4933            "report must name the file actually written, got: {msg}"
4934        );
4935
4936        let _ = std::fs::remove_dir_all(&src);
4937        let _ = std::fs::remove_dir_all(&dst_dir);
4938    }
4939
4940    /// python3-gated: parses the exported file with the stdlib XML parser to
4941    /// confirm it is well-formed. Skipped (not failed) when python3 is absent.
4942    #[test]
4943    fn run_export_graphml_is_well_formed_xml() {
4944        let has_python3 = std::process::Command::new("python3")
4945            .arg("--version")
4946            .output()
4947            .map(|o| o.status.success())
4948            .unwrap_or(false);
4949        if !has_python3 {
4950            eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
4951            return;
4952        }
4953
4954        let src = tmp("cli-export-gml-wf-src");
4955        let dst_dir = tmp("cli-export-gml-wf-dst");
4956        let dst = dst_dir.join("graph.graphml");
4957        let _ = run_demo(&src).expect("demo");
4958        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4959
4960        let status = std::process::Command::new("python3")
4961            .arg("-c")
4962            .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
4963            .arg(&dst)
4964            .status()
4965            .expect("run python3");
4966        assert!(
4967            status.success(),
4968            "python3's XML parser must accept the exported GraphML file"
4969        );
4970
4971        let _ = std::fs::remove_dir_all(&src);
4972        let _ = std::fs::remove_dir_all(&dst_dir);
4973    }
4974
4975    #[test]
4976    fn run_export_graphml_two_runs_byte_identical() {
4977        let src = tmp("cli-export-gml-bi-src");
4978        let dst_dir1 = tmp("cli-export-gml-bi-dst1");
4979        let dst_dir2 = tmp("cli-export-gml-bi-dst2");
4980        let dst1 = dst_dir1.join("graph.graphml");
4981        let dst2 = dst_dir2.join("graph.graphml");
4982        let _ = run_demo(&src).expect("demo");
4983
4984        run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
4985        run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
4986
4987        let f1 = std::fs::read(&dst1).expect("read first");
4988        let f2 = std::fs::read(&dst2).expect("read second");
4989        assert_eq!(
4990            f1, f2,
4991            "graph.graphml must be byte-identical across two export runs"
4992        );
4993
4994        let _ = std::fs::remove_dir_all(&src);
4995        let _ = std::fs::remove_dir_all(&dst_dir1);
4996        let _ = std::fs::remove_dir_all(&dst_dir2);
4997    }
4998
4999    #[test]
5000    fn run_export_graphml_escapes_and_lists() {
5001        use core_api::{GraphDb, Value};
5002        let src = tmp("cli-export-gml-esc-src");
5003        let dst_dir = tmp("cli-export-gml-esc-dst");
5004        let dst = dst_dir.join("graph.graphml");
5005
5006        {
5007            let mut db = GraphDb::open(&src).unwrap();
5008            db.insert_node(
5009                "Widget",
5010                "w1",
5011                vec![
5012                    (
5013                        "title".into(),
5014                        Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
5015                    ),
5016                    (
5017                        "tags".into(),
5018                        Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
5019                    ),
5020                ],
5021            )
5022            .unwrap();
5023        }
5024
5025        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
5026        let content = std::fs::read_to_string(&dst).expect("read graphml");
5027
5028        assert!(
5029            content.contains("Tom &amp; Jerry &lt;says&gt; &quot;hi&quot; &apos;bye&apos;"),
5030            "special XML characters in string props must be escaped, got: {content}"
5031        );
5032        assert!(
5033            !content.contains("Tom & Jerry <says>"),
5034            "unescaped special characters must not appear verbatim"
5035        );
5036        assert!(
5037            content.contains(
5038                "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
5039            ),
5040            "list-valued props must declare attr.type=\"string\""
5041        );
5042        assert!(
5043            content.contains("<data key=\"n_tags\">[&quot;a&quot;,&quot;b&quot;]</data>"),
5044            "list-valued props must render as XML-escaped JSON text, got: {content}"
5045        );
5046
5047        let _ = std::fs::remove_dir_all(&src);
5048        let _ = std::fs::remove_dir_all(&dst_dir);
5049    }
5050
5051    /// I2: when nodes disagree on the `Value` variant for a prop name (one
5052    /// int, one string), the key must declare `attr.type="string"` — a value
5053    /// of either type fits text — rather than picking one node's type and
5054    /// risking a value that doesn't fit it.
5055    #[test]
5056    fn run_export_graphml_mixed_type_prop_declares_string() {
5057        use core_api::{GraphDb, Value};
5058        let src = tmp("cli-export-gml-mixed-src");
5059        let dst_dir = tmp("cli-export-gml-mixed-dst");
5060        let dst = dst_dir.join("graph.graphml");
5061
5062        {
5063            let mut db = GraphDb::open(&src).unwrap();
5064            db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
5065                .unwrap();
5066            db.insert_node(
5067                "Metric",
5068                "m2",
5069                vec![("score".into(), Value::Str("high".into()))],
5070            )
5071            .unwrap();
5072        }
5073
5074        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
5075        let content = std::fs::read_to_string(&dst).expect("read graphml");
5076
5077        assert!(
5078            content.contains(
5079                "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
5080            ),
5081            "a prop name with conflicting value types across nodes must declare \
5082             attr.type=\"string\", got: {content}"
5083        );
5084        assert!(
5085            !content.contains("attr.name=\"score\" attr.type=\"long\""),
5086            "must not declare a narrower type once a conflict is seen, got: {content}"
5087        );
5088        // Each node's own value still renders in its own literal text form,
5089        // regardless of the declared (fallback) attr.type.
5090        assert!(
5091            content.contains("<data key=\"n_score\">5</data>"),
5092            "the int-valued node must still render its literal int text, got: {content}"
5093        );
5094        assert!(
5095            content.contains("<data key=\"n_score\">high</data>"),
5096            "the string-valued node must still render its literal string text, got: {content}"
5097        );
5098
5099        let _ = std::fs::remove_dir_all(&src);
5100        let _ = std::fs::remove_dir_all(&dst_dir);
5101    }
5102
5103    #[test]
5104    fn parse_algo_degree_defaults_dir_both() {
5105        let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
5106        match cmd {
5107            Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
5108            other => panic!("expected Algo, got {other:?}"),
5109        }
5110    }
5111
5112    #[test]
5113    fn parse_algo_degree_with_dir_flag() {
5114        for (arg, want) in [
5115            ("out", AlgoDir::Out),
5116            ("in", AlgoDir::In),
5117            ("both", AlgoDir::Both),
5118        ] {
5119            let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
5120            match cmd {
5121                Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
5122                other => panic!("expected Algo, got {other:?}"),
5123            }
5124        }
5125        // `--dir=out` form too.
5126        let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
5127        match cmd {
5128            Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
5129            other => panic!("expected Algo, got {other:?}"),
5130        }
5131    }
5132
5133    #[test]
5134    fn parse_algo_rejects_unknown_dir() {
5135        assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
5136    }
5137
5138    #[test]
5139    fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
5140        let cmd = parse_args(&[
5141            "algo",
5142            "communities",
5143            "/db",
5144            "--edge-type",
5145            "IMPORTS",
5146            "--edge-type=CO_CHANGED",
5147            "--weight-prop",
5148            "score",
5149            "--min-weight",
5150            "0.3",
5151            "--top",
5152            "5",
5153        ])
5154        .unwrap();
5155        match cmd {
5156            Command::Algo {
5157                subcmd,
5158                top,
5159                edge_types,
5160                weight_prop,
5161                min_weight,
5162                ..
5163            } => {
5164                assert_eq!(subcmd, AlgoSubcmd::Communities);
5165                assert_eq!(top, 5);
5166                assert_eq!(
5167                    edge_types,
5168                    vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
5169                );
5170                assert_eq!(weight_prop, Some("score".to_string()));
5171                assert_eq!(min_weight, Some(0.3));
5172            }
5173            other => panic!("expected Algo, got {other:?}"),
5174        }
5175    }
5176
5177    #[test]
5178    fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
5179        let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
5180        match cmd {
5181            Command::Algo {
5182                subcmd,
5183                edge_types,
5184                weight_prop,
5185                min_weight,
5186                ..
5187            } => {
5188                assert_eq!(subcmd, AlgoSubcmd::Communities);
5189                assert!(edge_types.is_empty());
5190                assert_eq!(weight_prop, None);
5191                assert_eq!(min_weight, None);
5192            }
5193            other => panic!("expected Algo, got {other:?}"),
5194        }
5195    }
5196
5197    /// I1: exporting a store containing NaN/Inf floats must succeed, not panic.
5198    /// The NaN field must be serialised as JSON null (lossy but safe).
5199    #[test]
5200    fn run_export_jsonl_nan_float_becomes_null() {
5201        use core_api::{GraphDb, Value};
5202        let src = tmp("cli-export-nan-src");
5203        let dst = tmp("cli-export-nan-dst");
5204
5205        // Insert a node with NaN, +Inf, and -Inf properties via the public API.
5206        {
5207            let mut db = GraphDb::open(&src).unwrap();
5208            db.insert_node(
5209                "Sensor",
5210                "s1",
5211                vec![
5212                    ("nan_val".into(), Value::Float(f64::NAN)),
5213                    ("pos_inf".into(), Value::Float(f64::INFINITY)),
5214                    ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
5215                    ("normal".into(), Value::Float(1.5)),
5216                ],
5217            )
5218            .unwrap();
5219        }
5220
5221        // Export must succeed.
5222        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
5223
5224        // nodes.jsonl must exist and the NaN fields must be null.
5225        let content =
5226            std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
5227        let row: serde_json::Value =
5228            serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
5229        assert_eq!(
5230            row["nan_val"],
5231            serde_json::Value::Null,
5232            "NaN must export as null"
5233        );
5234        assert_eq!(
5235            row["pos_inf"],
5236            serde_json::Value::Null,
5237            "+Inf must export as null"
5238        );
5239        assert_eq!(
5240            row["neg_inf"],
5241            serde_json::Value::Null,
5242            "-Inf must export as null"
5243        );
5244        // Normal float must survive.
5245        assert_eq!(
5246            row["normal"],
5247            serde_json::json!(1.5),
5248            "normal float roundtrips"
5249        );
5250
5251        let _ = std::fs::remove_dir_all(&src);
5252        let _ = std::fs::remove_dir_all(&dst);
5253    }
5254
5255    #[test]
5256    fn serve_tls_flags_parse_both_forms() {
5257        // --tls-cert VALUE --tls-key VALUE (space form)
5258        match parse_args(&[
5259            "serve",
5260            "/tmp/db",
5261            "--tls-cert",
5262            "/a/cert.pem",
5263            "--tls-key",
5264            "/a/key.pem",
5265        ])
5266        .unwrap()
5267        {
5268            Command::Serve {
5269                tls_cert, tls_key, ..
5270            } => {
5271                assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
5272                assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
5273            }
5274            other => panic!("{other:?}"),
5275        }
5276        // --tls-cert=VALUE --tls-key=VALUE (equals form)
5277        match parse_args(&[
5278            "serve",
5279            "/tmp/db",
5280            "--tls-cert=/b/cert.pem",
5281            "--tls-key=/b/key.pem",
5282        ])
5283        .unwrap()
5284        {
5285            Command::Serve {
5286                tls_cert, tls_key, ..
5287            } => {
5288                assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
5289                assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
5290            }
5291            other => panic!("{other:?}"),
5292        }
5293        // Neither → both None.
5294        match parse_args(&["serve", "/tmp/db"]).unwrap() {
5295            Command::Serve {
5296                tls_cert, tls_key, ..
5297            } => {
5298                assert_eq!(tls_cert, None);
5299                assert_eq!(tls_key, None);
5300            }
5301            other => panic!("{other:?}"),
5302        }
5303    }
5304
5305    #[test]
5306    fn serve_tls_flags_require_both() {
5307        // --tls-cert alone → error
5308        let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
5309        assert!(
5310            err.contains("tls-key"),
5311            "--tls-cert alone must mention --tls-key in error, got {err}"
5312        );
5313        // --tls-key alone → error
5314        let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
5315        assert!(
5316            err.contains("tls-cert"),
5317            "--tls-key alone must mention --tls-cert in error, got {err}"
5318        );
5319    }
5320
5321    #[test]
5322    fn version_flag_parses() {
5323        assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
5324        assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
5325        assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
5326    }
5327
5328    #[test]
5329    fn recall_parses_one_dir_and_is_listed_in_usage() {
5330        assert_eq!(
5331            parse_args(&["recall", "/tmp/db"]).unwrap(),
5332            Command::Recall {
5333                db_dir: Some(PathBuf::from("/tmp/db")),
5334                auto: false,
5335            }
5336        );
5337        assert!(
5338            parse_args(&["recall"]).is_err(),
5339            "one of <db-dir> or --auto is required"
5340        );
5341        assert!(usage().contains("mushroomdb recall <db-dir>"));
5342    }
5343
5344    #[test]
5345    fn why_takes_a_dir_and_two_keys() {
5346        assert_eq!(
5347            parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
5348            Command::Why {
5349                db_dir: PathBuf::from("/tmp/db"),
5350                a: "a.rs".to_string(),
5351                b: "b.rs".to_string(),
5352            }
5353        );
5354
5355        // Too few arguments, too many, and a key that looks like a flag.
5356        for args in [
5357            vec!["why", "/tmp/db", "a"],
5358            vec!["why", "/tmp/db", "a", "b", "c"],
5359            vec!["why", "/tmp/db", "-a", "b"],
5360        ] {
5361            assert!(parse_args(&args).is_err(), "{args:?} must not parse");
5362        }
5363        assert!(usage().contains("mushroomdb why <db-dir> <a> <b>"));
5364    }
5365
5366    /// Every hook-driven command takes either a path or `--auto`, never both
5367    /// and never neither.
5368    #[test]
5369    fn hook_commands_take_a_dir_or_auto() {
5370        assert_eq!(
5371            parse_args(&["mcp", "--auto"]).unwrap(),
5372            Command::Mcp {
5373                db_dir: None,
5374                auto: true,
5375                all_tools: false
5376            }
5377        );
5378        assert_eq!(
5379            parse_args(&["recall", "--auto"]).unwrap(),
5380            Command::Recall {
5381                db_dir: None,
5382                auto: true
5383            }
5384        );
5385        assert_eq!(
5386            parse_args(&["brief", "--auto"]).unwrap(),
5387            Command::Brief {
5388                db_dir: None,
5389                auto: true
5390            }
5391        );
5392        assert_eq!(
5393            parse_args(&["brief", "/tmp/db"]).unwrap(),
5394            Command::Brief {
5395                db_dir: Some(PathBuf::from("/tmp/db")),
5396                auto: false
5397            }
5398        );
5399        for cmd in ["mcp", "recall", "brief"] {
5400            assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
5401            assert!(
5402                parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
5403                "{cmd} with both"
5404            );
5405        }
5406        assert!(usage().contains("--auto"));
5407    }
5408
5409    /// Binding: `--all-tools` is `mcp`'s alone, sits either side of the store
5410    /// path, and every other flag is still rejected.
5411    #[test]
5412    fn mcp_takes_all_tools() {
5413        for args in [
5414            &["mcp", "/tmp/db", "--all-tools"][..],
5415            &["mcp", "--all-tools", "/tmp/db"][..],
5416        ] {
5417            assert_eq!(
5418                parse_args(args).unwrap(),
5419                Command::Mcp {
5420                    db_dir: Some(PathBuf::from("/tmp/db")),
5421                    auto: false,
5422                    all_tools: true
5423                },
5424                "{args:?}"
5425            );
5426        }
5427        assert_eq!(
5428            parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
5429            Command::Mcp {
5430                db_dir: None,
5431                auto: true,
5432                all_tools: true
5433            }
5434        );
5435        assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
5436        assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
5437        assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
5438        assert!(usage().contains("--all-tools"));
5439    }
5440
5441    #[test]
5442    fn ingest_git_parses_excludes() {
5443        let cmd = parse_args(&[
5444            "ingest-git",
5445            "/tmp/db",
5446            "/tmp/repo",
5447            "--exclude",
5448            "target/",
5449            "--exclude=*.lock",
5450            "--max-commits-per-file",
5451            "50",
5452            "--recurse-submodules",
5453            "--prs",
5454            "--ensure-gitignore",
5455        ])
5456        .unwrap();
5457        assert_eq!(
5458            cmd,
5459            Command::IngestGit {
5460                db_dir: PathBuf::from("/tmp/db"),
5461                opts: ingest_git::IngestGitOpts {
5462                    repo: PathBuf::from("/tmp/repo"),
5463                    exclude: vec!["target/".into(), "*.lock".into()],
5464                    max_commits_per_file: 50,
5465                    recurse_submodules: true,
5466                    prs: true,
5467                    structure: true,
5468                    docs: true,
5469                    ensure_gitignore: true,
5470                },
5471            }
5472        );
5473        // Defaults and arity.
5474        let Command::IngestGit { opts, .. } =
5475            parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
5476        else {
5477            panic!("expected IngestGit");
5478        };
5479        assert_eq!(
5480            opts.exclude,
5481            ingest_git::DEFAULT_EXCLUDES
5482                .iter()
5483                .map(|p| (*p).to_string())
5484                .collect::<Vec<_>>(),
5485            "with no --exclude the defaults apply"
5486        );
5487        assert_eq!(
5488            opts.max_commits_per_file,
5489            ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
5490        );
5491        assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
5492        assert!(
5493            opts.structure && opts.docs,
5494            "structure and docs default on and are recorded on the marker"
5495        );
5496        let Command::IngestGit { opts, .. } = parse_args(&[
5497            "ingest-git",
5498            "/tmp/db",
5499            "/tmp/repo",
5500            "--no-structure",
5501            "--no-docs",
5502        ])
5503        .unwrap() else {
5504            panic!("expected IngestGit");
5505        };
5506        assert!(!opts.structure && !opts.docs);
5507        assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
5508        assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
5509        assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
5510        assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
5511    }
5512
5513    #[test]
5514    fn version_constant_matches_cargo() {
5515        assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
5516        assert!(usage().contains("--version"));
5517    }
5518    // ── Namespaces on the CLI ────────────────────────────────────────────────
5519
5520    /// A store with two namespaces and one role bound to the first.
5521    fn ns_store(name: &str) -> PathBuf {
5522        let dir = tmp(name);
5523        let mut db = GraphDb::open(&dir).expect("open");
5524        for (key, ns) in [
5525            ("d1", None),
5526            ("a1", Some("tenant-a")),
5527            ("a2", Some("tenant-a")),
5528            ("b1", Some("tenant-b")),
5529        ] {
5530            let mut props = vec![("id".into(), Value::Str(key.into()))];
5531            if let Some(ns) = ns {
5532                props.push(("ns".into(), Value::Str(ns.into())));
5533            }
5534            db.insert_node("Doc", key, props).expect("insert");
5535        }
5536        db.apply_schema(&Schema {
5537            roles: vec![core_api::RoleDef {
5538                name: "a-reader".into(),
5539                keys: vec![],
5540                labels: vec!["Doc".into()],
5541                visible_where: None,
5542                namespaces: Some(vec!["tenant-a".into()]),
5543                write: None,
5544            }],
5545            ..Default::default()
5546        })
5547        .expect("schema");
5548        dir
5549    }
5550
5551    /// `stats` names every namespace and its live count once a store has more
5552    /// than the default one.
5553    #[test]
5554    fn format_stats_lists_namespaces() {
5555        let dir = ns_store("stats-namespaces");
5556        let text = format_stats(&read_stats(&dir).expect("stats"));
5557        assert!(
5558            text.contains("namespaces: default (1), tenant-a (2), tenant-b (1)"),
5559            "got:\n{text}"
5560        );
5561        let _ = std::fs::remove_dir_all(&dir);
5562    }
5563
5564    /// A single-tenant store's `stats` output is byte-identical to what it was
5565    /// before namespaces existed: no line at all.
5566    #[test]
5567    fn format_stats_omits_the_namespaces_line_on_one_namespace() {
5568        let dir = tmp("stats-one-namespace");
5569        {
5570            let mut db = GraphDb::open(&dir).expect("open");
5571            db.insert_node("Person", "a", vec![]).expect("insert");
5572        }
5573        let text = format_stats(&read_stats(&dir).expect("stats"));
5574        assert!(
5575            !text.contains("namespaces"),
5576            "a store with only `default` says nothing about namespaces, got:\n{text}"
5577        );
5578        let _ = std::fs::remove_dir_all(&dir);
5579    }
5580
5581    /// `query --namespace` and `query --role` parse, and they compose.
5582    #[test]
5583    fn parse_query_takes_a_role_and_a_namespace() {
5584        let Ok(Command::Query {
5585            role, namespace, ..
5586        }) = parse_args(&[
5587            "query",
5588            "/tmp/db",
5589            "--role",
5590            "a-reader",
5591            "--namespace",
5592            "tenant-a",
5593            "MATCH (n) RETURN n",
5594        ])
5595        else {
5596            panic!("expected Query");
5597        };
5598        assert_eq!(role.as_deref(), Some("a-reader"));
5599        assert_eq!(namespace.as_deref(), Some("tenant-a"));
5600
5601        let Ok(Command::Query {
5602            role, namespace, ..
5603        }) = parse_args(&[
5604            "query",
5605            "/tmp/db",
5606            "--namespace=tenant-b",
5607            "MATCH (n) RETURN n",
5608        ])
5609        else {
5610            panic!("expected Query");
5611        };
5612        assert_eq!(role, None);
5613        assert_eq!(namespace.as_deref(), Some("tenant-b"));
5614
5615        assert!(parse_args(&["query", "/tmp/db", "--namespace"]).is_err());
5616        assert!(parse_args(&["query", "/tmp/db", "--role"]).is_err());
5617        assert!(usage().contains("--namespace <ns>"));
5618    }
5619
5620    /// `query --role` answers as that role, `--namespace` narrows, and the two
5621    /// together intersect — a role bound to one namespace never sees another.
5622    #[test]
5623    fn run_query_with_a_role_and_a_namespace_never_widens() {
5624        let dir = ns_store("query-namespace");
5625        let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
5626
5627        let all = run_query(&dir, q, None, None).expect("query");
5628        assert!(all.contains("id=a1") && all.contains("id=b1") && all.contains("id=d1"));
5629
5630        let ns = run_query(&dir, q, None, Some("tenant-a")).expect("query");
5631        assert!(ns.contains("id=a1") && ns.contains("id=a2"), "got {ns}");
5632        assert!(!ns.contains("id=b1") && !ns.contains("id=d1"), "got {ns}");
5633
5634        let role = run_query(&dir, q, Some("a-reader"), None).expect("query");
5635        assert!(
5636            role.contains("id=a1") && !role.contains("id=b1"),
5637            "got {role}"
5638        );
5639
5640        let both = run_query(&dir, q, Some("a-reader"), Some("tenant-b")).expect("query");
5641        assert!(
5642            !both.contains("id=a1") && !both.contains("id=b1"),
5643            "role ∩ namespace, never role ∪ namespace: {both}"
5644        );
5645
5646        // Either argument makes the query a read: a restricted write is refused
5647        // and nothing lands.
5648        for (role, namespace) in [
5649            (None, Some("tenant-a")),
5650            (Some("a-reader"), None),
5651            (Some("a-reader"), Some("tenant-a")),
5652        ] {
5653            let write = run_query(&dir, "CREATE (n:Doc {id: 'z1'})", role, namespace);
5654            assert!(
5655                write
5656                    .as_ref()
5657                    .err()
5658                    .is_some_and(|e| e.0.contains("read-only")),
5659                "a restricted write must be refused, got {write:?}"
5660            );
5661            assert!(
5662                !GraphDb::open(&dir).expect("reopen").has_node("z1"),
5663                "the write must not have landed"
5664            );
5665        }
5666
5667        let unknown = run_query(&dir, q, Some("nobody"), None);
5668        assert!(unknown.is_err(), "an unknown role is an error");
5669        let invalid = run_query(&dir, q, None, Some("no spaces"));
5670        assert!(
5671            invalid
5672                .as_ref()
5673                .err()
5674                .is_some_and(|e| e.0.contains("valid namespace name")),
5675            "{invalid:?}"
5676        );
5677        let _ = std::fs::remove_dir_all(&dir);
5678    }
5679
5680    /// `asof --namespace` reads one namespace as it was at a commit.
5681    #[test]
5682    fn run_asof_in_a_namespace() {
5683        let dir = ns_store("asof-namespace");
5684        let at = {
5685            let db = GraphDb::open(&dir).expect("open");
5686            db.wal_total_commits().expect("commits") - 1
5687        };
5688        {
5689            let mut db = GraphDb::open(&dir).expect("open");
5690            db.insert_node(
5691                "Doc",
5692                "a3",
5693                vec![
5694                    ("id".into(), Value::Str("a3".into())),
5695                    ("ns".into(), Value::Str("tenant-a".into())),
5696                ],
5697            )
5698            .expect("insert");
5699        }
5700        let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
5701        let then = run_asof(&dir, Some(at), None, Some(q), Some("tenant-a")).expect("asof");
5702        assert!(
5703            then.contains("id=a1") && then.contains("id=a2"),
5704            "got {then}"
5705        );
5706        assert!(
5707            !then.contains("id=a3") && !then.contains("id=b1"),
5708            "a3 did not exist then and b1 is another namespace: {then}"
5709        );
5710        let now = run_asof(&dir, Some(at + 1), None, Some(q), Some("tenant-a")).expect("asof");
5711        assert!(now.contains("id=a3"), "got {now}");
5712        let _ = std::fs::remove_dir_all(&dir);
5713    }
5714
5715    /// `schema apply` takes a v4 roles file — a role bound to namespaces — and
5716    /// the sidecar it writes says version 4.
5717    #[test]
5718    fn schema_apply_accepts_v4_roles_with_namespaces() {
5719        let dir = tmp("schema-v4");
5720        {
5721            let mut db = GraphDb::open(&dir).expect("open");
5722            db.insert_node(
5723                "Doc",
5724                "a1",
5725                vec![("ns".into(), Value::Str("tenant-a".into()))],
5726            )
5727            .expect("insert");
5728        }
5729        let file = dir.join("schema.json");
5730        std::fs::write(
5731            &file,
5732            r#"{"roles": [{"name": "a-reader", "labels": ["Doc"], "keys": [],
5733                 "namespaces": ["tenant-a"]}]}"#,
5734        )
5735        .expect("write schema");
5736        let out = run_schema_apply(&dir, &file).expect("apply");
5737        assert!(out.contains("a-reader"), "got {out}");
5738        let sidecar = std::fs::read_to_string(dir.join("roles.json")).expect("roles.json");
5739        assert!(
5740            sidecar.contains("\"version\": 4") || sidecar.contains("\"version\":4"),
5741            "a role with namespaces writes version 4: {sidecar}"
5742        );
5743        assert!(sidecar.contains("tenant-a"), "{sidecar}");
5744        let _ = std::fs::remove_dir_all(&dir);
5745    }
5746}