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