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