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