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