1pub mod doctor;
7pub mod enrich;
8pub mod export;
9pub(crate) mod hook;
10pub mod impact_hook;
11pub mod ingest_git;
12pub mod install;
13pub mod intercept;
14pub mod recall;
15pub mod structure;
16
17use core_api::repograph;
18use core_api::schema::Schema;
19use core_api::{
20 default_max_edges, is_write_query, wal_commit_count_at, AlgoDir, BackupReport, DegreeConfig,
21 Explanation, GraphDb, IngestOptions, LouvainConfig, PageRankConfig, Predicate, ResultSet,
22 RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig, WriteGuard,
23};
24use export::ExportFormat;
25use std::collections::{BTreeMap, BTreeSet};
26use std::fmt::Write as _;
27use std::net::SocketAddr;
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31pub const AUTOMATIC_SNAPSHOT: SnapshotOptions = SnapshotOptions {
42 keep_wal: false,
43 archive_wal: true,
44};
45
46pub const AUTO_SNAPSHOT_RETENTION: u32 = 8;
75
76pub fn snapshot_automatically(db: &mut WriteGuard<'_>) -> Result<(), core_api::GraphError> {
86 db.set_wal_archive_retention(Some(AUTO_SNAPSHOT_RETENTION));
87 db.snapshot_with(AUTOMATIC_SNAPSHOT)
88}
89
90pub const SNAPSHOT_LOCK_WAIT: Duration = Duration::from_millis(500);
98
99pub fn snapshot_shared(db: &SharedDb) -> Result<(), core_api::GraphError> {
110 snapshot_automatically(&mut db.write_with_wait(SNAPSHOT_LOCK_WAIT)?)
111}
112
113pub const N_ORGS: usize = 10;
115pub const N_PROJECTS: usize = 20;
116pub const N_PEOPLE: usize = 30;
117
118pub const SAMPLE_QUERY: &str = "\
123MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
124RETURN p, proj, r.score AS score
125ORDER BY score DESC, proj";
126
127const SAMPLE_EXPLAIN_A: &str = "person-01";
128const SAMPLE_EXPLAIN_B: &str = "proj-01";
129
130pub const VERSION: &str = env!("CARGO_PKG_VERSION");
132
133#[must_use]
135pub fn version_string() -> String {
136 format!("mushroomdb {VERSION}")
137}
138
139#[must_use]
151pub fn resolve_auto_db(
152 env_project_dir: Option<&std::ffi::OsStr>,
153 cwd: &Path,
154 home: &Path,
155) -> PathBuf {
156 if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
157 return Path::new(dir).join("mushroom-memory");
158 }
159 if let Some(root) = worktree_root(cwd) {
160 return root.join("mushroom-memory");
161 }
162 home.join(".mushroomdb").join("memory")
163}
164
165#[must_use]
179pub fn worktree_root(dir: &Path) -> Option<&Path> {
180 dir.ancestors().find(|d| d.join(".git").exists())
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum ServeUi {
186 Filesystem(PathBuf),
187 Embedded,
188 None,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum AlgoSubcmd {
194 Pagerank,
195 Wcc,
196 Degree,
197 Communities,
198}
199
200#[derive(Debug, Clone, PartialEq)]
204pub enum Command {
205 Serve {
206 db_dir: PathBuf,
207 addr: SocketAddr,
208 ui: ServeUi,
209 demo_if_empty: bool,
212 token: Option<String>,
214 role_tokens: Vec<(String, String)>,
217 snapshot_every: Option<Duration>,
219 tls_cert: Option<PathBuf>,
221 tls_key: Option<PathBuf>,
223 },
224 Mcp {
225 db_dir: Option<PathBuf>,
227 auto: bool,
228 all_tools: bool,
234 },
235 Stats {
236 db_dir: PathBuf,
237 },
238 Demo {
239 db_dir: PathBuf,
240 },
241 AsOf {
243 db_dir: PathBuf,
244 commit: u64,
246 query: Option<String>,
248 },
249 Suggest {
251 db_dir: PathBuf,
252 },
253 Algo {
255 db_dir: PathBuf,
256 subcmd: AlgoSubcmd,
257 top: usize,
259 dir: AlgoDir,
262 edge_types: Vec<String>,
265 weight_prop: Option<String>,
267 min_weight: Option<f64>,
269 },
270 Query {
272 db_dir: PathBuf,
273 cypher: String,
275 },
276 Snapshot {
278 db_dir: PathBuf,
279 wal: WalDisposition,
280 retention: Option<u32>,
283 },
284 SchemaApply {
286 db_dir: PathBuf,
287 schema_file: PathBuf,
288 },
289 Migrate {
291 db_dir: PathBuf,
292 },
293 Verify {
295 db_dir: PathBuf,
296 },
297 Backup {
299 db_dir: PathBuf,
300 dest: PathBuf,
301 },
302 Export {
304 db_dir: PathBuf,
305 dest: PathBuf,
306 format: ExportFormat,
307 },
308 IngestGit {
310 db_dir: PathBuf,
311 opts: ingest_git::IngestGitOpts,
312 },
313 Install(install::InstallOpts),
315 Uninstall(install::InstallOpts),
317 Disable(install::ToggleOpts),
321 Enable(install::ToggleOpts),
324 Doctor(doctor::DoctorOpts),
326 Recall {
329 db_dir: Option<PathBuf>,
330 auto: bool,
331 },
332 Brief {
335 db_dir: Option<PathBuf>,
336 auto: bool,
337 },
338 Sync {
341 db_dir: Option<PathBuf>,
346 auto: bool,
347 json: bool,
351 },
352 Intercept {
357 db_dir: Option<PathBuf>,
358 auto: bool,
359 },
360 ImpactHook {
365 db_dir: Option<PathBuf>,
366 auto: bool,
367 },
368 Enrich {
373 db_dir: Option<PathBuf>,
374 auto: bool,
375 },
376 Touch {
379 db_dir: Option<PathBuf>,
380 auto: bool,
381 files: Vec<PathBuf>,
382 },
383 Map {
386 db_dir: PathBuf,
387 json: bool,
390 },
391 Explore {
394 db_dir: PathBuf,
395 target: String,
396 depth: repograph::Depth,
397 full: bool,
399 },
400 Context {
402 db_dir: PathBuf,
403 target: String,
404 full: bool,
407 },
408 Impact {
411 db_dir: PathBuf,
412 files: Vec<String>,
413 },
414 Owners {
416 db_dir: PathBuf,
417 path: String,
418 },
419 Why {
421 db_dir: PathBuf,
422 a: String,
423 b: String,
424 },
425 Version,
426 Help,
427}
428
429#[derive(Debug)]
431pub struct DemoOutcome {
432 pub auto_fk_rules: Vec<String>,
433 pub sample_query: String,
434 pub sample_result: ResultSet,
435 pub explanations: Vec<Explanation>,
436 pub stats: Stats,
437 pub suggestion: Option<RuleSuggestion>,
439}
440
441#[derive(Debug)]
443pub struct CliError(pub String);
444
445impl std::fmt::Display for CliError {
446 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447 f.write_str(&self.0)
448 }
449}
450
451impl std::error::Error for CliError {}
452
453impl From<core_api::GraphError> for CliError {
454 fn from(e: core_api::GraphError) -> Self {
455 CliError(e.to_string())
456 }
457}
458
459impl From<std::io::Error> for CliError {
460 fn from(e: std::io::Error) -> Self {
461 CliError(e.to_string())
462 }
463}
464
465pub fn usage() -> &'static str {
467 "\
468mushroomdb — embedded graph database
469
470Usage:
471 mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
472 [--command <path>] [--no-git-hooks] [--no-prewarm]
473 [--delivery cli|mcp|both] [--intercept-grep]
474 [--impact-before-edit] [--enrich-grep]
475 [--always-load|--no-always-load]
476 --delivery cli writes the skill and the hooks and registers no MCP
477 server: the skill teaches `mushroomdb <command>` instead (claude-code
478 only; cursor and codex are always registered as MCP servers)
479 --intercept-grep (deprecated, removed in 0.7) adds an experimental
480 PreToolUse hook (matcher Grep) that redirects a search for a known
481 symbol name to `explore`
482 --impact-before-edit (deprecated, removed in 0.7) adds an experimental
483 PreToolUse hook (matcher Edit|Write|MultiEdit) that injects the file's
484 blast radius before the edit
485 --enrich-grep (deprecated, removed in 0.7) adds an experimental
486 PostToolUse hook (matcher Grep) that appends what the graph knows
487 about the symbols the search matched
488 --always-load marks the registered MCP server alwaysLoad, so the host
489 keeps its tools in context instead of deferring them; already the
490 default when --db names a store and a server is registered
491 (--delivery mcp|both) — --no-always-load opts out
492 mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
493 mushroomdb disable [--platform claude-code|cursor|codex|all] [--project|--user]
494 turn an install off without removing it: hooks, MCP entry and git hook
495 blocks are removed; the skill, the store and .gitignore stay
496 mushroomdb enable [--platform claude-code|cursor|codex|all] [--project|--user]
497 turn a disabled install back on
498 mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
499 verify an install: config entry, store, hooks, git hooks, and a real
500 stdio handshake with the configured MCP command; exits 1 on any `fail`
501 mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>]
502 mushroomdb mcp <db-dir>|--auto [--all-tools]
503 --all-tools lists all 27 tools; the default follows the store — 3 on a
504 store `ingest-git` built (explore, query, stats), 15 on any other
505 (the rest stay callable, just unlisted)
506 mushroomdb stats <db-dir>
507 mushroomdb demo <db-dir>
508 mushroomdb recall <db-dir>|--auto hook body: reads a prompt payload on stdin, prints related graph facts
509 mushroomdb brief <db-dir>|--auto hook body: the repository in one block — size, synced sha, the most
510 central files and the most called symbols; byte-stable, so a
511 session host caches it once
512 mushroomdb sync <db-dir>|--auto [--json] (deprecated, removed in 0.7)
513 re-sync the repo the store was built from: new commits, then the
514 dirty working tree (git hook body)
515 mushroomdb map <db-dir> [--json] (deprecated, removed in 0.7)
516 summarise the graphed repository: clusters, key files, owners, hot files
517 --json prints the computed map instead of the rendered digest
518 mushroomdb explore <db-dir> <target> [--depth context|impact|history|all] [--full]
519 (deprecated, removed in 0.7)
520 one target from as many sides as asked for: the definition and
521 its callers (context), the blast radius (impact), the owner and
522 what it changes with (history), or all three
523 <target> is a file path, a symbol key, or a bare symbol name
524 --full also quotes the body from the working tree
525 mushroomdb context <db-dir> <target> [--full] (deprecated, removed in 0.7)
526 one file or symbol from every side: where it is, signature, callers,
527 callees, importers, co-change partners, commits, notes
528 <target> is a file path, a symbol key, or a bare symbol name
529 --full also quotes the body from the working tree
530 mushroomdb impact <db-dir> <file>... (deprecated, removed in 0.7)
531 what changing these files reaches: partners, importers,
532 and the symbols other files call
533 mushroomdb owners <db-dir> <path> (deprecated, removed in 0.7)
534 top author and share, who else knows it, last touch, last 4 quarters
535 mushroomdb why <db-dir> <a> <b> (deprecated, removed in 0.7)
536 every rule edge between two nodes with its evidence, or the
537 shortest path between them
538 mushroomdb touch <db-dir>|--auto [<file>...]
539 re-extract just these files; with no <file> reads them from a
540 PostToolUse payload on stdin (hook body)
541 mushroomdb intercept <db-dir>|--auto
542 hook body: reads a PreToolUse Grep payload on stdin; exits 2
543 with a one-line pointer to `explore` when the pattern names a
544 symbol the graph holds, else exits 0 in silence
545 mushroomdb impact-hook <db-dir>|--auto
546 hook body: reads a PreToolUse edit payload on stdin; prints the
547 edited file's blast radius as additionalContext, else nothing
548 mushroomdb enrich <db-dir>|--auto
549 hook body: reads a PostToolUse Grep payload on stdin; prints what
550 the graph knows about the symbols it matched, else nothing
551 mushroomdb suggest <db-dir>
552 mushroomdb asof <db-dir> --commit N [--query \"MATCH ...\"]
553 mushroomdb query <db-dir> [--query \"MATCH ...\"] <cypher…>
554 mushroomdb snapshot <db-dir> [--keep-wal|--truncate] [--retention N]
555 folds the WAL into snapshot.bin and archives it as wal.<N>.archive,
556 so node_history, edge_history, was_linked and asof keep reaching it;
557 --truncate discards it instead, --keep-wal leaves wal.bin whole
558 mushroomdb migrate <db-dir>
559 mushroomdb verify <db-dir> validate CRC32 integrity of every snapshot section
560 mushroomdb backup <db-dir> <dest> process-local consistent copy of the database to <dest>
561 WARNING: unsafe against a concurrently running serve process;
562 use POST /backup on the HTTP server for live-serve backups
563 mushroomdb export <db-dir> <dest> --format jsonl|parquet|graphml export all data
564 graphml writes one file: <dest>/graph.graphml if <dest> is an
565 existing directory, otherwise <dest> is the file path itself
566 (nodes + edges only; rules have no GraphML analogue)
567 mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
568 [--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
569 graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
570 --recurse-submodules also walks each initialised submodule
571 --prs links merged pull requests via gh (skipped when gh is unavailable)
572 --no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
573 --no-docs skips Markdown bodies, headings and mentions
574 --ensure-gitignore adds the database directory to the repo's .gitignore
575 with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
576 mushroomdb schema apply <db-dir> <schema.json>
577 mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
578 mushroomdb algo wcc <db-dir> [--top N]
579 mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
580 mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
581 mushroomdb --version
582 mushroomdb --help
583
584Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
585install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
586the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
587--command names one (a relative --command or --db is anchored to the current directory).
588--no-git-hooks skips the post-commit/checkout/merge sync hooks. --no-prewarm means no network and no
589resolution: neither the one-off package fetch nor locating the package's binary, so every hook keeps
590the slower `npx` form.
591uninstall resolves the same scope and falls back to the other one when the inferred scope has no
592manifest; undoing a Codex install needs --platform codex.
593A project install inside a git checkout writes --auto rather than a store path, so each `git
594worktree` gets its own store; outside a checkout, and with --db, the store is pinned to an absolute
595path instead.
596--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else mushroom-memory at the
597root of the working tree the current directory is in, else ~/.mushroomdb/memory.
598"
599}
600
601fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
602 let mut platform: Option<install::Platform> = None;
603 let mut scope: Option<install::Scope> = None;
604 let mut db: Option<PathBuf> = None;
605 let mut command: Option<PathBuf> = None;
606 let mut git_hooks = true;
607 let mut prewarm = true;
608 let mut delivery = install::Delivery::default();
609 let mut intercept_grep = false;
610 let mut impact_before_edit = false;
611 let mut enrich_grep = false;
612 let mut always_load: Option<bool> = None;
615 let mut i = 0;
616 while i < args.len() {
617 let a = args[i];
618 if a == "--delivery" {
619 let val = args
620 .get(i + 1)
621 .copied()
622 .ok_or_else(|| "missing value for --delivery".to_string())?;
623 delivery = install::Delivery::parse(val)?;
624 i += 2;
625 } else if let Some(val) = a.strip_prefix("--delivery=") {
626 delivery = install::Delivery::parse(val)?;
627 i += 1;
628 } else if a == "--platform" {
629 let val = args
630 .get(i + 1)
631 .copied()
632 .ok_or_else(|| "missing value for --platform".to_string())?;
633 platform = Some(install::Platform::parse(val)?);
634 i += 2;
635 } else if let Some(val) = a.strip_prefix("--platform=") {
636 platform = Some(install::Platform::parse(val)?);
637 i += 1;
638 } else if a == "--project" || a == "--user" {
639 let want = if a == "--project" {
640 install::Scope::Project
641 } else {
642 install::Scope::User
643 };
644 if scope.is_some_and(|s| s != want) {
647 return Err("--project and --user are mutually exclusive".to_string());
648 }
649 scope = Some(want);
650 i += 1;
651 } else if a == "--no-git-hooks" {
652 git_hooks = false;
653 i += 1;
654 } else if a == "--intercept-grep" {
655 intercept_grep = true;
656 i += 1;
657 } else if a == "--impact-before-edit" {
658 impact_before_edit = true;
659 i += 1;
660 } else if a == "--enrich-grep" {
661 enrich_grep = true;
662 i += 1;
663 } else if a == "--always-load" {
664 always_load = Some(true);
665 i += 1;
666 } else if a == "--no-always-load" {
667 always_load = Some(false);
668 i += 1;
669 } else if a == "--no-prewarm" {
670 prewarm = false;
671 i += 1;
672 } else if a == "--command" {
673 let val = args
674 .get(i + 1)
675 .copied()
676 .ok_or_else(|| "missing value for --command".to_string())?;
677 command = Some(PathBuf::from(val));
678 i += 2;
679 } else if let Some(val) = a.strip_prefix("--command=") {
680 command = Some(PathBuf::from(val));
681 i += 1;
682 } else if a == "--db" {
683 let val = args
684 .get(i + 1)
685 .copied()
686 .ok_or_else(|| "missing value for --db".to_string())?;
687 db = Some(PathBuf::from(val));
688 i += 2;
689 } else if let Some(val) = a.strip_prefix("--db=") {
690 db = Some(PathBuf::from(val));
691 i += 1;
692 } else if a.starts_with('-') {
693 return Err(format!("unexpected flag: {a}"));
694 } else {
695 return Err(format!("unexpected argument: {a}"));
696 }
697 }
698 let always_load =
710 always_load.unwrap_or(db.is_some() && !matches!(delivery, install::Delivery::Cli));
711 Ok(install::InstallOpts {
712 platform,
713 scope,
714 db,
715 command,
716 git_hooks,
717 prewarm,
718 delivery,
719 intercept_grep,
720 impact_before_edit,
721 enrich_grep,
722 always_load,
723 })
724}
725
726fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
727 let mut platform: Option<install::Platform> = None;
728 let mut scope: Option<install::Scope> = None;
729 let mut i = 0;
730 while i < args.len() {
731 let a = args[i];
732 if a == "--platform" {
733 let val = args
734 .get(i + 1)
735 .copied()
736 .ok_or_else(|| "missing value for --platform".to_string())?;
737 platform = Some(install::Platform::parse(val)?);
738 i += 2;
739 } else if let Some(val) = a.strip_prefix("--platform=") {
740 platform = Some(install::Platform::parse(val)?);
741 i += 1;
742 } else if a == "--project" || a == "--user" {
743 let want = if a == "--project" {
744 install::Scope::Project
745 } else {
746 install::Scope::User
747 };
748 if scope.is_some_and(|s| s != want) {
749 return Err("--project and --user are mutually exclusive".to_string());
750 }
751 scope = Some(want);
752 i += 1;
753 } else if a.starts_with('-') {
754 return Err(format!("unexpected flag: {a}"));
755 } else {
756 return Err(format!("unexpected argument: {a}"));
757 }
758 }
759 Ok(doctor::DoctorOpts { platform, scope })
760}
761
762fn parse_toggle_cmd(args: &[&str]) -> Result<install::ToggleOpts, String> {
767 let mut platform: Option<install::Platform> = None;
768 let mut scope: Option<install::Scope> = None;
769 let mut i = 0;
770 while i < args.len() {
771 let a = args[i];
772 if a == "--platform" {
773 let val = args
774 .get(i + 1)
775 .copied()
776 .ok_or_else(|| "missing value for --platform".to_string())?;
777 platform = Some(install::Platform::parse(val)?);
778 i += 2;
779 } else if let Some(val) = a.strip_prefix("--platform=") {
780 platform = Some(install::Platform::parse(val)?);
781 i += 1;
782 } else if a == "--project" || a == "--user" {
783 let want = if a == "--project" {
784 install::Scope::Project
785 } else {
786 install::Scope::User
787 };
788 if scope.is_some_and(|s| s != want) {
789 return Err("--project and --user are mutually exclusive".to_string());
790 }
791 scope = Some(want);
792 i += 1;
793 } else if a.starts_with('-') {
794 return Err(format!("unexpected flag: {a}"));
795 } else {
796 return Err(format!("unexpected argument: {a}"));
797 }
798 }
799 Ok(install::ToggleOpts { platform, scope })
800}
801
802fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
803 let mut positional = Vec::new();
804 let mut exclude = Vec::new();
805 let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
806 let mut recurse_submodules = false;
807 let mut prs = false;
808 let mut structure = true;
809 let mut docs = true;
810 let mut ensure_gitignore = false;
811 let mut i = 0;
812 while i < args.len() {
813 let a = args[i];
814 if a == "--recurse-submodules" {
815 recurse_submodules = true;
816 i += 1;
817 } else if a == "--prs" {
818 prs = true;
819 i += 1;
820 } else if a == "--no-structure" {
821 structure = false;
822 i += 1;
823 } else if a == "--no-docs" {
824 docs = false;
825 i += 1;
826 } else if a == "--ensure-gitignore" {
827 ensure_gitignore = true;
828 i += 1;
829 } else if a == "--exclude" {
830 exclude.push(
831 args.get(i + 1)
832 .copied()
833 .ok_or_else(|| "missing value for --exclude".to_string())?
834 .to_string(),
835 );
836 i += 2;
837 } else if let Some(val) = a.strip_prefix("--exclude=") {
838 exclude.push(val.to_string());
839 i += 1;
840 } else if a == "--max-commits-per-file" {
841 let val = args
842 .get(i + 1)
843 .copied()
844 .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
845 max_commits_per_file = val
846 .parse()
847 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
848 i += 2;
849 } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
850 max_commits_per_file = val
851 .parse()
852 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
853 i += 1;
854 } else if a.starts_with('-') {
855 return Err(format!("unexpected flag: {a}"));
856 } else {
857 positional.push(a);
858 i += 1;
859 }
860 }
861 let [db_dir, repo] = positional.as_slice() else {
862 return Err("ingest-git requires <db-dir> <repo-dir>".into());
863 };
864 if exclude.is_empty() {
868 exclude = ingest_git::DEFAULT_EXCLUDES
869 .iter()
870 .map(|p| (*p).to_string())
871 .collect();
872 }
873 Ok(Command::IngestGit {
874 db_dir: PathBuf::from(db_dir),
875 opts: ingest_git::IngestGitOpts {
876 repo: PathBuf::from(repo),
877 exclude,
878 max_commits_per_file,
879 recurse_submodules,
880 prs,
881 structure,
882 docs,
883 ensure_gitignore,
884 },
885 })
886}
887
888pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
890 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
891 if args.is_empty() {
892 return Ok(Command::Help);
893 }
894 match args[0] {
895 "--help" | "-h" | "help" => Ok(Command::Help),
896 "--version" | "-V" | "version" => Ok(Command::Version),
897 "serve" => parse_serve(&args[1..]),
898 "mcp" => parse_mcp(&args[1..]),
899 "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
900 "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
901 "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
902 "asof" => parse_asof(&args[1..]),
903 "algo" => parse_algo(&args[1..]),
904 "query" => parse_query(&args[1..]),
905 "snapshot" => parse_snapshot(&args[1..]),
906 "schema" => parse_schema(&args[1..]),
907 "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
908 "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
909 "backup" => parse_backup(&args[1..]),
910 "export" => parse_export(&args[1..]),
911 "recall" => parse_dir_or_auto("recall", &args[1..])
912 .map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
913 "brief" => parse_dir_or_auto("brief", &args[1..])
914 .map(|(db_dir, auto)| Command::Brief { db_dir, auto }),
915 "intercept" => parse_dir_or_auto("intercept", &args[1..])
916 .map(|(db_dir, auto)| Command::Intercept { db_dir, auto }),
917 "impact-hook" => parse_dir_or_auto("impact-hook", &args[1..])
918 .map(|(db_dir, auto)| Command::ImpactHook { db_dir, auto }),
919 "enrich" => parse_dir_or_auto("enrich", &args[1..])
920 .map(|(db_dir, auto)| Command::Enrich { db_dir, auto }),
921 "sync" => parse_sync(&args[1..]),
922 "map" => parse_dir_with_json("map", &args[1..])
923 .map(|(db_dir, json)| Command::Map { db_dir, json }),
924 "explore" => parse_explore(&args[1..]),
925 "context" => parse_context(&args[1..]),
926 "impact" => parse_positional("impact", &args[1..], 1, usize::MAX)
927 .map(|(db_dir, files)| Command::Impact { db_dir, files }),
928 "owners" => {
929 parse_positional("owners", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Owners {
930 db_dir,
931 path: rest[0].clone(),
932 })
933 }
934 "why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
935 db_dir,
936 a: rest[0].clone(),
937 b: rest[1].clone(),
938 }),
939 "touch" => parse_touch(&args[1..]),
940 "ingest-git" => parse_ingest_git(&args[1..]),
941 "install" => parse_install_cmd(&args[1..]).map(Command::Install),
942 "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
943 "disable" => parse_toggle_cmd(&args[1..]).map(Command::Disable),
944 "enable" => parse_toggle_cmd(&args[1..]).map(Command::Enable),
945 "doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
946 other => Err(format!("unknown command: {other}")),
947 }
948}
949
950fn default_addr() -> SocketAddr {
951 SocketAddr::from(([127, 0, 0, 1], 8080))
952}
953
954fn parse_serve(args: &[&str]) -> Result<Command, String> {
955 let mut db_dir = None;
956 let mut addr = default_addr();
957 let mut ui = ServeUi::Embedded;
958 let mut saw_ui = false;
959 let mut saw_no_ui = false;
960 let mut demo_if_empty = false;
961 let mut token = None;
962 let mut role_tokens: Vec<(String, String)> = Vec::new();
963 let mut snapshot_every = None;
964 let mut tls_cert: Option<PathBuf> = None;
965 let mut tls_key: Option<PathBuf> = None;
966 let mut i = 0;
967 while i < args.len() {
968 let a = args[i];
969 if a == "--addr" {
970 let val = args
971 .get(i + 1)
972 .copied()
973 .ok_or_else(|| "missing value for --addr".to_string())?;
974 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
975 i += 2;
976 } else if let Some(val) = a.strip_prefix("--addr=") {
977 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
978 i += 1;
979 } else if a == "--ui" {
980 let val = args
981 .get(i + 1)
982 .copied()
983 .ok_or_else(|| "missing value for --ui".to_string())?;
984 ui = ServeUi::Filesystem(PathBuf::from(val));
985 saw_ui = true;
986 i += 2;
987 } else if let Some(val) = a.strip_prefix("--ui=") {
988 ui = ServeUi::Filesystem(PathBuf::from(val));
989 saw_ui = true;
990 i += 1;
991 } else if a == "--no-ui" {
992 ui = ServeUi::None;
993 saw_no_ui = true;
994 i += 1;
995 } else if a == "--demo-if-empty" {
996 demo_if_empty = true;
997 i += 1;
998 } else if a == "--token" {
999 let val = args
1000 .get(i + 1)
1001 .copied()
1002 .ok_or_else(|| "missing value for --token".to_string())?;
1003 token = Some(val.to_string());
1004 i += 2;
1005 } else if let Some(val) = a.strip_prefix("--token=") {
1006 token = Some(val.to_string());
1007 i += 1;
1008 } else if a == "--role-token" {
1009 let val = args
1010 .get(i + 1)
1011 .copied()
1012 .ok_or_else(|| "missing value for --role-token".to_string())?;
1013 let (tok, role) = parse_role_token(val)?;
1014 role_tokens.push((tok, role));
1015 i += 2;
1016 } else if let Some(val) = a.strip_prefix("--role-token=") {
1017 let (tok, role) = parse_role_token(val)?;
1018 role_tokens.push((tok, role));
1019 i += 1;
1020 } else if a == "--snapshot-every" {
1021 let val = args
1022 .get(i + 1)
1023 .copied()
1024 .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
1025 snapshot_every = Some(parse_snapshot_every(val)?);
1026 i += 2;
1027 } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
1028 snapshot_every = Some(parse_snapshot_every(val)?);
1029 i += 1;
1030 } else if a == "--tls-cert" {
1031 let val = args
1032 .get(i + 1)
1033 .copied()
1034 .ok_or_else(|| "missing value for --tls-cert".to_string())?;
1035 tls_cert = Some(PathBuf::from(val));
1036 i += 2;
1037 } else if let Some(val) = a.strip_prefix("--tls-cert=") {
1038 tls_cert = Some(PathBuf::from(val));
1039 i += 1;
1040 } else if a == "--tls-key" {
1041 let val = args
1042 .get(i + 1)
1043 .copied()
1044 .ok_or_else(|| "missing value for --tls-key".to_string())?;
1045 tls_key = Some(PathBuf::from(val));
1046 i += 2;
1047 } else if let Some(val) = a.strip_prefix("--tls-key=") {
1048 tls_key = Some(PathBuf::from(val));
1049 i += 1;
1050 } else if a.starts_with('-') {
1051 return Err(format!("unexpected flag: {a}"));
1052 } else if db_dir.is_none() {
1053 db_dir = Some(PathBuf::from(a));
1054 i += 1;
1055 } else {
1056 return Err(format!("unexpected extra argument: {a}"));
1057 }
1058 }
1059 if saw_ui && saw_no_ui {
1060 return Err("cannot combine --ui and --no-ui".to_string());
1061 }
1062 match (&tls_cert, &tls_key) {
1063 (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
1064 (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
1065 _ => {}
1066 }
1067 let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
1068 Ok(Command::Serve {
1069 db_dir,
1070 addr,
1071 ui,
1072 demo_if_empty,
1073 token,
1074 role_tokens,
1075 snapshot_every,
1076 tls_cert,
1077 tls_key,
1078 })
1079}
1080
1081fn parse_role_token(val: &str) -> Result<(String, String), String> {
1082 let (tok, role) = val
1083 .split_once(':')
1084 .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
1085 if tok.is_empty() {
1086 return Err("--role-token: TOKEN must not be empty".to_string());
1087 }
1088 if role.is_empty() {
1089 return Err("--role-token: ROLE must not be empty".to_string());
1090 }
1091 Ok((tok.to_string(), role.to_string()))
1092}
1093
1094fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
1095 let secs: u64 = val
1096 .parse()
1097 .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
1098 if secs == 0 {
1099 return Err("--snapshot-every must be a positive number of seconds".into());
1100 }
1101 Ok(Duration::from_secs(secs))
1102}
1103
1104pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
1106 if !dir.is_dir() {
1107 return Err(format!("--ui directory does not exist: {}", dir.display()));
1108 }
1109 let index = dir.join("index.html");
1110 if !index.is_file() {
1111 return Err(format!(
1112 "--ui directory is missing index.html: {}",
1113 dir.display()
1114 ));
1115 }
1116 Ok(dir.to_path_buf())
1117}
1118
1119fn parse_asof(args: &[&str]) -> Result<Command, String> {
1120 let mut db_dir = None;
1121 let mut commit: Option<u64> = None;
1122 let mut query: Option<String> = None;
1123 let mut i = 0;
1124 while i < args.len() {
1125 let a = args[i];
1126 if a == "--commit" {
1127 let val = args
1128 .get(i + 1)
1129 .copied()
1130 .ok_or_else(|| "missing value for --commit".to_string())?;
1131 commit = Some(
1132 val.parse()
1133 .map_err(|_| format!("invalid commit index: {val}"))?,
1134 );
1135 i += 2;
1136 } else if let Some(val) = a.strip_prefix("--commit=") {
1137 commit = Some(
1138 val.parse()
1139 .map_err(|_| format!("invalid commit index: {val}"))?,
1140 );
1141 i += 1;
1142 } else if a == "--query" {
1143 let val = args
1144 .get(i + 1)
1145 .copied()
1146 .ok_or_else(|| "missing value for --query".to_string())?;
1147 query = Some(val.to_string());
1148 i += 2;
1149 } else if let Some(val) = a.strip_prefix("--query=") {
1150 query = Some(val.to_string());
1151 i += 1;
1152 } else if a.starts_with('-') {
1153 return Err(format!("unexpected flag: {a}"));
1154 } else if db_dir.is_none() {
1155 db_dir = Some(PathBuf::from(a));
1156 i += 1;
1157 } else {
1158 return Err(format!("unexpected extra argument: {a}"));
1159 }
1160 }
1161 let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
1162 let commit = commit.ok_or_else(|| "asof requires --commit N".to_string())?;
1163 Ok(Command::AsOf {
1164 db_dir,
1165 commit,
1166 query,
1167 })
1168}
1169
1170pub fn run_asof(db_dir: &Path, commit: u64, query: Option<&str>) -> Result<String, CliError> {
1172 let total = wal_commit_count_at(db_dir)?;
1173 let db = GraphDb::open_at(db_dir, commit)?;
1174 let mut out = String::new();
1175 let _ = writeln!(out, "as-of commit {} of {}", commit, total);
1176 if let Some(cypher) = query {
1177 let params = BTreeMap::new();
1178 let rs = db.query(cypher, ¶ms)?;
1179 out.push_str(&format_result_set(&rs));
1180 }
1181 Ok(out)
1182}
1183
1184fn parse_query(args: &[&str]) -> Result<Command, String> {
1185 let mut db_dir = None;
1186 let mut query_flag: Option<String> = None;
1187 let mut cypher_parts: Vec<&str> = Vec::new();
1188 let mut i = 0;
1189 while i < args.len() {
1190 let a = args[i];
1191 if a == "--query" {
1192 let val = args
1193 .get(i + 1)
1194 .copied()
1195 .ok_or_else(|| "missing value for --query".to_string())?;
1196 query_flag = Some(val.to_string());
1197 i += 2;
1198 } else if let Some(val) = a.strip_prefix("--query=") {
1199 query_flag = Some(val.to_string());
1200 i += 1;
1201 } else if a.starts_with('-') {
1202 return Err(format!("unexpected flag: {a}"));
1203 } else if db_dir.is_none() {
1204 db_dir = Some(PathBuf::from(a));
1205 i += 1;
1206 } else {
1207 cypher_parts.push(a);
1208 i += 1;
1209 }
1210 }
1211 let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
1212 let cypher = if let Some(q) = query_flag {
1213 if !cypher_parts.is_empty() {
1214 return Err(
1215 "query: pass Cypher as remaining arguments or --query, not both".to_string(),
1216 );
1217 }
1218 q
1219 } else {
1220 if cypher_parts.is_empty() {
1221 return Err("query requires a Cypher string".to_string());
1222 }
1223 cypher_parts.join(" ")
1224 };
1225 Ok(Command::Query { db_dir, cypher })
1226}
1227
1228pub fn run_query(db_dir: &Path, cypher: &str) -> Result<String, CliError> {
1230 let params = BTreeMap::new();
1231 let is_write = is_write_query(cypher).map_err(CliError)?;
1232 let rs = if is_write {
1233 let mut db = GraphDb::open(db_dir)?;
1234 db.query_write(cypher, ¶ms)?
1235 } else {
1236 let db = GraphDb::open(db_dir)?;
1237 db.query(cypher, ¶ms)?
1238 };
1239 Ok(format_result_set(&rs))
1240}
1241
1242fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
1243 let mut db_dir = None;
1244 let mut wal = WalDisposition::Archive;
1247 let mut retention: Option<u32> = None;
1248 let mut i = 0;
1249 while i < args.len() {
1250 let a = args[i];
1251 if a == "--keep-wal" {
1252 wal = WalDisposition::Keep;
1253 i += 1;
1254 } else if a == "--truncate" {
1255 wal = WalDisposition::Truncate;
1256 i += 1;
1257 } else if a == "--archive-wal" {
1258 wal = WalDisposition::Archive;
1260 i += 1;
1261 } else if a.starts_with("--retention=") {
1262 let v = a.trim_start_matches("--retention=");
1263 retention = Some(
1264 v.parse::<u32>()
1265 .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
1266 );
1267 i += 1;
1268 } else if a == "--retention" {
1269 i += 1;
1270 let v = args
1271 .get(i)
1272 .ok_or_else(|| "--retention requires a value".to_string())?;
1273 retention = Some(
1274 v.parse::<u32>()
1275 .map_err(|e| format!("--retention value error: {e}"))?,
1276 );
1277 i += 1;
1278 } else if a.starts_with('-') {
1279 return Err(format!("unexpected flag: {a}"));
1280 } else if db_dir.is_none() {
1281 db_dir = Some(PathBuf::from(a));
1282 i += 1;
1283 } else {
1284 return Err(format!("unexpected extra argument: {a}"));
1285 }
1286 }
1287 let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
1288 Ok(Command::Snapshot {
1289 db_dir,
1290 wal,
1291 retention,
1292 })
1293}
1294
1295pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
1304 let current = core_api::SNAPSHOT_VERSION;
1305 let from_ver = core_api::snapshot_version_at(db_dir)?;
1306
1307 if from_ver == Some(current) {
1308 return Ok(format!("already current (V{current})\n"));
1309 }
1310
1311 if from_ver.is_some() {
1316 std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
1317 }
1318
1319 let mut db = GraphDb::open_with_options(
1322 db_dir,
1323 core_api::OpenOptions {
1324 auto_migrate: false,
1325 ..Default::default()
1326 },
1327 )?;
1328 db.snapshot()?;
1329
1330 let msg = match from_ver {
1331 Some(ver) => format!("migrated V{ver} -> V{current}\n"),
1332 None => format!("migrated WAL-only -> V{current}\n"),
1333 };
1334 Ok(msg)
1335}
1336
1337pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
1343 if !db_dir.join("snapshot.bin").exists() {
1346 return Err(CliError(format!(
1347 "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
1348 db_dir.display(),
1349 db_dir.display()
1350 )));
1351 }
1352 let results = core_api::verify_snapshot(db_dir)
1353 .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
1354 let mut any_fail = false;
1355 let mut out = String::new();
1356 for (id, section_name, byte_len, result) in &results {
1357 match result {
1358 Ok(()) => {
1359 let _ = writeln!(
1360 out,
1361 " section {:2} ({:<12}) {:>10} bytes OK",
1362 id, section_name, byte_len
1363 );
1364 }
1365 Err(msg) => {
1366 let _ = writeln!(
1367 out,
1368 " section {:2} ({:<12}) {:>10} bytes CORRUPT: {msg}",
1369 id, section_name, byte_len
1370 );
1371 any_fail = true;
1372 }
1373 }
1374 }
1375 if any_fail {
1376 Err(CliError(format!("integrity check FAILED:\n{out}")))
1377 } else {
1378 Ok(format!(
1379 "integrity check OK ({} sections):\n{out}",
1380 results.len()
1381 ))
1382 }
1383}
1384
1385#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1387pub enum WalDisposition {
1388 #[default]
1392 Archive,
1393 Keep,
1396 Truncate,
1401}
1402
1403impl WalDisposition {
1404 fn options(self) -> SnapshotOptions {
1405 match self {
1406 WalDisposition::Archive => AUTOMATIC_SNAPSHOT,
1407 WalDisposition::Keep => SnapshotOptions {
1408 keep_wal: true,
1409 archive_wal: false,
1410 },
1411 WalDisposition::Truncate => SnapshotOptions {
1412 keep_wal: false,
1413 archive_wal: false,
1414 },
1415 }
1416 }
1417}
1418
1419pub fn run_snapshot(
1421 db_dir: &Path,
1422 wal: WalDisposition,
1423 retention: Option<u32>,
1424) -> Result<String, CliError> {
1425 let mut db = GraphDb::open(db_dir)?;
1426 if wal == WalDisposition::Archive {
1427 db.set_wal_archive_retention(retention);
1428 }
1429 db.snapshot_with(wal.options())?;
1430 Ok(format!(
1431 "snapshot written: {}\n",
1432 db_dir.join("snapshot.bin").display()
1433 ))
1434}
1435
1436fn parse_schema(args: &[&str]) -> Result<Command, String> {
1437 if args.is_empty() {
1438 return Err("schema requires a subcommand: apply".to_string());
1439 }
1440 match args[0] {
1441 "apply" => parse_schema_apply(&args[1..]),
1442 other => Err(format!(
1443 "unknown schema subcommand: {other}; expected apply"
1444 )),
1445 }
1446}
1447
1448fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
1449 let mut db_dir = None;
1450 let mut schema_file = None;
1451 for a in args {
1452 if a.starts_with('-') {
1453 return Err(format!("unexpected flag: {a}"));
1454 }
1455 if db_dir.is_none() {
1456 db_dir = Some(PathBuf::from(*a));
1457 } else if schema_file.is_none() {
1458 schema_file = Some(PathBuf::from(*a));
1459 } else {
1460 return Err(format!("unexpected extra argument: {a}"));
1461 }
1462 }
1463 let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
1464 let schema_file =
1465 schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
1466 Ok(Command::SchemaApply {
1467 db_dir,
1468 schema_file,
1469 })
1470}
1471
1472pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
1475 let json = std::fs::read_to_string(schema_file)
1476 .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
1477 let schema: Schema = serde_json::from_str(&json).map_err(|e| {
1478 CliError(format!(
1479 "invalid schema JSON in {}: {e}",
1480 schema_file.display()
1481 ))
1482 })?;
1483 let mut db = GraphDb::open(db_dir)?;
1484 let diff = db.apply_schema(&schema)?;
1485 let mut out = String::new();
1486 for entry in &diff.created {
1487 let _ = writeln!(out, "created {entry}");
1488 }
1489 for entry in &diff.updated {
1490 let _ = writeln!(out, "updated {entry}");
1491 }
1492 for entry in &diff.unchanged {
1493 let _ = writeln!(out, "unchanged {entry}");
1494 }
1495 if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
1496 let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
1497 }
1498 Ok(out)
1499}
1500
1501fn parse_backup(args: &[&str]) -> Result<Command, String> {
1502 let mut db_dir = None;
1503 let mut dest = None;
1504 for a in args {
1505 if a.starts_with('-') {
1506 return Err(format!("unexpected flag: {a}"));
1507 }
1508 if db_dir.is_none() {
1509 db_dir = Some(PathBuf::from(*a));
1510 } else if dest.is_none() {
1511 dest = Some(PathBuf::from(*a));
1512 } else {
1513 return Err(format!("unexpected extra argument: {a}"));
1514 }
1515 }
1516 let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
1517 let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
1518 Ok(Command::Backup { db_dir, dest })
1519}
1520
1521fn parse_export(args: &[&str]) -> Result<Command, String> {
1522 let mut db_dir = None;
1523 let mut dest = None;
1524 let mut format = ExportFormat::Jsonl;
1525 let mut i = 0;
1526 while i < args.len() {
1527 let a = args[i];
1528 if a == "--format" {
1529 let val = args
1530 .get(i + 1)
1531 .copied()
1532 .ok_or_else(|| "missing value for --format".to_string())?;
1533 format = ExportFormat::parse(val).ok_or_else(|| {
1534 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1535 })?;
1536 i += 2;
1537 } else if let Some(val) = a.strip_prefix("--format=") {
1538 format = ExportFormat::parse(val).ok_or_else(|| {
1539 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1540 })?;
1541 i += 1;
1542 } else if a.starts_with('-') {
1543 return Err(format!("unexpected flag: {a}"));
1544 } else if db_dir.is_none() {
1545 db_dir = Some(PathBuf::from(a));
1546 i += 1;
1547 } else if dest.is_none() {
1548 dest = Some(PathBuf::from(a));
1549 i += 1;
1550 } else {
1551 return Err(format!("unexpected extra argument: {a}"));
1552 }
1553 }
1554 let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
1555 let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
1556 Ok(Command::Export {
1557 db_dir,
1558 dest,
1559 format,
1560 })
1561}
1562
1563pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
1565 let db = GraphDb::open(db_dir)?;
1566 Ok(db.backup_to(dest)?)
1567}
1568
1569pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
1571 let mut out = String::new();
1572 writeln!(out, "backup to: {}", dest.display()).unwrap();
1573 writeln!(out, " files: {}", report.files.join(", ")).unwrap();
1574 writeln!(out, " bytes: {}", report.bytes).unwrap();
1575 writeln!(out, " verified: {}", report.verified).unwrap();
1576 out
1577}
1578
1579pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
1581 let db = GraphDb::open(db_dir)?;
1582 let nodes = db.all_nodes_for_export();
1583 let edges = db.all_edges_for_export();
1584 let mut rules = db.rules();
1585 rules.sort_by(|a, b| a.name.cmp(&b.name));
1586 let node_count = nodes.len();
1587 let edge_count = edges.len();
1588 let rule_count = rules.len();
1589 match format {
1590 ExportFormat::Jsonl => {
1591 export::write_jsonl(&nodes, &edges, &rules, dest)?;
1592 Ok(format!(
1593 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1594 dest.display(),
1595 format.name(),
1596 node_count,
1597 edge_count,
1598 rule_count
1599 ))
1600 }
1601 ExportFormat::Parquet => {
1602 export::write_parquet(&nodes, &edges, &rules, dest)?;
1603 Ok(format!(
1604 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1605 dest.display(),
1606 format.name(),
1607 node_count,
1608 edge_count,
1609 rule_count
1610 ))
1611 }
1612 ExportFormat::Graphml => {
1614 let file_path = export::write_graphml(&nodes, &edges, dest)?;
1615 Ok(format!(
1616 "exported to {} (format={}): {} nodes, {} edges\n",
1617 file_path.display(),
1618 format.name(),
1619 node_count,
1620 edge_count,
1621 ))
1622 }
1623 }
1624}
1625
1626fn format_result_set(rs: &ResultSet) -> String {
1627 let mut out = String::new();
1628 let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
1629 for i in 0..rs.len() {
1630 let cells: Vec<String> = rs
1631 .columns()
1632 .iter()
1633 .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
1634 .collect();
1635 let _ = writeln!(out, " {}", cells.join(" "));
1636 }
1637 out
1638}
1639
1640fn parse_algo(args: &[&str]) -> Result<Command, String> {
1641 if args.is_empty() {
1642 return Err(
1643 "algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
1644 );
1645 }
1646 let subcmd = match args[0] {
1647 "pagerank" => AlgoSubcmd::Pagerank,
1648 "wcc" => AlgoSubcmd::Wcc,
1649 "degree" => AlgoSubcmd::Degree,
1650 "communities" => AlgoSubcmd::Communities,
1651 other => {
1652 return Err(format!(
1653 "unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
1654 ))
1655 }
1656 };
1657 let rest = &args[1..];
1658 let mut db_dir = None;
1659 let mut top: usize = 20;
1660 let mut dir = AlgoDir::Both;
1661 let mut edge_types: Vec<String> = Vec::new();
1662 let mut weight_prop: Option<String> = None;
1663 let mut min_weight: Option<f64> = None;
1664 let mut i = 0;
1665 while i < rest.len() {
1666 let a = rest[i];
1667 if a == "--top" {
1668 let val = rest
1669 .get(i + 1)
1670 .copied()
1671 .ok_or_else(|| "missing value for --top".to_string())?;
1672 top = val
1673 .parse()
1674 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1675 i += 2;
1676 } else if let Some(val) = a.strip_prefix("--top=") {
1677 top = val
1678 .parse()
1679 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1680 i += 1;
1681 } else if a == "--dir" {
1682 let val = rest
1683 .get(i + 1)
1684 .copied()
1685 .ok_or_else(|| "missing value for --dir".to_string())?;
1686 dir = parse_algo_dir(val)?;
1687 i += 2;
1688 } else if let Some(val) = a.strip_prefix("--dir=") {
1689 dir = parse_algo_dir(val)?;
1690 i += 1;
1691 } else if a == "--edge-type" {
1692 let val = rest
1693 .get(i + 1)
1694 .copied()
1695 .ok_or_else(|| "missing value for --edge-type".to_string())?;
1696 edge_types.push(val.to_string());
1697 i += 2;
1698 } else if let Some(val) = a.strip_prefix("--edge-type=") {
1699 edge_types.push(val.to_string());
1700 i += 1;
1701 } else if a == "--weight-prop" {
1702 let val = rest
1703 .get(i + 1)
1704 .copied()
1705 .ok_or_else(|| "missing value for --weight-prop".to_string())?;
1706 weight_prop = Some(val.to_string());
1707 i += 2;
1708 } else if let Some(val) = a.strip_prefix("--weight-prop=") {
1709 weight_prop = Some(val.to_string());
1710 i += 1;
1711 } else if a == "--min-weight" {
1712 let val = rest
1713 .get(i + 1)
1714 .copied()
1715 .ok_or_else(|| "missing value for --min-weight".to_string())?;
1716 min_weight = Some(
1717 val.parse()
1718 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1719 );
1720 i += 2;
1721 } else if let Some(val) = a.strip_prefix("--min-weight=") {
1722 min_weight = Some(
1723 val.parse()
1724 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1725 );
1726 i += 1;
1727 } else if a.starts_with('-') {
1728 return Err(format!("unexpected flag: {a}"));
1729 } else if db_dir.is_none() {
1730 db_dir = Some(PathBuf::from(a));
1731 i += 1;
1732 } else {
1733 return Err(format!("unexpected extra argument: {a}"));
1734 }
1735 }
1736 let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
1737 Ok(Command::Algo {
1738 db_dir,
1739 subcmd,
1740 top,
1741 dir,
1742 edge_types,
1743 weight_prop,
1744 min_weight,
1745 })
1746}
1747
1748fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
1750 match val.to_ascii_lowercase().as_str() {
1751 "out" => Ok(AlgoDir::Out),
1752 "in" => Ok(AlgoDir::In),
1753 "both" => Ok(AlgoDir::Both),
1754 other => Err(format!("--dir must be one of out | in | both, got {other}")),
1755 }
1756}
1757
1758pub fn run_map(db_dir: &Path, json: bool) -> Result<String, CliError> {
1764 let db = open_for_reading(db_dir)?;
1765 let map = repograph::repo_map(&db, &repograph::MapOptions::default());
1766 if json {
1767 let mut out = serde_json::to_string_pretty(&map)
1768 .map_err(|e| CliError(format!("serialise map: {e}")))?;
1769 out.push('\n');
1770 return Ok(out);
1771 }
1772 Ok(repograph::render_map(&map))
1773}
1774
1775pub fn run_brief(db_dir: &Path) -> Result<String, CliError> {
1781 let db = open_for_reading(db_dir)?;
1782 let report = repograph::brief(&db, &repograph::BriefOptions::default());
1783 let code_graph = db.has_node(ingest_git::SYNC_KEY);
1784 Ok(repograph::render_brief(
1785 &report,
1786 &reach_line(db_dir, code_graph),
1787 ))
1788}
1789
1790fn reach_line(db_dir: &Path, code_graph: bool) -> String {
1812 let bin = install::detect_mcp_command(None).shell();
1813 let db = install::sh_quote(&db_dir.to_string_lossy());
1814 let (tools, shell) = if code_graph {
1815 (
1816 format!("explore <target> (MCP tool){}or:", repograph::render::SEP),
1817 format!("{bin} explore {db} <target>"),
1818 )
1819 } else {
1820 (
1821 format!(
1822 "explain_association <a> <b>{sep}query '<cypher>' (MCP tools; add role: <name> \
1823 to see as a role){sep}or:",
1824 sep = repograph::render::SEP
1825 ),
1826 format!("{bin} query {db} '<cypher>'"),
1827 )
1828 };
1829 match install::delivery_for_store(db_dir) {
1830 install::Delivery::Cli => shell,
1831 _ => format!("{tools} {shell}"),
1832 }
1833}
1834
1835fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
1845 if !db_dir.exists() {
1846 return Err(CliError(format!("no store at {}", db_dir.display())));
1847 }
1848 Ok(GraphDb::open_with_options(
1849 db_dir,
1850 core_api::OpenOptions {
1851 auto_migrate: false,
1852 repair_wal: false,
1853 read_only: true,
1854 },
1855 )?)
1856}
1857
1858pub fn run_explore(
1864 db_dir: &Path,
1865 target: &str,
1866 depth: repograph::Depth,
1867 full: bool,
1868) -> Result<String, CliError> {
1869 let db = open_for_reading(db_dir)?;
1870 let report = repograph::explore(&db, None, target, depth, full);
1871 Ok(repograph::render_explore(
1872 &report,
1873 repograph::DEFAULT_EXPLORE_BYTES,
1874 ))
1875}
1876
1877pub fn run_context(db_dir: &Path, target: &str, full: bool) -> Result<String, CliError> {
1883 let db = open_for_reading(db_dir)?;
1884 Ok(repograph::render_context(&repograph::context_with(
1885 &db,
1886 None,
1887 target,
1888 &repograph::ContextOptions { source: full },
1889 )))
1890}
1891
1892pub fn run_impact(db_dir: &Path, files: &[String]) -> Result<String, CliError> {
1897 let db = open_for_reading(db_dir)?;
1898 let modified: BTreeSet<String> = files.iter().cloned().collect();
1899 let report = repograph::impact(&db, files, &modified, &repograph::ImpactOptions::default());
1900 Ok(repograph::render_impact(&report))
1901}
1902
1903pub fn run_owners(db_dir: &Path, path: &str) -> Result<String, CliError> {
1905 let db = open_for_reading(db_dir)?;
1906 match repograph::owners(&db, path, None) {
1907 Some(report) => Ok(repograph::render_owners(&report)),
1908 None => Err(CliError(format!("no file in the store at {path}"))),
1909 }
1910}
1911
1912pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
1914 let db = open_for_reading(db_dir)?;
1915 Ok(repograph::render_why(&repograph::why(&db, a, b)))
1916}
1917
1918#[allow(clippy::too_many_arguments)]
1924pub fn run_algo(
1925 db_dir: &Path,
1926 subcmd: &AlgoSubcmd,
1927 top: usize,
1928 dir: AlgoDir,
1929 edge_types: Vec<String>,
1930 weight_prop: Option<String>,
1931 min_weight: Option<f64>,
1932) -> Result<String, CliError> {
1933 let db = GraphDb::open(db_dir)?;
1934 match subcmd {
1935 AlgoSubcmd::Pagerank => {
1936 let config = PageRankConfig {
1937 direction: dir,
1938 ..PageRankConfig::default()
1939 };
1940 let report = db.pagerank(&config);
1941 Ok(format_pagerank(&report, top))
1942 }
1943 AlgoSubcmd::Wcc => {
1944 let config = WccConfig::default();
1945 let report = db.connected_components(&config);
1946 Ok(format_wcc(&report, top))
1947 }
1948 AlgoSubcmd::Degree => {
1949 let config = DegreeConfig {
1950 direction: dir,
1951 ..DegreeConfig::default()
1952 };
1953 let report = db.degree_centrality(&config);
1954 Ok(format_degree(&report, top))
1955 }
1956 AlgoSubcmd::Communities => {
1957 let config = LouvainConfig {
1958 edge_types,
1959 weight_prop,
1960 min_weight,
1961 ..LouvainConfig::default()
1962 };
1963 let report = db.communities(&config);
1964 Ok(format_communities(&report, top))
1965 }
1966 }
1967}
1968
1969fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
1970 let mut buf = String::new();
1971 let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
1972 let rows = if top == 0 {
1973 report.scores.as_slice()
1974 } else {
1975 &report.scores[..top.min(report.scores.len())]
1976 };
1977 for (i, (key, score)) in rows.iter().enumerate() {
1978 let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
1979 }
1980 buf
1981}
1982
1983fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
1984 let mut buf = String::new();
1985 let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
1986 let rows = if top == 0 {
1987 report.components.as_slice()
1988 } else {
1989 &report.components[..top.min(report.components.len())]
1990 };
1991 for (key, comp_id) in rows {
1992 let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
1993 }
1994 buf
1995}
1996
1997fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
1998 let mut buf = String::new();
1999 let _ = writeln!(
2000 buf,
2001 "== degree centrality (truncated={}) ==",
2002 report.truncated
2003 );
2004 let rows = if top == 0 {
2005 report.scores.as_slice()
2006 } else {
2007 &report.scores[..top.min(report.scores.len())]
2008 };
2009 for (i, (key, deg)) in rows.iter().enumerate() {
2010 let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
2011 }
2012 buf
2013}
2014
2015fn format_communities(report: &core_api::CommunityReport, top: usize) -> String {
2018 let mut buf = String::new();
2019 let trunc = if report.truncated { " (truncated)" } else { "" };
2020 let _ = writeln!(
2021 buf,
2022 "== communities (modularity={:.2}){trunc} ==",
2023 report.modularity
2024 );
2025 let rows = if top == 0 {
2026 report.communities.as_slice()
2027 } else {
2028 &report.communities[..top.min(report.communities.len())]
2029 };
2030 for c in rows {
2031 let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
2032 let _ = writeln!(
2033 buf,
2034 " {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
2035 c.id,
2036 c.members.len(),
2037 c.cohesion,
2038 preview.join(", ")
2039 );
2040 }
2041 buf
2042}
2043
2044fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
2049 let mut db_dir = None;
2050 let mut auto = false;
2051 for a in args {
2052 if *a == "--auto" {
2053 auto = true;
2054 } else if a.starts_with('-') {
2055 return Err(format!("unexpected flag: {a}"));
2056 } else if db_dir.is_some() {
2057 return Err(format!("unexpected extra argument: {a}"));
2058 } else {
2059 db_dir = Some(PathBuf::from(*a));
2060 }
2061 }
2062 match (&db_dir, auto) {
2063 (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
2064 (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
2065 _ => Ok((db_dir, auto)),
2066 }
2067}
2068
2069fn parse_mcp(args: &[&str]) -> Result<Command, String> {
2073 let all_tools = args.contains(&"--all-tools");
2074 let rest: Vec<&str> = args
2075 .iter()
2076 .copied()
2077 .filter(|a| *a != "--all-tools")
2078 .collect();
2079 parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
2080 db_dir,
2081 auto,
2082 all_tools,
2083 })
2084}
2085
2086fn parse_sync(args: &[&str]) -> Result<Command, String> {
2091 let json = args.contains(&"--json");
2092 let rest: Vec<&str> = args.iter().copied().filter(|a| *a != "--json").collect();
2093 parse_dir_or_auto("sync", &rest).map(|(db_dir, auto)| Command::Sync { db_dir, auto, json })
2094}
2095
2096fn parse_touch(args: &[&str]) -> Result<Command, String> {
2099 let mut db_dir = None;
2100 let mut auto = false;
2101 let mut files = Vec::new();
2102 for a in args {
2103 if *a == "--auto" {
2104 auto = true;
2105 } else if a.starts_with('-') {
2106 return Err(format!("unexpected flag: {a}"));
2107 } else if db_dir.is_none() && !auto {
2108 db_dir = Some(PathBuf::from(*a));
2109 } else {
2110 files.push(PathBuf::from(*a));
2111 }
2112 }
2113 if db_dir.is_none() && !auto {
2114 return Err("touch requires <db-dir> or --auto".into());
2115 }
2116 if db_dir.is_some() && auto {
2117 return Err("touch: --auto takes no <db-dir>".into());
2118 }
2119 Ok(Command::Touch {
2120 db_dir,
2121 auto,
2122 files,
2123 })
2124}
2125
2126fn parse_positional(
2133 cmd: &str,
2134 args: &[&str],
2135 min: usize,
2136 max: usize,
2137) -> Result<(PathBuf, Vec<String>), String> {
2138 let mut rest: Vec<String> = Vec::new();
2139 let mut db_dir: Option<PathBuf> = None;
2140 for a in args {
2141 if a.starts_with('-') {
2142 return Err(format!("unexpected flag: {a}"));
2143 }
2144 match db_dir {
2145 None => db_dir = Some(PathBuf::from(*a)),
2146 Some(_) => rest.push((*a).to_string()),
2147 }
2148 }
2149 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
2150 if rest.len() < min {
2151 return Err(format!(
2152 "{cmd} requires <db-dir> and {min} more argument{}",
2153 if min == 1 { "" } else { "s" }
2154 ));
2155 }
2156 if rest.len() > max {
2157 return Err(format!("unexpected extra argument: {}", rest[max]));
2158 }
2159 Ok((db_dir, rest))
2160}
2161
2162fn parse_explore(args: &[&str]) -> Result<Command, String> {
2167 let mut rest: Vec<String> = Vec::new();
2168 let mut db_dir: Option<PathBuf> = None;
2169 let mut depth = repograph::Depth::Context;
2170 let mut full = false;
2171 let mut want_depth = false;
2172 for a in args {
2173 if want_depth {
2174 depth = repograph::Depth::parse(a).ok_or_else(|| {
2175 format!(
2176 "--depth must be one of {}, got {a}",
2177 repograph::Depth::NAMES.join(" | ")
2178 )
2179 })?;
2180 want_depth = false;
2181 } else if *a == "--depth" {
2182 want_depth = true;
2183 } else if *a == "--full" {
2184 full = true;
2185 } else if a.starts_with('-') {
2186 return Err(format!("unexpected flag: {a}"));
2187 } else if db_dir.is_none() {
2188 db_dir = Some(PathBuf::from(*a));
2189 } else {
2190 rest.push((*a).to_string());
2191 }
2192 }
2193 if want_depth {
2194 return Err("--depth requires a value".to_string());
2195 }
2196 let db_dir = db_dir.ok_or_else(|| "explore requires <db-dir>".to_string())?;
2197 match rest.len() {
2198 0 => Err("explore requires <db-dir> and 1 more argument".to_string()),
2199 1 => Ok(Command::Explore {
2200 db_dir,
2201 target: rest.remove(0),
2202 depth,
2203 full,
2204 }),
2205 _ => Err(format!("unexpected extra argument: {}", rest[1])),
2206 }
2207}
2208
2209fn parse_context(args: &[&str]) -> Result<Command, String> {
2215 let mut rest: Vec<String> = Vec::new();
2216 let mut db_dir: Option<PathBuf> = None;
2217 let mut full = false;
2218 for a in args {
2219 if *a == "--full" {
2220 full = true;
2221 } else if a.starts_with('-') {
2222 return Err(format!("unexpected flag: {a}"));
2223 } else if db_dir.is_none() {
2224 db_dir = Some(PathBuf::from(*a));
2225 } else {
2226 rest.push((*a).to_string());
2227 }
2228 }
2229 let db_dir = db_dir.ok_or_else(|| "context requires <db-dir>".to_string())?;
2230 match rest.len() {
2231 0 => Err("context requires <db-dir> and 1 more argument".to_string()),
2232 1 => Ok(Command::Context {
2233 db_dir,
2234 target: rest.remove(0),
2235 full,
2236 }),
2237 _ => Err(format!("unexpected extra argument: {}", rest[1])),
2238 }
2239}
2240
2241fn parse_dir_with_json(cmd: &str, args: &[&str]) -> Result<(PathBuf, bool), String> {
2243 let mut db_dir = None;
2244 let mut json = false;
2245 for a in args {
2246 if *a == "--json" {
2247 json = true;
2248 } else if a.starts_with('-') {
2249 return Err(format!("unexpected flag: {a}"));
2250 } else if db_dir.is_some() {
2251 return Err(format!("unexpected extra argument: {a}"));
2252 } else {
2253 db_dir = Some(PathBuf::from(*a));
2254 }
2255 }
2256 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
2257 Ok((db_dir, json))
2258}
2259
2260fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
2261 let mut db_dir = None;
2262 for a in args {
2263 if a.starts_with('-') {
2264 return Err(format!("unexpected flag: {a}"));
2265 }
2266 if db_dir.is_some() {
2267 return Err(format!("unexpected extra argument: {a}"));
2268 }
2269 db_dir = Some(PathBuf::from(*a));
2270 }
2271 db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
2272}
2273
2274pub fn format_stats(stats: &Stats) -> String {
2276 let mut out = String::new();
2277 let _ = writeln!(
2278 out,
2279 "nodes: {} live, {} tombstoned",
2280 stats.nodes_live, stats.nodes_tombstoned
2281 );
2282 let _ = writeln!(out, "edges: {}", stats.edges);
2283 let _ = writeln!(out, "rules: {}", stats.rules.len());
2284 for r in &stats.rules {
2285 let _ = writeln!(
2286 out,
2287 " {:<28} edges={} tripped={}",
2288 r.name, r.edges, r.tripped
2289 );
2290 }
2291 out
2292}
2293
2294pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
2296 let db = SharedDb::open(dir)?;
2297 let stats = db.read().stats();
2298 Ok(stats)
2299}
2300
2301pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
2307 refuse_non_empty(dir)?;
2308
2309 let db = SharedDb::open(dir)?;
2310 let opts = IngestOptions::default();
2311 let mut auto_fk_rules = Vec::new();
2312
2313 {
2314 let mut w = db.write();
2315 for (label, json) in [
2316 ("Org", org_json()),
2317 ("Project", project_json()),
2318 ("Person", person_json()),
2319 ] {
2320 let report = w.ingest_json(label, &json, &opts)?;
2321 if !report.row_errors.is_empty() {
2322 return Err(CliError(format!(
2323 "demo ingest of {label} had row errors: {:?}",
2324 report.row_errors
2325 )));
2326 }
2327 auto_fk_rules.extend(report.rules_created);
2328 }
2329 let skill_fit = Predicate::Overlap {
2330 field: "skills".into(),
2331 min: 0.5,
2332 };
2333 let skill_fit_k = Some(default_max_edges(&skill_fit));
2334 w.create_rule(RuleDef {
2335 name: "skill_fit".into(),
2336 src_label: "Person".into(),
2337 dst_label: "Project".into(),
2338 predicate: skill_fit,
2339 edge_type: "FIT".into(),
2340 weight_prop: Some("score".into()),
2341 max_edges: skill_fit_k,
2342 approximate: false,
2343 via_label: None,
2344 via_edge: None,
2345 via_dir: None,
2346 })?;
2347 let founded_within = Predicate::NumericWithin {
2348 field: "founded_year".into(),
2349 tolerance: 2.0,
2350 };
2351 let founded_within_k = Some(default_max_edges(&founded_within));
2352 w.create_rule(RuleDef {
2353 name: "founded_within".into(),
2354 src_label: "Org".into(),
2355 dst_label: "Org".into(),
2356 predicate: founded_within,
2357 edge_type: "FOUNDED_WITHIN".into(),
2358 weight_prop: Some("score".into()),
2359 max_edges: founded_within_k,
2360 approximate: false,
2361 via_label: None,
2362 via_edge: None,
2363 via_dir: None,
2364 })?;
2365 let nearby_office = Predicate::GeoRadius {
2366 field: "office".into(),
2367 km: 50.0,
2368 };
2369 let nearby_office_k = Some(default_max_edges(&nearby_office));
2370 w.create_rule(RuleDef {
2371 name: "nearby_office".into(),
2372 src_label: "Org".into(),
2373 dst_label: "Org".into(),
2374 predicate: nearby_office,
2375 edge_type: "NEARBY_OFFICE".into(),
2376 weight_prop: Some("score".into()),
2377 max_edges: nearby_office_k,
2378 approximate: false,
2379 via_label: None,
2380 via_edge: None,
2381 via_dir: None,
2382 })?;
2383 let similar_interests = Predicate::VectorSimilar {
2384 field: "embedding".into(),
2385 min: 0.8,
2386 };
2387 let similar_interests_k = Some(default_max_edges(&similar_interests));
2388 w.create_rule(RuleDef {
2389 name: "similar_interests".into(),
2390 src_label: "Person".into(),
2391 dst_label: "Person".into(),
2392 predicate: similar_interests,
2393 edge_type: "SIMILAR".into(),
2394 weight_prop: Some("score".into()),
2395 max_edges: similar_interests_k,
2396 approximate: false,
2397 via_label: None,
2398 via_edge: None,
2399 via_dir: None,
2400 })?;
2401 for (label, field) in [("Org", "name"), ("Project", "name"), ("Person", "name")] {
2403 w.enable_fulltext(label, field)?;
2404 }
2405 }
2406
2407 let r = db.read();
2408 let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
2409 let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
2410 let stats = r.stats();
2411 let suggestion = r.suggest_rules().into_iter().next();
2413
2414 Ok(DemoOutcome {
2415 auto_fk_rules,
2416 sample_query: SAMPLE_QUERY.to_string(),
2417 sample_result,
2418 explanations,
2419 stats,
2420 suggestion,
2421 })
2422}
2423
2424fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
2425 if dir.is_file() {
2426 return Err(CliError(format!(
2427 "demo refuses a non-empty directory: {} is a file",
2428 dir.display()
2429 )));
2430 }
2431 if !dir.exists() {
2432 return Ok(true);
2433 }
2434 Ok(std::fs::read_dir(dir)?.next().is_none())
2435}
2436
2437fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
2438 if dir_is_empty_or_absent(dir)? {
2439 Ok(())
2440 } else {
2441 Err(CliError(format!(
2442 "demo refuses a non-empty directory: {} \
2443 (directory must be empty — including hidden files)",
2444 dir.display()
2445 )))
2446 }
2447}
2448
2449pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
2451 if dir_is_empty_or_absent(dir)? {
2452 Ok(Some(run_demo(dir)?))
2453 } else {
2454 Ok(None)
2455 }
2456}
2457
2458fn json_array(rows: impl IntoIterator<Item = String>) -> String {
2459 let mut out = String::from("[");
2460 let mut first = true;
2461 for row in rows {
2462 if !first {
2463 out.push(',');
2464 }
2465 first = false;
2466 out.push_str(&row);
2467 }
2468 out.push(']');
2469 out
2470}
2471
2472fn wrap_proj(i: usize) -> usize {
2474 (i - 1) % N_PROJECTS + 1
2475}
2476
2477fn skill_window_json(start: usize, len: usize) -> String {
2479 let parts: Vec<String> = (0..len)
2480 .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
2481 .collect();
2482 format!("[{}]", parts.join(","))
2483}
2484
2485fn org_office(i: usize) -> (f64, f64) {
2489 match i {
2490 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"),
2501 }
2502}
2503
2504fn person_embedding_json(i: usize) -> String {
2508 let mut v = [0.0_f64; 8];
2509 match i {
2510 9 | 19 | 29 => {
2511 v[0] = 0.8;
2512 v[1] = 0.6;
2513 }
2514 10 | 20 | 30 => {
2515 v[0] = 0.6;
2516 v[1] = 0.8;
2517 }
2518 _ => {
2519 let axis = (i - 1) % 10;
2520 debug_assert!(axis < 8);
2521 v[axis] = 1.0;
2522 }
2523 }
2524 let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
2525 format!("[{}]", parts.join(","))
2526}
2527
2528fn org_json() -> String {
2529 json_array((1..=N_ORGS).map(|i| {
2530 let year = 2010 + (i as i64 - 1);
2531 let (lat, lon) = org_office(i);
2532 format!(
2533 r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
2534 skill_window_json(i, 3)
2535 )
2536 }))
2537}
2538
2539fn project_json() -> String {
2540 json_array((1..=N_PROJECTS).map(|i| {
2541 let org = (i - 1) % N_ORGS + 1;
2542 format!(
2543 r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
2544 skill_window_json(i, 3)
2545 )
2546 }))
2547}
2548
2549fn person_json() -> String {
2550 json_array((1..=N_PEOPLE).map(|i| {
2551 let org = (i - 1) % N_ORGS + 1;
2552 let proj = (i - 1) % N_PROJECTS + 1;
2553 format!(
2554 r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
2555 person_embedding_json(i),
2556 skill_window_json(proj, 3)
2557 )
2558 }))
2559}
2560
2561pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
2563 let mut buf = String::new();
2564 let _ = writeln!(buf, "== demo ==");
2565 let _ = writeln!(
2566 buf,
2567 "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
2568 );
2569 let _ = writeln!(
2570 buf,
2571 "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
2572 );
2573 let _ = writeln!(
2574 buf,
2575 "numeric rule: founded_within (Org.founded_year, tolerance 2)"
2576 );
2577 let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
2578 let _ = writeln!(
2579 buf,
2580 "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
2581 );
2582 let _ = writeln!(buf);
2583 let _ = writeln!(buf, "== auto-FK rules ==");
2584 let mut names = out.auto_fk_rules.clone();
2585 names.sort();
2586 for name in names {
2587 let _ = writeln!(buf, " {name}");
2588 }
2589 let _ = writeln!(buf);
2590 let _ = writeln!(buf, "== query ==");
2591 let _ = writeln!(buf, "{}", out.sample_query);
2592 let _ = writeln!(buf);
2593 let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
2594 for i in 0..out.sample_result.len() {
2595 let cells: Vec<String> = out
2596 .sample_result
2597 .columns()
2598 .iter()
2599 .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
2600 .collect();
2601 let _ = writeln!(buf, " {}", cells.join(" "));
2602 }
2603 let _ = writeln!(buf);
2604 let _ = writeln!(
2605 buf,
2606 "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
2607 );
2608 for e in &out.explanations {
2609 let weight = e
2610 .weight
2611 .map(|w| fmt_value(&Value::Float(w)))
2612 .unwrap_or_else(|| "none".into());
2613 let _ = writeln!(
2614 buf,
2615 " rule={} type={} {}→{} weight={}",
2616 e.rule, e.edge_type, e.src_key, e.dst_key, weight
2617 );
2618 }
2619 let _ = writeln!(buf);
2620 let _ = writeln!(buf, "== serve ==");
2621 let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
2622
2623 if let Some(s) = &out.suggestion {
2625 let _ = writeln!(buf);
2626 let _ = writeln!(buf, "== suggested rule (teaser) ==");
2627 let _ = writeln!(buf, " {}", s.def.name);
2628 let _ = writeln!(
2629 buf,
2630 " {} → {} via {:?}",
2631 s.def.src_label, s.def.dst_label, s.def.predicate
2632 );
2633 let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
2634 let _ = writeln!(buf, " {}", s.rationale);
2635 let _ = writeln!(
2636 buf,
2637 " (run `mushroomdb suggest {}` for full analysis)",
2638 dir.display()
2639 );
2640 }
2641
2642 buf
2643}
2644
2645pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
2647 let db = GraphDb::open(dir)?;
2648 Ok(db.suggest_rules())
2649}
2650
2651pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
2653 let mut buf = String::new();
2654 if suggestions.is_empty() {
2655 let _ = writeln!(
2656 buf,
2657 "no rule suggestions (database may be empty or rules already cover all patterns)"
2658 );
2659 return buf;
2660 }
2661 let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
2662 for (i, s) in suggestions.iter().enumerate() {
2663 let _ = writeln!(buf);
2664 let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
2665 let _ = writeln!(
2666 buf,
2667 " {} → {} via {:?}",
2668 s.def.src_label, s.def.dst_label, s.def.predicate
2669 );
2670 let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
2671 let _ = writeln!(buf, " rationale : {}", s.rationale);
2672 if !s.examples.is_empty() {
2673 let _ = writeln!(buf, " examples :");
2674 for (src, dst, score) in &s.examples {
2675 let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
2676 }
2677 }
2678 let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
2679 let _ = writeln!(
2680 buf,
2681 " to apply : POST /rules or db.create_rule(suggestion.def)"
2682 );
2683 }
2684 buf
2685}
2686
2687fn fmt_value(v: &Value) -> String {
2688 match v {
2689 Value::Int(i) => i.to_string(),
2690 Value::Float(f) => {
2691 let s = format!("{f}");
2692 if s.contains('.') || s.contains('e') || s.contains('E') {
2693 s
2694 } else {
2695 format!("{s}.0")
2696 }
2697 }
2698 Value::Str(s) => s.clone(),
2699 Value::Bool(b) => b.to_string(),
2700 Value::List(xs) => {
2701 let inner: Vec<String> = xs.iter().map(fmt_value).collect();
2702 format!("[{}]", inner.join(", "))
2703 }
2704 Value::Map(m) => {
2705 let inner: Vec<String> = m
2706 .iter()
2707 .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
2708 .collect();
2709 format!("{{{}}}", inner.join(", "))
2710 }
2711 }
2712}
2713
2714fn fmt_cell(cell: Option<&Value>) -> String {
2715 match cell {
2716 None => "null".into(),
2717 Some(v) => fmt_value(v),
2718 }
2719}
2720
2721#[cfg(test)]
2722mod tests {
2723 use super::*;
2724 use std::collections::BTreeSet;
2725 use std::net::SocketAddr;
2726 use std::path::PathBuf;
2727
2728 fn tmp(name: &str) -> PathBuf {
2729 let nanos = std::time::SystemTime::now()
2730 .duration_since(std::time::UNIX_EPOCH)
2731 .expect("clock")
2732 .as_nanos();
2733 let d = std::env::temp_dir().join(format!(
2734 "graphdb-cli-{}-{}-{}",
2735 name,
2736 std::process::id(),
2737 nanos
2738 ));
2739 let _ = std::fs::remove_dir_all(&d);
2740 d
2741 }
2742
2743 fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
2744 let g = db.read();
2745 let mut out = BTreeSet::new();
2746 for i in 1..=N_ORGS {
2747 let src = format!("org-{i:02}");
2748 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2749 for dst in nbrs {
2750 out.insert((src.clone(), dst));
2751 }
2752 }
2753 }
2754 for i in 1..=N_PEOPLE {
2755 let src = format!("person-{i:02}");
2756 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2757 for dst in nbrs {
2758 out.insert((src.clone(), dst));
2759 }
2760 }
2761 }
2762 out
2763 }
2764
2765 fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
2766 let hits: Vec<_> = db
2767 .read()
2768 .explain(a, b)
2769 .expect("explain")
2770 .into_iter()
2771 .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
2772 .collect();
2773 assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
2774 let got = hits[0].weight.expect("weighted");
2775 assert!(
2776 (got - want).abs() < 1e-12,
2777 "{rule} {a}→{b}: got {got} want {want}"
2778 );
2779 }
2780
2781 fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
2782 const R: f64 = 6371.0088;
2783 let phi1 = lat1.to_radians();
2784 let phi2 = lat2.to_radians();
2785 let dphi = (lat2 - lat1).to_radians();
2786 let dlam = (lon2 - lon1).to_radians();
2787 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
2788 .clamp(0.0, 1.0);
2789 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
2790 R * c
2791 }
2792
2793 fn default_bind() -> SocketAddr {
2794 SocketAddr::from(([127, 0, 0, 1], 8080))
2795 }
2796
2797 #[test]
2798 fn parse_args_table() {
2799 struct Case {
2800 args: &'static [&'static str],
2801 check: fn(Result<Command, String>),
2802 }
2803
2804 let cases = [
2805 Case {
2806 args: &[],
2807 check: |r| match r {
2808 Ok(Command::Help) => {}
2809 other => panic!("no-args → Help, got {other:?}"),
2810 },
2811 },
2812 Case {
2813 args: &["--help"],
2814 check: |r| match r {
2815 Ok(Command::Help) => {}
2816 other => panic!("--help → Help, got {other:?}"),
2817 },
2818 },
2819 Case {
2820 args: &["-h"],
2821 check: |r| match r {
2822 Ok(Command::Help) => {}
2823 other => panic!("-h → Help, got {other:?}"),
2824 },
2825 },
2826 Case {
2827 args: &["serve", "/tmp/demo-db"],
2828 check: |r| match r {
2829 Ok(Command::Serve {
2830 db_dir,
2831 addr,
2832 ui,
2833 demo_if_empty,
2834 token,
2835 role_tokens,
2836 snapshot_every,
2837 tls_cert,
2838 tls_key,
2839 }) => {
2840 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2841 assert_eq!(addr, default_bind());
2842 assert_eq!(ui, super::ServeUi::Embedded);
2843 assert!(!demo_if_empty);
2844 assert_eq!(token, None);
2845 assert!(role_tokens.is_empty());
2846 assert_eq!(snapshot_every, None);
2847 assert_eq!(tls_cert, None);
2848 assert_eq!(tls_key, None);
2849 }
2850 other => panic!("serve <dir> → Serve default addr, got {other:?}"),
2851 },
2852 },
2853 Case {
2854 args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
2855 check: |r| match r {
2856 Ok(Command::Serve {
2857 db_dir,
2858 addr,
2859 ui,
2860 demo_if_empty,
2861 token,
2862 role_tokens,
2863 snapshot_every,
2864 tls_cert,
2865 tls_key,
2866 }) => {
2867 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2868 assert_eq!(
2869 addr,
2870 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
2871 );
2872 assert_eq!(ui, super::ServeUi::Embedded);
2873 assert!(!demo_if_empty);
2874 assert_eq!(token, None);
2875 assert!(role_tokens.is_empty());
2876 assert_eq!(snapshot_every, None);
2877 assert_eq!(tls_cert, None);
2878 assert_eq!(tls_key, None);
2879 }
2880 other => panic!("serve --addr after dir, got {other:?}"),
2881 },
2882 },
2883 Case {
2884 args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
2885 check: |r| match r {
2886 Ok(Command::Serve {
2887 db_dir,
2888 addr,
2889 ui,
2890 demo_if_empty,
2891 token,
2892 role_tokens,
2893 snapshot_every,
2894 tls_cert,
2895 tls_key,
2896 }) => {
2897 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2898 assert_eq!(
2899 addr,
2900 "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
2901 );
2902 assert_eq!(ui, super::ServeUi::Embedded);
2903 assert!(!demo_if_empty);
2904 assert_eq!(token, None);
2905 let _ = role_tokens; assert_eq!(snapshot_every, None);
2907 assert_eq!(tls_cert, None);
2908 assert_eq!(tls_key, None);
2909 }
2910 other => panic!("serve --addr=VALUE, got {other:?}"),
2911 },
2912 },
2913 Case {
2914 args: &["mcp", "/tmp/demo-db"],
2915 check: |r| match r {
2916 Ok(Command::Mcp {
2917 db_dir,
2918 auto,
2919 all_tools,
2920 }) => {
2921 assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
2922 assert!(!auto);
2923 assert!(!all_tools, "the short list is the default");
2924 }
2925 other => panic!("mcp <dir>, got {other:?}"),
2926 },
2927 },
2928 Case {
2929 args: &["stats", "/tmp/demo-db"],
2930 check: |r| match r {
2931 Ok(Command::Stats { db_dir }) => {
2932 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2933 }
2934 other => panic!("stats <dir>, got {other:?}"),
2935 },
2936 },
2937 Case {
2938 args: &["demo", "/tmp/demo-db"],
2939 check: |r| match r {
2940 Ok(Command::Demo { db_dir }) => {
2941 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2942 }
2943 other => panic!("demo <dir>, got {other:?}"),
2944 },
2945 },
2946 Case {
2947 args: &["context", "db", "x"],
2948 check: |r| match r {
2949 Ok(Command::Context {
2950 db_dir,
2951 target,
2952 full,
2953 }) => {
2954 assert_eq!(db_dir, PathBuf::from("db"));
2955 assert_eq!(target, "x");
2956 assert!(!full, "the default answer is a pointer, not a body");
2957 }
2958 other => panic!("context <dir> <target>, got {other:?}"),
2959 },
2960 },
2961 Case {
2962 args: &["context", "db", "x", "--full"],
2963 check: |r| {
2964 assert_eq!(
2965 r.unwrap(),
2966 Command::Context {
2967 db_dir: PathBuf::from("db"),
2968 target: "x".into(),
2969 full: true,
2970 }
2971 );
2972 },
2973 },
2974 Case {
2975 args: &["explore", "db", "x", "--depth", "impact"],
2976 check: |r| {
2977 assert_eq!(
2978 r.unwrap(),
2979 Command::Explore {
2980 db_dir: PathBuf::from("db"),
2981 target: "x".into(),
2982 depth: repograph::Depth::Impact,
2983 full: false,
2984 }
2985 );
2986 },
2987 },
2988 Case {
2989 args: &["serve"],
2990 check: |r| {
2991 let e = r.expect_err("serve without dir");
2992 assert!(
2993 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2994 "missing-dir error should mention dir, got {e}"
2995 );
2996 },
2997 },
2998 Case {
2999 args: &["mcp"],
3000 check: |r| {
3001 let e = r.expect_err("mcp without dir");
3002 assert!(
3003 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3004 "missing-dir error should mention dir, got {e}"
3005 );
3006 },
3007 },
3008 Case {
3009 args: &["stats"],
3010 check: |r| {
3011 let e = r.expect_err("stats without dir");
3012 assert!(
3013 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3014 "missing-dir error should mention dir, got {e}"
3015 );
3016 },
3017 },
3018 Case {
3019 args: &["demo"],
3020 check: |r| {
3021 let e = r.expect_err("demo without dir");
3022 assert!(
3023 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3024 "missing-dir error should mention dir, got {e}"
3025 );
3026 },
3027 },
3028 Case {
3029 args: &["serve", "/tmp/demo-db", "--addr"],
3030 check: |r| {
3031 let e = r.expect_err("--addr missing value");
3032 assert!(
3033 e.to_lowercase().contains("addr"),
3034 "--addr missing value should mention addr, got {e}"
3035 );
3036 },
3037 },
3038 Case {
3039 args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
3040 check: |r| {
3041 let e = r.expect_err("invalid addr");
3042 assert!(
3043 e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
3044 "invalid addr should mention address, got {e}"
3045 );
3046 },
3047 },
3048 Case {
3049 args: &["frobnicate", "/tmp/demo-db"],
3050 check: |r| {
3051 let e = r.expect_err("unknown command");
3052 assert!(
3053 e.to_lowercase().contains("unknown")
3054 || e.to_lowercase().contains("frobnicate"),
3055 "unknown command should name it, got {e}"
3056 );
3057 },
3058 },
3059 Case {
3060 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
3061 check: |r| match r {
3062 Ok(Command::Serve { ui, .. }) => {
3063 assert_eq!(
3064 ui,
3065 super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
3066 );
3067 }
3068 other => panic!("serve --ui <dir>, got {other:?}"),
3069 },
3070 },
3071 Case {
3072 args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
3073 check: |r| match r {
3074 Ok(Command::Serve { ui, .. }) => {
3075 assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
3076 }
3077 other => panic!("serve --ui=VALUE, got {other:?}"),
3078 },
3079 },
3080 Case {
3081 args: &["serve", "/tmp/demo-db", "--ui"],
3082 check: |r| {
3083 let e = r.expect_err("--ui missing value");
3084 assert!(
3085 e.to_lowercase().contains("ui"),
3086 "--ui missing value should mention ui, got {e}"
3087 );
3088 },
3089 },
3090 Case {
3091 args: &["serve", "/tmp/demo-db", "--no-ui"],
3092 check: |r| match r {
3093 Ok(Command::Serve { ui, .. }) => {
3094 assert_eq!(ui, super::ServeUi::None);
3095 }
3096 other => panic!("serve --no-ui, got {other:?}"),
3097 },
3098 },
3099 Case {
3100 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
3101 check: |r| {
3102 let e = r.expect_err("combine --ui and --no-ui");
3103 assert!(
3104 e.contains("--ui") && e.contains("--no-ui"),
3105 "conflict should name both flags, got {e}"
3106 );
3107 },
3108 },
3109 Case {
3110 args: &["serve", "/tmp/demo-db", "extra"],
3111 check: |r| {
3112 let e = r.expect_err("extra positional");
3113 assert!(
3114 e.to_lowercase().contains("unexpected")
3115 || e.to_lowercase().contains("extra"),
3116 "extra arg should be rejected, got {e}"
3117 );
3118 },
3119 },
3120 Case {
3121 args: &[
3122 "serve",
3123 "/data",
3124 "--addr",
3125 "0.0.0.0:8080",
3126 "--demo-if-empty",
3127 ],
3128 check: |r| match r {
3129 Ok(Command::Serve {
3130 db_dir,
3131 addr,
3132 demo_if_empty,
3133 ui,
3134 token,
3135 snapshot_every,
3136 ..
3137 }) => {
3138 assert_eq!(db_dir, PathBuf::from("/data"));
3139 assert_eq!(
3140 addr,
3141 "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
3142 );
3143 assert!(demo_if_empty);
3144 assert_eq!(ui, super::ServeUi::Embedded);
3145 assert_eq!(token, None);
3146 assert_eq!(snapshot_every, None);
3147 }
3148 other => panic!("serve --demo-if-empty docker default, got {other:?}"),
3149 },
3150 },
3151 Case {
3152 args: &["install", "--project", "--delivery", "cli"],
3153 check: |r| match r {
3154 Ok(Command::Install(opts)) => {
3155 assert_eq!(opts.scope, Some(install::Scope::Project));
3156 assert_eq!(opts.delivery, install::Delivery::Cli);
3157 }
3158 other => panic!("install --delivery cli, got {other:?}"),
3159 },
3160 },
3161 Case {
3162 args: &["install", "--delivery=mcp"],
3163 check: |r| match r {
3164 Ok(Command::Install(opts)) => {
3165 assert_eq!(opts.delivery, install::Delivery::Mcp)
3166 }
3167 other => panic!("install --delivery=mcp, got {other:?}"),
3168 },
3169 },
3170 Case {
3171 args: &["install"],
3174 check: |r| match r {
3175 Ok(Command::Install(opts)) => {
3176 assert_eq!(opts.delivery, install::Delivery::Both)
3177 }
3178 other => panic!("install, got {other:?}"),
3179 },
3180 },
3181 Case {
3182 args: &["install", "--delivery", "sideways"],
3183 check: |r| match r {
3184 Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
3185 other => panic!("a bad --delivery must be refused, got {other:?}"),
3186 },
3187 },
3188 Case {
3189 args: &["install", "--intercept-grep"],
3190 check: |r| match r {
3191 Ok(Command::Install(opts)) => assert!(opts.intercept_grep),
3192 other => panic!("install --intercept-grep, got {other:?}"),
3193 },
3194 },
3195 Case {
3196 args: &[
3197 "install",
3198 "--impact-before-edit",
3199 "--enrich-grep",
3200 "--always-load",
3201 ],
3202 check: |r| match r {
3203 Ok(Command::Install(opts)) => {
3204 assert!(opts.impact_before_edit);
3205 assert!(opts.enrich_grep);
3206 assert!(opts.always_load);
3207 }
3208 other => panic!("install with the code-door flags, got {other:?}"),
3209 },
3210 },
3211 Case {
3212 args: &["install", "--delivery", "mcp", "--db", "./mem"],
3216 check: |r| match r {
3217 Ok(Command::Install(opts)) => assert!(opts.always_load),
3218 other => panic!("install --delivery mcp --db, got {other:?}"),
3219 },
3220 },
3221 Case {
3222 args: &["install", "--delivery", "both", "--db=./mem"],
3224 check: |r| match r {
3225 Ok(Command::Install(opts)) => assert!(opts.always_load),
3226 other => panic!("install --delivery both --db, got {other:?}"),
3227 },
3228 },
3229 Case {
3230 args: &[
3232 "install",
3233 "--delivery",
3234 "mcp",
3235 "--db",
3236 "./mem",
3237 "--no-always-load",
3238 ],
3239 check: |r| match r {
3240 Ok(Command::Install(opts)) => assert!(!opts.always_load),
3241 other => panic!("install --no-always-load, got {other:?}"),
3242 },
3243 },
3244 Case {
3245 args: &["install", "--delivery", "cli", "--db", "./mem"],
3249 check: |r| match r {
3250 Ok(Command::Install(opts)) => assert!(!opts.always_load),
3251 other => panic!("install --delivery cli --db, got {other:?}"),
3252 },
3253 },
3254 Case {
3255 args: &["install", "--always-load"],
3259 check: |r| match r {
3260 Ok(Command::Install(opts)) => assert!(opts.always_load),
3261 other => panic!("install --always-load, got {other:?}"),
3262 },
3263 },
3264 Case {
3265 args: &["install"],
3267 check: |r| match r {
3268 Ok(Command::Install(opts)) => {
3269 assert!(!opts.intercept_grep);
3270 assert!(!opts.impact_before_edit);
3271 assert!(!opts.enrich_grep);
3272 assert!(!opts.always_load);
3273 }
3274 other => panic!("install, got {other:?}"),
3275 },
3276 },
3277 Case {
3278 args: &["impact-hook", "--auto"],
3279 check: |r| match r {
3280 Ok(Command::ImpactHook { db_dir, auto }) => {
3281 assert!(db_dir.is_none() && auto);
3282 }
3283 other => panic!("impact-hook --auto, got {other:?}"),
3284 },
3285 },
3286 Case {
3287 args: &["enrich", "/tmp/db"],
3288 check: |r| match r {
3289 Ok(Command::Enrich { db_dir, auto }) => {
3290 assert_eq!(db_dir.as_deref(), Some(Path::new("/tmp/db")));
3291 assert!(!auto);
3292 }
3293 other => panic!("enrich /tmp/db, got {other:?}"),
3294 },
3295 },
3296 Case {
3297 args: &["enrich"],
3298 check: |r| match r {
3299 Err(e) => assert!(e.contains("enrich requires <db-dir> or --auto"), "{e}"),
3300 other => panic!("enrich with no store, got {other:?}"),
3301 },
3302 },
3303 Case {
3304 args: &["intercept", "--auto"],
3305 check: |r| match r {
3306 Ok(Command::Intercept { db_dir, auto }) => {
3307 assert_eq!(db_dir, None);
3308 assert!(auto);
3309 }
3310 other => panic!("intercept --auto, got {other:?}"),
3311 },
3312 },
3313 Case {
3314 args: &["intercept", "/tmp/db"],
3315 check: |r| match r {
3316 Ok(Command::Intercept { db_dir, auto }) => {
3317 assert_eq!(db_dir, Some(PathBuf::from("/tmp/db")));
3318 assert!(!auto);
3319 }
3320 other => panic!("intercept /tmp/db, got {other:?}"),
3321 },
3322 },
3323 Case {
3324 args: &["intercept"],
3325 check: |r| match r {
3326 Err(e) => assert!(e.contains("intercept requires <db-dir> or --auto"), "{e}"),
3327 other => panic!("intercept with no store, got {other:?}"),
3328 },
3329 },
3330 ];
3331
3332 for case in &cases {
3333 (case.check)(parse_args(case.args));
3334 }
3335 }
3336
3337 #[test]
3338 fn serve_default_addr_is_loopback_8080() {
3339 match parse_args(&["serve", "/tmp/db"]).unwrap() {
3340 Command::Serve { addr, .. } => {
3341 assert_eq!(
3342 addr,
3343 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
3344 );
3345 }
3346 other => panic!("{other:?}"),
3347 }
3348 }
3349
3350 #[test]
3351 fn serve_snapshot_every_parses_seconds() {
3352 match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
3353 Command::Serve { snapshot_every, .. } => {
3354 assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
3355 }
3356 other => panic!("{other:?}"),
3357 }
3358 match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
3359 Command::Serve { snapshot_every, .. } => {
3360 assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
3361 }
3362 other => panic!("{other:?}"),
3363 }
3364 match parse_args(&["serve", "/tmp/db"]).unwrap() {
3365 Command::Serve { snapshot_every, .. } => {
3366 assert_eq!(snapshot_every, None);
3367 }
3368 other => panic!("{other:?}"),
3369 }
3370 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
3371 assert!(
3372 err.contains("snapshot-every"),
3373 "missing value should name the flag, got {err}"
3374 );
3375 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
3376 assert!(
3377 err.contains("snapshot-every"),
3378 "zero should be rejected, got {err}"
3379 );
3380 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
3381 assert!(
3382 err.contains("snapshot-every"),
3383 "invalid value should name the flag, got {err}"
3384 );
3385 }
3386
3387 #[test]
3388 fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
3389 match parse_args(&[
3391 "serve",
3392 "/tmp/db",
3393 "--addr",
3394 "0.0.0.0:8080",
3395 "--token",
3396 "s3cret",
3397 ])
3398 .unwrap()
3399 {
3400 Command::Serve { token, addr, .. } => {
3401 assert_eq!(token.as_deref(), Some("s3cret"));
3402 assert_eq!(addr.ip().to_string(), "0.0.0.0");
3403 }
3404 other => panic!("{other:?}"),
3405 }
3406 }
3407
3408 #[test]
3409 fn parse_snapshot_and_query() {
3410 for (args, want) in [
3412 (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
3413 (
3414 vec!["snapshot", "/tmp/db", "--archive-wal"],
3415 WalDisposition::Archive,
3416 ),
3417 (
3418 vec!["snapshot", "/tmp/db", "--keep-wal"],
3419 WalDisposition::Keep,
3420 ),
3421 (
3422 vec!["snapshot", "/tmp/db", "--truncate"],
3423 WalDisposition::Truncate,
3424 ),
3425 ] {
3426 match parse_args(&args).unwrap() {
3427 Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
3428 other => panic!("{other:?}"),
3429 }
3430 }
3431 match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
3432 Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
3433 other => panic!("{other:?}"),
3434 }
3435 match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
3436 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3437 other => panic!("{other:?}"),
3438 }
3439 match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
3440 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3441 other => panic!("{other:?}"),
3442 }
3443 let text = usage();
3444 assert!(
3445 text.contains("query"),
3446 "usage should mention query, got:\n{text}"
3447 );
3448 assert!(
3449 text.contains("snapshot"),
3450 "usage should mention snapshot, got:\n{text}"
3451 );
3452 }
3453
3454 #[test]
3455 fn usage_lists_every_subcommand() {
3456 let text = usage();
3457 for word in [
3458 "serve",
3459 "mcp",
3460 "stats",
3461 "demo",
3462 "query",
3463 "snapshot",
3464 "--keep-wal",
3465 "mushroomdb",
3466 "--ui",
3467 "--no-ui",
3468 "--demo-if-empty",
3469 "--token",
3470 "--snapshot-every",
3471 ] {
3472 assert!(
3473 text.contains(word),
3474 "usage should mention {word}, got:\n{text}"
3475 );
3476 }
3477 }
3478
3479 #[test]
3480 fn validate_ui_dir_requires_index_html() {
3481 let missing = tmp("ui-missing");
3482 let err = super::validate_ui_dir(&missing).expect_err("missing dir");
3483 assert!(
3484 err.contains("does not exist"),
3485 "missing dir error, got {err}"
3486 );
3487
3488 let empty = tmp("ui-empty");
3489 std::fs::create_dir_all(&empty).unwrap();
3490 let err = super::validate_ui_dir(&empty).expect_err("no index");
3491 assert!(
3492 err.contains("index.html"),
3493 "missing index.html error, got {err}"
3494 );
3495
3496 let ok = tmp("ui-ok");
3497 std::fs::create_dir_all(&ok).unwrap();
3498 std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
3499 let got = super::validate_ui_dir(&ok).expect("valid ui dir");
3500 assert_eq!(got, ok);
3501 }
3502
3503 #[test]
3504 fn maybe_run_demo_if_empty_seeds_then_skips() {
3505 let dir = tmp("boot-empty");
3506 let first = super::maybe_run_demo_if_empty(&dir)
3507 .expect("empty dir demos")
3508 .expect("Some(DemoOutcome)");
3509 assert_eq!(first.stats.nodes_live, 60);
3510 let db = SharedDb::open(&dir).expect("reopen");
3511 assert!(db.read().has_node("person-01"));
3512 let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
3513 assert!(
3514 second.is_none(),
3515 "second boot must not re-demo a populated volume"
3516 );
3517
3518 let occupied = tmp("boot-occupied");
3519 std::fs::create_dir_all(&occupied).unwrap();
3520 std::fs::write(occupied.join("keep-me"), b"x").unwrap();
3521 let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
3522 assert!(skipped.is_none());
3523 assert_eq!(
3524 std::fs::read(occupied.join("keep-me")).unwrap(),
3525 b"x",
3526 "existing volume contents must be untouched"
3527 );
3528 }
3529
3530 #[test]
3531 fn demo_builder_is_deterministic_and_refuses_second_run() {
3532 let dir = tmp("demo");
3533 let out = run_demo(&dir).expect("first demo run");
3534
3535 assert_eq!(
3536 out.stats.nodes_live, 60,
3537 "10 orgs + 20 projects + 30 people"
3538 );
3539 assert_eq!(out.stats.nodes_tombstoned, 0);
3540 assert_eq!(out.stats.edges, 334);
3548 assert_eq!(
3549 out.stats.rules.len(),
3550 7,
3551 "3 auto-FK + overlap + numeric + geo + vector"
3552 );
3553 let fit = out
3554 .stats
3555 .rules
3556 .iter()
3557 .find(|r| r.name == "skill_fit")
3558 .expect("skill_fit");
3559 assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
3560 let founded = out
3561 .stats
3562 .rules
3563 .iter()
3564 .find(|r| r.name == "founded_within")
3565 .expect("founded_within");
3566 assert_eq!(founded.edges, 34);
3567 let nearby = out
3568 .stats
3569 .rules
3570 .iter()
3571 .find(|r| r.name == "nearby_office")
3572 .expect("nearby_office");
3573 assert_eq!(nearby.edges, 16);
3574 let similar = out
3575 .stats
3576 .rules
3577 .iter()
3578 .find(|r| r.name == "similar_interests")
3579 .expect("similar_interests");
3580 assert_eq!(similar.edges, 114);
3581
3582 let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
3583 names.sort_unstable();
3584 assert_eq!(
3585 names,
3586 vec![
3587 "auto_fk_person_org_id",
3588 "auto_fk_person_project_id",
3589 "auto_fk_project_org_id",
3590 "founded_within",
3591 "nearby_office",
3592 "similar_interests",
3593 "skill_fit",
3594 ]
3595 );
3596
3597 let db = SharedDb::open(&dir).expect("reopen demo");
3599 assert_eq!(
3600 db.read().fulltext_pairs(),
3601 vec![
3602 ("Org".to_string(), "name".to_string()),
3603 ("Person".to_string(), "name".to_string()),
3604 ("Project".to_string(), "name".to_string()),
3605 ]
3606 );
3607
3608 let mut auto = out.auto_fk_rules.clone();
3609 auto.sort();
3610 assert_eq!(
3611 auto,
3612 vec![
3613 "auto_fk_person_org_id".to_string(),
3614 "auto_fk_person_project_id".to_string(),
3615 "auto_fk_project_org_id".to_string(),
3616 ]
3617 );
3618
3619 assert!(
3620 !out.sample_result.is_empty(),
3621 "sample Cypher query must return rows"
3622 );
3623 assert!(
3624 out.sample_query.contains("ORDER BY score DESC"),
3625 "sample query must rank by score, got {}",
3626 out.sample_query
3627 );
3628 let scores: Vec<f64> = (0..out.sample_result.len())
3629 .map(|i| match out.sample_result.get(i, "score") {
3630 Some(Value::Float(f)) => *f,
3631 other => panic!("score col should be Float, got {other:?}"),
3632 })
3633 .collect();
3634 let distinct: std::collections::BTreeSet<u64> =
3635 scores.iter().map(|s| s.to_bits()).collect();
3636 assert!(
3637 distinct.len() >= 2,
3638 "sample results must be visibly ranked, got {scores:?}"
3639 );
3640 for w in scores.windows(2) {
3641 assert!(
3642 w[0] >= w[1],
3643 "scores must be non-increasing, got {scores:?}"
3644 );
3645 }
3646 assert!(
3647 !out.explanations.is_empty(),
3648 "explain(person-01, proj-01) must find the derived edges"
3649 );
3650
3651 let db = SharedDb::open(&dir).expect("reopen demo");
3652 assert_eq!(
3653 directed_pairs(&db, "FOUNDED_WITHIN"),
3654 [
3655 ("org-01", "org-02"),
3656 ("org-01", "org-03"),
3657 ("org-02", "org-01"),
3658 ("org-02", "org-03"),
3659 ("org-02", "org-04"),
3660 ("org-03", "org-01"),
3661 ("org-03", "org-02"),
3662 ("org-03", "org-04"),
3663 ("org-03", "org-05"),
3664 ("org-04", "org-02"),
3665 ("org-04", "org-03"),
3666 ("org-04", "org-05"),
3667 ("org-04", "org-06"),
3668 ("org-05", "org-03"),
3669 ("org-05", "org-04"),
3670 ("org-05", "org-06"),
3671 ("org-05", "org-07"),
3672 ("org-06", "org-04"),
3673 ("org-06", "org-05"),
3674 ("org-06", "org-07"),
3675 ("org-06", "org-08"),
3676 ("org-07", "org-05"),
3677 ("org-07", "org-06"),
3678 ("org-07", "org-08"),
3679 ("org-07", "org-09"),
3680 ("org-08", "org-06"),
3681 ("org-08", "org-07"),
3682 ("org-08", "org-09"),
3683 ("org-08", "org-10"),
3684 ("org-09", "org-07"),
3685 ("org-09", "org-08"),
3686 ("org-09", "org-10"),
3687 ("org-10", "org-08"),
3688 ("org-10", "org-09"),
3689 ]
3690 .into_iter()
3691 .map(|(a, b)| (a.to_string(), b.to_string()))
3692 .collect::<BTreeSet<_>>()
3693 );
3694 assert_eq!(
3695 directed_pairs(&db, "NEARBY_OFFICE"),
3696 [
3697 ("org-01", "org-07"),
3698 ("org-01", "org-10"),
3699 ("org-02", "org-09"),
3700 ("org-03", "org-08"),
3701 ("org-04", "org-05"),
3702 ("org-04", "org-06"),
3703 ("org-05", "org-04"),
3704 ("org-05", "org-06"),
3705 ("org-06", "org-04"),
3706 ("org-06", "org-05"),
3707 ("org-07", "org-01"),
3708 ("org-07", "org-10"),
3709 ("org-08", "org-03"),
3710 ("org-09", "org-02"),
3711 ("org-10", "org-01"),
3712 ("org-10", "org-07"),
3713 ]
3714 .into_iter()
3715 .map(|(a, b)| (a.to_string(), b.to_string()))
3716 .collect::<BTreeSet<_>>()
3717 );
3718 assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
3719 let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
3720 assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
3721 assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
3722 assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
3723
3724 let err = run_demo(&dir).expect_err("second run into the same dir");
3725 let msg = err.to_string().to_lowercase();
3726 assert!(
3727 msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
3728 "refuse message must mention non-empty dir, got {err}"
3729 );
3730 assert!(
3731 msg.contains("hidden"),
3732 "refuse message must mention hidden files, got {err}"
3733 );
3734
3735 let _ = std::fs::remove_dir_all(&dir);
3736 }
3737
3738 #[test]
3739 fn run_snapshot_writes_snapshot_bin() {
3740 let dir = tmp("snapshot-cli");
3741 {
3742 let mut db = GraphDb::open(&dir).expect("open");
3743 db.insert_node("Person", "alice", vec![]).expect("insert");
3744 }
3745 assert!(
3746 !dir.join("snapshot.bin").exists(),
3747 "GraphDb Drop must not snapshot"
3748 );
3749 let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
3750 assert!(
3751 dir.join("snapshot.bin").is_file(),
3752 "run_snapshot must write snapshot.bin"
3753 );
3754 assert!(
3755 out.contains("snapshot.bin"),
3756 "snapshot output should mention snapshot.bin, got {out}"
3757 );
3758 let db = GraphDb::open(&dir).expect("reopen");
3759 assert!(db.has_node("alice"), "reopen after snapshot must recover");
3760 let _ = std::fs::remove_dir_all(&dir);
3761 }
3762
3763 #[test]
3768 fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
3769 let dir = tmp("snapshot-archive");
3770 {
3771 let mut db = GraphDb::open(&dir).expect("open");
3772 db.insert_node("Person", "alice", vec![]).expect("insert");
3773 }
3774 let before = wal_commit_count_at(&dir).expect("count");
3775 assert!(before > 0, "the insert is a commit");
3776
3777 {
3779 let shared = SharedDb::open(&dir).expect("open");
3780 snapshot_shared(&shared).expect("snapshot");
3781 }
3782
3783 let archives = || {
3784 std::fs::read_dir(&dir)
3785 .expect("read dir")
3786 .filter_map(Result::ok)
3787 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
3788 .count()
3789 };
3790 assert_eq!(archives(), 1, "the WAL was archived, not dropped");
3791 assert!(
3792 dir.join("wal.genesis").is_file(),
3793 "the genesis marker is what lets asof reach an archived commit"
3794 );
3795 {
3796 let db = GraphDb::open(&dir).expect("reopen");
3797 assert!(db.has_node("alice"));
3798 assert!(
3799 !db.node_history("alice").expect("history").is_empty(),
3800 "the insert is still explainable"
3801 );
3802 }
3803 assert!(
3804 GraphDb::open_at(&dir, before - 1).is_ok(),
3805 "asof still reaches a commit the snapshot folded in"
3806 );
3807
3808 run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
3811 assert!(
3812 !dir.join("wal.genesis").exists(),
3813 "truncating ends asof's reach into the archives"
3814 );
3815 let db = GraphDb::open(&dir).expect("reopen");
3816 assert!(
3817 db.has_node("alice"),
3818 "the data survives; only the past goes"
3819 );
3820 let _ = std::fs::remove_dir_all(&dir);
3821 }
3822
3823 #[test]
3831 fn automatic_snapshots_keep_a_bounded_number_of_archives() {
3832 let dir = tmp("snapshot-retention");
3833 let archives = |d: &Path| {
3834 std::fs::read_dir(d)
3835 .expect("read dir")
3836 .filter_map(Result::ok)
3837 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
3838 .count()
3839 };
3840
3841 let rounds = 10;
3844 for i in 0..rounds {
3845 {
3846 let mut db = GraphDb::open(&dir).expect("open");
3847 db.insert_node("Person", &format!("p{i}"), vec![])
3848 .expect("insert");
3849 }
3850 let shared = SharedDb::open(&dir).expect("open shared");
3851 snapshot_shared(&shared).expect("snapshot");
3852 }
3853
3854 assert_eq!(
3855 archives(&dir),
3856 AUTO_SNAPSHOT_RETENTION as usize,
3857 "{rounds} automatic snapshots must not leave {rounds} archives"
3858 );
3859
3860 let db = GraphDb::open(&dir).expect("reopen");
3863 for i in 0..rounds {
3864 assert!(db.has_node(&format!("p{i}")), "p{i} survived the pruning");
3865 }
3866 assert!(
3870 db.node_history("p0").expect("history").is_empty(),
3871 "the pruned archives take their history with them"
3872 );
3873 assert!(
3874 !db.node_history("p9").expect("history").is_empty(),
3875 "the retained window is still explainable"
3876 );
3877 drop(db);
3878
3879 {
3882 let mut db = GraphDb::open(&dir).expect("open");
3883 db.insert_node("Person", "after", vec![]).expect("insert");
3884 }
3885 let latest = GraphDb::open(&dir).expect("reopen").commit_seq();
3886 assert!(
3887 GraphDb::open_at(&dir, latest - 1).is_ok(),
3888 "asof still reaches commits past the last snapshot"
3889 );
3890
3891 let manual = tmp("snapshot-retention-manual");
3894 for i in 0..3 {
3895 {
3896 let mut db = GraphDb::open(&manual).expect("open");
3897 db.insert_node("Person", &format!("p{i}"), vec![])
3898 .expect("insert");
3899 }
3900 run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
3901 }
3902 assert_eq!(
3903 archives(&manual),
3904 3,
3905 "`mushroomdb snapshot` with no --retention keeps every archive"
3906 );
3907
3908 let _ = std::fs::remove_dir_all(&dir);
3909 let _ = std::fs::remove_dir_all(&manual);
3910 }
3911
3912 #[test]
3913 fn run_query_formats_like_asof() {
3914 let dir = tmp("query-cli");
3915 {
3916 let mut db = GraphDb::open(&dir).expect("open");
3917 db.insert_node(
3918 "Person",
3919 "alice",
3920 vec![("id".into(), Value::Str("alice".into()))],
3921 )
3922 .expect("insert");
3923 }
3924 let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id").expect("query");
3925 assert!(out.contains("columns:"), "got {out}");
3926 assert!(out.contains("id=alice"), "got {out}");
3927 let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})").expect("write");
3928 let db = GraphDb::open(&dir).expect("reopen");
3929 assert!(db.has_node("bob"), "query_write must persist CREATE");
3930 let _ = std::fs::remove_dir_all(&dir);
3931 }
3932
3933 #[test]
3934 fn format_stats_contains_counts() {
3935 let dir = tmp("stats-smoke");
3936 let out = run_demo(&dir).expect("demo for stats smoke");
3937 let text = format_stats(&out.stats);
3938 assert!(
3939 text.contains("60"),
3940 "stats output should include live node count, got:\n{text}"
3941 );
3942 assert!(
3943 text.contains("334"),
3944 "stats output should include edge count, got:\n{text}"
3945 );
3946 assert!(
3947 text.to_lowercase().contains("node"),
3948 "stats output should mention nodes, got:\n{text}"
3949 );
3950 assert!(
3951 text.to_lowercase().contains("edge"),
3952 "stats output should mention edges, got:\n{text}"
3953 );
3954 let _ = std::fs::remove_dir_all(&dir);
3955 }
3956
3957 #[test]
3960 fn parse_backup_round_trip() {
3961 let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
3962 match r {
3963 Ok(Command::Backup { db_dir, dest }) => {
3964 assert_eq!(db_dir, PathBuf::from("/db/dir"));
3965 assert_eq!(dest, PathBuf::from("/backup/dest"));
3966 }
3967 other => panic!("backup parse, got {other:?}"),
3968 }
3969 }
3970
3971 #[test]
3972 fn parse_backup_missing_dest_errors() {
3973 let r = parse_args(&["backup", "/db/dir"]);
3974 assert!(r.is_err(), "backup without <dest> should error");
3975 let e = r.unwrap_err();
3976 assert!(
3977 e.to_lowercase().contains("dest"),
3978 "error should mention dest, got: {e}"
3979 );
3980 }
3981
3982 #[test]
3983 fn parse_export_defaults_to_jsonl() {
3984 let r = parse_args(&["export", "/db/dir", "/export/dest"]);
3985 match r {
3986 Ok(Command::Export { format, .. }) => {
3987 assert_eq!(format, ExportFormat::Jsonl);
3988 }
3989 other => panic!("export parse, got {other:?}"),
3990 }
3991 }
3992
3993 #[test]
3994 fn parse_export_parquet_flag() {
3995 let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
3996 match r {
3997 Ok(Command::Export { format, .. }) => {
3998 assert_eq!(format, ExportFormat::Parquet);
3999 }
4000 other => panic!("export --format parquet parse, got {other:?}"),
4001 }
4002 }
4003
4004 #[test]
4005 fn parse_export_parquet_flag_eq() {
4006 let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
4007 match r {
4008 Ok(Command::Export { format, .. }) => {
4009 assert_eq!(format, ExportFormat::Parquet);
4010 }
4011 other => panic!("export --format=parquet parse, got {other:?}"),
4012 }
4013 }
4014
4015 #[test]
4016 fn run_backup_cli_produces_verified_report() {
4017 let src = tmp("cli-backup-src");
4018 let dst = tmp("cli-backup-dst");
4019 let _ = run_demo(&src).expect("demo");
4020 let report = run_backup(&src, &dst).expect("run_backup");
4021 assert!(report.verified, "backup must be verified");
4022 assert!(!report.files.is_empty());
4023 assert!(report.bytes > 0);
4024 let _ = std::fs::remove_dir_all(&src);
4025 let _ = std::fs::remove_dir_all(&dst);
4026 }
4027
4028 #[test]
4029 fn run_export_jsonl_two_runs_byte_identical() {
4030 let src = tmp("cli-export-src");
4031 let dst1 = tmp("cli-export-dst1");
4032 let dst2 = tmp("cli-export-dst2");
4033 let _ = run_demo(&src).expect("demo");
4034
4035 run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
4036 run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
4037
4038 for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
4039 let f1 = std::fs::read(dst1.join(filename)).expect("read first");
4040 let f2 = std::fs::read(dst2.join(filename)).expect("read second");
4041 assert_eq!(
4042 f1, f2,
4043 "{filename} must be byte-identical across two export runs"
4044 );
4045 }
4046 let _ = std::fs::remove_dir_all(&src);
4047 let _ = std::fs::remove_dir_all(&dst1);
4048 let _ = std::fs::remove_dir_all(&dst2);
4049 }
4050
4051 #[test]
4052 fn run_export_jsonl_nodes_are_sorted() {
4053 let src = tmp("cli-export-sorted");
4054 let dst = tmp("cli-export-sorted-dst");
4055 let _ = run_demo(&src).expect("demo");
4056 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
4057
4058 let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
4059 let keys: Vec<String> = content
4060 .lines()
4061 .filter(|l| !l.is_empty())
4062 .map(|l| {
4063 let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
4064 v["key"].as_str().unwrap_or("").to_string()
4065 })
4066 .collect();
4067 let mut sorted = keys.clone();
4068 sorted.sort();
4069 assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
4070 let _ = std::fs::remove_dir_all(&src);
4071 let _ = std::fs::remove_dir_all(&dst);
4072 }
4073
4074 #[test]
4075 fn run_export_jsonl_derived_edges_have_rule() {
4076 let src = tmp("cli-export-derived");
4077 let dst = tmp("cli-export-derived-dst");
4078 let _ = run_demo(&src).expect("demo");
4079 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
4080
4081 let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
4082 let derived_lines: Vec<serde_json::Value> = content
4083 .lines()
4084 .filter(|l| !l.is_empty())
4085 .map(|l| serde_json::from_str(l).expect("parse line"))
4086 .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
4087 .collect();
4088 assert!(
4089 !derived_lines.is_empty(),
4090 "demo store should have derived edges"
4091 );
4092 for edge in &derived_lines {
4093 assert!(
4094 !edge["rule"].is_null(),
4095 "derived edge must have non-null rule: {edge}"
4096 );
4097 }
4098 let _ = std::fs::remove_dir_all(&src);
4099 let _ = std::fs::remove_dir_all(&dst);
4100 }
4101
4102 #[test]
4103 fn run_export_parquet_produces_files() {
4104 let src = tmp("cli-export-parq-src");
4105 let dst = tmp("cli-export-parq-dst");
4106 let _ = run_demo(&src).expect("demo");
4107 run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
4108
4109 assert!(
4110 dst.join("nodes.parquet").exists(),
4111 "nodes.parquet must exist"
4112 );
4113 assert!(
4114 dst.join("edges.parquet").exists(),
4115 "edges.parquet must exist"
4116 );
4117 assert!(
4118 dst.join("rules.parquet").exists(),
4119 "rules.parquet must exist"
4120 );
4121 for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
4123 let meta = std::fs::metadata(dst.join(f)).expect("metadata");
4124 assert!(meta.len() > 0, "{f} must be non-empty");
4125 }
4126 let _ = std::fs::remove_dir_all(&src);
4127 let _ = std::fs::remove_dir_all(&dst);
4128 }
4129
4130 #[test]
4131 fn parse_export_graphml_flag() {
4132 let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
4133 match r {
4134 Ok(Command::Export { format, .. }) => {
4135 assert_eq!(format, ExportFormat::Graphml);
4136 }
4137 other => panic!("export --format graphml parse, got {other:?}"),
4138 }
4139 }
4140
4141 #[test]
4142 fn run_export_graphml_structure() {
4143 let src = tmp("cli-export-gml-src");
4144 let dst_dir = tmp("cli-export-gml-dst");
4145 let dst = dst_dir.join("graph.graphml");
4146 let _ = run_demo(&src).expect("demo");
4147 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4148
4149 let content = std::fs::read_to_string(&dst).expect("read graphml");
4150
4151 assert!(
4152 content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
4153 "must start with an XML declaration"
4154 );
4155 assert!(
4156 content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
4157 "must use the standard GraphML namespace"
4158 );
4159 assert!(
4160 content.contains(
4161 "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
4162 ),
4163 "must declare the node label key"
4164 );
4165 assert!(
4166 content.contains(
4167 "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
4168 ),
4169 "must declare the edge type key"
4170 );
4171 assert!(
4172 content.contains(
4173 "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
4174 ),
4175 "must declare the edge derived key"
4176 );
4177 assert!(
4178 content.contains(
4179 "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
4180 ),
4181 "must declare the edge rule key"
4182 );
4183 assert!(
4184 content.contains(
4185 "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
4186 ),
4187 "must declare the edge weight key"
4188 );
4189 assert!(
4193 content.contains(
4194 "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
4195 ),
4196 "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
4197 );
4198 assert!(
4199 content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
4200 "must declare a single directed graph element"
4201 );
4202 assert!(content.contains("<node id="), "must contain node elements");
4203 assert!(
4204 content.contains("<edge id=\"e0\" source=\""),
4205 "must contain a sequentially-numbered edge starting at e0"
4206 );
4207 assert!(
4208 content.trim_end().ends_with("</graphml>"),
4209 "must close the root element"
4210 );
4211
4212 assert!(
4215 content.contains("<data key=\"e_rule\">skill_fit</data>")
4216 || content.contains("<data key=\"e_rule\">founded_within</data>"),
4217 "at least one derived edge must carry its rule name"
4218 );
4219 assert!(
4220 content.contains(&format!(
4221 "<data key=\"{}\">",
4222 "e_weight" )),
4224 "at least one derived edge must carry a weight value"
4225 );
4226
4227 let _ = std::fs::remove_dir_all(&src);
4228 let _ = std::fs::remove_dir_all(&dst_dir);
4229 }
4230
4231 #[test]
4232 fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
4233 let src = tmp("cli-export-gml-dir-src");
4234 let dst_dir = tmp("cli-export-gml-dir-dst");
4235 std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
4236 let _ = run_demo(&src).expect("demo");
4237
4238 let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
4239
4240 assert!(
4241 dst_dir.join("graph.graphml").exists(),
4242 "an existing directory dest must produce dest/graph.graphml"
4243 );
4244 assert!(
4245 msg.contains("graph.graphml"),
4246 "report must name the file actually written, got: {msg}"
4247 );
4248
4249 let _ = std::fs::remove_dir_all(&src);
4250 let _ = std::fs::remove_dir_all(&dst_dir);
4251 }
4252
4253 #[test]
4256 fn run_export_graphml_is_well_formed_xml() {
4257 let has_python3 = std::process::Command::new("python3")
4258 .arg("--version")
4259 .output()
4260 .map(|o| o.status.success())
4261 .unwrap_or(false);
4262 if !has_python3 {
4263 eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
4264 return;
4265 }
4266
4267 let src = tmp("cli-export-gml-wf-src");
4268 let dst_dir = tmp("cli-export-gml-wf-dst");
4269 let dst = dst_dir.join("graph.graphml");
4270 let _ = run_demo(&src).expect("demo");
4271 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4272
4273 let status = std::process::Command::new("python3")
4274 .arg("-c")
4275 .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
4276 .arg(&dst)
4277 .status()
4278 .expect("run python3");
4279 assert!(
4280 status.success(),
4281 "python3's XML parser must accept the exported GraphML file"
4282 );
4283
4284 let _ = std::fs::remove_dir_all(&src);
4285 let _ = std::fs::remove_dir_all(&dst_dir);
4286 }
4287
4288 #[test]
4289 fn run_export_graphml_two_runs_byte_identical() {
4290 let src = tmp("cli-export-gml-bi-src");
4291 let dst_dir1 = tmp("cli-export-gml-bi-dst1");
4292 let dst_dir2 = tmp("cli-export-gml-bi-dst2");
4293 let dst1 = dst_dir1.join("graph.graphml");
4294 let dst2 = dst_dir2.join("graph.graphml");
4295 let _ = run_demo(&src).expect("demo");
4296
4297 run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
4298 run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
4299
4300 let f1 = std::fs::read(&dst1).expect("read first");
4301 let f2 = std::fs::read(&dst2).expect("read second");
4302 assert_eq!(
4303 f1, f2,
4304 "graph.graphml must be byte-identical across two export runs"
4305 );
4306
4307 let _ = std::fs::remove_dir_all(&src);
4308 let _ = std::fs::remove_dir_all(&dst_dir1);
4309 let _ = std::fs::remove_dir_all(&dst_dir2);
4310 }
4311
4312 #[test]
4313 fn run_export_graphml_escapes_and_lists() {
4314 use core_api::{GraphDb, Value};
4315 let src = tmp("cli-export-gml-esc-src");
4316 let dst_dir = tmp("cli-export-gml-esc-dst");
4317 let dst = dst_dir.join("graph.graphml");
4318
4319 {
4320 let mut db = GraphDb::open(&src).unwrap();
4321 db.insert_node(
4322 "Widget",
4323 "w1",
4324 vec![
4325 (
4326 "title".into(),
4327 Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
4328 ),
4329 (
4330 "tags".into(),
4331 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
4332 ),
4333 ],
4334 )
4335 .unwrap();
4336 }
4337
4338 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4339 let content = std::fs::read_to_string(&dst).expect("read graphml");
4340
4341 assert!(
4342 content.contains("Tom & Jerry <says> "hi" 'bye'"),
4343 "special XML characters in string props must be escaped, got: {content}"
4344 );
4345 assert!(
4346 !content.contains("Tom & Jerry <says>"),
4347 "unescaped special characters must not appear verbatim"
4348 );
4349 assert!(
4350 content.contains(
4351 "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
4352 ),
4353 "list-valued props must declare attr.type=\"string\""
4354 );
4355 assert!(
4356 content.contains("<data key=\"n_tags\">["a","b"]</data>"),
4357 "list-valued props must render as XML-escaped JSON text, got: {content}"
4358 );
4359
4360 let _ = std::fs::remove_dir_all(&src);
4361 let _ = std::fs::remove_dir_all(&dst_dir);
4362 }
4363
4364 #[test]
4369 fn run_export_graphml_mixed_type_prop_declares_string() {
4370 use core_api::{GraphDb, Value};
4371 let src = tmp("cli-export-gml-mixed-src");
4372 let dst_dir = tmp("cli-export-gml-mixed-dst");
4373 let dst = dst_dir.join("graph.graphml");
4374
4375 {
4376 let mut db = GraphDb::open(&src).unwrap();
4377 db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
4378 .unwrap();
4379 db.insert_node(
4380 "Metric",
4381 "m2",
4382 vec![("score".into(), Value::Str("high".into()))],
4383 )
4384 .unwrap();
4385 }
4386
4387 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4388 let content = std::fs::read_to_string(&dst).expect("read graphml");
4389
4390 assert!(
4391 content.contains(
4392 "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
4393 ),
4394 "a prop name with conflicting value types across nodes must declare \
4395 attr.type=\"string\", got: {content}"
4396 );
4397 assert!(
4398 !content.contains("attr.name=\"score\" attr.type=\"long\""),
4399 "must not declare a narrower type once a conflict is seen, got: {content}"
4400 );
4401 assert!(
4404 content.contains("<data key=\"n_score\">5</data>"),
4405 "the int-valued node must still render its literal int text, got: {content}"
4406 );
4407 assert!(
4408 content.contains("<data key=\"n_score\">high</data>"),
4409 "the string-valued node must still render its literal string text, got: {content}"
4410 );
4411
4412 let _ = std::fs::remove_dir_all(&src);
4413 let _ = std::fs::remove_dir_all(&dst_dir);
4414 }
4415
4416 #[test]
4417 fn parse_algo_degree_defaults_dir_both() {
4418 let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
4419 match cmd {
4420 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
4421 other => panic!("expected Algo, got {other:?}"),
4422 }
4423 }
4424
4425 #[test]
4426 fn parse_algo_degree_with_dir_flag() {
4427 for (arg, want) in [
4428 ("out", AlgoDir::Out),
4429 ("in", AlgoDir::In),
4430 ("both", AlgoDir::Both),
4431 ] {
4432 let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
4433 match cmd {
4434 Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
4435 other => panic!("expected Algo, got {other:?}"),
4436 }
4437 }
4438 let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
4440 match cmd {
4441 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
4442 other => panic!("expected Algo, got {other:?}"),
4443 }
4444 }
4445
4446 #[test]
4447 fn parse_algo_rejects_unknown_dir() {
4448 assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
4449 }
4450
4451 #[test]
4452 fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
4453 let cmd = parse_args(&[
4454 "algo",
4455 "communities",
4456 "/db",
4457 "--edge-type",
4458 "IMPORTS",
4459 "--edge-type=CO_CHANGED",
4460 "--weight-prop",
4461 "score",
4462 "--min-weight",
4463 "0.3",
4464 "--top",
4465 "5",
4466 ])
4467 .unwrap();
4468 match cmd {
4469 Command::Algo {
4470 subcmd,
4471 top,
4472 edge_types,
4473 weight_prop,
4474 min_weight,
4475 ..
4476 } => {
4477 assert_eq!(subcmd, AlgoSubcmd::Communities);
4478 assert_eq!(top, 5);
4479 assert_eq!(
4480 edge_types,
4481 vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
4482 );
4483 assert_eq!(weight_prop, Some("score".to_string()));
4484 assert_eq!(min_weight, Some(0.3));
4485 }
4486 other => panic!("expected Algo, got {other:?}"),
4487 }
4488 }
4489
4490 #[test]
4491 fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
4492 let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
4493 match cmd {
4494 Command::Algo {
4495 subcmd,
4496 edge_types,
4497 weight_prop,
4498 min_weight,
4499 ..
4500 } => {
4501 assert_eq!(subcmd, AlgoSubcmd::Communities);
4502 assert!(edge_types.is_empty());
4503 assert_eq!(weight_prop, None);
4504 assert_eq!(min_weight, None);
4505 }
4506 other => panic!("expected Algo, got {other:?}"),
4507 }
4508 }
4509
4510 #[test]
4513 fn run_export_jsonl_nan_float_becomes_null() {
4514 use core_api::{GraphDb, Value};
4515 let src = tmp("cli-export-nan-src");
4516 let dst = tmp("cli-export-nan-dst");
4517
4518 {
4520 let mut db = GraphDb::open(&src).unwrap();
4521 db.insert_node(
4522 "Sensor",
4523 "s1",
4524 vec![
4525 ("nan_val".into(), Value::Float(f64::NAN)),
4526 ("pos_inf".into(), Value::Float(f64::INFINITY)),
4527 ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
4528 ("normal".into(), Value::Float(1.5)),
4529 ],
4530 )
4531 .unwrap();
4532 }
4533
4534 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
4536
4537 let content =
4539 std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
4540 let row: serde_json::Value =
4541 serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
4542 assert_eq!(
4543 row["nan_val"],
4544 serde_json::Value::Null,
4545 "NaN must export as null"
4546 );
4547 assert_eq!(
4548 row["pos_inf"],
4549 serde_json::Value::Null,
4550 "+Inf must export as null"
4551 );
4552 assert_eq!(
4553 row["neg_inf"],
4554 serde_json::Value::Null,
4555 "-Inf must export as null"
4556 );
4557 assert_eq!(
4559 row["normal"],
4560 serde_json::json!(1.5),
4561 "normal float roundtrips"
4562 );
4563
4564 let _ = std::fs::remove_dir_all(&src);
4565 let _ = std::fs::remove_dir_all(&dst);
4566 }
4567
4568 #[test]
4569 fn serve_tls_flags_parse_both_forms() {
4570 match parse_args(&[
4572 "serve",
4573 "/tmp/db",
4574 "--tls-cert",
4575 "/a/cert.pem",
4576 "--tls-key",
4577 "/a/key.pem",
4578 ])
4579 .unwrap()
4580 {
4581 Command::Serve {
4582 tls_cert, tls_key, ..
4583 } => {
4584 assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
4585 assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
4586 }
4587 other => panic!("{other:?}"),
4588 }
4589 match parse_args(&[
4591 "serve",
4592 "/tmp/db",
4593 "--tls-cert=/b/cert.pem",
4594 "--tls-key=/b/key.pem",
4595 ])
4596 .unwrap()
4597 {
4598 Command::Serve {
4599 tls_cert, tls_key, ..
4600 } => {
4601 assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
4602 assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
4603 }
4604 other => panic!("{other:?}"),
4605 }
4606 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4608 Command::Serve {
4609 tls_cert, tls_key, ..
4610 } => {
4611 assert_eq!(tls_cert, None);
4612 assert_eq!(tls_key, None);
4613 }
4614 other => panic!("{other:?}"),
4615 }
4616 }
4617
4618 #[test]
4619 fn serve_tls_flags_require_both() {
4620 let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
4622 assert!(
4623 err.contains("tls-key"),
4624 "--tls-cert alone must mention --tls-key in error, got {err}"
4625 );
4626 let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
4628 assert!(
4629 err.contains("tls-cert"),
4630 "--tls-key alone must mention --tls-cert in error, got {err}"
4631 );
4632 }
4633
4634 #[test]
4635 fn version_flag_parses() {
4636 assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
4637 assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
4638 assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
4639 }
4640
4641 #[test]
4642 fn recall_parses_one_dir_and_is_listed_in_usage() {
4643 assert_eq!(
4644 parse_args(&["recall", "/tmp/db"]).unwrap(),
4645 Command::Recall {
4646 db_dir: Some(PathBuf::from("/tmp/db")),
4647 auto: false,
4648 }
4649 );
4650 assert!(
4651 parse_args(&["recall"]).is_err(),
4652 "one of <db-dir> or --auto is required"
4653 );
4654 assert!(usage().contains("mushroomdb recall <db-dir>"));
4655 }
4656
4657 #[test]
4658 fn map_parses_a_dir_and_an_optional_json_flag() {
4659 assert_eq!(
4660 parse_args(&["map", "/tmp/db"]).unwrap(),
4661 Command::Map {
4662 db_dir: PathBuf::from("/tmp/db"),
4663 json: false,
4664 }
4665 );
4666 let want = Command::Map {
4668 db_dir: PathBuf::from("/tmp/db"),
4669 json: true,
4670 };
4671 assert_eq!(parse_args(&["map", "/tmp/db", "--json"]).unwrap(), want);
4672 assert_eq!(parse_args(&["map", "--json", "/tmp/db"]).unwrap(), want);
4673 assert!(parse_args(&["map"]).is_err(), "<db-dir> is required");
4674 assert!(parse_args(&["map", "/tmp/db", "/tmp/other"]).is_err());
4675 assert!(parse_args(&["map", "/tmp/db", "--nope"]).is_err());
4676 assert!(usage().contains("mushroomdb map <db-dir> [--json]"));
4677 }
4678
4679 #[test]
4680 fn the_graph_tools_take_a_dir_and_their_keys() {
4681 assert_eq!(
4682 parse_args(&["context", "/tmp/db", "src/db.rs#open"]).unwrap(),
4683 Command::Context {
4684 db_dir: PathBuf::from("/tmp/db"),
4685 target: "src/db.rs#open".to_string(),
4686 full: false,
4687 }
4688 );
4689 assert_eq!(
4690 parse_args(&["explore", "/tmp/db", "open"]).unwrap(),
4691 Command::Explore {
4692 db_dir: PathBuf::from("/tmp/db"),
4693 target: "open".to_string(),
4694 depth: repograph::Depth::Context,
4695 full: false,
4696 },
4697 "the default depth is the cheapest one"
4698 );
4699 assert_eq!(
4700 parse_args(&["explore", "/tmp/db", "open", "--depth", "all", "--full"]).unwrap(),
4701 Command::Explore {
4702 db_dir: PathBuf::from("/tmp/db"),
4703 target: "open".to_string(),
4704 depth: repograph::Depth::All,
4705 full: true,
4706 }
4707 );
4708 assert_eq!(
4709 parse_args(&["impact", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
4710 Command::Impact {
4711 db_dir: PathBuf::from("/tmp/db"),
4712 files: vec!["a.rs".to_string(), "b.rs".to_string()],
4713 }
4714 );
4715 assert_eq!(
4716 parse_args(&["owners", "/tmp/db", "a.rs"]).unwrap(),
4717 Command::Owners {
4718 db_dir: PathBuf::from("/tmp/db"),
4719 path: "a.rs".to_string(),
4720 }
4721 );
4722 assert_eq!(
4723 parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
4724 Command::Why {
4725 db_dir: PathBuf::from("/tmp/db"),
4726 a: "a.rs".to_string(),
4727 b: "b.rs".to_string(),
4728 }
4729 );
4730
4731 for args in [
4733 vec!["context", "/tmp/db"],
4734 vec!["context", "/tmp/db", "a", "b"],
4735 vec!["impact", "/tmp/db"],
4736 vec!["owners", "/tmp/db"],
4737 vec!["why", "/tmp/db", "a"],
4738 vec!["why", "/tmp/db", "a", "b", "c"],
4739 vec!["why", "/tmp/db", "-a", "b"],
4740 vec!["context"],
4741 vec!["explore"],
4742 vec!["explore", "/tmp/db"],
4743 vec!["explore", "/tmp/db", "a", "b"],
4744 vec!["explore", "/tmp/db", "a", "--depth"],
4745 vec!["explore", "/tmp/db", "a", "--depth", "everything"],
4746 vec!["explore", "/tmp/db", "a", "--nope"],
4747 ] {
4748 assert!(parse_args(&args).is_err(), "{args:?} must not parse");
4749 }
4750 for line in [
4751 "mushroomdb explore <db-dir> <target>",
4752 "mushroomdb context <db-dir> <target>",
4753 "mushroomdb impact <db-dir> <file>...",
4754 "mushroomdb owners <db-dir> <path>",
4755 "mushroomdb why <db-dir> <a> <b>",
4756 ] {
4757 assert!(usage().contains(line), "usage is missing {line:?}");
4758 }
4759 }
4760
4761 #[test]
4766 fn usage_marks_the_deprecated_subcommands() {
4767 let text = usage();
4768 for prefix in [
4769 "mushroomdb explore <db-dir> <target>",
4770 "mushroomdb map <db-dir> [--json]",
4771 "mushroomdb context <db-dir> <target>",
4772 "mushroomdb impact <db-dir> <file>...",
4773 "mushroomdb owners <db-dir> <path>",
4774 "mushroomdb why <db-dir> <a> <b>",
4775 "mushroomdb sync <db-dir>|--auto",
4776 ] {
4777 let start = text
4778 .find(prefix)
4779 .unwrap_or_else(|| panic!("usage is missing {prefix:?}"));
4780 let rest = &text[start..];
4781 let block_end = rest.find("\n mushroomdb ").unwrap_or(rest.len());
4782 assert!(
4783 rest[..block_end].contains("(deprecated, removed in 0.7)"),
4784 "usage does not mark {prefix:?} deprecated"
4785 );
4786 }
4787 }
4788
4789 #[test]
4792 fn hook_commands_take_a_dir_or_auto() {
4793 assert_eq!(
4794 parse_args(&["mcp", "--auto"]).unwrap(),
4795 Command::Mcp {
4796 db_dir: None,
4797 auto: true,
4798 all_tools: false
4799 }
4800 );
4801 assert_eq!(
4802 parse_args(&["recall", "--auto"]).unwrap(),
4803 Command::Recall {
4804 db_dir: None,
4805 auto: true
4806 }
4807 );
4808 assert_eq!(
4809 parse_args(&["brief", "--auto"]).unwrap(),
4810 Command::Brief {
4811 db_dir: None,
4812 auto: true
4813 }
4814 );
4815 assert_eq!(
4816 parse_args(&["brief", "/tmp/db"]).unwrap(),
4817 Command::Brief {
4818 db_dir: Some(PathBuf::from("/tmp/db")),
4819 auto: false
4820 }
4821 );
4822 for cmd in ["mcp", "recall", "touch", "brief"] {
4823 assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
4824 assert!(
4825 parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
4826 "{cmd} with both"
4827 );
4828 }
4829 assert!(usage().contains("--auto"));
4830 }
4831
4832 #[test]
4835 fn mcp_takes_all_tools() {
4836 for args in [
4837 &["mcp", "/tmp/db", "--all-tools"][..],
4838 &["mcp", "--all-tools", "/tmp/db"][..],
4839 ] {
4840 assert_eq!(
4841 parse_args(args).unwrap(),
4842 Command::Mcp {
4843 db_dir: Some(PathBuf::from("/tmp/db")),
4844 auto: false,
4845 all_tools: true
4846 },
4847 "{args:?}"
4848 );
4849 }
4850 assert_eq!(
4851 parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
4852 Command::Mcp {
4853 db_dir: None,
4854 auto: true,
4855 all_tools: true
4856 }
4857 );
4858 assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
4859 assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
4860 assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
4861 assert!(usage().contains("--all-tools"));
4862 }
4863
4864 #[test]
4865 fn sync_and_touch_parse() {
4866 assert_eq!(
4867 parse_args(&["sync", "/tmp/db"]).unwrap(),
4868 Command::Sync {
4869 db_dir: Some(PathBuf::from("/tmp/db")),
4870 auto: false,
4871 json: false,
4872 }
4873 );
4874 assert_eq!(
4875 parse_args(&["sync", "/tmp/db", "--json"]).unwrap(),
4876 Command::Sync {
4877 db_dir: Some(PathBuf::from("/tmp/db")),
4878 auto: false,
4879 json: true,
4880 }
4881 );
4882 assert_eq!(
4885 parse_args(&["sync", "--auto"]).unwrap(),
4886 Command::Sync {
4887 db_dir: None,
4888 auto: true,
4889 json: false,
4890 }
4891 );
4892 assert_eq!(
4893 parse_args(&["sync", "--auto", "--json"]).unwrap(),
4894 Command::Sync {
4895 db_dir: None,
4896 auto: true,
4897 json: true,
4898 }
4899 );
4900 assert!(
4901 parse_args(&["sync"]).is_err(),
4902 "one of <db-dir> or --auto is required"
4903 );
4904 assert!(
4905 parse_args(&["sync", "/tmp/db", "--auto"]).is_err(),
4906 "--auto and a path contradict each other"
4907 );
4908
4909 assert_eq!(
4911 parse_args(&["touch", "/tmp/db", "src/a.rs", "src/b.rs"]).unwrap(),
4912 Command::Touch {
4913 db_dir: Some(PathBuf::from("/tmp/db")),
4914 auto: false,
4915 files: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
4916 }
4917 );
4918 assert_eq!(
4920 parse_args(&["touch", "--auto", "src/a.rs"]).unwrap(),
4921 Command::Touch {
4922 db_dir: None,
4923 auto: true,
4924 files: vec![PathBuf::from("src/a.rs")],
4925 }
4926 );
4927 assert_eq!(
4929 parse_args(&["touch", "--auto"]).unwrap(),
4930 Command::Touch {
4931 db_dir: None,
4932 auto: true,
4933 files: vec![],
4934 }
4935 );
4936 assert!(usage().contains("mushroomdb sync <db-dir>"));
4937 assert!(usage().contains("mushroomdb touch"));
4938 }
4939
4940 #[test]
4941 fn ingest_git_parses_excludes() {
4942 let cmd = parse_args(&[
4943 "ingest-git",
4944 "/tmp/db",
4945 "/tmp/repo",
4946 "--exclude",
4947 "target/",
4948 "--exclude=*.lock",
4949 "--max-commits-per-file",
4950 "50",
4951 "--recurse-submodules",
4952 "--prs",
4953 "--ensure-gitignore",
4954 ])
4955 .unwrap();
4956 assert_eq!(
4957 cmd,
4958 Command::IngestGit {
4959 db_dir: PathBuf::from("/tmp/db"),
4960 opts: ingest_git::IngestGitOpts {
4961 repo: PathBuf::from("/tmp/repo"),
4962 exclude: vec!["target/".into(), "*.lock".into()],
4963 max_commits_per_file: 50,
4964 recurse_submodules: true,
4965 prs: true,
4966 structure: true,
4967 docs: true,
4968 ensure_gitignore: true,
4969 },
4970 }
4971 );
4972 let Command::IngestGit { opts, .. } =
4974 parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
4975 else {
4976 panic!("expected IngestGit");
4977 };
4978 assert_eq!(
4979 opts.exclude,
4980 ingest_git::DEFAULT_EXCLUDES
4981 .iter()
4982 .map(|p| (*p).to_string())
4983 .collect::<Vec<_>>(),
4984 "with no --exclude the defaults apply"
4985 );
4986 assert_eq!(
4987 opts.max_commits_per_file,
4988 ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
4989 );
4990 assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
4991 assert!(
4992 opts.structure && opts.docs,
4993 "structure and docs default on and are recorded on the marker"
4994 );
4995 let Command::IngestGit { opts, .. } = parse_args(&[
4996 "ingest-git",
4997 "/tmp/db",
4998 "/tmp/repo",
4999 "--no-structure",
5000 "--no-docs",
5001 ])
5002 .unwrap() else {
5003 panic!("expected IngestGit");
5004 };
5005 assert!(!opts.structure && !opts.docs);
5006 assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
5007 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
5008 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
5009 assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
5010 }
5011
5012 #[test]
5013 fn version_constant_matches_cargo() {
5014 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
5015 assert!(usage().contains("--version"));
5016 }
5017}