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