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