1use 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
18pub const N_ORGS: usize = 10;
20pub const N_PROJECTS: usize = 20;
21pub const N_PEOPLE: usize = 30;
22
23pub 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#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ServeUi {
38 Filesystem(PathBuf),
39 Embedded,
40 None,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum AlgoSubcmd {
46 Pagerank,
47 Wcc,
48 Degree,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Command {
54 Serve {
55 db_dir: PathBuf,
56 addr: SocketAddr,
57 ui: ServeUi,
58 demo_if_empty: bool,
61 token: Option<String>,
63 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 AsOf {
77 db_dir: PathBuf,
78 commit: u64,
80 query: Option<String>,
82 },
83 Suggest {
85 db_dir: PathBuf,
86 },
87 Algo {
89 db_dir: PathBuf,
90 subcmd: AlgoSubcmd,
91 top: usize,
93 },
94 Query {
96 db_dir: PathBuf,
97 cypher: String,
99 },
100 Snapshot {
102 db_dir: PathBuf,
103 keep_wal: bool,
104 },
105 SchemaApply {
107 db_dir: PathBuf,
108 schema_file: PathBuf,
109 },
110 Help,
111}
112
113#[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 pub suggestion: Option<RuleSuggestion>,
123}
124
125#[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
149pub 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
173pub 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
293pub 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
359pub 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, ¶ms)?;
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
417pub 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, ¶ms)?
424 } else {
425 let db = GraphDb::open(db_dir)?;
426 db.query(cypher, ¶ms)?
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
453pub 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
503pub 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
597pub 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
682pub 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
702pub 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
709pub 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 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
853pub 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
876fn wrap_proj(i: usize) -> usize {
878 (i - 1) % N_PROJECTS + 1
879}
880
881fn 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
889fn org_office(i: usize) -> (f64, f64) {
893 match i {
894 1 => (40.7128, -74.0060), 2 => (48.8566, 2.3522), 3 => (51.5074, -0.1278), 4 => (37.7749, -122.4194), 5 => (37.8044, -122.2711), 6 => (37.8715, -122.2730), 7 => (40.7178, -74.0431), 8 => (51.4769, 0.0005), 9 => (48.8014, 2.1301), 10 => (40.7357, -74.1724), _ => unreachable!("demo orgs are 1..=10"),
905 }
906}
907
908fn 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
965pub 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 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
1049pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
1051 let db = GraphDb::open(dir)?;
1052 Ok(db.suggest_rules())
1053}
1054
1055pub 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 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 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}