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