1pub mod doctor;
7pub mod export;
8pub mod ingest_git;
9pub mod install;
10pub mod recall;
11pub mod structure;
12
13use core_api::repograph;
14use core_api::schema::Schema;
15use core_api::{
16 default_max_edges, is_write_query, wal_commit_count_at, AlgoDir, BackupReport, DegreeConfig,
17 Explanation, GraphDb, IngestOptions, LouvainConfig, PageRankConfig, Predicate, ResultSet,
18 RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig, WriteGuard,
19};
20use export::ExportFormat;
21use std::collections::{BTreeMap, BTreeSet};
22use std::fmt::Write as _;
23use std::net::SocketAddr;
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27pub const AUTOMATIC_SNAPSHOT: SnapshotOptions = SnapshotOptions {
38 keep_wal: false,
39 archive_wal: true,
40};
41
42pub const AUTO_SNAPSHOT_RETENTION: u32 = 8;
71
72pub fn snapshot_automatically(db: &mut WriteGuard<'_>) -> Result<(), core_api::GraphError> {
82 db.set_wal_archive_retention(Some(AUTO_SNAPSHOT_RETENTION));
83 db.snapshot_with(AUTOMATIC_SNAPSHOT)
84}
85
86pub const SNAPSHOT_LOCK_WAIT: Duration = Duration::from_millis(500);
94
95pub fn snapshot_shared(db: &SharedDb) -> Result<(), core_api::GraphError> {
106 snapshot_automatically(&mut db.write_with_wait(SNAPSHOT_LOCK_WAIT)?)
107}
108
109pub const N_ORGS: usize = 10;
111pub const N_PROJECTS: usize = 20;
112pub const N_PEOPLE: usize = 30;
113
114pub const SAMPLE_QUERY: &str = "\
119MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
120RETURN p, proj, r.score AS score
121ORDER BY score DESC, proj";
122
123const SAMPLE_EXPLAIN_A: &str = "person-01";
124const SAMPLE_EXPLAIN_B: &str = "proj-01";
125
126pub const VERSION: &str = env!("CARGO_PKG_VERSION");
128
129#[must_use]
131pub fn version_string() -> String {
132 format!("mushroomdb {VERSION}")
133}
134
135#[must_use]
147pub fn resolve_auto_db(
148 env_project_dir: Option<&std::ffi::OsStr>,
149 cwd: &Path,
150 home: &Path,
151) -> PathBuf {
152 if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
153 return Path::new(dir).join("mushroom-memory");
154 }
155 if let Some(root) = worktree_root(cwd) {
156 return root.join("mushroom-memory");
157 }
158 home.join(".mushroomdb").join("memory")
159}
160
161#[must_use]
175pub fn worktree_root(dir: &Path) -> Option<&Path> {
176 dir.ancestors().find(|d| d.join(".git").exists())
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum ServeUi {
182 Filesystem(PathBuf),
183 Embedded,
184 None,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum AlgoSubcmd {
190 Pagerank,
191 Wcc,
192 Degree,
193 Communities,
194}
195
196#[derive(Debug, Clone, PartialEq)]
200pub enum Command {
201 Serve {
202 db_dir: PathBuf,
203 addr: SocketAddr,
204 ui: ServeUi,
205 demo_if_empty: bool,
208 token: Option<String>,
210 role_tokens: Vec<(String, String)>,
213 snapshot_every: Option<Duration>,
215 tls_cert: Option<PathBuf>,
217 tls_key: Option<PathBuf>,
219 },
220 Mcp {
221 db_dir: Option<PathBuf>,
223 auto: bool,
224 all_tools: bool,
229 },
230 Stats {
231 db_dir: PathBuf,
232 },
233 Demo {
234 db_dir: PathBuf,
235 },
236 AsOf {
238 db_dir: PathBuf,
239 commit: u64,
241 query: Option<String>,
243 },
244 Suggest {
246 db_dir: PathBuf,
247 },
248 Algo {
250 db_dir: PathBuf,
251 subcmd: AlgoSubcmd,
252 top: usize,
254 dir: AlgoDir,
257 edge_types: Vec<String>,
260 weight_prop: Option<String>,
262 min_weight: Option<f64>,
264 },
265 Query {
267 db_dir: PathBuf,
268 cypher: String,
270 },
271 Snapshot {
273 db_dir: PathBuf,
274 wal: WalDisposition,
275 retention: Option<u32>,
278 },
279 SchemaApply {
281 db_dir: PathBuf,
282 schema_file: PathBuf,
283 },
284 Migrate {
286 db_dir: PathBuf,
287 },
288 Verify {
290 db_dir: PathBuf,
291 },
292 Backup {
294 db_dir: PathBuf,
295 dest: PathBuf,
296 },
297 Export {
299 db_dir: PathBuf,
300 dest: PathBuf,
301 format: ExportFormat,
302 },
303 IngestGit {
305 db_dir: PathBuf,
306 opts: ingest_git::IngestGitOpts,
307 },
308 Install(install::InstallOpts),
310 Uninstall(install::InstallOpts),
312 Disable(install::ToggleOpts),
316 Enable(install::ToggleOpts),
319 Doctor(doctor::DoctorOpts),
321 Recall {
324 db_dir: Option<PathBuf>,
325 auto: bool,
326 },
327 Sync {
330 db_dir: Option<PathBuf>,
335 auto: bool,
336 json: bool,
340 },
341 Touch {
344 db_dir: Option<PathBuf>,
345 auto: bool,
346 files: Vec<PathBuf>,
347 },
348 Map {
351 db_dir: PathBuf,
352 json: bool,
355 },
356 Context {
358 db_dir: PathBuf,
359 target: String,
360 },
361 Impact {
364 db_dir: PathBuf,
365 files: Vec<String>,
366 },
367 Owners {
369 db_dir: PathBuf,
370 path: String,
371 },
372 Why {
374 db_dir: PathBuf,
375 a: String,
376 b: String,
377 },
378 Version,
379 Help,
380}
381
382#[derive(Debug)]
384pub struct DemoOutcome {
385 pub auto_fk_rules: Vec<String>,
386 pub sample_query: String,
387 pub sample_result: ResultSet,
388 pub explanations: Vec<Explanation>,
389 pub stats: Stats,
390 pub suggestion: Option<RuleSuggestion>,
392}
393
394#[derive(Debug)]
396pub struct CliError(pub String);
397
398impl std::fmt::Display for CliError {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 f.write_str(&self.0)
401 }
402}
403
404impl std::error::Error for CliError {}
405
406impl From<core_api::GraphError> for CliError {
407 fn from(e: core_api::GraphError) -> Self {
408 CliError(e.to_string())
409 }
410}
411
412impl From<std::io::Error> for CliError {
413 fn from(e: std::io::Error) -> Self {
414 CliError(e.to_string())
415 }
416}
417
418pub fn usage() -> &'static str {
420 "\
421mushroomdb — embedded graph database
422
423Usage:
424 mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
425 [--command <path>] [--no-git-hooks] [--no-prewarm]
426 mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
427 mushroomdb disable [--platform claude-code|cursor|codex|all] [--project|--user]
428 turn an install off without removing it: hooks, MCP entry and git hook
429 blocks are removed; the skill, the store and .gitignore stay
430 mushroomdb enable [--platform claude-code|cursor|codex|all] [--project|--user]
431 turn a disabled install back on
432 mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
433 verify an install: config entry, store, hooks, git hooks, and a real
434 stdio handshake with the configured MCP command; exits 1 on any `fail`
435 mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>]
436 mushroomdb mcp <db-dir>|--auto [--all-tools]
437 --all-tools lists all 24 tools; the default lists the 11 a coding
438 agent reaches for (the rest stay callable, just unlisted)
439 mushroomdb stats <db-dir>
440 mushroomdb demo <db-dir>
441 mushroomdb recall <db-dir>|--auto hook body: reads a prompt payload on stdin, prints related graph facts
442 mushroomdb sync <db-dir>|--auto [--json]
443 re-sync the repo the store was built from: new commits, then the
444 dirty working tree (git hook body)
445 mushroomdb map <db-dir> [--json] summarise the graphed repository: clusters, key files, owners, hot files
446 --json prints the computed map instead of the rendered digest
447 mushroomdb context <db-dir> <target> one file or symbol from every side: signature, source, callers,
448 callees, importers, co-change partners, commits, notes
449 <target> is a file path, a symbol key, or a bare symbol name
450 mushroomdb impact <db-dir> <file>... what changing these files reaches: partners, importers,
451 and the symbols other files call
452 mushroomdb owners <db-dir> <path> top author and share, who else knows it, last touch, last 4 quarters
453 mushroomdb why <db-dir> <a> <b> every rule edge between two nodes with its evidence, or the
454 shortest path between them
455 mushroomdb touch <db-dir>|--auto [<file>...]
456 re-extract just these files; with no <file> reads them from a
457 PostToolUse payload on stdin (hook body)
458 mushroomdb suggest <db-dir>
459 mushroomdb asof <db-dir> --commit N [--query \"MATCH ...\"]
460 mushroomdb query <db-dir> [--query \"MATCH ...\"] <cypher…>
461 mushroomdb snapshot <db-dir> [--keep-wal|--truncate] [--retention N]
462 folds the WAL into snapshot.bin and archives it as wal.<N>.archive,
463 so node_history, edge_history, was_linked and asof keep reaching it;
464 --truncate discards it instead, --keep-wal leaves wal.bin whole
465 mushroomdb migrate <db-dir>
466 mushroomdb verify <db-dir> validate CRC32 integrity of every snapshot section
467 mushroomdb backup <db-dir> <dest> process-local consistent copy of the database to <dest>
468 WARNING: unsafe against a concurrently running serve process;
469 use POST /backup on the HTTP server for live-serve backups
470 mushroomdb export <db-dir> <dest> --format jsonl|parquet|graphml export all data
471 graphml writes one file: <dest>/graph.graphml if <dest> is an
472 existing directory, otherwise <dest> is the file path itself
473 (nodes + edges only; rules have no GraphML analogue)
474 mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
475 [--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
476 graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
477 --recurse-submodules also walks each initialised submodule
478 --prs links merged pull requests via gh (skipped when gh is unavailable)
479 --no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
480 --no-docs skips Markdown bodies, headings and mentions
481 --ensure-gitignore adds the database directory to the repo's .gitignore
482 with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
483 mushroomdb schema apply <db-dir> <schema.json>
484 mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
485 mushroomdb algo wcc <db-dir> [--top N]
486 mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
487 mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
488 mushroomdb --version
489 mushroomdb --help
490
491Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
492install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
493the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
494--command names one (a relative --command or --db is anchored to the current directory).
495--no-git-hooks skips the post-commit/checkout/merge sync hooks. --no-prewarm means no network and no
496resolution: neither the one-off package fetch nor locating the package's binary, so every hook keeps
497the slower `npx` form.
498uninstall resolves the same scope and falls back to the other one when the inferred scope has no
499manifest; undoing a Codex install needs --platform codex.
500A project install inside a git checkout writes --auto rather than a store path, so each `git
501worktree` gets its own store; outside a checkout, and with --db, the store is pinned to an absolute
502path instead.
503--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else mushroom-memory at the
504root of the working tree the current directory is in, else ~/.mushroomdb/memory.
505"
506}
507
508fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
509 let mut platform: Option<install::Platform> = None;
510 let mut scope: Option<install::Scope> = None;
511 let mut db: Option<PathBuf> = None;
512 let mut command: Option<PathBuf> = None;
513 let mut git_hooks = true;
514 let mut prewarm = true;
515 let mut i = 0;
516 while i < args.len() {
517 let a = args[i];
518 if a == "--platform" {
519 let val = args
520 .get(i + 1)
521 .copied()
522 .ok_or_else(|| "missing value for --platform".to_string())?;
523 platform = Some(install::Platform::parse(val)?);
524 i += 2;
525 } else if let Some(val) = a.strip_prefix("--platform=") {
526 platform = Some(install::Platform::parse(val)?);
527 i += 1;
528 } else if a == "--project" || a == "--user" {
529 let want = if a == "--project" {
530 install::Scope::Project
531 } else {
532 install::Scope::User
533 };
534 if scope.is_some_and(|s| s != want) {
537 return Err("--project and --user are mutually exclusive".to_string());
538 }
539 scope = Some(want);
540 i += 1;
541 } else if a == "--no-git-hooks" {
542 git_hooks = false;
543 i += 1;
544 } else if a == "--no-prewarm" {
545 prewarm = false;
546 i += 1;
547 } else if a == "--command" {
548 let val = args
549 .get(i + 1)
550 .copied()
551 .ok_or_else(|| "missing value for --command".to_string())?;
552 command = Some(PathBuf::from(val));
553 i += 2;
554 } else if let Some(val) = a.strip_prefix("--command=") {
555 command = Some(PathBuf::from(val));
556 i += 1;
557 } else if a == "--db" {
558 let val = args
559 .get(i + 1)
560 .copied()
561 .ok_or_else(|| "missing value for --db".to_string())?;
562 db = Some(PathBuf::from(val));
563 i += 2;
564 } else if let Some(val) = a.strip_prefix("--db=") {
565 db = Some(PathBuf::from(val));
566 i += 1;
567 } else if a.starts_with('-') {
568 return Err(format!("unexpected flag: {a}"));
569 } else {
570 return Err(format!("unexpected argument: {a}"));
571 }
572 }
573 Ok(install::InstallOpts {
574 platform,
575 scope,
576 db,
577 command,
578 git_hooks,
579 prewarm,
580 })
581}
582
583fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
584 let mut platform: Option<install::Platform> = None;
585 let mut scope: Option<install::Scope> = None;
586 let mut i = 0;
587 while i < args.len() {
588 let a = args[i];
589 if a == "--platform" {
590 let val = args
591 .get(i + 1)
592 .copied()
593 .ok_or_else(|| "missing value for --platform".to_string())?;
594 platform = Some(install::Platform::parse(val)?);
595 i += 2;
596 } else if let Some(val) = a.strip_prefix("--platform=") {
597 platform = Some(install::Platform::parse(val)?);
598 i += 1;
599 } else if a == "--project" || a == "--user" {
600 let want = if a == "--project" {
601 install::Scope::Project
602 } else {
603 install::Scope::User
604 };
605 if scope.is_some_and(|s| s != want) {
606 return Err("--project and --user are mutually exclusive".to_string());
607 }
608 scope = Some(want);
609 i += 1;
610 } else if a.starts_with('-') {
611 return Err(format!("unexpected flag: {a}"));
612 } else {
613 return Err(format!("unexpected argument: {a}"));
614 }
615 }
616 Ok(doctor::DoctorOpts { platform, scope })
617}
618
619fn parse_toggle_cmd(args: &[&str]) -> Result<install::ToggleOpts, String> {
624 let mut platform: Option<install::Platform> = None;
625 let mut scope: Option<install::Scope> = None;
626 let mut i = 0;
627 while i < args.len() {
628 let a = args[i];
629 if a == "--platform" {
630 let val = args
631 .get(i + 1)
632 .copied()
633 .ok_or_else(|| "missing value for --platform".to_string())?;
634 platform = Some(install::Platform::parse(val)?);
635 i += 2;
636 } else if let Some(val) = a.strip_prefix("--platform=") {
637 platform = Some(install::Platform::parse(val)?);
638 i += 1;
639 } else if a == "--project" || a == "--user" {
640 let want = if a == "--project" {
641 install::Scope::Project
642 } else {
643 install::Scope::User
644 };
645 if scope.is_some_and(|s| s != want) {
646 return Err("--project and --user are mutually exclusive".to_string());
647 }
648 scope = Some(want);
649 i += 1;
650 } else if a.starts_with('-') {
651 return Err(format!("unexpected flag: {a}"));
652 } else {
653 return Err(format!("unexpected argument: {a}"));
654 }
655 }
656 Ok(install::ToggleOpts { platform, scope })
657}
658
659fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
660 let mut positional = Vec::new();
661 let mut exclude = Vec::new();
662 let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
663 let mut recurse_submodules = false;
664 let mut prs = false;
665 let mut structure = true;
666 let mut docs = true;
667 let mut ensure_gitignore = false;
668 let mut i = 0;
669 while i < args.len() {
670 let a = args[i];
671 if a == "--recurse-submodules" {
672 recurse_submodules = true;
673 i += 1;
674 } else if a == "--prs" {
675 prs = true;
676 i += 1;
677 } else if a == "--no-structure" {
678 structure = false;
679 i += 1;
680 } else if a == "--no-docs" {
681 docs = false;
682 i += 1;
683 } else if a == "--ensure-gitignore" {
684 ensure_gitignore = true;
685 i += 1;
686 } else if a == "--exclude" {
687 exclude.push(
688 args.get(i + 1)
689 .copied()
690 .ok_or_else(|| "missing value for --exclude".to_string())?
691 .to_string(),
692 );
693 i += 2;
694 } else if let Some(val) = a.strip_prefix("--exclude=") {
695 exclude.push(val.to_string());
696 i += 1;
697 } else if a == "--max-commits-per-file" {
698 let val = args
699 .get(i + 1)
700 .copied()
701 .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
702 max_commits_per_file = val
703 .parse()
704 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
705 i += 2;
706 } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
707 max_commits_per_file = val
708 .parse()
709 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
710 i += 1;
711 } else if a.starts_with('-') {
712 return Err(format!("unexpected flag: {a}"));
713 } else {
714 positional.push(a);
715 i += 1;
716 }
717 }
718 let [db_dir, repo] = positional.as_slice() else {
719 return Err("ingest-git requires <db-dir> <repo-dir>".into());
720 };
721 if exclude.is_empty() {
725 exclude = ingest_git::DEFAULT_EXCLUDES
726 .iter()
727 .map(|p| (*p).to_string())
728 .collect();
729 }
730 Ok(Command::IngestGit {
731 db_dir: PathBuf::from(db_dir),
732 opts: ingest_git::IngestGitOpts {
733 repo: PathBuf::from(repo),
734 exclude,
735 max_commits_per_file,
736 recurse_submodules,
737 prs,
738 structure,
739 docs,
740 ensure_gitignore,
741 },
742 })
743}
744
745pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
747 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
748 if args.is_empty() {
749 return Ok(Command::Help);
750 }
751 match args[0] {
752 "--help" | "-h" | "help" => Ok(Command::Help),
753 "--version" | "-V" | "version" => Ok(Command::Version),
754 "serve" => parse_serve(&args[1..]),
755 "mcp" => parse_mcp(&args[1..]),
756 "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
757 "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
758 "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
759 "asof" => parse_asof(&args[1..]),
760 "algo" => parse_algo(&args[1..]),
761 "query" => parse_query(&args[1..]),
762 "snapshot" => parse_snapshot(&args[1..]),
763 "schema" => parse_schema(&args[1..]),
764 "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
765 "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
766 "backup" => parse_backup(&args[1..]),
767 "export" => parse_export(&args[1..]),
768 "recall" => parse_dir_or_auto("recall", &args[1..])
769 .map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
770 "sync" => parse_sync(&args[1..]),
771 "map" => parse_dir_with_json("map", &args[1..])
772 .map(|(db_dir, json)| Command::Map { db_dir, json }),
773 "context" => {
774 parse_positional("context", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Context {
775 db_dir,
776 target: rest[0].clone(),
777 })
778 }
779 "impact" => parse_positional("impact", &args[1..], 1, usize::MAX)
780 .map(|(db_dir, files)| Command::Impact { db_dir, files }),
781 "owners" => {
782 parse_positional("owners", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Owners {
783 db_dir,
784 path: rest[0].clone(),
785 })
786 }
787 "why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
788 db_dir,
789 a: rest[0].clone(),
790 b: rest[1].clone(),
791 }),
792 "touch" => parse_touch(&args[1..]),
793 "ingest-git" => parse_ingest_git(&args[1..]),
794 "install" => parse_install_cmd(&args[1..]).map(Command::Install),
795 "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
796 "disable" => parse_toggle_cmd(&args[1..]).map(Command::Disable),
797 "enable" => parse_toggle_cmd(&args[1..]).map(Command::Enable),
798 "doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
799 other => Err(format!("unknown command: {other}")),
800 }
801}
802
803fn default_addr() -> SocketAddr {
804 SocketAddr::from(([127, 0, 0, 1], 8080))
805}
806
807fn parse_serve(args: &[&str]) -> Result<Command, String> {
808 let mut db_dir = None;
809 let mut addr = default_addr();
810 let mut ui = ServeUi::Embedded;
811 let mut saw_ui = false;
812 let mut saw_no_ui = false;
813 let mut demo_if_empty = false;
814 let mut token = None;
815 let mut role_tokens: Vec<(String, String)> = Vec::new();
816 let mut snapshot_every = None;
817 let mut tls_cert: Option<PathBuf> = None;
818 let mut tls_key: Option<PathBuf> = None;
819 let mut i = 0;
820 while i < args.len() {
821 let a = args[i];
822 if a == "--addr" {
823 let val = args
824 .get(i + 1)
825 .copied()
826 .ok_or_else(|| "missing value for --addr".to_string())?;
827 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
828 i += 2;
829 } else if let Some(val) = a.strip_prefix("--addr=") {
830 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
831 i += 1;
832 } else if a == "--ui" {
833 let val = args
834 .get(i + 1)
835 .copied()
836 .ok_or_else(|| "missing value for --ui".to_string())?;
837 ui = ServeUi::Filesystem(PathBuf::from(val));
838 saw_ui = true;
839 i += 2;
840 } else if let Some(val) = a.strip_prefix("--ui=") {
841 ui = ServeUi::Filesystem(PathBuf::from(val));
842 saw_ui = true;
843 i += 1;
844 } else if a == "--no-ui" {
845 ui = ServeUi::None;
846 saw_no_ui = true;
847 i += 1;
848 } else if a == "--demo-if-empty" {
849 demo_if_empty = true;
850 i += 1;
851 } else if a == "--token" {
852 let val = args
853 .get(i + 1)
854 .copied()
855 .ok_or_else(|| "missing value for --token".to_string())?;
856 token = Some(val.to_string());
857 i += 2;
858 } else if let Some(val) = a.strip_prefix("--token=") {
859 token = Some(val.to_string());
860 i += 1;
861 } else if a == "--role-token" {
862 let val = args
863 .get(i + 1)
864 .copied()
865 .ok_or_else(|| "missing value for --role-token".to_string())?;
866 let (tok, role) = parse_role_token(val)?;
867 role_tokens.push((tok, role));
868 i += 2;
869 } else if let Some(val) = a.strip_prefix("--role-token=") {
870 let (tok, role) = parse_role_token(val)?;
871 role_tokens.push((tok, role));
872 i += 1;
873 } else if a == "--snapshot-every" {
874 let val = args
875 .get(i + 1)
876 .copied()
877 .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
878 snapshot_every = Some(parse_snapshot_every(val)?);
879 i += 2;
880 } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
881 snapshot_every = Some(parse_snapshot_every(val)?);
882 i += 1;
883 } else if a == "--tls-cert" {
884 let val = args
885 .get(i + 1)
886 .copied()
887 .ok_or_else(|| "missing value for --tls-cert".to_string())?;
888 tls_cert = Some(PathBuf::from(val));
889 i += 2;
890 } else if let Some(val) = a.strip_prefix("--tls-cert=") {
891 tls_cert = Some(PathBuf::from(val));
892 i += 1;
893 } else if a == "--tls-key" {
894 let val = args
895 .get(i + 1)
896 .copied()
897 .ok_or_else(|| "missing value for --tls-key".to_string())?;
898 tls_key = Some(PathBuf::from(val));
899 i += 2;
900 } else if let Some(val) = a.strip_prefix("--tls-key=") {
901 tls_key = Some(PathBuf::from(val));
902 i += 1;
903 } else if a.starts_with('-') {
904 return Err(format!("unexpected flag: {a}"));
905 } else if db_dir.is_none() {
906 db_dir = Some(PathBuf::from(a));
907 i += 1;
908 } else {
909 return Err(format!("unexpected extra argument: {a}"));
910 }
911 }
912 if saw_ui && saw_no_ui {
913 return Err("cannot combine --ui and --no-ui".to_string());
914 }
915 match (&tls_cert, &tls_key) {
916 (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
917 (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
918 _ => {}
919 }
920 let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
921 Ok(Command::Serve {
922 db_dir,
923 addr,
924 ui,
925 demo_if_empty,
926 token,
927 role_tokens,
928 snapshot_every,
929 tls_cert,
930 tls_key,
931 })
932}
933
934fn parse_role_token(val: &str) -> Result<(String, String), String> {
935 let (tok, role) = val
936 .split_once(':')
937 .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
938 if tok.is_empty() {
939 return Err("--role-token: TOKEN must not be empty".to_string());
940 }
941 if role.is_empty() {
942 return Err("--role-token: ROLE must not be empty".to_string());
943 }
944 Ok((tok.to_string(), role.to_string()))
945}
946
947fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
948 let secs: u64 = val
949 .parse()
950 .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
951 if secs == 0 {
952 return Err("--snapshot-every must be a positive number of seconds".into());
953 }
954 Ok(Duration::from_secs(secs))
955}
956
957pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
959 if !dir.is_dir() {
960 return Err(format!("--ui directory does not exist: {}", dir.display()));
961 }
962 let index = dir.join("index.html");
963 if !index.is_file() {
964 return Err(format!(
965 "--ui directory is missing index.html: {}",
966 dir.display()
967 ));
968 }
969 Ok(dir.to_path_buf())
970}
971
972fn parse_asof(args: &[&str]) -> Result<Command, String> {
973 let mut db_dir = None;
974 let mut commit: Option<u64> = None;
975 let mut query: Option<String> = None;
976 let mut i = 0;
977 while i < args.len() {
978 let a = args[i];
979 if a == "--commit" {
980 let val = args
981 .get(i + 1)
982 .copied()
983 .ok_or_else(|| "missing value for --commit".to_string())?;
984 commit = Some(
985 val.parse()
986 .map_err(|_| format!("invalid commit index: {val}"))?,
987 );
988 i += 2;
989 } else if let Some(val) = a.strip_prefix("--commit=") {
990 commit = Some(
991 val.parse()
992 .map_err(|_| format!("invalid commit index: {val}"))?,
993 );
994 i += 1;
995 } else if a == "--query" {
996 let val = args
997 .get(i + 1)
998 .copied()
999 .ok_or_else(|| "missing value for --query".to_string())?;
1000 query = Some(val.to_string());
1001 i += 2;
1002 } else if let Some(val) = a.strip_prefix("--query=") {
1003 query = Some(val.to_string());
1004 i += 1;
1005 } else if a.starts_with('-') {
1006 return Err(format!("unexpected flag: {a}"));
1007 } else if db_dir.is_none() {
1008 db_dir = Some(PathBuf::from(a));
1009 i += 1;
1010 } else {
1011 return Err(format!("unexpected extra argument: {a}"));
1012 }
1013 }
1014 let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
1015 let commit = commit.ok_or_else(|| "asof requires --commit N".to_string())?;
1016 Ok(Command::AsOf {
1017 db_dir,
1018 commit,
1019 query,
1020 })
1021}
1022
1023pub fn run_asof(db_dir: &Path, commit: u64, query: Option<&str>) -> Result<String, CliError> {
1025 let total = wal_commit_count_at(db_dir)?;
1026 let db = GraphDb::open_at(db_dir, commit)?;
1027 let mut out = String::new();
1028 let _ = writeln!(out, "as-of commit {} of {}", commit, total);
1029 if let Some(cypher) = query {
1030 let params = BTreeMap::new();
1031 let rs = db.query(cypher, ¶ms)?;
1032 out.push_str(&format_result_set(&rs));
1033 }
1034 Ok(out)
1035}
1036
1037fn parse_query(args: &[&str]) -> Result<Command, String> {
1038 let mut db_dir = None;
1039 let mut query_flag: Option<String> = None;
1040 let mut cypher_parts: Vec<&str> = Vec::new();
1041 let mut i = 0;
1042 while i < args.len() {
1043 let a = args[i];
1044 if a == "--query" {
1045 let val = args
1046 .get(i + 1)
1047 .copied()
1048 .ok_or_else(|| "missing value for --query".to_string())?;
1049 query_flag = Some(val.to_string());
1050 i += 2;
1051 } else if let Some(val) = a.strip_prefix("--query=") {
1052 query_flag = Some(val.to_string());
1053 i += 1;
1054 } else if a.starts_with('-') {
1055 return Err(format!("unexpected flag: {a}"));
1056 } else if db_dir.is_none() {
1057 db_dir = Some(PathBuf::from(a));
1058 i += 1;
1059 } else {
1060 cypher_parts.push(a);
1061 i += 1;
1062 }
1063 }
1064 let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
1065 let cypher = if let Some(q) = query_flag {
1066 if !cypher_parts.is_empty() {
1067 return Err(
1068 "query: pass Cypher as remaining arguments or --query, not both".to_string(),
1069 );
1070 }
1071 q
1072 } else {
1073 if cypher_parts.is_empty() {
1074 return Err("query requires a Cypher string".to_string());
1075 }
1076 cypher_parts.join(" ")
1077 };
1078 Ok(Command::Query { db_dir, cypher })
1079}
1080
1081pub fn run_query(db_dir: &Path, cypher: &str) -> Result<String, CliError> {
1083 let params = BTreeMap::new();
1084 let is_write = is_write_query(cypher).map_err(CliError)?;
1085 let rs = if is_write {
1086 let mut db = GraphDb::open(db_dir)?;
1087 db.query_write(cypher, ¶ms)?
1088 } else {
1089 let db = GraphDb::open(db_dir)?;
1090 db.query(cypher, ¶ms)?
1091 };
1092 Ok(format_result_set(&rs))
1093}
1094
1095fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
1096 let mut db_dir = None;
1097 let mut wal = WalDisposition::Archive;
1100 let mut retention: Option<u32> = None;
1101 let mut i = 0;
1102 while i < args.len() {
1103 let a = args[i];
1104 if a == "--keep-wal" {
1105 wal = WalDisposition::Keep;
1106 i += 1;
1107 } else if a == "--truncate" {
1108 wal = WalDisposition::Truncate;
1109 i += 1;
1110 } else if a == "--archive-wal" {
1111 wal = WalDisposition::Archive;
1113 i += 1;
1114 } else if a.starts_with("--retention=") {
1115 let v = a.trim_start_matches("--retention=");
1116 retention = Some(
1117 v.parse::<u32>()
1118 .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
1119 );
1120 i += 1;
1121 } else if a == "--retention" {
1122 i += 1;
1123 let v = args
1124 .get(i)
1125 .ok_or_else(|| "--retention requires a value".to_string())?;
1126 retention = Some(
1127 v.parse::<u32>()
1128 .map_err(|e| format!("--retention value error: {e}"))?,
1129 );
1130 i += 1;
1131 } else if a.starts_with('-') {
1132 return Err(format!("unexpected flag: {a}"));
1133 } else if db_dir.is_none() {
1134 db_dir = Some(PathBuf::from(a));
1135 i += 1;
1136 } else {
1137 return Err(format!("unexpected extra argument: {a}"));
1138 }
1139 }
1140 let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
1141 Ok(Command::Snapshot {
1142 db_dir,
1143 wal,
1144 retention,
1145 })
1146}
1147
1148pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
1157 let current = core_api::SNAPSHOT_VERSION;
1158 let from_ver = core_api::snapshot_version_at(db_dir)?;
1159
1160 if from_ver == Some(current) {
1161 return Ok(format!("already current (V{current})\n"));
1162 }
1163
1164 if from_ver.is_some() {
1169 std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
1170 }
1171
1172 let mut db = GraphDb::open_with_options(
1175 db_dir,
1176 core_api::OpenOptions {
1177 auto_migrate: false,
1178 ..Default::default()
1179 },
1180 )?;
1181 db.snapshot()?;
1182
1183 let msg = match from_ver {
1184 Some(ver) => format!("migrated V{ver} -> V{current}\n"),
1185 None => format!("migrated WAL-only -> V{current}\n"),
1186 };
1187 Ok(msg)
1188}
1189
1190pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
1196 if !db_dir.join("snapshot.bin").exists() {
1199 return Err(CliError(format!(
1200 "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
1201 db_dir.display(),
1202 db_dir.display()
1203 )));
1204 }
1205 let results = core_api::verify_snapshot(db_dir)
1206 .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
1207 let mut any_fail = false;
1208 let mut out = String::new();
1209 for (id, section_name, byte_len, result) in &results {
1210 match result {
1211 Ok(()) => {
1212 let _ = writeln!(
1213 out,
1214 " section {:2} ({:<12}) {:>10} bytes OK",
1215 id, section_name, byte_len
1216 );
1217 }
1218 Err(msg) => {
1219 let _ = writeln!(
1220 out,
1221 " section {:2} ({:<12}) {:>10} bytes CORRUPT: {msg}",
1222 id, section_name, byte_len
1223 );
1224 any_fail = true;
1225 }
1226 }
1227 }
1228 if any_fail {
1229 Err(CliError(format!("integrity check FAILED:\n{out}")))
1230 } else {
1231 Ok(format!(
1232 "integrity check OK ({} sections):\n{out}",
1233 results.len()
1234 ))
1235 }
1236}
1237
1238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1240pub enum WalDisposition {
1241 #[default]
1245 Archive,
1246 Keep,
1249 Truncate,
1254}
1255
1256impl WalDisposition {
1257 fn options(self) -> SnapshotOptions {
1258 match self {
1259 WalDisposition::Archive => AUTOMATIC_SNAPSHOT,
1260 WalDisposition::Keep => SnapshotOptions {
1261 keep_wal: true,
1262 archive_wal: false,
1263 },
1264 WalDisposition::Truncate => SnapshotOptions {
1265 keep_wal: false,
1266 archive_wal: false,
1267 },
1268 }
1269 }
1270}
1271
1272pub fn run_snapshot(
1274 db_dir: &Path,
1275 wal: WalDisposition,
1276 retention: Option<u32>,
1277) -> Result<String, CliError> {
1278 let mut db = GraphDb::open(db_dir)?;
1279 if wal == WalDisposition::Archive {
1280 db.set_wal_archive_retention(retention);
1281 }
1282 db.snapshot_with(wal.options())?;
1283 Ok(format!(
1284 "snapshot written: {}\n",
1285 db_dir.join("snapshot.bin").display()
1286 ))
1287}
1288
1289fn parse_schema(args: &[&str]) -> Result<Command, String> {
1290 if args.is_empty() {
1291 return Err("schema requires a subcommand: apply".to_string());
1292 }
1293 match args[0] {
1294 "apply" => parse_schema_apply(&args[1..]),
1295 other => Err(format!(
1296 "unknown schema subcommand: {other}; expected apply"
1297 )),
1298 }
1299}
1300
1301fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
1302 let mut db_dir = None;
1303 let mut schema_file = None;
1304 for a in args {
1305 if a.starts_with('-') {
1306 return Err(format!("unexpected flag: {a}"));
1307 }
1308 if db_dir.is_none() {
1309 db_dir = Some(PathBuf::from(*a));
1310 } else if schema_file.is_none() {
1311 schema_file = Some(PathBuf::from(*a));
1312 } else {
1313 return Err(format!("unexpected extra argument: {a}"));
1314 }
1315 }
1316 let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
1317 let schema_file =
1318 schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
1319 Ok(Command::SchemaApply {
1320 db_dir,
1321 schema_file,
1322 })
1323}
1324
1325pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
1328 let json = std::fs::read_to_string(schema_file)
1329 .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
1330 let schema: Schema = serde_json::from_str(&json).map_err(|e| {
1331 CliError(format!(
1332 "invalid schema JSON in {}: {e}",
1333 schema_file.display()
1334 ))
1335 })?;
1336 let mut db = GraphDb::open(db_dir)?;
1337 let diff = db.apply_schema(&schema)?;
1338 let mut out = String::new();
1339 for entry in &diff.created {
1340 let _ = writeln!(out, "created {entry}");
1341 }
1342 for entry in &diff.updated {
1343 let _ = writeln!(out, "updated {entry}");
1344 }
1345 for entry in &diff.unchanged {
1346 let _ = writeln!(out, "unchanged {entry}");
1347 }
1348 if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
1349 let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
1350 }
1351 Ok(out)
1352}
1353
1354fn parse_backup(args: &[&str]) -> Result<Command, String> {
1355 let mut db_dir = None;
1356 let mut dest = None;
1357 for a in args {
1358 if a.starts_with('-') {
1359 return Err(format!("unexpected flag: {a}"));
1360 }
1361 if db_dir.is_none() {
1362 db_dir = Some(PathBuf::from(*a));
1363 } else if dest.is_none() {
1364 dest = Some(PathBuf::from(*a));
1365 } else {
1366 return Err(format!("unexpected extra argument: {a}"));
1367 }
1368 }
1369 let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
1370 let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
1371 Ok(Command::Backup { db_dir, dest })
1372}
1373
1374fn parse_export(args: &[&str]) -> Result<Command, String> {
1375 let mut db_dir = None;
1376 let mut dest = None;
1377 let mut format = ExportFormat::Jsonl;
1378 let mut i = 0;
1379 while i < args.len() {
1380 let a = args[i];
1381 if a == "--format" {
1382 let val = args
1383 .get(i + 1)
1384 .copied()
1385 .ok_or_else(|| "missing value for --format".to_string())?;
1386 format = ExportFormat::parse(val).ok_or_else(|| {
1387 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1388 })?;
1389 i += 2;
1390 } else if let Some(val) = a.strip_prefix("--format=") {
1391 format = ExportFormat::parse(val).ok_or_else(|| {
1392 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1393 })?;
1394 i += 1;
1395 } else if a.starts_with('-') {
1396 return Err(format!("unexpected flag: {a}"));
1397 } else if db_dir.is_none() {
1398 db_dir = Some(PathBuf::from(a));
1399 i += 1;
1400 } else if dest.is_none() {
1401 dest = Some(PathBuf::from(a));
1402 i += 1;
1403 } else {
1404 return Err(format!("unexpected extra argument: {a}"));
1405 }
1406 }
1407 let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
1408 let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
1409 Ok(Command::Export {
1410 db_dir,
1411 dest,
1412 format,
1413 })
1414}
1415
1416pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
1418 let db = GraphDb::open(db_dir)?;
1419 Ok(db.backup_to(dest)?)
1420}
1421
1422pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
1424 let mut out = String::new();
1425 writeln!(out, "backup to: {}", dest.display()).unwrap();
1426 writeln!(out, " files: {}", report.files.join(", ")).unwrap();
1427 writeln!(out, " bytes: {}", report.bytes).unwrap();
1428 writeln!(out, " verified: {}", report.verified).unwrap();
1429 out
1430}
1431
1432pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
1434 let db = GraphDb::open(db_dir)?;
1435 let nodes = db.all_nodes_for_export();
1436 let edges = db.all_edges_for_export();
1437 let mut rules = db.rules();
1438 rules.sort_by(|a, b| a.name.cmp(&b.name));
1439 let node_count = nodes.len();
1440 let edge_count = edges.len();
1441 let rule_count = rules.len();
1442 match format {
1443 ExportFormat::Jsonl => {
1444 export::write_jsonl(&nodes, &edges, &rules, dest)?;
1445 Ok(format!(
1446 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1447 dest.display(),
1448 format.name(),
1449 node_count,
1450 edge_count,
1451 rule_count
1452 ))
1453 }
1454 ExportFormat::Parquet => {
1455 export::write_parquet(&nodes, &edges, &rules, dest)?;
1456 Ok(format!(
1457 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1458 dest.display(),
1459 format.name(),
1460 node_count,
1461 edge_count,
1462 rule_count
1463 ))
1464 }
1465 ExportFormat::Graphml => {
1467 let file_path = export::write_graphml(&nodes, &edges, dest)?;
1468 Ok(format!(
1469 "exported to {} (format={}): {} nodes, {} edges\n",
1470 file_path.display(),
1471 format.name(),
1472 node_count,
1473 edge_count,
1474 ))
1475 }
1476 }
1477}
1478
1479fn format_result_set(rs: &ResultSet) -> String {
1480 let mut out = String::new();
1481 let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
1482 for i in 0..rs.len() {
1483 let cells: Vec<String> = rs
1484 .columns()
1485 .iter()
1486 .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
1487 .collect();
1488 let _ = writeln!(out, " {}", cells.join(" "));
1489 }
1490 out
1491}
1492
1493fn parse_algo(args: &[&str]) -> Result<Command, String> {
1494 if args.is_empty() {
1495 return Err(
1496 "algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
1497 );
1498 }
1499 let subcmd = match args[0] {
1500 "pagerank" => AlgoSubcmd::Pagerank,
1501 "wcc" => AlgoSubcmd::Wcc,
1502 "degree" => AlgoSubcmd::Degree,
1503 "communities" => AlgoSubcmd::Communities,
1504 other => {
1505 return Err(format!(
1506 "unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
1507 ))
1508 }
1509 };
1510 let rest = &args[1..];
1511 let mut db_dir = None;
1512 let mut top: usize = 20;
1513 let mut dir = AlgoDir::Both;
1514 let mut edge_types: Vec<String> = Vec::new();
1515 let mut weight_prop: Option<String> = None;
1516 let mut min_weight: Option<f64> = None;
1517 let mut i = 0;
1518 while i < rest.len() {
1519 let a = rest[i];
1520 if a == "--top" {
1521 let val = rest
1522 .get(i + 1)
1523 .copied()
1524 .ok_or_else(|| "missing value for --top".to_string())?;
1525 top = val
1526 .parse()
1527 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1528 i += 2;
1529 } else if let Some(val) = a.strip_prefix("--top=") {
1530 top = val
1531 .parse()
1532 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1533 i += 1;
1534 } else if a == "--dir" {
1535 let val = rest
1536 .get(i + 1)
1537 .copied()
1538 .ok_or_else(|| "missing value for --dir".to_string())?;
1539 dir = parse_algo_dir(val)?;
1540 i += 2;
1541 } else if let Some(val) = a.strip_prefix("--dir=") {
1542 dir = parse_algo_dir(val)?;
1543 i += 1;
1544 } else if a == "--edge-type" {
1545 let val = rest
1546 .get(i + 1)
1547 .copied()
1548 .ok_or_else(|| "missing value for --edge-type".to_string())?;
1549 edge_types.push(val.to_string());
1550 i += 2;
1551 } else if let Some(val) = a.strip_prefix("--edge-type=") {
1552 edge_types.push(val.to_string());
1553 i += 1;
1554 } else if a == "--weight-prop" {
1555 let val = rest
1556 .get(i + 1)
1557 .copied()
1558 .ok_or_else(|| "missing value for --weight-prop".to_string())?;
1559 weight_prop = Some(val.to_string());
1560 i += 2;
1561 } else if let Some(val) = a.strip_prefix("--weight-prop=") {
1562 weight_prop = Some(val.to_string());
1563 i += 1;
1564 } else if a == "--min-weight" {
1565 let val = rest
1566 .get(i + 1)
1567 .copied()
1568 .ok_or_else(|| "missing value for --min-weight".to_string())?;
1569 min_weight = Some(
1570 val.parse()
1571 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1572 );
1573 i += 2;
1574 } else if let Some(val) = a.strip_prefix("--min-weight=") {
1575 min_weight = Some(
1576 val.parse()
1577 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1578 );
1579 i += 1;
1580 } else if a.starts_with('-') {
1581 return Err(format!("unexpected flag: {a}"));
1582 } else if db_dir.is_none() {
1583 db_dir = Some(PathBuf::from(a));
1584 i += 1;
1585 } else {
1586 return Err(format!("unexpected extra argument: {a}"));
1587 }
1588 }
1589 let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
1590 Ok(Command::Algo {
1591 db_dir,
1592 subcmd,
1593 top,
1594 dir,
1595 edge_types,
1596 weight_prop,
1597 min_weight,
1598 })
1599}
1600
1601fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
1603 match val.to_ascii_lowercase().as_str() {
1604 "out" => Ok(AlgoDir::Out),
1605 "in" => Ok(AlgoDir::In),
1606 "both" => Ok(AlgoDir::Both),
1607 other => Err(format!("--dir must be one of out | in | both, got {other}")),
1608 }
1609}
1610
1611pub fn run_map(db_dir: &Path, json: bool) -> Result<String, CliError> {
1617 let db = open_for_reading(db_dir)?;
1618 let map = repograph::repo_map(&db, &repograph::MapOptions::default());
1619 if json {
1620 let mut out = serde_json::to_string_pretty(&map)
1621 .map_err(|e| CliError(format!("serialise map: {e}")))?;
1622 out.push('\n');
1623 return Ok(out);
1624 }
1625 Ok(repograph::render_map(&map))
1626}
1627
1628fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
1632 Ok(GraphDb::open_with_options(
1633 db_dir,
1634 core_api::OpenOptions {
1635 auto_migrate: false,
1636 repair_wal: false,
1637 read_only: true,
1638 },
1639 )?)
1640}
1641
1642pub fn run_context(db_dir: &Path, target: &str) -> Result<String, CliError> {
1647 let db = open_for_reading(db_dir)?;
1648 Ok(repograph::render_context(&repograph::context(
1649 &db, None, target,
1650 )))
1651}
1652
1653pub fn run_impact(db_dir: &Path, files: &[String]) -> Result<String, CliError> {
1658 let db = open_for_reading(db_dir)?;
1659 let modified: BTreeSet<String> = files.iter().cloned().collect();
1660 let report = repograph::impact(&db, files, &modified, &repograph::ImpactOptions::default());
1661 Ok(repograph::render_impact(&report))
1662}
1663
1664pub fn run_owners(db_dir: &Path, path: &str) -> Result<String, CliError> {
1666 let db = open_for_reading(db_dir)?;
1667 match repograph::owners(&db, path, None) {
1668 Some(report) => Ok(repograph::render_owners(&report)),
1669 None => Err(CliError(format!("no file in the store at {path}"))),
1670 }
1671}
1672
1673pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
1675 let db = open_for_reading(db_dir)?;
1676 Ok(repograph::render_why(&repograph::why(&db, a, b)))
1677}
1678
1679#[allow(clippy::too_many_arguments)]
1685pub fn run_algo(
1686 db_dir: &Path,
1687 subcmd: &AlgoSubcmd,
1688 top: usize,
1689 dir: AlgoDir,
1690 edge_types: Vec<String>,
1691 weight_prop: Option<String>,
1692 min_weight: Option<f64>,
1693) -> Result<String, CliError> {
1694 let db = GraphDb::open(db_dir)?;
1695 match subcmd {
1696 AlgoSubcmd::Pagerank => {
1697 let config = PageRankConfig {
1698 direction: dir,
1699 ..PageRankConfig::default()
1700 };
1701 let report = db.pagerank(&config);
1702 Ok(format_pagerank(&report, top))
1703 }
1704 AlgoSubcmd::Wcc => {
1705 let config = WccConfig::default();
1706 let report = db.connected_components(&config);
1707 Ok(format_wcc(&report, top))
1708 }
1709 AlgoSubcmd::Degree => {
1710 let config = DegreeConfig {
1711 direction: dir,
1712 ..DegreeConfig::default()
1713 };
1714 let report = db.degree_centrality(&config);
1715 Ok(format_degree(&report, top))
1716 }
1717 AlgoSubcmd::Communities => {
1718 let config = LouvainConfig {
1719 edge_types,
1720 weight_prop,
1721 min_weight,
1722 ..LouvainConfig::default()
1723 };
1724 let report = db.communities(&config);
1725 Ok(format_communities(&report, top))
1726 }
1727 }
1728}
1729
1730fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
1731 let mut buf = String::new();
1732 let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
1733 let rows = if top == 0 {
1734 report.scores.as_slice()
1735 } else {
1736 &report.scores[..top.min(report.scores.len())]
1737 };
1738 for (i, (key, score)) in rows.iter().enumerate() {
1739 let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
1740 }
1741 buf
1742}
1743
1744fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
1745 let mut buf = String::new();
1746 let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
1747 let rows = if top == 0 {
1748 report.components.as_slice()
1749 } else {
1750 &report.components[..top.min(report.components.len())]
1751 };
1752 for (key, comp_id) in rows {
1753 let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
1754 }
1755 buf
1756}
1757
1758fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
1759 let mut buf = String::new();
1760 let _ = writeln!(
1761 buf,
1762 "== degree centrality (truncated={}) ==",
1763 report.truncated
1764 );
1765 let rows = if top == 0 {
1766 report.scores.as_slice()
1767 } else {
1768 &report.scores[..top.min(report.scores.len())]
1769 };
1770 for (i, (key, deg)) in rows.iter().enumerate() {
1771 let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
1772 }
1773 buf
1774}
1775
1776fn format_communities(report: &core_api::CommunityReport, top: usize) -> String {
1779 let mut buf = String::new();
1780 let trunc = if report.truncated { " (truncated)" } else { "" };
1781 let _ = writeln!(
1782 buf,
1783 "== communities (modularity={:.2}){trunc} ==",
1784 report.modularity
1785 );
1786 let rows = if top == 0 {
1787 report.communities.as_slice()
1788 } else {
1789 &report.communities[..top.min(report.communities.len())]
1790 };
1791 for c in rows {
1792 let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
1793 let _ = writeln!(
1794 buf,
1795 " {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
1796 c.id,
1797 c.members.len(),
1798 c.cohesion,
1799 preview.join(", ")
1800 );
1801 }
1802 buf
1803}
1804
1805fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
1810 let mut db_dir = None;
1811 let mut auto = false;
1812 for a in args {
1813 if *a == "--auto" {
1814 auto = true;
1815 } else if a.starts_with('-') {
1816 return Err(format!("unexpected flag: {a}"));
1817 } else if db_dir.is_some() {
1818 return Err(format!("unexpected extra argument: {a}"));
1819 } else {
1820 db_dir = Some(PathBuf::from(*a));
1821 }
1822 }
1823 match (&db_dir, auto) {
1824 (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
1825 (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
1826 _ => Ok((db_dir, auto)),
1827 }
1828}
1829
1830fn parse_mcp(args: &[&str]) -> Result<Command, String> {
1834 let all_tools = args.contains(&"--all-tools");
1835 let rest: Vec<&str> = args
1836 .iter()
1837 .copied()
1838 .filter(|a| *a != "--all-tools")
1839 .collect();
1840 parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
1841 db_dir,
1842 auto,
1843 all_tools,
1844 })
1845}
1846
1847fn parse_sync(args: &[&str]) -> Result<Command, String> {
1852 let json = args.contains(&"--json");
1853 let rest: Vec<&str> = args.iter().copied().filter(|a| *a != "--json").collect();
1854 parse_dir_or_auto("sync", &rest).map(|(db_dir, auto)| Command::Sync { db_dir, auto, json })
1855}
1856
1857fn parse_touch(args: &[&str]) -> Result<Command, String> {
1860 let mut db_dir = None;
1861 let mut auto = false;
1862 let mut files = Vec::new();
1863 for a in args {
1864 if *a == "--auto" {
1865 auto = true;
1866 } else if a.starts_with('-') {
1867 return Err(format!("unexpected flag: {a}"));
1868 } else if db_dir.is_none() && !auto {
1869 db_dir = Some(PathBuf::from(*a));
1870 } else {
1871 files.push(PathBuf::from(*a));
1872 }
1873 }
1874 if db_dir.is_none() && !auto {
1875 return Err("touch requires <db-dir> or --auto".into());
1876 }
1877 if db_dir.is_some() && auto {
1878 return Err("touch: --auto takes no <db-dir>".into());
1879 }
1880 Ok(Command::Touch {
1881 db_dir,
1882 auto,
1883 files,
1884 })
1885}
1886
1887fn parse_positional(
1894 cmd: &str,
1895 args: &[&str],
1896 min: usize,
1897 max: usize,
1898) -> Result<(PathBuf, Vec<String>), String> {
1899 let mut rest: Vec<String> = Vec::new();
1900 let mut db_dir: Option<PathBuf> = None;
1901 for a in args {
1902 if a.starts_with('-') {
1903 return Err(format!("unexpected flag: {a}"));
1904 }
1905 match db_dir {
1906 None => db_dir = Some(PathBuf::from(*a)),
1907 Some(_) => rest.push((*a).to_string()),
1908 }
1909 }
1910 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
1911 if rest.len() < min {
1912 return Err(format!(
1913 "{cmd} requires <db-dir> and {min} more argument{}",
1914 if min == 1 { "" } else { "s" }
1915 ));
1916 }
1917 if rest.len() > max {
1918 return Err(format!("unexpected extra argument: {}", rest[max]));
1919 }
1920 Ok((db_dir, rest))
1921}
1922
1923fn parse_dir_with_json(cmd: &str, args: &[&str]) -> Result<(PathBuf, bool), String> {
1925 let mut db_dir = None;
1926 let mut json = false;
1927 for a in args {
1928 if *a == "--json" {
1929 json = true;
1930 } else if a.starts_with('-') {
1931 return Err(format!("unexpected flag: {a}"));
1932 } else if db_dir.is_some() {
1933 return Err(format!("unexpected extra argument: {a}"));
1934 } else {
1935 db_dir = Some(PathBuf::from(*a));
1936 }
1937 }
1938 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
1939 Ok((db_dir, json))
1940}
1941
1942fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
1943 let mut db_dir = None;
1944 for a in args {
1945 if a.starts_with('-') {
1946 return Err(format!("unexpected flag: {a}"));
1947 }
1948 if db_dir.is_some() {
1949 return Err(format!("unexpected extra argument: {a}"));
1950 }
1951 db_dir = Some(PathBuf::from(*a));
1952 }
1953 db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
1954}
1955
1956pub fn format_stats(stats: &Stats) -> String {
1958 let mut out = String::new();
1959 let _ = writeln!(
1960 out,
1961 "nodes: {} live, {} tombstoned",
1962 stats.nodes_live, stats.nodes_tombstoned
1963 );
1964 let _ = writeln!(out, "edges: {}", stats.edges);
1965 let _ = writeln!(out, "rules: {}", stats.rules.len());
1966 for r in &stats.rules {
1967 let _ = writeln!(
1968 out,
1969 " {:<28} edges={} tripped={}",
1970 r.name, r.edges, r.tripped
1971 );
1972 }
1973 out
1974}
1975
1976pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
1978 let db = SharedDb::open(dir)?;
1979 let stats = db.read().stats();
1980 Ok(stats)
1981}
1982
1983pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
1989 refuse_non_empty(dir)?;
1990
1991 let db = SharedDb::open(dir)?;
1992 let opts = IngestOptions::default();
1993 let mut auto_fk_rules = Vec::new();
1994
1995 {
1996 let mut w = db.write();
1997 for (label, json) in [
1998 ("Org", org_json()),
1999 ("Project", project_json()),
2000 ("Person", person_json()),
2001 ] {
2002 let report = w.ingest_json(label, &json, &opts)?;
2003 if !report.row_errors.is_empty() {
2004 return Err(CliError(format!(
2005 "demo ingest of {label} had row errors: {:?}",
2006 report.row_errors
2007 )));
2008 }
2009 auto_fk_rules.extend(report.rules_created);
2010 }
2011 let skill_fit = Predicate::Overlap {
2012 field: "skills".into(),
2013 min: 0.5,
2014 };
2015 let skill_fit_k = Some(default_max_edges(&skill_fit));
2016 w.create_rule(RuleDef {
2017 name: "skill_fit".into(),
2018 src_label: "Person".into(),
2019 dst_label: "Project".into(),
2020 predicate: skill_fit,
2021 edge_type: "FIT".into(),
2022 weight_prop: Some("score".into()),
2023 max_edges: skill_fit_k,
2024 approximate: false,
2025 via_label: None,
2026 via_edge: None,
2027 via_dir: None,
2028 })?;
2029 let founded_within = Predicate::NumericWithin {
2030 field: "founded_year".into(),
2031 tolerance: 2.0,
2032 };
2033 let founded_within_k = Some(default_max_edges(&founded_within));
2034 w.create_rule(RuleDef {
2035 name: "founded_within".into(),
2036 src_label: "Org".into(),
2037 dst_label: "Org".into(),
2038 predicate: founded_within,
2039 edge_type: "FOUNDED_WITHIN".into(),
2040 weight_prop: Some("score".into()),
2041 max_edges: founded_within_k,
2042 approximate: false,
2043 via_label: None,
2044 via_edge: None,
2045 via_dir: None,
2046 })?;
2047 let nearby_office = Predicate::GeoRadius {
2048 field: "office".into(),
2049 km: 50.0,
2050 };
2051 let nearby_office_k = Some(default_max_edges(&nearby_office));
2052 w.create_rule(RuleDef {
2053 name: "nearby_office".into(),
2054 src_label: "Org".into(),
2055 dst_label: "Org".into(),
2056 predicate: nearby_office,
2057 edge_type: "NEARBY_OFFICE".into(),
2058 weight_prop: Some("score".into()),
2059 max_edges: nearby_office_k,
2060 approximate: false,
2061 via_label: None,
2062 via_edge: None,
2063 via_dir: None,
2064 })?;
2065 let similar_interests = Predicate::VectorSimilar {
2066 field: "embedding".into(),
2067 min: 0.8,
2068 };
2069 let similar_interests_k = Some(default_max_edges(&similar_interests));
2070 w.create_rule(RuleDef {
2071 name: "similar_interests".into(),
2072 src_label: "Person".into(),
2073 dst_label: "Person".into(),
2074 predicate: similar_interests,
2075 edge_type: "SIMILAR".into(),
2076 weight_prop: Some("score".into()),
2077 max_edges: similar_interests_k,
2078 approximate: false,
2079 via_label: None,
2080 via_edge: None,
2081 via_dir: None,
2082 })?;
2083 for (label, field) in [("Org", "name"), ("Project", "name"), ("Person", "name")] {
2085 w.enable_fulltext(label, field)?;
2086 }
2087 }
2088
2089 let r = db.read();
2090 let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
2091 let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
2092 let stats = r.stats();
2093 let suggestion = r.suggest_rules().into_iter().next();
2095
2096 Ok(DemoOutcome {
2097 auto_fk_rules,
2098 sample_query: SAMPLE_QUERY.to_string(),
2099 sample_result,
2100 explanations,
2101 stats,
2102 suggestion,
2103 })
2104}
2105
2106fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
2107 if dir.is_file() {
2108 return Err(CliError(format!(
2109 "demo refuses a non-empty directory: {} is a file",
2110 dir.display()
2111 )));
2112 }
2113 if !dir.exists() {
2114 return Ok(true);
2115 }
2116 Ok(std::fs::read_dir(dir)?.next().is_none())
2117}
2118
2119fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
2120 if dir_is_empty_or_absent(dir)? {
2121 Ok(())
2122 } else {
2123 Err(CliError(format!(
2124 "demo refuses a non-empty directory: {} \
2125 (directory must be empty — including hidden files)",
2126 dir.display()
2127 )))
2128 }
2129}
2130
2131pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
2133 if dir_is_empty_or_absent(dir)? {
2134 Ok(Some(run_demo(dir)?))
2135 } else {
2136 Ok(None)
2137 }
2138}
2139
2140fn json_array(rows: impl IntoIterator<Item = String>) -> String {
2141 let mut out = String::from("[");
2142 let mut first = true;
2143 for row in rows {
2144 if !first {
2145 out.push(',');
2146 }
2147 first = false;
2148 out.push_str(&row);
2149 }
2150 out.push(']');
2151 out
2152}
2153
2154fn wrap_proj(i: usize) -> usize {
2156 (i - 1) % N_PROJECTS + 1
2157}
2158
2159fn skill_window_json(start: usize, len: usize) -> String {
2161 let parts: Vec<String> = (0..len)
2162 .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
2163 .collect();
2164 format!("[{}]", parts.join(","))
2165}
2166
2167fn org_office(i: usize) -> (f64, f64) {
2171 match i {
2172 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"),
2183 }
2184}
2185
2186fn person_embedding_json(i: usize) -> String {
2190 let mut v = [0.0_f64; 8];
2191 match i {
2192 9 | 19 | 29 => {
2193 v[0] = 0.8;
2194 v[1] = 0.6;
2195 }
2196 10 | 20 | 30 => {
2197 v[0] = 0.6;
2198 v[1] = 0.8;
2199 }
2200 _ => {
2201 let axis = (i - 1) % 10;
2202 debug_assert!(axis < 8);
2203 v[axis] = 1.0;
2204 }
2205 }
2206 let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
2207 format!("[{}]", parts.join(","))
2208}
2209
2210fn org_json() -> String {
2211 json_array((1..=N_ORGS).map(|i| {
2212 let year = 2010 + (i as i64 - 1);
2213 let (lat, lon) = org_office(i);
2214 format!(
2215 r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
2216 skill_window_json(i, 3)
2217 )
2218 }))
2219}
2220
2221fn project_json() -> String {
2222 json_array((1..=N_PROJECTS).map(|i| {
2223 let org = (i - 1) % N_ORGS + 1;
2224 format!(
2225 r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
2226 skill_window_json(i, 3)
2227 )
2228 }))
2229}
2230
2231fn person_json() -> String {
2232 json_array((1..=N_PEOPLE).map(|i| {
2233 let org = (i - 1) % N_ORGS + 1;
2234 let proj = (i - 1) % N_PROJECTS + 1;
2235 format!(
2236 r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
2237 person_embedding_json(i),
2238 skill_window_json(proj, 3)
2239 )
2240 }))
2241}
2242
2243pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
2245 let mut buf = String::new();
2246 let _ = writeln!(buf, "== demo ==");
2247 let _ = writeln!(
2248 buf,
2249 "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
2250 );
2251 let _ = writeln!(
2252 buf,
2253 "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
2254 );
2255 let _ = writeln!(
2256 buf,
2257 "numeric rule: founded_within (Org.founded_year, tolerance 2)"
2258 );
2259 let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
2260 let _ = writeln!(
2261 buf,
2262 "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
2263 );
2264 let _ = writeln!(buf);
2265 let _ = writeln!(buf, "== auto-FK rules ==");
2266 let mut names = out.auto_fk_rules.clone();
2267 names.sort();
2268 for name in names {
2269 let _ = writeln!(buf, " {name}");
2270 }
2271 let _ = writeln!(buf);
2272 let _ = writeln!(buf, "== query ==");
2273 let _ = writeln!(buf, "{}", out.sample_query);
2274 let _ = writeln!(buf);
2275 let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
2276 for i in 0..out.sample_result.len() {
2277 let cells: Vec<String> = out
2278 .sample_result
2279 .columns()
2280 .iter()
2281 .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
2282 .collect();
2283 let _ = writeln!(buf, " {}", cells.join(" "));
2284 }
2285 let _ = writeln!(buf);
2286 let _ = writeln!(
2287 buf,
2288 "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
2289 );
2290 for e in &out.explanations {
2291 let weight = e
2292 .weight
2293 .map(|w| fmt_value(&Value::Float(w)))
2294 .unwrap_or_else(|| "none".into());
2295 let _ = writeln!(
2296 buf,
2297 " rule={} type={} {}→{} weight={}",
2298 e.rule, e.edge_type, e.src_key, e.dst_key, weight
2299 );
2300 }
2301 let _ = writeln!(buf);
2302 let _ = writeln!(buf, "== serve ==");
2303 let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
2304
2305 if let Some(s) = &out.suggestion {
2307 let _ = writeln!(buf);
2308 let _ = writeln!(buf, "== suggested rule (teaser) ==");
2309 let _ = writeln!(buf, " {}", s.def.name);
2310 let _ = writeln!(
2311 buf,
2312 " {} → {} via {:?}",
2313 s.def.src_label, s.def.dst_label, s.def.predicate
2314 );
2315 let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
2316 let _ = writeln!(buf, " {}", s.rationale);
2317 let _ = writeln!(
2318 buf,
2319 " (run `mushroomdb suggest {}` for full analysis)",
2320 dir.display()
2321 );
2322 }
2323
2324 buf
2325}
2326
2327pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
2329 let db = GraphDb::open(dir)?;
2330 Ok(db.suggest_rules())
2331}
2332
2333pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
2335 let mut buf = String::new();
2336 if suggestions.is_empty() {
2337 let _ = writeln!(
2338 buf,
2339 "no rule suggestions (database may be empty or rules already cover all patterns)"
2340 );
2341 return buf;
2342 }
2343 let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
2344 for (i, s) in suggestions.iter().enumerate() {
2345 let _ = writeln!(buf);
2346 let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
2347 let _ = writeln!(
2348 buf,
2349 " {} → {} via {:?}",
2350 s.def.src_label, s.def.dst_label, s.def.predicate
2351 );
2352 let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
2353 let _ = writeln!(buf, " rationale : {}", s.rationale);
2354 if !s.examples.is_empty() {
2355 let _ = writeln!(buf, " examples :");
2356 for (src, dst, score) in &s.examples {
2357 let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
2358 }
2359 }
2360 let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
2361 let _ = writeln!(
2362 buf,
2363 " to apply : POST /rules or db.create_rule(suggestion.def)"
2364 );
2365 }
2366 buf
2367}
2368
2369fn fmt_value(v: &Value) -> String {
2370 match v {
2371 Value::Int(i) => i.to_string(),
2372 Value::Float(f) => {
2373 let s = format!("{f}");
2374 if s.contains('.') || s.contains('e') || s.contains('E') {
2375 s
2376 } else {
2377 format!("{s}.0")
2378 }
2379 }
2380 Value::Str(s) => s.clone(),
2381 Value::Bool(b) => b.to_string(),
2382 Value::List(xs) => {
2383 let inner: Vec<String> = xs.iter().map(fmt_value).collect();
2384 format!("[{}]", inner.join(", "))
2385 }
2386 Value::Map(m) => {
2387 let inner: Vec<String> = m
2388 .iter()
2389 .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
2390 .collect();
2391 format!("{{{}}}", inner.join(", "))
2392 }
2393 }
2394}
2395
2396fn fmt_cell(cell: Option<&Value>) -> String {
2397 match cell {
2398 None => "null".into(),
2399 Some(v) => fmt_value(v),
2400 }
2401}
2402
2403#[cfg(test)]
2404mod tests {
2405 use super::*;
2406 use std::collections::BTreeSet;
2407 use std::net::SocketAddr;
2408 use std::path::PathBuf;
2409
2410 fn tmp(name: &str) -> PathBuf {
2411 let nanos = std::time::SystemTime::now()
2412 .duration_since(std::time::UNIX_EPOCH)
2413 .expect("clock")
2414 .as_nanos();
2415 let d = std::env::temp_dir().join(format!(
2416 "graphdb-cli-{}-{}-{}",
2417 name,
2418 std::process::id(),
2419 nanos
2420 ));
2421 let _ = std::fs::remove_dir_all(&d);
2422 d
2423 }
2424
2425 fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
2426 let g = db.read();
2427 let mut out = BTreeSet::new();
2428 for i in 1..=N_ORGS {
2429 let src = format!("org-{i:02}");
2430 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2431 for dst in nbrs {
2432 out.insert((src.clone(), dst));
2433 }
2434 }
2435 }
2436 for i in 1..=N_PEOPLE {
2437 let src = format!("person-{i:02}");
2438 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2439 for dst in nbrs {
2440 out.insert((src.clone(), dst));
2441 }
2442 }
2443 }
2444 out
2445 }
2446
2447 fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
2448 let hits: Vec<_> = db
2449 .read()
2450 .explain(a, b)
2451 .expect("explain")
2452 .into_iter()
2453 .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
2454 .collect();
2455 assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
2456 let got = hits[0].weight.expect("weighted");
2457 assert!(
2458 (got - want).abs() < 1e-12,
2459 "{rule} {a}→{b}: got {got} want {want}"
2460 );
2461 }
2462
2463 fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
2464 const R: f64 = 6371.0088;
2465 let phi1 = lat1.to_radians();
2466 let phi2 = lat2.to_radians();
2467 let dphi = (lat2 - lat1).to_radians();
2468 let dlam = (lon2 - lon1).to_radians();
2469 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
2470 .clamp(0.0, 1.0);
2471 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
2472 R * c
2473 }
2474
2475 fn default_bind() -> SocketAddr {
2476 SocketAddr::from(([127, 0, 0, 1], 8080))
2477 }
2478
2479 #[test]
2480 fn parse_args_table() {
2481 struct Case {
2482 args: &'static [&'static str],
2483 check: fn(Result<Command, String>),
2484 }
2485
2486 let cases = [
2487 Case {
2488 args: &[],
2489 check: |r| match r {
2490 Ok(Command::Help) => {}
2491 other => panic!("no-args → Help, got {other:?}"),
2492 },
2493 },
2494 Case {
2495 args: &["--help"],
2496 check: |r| match r {
2497 Ok(Command::Help) => {}
2498 other => panic!("--help → Help, got {other:?}"),
2499 },
2500 },
2501 Case {
2502 args: &["-h"],
2503 check: |r| match r {
2504 Ok(Command::Help) => {}
2505 other => panic!("-h → Help, got {other:?}"),
2506 },
2507 },
2508 Case {
2509 args: &["serve", "/tmp/demo-db"],
2510 check: |r| match r {
2511 Ok(Command::Serve {
2512 db_dir,
2513 addr,
2514 ui,
2515 demo_if_empty,
2516 token,
2517 role_tokens,
2518 snapshot_every,
2519 tls_cert,
2520 tls_key,
2521 }) => {
2522 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2523 assert_eq!(addr, default_bind());
2524 assert_eq!(ui, super::ServeUi::Embedded);
2525 assert!(!demo_if_empty);
2526 assert_eq!(token, None);
2527 assert!(role_tokens.is_empty());
2528 assert_eq!(snapshot_every, None);
2529 assert_eq!(tls_cert, None);
2530 assert_eq!(tls_key, None);
2531 }
2532 other => panic!("serve <dir> → Serve default addr, got {other:?}"),
2533 },
2534 },
2535 Case {
2536 args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
2537 check: |r| match r {
2538 Ok(Command::Serve {
2539 db_dir,
2540 addr,
2541 ui,
2542 demo_if_empty,
2543 token,
2544 role_tokens,
2545 snapshot_every,
2546 tls_cert,
2547 tls_key,
2548 }) => {
2549 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2550 assert_eq!(
2551 addr,
2552 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
2553 );
2554 assert_eq!(ui, super::ServeUi::Embedded);
2555 assert!(!demo_if_empty);
2556 assert_eq!(token, None);
2557 assert!(role_tokens.is_empty());
2558 assert_eq!(snapshot_every, None);
2559 assert_eq!(tls_cert, None);
2560 assert_eq!(tls_key, None);
2561 }
2562 other => panic!("serve --addr after dir, got {other:?}"),
2563 },
2564 },
2565 Case {
2566 args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
2567 check: |r| match r {
2568 Ok(Command::Serve {
2569 db_dir,
2570 addr,
2571 ui,
2572 demo_if_empty,
2573 token,
2574 role_tokens,
2575 snapshot_every,
2576 tls_cert,
2577 tls_key,
2578 }) => {
2579 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2580 assert_eq!(
2581 addr,
2582 "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
2583 );
2584 assert_eq!(ui, super::ServeUi::Embedded);
2585 assert!(!demo_if_empty);
2586 assert_eq!(token, None);
2587 let _ = role_tokens; assert_eq!(snapshot_every, None);
2589 assert_eq!(tls_cert, None);
2590 assert_eq!(tls_key, None);
2591 }
2592 other => panic!("serve --addr=VALUE, got {other:?}"),
2593 },
2594 },
2595 Case {
2596 args: &["mcp", "/tmp/demo-db"],
2597 check: |r| match r {
2598 Ok(Command::Mcp {
2599 db_dir,
2600 auto,
2601 all_tools,
2602 }) => {
2603 assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
2604 assert!(!auto);
2605 assert!(!all_tools, "the short list is the default");
2606 }
2607 other => panic!("mcp <dir>, got {other:?}"),
2608 },
2609 },
2610 Case {
2611 args: &["stats", "/tmp/demo-db"],
2612 check: |r| match r {
2613 Ok(Command::Stats { db_dir }) => {
2614 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2615 }
2616 other => panic!("stats <dir>, got {other:?}"),
2617 },
2618 },
2619 Case {
2620 args: &["demo", "/tmp/demo-db"],
2621 check: |r| match r {
2622 Ok(Command::Demo { db_dir }) => {
2623 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2624 }
2625 other => panic!("demo <dir>, got {other:?}"),
2626 },
2627 },
2628 Case {
2629 args: &["serve"],
2630 check: |r| {
2631 let e = r.expect_err("serve without dir");
2632 assert!(
2633 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2634 "missing-dir error should mention dir, got {e}"
2635 );
2636 },
2637 },
2638 Case {
2639 args: &["mcp"],
2640 check: |r| {
2641 let e = r.expect_err("mcp without dir");
2642 assert!(
2643 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2644 "missing-dir error should mention dir, got {e}"
2645 );
2646 },
2647 },
2648 Case {
2649 args: &["stats"],
2650 check: |r| {
2651 let e = r.expect_err("stats without dir");
2652 assert!(
2653 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2654 "missing-dir error should mention dir, got {e}"
2655 );
2656 },
2657 },
2658 Case {
2659 args: &["demo"],
2660 check: |r| {
2661 let e = r.expect_err("demo without dir");
2662 assert!(
2663 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2664 "missing-dir error should mention dir, got {e}"
2665 );
2666 },
2667 },
2668 Case {
2669 args: &["serve", "/tmp/demo-db", "--addr"],
2670 check: |r| {
2671 let e = r.expect_err("--addr missing value");
2672 assert!(
2673 e.to_lowercase().contains("addr"),
2674 "--addr missing value should mention addr, got {e}"
2675 );
2676 },
2677 },
2678 Case {
2679 args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
2680 check: |r| {
2681 let e = r.expect_err("invalid addr");
2682 assert!(
2683 e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
2684 "invalid addr should mention address, got {e}"
2685 );
2686 },
2687 },
2688 Case {
2689 args: &["frobnicate", "/tmp/demo-db"],
2690 check: |r| {
2691 let e = r.expect_err("unknown command");
2692 assert!(
2693 e.to_lowercase().contains("unknown")
2694 || e.to_lowercase().contains("frobnicate"),
2695 "unknown command should name it, got {e}"
2696 );
2697 },
2698 },
2699 Case {
2700 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
2701 check: |r| match r {
2702 Ok(Command::Serve { ui, .. }) => {
2703 assert_eq!(
2704 ui,
2705 super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
2706 );
2707 }
2708 other => panic!("serve --ui <dir>, got {other:?}"),
2709 },
2710 },
2711 Case {
2712 args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
2713 check: |r| match r {
2714 Ok(Command::Serve { ui, .. }) => {
2715 assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
2716 }
2717 other => panic!("serve --ui=VALUE, got {other:?}"),
2718 },
2719 },
2720 Case {
2721 args: &["serve", "/tmp/demo-db", "--ui"],
2722 check: |r| {
2723 let e = r.expect_err("--ui missing value");
2724 assert!(
2725 e.to_lowercase().contains("ui"),
2726 "--ui missing value should mention ui, got {e}"
2727 );
2728 },
2729 },
2730 Case {
2731 args: &["serve", "/tmp/demo-db", "--no-ui"],
2732 check: |r| match r {
2733 Ok(Command::Serve { ui, .. }) => {
2734 assert_eq!(ui, super::ServeUi::None);
2735 }
2736 other => panic!("serve --no-ui, got {other:?}"),
2737 },
2738 },
2739 Case {
2740 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
2741 check: |r| {
2742 let e = r.expect_err("combine --ui and --no-ui");
2743 assert!(
2744 e.contains("--ui") && e.contains("--no-ui"),
2745 "conflict should name both flags, got {e}"
2746 );
2747 },
2748 },
2749 Case {
2750 args: &["serve", "/tmp/demo-db", "extra"],
2751 check: |r| {
2752 let e = r.expect_err("extra positional");
2753 assert!(
2754 e.to_lowercase().contains("unexpected")
2755 || e.to_lowercase().contains("extra"),
2756 "extra arg should be rejected, got {e}"
2757 );
2758 },
2759 },
2760 Case {
2761 args: &[
2762 "serve",
2763 "/data",
2764 "--addr",
2765 "0.0.0.0:8080",
2766 "--demo-if-empty",
2767 ],
2768 check: |r| match r {
2769 Ok(Command::Serve {
2770 db_dir,
2771 addr,
2772 demo_if_empty,
2773 ui,
2774 token,
2775 snapshot_every,
2776 ..
2777 }) => {
2778 assert_eq!(db_dir, PathBuf::from("/data"));
2779 assert_eq!(
2780 addr,
2781 "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
2782 );
2783 assert!(demo_if_empty);
2784 assert_eq!(ui, super::ServeUi::Embedded);
2785 assert_eq!(token, None);
2786 assert_eq!(snapshot_every, None);
2787 }
2788 other => panic!("serve --demo-if-empty docker default, got {other:?}"),
2789 },
2790 },
2791 ];
2792
2793 for case in &cases {
2794 (case.check)(parse_args(case.args));
2795 }
2796 }
2797
2798 #[test]
2799 fn serve_default_addr_is_loopback_8080() {
2800 match parse_args(&["serve", "/tmp/db"]).unwrap() {
2801 Command::Serve { addr, .. } => {
2802 assert_eq!(
2803 addr,
2804 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
2805 );
2806 }
2807 other => panic!("{other:?}"),
2808 }
2809 }
2810
2811 #[test]
2812 fn serve_snapshot_every_parses_seconds() {
2813 match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
2814 Command::Serve { snapshot_every, .. } => {
2815 assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
2816 }
2817 other => panic!("{other:?}"),
2818 }
2819 match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
2820 Command::Serve { snapshot_every, .. } => {
2821 assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
2822 }
2823 other => panic!("{other:?}"),
2824 }
2825 match parse_args(&["serve", "/tmp/db"]).unwrap() {
2826 Command::Serve { snapshot_every, .. } => {
2827 assert_eq!(snapshot_every, None);
2828 }
2829 other => panic!("{other:?}"),
2830 }
2831 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
2832 assert!(
2833 err.contains("snapshot-every"),
2834 "missing value should name the flag, got {err}"
2835 );
2836 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
2837 assert!(
2838 err.contains("snapshot-every"),
2839 "zero should be rejected, got {err}"
2840 );
2841 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
2842 assert!(
2843 err.contains("snapshot-every"),
2844 "invalid value should name the flag, got {err}"
2845 );
2846 }
2847
2848 #[test]
2849 fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
2850 match parse_args(&[
2852 "serve",
2853 "/tmp/db",
2854 "--addr",
2855 "0.0.0.0:8080",
2856 "--token",
2857 "s3cret",
2858 ])
2859 .unwrap()
2860 {
2861 Command::Serve { token, addr, .. } => {
2862 assert_eq!(token.as_deref(), Some("s3cret"));
2863 assert_eq!(addr.ip().to_string(), "0.0.0.0");
2864 }
2865 other => panic!("{other:?}"),
2866 }
2867 }
2868
2869 #[test]
2870 fn parse_snapshot_and_query() {
2871 for (args, want) in [
2873 (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
2874 (
2875 vec!["snapshot", "/tmp/db", "--archive-wal"],
2876 WalDisposition::Archive,
2877 ),
2878 (
2879 vec!["snapshot", "/tmp/db", "--keep-wal"],
2880 WalDisposition::Keep,
2881 ),
2882 (
2883 vec!["snapshot", "/tmp/db", "--truncate"],
2884 WalDisposition::Truncate,
2885 ),
2886 ] {
2887 match parse_args(&args).unwrap() {
2888 Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
2889 other => panic!("{other:?}"),
2890 }
2891 }
2892 match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
2893 Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
2894 other => panic!("{other:?}"),
2895 }
2896 match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
2897 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
2898 other => panic!("{other:?}"),
2899 }
2900 match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
2901 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
2902 other => panic!("{other:?}"),
2903 }
2904 let text = usage();
2905 assert!(
2906 text.contains("query"),
2907 "usage should mention query, got:\n{text}"
2908 );
2909 assert!(
2910 text.contains("snapshot"),
2911 "usage should mention snapshot, got:\n{text}"
2912 );
2913 }
2914
2915 #[test]
2916 fn usage_lists_every_subcommand() {
2917 let text = usage();
2918 for word in [
2919 "serve",
2920 "mcp",
2921 "stats",
2922 "demo",
2923 "query",
2924 "snapshot",
2925 "--keep-wal",
2926 "mushroomdb",
2927 "--ui",
2928 "--no-ui",
2929 "--demo-if-empty",
2930 "--token",
2931 "--snapshot-every",
2932 ] {
2933 assert!(
2934 text.contains(word),
2935 "usage should mention {word}, got:\n{text}"
2936 );
2937 }
2938 }
2939
2940 #[test]
2941 fn validate_ui_dir_requires_index_html() {
2942 let missing = tmp("ui-missing");
2943 let err = super::validate_ui_dir(&missing).expect_err("missing dir");
2944 assert!(
2945 err.contains("does not exist"),
2946 "missing dir error, got {err}"
2947 );
2948
2949 let empty = tmp("ui-empty");
2950 std::fs::create_dir_all(&empty).unwrap();
2951 let err = super::validate_ui_dir(&empty).expect_err("no index");
2952 assert!(
2953 err.contains("index.html"),
2954 "missing index.html error, got {err}"
2955 );
2956
2957 let ok = tmp("ui-ok");
2958 std::fs::create_dir_all(&ok).unwrap();
2959 std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
2960 let got = super::validate_ui_dir(&ok).expect("valid ui dir");
2961 assert_eq!(got, ok);
2962 }
2963
2964 #[test]
2965 fn maybe_run_demo_if_empty_seeds_then_skips() {
2966 let dir = tmp("boot-empty");
2967 let first = super::maybe_run_demo_if_empty(&dir)
2968 .expect("empty dir demos")
2969 .expect("Some(DemoOutcome)");
2970 assert_eq!(first.stats.nodes_live, 60);
2971 let db = SharedDb::open(&dir).expect("reopen");
2972 assert!(db.read().has_node("person-01"));
2973 let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
2974 assert!(
2975 second.is_none(),
2976 "second boot must not re-demo a populated volume"
2977 );
2978
2979 let occupied = tmp("boot-occupied");
2980 std::fs::create_dir_all(&occupied).unwrap();
2981 std::fs::write(occupied.join("keep-me"), b"x").unwrap();
2982 let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
2983 assert!(skipped.is_none());
2984 assert_eq!(
2985 std::fs::read(occupied.join("keep-me")).unwrap(),
2986 b"x",
2987 "existing volume contents must be untouched"
2988 );
2989 }
2990
2991 #[test]
2992 fn demo_builder_is_deterministic_and_refuses_second_run() {
2993 let dir = tmp("demo");
2994 let out = run_demo(&dir).expect("first demo run");
2995
2996 assert_eq!(
2997 out.stats.nodes_live, 60,
2998 "10 orgs + 20 projects + 30 people"
2999 );
3000 assert_eq!(out.stats.nodes_tombstoned, 0);
3001 assert_eq!(out.stats.edges, 334);
3009 assert_eq!(
3010 out.stats.rules.len(),
3011 7,
3012 "3 auto-FK + overlap + numeric + geo + vector"
3013 );
3014 let fit = out
3015 .stats
3016 .rules
3017 .iter()
3018 .find(|r| r.name == "skill_fit")
3019 .expect("skill_fit");
3020 assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
3021 let founded = out
3022 .stats
3023 .rules
3024 .iter()
3025 .find(|r| r.name == "founded_within")
3026 .expect("founded_within");
3027 assert_eq!(founded.edges, 34);
3028 let nearby = out
3029 .stats
3030 .rules
3031 .iter()
3032 .find(|r| r.name == "nearby_office")
3033 .expect("nearby_office");
3034 assert_eq!(nearby.edges, 16);
3035 let similar = out
3036 .stats
3037 .rules
3038 .iter()
3039 .find(|r| r.name == "similar_interests")
3040 .expect("similar_interests");
3041 assert_eq!(similar.edges, 114);
3042
3043 let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
3044 names.sort_unstable();
3045 assert_eq!(
3046 names,
3047 vec![
3048 "auto_fk_person_org_id",
3049 "auto_fk_person_project_id",
3050 "auto_fk_project_org_id",
3051 "founded_within",
3052 "nearby_office",
3053 "similar_interests",
3054 "skill_fit",
3055 ]
3056 );
3057
3058 let db = SharedDb::open(&dir).expect("reopen demo");
3060 assert_eq!(
3061 db.read().fulltext_pairs(),
3062 vec![
3063 ("Org".to_string(), "name".to_string()),
3064 ("Person".to_string(), "name".to_string()),
3065 ("Project".to_string(), "name".to_string()),
3066 ]
3067 );
3068
3069 let mut auto = out.auto_fk_rules.clone();
3070 auto.sort();
3071 assert_eq!(
3072 auto,
3073 vec![
3074 "auto_fk_person_org_id".to_string(),
3075 "auto_fk_person_project_id".to_string(),
3076 "auto_fk_project_org_id".to_string(),
3077 ]
3078 );
3079
3080 assert!(
3081 !out.sample_result.is_empty(),
3082 "sample Cypher query must return rows"
3083 );
3084 assert!(
3085 out.sample_query.contains("ORDER BY score DESC"),
3086 "sample query must rank by score, got {}",
3087 out.sample_query
3088 );
3089 let scores: Vec<f64> = (0..out.sample_result.len())
3090 .map(|i| match out.sample_result.get(i, "score") {
3091 Some(Value::Float(f)) => *f,
3092 other => panic!("score col should be Float, got {other:?}"),
3093 })
3094 .collect();
3095 let distinct: std::collections::BTreeSet<u64> =
3096 scores.iter().map(|s| s.to_bits()).collect();
3097 assert!(
3098 distinct.len() >= 2,
3099 "sample results must be visibly ranked, got {scores:?}"
3100 );
3101 for w in scores.windows(2) {
3102 assert!(
3103 w[0] >= w[1],
3104 "scores must be non-increasing, got {scores:?}"
3105 );
3106 }
3107 assert!(
3108 !out.explanations.is_empty(),
3109 "explain(person-01, proj-01) must find the derived edges"
3110 );
3111
3112 let db = SharedDb::open(&dir).expect("reopen demo");
3113 assert_eq!(
3114 directed_pairs(&db, "FOUNDED_WITHIN"),
3115 [
3116 ("org-01", "org-02"),
3117 ("org-01", "org-03"),
3118 ("org-02", "org-01"),
3119 ("org-02", "org-03"),
3120 ("org-02", "org-04"),
3121 ("org-03", "org-01"),
3122 ("org-03", "org-02"),
3123 ("org-03", "org-04"),
3124 ("org-03", "org-05"),
3125 ("org-04", "org-02"),
3126 ("org-04", "org-03"),
3127 ("org-04", "org-05"),
3128 ("org-04", "org-06"),
3129 ("org-05", "org-03"),
3130 ("org-05", "org-04"),
3131 ("org-05", "org-06"),
3132 ("org-05", "org-07"),
3133 ("org-06", "org-04"),
3134 ("org-06", "org-05"),
3135 ("org-06", "org-07"),
3136 ("org-06", "org-08"),
3137 ("org-07", "org-05"),
3138 ("org-07", "org-06"),
3139 ("org-07", "org-08"),
3140 ("org-07", "org-09"),
3141 ("org-08", "org-06"),
3142 ("org-08", "org-07"),
3143 ("org-08", "org-09"),
3144 ("org-08", "org-10"),
3145 ("org-09", "org-07"),
3146 ("org-09", "org-08"),
3147 ("org-09", "org-10"),
3148 ("org-10", "org-08"),
3149 ("org-10", "org-09"),
3150 ]
3151 .into_iter()
3152 .map(|(a, b)| (a.to_string(), b.to_string()))
3153 .collect::<BTreeSet<_>>()
3154 );
3155 assert_eq!(
3156 directed_pairs(&db, "NEARBY_OFFICE"),
3157 [
3158 ("org-01", "org-07"),
3159 ("org-01", "org-10"),
3160 ("org-02", "org-09"),
3161 ("org-03", "org-08"),
3162 ("org-04", "org-05"),
3163 ("org-04", "org-06"),
3164 ("org-05", "org-04"),
3165 ("org-05", "org-06"),
3166 ("org-06", "org-04"),
3167 ("org-06", "org-05"),
3168 ("org-07", "org-01"),
3169 ("org-07", "org-10"),
3170 ("org-08", "org-03"),
3171 ("org-09", "org-02"),
3172 ("org-10", "org-01"),
3173 ("org-10", "org-07"),
3174 ]
3175 .into_iter()
3176 .map(|(a, b)| (a.to_string(), b.to_string()))
3177 .collect::<BTreeSet<_>>()
3178 );
3179 assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
3180 let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
3181 assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
3182 assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
3183 assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
3184
3185 let err = run_demo(&dir).expect_err("second run into the same dir");
3186 let msg = err.to_string().to_lowercase();
3187 assert!(
3188 msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
3189 "refuse message must mention non-empty dir, got {err}"
3190 );
3191 assert!(
3192 msg.contains("hidden"),
3193 "refuse message must mention hidden files, got {err}"
3194 );
3195
3196 let _ = std::fs::remove_dir_all(&dir);
3197 }
3198
3199 #[test]
3200 fn run_snapshot_writes_snapshot_bin() {
3201 let dir = tmp("snapshot-cli");
3202 {
3203 let mut db = GraphDb::open(&dir).expect("open");
3204 db.insert_node("Person", "alice", vec![]).expect("insert");
3205 }
3206 assert!(
3207 !dir.join("snapshot.bin").exists(),
3208 "GraphDb Drop must not snapshot"
3209 );
3210 let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
3211 assert!(
3212 dir.join("snapshot.bin").is_file(),
3213 "run_snapshot must write snapshot.bin"
3214 );
3215 assert!(
3216 out.contains("snapshot.bin"),
3217 "snapshot output should mention snapshot.bin, got {out}"
3218 );
3219 let db = GraphDb::open(&dir).expect("reopen");
3220 assert!(db.has_node("alice"), "reopen after snapshot must recover");
3221 let _ = std::fs::remove_dir_all(&dir);
3222 }
3223
3224 #[test]
3229 fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
3230 let dir = tmp("snapshot-archive");
3231 {
3232 let mut db = GraphDb::open(&dir).expect("open");
3233 db.insert_node("Person", "alice", vec![]).expect("insert");
3234 }
3235 let before = wal_commit_count_at(&dir).expect("count");
3236 assert!(before > 0, "the insert is a commit");
3237
3238 {
3240 let shared = SharedDb::open(&dir).expect("open");
3241 snapshot_shared(&shared).expect("snapshot");
3242 }
3243
3244 let archives = || {
3245 std::fs::read_dir(&dir)
3246 .expect("read dir")
3247 .filter_map(Result::ok)
3248 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
3249 .count()
3250 };
3251 assert_eq!(archives(), 1, "the WAL was archived, not dropped");
3252 assert!(
3253 dir.join("wal.genesis").is_file(),
3254 "the genesis marker is what lets asof reach an archived commit"
3255 );
3256 {
3257 let db = GraphDb::open(&dir).expect("reopen");
3258 assert!(db.has_node("alice"));
3259 assert!(
3260 !db.node_history("alice").expect("history").is_empty(),
3261 "the insert is still explainable"
3262 );
3263 }
3264 assert!(
3265 GraphDb::open_at(&dir, before - 1).is_ok(),
3266 "asof still reaches a commit the snapshot folded in"
3267 );
3268
3269 run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
3272 assert!(
3273 !dir.join("wal.genesis").exists(),
3274 "truncating ends asof's reach into the archives"
3275 );
3276 let db = GraphDb::open(&dir).expect("reopen");
3277 assert!(
3278 db.has_node("alice"),
3279 "the data survives; only the past goes"
3280 );
3281 let _ = std::fs::remove_dir_all(&dir);
3282 }
3283
3284 #[test]
3292 fn automatic_snapshots_keep_a_bounded_number_of_archives() {
3293 let dir = tmp("snapshot-retention");
3294 let archives = |d: &Path| {
3295 std::fs::read_dir(d)
3296 .expect("read dir")
3297 .filter_map(Result::ok)
3298 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
3299 .count()
3300 };
3301
3302 let rounds = 10;
3305 for i in 0..rounds {
3306 {
3307 let mut db = GraphDb::open(&dir).expect("open");
3308 db.insert_node("Person", &format!("p{i}"), vec![])
3309 .expect("insert");
3310 }
3311 let shared = SharedDb::open(&dir).expect("open shared");
3312 snapshot_shared(&shared).expect("snapshot");
3313 }
3314
3315 assert_eq!(
3316 archives(&dir),
3317 AUTO_SNAPSHOT_RETENTION as usize,
3318 "{rounds} automatic snapshots must not leave {rounds} archives"
3319 );
3320
3321 let db = GraphDb::open(&dir).expect("reopen");
3324 for i in 0..rounds {
3325 assert!(db.has_node(&format!("p{i}")), "p{i} survived the pruning");
3326 }
3327 assert!(
3331 db.node_history("p0").expect("history").is_empty(),
3332 "the pruned archives take their history with them"
3333 );
3334 assert!(
3335 !db.node_history("p9").expect("history").is_empty(),
3336 "the retained window is still explainable"
3337 );
3338 drop(db);
3339
3340 {
3343 let mut db = GraphDb::open(&dir).expect("open");
3344 db.insert_node("Person", "after", vec![]).expect("insert");
3345 }
3346 let latest = GraphDb::open(&dir).expect("reopen").commit_seq();
3347 assert!(
3348 GraphDb::open_at(&dir, latest - 1).is_ok(),
3349 "asof still reaches commits past the last snapshot"
3350 );
3351
3352 let manual = tmp("snapshot-retention-manual");
3355 for i in 0..3 {
3356 {
3357 let mut db = GraphDb::open(&manual).expect("open");
3358 db.insert_node("Person", &format!("p{i}"), vec![])
3359 .expect("insert");
3360 }
3361 run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
3362 }
3363 assert_eq!(
3364 archives(&manual),
3365 3,
3366 "`mushroomdb snapshot` with no --retention keeps every archive"
3367 );
3368
3369 let _ = std::fs::remove_dir_all(&dir);
3370 let _ = std::fs::remove_dir_all(&manual);
3371 }
3372
3373 #[test]
3374 fn run_query_formats_like_asof() {
3375 let dir = tmp("query-cli");
3376 {
3377 let mut db = GraphDb::open(&dir).expect("open");
3378 db.insert_node(
3379 "Person",
3380 "alice",
3381 vec![("id".into(), Value::Str("alice".into()))],
3382 )
3383 .expect("insert");
3384 }
3385 let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id").expect("query");
3386 assert!(out.contains("columns:"), "got {out}");
3387 assert!(out.contains("id=alice"), "got {out}");
3388 let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})").expect("write");
3389 let db = GraphDb::open(&dir).expect("reopen");
3390 assert!(db.has_node("bob"), "query_write must persist CREATE");
3391 let _ = std::fs::remove_dir_all(&dir);
3392 }
3393
3394 #[test]
3395 fn format_stats_contains_counts() {
3396 let dir = tmp("stats-smoke");
3397 let out = run_demo(&dir).expect("demo for stats smoke");
3398 let text = format_stats(&out.stats);
3399 assert!(
3400 text.contains("60"),
3401 "stats output should include live node count, got:\n{text}"
3402 );
3403 assert!(
3404 text.contains("334"),
3405 "stats output should include edge count, got:\n{text}"
3406 );
3407 assert!(
3408 text.to_lowercase().contains("node"),
3409 "stats output should mention nodes, got:\n{text}"
3410 );
3411 assert!(
3412 text.to_lowercase().contains("edge"),
3413 "stats output should mention edges, got:\n{text}"
3414 );
3415 let _ = std::fs::remove_dir_all(&dir);
3416 }
3417
3418 #[test]
3421 fn parse_backup_round_trip() {
3422 let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
3423 match r {
3424 Ok(Command::Backup { db_dir, dest }) => {
3425 assert_eq!(db_dir, PathBuf::from("/db/dir"));
3426 assert_eq!(dest, PathBuf::from("/backup/dest"));
3427 }
3428 other => panic!("backup parse, got {other:?}"),
3429 }
3430 }
3431
3432 #[test]
3433 fn parse_backup_missing_dest_errors() {
3434 let r = parse_args(&["backup", "/db/dir"]);
3435 assert!(r.is_err(), "backup without <dest> should error");
3436 let e = r.unwrap_err();
3437 assert!(
3438 e.to_lowercase().contains("dest"),
3439 "error should mention dest, got: {e}"
3440 );
3441 }
3442
3443 #[test]
3444 fn parse_export_defaults_to_jsonl() {
3445 let r = parse_args(&["export", "/db/dir", "/export/dest"]);
3446 match r {
3447 Ok(Command::Export { format, .. }) => {
3448 assert_eq!(format, ExportFormat::Jsonl);
3449 }
3450 other => panic!("export parse, got {other:?}"),
3451 }
3452 }
3453
3454 #[test]
3455 fn parse_export_parquet_flag() {
3456 let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
3457 match r {
3458 Ok(Command::Export { format, .. }) => {
3459 assert_eq!(format, ExportFormat::Parquet);
3460 }
3461 other => panic!("export --format parquet parse, got {other:?}"),
3462 }
3463 }
3464
3465 #[test]
3466 fn parse_export_parquet_flag_eq() {
3467 let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
3468 match r {
3469 Ok(Command::Export { format, .. }) => {
3470 assert_eq!(format, ExportFormat::Parquet);
3471 }
3472 other => panic!("export --format=parquet parse, got {other:?}"),
3473 }
3474 }
3475
3476 #[test]
3477 fn run_backup_cli_produces_verified_report() {
3478 let src = tmp("cli-backup-src");
3479 let dst = tmp("cli-backup-dst");
3480 let _ = run_demo(&src).expect("demo");
3481 let report = run_backup(&src, &dst).expect("run_backup");
3482 assert!(report.verified, "backup must be verified");
3483 assert!(!report.files.is_empty());
3484 assert!(report.bytes > 0);
3485 let _ = std::fs::remove_dir_all(&src);
3486 let _ = std::fs::remove_dir_all(&dst);
3487 }
3488
3489 #[test]
3490 fn run_export_jsonl_two_runs_byte_identical() {
3491 let src = tmp("cli-export-src");
3492 let dst1 = tmp("cli-export-dst1");
3493 let dst2 = tmp("cli-export-dst2");
3494 let _ = run_demo(&src).expect("demo");
3495
3496 run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
3497 run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
3498
3499 for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
3500 let f1 = std::fs::read(dst1.join(filename)).expect("read first");
3501 let f2 = std::fs::read(dst2.join(filename)).expect("read second");
3502 assert_eq!(
3503 f1, f2,
3504 "{filename} must be byte-identical across two export runs"
3505 );
3506 }
3507 let _ = std::fs::remove_dir_all(&src);
3508 let _ = std::fs::remove_dir_all(&dst1);
3509 let _ = std::fs::remove_dir_all(&dst2);
3510 }
3511
3512 #[test]
3513 fn run_export_jsonl_nodes_are_sorted() {
3514 let src = tmp("cli-export-sorted");
3515 let dst = tmp("cli-export-sorted-dst");
3516 let _ = run_demo(&src).expect("demo");
3517 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
3518
3519 let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
3520 let keys: Vec<String> = content
3521 .lines()
3522 .filter(|l| !l.is_empty())
3523 .map(|l| {
3524 let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
3525 v["key"].as_str().unwrap_or("").to_string()
3526 })
3527 .collect();
3528 let mut sorted = keys.clone();
3529 sorted.sort();
3530 assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
3531 let _ = std::fs::remove_dir_all(&src);
3532 let _ = std::fs::remove_dir_all(&dst);
3533 }
3534
3535 #[test]
3536 fn run_export_jsonl_derived_edges_have_rule() {
3537 let src = tmp("cli-export-derived");
3538 let dst = tmp("cli-export-derived-dst");
3539 let _ = run_demo(&src).expect("demo");
3540 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
3541
3542 let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
3543 let derived_lines: Vec<serde_json::Value> = content
3544 .lines()
3545 .filter(|l| !l.is_empty())
3546 .map(|l| serde_json::from_str(l).expect("parse line"))
3547 .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
3548 .collect();
3549 assert!(
3550 !derived_lines.is_empty(),
3551 "demo store should have derived edges"
3552 );
3553 for edge in &derived_lines {
3554 assert!(
3555 !edge["rule"].is_null(),
3556 "derived edge must have non-null rule: {edge}"
3557 );
3558 }
3559 let _ = std::fs::remove_dir_all(&src);
3560 let _ = std::fs::remove_dir_all(&dst);
3561 }
3562
3563 #[test]
3564 fn run_export_parquet_produces_files() {
3565 let src = tmp("cli-export-parq-src");
3566 let dst = tmp("cli-export-parq-dst");
3567 let _ = run_demo(&src).expect("demo");
3568 run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
3569
3570 assert!(
3571 dst.join("nodes.parquet").exists(),
3572 "nodes.parquet must exist"
3573 );
3574 assert!(
3575 dst.join("edges.parquet").exists(),
3576 "edges.parquet must exist"
3577 );
3578 assert!(
3579 dst.join("rules.parquet").exists(),
3580 "rules.parquet must exist"
3581 );
3582 for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
3584 let meta = std::fs::metadata(dst.join(f)).expect("metadata");
3585 assert!(meta.len() > 0, "{f} must be non-empty");
3586 }
3587 let _ = std::fs::remove_dir_all(&src);
3588 let _ = std::fs::remove_dir_all(&dst);
3589 }
3590
3591 #[test]
3592 fn parse_export_graphml_flag() {
3593 let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
3594 match r {
3595 Ok(Command::Export { format, .. }) => {
3596 assert_eq!(format, ExportFormat::Graphml);
3597 }
3598 other => panic!("export --format graphml parse, got {other:?}"),
3599 }
3600 }
3601
3602 #[test]
3603 fn run_export_graphml_structure() {
3604 let src = tmp("cli-export-gml-src");
3605 let dst_dir = tmp("cli-export-gml-dst");
3606 let dst = dst_dir.join("graph.graphml");
3607 let _ = run_demo(&src).expect("demo");
3608 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3609
3610 let content = std::fs::read_to_string(&dst).expect("read graphml");
3611
3612 assert!(
3613 content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
3614 "must start with an XML declaration"
3615 );
3616 assert!(
3617 content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
3618 "must use the standard GraphML namespace"
3619 );
3620 assert!(
3621 content.contains(
3622 "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
3623 ),
3624 "must declare the node label key"
3625 );
3626 assert!(
3627 content.contains(
3628 "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
3629 ),
3630 "must declare the edge type key"
3631 );
3632 assert!(
3633 content.contains(
3634 "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
3635 ),
3636 "must declare the edge derived key"
3637 );
3638 assert!(
3639 content.contains(
3640 "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
3641 ),
3642 "must declare the edge rule key"
3643 );
3644 assert!(
3645 content.contains(
3646 "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
3647 ),
3648 "must declare the edge weight key"
3649 );
3650 assert!(
3654 content.contains(
3655 "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
3656 ),
3657 "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
3658 );
3659 assert!(
3660 content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
3661 "must declare a single directed graph element"
3662 );
3663 assert!(content.contains("<node id="), "must contain node elements");
3664 assert!(
3665 content.contains("<edge id=\"e0\" source=\""),
3666 "must contain a sequentially-numbered edge starting at e0"
3667 );
3668 assert!(
3669 content.trim_end().ends_with("</graphml>"),
3670 "must close the root element"
3671 );
3672
3673 assert!(
3676 content.contains("<data key=\"e_rule\">skill_fit</data>")
3677 || content.contains("<data key=\"e_rule\">founded_within</data>"),
3678 "at least one derived edge must carry its rule name"
3679 );
3680 assert!(
3681 content.contains(&format!(
3682 "<data key=\"{}\">",
3683 "e_weight" )),
3685 "at least one derived edge must carry a weight value"
3686 );
3687
3688 let _ = std::fs::remove_dir_all(&src);
3689 let _ = std::fs::remove_dir_all(&dst_dir);
3690 }
3691
3692 #[test]
3693 fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
3694 let src = tmp("cli-export-gml-dir-src");
3695 let dst_dir = tmp("cli-export-gml-dir-dst");
3696 std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
3697 let _ = run_demo(&src).expect("demo");
3698
3699 let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
3700
3701 assert!(
3702 dst_dir.join("graph.graphml").exists(),
3703 "an existing directory dest must produce dest/graph.graphml"
3704 );
3705 assert!(
3706 msg.contains("graph.graphml"),
3707 "report must name the file actually written, got: {msg}"
3708 );
3709
3710 let _ = std::fs::remove_dir_all(&src);
3711 let _ = std::fs::remove_dir_all(&dst_dir);
3712 }
3713
3714 #[test]
3717 fn run_export_graphml_is_well_formed_xml() {
3718 let has_python3 = std::process::Command::new("python3")
3719 .arg("--version")
3720 .output()
3721 .map(|o| o.status.success())
3722 .unwrap_or(false);
3723 if !has_python3 {
3724 eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
3725 return;
3726 }
3727
3728 let src = tmp("cli-export-gml-wf-src");
3729 let dst_dir = tmp("cli-export-gml-wf-dst");
3730 let dst = dst_dir.join("graph.graphml");
3731 let _ = run_demo(&src).expect("demo");
3732 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3733
3734 let status = std::process::Command::new("python3")
3735 .arg("-c")
3736 .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
3737 .arg(&dst)
3738 .status()
3739 .expect("run python3");
3740 assert!(
3741 status.success(),
3742 "python3's XML parser must accept the exported GraphML file"
3743 );
3744
3745 let _ = std::fs::remove_dir_all(&src);
3746 let _ = std::fs::remove_dir_all(&dst_dir);
3747 }
3748
3749 #[test]
3750 fn run_export_graphml_two_runs_byte_identical() {
3751 let src = tmp("cli-export-gml-bi-src");
3752 let dst_dir1 = tmp("cli-export-gml-bi-dst1");
3753 let dst_dir2 = tmp("cli-export-gml-bi-dst2");
3754 let dst1 = dst_dir1.join("graph.graphml");
3755 let dst2 = dst_dir2.join("graph.graphml");
3756 let _ = run_demo(&src).expect("demo");
3757
3758 run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
3759 run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
3760
3761 let f1 = std::fs::read(&dst1).expect("read first");
3762 let f2 = std::fs::read(&dst2).expect("read second");
3763 assert_eq!(
3764 f1, f2,
3765 "graph.graphml must be byte-identical across two export runs"
3766 );
3767
3768 let _ = std::fs::remove_dir_all(&src);
3769 let _ = std::fs::remove_dir_all(&dst_dir1);
3770 let _ = std::fs::remove_dir_all(&dst_dir2);
3771 }
3772
3773 #[test]
3774 fn run_export_graphml_escapes_and_lists() {
3775 use core_api::{GraphDb, Value};
3776 let src = tmp("cli-export-gml-esc-src");
3777 let dst_dir = tmp("cli-export-gml-esc-dst");
3778 let dst = dst_dir.join("graph.graphml");
3779
3780 {
3781 let mut db = GraphDb::open(&src).unwrap();
3782 db.insert_node(
3783 "Widget",
3784 "w1",
3785 vec![
3786 (
3787 "title".into(),
3788 Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
3789 ),
3790 (
3791 "tags".into(),
3792 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
3793 ),
3794 ],
3795 )
3796 .unwrap();
3797 }
3798
3799 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3800 let content = std::fs::read_to_string(&dst).expect("read graphml");
3801
3802 assert!(
3803 content.contains("Tom & Jerry <says> "hi" 'bye'"),
3804 "special XML characters in string props must be escaped, got: {content}"
3805 );
3806 assert!(
3807 !content.contains("Tom & Jerry <says>"),
3808 "unescaped special characters must not appear verbatim"
3809 );
3810 assert!(
3811 content.contains(
3812 "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
3813 ),
3814 "list-valued props must declare attr.type=\"string\""
3815 );
3816 assert!(
3817 content.contains("<data key=\"n_tags\">["a","b"]</data>"),
3818 "list-valued props must render as XML-escaped JSON text, got: {content}"
3819 );
3820
3821 let _ = std::fs::remove_dir_all(&src);
3822 let _ = std::fs::remove_dir_all(&dst_dir);
3823 }
3824
3825 #[test]
3830 fn run_export_graphml_mixed_type_prop_declares_string() {
3831 use core_api::{GraphDb, Value};
3832 let src = tmp("cli-export-gml-mixed-src");
3833 let dst_dir = tmp("cli-export-gml-mixed-dst");
3834 let dst = dst_dir.join("graph.graphml");
3835
3836 {
3837 let mut db = GraphDb::open(&src).unwrap();
3838 db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
3839 .unwrap();
3840 db.insert_node(
3841 "Metric",
3842 "m2",
3843 vec![("score".into(), Value::Str("high".into()))],
3844 )
3845 .unwrap();
3846 }
3847
3848 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3849 let content = std::fs::read_to_string(&dst).expect("read graphml");
3850
3851 assert!(
3852 content.contains(
3853 "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
3854 ),
3855 "a prop name with conflicting value types across nodes must declare \
3856 attr.type=\"string\", got: {content}"
3857 );
3858 assert!(
3859 !content.contains("attr.name=\"score\" attr.type=\"long\""),
3860 "must not declare a narrower type once a conflict is seen, got: {content}"
3861 );
3862 assert!(
3865 content.contains("<data key=\"n_score\">5</data>"),
3866 "the int-valued node must still render its literal int text, got: {content}"
3867 );
3868 assert!(
3869 content.contains("<data key=\"n_score\">high</data>"),
3870 "the string-valued node must still render its literal string text, got: {content}"
3871 );
3872
3873 let _ = std::fs::remove_dir_all(&src);
3874 let _ = std::fs::remove_dir_all(&dst_dir);
3875 }
3876
3877 #[test]
3878 fn parse_algo_degree_defaults_dir_both() {
3879 let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
3880 match cmd {
3881 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
3882 other => panic!("expected Algo, got {other:?}"),
3883 }
3884 }
3885
3886 #[test]
3887 fn parse_algo_degree_with_dir_flag() {
3888 for (arg, want) in [
3889 ("out", AlgoDir::Out),
3890 ("in", AlgoDir::In),
3891 ("both", AlgoDir::Both),
3892 ] {
3893 let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
3894 match cmd {
3895 Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
3896 other => panic!("expected Algo, got {other:?}"),
3897 }
3898 }
3899 let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
3901 match cmd {
3902 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
3903 other => panic!("expected Algo, got {other:?}"),
3904 }
3905 }
3906
3907 #[test]
3908 fn parse_algo_rejects_unknown_dir() {
3909 assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
3910 }
3911
3912 #[test]
3913 fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
3914 let cmd = parse_args(&[
3915 "algo",
3916 "communities",
3917 "/db",
3918 "--edge-type",
3919 "IMPORTS",
3920 "--edge-type=CO_CHANGED",
3921 "--weight-prop",
3922 "score",
3923 "--min-weight",
3924 "0.3",
3925 "--top",
3926 "5",
3927 ])
3928 .unwrap();
3929 match cmd {
3930 Command::Algo {
3931 subcmd,
3932 top,
3933 edge_types,
3934 weight_prop,
3935 min_weight,
3936 ..
3937 } => {
3938 assert_eq!(subcmd, AlgoSubcmd::Communities);
3939 assert_eq!(top, 5);
3940 assert_eq!(
3941 edge_types,
3942 vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
3943 );
3944 assert_eq!(weight_prop, Some("score".to_string()));
3945 assert_eq!(min_weight, Some(0.3));
3946 }
3947 other => panic!("expected Algo, got {other:?}"),
3948 }
3949 }
3950
3951 #[test]
3952 fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
3953 let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
3954 match cmd {
3955 Command::Algo {
3956 subcmd,
3957 edge_types,
3958 weight_prop,
3959 min_weight,
3960 ..
3961 } => {
3962 assert_eq!(subcmd, AlgoSubcmd::Communities);
3963 assert!(edge_types.is_empty());
3964 assert_eq!(weight_prop, None);
3965 assert_eq!(min_weight, None);
3966 }
3967 other => panic!("expected Algo, got {other:?}"),
3968 }
3969 }
3970
3971 #[test]
3974 fn run_export_jsonl_nan_float_becomes_null() {
3975 use core_api::{GraphDb, Value};
3976 let src = tmp("cli-export-nan-src");
3977 let dst = tmp("cli-export-nan-dst");
3978
3979 {
3981 let mut db = GraphDb::open(&src).unwrap();
3982 db.insert_node(
3983 "Sensor",
3984 "s1",
3985 vec![
3986 ("nan_val".into(), Value::Float(f64::NAN)),
3987 ("pos_inf".into(), Value::Float(f64::INFINITY)),
3988 ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
3989 ("normal".into(), Value::Float(1.5)),
3990 ],
3991 )
3992 .unwrap();
3993 }
3994
3995 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
3997
3998 let content =
4000 std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
4001 let row: serde_json::Value =
4002 serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
4003 assert_eq!(
4004 row["nan_val"],
4005 serde_json::Value::Null,
4006 "NaN must export as null"
4007 );
4008 assert_eq!(
4009 row["pos_inf"],
4010 serde_json::Value::Null,
4011 "+Inf must export as null"
4012 );
4013 assert_eq!(
4014 row["neg_inf"],
4015 serde_json::Value::Null,
4016 "-Inf must export as null"
4017 );
4018 assert_eq!(
4020 row["normal"],
4021 serde_json::json!(1.5),
4022 "normal float roundtrips"
4023 );
4024
4025 let _ = std::fs::remove_dir_all(&src);
4026 let _ = std::fs::remove_dir_all(&dst);
4027 }
4028
4029 #[test]
4030 fn serve_tls_flags_parse_both_forms() {
4031 match parse_args(&[
4033 "serve",
4034 "/tmp/db",
4035 "--tls-cert",
4036 "/a/cert.pem",
4037 "--tls-key",
4038 "/a/key.pem",
4039 ])
4040 .unwrap()
4041 {
4042 Command::Serve {
4043 tls_cert, tls_key, ..
4044 } => {
4045 assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
4046 assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
4047 }
4048 other => panic!("{other:?}"),
4049 }
4050 match parse_args(&[
4052 "serve",
4053 "/tmp/db",
4054 "--tls-cert=/b/cert.pem",
4055 "--tls-key=/b/key.pem",
4056 ])
4057 .unwrap()
4058 {
4059 Command::Serve {
4060 tls_cert, tls_key, ..
4061 } => {
4062 assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
4063 assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
4064 }
4065 other => panic!("{other:?}"),
4066 }
4067 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4069 Command::Serve {
4070 tls_cert, tls_key, ..
4071 } => {
4072 assert_eq!(tls_cert, None);
4073 assert_eq!(tls_key, None);
4074 }
4075 other => panic!("{other:?}"),
4076 }
4077 }
4078
4079 #[test]
4080 fn serve_tls_flags_require_both() {
4081 let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
4083 assert!(
4084 err.contains("tls-key"),
4085 "--tls-cert alone must mention --tls-key in error, got {err}"
4086 );
4087 let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
4089 assert!(
4090 err.contains("tls-cert"),
4091 "--tls-key alone must mention --tls-cert in error, got {err}"
4092 );
4093 }
4094
4095 #[test]
4096 fn version_flag_parses() {
4097 assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
4098 assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
4099 assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
4100 }
4101
4102 #[test]
4103 fn recall_parses_one_dir_and_is_listed_in_usage() {
4104 assert_eq!(
4105 parse_args(&["recall", "/tmp/db"]).unwrap(),
4106 Command::Recall {
4107 db_dir: Some(PathBuf::from("/tmp/db")),
4108 auto: false,
4109 }
4110 );
4111 assert!(
4112 parse_args(&["recall"]).is_err(),
4113 "one of <db-dir> or --auto is required"
4114 );
4115 assert!(usage().contains("mushroomdb recall <db-dir>"));
4116 }
4117
4118 #[test]
4119 fn map_parses_a_dir_and_an_optional_json_flag() {
4120 assert_eq!(
4121 parse_args(&["map", "/tmp/db"]).unwrap(),
4122 Command::Map {
4123 db_dir: PathBuf::from("/tmp/db"),
4124 json: false,
4125 }
4126 );
4127 let want = Command::Map {
4129 db_dir: PathBuf::from("/tmp/db"),
4130 json: true,
4131 };
4132 assert_eq!(parse_args(&["map", "/tmp/db", "--json"]).unwrap(), want);
4133 assert_eq!(parse_args(&["map", "--json", "/tmp/db"]).unwrap(), want);
4134 assert!(parse_args(&["map"]).is_err(), "<db-dir> is required");
4135 assert!(parse_args(&["map", "/tmp/db", "/tmp/other"]).is_err());
4136 assert!(parse_args(&["map", "/tmp/db", "--nope"]).is_err());
4137 assert!(usage().contains("mushroomdb map <db-dir> [--json]"));
4138 }
4139
4140 #[test]
4141 fn the_graph_tools_take_a_dir_and_their_keys() {
4142 assert_eq!(
4143 parse_args(&["context", "/tmp/db", "src/db.rs#open"]).unwrap(),
4144 Command::Context {
4145 db_dir: PathBuf::from("/tmp/db"),
4146 target: "src/db.rs#open".to_string(),
4147 }
4148 );
4149 assert_eq!(
4150 parse_args(&["impact", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
4151 Command::Impact {
4152 db_dir: PathBuf::from("/tmp/db"),
4153 files: vec!["a.rs".to_string(), "b.rs".to_string()],
4154 }
4155 );
4156 assert_eq!(
4157 parse_args(&["owners", "/tmp/db", "a.rs"]).unwrap(),
4158 Command::Owners {
4159 db_dir: PathBuf::from("/tmp/db"),
4160 path: "a.rs".to_string(),
4161 }
4162 );
4163 assert_eq!(
4164 parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
4165 Command::Why {
4166 db_dir: PathBuf::from("/tmp/db"),
4167 a: "a.rs".to_string(),
4168 b: "b.rs".to_string(),
4169 }
4170 );
4171
4172 for args in [
4174 vec!["context", "/tmp/db"],
4175 vec!["context", "/tmp/db", "a", "b"],
4176 vec!["impact", "/tmp/db"],
4177 vec!["owners", "/tmp/db"],
4178 vec!["why", "/tmp/db", "a"],
4179 vec!["why", "/tmp/db", "a", "b", "c"],
4180 vec!["why", "/tmp/db", "-a", "b"],
4181 vec!["context"],
4182 ] {
4183 assert!(parse_args(&args).is_err(), "{args:?} must not parse");
4184 }
4185 for line in [
4186 "mushroomdb context <db-dir> <target>",
4187 "mushroomdb impact <db-dir> <file>...",
4188 "mushroomdb owners <db-dir> <path>",
4189 "mushroomdb why <db-dir> <a> <b>",
4190 ] {
4191 assert!(usage().contains(line), "usage is missing {line:?}");
4192 }
4193 }
4194
4195 #[test]
4198 fn hook_commands_take_a_dir_or_auto() {
4199 assert_eq!(
4200 parse_args(&["mcp", "--auto"]).unwrap(),
4201 Command::Mcp {
4202 db_dir: None,
4203 auto: true,
4204 all_tools: false
4205 }
4206 );
4207 assert_eq!(
4208 parse_args(&["recall", "--auto"]).unwrap(),
4209 Command::Recall {
4210 db_dir: None,
4211 auto: true
4212 }
4213 );
4214 for cmd in ["mcp", "recall", "touch"] {
4215 assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
4216 assert!(
4217 parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
4218 "{cmd} with both"
4219 );
4220 }
4221 assert!(usage().contains("--auto"));
4222 }
4223
4224 #[test]
4227 fn mcp_takes_all_tools() {
4228 for args in [
4229 &["mcp", "/tmp/db", "--all-tools"][..],
4230 &["mcp", "--all-tools", "/tmp/db"][..],
4231 ] {
4232 assert_eq!(
4233 parse_args(args).unwrap(),
4234 Command::Mcp {
4235 db_dir: Some(PathBuf::from("/tmp/db")),
4236 auto: false,
4237 all_tools: true
4238 },
4239 "{args:?}"
4240 );
4241 }
4242 assert_eq!(
4243 parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
4244 Command::Mcp {
4245 db_dir: None,
4246 auto: true,
4247 all_tools: true
4248 }
4249 );
4250 assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
4251 assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
4252 assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
4253 assert!(usage().contains("--all-tools"));
4254 }
4255
4256 #[test]
4257 fn sync_and_touch_parse() {
4258 assert_eq!(
4259 parse_args(&["sync", "/tmp/db"]).unwrap(),
4260 Command::Sync {
4261 db_dir: Some(PathBuf::from("/tmp/db")),
4262 auto: false,
4263 json: false,
4264 }
4265 );
4266 assert_eq!(
4267 parse_args(&["sync", "/tmp/db", "--json"]).unwrap(),
4268 Command::Sync {
4269 db_dir: Some(PathBuf::from("/tmp/db")),
4270 auto: false,
4271 json: true,
4272 }
4273 );
4274 assert_eq!(
4277 parse_args(&["sync", "--auto"]).unwrap(),
4278 Command::Sync {
4279 db_dir: None,
4280 auto: true,
4281 json: false,
4282 }
4283 );
4284 assert_eq!(
4285 parse_args(&["sync", "--auto", "--json"]).unwrap(),
4286 Command::Sync {
4287 db_dir: None,
4288 auto: true,
4289 json: true,
4290 }
4291 );
4292 assert!(
4293 parse_args(&["sync"]).is_err(),
4294 "one of <db-dir> or --auto is required"
4295 );
4296 assert!(
4297 parse_args(&["sync", "/tmp/db", "--auto"]).is_err(),
4298 "--auto and a path contradict each other"
4299 );
4300
4301 assert_eq!(
4303 parse_args(&["touch", "/tmp/db", "src/a.rs", "src/b.rs"]).unwrap(),
4304 Command::Touch {
4305 db_dir: Some(PathBuf::from("/tmp/db")),
4306 auto: false,
4307 files: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
4308 }
4309 );
4310 assert_eq!(
4312 parse_args(&["touch", "--auto", "src/a.rs"]).unwrap(),
4313 Command::Touch {
4314 db_dir: None,
4315 auto: true,
4316 files: vec![PathBuf::from("src/a.rs")],
4317 }
4318 );
4319 assert_eq!(
4321 parse_args(&["touch", "--auto"]).unwrap(),
4322 Command::Touch {
4323 db_dir: None,
4324 auto: true,
4325 files: vec![],
4326 }
4327 );
4328 assert!(usage().contains("mushroomdb sync <db-dir>"));
4329 assert!(usage().contains("mushroomdb touch"));
4330 }
4331
4332 #[test]
4333 fn ingest_git_parses_excludes() {
4334 let cmd = parse_args(&[
4335 "ingest-git",
4336 "/tmp/db",
4337 "/tmp/repo",
4338 "--exclude",
4339 "target/",
4340 "--exclude=*.lock",
4341 "--max-commits-per-file",
4342 "50",
4343 "--recurse-submodules",
4344 "--prs",
4345 "--ensure-gitignore",
4346 ])
4347 .unwrap();
4348 assert_eq!(
4349 cmd,
4350 Command::IngestGit {
4351 db_dir: PathBuf::from("/tmp/db"),
4352 opts: ingest_git::IngestGitOpts {
4353 repo: PathBuf::from("/tmp/repo"),
4354 exclude: vec!["target/".into(), "*.lock".into()],
4355 max_commits_per_file: 50,
4356 recurse_submodules: true,
4357 prs: true,
4358 structure: true,
4359 docs: true,
4360 ensure_gitignore: true,
4361 },
4362 }
4363 );
4364 let Command::IngestGit { opts, .. } =
4366 parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
4367 else {
4368 panic!("expected IngestGit");
4369 };
4370 assert_eq!(
4371 opts.exclude,
4372 ingest_git::DEFAULT_EXCLUDES
4373 .iter()
4374 .map(|p| (*p).to_string())
4375 .collect::<Vec<_>>(),
4376 "with no --exclude the defaults apply"
4377 );
4378 assert_eq!(
4379 opts.max_commits_per_file,
4380 ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
4381 );
4382 assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
4383 assert!(
4384 opts.structure && opts.docs,
4385 "structure and docs default on and are recorded on the marker"
4386 );
4387 let Command::IngestGit { opts, .. } = parse_args(&[
4388 "ingest-git",
4389 "/tmp/db",
4390 "/tmp/repo",
4391 "--no-structure",
4392 "--no-docs",
4393 ])
4394 .unwrap() else {
4395 panic!("expected IngestGit");
4396 };
4397 assert!(!opts.structure && !opts.docs);
4398 assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
4399 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
4400 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
4401 assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
4402 }
4403
4404 #[test]
4405 fn version_constant_matches_cargo() {
4406 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
4407 assert!(usage().contains("--version"));
4408 }
4409}