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