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