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