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