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