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