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