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