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