Skip to main content

cli/
lib.rs

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