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 export;
7pub mod ingest_git;
8pub mod install;
9pub mod recall;
10
11use core_api::schema::Schema;
12use core_api::{
13    default_max_edges, is_write_query, wal_commit_count_at, AlgoDir, BackupReport, DegreeConfig,
14    Explanation, GraphDb, IngestOptions, PageRankConfig, Predicate, ResultSet, RuleDef,
15    RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig,
16};
17use export::ExportFormat;
18use std::collections::BTreeMap;
19use std::fmt::Write as _;
20use std::net::SocketAddr;
21use std::path::{Path, PathBuf};
22use std::time::Duration;
23
24/// Deterministic demo: 10 Orgs, 20 Projects, 30 People.
25pub const N_ORGS: usize = 10;
26pub const N_PROJECTS: usize = 20;
27pub const N_PEOPLE: usize = 30;
28
29/// Sample query printed by `mushroomdb demo` and executed against the fresh store.
30///
31/// Scoped to one person so `ORDER BY score DESC` is visibly ranked (a global
32/// `LIMIT 5` would be five 1.0 home-project hits).
33pub const SAMPLE_QUERY: &str = "\
34MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
35RETURN p, proj, r.score AS score
36ORDER BY score DESC, proj";
37
38const SAMPLE_EXPLAIN_A: &str = "person-01";
39const SAMPLE_EXPLAIN_B: &str = "proj-01";
40
41/// Build version, printed by `mushroomdb --version`.
42pub const VERSION: &str = env!("CARGO_PKG_VERSION");
43
44/// How `serve` should mount a UI. Precedence: `--ui dir` > embedded > `--no-ui`.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ServeUi {
47    Filesystem(PathBuf),
48    Embedded,
49    None,
50}
51
52/// Algorithm subcommand for `mushroomdb algo`.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum AlgoSubcmd {
55    Pagerank,
56    Wcc,
57    Degree,
58}
59
60/// Parsed `mushroomdb` invocation.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Command {
63    Serve {
64        db_dir: PathBuf,
65        addr: SocketAddr,
66        ui: ServeUi,
67        /// If the db dir is missing or empty, run [`run_demo`] before serving.
68        /// Docker's default CMD uses this so a fresh volume is ready on first boot.
69        demo_if_empty: bool,
70        /// Bearer token for non-loopback binds. Loopback may omit it.
71        token: Option<String>,
72        /// Role-bound tokens from `--role-token TOKEN:ROLE` flags.
73        /// Merged with `MUSHROOMDB_ROLE_TOKENS` env var in main before serving.
74        role_tokens: Vec<(String, String)>,
75        /// Periodic snapshot cadence. `None` = off (default).
76        snapshot_every: Option<Duration>,
77        /// Path to PEM certificate for native TLS (`--tls-cert`). Requires `--tls-key`.
78        tls_cert: Option<PathBuf>,
79        /// Path to PEM private key for native TLS (`--tls-key`). Requires `--tls-cert`.
80        tls_key: Option<PathBuf>,
81    },
82    Mcp {
83        db_dir: PathBuf,
84    },
85    Stats {
86        db_dir: PathBuf,
87    },
88    Demo {
89        db_dir: PathBuf,
90    },
91    /// Read-only view of the database at a past commit.
92    AsOf {
93        db_dir: PathBuf,
94        /// 0-based WAL commit index to replay up to (inclusive).
95        commit: u64,
96        /// Optional Cypher read query to execute against the as-of view.
97        query: Option<String>,
98    },
99    /// Profile the database and suggest linking rules with estimated edge counts.
100    Suggest {
101        db_dir: PathBuf,
102    },
103    /// Run a graph algorithm (pagerank / wcc / degree).
104    Algo {
105        db_dir: PathBuf,
106        subcmd: AlgoSubcmd,
107        /// Print only the top N results (0 = all).
108        top: usize,
109        /// Edge direction for degree/pagerank (`out` / `in` / `both`).
110        /// Ignored by `wcc`, which is always undirected.
111        dir: AlgoDir,
112    },
113    /// Run a Cypher query (read or write).
114    Query {
115        db_dir: PathBuf,
116        /// Positional after dir (remaining args joined), or `--query`.
117        cypher: String,
118    },
119    /// Write `snapshot.bin` (default truncates WAL unless `--keep-wal`).
120    Snapshot {
121        db_dir: PathBuf,
122        keep_wal: bool,
123        /// Rename WAL to wal.<commit_seq>.archive before writing fresh baseline.
124        archive_wal: bool,
125        /// Keep the newest N archives; prune oldest at snapshot time.
126        /// None = unlimited. Applies only when archive_wal is true.
127        retention: Option<u32>,
128    },
129    /// Apply a JSON schema file idempotently (`schema apply <db-dir> <schema.json>`).
130    SchemaApply {
131        db_dir: PathBuf,
132        schema_file: PathBuf,
133    },
134    /// Migrate an old-format snapshot to the current version and keep `.bak`.
135    Migrate {
136        db_dir: PathBuf,
137    },
138    /// Validate CRC32 integrity of every section in the V8 snapshot.
139    Verify {
140        db_dir: PathBuf,
141    },
142    /// Create a consistent, verified copy of the database directory.
143    Backup {
144        db_dir: PathBuf,
145        dest: PathBuf,
146    },
147    /// Export all nodes, edges, and rules to a destination directory.
148    Export {
149        db_dir: PathBuf,
150        dest: PathBuf,
151        format: ExportFormat,
152    },
153    /// Build (or incrementally sync) a graph of a git repository.
154    IngestGit {
155        db_dir: PathBuf,
156        opts: ingest_git::IngestGitOpts,
157    },
158    /// Wire the /mushroom skill and MCP server into Claude Code / Cursor.
159    Install(install::InstallOpts),
160    /// Undo what `install` wrote (manifest-driven).
161    Uninstall(install::InstallOpts),
162    /// Body of the Claude Code UserPromptSubmit hook: reads a prompt payload on
163    /// stdin, prints related graph facts on stdout.
164    Recall {
165        db_dir: PathBuf,
166    },
167    Version,
168    Help,
169}
170
171/// Outcome of [`run_demo`]. Counts are deterministic.
172#[derive(Debug)]
173pub struct DemoOutcome {
174    pub auto_fk_rules: Vec<String>,
175    pub sample_query: String,
176    pub sample_result: ResultSet,
177    pub explanations: Vec<Explanation>,
178    pub stats: Stats,
179    /// First suggestion from the rule suggester (teaser only — not auto-applied).
180    pub suggestion: Option<RuleSuggestion>,
181}
182
183/// CLI-facing error. [`Display`] is the message printed to stderr.
184#[derive(Debug)]
185pub struct CliError(pub String);
186
187impl std::fmt::Display for CliError {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.write_str(&self.0)
190    }
191}
192
193impl std::error::Error for CliError {}
194
195impl From<core_api::GraphError> for CliError {
196    fn from(e: core_api::GraphError) -> Self {
197        CliError(e.to_string())
198    }
199}
200
201impl From<std::io::Error> for CliError {
202    fn from(e: std::io::Error) -> Self {
203        CliError(e.to_string())
204    }
205}
206
207/// Usage text for no-args / `--help` / `-h`.
208pub fn usage() -> &'static str {
209    "\
210mushroomdb — embedded graph database
211
212Usage:
213  mushroomdb install [--platform claude-code|cursor|all] [--project] [--db <path>]
214  mushroomdb uninstall [--platform claude-code|cursor|all] [--project] [--db <path>]
215  mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>]
216  mushroomdb mcp <db-dir>
217  mushroomdb stats <db-dir>
218  mushroomdb demo <db-dir>
219  mushroomdb recall <db-dir>       hook body: reads a prompt payload on stdin, prints related graph facts
220  mushroomdb suggest <db-dir>
221  mushroomdb asof <db-dir> --commit N [--query \"MATCH ...\"]
222  mushroomdb query <db-dir> [--query \"MATCH ...\"] <cypher…>
223  mushroomdb snapshot <db-dir> [--keep-wal]
224  mushroomdb migrate <db-dir>
225  mushroomdb verify <db-dir>       validate CRC32 integrity of every snapshot section
226  mushroomdb backup <db-dir> <dest>   process-local consistent copy of the database to <dest>
227                                      WARNING: unsafe against a concurrently running serve process;
228                                      use POST /backup on the HTTP server for live-serve backups
229  mushroomdb export <db-dir> <dest> --format jsonl|parquet   export all data
230  mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]...   graph a git repo (authors, commits, files, co-change + ownership rules); re-run to sync
231  mushroomdb schema apply <db-dir> <schema.json>
232  mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
233  mushroomdb algo wcc <db-dir> [--top N]
234  mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
235  mushroomdb --version
236  mushroomdb --help
237
238Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
239install defaults: --platform auto-detect, user scope (omit --project for ~/.mushroomdb/memory).
240"
241}
242
243fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
244    let mut platform: Option<install::Platform> = None;
245    let mut project = false;
246    let mut db: Option<PathBuf> = None;
247    let mut i = 0;
248    while i < args.len() {
249        let a = args[i];
250        if a == "--platform" {
251            let val = args
252                .get(i + 1)
253                .copied()
254                .ok_or_else(|| "missing value for --platform".to_string())?;
255            platform = Some(install::Platform::parse(val)?);
256            i += 2;
257        } else if let Some(val) = a.strip_prefix("--platform=") {
258            platform = Some(install::Platform::parse(val)?);
259            i += 1;
260        } else if a == "--project" {
261            project = true;
262            i += 1;
263        } else if a == "--db" {
264            let val = args
265                .get(i + 1)
266                .copied()
267                .ok_or_else(|| "missing value for --db".to_string())?;
268            db = Some(PathBuf::from(val));
269            i += 2;
270        } else if let Some(val) = a.strip_prefix("--db=") {
271            db = Some(PathBuf::from(val));
272            i += 1;
273        } else if a.starts_with('-') {
274            return Err(format!("unexpected flag: {a}"));
275        } else {
276            return Err(format!("unexpected argument: {a}"));
277        }
278    }
279    Ok(install::InstallOpts {
280        platform,
281        project,
282        db,
283    })
284}
285
286fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
287    let mut positional = Vec::new();
288    let mut exclude = Vec::new();
289    let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
290    let mut i = 0;
291    while i < args.len() {
292        let a = args[i];
293        if a == "--exclude" {
294            exclude.push(
295                args.get(i + 1)
296                    .copied()
297                    .ok_or_else(|| "missing value for --exclude".to_string())?
298                    .to_string(),
299            );
300            i += 2;
301        } else if let Some(val) = a.strip_prefix("--exclude=") {
302            exclude.push(val.to_string());
303            i += 1;
304        } else if a == "--max-commits-per-file" {
305            let val = args
306                .get(i + 1)
307                .copied()
308                .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
309            max_commits_per_file = val
310                .parse()
311                .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
312            i += 2;
313        } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
314            max_commits_per_file = val
315                .parse()
316                .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
317            i += 1;
318        } else if a.starts_with('-') {
319            return Err(format!("unexpected flag: {a}"));
320        } else {
321            positional.push(a);
322            i += 1;
323        }
324    }
325    let [db_dir, repo] = positional.as_slice() else {
326        return Err("ingest-git requires <db-dir> <repo-dir>".into());
327    };
328    Ok(Command::IngestGit {
329        db_dir: PathBuf::from(db_dir),
330        opts: ingest_git::IngestGitOpts {
331            repo: PathBuf::from(repo),
332            exclude,
333            max_commits_per_file,
334        },
335    })
336}
337
338/// Parse argv after the binary name. Hand-rolled — no clap.
339pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
340    let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
341    if args.is_empty() {
342        return Ok(Command::Help);
343    }
344    match args[0] {
345        "--help" | "-h" | "help" => Ok(Command::Help),
346        "--version" | "-V" | "version" => Ok(Command::Version),
347        "serve" => parse_serve(&args[1..]),
348        "mcp" => parse_one_dir("mcp", &args[1..]).map(|db_dir| Command::Mcp { db_dir }),
349        "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
350        "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
351        "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
352        "asof" => parse_asof(&args[1..]),
353        "algo" => parse_algo(&args[1..]),
354        "query" => parse_query(&args[1..]),
355        "snapshot" => parse_snapshot(&args[1..]),
356        "schema" => parse_schema(&args[1..]),
357        "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
358        "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
359        "backup" => parse_backup(&args[1..]),
360        "export" => parse_export(&args[1..]),
361        "recall" => parse_one_dir("recall", &args[1..]).map(|db_dir| Command::Recall { db_dir }),
362        "ingest-git" => parse_ingest_git(&args[1..]),
363        "install" => parse_install_cmd(&args[1..]).map(Command::Install),
364        "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
365        other => Err(format!("unknown command: {other}")),
366    }
367}
368
369fn default_addr() -> SocketAddr {
370    SocketAddr::from(([127, 0, 0, 1], 8080))
371}
372
373fn parse_serve(args: &[&str]) -> Result<Command, String> {
374    let mut db_dir = None;
375    let mut addr = default_addr();
376    let mut ui = ServeUi::Embedded;
377    let mut saw_ui = false;
378    let mut saw_no_ui = false;
379    let mut demo_if_empty = false;
380    let mut token = None;
381    let mut role_tokens: Vec<(String, String)> = Vec::new();
382    let mut snapshot_every = None;
383    let mut tls_cert: Option<PathBuf> = None;
384    let mut tls_key: Option<PathBuf> = None;
385    let mut i = 0;
386    while i < args.len() {
387        let a = args[i];
388        if a == "--addr" {
389            let val = args
390                .get(i + 1)
391                .copied()
392                .ok_or_else(|| "missing value for --addr".to_string())?;
393            addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
394            i += 2;
395        } else if let Some(val) = a.strip_prefix("--addr=") {
396            addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
397            i += 1;
398        } else if a == "--ui" {
399            let val = args
400                .get(i + 1)
401                .copied()
402                .ok_or_else(|| "missing value for --ui".to_string())?;
403            ui = ServeUi::Filesystem(PathBuf::from(val));
404            saw_ui = true;
405            i += 2;
406        } else if let Some(val) = a.strip_prefix("--ui=") {
407            ui = ServeUi::Filesystem(PathBuf::from(val));
408            saw_ui = true;
409            i += 1;
410        } else if a == "--no-ui" {
411            ui = ServeUi::None;
412            saw_no_ui = true;
413            i += 1;
414        } else if a == "--demo-if-empty" {
415            demo_if_empty = true;
416            i += 1;
417        } else if a == "--token" {
418            let val = args
419                .get(i + 1)
420                .copied()
421                .ok_or_else(|| "missing value for --token".to_string())?;
422            token = Some(val.to_string());
423            i += 2;
424        } else if let Some(val) = a.strip_prefix("--token=") {
425            token = Some(val.to_string());
426            i += 1;
427        } else if a == "--role-token" {
428            let val = args
429                .get(i + 1)
430                .copied()
431                .ok_or_else(|| "missing value for --role-token".to_string())?;
432            let (tok, role) = parse_role_token(val)?;
433            role_tokens.push((tok, role));
434            i += 2;
435        } else if let Some(val) = a.strip_prefix("--role-token=") {
436            let (tok, role) = parse_role_token(val)?;
437            role_tokens.push((tok, role));
438            i += 1;
439        } else if a == "--snapshot-every" {
440            let val = args
441                .get(i + 1)
442                .copied()
443                .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
444            snapshot_every = Some(parse_snapshot_every(val)?);
445            i += 2;
446        } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
447            snapshot_every = Some(parse_snapshot_every(val)?);
448            i += 1;
449        } else if a == "--tls-cert" {
450            let val = args
451                .get(i + 1)
452                .copied()
453                .ok_or_else(|| "missing value for --tls-cert".to_string())?;
454            tls_cert = Some(PathBuf::from(val));
455            i += 2;
456        } else if let Some(val) = a.strip_prefix("--tls-cert=") {
457            tls_cert = Some(PathBuf::from(val));
458            i += 1;
459        } else if a == "--tls-key" {
460            let val = args
461                .get(i + 1)
462                .copied()
463                .ok_or_else(|| "missing value for --tls-key".to_string())?;
464            tls_key = Some(PathBuf::from(val));
465            i += 2;
466        } else if let Some(val) = a.strip_prefix("--tls-key=") {
467            tls_key = Some(PathBuf::from(val));
468            i += 1;
469        } else if a.starts_with('-') {
470            return Err(format!("unexpected flag: {a}"));
471        } else if db_dir.is_none() {
472            db_dir = Some(PathBuf::from(a));
473            i += 1;
474        } else {
475            return Err(format!("unexpected extra argument: {a}"));
476        }
477    }
478    if saw_ui && saw_no_ui {
479        return Err("cannot combine --ui and --no-ui".to_string());
480    }
481    match (&tls_cert, &tls_key) {
482        (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
483        (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
484        _ => {}
485    }
486    let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
487    Ok(Command::Serve {
488        db_dir,
489        addr,
490        ui,
491        demo_if_empty,
492        token,
493        role_tokens,
494        snapshot_every,
495        tls_cert,
496        tls_key,
497    })
498}
499
500fn parse_role_token(val: &str) -> Result<(String, String), String> {
501    let (tok, role) = val
502        .split_once(':')
503        .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
504    if tok.is_empty() {
505        return Err("--role-token: TOKEN must not be empty".to_string());
506    }
507    if role.is_empty() {
508        return Err("--role-token: ROLE must not be empty".to_string());
509    }
510    Ok((tok.to_string(), role.to_string()))
511}
512
513fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
514    let secs: u64 = val
515        .parse()
516        .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
517    if secs == 0 {
518        return Err("--snapshot-every must be a positive number of seconds".into());
519    }
520    Ok(Duration::from_secs(secs))
521}
522
523/// `--ui <dir>` must be a directory that contains `index.html`.
524pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
525    if !dir.is_dir() {
526        return Err(format!("--ui directory does not exist: {}", dir.display()));
527    }
528    let index = dir.join("index.html");
529    if !index.is_file() {
530        return Err(format!(
531            "--ui directory is missing index.html: {}",
532            dir.display()
533        ));
534    }
535    Ok(dir.to_path_buf())
536}
537
538fn parse_asof(args: &[&str]) -> Result<Command, String> {
539    let mut db_dir = None;
540    let mut commit: Option<u64> = None;
541    let mut query: Option<String> = None;
542    let mut i = 0;
543    while i < args.len() {
544        let a = args[i];
545        if a == "--commit" {
546            let val = args
547                .get(i + 1)
548                .copied()
549                .ok_or_else(|| "missing value for --commit".to_string())?;
550            commit = Some(
551                val.parse()
552                    .map_err(|_| format!("invalid commit index: {val}"))?,
553            );
554            i += 2;
555        } else if let Some(val) = a.strip_prefix("--commit=") {
556            commit = Some(
557                val.parse()
558                    .map_err(|_| format!("invalid commit index: {val}"))?,
559            );
560            i += 1;
561        } else if a == "--query" {
562            let val = args
563                .get(i + 1)
564                .copied()
565                .ok_or_else(|| "missing value for --query".to_string())?;
566            query = Some(val.to_string());
567            i += 2;
568        } else if let Some(val) = a.strip_prefix("--query=") {
569            query = Some(val.to_string());
570            i += 1;
571        } else if a.starts_with('-') {
572            return Err(format!("unexpected flag: {a}"));
573        } else if db_dir.is_none() {
574            db_dir = Some(PathBuf::from(a));
575            i += 1;
576        } else {
577            return Err(format!("unexpected extra argument: {a}"));
578        }
579    }
580    let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
581    let commit = commit.ok_or_else(|| "asof requires --commit N".to_string())?;
582    Ok(Command::AsOf {
583        db_dir,
584        commit,
585        query,
586    })
587}
588
589/// Execute an as-of query at the given commit and print results.
590pub fn run_asof(db_dir: &Path, commit: u64, query: Option<&str>) -> Result<String, CliError> {
591    let total = wal_commit_count_at(db_dir)?;
592    let db = GraphDb::open_at(db_dir, commit)?;
593    let mut out = String::new();
594    let _ = writeln!(out, "as-of commit {} of {}", commit, total);
595    if let Some(cypher) = query {
596        let params = BTreeMap::new();
597        let rs = db.query(cypher, &params)?;
598        out.push_str(&format_result_set(&rs));
599    }
600    Ok(out)
601}
602
603fn parse_query(args: &[&str]) -> Result<Command, String> {
604    let mut db_dir = None;
605    let mut query_flag: Option<String> = None;
606    let mut cypher_parts: Vec<&str> = Vec::new();
607    let mut i = 0;
608    while i < args.len() {
609        let a = args[i];
610        if a == "--query" {
611            let val = args
612                .get(i + 1)
613                .copied()
614                .ok_or_else(|| "missing value for --query".to_string())?;
615            query_flag = Some(val.to_string());
616            i += 2;
617        } else if let Some(val) = a.strip_prefix("--query=") {
618            query_flag = Some(val.to_string());
619            i += 1;
620        } else if a.starts_with('-') {
621            return Err(format!("unexpected flag: {a}"));
622        } else if db_dir.is_none() {
623            db_dir = Some(PathBuf::from(a));
624            i += 1;
625        } else {
626            cypher_parts.push(a);
627            i += 1;
628        }
629    }
630    let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
631    let cypher = if let Some(q) = query_flag {
632        if !cypher_parts.is_empty() {
633            return Err(
634                "query: pass Cypher as remaining arguments or --query, not both".to_string(),
635            );
636        }
637        q
638    } else {
639        if cypher_parts.is_empty() {
640            return Err("query requires a Cypher string".to_string());
641        }
642        cypher_parts.join(" ")
643    };
644    Ok(Command::Query { db_dir, cypher })
645}
646
647/// Run a Cypher read or write and print columns/rows like [`run_asof`].
648pub fn run_query(db_dir: &Path, cypher: &str) -> Result<String, CliError> {
649    let params = BTreeMap::new();
650    let is_write = is_write_query(cypher).map_err(CliError)?;
651    let rs = if is_write {
652        let mut db = GraphDb::open(db_dir)?;
653        db.query_write(cypher, &params)?
654    } else {
655        let db = GraphDb::open(db_dir)?;
656        db.query(cypher, &params)?
657    };
658    Ok(format_result_set(&rs))
659}
660
661fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
662    let mut db_dir = None;
663    let mut keep_wal = false;
664    let mut archive_wal = false;
665    let mut retention: Option<u32> = None;
666    let mut i = 0;
667    while i < args.len() {
668        let a = args[i];
669        if a == "--keep-wal" {
670            keep_wal = true;
671            i += 1;
672        } else if a == "--archive-wal" {
673            archive_wal = true;
674            i += 1;
675        } else if a.starts_with("--retention=") {
676            let v = a.trim_start_matches("--retention=");
677            retention = Some(
678                v.parse::<u32>()
679                    .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
680            );
681            i += 1;
682        } else if a == "--retention" {
683            i += 1;
684            let v = args
685                .get(i)
686                .ok_or_else(|| "--retention requires a value".to_string())?;
687            retention = Some(
688                v.parse::<u32>()
689                    .map_err(|e| format!("--retention value error: {e}"))?,
690            );
691            i += 1;
692        } else if a.starts_with('-') {
693            return Err(format!("unexpected flag: {a}"));
694        } else if db_dir.is_none() {
695            db_dir = Some(PathBuf::from(a));
696            i += 1;
697        } else {
698            return Err(format!("unexpected extra argument: {a}"));
699        }
700    }
701    let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
702    Ok(Command::Snapshot {
703        db_dir,
704        keep_wal,
705        archive_wal,
706        retention,
707    })
708}
709
710/// Migrate the snapshot at `db_dir` to the current format version.
711///
712/// - If the snapshot is already at the current version, prints
713///   `already current (V<N>)`.
714/// - If the snapshot is an older version, writes `snapshot.bin.bak` (atomic +
715///   fsynced) then performs a truncating snapshot at the current version, and
716///   prints `migrated V<from> -> V<current>`.
717/// - WAL-only stores (no snapshot) are treated as needing a fresh snapshot.
718pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
719    let current = core_api::SNAPSHOT_VERSION;
720    let from_ver = core_api::snapshot_version_at(db_dir)?;
721
722    if from_ver == Some(current) {
723        return Ok(format!("already current (V{current})\n"));
724    }
725
726    // Copy the original snapshot to .bak at OS level — no in-memory buffer
727    // required for a 2+ GiB file.  The original snapshot.bin is authoritative
728    // until snapshot_with's write_atomic (tmp+rename) succeeds, so a torn .bak
729    // on crash is acceptable.
730    if from_ver.is_some() {
731        std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
732    }
733
734    // Open with auto_migrate=false to avoid double-migration, then write
735    // the truncating snapshot (CLI migrate always truncates the WAL).
736    let mut db = GraphDb::open_with_options(
737        db_dir,
738        core_api::OpenOptions {
739            auto_migrate: false,
740            ..Default::default()
741        },
742    )?;
743    db.snapshot()?;
744
745    let msg = match from_ver {
746        Some(ver) => format!("migrated V{ver} -> V{current}\n"),
747        None => format!("migrated WAL-only -> V{current}\n"),
748    };
749    Ok(msg)
750}
751
752/// Validate the CRC32 integrity of every section in a V8 snapshot.
753///
754/// Exits with a non-zero code if any section is corrupt.  This is the
755/// explicit integrity audit path; mushroomdb does NOT CRC-check large
756/// sections on the hot query path (see format-stability.md).
757pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
758    // A store that has only ever been written via the WAL has no snapshot yet;
759    // give an actionable message instead of a raw "No such file" io error.
760    if !db_dir.join("snapshot.bin").exists() {
761        return Err(CliError(format!(
762            "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
763            db_dir.display(),
764            db_dir.display()
765        )));
766    }
767    let results = core_api::verify_snapshot(db_dir)
768        .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
769    let mut any_fail = false;
770    let mut out = String::new();
771    for (id, section_name, byte_len, result) in &results {
772        match result {
773            Ok(()) => {
774                let _ = writeln!(
775                    out,
776                    "  section {:2} ({:<12}) {:>10} bytes  OK",
777                    id, section_name, byte_len
778                );
779            }
780            Err(msg) => {
781                let _ = writeln!(
782                    out,
783                    "  section {:2} ({:<12}) {:>10} bytes  CORRUPT: {msg}",
784                    id, section_name, byte_len
785                );
786                any_fail = true;
787            }
788        }
789    }
790    if any_fail {
791        Err(CliError(format!("integrity check FAILED:\n{out}")))
792    } else {
793        Ok(format!(
794            "integrity check OK ({} sections):\n{out}",
795            results.len()
796        ))
797    }
798}
799
800/// Open `dir` and write `snapshot.bin`. Default truncates the WAL.
801pub fn run_snapshot(
802    db_dir: &Path,
803    keep_wal: bool,
804    archive_wal: bool,
805    retention: Option<u32>,
806) -> Result<String, CliError> {
807    let mut db = GraphDb::open(db_dir)?;
808    if archive_wal {
809        db.set_wal_archive_retention(retention);
810        db.snapshot_with(SnapshotOptions {
811            archive_wal: true,
812            keep_wal: false,
813        })?;
814    } else if keep_wal {
815        db.snapshot_with(SnapshotOptions {
816            keep_wal: true,
817            archive_wal: false,
818        })?;
819    } else {
820        db.snapshot()?;
821    }
822    Ok(format!(
823        "snapshot written: {}\n",
824        db_dir.join("snapshot.bin").display()
825    ))
826}
827
828fn parse_schema(args: &[&str]) -> Result<Command, String> {
829    if args.is_empty() {
830        return Err("schema requires a subcommand: apply".to_string());
831    }
832    match args[0] {
833        "apply" => parse_schema_apply(&args[1..]),
834        other => Err(format!(
835            "unknown schema subcommand: {other}; expected apply"
836        )),
837    }
838}
839
840fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
841    let mut db_dir = None;
842    let mut schema_file = None;
843    for a in args {
844        if a.starts_with('-') {
845            return Err(format!("unexpected flag: {a}"));
846        }
847        if db_dir.is_none() {
848            db_dir = Some(PathBuf::from(*a));
849        } else if schema_file.is_none() {
850            schema_file = Some(PathBuf::from(*a));
851        } else {
852            return Err(format!("unexpected extra argument: {a}"));
853        }
854    }
855    let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
856    let schema_file =
857        schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
858    Ok(Command::SchemaApply {
859        db_dir,
860        schema_file,
861    })
862}
863
864/// Read `schema_file`, open `db_dir`, apply the schema, and return the diff
865/// as one line per entry: `"created rule:x"`, `"updated view:y"`, etc.
866pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
867    let json = std::fs::read_to_string(schema_file)
868        .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
869    let schema: Schema = serde_json::from_str(&json).map_err(|e| {
870        CliError(format!(
871            "invalid schema JSON in {}: {e}",
872            schema_file.display()
873        ))
874    })?;
875    let mut db = GraphDb::open(db_dir)?;
876    let diff = db.apply_schema(&schema)?;
877    let mut out = String::new();
878    for entry in &diff.created {
879        let _ = writeln!(out, "created {entry}");
880    }
881    for entry in &diff.updated {
882        let _ = writeln!(out, "updated {entry}");
883    }
884    for entry in &diff.unchanged {
885        let _ = writeln!(out, "unchanged {entry}");
886    }
887    if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
888        let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
889    }
890    Ok(out)
891}
892
893fn parse_backup(args: &[&str]) -> Result<Command, String> {
894    let mut db_dir = None;
895    let mut dest = None;
896    for a in args {
897        if a.starts_with('-') {
898            return Err(format!("unexpected flag: {a}"));
899        }
900        if db_dir.is_none() {
901            db_dir = Some(PathBuf::from(*a));
902        } else if dest.is_none() {
903            dest = Some(PathBuf::from(*a));
904        } else {
905            return Err(format!("unexpected extra argument: {a}"));
906        }
907    }
908    let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
909    let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
910    Ok(Command::Backup { db_dir, dest })
911}
912
913fn parse_export(args: &[&str]) -> Result<Command, String> {
914    let mut db_dir = None;
915    let mut dest = None;
916    let mut format = ExportFormat::Jsonl;
917    let mut i = 0;
918    while i < args.len() {
919        let a = args[i];
920        if a == "--format" {
921            let val = args
922                .get(i + 1)
923                .copied()
924                .ok_or_else(|| "missing value for --format".to_string())?;
925            format = ExportFormat::parse(val)
926                .ok_or_else(|| format!("unknown format '{val}'; expected jsonl or parquet"))?;
927            i += 2;
928        } else if let Some(val) = a.strip_prefix("--format=") {
929            format = ExportFormat::parse(val)
930                .ok_or_else(|| format!("unknown format '{val}'; expected jsonl or parquet"))?;
931            i += 1;
932        } else if a.starts_with('-') {
933            return Err(format!("unexpected flag: {a}"));
934        } else if db_dir.is_none() {
935            db_dir = Some(PathBuf::from(a));
936            i += 1;
937        } else if dest.is_none() {
938            dest = Some(PathBuf::from(a));
939            i += 1;
940        } else {
941            return Err(format!("unexpected extra argument: {a}"));
942        }
943    }
944    let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
945    let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
946    Ok(Command::Export {
947        db_dir,
948        dest,
949        format,
950    })
951}
952
953/// Create a consistent, verified backup of `db_dir` to `dest`.
954pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
955    let db = GraphDb::open(db_dir)?;
956    Ok(db.backup_to(dest)?)
957}
958
959/// Format a [`BackupReport`] for display.
960pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
961    let mut out = String::new();
962    writeln!(out, "backup to: {}", dest.display()).unwrap();
963    writeln!(out, "  files: {}", report.files.join(", ")).unwrap();
964    writeln!(out, "  bytes: {}", report.bytes).unwrap();
965    writeln!(out, "  verified: {}", report.verified).unwrap();
966    out
967}
968
969/// Export all data from `db_dir` to `dest` in `format`.
970pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
971    let db = GraphDb::open(db_dir)?;
972    let nodes = db.all_nodes_for_export();
973    let edges = db.all_edges_for_export();
974    let mut rules = db.rules();
975    rules.sort_by(|a, b| a.name.cmp(&b.name));
976    let node_count = nodes.len();
977    let edge_count = edges.len();
978    let rule_count = rules.len();
979    match format {
980        ExportFormat::Jsonl => export::write_jsonl(&nodes, &edges, &rules, dest)?,
981        ExportFormat::Parquet => export::write_parquet(&nodes, &edges, &rules, dest)?,
982    }
983    Ok(format!(
984        "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
985        dest.display(),
986        format.name(),
987        node_count,
988        edge_count,
989        rule_count
990    ))
991}
992
993fn format_result_set(rs: &ResultSet) -> String {
994    let mut out = String::new();
995    let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
996    for i in 0..rs.len() {
997        let cells: Vec<String> = rs
998            .columns()
999            .iter()
1000            .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
1001            .collect();
1002        let _ = writeln!(out, "  {}", cells.join("  "));
1003    }
1004    out
1005}
1006
1007fn parse_algo(args: &[&str]) -> Result<Command, String> {
1008    if args.is_empty() {
1009        return Err("algo requires a subcommand: pagerank | wcc | degree".to_string());
1010    }
1011    let subcmd = match args[0] {
1012        "pagerank" => AlgoSubcmd::Pagerank,
1013        "wcc" => AlgoSubcmd::Wcc,
1014        "degree" => AlgoSubcmd::Degree,
1015        other => {
1016            return Err(format!(
1017                "unknown algo subcommand: {other}; expected pagerank | wcc | degree"
1018            ))
1019        }
1020    };
1021    let rest = &args[1..];
1022    let mut db_dir = None;
1023    let mut top: usize = 20;
1024    let mut dir = AlgoDir::Both;
1025    let mut i = 0;
1026    while i < rest.len() {
1027        let a = rest[i];
1028        if a == "--top" {
1029            let val = rest
1030                .get(i + 1)
1031                .copied()
1032                .ok_or_else(|| "missing value for --top".to_string())?;
1033            top = val
1034                .parse()
1035                .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1036            i += 2;
1037        } else if let Some(val) = a.strip_prefix("--top=") {
1038            top = val
1039                .parse()
1040                .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1041            i += 1;
1042        } else if a == "--dir" {
1043            let val = rest
1044                .get(i + 1)
1045                .copied()
1046                .ok_or_else(|| "missing value for --dir".to_string())?;
1047            dir = parse_algo_dir(val)?;
1048            i += 2;
1049        } else if let Some(val) = a.strip_prefix("--dir=") {
1050            dir = parse_algo_dir(val)?;
1051            i += 1;
1052        } else if a.starts_with('-') {
1053            return Err(format!("unexpected flag: {a}"));
1054        } else if db_dir.is_none() {
1055            db_dir = Some(PathBuf::from(a));
1056            i += 1;
1057        } else {
1058            return Err(format!("unexpected extra argument: {a}"));
1059        }
1060    }
1061    let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
1062    Ok(Command::Algo {
1063        db_dir,
1064        subcmd,
1065        top,
1066        dir,
1067    })
1068}
1069
1070/// Parse the `--dir` value for `algo` into an [`AlgoDir`].
1071fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
1072    match val.to_ascii_lowercase().as_str() {
1073        "out" => Ok(AlgoDir::Out),
1074        "in" => Ok(AlgoDir::In),
1075        "both" => Ok(AlgoDir::Both),
1076        other => Err(format!("--dir must be one of out | in | both, got {other}")),
1077    }
1078}
1079
1080/// Run a graph algorithm and return a formatted string.
1081///
1082/// `dir` selects the edge direction for `degree` and `pagerank`; `wcc` is
1083/// always undirected and ignores it.
1084pub fn run_algo(
1085    db_dir: &Path,
1086    subcmd: &AlgoSubcmd,
1087    top: usize,
1088    dir: AlgoDir,
1089) -> Result<String, CliError> {
1090    let db = GraphDb::open(db_dir)?;
1091    match subcmd {
1092        AlgoSubcmd::Pagerank => {
1093            let config = PageRankConfig {
1094                direction: dir,
1095                ..PageRankConfig::default()
1096            };
1097            let report = db.pagerank(&config);
1098            Ok(format_pagerank(&report, top))
1099        }
1100        AlgoSubcmd::Wcc => {
1101            let config = WccConfig::default();
1102            let report = db.connected_components(&config);
1103            Ok(format_wcc(&report, top))
1104        }
1105        AlgoSubcmd::Degree => {
1106            let config = DegreeConfig {
1107                direction: dir,
1108                ..DegreeConfig::default()
1109            };
1110            let report = db.degree_centrality(&config);
1111            Ok(format_degree(&report, top))
1112        }
1113    }
1114}
1115
1116fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
1117    let mut buf = String::new();
1118    let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
1119    let rows = if top == 0 {
1120        report.scores.as_slice()
1121    } else {
1122        &report.scores[..top.min(report.scores.len())]
1123    };
1124    for (i, (key, score)) in rows.iter().enumerate() {
1125        let _ = writeln!(buf, "  {:>4}  {:<40}  {:.6}", i + 1, key, score);
1126    }
1127    buf
1128}
1129
1130fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
1131    let mut buf = String::new();
1132    let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
1133    let rows = if top == 0 {
1134        report.components.as_slice()
1135    } else {
1136        &report.components[..top.min(report.components.len())]
1137    };
1138    for (key, comp_id) in rows {
1139        let _ = writeln!(buf, "  {:<40}  component={}", key, comp_id);
1140    }
1141    buf
1142}
1143
1144fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
1145    let mut buf = String::new();
1146    let _ = writeln!(
1147        buf,
1148        "== degree centrality (truncated={}) ==",
1149        report.truncated
1150    );
1151    let rows = if top == 0 {
1152        report.scores.as_slice()
1153    } else {
1154        &report.scores[..top.min(report.scores.len())]
1155    };
1156    for (i, (key, deg)) in rows.iter().enumerate() {
1157        let _ = writeln!(buf, "  {:>4}  {:<40}  degree={}", i + 1, key, deg);
1158    }
1159    buf
1160}
1161
1162fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
1163    let mut db_dir = None;
1164    for a in args {
1165        if a.starts_with('-') {
1166            return Err(format!("unexpected flag: {a}"));
1167        }
1168        if db_dir.is_some() {
1169            return Err(format!("unexpected extra argument: {a}"));
1170        }
1171        db_dir = Some(PathBuf::from(*a));
1172    }
1173    db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
1174}
1175
1176/// Pretty-print [`Stats`] for `mushroomdb stats` and the demo smoke test.
1177pub fn format_stats(stats: &Stats) -> String {
1178    let mut out = String::new();
1179    let _ = writeln!(
1180        out,
1181        "nodes: {} live, {} tombstoned",
1182        stats.nodes_live, stats.nodes_tombstoned
1183    );
1184    let _ = writeln!(out, "edges: {}", stats.edges);
1185    let _ = writeln!(out, "rules: {}", stats.rules.len());
1186    for r in &stats.rules {
1187        let _ = writeln!(
1188            out,
1189            "  {:<28} edges={}  tripped={}",
1190            r.name, r.edges, r.tripped
1191        );
1192    }
1193    out
1194}
1195
1196/// Open `dir` and return live stats.
1197pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
1198    let db = SharedDb::open(dir)?;
1199    let stats = db.read().stats();
1200    Ok(stats)
1201}
1202
1203/// Build the deterministic demo dataset in an empty `dir`.
1204///
1205/// Refuses if `dir` already exists and is not empty. Ingests 10 Orgs, 20
1206/// Projects, 30 People via [`SharedDb`] / `ingest_json` (auto-FK on `*_id`)
1207/// then declares `skill_fit` plus the three Predicates II rules.
1208pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
1209    refuse_non_empty(dir)?;
1210
1211    let db = SharedDb::open(dir)?;
1212    let opts = IngestOptions::default();
1213    let mut auto_fk_rules = Vec::new();
1214
1215    {
1216        let mut w = db.write();
1217        for (label, json) in [
1218            ("Org", org_json()),
1219            ("Project", project_json()),
1220            ("Person", person_json()),
1221        ] {
1222            let report = w.ingest_json(label, &json, &opts)?;
1223            if !report.row_errors.is_empty() {
1224                return Err(CliError(format!(
1225                    "demo ingest of {label} had row errors: {:?}",
1226                    report.row_errors
1227                )));
1228            }
1229            auto_fk_rules.extend(report.rules_created);
1230        }
1231        let skill_fit = Predicate::Overlap {
1232            field: "skills".into(),
1233            min: 0.5,
1234        };
1235        let skill_fit_k = Some(default_max_edges(&skill_fit));
1236        w.create_rule(RuleDef {
1237            name: "skill_fit".into(),
1238            src_label: "Person".into(),
1239            dst_label: "Project".into(),
1240            predicate: skill_fit,
1241            edge_type: "FIT".into(),
1242            weight_prop: Some("score".into()),
1243            max_edges: skill_fit_k,
1244            approximate: false,
1245            via_label: None,
1246            via_edge: None,
1247            via_dir: None,
1248        })?;
1249        let founded_within = Predicate::NumericWithin {
1250            field: "founded_year".into(),
1251            tolerance: 2.0,
1252        };
1253        let founded_within_k = Some(default_max_edges(&founded_within));
1254        w.create_rule(RuleDef {
1255            name: "founded_within".into(),
1256            src_label: "Org".into(),
1257            dst_label: "Org".into(),
1258            predicate: founded_within,
1259            edge_type: "FOUNDED_WITHIN".into(),
1260            weight_prop: Some("score".into()),
1261            max_edges: founded_within_k,
1262            approximate: false,
1263            via_label: None,
1264            via_edge: None,
1265            via_dir: None,
1266        })?;
1267        let nearby_office = Predicate::GeoRadius {
1268            field: "office".into(),
1269            km: 50.0,
1270        };
1271        let nearby_office_k = Some(default_max_edges(&nearby_office));
1272        w.create_rule(RuleDef {
1273            name: "nearby_office".into(),
1274            src_label: "Org".into(),
1275            dst_label: "Org".into(),
1276            predicate: nearby_office,
1277            edge_type: "NEARBY_OFFICE".into(),
1278            weight_prop: Some("score".into()),
1279            max_edges: nearby_office_k,
1280            approximate: false,
1281            via_label: None,
1282            via_edge: None,
1283            via_dir: None,
1284        })?;
1285        let similar_interests = Predicate::VectorSimilar {
1286            field: "embedding".into(),
1287            min: 0.8,
1288        };
1289        let similar_interests_k = Some(default_max_edges(&similar_interests));
1290        w.create_rule(RuleDef {
1291            name: "similar_interests".into(),
1292            src_label: "Person".into(),
1293            dst_label: "Person".into(),
1294            predicate: similar_interests,
1295            edge_type: "SIMILAR".into(),
1296            weight_prop: Some("score".into()),
1297            max_edges: similar_interests_k,
1298            approximate: false,
1299            via_label: None,
1300            via_edge: None,
1301            via_dir: None,
1302        })?;
1303        // Name lookup for `mushroomdb recall`. Adds no nodes or edges.
1304        for (label, field) in [("Org", "name"), ("Project", "name"), ("Person", "name")] {
1305            w.enable_fulltext(label, field)?;
1306        }
1307    }
1308
1309    let r = db.read();
1310    let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
1311    let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
1312    let stats = r.stats();
1313    // Rule suggestion teaser: first suggestion sorted by est_edges desc.
1314    let suggestion = r.suggest_rules().into_iter().next();
1315
1316    Ok(DemoOutcome {
1317        auto_fk_rules,
1318        sample_query: SAMPLE_QUERY.to_string(),
1319        sample_result,
1320        explanations,
1321        stats,
1322        suggestion,
1323    })
1324}
1325
1326fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
1327    if dir.is_file() {
1328        return Err(CliError(format!(
1329            "demo refuses a non-empty directory: {} is a file",
1330            dir.display()
1331        )));
1332    }
1333    if !dir.exists() {
1334        return Ok(true);
1335    }
1336    Ok(std::fs::read_dir(dir)?.next().is_none())
1337}
1338
1339fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
1340    if dir_is_empty_or_absent(dir)? {
1341        Ok(())
1342    } else {
1343        Err(CliError(format!(
1344            "demo refuses a non-empty directory: {} \
1345             (directory must be empty — including hidden files)",
1346            dir.display()
1347        )))
1348    }
1349}
1350
1351/// Run [`run_demo`] when `dir` is missing or empty; otherwise leave it alone.
1352pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
1353    if dir_is_empty_or_absent(dir)? {
1354        Ok(Some(run_demo(dir)?))
1355    } else {
1356        Ok(None)
1357    }
1358}
1359
1360fn json_array(rows: impl IntoIterator<Item = String>) -> String {
1361    let mut out = String::from("[");
1362    let mut first = true;
1363    for row in rows {
1364        if !first {
1365            out.push(',');
1366        }
1367        first = false;
1368        out.push_str(&row);
1369    }
1370    out.push(']');
1371    out
1372}
1373
1374/// Wrap a 1-based project index into `1..=N_PROJECTS`.
1375fn wrap_proj(i: usize) -> usize {
1376    (i - 1) % N_PROJECTS + 1
1377}
1378
1379/// Sliding window of `len` skill tokens starting at project `start`.
1380fn skill_window_json(start: usize, len: usize) -> String {
1381    let parts: Vec<String> = (0..len)
1382        .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
1383        .collect();
1384    format!("[{}]", parts.join(","))
1385}
1386
1387/// Real city [lat, lon] for org `i` (1-based). Four clusters sit inside 50 km:
1388/// NYC / Jersey City / Newark, SF / Oakland / Berkeley, London / Greenwich,
1389/// Paris / Versailles.
1390fn org_office(i: usize) -> (f64, f64) {
1391    match i {
1392        1 => (40.7128, -74.0060),  // New York
1393        2 => (48.8566, 2.3522),    // Paris
1394        3 => (51.5074, -0.1278),   // London
1395        4 => (37.7749, -122.4194), // San Francisco
1396        5 => (37.8044, -122.2711), // Oakland
1397        6 => (37.8715, -122.2730), // Berkeley
1398        7 => (40.7178, -74.0431),  // Jersey City
1399        8 => (51.4769, 0.0005),    // Greenwich
1400        9 => (48.8014, 2.1301),    // Versailles
1401        10 => (40.7357, -74.1724), // Newark
1402        _ => unreachable!("demo orgs are 1..=10"),
1403    }
1404}
1405
1406/// Dim-8 embedding for person `i`. Groups of three share a unit axis (cos = 1);
1407/// two extra groups are (0.8, 0.6, …) and (0.6, 0.8, …) so cos = 0.8 / 0.96
1408/// against the first two axes is hand-checkable.
1409fn person_embedding_json(i: usize) -> String {
1410    let mut v = [0.0_f64; 8];
1411    match i {
1412        9 | 19 | 29 => {
1413            v[0] = 0.8;
1414            v[1] = 0.6;
1415        }
1416        10 | 20 | 30 => {
1417            v[0] = 0.6;
1418            v[1] = 0.8;
1419        }
1420        _ => {
1421            let axis = (i - 1) % 10;
1422            debug_assert!(axis < 8);
1423            v[axis] = 1.0;
1424        }
1425    }
1426    let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
1427    format!("[{}]", parts.join(","))
1428}
1429
1430fn org_json() -> String {
1431    json_array((1..=N_ORGS).map(|i| {
1432        let year = 2010 + (i as i64 - 1);
1433        let (lat, lon) = org_office(i);
1434        format!(
1435            r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
1436            skill_window_json(i, 3)
1437        )
1438    }))
1439}
1440
1441fn project_json() -> String {
1442    json_array((1..=N_PROJECTS).map(|i| {
1443        let org = (i - 1) % N_ORGS + 1;
1444        format!(
1445            r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
1446            skill_window_json(i, 3)
1447        )
1448    }))
1449}
1450
1451fn person_json() -> String {
1452    json_array((1..=N_PEOPLE).map(|i| {
1453        let org = (i - 1) % N_ORGS + 1;
1454        let proj = (i - 1) % N_PROJECTS + 1;
1455        format!(
1456            r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
1457            person_embedding_json(i),
1458            skill_window_json(proj, 3)
1459        )
1460    }))
1461}
1462
1463/// Render a [`DemoOutcome`] the way `mushroomdb demo` prints it.
1464pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
1465    let mut buf = String::new();
1466    let _ = writeln!(buf, "== demo ==");
1467    let _ = writeln!(
1468        buf,
1469        "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
1470    );
1471    let _ = writeln!(
1472        buf,
1473        "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
1474    );
1475    let _ = writeln!(
1476        buf,
1477        "numeric rule: founded_within (Org.founded_year, tolerance 2)"
1478    );
1479    let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
1480    let _ = writeln!(
1481        buf,
1482        "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
1483    );
1484    let _ = writeln!(buf);
1485    let _ = writeln!(buf, "== auto-FK rules ==");
1486    let mut names = out.auto_fk_rules.clone();
1487    names.sort();
1488    for name in names {
1489        let _ = writeln!(buf, "  {name}");
1490    }
1491    let _ = writeln!(buf);
1492    let _ = writeln!(buf, "== query ==");
1493    let _ = writeln!(buf, "{}", out.sample_query);
1494    let _ = writeln!(buf);
1495    let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
1496    for i in 0..out.sample_result.len() {
1497        let cells: Vec<String> = out
1498            .sample_result
1499            .columns()
1500            .iter()
1501            .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
1502            .collect();
1503        let _ = writeln!(buf, "  {}", cells.join("  "));
1504    }
1505    let _ = writeln!(buf);
1506    let _ = writeln!(
1507        buf,
1508        "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
1509    );
1510    for e in &out.explanations {
1511        let weight = e
1512            .weight
1513            .map(|w| fmt_value(&Value::Float(w)))
1514            .unwrap_or_else(|| "none".into());
1515        let _ = writeln!(
1516            buf,
1517            "  rule={}  type={}  {}→{}  weight={}",
1518            e.rule, e.edge_type, e.src_key, e.dst_key, weight
1519        );
1520    }
1521    let _ = writeln!(buf);
1522    let _ = writeln!(buf, "== serve ==");
1523    let _ = writeln!(buf, "  mushroomdb serve {}", dir.display());
1524
1525    // Teaser: one suggestion from the rule suggester (not auto-applied).
1526    if let Some(s) = &out.suggestion {
1527        let _ = writeln!(buf);
1528        let _ = writeln!(buf, "== suggested rule (teaser) ==");
1529        let _ = writeln!(buf, "  {}", s.def.name);
1530        let _ = writeln!(
1531            buf,
1532            "  {} → {} via {:?}",
1533            s.def.src_label, s.def.dst_label, s.def.predicate
1534        );
1535        let _ = writeln!(buf, "  est_edges: ~{}", s.est_edges);
1536        let _ = writeln!(buf, "  {}", s.rationale);
1537        let _ = writeln!(
1538            buf,
1539            "  (run `mushroomdb suggest {}` for full analysis)",
1540            dir.display()
1541        );
1542    }
1543
1544    buf
1545}
1546
1547/// Profile the database at `dir` and return all rule suggestions.
1548pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
1549    let db = GraphDb::open(dir)?;
1550    Ok(db.suggest_rules())
1551}
1552
1553/// Pretty-print a list of [`RuleSuggestion`]s for `mushroomdb suggest`.
1554pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
1555    let mut buf = String::new();
1556    if suggestions.is_empty() {
1557        let _ = writeln!(
1558            buf,
1559            "no rule suggestions (database may be empty or rules already cover all patterns)"
1560        );
1561        return buf;
1562    }
1563    let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
1564    for (i, s) in suggestions.iter().enumerate() {
1565        let _ = writeln!(buf);
1566        let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
1567        let _ = writeln!(
1568            buf,
1569            "    {} → {}  via {:?}",
1570            s.def.src_label, s.def.dst_label, s.def.predicate
1571        );
1572        let _ = writeln!(buf, "    est_edges : ~{}", s.est_edges);
1573        let _ = writeln!(buf, "    rationale : {}", s.rationale);
1574        if !s.examples.is_empty() {
1575            let _ = writeln!(buf, "    examples  :");
1576            for (src, dst, score) in &s.examples {
1577                let _ = writeln!(buf, "      {src} → {dst}  score={score:.4}");
1578            }
1579        }
1580        let _ = writeln!(buf, "    predicate : {:?}", s.def.predicate);
1581        let _ = writeln!(
1582            buf,
1583            "    to apply  : POST /rules  or  db.create_rule(suggestion.def)"
1584        );
1585    }
1586    buf
1587}
1588
1589fn fmt_value(v: &Value) -> String {
1590    match v {
1591        Value::Int(i) => i.to_string(),
1592        Value::Float(f) => {
1593            let s = format!("{f}");
1594            if s.contains('.') || s.contains('e') || s.contains('E') {
1595                s
1596            } else {
1597                format!("{s}.0")
1598            }
1599        }
1600        Value::Str(s) => s.clone(),
1601        Value::Bool(b) => b.to_string(),
1602        Value::List(xs) => {
1603            let inner: Vec<String> = xs.iter().map(fmt_value).collect();
1604            format!("[{}]", inner.join(", "))
1605        }
1606        Value::Map(m) => {
1607            let inner: Vec<String> = m
1608                .iter()
1609                .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
1610                .collect();
1611            format!("{{{}}}", inner.join(", "))
1612        }
1613    }
1614}
1615
1616fn fmt_cell(cell: Option<&Value>) -> String {
1617    match cell {
1618        None => "null".into(),
1619        Some(v) => fmt_value(v),
1620    }
1621}
1622
1623#[cfg(test)]
1624mod tests {
1625    use super::*;
1626    use std::collections::BTreeSet;
1627    use std::net::SocketAddr;
1628    use std::path::PathBuf;
1629
1630    fn tmp(name: &str) -> PathBuf {
1631        let nanos = std::time::SystemTime::now()
1632            .duration_since(std::time::UNIX_EPOCH)
1633            .expect("clock")
1634            .as_nanos();
1635        let d = std::env::temp_dir().join(format!(
1636            "graphdb-cli-{}-{}-{}",
1637            name,
1638            std::process::id(),
1639            nanos
1640        ));
1641        let _ = std::fs::remove_dir_all(&d);
1642        d
1643    }
1644
1645    fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
1646        let g = db.read();
1647        let mut out = BTreeSet::new();
1648        for i in 1..=N_ORGS {
1649            let src = format!("org-{i:02}");
1650            if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
1651                for dst in nbrs {
1652                    out.insert((src.clone(), dst));
1653                }
1654            }
1655        }
1656        for i in 1..=N_PEOPLE {
1657            let src = format!("person-{i:02}");
1658            if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
1659                for dst in nbrs {
1660                    out.insert((src.clone(), dst));
1661                }
1662            }
1663        }
1664        out
1665    }
1666
1667    fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
1668        let hits: Vec<_> = db
1669            .read()
1670            .explain(a, b)
1671            .expect("explain")
1672            .into_iter()
1673            .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
1674            .collect();
1675        assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
1676        let got = hits[0].weight.expect("weighted");
1677        assert!(
1678            (got - want).abs() < 1e-12,
1679            "{rule} {a}→{b}: got {got} want {want}"
1680        );
1681    }
1682
1683    fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
1684        const R: f64 = 6371.0088;
1685        let phi1 = lat1.to_radians();
1686        let phi2 = lat2.to_radians();
1687        let dphi = (lat2 - lat1).to_radians();
1688        let dlam = (lon2 - lon1).to_radians();
1689        let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
1690            .clamp(0.0, 1.0);
1691        let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
1692        R * c
1693    }
1694
1695    fn default_bind() -> SocketAddr {
1696        SocketAddr::from(([127, 0, 0, 1], 8080))
1697    }
1698
1699    #[test]
1700    fn parse_args_table() {
1701        struct Case {
1702            args: &'static [&'static str],
1703            check: fn(Result<Command, String>),
1704        }
1705
1706        let cases = [
1707            Case {
1708                args: &[],
1709                check: |r| match r {
1710                    Ok(Command::Help) => {}
1711                    other => panic!("no-args → Help, got {other:?}"),
1712                },
1713            },
1714            Case {
1715                args: &["--help"],
1716                check: |r| match r {
1717                    Ok(Command::Help) => {}
1718                    other => panic!("--help → Help, got {other:?}"),
1719                },
1720            },
1721            Case {
1722                args: &["-h"],
1723                check: |r| match r {
1724                    Ok(Command::Help) => {}
1725                    other => panic!("-h → Help, got {other:?}"),
1726                },
1727            },
1728            Case {
1729                args: &["serve", "/tmp/demo-db"],
1730                check: |r| match r {
1731                    Ok(Command::Serve {
1732                        db_dir,
1733                        addr,
1734                        ui,
1735                        demo_if_empty,
1736                        token,
1737                        role_tokens,
1738                        snapshot_every,
1739                        tls_cert,
1740                        tls_key,
1741                    }) => {
1742                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1743                        assert_eq!(addr, default_bind());
1744                        assert_eq!(ui, super::ServeUi::Embedded);
1745                        assert!(!demo_if_empty);
1746                        assert_eq!(token, None);
1747                        assert!(role_tokens.is_empty());
1748                        assert_eq!(snapshot_every, None);
1749                        assert_eq!(tls_cert, None);
1750                        assert_eq!(tls_key, None);
1751                    }
1752                    other => panic!("serve <dir> → Serve default addr, got {other:?}"),
1753                },
1754            },
1755            Case {
1756                args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
1757                check: |r| match r {
1758                    Ok(Command::Serve {
1759                        db_dir,
1760                        addr,
1761                        ui,
1762                        demo_if_empty,
1763                        token,
1764                        role_tokens,
1765                        snapshot_every,
1766                        tls_cert,
1767                        tls_key,
1768                    }) => {
1769                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1770                        assert_eq!(
1771                            addr,
1772                            "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
1773                        );
1774                        assert_eq!(ui, super::ServeUi::Embedded);
1775                        assert!(!demo_if_empty);
1776                        assert_eq!(token, None);
1777                        assert!(role_tokens.is_empty());
1778                        assert_eq!(snapshot_every, None);
1779                        assert_eq!(tls_cert, None);
1780                        assert_eq!(tls_key, None);
1781                    }
1782                    other => panic!("serve --addr after dir, got {other:?}"),
1783                },
1784            },
1785            Case {
1786                args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
1787                check: |r| match r {
1788                    Ok(Command::Serve {
1789                        db_dir,
1790                        addr,
1791                        ui,
1792                        demo_if_empty,
1793                        token,
1794                        role_tokens,
1795                        snapshot_every,
1796                        tls_cert,
1797                        tls_key,
1798                    }) => {
1799                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1800                        assert_eq!(
1801                            addr,
1802                            "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
1803                        );
1804                        assert_eq!(ui, super::ServeUi::Embedded);
1805                        assert!(!demo_if_empty);
1806                        assert_eq!(token, None);
1807                        let _ = role_tokens; // empty, not asserted
1808                        assert_eq!(snapshot_every, None);
1809                        assert_eq!(tls_cert, None);
1810                        assert_eq!(tls_key, None);
1811                    }
1812                    other => panic!("serve --addr=VALUE, got {other:?}"),
1813                },
1814            },
1815            Case {
1816                args: &["mcp", "/tmp/demo-db"],
1817                check: |r| match r {
1818                    Ok(Command::Mcp { db_dir }) => {
1819                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1820                    }
1821                    other => panic!("mcp <dir>, got {other:?}"),
1822                },
1823            },
1824            Case {
1825                args: &["stats", "/tmp/demo-db"],
1826                check: |r| match r {
1827                    Ok(Command::Stats { db_dir }) => {
1828                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1829                    }
1830                    other => panic!("stats <dir>, got {other:?}"),
1831                },
1832            },
1833            Case {
1834                args: &["demo", "/tmp/demo-db"],
1835                check: |r| match r {
1836                    Ok(Command::Demo { db_dir }) => {
1837                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1838                    }
1839                    other => panic!("demo <dir>, got {other:?}"),
1840                },
1841            },
1842            Case {
1843                args: &["serve"],
1844                check: |r| {
1845                    let e = r.expect_err("serve without dir");
1846                    assert!(
1847                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1848                        "missing-dir error should mention dir, got {e}"
1849                    );
1850                },
1851            },
1852            Case {
1853                args: &["mcp"],
1854                check: |r| {
1855                    let e = r.expect_err("mcp without dir");
1856                    assert!(
1857                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1858                        "missing-dir error should mention dir, got {e}"
1859                    );
1860                },
1861            },
1862            Case {
1863                args: &["stats"],
1864                check: |r| {
1865                    let e = r.expect_err("stats without dir");
1866                    assert!(
1867                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1868                        "missing-dir error should mention dir, got {e}"
1869                    );
1870                },
1871            },
1872            Case {
1873                args: &["demo"],
1874                check: |r| {
1875                    let e = r.expect_err("demo without dir");
1876                    assert!(
1877                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1878                        "missing-dir error should mention dir, got {e}"
1879                    );
1880                },
1881            },
1882            Case {
1883                args: &["serve", "/tmp/demo-db", "--addr"],
1884                check: |r| {
1885                    let e = r.expect_err("--addr missing value");
1886                    assert!(
1887                        e.to_lowercase().contains("addr"),
1888                        "--addr missing value should mention addr, got {e}"
1889                    );
1890                },
1891            },
1892            Case {
1893                args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
1894                check: |r| {
1895                    let e = r.expect_err("invalid addr");
1896                    assert!(
1897                        e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
1898                        "invalid addr should mention address, got {e}"
1899                    );
1900                },
1901            },
1902            Case {
1903                args: &["frobnicate", "/tmp/demo-db"],
1904                check: |r| {
1905                    let e = r.expect_err("unknown command");
1906                    assert!(
1907                        e.to_lowercase().contains("unknown")
1908                            || e.to_lowercase().contains("frobnicate"),
1909                        "unknown command should name it, got {e}"
1910                    );
1911                },
1912            },
1913            Case {
1914                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
1915                check: |r| match r {
1916                    Ok(Command::Serve { ui, .. }) => {
1917                        assert_eq!(
1918                            ui,
1919                            super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
1920                        );
1921                    }
1922                    other => panic!("serve --ui <dir>, got {other:?}"),
1923                },
1924            },
1925            Case {
1926                args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
1927                check: |r| match r {
1928                    Ok(Command::Serve { ui, .. }) => {
1929                        assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
1930                    }
1931                    other => panic!("serve --ui=VALUE, got {other:?}"),
1932                },
1933            },
1934            Case {
1935                args: &["serve", "/tmp/demo-db", "--ui"],
1936                check: |r| {
1937                    let e = r.expect_err("--ui missing value");
1938                    assert!(
1939                        e.to_lowercase().contains("ui"),
1940                        "--ui missing value should mention ui, got {e}"
1941                    );
1942                },
1943            },
1944            Case {
1945                args: &["serve", "/tmp/demo-db", "--no-ui"],
1946                check: |r| match r {
1947                    Ok(Command::Serve { ui, .. }) => {
1948                        assert_eq!(ui, super::ServeUi::None);
1949                    }
1950                    other => panic!("serve --no-ui, got {other:?}"),
1951                },
1952            },
1953            Case {
1954                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
1955                check: |r| {
1956                    let e = r.expect_err("combine --ui and --no-ui");
1957                    assert!(
1958                        e.contains("--ui") && e.contains("--no-ui"),
1959                        "conflict should name both flags, got {e}"
1960                    );
1961                },
1962            },
1963            Case {
1964                args: &["serve", "/tmp/demo-db", "extra"],
1965                check: |r| {
1966                    let e = r.expect_err("extra positional");
1967                    assert!(
1968                        e.to_lowercase().contains("unexpected")
1969                            || e.to_lowercase().contains("extra"),
1970                        "extra arg should be rejected, got {e}"
1971                    );
1972                },
1973            },
1974            Case {
1975                args: &[
1976                    "serve",
1977                    "/data",
1978                    "--addr",
1979                    "0.0.0.0:8080",
1980                    "--demo-if-empty",
1981                ],
1982                check: |r| match r {
1983                    Ok(Command::Serve {
1984                        db_dir,
1985                        addr,
1986                        demo_if_empty,
1987                        ui,
1988                        token,
1989                        snapshot_every,
1990                        ..
1991                    }) => {
1992                        assert_eq!(db_dir, PathBuf::from("/data"));
1993                        assert_eq!(
1994                            addr,
1995                            "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
1996                        );
1997                        assert!(demo_if_empty);
1998                        assert_eq!(ui, super::ServeUi::Embedded);
1999                        assert_eq!(token, None);
2000                        assert_eq!(snapshot_every, None);
2001                    }
2002                    other => panic!("serve --demo-if-empty docker default, got {other:?}"),
2003                },
2004            },
2005        ];
2006
2007        for case in &cases {
2008            (case.check)(parse_args(case.args));
2009        }
2010    }
2011
2012    #[test]
2013    fn serve_default_addr_is_loopback_8080() {
2014        match parse_args(&["serve", "/tmp/db"]).unwrap() {
2015            Command::Serve { addr, .. } => {
2016                assert_eq!(
2017                    addr,
2018                    "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
2019                );
2020            }
2021            other => panic!("{other:?}"),
2022        }
2023    }
2024
2025    #[test]
2026    fn serve_snapshot_every_parses_seconds() {
2027        match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
2028            Command::Serve { snapshot_every, .. } => {
2029                assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
2030            }
2031            other => panic!("{other:?}"),
2032        }
2033        match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
2034            Command::Serve { snapshot_every, .. } => {
2035                assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
2036            }
2037            other => panic!("{other:?}"),
2038        }
2039        match parse_args(&["serve", "/tmp/db"]).unwrap() {
2040            Command::Serve { snapshot_every, .. } => {
2041                assert_eq!(snapshot_every, None);
2042            }
2043            other => panic!("{other:?}"),
2044        }
2045        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
2046        assert!(
2047            err.contains("snapshot-every"),
2048            "missing value should name the flag, got {err}"
2049        );
2050        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
2051        assert!(
2052            err.contains("snapshot-every"),
2053            "zero should be rejected, got {err}"
2054        );
2055        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
2056        assert!(
2057            err.contains("snapshot-every"),
2058            "invalid value should name the flag, got {err}"
2059        );
2060    }
2061
2062    #[test]
2063    fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
2064        // parse succeeds; main() enforces the bind rule. Token is stored.
2065        match parse_args(&[
2066            "serve",
2067            "/tmp/db",
2068            "--addr",
2069            "0.0.0.0:8080",
2070            "--token",
2071            "s3cret",
2072        ])
2073        .unwrap()
2074        {
2075            Command::Serve { token, addr, .. } => {
2076                assert_eq!(token.as_deref(), Some("s3cret"));
2077                assert_eq!(addr.ip().to_string(), "0.0.0.0");
2078            }
2079            other => panic!("{other:?}"),
2080        }
2081    }
2082
2083    #[test]
2084    fn parse_snapshot_and_query() {
2085        match parse_args(&["snapshot", "/tmp/db"]).unwrap() {
2086            Command::Snapshot { keep_wal, .. } => assert!(!keep_wal),
2087            other => panic!("{other:?}"),
2088        }
2089        match parse_args(&["snapshot", "/tmp/db", "--keep-wal"]).unwrap() {
2090            Command::Snapshot { keep_wal, .. } => assert!(keep_wal),
2091            other => panic!("{other:?}"),
2092        }
2093        match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
2094            Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
2095            other => panic!("{other:?}"),
2096        }
2097        match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
2098            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
2099            other => panic!("{other:?}"),
2100        }
2101        match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
2102            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
2103            other => panic!("{other:?}"),
2104        }
2105        let text = usage();
2106        assert!(
2107            text.contains("query"),
2108            "usage should mention query, got:\n{text}"
2109        );
2110        assert!(
2111            text.contains("snapshot"),
2112            "usage should mention snapshot, got:\n{text}"
2113        );
2114    }
2115
2116    #[test]
2117    fn usage_lists_every_subcommand() {
2118        let text = usage();
2119        for word in [
2120            "serve",
2121            "mcp",
2122            "stats",
2123            "demo",
2124            "query",
2125            "snapshot",
2126            "--keep-wal",
2127            "mushroomdb",
2128            "--ui",
2129            "--no-ui",
2130            "--demo-if-empty",
2131            "--token",
2132            "--snapshot-every",
2133        ] {
2134            assert!(
2135                text.contains(word),
2136                "usage should mention {word}, got:\n{text}"
2137            );
2138        }
2139    }
2140
2141    #[test]
2142    fn validate_ui_dir_requires_index_html() {
2143        let missing = tmp("ui-missing");
2144        let err = super::validate_ui_dir(&missing).expect_err("missing dir");
2145        assert!(
2146            err.contains("does not exist"),
2147            "missing dir error, got {err}"
2148        );
2149
2150        let empty = tmp("ui-empty");
2151        std::fs::create_dir_all(&empty).unwrap();
2152        let err = super::validate_ui_dir(&empty).expect_err("no index");
2153        assert!(
2154            err.contains("index.html"),
2155            "missing index.html error, got {err}"
2156        );
2157
2158        let ok = tmp("ui-ok");
2159        std::fs::create_dir_all(&ok).unwrap();
2160        std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
2161        let got = super::validate_ui_dir(&ok).expect("valid ui dir");
2162        assert_eq!(got, ok);
2163    }
2164
2165    #[test]
2166    fn maybe_run_demo_if_empty_seeds_then_skips() {
2167        let dir = tmp("boot-empty");
2168        let first = super::maybe_run_demo_if_empty(&dir)
2169            .expect("empty dir demos")
2170            .expect("Some(DemoOutcome)");
2171        assert_eq!(first.stats.nodes_live, 60);
2172        let db = SharedDb::open(&dir).expect("reopen");
2173        assert!(db.read().has_node("person-01"));
2174        let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
2175        assert!(
2176            second.is_none(),
2177            "second boot must not re-demo a populated volume"
2178        );
2179
2180        let occupied = tmp("boot-occupied");
2181        std::fs::create_dir_all(&occupied).unwrap();
2182        std::fs::write(occupied.join("keep-me"), b"x").unwrap();
2183        let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
2184        assert!(skipped.is_none());
2185        assert_eq!(
2186            std::fs::read(occupied.join("keep-me")).unwrap(),
2187            b"x",
2188            "existing volume contents must be untouched"
2189        );
2190    }
2191
2192    #[test]
2193    fn demo_builder_is_deterministic_and_refuses_second_run() {
2194        let dir = tmp("demo");
2195        let out = run_demo(&dir).expect("first demo run");
2196
2197        assert_eq!(
2198            out.stats.nodes_live, 60,
2199            "10 orgs + 20 projects + 30 people"
2200        );
2201        assert_eq!(out.stats.nodes_tombstoned, 0);
2202        // Auto-FK: 20 project→org + 30 person→org + 30 person→project = 80.
2203        // FIT: each of 30 people matches home (Jaccard 1.0) and two adjacent
2204        // projects (3-skill window shifted ±1 → Jaccard 2/4 = 0.5) = 30*3 = 90.
2205        // founded_within: |year_i − year_j| ≤ 2 on 2010+(i-1) → 17 pairs × 2 = 34.
2206        // nearby_office: 4 city clusters (NYC/SF/London/Paris) → 8 pairs × 2 = 16.
2207        // similar_interests: dim-8 groups → 57 pairs × 2 = 114.
2208        // Total: 80 + 90 + 34 + 16 + 114 = 334.
2209        assert_eq!(out.stats.edges, 334);
2210        assert_eq!(
2211            out.stats.rules.len(),
2212            7,
2213            "3 auto-FK + overlap + numeric + geo + vector"
2214        );
2215        let fit = out
2216            .stats
2217            .rules
2218            .iter()
2219            .find(|r| r.name == "skill_fit")
2220            .expect("skill_fit");
2221        assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
2222        let founded = out
2223            .stats
2224            .rules
2225            .iter()
2226            .find(|r| r.name == "founded_within")
2227            .expect("founded_within");
2228        assert_eq!(founded.edges, 34);
2229        let nearby = out
2230            .stats
2231            .rules
2232            .iter()
2233            .find(|r| r.name == "nearby_office")
2234            .expect("nearby_office");
2235        assert_eq!(nearby.edges, 16);
2236        let similar = out
2237            .stats
2238            .rules
2239            .iter()
2240            .find(|r| r.name == "similar_interests")
2241            .expect("similar_interests");
2242        assert_eq!(similar.edges, 114);
2243
2244        let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
2245        names.sort_unstable();
2246        assert_eq!(
2247            names,
2248            vec![
2249                "auto_fk_person_org_id",
2250                "auto_fk_person_project_id",
2251                "auto_fk_project_org_id",
2252                "founded_within",
2253                "nearby_office",
2254                "similar_interests",
2255                "skill_fit",
2256            ]
2257        );
2258
2259        // `recall` needs a name index; enabling it adds no nodes, edges or rules.
2260        let db = SharedDb::open(&dir).expect("reopen demo");
2261        assert_eq!(
2262            db.read().fulltext_pairs(),
2263            vec![
2264                ("Org".to_string(), "name".to_string()),
2265                ("Person".to_string(), "name".to_string()),
2266                ("Project".to_string(), "name".to_string()),
2267            ]
2268        );
2269
2270        let mut auto = out.auto_fk_rules.clone();
2271        auto.sort();
2272        assert_eq!(
2273            auto,
2274            vec![
2275                "auto_fk_person_org_id".to_string(),
2276                "auto_fk_person_project_id".to_string(),
2277                "auto_fk_project_org_id".to_string(),
2278            ]
2279        );
2280
2281        assert!(
2282            !out.sample_result.is_empty(),
2283            "sample Cypher query must return rows"
2284        );
2285        assert!(
2286            out.sample_query.contains("ORDER BY score DESC"),
2287            "sample query must rank by score, got {}",
2288            out.sample_query
2289        );
2290        let scores: Vec<f64> = (0..out.sample_result.len())
2291            .map(|i| match out.sample_result.get(i, "score") {
2292                Some(Value::Float(f)) => *f,
2293                other => panic!("score col should be Float, got {other:?}"),
2294            })
2295            .collect();
2296        let distinct: std::collections::BTreeSet<u64> =
2297            scores.iter().map(|s| s.to_bits()).collect();
2298        assert!(
2299            distinct.len() >= 2,
2300            "sample results must be visibly ranked, got {scores:?}"
2301        );
2302        for w in scores.windows(2) {
2303            assert!(
2304                w[0] >= w[1],
2305                "scores must be non-increasing, got {scores:?}"
2306            );
2307        }
2308        assert!(
2309            !out.explanations.is_empty(),
2310            "explain(person-01, proj-01) must find the derived edges"
2311        );
2312
2313        let db = SharedDb::open(&dir).expect("reopen demo");
2314        assert_eq!(
2315            directed_pairs(&db, "FOUNDED_WITHIN"),
2316            [
2317                ("org-01", "org-02"),
2318                ("org-01", "org-03"),
2319                ("org-02", "org-01"),
2320                ("org-02", "org-03"),
2321                ("org-02", "org-04"),
2322                ("org-03", "org-01"),
2323                ("org-03", "org-02"),
2324                ("org-03", "org-04"),
2325                ("org-03", "org-05"),
2326                ("org-04", "org-02"),
2327                ("org-04", "org-03"),
2328                ("org-04", "org-05"),
2329                ("org-04", "org-06"),
2330                ("org-05", "org-03"),
2331                ("org-05", "org-04"),
2332                ("org-05", "org-06"),
2333                ("org-05", "org-07"),
2334                ("org-06", "org-04"),
2335                ("org-06", "org-05"),
2336                ("org-06", "org-07"),
2337                ("org-06", "org-08"),
2338                ("org-07", "org-05"),
2339                ("org-07", "org-06"),
2340                ("org-07", "org-08"),
2341                ("org-07", "org-09"),
2342                ("org-08", "org-06"),
2343                ("org-08", "org-07"),
2344                ("org-08", "org-09"),
2345                ("org-08", "org-10"),
2346                ("org-09", "org-07"),
2347                ("org-09", "org-08"),
2348                ("org-09", "org-10"),
2349                ("org-10", "org-08"),
2350                ("org-10", "org-09"),
2351            ]
2352            .into_iter()
2353            .map(|(a, b)| (a.to_string(), b.to_string()))
2354            .collect::<BTreeSet<_>>()
2355        );
2356        assert_eq!(
2357            directed_pairs(&db, "NEARBY_OFFICE"),
2358            [
2359                ("org-01", "org-07"),
2360                ("org-01", "org-10"),
2361                ("org-02", "org-09"),
2362                ("org-03", "org-08"),
2363                ("org-04", "org-05"),
2364                ("org-04", "org-06"),
2365                ("org-05", "org-04"),
2366                ("org-05", "org-06"),
2367                ("org-06", "org-04"),
2368                ("org-06", "org-05"),
2369                ("org-07", "org-01"),
2370                ("org-07", "org-10"),
2371                ("org-08", "org-03"),
2372                ("org-09", "org-02"),
2373                ("org-10", "org-01"),
2374                ("org-10", "org-07"),
2375            ]
2376            .into_iter()
2377            .map(|(a, b)| (a.to_string(), b.to_string()))
2378            .collect::<BTreeSet<_>>()
2379        );
2380        assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
2381        let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
2382        assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
2383        assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
2384        assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
2385
2386        let err = run_demo(&dir).expect_err("second run into the same dir");
2387        let msg = err.to_string().to_lowercase();
2388        assert!(
2389            msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
2390            "refuse message must mention non-empty dir, got {err}"
2391        );
2392        assert!(
2393            msg.contains("hidden"),
2394            "refuse message must mention hidden files, got {err}"
2395        );
2396
2397        let _ = std::fs::remove_dir_all(&dir);
2398    }
2399
2400    #[test]
2401    fn run_snapshot_writes_snapshot_bin() {
2402        let dir = tmp("snapshot-cli");
2403        {
2404            let mut db = GraphDb::open(&dir).expect("open");
2405            db.insert_node("Person", "alice", vec![]).expect("insert");
2406        }
2407        assert!(
2408            !dir.join("snapshot.bin").exists(),
2409            "GraphDb Drop must not snapshot"
2410        );
2411        let out = run_snapshot(&dir, false, false, None).expect("snapshot");
2412        assert!(
2413            dir.join("snapshot.bin").is_file(),
2414            "run_snapshot must write snapshot.bin"
2415        );
2416        assert!(
2417            out.contains("snapshot.bin"),
2418            "snapshot output should mention snapshot.bin, got {out}"
2419        );
2420        let db = GraphDb::open(&dir).expect("reopen");
2421        assert!(db.has_node("alice"), "reopen after snapshot must recover");
2422        let _ = std::fs::remove_dir_all(&dir);
2423    }
2424
2425    #[test]
2426    fn run_query_formats_like_asof() {
2427        let dir = tmp("query-cli");
2428        {
2429            let mut db = GraphDb::open(&dir).expect("open");
2430            db.insert_node(
2431                "Person",
2432                "alice",
2433                vec![("id".into(), Value::Str("alice".into()))],
2434            )
2435            .expect("insert");
2436        }
2437        let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id").expect("query");
2438        assert!(out.contains("columns:"), "got {out}");
2439        assert!(out.contains("id=alice"), "got {out}");
2440        let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})").expect("write");
2441        let db = GraphDb::open(&dir).expect("reopen");
2442        assert!(db.has_node("bob"), "query_write must persist CREATE");
2443        let _ = std::fs::remove_dir_all(&dir);
2444    }
2445
2446    #[test]
2447    fn format_stats_contains_counts() {
2448        let dir = tmp("stats-smoke");
2449        let out = run_demo(&dir).expect("demo for stats smoke");
2450        let text = format_stats(&out.stats);
2451        assert!(
2452            text.contains("60"),
2453            "stats output should include live node count, got:\n{text}"
2454        );
2455        assert!(
2456            text.contains("334"),
2457            "stats output should include edge count, got:\n{text}"
2458        );
2459        assert!(
2460            text.to_lowercase().contains("node"),
2461            "stats output should mention nodes, got:\n{text}"
2462        );
2463        assert!(
2464            text.to_lowercase().contains("edge"),
2465            "stats output should mention edges, got:\n{text}"
2466        );
2467        let _ = std::fs::remove_dir_all(&dir);
2468    }
2469
2470    // ── backup CLI tests ──────────────────────────────────────────────────────
2471
2472    #[test]
2473    fn parse_backup_round_trip() {
2474        let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
2475        match r {
2476            Ok(Command::Backup { db_dir, dest }) => {
2477                assert_eq!(db_dir, PathBuf::from("/db/dir"));
2478                assert_eq!(dest, PathBuf::from("/backup/dest"));
2479            }
2480            other => panic!("backup parse, got {other:?}"),
2481        }
2482    }
2483
2484    #[test]
2485    fn parse_backup_missing_dest_errors() {
2486        let r = parse_args(&["backup", "/db/dir"]);
2487        assert!(r.is_err(), "backup without <dest> should error");
2488        let e = r.unwrap_err();
2489        assert!(
2490            e.to_lowercase().contains("dest"),
2491            "error should mention dest, got: {e}"
2492        );
2493    }
2494
2495    #[test]
2496    fn parse_export_defaults_to_jsonl() {
2497        let r = parse_args(&["export", "/db/dir", "/export/dest"]);
2498        match r {
2499            Ok(Command::Export { format, .. }) => {
2500                assert_eq!(format, ExportFormat::Jsonl);
2501            }
2502            other => panic!("export parse, got {other:?}"),
2503        }
2504    }
2505
2506    #[test]
2507    fn parse_export_parquet_flag() {
2508        let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
2509        match r {
2510            Ok(Command::Export { format, .. }) => {
2511                assert_eq!(format, ExportFormat::Parquet);
2512            }
2513            other => panic!("export --format parquet parse, got {other:?}"),
2514        }
2515    }
2516
2517    #[test]
2518    fn parse_export_parquet_flag_eq() {
2519        let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
2520        match r {
2521            Ok(Command::Export { format, .. }) => {
2522                assert_eq!(format, ExportFormat::Parquet);
2523            }
2524            other => panic!("export --format=parquet parse, got {other:?}"),
2525        }
2526    }
2527
2528    #[test]
2529    fn run_backup_cli_produces_verified_report() {
2530        let src = tmp("cli-backup-src");
2531        let dst = tmp("cli-backup-dst");
2532        let _ = run_demo(&src).expect("demo");
2533        let report = run_backup(&src, &dst).expect("run_backup");
2534        assert!(report.verified, "backup must be verified");
2535        assert!(!report.files.is_empty());
2536        assert!(report.bytes > 0);
2537        let _ = std::fs::remove_dir_all(&src);
2538        let _ = std::fs::remove_dir_all(&dst);
2539    }
2540
2541    #[test]
2542    fn run_export_jsonl_two_runs_byte_identical() {
2543        let src = tmp("cli-export-src");
2544        let dst1 = tmp("cli-export-dst1");
2545        let dst2 = tmp("cli-export-dst2");
2546        let _ = run_demo(&src).expect("demo");
2547
2548        run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
2549        run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
2550
2551        for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
2552            let f1 = std::fs::read(dst1.join(filename)).expect("read first");
2553            let f2 = std::fs::read(dst2.join(filename)).expect("read second");
2554            assert_eq!(
2555                f1, f2,
2556                "{filename} must be byte-identical across two export runs"
2557            );
2558        }
2559        let _ = std::fs::remove_dir_all(&src);
2560        let _ = std::fs::remove_dir_all(&dst1);
2561        let _ = std::fs::remove_dir_all(&dst2);
2562    }
2563
2564    #[test]
2565    fn run_export_jsonl_nodes_are_sorted() {
2566        let src = tmp("cli-export-sorted");
2567        let dst = tmp("cli-export-sorted-dst");
2568        let _ = run_demo(&src).expect("demo");
2569        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
2570
2571        let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
2572        let keys: Vec<String> = content
2573            .lines()
2574            .filter(|l| !l.is_empty())
2575            .map(|l| {
2576                let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
2577                v["key"].as_str().unwrap_or("").to_string()
2578            })
2579            .collect();
2580        let mut sorted = keys.clone();
2581        sorted.sort();
2582        assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
2583        let _ = std::fs::remove_dir_all(&src);
2584        let _ = std::fs::remove_dir_all(&dst);
2585    }
2586
2587    #[test]
2588    fn run_export_jsonl_derived_edges_have_rule() {
2589        let src = tmp("cli-export-derived");
2590        let dst = tmp("cli-export-derived-dst");
2591        let _ = run_demo(&src).expect("demo");
2592        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
2593
2594        let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
2595        let derived_lines: Vec<serde_json::Value> = content
2596            .lines()
2597            .filter(|l| !l.is_empty())
2598            .map(|l| serde_json::from_str(l).expect("parse line"))
2599            .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
2600            .collect();
2601        assert!(
2602            !derived_lines.is_empty(),
2603            "demo store should have derived edges"
2604        );
2605        for edge in &derived_lines {
2606            assert!(
2607                !edge["rule"].is_null(),
2608                "derived edge must have non-null rule: {edge}"
2609            );
2610        }
2611        let _ = std::fs::remove_dir_all(&src);
2612        let _ = std::fs::remove_dir_all(&dst);
2613    }
2614
2615    #[test]
2616    fn run_export_parquet_produces_files() {
2617        let src = tmp("cli-export-parq-src");
2618        let dst = tmp("cli-export-parq-dst");
2619        let _ = run_demo(&src).expect("demo");
2620        run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
2621
2622        assert!(
2623            dst.join("nodes.parquet").exists(),
2624            "nodes.parquet must exist"
2625        );
2626        assert!(
2627            dst.join("edges.parquet").exists(),
2628            "edges.parquet must exist"
2629        );
2630        assert!(
2631            dst.join("rules.parquet").exists(),
2632            "rules.parquet must exist"
2633        );
2634        // All files must be non-empty.
2635        for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
2636            let meta = std::fs::metadata(dst.join(f)).expect("metadata");
2637            assert!(meta.len() > 0, "{f} must be non-empty");
2638        }
2639        let _ = std::fs::remove_dir_all(&src);
2640        let _ = std::fs::remove_dir_all(&dst);
2641    }
2642
2643    #[test]
2644    fn parse_algo_degree_defaults_dir_both() {
2645        let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
2646        match cmd {
2647            Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
2648            other => panic!("expected Algo, got {other:?}"),
2649        }
2650    }
2651
2652    #[test]
2653    fn parse_algo_degree_with_dir_flag() {
2654        for (arg, want) in [
2655            ("out", AlgoDir::Out),
2656            ("in", AlgoDir::In),
2657            ("both", AlgoDir::Both),
2658        ] {
2659            let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
2660            match cmd {
2661                Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
2662                other => panic!("expected Algo, got {other:?}"),
2663            }
2664        }
2665        // `--dir=out` form too.
2666        let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
2667        match cmd {
2668            Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
2669            other => panic!("expected Algo, got {other:?}"),
2670        }
2671    }
2672
2673    #[test]
2674    fn parse_algo_rejects_unknown_dir() {
2675        assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
2676    }
2677
2678    /// I1: exporting a store containing NaN/Inf floats must succeed, not panic.
2679    /// The NaN field must be serialised as JSON null (lossy but safe).
2680    #[test]
2681    fn run_export_jsonl_nan_float_becomes_null() {
2682        use core_api::{GraphDb, Value};
2683        let src = tmp("cli-export-nan-src");
2684        let dst = tmp("cli-export-nan-dst");
2685
2686        // Insert a node with NaN, +Inf, and -Inf properties via the public API.
2687        {
2688            let mut db = GraphDb::open(&src).unwrap();
2689            db.insert_node(
2690                "Sensor",
2691                "s1",
2692                vec![
2693                    ("nan_val".into(), Value::Float(f64::NAN)),
2694                    ("pos_inf".into(), Value::Float(f64::INFINITY)),
2695                    ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
2696                    ("normal".into(), Value::Float(1.5)),
2697                ],
2698            )
2699            .unwrap();
2700        }
2701
2702        // Export must succeed.
2703        run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
2704
2705        // nodes.jsonl must exist and the NaN fields must be null.
2706        let content =
2707            std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
2708        let row: serde_json::Value =
2709            serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
2710        assert_eq!(
2711            row["nan_val"],
2712            serde_json::Value::Null,
2713            "NaN must export as null"
2714        );
2715        assert_eq!(
2716            row["pos_inf"],
2717            serde_json::Value::Null,
2718            "+Inf must export as null"
2719        );
2720        assert_eq!(
2721            row["neg_inf"],
2722            serde_json::Value::Null,
2723            "-Inf must export as null"
2724        );
2725        // Normal float must survive.
2726        assert_eq!(
2727            row["normal"],
2728            serde_json::json!(1.5),
2729            "normal float roundtrips"
2730        );
2731
2732        let _ = std::fs::remove_dir_all(&src);
2733        let _ = std::fs::remove_dir_all(&dst);
2734    }
2735
2736    #[test]
2737    fn serve_tls_flags_parse_both_forms() {
2738        // --tls-cert VALUE --tls-key VALUE (space form)
2739        match parse_args(&[
2740            "serve",
2741            "/tmp/db",
2742            "--tls-cert",
2743            "/a/cert.pem",
2744            "--tls-key",
2745            "/a/key.pem",
2746        ])
2747        .unwrap()
2748        {
2749            Command::Serve {
2750                tls_cert, tls_key, ..
2751            } => {
2752                assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
2753                assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
2754            }
2755            other => panic!("{other:?}"),
2756        }
2757        // --tls-cert=VALUE --tls-key=VALUE (equals form)
2758        match parse_args(&[
2759            "serve",
2760            "/tmp/db",
2761            "--tls-cert=/b/cert.pem",
2762            "--tls-key=/b/key.pem",
2763        ])
2764        .unwrap()
2765        {
2766            Command::Serve {
2767                tls_cert, tls_key, ..
2768            } => {
2769                assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
2770                assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
2771            }
2772            other => panic!("{other:?}"),
2773        }
2774        // Neither → both None.
2775        match parse_args(&["serve", "/tmp/db"]).unwrap() {
2776            Command::Serve {
2777                tls_cert, tls_key, ..
2778            } => {
2779                assert_eq!(tls_cert, None);
2780                assert_eq!(tls_key, None);
2781            }
2782            other => panic!("{other:?}"),
2783        }
2784    }
2785
2786    #[test]
2787    fn serve_tls_flags_require_both() {
2788        // --tls-cert alone → error
2789        let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
2790        assert!(
2791            err.contains("tls-key"),
2792            "--tls-cert alone must mention --tls-key in error, got {err}"
2793        );
2794        // --tls-key alone → error
2795        let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
2796        assert!(
2797            err.contains("tls-cert"),
2798            "--tls-key alone must mention --tls-cert in error, got {err}"
2799        );
2800    }
2801
2802    #[test]
2803    fn version_flag_parses() {
2804        assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
2805        assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
2806        assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
2807    }
2808
2809    #[test]
2810    fn recall_parses_one_dir_and_is_listed_in_usage() {
2811        assert_eq!(
2812            parse_args(&["recall", "/tmp/db"]).unwrap(),
2813            Command::Recall {
2814                db_dir: PathBuf::from("/tmp/db")
2815            }
2816        );
2817        assert!(parse_args(&["recall"]).is_err(), "db-dir is required");
2818        assert!(usage().contains("mushroomdb recall <db-dir>"));
2819    }
2820
2821    #[test]
2822    fn ingest_git_parses_excludes() {
2823        let cmd = parse_args(&[
2824            "ingest-git",
2825            "/tmp/db",
2826            "/tmp/repo",
2827            "--exclude",
2828            "target/",
2829            "--exclude=*.lock",
2830            "--max-commits-per-file",
2831            "50",
2832        ])
2833        .unwrap();
2834        assert_eq!(
2835            cmd,
2836            Command::IngestGit {
2837                db_dir: PathBuf::from("/tmp/db"),
2838                opts: ingest_git::IngestGitOpts {
2839                    repo: PathBuf::from("/tmp/repo"),
2840                    exclude: vec!["target/".into(), "*.lock".into()],
2841                    max_commits_per_file: 50,
2842                },
2843            }
2844        );
2845        // Defaults and arity.
2846        let Command::IngestGit { opts, .. } =
2847            parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
2848        else {
2849            panic!("expected IngestGit");
2850        };
2851        assert!(opts.exclude.is_empty());
2852        assert_eq!(
2853            opts.max_commits_per_file,
2854            ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
2855        );
2856        assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
2857        assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
2858        assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
2859        assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
2860    }
2861
2862    #[test]
2863    fn version_constant_matches_cargo() {
2864        assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
2865        assert!(usage().contains("--version"));
2866    }
2867}