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/// `why`'s reply as it goes to stdout. A person at a terminal reads the
3094/// framing line as a warning, so `styled` dims it; the text stays, because an
3095/// agent may be reading through a terminal too. Piped output is untouched.
3096pub fn why_for_stdout(out: String, styled: bool) -> String {
3097    let framing = mushroomdb::digest::UNTRUSTED_FRAMING;
3098    match out.strip_prefix(framing) {
3099        Some(rest) if styled => {
3100            format!("\x1b[2m{}\x1b[0m\n{rest}", framing.trim_end_matches('\n'))
3101        }
3102        _ => out,
3103    }
3104}
3105
3106/// Run a graph algorithm and return a formatted string.
3107///
3108/// `dir` selects the edge direction for `degree` and `pagerank`; `wcc` and
3109/// `communities` are always undirected and ignore it. `edge_types` /
3110/// `weight_prop` / `min_weight` are used by `communities` only.
3111#[allow(clippy::too_many_arguments)]
3112pub fn run_algo(
3113    db_dir: &Path,
3114    subcmd: &AlgoSubcmd,
3115    top: usize,
3116    dir: AlgoDir,
3117    edge_types: Vec<String>,
3118    weight_prop: Option<String>,
3119    min_weight: Option<f64>,
3120) -> Result<String, CliError> {
3121    let db = GraphDb::open(db_dir)?;
3122    match subcmd {
3123        AlgoSubcmd::Pagerank => {
3124            let config = PageRankConfig {
3125                direction: dir,
3126                ..PageRankConfig::default()
3127            };
3128            let report = db.pagerank(&config);
3129            Ok(format_pagerank(&report, top))
3130        }
3131        AlgoSubcmd::Wcc => {
3132            let config = WccConfig::default();
3133            let report = db.connected_components(&config);
3134            Ok(format_wcc(&report, top))
3135        }
3136        AlgoSubcmd::Degree => {
3137            let config = DegreeConfig {
3138                direction: dir,
3139                ..DegreeConfig::default()
3140            };
3141            let report = db.degree_centrality(&config);
3142            Ok(format_degree(&report, top))
3143        }
3144        AlgoSubcmd::Communities => {
3145            let config = LouvainConfig {
3146                edge_types,
3147                weight_prop,
3148                min_weight,
3149                ..LouvainConfig::default()
3150            };
3151            let report = db.communities(&config);
3152            Ok(format_communities(&report, top))
3153        }
3154    }
3155}
3156
3157fn format_pagerank(report: &mushroomdb::PageRankReport, top: usize) -> String {
3158    let mut buf = String::new();
3159    let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
3160    let rows = if top == 0 {
3161        report.scores.as_slice()
3162    } else {
3163        &report.scores[..top.min(report.scores.len())]
3164    };
3165    for (i, (key, score)) in rows.iter().enumerate() {
3166        let _ = writeln!(buf, "  {:>4}  {:<40}  {:.6}", i + 1, key, score);
3167    }
3168    buf
3169}
3170
3171fn format_wcc(report: &mushroomdb::WccReport, top: usize) -> String {
3172    let mut buf = String::new();
3173    let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
3174    let rows = if top == 0 {
3175        report.components.as_slice()
3176    } else {
3177        &report.components[..top.min(report.components.len())]
3178    };
3179    for (key, comp_id) in rows {
3180        let _ = writeln!(buf, "  {:<40}  component={}", key, comp_id);
3181    }
3182    buf
3183}
3184
3185fn format_degree(report: &mushroomdb::DegreeReport, top: usize) -> String {
3186    let mut buf = String::new();
3187    let _ = writeln!(
3188        buf,
3189        "== degree centrality (truncated={}) ==",
3190        report.truncated
3191    );
3192    let rows = if top == 0 {
3193        report.scores.as_slice()
3194    } else {
3195        &report.scores[..top.min(report.scores.len())]
3196    };
3197    for (i, (key, deg)) in rows.iter().enumerate() {
3198        let _ = writeln!(buf, "  {:>4}  {:<40}  degree={}", i + 1, key, deg);
3199    }
3200    buf
3201}
3202
3203/// One line per community: id, size, cohesion, first 3 members.
3204/// Prints `(truncated)` in the header when the time budget fired.
3205fn format_communities(report: &mushroomdb::CommunityReport, top: usize) -> String {
3206    let mut buf = String::new();
3207    let trunc = if report.truncated { " (truncated)" } else { "" };
3208    let _ = writeln!(
3209        buf,
3210        "== communities (modularity={:.2}){trunc} ==",
3211        report.modularity
3212    );
3213    let rows = if top == 0 {
3214        report.communities.as_slice()
3215    } else {
3216        &report.communities[..top.min(report.communities.len())]
3217    };
3218    for c in rows {
3219        let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
3220        let _ = writeln!(
3221            buf,
3222            "  {:>4}  size={:<6} cohesion={:<6.2} members=[{}]",
3223            c.id,
3224            c.members.len(),
3225            c.cohesion,
3226            preview.join(", ")
3227        );
3228    }
3229    buf
3230}
3231
3232/// `<db-dir>` or `--auto`, for the commands a hook line invokes.
3233///
3234/// Exactly one of the two: `--auto` says "work it out from the environment",
3235/// which a stated path contradicts rather than refines.
3236fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
3237    let mut db_dir = None;
3238    let mut auto = false;
3239    for a in args {
3240        if *a == "--auto" {
3241            auto = true;
3242        } else if a.starts_with('-') {
3243            return Err(format!("unexpected flag: {a}"));
3244        } else if db_dir.is_some() {
3245            return Err(format!("unexpected extra argument: {a}"));
3246        } else {
3247            db_dir = Some(PathBuf::from(*a));
3248        }
3249    }
3250    match (&db_dir, auto) {
3251        (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
3252        (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
3253        _ => Ok((db_dir, auto)),
3254    }
3255}
3256
3257/// `mcp [<db-dir>|--auto] [--all-tools]`. Every other flag is
3258/// [`parse_dir_or_auto`]'s to reject, so `--all-tools` is stripped here and
3259/// the rest of the line parses exactly as `recall`'s does.
3260fn parse_mcp(args: &[&str]) -> Result<Command, String> {
3261    let all_tools = args.contains(&"--all-tools");
3262    let rest: Vec<&str> = args
3263        .iter()
3264        .copied()
3265        .filter(|a| *a != "--all-tools")
3266        .collect();
3267    parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
3268        db_dir,
3269        auto,
3270        all_tools,
3271    })
3272}
3273
3274/// `<db-dir>` followed by between `min` and `max` further arguments, none of
3275/// which may look like a flag.
3276///
3277/// The graph tools take keys — paths and symbol names — and a key beginning
3278/// with `-` is far more likely to be a typo'd flag than a file called `-x`, so
3279/// it is refused rather than looked up and reported missing.
3280fn parse_positional(
3281    cmd: &str,
3282    args: &[&str],
3283    min: usize,
3284    max: usize,
3285) -> Result<(PathBuf, Vec<String>), String> {
3286    let mut rest: Vec<String> = Vec::new();
3287    let mut db_dir: Option<PathBuf> = None;
3288    for a in args {
3289        if a.starts_with('-') {
3290            return Err(format!("unexpected flag: {a}"));
3291        }
3292        match db_dir {
3293            None => db_dir = Some(PathBuf::from(*a)),
3294            Some(_) => rest.push((*a).to_string()),
3295        }
3296    }
3297    let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
3298    if rest.len() < min {
3299        return Err(format!(
3300            "{cmd} requires <db-dir> and {min} more argument{}",
3301            if min == 1 { "" } else { "s" }
3302        ));
3303    }
3304    if rest.len() > max {
3305        return Err(format!("unexpected extra argument: {}", rest[max]));
3306    }
3307    Ok((db_dir, rest))
3308}
3309
3310fn parse_keys(args: &[&str]) -> Result<Command, String> {
3311    match args.first().copied() {
3312        Some("hash") if args.len() == 1 => Ok(Command::KeysHash),
3313        Some("hash") => Err("keys hash takes no arguments; it reads the token from stdin".into()),
3314        Some("add") => {
3315            let mut positional: Vec<&str> = Vec::new();
3316            let mut tier: Option<String> = None;
3317            let mut role: Option<String> = None;
3318            let mut i = 1;
3319            while i < args.len() {
3320                match args[i] {
3321                    "--tier" | "--role" => {
3322                        let flag = args[i];
3323                        i += 1;
3324                        let value = args
3325                            .get(i)
3326                            .ok_or_else(|| format!("{flag} requires a value"))?;
3327                        let slot = if flag == "--tier" {
3328                            &mut tier
3329                        } else {
3330                            &mut role
3331                        };
3332                        if slot.replace((*value).to_string()).is_some() {
3333                            return Err(format!("{flag} given twice"));
3334                        }
3335                    }
3336                    flag if flag.starts_with('-') => {
3337                        return Err(format!("unexpected flag: {flag}"))
3338                    }
3339                    other => positional.push(other),
3340                }
3341                i += 1;
3342            }
3343            let [db_dir, name] = positional.as_slice() else {
3344                return Err("keys add requires <db-dir> <name>".into());
3345            };
3346            let tier = tier.ok_or("keys add requires --tier admin|write|read")?;
3347            match (tier.as_str(), role.is_some()) {
3348                ("admin" | "write", false) => {}
3349                ("admin" | "write", true) => return Err("--role goes only with --tier read".into()),
3350                ("read", true) => {}
3351                ("read", false) => return Err("--tier read requires --role <name>".into()),
3352                _ => return Err("--tier must be admin, write or read".into()),
3353            }
3354            Ok(Command::KeysAdd {
3355                db_dir: PathBuf::from(*db_dir),
3356                name: (*name).to_string(),
3357                tier,
3358                role,
3359            })
3360        }
3361        _ => Err("keys requires a subcommand: add or hash".into()),
3362    }
3363}
3364
3365fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
3366    let mut db_dir = None;
3367    for a in args {
3368        if a.starts_with('-') {
3369            return Err(format!("unexpected flag: {a}"));
3370        }
3371        if db_dir.is_some() {
3372            return Err(format!("unexpected extra argument: {a}"));
3373        }
3374        db_dir = Some(PathBuf::from(*a));
3375    }
3376    db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
3377}
3378
3379/// Pretty-print [`Stats`] for `mushroomdb stats` and the demo smoke test.
3380pub fn format_stats(stats: &Stats) -> String {
3381    let mut out = String::new();
3382    let _ = writeln!(
3383        out,
3384        "nodes: {} live, {} tombstoned",
3385        stats.nodes_live, stats.nodes_tombstoned
3386    );
3387    let _ = writeln!(out, "edges: {}", stats.edges);
3388    if stats.history_floor == 0 {
3389        let _ = writeln!(out, "history: complete (nothing pruned)");
3390    } else {
3391        let _ = writeln!(
3392            out,
3393            "history: reaches back to commit {}",
3394            stats.history_floor
3395        );
3396    }
3397    // A store that names no namespace is one implicit `default` namespace, and
3398    // saying so would be noise on every single-tenant store — so the line
3399    // appears only once there is more than one, which keeps existing output
3400    // byte-identical.
3401    if stats.namespaces.len() > 1 {
3402        let names: Vec<String> = stats
3403            .namespaces
3404            .iter()
3405            .map(|n| format!("{} ({})", n.name, n.nodes_live))
3406            .collect();
3407        let _ = writeln!(out, "namespaces: {}", names.join(", "));
3408    }
3409    let _ = writeln!(out, "rules: {}", stats.rules.len());
3410    for r in &stats.rules {
3411        let _ = writeln!(
3412            out,
3413            "  {:<28} edges={}  tripped={}",
3414            r.name, r.edges, r.tripped
3415        );
3416    }
3417    out
3418}
3419
3420/// Open `dir` and return live stats.
3421pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
3422    let db = SharedDb::open(dir)?;
3423    let stats = db.read().stats();
3424    Ok(stats)
3425}
3426
3427/// Build the deterministic demo dataset in an empty `dir`.
3428///
3429/// Refuses if `dir` already exists and is not empty. Ingests 10 Orgs, 20
3430/// Projects, 30 People via [`SharedDb`] / `ingest_json` (auto-FK on `*_id`)
3431/// then declares `skill_fit` plus the three Predicates II rules.
3432pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
3433    refuse_non_empty(dir)?;
3434
3435    let db = SharedDb::open(dir)?;
3436    let opts = IngestOptions::default();
3437    let mut auto_fk_rules = Vec::new();
3438
3439    {
3440        let mut w = db.write();
3441        // A demo store is the first store most readers open. Declare the
3442        // memory schema before the first write so `recall` can answer on it.
3443        w.apply_schema(&mushroomdb::memory_schema::memory_defaults())?;
3444        for (label, json) in [
3445            ("Org", org_json()),
3446            ("Project", project_json()),
3447            ("Person", person_json()),
3448        ] {
3449            let report = w.ingest_json(label, &json, &opts)?;
3450            if !report.row_errors.is_empty() {
3451                return Err(CliError(format!(
3452                    "demo ingest of {label} had row errors: {:?}",
3453                    report.row_errors
3454                )));
3455            }
3456            auto_fk_rules.extend(report.rules_created);
3457        }
3458        let skill_fit = Predicate::Overlap {
3459            field: "skills".into(),
3460            min: 0.5,
3461        };
3462        let skill_fit_k = Some(default_max_edges(&skill_fit));
3463        w.create_rule(RuleDef {
3464            name: "skill_fit".into(),
3465            src_label: "Person".into(),
3466            dst_label: "Project".into(),
3467            predicate: skill_fit,
3468            edge_type: "FIT".into(),
3469            weight_prop: Some("score".into()),
3470            max_edges: skill_fit_k,
3471            approximate: false,
3472            via_label: None,
3473            via_edge: None,
3474            via_dir: None,
3475            namespace: None,
3476        })?;
3477        let founded_within = Predicate::NumericWithin {
3478            field: "founded_year".into(),
3479            tolerance: 2.0,
3480        };
3481        let founded_within_k = Some(default_max_edges(&founded_within));
3482        w.create_rule(RuleDef {
3483            name: "founded_within".into(),
3484            src_label: "Org".into(),
3485            dst_label: "Org".into(),
3486            predicate: founded_within,
3487            edge_type: "FOUNDED_WITHIN".into(),
3488            weight_prop: Some("score".into()),
3489            max_edges: founded_within_k,
3490            approximate: false,
3491            via_label: None,
3492            via_edge: None,
3493            via_dir: None,
3494            namespace: None,
3495        })?;
3496        let nearby_office = Predicate::GeoRadius {
3497            field: "office".into(),
3498            km: 50.0,
3499        };
3500        let nearby_office_k = Some(default_max_edges(&nearby_office));
3501        w.create_rule(RuleDef {
3502            name: "nearby_office".into(),
3503            src_label: "Org".into(),
3504            dst_label: "Org".into(),
3505            predicate: nearby_office,
3506            edge_type: "NEARBY_OFFICE".into(),
3507            weight_prop: Some("score".into()),
3508            max_edges: nearby_office_k,
3509            approximate: false,
3510            via_label: None,
3511            via_edge: None,
3512            via_dir: None,
3513            namespace: None,
3514        })?;
3515        let similar_interests = Predicate::VectorSimilar {
3516            field: "embedding".into(),
3517            min: 0.8,
3518        };
3519        let similar_interests_k = Some(default_max_edges(&similar_interests));
3520        w.create_rule(RuleDef {
3521            name: "similar_interests".into(),
3522            src_label: "Person".into(),
3523            dst_label: "Person".into(),
3524            predicate: similar_interests,
3525            edge_type: "SIMILAR".into(),
3526            weight_prop: Some("score".into()),
3527            max_edges: similar_interests_k,
3528            approximate: false,
3529            via_label: None,
3530            via_edge: None,
3531            via_dir: None,
3532            namespace: None,
3533        })?;
3534    }
3535
3536    let r = db.read();
3537    let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
3538    let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
3539    let stats = r.stats();
3540    // Rule suggestion teaser: first suggestion sorted by est_edges desc.
3541    let suggestion = r.suggest_rules().into_iter().next();
3542
3543    Ok(DemoOutcome {
3544        auto_fk_rules,
3545        sample_query: SAMPLE_QUERY.to_string(),
3546        sample_result,
3547        explanations,
3548        stats,
3549        suggestion,
3550    })
3551}
3552
3553fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
3554    if dir.is_file() {
3555        return Err(CliError(format!(
3556            "demo refuses a non-empty directory: {} is a file",
3557            dir.display()
3558        )));
3559    }
3560    if !dir.exists() {
3561        return Ok(true);
3562    }
3563    Ok(std::fs::read_dir(dir)?.next().is_none())
3564}
3565
3566fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
3567    if dir_is_empty_or_absent(dir)? {
3568        Ok(())
3569    } else {
3570        Err(CliError(format!(
3571            "demo refuses a non-empty directory: {} \
3572             (directory must be empty — including hidden files)",
3573            dir.display()
3574        )))
3575    }
3576}
3577
3578/// Run [`run_demo`] when `dir` is missing or empty; otherwise leave it alone.
3579pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
3580    if dir_is_empty_or_absent(dir)? {
3581        Ok(Some(run_demo(dir)?))
3582    } else {
3583        Ok(None)
3584    }
3585}
3586
3587fn json_array(rows: impl IntoIterator<Item = String>) -> String {
3588    let mut out = String::from("[");
3589    let mut first = true;
3590    for row in rows {
3591        if !first {
3592            out.push(',');
3593        }
3594        first = false;
3595        out.push_str(&row);
3596    }
3597    out.push(']');
3598    out
3599}
3600
3601/// Wrap a 1-based project index into `1..=N_PROJECTS`.
3602fn wrap_proj(i: usize) -> usize {
3603    (i - 1) % N_PROJECTS + 1
3604}
3605
3606/// Sliding window of `len` skill tokens starting at project `start`.
3607fn skill_window_json(start: usize, len: usize) -> String {
3608    let parts: Vec<String> = (0..len)
3609        .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
3610        .collect();
3611    format!("[{}]", parts.join(","))
3612}
3613
3614/// Real city [lat, lon] for org `i` (1-based). Four clusters sit inside 50 km:
3615/// NYC / Jersey City / Newark, SF / Oakland / Berkeley, London / Greenwich,
3616/// Paris / Versailles.
3617fn org_office(i: usize) -> (f64, f64) {
3618    match i {
3619        1 => (40.7128, -74.0060),  // New York
3620        2 => (48.8566, 2.3522),    // Paris
3621        3 => (51.5074, -0.1278),   // London
3622        4 => (37.7749, -122.4194), // San Francisco
3623        5 => (37.8044, -122.2711), // Oakland
3624        6 => (37.8715, -122.2730), // Berkeley
3625        7 => (40.7178, -74.0431),  // Jersey City
3626        8 => (51.4769, 0.0005),    // Greenwich
3627        9 => (48.8014, 2.1301),    // Versailles
3628        10 => (40.7357, -74.1724), // Newark
3629        _ => unreachable!("demo orgs are 1..=10"),
3630    }
3631}
3632
3633/// Dim-8 embedding for person `i`. Groups of three share a unit axis (cos = 1);
3634/// two extra groups are (0.8, 0.6, …) and (0.6, 0.8, …) so cos = 0.8 / 0.96
3635/// against the first two axes is hand-checkable.
3636fn person_embedding_json(i: usize) -> String {
3637    let mut v = [0.0_f64; 8];
3638    match i {
3639        9 | 19 | 29 => {
3640            v[0] = 0.8;
3641            v[1] = 0.6;
3642        }
3643        10 | 20 | 30 => {
3644            v[0] = 0.6;
3645            v[1] = 0.8;
3646        }
3647        _ => {
3648            let axis = (i - 1) % 10;
3649            debug_assert!(axis < 8);
3650            v[axis] = 1.0;
3651        }
3652    }
3653    let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
3654    format!("[{}]", parts.join(","))
3655}
3656
3657fn org_json() -> String {
3658    json_array((1..=N_ORGS).map(|i| {
3659        let year = 2010 + (i as i64 - 1);
3660        let (lat, lon) = org_office(i);
3661        format!(
3662            r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
3663            skill_window_json(i, 3)
3664        )
3665    }))
3666}
3667
3668fn project_json() -> String {
3669    json_array((1..=N_PROJECTS).map(|i| {
3670        let org = (i - 1) % N_ORGS + 1;
3671        format!(
3672            r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
3673            skill_window_json(i, 3)
3674        )
3675    }))
3676}
3677
3678fn person_json() -> String {
3679    json_array((1..=N_PEOPLE).map(|i| {
3680        let org = (i - 1) % N_ORGS + 1;
3681        let proj = (i - 1) % N_PROJECTS + 1;
3682        format!(
3683            r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
3684            person_embedding_json(i),
3685            skill_window_json(proj, 3)
3686        )
3687    }))
3688}
3689
3690/// Render a [`DemoOutcome`] the way `mushroomdb demo` prints it.
3691pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
3692    let mut buf = String::new();
3693    let _ = writeln!(buf, "== demo ==");
3694    let _ = writeln!(
3695        buf,
3696        "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
3697    );
3698    let _ = writeln!(
3699        buf,
3700        "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
3701    );
3702    let _ = writeln!(
3703        buf,
3704        "numeric rule: founded_within (Org.founded_year, tolerance 2)"
3705    );
3706    let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
3707    let _ = writeln!(
3708        buf,
3709        "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
3710    );
3711    let _ = writeln!(buf);
3712    let _ = writeln!(buf, "== auto-FK rules ==");
3713    let mut names = out.auto_fk_rules.clone();
3714    names.sort();
3715    for name in names {
3716        let _ = writeln!(buf, "  {name}");
3717    }
3718    let _ = writeln!(buf);
3719    let _ = writeln!(buf, "== query ==");
3720    let _ = writeln!(buf, "{}", out.sample_query);
3721    let _ = writeln!(buf);
3722    let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
3723    for i in 0..out.sample_result.len() {
3724        let cells: Vec<String> = out
3725            .sample_result
3726            .columns()
3727            .iter()
3728            .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
3729            .collect();
3730        let _ = writeln!(buf, "  {}", cells.join("  "));
3731    }
3732    let _ = writeln!(buf);
3733    let _ = writeln!(
3734        buf,
3735        "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
3736    );
3737    for e in &out.explanations {
3738        let weight = e
3739            .weight
3740            .map(|w| fmt_value(&Value::Float(w)))
3741            .unwrap_or_else(|| "none".into());
3742        let _ = writeln!(
3743            buf,
3744            "  rule={}  type={}  {}→{}  weight={}",
3745            e.rule, e.edge_type, e.src_key, e.dst_key, weight
3746        );
3747    }
3748    let _ = writeln!(buf);
3749    let _ = writeln!(buf, "== serve ==");
3750    let _ = writeln!(buf, "  mushroomdb serve {}", dir.display());
3751
3752    // Teaser: one suggestion from the rule suggester (not auto-applied).
3753    if let Some(s) = &out.suggestion {
3754        let _ = writeln!(buf);
3755        let _ = writeln!(buf, "== suggested rule (teaser) ==");
3756        let _ = writeln!(buf, "  {}", s.def.name);
3757        let _ = writeln!(
3758            buf,
3759            "  {} → {} via {:?}",
3760            s.def.src_label, s.def.dst_label, s.def.predicate
3761        );
3762        let _ = writeln!(buf, "  est_edges: ~{}", s.est_edges);
3763        let _ = writeln!(buf, "  {}", s.rationale);
3764        let _ = writeln!(
3765            buf,
3766            "  (run `mushroomdb suggest {}` for full analysis)",
3767            dir.display()
3768        );
3769    }
3770
3771    buf
3772}
3773
3774/// Profile the database at `dir` and return all rule suggestions.
3775pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
3776    let db = GraphDb::open(dir)?;
3777    Ok(db.suggest_rules())
3778}
3779
3780/// Pretty-print a list of [`RuleSuggestion`]s for `mushroomdb suggest`.
3781pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
3782    let mut buf = String::new();
3783    if suggestions.is_empty() {
3784        let _ = writeln!(
3785            buf,
3786            "no rule suggestions (database may be empty or rules already cover all patterns)"
3787        );
3788        return buf;
3789    }
3790    let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
3791    for (i, s) in suggestions.iter().enumerate() {
3792        let _ = writeln!(buf);
3793        let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
3794        let _ = writeln!(
3795            buf,
3796            "    {} → {}  via {:?}",
3797            s.def.src_label, s.def.dst_label, s.def.predicate
3798        );
3799        let _ = writeln!(buf, "    est_edges : ~{}", s.est_edges);
3800        let _ = writeln!(buf, "    rationale : {}", s.rationale);
3801        if !s.examples.is_empty() {
3802            let _ = writeln!(buf, "    examples  :");
3803            for (src, dst, score) in &s.examples {
3804                let _ = writeln!(buf, "      {src} → {dst}  score={score:.4}");
3805            }
3806        }
3807        let _ = writeln!(buf, "    predicate : {:?}", s.def.predicate);
3808        let _ = writeln!(
3809            buf,
3810            "    to apply  : POST /rules  or  db.create_rule(suggestion.def)"
3811        );
3812    }
3813    buf
3814}
3815
3816fn fmt_value(v: &Value) -> String {
3817    match v {
3818        Value::Int(i) => i.to_string(),
3819        Value::Float(f) => {
3820            let s = format!("{f}");
3821            if s.contains('.') || s.contains('e') || s.contains('E') {
3822                s
3823            } else {
3824                format!("{s}.0")
3825            }
3826        }
3827        Value::Str(s) => s.clone(),
3828        Value::Bool(b) => b.to_string(),
3829        Value::List(xs) => {
3830            let inner: Vec<String> = xs.iter().map(fmt_value).collect();
3831            format!("[{}]", inner.join(", "))
3832        }
3833        Value::Map(m) => {
3834            let inner: Vec<String> = m
3835                .iter()
3836                .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
3837                .collect();
3838            format!("{{{}}}", inner.join(", "))
3839        }
3840        Value::Date(_)
3841        | Value::LocalDateTime(_)
3842        | Value::DateTime { .. }
3843        | Value::Duration { .. }
3844        | Value::Binary(_) => mushroomdb::v11_display_text(v).unwrap_or_default(),
3845        // A null list element or map value.
3846        Value::Null => "null".to_string(),
3847    }
3848}
3849
3850fn fmt_cell(cell: Option<&Value>) -> String {
3851    match cell {
3852        None => "null".into(),
3853        Some(v) => fmt_value(v),
3854    }
3855}
3856
3857#[cfg(test)]
3858mod tests {
3859    use super::*;
3860    use std::collections::BTreeSet;
3861    use std::net::SocketAddr;
3862    use std::path::PathBuf;
3863
3864    fn tmp(name: &str) -> PathBuf {
3865        let nanos = std::time::SystemTime::now()
3866            .duration_since(std::time::UNIX_EPOCH)
3867            .expect("clock")
3868            .as_nanos();
3869        let d = std::env::temp_dir().join(format!(
3870            "graphdb-cli-{}-{}-{}",
3871            name,
3872            std::process::id(),
3873            nanos
3874        ));
3875        let _ = std::fs::remove_dir_all(&d);
3876        d
3877    }
3878
3879    #[test]
3880    fn why_for_stdout_dims_the_framing_only_when_styled() {
3881        let framing = mushroomdb::digest::UNTRUSTED_FRAMING;
3882        let out = format!("{framing}mushroomdb explain — a ↔ b: 0 relationship(s)\n");
3883        assert_eq!(why_for_stdout(out.clone(), false), out);
3884        let styled = why_for_stdout(out.clone(), true);
3885        assert_eq!(
3886            styled,
3887            format!(
3888                "\x1b[2m{}\x1b[0m\nmushroomdb explain — a ↔ b: 0 relationship(s)\n",
3889                framing.trim_end_matches('\n')
3890            )
3891        );
3892        // The framing text survives styling: strip the escapes and it is back.
3893        assert_eq!(styled.replace("\x1b[2m", "").replace("\x1b[0m", ""), out);
3894        // A reply without the framing is never touched.
3895        assert_eq!(why_for_stdout("plain\n".into(), true), "plain\n");
3896    }
3897
3898    fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
3899        let g = db.read();
3900        let mut out = BTreeSet::new();
3901        for i in 1..=N_ORGS {
3902            let src = format!("org-{i:02}");
3903            if let Ok(nbrs) = g.neighbors(&src, etype, mushroomdb::Direction::Out) {
3904                for dst in nbrs {
3905                    out.insert((src.clone(), dst));
3906                }
3907            }
3908        }
3909        for i in 1..=N_PEOPLE {
3910            let src = format!("person-{i:02}");
3911            if let Ok(nbrs) = g.neighbors(&src, etype, mushroomdb::Direction::Out) {
3912                for dst in nbrs {
3913                    out.insert((src.clone(), dst));
3914                }
3915            }
3916        }
3917        out
3918    }
3919
3920    fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
3921        let hits: Vec<_> = db
3922            .read()
3923            .explain(a, b)
3924            .expect("explain")
3925            .into_iter()
3926            .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
3927            .collect();
3928        assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
3929        let got = hits[0].weight.expect("weighted");
3930        assert!(
3931            (got - want).abs() < 1e-12,
3932            "{rule} {a}→{b}: got {got} want {want}"
3933        );
3934    }
3935
3936    fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
3937        const R: f64 = 6371.0088;
3938        let phi1 = lat1.to_radians();
3939        let phi2 = lat2.to_radians();
3940        let dphi = (lat2 - lat1).to_radians();
3941        let dlam = (lon2 - lon1).to_radians();
3942        let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
3943            .clamp(0.0, 1.0);
3944        let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
3945        R * c
3946    }
3947
3948    fn default_bind() -> SocketAddr {
3949        SocketAddr::from(([127, 0, 0, 1], 8080))
3950    }
3951
3952    #[test]
3953    fn parse_args_table() {
3954        struct Case {
3955            args: &'static [&'static str],
3956            check: fn(Result<Command, String>),
3957        }
3958
3959        let cases = [
3960            Case {
3961                args: &[],
3962                check: |r| match r {
3963                    Ok(Command::Help) => {}
3964                    other => panic!("no-args → Help, got {other:?}"),
3965                },
3966            },
3967            Case {
3968                args: &["--help"],
3969                check: |r| match r {
3970                    Ok(Command::Help) => {}
3971                    other => panic!("--help → Help, got {other:?}"),
3972                },
3973            },
3974            Case {
3975                args: &["-h"],
3976                check: |r| match r {
3977                    Ok(Command::Help) => {}
3978                    other => panic!("-h → Help, got {other:?}"),
3979                },
3980            },
3981            Case {
3982                args: &["serve", "/tmp/demo-db"],
3983                check: |r| match r {
3984                    Ok(Command::Serve {
3985                        db_dir,
3986                        addr,
3987                        ui,
3988                        demo_if_empty,
3989                        token,
3990                        role_tokens,
3991                        snapshot_every,
3992                        shutdown_timeout: _,
3993                        restore_from,
3994                        tls_cert,
3995                        tls_key,
3996                        bolt,
3997                        tx,
3998                    }) => {
3999                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4000                        assert_eq!(addr, default_bind());
4001                        assert_eq!(ui, super::ServeUi::Embedded);
4002                        assert!(!demo_if_empty);
4003                        assert_eq!(token, None);
4004                        assert!(role_tokens.is_empty());
4005                        assert_eq!(snapshot_every, None);
4006                        assert_eq!(restore_from, None);
4007                        assert_eq!(tls_cert, None);
4008                        assert_eq!(tls_key, None);
4009                        assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4010                        assert_eq!(tx, super::TxFlags::default());
4011                    }
4012                    other => panic!("serve <dir> → Serve default addr, got {other:?}"),
4013                },
4014            },
4015            Case {
4016                args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
4017                check: |r| match r {
4018                    Ok(Command::Serve {
4019                        db_dir,
4020                        addr,
4021                        ui,
4022                        demo_if_empty,
4023                        token,
4024                        role_tokens,
4025                        snapshot_every,
4026                        shutdown_timeout: _,
4027                        restore_from,
4028                        tls_cert,
4029                        tls_key,
4030                        bolt,
4031                        tx,
4032                    }) => {
4033                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4034                        assert_eq!(
4035                            addr,
4036                            "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
4037                        );
4038                        assert_eq!(ui, super::ServeUi::Embedded);
4039                        assert!(!demo_if_empty);
4040                        assert_eq!(token, None);
4041                        assert!(role_tokens.is_empty());
4042                        assert_eq!(snapshot_every, None);
4043                        assert_eq!(restore_from, None);
4044                        assert_eq!(tls_cert, None);
4045                        assert_eq!(tls_key, None);
4046                        assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4047                        assert_eq!(tx, super::TxFlags::default());
4048                    }
4049                    other => panic!("serve --addr after dir, got {other:?}"),
4050                },
4051            },
4052            Case {
4053                args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
4054                check: |r| match r {
4055                    Ok(Command::Serve {
4056                        db_dir,
4057                        addr,
4058                        ui,
4059                        demo_if_empty,
4060                        token,
4061                        role_tokens,
4062                        snapshot_every,
4063                        shutdown_timeout: _,
4064                        restore_from,
4065                        tls_cert,
4066                        tls_key,
4067                        bolt,
4068                        tx,
4069                    }) => {
4070                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4071                        assert_eq!(
4072                            addr,
4073                            "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
4074                        );
4075                        assert_eq!(ui, super::ServeUi::Embedded);
4076                        assert!(!demo_if_empty);
4077                        assert_eq!(token, None);
4078                        let _ = role_tokens; // empty, not asserted
4079                        assert_eq!(snapshot_every, None);
4080                        assert_eq!(restore_from, None);
4081                        assert_eq!(tls_cert, None);
4082                        assert_eq!(tls_key, None);
4083                        assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4084                        assert_eq!(tx, super::TxFlags::default());
4085                    }
4086                    other => panic!("serve --addr=VALUE, got {other:?}"),
4087                },
4088            },
4089            Case {
4090                args: &["mcp", "/tmp/demo-db"],
4091                check: |r| match r {
4092                    Ok(Command::Mcp {
4093                        db_dir,
4094                        auto,
4095                        all_tools,
4096                    }) => {
4097                        assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
4098                        assert!(!auto);
4099                        assert!(!all_tools, "the short list is the default");
4100                    }
4101                    other => panic!("mcp <dir>, got {other:?}"),
4102                },
4103            },
4104            Case {
4105                args: &["stats", "/tmp/demo-db"],
4106                check: |r| match r {
4107                    Ok(Command::Stats { db_dir }) => {
4108                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4109                    }
4110                    other => panic!("stats <dir>, got {other:?}"),
4111                },
4112            },
4113            Case {
4114                args: &["demo", "/tmp/demo-db"],
4115                check: |r| match r {
4116                    Ok(Command::Demo { db_dir }) => {
4117                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4118                    }
4119                    other => panic!("demo <dir>, got {other:?}"),
4120                },
4121            },
4122            Case {
4123                args: &["serve"],
4124                check: |r| {
4125                    let e = r.expect_err("serve without dir");
4126                    assert!(
4127                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4128                        "missing-dir error should mention dir, got {e}"
4129                    );
4130                },
4131            },
4132            Case {
4133                args: &["mcp"],
4134                check: |r| {
4135                    let e = r.expect_err("mcp without dir");
4136                    assert!(
4137                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4138                        "missing-dir error should mention dir, got {e}"
4139                    );
4140                },
4141            },
4142            Case {
4143                args: &["stats"],
4144                check: |r| {
4145                    let e = r.expect_err("stats without dir");
4146                    assert!(
4147                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4148                        "missing-dir error should mention dir, got {e}"
4149                    );
4150                },
4151            },
4152            Case {
4153                args: &["demo"],
4154                check: |r| {
4155                    let e = r.expect_err("demo without dir");
4156                    assert!(
4157                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4158                        "missing-dir error should mention dir, got {e}"
4159                    );
4160                },
4161            },
4162            Case {
4163                args: &["serve", "/tmp/demo-db", "--addr"],
4164                check: |r| {
4165                    let e = r.expect_err("--addr missing value");
4166                    assert!(
4167                        e.to_lowercase().contains("addr"),
4168                        "--addr missing value should mention addr, got {e}"
4169                    );
4170                },
4171            },
4172            Case {
4173                args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
4174                check: |r| {
4175                    let e = r.expect_err("invalid addr");
4176                    assert!(
4177                        e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
4178                        "invalid addr should mention address, got {e}"
4179                    );
4180                },
4181            },
4182            Case {
4183                args: &["frobnicate", "/tmp/demo-db"],
4184                check: |r| {
4185                    let e = r.expect_err("unknown command");
4186                    assert!(
4187                        e.to_lowercase().contains("unknown")
4188                            || e.to_lowercase().contains("frobnicate"),
4189                        "unknown command should name it, got {e}"
4190                    );
4191                },
4192            },
4193            Case {
4194                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
4195                check: |r| match r {
4196                    Ok(Command::Serve { ui, .. }) => {
4197                        assert_eq!(
4198                            ui,
4199                            super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
4200                        );
4201                    }
4202                    other => panic!("serve --ui <dir>, got {other:?}"),
4203                },
4204            },
4205            Case {
4206                args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
4207                check: |r| match r {
4208                    Ok(Command::Serve { ui, .. }) => {
4209                        assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
4210                    }
4211                    other => panic!("serve --ui=VALUE, got {other:?}"),
4212                },
4213            },
4214            Case {
4215                args: &["serve", "/tmp/demo-db", "--ui"],
4216                check: |r| {
4217                    let e = r.expect_err("--ui missing value");
4218                    assert!(
4219                        e.to_lowercase().contains("ui"),
4220                        "--ui missing value should mention ui, got {e}"
4221                    );
4222                },
4223            },
4224            Case {
4225                args: &["serve", "/tmp/demo-db", "--no-ui"],
4226                check: |r| match r {
4227                    Ok(Command::Serve { ui, .. }) => {
4228                        assert_eq!(ui, super::ServeUi::None);
4229                    }
4230                    other => panic!("serve --no-ui, got {other:?}"),
4231                },
4232            },
4233            Case {
4234                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
4235                check: |r| {
4236                    let e = r.expect_err("combine --ui and --no-ui");
4237                    assert!(
4238                        e.contains("--ui") && e.contains("--no-ui"),
4239                        "conflict should name both flags, got {e}"
4240                    );
4241                },
4242            },
4243            Case {
4244                args: &["serve", "/tmp/demo-db", "extra"],
4245                check: |r| {
4246                    let e = r.expect_err("extra positional");
4247                    assert!(
4248                        e.to_lowercase().contains("unexpected")
4249                            || e.to_lowercase().contains("extra"),
4250                        "extra arg should be rejected, got {e}"
4251                    );
4252                },
4253            },
4254            Case {
4255                args: &[
4256                    "serve",
4257                    "/data",
4258                    "--addr",
4259                    "0.0.0.0:8080",
4260                    "--demo-if-empty",
4261                ],
4262                check: |r| match r {
4263                    Ok(Command::Serve {
4264                        db_dir,
4265                        addr,
4266                        demo_if_empty,
4267                        ui,
4268                        token,
4269                        snapshot_every,
4270                        ..
4271                    }) => {
4272                        assert_eq!(db_dir, PathBuf::from("/data"));
4273                        assert_eq!(
4274                            addr,
4275                            "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
4276                        );
4277                        assert!(demo_if_empty);
4278                        assert_eq!(ui, super::ServeUi::Embedded);
4279                        assert_eq!(token, None);
4280                        assert_eq!(snapshot_every, None);
4281                    }
4282                    other => panic!("serve --demo-if-empty docker default, got {other:?}"),
4283                },
4284            },
4285            Case {
4286                args: &["install", "--project", "--delivery", "cli"],
4287                check: |r| match r {
4288                    Ok(Command::Install(opts)) => {
4289                        assert_eq!(opts.scope, Some(install::Scope::Project));
4290                        assert_eq!(opts.delivery, install::Delivery::Cli);
4291                    }
4292                    other => panic!("install --delivery cli, got {other:?}"),
4293                },
4294            },
4295            Case {
4296                args: &["install", "--delivery=mcp"],
4297                check: |r| match r {
4298                    Ok(Command::Install(opts)) => {
4299                        assert_eq!(opts.delivery, install::Delivery::Mcp)
4300                    }
4301                    other => panic!("install --delivery=mcp, got {other:?}"),
4302                },
4303            },
4304            Case {
4305                // No flag is the default, and it is the one that opens both
4306                // doors: an upgrade must not quietly drop a user's server.
4307                args: &["install"],
4308                check: |r| match r {
4309                    Ok(Command::Install(opts)) => {
4310                        assert_eq!(opts.delivery, install::Delivery::Both)
4311                    }
4312                    other => panic!("install, got {other:?}"),
4313                },
4314            },
4315            Case {
4316                args: &["install", "--delivery", "sideways"],
4317                check: |r| match r {
4318                    Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
4319                    other => panic!("a bad --delivery must be refused, got {other:?}"),
4320                },
4321            },
4322            Case {
4323                args: &["install", "--always-load"],
4324                check: |r| match r {
4325                    Ok(Command::Install(opts)) => assert!(opts.always_load),
4326                    other => panic!("install --always-load, got {other:?}"),
4327                },
4328            },
4329            Case {
4330                // 0.7 writes no git hooks, so `--no-git-hooks` asks for what
4331                // every install already gets: accepted, not refused.
4332                args: &["install", "--no-git-hooks"],
4333                check: |r| match r {
4334                    Ok(Command::Install(_)) => {}
4335                    other => panic!("install --no-git-hooks, got {other:?}"),
4336                },
4337            },
4338            Case {
4339                // An install that names its store and registers a server is
4340                // an entity-store install: `alwaysLoad` without asking, so a
4341                // session meets the tools rather than searching for them.
4342                args: &["install", "--delivery", "mcp", "--db", "./mem"],
4343                check: |r| match r {
4344                    Ok(Command::Install(opts)) => assert!(opts.always_load),
4345                    other => panic!("install --delivery mcp --db, got {other:?}"),
4346                },
4347            },
4348            Case {
4349                // `both` registers a server too, so it defaults the same way.
4350                args: &["install", "--delivery", "both", "--db=./mem"],
4351                check: |r| match r {
4352                    Ok(Command::Install(opts)) => assert!(opts.always_load),
4353                    other => panic!("install --delivery both --db, got {other:?}"),
4354                },
4355            },
4356            Case {
4357                // …and `--no-always-load` is the way out of it.
4358                args: &[
4359                    "install",
4360                    "--delivery",
4361                    "mcp",
4362                    "--db",
4363                    "./mem",
4364                    "--no-always-load",
4365                ],
4366                check: |r| match r {
4367                    Ok(Command::Install(opts)) => assert!(!opts.always_load),
4368                    other => panic!("install --no-always-load, got {other:?}"),
4369                },
4370            },
4371            Case {
4372                // A `--delivery cli` install registers no server, so there is
4373                // no entry for the key to go on: naming a store cannot turn
4374                // it on.
4375                args: &["install", "--delivery", "cli", "--db", "./mem"],
4376                check: |r| match r {
4377                    Ok(Command::Install(opts)) => assert!(!opts.always_load),
4378                    other => panic!("install --delivery cli --db, got {other:?}"),
4379                },
4380            },
4381            Case {
4382                // An install with no `--db` resolves whatever store the
4383                // directory has — usually the code graph — and stays opt-in.
4384                // `--always-load` still forces it.
4385                args: &["install", "--always-load"],
4386                check: |r| match r {
4387                    Ok(Command::Install(opts)) => assert!(opts.always_load),
4388                    other => panic!("install --always-load, got {other:?}"),
4389                },
4390            },
4391            Case {
4392                // The one experiment left is off unless it is asked for.
4393                args: &["install"],
4394                check: |r| match r {
4395                    Ok(Command::Install(opts)) => assert!(!opts.always_load),
4396                    other => panic!("install, got {other:?}"),
4397                },
4398            },
4399        ];
4400
4401        for case in &cases {
4402            (case.check)(parse_args(case.args));
4403        }
4404    }
4405
4406    /// The five code-graph subcommands a person typed, left in 0.7. Each is an
4407    /// unknown command now — not a stub that prints a deprecation — and the
4408    /// help text names none of them. The other five were hook bodies; see
4409    /// [`the_retired_hook_bodies_parse_as_inert`].
4410    #[test]
4411    fn the_retired_code_graph_subcommands_are_unknown() {
4412        for sub in ["map", "explore", "context", "impact", "owners"] {
4413            match parse_args(&[sub, "/tmp/db"]) {
4414                Err(e) => assert_eq!(e, format!("unknown command: {sub}")),
4415                other => panic!("{sub} still parses: {other:?}"),
4416            }
4417            assert!(
4418                !usage().contains(&format!("mushroomdb {sub} ")),
4419                "the help still names {sub}"
4420            );
4421        }
4422    }
4423
4424    /// The five hook bodies 0.6 installed parse, with any arguments, to the
4425    /// inert [`Command::RetiredHook`], so an upgraded machine that has not
4426    /// re-run `install` is not shown a hook error on every tool call. The help
4427    /// still names none of them.
4428    #[test]
4429    fn the_retired_hook_bodies_parse_as_inert() {
4430        for sub in ["touch", "intercept", "impact-hook", "enrich", "sync"] {
4431            for args in [
4432                vec![sub],
4433                vec![sub, "--auto"],
4434                vec![sub, "/tmp/db", "--whatever", "x"],
4435            ] {
4436                assert_eq!(parse_args(&args), Ok(Command::RetiredHook), "{args:?}");
4437            }
4438            assert!(
4439                !usage().contains(&format!("mushroomdb {sub}")),
4440                "the help still names {sub}"
4441            );
4442        }
4443    }
4444
4445    #[test]
4446    fn serve_default_addr_is_loopback_8080() {
4447        match parse_args(&["serve", "/tmp/db"]).unwrap() {
4448            Command::Serve { addr, .. } => {
4449                assert_eq!(
4450                    addr,
4451                    "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
4452                );
4453            }
4454            other => panic!("{other:?}"),
4455        }
4456    }
4457
4458    #[test]
4459    fn the_shutdown_plan_uses_the_nominal_split_with_room_and_scales_without() {
4460        let s = Duration::from_secs;
4461        // Default timeout, default backup bound: 25 + 8 + 22.
4462        let p = shutdown_plan(s(55), s(22));
4463        assert_eq!((p.drain, p.snapshot, p.backup), (s(25), s(8), s(22)));
4464        // More room goes to the snapshot.
4465        let p = shutdown_plan(s(100), s(22));
4466        assert_eq!((p.drain, p.snapshot, p.backup), (s(25), s(53), s(22)));
4467        // Less room: every phase shrinks, and the sum never exceeds the timeout.
4468        for total in [1, 2, 5, 10, 25, 40, 54] {
4469            for bound in [0, 1, 3, 22, 60] {
4470                let p = shutdown_plan(s(total), s(bound));
4471                assert!(
4472                    p.drain + p.snapshot + p.backup <= s(total),
4473                    "{total}/{bound}: {p:?}"
4474                );
4475                assert!(p.drain <= s(SHUTDOWN_DRAIN_SECS));
4476                if bound == 0 {
4477                    assert_eq!(p.backup, Duration::ZERO);
4478                } else {
4479                    assert!(p.backup <= s(bound).min(s(total)));
4480                    assert!(p.backup >= s(2).min(s(bound)).min(s(total)), "{p:?}");
4481                }
4482            }
4483        }
4484        // 25 s with backup: the backup keeps a real share.
4485        let p = shutdown_plan(s(25), s(22));
4486        assert!(p.backup >= s(9) && p.drain >= s(10), "{p:?}");
4487    }
4488
4489    #[test]
4490    fn serve_parses_the_bolt_flags_in_both_spellings() {
4491        let bolt = |args: &[&str]| match parse_args(args).unwrap() {
4492            Command::Serve { bolt, .. } => bolt,
4493            other => panic!("{other:?}"),
4494        };
4495        let flags = bolt(&[
4496            "serve",
4497            "/tmp/db",
4498            "--bolt-port",
4499            "7687",
4500            "--bolt-tls",
4501            "--bolt-advertised-host=db.example.test",
4502            "--bolt-advertised-port",
4503            "17687",
4504            "--bolt-database=orders",
4505            "--bolt-max-connections",
4506            "8",
4507            "--bolt-tx-timeout-secs=12",
4508            "--bookmark-timeout-secs",
4509            "3",
4510        ]);
4511        assert_eq!(
4512            flags,
4513            serve_bolt::BoltFlags {
4514                port: Some(7687),
4515                tls: Some(true),
4516                advertised_host: Some("db.example.test".into()),
4517                advertised_port: Some(17687),
4518                database: Some("orders".into()),
4519                max_connections: Some(8),
4520                tx_timeout: Some(Duration::from_secs(12)),
4521                bookmark_timeout: Some(Duration::from_secs(3)),
4522            }
4523        );
4524        assert_eq!(
4525            bolt(&["serve", "/tmp/db", "--bolt-port=0", "--bolt-plaintext"]).tls,
4526            Some(false)
4527        );
4528        for bad in [
4529            &["serve", "/tmp/db", "--bolt-port", "70000"][..],
4530            &["serve", "/tmp/db", "--bolt-port"],
4531            &["serve", "/tmp/db", "--bolt-tls", "--bolt-plaintext"],
4532            &["serve", "/tmp/db", "--bolt-tx-timeout-secs", "0"],
4533            &["serve", "/tmp/db", "--bookmark-timeout-secs=x"],
4534            &["serve", "/tmp/db", "--bolt-max-connections", "-1"],
4535        ] {
4536            assert!(parse_args(bad).is_err(), "{bad:?}");
4537        }
4538    }
4539
4540    #[test]
4541    fn serve_parses_the_tx_flags_in_both_spellings_and_refuses_zero() {
4542        let tx = |args: &[&str]| match parse_args(args).unwrap() {
4543            Command::Serve { tx, .. } => tx,
4544            other => panic!("{other:?}"),
4545        };
4546        assert_eq!(tx(&["serve", "/tmp/db"]), TxFlags::default());
4547        assert_eq!(
4548            tx(&[
4549                "serve",
4550                "/tmp/db",
4551                "--tx-max",
4552                "7",
4553                "--tx-timeout-secs=4",
4554                "--tx-max-lifetime-secs",
4555                "60",
4556            ]),
4557            TxFlags {
4558                max_open: NonZeroU64::new(7),
4559                idle_timeout_secs: NonZeroU64::new(4),
4560                max_lifetime_secs: NonZeroU64::new(60),
4561            }
4562        );
4563        for bad in [
4564            &["serve", "/tmp/db", "--tx-max", "0"][..],
4565            &["serve", "/tmp/db", "--tx-max"],
4566            &["serve", "/tmp/db", "--tx-timeout-secs=0"],
4567            &["serve", "/tmp/db", "--tx-max-lifetime-secs", "-5"],
4568            &["serve", "/tmp/db", "--tx-timeout-secs", "soon"],
4569        ] {
4570            assert!(parse_args(bad).is_err(), "{bad:?}");
4571        }
4572    }
4573
4574    #[test]
4575    fn a_tx_flag_wins_over_its_variable_and_an_unset_flag_leaves_the_variable() {
4576        let env = |name: &str| match name {
4577            server::TX_MAX_ENV => Some("5".to_string()),
4578            server::TX_TIMEOUT_ENV => Some("9".to_string()),
4579            server::TX_MAX_LIFETIME_ENV => Some("99".to_string()),
4580            _ => None,
4581        };
4582        let s = Duration::from_secs;
4583        let from_env = resolve_tx_limits(&TxFlags::default(), &env);
4584        assert_eq!(
4585            (
4586                from_env.max_open,
4587                from_env.idle_timeout,
4588                from_env.max_lifetime
4589            ),
4590            (5, s(9), s(99))
4591        );
4592        let flags = TxFlags {
4593            max_open: NonZeroU64::new(2),
4594            idle_timeout_secs: None,
4595            max_lifetime_secs: NonZeroU64::new(40),
4596        };
4597        let mixed = resolve_tx_limits(&flags, &env);
4598        assert_eq!(
4599            (mixed.max_open, mixed.idle_timeout, mixed.max_lifetime),
4600            (2, s(9), s(40))
4601        );
4602        // Neither: the defaults.
4603        assert_eq!(
4604            resolve_tx_limits(&TxFlags::default(), &|_| None),
4605            server::TxLimits::default()
4606        );
4607        // A flag given: an invalid variable beside it is not read.
4608        let invalid = |name: &str| (name == server::TX_MAX_ENV).then(|| "lots".to_string());
4609        let given = TxFlags {
4610            max_open: NonZeroU64::new(3),
4611            ..TxFlags::default()
4612        };
4613        assert_eq!(resolve_tx_limits(&given, &invalid).max_open, 3);
4614    }
4615
4616    #[test]
4617    fn serve_shutdown_timeout_parses_seconds_and_defaults_to_55() {
4618        match parse_args(&["serve", "/tmp/db"]).unwrap() {
4619            Command::Serve {
4620                shutdown_timeout, ..
4621            } => assert_eq!(shutdown_timeout, Duration::from_secs(55)),
4622            other => panic!("{other:?}"),
4623        }
4624        match parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs", "90"]).unwrap() {
4625            Command::Serve {
4626                shutdown_timeout, ..
4627            } => assert_eq!(shutdown_timeout, Duration::from_secs(90)),
4628            other => panic!("{other:?}"),
4629        }
4630        match parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs=7"]).unwrap() {
4631            Command::Serve {
4632                shutdown_timeout, ..
4633            } => assert_eq!(shutdown_timeout, Duration::from_secs(7)),
4634            other => panic!("{other:?}"),
4635        }
4636        for bad in ["0", "nope"] {
4637            let err =
4638                parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs", bad]).unwrap_err();
4639            assert!(err.contains("shutdown-timeout-secs"), "{err}");
4640        }
4641        assert!(parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs"]).is_err());
4642    }
4643
4644    #[test]
4645    fn serve_snapshot_every_parses_seconds() {
4646        match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
4647            Command::Serve { snapshot_every, .. } => {
4648                assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
4649            }
4650            other => panic!("{other:?}"),
4651        }
4652        match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
4653            Command::Serve { snapshot_every, .. } => {
4654                assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
4655            }
4656            other => panic!("{other:?}"),
4657        }
4658        match parse_args(&["serve", "/tmp/db"]).unwrap() {
4659            Command::Serve { snapshot_every, .. } => {
4660                assert_eq!(snapshot_every, None);
4661            }
4662            other => panic!("{other:?}"),
4663        }
4664        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
4665        assert!(
4666            err.contains("snapshot-every"),
4667            "missing value should name the flag, got {err}"
4668        );
4669        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
4670        assert!(
4671            err.contains("snapshot-every"),
4672            "zero should be rejected, got {err}"
4673        );
4674        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
4675        assert!(
4676            err.contains("snapshot-every"),
4677            "invalid value should name the flag, got {err}"
4678        );
4679    }
4680
4681    #[test]
4682    fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
4683        // parse succeeds; main() enforces the bind rule. Token is stored.
4684        match parse_args(&[
4685            "serve",
4686            "/tmp/db",
4687            "--addr",
4688            "0.0.0.0:8080",
4689            "--token",
4690            "s3cret",
4691        ])
4692        .unwrap()
4693        {
4694            Command::Serve { token, addr, .. } => {
4695                assert_eq!(token.as_deref(), Some("s3cret"));
4696                assert_eq!(addr.ip().to_string(), "0.0.0.0");
4697            }
4698            other => panic!("{other:?}"),
4699        }
4700    }
4701
4702    #[test]
4703    fn parse_build_index_both_forms_and_a_missing_value() {
4704        match parse_args(&["build-index", "/tmp/db"]).unwrap() {
4705            Command::BuildIndex { db_dir, rule } => {
4706                assert_eq!(db_dir, PathBuf::from("/tmp/db"));
4707                assert_eq!(rule, None);
4708            }
4709            other => panic!("{other:?}"),
4710        }
4711        for args in [
4712            vec!["build-index", "/tmp/db", "--rule", "sim"],
4713            vec!["build-index", "/tmp/db", "--rule=sim"],
4714        ] {
4715            match parse_args(&args).unwrap() {
4716                Command::BuildIndex { db_dir, rule } => {
4717                    assert_eq!(db_dir, PathBuf::from("/tmp/db"));
4718                    assert_eq!(rule.as_deref(), Some("sim"), "{args:?}");
4719                }
4720                other => panic!("{other:?}"),
4721            }
4722        }
4723        assert_eq!(
4724            parse_args(&["build-index", "/tmp/db", "--rule"]).unwrap_err(),
4725            "--rule requires a value"
4726        );
4727        assert_eq!(
4728            parse_args(&["build-index", "/tmp/db", "--rule="]).unwrap_err(),
4729            "--rule requires a value"
4730        );
4731        assert_eq!(
4732            parse_args(&["build-index"]).unwrap_err(),
4733            "build-index requires <db-dir>"
4734        );
4735        assert!(usage().contains("mushroomdb build-index <db-dir> [--rule <name>]"));
4736    }
4737
4738    #[test]
4739    fn parse_snapshot_and_query() {
4740        // Archiving is what a snapshot does unless the user says otherwise.
4741        for (args, want) in [
4742            (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
4743            (
4744                vec!["snapshot", "/tmp/db", "--archive-wal"],
4745                WalDisposition::Archive,
4746            ),
4747            (
4748                vec!["snapshot", "/tmp/db", "--keep-wal"],
4749                WalDisposition::Keep,
4750            ),
4751            (
4752                vec!["snapshot", "/tmp/db", "--truncate"],
4753                WalDisposition::Truncate,
4754            ),
4755        ] {
4756            match parse_args(&args).unwrap() {
4757                Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
4758                other => panic!("{other:?}"),
4759            }
4760        }
4761        match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
4762            Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
4763            other => panic!("{other:?}"),
4764        }
4765        match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
4766            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
4767            other => panic!("{other:?}"),
4768        }
4769        match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
4770            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
4771            other => panic!("{other:?}"),
4772        }
4773        let text = usage();
4774        assert!(
4775            text.contains("query"),
4776            "usage should mention query, got:\n{text}"
4777        );
4778        assert!(
4779            text.contains("snapshot"),
4780            "usage should mention snapshot, got:\n{text}"
4781        );
4782    }
4783
4784    /// Binding: the help describes the tree 0.7 ships. One tool surface, the
4785    /// association tools listed on every store, and one brief — the memory
4786    /// schema's. The code-graph listing and the repository brief are gone.
4787    #[test]
4788    fn usage_describes_one_surface_and_the_memory_brief() {
4789        let text = usage();
4790        assert!(
4791            text.contains(
4792                "--all-tools lists every served tool; the default lists the association tools on every store,\n\
4793                 \x20                    a store `ingest-git` built included"
4794            ),
4795            "mcp help line, got:\n{text}"
4796        );
4797        assert!(
4798            text.contains(
4799                "mushroomdb brief <db-dir>|--auto    hook body: the store's schema in one block — labels,\n\
4800                 \x20                                     edge types, history depth, roles and one worked call per\n\
4801                 \x20                                     question kind; byte-stable, so a session host caches it once"
4802            ),
4803            "brief help line, got:\n{text}"
4804        );
4805        for stale in [
4806            "28 tools",
4807            "explore, query, stats",
4808            "most called symbols",
4809            "synced sha",
4810        ] {
4811            assert!(!text.contains(stale), "usage still says {stale:?}");
4812        }
4813    }
4814
4815    #[test]
4816    fn parse_keys_subcommands() {
4817        match parse_args(&["keys", "add", "/d", "app", "--tier", "write"]).unwrap() {
4818            Command::KeysAdd {
4819                name, tier, role, ..
4820            } => assert_eq!((name.as_str(), tier.as_str(), role), ("app", "write", None)),
4821            other => panic!("{other:?}"),
4822        }
4823        match parse_args(&["keys", "add", "/d", "t", "--role", "r", "--tier", "read"]).unwrap() {
4824            Command::KeysAdd { tier, role, .. } => {
4825                assert_eq!((tier.as_str(), role.as_deref()), ("read", Some("r")))
4826            }
4827            other => panic!("{other:?}"),
4828        }
4829        assert!(matches!(
4830            parse_args(&["keys", "hash"]),
4831            Ok(Command::KeysHash)
4832        ));
4833        for bad in [
4834            vec!["keys"],
4835            vec!["keys", "hash", "x"],
4836            vec!["keys", "add", "/d", "app"],
4837            vec!["keys", "add", "/d", "--tier", "write"],
4838            vec!["keys", "add", "/d", "app", "--tier", "read"],
4839            vec!["keys", "add", "/d", "app", "--tier", "write", "--role", "r"],
4840            vec!["keys", "add", "/d", "app", "--tier", "root"],
4841            vec![
4842                "keys", "add", "/d", "app", "--tier", "write", "--tier", "read",
4843            ],
4844            vec!["keys", "add", "/d", "app", "--tier", "write", "--bogus"],
4845        ] {
4846            assert!(parse_args(&bad).is_err(), "{bad:?}");
4847        }
4848    }
4849
4850    #[test]
4851    fn usage_lists_every_subcommand() {
4852        let text = usage();
4853        for word in [
4854            "serve",
4855            "mcp",
4856            "stats",
4857            "demo",
4858            "query",
4859            "snapshot",
4860            "--keep-wal",
4861            "mushroomdb",
4862            "--ui",
4863            "--no-ui",
4864            "--demo-if-empty",
4865            "--token",
4866            "--role-token TOKEN:ROLE",
4867            "--tls-cert <pem> --tls-key <pem>",
4868            "a build with the `tls` feature",
4869            "--snapshot-every",
4870        ] {
4871            assert!(
4872                text.contains(word),
4873                "usage should mention {word}, got:\n{text}"
4874            );
4875        }
4876    }
4877
4878    #[test]
4879    fn validate_ui_dir_requires_index_html() {
4880        let missing = tmp("ui-missing");
4881        let err = super::validate_ui_dir(&missing).expect_err("missing dir");
4882        assert!(
4883            err.contains("does not exist"),
4884            "missing dir error, got {err}"
4885        );
4886
4887        let empty = tmp("ui-empty");
4888        std::fs::create_dir_all(&empty).unwrap();
4889        let err = super::validate_ui_dir(&empty).expect_err("no index");
4890        assert!(
4891            err.contains("index.html"),
4892            "missing index.html error, got {err}"
4893        );
4894
4895        let ok = tmp("ui-ok");
4896        std::fs::create_dir_all(&ok).unwrap();
4897        std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
4898        let got = super::validate_ui_dir(&ok).expect("valid ui dir");
4899        assert_eq!(got, ok);
4900    }
4901
4902    #[test]
4903    fn maybe_run_demo_if_empty_seeds_then_skips() {
4904        let dir = tmp("boot-empty");
4905        let first = super::maybe_run_demo_if_empty(&dir)
4906            .expect("empty dir demos")
4907            .expect("Some(DemoOutcome)");
4908        assert_eq!(first.stats.nodes_live, 60);
4909        let db = SharedDb::open(&dir).expect("reopen");
4910        assert!(db.read().has_node("person-01"));
4911        let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
4912        assert!(
4913            second.is_none(),
4914            "second boot must not re-demo a populated volume"
4915        );
4916
4917        let occupied = tmp("boot-occupied");
4918        std::fs::create_dir_all(&occupied).unwrap();
4919        std::fs::write(occupied.join("keep-me"), b"x").unwrap();
4920        let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
4921        assert!(skipped.is_none());
4922        assert_eq!(
4923            std::fs::read(occupied.join("keep-me")).unwrap(),
4924            b"x",
4925            "existing volume contents must be untouched"
4926        );
4927    }
4928
4929    #[test]
4930    fn demo_builder_is_deterministic_and_refuses_second_run() {
4931        let dir = tmp("demo");
4932        let out = run_demo(&dir).expect("first demo run");
4933
4934        assert_eq!(
4935            out.stats.nodes_live, 60,
4936            "10 orgs + 20 projects + 30 people"
4937        );
4938        assert_eq!(out.stats.nodes_tombstoned, 0);
4939        // Auto-FK: 20 project→org + 30 person→org + 30 person→project = 80.
4940        // FIT: each of 30 people matches home (Jaccard 1.0) and two adjacent
4941        // projects (3-skill window shifted ±1 → Jaccard 2/4 = 0.5) = 30*3 = 90.
4942        // founded_within: |year_i − year_j| ≤ 2 on 2010+(i-1) → 17 pairs × 2 = 34.
4943        // nearby_office: 4 city clusters (NYC/SF/London/Paris) → 8 pairs × 2 = 16.
4944        // similar_interests: dim-8 groups → 57 pairs × 2 = 114.
4945        // Total: 80 + 90 + 34 + 16 + 114 = 334.
4946        assert_eq!(out.stats.edges, 334);
4947        assert_eq!(
4948            out.stats.rules.len(),
4949            7,
4950            "3 auto-FK + overlap + numeric + geo + vector"
4951        );
4952        let fit = out
4953            .stats
4954            .rules
4955            .iter()
4956            .find(|r| r.name == "skill_fit")
4957            .expect("skill_fit");
4958        assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
4959        let founded = out
4960            .stats
4961            .rules
4962            .iter()
4963            .find(|r| r.name == "founded_within")
4964            .expect("founded_within");
4965        assert_eq!(founded.edges, 34);
4966        let nearby = out
4967            .stats
4968            .rules
4969            .iter()
4970            .find(|r| r.name == "nearby_office")
4971            .expect("nearby_office");
4972        assert_eq!(nearby.edges, 16);
4973        let similar = out
4974            .stats
4975            .rules
4976            .iter()
4977            .find(|r| r.name == "similar_interests")
4978            .expect("similar_interests");
4979        assert_eq!(similar.edges, 114);
4980
4981        let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
4982        names.sort_unstable();
4983        assert_eq!(
4984            names,
4985            vec![
4986                "auto_fk_person_org_id",
4987                "auto_fk_person_project_id",
4988                "auto_fk_project_org_id",
4989                "founded_within",
4990                "nearby_office",
4991                "similar_interests",
4992                "skill_fit",
4993            ]
4994        );
4995
4996        // `recall` needs a name index; the memory schema applied at store
4997        // creation adds it, plus every other memory-entity label — no nodes,
4998        // edges or rules.
4999        let db = SharedDb::open(&dir).expect("reopen demo");
5000        assert_eq!(
5001            db.read().fulltext_pairs(),
5002            vec![
5003                ("Concept".to_string(), "name".to_string()),
5004                ("Concept".to_string(), "summary".to_string()),
5005                ("Entity".to_string(), "name".to_string()),
5006                ("Event".to_string(), "name".to_string()),
5007                ("Note".to_string(), "text".to_string()),
5008                ("Org".to_string(), "name".to_string()),
5009                ("Person".to_string(), "name".to_string()),
5010                ("Project".to_string(), "name".to_string()),
5011            ]
5012        );
5013
5014        let mut auto = out.auto_fk_rules.clone();
5015        auto.sort();
5016        assert_eq!(
5017            auto,
5018            vec![
5019                "auto_fk_person_org_id".to_string(),
5020                "auto_fk_person_project_id".to_string(),
5021                "auto_fk_project_org_id".to_string(),
5022            ]
5023        );
5024
5025        assert!(
5026            !out.sample_result.is_empty(),
5027            "sample Cypher query must return rows"
5028        );
5029        assert!(
5030            out.sample_query.contains("ORDER BY score DESC"),
5031            "sample query must rank by score, got {}",
5032            out.sample_query
5033        );
5034        let scores: Vec<f64> = (0..out.sample_result.len())
5035            .map(|i| match out.sample_result.get(i, "score") {
5036                Some(Value::Float(f)) => *f,
5037                other => panic!("score col should be Float, got {other:?}"),
5038            })
5039            .collect();
5040        let distinct: std::collections::BTreeSet<u64> =
5041            scores.iter().map(|s| s.to_bits()).collect();
5042        assert!(
5043            distinct.len() >= 2,
5044            "sample results must be visibly ranked, got {scores:?}"
5045        );
5046        for w in scores.windows(2) {
5047            assert!(
5048                w[0] >= w[1],
5049                "scores must be non-increasing, got {scores:?}"
5050            );
5051        }
5052        assert!(
5053            !out.explanations.is_empty(),
5054            "explain(person-01, proj-01) must find the derived edges"
5055        );
5056
5057        let db = SharedDb::open(&dir).expect("reopen demo");
5058        assert_eq!(
5059            directed_pairs(&db, "FOUNDED_WITHIN"),
5060            [
5061                ("org-01", "org-02"),
5062                ("org-01", "org-03"),
5063                ("org-02", "org-01"),
5064                ("org-02", "org-03"),
5065                ("org-02", "org-04"),
5066                ("org-03", "org-01"),
5067                ("org-03", "org-02"),
5068                ("org-03", "org-04"),
5069                ("org-03", "org-05"),
5070                ("org-04", "org-02"),
5071                ("org-04", "org-03"),
5072                ("org-04", "org-05"),
5073                ("org-04", "org-06"),
5074                ("org-05", "org-03"),
5075                ("org-05", "org-04"),
5076                ("org-05", "org-06"),
5077                ("org-05", "org-07"),
5078                ("org-06", "org-04"),
5079                ("org-06", "org-05"),
5080                ("org-06", "org-07"),
5081                ("org-06", "org-08"),
5082                ("org-07", "org-05"),
5083                ("org-07", "org-06"),
5084                ("org-07", "org-08"),
5085                ("org-07", "org-09"),
5086                ("org-08", "org-06"),
5087                ("org-08", "org-07"),
5088                ("org-08", "org-09"),
5089                ("org-08", "org-10"),
5090                ("org-09", "org-07"),
5091                ("org-09", "org-08"),
5092                ("org-09", "org-10"),
5093                ("org-10", "org-08"),
5094                ("org-10", "org-09"),
5095            ]
5096            .into_iter()
5097            .map(|(a, b)| (a.to_string(), b.to_string()))
5098            .collect::<BTreeSet<_>>()
5099        );
5100        assert_eq!(
5101            directed_pairs(&db, "NEARBY_OFFICE"),
5102            [
5103                ("org-01", "org-07"),
5104                ("org-01", "org-10"),
5105                ("org-02", "org-09"),
5106                ("org-03", "org-08"),
5107                ("org-04", "org-05"),
5108                ("org-04", "org-06"),
5109                ("org-05", "org-04"),
5110                ("org-05", "org-06"),
5111                ("org-06", "org-04"),
5112                ("org-06", "org-05"),
5113                ("org-07", "org-01"),
5114                ("org-07", "org-10"),
5115                ("org-08", "org-03"),
5116                ("org-09", "org-02"),
5117                ("org-10", "org-01"),
5118                ("org-10", "org-07"),
5119            ]
5120            .into_iter()
5121            .map(|(a, b)| (a.to_string(), b.to_string()))
5122            .collect::<BTreeSet<_>>()
5123        );
5124        assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
5125        let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
5126        assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
5127        assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
5128        assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
5129
5130        let err = run_demo(&dir).expect_err("second run into the same dir");
5131        let msg = err.to_string().to_lowercase();
5132        assert!(
5133            msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
5134            "refuse message must mention non-empty dir, got {err}"
5135        );
5136        assert!(
5137            msg.contains("hidden"),
5138            "refuse message must mention hidden files, got {err}"
5139        );
5140
5141        let _ = std::fs::remove_dir_all(&dir);
5142    }
5143
5144    #[test]
5145    fn run_snapshot_writes_snapshot_bin() {
5146        let dir = tmp("snapshot-cli");
5147        {
5148            let mut db = GraphDb::open(&dir).expect("open");
5149            db.insert_node("Person", "alice", vec![]).expect("insert");
5150        }
5151        assert!(
5152            !dir.join("snapshot.bin").exists(),
5153            "GraphDb Drop must not snapshot"
5154        );
5155        let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
5156        assert!(
5157            dir.join("snapshot.bin").is_file(),
5158            "run_snapshot must write snapshot.bin"
5159        );
5160        assert!(
5161            out.contains("snapshot.bin"),
5162            "snapshot output should mention snapshot.bin, got {out}"
5163        );
5164        let db = GraphDb::open(&dir).expect("reopen");
5165        assert!(db.has_node("alice"), "reopen after snapshot must recover");
5166        let _ = std::fs::remove_dir_all(&dir);
5167    }
5168
5169    /// Every snapshot mushroomdb takes on its own — the ingest's, a
5170    /// `serve --snapshot-every` tick, the graceful-shutdown one — archives the
5171    /// WAL, so a store never loses its past to a write nobody asked for. Only
5172    /// an explicit `--truncate` ends that reach.
5173    #[test]
5174    fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
5175        let dir = tmp("snapshot-archive");
5176        {
5177            let mut db = GraphDb::open(&dir).expect("open");
5178            db.insert_node("Person", "alice", vec![]).expect("insert");
5179        }
5180        let before = mushroomdb::wal_commit_count_at(&dir).expect("count");
5181        assert!(before > 0, "the insert is a commit");
5182
5183        // Exactly the call `serve` makes on a tick and on shutdown.
5184        {
5185            let shared = SharedDb::open(&dir).expect("open");
5186            snapshot_shared(&shared).expect("snapshot");
5187        }
5188
5189        let archives = || {
5190            std::fs::read_dir(&dir)
5191                .expect("read dir")
5192                .filter_map(Result::ok)
5193                .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5194                .count()
5195        };
5196        assert_eq!(archives(), 1, "the WAL was archived, not dropped");
5197        assert!(
5198            dir.join("wal.genesis").is_file(),
5199            "the genesis marker is what lets asof reach an archived commit"
5200        );
5201        {
5202            let db = GraphDb::open(&dir).expect("reopen");
5203            assert!(db.has_node("alice"));
5204            assert!(
5205                !db.node_history("alice").expect("history").items.is_empty(),
5206                "the insert is still explainable"
5207            );
5208        }
5209        assert!(
5210            GraphDb::open_at(&dir, before - 1).is_ok(),
5211            "asof still reaches a commit the snapshot folded in"
5212        );
5213
5214        // A truncating snapshot is the destructive one, and only the user asks
5215        // for it.
5216        run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
5217        assert!(
5218            !dir.join("wal.genesis").exists(),
5219            "truncating ends asof's reach into the archives"
5220        );
5221        let db = GraphDb::open(&dir).expect("reopen");
5222        assert!(
5223            db.has_node("alice"),
5224            "the data survives; only the past goes"
5225        );
5226        let _ = std::fs::remove_dir_all(&dir);
5227    }
5228
5229    /// Automatic snapshots keep every archive: history is the thing archives
5230    /// exist for, and nothing deletes it unless a caller asks with
5231    /// `--retention`.
5232    #[test]
5233    fn automatic_snapshots_keep_every_archive() {
5234        let dir = tmp("snapshot-retention");
5235        let archives = |d: &Path| {
5236            std::fs::read_dir(d)
5237                .expect("read dir")
5238                .filter_map(Result::ok)
5239                .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5240                .count()
5241        };
5242
5243        // Ten rounds of "commit something, then take the snapshot the ingest,
5244        // the sync hook and the server tick all take".
5245        let rounds = 10;
5246        for i in 0..rounds {
5247            {
5248                let mut db = GraphDb::open(&dir).expect("open");
5249                db.insert_node("Person", &format!("p{i}"), vec![])
5250                    .expect("insert");
5251            }
5252            let shared = SharedDb::open(&dir).expect("open shared");
5253            snapshot_shared(&shared).expect("snapshot");
5254        }
5255
5256        assert_eq!(
5257            archives(&dir),
5258            rounds,
5259            "an automatic snapshot must not delete an archive"
5260        );
5261
5262        let db = GraphDb::open(&dir).expect("reopen");
5263        assert_eq!(
5264            db.wal_horizon_floor(),
5265            0,
5266            "nothing was pruned, so the floor stays at 0"
5267        );
5268        for i in 0..rounds {
5269            assert!(db.has_node(&format!("p{i}")), "p{i} survived");
5270        }
5271        assert!(
5272            !db.node_history("p0").expect("history").items.is_empty(),
5273            "the oldest history is still there: that is the point of the default"
5274        );
5275        assert_eq!(db.node_history("p0").expect("history").horizon, 0);
5276        drop(db);
5277
5278        // The explicit command is the user's, and keeps everything unless the
5279        // user says otherwise.
5280        let manual = tmp("snapshot-retention-manual");
5281        for i in 0..3 {
5282            {
5283                let mut db = GraphDb::open(&manual).expect("open");
5284                db.insert_node("Person", &format!("p{i}"), vec![])
5285                    .expect("insert");
5286            }
5287            run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
5288        }
5289        assert_eq!(
5290            archives(&manual),
5291            3,
5292            "`mushroomdb snapshot` with no --retention keeps every archive"
5293        );
5294
5295        let _ = std::fs::remove_dir_all(&dir);
5296        let _ = std::fs::remove_dir_all(&manual);
5297    }
5298
5299    /// `--retention N` still bounds the archives when a caller asks for it —
5300    /// the automatic default changed, not the escape hatch.
5301    #[test]
5302    fn retention_is_still_available_when_configured() {
5303        let dir = tmp("retention-configured");
5304        for i in 0..5 {
5305            {
5306                let mut db = GraphDb::open(&dir).unwrap();
5307                db.insert_node("Person", &format!("p{i}"), vec![]).unwrap();
5308            }
5309            run_snapshot(&dir, WalDisposition::Archive, Some(2)).unwrap();
5310        }
5311        let archives = std::fs::read_dir(&dir)
5312            .expect("read dir")
5313            .filter_map(Result::ok)
5314            .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5315            .count();
5316        assert_eq!(archives, 2, "--retention 2 still prunes to two");
5317        assert!(GraphDb::open(&dir).unwrap().wal_horizon_floor() > 0);
5318        let _ = std::fs::remove_dir_all(&dir);
5319    }
5320
5321    #[test]
5322    fn run_query_formats_like_asof() {
5323        let dir = tmp("query-cli");
5324        {
5325            let mut db = GraphDb::open(&dir).expect("open");
5326            db.insert_node(
5327                "Person",
5328                "alice",
5329                vec![("id".into(), Value::Str("alice".into()))],
5330            )
5331            .expect("insert");
5332        }
5333        let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id", None, None).expect("query");
5334        assert!(out.contains("columns:"), "got {out}");
5335        assert!(out.contains("id=alice"), "got {out}");
5336        let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})", None, None).expect("write");
5337        let db = GraphDb::open(&dir).expect("reopen");
5338        assert!(db.has_node("bob"), "query_write must persist CREATE");
5339        let _ = std::fs::remove_dir_all(&dir);
5340    }
5341
5342    /// The as-of header says how far back history reaches, and counts every
5343    /// commit the store still holds — including the archived ones the live
5344    /// WAL no longer carries.
5345    #[test]
5346    fn asof_header_names_the_horizon() {
5347        let dir = tmp("asof-horizon");
5348        // Ten rounds of "write, then snapshot with an explicit retention": the
5349        // bound prunes the oldest archives and the floor advances past 0. The
5350        // automatic path no longer prunes on its own, so the horizon here is
5351        // built with `--retention` rather than the automatic default.
5352        for i in 0..10 {
5353            {
5354                let mut db = GraphDb::open(&dir).expect("open");
5355                db.insert_node("Person", &format!("p{i}"), vec![])
5356                    .expect("insert");
5357            }
5358            run_snapshot(&dir, WalDisposition::Archive, Some(2)).expect("snapshot");
5359        }
5360        // One more write after the last snapshot: that commit lives in the live
5361        // WAL, which is what `asof` can still reconstruct on a pruned store.
5362        {
5363            let mut db = GraphDb::open(&dir).expect("open");
5364            db.insert_node("Person", "after", vec![]).expect("insert");
5365        }
5366        let (floor, total) = {
5367            let db = GraphDb::open(&dir).expect("reopen");
5368            (
5369                db.wal_horizon_floor(),
5370                db.wal_total_commits().expect("total"),
5371            )
5372        };
5373        assert!(floor > 0, "the retention must have pruned something");
5374
5375        let out = run_asof(&dir, Some(total - 1), None, None, None).expect("asof");
5376        assert_eq!(
5377            out.trim(),
5378            format!(
5379                "as-of commit {} of {total} (history reaches back to commit {floor})",
5380                total - 1
5381            ),
5382            "the header must name the horizon it can reach"
5383        );
5384        let _ = std::fs::remove_dir_all(&dir);
5385
5386        // A store that has pruned nothing reads exactly as it always did.
5387        let clean = tmp("asof-clean");
5388        {
5389            let mut db = GraphDb::open(&clean).expect("open");
5390            db.insert_node("Person", "a", vec![]).expect("insert");
5391        }
5392        assert_eq!(
5393            run_asof(&clean, Some(0), None, None, None)
5394                .expect("asof")
5395                .trim(),
5396            "as-of commit 0 of 1"
5397        );
5398        let _ = std::fs::remove_dir_all(&clean);
5399    }
5400
5401    /// `stats` says whether history is complete, and where it starts when it
5402    /// is not.
5403    #[test]
5404    fn format_stats_says_how_far_back_history_reaches() {
5405        let dir = tmp("stats-horizon");
5406        {
5407            let mut db = GraphDb::open(&dir).expect("open");
5408            db.insert_node("Person", "a", vec![]).expect("insert");
5409        }
5410        let text = format_stats(&read_stats(&dir).expect("stats"));
5411        assert!(
5412            text.contains("history: complete (nothing pruned)"),
5413            "an unpruned store says so, got:\n{text}"
5414        );
5415        let _ = std::fs::remove_dir_all(&dir);
5416
5417        let pruned = tmp("stats-horizon-pruned");
5418        for i in 0..10 {
5419            {
5420                let mut db = GraphDb::open(&pruned).expect("open");
5421                db.insert_node("Person", &format!("p{i}"), vec![])
5422                    .expect("insert");
5423            }
5424            run_snapshot(&pruned, WalDisposition::Archive, Some(2)).expect("snapshot");
5425        }
5426        let stats = read_stats(&pruned).expect("stats");
5427        assert!(stats.history_floor > 0, "the retention must have pruned");
5428        assert!(
5429            format_stats(&stats).contains(&format!(
5430                "history: reaches back to commit {}",
5431                stats.history_floor
5432            )),
5433            "a pruned store names its floor"
5434        );
5435        let _ = std::fs::remove_dir_all(&pruned);
5436
5437        // A truncating snapshot discards history too, and says so the same way.
5438        let truncated = tmp("stats-horizon-truncated");
5439        {
5440            let mut db = GraphDb::open(&truncated).expect("open");
5441            db.insert_node("Person", "a", vec![]).expect("insert");
5442            db.insert_node("Person", "b", vec![]).expect("insert");
5443        }
5444        run_snapshot(&truncated, WalDisposition::Truncate, None).expect("truncate");
5445        let stats = read_stats(&truncated).expect("stats");
5446        assert_eq!(stats.history_floor, 2, "two commits were discarded");
5447        assert!(
5448            format_stats(&stats).contains("history: reaches back to commit 2"),
5449            "a truncated store names its floor, got:\n{}",
5450            format_stats(&stats)
5451        );
5452        let _ = std::fs::remove_dir_all(&truncated);
5453    }
5454
5455    #[test]
5456    fn format_stats_contains_counts() {
5457        let dir = tmp("stats-smoke");
5458        let out = run_demo(&dir).expect("demo for stats smoke");
5459        let text = format_stats(&out.stats);
5460        assert!(
5461            text.contains("60"),
5462            "stats output should include live node count, got:\n{text}"
5463        );
5464        assert!(
5465            text.contains("334"),
5466            "stats output should include edge count, got:\n{text}"
5467        );
5468        assert!(
5469            text.to_lowercase().contains("node"),
5470            "stats output should mention nodes, got:\n{text}"
5471        );
5472        assert!(
5473            text.to_lowercase().contains("edge"),
5474            "stats output should mention edges, got:\n{text}"
5475        );
5476        let _ = std::fs::remove_dir_all(&dir);
5477    }
5478
5479    // ── backup CLI tests ──────────────────────────────────────────────────────
5480
5481    #[test]
5482    fn parse_backup_round_trip() {
5483        let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
5484        match r {
5485            Ok(Command::Backup { db_dir, dest }) => {
5486                assert_eq!(db_dir, PathBuf::from("/db/dir"));
5487                assert_eq!(dest, PathBuf::from("/backup/dest"));
5488            }
5489            other => panic!("backup parse, got {other:?}"),
5490        }
5491    }
5492
5493    #[test]
5494    fn parse_restore_takes_from_at_and_into() {
5495        assert_eq!(
5496            parse_args(&["restore", "--from", "s3://b/p"]),
5497            Ok(Command::RestoreS3 {
5498                from: "s3://b/p".into(),
5499                at: None,
5500                into: None,
5501            })
5502        );
5503        assert_eq!(
5504            parse_args(&[
5505                "restore",
5506                "--from=minio://b/p",
5507                "--at",
5508                "75",
5509                "--into",
5510                "/tmp/r",
5511            ]),
5512            Ok(Command::RestoreS3 {
5513                from: "minio://b/p".into(),
5514                at: Some("75".into()),
5515                into: Some(PathBuf::from("/tmp/r")),
5516            })
5517        );
5518        match parse_args(&["restore", "--from", "s3://b", "--at=2026-10-01T12:00:00Z"]) {
5519            Ok(Command::RestoreS3 { at, .. }) => {
5520                assert_eq!(at.as_deref(), Some("2026-10-01T12:00:00Z"))
5521            }
5522            other => panic!("restore parse, got {other:?}"),
5523        }
5524    }
5525
5526    #[test]
5527    fn parse_restore_refuses_what_it_cannot_use() {
5528        for (args, needle) in [
5529            (vec!["restore"], "--from"),
5530            (vec!["restore", "--into", "x"], "--from"),
5531            (
5532                vec!["restore", "--from", "s3://b", "--at", "yesterday"],
5533                "RFC 3339",
5534            ),
5535            (vec!["restore", "--from", "s3://b", "--at"], "missing value"),
5536            (
5537                vec!["restore", "--from", "s3://b", "extra"],
5538                "unexpected argument",
5539            ),
5540            (
5541                vec!["restore", "--from", "s3://b", "--force"],
5542                "unexpected flag",
5543            ),
5544        ] {
5545            let e = parse_args(&args).unwrap_err();
5546            assert!(e.contains(needle), "{args:?}: {e}");
5547        }
5548    }
5549
5550    #[test]
5551    fn restore_with_a_bad_url_fails_before_touching_anything() {
5552        let parent = tmp("restore-bad-url");
5553        let into = parent.join("restored");
5554        let e = run_restore_s3("gs://b/p", None, Some(&into)).unwrap_err();
5555        assert!(e.0.contains("--from"), "{e}");
5556        assert!(!into.exists());
5557    }
5558
5559    #[test]
5560    fn parse_backup_missing_dest_errors() {
5561        let r = parse_args(&["backup", "/db/dir"]);
5562        assert!(r.is_err(), "backup without <dest> should error");
5563        let e = r.unwrap_err();
5564        assert!(
5565            e.to_lowercase().contains("dest"),
5566            "error should mention dest, got: {e}"
5567        );
5568    }
5569
5570    #[test]
5571    fn parse_export_defaults_to_jsonl() {
5572        let r = parse_args(&["export", "/db/dir", "/export/dest"]);
5573        match r {
5574            Ok(Command::Export { format, .. }) => {
5575                assert_eq!(format, ExportFormat::Jsonl);
5576            }
5577            other => panic!("export parse, got {other:?}"),
5578        }
5579    }
5580
5581    #[test]
5582    fn parse_export_parquet_flag() {
5583        let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
5584        match r {
5585            Ok(Command::Export { format, .. }) => {
5586                assert_eq!(format, ExportFormat::Parquet);
5587            }
5588            other => panic!("export --format parquet parse, got {other:?}"),
5589        }
5590    }
5591
5592    #[test]
5593    fn parse_export_parquet_flag_eq() {
5594        let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
5595        match r {
5596            Ok(Command::Export { format, .. }) => {
5597                assert_eq!(format, ExportFormat::Parquet);
5598            }
5599            other => panic!("export --format=parquet parse, got {other:?}"),
5600        }
5601    }
5602
5603    #[test]
5604    fn run_backup_cli_produces_verified_report() {
5605        let src = tmp("cli-backup-src");
5606        let dst = tmp("cli-backup-dst");
5607        let _ = run_demo(&src).expect("demo");
5608        let report = run_backup(&src, &dst).expect("run_backup");
5609        assert!(report.verified, "backup must be verified");
5610        assert!(!report.files.is_empty());
5611        assert!(report.bytes > 0);
5612        let _ = std::fs::remove_dir_all(&src);
5613        let _ = std::fs::remove_dir_all(&dst);
5614    }
5615
5616    /// Build a one-node store at `dir` and snapshot it, so `backup_to` has a
5617    /// `snapshot.bin` to copy.
5618    fn seed_store(dir: &Path, key: &str) {
5619        let mut db = GraphDb::open(dir).expect("open seed store");
5620        db.insert_node("N", key, vec![]).expect("insert seed node");
5621        db.snapshot().expect("snapshot seed store");
5622    }
5623
5624    #[test]
5625    fn restore_from_seeds_an_empty_dir() {
5626        let src = tmp("restore-src");
5627        seed_store(&src, "a");
5628        let vault = tmp("restore-vault");
5629        run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5630
5631        let fresh = tmp("restore-fresh");
5632        match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
5633            RestoreOutcome::Restored { files, bytes, .. } => {
5634                assert!(
5635                    files.contains(&"snapshot.bin".to_string()),
5636                    "expected snapshot.bin among {files:?}"
5637                );
5638                assert!(bytes > 0, "expected a non-zero byte count");
5639            }
5640            other => panic!("expected Restored, got {other:?}"),
5641        }
5642        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5643
5644        let _ = std::fs::remove_dir_all(&src);
5645        let _ = std::fs::remove_dir_all(&vault);
5646        let _ = std::fs::remove_dir_all(&fresh);
5647    }
5648
5649    #[test]
5650    fn restore_from_a_backup_dir_itself() {
5651        let src = tmp("restore-direct-src");
5652        seed_store(&src, "a");
5653        let backup = tmp("restore-direct-backup");
5654        run_backup(&src, &backup).expect("backup");
5655
5656        let fresh = tmp("restore-direct-fresh");
5657        match restore_if_empty(&fresh, &backup).expect("restore_if_empty") {
5658            RestoreOutcome::Restored { from, .. } => assert_eq!(from, backup),
5659            other => panic!("expected Restored, got {other:?}"),
5660        }
5661        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5662
5663        let _ = std::fs::remove_dir_all(&src);
5664        let _ = std::fs::remove_dir_all(&backup);
5665        let _ = std::fs::remove_dir_all(&fresh);
5666    }
5667
5668    /// A store that has never snapshotted backs up as `wal.bin` and nothing
5669    /// else — `mushroomdb demo` then `mushroomdb backup` is exactly that — and
5670    /// it carries every commit. Ranking on `snapshot.bin` alone would skip it
5671    /// and start the server empty, which is the failure the flag exists to
5672    /// prevent.
5673    #[test]
5674    fn restore_from_a_wal_only_backup() {
5675        let src = tmp("restore-walonly-src");
5676        {
5677            let mut db = GraphDb::open(&src).expect("open src");
5678            db.insert_node("N", "a", vec![]).expect("insert");
5679        }
5680        assert!(
5681            !src.join("snapshot.bin").exists(),
5682            "test setup: src must not have snapshotted"
5683        );
5684        let vault = tmp("restore-walonly-vault");
5685        run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5686
5687        let fresh = tmp("restore-walonly-fresh");
5688        match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
5689            RestoreOutcome::Restored { files, .. } => assert!(
5690                files.contains(&"wal.bin".to_string()),
5691                "expected wal.bin among {files:?}"
5692            ),
5693            other => panic!("expected Restored, got {other:?}"),
5694        }
5695        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5696
5697        let _ = std::fs::remove_dir_all(&src);
5698        let _ = std::fs::remove_dir_all(&vault);
5699        let _ = std::fs::remove_dir_all(&fresh);
5700    }
5701
5702    #[test]
5703    fn restore_from_picks_latest_then_newest() {
5704        let older_src = tmp("restore-rank-old-src");
5705        seed_store(&older_src, "old");
5706        let newer_src = tmp("restore-rank-new-src");
5707        seed_store(&newer_src, "new");
5708        let latest_src = tmp("restore-rank-latest-src");
5709        seed_store(&latest_src, "named-latest");
5710
5711        // Newest by mtime, with no `latest/` present.
5712        let vault = tmp("restore-rank-vault");
5713        run_backup(&older_src, &vault.join("2026-09-01")).expect("backup old");
5714        std::thread::sleep(std::time::Duration::from_millis(1100));
5715        run_backup(&newer_src, &vault.join("2026-09-02")).expect("backup new");
5716
5717        let fresh = tmp("restore-rank-fresh");
5718        match restore_if_empty(&fresh, &vault).expect("restore by mtime") {
5719            RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("2026-09-02")),
5720            other => panic!("expected Restored, got {other:?}"),
5721        }
5722        assert!(GraphDb::open(&fresh)
5723            .expect("open restored")
5724            .has_node("new"));
5725
5726        // `latest/` wins outright, even though it is older than 2026-09-02.
5727        run_backup(&latest_src, &vault.join("latest")).expect("backup latest");
5728        let older_than_latest = std::fs::metadata(vault.join("2026-09-02").join("snapshot.bin"))
5729            .expect("stat newest")
5730            .modified()
5731            .expect("mtime");
5732        let latest_mtime = std::fs::metadata(vault.join("latest").join("snapshot.bin"))
5733            .expect("stat latest")
5734            .modified()
5735            .expect("mtime");
5736        assert!(
5737            latest_mtime >= older_than_latest,
5738            "test setup: latest/ should not be older here"
5739        );
5740
5741        let fresh2 = tmp("restore-rank-fresh2");
5742        match restore_if_empty(&fresh2, &vault).expect("restore by name") {
5743            RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("latest")),
5744            other => panic!("expected Restored, got {other:?}"),
5745        }
5746        assert!(GraphDb::open(&fresh2)
5747            .expect("open restored")
5748            .has_node("named-latest"));
5749
5750        for d in [&older_src, &newer_src, &latest_src, &vault, &fresh, &fresh2] {
5751            let _ = std::fs::remove_dir_all(d);
5752        }
5753    }
5754
5755    #[test]
5756    fn restore_from_is_a_no_op_when_a_store_exists() {
5757        let src = tmp("restore-noop-src");
5758        seed_store(&src, "a");
5759        let vault = tmp("restore-noop-vault");
5760        run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5761
5762        let existing = tmp("restore-noop-existing");
5763        seed_store(&existing, "b");
5764
5765        assert_eq!(
5766            restore_if_empty(&existing, &vault).expect("restore_if_empty"),
5767            RestoreOutcome::AlreadyPresent
5768        );
5769        let db = GraphDb::open(&existing).expect("open existing");
5770        assert!(db.has_node("b"), "the existing store must survive");
5771        assert!(!db.has_node("a"), "the backup must not have been copied in");
5772
5773        let _ = std::fs::remove_dir_all(&src);
5774        let _ = std::fs::remove_dir_all(&vault);
5775        let _ = std::fs::remove_dir_all(&existing);
5776    }
5777
5778    #[test]
5779    fn restore_from_an_empty_vault_is_not_an_error() {
5780        let vault = tmp("restore-empty-vault");
5781        std::fs::create_dir_all(&vault).expect("mkdir vault");
5782        let fresh = tmp("restore-empty-fresh");
5783        assert_eq!(
5784            restore_if_empty(&fresh, &vault).expect("restore_if_empty"),
5785            RestoreOutcome::Empty
5786        );
5787        // A missing `from` is the same warning: a first boot with no volume yet.
5788        assert_eq!(
5789            restore_if_empty(&fresh, &vault.join("nope")).expect("restore_if_empty"),
5790            RestoreOutcome::Empty
5791        );
5792
5793        let _ = std::fs::remove_dir_all(&vault);
5794        let _ = std::fs::remove_dir_all(&fresh);
5795    }
5796
5797    #[test]
5798    fn restore_from_a_corrupt_backup_fails_loudly() {
5799        let src = tmp("restore-corrupt-src");
5800        seed_store(&src, "a");
5801        let vault = tmp("restore-corrupt-vault");
5802        let backup = vault.join("2026-09-10T00-00Z");
5803        run_backup(&src, &backup).expect("backup");
5804
5805        // Truncate the copied snapshot: the CRC check on open must reject it.
5806        let snap = backup.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 fresh = tmp("restore-corrupt-fresh");
5811        let err = restore_if_empty(&fresh, &vault).expect_err("expected a hard failure");
5812        let msg = err.to_string();
5813        assert!(
5814            msg.contains(&fresh.display().to_string()),
5815            "error must name the restored dir, got: {msg}"
5816        );
5817        assert!(
5818            msg.contains(&backup.display().to_string()),
5819            "error must name the backup, got: {msg}"
5820        );
5821
5822        let _ = std::fs::remove_dir_all(&src);
5823        let _ = std::fs::remove_dir_all(&vault);
5824        let _ = std::fs::remove_dir_all(&fresh);
5825    }
5826
5827    /// A failed restore is all-or-nothing: nothing of the bad copy reaches
5828    /// `db_dir`, so the next boot restores rather than reporting
5829    /// `AlreadyPresent` over a half-written store.
5830    #[test]
5831    fn a_failed_restore_leaves_the_db_dir_untouched() {
5832        let src = tmp("restore-atomic-src");
5833        seed_store(&src, "a");
5834
5835        let bad_vault = tmp("restore-atomic-bad-vault");
5836        let bad = bad_vault.join("2026-09-10T00-00Z");
5837        run_backup(&src, &bad).expect("backup the bad one");
5838        let snap = bad.join("snapshot.bin");
5839        let bytes = std::fs::read(&snap).expect("read snapshot");
5840        std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
5841
5842        let good_vault = tmp("restore-atomic-good-vault");
5843        run_backup(&src, &good_vault.join("2026-09-11T00-00Z")).expect("backup the good one");
5844
5845        let fresh = tmp("restore-atomic-fresh");
5846        let err = restore_if_empty(&fresh, &bad_vault).expect_err("expected a hard failure");
5847        let msg = err.to_string();
5848        assert!(
5849            msg.contains(&fresh.display().to_string()) && msg.contains(&bad.display().to_string()),
5850            "error must name both paths, got: {msg}"
5851        );
5852
5853        // Nothing was left behind — not the copied files, not the staging dir.
5854        let leftovers: Vec<String> = std::fs::read_dir(&fresh)
5855            .expect("read fresh")
5856            .flatten()
5857            .filter_map(|e| e.file_name().into_string().ok())
5858            .collect();
5859        assert!(
5860            leftovers.is_empty(),
5861            "a failed restore must leave nothing behind, found: {leftovers:?}"
5862        );
5863        assert!(!holds_a_store(&fresh), "the dir must not hold a store");
5864
5865        // So the retry against a good backup restores, rather than deciding a
5866        // store is already there.
5867        match restore_if_empty(&fresh, &good_vault).expect("retry must restore") {
5868            RestoreOutcome::Restored { from, .. } => {
5869                assert_eq!(from, good_vault.join("2026-09-11T00-00Z"))
5870            }
5871            other => panic!("expected Restored on retry, got {other:?}"),
5872        }
5873        assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5874
5875        for d in [&src, &bad_vault, &good_vault, &fresh] {
5876            let _ = std::fs::remove_dir_all(d);
5877        }
5878    }
5879
5880    /// The staging directory never outlives a successful restore either — a
5881    /// served store directory holds the store and nothing else.
5882    #[test]
5883    fn a_successful_restore_leaves_no_staging_dir() {
5884        let src = tmp("restore-staging-src");
5885        seed_store(&src, "a");
5886        let vault = tmp("restore-staging-vault");
5887        run_backup(&src, &vault.join("2026-09-11T00-00Z")).expect("backup");
5888
5889        let fresh = tmp("restore-staging-fresh");
5890        restore_if_empty(&fresh, &vault).expect("restore");
5891
5892        let stray: Vec<String> = std::fs::read_dir(&fresh)
5893            .expect("read fresh")
5894            .flatten()
5895            .filter_map(|e| e.file_name().into_string().ok())
5896            .filter(|n| n.starts_with(".restore-"))
5897            .collect();
5898        assert!(
5899            stray.is_empty(),
5900            "staging dir must be gone, found: {stray:?}"
5901        );
5902
5903        for d in [&src, &vault, &fresh] {
5904            let _ = std::fs::remove_dir_all(d);
5905        }
5906    }
5907
5908    #[test]
5909    fn serve_parses_restore_from() {
5910        match parse_args(&["serve", "/tmp/db", "--restore-from", "/vol/backups"]) {
5911            Ok(Command::Serve { restore_from, .. }) => {
5912                assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
5913            }
5914            other => panic!("--restore-from parse, got {other:?}"),
5915        }
5916        match parse_args(&["serve", "/tmp/db", "--restore-from=/vol/backups"]) {
5917            Ok(Command::Serve { restore_from, .. }) => {
5918                assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
5919            }
5920            other => panic!("--restore-from= parse, got {other:?}"),
5921        }
5922        match parse_args(&["serve", "/tmp/db"]) {
5923            Ok(Command::Serve { restore_from, .. }) => assert_eq!(restore_from, None),
5924            other => panic!("default restore_from, got {other:?}"),
5925        }
5926        let err = parse_args(&["serve", "/tmp/db", "--restore-from"])
5927            .expect_err("missing value must be an error");
5928        assert!(
5929            err.contains("--restore-from"),
5930            "error must name the flag, got: {err}"
5931        );
5932    }
5933
5934    #[test]
5935    fn run_export_jsonl_two_runs_byte_identical() {
5936        let src = tmp("cli-export-src");
5937        let dst1 = tmp("cli-export-dst1");
5938        let dst2 = tmp("cli-export-dst2");
5939        let _ = run_demo(&src).expect("demo");
5940
5941        run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
5942        run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
5943
5944        for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
5945            let f1 = std::fs::read(dst1.join(filename)).expect("read first");
5946            let f2 = std::fs::read(dst2.join(filename)).expect("read second");
5947            assert_eq!(
5948                f1, f2,
5949                "{filename} must be byte-identical across two export runs"
5950            );
5951        }
5952        let _ = std::fs::remove_dir_all(&src);
5953        let _ = std::fs::remove_dir_all(&dst1);
5954        let _ = std::fs::remove_dir_all(&dst2);
5955    }
5956
5957    #[test]
5958    fn run_export_jsonl_nodes_are_sorted() {
5959        let src = tmp("cli-export-sorted");
5960        let dst = tmp("cli-export-sorted-dst");
5961        let _ = run_demo(&src).expect("demo");
5962        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
5963
5964        let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
5965        let keys: Vec<String> = content
5966            .lines()
5967            .filter(|l| !l.is_empty())
5968            .map(|l| {
5969                let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
5970                v["key"].as_str().unwrap_or("").to_string()
5971            })
5972            .collect();
5973        let mut sorted = keys.clone();
5974        sorted.sort();
5975        assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
5976        let _ = std::fs::remove_dir_all(&src);
5977        let _ = std::fs::remove_dir_all(&dst);
5978    }
5979
5980    #[test]
5981    fn run_export_jsonl_derived_edges_have_rule() {
5982        let src = tmp("cli-export-derived");
5983        let dst = tmp("cli-export-derived-dst");
5984        let _ = run_demo(&src).expect("demo");
5985        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
5986
5987        let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
5988        let derived_lines: Vec<serde_json::Value> = content
5989            .lines()
5990            .filter(|l| !l.is_empty())
5991            .map(|l| serde_json::from_str(l).expect("parse line"))
5992            .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
5993            .collect();
5994        assert!(
5995            !derived_lines.is_empty(),
5996            "demo store should have derived edges"
5997        );
5998        for edge in &derived_lines {
5999            assert!(
6000                !edge["rule"].is_null(),
6001                "derived edge must have non-null rule: {edge}"
6002            );
6003        }
6004        let _ = std::fs::remove_dir_all(&src);
6005        let _ = std::fs::remove_dir_all(&dst);
6006    }
6007
6008    #[test]
6009    fn run_export_parquet_produces_files() {
6010        let src = tmp("cli-export-parq-src");
6011        let dst = tmp("cli-export-parq-dst");
6012        let _ = run_demo(&src).expect("demo");
6013        run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
6014
6015        assert!(
6016            dst.join("nodes.parquet").exists(),
6017            "nodes.parquet must exist"
6018        );
6019        assert!(
6020            dst.join("edges.parquet").exists(),
6021            "edges.parquet must exist"
6022        );
6023        assert!(
6024            dst.join("rules.parquet").exists(),
6025            "rules.parquet must exist"
6026        );
6027        // All files must be non-empty.
6028        for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
6029            let meta = std::fs::metadata(dst.join(f)).expect("metadata");
6030            assert!(meta.len() > 0, "{f} must be non-empty");
6031        }
6032        let _ = std::fs::remove_dir_all(&src);
6033        let _ = std::fs::remove_dir_all(&dst);
6034    }
6035
6036    #[test]
6037    fn parse_export_graphml_flag() {
6038        let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
6039        match r {
6040            Ok(Command::Export { format, .. }) => {
6041                assert_eq!(format, ExportFormat::Graphml);
6042            }
6043            other => panic!("export --format graphml parse, got {other:?}"),
6044        }
6045    }
6046
6047    #[test]
6048    fn run_export_graphml_structure() {
6049        let src = tmp("cli-export-gml-src");
6050        let dst_dir = tmp("cli-export-gml-dst");
6051        let dst = dst_dir.join("graph.graphml");
6052        let _ = run_demo(&src).expect("demo");
6053        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6054
6055        let content = std::fs::read_to_string(&dst).expect("read graphml");
6056
6057        assert!(
6058            content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
6059            "must start with an XML declaration"
6060        );
6061        assert!(
6062            content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
6063            "must use the standard GraphML namespace"
6064        );
6065        assert!(
6066            content.contains(
6067                "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
6068            ),
6069            "must declare the node label key"
6070        );
6071        assert!(
6072            content.contains(
6073                "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
6074            ),
6075            "must declare the edge type key"
6076        );
6077        assert!(
6078            content.contains(
6079                "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
6080            ),
6081            "must declare the edge derived key"
6082        );
6083        assert!(
6084            content.contains(
6085                "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
6086            ),
6087            "must declare the edge rule key"
6088        );
6089        assert!(
6090            content.contains(
6091                "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
6092            ),
6093            "must declare the edge weight key"
6094        );
6095        // The demo store's Org nodes carry `founded_year` as a JSON integer
6096        // (`Value::Int`, a 64-bit i64). GraphML's informal convention treats
6097        // `attr.type="int"` as 32-bit, so this must declare `"long"`.
6098        assert!(
6099            content.contains(
6100                "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
6101            ),
6102            "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
6103        );
6104        assert!(
6105            content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
6106            "must declare a single directed graph element"
6107        );
6108        assert!(content.contains("<node id="), "must contain node elements");
6109        assert!(
6110            content.contains("<edge id=\"e0\" source=\""),
6111            "must contain a sequentially-numbered edge starting at e0"
6112        );
6113        assert!(
6114            content.trim_end().ends_with("</graphml>"),
6115            "must close the root element"
6116        );
6117
6118        // The demo store's `skill_fit` rule declares weight_prop "score"; its
6119        // derived FIT edges must carry both a rule and a weight in GraphML.
6120        assert!(
6121            content.contains("<data key=\"e_rule\">skill_fit</data>")
6122                || content.contains("<data key=\"e_rule\">founded_within</data>"),
6123            "at least one derived edge must carry its rule name"
6124        );
6125        assert!(
6126            content.contains(&format!(
6127                "<data key=\"{}\">",
6128                "e_weight" /* declared above */
6129            )),
6130            "at least one derived edge must carry a weight value"
6131        );
6132
6133        let _ = std::fs::remove_dir_all(&src);
6134        let _ = std::fs::remove_dir_all(&dst_dir);
6135    }
6136
6137    #[test]
6138    fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
6139        let src = tmp("cli-export-gml-dir-src");
6140        let dst_dir = tmp("cli-export-gml-dir-dst");
6141        std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
6142        let _ = run_demo(&src).expect("demo");
6143
6144        let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
6145
6146        assert!(
6147            dst_dir.join("graph.graphml").exists(),
6148            "an existing directory dest must produce dest/graph.graphml"
6149        );
6150        assert!(
6151            msg.contains("graph.graphml"),
6152            "report must name the file actually written, got: {msg}"
6153        );
6154
6155        let _ = std::fs::remove_dir_all(&src);
6156        let _ = std::fs::remove_dir_all(&dst_dir);
6157    }
6158
6159    /// python3-gated: parses the exported file with the stdlib XML parser to
6160    /// confirm it is well-formed. Skipped (not failed) when python3 is absent.
6161    #[test]
6162    fn run_export_graphml_is_well_formed_xml() {
6163        let has_python3 = std::process::Command::new("python3")
6164            .arg("--version")
6165            .output()
6166            .map(|o| o.status.success())
6167            .unwrap_or(false);
6168        if !has_python3 {
6169            eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
6170            return;
6171        }
6172
6173        let src = tmp("cli-export-gml-wf-src");
6174        let dst_dir = tmp("cli-export-gml-wf-dst");
6175        let dst = dst_dir.join("graph.graphml");
6176        let _ = run_demo(&src).expect("demo");
6177        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6178
6179        let status = std::process::Command::new("python3")
6180            .arg("-c")
6181            .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
6182            .arg(&dst)
6183            .status()
6184            .expect("run python3");
6185        assert!(
6186            status.success(),
6187            "python3's XML parser must accept the exported GraphML file"
6188        );
6189
6190        let _ = std::fs::remove_dir_all(&src);
6191        let _ = std::fs::remove_dir_all(&dst_dir);
6192    }
6193
6194    #[test]
6195    fn run_export_graphml_two_runs_byte_identical() {
6196        let src = tmp("cli-export-gml-bi-src");
6197        let dst_dir1 = tmp("cli-export-gml-bi-dst1");
6198        let dst_dir2 = tmp("cli-export-gml-bi-dst2");
6199        let dst1 = dst_dir1.join("graph.graphml");
6200        let dst2 = dst_dir2.join("graph.graphml");
6201        let _ = run_demo(&src).expect("demo");
6202
6203        run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
6204        run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
6205
6206        let f1 = std::fs::read(&dst1).expect("read first");
6207        let f2 = std::fs::read(&dst2).expect("read second");
6208        assert_eq!(
6209            f1, f2,
6210            "graph.graphml must be byte-identical across two export runs"
6211        );
6212
6213        let _ = std::fs::remove_dir_all(&src);
6214        let _ = std::fs::remove_dir_all(&dst_dir1);
6215        let _ = std::fs::remove_dir_all(&dst_dir2);
6216    }
6217
6218    #[test]
6219    fn run_export_graphml_escapes_and_lists() {
6220        use mushroomdb::{GraphDb, Value};
6221        let src = tmp("cli-export-gml-esc-src");
6222        let dst_dir = tmp("cli-export-gml-esc-dst");
6223        let dst = dst_dir.join("graph.graphml");
6224
6225        {
6226            let mut db = GraphDb::open(&src).unwrap();
6227            db.insert_node(
6228                "Widget",
6229                "w1",
6230                vec![
6231                    (
6232                        "title".into(),
6233                        Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
6234                    ),
6235                    (
6236                        "tags".into(),
6237                        Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
6238                    ),
6239                ],
6240            )
6241            .unwrap();
6242        }
6243
6244        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6245        let content = std::fs::read_to_string(&dst).expect("read graphml");
6246
6247        assert!(
6248            content.contains("Tom &amp; Jerry &lt;says&gt; &quot;hi&quot; &apos;bye&apos;"),
6249            "special XML characters in string props must be escaped, got: {content}"
6250        );
6251        assert!(
6252            !content.contains("Tom & Jerry <says>"),
6253            "unescaped special characters must not appear verbatim"
6254        );
6255        assert!(
6256            content.contains(
6257                "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
6258            ),
6259            "list-valued props must declare attr.type=\"string\""
6260        );
6261        assert!(
6262            content.contains("<data key=\"n_tags\">[&quot;a&quot;,&quot;b&quot;]</data>"),
6263            "list-valued props must render as XML-escaped JSON text, got: {content}"
6264        );
6265
6266        let _ = std::fs::remove_dir_all(&src);
6267        let _ = std::fs::remove_dir_all(&dst_dir);
6268    }
6269
6270    /// I2: when nodes disagree on the `Value` variant for a prop name (one
6271    /// int, one string), the key must declare `attr.type="string"` — a value
6272    /// of either type fits text — rather than picking one node's type and
6273    /// risking a value that doesn't fit it.
6274    #[test]
6275    fn run_export_graphml_mixed_type_prop_declares_string() {
6276        use mushroomdb::{GraphDb, Value};
6277        let src = tmp("cli-export-gml-mixed-src");
6278        let dst_dir = tmp("cli-export-gml-mixed-dst");
6279        let dst = dst_dir.join("graph.graphml");
6280
6281        {
6282            let mut db = GraphDb::open(&src).unwrap();
6283            db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
6284                .unwrap();
6285            db.insert_node(
6286                "Metric",
6287                "m2",
6288                vec![("score".into(), Value::Str("high".into()))],
6289            )
6290            .unwrap();
6291        }
6292
6293        run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6294        let content = std::fs::read_to_string(&dst).expect("read graphml");
6295
6296        assert!(
6297            content.contains(
6298                "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
6299            ),
6300            "a prop name with conflicting value types across nodes must declare \
6301             attr.type=\"string\", got: {content}"
6302        );
6303        assert!(
6304            !content.contains("attr.name=\"score\" attr.type=\"long\""),
6305            "must not declare a narrower type once a conflict is seen, got: {content}"
6306        );
6307        // Each node's own value still renders in its own literal text form,
6308        // regardless of the declared (fallback) attr.type.
6309        assert!(
6310            content.contains("<data key=\"n_score\">5</data>"),
6311            "the int-valued node must still render its literal int text, got: {content}"
6312        );
6313        assert!(
6314            content.contains("<data key=\"n_score\">high</data>"),
6315            "the string-valued node must still render its literal string text, got: {content}"
6316        );
6317
6318        let _ = std::fs::remove_dir_all(&src);
6319        let _ = std::fs::remove_dir_all(&dst_dir);
6320    }
6321
6322    #[test]
6323    fn parse_algo_degree_defaults_dir_both() {
6324        let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
6325        match cmd {
6326            Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
6327            other => panic!("expected Algo, got {other:?}"),
6328        }
6329    }
6330
6331    #[test]
6332    fn parse_algo_degree_with_dir_flag() {
6333        for (arg, want) in [
6334            ("out", AlgoDir::Out),
6335            ("in", AlgoDir::In),
6336            ("both", AlgoDir::Both),
6337        ] {
6338            let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
6339            match cmd {
6340                Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
6341                other => panic!("expected Algo, got {other:?}"),
6342            }
6343        }
6344        // `--dir=out` form too.
6345        let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
6346        match cmd {
6347            Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
6348            other => panic!("expected Algo, got {other:?}"),
6349        }
6350    }
6351
6352    #[test]
6353    fn parse_algo_rejects_unknown_dir() {
6354        assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
6355    }
6356
6357    #[test]
6358    fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
6359        let cmd = parse_args(&[
6360            "algo",
6361            "communities",
6362            "/db",
6363            "--edge-type",
6364            "IMPORTS",
6365            "--edge-type=CO_CHANGED",
6366            "--weight-prop",
6367            "score",
6368            "--min-weight",
6369            "0.3",
6370            "--top",
6371            "5",
6372        ])
6373        .unwrap();
6374        match cmd {
6375            Command::Algo {
6376                subcmd,
6377                top,
6378                edge_types,
6379                weight_prop,
6380                min_weight,
6381                ..
6382            } => {
6383                assert_eq!(subcmd, AlgoSubcmd::Communities);
6384                assert_eq!(top, 5);
6385                assert_eq!(
6386                    edge_types,
6387                    vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
6388                );
6389                assert_eq!(weight_prop, Some("score".to_string()));
6390                assert_eq!(min_weight, Some(0.3));
6391            }
6392            other => panic!("expected Algo, got {other:?}"),
6393        }
6394    }
6395
6396    #[test]
6397    fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
6398        let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
6399        match cmd {
6400            Command::Algo {
6401                subcmd,
6402                edge_types,
6403                weight_prop,
6404                min_weight,
6405                ..
6406            } => {
6407                assert_eq!(subcmd, AlgoSubcmd::Communities);
6408                assert!(edge_types.is_empty());
6409                assert_eq!(weight_prop, None);
6410                assert_eq!(min_weight, None);
6411            }
6412            other => panic!("expected Algo, got {other:?}"),
6413        }
6414    }
6415
6416    /// I1: exporting a store containing NaN/Inf floats must succeed, not panic.
6417    /// The NaN field must be serialised as JSON null (lossy but safe).
6418    #[test]
6419    fn run_export_jsonl_nan_float_becomes_null() {
6420        use mushroomdb::{GraphDb, Value};
6421        let src = tmp("cli-export-nan-src");
6422        let dst = tmp("cli-export-nan-dst");
6423
6424        // Insert a node with NaN, +Inf, and -Inf properties via the public API.
6425        {
6426            let mut db = GraphDb::open(&src).unwrap();
6427            db.insert_node(
6428                "Sensor",
6429                "s1",
6430                vec![
6431                    ("nan_val".into(), Value::Float(f64::NAN)),
6432                    ("pos_inf".into(), Value::Float(f64::INFINITY)),
6433                    ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
6434                    ("normal".into(), Value::Float(1.5)),
6435                ],
6436            )
6437            .unwrap();
6438        }
6439
6440        // Export must succeed.
6441        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
6442
6443        // nodes.jsonl must exist and the NaN fields must be null.
6444        let content =
6445            std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
6446        let row: serde_json::Value =
6447            serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
6448        assert_eq!(
6449            row["nan_val"],
6450            serde_json::Value::Null,
6451            "NaN must export as null"
6452        );
6453        assert_eq!(
6454            row["pos_inf"],
6455            serde_json::Value::Null,
6456            "+Inf must export as null"
6457        );
6458        assert_eq!(
6459            row["neg_inf"],
6460            serde_json::Value::Null,
6461            "-Inf must export as null"
6462        );
6463        // Normal float must survive.
6464        assert_eq!(
6465            row["normal"],
6466            serde_json::json!(1.5),
6467            "normal float roundtrips"
6468        );
6469
6470        let _ = std::fs::remove_dir_all(&src);
6471        let _ = std::fs::remove_dir_all(&dst);
6472    }
6473
6474    #[test]
6475    fn serve_tls_flags_parse_both_forms() {
6476        // --tls-cert VALUE --tls-key VALUE (space form)
6477        match parse_args(&[
6478            "serve",
6479            "/tmp/db",
6480            "--tls-cert",
6481            "/a/cert.pem",
6482            "--tls-key",
6483            "/a/key.pem",
6484        ])
6485        .unwrap()
6486        {
6487            Command::Serve {
6488                tls_cert, tls_key, ..
6489            } => {
6490                assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
6491                assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
6492            }
6493            other => panic!("{other:?}"),
6494        }
6495        // --tls-cert=VALUE --tls-key=VALUE (equals form)
6496        match parse_args(&[
6497            "serve",
6498            "/tmp/db",
6499            "--tls-cert=/b/cert.pem",
6500            "--tls-key=/b/key.pem",
6501        ])
6502        .unwrap()
6503        {
6504            Command::Serve {
6505                tls_cert, tls_key, ..
6506            } => {
6507                assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
6508                assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
6509            }
6510            other => panic!("{other:?}"),
6511        }
6512        // Neither → both None.
6513        match parse_args(&["serve", "/tmp/db"]).unwrap() {
6514            Command::Serve {
6515                tls_cert, tls_key, ..
6516            } => {
6517                assert_eq!(tls_cert, None);
6518                assert_eq!(tls_key, None);
6519            }
6520            other => panic!("{other:?}"),
6521        }
6522    }
6523
6524    #[test]
6525    fn serve_tls_flags_require_both() {
6526        // --tls-cert alone → error
6527        let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
6528        assert!(
6529            err.contains("tls-key"),
6530            "--tls-cert alone must mention --tls-key in error, got {err}"
6531        );
6532        // --tls-key alone → error
6533        let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
6534        assert!(
6535            err.contains("tls-cert"),
6536            "--tls-key alone must mention --tls-cert in error, got {err}"
6537        );
6538    }
6539
6540    #[test]
6541    fn version_flag_parses() {
6542        assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
6543        assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
6544        assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
6545    }
6546
6547    #[test]
6548    fn recall_parses_one_dir_and_is_listed_in_usage() {
6549        assert_eq!(
6550            parse_args(&["recall", "/tmp/db"]).unwrap(),
6551            Command::Recall {
6552                db_dir: Some(PathBuf::from("/tmp/db")),
6553                auto: false,
6554            }
6555        );
6556        assert!(
6557            parse_args(&["recall"]).is_err(),
6558            "one of <db-dir> or --auto is required"
6559        );
6560        assert!(usage().contains("mushroomdb recall <db-dir>"));
6561    }
6562
6563    #[test]
6564    fn why_takes_a_dir_and_two_keys() {
6565        assert_eq!(
6566            parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
6567            Command::Why {
6568                db_dir: PathBuf::from("/tmp/db"),
6569                a: "a.rs".to_string(),
6570                b: "b.rs".to_string(),
6571            }
6572        );
6573
6574        // Too few arguments, too many, and a key that looks like a flag.
6575        for args in [
6576            vec!["why", "/tmp/db", "a"],
6577            vec!["why", "/tmp/db", "a", "b", "c"],
6578            vec!["why", "/tmp/db", "-a", "b"],
6579        ] {
6580            assert!(parse_args(&args).is_err(), "{args:?} must not parse");
6581        }
6582        assert!(usage().contains("mushroomdb why <db-dir> <a> <b>"));
6583    }
6584
6585    /// Every hook-driven command takes either a path or `--auto`, never both
6586    /// and never neither.
6587    #[test]
6588    fn hook_commands_take_a_dir_or_auto() {
6589        assert_eq!(
6590            parse_args(&["mcp", "--auto"]).unwrap(),
6591            Command::Mcp {
6592                db_dir: None,
6593                auto: true,
6594                all_tools: false
6595            }
6596        );
6597        assert_eq!(
6598            parse_args(&["recall", "--auto"]).unwrap(),
6599            Command::Recall {
6600                db_dir: None,
6601                auto: true
6602            }
6603        );
6604        assert_eq!(
6605            parse_args(&["brief", "--auto"]).unwrap(),
6606            Command::Brief {
6607                db_dir: None,
6608                auto: true
6609            }
6610        );
6611        assert_eq!(
6612            parse_args(&["brief", "/tmp/db"]).unwrap(),
6613            Command::Brief {
6614                db_dir: Some(PathBuf::from("/tmp/db")),
6615                auto: false
6616            }
6617        );
6618        for cmd in ["mcp", "recall", "brief"] {
6619            assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
6620            assert!(
6621                parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
6622                "{cmd} with both"
6623            );
6624        }
6625        assert!(usage().contains("--auto"));
6626    }
6627
6628    /// Binding: `--all-tools` is `mcp`'s alone, sits either side of the store
6629    /// path, and every other flag is still rejected.
6630    #[test]
6631    fn mcp_takes_all_tools() {
6632        for args in [
6633            &["mcp", "/tmp/db", "--all-tools"][..],
6634            &["mcp", "--all-tools", "/tmp/db"][..],
6635        ] {
6636            assert_eq!(
6637                parse_args(args).unwrap(),
6638                Command::Mcp {
6639                    db_dir: Some(PathBuf::from("/tmp/db")),
6640                    auto: false,
6641                    all_tools: true
6642                },
6643                "{args:?}"
6644            );
6645        }
6646        assert_eq!(
6647            parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
6648            Command::Mcp {
6649                db_dir: None,
6650                auto: true,
6651                all_tools: true
6652            }
6653        );
6654        assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
6655        assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
6656        assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
6657        assert!(usage().contains("--all-tools"));
6658    }
6659
6660    #[test]
6661    fn ingest_git_parses_excludes() {
6662        let cmd = parse_args(&[
6663            "ingest-git",
6664            "/tmp/db",
6665            "/tmp/repo",
6666            "--exclude",
6667            "target/",
6668            "--exclude=*.lock",
6669            "--max-commits-per-file",
6670            "50",
6671            "--recurse-submodules",
6672            "--prs",
6673            "--ensure-gitignore",
6674        ])
6675        .unwrap();
6676        assert_eq!(
6677            cmd,
6678            Command::IngestGit {
6679                db_dir: PathBuf::from("/tmp/db"),
6680                opts: ingest_git::IngestGitOpts {
6681                    repo: PathBuf::from("/tmp/repo"),
6682                    exclude: vec!["target/".into(), "*.lock".into()],
6683                    max_commits_per_file: 50,
6684                    recurse_submodules: true,
6685                    prs: true,
6686                    structure: true,
6687                    docs: true,
6688                    ensure_gitignore: true,
6689                },
6690            }
6691        );
6692        // Defaults and arity.
6693        let Command::IngestGit { opts, .. } =
6694            parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
6695        else {
6696            panic!("expected IngestGit");
6697        };
6698        assert_eq!(
6699            opts.exclude,
6700            ingest_git::DEFAULT_EXCLUDES
6701                .iter()
6702                .map(|p| (*p).to_string())
6703                .collect::<Vec<_>>(),
6704            "with no --exclude the defaults apply"
6705        );
6706        assert_eq!(
6707            opts.max_commits_per_file,
6708            ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
6709        );
6710        assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
6711        assert!(
6712            opts.structure && opts.docs,
6713            "structure and docs default on and are recorded on the marker"
6714        );
6715        let Command::IngestGit { opts, .. } = parse_args(&[
6716            "ingest-git",
6717            "/tmp/db",
6718            "/tmp/repo",
6719            "--no-structure",
6720            "--no-docs",
6721        ])
6722        .unwrap() else {
6723            panic!("expected IngestGit");
6724        };
6725        assert!(!opts.structure && !opts.docs);
6726        assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
6727        assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
6728        assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
6729        assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
6730    }
6731
6732    #[test]
6733    fn version_constant_matches_cargo() {
6734        assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
6735        assert!(usage().contains("--version"));
6736    }
6737    // ── Namespaces on the CLI ────────────────────────────────────────────────
6738
6739    /// A store with two namespaces and one role bound to the first.
6740    fn ns_store(name: &str) -> PathBuf {
6741        let dir = tmp(name);
6742        let mut db = GraphDb::open(&dir).expect("open");
6743        for (key, ns) in [
6744            ("d1", None),
6745            ("a1", Some("tenant-a")),
6746            ("a2", Some("tenant-a")),
6747            ("b1", Some("tenant-b")),
6748        ] {
6749            let mut props = vec![("id".into(), Value::Str(key.into()))];
6750            if let Some(ns) = ns {
6751                props.push(("ns".into(), Value::Str(ns.into())));
6752            }
6753            db.insert_node("Doc", key, props).expect("insert");
6754        }
6755        db.apply_schema(&Schema {
6756            roles: vec![mushroomdb::RoleDef {
6757                name: "a-reader".into(),
6758                keys: vec![],
6759                labels: vec!["Doc".into()],
6760                visible_where: None,
6761                namespaces: Some(vec!["tenant-a".into()]),
6762                write: None,
6763            }],
6764            ..Default::default()
6765        })
6766        .expect("schema");
6767        dir
6768    }
6769
6770    /// `stats` names every namespace and its live count once a store has more
6771    /// than the default one.
6772    #[test]
6773    fn format_stats_lists_namespaces() {
6774        let dir = ns_store("stats-namespaces");
6775        let text = format_stats(&read_stats(&dir).expect("stats"));
6776        assert!(
6777            text.contains("namespaces: default (1), tenant-a (2), tenant-b (1)"),
6778            "got:\n{text}"
6779        );
6780        let _ = std::fs::remove_dir_all(&dir);
6781    }
6782
6783    /// A single-tenant store's `stats` output is byte-identical to what it was
6784    /// before namespaces existed: no line at all.
6785    #[test]
6786    fn format_stats_omits_the_namespaces_line_on_one_namespace() {
6787        let dir = tmp("stats-one-namespace");
6788        {
6789            let mut db = GraphDb::open(&dir).expect("open");
6790            db.insert_node("Person", "a", vec![]).expect("insert");
6791        }
6792        let text = format_stats(&read_stats(&dir).expect("stats"));
6793        assert!(
6794            !text.contains("namespaces"),
6795            "a store with only `default` says nothing about namespaces, got:\n{text}"
6796        );
6797        let _ = std::fs::remove_dir_all(&dir);
6798    }
6799
6800    /// `query --namespace` and `query --role` parse, and they compose.
6801    #[test]
6802    fn parse_query_takes_a_role_and_a_namespace() {
6803        let Ok(Command::Query {
6804            role, namespace, ..
6805        }) = parse_args(&[
6806            "query",
6807            "/tmp/db",
6808            "--role",
6809            "a-reader",
6810            "--namespace",
6811            "tenant-a",
6812            "MATCH (n) RETURN n",
6813        ])
6814        else {
6815            panic!("expected Query");
6816        };
6817        assert_eq!(role.as_deref(), Some("a-reader"));
6818        assert_eq!(namespace.as_deref(), Some("tenant-a"));
6819
6820        let Ok(Command::Query {
6821            role, namespace, ..
6822        }) = parse_args(&[
6823            "query",
6824            "/tmp/db",
6825            "--namespace=tenant-b",
6826            "MATCH (n) RETURN n",
6827        ])
6828        else {
6829            panic!("expected Query");
6830        };
6831        assert_eq!(role, None);
6832        assert_eq!(namespace.as_deref(), Some("tenant-b"));
6833
6834        assert!(parse_args(&["query", "/tmp/db", "--namespace"]).is_err());
6835        assert!(parse_args(&["query", "/tmp/db", "--role"]).is_err());
6836        assert!(usage().contains("--namespace <ns>"));
6837    }
6838
6839    /// `query` and `asof` take the statement from `--file` or `--stdin`, and
6840    /// refuse a second source rather than pick one.
6841    #[test]
6842    fn parse_query_and_asof_take_a_file_or_stdin() {
6843        let Ok(Command::Query {
6844            cypher,
6845            file,
6846            stdin,
6847            ..
6848        }) = parse_args(&["query", "/tmp/db", "--file", "/tmp/q.cyp"])
6849        else {
6850            panic!("expected Query");
6851        };
6852        assert_eq!(cypher, "");
6853        assert_eq!(file, Some(PathBuf::from("/tmp/q.cyp")));
6854        assert!(!stdin);
6855
6856        let Ok(Command::Query { file, stdin, .. }) = parse_args(&["query", "/tmp/db", "--stdin"])
6857        else {
6858            panic!("expected Query");
6859        };
6860        assert_eq!(file, None);
6861        assert!(stdin);
6862
6863        let Ok(Command::AsOf {
6864            commit,
6865            query,
6866            file,
6867            stdin,
6868            ..
6869        }) = parse_args(&["asof", "/tmp/db", "--commit", "1", "--file=/tmp/q.cyp"])
6870        else {
6871            panic!("expected AsOf");
6872        };
6873        assert_eq!(commit, Some(1));
6874        assert_eq!(query, None);
6875        assert_eq!(file, Some(PathBuf::from("/tmp/q.cyp")));
6876        assert!(!stdin);
6877
6878        let Ok(Command::AsOf { stdin, .. }) =
6879            parse_args(&["asof", "/tmp/db", "--commit", "1", "--stdin"])
6880        else {
6881            panic!("expected AsOf");
6882        };
6883        assert!(stdin);
6884
6885        for args in [
6886            &["query", "/tmp/db", "--stdin", "--file", "/tmp/q"][..],
6887            &["query", "/tmp/db", "--stdin", "MATCH (n) RETURN n"],
6888            &[
6889                "query",
6890                "/tmp/db",
6891                "--file",
6892                "/tmp/q",
6893                "--query",
6894                "MATCH (n) RETURN n",
6895            ],
6896            &[
6897                "asof",
6898                "/tmp/db",
6899                "--commit",
6900                "1",
6901                "--stdin",
6902                "--query",
6903                "MATCH (n) RETURN n",
6904            ],
6905            &[
6906                "asof", "/tmp/db", "--commit", "1", "--stdin", "--file", "/tmp/q",
6907            ],
6908            &["query", "/tmp/db", "--file"],
6909        ] {
6910            assert!(parse_args(args).is_err(), "{args:?} should be refused");
6911        }
6912        assert!(usage().contains("--file <path>|--stdin"));
6913    }
6914
6915    /// The trailing newline a file or pipe ends in is not part of the
6916    /// statement; an empty one is refused.
6917    #[test]
6918    fn resolve_statement_drops_the_trailing_newline() {
6919        let mut piped: &[u8] = b"MATCH (n) RETURN count(n)\n\n";
6920        let got = resolve_statement(None, None, true, &mut piped).expect("stdin");
6921        assert_eq!(got.as_deref(), Some("MATCH (n) RETURN count(n)"));
6922
6923        let mut unused: &[u8] = b"";
6924        let inline = resolve_statement(Some("MATCH (n) RETURN n"), None, false, &mut unused)
6925            .expect("inline");
6926        assert_eq!(inline.as_deref(), Some("MATCH (n) RETURN n"));
6927        assert_eq!(
6928            resolve_statement(None, None, false, &mut unused).unwrap(),
6929            None
6930        );
6931
6932        // A byte-order mark and one trailing semicolon are not the statement,
6933        // whichever way it arrives.
6934        let mut bom: &[u8] = b"\xEF\xBB\xBFMATCH (n) RETURN n;  \n";
6935        let got = resolve_statement(None, None, true, &mut bom).expect("bom");
6936        assert_eq!(got.as_deref(), Some("MATCH (n) RETURN n"));
6937        let got = resolve_statement(Some("MATCH (n) RETURN n ; "), None, false, &mut unused)
6938            .expect("inline semicolon");
6939        assert_eq!(got.as_deref(), Some("MATCH (n) RETURN n"));
6940        let got = resolve_statement(Some("RETURN ';;'"), None, false, &mut unused).unwrap();
6941        assert_eq!(got.as_deref(), Some("RETURN ';;'"));
6942
6943        let mut blank: &[u8] = b" \n";
6944        assert!(resolve_statement(None, None, true, &mut blank).is_err());
6945        assert!(resolve_statement(
6946            None,
6947            Some(Path::new("/nonexistent/q.cyp")),
6948            false,
6949            &mut unused
6950        )
6951        .is_err());
6952    }
6953
6954    /// `query --role` answers as that role, `--namespace` narrows, and the two
6955    /// together intersect — a role bound to one namespace never sees another.
6956    #[test]
6957    fn run_query_with_a_role_and_a_namespace_never_widens() {
6958        let dir = ns_store("query-namespace");
6959        let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
6960
6961        let all = run_query(&dir, q, None, None).expect("query");
6962        assert!(all.contains("id=a1") && all.contains("id=b1") && all.contains("id=d1"));
6963
6964        let ns = run_query(&dir, q, None, Some("tenant-a")).expect("query");
6965        assert!(ns.contains("id=a1") && ns.contains("id=a2"), "got {ns}");
6966        assert!(!ns.contains("id=b1") && !ns.contains("id=d1"), "got {ns}");
6967
6968        let role = run_query(&dir, q, Some("a-reader"), None).expect("query");
6969        assert!(
6970            role.contains("id=a1") && !role.contains("id=b1"),
6971            "got {role}"
6972        );
6973
6974        let both = run_query(&dir, q, Some("a-reader"), Some("tenant-b")).expect("query");
6975        assert!(
6976            !both.contains("id=a1") && !both.contains("id=b1"),
6977            "role ∩ namespace, never role ∪ namespace: {both}"
6978        );
6979
6980        // Either argument makes the query a read: a restricted write is refused
6981        // and nothing lands.
6982        for (role, namespace) in [
6983            (None, Some("tenant-a")),
6984            (Some("a-reader"), None),
6985            (Some("a-reader"), Some("tenant-a")),
6986        ] {
6987            let write = run_query(&dir, "CREATE (n:Doc {id: 'z1'})", role, namespace);
6988            assert!(
6989                write
6990                    .as_ref()
6991                    .err()
6992                    .is_some_and(|e| e.0.contains("read-only")),
6993                "a restricted write must be refused, got {write:?}"
6994            );
6995            assert!(
6996                !GraphDb::open(&dir).expect("reopen").has_node("z1"),
6997                "the write must not have landed"
6998            );
6999        }
7000
7001        let unknown = run_query(&dir, q, Some("nobody"), None);
7002        assert!(unknown.is_err(), "an unknown role is an error");
7003        let invalid = run_query(&dir, q, None, Some("no spaces"));
7004        assert!(
7005            invalid
7006                .as_ref()
7007                .err()
7008                .is_some_and(|e| e.0.contains("valid namespace name")),
7009            "{invalid:?}"
7010        );
7011        let _ = std::fs::remove_dir_all(&dir);
7012    }
7013
7014    /// `asof --namespace` reads one namespace as it was at a commit.
7015    #[test]
7016    fn run_asof_in_a_namespace() {
7017        let dir = ns_store("asof-namespace");
7018        let at = {
7019            let db = GraphDb::open(&dir).expect("open");
7020            db.wal_total_commits().expect("commits") - 1
7021        };
7022        {
7023            let mut db = GraphDb::open(&dir).expect("open");
7024            db.insert_node(
7025                "Doc",
7026                "a3",
7027                vec![
7028                    ("id".into(), Value::Str("a3".into())),
7029                    ("ns".into(), Value::Str("tenant-a".into())),
7030                ],
7031            )
7032            .expect("insert");
7033        }
7034        let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
7035        let then = run_asof(&dir, Some(at), None, Some(q), Some("tenant-a")).expect("asof");
7036        assert!(
7037            then.contains("id=a1") && then.contains("id=a2"),
7038            "got {then}"
7039        );
7040        assert!(
7041            !then.contains("id=a3") && !then.contains("id=b1"),
7042            "a3 did not exist then and b1 is another namespace: {then}"
7043        );
7044        let now = run_asof(&dir, Some(at + 1), None, Some(q), Some("tenant-a")).expect("asof");
7045        assert!(now.contains("id=a3"), "got {now}");
7046        let _ = std::fs::remove_dir_all(&dir);
7047    }
7048
7049    /// `schema apply` takes a v4 roles file — a role bound to namespaces — and
7050    /// the sidecar it writes says version 4.
7051    #[test]
7052    fn schema_apply_accepts_v4_roles_with_namespaces() {
7053        let dir = tmp("schema-v4");
7054        {
7055            let mut db = GraphDb::open(&dir).expect("open");
7056            db.insert_node(
7057                "Doc",
7058                "a1",
7059                vec![("ns".into(), Value::Str("tenant-a".into()))],
7060            )
7061            .expect("insert");
7062        }
7063        let file = dir.join("schema.json");
7064        std::fs::write(
7065            &file,
7066            r#"{"roles": [{"name": "a-reader", "labels": ["Doc"], "keys": [],
7067                 "namespaces": ["tenant-a"]}]}"#,
7068        )
7069        .expect("write schema");
7070        let out = run_schema_apply(&dir, &file).expect("apply");
7071        assert!(out.contains("a-reader"), "got {out}");
7072        let sidecar = std::fs::read_to_string(dir.join("roles.json")).expect("roles.json");
7073        assert!(
7074            sidecar.contains("\"version\": 4") || sidecar.contains("\"version\":4"),
7075            "a role with namespaces writes version 4: {sidecar}"
7076        );
7077        assert!(sidecar.contains("tenant-a"), "{sidecar}");
7078        let _ = std::fs::remove_dir_all(&dir);
7079    }
7080}