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