Skip to main content

cli/
lib.rs

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