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
6use core_api::schema::Schema;
7use core_api::{
8    default_max_edges, is_write_query, wal_commit_count_at, AlgoDir, DegreeConfig, Explanation,
9    GraphDb, IngestOptions, PageRankConfig, Predicate, ResultSet, RuleDef, RuleSuggestion,
10    SharedDb, SnapshotOptions, Stats, Value, WccConfig,
11};
12use std::collections::BTreeMap;
13use std::fmt::Write as _;
14use std::net::SocketAddr;
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18/// Deterministic demo: 10 Orgs, 20 Projects, 30 People.
19pub const N_ORGS: usize = 10;
20pub const N_PROJECTS: usize = 20;
21pub const N_PEOPLE: usize = 30;
22
23/// Sample query printed by `mushroomdb demo` and executed against the fresh store.
24///
25/// Scoped to one person so `ORDER BY score DESC` is visibly ranked (a global
26/// `LIMIT 5` would be five 1.0 home-project hits).
27pub const SAMPLE_QUERY: &str = "\
28MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
29RETURN p, proj, r.score AS score
30ORDER BY score DESC, proj";
31
32const SAMPLE_EXPLAIN_A: &str = "person-01";
33const SAMPLE_EXPLAIN_B: &str = "proj-01";
34
35/// How `serve` should mount a UI. Precedence: `--ui dir` > embedded > `--no-ui`.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ServeUi {
38    Filesystem(PathBuf),
39    Embedded,
40    None,
41}
42
43/// Algorithm subcommand for `mushroomdb algo`.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum AlgoSubcmd {
46    Pagerank,
47    Wcc,
48    Degree,
49}
50
51/// Parsed `mushroomdb` invocation.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Command {
54    Serve {
55        db_dir: PathBuf,
56        addr: SocketAddr,
57        ui: ServeUi,
58        /// If the db dir is missing or empty, run [`run_demo`] before serving.
59        /// Docker's default CMD uses this so a fresh volume is ready on first boot.
60        demo_if_empty: bool,
61        /// Bearer token for non-loopback binds. Loopback may omit it.
62        token: Option<String>,
63        /// Periodic snapshot cadence. `None` = off (default).
64        snapshot_every: Option<Duration>,
65    },
66    Mcp {
67        db_dir: PathBuf,
68    },
69    Stats {
70        db_dir: PathBuf,
71    },
72    Demo {
73        db_dir: PathBuf,
74    },
75    /// Read-only view of the database at a past commit.
76    AsOf {
77        db_dir: PathBuf,
78        /// 0-based WAL commit index to replay up to (inclusive).
79        commit: u64,
80        /// Optional Cypher read query to execute against the as-of view.
81        query: Option<String>,
82    },
83    /// Profile the database and suggest linking rules with estimated edge counts.
84    Suggest {
85        db_dir: PathBuf,
86    },
87    /// Run a graph algorithm (pagerank / wcc / degree).
88    Algo {
89        db_dir: PathBuf,
90        subcmd: AlgoSubcmd,
91        /// Print only the top N results (0 = all).
92        top: usize,
93    },
94    /// Run a Cypher query (read or write).
95    Query {
96        db_dir: PathBuf,
97        /// Positional after dir (remaining args joined), or `--query`.
98        cypher: String,
99    },
100    /// Write `snapshot.bin` (default truncates WAL unless `--keep-wal`).
101    Snapshot {
102        db_dir: PathBuf,
103        keep_wal: bool,
104    },
105    /// Apply a JSON schema file idempotently (`schema apply <db-dir> <schema.json>`).
106    SchemaApply {
107        db_dir: PathBuf,
108        schema_file: PathBuf,
109    },
110    Help,
111}
112
113/// Outcome of [`run_demo`]. Counts are deterministic.
114#[derive(Debug)]
115pub struct DemoOutcome {
116    pub auto_fk_rules: Vec<String>,
117    pub sample_query: String,
118    pub sample_result: ResultSet,
119    pub explanations: Vec<Explanation>,
120    pub stats: Stats,
121    /// First suggestion from the rule suggester (teaser only — not auto-applied).
122    pub suggestion: Option<RuleSuggestion>,
123}
124
125/// CLI-facing error. [`Display`] is the message printed to stderr.
126#[derive(Debug)]
127pub struct CliError(pub String);
128
129impl std::fmt::Display for CliError {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.write_str(&self.0)
132    }
133}
134
135impl std::error::Error for CliError {}
136
137impl From<core_api::GraphError> for CliError {
138    fn from(e: core_api::GraphError) -> Self {
139        CliError(e.to_string())
140    }
141}
142
143impl From<std::io::Error> for CliError {
144    fn from(e: std::io::Error) -> Self {
145        CliError(e.to_string())
146    }
147}
148
149/// Usage text for no-args / `--help` / `-h`.
150pub fn usage() -> &'static str {
151    "\
152mushroomdb — embedded graph database
153
154Usage:
155  mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>]
156  mushroomdb mcp <db-dir>
157  mushroomdb stats <db-dir>
158  mushroomdb demo <db-dir>
159  mushroomdb suggest <db-dir>
160  mushroomdb asof <db-dir> --commit N [--query \"MATCH ...\"]
161  mushroomdb query <db-dir> [--query \"MATCH ...\"] <cypher…>
162  mushroomdb snapshot <db-dir> [--keep-wal]
163  mushroomdb schema apply <db-dir> <schema.json>
164  mushroomdb algo pagerank <db-dir> [--top N]
165  mushroomdb algo wcc <db-dir> [--top N]
166  mushroomdb algo degree <db-dir> [--top N]
167  mushroomdb --help
168
169Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
170"
171}
172
173/// Parse argv after the binary name. Hand-rolled — no clap.
174pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
175    let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
176    if args.is_empty() {
177        return Ok(Command::Help);
178    }
179    match args[0] {
180        "--help" | "-h" | "help" => Ok(Command::Help),
181        "serve" => parse_serve(&args[1..]),
182        "mcp" => parse_one_dir("mcp", &args[1..]).map(|db_dir| Command::Mcp { db_dir }),
183        "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
184        "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
185        "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
186        "asof" => parse_asof(&args[1..]),
187        "algo" => parse_algo(&args[1..]),
188        "query" => parse_query(&args[1..]),
189        "snapshot" => parse_snapshot(&args[1..]),
190        "schema" => parse_schema(&args[1..]),
191        other => Err(format!("unknown command: {other}")),
192    }
193}
194
195fn default_addr() -> SocketAddr {
196    SocketAddr::from(([127, 0, 0, 1], 8080))
197}
198
199fn parse_serve(args: &[&str]) -> Result<Command, String> {
200    let mut db_dir = None;
201    let mut addr = default_addr();
202    let mut ui = ServeUi::Embedded;
203    let mut saw_ui = false;
204    let mut saw_no_ui = false;
205    let mut demo_if_empty = false;
206    let mut token = None;
207    let mut snapshot_every = None;
208    let mut i = 0;
209    while i < args.len() {
210        let a = args[i];
211        if a == "--addr" {
212            let val = args
213                .get(i + 1)
214                .copied()
215                .ok_or_else(|| "missing value for --addr".to_string())?;
216            addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
217            i += 2;
218        } else if let Some(val) = a.strip_prefix("--addr=") {
219            addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
220            i += 1;
221        } else if a == "--ui" {
222            let val = args
223                .get(i + 1)
224                .copied()
225                .ok_or_else(|| "missing value for --ui".to_string())?;
226            ui = ServeUi::Filesystem(PathBuf::from(val));
227            saw_ui = true;
228            i += 2;
229        } else if let Some(val) = a.strip_prefix("--ui=") {
230            ui = ServeUi::Filesystem(PathBuf::from(val));
231            saw_ui = true;
232            i += 1;
233        } else if a == "--no-ui" {
234            ui = ServeUi::None;
235            saw_no_ui = true;
236            i += 1;
237        } else if a == "--demo-if-empty" {
238            demo_if_empty = true;
239            i += 1;
240        } else if a == "--token" {
241            let val = args
242                .get(i + 1)
243                .copied()
244                .ok_or_else(|| "missing value for --token".to_string())?;
245            token = Some(val.to_string());
246            i += 2;
247        } else if let Some(val) = a.strip_prefix("--token=") {
248            token = Some(val.to_string());
249            i += 1;
250        } else if a == "--snapshot-every" {
251            let val = args
252                .get(i + 1)
253                .copied()
254                .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
255            snapshot_every = Some(parse_snapshot_every(val)?);
256            i += 2;
257        } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
258            snapshot_every = Some(parse_snapshot_every(val)?);
259            i += 1;
260        } else if a.starts_with('-') {
261            return Err(format!("unexpected flag: {a}"));
262        } else if db_dir.is_none() {
263            db_dir = Some(PathBuf::from(a));
264            i += 1;
265        } else {
266            return Err(format!("unexpected extra argument: {a}"));
267        }
268    }
269    if saw_ui && saw_no_ui {
270        return Err("cannot combine --ui and --no-ui".to_string());
271    }
272    let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
273    Ok(Command::Serve {
274        db_dir,
275        addr,
276        ui,
277        demo_if_empty,
278        token,
279        snapshot_every,
280    })
281}
282
283fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
284    let secs: u64 = val
285        .parse()
286        .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
287    if secs == 0 {
288        return Err("--snapshot-every must be a positive number of seconds".into());
289    }
290    Ok(Duration::from_secs(secs))
291}
292
293/// `--ui <dir>` must be a directory that contains `index.html`.
294pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
295    if !dir.is_dir() {
296        return Err(format!("--ui directory does not exist: {}", dir.display()));
297    }
298    let index = dir.join("index.html");
299    if !index.is_file() {
300        return Err(format!(
301            "--ui directory is missing index.html: {}",
302            dir.display()
303        ));
304    }
305    Ok(dir.to_path_buf())
306}
307
308fn parse_asof(args: &[&str]) -> Result<Command, String> {
309    let mut db_dir = None;
310    let mut commit: Option<u64> = None;
311    let mut query: Option<String> = None;
312    let mut i = 0;
313    while i < args.len() {
314        let a = args[i];
315        if a == "--commit" {
316            let val = args
317                .get(i + 1)
318                .copied()
319                .ok_or_else(|| "missing value for --commit".to_string())?;
320            commit = Some(
321                val.parse()
322                    .map_err(|_| format!("invalid commit index: {val}"))?,
323            );
324            i += 2;
325        } else if let Some(val) = a.strip_prefix("--commit=") {
326            commit = Some(
327                val.parse()
328                    .map_err(|_| format!("invalid commit index: {val}"))?,
329            );
330            i += 1;
331        } else if a == "--query" {
332            let val = args
333                .get(i + 1)
334                .copied()
335                .ok_or_else(|| "missing value for --query".to_string())?;
336            query = Some(val.to_string());
337            i += 2;
338        } else if let Some(val) = a.strip_prefix("--query=") {
339            query = Some(val.to_string());
340            i += 1;
341        } else if a.starts_with('-') {
342            return Err(format!("unexpected flag: {a}"));
343        } else if db_dir.is_none() {
344            db_dir = Some(PathBuf::from(a));
345            i += 1;
346        } else {
347            return Err(format!("unexpected extra argument: {a}"));
348        }
349    }
350    let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
351    let commit = commit.ok_or_else(|| "asof requires --commit N".to_string())?;
352    Ok(Command::AsOf {
353        db_dir,
354        commit,
355        query,
356    })
357}
358
359/// Execute an as-of query at the given commit and print results.
360pub fn run_asof(db_dir: &Path, commit: u64, query: Option<&str>) -> Result<String, CliError> {
361    let total = wal_commit_count_at(db_dir)?;
362    let db = GraphDb::open_at(db_dir, commit)?;
363    let mut out = String::new();
364    let _ = writeln!(out, "as-of commit {} of {}", commit, total);
365    if let Some(cypher) = query {
366        let params = BTreeMap::new();
367        let rs = db.query(cypher, &params)?;
368        out.push_str(&format_result_set(&rs));
369    }
370    Ok(out)
371}
372
373fn parse_query(args: &[&str]) -> Result<Command, String> {
374    let mut db_dir = None;
375    let mut query_flag: Option<String> = None;
376    let mut cypher_parts: Vec<&str> = Vec::new();
377    let mut i = 0;
378    while i < args.len() {
379        let a = args[i];
380        if a == "--query" {
381            let val = args
382                .get(i + 1)
383                .copied()
384                .ok_or_else(|| "missing value for --query".to_string())?;
385            query_flag = Some(val.to_string());
386            i += 2;
387        } else if let Some(val) = a.strip_prefix("--query=") {
388            query_flag = Some(val.to_string());
389            i += 1;
390        } else if a.starts_with('-') {
391            return Err(format!("unexpected flag: {a}"));
392        } else if db_dir.is_none() {
393            db_dir = Some(PathBuf::from(a));
394            i += 1;
395        } else {
396            cypher_parts.push(a);
397            i += 1;
398        }
399    }
400    let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
401    let cypher = if let Some(q) = query_flag {
402        if !cypher_parts.is_empty() {
403            return Err(
404                "query: pass Cypher as remaining arguments or --query, not both".to_string(),
405            );
406        }
407        q
408    } else {
409        if cypher_parts.is_empty() {
410            return Err("query requires a Cypher string".to_string());
411        }
412        cypher_parts.join(" ")
413    };
414    Ok(Command::Query { db_dir, cypher })
415}
416
417/// Run a Cypher read or write and print columns/rows like [`run_asof`].
418pub fn run_query(db_dir: &Path, cypher: &str) -> Result<String, CliError> {
419    let params = BTreeMap::new();
420    let is_write = is_write_query(cypher).map_err(CliError)?;
421    let rs = if is_write {
422        let mut db = GraphDb::open(db_dir)?;
423        db.query_write(cypher, &params)?
424    } else {
425        let db = GraphDb::open(db_dir)?;
426        db.query(cypher, &params)?
427    };
428    Ok(format_result_set(&rs))
429}
430
431fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
432    let mut db_dir = None;
433    let mut keep_wal = false;
434    let mut i = 0;
435    while i < args.len() {
436        let a = args[i];
437        if a == "--keep-wal" {
438            keep_wal = true;
439            i += 1;
440        } else if a.starts_with('-') {
441            return Err(format!("unexpected flag: {a}"));
442        } else if db_dir.is_none() {
443            db_dir = Some(PathBuf::from(a));
444            i += 1;
445        } else {
446            return Err(format!("unexpected extra argument: {a}"));
447        }
448    }
449    let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
450    Ok(Command::Snapshot { db_dir, keep_wal })
451}
452
453/// Open `dir` and write `snapshot.bin`. Default truncates the WAL.
454pub fn run_snapshot(db_dir: &Path, keep_wal: bool) -> Result<String, CliError> {
455    let mut db = GraphDb::open(db_dir)?;
456    if keep_wal {
457        db.snapshot_with(SnapshotOptions { keep_wal: true })?;
458    } else {
459        db.snapshot()?;
460    }
461    Ok(format!(
462        "snapshot written: {}\n",
463        db_dir.join("snapshot.bin").display()
464    ))
465}
466
467fn parse_schema(args: &[&str]) -> Result<Command, String> {
468    if args.is_empty() {
469        return Err("schema requires a subcommand: apply".to_string());
470    }
471    match args[0] {
472        "apply" => parse_schema_apply(&args[1..]),
473        other => Err(format!(
474            "unknown schema subcommand: {other}; expected apply"
475        )),
476    }
477}
478
479fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
480    let mut db_dir = None;
481    let mut schema_file = None;
482    for a in args {
483        if a.starts_with('-') {
484            return Err(format!("unexpected flag: {a}"));
485        }
486        if db_dir.is_none() {
487            db_dir = Some(PathBuf::from(*a));
488        } else if schema_file.is_none() {
489            schema_file = Some(PathBuf::from(*a));
490        } else {
491            return Err(format!("unexpected extra argument: {a}"));
492        }
493    }
494    let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
495    let schema_file =
496        schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
497    Ok(Command::SchemaApply {
498        db_dir,
499        schema_file,
500    })
501}
502
503/// Read `schema_file`, open `db_dir`, apply the schema, and return the diff
504/// as one line per entry: `"created rule:x"`, `"updated view:y"`, etc.
505pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
506    let json = std::fs::read_to_string(schema_file)
507        .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
508    let schema: Schema = serde_json::from_str(&json).map_err(|e| {
509        CliError(format!(
510            "invalid schema JSON in {}: {e}",
511            schema_file.display()
512        ))
513    })?;
514    let mut db = GraphDb::open(db_dir)?;
515    let diff = db.apply_schema(&schema)?;
516    let mut out = String::new();
517    for entry in &diff.created {
518        let _ = writeln!(out, "created {entry}");
519    }
520    for entry in &diff.updated {
521        let _ = writeln!(out, "updated {entry}");
522    }
523    for entry in &diff.unchanged {
524        let _ = writeln!(out, "unchanged {entry}");
525    }
526    if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
527        let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
528    }
529    Ok(out)
530}
531
532fn format_result_set(rs: &ResultSet) -> String {
533    let mut out = String::new();
534    let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
535    for i in 0..rs.len() {
536        let cells: Vec<String> = rs
537            .columns()
538            .iter()
539            .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
540            .collect();
541        let _ = writeln!(out, "  {}", cells.join("  "));
542    }
543    out
544}
545
546fn parse_algo(args: &[&str]) -> Result<Command, String> {
547    if args.is_empty() {
548        return Err("algo requires a subcommand: pagerank | wcc | degree".to_string());
549    }
550    let subcmd = match args[0] {
551        "pagerank" => AlgoSubcmd::Pagerank,
552        "wcc" => AlgoSubcmd::Wcc,
553        "degree" => AlgoSubcmd::Degree,
554        other => {
555            return Err(format!(
556                "unknown algo subcommand: {other}; expected pagerank | wcc | degree"
557            ))
558        }
559    };
560    let rest = &args[1..];
561    let mut db_dir = None;
562    let mut top: usize = 20;
563    let mut i = 0;
564    while i < rest.len() {
565        let a = rest[i];
566        if a == "--top" {
567            let val = rest
568                .get(i + 1)
569                .copied()
570                .ok_or_else(|| "missing value for --top".to_string())?;
571            top = val
572                .parse()
573                .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
574            i += 2;
575        } else if let Some(val) = a.strip_prefix("--top=") {
576            top = val
577                .parse()
578                .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
579            i += 1;
580        } else if a.starts_with('-') {
581            return Err(format!("unexpected flag: {a}"));
582        } else if db_dir.is_none() {
583            db_dir = Some(PathBuf::from(a));
584            i += 1;
585        } else {
586            return Err(format!("unexpected extra argument: {a}"));
587        }
588    }
589    let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
590    Ok(Command::Algo {
591        db_dir,
592        subcmd,
593        top,
594    })
595}
596
597/// Run a graph algorithm and return a formatted string.
598pub fn run_algo(db_dir: &Path, subcmd: &AlgoSubcmd, top: usize) -> Result<String, CliError> {
599    let db = GraphDb::open(db_dir)?;
600    match subcmd {
601        AlgoSubcmd::Pagerank => {
602            let config = PageRankConfig::default();
603            let report = db.pagerank(&config);
604            Ok(format_pagerank(&report, top))
605        }
606        AlgoSubcmd::Wcc => {
607            let config = WccConfig::default();
608            let report = db.connected_components(&config);
609            Ok(format_wcc(&report, top))
610        }
611        AlgoSubcmd::Degree => {
612            let config = DegreeConfig {
613                direction: AlgoDir::Both,
614                ..DegreeConfig::default()
615            };
616            let report = db.degree_centrality(&config);
617            Ok(format_degree(&report, top))
618        }
619    }
620}
621
622fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
623    let mut buf = String::new();
624    let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
625    let rows = if top == 0 {
626        report.scores.as_slice()
627    } else {
628        &report.scores[..top.min(report.scores.len())]
629    };
630    for (i, (key, score)) in rows.iter().enumerate() {
631        let _ = writeln!(buf, "  {:>4}  {:<40}  {:.6}", i + 1, key, score);
632    }
633    buf
634}
635
636fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
637    let mut buf = String::new();
638    let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
639    let rows = if top == 0 {
640        report.components.as_slice()
641    } else {
642        &report.components[..top.min(report.components.len())]
643    };
644    for (key, comp_id) in rows {
645        let _ = writeln!(buf, "  {:<40}  component={}", key, comp_id);
646    }
647    buf
648}
649
650fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
651    let mut buf = String::new();
652    let _ = writeln!(
653        buf,
654        "== degree centrality (truncated={}) ==",
655        report.truncated
656    );
657    let rows = if top == 0 {
658        report.scores.as_slice()
659    } else {
660        &report.scores[..top.min(report.scores.len())]
661    };
662    for (i, (key, deg)) in rows.iter().enumerate() {
663        let _ = writeln!(buf, "  {:>4}  {:<40}  degree={}", i + 1, key, deg);
664    }
665    buf
666}
667
668fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
669    let mut db_dir = None;
670    for a in args {
671        if a.starts_with('-') {
672            return Err(format!("unexpected flag: {a}"));
673        }
674        if db_dir.is_some() {
675            return Err(format!("unexpected extra argument: {a}"));
676        }
677        db_dir = Some(PathBuf::from(*a));
678    }
679    db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
680}
681
682/// Pretty-print [`Stats`] for `mushroomdb stats` and the demo smoke test.
683pub fn format_stats(stats: &Stats) -> String {
684    let mut out = String::new();
685    let _ = writeln!(
686        out,
687        "nodes: {} live, {} tombstoned",
688        stats.nodes_live, stats.nodes_tombstoned
689    );
690    let _ = writeln!(out, "edges: {}", stats.edges);
691    let _ = writeln!(out, "rules: {}", stats.rules.len());
692    for r in &stats.rules {
693        let _ = writeln!(
694            out,
695            "  {:<28} edges={}  tripped={}",
696            r.name, r.edges, r.tripped
697        );
698    }
699    out
700}
701
702/// Open `dir` and return live stats.
703pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
704    let db = SharedDb::open(dir)?;
705    let stats = db.read().stats();
706    Ok(stats)
707}
708
709/// Build the deterministic demo dataset in an empty `dir`.
710///
711/// Refuses if `dir` already exists and is not empty. Ingests 10 Orgs, 20
712/// Projects, 30 People via [`SharedDb`] / `ingest_json` (auto-FK on `*_id`)
713/// then declares `skill_fit` plus the three Predicates II rules.
714pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
715    refuse_non_empty(dir)?;
716
717    let db = SharedDb::open(dir)?;
718    let opts = IngestOptions::default();
719    let mut auto_fk_rules = Vec::new();
720
721    {
722        let mut w = db.write();
723        for (label, json) in [
724            ("Org", org_json()),
725            ("Project", project_json()),
726            ("Person", person_json()),
727        ] {
728            let report = w.ingest_json(label, &json, &opts)?;
729            if !report.row_errors.is_empty() {
730                return Err(CliError(format!(
731                    "demo ingest of {label} had row errors: {:?}",
732                    report.row_errors
733                )));
734            }
735            auto_fk_rules.extend(report.rules_created);
736        }
737        let skill_fit = Predicate::Overlap {
738            field: "skills".into(),
739            min: 0.5,
740        };
741        let skill_fit_k = Some(default_max_edges(&skill_fit));
742        w.create_rule(RuleDef {
743            name: "skill_fit".into(),
744            src_label: "Person".into(),
745            dst_label: "Project".into(),
746            predicate: skill_fit,
747            edge_type: "FIT".into(),
748            weight_prop: Some("score".into()),
749            max_edges: skill_fit_k,
750            approximate: false,
751            via_label: None,
752            via_edge: None,
753            via_dir: None,
754        })?;
755        let founded_within = Predicate::NumericWithin {
756            field: "founded_year".into(),
757            tolerance: 2.0,
758        };
759        let founded_within_k = Some(default_max_edges(&founded_within));
760        w.create_rule(RuleDef {
761            name: "founded_within".into(),
762            src_label: "Org".into(),
763            dst_label: "Org".into(),
764            predicate: founded_within,
765            edge_type: "FOUNDED_WITHIN".into(),
766            weight_prop: Some("score".into()),
767            max_edges: founded_within_k,
768            approximate: false,
769            via_label: None,
770            via_edge: None,
771            via_dir: None,
772        })?;
773        let nearby_office = Predicate::GeoRadius {
774            field: "office".into(),
775            km: 50.0,
776        };
777        let nearby_office_k = Some(default_max_edges(&nearby_office));
778        w.create_rule(RuleDef {
779            name: "nearby_office".into(),
780            src_label: "Org".into(),
781            dst_label: "Org".into(),
782            predicate: nearby_office,
783            edge_type: "NEARBY_OFFICE".into(),
784            weight_prop: Some("score".into()),
785            max_edges: nearby_office_k,
786            approximate: false,
787            via_label: None,
788            via_edge: None,
789            via_dir: None,
790        })?;
791        let similar_interests = Predicate::VectorSimilar {
792            field: "embedding".into(),
793            min: 0.8,
794        };
795        let similar_interests_k = Some(default_max_edges(&similar_interests));
796        w.create_rule(RuleDef {
797            name: "similar_interests".into(),
798            src_label: "Person".into(),
799            dst_label: "Person".into(),
800            predicate: similar_interests,
801            edge_type: "SIMILAR".into(),
802            weight_prop: Some("score".into()),
803            max_edges: similar_interests_k,
804            approximate: false,
805            via_label: None,
806            via_edge: None,
807            via_dir: None,
808        })?;
809    }
810
811    let r = db.read();
812    let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
813    let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
814    let stats = r.stats();
815    // Rule suggestion teaser: first suggestion sorted by est_edges desc.
816    let suggestion = r.suggest_rules().into_iter().next();
817
818    Ok(DemoOutcome {
819        auto_fk_rules,
820        sample_query: SAMPLE_QUERY.to_string(),
821        sample_result,
822        explanations,
823        stats,
824        suggestion,
825    })
826}
827
828fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
829    if dir.is_file() {
830        return Err(CliError(format!(
831            "demo refuses a non-empty directory: {} is a file",
832            dir.display()
833        )));
834    }
835    if !dir.exists() {
836        return Ok(true);
837    }
838    Ok(std::fs::read_dir(dir)?.next().is_none())
839}
840
841fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
842    if dir_is_empty_or_absent(dir)? {
843        Ok(())
844    } else {
845        Err(CliError(format!(
846            "demo refuses a non-empty directory: {} \
847             (directory must be empty — including hidden files)",
848            dir.display()
849        )))
850    }
851}
852
853/// Run [`run_demo`] when `dir` is missing or empty; otherwise leave it alone.
854pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
855    if dir_is_empty_or_absent(dir)? {
856        Ok(Some(run_demo(dir)?))
857    } else {
858        Ok(None)
859    }
860}
861
862fn json_array(rows: impl IntoIterator<Item = String>) -> String {
863    let mut out = String::from("[");
864    let mut first = true;
865    for row in rows {
866        if !first {
867            out.push(',');
868        }
869        first = false;
870        out.push_str(&row);
871    }
872    out.push(']');
873    out
874}
875
876/// Wrap a 1-based project index into `1..=N_PROJECTS`.
877fn wrap_proj(i: usize) -> usize {
878    (i - 1) % N_PROJECTS + 1
879}
880
881/// Sliding window of `len` skill tokens starting at project `start`.
882fn skill_window_json(start: usize, len: usize) -> String {
883    let parts: Vec<String> = (0..len)
884        .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
885        .collect();
886    format!("[{}]", parts.join(","))
887}
888
889/// Real city [lat, lon] for org `i` (1-based). Four clusters sit inside 50 km:
890/// NYC / Jersey City / Newark, SF / Oakland / Berkeley, London / Greenwich,
891/// Paris / Versailles.
892fn org_office(i: usize) -> (f64, f64) {
893    match i {
894        1 => (40.7128, -74.0060),  // New York
895        2 => (48.8566, 2.3522),    // Paris
896        3 => (51.5074, -0.1278),   // London
897        4 => (37.7749, -122.4194), // San Francisco
898        5 => (37.8044, -122.2711), // Oakland
899        6 => (37.8715, -122.2730), // Berkeley
900        7 => (40.7178, -74.0431),  // Jersey City
901        8 => (51.4769, 0.0005),    // Greenwich
902        9 => (48.8014, 2.1301),    // Versailles
903        10 => (40.7357, -74.1724), // Newark
904        _ => unreachable!("demo orgs are 1..=10"),
905    }
906}
907
908/// Dim-8 embedding for person `i`. Groups of three share a unit axis (cos = 1);
909/// two extra groups are (0.8, 0.6, …) and (0.6, 0.8, …) so cos = 0.8 / 0.96
910/// against the first two axes is hand-checkable.
911fn person_embedding_json(i: usize) -> String {
912    let mut v = [0.0_f64; 8];
913    match i {
914        9 | 19 | 29 => {
915            v[0] = 0.8;
916            v[1] = 0.6;
917        }
918        10 | 20 | 30 => {
919            v[0] = 0.6;
920            v[1] = 0.8;
921        }
922        _ => {
923            let axis = (i - 1) % 10;
924            debug_assert!(axis < 8);
925            v[axis] = 1.0;
926        }
927    }
928    let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
929    format!("[{}]", parts.join(","))
930}
931
932fn org_json() -> String {
933    json_array((1..=N_ORGS).map(|i| {
934        let year = 2010 + (i as i64 - 1);
935        let (lat, lon) = org_office(i);
936        format!(
937            r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
938            skill_window_json(i, 3)
939        )
940    }))
941}
942
943fn project_json() -> String {
944    json_array((1..=N_PROJECTS).map(|i| {
945        let org = (i - 1) % N_ORGS + 1;
946        format!(
947            r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
948            skill_window_json(i, 3)
949        )
950    }))
951}
952
953fn person_json() -> String {
954    json_array((1..=N_PEOPLE).map(|i| {
955        let org = (i - 1) % N_ORGS + 1;
956        let proj = (i - 1) % N_PROJECTS + 1;
957        format!(
958            r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
959            person_embedding_json(i),
960            skill_window_json(proj, 3)
961        )
962    }))
963}
964
965/// Render a [`DemoOutcome`] the way `mushroomdb demo` prints it.
966pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
967    let mut buf = String::new();
968    let _ = writeln!(buf, "== demo ==");
969    let _ = writeln!(
970        buf,
971        "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
972    );
973    let _ = writeln!(
974        buf,
975        "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
976    );
977    let _ = writeln!(
978        buf,
979        "numeric rule: founded_within (Org.founded_year, tolerance 2)"
980    );
981    let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
982    let _ = writeln!(
983        buf,
984        "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
985    );
986    let _ = writeln!(buf);
987    let _ = writeln!(buf, "== auto-FK rules ==");
988    let mut names = out.auto_fk_rules.clone();
989    names.sort();
990    for name in names {
991        let _ = writeln!(buf, "  {name}");
992    }
993    let _ = writeln!(buf);
994    let _ = writeln!(buf, "== query ==");
995    let _ = writeln!(buf, "{}", out.sample_query);
996    let _ = writeln!(buf);
997    let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
998    for i in 0..out.sample_result.len() {
999        let cells: Vec<String> = out
1000            .sample_result
1001            .columns()
1002            .iter()
1003            .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
1004            .collect();
1005        let _ = writeln!(buf, "  {}", cells.join("  "));
1006    }
1007    let _ = writeln!(buf);
1008    let _ = writeln!(
1009        buf,
1010        "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
1011    );
1012    for e in &out.explanations {
1013        let weight = e
1014            .weight
1015            .map(|w| fmt_value(&Value::Float(w)))
1016            .unwrap_or_else(|| "none".into());
1017        let _ = writeln!(
1018            buf,
1019            "  rule={}  type={}  {}→{}  weight={}",
1020            e.rule, e.edge_type, e.src_key, e.dst_key, weight
1021        );
1022    }
1023    let _ = writeln!(buf);
1024    let _ = writeln!(buf, "== serve ==");
1025    let _ = writeln!(buf, "  mushroomdb serve {}", dir.display());
1026
1027    // Teaser: one suggestion from the rule suggester (not auto-applied).
1028    if let Some(s) = &out.suggestion {
1029        let _ = writeln!(buf);
1030        let _ = writeln!(buf, "== suggested rule (teaser) ==");
1031        let _ = writeln!(buf, "  {}", s.def.name);
1032        let _ = writeln!(
1033            buf,
1034            "  {} → {} via {:?}",
1035            s.def.src_label, s.def.dst_label, s.def.predicate
1036        );
1037        let _ = writeln!(buf, "  est_edges: ~{}", s.est_edges);
1038        let _ = writeln!(buf, "  {}", s.rationale);
1039        let _ = writeln!(
1040            buf,
1041            "  (run `mushroomdb suggest {}` for full analysis)",
1042            dir.display()
1043        );
1044    }
1045
1046    buf
1047}
1048
1049/// Profile the database at `dir` and return all rule suggestions.
1050pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
1051    let db = GraphDb::open(dir)?;
1052    Ok(db.suggest_rules())
1053}
1054
1055/// Pretty-print a list of [`RuleSuggestion`]s for `mushroomdb suggest`.
1056pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
1057    let mut buf = String::new();
1058    if suggestions.is_empty() {
1059        let _ = writeln!(
1060            buf,
1061            "no rule suggestions (database may be empty or rules already cover all patterns)"
1062        );
1063        return buf;
1064    }
1065    let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
1066    for (i, s) in suggestions.iter().enumerate() {
1067        let _ = writeln!(buf);
1068        let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
1069        let _ = writeln!(
1070            buf,
1071            "    {} → {}  via {:?}",
1072            s.def.src_label, s.def.dst_label, s.def.predicate
1073        );
1074        let _ = writeln!(buf, "    est_edges : ~{}", s.est_edges);
1075        let _ = writeln!(buf, "    rationale : {}", s.rationale);
1076        if !s.examples.is_empty() {
1077            let _ = writeln!(buf, "    examples  :");
1078            for (src, dst, score) in &s.examples {
1079                let _ = writeln!(buf, "      {src} → {dst}  score={score:.4}");
1080            }
1081        }
1082        let _ = writeln!(buf, "    predicate : {:?}", s.def.predicate);
1083        let _ = writeln!(
1084            buf,
1085            "    to apply  : POST /rules  or  db.create_rule(suggestion.def)"
1086        );
1087    }
1088    buf
1089}
1090
1091fn fmt_value(v: &Value) -> String {
1092    match v {
1093        Value::Int(i) => i.to_string(),
1094        Value::Float(f) => {
1095            let s = format!("{f}");
1096            if s.contains('.') || s.contains('e') || s.contains('E') {
1097                s
1098            } else {
1099                format!("{s}.0")
1100            }
1101        }
1102        Value::Str(s) => s.clone(),
1103        Value::Bool(b) => b.to_string(),
1104        Value::List(xs) => {
1105            let inner: Vec<String> = xs.iter().map(fmt_value).collect();
1106            format!("[{}]", inner.join(", "))
1107        }
1108        Value::Map(m) => {
1109            let inner: Vec<String> = m
1110                .iter()
1111                .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
1112                .collect();
1113            format!("{{{}}}", inner.join(", "))
1114        }
1115    }
1116}
1117
1118fn fmt_cell(cell: Option<&Value>) -> String {
1119    match cell {
1120        None => "null".into(),
1121        Some(v) => fmt_value(v),
1122    }
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    use super::*;
1128    use std::collections::BTreeSet;
1129    use std::net::SocketAddr;
1130    use std::path::PathBuf;
1131
1132    fn tmp(name: &str) -> PathBuf {
1133        let nanos = std::time::SystemTime::now()
1134            .duration_since(std::time::UNIX_EPOCH)
1135            .expect("clock")
1136            .as_nanos();
1137        let d = std::env::temp_dir().join(format!(
1138            "graphdb-cli-{}-{}-{}",
1139            name,
1140            std::process::id(),
1141            nanos
1142        ));
1143        let _ = std::fs::remove_dir_all(&d);
1144        d
1145    }
1146
1147    fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
1148        let g = db.read();
1149        let mut out = BTreeSet::new();
1150        for i in 1..=N_ORGS {
1151            let src = format!("org-{i:02}");
1152            if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
1153                for dst in nbrs {
1154                    out.insert((src.clone(), dst));
1155                }
1156            }
1157        }
1158        for i in 1..=N_PEOPLE {
1159            let src = format!("person-{i:02}");
1160            if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
1161                for dst in nbrs {
1162                    out.insert((src.clone(), dst));
1163                }
1164            }
1165        }
1166        out
1167    }
1168
1169    fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
1170        let hits: Vec<_> = db
1171            .read()
1172            .explain(a, b)
1173            .expect("explain")
1174            .into_iter()
1175            .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
1176            .collect();
1177        assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
1178        let got = hits[0].weight.expect("weighted");
1179        assert!(
1180            (got - want).abs() < 1e-12,
1181            "{rule} {a}→{b}: got {got} want {want}"
1182        );
1183    }
1184
1185    fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
1186        const R: f64 = 6371.0088;
1187        let phi1 = lat1.to_radians();
1188        let phi2 = lat2.to_radians();
1189        let dphi = (lat2 - lat1).to_radians();
1190        let dlam = (lon2 - lon1).to_radians();
1191        let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
1192            .clamp(0.0, 1.0);
1193        let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
1194        R * c
1195    }
1196
1197    fn default_bind() -> SocketAddr {
1198        SocketAddr::from(([127, 0, 0, 1], 8080))
1199    }
1200
1201    #[test]
1202    fn parse_args_table() {
1203        struct Case {
1204            args: &'static [&'static str],
1205            check: fn(Result<Command, String>),
1206        }
1207
1208        let cases = [
1209            Case {
1210                args: &[],
1211                check: |r| match r {
1212                    Ok(Command::Help) => {}
1213                    other => panic!("no-args → Help, got {other:?}"),
1214                },
1215            },
1216            Case {
1217                args: &["--help"],
1218                check: |r| match r {
1219                    Ok(Command::Help) => {}
1220                    other => panic!("--help → Help, got {other:?}"),
1221                },
1222            },
1223            Case {
1224                args: &["-h"],
1225                check: |r| match r {
1226                    Ok(Command::Help) => {}
1227                    other => panic!("-h → Help, got {other:?}"),
1228                },
1229            },
1230            Case {
1231                args: &["serve", "/tmp/demo-db"],
1232                check: |r| match r {
1233                    Ok(Command::Serve {
1234                        db_dir,
1235                        addr,
1236                        ui,
1237                        demo_if_empty,
1238                        token,
1239                        snapshot_every,
1240                    }) => {
1241                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1242                        assert_eq!(addr, default_bind());
1243                        assert_eq!(ui, super::ServeUi::Embedded);
1244                        assert!(!demo_if_empty);
1245                        assert_eq!(token, None);
1246                        assert_eq!(snapshot_every, None);
1247                    }
1248                    other => panic!("serve <dir> → Serve default addr, got {other:?}"),
1249                },
1250            },
1251            Case {
1252                args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
1253                check: |r| match r {
1254                    Ok(Command::Serve {
1255                        db_dir,
1256                        addr,
1257                        ui,
1258                        demo_if_empty,
1259                        token,
1260                        snapshot_every,
1261                    }) => {
1262                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1263                        assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
1264                        assert_eq!(ui, super::ServeUi::Embedded);
1265                        assert!(!demo_if_empty);
1266                        assert_eq!(token, None);
1267                        assert_eq!(snapshot_every, None);
1268                    }
1269                    other => panic!("serve --addr after dir, got {other:?}"),
1270                },
1271            },
1272            Case {
1273                args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
1274                check: |r| match r {
1275                    Ok(Command::Serve {
1276                        db_dir,
1277                        addr,
1278                        ui,
1279                        demo_if_empty,
1280                        token,
1281                        snapshot_every,
1282                    }) => {
1283                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1284                        assert_eq!(addr, "127.0.0.1:9090".parse().unwrap());
1285                        assert_eq!(ui, super::ServeUi::Embedded);
1286                        assert!(!demo_if_empty);
1287                        assert_eq!(token, None);
1288                        assert_eq!(snapshot_every, None);
1289                    }
1290                    other => panic!("serve --addr=VALUE, got {other:?}"),
1291                },
1292            },
1293            Case {
1294                args: &["mcp", "/tmp/demo-db"],
1295                check: |r| match r {
1296                    Ok(Command::Mcp { db_dir }) => {
1297                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1298                    }
1299                    other => panic!("mcp <dir>, got {other:?}"),
1300                },
1301            },
1302            Case {
1303                args: &["stats", "/tmp/demo-db"],
1304                check: |r| match r {
1305                    Ok(Command::Stats { db_dir }) => {
1306                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1307                    }
1308                    other => panic!("stats <dir>, got {other:?}"),
1309                },
1310            },
1311            Case {
1312                args: &["demo", "/tmp/demo-db"],
1313                check: |r| match r {
1314                    Ok(Command::Demo { db_dir }) => {
1315                        assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
1316                    }
1317                    other => panic!("demo <dir>, got {other:?}"),
1318                },
1319            },
1320            Case {
1321                args: &["serve"],
1322                check: |r| {
1323                    let e = r.expect_err("serve without dir");
1324                    assert!(
1325                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1326                        "missing-dir error should mention dir, got {e}"
1327                    );
1328                },
1329            },
1330            Case {
1331                args: &["mcp"],
1332                check: |r| {
1333                    let e = r.expect_err("mcp without dir");
1334                    assert!(
1335                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1336                        "missing-dir error should mention dir, got {e}"
1337                    );
1338                },
1339            },
1340            Case {
1341                args: &["stats"],
1342                check: |r| {
1343                    let e = r.expect_err("stats without dir");
1344                    assert!(
1345                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1346                        "missing-dir error should mention dir, got {e}"
1347                    );
1348                },
1349            },
1350            Case {
1351                args: &["demo"],
1352                check: |r| {
1353                    let e = r.expect_err("demo without dir");
1354                    assert!(
1355                        e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
1356                        "missing-dir error should mention dir, got {e}"
1357                    );
1358                },
1359            },
1360            Case {
1361                args: &["serve", "/tmp/demo-db", "--addr"],
1362                check: |r| {
1363                    let e = r.expect_err("--addr missing value");
1364                    assert!(
1365                        e.to_lowercase().contains("addr"),
1366                        "--addr missing value should mention addr, got {e}"
1367                    );
1368                },
1369            },
1370            Case {
1371                args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
1372                check: |r| {
1373                    let e = r.expect_err("invalid addr");
1374                    assert!(
1375                        e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
1376                        "invalid addr should mention address, got {e}"
1377                    );
1378                },
1379            },
1380            Case {
1381                args: &["frobnicate", "/tmp/demo-db"],
1382                check: |r| {
1383                    let e = r.expect_err("unknown command");
1384                    assert!(
1385                        e.to_lowercase().contains("unknown")
1386                            || e.to_lowercase().contains("frobnicate"),
1387                        "unknown command should name it, got {e}"
1388                    );
1389                },
1390            },
1391            Case {
1392                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
1393                check: |r| match r {
1394                    Ok(Command::Serve { ui, .. }) => {
1395                        assert_eq!(
1396                            ui,
1397                            super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
1398                        );
1399                    }
1400                    other => panic!("serve --ui <dir>, got {other:?}"),
1401                },
1402            },
1403            Case {
1404                args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
1405                check: |r| match r {
1406                    Ok(Command::Serve { ui, .. }) => {
1407                        assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
1408                    }
1409                    other => panic!("serve --ui=VALUE, got {other:?}"),
1410                },
1411            },
1412            Case {
1413                args: &["serve", "/tmp/demo-db", "--ui"],
1414                check: |r| {
1415                    let e = r.expect_err("--ui missing value");
1416                    assert!(
1417                        e.to_lowercase().contains("ui"),
1418                        "--ui missing value should mention ui, got {e}"
1419                    );
1420                },
1421            },
1422            Case {
1423                args: &["serve", "/tmp/demo-db", "--no-ui"],
1424                check: |r| match r {
1425                    Ok(Command::Serve { ui, .. }) => {
1426                        assert_eq!(ui, super::ServeUi::None);
1427                    }
1428                    other => panic!("serve --no-ui, got {other:?}"),
1429                },
1430            },
1431            Case {
1432                args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
1433                check: |r| {
1434                    let e = r.expect_err("combine --ui and --no-ui");
1435                    assert!(
1436                        e.contains("--ui") && e.contains("--no-ui"),
1437                        "conflict should name both flags, got {e}"
1438                    );
1439                },
1440            },
1441            Case {
1442                args: &["serve", "/tmp/demo-db", "extra"],
1443                check: |r| {
1444                    let e = r.expect_err("extra positional");
1445                    assert!(
1446                        e.to_lowercase().contains("unexpected")
1447                            || e.to_lowercase().contains("extra"),
1448                        "extra arg should be rejected, got {e}"
1449                    );
1450                },
1451            },
1452            Case {
1453                args: &[
1454                    "serve",
1455                    "/data",
1456                    "--addr",
1457                    "0.0.0.0:8080",
1458                    "--demo-if-empty",
1459                ],
1460                check: |r| match r {
1461                    Ok(Command::Serve {
1462                        db_dir,
1463                        addr,
1464                        demo_if_empty,
1465                        ui,
1466                        token,
1467                        snapshot_every,
1468                    }) => {
1469                        assert_eq!(db_dir, PathBuf::from("/data"));
1470                        assert_eq!(addr, "0.0.0.0:8080".parse().unwrap());
1471                        assert!(demo_if_empty);
1472                        assert_eq!(ui, super::ServeUi::Embedded);
1473                        assert_eq!(token, None);
1474                        assert_eq!(snapshot_every, None);
1475                    }
1476                    other => panic!("serve --demo-if-empty docker default, got {other:?}"),
1477                },
1478            },
1479        ];
1480
1481        for case in &cases {
1482            (case.check)(parse_args(case.args));
1483        }
1484    }
1485
1486    #[test]
1487    fn serve_default_addr_is_loopback_8080() {
1488        match parse_args(&["serve", "/tmp/db"]).unwrap() {
1489            Command::Serve { addr, .. } => {
1490                assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
1491            }
1492            other => panic!("{other:?}"),
1493        }
1494    }
1495
1496    #[test]
1497    fn serve_snapshot_every_parses_seconds() {
1498        match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
1499            Command::Serve { snapshot_every, .. } => {
1500                assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
1501            }
1502            other => panic!("{other:?}"),
1503        }
1504        match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
1505            Command::Serve { snapshot_every, .. } => {
1506                assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
1507            }
1508            other => panic!("{other:?}"),
1509        }
1510        match parse_args(&["serve", "/tmp/db"]).unwrap() {
1511            Command::Serve { snapshot_every, .. } => {
1512                assert_eq!(snapshot_every, None);
1513            }
1514            other => panic!("{other:?}"),
1515        }
1516        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
1517        assert!(
1518            err.contains("snapshot-every"),
1519            "missing value should name the flag, got {err}"
1520        );
1521        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
1522        assert!(
1523            err.contains("snapshot-every"),
1524            "zero should be rejected, got {err}"
1525        );
1526        let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
1527        assert!(
1528            err.contains("snapshot-every"),
1529            "invalid value should name the flag, got {err}"
1530        );
1531    }
1532
1533    #[test]
1534    fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
1535        // parse succeeds; main() enforces the bind rule. Token is stored.
1536        match parse_args(&[
1537            "serve",
1538            "/tmp/db",
1539            "--addr",
1540            "0.0.0.0:8080",
1541            "--token",
1542            "s3cret",
1543        ])
1544        .unwrap()
1545        {
1546            Command::Serve { token, addr, .. } => {
1547                assert_eq!(token.as_deref(), Some("s3cret"));
1548                assert_eq!(addr.ip().to_string(), "0.0.0.0");
1549            }
1550            other => panic!("{other:?}"),
1551        }
1552    }
1553
1554    #[test]
1555    fn parse_snapshot_and_query() {
1556        match parse_args(&["snapshot", "/tmp/db"]).unwrap() {
1557            Command::Snapshot { keep_wal, .. } => assert!(!keep_wal),
1558            other => panic!("{other:?}"),
1559        }
1560        match parse_args(&["snapshot", "/tmp/db", "--keep-wal"]).unwrap() {
1561            Command::Snapshot { keep_wal, .. } => assert!(keep_wal),
1562            other => panic!("{other:?}"),
1563        }
1564        match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
1565            Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
1566            other => panic!("{other:?}"),
1567        }
1568        match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
1569            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
1570            other => panic!("{other:?}"),
1571        }
1572        match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
1573            Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
1574            other => panic!("{other:?}"),
1575        }
1576        let text = usage();
1577        assert!(
1578            text.contains("query"),
1579            "usage should mention query, got:\n{text}"
1580        );
1581        assert!(
1582            text.contains("snapshot"),
1583            "usage should mention snapshot, got:\n{text}"
1584        );
1585    }
1586
1587    #[test]
1588    fn usage_lists_every_subcommand() {
1589        let text = usage();
1590        for word in [
1591            "serve",
1592            "mcp",
1593            "stats",
1594            "demo",
1595            "query",
1596            "snapshot",
1597            "--keep-wal",
1598            "mushroomdb",
1599            "--ui",
1600            "--no-ui",
1601            "--demo-if-empty",
1602            "--token",
1603            "--snapshot-every",
1604        ] {
1605            assert!(
1606                text.contains(word),
1607                "usage should mention {word}, got:\n{text}"
1608            );
1609        }
1610    }
1611
1612    #[test]
1613    fn validate_ui_dir_requires_index_html() {
1614        let missing = tmp("ui-missing");
1615        let err = super::validate_ui_dir(&missing).expect_err("missing dir");
1616        assert!(
1617            err.contains("does not exist"),
1618            "missing dir error, got {err}"
1619        );
1620
1621        let empty = tmp("ui-empty");
1622        std::fs::create_dir_all(&empty).unwrap();
1623        let err = super::validate_ui_dir(&empty).expect_err("no index");
1624        assert!(
1625            err.contains("index.html"),
1626            "missing index.html error, got {err}"
1627        );
1628
1629        let ok = tmp("ui-ok");
1630        std::fs::create_dir_all(&ok).unwrap();
1631        std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
1632        let got = super::validate_ui_dir(&ok).expect("valid ui dir");
1633        assert_eq!(got, ok);
1634    }
1635
1636    #[test]
1637    fn maybe_run_demo_if_empty_seeds_then_skips() {
1638        let dir = tmp("boot-empty");
1639        let first = super::maybe_run_demo_if_empty(&dir)
1640            .expect("empty dir demos")
1641            .expect("Some(DemoOutcome)");
1642        assert_eq!(first.stats.nodes_live, 60);
1643        let db = SharedDb::open(&dir).expect("reopen");
1644        assert!(db.read().has_node("person-01"));
1645        let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
1646        assert!(
1647            second.is_none(),
1648            "second boot must not re-demo a populated volume"
1649        );
1650
1651        let occupied = tmp("boot-occupied");
1652        std::fs::create_dir_all(&occupied).unwrap();
1653        std::fs::write(occupied.join("keep-me"), b"x").unwrap();
1654        let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
1655        assert!(skipped.is_none());
1656        assert_eq!(
1657            std::fs::read(occupied.join("keep-me")).unwrap(),
1658            b"x",
1659            "existing volume contents must be untouched"
1660        );
1661    }
1662
1663    #[test]
1664    fn demo_builder_is_deterministic_and_refuses_second_run() {
1665        let dir = tmp("demo");
1666        let out = run_demo(&dir).expect("first demo run");
1667
1668        assert_eq!(
1669            out.stats.nodes_live, 60,
1670            "10 orgs + 20 projects + 30 people"
1671        );
1672        assert_eq!(out.stats.nodes_tombstoned, 0);
1673        // Auto-FK: 20 project→org + 30 person→org + 30 person→project = 80.
1674        // FIT: each of 30 people matches home (Jaccard 1.0) and two adjacent
1675        // projects (3-skill window shifted ±1 → Jaccard 2/4 = 0.5) = 30*3 = 90.
1676        // founded_within: |year_i − year_j| ≤ 2 on 2010+(i-1) → 17 pairs × 2 = 34.
1677        // nearby_office: 4 city clusters (NYC/SF/London/Paris) → 8 pairs × 2 = 16.
1678        // similar_interests: dim-8 groups → 57 pairs × 2 = 114.
1679        // Total: 80 + 90 + 34 + 16 + 114 = 334.
1680        assert_eq!(out.stats.edges, 334);
1681        assert_eq!(
1682            out.stats.rules.len(),
1683            7,
1684            "3 auto-FK + overlap + numeric + geo + vector"
1685        );
1686        let fit = out
1687            .stats
1688            .rules
1689            .iter()
1690            .find(|r| r.name == "skill_fit")
1691            .expect("skill_fit");
1692        assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
1693        let founded = out
1694            .stats
1695            .rules
1696            .iter()
1697            .find(|r| r.name == "founded_within")
1698            .expect("founded_within");
1699        assert_eq!(founded.edges, 34);
1700        let nearby = out
1701            .stats
1702            .rules
1703            .iter()
1704            .find(|r| r.name == "nearby_office")
1705            .expect("nearby_office");
1706        assert_eq!(nearby.edges, 16);
1707        let similar = out
1708            .stats
1709            .rules
1710            .iter()
1711            .find(|r| r.name == "similar_interests")
1712            .expect("similar_interests");
1713        assert_eq!(similar.edges, 114);
1714
1715        let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
1716        names.sort_unstable();
1717        assert_eq!(
1718            names,
1719            vec![
1720                "auto_fk_person_org_id",
1721                "auto_fk_person_project_id",
1722                "auto_fk_project_org_id",
1723                "founded_within",
1724                "nearby_office",
1725                "similar_interests",
1726                "skill_fit",
1727            ]
1728        );
1729
1730        let mut auto = out.auto_fk_rules.clone();
1731        auto.sort();
1732        assert_eq!(
1733            auto,
1734            vec![
1735                "auto_fk_person_org_id".to_string(),
1736                "auto_fk_person_project_id".to_string(),
1737                "auto_fk_project_org_id".to_string(),
1738            ]
1739        );
1740
1741        assert!(
1742            !out.sample_result.is_empty(),
1743            "sample Cypher query must return rows"
1744        );
1745        assert!(
1746            out.sample_query.contains("ORDER BY score DESC"),
1747            "sample query must rank by score, got {}",
1748            out.sample_query
1749        );
1750        let scores: Vec<f64> = (0..out.sample_result.len())
1751            .map(|i| match out.sample_result.get(i, "score") {
1752                Some(Value::Float(f)) => *f,
1753                other => panic!("score col should be Float, got {other:?}"),
1754            })
1755            .collect();
1756        let distinct: std::collections::BTreeSet<u64> =
1757            scores.iter().map(|s| s.to_bits()).collect();
1758        assert!(
1759            distinct.len() >= 2,
1760            "sample results must be visibly ranked, got {scores:?}"
1761        );
1762        for w in scores.windows(2) {
1763            assert!(
1764                w[0] >= w[1],
1765                "scores must be non-increasing, got {scores:?}"
1766            );
1767        }
1768        assert!(
1769            !out.explanations.is_empty(),
1770            "explain(person-01, proj-01) must find the derived edges"
1771        );
1772
1773        let db = SharedDb::open(&dir).expect("reopen demo");
1774        assert_eq!(
1775            directed_pairs(&db, "FOUNDED_WITHIN"),
1776            [
1777                ("org-01", "org-02"),
1778                ("org-01", "org-03"),
1779                ("org-02", "org-01"),
1780                ("org-02", "org-03"),
1781                ("org-02", "org-04"),
1782                ("org-03", "org-01"),
1783                ("org-03", "org-02"),
1784                ("org-03", "org-04"),
1785                ("org-03", "org-05"),
1786                ("org-04", "org-02"),
1787                ("org-04", "org-03"),
1788                ("org-04", "org-05"),
1789                ("org-04", "org-06"),
1790                ("org-05", "org-03"),
1791                ("org-05", "org-04"),
1792                ("org-05", "org-06"),
1793                ("org-05", "org-07"),
1794                ("org-06", "org-04"),
1795                ("org-06", "org-05"),
1796                ("org-06", "org-07"),
1797                ("org-06", "org-08"),
1798                ("org-07", "org-05"),
1799                ("org-07", "org-06"),
1800                ("org-07", "org-08"),
1801                ("org-07", "org-09"),
1802                ("org-08", "org-06"),
1803                ("org-08", "org-07"),
1804                ("org-08", "org-09"),
1805                ("org-08", "org-10"),
1806                ("org-09", "org-07"),
1807                ("org-09", "org-08"),
1808                ("org-09", "org-10"),
1809                ("org-10", "org-08"),
1810                ("org-10", "org-09"),
1811            ]
1812            .into_iter()
1813            .map(|(a, b)| (a.to_string(), b.to_string()))
1814            .collect::<BTreeSet<_>>()
1815        );
1816        assert_eq!(
1817            directed_pairs(&db, "NEARBY_OFFICE"),
1818            [
1819                ("org-01", "org-07"),
1820                ("org-01", "org-10"),
1821                ("org-02", "org-09"),
1822                ("org-03", "org-08"),
1823                ("org-04", "org-05"),
1824                ("org-04", "org-06"),
1825                ("org-05", "org-04"),
1826                ("org-05", "org-06"),
1827                ("org-06", "org-04"),
1828                ("org-06", "org-05"),
1829                ("org-07", "org-01"),
1830                ("org-07", "org-10"),
1831                ("org-08", "org-03"),
1832                ("org-09", "org-02"),
1833                ("org-10", "org-01"),
1834                ("org-10", "org-07"),
1835            ]
1836            .into_iter()
1837            .map(|(a, b)| (a.to_string(), b.to_string()))
1838            .collect::<BTreeSet<_>>()
1839        );
1840        assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
1841        let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
1842        assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
1843        assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
1844        assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
1845
1846        let err = run_demo(&dir).expect_err("second run into the same dir");
1847        let msg = err.to_string().to_lowercase();
1848        assert!(
1849            msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
1850            "refuse message must mention non-empty dir, got {err}"
1851        );
1852        assert!(
1853            msg.contains("hidden"),
1854            "refuse message must mention hidden files, got {err}"
1855        );
1856
1857        let _ = std::fs::remove_dir_all(&dir);
1858    }
1859
1860    #[test]
1861    fn run_snapshot_writes_snapshot_bin() {
1862        let dir = tmp("snapshot-cli");
1863        {
1864            let mut db = GraphDb::open(&dir).expect("open");
1865            db.insert_node("Person", "alice", vec![]).expect("insert");
1866        }
1867        assert!(
1868            !dir.join("snapshot.bin").exists(),
1869            "GraphDb Drop must not snapshot"
1870        );
1871        let out = run_snapshot(&dir, false).expect("snapshot");
1872        assert!(
1873            dir.join("snapshot.bin").is_file(),
1874            "run_snapshot must write snapshot.bin"
1875        );
1876        assert!(
1877            out.contains("snapshot.bin"),
1878            "snapshot output should mention snapshot.bin, got {out}"
1879        );
1880        let db = GraphDb::open(&dir).expect("reopen");
1881        assert!(db.has_node("alice"), "reopen after snapshot must recover");
1882        let _ = std::fs::remove_dir_all(&dir);
1883    }
1884
1885    #[test]
1886    fn run_query_formats_like_asof() {
1887        let dir = tmp("query-cli");
1888        {
1889            let mut db = GraphDb::open(&dir).expect("open");
1890            db.insert_node(
1891                "Person",
1892                "alice",
1893                vec![("id".into(), Value::Str("alice".into()))],
1894            )
1895            .expect("insert");
1896        }
1897        let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id").expect("query");
1898        assert!(out.contains("columns:"), "got {out}");
1899        assert!(out.contains("id=alice"), "got {out}");
1900        let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})").expect("write");
1901        let db = GraphDb::open(&dir).expect("reopen");
1902        assert!(db.has_node("bob"), "query_write must persist CREATE");
1903        let _ = std::fs::remove_dir_all(&dir);
1904    }
1905
1906    #[test]
1907    fn format_stats_contains_counts() {
1908        let dir = tmp("stats-smoke");
1909        let out = run_demo(&dir).expect("demo for stats smoke");
1910        let text = format_stats(&out.stats);
1911        assert!(
1912            text.contains("60"),
1913            "stats output should include live node count, got:\n{text}"
1914        );
1915        assert!(
1916            text.contains("334"),
1917            "stats output should include edge count, got:\n{text}"
1918        );
1919        assert!(
1920            text.to_lowercase().contains("node"),
1921            "stats output should mention nodes, got:\n{text}"
1922        );
1923        assert!(
1924            text.to_lowercase().contains("edge"),
1925            "stats output should mention edges, got:\n{text}"
1926        );
1927        let _ = std::fs::remove_dir_all(&dir);
1928    }
1929}