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