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