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;
18#[cfg(test)]
19use core_api::restore::holds_a_store;
20pub use core_api::restore::RestoreOutcome;
22use core_api::schema::Schema;
23use core_api::{
24 default_max_edges, is_write_query, valid_namespace, AlgoDir, BackupReport, DegreeConfig,
25 Explanation, GraphDb, IngestOptions, LouvainConfig, PageRankConfig, Predicate, RealFs,
26 ResultSet, RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig,
27 WriteGuard, NS_MAX_LEN,
28};
29use export::ExportFormat;
30use std::collections::{BTreeMap, BTreeSet};
31use std::fmt::Write as _;
32use std::net::SocketAddr;
33use std::path::{Path, PathBuf};
34use std::time::Duration;
35
36pub const AUTOMATIC_SNAPSHOT: SnapshotOptions = SnapshotOptions {
47 keep_wal: false,
48 archive_wal: true,
49};
50
51pub const AUTO_SNAPSHOT_RETENTION: Option<u32> = None;
81
82pub fn snapshot_automatically(db: &mut WriteGuard<'_>) -> Result<(), core_api::GraphError> {
92 db.set_wal_archive_retention(AUTO_SNAPSHOT_RETENTION);
93 db.snapshot_with(AUTOMATIC_SNAPSHOT)
94}
95
96pub const SNAPSHOT_LOCK_WAIT: Duration = Duration::from_millis(500);
104
105pub fn snapshot_shared(db: &SharedDb) -> Result<(), core_api::GraphError> {
116 snapshot_automatically(&mut db.write_with_wait(SNAPSHOT_LOCK_WAIT)?)
117}
118
119pub const N_ORGS: usize = 10;
121pub const N_PROJECTS: usize = 20;
122pub const N_PEOPLE: usize = 30;
123
124pub const SAMPLE_QUERY: &str = "\
129MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
130RETURN p, proj, r.score AS score
131ORDER BY score DESC, proj";
132
133const SAMPLE_EXPLAIN_A: &str = "person-01";
134const SAMPLE_EXPLAIN_B: &str = "proj-01";
135
136pub const VERSION: &str = env!("CARGO_PKG_VERSION");
138
139#[must_use]
141pub fn version_string() -> String {
142 format!("mushroomdb {VERSION}")
143}
144
145#[must_use]
157pub fn resolve_auto_db(
158 env_project_dir: Option<&std::ffi::OsStr>,
159 cwd: &Path,
160 home: &Path,
161) -> PathBuf {
162 if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
163 return Path::new(dir).join("mushroom-memory");
164 }
165 if let Some(root) = worktree_root(cwd) {
166 return root.join("mushroom-memory");
167 }
168 home.join(".mushroomdb").join("memory")
169}
170
171#[must_use]
185pub fn worktree_root(dir: &Path) -> Option<&Path> {
186 dir.ancestors().find(|d| d.join(".git").exists())
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum ServeUi {
192 Filesystem(PathBuf),
193 Embedded,
194 None,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq)]
199pub enum AlgoSubcmd {
200 Pagerank,
201 Wcc,
202 Degree,
203 Communities,
204}
205
206#[derive(Debug, Clone, PartialEq)]
210pub enum Command {
211 Serve {
212 db_dir: PathBuf,
213 addr: SocketAddr,
214 ui: ServeUi,
215 demo_if_empty: bool,
218 token: Option<String>,
220 role_tokens: Vec<(String, String)>,
223 snapshot_every: Option<Duration>,
225 restore_from: Option<PathBuf>,
228 tls_cert: Option<PathBuf>,
230 tls_key: Option<PathBuf>,
232 },
233 Mcp {
234 db_dir: Option<PathBuf>,
236 auto: bool,
237 all_tools: bool,
243 },
244 Stats {
245 db_dir: PathBuf,
246 },
247 Demo {
248 db_dir: PathBuf,
249 },
250 AsOf {
252 db_dir: PathBuf,
253 commit: Option<u64>,
260 at: Option<String>,
263 query: Option<String>,
265 namespace: Option<String>,
267 },
268 Suggest {
270 db_dir: PathBuf,
271 },
272 Algo {
274 db_dir: PathBuf,
275 subcmd: AlgoSubcmd,
276 top: usize,
278 dir: AlgoDir,
281 edge_types: Vec<String>,
284 weight_prop: Option<String>,
286 min_weight: Option<f64>,
288 },
289 Query {
291 db_dir: PathBuf,
292 cypher: String,
294 role: Option<String>,
296 namespace: Option<String>,
298 },
299 Snapshot {
301 db_dir: PathBuf,
302 wal: WalDisposition,
303 retention: Option<u32>,
306 },
307 BuildIndex {
309 db_dir: PathBuf,
310 rule: Option<String>,
312 },
313 SchemaApply {
315 db_dir: PathBuf,
316 schema_file: PathBuf,
317 },
318 Migrate {
320 db_dir: PathBuf,
321 },
322 Verify {
324 db_dir: PathBuf,
325 },
326 Backup {
328 db_dir: PathBuf,
329 dest: PathBuf,
330 },
331 Export {
333 db_dir: PathBuf,
334 dest: PathBuf,
335 format: ExportFormat,
336 },
337 IngestGit {
339 db_dir: PathBuf,
340 opts: ingest_git::IngestGitOpts,
341 },
342 Install(install::InstallOpts),
344 Uninstall(install::InstallOpts),
346 Disable(install::ToggleOpts),
350 Enable(install::ToggleOpts),
353 Doctor(doctor::DoctorOpts),
355 Recall {
358 db_dir: Option<PathBuf>,
359 auto: bool,
360 },
361 Brief {
364 db_dir: Option<PathBuf>,
365 auto: bool,
366 },
367 Sync {
370 db_dir: Option<PathBuf>,
375 auto: bool,
376 json: bool,
380 },
381 Intercept {
386 db_dir: Option<PathBuf>,
387 auto: bool,
388 },
389 ImpactHook {
394 db_dir: Option<PathBuf>,
395 auto: bool,
396 },
397 Enrich {
402 db_dir: Option<PathBuf>,
403 auto: bool,
404 },
405 Touch {
408 db_dir: Option<PathBuf>,
409 auto: bool,
410 files: Vec<PathBuf>,
411 },
412 Map {
415 db_dir: PathBuf,
416 json: bool,
419 },
420 Explore {
423 db_dir: PathBuf,
424 target: String,
425 depth: repograph::Depth,
426 full: bool,
428 },
429 Context {
431 db_dir: PathBuf,
432 target: String,
433 full: bool,
436 },
437 Impact {
440 db_dir: PathBuf,
441 files: Vec<String>,
442 },
443 Owners {
445 db_dir: PathBuf,
446 path: String,
447 },
448 Why {
450 db_dir: PathBuf,
451 a: String,
452 b: String,
453 },
454 Version,
455 Help,
456}
457
458#[derive(Debug)]
460pub struct DemoOutcome {
461 pub auto_fk_rules: Vec<String>,
462 pub sample_query: String,
463 pub sample_result: ResultSet,
464 pub explanations: Vec<Explanation>,
465 pub stats: Stats,
466 pub suggestion: Option<RuleSuggestion>,
468}
469
470#[derive(Debug)]
472pub struct CliError(pub String);
473
474impl std::fmt::Display for CliError {
475 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 f.write_str(&self.0)
477 }
478}
479
480impl std::error::Error for CliError {}
481
482impl From<core_api::GraphError> for CliError {
483 fn from(e: core_api::GraphError) -> Self {
484 CliError(e.to_string())
485 }
486}
487
488impl From<std::io::Error> for CliError {
489 fn from(e: std::io::Error) -> Self {
490 CliError(e.to_string())
491 }
492}
493
494pub fn usage() -> &'static str {
496 "\
497mushroomdb — embedded graph database
498
499Usage:
500 mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
501 [--command <path>] [--no-git-hooks] [--no-prewarm]
502 [--delivery cli|mcp|both] [--intercept-grep]
503 [--impact-before-edit] [--enrich-grep]
504 [--always-load|--no-always-load]
505 --delivery cli writes the skill and the hooks and registers no MCP
506 server: the skill teaches `mushroomdb <command>` instead (claude-code
507 only; cursor and codex are always registered as MCP servers)
508 --intercept-grep (deprecated, removed in 0.7) adds an experimental
509 PreToolUse hook (matcher Grep) that redirects a search for a known
510 symbol name to `explore`
511 --impact-before-edit (deprecated, removed in 0.7) adds an experimental
512 PreToolUse hook (matcher Edit|Write|MultiEdit) that injects the file's
513 blast radius before the edit
514 --enrich-grep (deprecated, removed in 0.7) adds an experimental
515 PostToolUse hook (matcher Grep) that appends what the graph knows
516 about the symbols the search matched
517 --always-load marks the registered MCP server alwaysLoad, so the host
518 keeps its tools in context instead of deferring them; already the
519 default when --db names a store and a server is registered
520 (--delivery mcp|both) — --no-always-load opts out
521 mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
522 mushroomdb disable [--platform claude-code|cursor|codex|all] [--project|--user]
523 turn an install off without removing it: hooks, MCP entry and git hook
524 blocks are removed; the skill, the store and .gitignore stay
525 mushroomdb enable [--platform claude-code|cursor|codex|all] [--project|--user]
526 turn a disabled install back on
527 mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
528 verify an install: config entry, store, hooks, git hooks, and a real
529 stdio handshake with the configured MCP command; exits 1 on any `fail`
530 mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>] [--restore-from <dir>]
531 --restore-from seeds an empty <db-dir> from the newest backup under <dir>
532 (or from <dir> itself if it is one); a no-op when <db-dir> already holds a store
533 mushroomdb mcp <db-dir>|--auto [--all-tools]
534 --all-tools lists all 28 tools; the default follows the store — 3 on a
535 store `ingest-git` built (explore, query, stats), 19 on any other
536 (the rest stay callable, just unlisted)
537 mushroomdb stats <db-dir>
538 mushroomdb demo <db-dir>
539 mushroomdb recall <db-dir>|--auto hook body: reads a prompt payload on stdin, prints related graph facts
540 mushroomdb brief <db-dir>|--auto hook body: the repository in one block — size, synced sha, the most
541 central files and the most called symbols; byte-stable, so a
542 session host caches it once
543 mushroomdb sync <db-dir>|--auto [--json] (deprecated, removed in 0.7)
544 re-sync the repo the store was built from: new commits, then the
545 dirty working tree (git hook body)
546 mushroomdb map <db-dir> [--json] (deprecated, removed in 0.7)
547 summarise the graphed repository: clusters, key files, owners, hot files
548 --json prints the computed map instead of the rendered digest
549 mushroomdb explore <db-dir> <target> [--depth context|impact|history|all] [--full]
550 (deprecated, removed in 0.7)
551 one target from as many sides as asked for: the definition and
552 its callers (context), the blast radius (impact), the owner and
553 what it changes with (history), or all three
554 <target> is a file path, a symbol key, or a bare symbol name
555 --full also quotes the body from the working tree
556 mushroomdb context <db-dir> <target> [--full] (deprecated, removed in 0.7)
557 one file or symbol from every side: where it is, signature, callers,
558 callees, importers, co-change partners, commits, notes
559 <target> is a file path, a symbol key, or a bare symbol name
560 --full also quotes the body from the working tree
561 mushroomdb impact <db-dir> <file>... (deprecated, removed in 0.7)
562 what changing these files reaches: partners, importers,
563 and the symbols other files call
564 mushroomdb owners <db-dir> <path> (deprecated, removed in 0.7)
565 top author and share, who else knows it, last touch, last 4 quarters
566 mushroomdb why <db-dir> <a> <b> (deprecated, removed in 0.7)
567 every rule edge between two nodes with its evidence, or the
568 shortest path between them
569 mushroomdb touch <db-dir>|--auto [<file>...]
570 re-extract just these files; with no <file> reads them from a
571 PostToolUse payload on stdin (hook body)
572 mushroomdb intercept <db-dir>|--auto
573 hook body: reads a PreToolUse Grep payload on stdin; exits 2
574 with a one-line pointer to `explore` when the pattern names a
575 symbol the graph holds, else exits 0 in silence
576 mushroomdb impact-hook <db-dir>|--auto
577 hook body: reads a PreToolUse edit payload on stdin; prints the
578 edited file's blast radius as additionalContext, else nothing
579 mushroomdb enrich <db-dir>|--auto
580 hook body: reads a PostToolUse Grep payload on stdin; prints what
581 the graph knows about the symbols it matched, else nothing
582 mushroomdb suggest <db-dir>
583 mushroomdb asof <db-dir> --commit N|--at <date> [--query \"MATCH ...\"] [--namespace <ns>]
584 --at takes an RFC 3339 date (2026-06-19, or 2026-06-19T12:00:00Z)
585 and resolves to the last commit at or before it; exactly one of
586 --commit and --at
587 mushroomdb query <db-dir> [--query \"MATCH ...\"] [--role <name>] [--namespace <ns>] <cypher…>
588 --role answers as one of the store's roles and --namespace from one
589 namespace; together they intersect, so neither ever widens the other
590 and either one makes the query a read
591 mushroomdb snapshot <db-dir> [--keep-wal|--truncate] [--retention N]
592 folds the WAL into snapshot.bin and archives it as wal.<N>.archive,
593 so node_history, edge_history, was_linked and asof keep reaching it;
594 --truncate discards it instead, --keep-wal leaves wal.bin whole
595 mushroomdb build-index <db-dir> [--rule <name>]
596 drives a rule's vector index to completion a slice at a time, for an
597 operator who wants the build finished before traffic arrives; a rule
598 created over a large corpus derives no edges until its index is whole
599 mushroomdb migrate <db-dir>
600 mushroomdb verify <db-dir> validate CRC32 integrity of every snapshot section
601 mushroomdb backup <db-dir> <dest> process-local consistent copy of the database to <dest>
602 WARNING: unsafe against a concurrently running serve process;
603 use POST /backup on the HTTP server for live-serve backups
604 mushroomdb export <db-dir> <dest> --format jsonl|parquet|graphml export all data
605 graphml writes one file: <dest>/graph.graphml if <dest> is an
606 existing directory, otherwise <dest> is the file path itself
607 (nodes + edges only; rules have no GraphML analogue)
608 mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
609 [--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
610 graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
611 --recurse-submodules also walks each initialised submodule
612 --prs links merged pull requests via gh (skipped when gh is unavailable)
613 --no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
614 --no-docs skips Markdown bodies, headings and mentions
615 --ensure-gitignore adds the database directory to the repo's .gitignore
616 with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
617 mushroomdb schema apply <db-dir> <schema.json>
618 mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
619 mushroomdb algo wcc <db-dir> [--top N]
620 mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
621 mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
622 mushroomdb --version
623 mushroomdb --help
624
625Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
626install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
627the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
628--command names one (a relative --command or --db is anchored to the current directory).
629--no-git-hooks skips the post-commit/checkout/merge sync hooks. --no-prewarm means no network and no
630resolution: neither the one-off package fetch nor locating the package's binary, so every hook keeps
631the slower `npx` form.
632uninstall resolves the same scope and falls back to the other one when the inferred scope has no
633manifest; undoing a Codex install needs --platform codex.
634A project install inside a git checkout writes --auto rather than a store path, so each `git
635worktree` gets its own store; outside a checkout, and with --db, the store is pinned to an absolute
636path instead.
637--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else mushroom-memory at the
638root of the working tree the current directory is in, else ~/.mushroomdb/memory.
639"
640}
641
642fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
643 let mut platform: Option<install::Platform> = None;
644 let mut scope: Option<install::Scope> = None;
645 let mut db: Option<PathBuf> = None;
646 let mut command: Option<PathBuf> = None;
647 let mut git_hooks = true;
648 let mut prewarm = true;
649 let mut delivery = install::Delivery::default();
650 let mut intercept_grep = false;
651 let mut impact_before_edit = false;
652 let mut enrich_grep = false;
653 let mut always_load: Option<bool> = None;
656 let mut i = 0;
657 while i < args.len() {
658 let a = args[i];
659 if a == "--delivery" {
660 let val = args
661 .get(i + 1)
662 .copied()
663 .ok_or_else(|| "missing value for --delivery".to_string())?;
664 delivery = install::Delivery::parse(val)?;
665 i += 2;
666 } else if let Some(val) = a.strip_prefix("--delivery=") {
667 delivery = install::Delivery::parse(val)?;
668 i += 1;
669 } else if a == "--platform" {
670 let val = args
671 .get(i + 1)
672 .copied()
673 .ok_or_else(|| "missing value for --platform".to_string())?;
674 platform = Some(install::Platform::parse(val)?);
675 i += 2;
676 } else if let Some(val) = a.strip_prefix("--platform=") {
677 platform = Some(install::Platform::parse(val)?);
678 i += 1;
679 } else if a == "--project" || a == "--user" {
680 let want = if a == "--project" {
681 install::Scope::Project
682 } else {
683 install::Scope::User
684 };
685 if scope.is_some_and(|s| s != want) {
688 return Err("--project and --user are mutually exclusive".to_string());
689 }
690 scope = Some(want);
691 i += 1;
692 } else if a == "--no-git-hooks" {
693 git_hooks = false;
694 i += 1;
695 } else if a == "--intercept-grep" {
696 intercept_grep = true;
697 i += 1;
698 } else if a == "--impact-before-edit" {
699 impact_before_edit = true;
700 i += 1;
701 } else if a == "--enrich-grep" {
702 enrich_grep = true;
703 i += 1;
704 } else if a == "--always-load" {
705 always_load = Some(true);
706 i += 1;
707 } else if a == "--no-always-load" {
708 always_load = Some(false);
709 i += 1;
710 } else if a == "--no-prewarm" {
711 prewarm = false;
712 i += 1;
713 } else if a == "--command" {
714 let val = args
715 .get(i + 1)
716 .copied()
717 .ok_or_else(|| "missing value for --command".to_string())?;
718 command = Some(PathBuf::from(val));
719 i += 2;
720 } else if let Some(val) = a.strip_prefix("--command=") {
721 command = Some(PathBuf::from(val));
722 i += 1;
723 } else if a == "--db" {
724 let val = args
725 .get(i + 1)
726 .copied()
727 .ok_or_else(|| "missing value for --db".to_string())?;
728 db = Some(PathBuf::from(val));
729 i += 2;
730 } else if let Some(val) = a.strip_prefix("--db=") {
731 db = Some(PathBuf::from(val));
732 i += 1;
733 } else if a.starts_with('-') {
734 return Err(format!("unexpected flag: {a}"));
735 } else {
736 return Err(format!("unexpected argument: {a}"));
737 }
738 }
739 let always_load =
751 always_load.unwrap_or(db.is_some() && !matches!(delivery, install::Delivery::Cli));
752 Ok(install::InstallOpts {
753 platform,
754 scope,
755 db,
756 command,
757 git_hooks,
758 prewarm,
759 delivery,
760 intercept_grep,
761 impact_before_edit,
762 enrich_grep,
763 always_load,
764 })
765}
766
767fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
768 let mut platform: Option<install::Platform> = None;
769 let mut scope: Option<install::Scope> = None;
770 let mut i = 0;
771 while i < args.len() {
772 let a = args[i];
773 if a == "--platform" {
774 let val = args
775 .get(i + 1)
776 .copied()
777 .ok_or_else(|| "missing value for --platform".to_string())?;
778 platform = Some(install::Platform::parse(val)?);
779 i += 2;
780 } else if let Some(val) = a.strip_prefix("--platform=") {
781 platform = Some(install::Platform::parse(val)?);
782 i += 1;
783 } else if a == "--project" || a == "--user" {
784 let want = if a == "--project" {
785 install::Scope::Project
786 } else {
787 install::Scope::User
788 };
789 if scope.is_some_and(|s| s != want) {
790 return Err("--project and --user are mutually exclusive".to_string());
791 }
792 scope = Some(want);
793 i += 1;
794 } else if a.starts_with('-') {
795 return Err(format!("unexpected flag: {a}"));
796 } else {
797 return Err(format!("unexpected argument: {a}"));
798 }
799 }
800 Ok(doctor::DoctorOpts { platform, scope })
801}
802
803fn parse_toggle_cmd(args: &[&str]) -> Result<install::ToggleOpts, String> {
808 let mut platform: Option<install::Platform> = None;
809 let mut scope: Option<install::Scope> = None;
810 let mut i = 0;
811 while i < args.len() {
812 let a = args[i];
813 if a == "--platform" {
814 let val = args
815 .get(i + 1)
816 .copied()
817 .ok_or_else(|| "missing value for --platform".to_string())?;
818 platform = Some(install::Platform::parse(val)?);
819 i += 2;
820 } else if let Some(val) = a.strip_prefix("--platform=") {
821 platform = Some(install::Platform::parse(val)?);
822 i += 1;
823 } else if a == "--project" || a == "--user" {
824 let want = if a == "--project" {
825 install::Scope::Project
826 } else {
827 install::Scope::User
828 };
829 if scope.is_some_and(|s| s != want) {
830 return Err("--project and --user are mutually exclusive".to_string());
831 }
832 scope = Some(want);
833 i += 1;
834 } else if a.starts_with('-') {
835 return Err(format!("unexpected flag: {a}"));
836 } else {
837 return Err(format!("unexpected argument: {a}"));
838 }
839 }
840 Ok(install::ToggleOpts { platform, scope })
841}
842
843fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
844 let mut positional = Vec::new();
845 let mut exclude = Vec::new();
846 let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
847 let mut recurse_submodules = false;
848 let mut prs = false;
849 let mut structure = true;
850 let mut docs = true;
851 let mut ensure_gitignore = false;
852 let mut i = 0;
853 while i < args.len() {
854 let a = args[i];
855 if a == "--recurse-submodules" {
856 recurse_submodules = true;
857 i += 1;
858 } else if a == "--prs" {
859 prs = true;
860 i += 1;
861 } else if a == "--no-structure" {
862 structure = false;
863 i += 1;
864 } else if a == "--no-docs" {
865 docs = false;
866 i += 1;
867 } else if a == "--ensure-gitignore" {
868 ensure_gitignore = true;
869 i += 1;
870 } else if a == "--exclude" {
871 exclude.push(
872 args.get(i + 1)
873 .copied()
874 .ok_or_else(|| "missing value for --exclude".to_string())?
875 .to_string(),
876 );
877 i += 2;
878 } else if let Some(val) = a.strip_prefix("--exclude=") {
879 exclude.push(val.to_string());
880 i += 1;
881 } else if a == "--max-commits-per-file" {
882 let val = args
883 .get(i + 1)
884 .copied()
885 .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
886 max_commits_per_file = val
887 .parse()
888 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
889 i += 2;
890 } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
891 max_commits_per_file = val
892 .parse()
893 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
894 i += 1;
895 } else if a.starts_with('-') {
896 return Err(format!("unexpected flag: {a}"));
897 } else {
898 positional.push(a);
899 i += 1;
900 }
901 }
902 let [db_dir, repo] = positional.as_slice() else {
903 return Err("ingest-git requires <db-dir> <repo-dir>".into());
904 };
905 if exclude.is_empty() {
909 exclude = ingest_git::DEFAULT_EXCLUDES
910 .iter()
911 .map(|p| (*p).to_string())
912 .collect();
913 }
914 Ok(Command::IngestGit {
915 db_dir: PathBuf::from(db_dir),
916 opts: ingest_git::IngestGitOpts {
917 repo: PathBuf::from(repo),
918 exclude,
919 max_commits_per_file,
920 recurse_submodules,
921 prs,
922 structure,
923 docs,
924 ensure_gitignore,
925 },
926 })
927}
928
929pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
931 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
932 if args.is_empty() {
933 return Ok(Command::Help);
934 }
935 match args[0] {
936 "--help" | "-h" | "help" => Ok(Command::Help),
937 "--version" | "-V" | "version" => Ok(Command::Version),
938 "serve" => parse_serve(&args[1..]),
939 "mcp" => parse_mcp(&args[1..]),
940 "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
941 "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
942 "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
943 "asof" => parse_asof(&args[1..]),
944 "algo" => parse_algo(&args[1..]),
945 "query" => parse_query(&args[1..]),
946 "snapshot" => parse_snapshot(&args[1..]),
947 "build-index" => parse_build_index(&args[1..]),
948 "schema" => parse_schema(&args[1..]),
949 "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
950 "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
951 "backup" => parse_backup(&args[1..]),
952 "export" => parse_export(&args[1..]),
953 "recall" => parse_dir_or_auto("recall", &args[1..])
954 .map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
955 "brief" => parse_dir_or_auto("brief", &args[1..])
956 .map(|(db_dir, auto)| Command::Brief { db_dir, auto }),
957 "intercept" => parse_dir_or_auto("intercept", &args[1..])
958 .map(|(db_dir, auto)| Command::Intercept { db_dir, auto }),
959 "impact-hook" => parse_dir_or_auto("impact-hook", &args[1..])
960 .map(|(db_dir, auto)| Command::ImpactHook { db_dir, auto }),
961 "enrich" => parse_dir_or_auto("enrich", &args[1..])
962 .map(|(db_dir, auto)| Command::Enrich { db_dir, auto }),
963 "sync" => parse_sync(&args[1..]),
964 "map" => parse_dir_with_json("map", &args[1..])
965 .map(|(db_dir, json)| Command::Map { db_dir, json }),
966 "explore" => parse_explore(&args[1..]),
967 "context" => parse_context(&args[1..]),
968 "impact" => parse_positional("impact", &args[1..], 1, usize::MAX)
969 .map(|(db_dir, files)| Command::Impact { db_dir, files }),
970 "owners" => {
971 parse_positional("owners", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Owners {
972 db_dir,
973 path: rest[0].clone(),
974 })
975 }
976 "why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
977 db_dir,
978 a: rest[0].clone(),
979 b: rest[1].clone(),
980 }),
981 "touch" => parse_touch(&args[1..]),
982 "ingest-git" => parse_ingest_git(&args[1..]),
983 "install" => parse_install_cmd(&args[1..]).map(Command::Install),
984 "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
985 "disable" => parse_toggle_cmd(&args[1..]).map(Command::Disable),
986 "enable" => parse_toggle_cmd(&args[1..]).map(Command::Enable),
987 "doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
988 other => Err(format!("unknown command: {other}")),
989 }
990}
991
992fn default_addr() -> SocketAddr {
993 SocketAddr::from(([127, 0, 0, 1], 8080))
994}
995
996fn parse_serve(args: &[&str]) -> Result<Command, String> {
997 let mut db_dir = None;
998 let mut addr = default_addr();
999 let mut ui = ServeUi::Embedded;
1000 let mut saw_ui = false;
1001 let mut saw_no_ui = false;
1002 let mut demo_if_empty = false;
1003 let mut token = None;
1004 let mut role_tokens: Vec<(String, String)> = Vec::new();
1005 let mut snapshot_every = None;
1006 let mut restore_from: Option<PathBuf> = None;
1007 let mut tls_cert: Option<PathBuf> = None;
1008 let mut tls_key: Option<PathBuf> = None;
1009 let mut i = 0;
1010 while i < args.len() {
1011 let a = args[i];
1012 if a == "--addr" {
1013 let val = args
1014 .get(i + 1)
1015 .copied()
1016 .ok_or_else(|| "missing value for --addr".to_string())?;
1017 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
1018 i += 2;
1019 } else if let Some(val) = a.strip_prefix("--addr=") {
1020 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
1021 i += 1;
1022 } else if a == "--ui" {
1023 let val = args
1024 .get(i + 1)
1025 .copied()
1026 .ok_or_else(|| "missing value for --ui".to_string())?;
1027 ui = ServeUi::Filesystem(PathBuf::from(val));
1028 saw_ui = true;
1029 i += 2;
1030 } else if let Some(val) = a.strip_prefix("--ui=") {
1031 ui = ServeUi::Filesystem(PathBuf::from(val));
1032 saw_ui = true;
1033 i += 1;
1034 } else if a == "--no-ui" {
1035 ui = ServeUi::None;
1036 saw_no_ui = true;
1037 i += 1;
1038 } else if a == "--demo-if-empty" {
1039 demo_if_empty = true;
1040 i += 1;
1041 } else if a == "--token" {
1042 let val = args
1043 .get(i + 1)
1044 .copied()
1045 .ok_or_else(|| "missing value for --token".to_string())?;
1046 token = Some(val.to_string());
1047 i += 2;
1048 } else if let Some(val) = a.strip_prefix("--token=") {
1049 token = Some(val.to_string());
1050 i += 1;
1051 } else if a == "--role-token" {
1052 let val = args
1053 .get(i + 1)
1054 .copied()
1055 .ok_or_else(|| "missing value for --role-token".to_string())?;
1056 let (tok, role) = parse_role_token(val)?;
1057 role_tokens.push((tok, role));
1058 i += 2;
1059 } else if let Some(val) = a.strip_prefix("--role-token=") {
1060 let (tok, role) = parse_role_token(val)?;
1061 role_tokens.push((tok, role));
1062 i += 1;
1063 } else if a == "--snapshot-every" {
1064 let val = args
1065 .get(i + 1)
1066 .copied()
1067 .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
1068 snapshot_every = Some(parse_snapshot_every(val)?);
1069 i += 2;
1070 } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
1071 snapshot_every = Some(parse_snapshot_every(val)?);
1072 i += 1;
1073 } else if a == "--restore-from" {
1074 let val = args
1075 .get(i + 1)
1076 .copied()
1077 .ok_or_else(|| "missing value for --restore-from".to_string())?;
1078 restore_from = Some(PathBuf::from(val));
1079 i += 2;
1080 } else if let Some(val) = a.strip_prefix("--restore-from=") {
1081 restore_from = Some(PathBuf::from(val));
1082 i += 1;
1083 } else if a == "--tls-cert" {
1084 let val = args
1085 .get(i + 1)
1086 .copied()
1087 .ok_or_else(|| "missing value for --tls-cert".to_string())?;
1088 tls_cert = Some(PathBuf::from(val));
1089 i += 2;
1090 } else if let Some(val) = a.strip_prefix("--tls-cert=") {
1091 tls_cert = Some(PathBuf::from(val));
1092 i += 1;
1093 } else if a == "--tls-key" {
1094 let val = args
1095 .get(i + 1)
1096 .copied()
1097 .ok_or_else(|| "missing value for --tls-key".to_string())?;
1098 tls_key = Some(PathBuf::from(val));
1099 i += 2;
1100 } else if let Some(val) = a.strip_prefix("--tls-key=") {
1101 tls_key = Some(PathBuf::from(val));
1102 i += 1;
1103 } else if a.starts_with('-') {
1104 return Err(format!("unexpected flag: {a}"));
1105 } else if db_dir.is_none() {
1106 db_dir = Some(PathBuf::from(a));
1107 i += 1;
1108 } else {
1109 return Err(format!("unexpected extra argument: {a}"));
1110 }
1111 }
1112 if saw_ui && saw_no_ui {
1113 return Err("cannot combine --ui and --no-ui".to_string());
1114 }
1115 match (&tls_cert, &tls_key) {
1116 (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
1117 (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
1118 _ => {}
1119 }
1120 let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
1121 Ok(Command::Serve {
1122 db_dir,
1123 addr,
1124 ui,
1125 demo_if_empty,
1126 token,
1127 role_tokens,
1128 snapshot_every,
1129 restore_from,
1130 tls_cert,
1131 tls_key,
1132 })
1133}
1134
1135fn parse_role_token(val: &str) -> Result<(String, String), String> {
1136 let (tok, role) = val
1137 .split_once(':')
1138 .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
1139 if tok.is_empty() {
1140 return Err("--role-token: TOKEN must not be empty".to_string());
1141 }
1142 if role.is_empty() {
1143 return Err("--role-token: ROLE must not be empty".to_string());
1144 }
1145 Ok((tok.to_string(), role.to_string()))
1146}
1147
1148fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
1149 let secs: u64 = val
1150 .parse()
1151 .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
1152 if secs == 0 {
1153 return Err("--snapshot-every must be a positive number of seconds".into());
1154 }
1155 Ok(Duration::from_secs(secs))
1156}
1157
1158pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
1160 if !dir.is_dir() {
1161 return Err(format!("--ui directory does not exist: {}", dir.display()));
1162 }
1163 let index = dir.join("index.html");
1164 if !index.is_file() {
1165 return Err(format!(
1166 "--ui directory is missing index.html: {}",
1167 dir.display()
1168 ));
1169 }
1170 Ok(dir.to_path_buf())
1171}
1172
1173fn parse_asof(args: &[&str]) -> Result<Command, String> {
1174 let mut db_dir = None;
1175 let mut commit: Option<u64> = None;
1176 let mut at: Option<String> = None;
1177 let mut query: Option<String> = None;
1178 let mut namespace: Option<String> = None;
1179 let mut i = 0;
1180 while i < args.len() {
1181 let a = args[i];
1182 if a == "--at" {
1183 let val = args
1184 .get(i + 1)
1185 .copied()
1186 .ok_or_else(|| "missing value for --at".to_string())?;
1187 at = Some(val.to_string());
1188 i += 2;
1189 } else if let Some(val) = a.strip_prefix("--at=") {
1190 at = Some(val.to_string());
1191 i += 1;
1192 } else if a == "--commit" {
1193 let val = args
1194 .get(i + 1)
1195 .copied()
1196 .ok_or_else(|| "missing value for --commit".to_string())?;
1197 commit = Some(
1198 val.parse()
1199 .map_err(|_| format!("invalid commit index: {val}"))?,
1200 );
1201 i += 2;
1202 } else if let Some(val) = a.strip_prefix("--commit=") {
1203 commit = Some(
1204 val.parse()
1205 .map_err(|_| format!("invalid commit index: {val}"))?,
1206 );
1207 i += 1;
1208 } else if a == "--query" {
1209 let val = args
1210 .get(i + 1)
1211 .copied()
1212 .ok_or_else(|| "missing value for --query".to_string())?;
1213 query = Some(val.to_string());
1214 i += 2;
1215 } else if let Some(val) = a.strip_prefix("--query=") {
1216 query = Some(val.to_string());
1217 i += 1;
1218 } else if a == "--namespace" {
1219 let val = args
1220 .get(i + 1)
1221 .copied()
1222 .ok_or_else(|| "missing value for --namespace".to_string())?;
1223 namespace = Some(val.to_string());
1224 i += 2;
1225 } else if let Some(val) = a.strip_prefix("--namespace=") {
1226 namespace = Some(val.to_string());
1227 i += 1;
1228 } else if a.starts_with('-') {
1229 return Err(format!("unexpected flag: {a}"));
1230 } else if db_dir.is_none() {
1231 db_dir = Some(PathBuf::from(a));
1232 i += 1;
1233 } else {
1234 return Err(format!("unexpected extra argument: {a}"));
1235 }
1236 }
1237 let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
1238 match (commit.is_some(), at.is_some()) {
1239 (false, false) => {
1240 return Err("asof requires --commit N or --at <date>".to_string());
1241 }
1242 (true, true) => {
1243 return Err(
1244 "asof takes --commit or --at, not both: a precedence rule between a \
1245 commit index and a date is how a caller ends up believing it asked \
1246 for one and got the other"
1247 .to_string(),
1248 );
1249 }
1250 _ => {}
1251 }
1252 Ok(Command::AsOf {
1253 db_dir,
1254 commit,
1255 at,
1256 query,
1257 namespace,
1258 })
1259}
1260
1261pub fn run_asof(
1267 db_dir: &Path,
1268 commit: Option<u64>,
1269 at: Option<&str>,
1270 query: Option<&str>,
1271 namespace: Option<&str>,
1272) -> Result<String, CliError> {
1273 let namespace = check_namespace(namespace)?;
1274 let commit = match (commit, at) {
1278 (Some(c), _) => c,
1279 (None, Some(date)) => GraphDb::open(db_dir)?.resolve_date(date)?,
1280 (None, None) => {
1281 return Err(CliError(
1282 "asof requires --commit N or --at <date>".to_string(),
1283 ))
1284 }
1285 };
1286 let db = GraphDb::open_at(db_dir, commit)?;
1289 let total = db.wal_total_commits()?;
1290 let floor = db.wal_horizon_floor();
1291 let mut out = String::new();
1292 if floor == 0 {
1293 let _ = writeln!(out, "as-of commit {} of {}", commit, total);
1294 } else {
1295 let _ = writeln!(
1296 out,
1297 "as-of commit {} of {} (history reaches back to commit {})",
1298 commit, total, floor
1299 );
1300 }
1301 if let Some(cypher) = query {
1302 let params = BTreeMap::new();
1303 let rs = match namespace {
1304 Some(ns) => db.query_masked(cypher, ¶ms, &db.mask_for_namespace(ns))?,
1305 None => db.query(cypher, ¶ms)?,
1306 };
1307 out.push_str(&format_result_set(&rs));
1308 }
1309 Ok(out)
1310}
1311
1312fn check_namespace(namespace: Option<&str>) -> Result<Option<&str>, CliError> {
1315 match namespace {
1316 Some(ns) if !valid_namespace(ns) => Err(CliError(format!(
1317 "namespace {ns:?} is not a valid namespace name — 1 to {NS_MAX_LEN} characters of \
1318 [A-Za-z0-9_.-]"
1319 ))),
1320 other => Ok(other),
1321 }
1322}
1323
1324fn parse_query(args: &[&str]) -> Result<Command, String> {
1325 let mut db_dir = None;
1326 let mut query_flag: Option<String> = None;
1327 let mut role: Option<String> = None;
1328 let mut namespace: Option<String> = None;
1329 let mut cypher_parts: Vec<&str> = Vec::new();
1330 let mut i = 0;
1331 while i < args.len() {
1332 let a = args[i];
1333 if a == "--query" {
1334 let val = args
1335 .get(i + 1)
1336 .copied()
1337 .ok_or_else(|| "missing value for --query".to_string())?;
1338 query_flag = Some(val.to_string());
1339 i += 2;
1340 } else if let Some(val) = a.strip_prefix("--query=") {
1341 query_flag = Some(val.to_string());
1342 i += 1;
1343 } else if a == "--role" {
1344 let val = args
1345 .get(i + 1)
1346 .copied()
1347 .ok_or_else(|| "missing value for --role".to_string())?;
1348 role = Some(val.to_string());
1349 i += 2;
1350 } else if let Some(val) = a.strip_prefix("--role=") {
1351 role = Some(val.to_string());
1352 i += 1;
1353 } else if a == "--namespace" {
1354 let val = args
1355 .get(i + 1)
1356 .copied()
1357 .ok_or_else(|| "missing value for --namespace".to_string())?;
1358 namespace = Some(val.to_string());
1359 i += 2;
1360 } else if let Some(val) = a.strip_prefix("--namespace=") {
1361 namespace = Some(val.to_string());
1362 i += 1;
1363 } else if a.starts_with('-') {
1364 return Err(format!("unexpected flag: {a}"));
1365 } else if db_dir.is_none() {
1366 db_dir = Some(PathBuf::from(a));
1367 i += 1;
1368 } else {
1369 cypher_parts.push(a);
1370 i += 1;
1371 }
1372 }
1373 let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
1374 let cypher = if let Some(q) = query_flag {
1375 if !cypher_parts.is_empty() {
1376 return Err(
1377 "query: pass Cypher as remaining arguments or --query, not both".to_string(),
1378 );
1379 }
1380 q
1381 } else {
1382 if cypher_parts.is_empty() {
1383 return Err("query requires a Cypher string".to_string());
1384 }
1385 cypher_parts.join(" ")
1386 };
1387 Ok(Command::Query {
1388 db_dir,
1389 cypher,
1390 role,
1391 namespace,
1392 })
1393}
1394
1395pub fn run_query(
1402 db_dir: &Path,
1403 cypher: &str,
1404 role: Option<&str>,
1405 namespace: Option<&str>,
1406) -> Result<String, CliError> {
1407 let namespace = check_namespace(namespace)?;
1408 let params = BTreeMap::new();
1409 if role.is_some() || namespace.is_some() {
1410 let db = GraphDb::open(db_dir)?;
1411 let mask = match (role, namespace) {
1414 (Some(role), Some(ns)) => db
1415 .mask_for_role(role)?
1416 .intersect(&db.mask_for_namespace(ns)),
1417 (Some(role), None) => db.mask_for_role(role)?,
1418 (None, Some(ns)) => db.mask_for_namespace(ns),
1419 (None, None) => unreachable!("one of the two is Some in this branch"),
1420 };
1421 return Ok(format_result_set(&db.query_masked(cypher, ¶ms, &mask)?));
1422 }
1423 let is_write = is_write_query(cypher).map_err(CliError)?;
1424 let rs = if is_write {
1425 let mut db = GraphDb::open(db_dir)?;
1426 db.query_write(cypher, ¶ms)?
1427 } else {
1428 let db = GraphDb::open(db_dir)?;
1429 db.query(cypher, ¶ms)?
1430 };
1431 Ok(format_result_set(&rs))
1432}
1433
1434fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
1435 let mut db_dir = None;
1436 let mut wal = WalDisposition::Archive;
1439 let mut retention: Option<u32> = None;
1440 let mut i = 0;
1441 while i < args.len() {
1442 let a = args[i];
1443 if a == "--keep-wal" {
1444 wal = WalDisposition::Keep;
1445 i += 1;
1446 } else if a == "--truncate" {
1447 wal = WalDisposition::Truncate;
1448 i += 1;
1449 } else if a == "--archive-wal" {
1450 wal = WalDisposition::Archive;
1452 i += 1;
1453 } else if a.starts_with("--retention=") {
1454 let v = a.trim_start_matches("--retention=");
1455 retention = Some(
1456 v.parse::<u32>()
1457 .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
1458 );
1459 i += 1;
1460 } else if a == "--retention" {
1461 i += 1;
1462 let v = args
1463 .get(i)
1464 .ok_or_else(|| "--retention requires a value".to_string())?;
1465 retention = Some(
1466 v.parse::<u32>()
1467 .map_err(|e| format!("--retention value error: {e}"))?,
1468 );
1469 i += 1;
1470 } else if a.starts_with('-') {
1471 return Err(format!("unexpected flag: {a}"));
1472 } else if db_dir.is_none() {
1473 db_dir = Some(PathBuf::from(a));
1474 i += 1;
1475 } else {
1476 return Err(format!("unexpected extra argument: {a}"));
1477 }
1478 }
1479 let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
1480 Ok(Command::Snapshot {
1481 db_dir,
1482 wal,
1483 retention,
1484 })
1485}
1486
1487pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
1496 let current = core_api::SNAPSHOT_VERSION;
1497 let from_ver = core_api::snapshot_version_at(db_dir)?;
1498
1499 if let Some(ver) = from_ver {
1503 if ver >= current {
1504 return Ok(format!("already current (V{ver})\n"));
1505 }
1506 }
1507
1508 if from_ver.is_some() {
1513 std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
1514 }
1515
1516 let mut db = GraphDb::open_with_options(
1519 db_dir,
1520 core_api::OpenOptions {
1521 auto_migrate: false,
1522 ..Default::default()
1523 },
1524 )?;
1525 db.snapshot()?;
1526
1527 let msg = match from_ver {
1528 Some(ver) => format!("migrated V{ver} -> V{current}\n"),
1529 None => format!("migrated WAL-only -> V{current}\n"),
1530 };
1531 Ok(msg)
1532}
1533
1534pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
1540 if !db_dir.join("snapshot.bin").exists() {
1543 return Err(CliError(format!(
1544 "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
1545 db_dir.display(),
1546 db_dir.display()
1547 )));
1548 }
1549 let results = core_api::verify_snapshot(db_dir)
1550 .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
1551 let mut any_fail = false;
1552 let mut out = String::new();
1553 for (id, section_name, byte_len, result) in &results {
1554 match result {
1555 Ok(()) => {
1556 let _ = writeln!(
1557 out,
1558 " section {:2} ({:<12}) {:>10} bytes OK",
1559 id, section_name, byte_len
1560 );
1561 }
1562 Err(msg) => {
1563 let _ = writeln!(
1564 out,
1565 " section {:2} ({:<12}) {:>10} bytes CORRUPT: {msg}",
1566 id, section_name, byte_len
1567 );
1568 any_fail = true;
1569 }
1570 }
1571 }
1572 if any_fail {
1573 Err(CliError(format!("integrity check FAILED:\n{out}")))
1574 } else {
1575 Ok(format!(
1576 "integrity check OK ({} sections):\n{out}",
1577 results.len()
1578 ))
1579 }
1580}
1581
1582#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1584pub enum WalDisposition {
1585 #[default]
1589 Archive,
1590 Keep,
1593 Truncate,
1598}
1599
1600impl WalDisposition {
1601 fn options(self) -> SnapshotOptions {
1602 match self {
1603 WalDisposition::Archive => AUTOMATIC_SNAPSHOT,
1604 WalDisposition::Keep => SnapshotOptions {
1605 keep_wal: true,
1606 archive_wal: false,
1607 },
1608 WalDisposition::Truncate => SnapshotOptions {
1609 keep_wal: false,
1610 archive_wal: false,
1611 },
1612 }
1613 }
1614}
1615
1616pub fn run_snapshot(
1618 db_dir: &Path,
1619 wal: WalDisposition,
1620 retention: Option<u32>,
1621) -> Result<String, CliError> {
1622 let mut db = GraphDb::open(db_dir)?;
1623 if wal == WalDisposition::Archive {
1624 db.set_wal_archive_retention(retention);
1625 }
1626 db.snapshot_with(wal.options())?;
1627 Ok(format!(
1628 "snapshot written: {}\n",
1629 db_dir.join("snapshot.bin").display()
1630 ))
1631}
1632
1633fn parse_build_index(args: &[&str]) -> Result<Command, String> {
1634 let mut db_dir: Option<PathBuf> = None;
1635 let mut rule: Option<String> = None;
1636 let mut i = 0;
1637 while i < args.len() {
1638 let a = args[i];
1639 if let Some(v) = a.strip_prefix("--rule=") {
1640 if v.is_empty() {
1641 return Err("--rule requires a value".to_string());
1642 }
1643 rule = Some(v.to_string());
1644 i += 1;
1645 } else if a == "--rule" {
1646 i += 1;
1647 let v = args
1648 .get(i)
1649 .ok_or_else(|| "--rule requires a value".to_string())?;
1650 rule = Some((*v).to_string());
1651 i += 1;
1652 } else if a.starts_with('-') {
1653 return Err(format!("unexpected flag: {a}"));
1654 } else if db_dir.is_none() {
1655 db_dir = Some(PathBuf::from(a));
1656 i += 1;
1657 } else {
1658 return Err(format!("unexpected extra argument: {a}"));
1659 }
1660 }
1661 let db_dir = db_dir.ok_or_else(|| "build-index requires <db-dir>".to_string())?;
1662 Ok(Command::BuildIndex { db_dir, rule })
1663}
1664
1665pub fn run_build_index(db_dir: &Path, rule: Option<&str>) -> Result<String, CliError> {
1672 let mut db = GraphDb::open(db_dir)?;
1673 build_index_on(&mut db, rule)
1674}
1675
1676pub fn build_index_on(db: &mut GraphDb<RealFs>, rule: Option<&str>) -> Result<String, CliError> {
1688 let mut out = String::new();
1689 let interesting = |name: &str| rule.is_none_or(|r| r == name);
1690 let known_rules: Vec<String> = db.stats().rules.iter().map(|r| r.name.clone()).collect();
1693
1694 loop {
1698 let (finished, outstanding) = db.pump_index_build_reporting()?;
1699 for b in &outstanding {
1700 if interesting(&b.rule) {
1701 out.push_str(&format!("building {}: {}/{}\n", b.rule, b.indexed, b.total));
1702 }
1703 }
1704 for b in &finished {
1708 if interesting(&b.rule) {
1709 out.push_str(&format!("built {}: {} vectors\n", b.rule, b.total));
1710 }
1711 }
1712 if outstanding.is_empty() {
1713 break;
1714 }
1715 }
1716 if out.is_empty() {
1717 match rule {
1718 Some(r) if !known_rules.iter().any(|k| k == r) => out.push_str(&format!(
1722 "no rule named {r:?} in this store; nothing to build\n"
1723 )),
1724 Some(r) => out.push_str(&format!("nothing to build for rule {r:?}\n")),
1725 None => out.push_str("nothing to build\n"),
1726 }
1727 }
1728 Ok(out)
1729}
1730
1731fn parse_schema(args: &[&str]) -> Result<Command, String> {
1732 if args.is_empty() {
1733 return Err("schema requires a subcommand: apply".to_string());
1734 }
1735 match args[0] {
1736 "apply" => parse_schema_apply(&args[1..]),
1737 other => Err(format!(
1738 "unknown schema subcommand: {other}; expected apply"
1739 )),
1740 }
1741}
1742
1743fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
1744 let mut db_dir = None;
1745 let mut schema_file = None;
1746 for a in args {
1747 if a.starts_with('-') {
1748 return Err(format!("unexpected flag: {a}"));
1749 }
1750 if db_dir.is_none() {
1751 db_dir = Some(PathBuf::from(*a));
1752 } else if schema_file.is_none() {
1753 schema_file = Some(PathBuf::from(*a));
1754 } else {
1755 return Err(format!("unexpected extra argument: {a}"));
1756 }
1757 }
1758 let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
1759 let schema_file =
1760 schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
1761 Ok(Command::SchemaApply {
1762 db_dir,
1763 schema_file,
1764 })
1765}
1766
1767pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
1770 let json = std::fs::read_to_string(schema_file)
1771 .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
1772 let schema: Schema = serde_json::from_str(&json).map_err(|e| {
1773 CliError(format!(
1774 "invalid schema JSON in {}: {e}",
1775 schema_file.display()
1776 ))
1777 })?;
1778 let mut db = GraphDb::open(db_dir)?;
1779 let diff = db.apply_schema(&schema)?;
1780 let mut out = String::new();
1781 for entry in &diff.created {
1782 let _ = writeln!(out, "created {entry}");
1783 }
1784 for entry in &diff.updated {
1785 let _ = writeln!(out, "updated {entry}");
1786 }
1787 for entry in &diff.unchanged {
1788 let _ = writeln!(out, "unchanged {entry}");
1789 }
1790 if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
1791 let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
1792 }
1793 Ok(out)
1794}
1795
1796fn parse_backup(args: &[&str]) -> Result<Command, String> {
1797 let mut db_dir = None;
1798 let mut dest = None;
1799 for a in args {
1800 if a.starts_with('-') {
1801 return Err(format!("unexpected flag: {a}"));
1802 }
1803 if db_dir.is_none() {
1804 db_dir = Some(PathBuf::from(*a));
1805 } else if dest.is_none() {
1806 dest = Some(PathBuf::from(*a));
1807 } else {
1808 return Err(format!("unexpected extra argument: {a}"));
1809 }
1810 }
1811 let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
1812 let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
1813 Ok(Command::Backup { db_dir, dest })
1814}
1815
1816fn parse_export(args: &[&str]) -> Result<Command, String> {
1817 let mut db_dir = None;
1818 let mut dest = None;
1819 let mut format = ExportFormat::Jsonl;
1820 let mut i = 0;
1821 while i < args.len() {
1822 let a = args[i];
1823 if a == "--format" {
1824 let val = args
1825 .get(i + 1)
1826 .copied()
1827 .ok_or_else(|| "missing value for --format".to_string())?;
1828 format = ExportFormat::parse(val).ok_or_else(|| {
1829 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1830 })?;
1831 i += 2;
1832 } else if let Some(val) = a.strip_prefix("--format=") {
1833 format = ExportFormat::parse(val).ok_or_else(|| {
1834 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1835 })?;
1836 i += 1;
1837 } else if a.starts_with('-') {
1838 return Err(format!("unexpected flag: {a}"));
1839 } else if db_dir.is_none() {
1840 db_dir = Some(PathBuf::from(a));
1841 i += 1;
1842 } else if dest.is_none() {
1843 dest = Some(PathBuf::from(a));
1844 i += 1;
1845 } else {
1846 return Err(format!("unexpected extra argument: {a}"));
1847 }
1848 }
1849 let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
1850 let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
1851 Ok(Command::Export {
1852 db_dir,
1853 dest,
1854 format,
1855 })
1856}
1857
1858pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
1860 let db = GraphDb::open(db_dir)?;
1861 Ok(db.backup_to(dest)?)
1862}
1863
1864pub fn restore_if_empty(db_dir: &Path, from: &Path) -> Result<RestoreOutcome, CliError> {
1872 core_api::restore::restore_if_empty(db_dir, from).map_err(|e| match e {
1873 core_api::GraphError::Io(io) => CliError(io.to_string()),
1874 other => CliError(other.to_string()),
1875 })
1876}
1877
1878pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
1880 let mut out = String::new();
1881 writeln!(out, "backup to: {}", dest.display()).unwrap();
1882 writeln!(out, " files: {}", report.files.join(", ")).unwrap();
1883 writeln!(out, " bytes: {}", report.bytes).unwrap();
1884 writeln!(out, " verified: {}", report.verified).unwrap();
1885 out
1886}
1887
1888pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
1890 let db = GraphDb::open(db_dir)?;
1891 let nodes = db.all_nodes_for_export();
1892 let edges = db.all_edges_for_export();
1893 let mut rules = db.rules();
1894 rules.sort_by(|a, b| a.name.cmp(&b.name));
1895 let node_count = nodes.len();
1896 let edge_count = edges.len();
1897 let rule_count = rules.len();
1898 match format {
1899 ExportFormat::Jsonl => {
1900 export::write_jsonl(&nodes, &edges, &rules, dest)?;
1901 Ok(format!(
1902 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1903 dest.display(),
1904 format.name(),
1905 node_count,
1906 edge_count,
1907 rule_count
1908 ))
1909 }
1910 ExportFormat::Parquet => {
1911 export::write_parquet(&nodes, &edges, &rules, dest)?;
1912 Ok(format!(
1913 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1914 dest.display(),
1915 format.name(),
1916 node_count,
1917 edge_count,
1918 rule_count
1919 ))
1920 }
1921 ExportFormat::Graphml => {
1923 let file_path = export::write_graphml(&nodes, &edges, dest)?;
1924 Ok(format!(
1925 "exported to {} (format={}): {} nodes, {} edges\n",
1926 file_path.display(),
1927 format.name(),
1928 node_count,
1929 edge_count,
1930 ))
1931 }
1932 }
1933}
1934
1935fn format_result_set(rs: &ResultSet) -> String {
1936 let mut out = String::new();
1937 let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
1938 for i in 0..rs.len() {
1939 let cells: Vec<String> = rs
1940 .columns()
1941 .iter()
1942 .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
1943 .collect();
1944 let _ = writeln!(out, " {}", cells.join(" "));
1945 }
1946 out
1947}
1948
1949fn parse_algo(args: &[&str]) -> Result<Command, String> {
1950 if args.is_empty() {
1951 return Err(
1952 "algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
1953 );
1954 }
1955 let subcmd = match args[0] {
1956 "pagerank" => AlgoSubcmd::Pagerank,
1957 "wcc" => AlgoSubcmd::Wcc,
1958 "degree" => AlgoSubcmd::Degree,
1959 "communities" => AlgoSubcmd::Communities,
1960 other => {
1961 return Err(format!(
1962 "unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
1963 ))
1964 }
1965 };
1966 let rest = &args[1..];
1967 let mut db_dir = None;
1968 let mut top: usize = 20;
1969 let mut dir = AlgoDir::Both;
1970 let mut edge_types: Vec<String> = Vec::new();
1971 let mut weight_prop: Option<String> = None;
1972 let mut min_weight: Option<f64> = None;
1973 let mut i = 0;
1974 while i < rest.len() {
1975 let a = rest[i];
1976 if a == "--top" {
1977 let val = rest
1978 .get(i + 1)
1979 .copied()
1980 .ok_or_else(|| "missing value for --top".to_string())?;
1981 top = val
1982 .parse()
1983 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1984 i += 2;
1985 } else if let Some(val) = a.strip_prefix("--top=") {
1986 top = val
1987 .parse()
1988 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1989 i += 1;
1990 } else if a == "--dir" {
1991 let val = rest
1992 .get(i + 1)
1993 .copied()
1994 .ok_or_else(|| "missing value for --dir".to_string())?;
1995 dir = parse_algo_dir(val)?;
1996 i += 2;
1997 } else if let Some(val) = a.strip_prefix("--dir=") {
1998 dir = parse_algo_dir(val)?;
1999 i += 1;
2000 } else if a == "--edge-type" {
2001 let val = rest
2002 .get(i + 1)
2003 .copied()
2004 .ok_or_else(|| "missing value for --edge-type".to_string())?;
2005 edge_types.push(val.to_string());
2006 i += 2;
2007 } else if let Some(val) = a.strip_prefix("--edge-type=") {
2008 edge_types.push(val.to_string());
2009 i += 1;
2010 } else if a == "--weight-prop" {
2011 let val = rest
2012 .get(i + 1)
2013 .copied()
2014 .ok_or_else(|| "missing value for --weight-prop".to_string())?;
2015 weight_prop = Some(val.to_string());
2016 i += 2;
2017 } else if let Some(val) = a.strip_prefix("--weight-prop=") {
2018 weight_prop = Some(val.to_string());
2019 i += 1;
2020 } else if a == "--min-weight" {
2021 let val = rest
2022 .get(i + 1)
2023 .copied()
2024 .ok_or_else(|| "missing value for --min-weight".to_string())?;
2025 min_weight = Some(
2026 val.parse()
2027 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
2028 );
2029 i += 2;
2030 } else if let Some(val) = a.strip_prefix("--min-weight=") {
2031 min_weight = Some(
2032 val.parse()
2033 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
2034 );
2035 i += 1;
2036 } else if a.starts_with('-') {
2037 return Err(format!("unexpected flag: {a}"));
2038 } else if db_dir.is_none() {
2039 db_dir = Some(PathBuf::from(a));
2040 i += 1;
2041 } else {
2042 return Err(format!("unexpected extra argument: {a}"));
2043 }
2044 }
2045 let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
2046 Ok(Command::Algo {
2047 db_dir,
2048 subcmd,
2049 top,
2050 dir,
2051 edge_types,
2052 weight_prop,
2053 min_weight,
2054 })
2055}
2056
2057fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
2059 match val.to_ascii_lowercase().as_str() {
2060 "out" => Ok(AlgoDir::Out),
2061 "in" => Ok(AlgoDir::In),
2062 "both" => Ok(AlgoDir::Both),
2063 other => Err(format!("--dir must be one of out | in | both, got {other}")),
2064 }
2065}
2066
2067pub fn run_map(db_dir: &Path, json: bool) -> Result<String, CliError> {
2073 let db = open_for_reading(db_dir)?;
2074 let map = repograph::repo_map(&db, &repograph::MapOptions::default());
2075 if json {
2076 let mut out = serde_json::to_string_pretty(&map)
2077 .map_err(|e| CliError(format!("serialise map: {e}")))?;
2078 out.push('\n');
2079 return Ok(out);
2080 }
2081 Ok(repograph::render_map(&map))
2082}
2083
2084pub fn run_brief(db_dir: &Path) -> Result<String, CliError> {
2090 let db = open_for_reading(db_dir)?;
2091 let report = repograph::brief(&db, &repograph::BriefOptions::default());
2092 let code_graph = db.has_node(ingest_git::SYNC_KEY);
2093 Ok(repograph::render_brief(
2094 &report,
2095 &reach_line(db_dir, code_graph),
2096 ))
2097}
2098
2099fn reach_line(db_dir: &Path, code_graph: bool) -> String {
2121 let bin = install::detect_mcp_command(None).shell();
2122 let db = install::sh_quote(&db_dir.to_string_lossy());
2123 let (tools, shell) = if code_graph {
2124 (
2125 format!("explore <target> (MCP tool){}or:", repograph::render::SEP),
2126 format!("{bin} explore {db} <target>"),
2127 )
2128 } else {
2129 (
2130 format!(
2131 "explain_association <a> <b>{sep}query '<cypher>' (MCP tools; add role: <name> \
2132 or namespace: <ns> to narrow what it sees){sep}or:",
2133 sep = repograph::render::SEP
2134 ),
2135 format!("{bin} query {db} '<cypher>'"),
2136 )
2137 };
2138 match install::delivery_for_store(db_dir) {
2139 install::Delivery::Cli => shell,
2140 _ => format!("{tools} {shell}"),
2141 }
2142}
2143
2144fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
2154 if !db_dir.exists() {
2155 return Err(CliError(format!("no store at {}", db_dir.display())));
2156 }
2157 Ok(GraphDb::open_with_options(
2158 db_dir,
2159 core_api::OpenOptions {
2160 auto_migrate: false,
2161 repair_wal: false,
2162 read_only: true,
2163 },
2164 )?)
2165}
2166
2167pub fn run_explore(
2173 db_dir: &Path,
2174 target: &str,
2175 depth: repograph::Depth,
2176 full: bool,
2177) -> Result<String, CliError> {
2178 let db = open_for_reading(db_dir)?;
2179 let report = repograph::explore(&db, None, target, depth, full);
2180 Ok(repograph::render_explore(
2181 &report,
2182 repograph::DEFAULT_EXPLORE_BYTES,
2183 ))
2184}
2185
2186pub fn run_context(db_dir: &Path, target: &str, full: bool) -> Result<String, CliError> {
2192 let db = open_for_reading(db_dir)?;
2193 Ok(repograph::render_context(&repograph::context_with(
2194 &db,
2195 None,
2196 target,
2197 &repograph::ContextOptions { source: full },
2198 )))
2199}
2200
2201pub fn run_impact(db_dir: &Path, files: &[String]) -> Result<String, CliError> {
2206 let db = open_for_reading(db_dir)?;
2207 let modified: BTreeSet<String> = files.iter().cloned().collect();
2208 let report = repograph::impact(&db, files, &modified, &repograph::ImpactOptions::default());
2209 Ok(repograph::render_impact(&report))
2210}
2211
2212pub fn run_owners(db_dir: &Path, path: &str) -> Result<String, CliError> {
2214 let db = open_for_reading(db_dir)?;
2215 match repograph::owners(&db, path, None) {
2216 Some(report) => Ok(repograph::render_owners(&report)),
2217 None => Err(CliError(format!("no file in the store at {path}"))),
2218 }
2219}
2220
2221pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
2223 let db = open_for_reading(db_dir)?;
2224 Ok(repograph::render_why(&repograph::why(&db, a, b)))
2225}
2226
2227#[allow(clippy::too_many_arguments)]
2233pub fn run_algo(
2234 db_dir: &Path,
2235 subcmd: &AlgoSubcmd,
2236 top: usize,
2237 dir: AlgoDir,
2238 edge_types: Vec<String>,
2239 weight_prop: Option<String>,
2240 min_weight: Option<f64>,
2241) -> Result<String, CliError> {
2242 let db = GraphDb::open(db_dir)?;
2243 match subcmd {
2244 AlgoSubcmd::Pagerank => {
2245 let config = PageRankConfig {
2246 direction: dir,
2247 ..PageRankConfig::default()
2248 };
2249 let report = db.pagerank(&config);
2250 Ok(format_pagerank(&report, top))
2251 }
2252 AlgoSubcmd::Wcc => {
2253 let config = WccConfig::default();
2254 let report = db.connected_components(&config);
2255 Ok(format_wcc(&report, top))
2256 }
2257 AlgoSubcmd::Degree => {
2258 let config = DegreeConfig {
2259 direction: dir,
2260 ..DegreeConfig::default()
2261 };
2262 let report = db.degree_centrality(&config);
2263 Ok(format_degree(&report, top))
2264 }
2265 AlgoSubcmd::Communities => {
2266 let config = LouvainConfig {
2267 edge_types,
2268 weight_prop,
2269 min_weight,
2270 ..LouvainConfig::default()
2271 };
2272 let report = db.communities(&config);
2273 Ok(format_communities(&report, top))
2274 }
2275 }
2276}
2277
2278fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
2279 let mut buf = String::new();
2280 let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
2281 let rows = if top == 0 {
2282 report.scores.as_slice()
2283 } else {
2284 &report.scores[..top.min(report.scores.len())]
2285 };
2286 for (i, (key, score)) in rows.iter().enumerate() {
2287 let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
2288 }
2289 buf
2290}
2291
2292fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
2293 let mut buf = String::new();
2294 let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
2295 let rows = if top == 0 {
2296 report.components.as_slice()
2297 } else {
2298 &report.components[..top.min(report.components.len())]
2299 };
2300 for (key, comp_id) in rows {
2301 let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
2302 }
2303 buf
2304}
2305
2306fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
2307 let mut buf = String::new();
2308 let _ = writeln!(
2309 buf,
2310 "== degree centrality (truncated={}) ==",
2311 report.truncated
2312 );
2313 let rows = if top == 0 {
2314 report.scores.as_slice()
2315 } else {
2316 &report.scores[..top.min(report.scores.len())]
2317 };
2318 for (i, (key, deg)) in rows.iter().enumerate() {
2319 let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
2320 }
2321 buf
2322}
2323
2324fn format_communities(report: &core_api::CommunityReport, top: usize) -> String {
2327 let mut buf = String::new();
2328 let trunc = if report.truncated { " (truncated)" } else { "" };
2329 let _ = writeln!(
2330 buf,
2331 "== communities (modularity={:.2}){trunc} ==",
2332 report.modularity
2333 );
2334 let rows = if top == 0 {
2335 report.communities.as_slice()
2336 } else {
2337 &report.communities[..top.min(report.communities.len())]
2338 };
2339 for c in rows {
2340 let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
2341 let _ = writeln!(
2342 buf,
2343 " {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
2344 c.id,
2345 c.members.len(),
2346 c.cohesion,
2347 preview.join(", ")
2348 );
2349 }
2350 buf
2351}
2352
2353fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
2358 let mut db_dir = None;
2359 let mut auto = false;
2360 for a in args {
2361 if *a == "--auto" {
2362 auto = true;
2363 } else if a.starts_with('-') {
2364 return Err(format!("unexpected flag: {a}"));
2365 } else if db_dir.is_some() {
2366 return Err(format!("unexpected extra argument: {a}"));
2367 } else {
2368 db_dir = Some(PathBuf::from(*a));
2369 }
2370 }
2371 match (&db_dir, auto) {
2372 (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
2373 (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
2374 _ => Ok((db_dir, auto)),
2375 }
2376}
2377
2378fn parse_mcp(args: &[&str]) -> Result<Command, String> {
2382 let all_tools = args.contains(&"--all-tools");
2383 let rest: Vec<&str> = args
2384 .iter()
2385 .copied()
2386 .filter(|a| *a != "--all-tools")
2387 .collect();
2388 parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
2389 db_dir,
2390 auto,
2391 all_tools,
2392 })
2393}
2394
2395fn parse_sync(args: &[&str]) -> Result<Command, String> {
2400 let json = args.contains(&"--json");
2401 let rest: Vec<&str> = args.iter().copied().filter(|a| *a != "--json").collect();
2402 parse_dir_or_auto("sync", &rest).map(|(db_dir, auto)| Command::Sync { db_dir, auto, json })
2403}
2404
2405fn parse_touch(args: &[&str]) -> Result<Command, String> {
2408 let mut db_dir = None;
2409 let mut auto = false;
2410 let mut files = Vec::new();
2411 for a in args {
2412 if *a == "--auto" {
2413 auto = true;
2414 } else if a.starts_with('-') {
2415 return Err(format!("unexpected flag: {a}"));
2416 } else if db_dir.is_none() && !auto {
2417 db_dir = Some(PathBuf::from(*a));
2418 } else {
2419 files.push(PathBuf::from(*a));
2420 }
2421 }
2422 if db_dir.is_none() && !auto {
2423 return Err("touch requires <db-dir> or --auto".into());
2424 }
2425 if db_dir.is_some() && auto {
2426 return Err("touch: --auto takes no <db-dir>".into());
2427 }
2428 Ok(Command::Touch {
2429 db_dir,
2430 auto,
2431 files,
2432 })
2433}
2434
2435fn parse_positional(
2442 cmd: &str,
2443 args: &[&str],
2444 min: usize,
2445 max: usize,
2446) -> Result<(PathBuf, Vec<String>), String> {
2447 let mut rest: Vec<String> = Vec::new();
2448 let mut db_dir: Option<PathBuf> = None;
2449 for a in args {
2450 if a.starts_with('-') {
2451 return Err(format!("unexpected flag: {a}"));
2452 }
2453 match db_dir {
2454 None => db_dir = Some(PathBuf::from(*a)),
2455 Some(_) => rest.push((*a).to_string()),
2456 }
2457 }
2458 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
2459 if rest.len() < min {
2460 return Err(format!(
2461 "{cmd} requires <db-dir> and {min} more argument{}",
2462 if min == 1 { "" } else { "s" }
2463 ));
2464 }
2465 if rest.len() > max {
2466 return Err(format!("unexpected extra argument: {}", rest[max]));
2467 }
2468 Ok((db_dir, rest))
2469}
2470
2471fn parse_explore(args: &[&str]) -> Result<Command, String> {
2476 let mut rest: Vec<String> = Vec::new();
2477 let mut db_dir: Option<PathBuf> = None;
2478 let mut depth = repograph::Depth::Context;
2479 let mut full = false;
2480 let mut want_depth = false;
2481 for a in args {
2482 if want_depth {
2483 depth = repograph::Depth::parse(a).ok_or_else(|| {
2484 format!(
2485 "--depth must be one of {}, got {a}",
2486 repograph::Depth::NAMES.join(" | ")
2487 )
2488 })?;
2489 want_depth = false;
2490 } else if *a == "--depth" {
2491 want_depth = true;
2492 } else if *a == "--full" {
2493 full = true;
2494 } else if a.starts_with('-') {
2495 return Err(format!("unexpected flag: {a}"));
2496 } else if db_dir.is_none() {
2497 db_dir = Some(PathBuf::from(*a));
2498 } else {
2499 rest.push((*a).to_string());
2500 }
2501 }
2502 if want_depth {
2503 return Err("--depth requires a value".to_string());
2504 }
2505 let db_dir = db_dir.ok_or_else(|| "explore requires <db-dir>".to_string())?;
2506 match rest.len() {
2507 0 => Err("explore requires <db-dir> and 1 more argument".to_string()),
2508 1 => Ok(Command::Explore {
2509 db_dir,
2510 target: rest.remove(0),
2511 depth,
2512 full,
2513 }),
2514 _ => Err(format!("unexpected extra argument: {}", rest[1])),
2515 }
2516}
2517
2518fn parse_context(args: &[&str]) -> Result<Command, String> {
2524 let mut rest: Vec<String> = Vec::new();
2525 let mut db_dir: Option<PathBuf> = None;
2526 let mut full = false;
2527 for a in args {
2528 if *a == "--full" {
2529 full = true;
2530 } else if a.starts_with('-') {
2531 return Err(format!("unexpected flag: {a}"));
2532 } else if db_dir.is_none() {
2533 db_dir = Some(PathBuf::from(*a));
2534 } else {
2535 rest.push((*a).to_string());
2536 }
2537 }
2538 let db_dir = db_dir.ok_or_else(|| "context requires <db-dir>".to_string())?;
2539 match rest.len() {
2540 0 => Err("context requires <db-dir> and 1 more argument".to_string()),
2541 1 => Ok(Command::Context {
2542 db_dir,
2543 target: rest.remove(0),
2544 full,
2545 }),
2546 _ => Err(format!("unexpected extra argument: {}", rest[1])),
2547 }
2548}
2549
2550fn parse_dir_with_json(cmd: &str, args: &[&str]) -> Result<(PathBuf, bool), String> {
2552 let mut db_dir = None;
2553 let mut json = false;
2554 for a in args {
2555 if *a == "--json" {
2556 json = true;
2557 } else if a.starts_with('-') {
2558 return Err(format!("unexpected flag: {a}"));
2559 } else if db_dir.is_some() {
2560 return Err(format!("unexpected extra argument: {a}"));
2561 } else {
2562 db_dir = Some(PathBuf::from(*a));
2563 }
2564 }
2565 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
2566 Ok((db_dir, json))
2567}
2568
2569fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
2570 let mut db_dir = None;
2571 for a in args {
2572 if a.starts_with('-') {
2573 return Err(format!("unexpected flag: {a}"));
2574 }
2575 if db_dir.is_some() {
2576 return Err(format!("unexpected extra argument: {a}"));
2577 }
2578 db_dir = Some(PathBuf::from(*a));
2579 }
2580 db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
2581}
2582
2583pub fn format_stats(stats: &Stats) -> String {
2585 let mut out = String::new();
2586 let _ = writeln!(
2587 out,
2588 "nodes: {} live, {} tombstoned",
2589 stats.nodes_live, stats.nodes_tombstoned
2590 );
2591 let _ = writeln!(out, "edges: {}", stats.edges);
2592 if stats.history_floor == 0 {
2593 let _ = writeln!(out, "history: complete (nothing pruned)");
2594 } else {
2595 let _ = writeln!(
2596 out,
2597 "history: reaches back to commit {}",
2598 stats.history_floor
2599 );
2600 }
2601 if stats.namespaces.len() > 1 {
2606 let names: Vec<String> = stats
2607 .namespaces
2608 .iter()
2609 .map(|n| format!("{} ({})", n.name, n.nodes_live))
2610 .collect();
2611 let _ = writeln!(out, "namespaces: {}", names.join(", "));
2612 }
2613 let _ = writeln!(out, "rules: {}", stats.rules.len());
2614 for r in &stats.rules {
2615 let _ = writeln!(
2616 out,
2617 " {:<28} edges={} tripped={}",
2618 r.name, r.edges, r.tripped
2619 );
2620 }
2621 out
2622}
2623
2624pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
2626 let db = SharedDb::open(dir)?;
2627 let stats = db.read().stats();
2628 Ok(stats)
2629}
2630
2631pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
2637 refuse_non_empty(dir)?;
2638
2639 let db = SharedDb::open(dir)?;
2640 let opts = IngestOptions::default();
2641 let mut auto_fk_rules = Vec::new();
2642
2643 {
2644 let mut w = db.write();
2645 for (label, json) in [
2646 ("Org", org_json()),
2647 ("Project", project_json()),
2648 ("Person", person_json()),
2649 ] {
2650 let report = w.ingest_json(label, &json, &opts)?;
2651 if !report.row_errors.is_empty() {
2652 return Err(CliError(format!(
2653 "demo ingest of {label} had row errors: {:?}",
2654 report.row_errors
2655 )));
2656 }
2657 auto_fk_rules.extend(report.rules_created);
2658 }
2659 let skill_fit = Predicate::Overlap {
2660 field: "skills".into(),
2661 min: 0.5,
2662 };
2663 let skill_fit_k = Some(default_max_edges(&skill_fit));
2664 w.create_rule(RuleDef {
2665 name: "skill_fit".into(),
2666 src_label: "Person".into(),
2667 dst_label: "Project".into(),
2668 predicate: skill_fit,
2669 edge_type: "FIT".into(),
2670 weight_prop: Some("score".into()),
2671 max_edges: skill_fit_k,
2672 approximate: false,
2673 via_label: None,
2674 via_edge: None,
2675 via_dir: None,
2676 namespace: None,
2677 })?;
2678 let founded_within = Predicate::NumericWithin {
2679 field: "founded_year".into(),
2680 tolerance: 2.0,
2681 };
2682 let founded_within_k = Some(default_max_edges(&founded_within));
2683 w.create_rule(RuleDef {
2684 name: "founded_within".into(),
2685 src_label: "Org".into(),
2686 dst_label: "Org".into(),
2687 predicate: founded_within,
2688 edge_type: "FOUNDED_WITHIN".into(),
2689 weight_prop: Some("score".into()),
2690 max_edges: founded_within_k,
2691 approximate: false,
2692 via_label: None,
2693 via_edge: None,
2694 via_dir: None,
2695 namespace: None,
2696 })?;
2697 let nearby_office = Predicate::GeoRadius {
2698 field: "office".into(),
2699 km: 50.0,
2700 };
2701 let nearby_office_k = Some(default_max_edges(&nearby_office));
2702 w.create_rule(RuleDef {
2703 name: "nearby_office".into(),
2704 src_label: "Org".into(),
2705 dst_label: "Org".into(),
2706 predicate: nearby_office,
2707 edge_type: "NEARBY_OFFICE".into(),
2708 weight_prop: Some("score".into()),
2709 max_edges: nearby_office_k,
2710 approximate: false,
2711 via_label: None,
2712 via_edge: None,
2713 via_dir: None,
2714 namespace: None,
2715 })?;
2716 let similar_interests = Predicate::VectorSimilar {
2717 field: "embedding".into(),
2718 min: 0.8,
2719 };
2720 let similar_interests_k = Some(default_max_edges(&similar_interests));
2721 w.create_rule(RuleDef {
2722 name: "similar_interests".into(),
2723 src_label: "Person".into(),
2724 dst_label: "Person".into(),
2725 predicate: similar_interests,
2726 edge_type: "SIMILAR".into(),
2727 weight_prop: Some("score".into()),
2728 max_edges: similar_interests_k,
2729 approximate: false,
2730 via_label: None,
2731 via_edge: None,
2732 via_dir: None,
2733 namespace: None,
2734 })?;
2735 for (label, field) in [("Org", "name"), ("Project", "name"), ("Person", "name")] {
2737 w.enable_fulltext(label, field)?;
2738 }
2739 }
2740
2741 let r = db.read();
2742 let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
2743 let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
2744 let stats = r.stats();
2745 let suggestion = r.suggest_rules().into_iter().next();
2747
2748 Ok(DemoOutcome {
2749 auto_fk_rules,
2750 sample_query: SAMPLE_QUERY.to_string(),
2751 sample_result,
2752 explanations,
2753 stats,
2754 suggestion,
2755 })
2756}
2757
2758fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
2759 if dir.is_file() {
2760 return Err(CliError(format!(
2761 "demo refuses a non-empty directory: {} is a file",
2762 dir.display()
2763 )));
2764 }
2765 if !dir.exists() {
2766 return Ok(true);
2767 }
2768 Ok(std::fs::read_dir(dir)?.next().is_none())
2769}
2770
2771fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
2772 if dir_is_empty_or_absent(dir)? {
2773 Ok(())
2774 } else {
2775 Err(CliError(format!(
2776 "demo refuses a non-empty directory: {} \
2777 (directory must be empty — including hidden files)",
2778 dir.display()
2779 )))
2780 }
2781}
2782
2783pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
2785 if dir_is_empty_or_absent(dir)? {
2786 Ok(Some(run_demo(dir)?))
2787 } else {
2788 Ok(None)
2789 }
2790}
2791
2792fn json_array(rows: impl IntoIterator<Item = String>) -> String {
2793 let mut out = String::from("[");
2794 let mut first = true;
2795 for row in rows {
2796 if !first {
2797 out.push(',');
2798 }
2799 first = false;
2800 out.push_str(&row);
2801 }
2802 out.push(']');
2803 out
2804}
2805
2806fn wrap_proj(i: usize) -> usize {
2808 (i - 1) % N_PROJECTS + 1
2809}
2810
2811fn skill_window_json(start: usize, len: usize) -> String {
2813 let parts: Vec<String> = (0..len)
2814 .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
2815 .collect();
2816 format!("[{}]", parts.join(","))
2817}
2818
2819fn org_office(i: usize) -> (f64, f64) {
2823 match i {
2824 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"),
2835 }
2836}
2837
2838fn person_embedding_json(i: usize) -> String {
2842 let mut v = [0.0_f64; 8];
2843 match i {
2844 9 | 19 | 29 => {
2845 v[0] = 0.8;
2846 v[1] = 0.6;
2847 }
2848 10 | 20 | 30 => {
2849 v[0] = 0.6;
2850 v[1] = 0.8;
2851 }
2852 _ => {
2853 let axis = (i - 1) % 10;
2854 debug_assert!(axis < 8);
2855 v[axis] = 1.0;
2856 }
2857 }
2858 let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
2859 format!("[{}]", parts.join(","))
2860}
2861
2862fn org_json() -> String {
2863 json_array((1..=N_ORGS).map(|i| {
2864 let year = 2010 + (i as i64 - 1);
2865 let (lat, lon) = org_office(i);
2866 format!(
2867 r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
2868 skill_window_json(i, 3)
2869 )
2870 }))
2871}
2872
2873fn project_json() -> String {
2874 json_array((1..=N_PROJECTS).map(|i| {
2875 let org = (i - 1) % N_ORGS + 1;
2876 format!(
2877 r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
2878 skill_window_json(i, 3)
2879 )
2880 }))
2881}
2882
2883fn person_json() -> String {
2884 json_array((1..=N_PEOPLE).map(|i| {
2885 let org = (i - 1) % N_ORGS + 1;
2886 let proj = (i - 1) % N_PROJECTS + 1;
2887 format!(
2888 r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
2889 person_embedding_json(i),
2890 skill_window_json(proj, 3)
2891 )
2892 }))
2893}
2894
2895pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
2897 let mut buf = String::new();
2898 let _ = writeln!(buf, "== demo ==");
2899 let _ = writeln!(
2900 buf,
2901 "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
2902 );
2903 let _ = writeln!(
2904 buf,
2905 "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
2906 );
2907 let _ = writeln!(
2908 buf,
2909 "numeric rule: founded_within (Org.founded_year, tolerance 2)"
2910 );
2911 let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
2912 let _ = writeln!(
2913 buf,
2914 "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
2915 );
2916 let _ = writeln!(buf);
2917 let _ = writeln!(buf, "== auto-FK rules ==");
2918 let mut names = out.auto_fk_rules.clone();
2919 names.sort();
2920 for name in names {
2921 let _ = writeln!(buf, " {name}");
2922 }
2923 let _ = writeln!(buf);
2924 let _ = writeln!(buf, "== query ==");
2925 let _ = writeln!(buf, "{}", out.sample_query);
2926 let _ = writeln!(buf);
2927 let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
2928 for i in 0..out.sample_result.len() {
2929 let cells: Vec<String> = out
2930 .sample_result
2931 .columns()
2932 .iter()
2933 .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
2934 .collect();
2935 let _ = writeln!(buf, " {}", cells.join(" "));
2936 }
2937 let _ = writeln!(buf);
2938 let _ = writeln!(
2939 buf,
2940 "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
2941 );
2942 for e in &out.explanations {
2943 let weight = e
2944 .weight
2945 .map(|w| fmt_value(&Value::Float(w)))
2946 .unwrap_or_else(|| "none".into());
2947 let _ = writeln!(
2948 buf,
2949 " rule={} type={} {}→{} weight={}",
2950 e.rule, e.edge_type, e.src_key, e.dst_key, weight
2951 );
2952 }
2953 let _ = writeln!(buf);
2954 let _ = writeln!(buf, "== serve ==");
2955 let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
2956
2957 if let Some(s) = &out.suggestion {
2959 let _ = writeln!(buf);
2960 let _ = writeln!(buf, "== suggested rule (teaser) ==");
2961 let _ = writeln!(buf, " {}", s.def.name);
2962 let _ = writeln!(
2963 buf,
2964 " {} → {} via {:?}",
2965 s.def.src_label, s.def.dst_label, s.def.predicate
2966 );
2967 let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
2968 let _ = writeln!(buf, " {}", s.rationale);
2969 let _ = writeln!(
2970 buf,
2971 " (run `mushroomdb suggest {}` for full analysis)",
2972 dir.display()
2973 );
2974 }
2975
2976 buf
2977}
2978
2979pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
2981 let db = GraphDb::open(dir)?;
2982 Ok(db.suggest_rules())
2983}
2984
2985pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
2987 let mut buf = String::new();
2988 if suggestions.is_empty() {
2989 let _ = writeln!(
2990 buf,
2991 "no rule suggestions (database may be empty or rules already cover all patterns)"
2992 );
2993 return buf;
2994 }
2995 let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
2996 for (i, s) in suggestions.iter().enumerate() {
2997 let _ = writeln!(buf);
2998 let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
2999 let _ = writeln!(
3000 buf,
3001 " {} → {} via {:?}",
3002 s.def.src_label, s.def.dst_label, s.def.predicate
3003 );
3004 let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
3005 let _ = writeln!(buf, " rationale : {}", s.rationale);
3006 if !s.examples.is_empty() {
3007 let _ = writeln!(buf, " examples :");
3008 for (src, dst, score) in &s.examples {
3009 let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
3010 }
3011 }
3012 let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
3013 let _ = writeln!(
3014 buf,
3015 " to apply : POST /rules or db.create_rule(suggestion.def)"
3016 );
3017 }
3018 buf
3019}
3020
3021fn fmt_value(v: &Value) -> String {
3022 match v {
3023 Value::Int(i) => i.to_string(),
3024 Value::Float(f) => {
3025 let s = format!("{f}");
3026 if s.contains('.') || s.contains('e') || s.contains('E') {
3027 s
3028 } else {
3029 format!("{s}.0")
3030 }
3031 }
3032 Value::Str(s) => s.clone(),
3033 Value::Bool(b) => b.to_string(),
3034 Value::List(xs) => {
3035 let inner: Vec<String> = xs.iter().map(fmt_value).collect();
3036 format!("[{}]", inner.join(", "))
3037 }
3038 Value::Map(m) => {
3039 let inner: Vec<String> = m
3040 .iter()
3041 .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
3042 .collect();
3043 format!("{{{}}}", inner.join(", "))
3044 }
3045 }
3046}
3047
3048fn fmt_cell(cell: Option<&Value>) -> String {
3049 match cell {
3050 None => "null".into(),
3051 Some(v) => fmt_value(v),
3052 }
3053}
3054
3055#[cfg(test)]
3056mod tests {
3057 use super::*;
3058 use std::collections::BTreeSet;
3059 use std::net::SocketAddr;
3060 use std::path::PathBuf;
3061
3062 fn tmp(name: &str) -> PathBuf {
3063 let nanos = std::time::SystemTime::now()
3064 .duration_since(std::time::UNIX_EPOCH)
3065 .expect("clock")
3066 .as_nanos();
3067 let d = std::env::temp_dir().join(format!(
3068 "graphdb-cli-{}-{}-{}",
3069 name,
3070 std::process::id(),
3071 nanos
3072 ));
3073 let _ = std::fs::remove_dir_all(&d);
3074 d
3075 }
3076
3077 fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
3078 let g = db.read();
3079 let mut out = BTreeSet::new();
3080 for i in 1..=N_ORGS {
3081 let src = format!("org-{i:02}");
3082 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
3083 for dst in nbrs {
3084 out.insert((src.clone(), dst));
3085 }
3086 }
3087 }
3088 for i in 1..=N_PEOPLE {
3089 let src = format!("person-{i:02}");
3090 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
3091 for dst in nbrs {
3092 out.insert((src.clone(), dst));
3093 }
3094 }
3095 }
3096 out
3097 }
3098
3099 fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
3100 let hits: Vec<_> = db
3101 .read()
3102 .explain(a, b)
3103 .expect("explain")
3104 .into_iter()
3105 .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
3106 .collect();
3107 assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
3108 let got = hits[0].weight.expect("weighted");
3109 assert!(
3110 (got - want).abs() < 1e-12,
3111 "{rule} {a}→{b}: got {got} want {want}"
3112 );
3113 }
3114
3115 fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
3116 const R: f64 = 6371.0088;
3117 let phi1 = lat1.to_radians();
3118 let phi2 = lat2.to_radians();
3119 let dphi = (lat2 - lat1).to_radians();
3120 let dlam = (lon2 - lon1).to_radians();
3121 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
3122 .clamp(0.0, 1.0);
3123 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
3124 R * c
3125 }
3126
3127 fn default_bind() -> SocketAddr {
3128 SocketAddr::from(([127, 0, 0, 1], 8080))
3129 }
3130
3131 #[test]
3132 fn parse_args_table() {
3133 struct Case {
3134 args: &'static [&'static str],
3135 check: fn(Result<Command, String>),
3136 }
3137
3138 let cases = [
3139 Case {
3140 args: &[],
3141 check: |r| match r {
3142 Ok(Command::Help) => {}
3143 other => panic!("no-args → Help, got {other:?}"),
3144 },
3145 },
3146 Case {
3147 args: &["--help"],
3148 check: |r| match r {
3149 Ok(Command::Help) => {}
3150 other => panic!("--help → Help, got {other:?}"),
3151 },
3152 },
3153 Case {
3154 args: &["-h"],
3155 check: |r| match r {
3156 Ok(Command::Help) => {}
3157 other => panic!("-h → Help, got {other:?}"),
3158 },
3159 },
3160 Case {
3161 args: &["serve", "/tmp/demo-db"],
3162 check: |r| match r {
3163 Ok(Command::Serve {
3164 db_dir,
3165 addr,
3166 ui,
3167 demo_if_empty,
3168 token,
3169 role_tokens,
3170 snapshot_every,
3171 restore_from,
3172 tls_cert,
3173 tls_key,
3174 }) => {
3175 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3176 assert_eq!(addr, default_bind());
3177 assert_eq!(ui, super::ServeUi::Embedded);
3178 assert!(!demo_if_empty);
3179 assert_eq!(token, None);
3180 assert!(role_tokens.is_empty());
3181 assert_eq!(snapshot_every, None);
3182 assert_eq!(restore_from, None);
3183 assert_eq!(tls_cert, None);
3184 assert_eq!(tls_key, None);
3185 }
3186 other => panic!("serve <dir> → Serve default addr, got {other:?}"),
3187 },
3188 },
3189 Case {
3190 args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
3191 check: |r| match r {
3192 Ok(Command::Serve {
3193 db_dir,
3194 addr,
3195 ui,
3196 demo_if_empty,
3197 token,
3198 role_tokens,
3199 snapshot_every,
3200 restore_from,
3201 tls_cert,
3202 tls_key,
3203 }) => {
3204 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3205 assert_eq!(
3206 addr,
3207 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
3208 );
3209 assert_eq!(ui, super::ServeUi::Embedded);
3210 assert!(!demo_if_empty);
3211 assert_eq!(token, None);
3212 assert!(role_tokens.is_empty());
3213 assert_eq!(snapshot_every, None);
3214 assert_eq!(restore_from, None);
3215 assert_eq!(tls_cert, None);
3216 assert_eq!(tls_key, None);
3217 }
3218 other => panic!("serve --addr after dir, got {other:?}"),
3219 },
3220 },
3221 Case {
3222 args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
3223 check: |r| match r {
3224 Ok(Command::Serve {
3225 db_dir,
3226 addr,
3227 ui,
3228 demo_if_empty,
3229 token,
3230 role_tokens,
3231 snapshot_every,
3232 restore_from,
3233 tls_cert,
3234 tls_key,
3235 }) => {
3236 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3237 assert_eq!(
3238 addr,
3239 "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
3240 );
3241 assert_eq!(ui, super::ServeUi::Embedded);
3242 assert!(!demo_if_empty);
3243 assert_eq!(token, None);
3244 let _ = role_tokens; assert_eq!(snapshot_every, None);
3246 assert_eq!(restore_from, None);
3247 assert_eq!(tls_cert, None);
3248 assert_eq!(tls_key, None);
3249 }
3250 other => panic!("serve --addr=VALUE, got {other:?}"),
3251 },
3252 },
3253 Case {
3254 args: &["mcp", "/tmp/demo-db"],
3255 check: |r| match r {
3256 Ok(Command::Mcp {
3257 db_dir,
3258 auto,
3259 all_tools,
3260 }) => {
3261 assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
3262 assert!(!auto);
3263 assert!(!all_tools, "the short list is the default");
3264 }
3265 other => panic!("mcp <dir>, got {other:?}"),
3266 },
3267 },
3268 Case {
3269 args: &["stats", "/tmp/demo-db"],
3270 check: |r| match r {
3271 Ok(Command::Stats { db_dir }) => {
3272 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3273 }
3274 other => panic!("stats <dir>, got {other:?}"),
3275 },
3276 },
3277 Case {
3278 args: &["demo", "/tmp/demo-db"],
3279 check: |r| match r {
3280 Ok(Command::Demo { db_dir }) => {
3281 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3282 }
3283 other => panic!("demo <dir>, got {other:?}"),
3284 },
3285 },
3286 Case {
3287 args: &["context", "db", "x"],
3288 check: |r| match r {
3289 Ok(Command::Context {
3290 db_dir,
3291 target,
3292 full,
3293 }) => {
3294 assert_eq!(db_dir, PathBuf::from("db"));
3295 assert_eq!(target, "x");
3296 assert!(!full, "the default answer is a pointer, not a body");
3297 }
3298 other => panic!("context <dir> <target>, got {other:?}"),
3299 },
3300 },
3301 Case {
3302 args: &["context", "db", "x", "--full"],
3303 check: |r| {
3304 assert_eq!(
3305 r.unwrap(),
3306 Command::Context {
3307 db_dir: PathBuf::from("db"),
3308 target: "x".into(),
3309 full: true,
3310 }
3311 );
3312 },
3313 },
3314 Case {
3315 args: &["explore", "db", "x", "--depth", "impact"],
3316 check: |r| {
3317 assert_eq!(
3318 r.unwrap(),
3319 Command::Explore {
3320 db_dir: PathBuf::from("db"),
3321 target: "x".into(),
3322 depth: repograph::Depth::Impact,
3323 full: false,
3324 }
3325 );
3326 },
3327 },
3328 Case {
3329 args: &["serve"],
3330 check: |r| {
3331 let e = r.expect_err("serve without dir");
3332 assert!(
3333 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3334 "missing-dir error should mention dir, got {e}"
3335 );
3336 },
3337 },
3338 Case {
3339 args: &["mcp"],
3340 check: |r| {
3341 let e = r.expect_err("mcp without dir");
3342 assert!(
3343 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3344 "missing-dir error should mention dir, got {e}"
3345 );
3346 },
3347 },
3348 Case {
3349 args: &["stats"],
3350 check: |r| {
3351 let e = r.expect_err("stats without dir");
3352 assert!(
3353 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3354 "missing-dir error should mention dir, got {e}"
3355 );
3356 },
3357 },
3358 Case {
3359 args: &["demo"],
3360 check: |r| {
3361 let e = r.expect_err("demo without dir");
3362 assert!(
3363 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
3364 "missing-dir error should mention dir, got {e}"
3365 );
3366 },
3367 },
3368 Case {
3369 args: &["serve", "/tmp/demo-db", "--addr"],
3370 check: |r| {
3371 let e = r.expect_err("--addr missing value");
3372 assert!(
3373 e.to_lowercase().contains("addr"),
3374 "--addr missing value should mention addr, got {e}"
3375 );
3376 },
3377 },
3378 Case {
3379 args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
3380 check: |r| {
3381 let e = r.expect_err("invalid addr");
3382 assert!(
3383 e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
3384 "invalid addr should mention address, got {e}"
3385 );
3386 },
3387 },
3388 Case {
3389 args: &["frobnicate", "/tmp/demo-db"],
3390 check: |r| {
3391 let e = r.expect_err("unknown command");
3392 assert!(
3393 e.to_lowercase().contains("unknown")
3394 || e.to_lowercase().contains("frobnicate"),
3395 "unknown command should name it, got {e}"
3396 );
3397 },
3398 },
3399 Case {
3400 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
3401 check: |r| match r {
3402 Ok(Command::Serve { ui, .. }) => {
3403 assert_eq!(
3404 ui,
3405 super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
3406 );
3407 }
3408 other => panic!("serve --ui <dir>, got {other:?}"),
3409 },
3410 },
3411 Case {
3412 args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
3413 check: |r| match r {
3414 Ok(Command::Serve { ui, .. }) => {
3415 assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
3416 }
3417 other => panic!("serve --ui=VALUE, got {other:?}"),
3418 },
3419 },
3420 Case {
3421 args: &["serve", "/tmp/demo-db", "--ui"],
3422 check: |r| {
3423 let e = r.expect_err("--ui missing value");
3424 assert!(
3425 e.to_lowercase().contains("ui"),
3426 "--ui missing value should mention ui, got {e}"
3427 );
3428 },
3429 },
3430 Case {
3431 args: &["serve", "/tmp/demo-db", "--no-ui"],
3432 check: |r| match r {
3433 Ok(Command::Serve { ui, .. }) => {
3434 assert_eq!(ui, super::ServeUi::None);
3435 }
3436 other => panic!("serve --no-ui, got {other:?}"),
3437 },
3438 },
3439 Case {
3440 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
3441 check: |r| {
3442 let e = r.expect_err("combine --ui and --no-ui");
3443 assert!(
3444 e.contains("--ui") && e.contains("--no-ui"),
3445 "conflict should name both flags, got {e}"
3446 );
3447 },
3448 },
3449 Case {
3450 args: &["serve", "/tmp/demo-db", "extra"],
3451 check: |r| {
3452 let e = r.expect_err("extra positional");
3453 assert!(
3454 e.to_lowercase().contains("unexpected")
3455 || e.to_lowercase().contains("extra"),
3456 "extra arg should be rejected, got {e}"
3457 );
3458 },
3459 },
3460 Case {
3461 args: &[
3462 "serve",
3463 "/data",
3464 "--addr",
3465 "0.0.0.0:8080",
3466 "--demo-if-empty",
3467 ],
3468 check: |r| match r {
3469 Ok(Command::Serve {
3470 db_dir,
3471 addr,
3472 demo_if_empty,
3473 ui,
3474 token,
3475 snapshot_every,
3476 ..
3477 }) => {
3478 assert_eq!(db_dir, PathBuf::from("/data"));
3479 assert_eq!(
3480 addr,
3481 "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
3482 );
3483 assert!(demo_if_empty);
3484 assert_eq!(ui, super::ServeUi::Embedded);
3485 assert_eq!(token, None);
3486 assert_eq!(snapshot_every, None);
3487 }
3488 other => panic!("serve --demo-if-empty docker default, got {other:?}"),
3489 },
3490 },
3491 Case {
3492 args: &["install", "--project", "--delivery", "cli"],
3493 check: |r| match r {
3494 Ok(Command::Install(opts)) => {
3495 assert_eq!(opts.scope, Some(install::Scope::Project));
3496 assert_eq!(opts.delivery, install::Delivery::Cli);
3497 }
3498 other => panic!("install --delivery cli, got {other:?}"),
3499 },
3500 },
3501 Case {
3502 args: &["install", "--delivery=mcp"],
3503 check: |r| match r {
3504 Ok(Command::Install(opts)) => {
3505 assert_eq!(opts.delivery, install::Delivery::Mcp)
3506 }
3507 other => panic!("install --delivery=mcp, got {other:?}"),
3508 },
3509 },
3510 Case {
3511 args: &["install"],
3514 check: |r| match r {
3515 Ok(Command::Install(opts)) => {
3516 assert_eq!(opts.delivery, install::Delivery::Both)
3517 }
3518 other => panic!("install, got {other:?}"),
3519 },
3520 },
3521 Case {
3522 args: &["install", "--delivery", "sideways"],
3523 check: |r| match r {
3524 Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
3525 other => panic!("a bad --delivery must be refused, got {other:?}"),
3526 },
3527 },
3528 Case {
3529 args: &["install", "--intercept-grep"],
3530 check: |r| match r {
3531 Ok(Command::Install(opts)) => assert!(opts.intercept_grep),
3532 other => panic!("install --intercept-grep, got {other:?}"),
3533 },
3534 },
3535 Case {
3536 args: &[
3537 "install",
3538 "--impact-before-edit",
3539 "--enrich-grep",
3540 "--always-load",
3541 ],
3542 check: |r| match r {
3543 Ok(Command::Install(opts)) => {
3544 assert!(opts.impact_before_edit);
3545 assert!(opts.enrich_grep);
3546 assert!(opts.always_load);
3547 }
3548 other => panic!("install with the code-door flags, got {other:?}"),
3549 },
3550 },
3551 Case {
3552 args: &["install", "--delivery", "mcp", "--db", "./mem"],
3556 check: |r| match r {
3557 Ok(Command::Install(opts)) => assert!(opts.always_load),
3558 other => panic!("install --delivery mcp --db, got {other:?}"),
3559 },
3560 },
3561 Case {
3562 args: &["install", "--delivery", "both", "--db=./mem"],
3564 check: |r| match r {
3565 Ok(Command::Install(opts)) => assert!(opts.always_load),
3566 other => panic!("install --delivery both --db, got {other:?}"),
3567 },
3568 },
3569 Case {
3570 args: &[
3572 "install",
3573 "--delivery",
3574 "mcp",
3575 "--db",
3576 "./mem",
3577 "--no-always-load",
3578 ],
3579 check: |r| match r {
3580 Ok(Command::Install(opts)) => assert!(!opts.always_load),
3581 other => panic!("install --no-always-load, got {other:?}"),
3582 },
3583 },
3584 Case {
3585 args: &["install", "--delivery", "cli", "--db", "./mem"],
3589 check: |r| match r {
3590 Ok(Command::Install(opts)) => assert!(!opts.always_load),
3591 other => panic!("install --delivery cli --db, got {other:?}"),
3592 },
3593 },
3594 Case {
3595 args: &["install", "--always-load"],
3599 check: |r| match r {
3600 Ok(Command::Install(opts)) => assert!(opts.always_load),
3601 other => panic!("install --always-load, got {other:?}"),
3602 },
3603 },
3604 Case {
3605 args: &["install"],
3607 check: |r| match r {
3608 Ok(Command::Install(opts)) => {
3609 assert!(!opts.intercept_grep);
3610 assert!(!opts.impact_before_edit);
3611 assert!(!opts.enrich_grep);
3612 assert!(!opts.always_load);
3613 }
3614 other => panic!("install, got {other:?}"),
3615 },
3616 },
3617 Case {
3618 args: &["impact-hook", "--auto"],
3619 check: |r| match r {
3620 Ok(Command::ImpactHook { db_dir, auto }) => {
3621 assert!(db_dir.is_none() && auto);
3622 }
3623 other => panic!("impact-hook --auto, got {other:?}"),
3624 },
3625 },
3626 Case {
3627 args: &["enrich", "/tmp/db"],
3628 check: |r| match r {
3629 Ok(Command::Enrich { db_dir, auto }) => {
3630 assert_eq!(db_dir.as_deref(), Some(Path::new("/tmp/db")));
3631 assert!(!auto);
3632 }
3633 other => panic!("enrich /tmp/db, got {other:?}"),
3634 },
3635 },
3636 Case {
3637 args: &["enrich"],
3638 check: |r| match r {
3639 Err(e) => assert!(e.contains("enrich requires <db-dir> or --auto"), "{e}"),
3640 other => panic!("enrich with no store, got {other:?}"),
3641 },
3642 },
3643 Case {
3644 args: &["intercept", "--auto"],
3645 check: |r| match r {
3646 Ok(Command::Intercept { db_dir, auto }) => {
3647 assert_eq!(db_dir, None);
3648 assert!(auto);
3649 }
3650 other => panic!("intercept --auto, got {other:?}"),
3651 },
3652 },
3653 Case {
3654 args: &["intercept", "/tmp/db"],
3655 check: |r| match r {
3656 Ok(Command::Intercept { db_dir, auto }) => {
3657 assert_eq!(db_dir, Some(PathBuf::from("/tmp/db")));
3658 assert!(!auto);
3659 }
3660 other => panic!("intercept /tmp/db, got {other:?}"),
3661 },
3662 },
3663 Case {
3664 args: &["intercept"],
3665 check: |r| match r {
3666 Err(e) => assert!(e.contains("intercept requires <db-dir> or --auto"), "{e}"),
3667 other => panic!("intercept with no store, got {other:?}"),
3668 },
3669 },
3670 ];
3671
3672 for case in &cases {
3673 (case.check)(parse_args(case.args));
3674 }
3675 }
3676
3677 #[test]
3678 fn serve_default_addr_is_loopback_8080() {
3679 match parse_args(&["serve", "/tmp/db"]).unwrap() {
3680 Command::Serve { addr, .. } => {
3681 assert_eq!(
3682 addr,
3683 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
3684 );
3685 }
3686 other => panic!("{other:?}"),
3687 }
3688 }
3689
3690 #[test]
3691 fn serve_snapshot_every_parses_seconds() {
3692 match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
3693 Command::Serve { snapshot_every, .. } => {
3694 assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
3695 }
3696 other => panic!("{other:?}"),
3697 }
3698 match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
3699 Command::Serve { snapshot_every, .. } => {
3700 assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
3701 }
3702 other => panic!("{other:?}"),
3703 }
3704 match parse_args(&["serve", "/tmp/db"]).unwrap() {
3705 Command::Serve { snapshot_every, .. } => {
3706 assert_eq!(snapshot_every, None);
3707 }
3708 other => panic!("{other:?}"),
3709 }
3710 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
3711 assert!(
3712 err.contains("snapshot-every"),
3713 "missing value should name the flag, got {err}"
3714 );
3715 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
3716 assert!(
3717 err.contains("snapshot-every"),
3718 "zero should be rejected, got {err}"
3719 );
3720 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
3721 assert!(
3722 err.contains("snapshot-every"),
3723 "invalid value should name the flag, got {err}"
3724 );
3725 }
3726
3727 #[test]
3728 fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
3729 match parse_args(&[
3731 "serve",
3732 "/tmp/db",
3733 "--addr",
3734 "0.0.0.0:8080",
3735 "--token",
3736 "s3cret",
3737 ])
3738 .unwrap()
3739 {
3740 Command::Serve { token, addr, .. } => {
3741 assert_eq!(token.as_deref(), Some("s3cret"));
3742 assert_eq!(addr.ip().to_string(), "0.0.0.0");
3743 }
3744 other => panic!("{other:?}"),
3745 }
3746 }
3747
3748 #[test]
3749 fn parse_build_index_both_forms_and_a_missing_value() {
3750 match parse_args(&["build-index", "/tmp/db"]).unwrap() {
3751 Command::BuildIndex { db_dir, rule } => {
3752 assert_eq!(db_dir, PathBuf::from("/tmp/db"));
3753 assert_eq!(rule, None);
3754 }
3755 other => panic!("{other:?}"),
3756 }
3757 for args in [
3758 vec!["build-index", "/tmp/db", "--rule", "sim"],
3759 vec!["build-index", "/tmp/db", "--rule=sim"],
3760 ] {
3761 match parse_args(&args).unwrap() {
3762 Command::BuildIndex { db_dir, rule } => {
3763 assert_eq!(db_dir, PathBuf::from("/tmp/db"));
3764 assert_eq!(rule.as_deref(), Some("sim"), "{args:?}");
3765 }
3766 other => panic!("{other:?}"),
3767 }
3768 }
3769 assert_eq!(
3770 parse_args(&["build-index", "/tmp/db", "--rule"]).unwrap_err(),
3771 "--rule requires a value"
3772 );
3773 assert_eq!(
3774 parse_args(&["build-index", "/tmp/db", "--rule="]).unwrap_err(),
3775 "--rule requires a value"
3776 );
3777 assert_eq!(
3778 parse_args(&["build-index"]).unwrap_err(),
3779 "build-index requires <db-dir>"
3780 );
3781 assert!(usage().contains("mushroomdb build-index <db-dir> [--rule <name>]"));
3782 }
3783
3784 #[test]
3785 fn parse_snapshot_and_query() {
3786 for (args, want) in [
3788 (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
3789 (
3790 vec!["snapshot", "/tmp/db", "--archive-wal"],
3791 WalDisposition::Archive,
3792 ),
3793 (
3794 vec!["snapshot", "/tmp/db", "--keep-wal"],
3795 WalDisposition::Keep,
3796 ),
3797 (
3798 vec!["snapshot", "/tmp/db", "--truncate"],
3799 WalDisposition::Truncate,
3800 ),
3801 ] {
3802 match parse_args(&args).unwrap() {
3803 Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
3804 other => panic!("{other:?}"),
3805 }
3806 }
3807 match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
3808 Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
3809 other => panic!("{other:?}"),
3810 }
3811 match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
3812 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3813 other => panic!("{other:?}"),
3814 }
3815 match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
3816 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3817 other => panic!("{other:?}"),
3818 }
3819 let text = usage();
3820 assert!(
3821 text.contains("query"),
3822 "usage should mention query, got:\n{text}"
3823 );
3824 assert!(
3825 text.contains("snapshot"),
3826 "usage should mention snapshot, got:\n{text}"
3827 );
3828 }
3829
3830 #[test]
3831 fn usage_lists_every_subcommand() {
3832 let text = usage();
3833 for word in [
3834 "serve",
3835 "mcp",
3836 "stats",
3837 "demo",
3838 "query",
3839 "snapshot",
3840 "--keep-wal",
3841 "mushroomdb",
3842 "--ui",
3843 "--no-ui",
3844 "--demo-if-empty",
3845 "--token",
3846 "--snapshot-every",
3847 ] {
3848 assert!(
3849 text.contains(word),
3850 "usage should mention {word}, got:\n{text}"
3851 );
3852 }
3853 }
3854
3855 #[test]
3856 fn validate_ui_dir_requires_index_html() {
3857 let missing = tmp("ui-missing");
3858 let err = super::validate_ui_dir(&missing).expect_err("missing dir");
3859 assert!(
3860 err.contains("does not exist"),
3861 "missing dir error, got {err}"
3862 );
3863
3864 let empty = tmp("ui-empty");
3865 std::fs::create_dir_all(&empty).unwrap();
3866 let err = super::validate_ui_dir(&empty).expect_err("no index");
3867 assert!(
3868 err.contains("index.html"),
3869 "missing index.html error, got {err}"
3870 );
3871
3872 let ok = tmp("ui-ok");
3873 std::fs::create_dir_all(&ok).unwrap();
3874 std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
3875 let got = super::validate_ui_dir(&ok).expect("valid ui dir");
3876 assert_eq!(got, ok);
3877 }
3878
3879 #[test]
3880 fn maybe_run_demo_if_empty_seeds_then_skips() {
3881 let dir = tmp("boot-empty");
3882 let first = super::maybe_run_demo_if_empty(&dir)
3883 .expect("empty dir demos")
3884 .expect("Some(DemoOutcome)");
3885 assert_eq!(first.stats.nodes_live, 60);
3886 let db = SharedDb::open(&dir).expect("reopen");
3887 assert!(db.read().has_node("person-01"));
3888 let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
3889 assert!(
3890 second.is_none(),
3891 "second boot must not re-demo a populated volume"
3892 );
3893
3894 let occupied = tmp("boot-occupied");
3895 std::fs::create_dir_all(&occupied).unwrap();
3896 std::fs::write(occupied.join("keep-me"), b"x").unwrap();
3897 let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
3898 assert!(skipped.is_none());
3899 assert_eq!(
3900 std::fs::read(occupied.join("keep-me")).unwrap(),
3901 b"x",
3902 "existing volume contents must be untouched"
3903 );
3904 }
3905
3906 #[test]
3907 fn demo_builder_is_deterministic_and_refuses_second_run() {
3908 let dir = tmp("demo");
3909 let out = run_demo(&dir).expect("first demo run");
3910
3911 assert_eq!(
3912 out.stats.nodes_live, 60,
3913 "10 orgs + 20 projects + 30 people"
3914 );
3915 assert_eq!(out.stats.nodes_tombstoned, 0);
3916 assert_eq!(out.stats.edges, 334);
3924 assert_eq!(
3925 out.stats.rules.len(),
3926 7,
3927 "3 auto-FK + overlap + numeric + geo + vector"
3928 );
3929 let fit = out
3930 .stats
3931 .rules
3932 .iter()
3933 .find(|r| r.name == "skill_fit")
3934 .expect("skill_fit");
3935 assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
3936 let founded = out
3937 .stats
3938 .rules
3939 .iter()
3940 .find(|r| r.name == "founded_within")
3941 .expect("founded_within");
3942 assert_eq!(founded.edges, 34);
3943 let nearby = out
3944 .stats
3945 .rules
3946 .iter()
3947 .find(|r| r.name == "nearby_office")
3948 .expect("nearby_office");
3949 assert_eq!(nearby.edges, 16);
3950 let similar = out
3951 .stats
3952 .rules
3953 .iter()
3954 .find(|r| r.name == "similar_interests")
3955 .expect("similar_interests");
3956 assert_eq!(similar.edges, 114);
3957
3958 let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
3959 names.sort_unstable();
3960 assert_eq!(
3961 names,
3962 vec![
3963 "auto_fk_person_org_id",
3964 "auto_fk_person_project_id",
3965 "auto_fk_project_org_id",
3966 "founded_within",
3967 "nearby_office",
3968 "similar_interests",
3969 "skill_fit",
3970 ]
3971 );
3972
3973 let db = SharedDb::open(&dir).expect("reopen demo");
3975 assert_eq!(
3976 db.read().fulltext_pairs(),
3977 vec![
3978 ("Org".to_string(), "name".to_string()),
3979 ("Person".to_string(), "name".to_string()),
3980 ("Project".to_string(), "name".to_string()),
3981 ]
3982 );
3983
3984 let mut auto = out.auto_fk_rules.clone();
3985 auto.sort();
3986 assert_eq!(
3987 auto,
3988 vec![
3989 "auto_fk_person_org_id".to_string(),
3990 "auto_fk_person_project_id".to_string(),
3991 "auto_fk_project_org_id".to_string(),
3992 ]
3993 );
3994
3995 assert!(
3996 !out.sample_result.is_empty(),
3997 "sample Cypher query must return rows"
3998 );
3999 assert!(
4000 out.sample_query.contains("ORDER BY score DESC"),
4001 "sample query must rank by score, got {}",
4002 out.sample_query
4003 );
4004 let scores: Vec<f64> = (0..out.sample_result.len())
4005 .map(|i| match out.sample_result.get(i, "score") {
4006 Some(Value::Float(f)) => *f,
4007 other => panic!("score col should be Float, got {other:?}"),
4008 })
4009 .collect();
4010 let distinct: std::collections::BTreeSet<u64> =
4011 scores.iter().map(|s| s.to_bits()).collect();
4012 assert!(
4013 distinct.len() >= 2,
4014 "sample results must be visibly ranked, got {scores:?}"
4015 );
4016 for w in scores.windows(2) {
4017 assert!(
4018 w[0] >= w[1],
4019 "scores must be non-increasing, got {scores:?}"
4020 );
4021 }
4022 assert!(
4023 !out.explanations.is_empty(),
4024 "explain(person-01, proj-01) must find the derived edges"
4025 );
4026
4027 let db = SharedDb::open(&dir).expect("reopen demo");
4028 assert_eq!(
4029 directed_pairs(&db, "FOUNDED_WITHIN"),
4030 [
4031 ("org-01", "org-02"),
4032 ("org-01", "org-03"),
4033 ("org-02", "org-01"),
4034 ("org-02", "org-03"),
4035 ("org-02", "org-04"),
4036 ("org-03", "org-01"),
4037 ("org-03", "org-02"),
4038 ("org-03", "org-04"),
4039 ("org-03", "org-05"),
4040 ("org-04", "org-02"),
4041 ("org-04", "org-03"),
4042 ("org-04", "org-05"),
4043 ("org-04", "org-06"),
4044 ("org-05", "org-03"),
4045 ("org-05", "org-04"),
4046 ("org-05", "org-06"),
4047 ("org-05", "org-07"),
4048 ("org-06", "org-04"),
4049 ("org-06", "org-05"),
4050 ("org-06", "org-07"),
4051 ("org-06", "org-08"),
4052 ("org-07", "org-05"),
4053 ("org-07", "org-06"),
4054 ("org-07", "org-08"),
4055 ("org-07", "org-09"),
4056 ("org-08", "org-06"),
4057 ("org-08", "org-07"),
4058 ("org-08", "org-09"),
4059 ("org-08", "org-10"),
4060 ("org-09", "org-07"),
4061 ("org-09", "org-08"),
4062 ("org-09", "org-10"),
4063 ("org-10", "org-08"),
4064 ("org-10", "org-09"),
4065 ]
4066 .into_iter()
4067 .map(|(a, b)| (a.to_string(), b.to_string()))
4068 .collect::<BTreeSet<_>>()
4069 );
4070 assert_eq!(
4071 directed_pairs(&db, "NEARBY_OFFICE"),
4072 [
4073 ("org-01", "org-07"),
4074 ("org-01", "org-10"),
4075 ("org-02", "org-09"),
4076 ("org-03", "org-08"),
4077 ("org-04", "org-05"),
4078 ("org-04", "org-06"),
4079 ("org-05", "org-04"),
4080 ("org-05", "org-06"),
4081 ("org-06", "org-04"),
4082 ("org-06", "org-05"),
4083 ("org-07", "org-01"),
4084 ("org-07", "org-10"),
4085 ("org-08", "org-03"),
4086 ("org-09", "org-02"),
4087 ("org-10", "org-01"),
4088 ("org-10", "org-07"),
4089 ]
4090 .into_iter()
4091 .map(|(a, b)| (a.to_string(), b.to_string()))
4092 .collect::<BTreeSet<_>>()
4093 );
4094 assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
4095 let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
4096 assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
4097 assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
4098 assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
4099
4100 let err = run_demo(&dir).expect_err("second run into the same dir");
4101 let msg = err.to_string().to_lowercase();
4102 assert!(
4103 msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
4104 "refuse message must mention non-empty dir, got {err}"
4105 );
4106 assert!(
4107 msg.contains("hidden"),
4108 "refuse message must mention hidden files, got {err}"
4109 );
4110
4111 let _ = std::fs::remove_dir_all(&dir);
4112 }
4113
4114 #[test]
4115 fn run_snapshot_writes_snapshot_bin() {
4116 let dir = tmp("snapshot-cli");
4117 {
4118 let mut db = GraphDb::open(&dir).expect("open");
4119 db.insert_node("Person", "alice", vec![]).expect("insert");
4120 }
4121 assert!(
4122 !dir.join("snapshot.bin").exists(),
4123 "GraphDb Drop must not snapshot"
4124 );
4125 let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
4126 assert!(
4127 dir.join("snapshot.bin").is_file(),
4128 "run_snapshot must write snapshot.bin"
4129 );
4130 assert!(
4131 out.contains("snapshot.bin"),
4132 "snapshot output should mention snapshot.bin, got {out}"
4133 );
4134 let db = GraphDb::open(&dir).expect("reopen");
4135 assert!(db.has_node("alice"), "reopen after snapshot must recover");
4136 let _ = std::fs::remove_dir_all(&dir);
4137 }
4138
4139 #[test]
4144 fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
4145 let dir = tmp("snapshot-archive");
4146 {
4147 let mut db = GraphDb::open(&dir).expect("open");
4148 db.insert_node("Person", "alice", vec![]).expect("insert");
4149 }
4150 let before = core_api::wal_commit_count_at(&dir).expect("count");
4151 assert!(before > 0, "the insert is a commit");
4152
4153 {
4155 let shared = SharedDb::open(&dir).expect("open");
4156 snapshot_shared(&shared).expect("snapshot");
4157 }
4158
4159 let archives = || {
4160 std::fs::read_dir(&dir)
4161 .expect("read dir")
4162 .filter_map(Result::ok)
4163 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
4164 .count()
4165 };
4166 assert_eq!(archives(), 1, "the WAL was archived, not dropped");
4167 assert!(
4168 dir.join("wal.genesis").is_file(),
4169 "the genesis marker is what lets asof reach an archived commit"
4170 );
4171 {
4172 let db = GraphDb::open(&dir).expect("reopen");
4173 assert!(db.has_node("alice"));
4174 assert!(
4175 !db.node_history("alice").expect("history").items.is_empty(),
4176 "the insert is still explainable"
4177 );
4178 }
4179 assert!(
4180 GraphDb::open_at(&dir, before - 1).is_ok(),
4181 "asof still reaches a commit the snapshot folded in"
4182 );
4183
4184 run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
4187 assert!(
4188 !dir.join("wal.genesis").exists(),
4189 "truncating ends asof's reach into the archives"
4190 );
4191 let db = GraphDb::open(&dir).expect("reopen");
4192 assert!(
4193 db.has_node("alice"),
4194 "the data survives; only the past goes"
4195 );
4196 let _ = std::fs::remove_dir_all(&dir);
4197 }
4198
4199 #[test]
4203 fn automatic_snapshots_keep_every_archive() {
4204 let dir = tmp("snapshot-retention");
4205 let archives = |d: &Path| {
4206 std::fs::read_dir(d)
4207 .expect("read dir")
4208 .filter_map(Result::ok)
4209 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
4210 .count()
4211 };
4212
4213 let rounds = 10;
4216 for i in 0..rounds {
4217 {
4218 let mut db = GraphDb::open(&dir).expect("open");
4219 db.insert_node("Person", &format!("p{i}"), vec![])
4220 .expect("insert");
4221 }
4222 let shared = SharedDb::open(&dir).expect("open shared");
4223 snapshot_shared(&shared).expect("snapshot");
4224 }
4225
4226 assert_eq!(
4227 archives(&dir),
4228 rounds,
4229 "an automatic snapshot must not delete an archive"
4230 );
4231
4232 let db = GraphDb::open(&dir).expect("reopen");
4233 assert_eq!(
4234 db.wal_horizon_floor(),
4235 0,
4236 "nothing was pruned, so the floor stays at 0"
4237 );
4238 for i in 0..rounds {
4239 assert!(db.has_node(&format!("p{i}")), "p{i} survived");
4240 }
4241 assert!(
4242 !db.node_history("p0").expect("history").items.is_empty(),
4243 "the oldest history is still there: that is the point of the default"
4244 );
4245 assert_eq!(db.node_history("p0").expect("history").horizon, 0);
4246 drop(db);
4247
4248 let manual = tmp("snapshot-retention-manual");
4251 for i in 0..3 {
4252 {
4253 let mut db = GraphDb::open(&manual).expect("open");
4254 db.insert_node("Person", &format!("p{i}"), vec![])
4255 .expect("insert");
4256 }
4257 run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
4258 }
4259 assert_eq!(
4260 archives(&manual),
4261 3,
4262 "`mushroomdb snapshot` with no --retention keeps every archive"
4263 );
4264
4265 let _ = std::fs::remove_dir_all(&dir);
4266 let _ = std::fs::remove_dir_all(&manual);
4267 }
4268
4269 #[test]
4272 fn retention_is_still_available_when_configured() {
4273 let dir = tmp("retention-configured");
4274 for i in 0..5 {
4275 {
4276 let mut db = GraphDb::open(&dir).unwrap();
4277 db.insert_node("Person", &format!("p{i}"), vec![]).unwrap();
4278 }
4279 run_snapshot(&dir, WalDisposition::Archive, Some(2)).unwrap();
4280 }
4281 let archives = std::fs::read_dir(&dir)
4282 .expect("read dir")
4283 .filter_map(Result::ok)
4284 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
4285 .count();
4286 assert_eq!(archives, 2, "--retention 2 still prunes to two");
4287 assert!(GraphDb::open(&dir).unwrap().wal_horizon_floor() > 0);
4288 let _ = std::fs::remove_dir_all(&dir);
4289 }
4290
4291 #[test]
4292 fn run_query_formats_like_asof() {
4293 let dir = tmp("query-cli");
4294 {
4295 let mut db = GraphDb::open(&dir).expect("open");
4296 db.insert_node(
4297 "Person",
4298 "alice",
4299 vec![("id".into(), Value::Str("alice".into()))],
4300 )
4301 .expect("insert");
4302 }
4303 let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id", None, None).expect("query");
4304 assert!(out.contains("columns:"), "got {out}");
4305 assert!(out.contains("id=alice"), "got {out}");
4306 let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})", None, None).expect("write");
4307 let db = GraphDb::open(&dir).expect("reopen");
4308 assert!(db.has_node("bob"), "query_write must persist CREATE");
4309 let _ = std::fs::remove_dir_all(&dir);
4310 }
4311
4312 #[test]
4316 fn asof_header_names_the_horizon() {
4317 let dir = tmp("asof-horizon");
4318 for i in 0..10 {
4323 {
4324 let mut db = GraphDb::open(&dir).expect("open");
4325 db.insert_node("Person", &format!("p{i}"), vec![])
4326 .expect("insert");
4327 }
4328 run_snapshot(&dir, WalDisposition::Archive, Some(2)).expect("snapshot");
4329 }
4330 {
4333 let mut db = GraphDb::open(&dir).expect("open");
4334 db.insert_node("Person", "after", vec![]).expect("insert");
4335 }
4336 let (floor, total) = {
4337 let db = GraphDb::open(&dir).expect("reopen");
4338 (
4339 db.wal_horizon_floor(),
4340 db.wal_total_commits().expect("total"),
4341 )
4342 };
4343 assert!(floor > 0, "the retention must have pruned something");
4344
4345 let out = run_asof(&dir, Some(total - 1), None, None, None).expect("asof");
4346 assert_eq!(
4347 out.trim(),
4348 format!(
4349 "as-of commit {} of {total} (history reaches back to commit {floor})",
4350 total - 1
4351 ),
4352 "the header must name the horizon it can reach"
4353 );
4354 let _ = std::fs::remove_dir_all(&dir);
4355
4356 let clean = tmp("asof-clean");
4358 {
4359 let mut db = GraphDb::open(&clean).expect("open");
4360 db.insert_node("Person", "a", vec![]).expect("insert");
4361 }
4362 assert_eq!(
4363 run_asof(&clean, Some(0), None, None, None)
4364 .expect("asof")
4365 .trim(),
4366 "as-of commit 0 of 1"
4367 );
4368 let _ = std::fs::remove_dir_all(&clean);
4369 }
4370
4371 #[test]
4374 fn format_stats_says_how_far_back_history_reaches() {
4375 let dir = tmp("stats-horizon");
4376 {
4377 let mut db = GraphDb::open(&dir).expect("open");
4378 db.insert_node("Person", "a", vec![]).expect("insert");
4379 }
4380 let text = format_stats(&read_stats(&dir).expect("stats"));
4381 assert!(
4382 text.contains("history: complete (nothing pruned)"),
4383 "an unpruned store says so, got:\n{text}"
4384 );
4385 let _ = std::fs::remove_dir_all(&dir);
4386
4387 let pruned = tmp("stats-horizon-pruned");
4388 for i in 0..10 {
4389 {
4390 let mut db = GraphDb::open(&pruned).expect("open");
4391 db.insert_node("Person", &format!("p{i}"), vec![])
4392 .expect("insert");
4393 }
4394 run_snapshot(&pruned, WalDisposition::Archive, Some(2)).expect("snapshot");
4395 }
4396 let stats = read_stats(&pruned).expect("stats");
4397 assert!(stats.history_floor > 0, "the retention must have pruned");
4398 assert!(
4399 format_stats(&stats).contains(&format!(
4400 "history: reaches back to commit {}",
4401 stats.history_floor
4402 )),
4403 "a pruned store names its floor"
4404 );
4405 let _ = std::fs::remove_dir_all(&pruned);
4406 }
4407
4408 #[test]
4409 fn format_stats_contains_counts() {
4410 let dir = tmp("stats-smoke");
4411 let out = run_demo(&dir).expect("demo for stats smoke");
4412 let text = format_stats(&out.stats);
4413 assert!(
4414 text.contains("60"),
4415 "stats output should include live node count, got:\n{text}"
4416 );
4417 assert!(
4418 text.contains("334"),
4419 "stats output should include edge count, got:\n{text}"
4420 );
4421 assert!(
4422 text.to_lowercase().contains("node"),
4423 "stats output should mention nodes, got:\n{text}"
4424 );
4425 assert!(
4426 text.to_lowercase().contains("edge"),
4427 "stats output should mention edges, got:\n{text}"
4428 );
4429 let _ = std::fs::remove_dir_all(&dir);
4430 }
4431
4432 #[test]
4435 fn parse_backup_round_trip() {
4436 let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
4437 match r {
4438 Ok(Command::Backup { db_dir, dest }) => {
4439 assert_eq!(db_dir, PathBuf::from("/db/dir"));
4440 assert_eq!(dest, PathBuf::from("/backup/dest"));
4441 }
4442 other => panic!("backup parse, got {other:?}"),
4443 }
4444 }
4445
4446 #[test]
4447 fn parse_backup_missing_dest_errors() {
4448 let r = parse_args(&["backup", "/db/dir"]);
4449 assert!(r.is_err(), "backup without <dest> should error");
4450 let e = r.unwrap_err();
4451 assert!(
4452 e.to_lowercase().contains("dest"),
4453 "error should mention dest, got: {e}"
4454 );
4455 }
4456
4457 #[test]
4458 fn parse_export_defaults_to_jsonl() {
4459 let r = parse_args(&["export", "/db/dir", "/export/dest"]);
4460 match r {
4461 Ok(Command::Export { format, .. }) => {
4462 assert_eq!(format, ExportFormat::Jsonl);
4463 }
4464 other => panic!("export parse, got {other:?}"),
4465 }
4466 }
4467
4468 #[test]
4469 fn parse_export_parquet_flag() {
4470 let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
4471 match r {
4472 Ok(Command::Export { format, .. }) => {
4473 assert_eq!(format, ExportFormat::Parquet);
4474 }
4475 other => panic!("export --format parquet parse, got {other:?}"),
4476 }
4477 }
4478
4479 #[test]
4480 fn parse_export_parquet_flag_eq() {
4481 let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
4482 match r {
4483 Ok(Command::Export { format, .. }) => {
4484 assert_eq!(format, ExportFormat::Parquet);
4485 }
4486 other => panic!("export --format=parquet parse, got {other:?}"),
4487 }
4488 }
4489
4490 #[test]
4491 fn run_backup_cli_produces_verified_report() {
4492 let src = tmp("cli-backup-src");
4493 let dst = tmp("cli-backup-dst");
4494 let _ = run_demo(&src).expect("demo");
4495 let report = run_backup(&src, &dst).expect("run_backup");
4496 assert!(report.verified, "backup must be verified");
4497 assert!(!report.files.is_empty());
4498 assert!(report.bytes > 0);
4499 let _ = std::fs::remove_dir_all(&src);
4500 let _ = std::fs::remove_dir_all(&dst);
4501 }
4502
4503 fn seed_store(dir: &Path, key: &str) {
4506 let mut db = GraphDb::open(dir).expect("open seed store");
4507 db.insert_node("N", key, vec![]).expect("insert seed node");
4508 db.snapshot().expect("snapshot seed store");
4509 }
4510
4511 #[test]
4512 fn restore_from_seeds_an_empty_dir() {
4513 let src = tmp("restore-src");
4514 seed_store(&src, "a");
4515 let vault = tmp("restore-vault");
4516 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
4517
4518 let fresh = tmp("restore-fresh");
4519 match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
4520 RestoreOutcome::Restored { files, bytes, .. } => {
4521 assert!(
4522 files.contains(&"snapshot.bin".to_string()),
4523 "expected snapshot.bin among {files:?}"
4524 );
4525 assert!(bytes > 0, "expected a non-zero byte count");
4526 }
4527 other => panic!("expected Restored, got {other:?}"),
4528 }
4529 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4530
4531 let _ = std::fs::remove_dir_all(&src);
4532 let _ = std::fs::remove_dir_all(&vault);
4533 let _ = std::fs::remove_dir_all(&fresh);
4534 }
4535
4536 #[test]
4537 fn restore_from_a_backup_dir_itself() {
4538 let src = tmp("restore-direct-src");
4539 seed_store(&src, "a");
4540 let backup = tmp("restore-direct-backup");
4541 run_backup(&src, &backup).expect("backup");
4542
4543 let fresh = tmp("restore-direct-fresh");
4544 match restore_if_empty(&fresh, &backup).expect("restore_if_empty") {
4545 RestoreOutcome::Restored { from, .. } => assert_eq!(from, backup),
4546 other => panic!("expected Restored, got {other:?}"),
4547 }
4548 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4549
4550 let _ = std::fs::remove_dir_all(&src);
4551 let _ = std::fs::remove_dir_all(&backup);
4552 let _ = std::fs::remove_dir_all(&fresh);
4553 }
4554
4555 #[test]
4561 fn restore_from_a_wal_only_backup() {
4562 let src = tmp("restore-walonly-src");
4563 {
4564 let mut db = GraphDb::open(&src).expect("open src");
4565 db.insert_node("N", "a", vec![]).expect("insert");
4566 }
4567 assert!(
4568 !src.join("snapshot.bin").exists(),
4569 "test setup: src must not have snapshotted"
4570 );
4571 let vault = tmp("restore-walonly-vault");
4572 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
4573
4574 let fresh = tmp("restore-walonly-fresh");
4575 match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
4576 RestoreOutcome::Restored { files, .. } => assert!(
4577 files.contains(&"wal.bin".to_string()),
4578 "expected wal.bin among {files:?}"
4579 ),
4580 other => panic!("expected Restored, got {other:?}"),
4581 }
4582 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4583
4584 let _ = std::fs::remove_dir_all(&src);
4585 let _ = std::fs::remove_dir_all(&vault);
4586 let _ = std::fs::remove_dir_all(&fresh);
4587 }
4588
4589 #[test]
4590 fn restore_from_picks_latest_then_newest() {
4591 let older_src = tmp("restore-rank-old-src");
4592 seed_store(&older_src, "old");
4593 let newer_src = tmp("restore-rank-new-src");
4594 seed_store(&newer_src, "new");
4595 let latest_src = tmp("restore-rank-latest-src");
4596 seed_store(&latest_src, "named-latest");
4597
4598 let vault = tmp("restore-rank-vault");
4600 run_backup(&older_src, &vault.join("2026-09-01")).expect("backup old");
4601 std::thread::sleep(std::time::Duration::from_millis(1100));
4602 run_backup(&newer_src, &vault.join("2026-09-02")).expect("backup new");
4603
4604 let fresh = tmp("restore-rank-fresh");
4605 match restore_if_empty(&fresh, &vault).expect("restore by mtime") {
4606 RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("2026-09-02")),
4607 other => panic!("expected Restored, got {other:?}"),
4608 }
4609 assert!(GraphDb::open(&fresh)
4610 .expect("open restored")
4611 .has_node("new"));
4612
4613 run_backup(&latest_src, &vault.join("latest")).expect("backup latest");
4615 let older_than_latest = std::fs::metadata(vault.join("2026-09-02").join("snapshot.bin"))
4616 .expect("stat newest")
4617 .modified()
4618 .expect("mtime");
4619 let latest_mtime = std::fs::metadata(vault.join("latest").join("snapshot.bin"))
4620 .expect("stat latest")
4621 .modified()
4622 .expect("mtime");
4623 assert!(
4624 latest_mtime >= older_than_latest,
4625 "test setup: latest/ should not be older here"
4626 );
4627
4628 let fresh2 = tmp("restore-rank-fresh2");
4629 match restore_if_empty(&fresh2, &vault).expect("restore by name") {
4630 RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("latest")),
4631 other => panic!("expected Restored, got {other:?}"),
4632 }
4633 assert!(GraphDb::open(&fresh2)
4634 .expect("open restored")
4635 .has_node("named-latest"));
4636
4637 for d in [&older_src, &newer_src, &latest_src, &vault, &fresh, &fresh2] {
4638 let _ = std::fs::remove_dir_all(d);
4639 }
4640 }
4641
4642 #[test]
4643 fn restore_from_is_a_no_op_when_a_store_exists() {
4644 let src = tmp("restore-noop-src");
4645 seed_store(&src, "a");
4646 let vault = tmp("restore-noop-vault");
4647 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
4648
4649 let existing = tmp("restore-noop-existing");
4650 seed_store(&existing, "b");
4651
4652 assert_eq!(
4653 restore_if_empty(&existing, &vault).expect("restore_if_empty"),
4654 RestoreOutcome::AlreadyPresent
4655 );
4656 let db = GraphDb::open(&existing).expect("open existing");
4657 assert!(db.has_node("b"), "the existing store must survive");
4658 assert!(!db.has_node("a"), "the backup must not have been copied in");
4659
4660 let _ = std::fs::remove_dir_all(&src);
4661 let _ = std::fs::remove_dir_all(&vault);
4662 let _ = std::fs::remove_dir_all(&existing);
4663 }
4664
4665 #[test]
4666 fn restore_from_an_empty_vault_is_not_an_error() {
4667 let vault = tmp("restore-empty-vault");
4668 std::fs::create_dir_all(&vault).expect("mkdir vault");
4669 let fresh = tmp("restore-empty-fresh");
4670 assert_eq!(
4671 restore_if_empty(&fresh, &vault).expect("restore_if_empty"),
4672 RestoreOutcome::Empty
4673 );
4674 assert_eq!(
4676 restore_if_empty(&fresh, &vault.join("nope")).expect("restore_if_empty"),
4677 RestoreOutcome::Empty
4678 );
4679
4680 let _ = std::fs::remove_dir_all(&vault);
4681 let _ = std::fs::remove_dir_all(&fresh);
4682 }
4683
4684 #[test]
4685 fn restore_from_a_corrupt_backup_fails_loudly() {
4686 let src = tmp("restore-corrupt-src");
4687 seed_store(&src, "a");
4688 let vault = tmp("restore-corrupt-vault");
4689 let backup = vault.join("2026-09-10T00-00Z");
4690 run_backup(&src, &backup).expect("backup");
4691
4692 let snap = backup.join("snapshot.bin");
4694 let bytes = std::fs::read(&snap).expect("read snapshot");
4695 std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
4696
4697 let fresh = tmp("restore-corrupt-fresh");
4698 let err = restore_if_empty(&fresh, &vault).expect_err("expected a hard failure");
4699 let msg = err.to_string();
4700 assert!(
4701 msg.contains(&fresh.display().to_string()),
4702 "error must name the restored dir, got: {msg}"
4703 );
4704 assert!(
4705 msg.contains(&backup.display().to_string()),
4706 "error must name the backup, got: {msg}"
4707 );
4708
4709 let _ = std::fs::remove_dir_all(&src);
4710 let _ = std::fs::remove_dir_all(&vault);
4711 let _ = std::fs::remove_dir_all(&fresh);
4712 }
4713
4714 #[test]
4718 fn a_failed_restore_leaves_the_db_dir_untouched() {
4719 let src = tmp("restore-atomic-src");
4720 seed_store(&src, "a");
4721
4722 let bad_vault = tmp("restore-atomic-bad-vault");
4723 let bad = bad_vault.join("2026-09-10T00-00Z");
4724 run_backup(&src, &bad).expect("backup the bad one");
4725 let snap = bad.join("snapshot.bin");
4726 let bytes = std::fs::read(&snap).expect("read snapshot");
4727 std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
4728
4729 let good_vault = tmp("restore-atomic-good-vault");
4730 run_backup(&src, &good_vault.join("2026-09-11T00-00Z")).expect("backup the good one");
4731
4732 let fresh = tmp("restore-atomic-fresh");
4733 let err = restore_if_empty(&fresh, &bad_vault).expect_err("expected a hard failure");
4734 let msg = err.to_string();
4735 assert!(
4736 msg.contains(&fresh.display().to_string()) && msg.contains(&bad.display().to_string()),
4737 "error must name both paths, got: {msg}"
4738 );
4739
4740 let leftovers: Vec<String> = std::fs::read_dir(&fresh)
4742 .expect("read fresh")
4743 .flatten()
4744 .filter_map(|e| e.file_name().into_string().ok())
4745 .collect();
4746 assert!(
4747 leftovers.is_empty(),
4748 "a failed restore must leave nothing behind, found: {leftovers:?}"
4749 );
4750 assert!(!holds_a_store(&fresh), "the dir must not hold a store");
4751
4752 match restore_if_empty(&fresh, &good_vault).expect("retry must restore") {
4755 RestoreOutcome::Restored { from, .. } => {
4756 assert_eq!(from, good_vault.join("2026-09-11T00-00Z"))
4757 }
4758 other => panic!("expected Restored on retry, got {other:?}"),
4759 }
4760 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
4761
4762 for d in [&src, &bad_vault, &good_vault, &fresh] {
4763 let _ = std::fs::remove_dir_all(d);
4764 }
4765 }
4766
4767 #[test]
4770 fn a_successful_restore_leaves_no_staging_dir() {
4771 let src = tmp("restore-staging-src");
4772 seed_store(&src, "a");
4773 let vault = tmp("restore-staging-vault");
4774 run_backup(&src, &vault.join("2026-09-11T00-00Z")).expect("backup");
4775
4776 let fresh = tmp("restore-staging-fresh");
4777 restore_if_empty(&fresh, &vault).expect("restore");
4778
4779 let stray: Vec<String> = std::fs::read_dir(&fresh)
4780 .expect("read fresh")
4781 .flatten()
4782 .filter_map(|e| e.file_name().into_string().ok())
4783 .filter(|n| n.starts_with(".restore-"))
4784 .collect();
4785 assert!(
4786 stray.is_empty(),
4787 "staging dir must be gone, found: {stray:?}"
4788 );
4789
4790 for d in [&src, &vault, &fresh] {
4791 let _ = std::fs::remove_dir_all(d);
4792 }
4793 }
4794
4795 #[test]
4796 fn serve_parses_restore_from() {
4797 match parse_args(&["serve", "/tmp/db", "--restore-from", "/vol/backups"]) {
4798 Ok(Command::Serve { restore_from, .. }) => {
4799 assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
4800 }
4801 other => panic!("--restore-from parse, got {other:?}"),
4802 }
4803 match parse_args(&["serve", "/tmp/db", "--restore-from=/vol/backups"]) {
4804 Ok(Command::Serve { restore_from, .. }) => {
4805 assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
4806 }
4807 other => panic!("--restore-from= parse, got {other:?}"),
4808 }
4809 match parse_args(&["serve", "/tmp/db"]) {
4810 Ok(Command::Serve { restore_from, .. }) => assert_eq!(restore_from, None),
4811 other => panic!("default restore_from, got {other:?}"),
4812 }
4813 let err = parse_args(&["serve", "/tmp/db", "--restore-from"])
4814 .expect_err("missing value must be an error");
4815 assert!(
4816 err.contains("--restore-from"),
4817 "error must name the flag, got: {err}"
4818 );
4819 }
4820
4821 #[test]
4822 fn run_export_jsonl_two_runs_byte_identical() {
4823 let src = tmp("cli-export-src");
4824 let dst1 = tmp("cli-export-dst1");
4825 let dst2 = tmp("cli-export-dst2");
4826 let _ = run_demo(&src).expect("demo");
4827
4828 run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
4829 run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
4830
4831 for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
4832 let f1 = std::fs::read(dst1.join(filename)).expect("read first");
4833 let f2 = std::fs::read(dst2.join(filename)).expect("read second");
4834 assert_eq!(
4835 f1, f2,
4836 "{filename} must be byte-identical across two export runs"
4837 );
4838 }
4839 let _ = std::fs::remove_dir_all(&src);
4840 let _ = std::fs::remove_dir_all(&dst1);
4841 let _ = std::fs::remove_dir_all(&dst2);
4842 }
4843
4844 #[test]
4845 fn run_export_jsonl_nodes_are_sorted() {
4846 let src = tmp("cli-export-sorted");
4847 let dst = tmp("cli-export-sorted-dst");
4848 let _ = run_demo(&src).expect("demo");
4849 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
4850
4851 let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
4852 let keys: Vec<String> = content
4853 .lines()
4854 .filter(|l| !l.is_empty())
4855 .map(|l| {
4856 let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
4857 v["key"].as_str().unwrap_or("").to_string()
4858 })
4859 .collect();
4860 let mut sorted = keys.clone();
4861 sorted.sort();
4862 assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
4863 let _ = std::fs::remove_dir_all(&src);
4864 let _ = std::fs::remove_dir_all(&dst);
4865 }
4866
4867 #[test]
4868 fn run_export_jsonl_derived_edges_have_rule() {
4869 let src = tmp("cli-export-derived");
4870 let dst = tmp("cli-export-derived-dst");
4871 let _ = run_demo(&src).expect("demo");
4872 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
4873
4874 let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
4875 let derived_lines: Vec<serde_json::Value> = content
4876 .lines()
4877 .filter(|l| !l.is_empty())
4878 .map(|l| serde_json::from_str(l).expect("parse line"))
4879 .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
4880 .collect();
4881 assert!(
4882 !derived_lines.is_empty(),
4883 "demo store should have derived edges"
4884 );
4885 for edge in &derived_lines {
4886 assert!(
4887 !edge["rule"].is_null(),
4888 "derived edge must have non-null rule: {edge}"
4889 );
4890 }
4891 let _ = std::fs::remove_dir_all(&src);
4892 let _ = std::fs::remove_dir_all(&dst);
4893 }
4894
4895 #[test]
4896 fn run_export_parquet_produces_files() {
4897 let src = tmp("cli-export-parq-src");
4898 let dst = tmp("cli-export-parq-dst");
4899 let _ = run_demo(&src).expect("demo");
4900 run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
4901
4902 assert!(
4903 dst.join("nodes.parquet").exists(),
4904 "nodes.parquet must exist"
4905 );
4906 assert!(
4907 dst.join("edges.parquet").exists(),
4908 "edges.parquet must exist"
4909 );
4910 assert!(
4911 dst.join("rules.parquet").exists(),
4912 "rules.parquet must exist"
4913 );
4914 for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
4916 let meta = std::fs::metadata(dst.join(f)).expect("metadata");
4917 assert!(meta.len() > 0, "{f} must be non-empty");
4918 }
4919 let _ = std::fs::remove_dir_all(&src);
4920 let _ = std::fs::remove_dir_all(&dst);
4921 }
4922
4923 #[test]
4924 fn parse_export_graphml_flag() {
4925 let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
4926 match r {
4927 Ok(Command::Export { format, .. }) => {
4928 assert_eq!(format, ExportFormat::Graphml);
4929 }
4930 other => panic!("export --format graphml parse, got {other:?}"),
4931 }
4932 }
4933
4934 #[test]
4935 fn run_export_graphml_structure() {
4936 let src = tmp("cli-export-gml-src");
4937 let dst_dir = tmp("cli-export-gml-dst");
4938 let dst = dst_dir.join("graph.graphml");
4939 let _ = run_demo(&src).expect("demo");
4940 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4941
4942 let content = std::fs::read_to_string(&dst).expect("read graphml");
4943
4944 assert!(
4945 content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
4946 "must start with an XML declaration"
4947 );
4948 assert!(
4949 content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
4950 "must use the standard GraphML namespace"
4951 );
4952 assert!(
4953 content.contains(
4954 "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
4955 ),
4956 "must declare the node label key"
4957 );
4958 assert!(
4959 content.contains(
4960 "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
4961 ),
4962 "must declare the edge type key"
4963 );
4964 assert!(
4965 content.contains(
4966 "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
4967 ),
4968 "must declare the edge derived key"
4969 );
4970 assert!(
4971 content.contains(
4972 "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
4973 ),
4974 "must declare the edge rule key"
4975 );
4976 assert!(
4977 content.contains(
4978 "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
4979 ),
4980 "must declare the edge weight key"
4981 );
4982 assert!(
4986 content.contains(
4987 "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
4988 ),
4989 "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
4990 );
4991 assert!(
4992 content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
4993 "must declare a single directed graph element"
4994 );
4995 assert!(content.contains("<node id="), "must contain node elements");
4996 assert!(
4997 content.contains("<edge id=\"e0\" source=\""),
4998 "must contain a sequentially-numbered edge starting at e0"
4999 );
5000 assert!(
5001 content.trim_end().ends_with("</graphml>"),
5002 "must close the root element"
5003 );
5004
5005 assert!(
5008 content.contains("<data key=\"e_rule\">skill_fit</data>")
5009 || content.contains("<data key=\"e_rule\">founded_within</data>"),
5010 "at least one derived edge must carry its rule name"
5011 );
5012 assert!(
5013 content.contains(&format!(
5014 "<data key=\"{}\">",
5015 "e_weight" )),
5017 "at least one derived edge must carry a weight value"
5018 );
5019
5020 let _ = std::fs::remove_dir_all(&src);
5021 let _ = std::fs::remove_dir_all(&dst_dir);
5022 }
5023
5024 #[test]
5025 fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
5026 let src = tmp("cli-export-gml-dir-src");
5027 let dst_dir = tmp("cli-export-gml-dir-dst");
5028 std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
5029 let _ = run_demo(&src).expect("demo");
5030
5031 let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
5032
5033 assert!(
5034 dst_dir.join("graph.graphml").exists(),
5035 "an existing directory dest must produce dest/graph.graphml"
5036 );
5037 assert!(
5038 msg.contains("graph.graphml"),
5039 "report must name the file actually written, got: {msg}"
5040 );
5041
5042 let _ = std::fs::remove_dir_all(&src);
5043 let _ = std::fs::remove_dir_all(&dst_dir);
5044 }
5045
5046 #[test]
5049 fn run_export_graphml_is_well_formed_xml() {
5050 let has_python3 = std::process::Command::new("python3")
5051 .arg("--version")
5052 .output()
5053 .map(|o| o.status.success())
5054 .unwrap_or(false);
5055 if !has_python3 {
5056 eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
5057 return;
5058 }
5059
5060 let src = tmp("cli-export-gml-wf-src");
5061 let dst_dir = tmp("cli-export-gml-wf-dst");
5062 let dst = dst_dir.join("graph.graphml");
5063 let _ = run_demo(&src).expect("demo");
5064 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
5065
5066 let status = std::process::Command::new("python3")
5067 .arg("-c")
5068 .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
5069 .arg(&dst)
5070 .status()
5071 .expect("run python3");
5072 assert!(
5073 status.success(),
5074 "python3's XML parser must accept the exported GraphML file"
5075 );
5076
5077 let _ = std::fs::remove_dir_all(&src);
5078 let _ = std::fs::remove_dir_all(&dst_dir);
5079 }
5080
5081 #[test]
5082 fn run_export_graphml_two_runs_byte_identical() {
5083 let src = tmp("cli-export-gml-bi-src");
5084 let dst_dir1 = tmp("cli-export-gml-bi-dst1");
5085 let dst_dir2 = tmp("cli-export-gml-bi-dst2");
5086 let dst1 = dst_dir1.join("graph.graphml");
5087 let dst2 = dst_dir2.join("graph.graphml");
5088 let _ = run_demo(&src).expect("demo");
5089
5090 run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
5091 run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
5092
5093 let f1 = std::fs::read(&dst1).expect("read first");
5094 let f2 = std::fs::read(&dst2).expect("read second");
5095 assert_eq!(
5096 f1, f2,
5097 "graph.graphml must be byte-identical across two export runs"
5098 );
5099
5100 let _ = std::fs::remove_dir_all(&src);
5101 let _ = std::fs::remove_dir_all(&dst_dir1);
5102 let _ = std::fs::remove_dir_all(&dst_dir2);
5103 }
5104
5105 #[test]
5106 fn run_export_graphml_escapes_and_lists() {
5107 use core_api::{GraphDb, Value};
5108 let src = tmp("cli-export-gml-esc-src");
5109 let dst_dir = tmp("cli-export-gml-esc-dst");
5110 let dst = dst_dir.join("graph.graphml");
5111
5112 {
5113 let mut db = GraphDb::open(&src).unwrap();
5114 db.insert_node(
5115 "Widget",
5116 "w1",
5117 vec![
5118 (
5119 "title".into(),
5120 Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
5121 ),
5122 (
5123 "tags".into(),
5124 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
5125 ),
5126 ],
5127 )
5128 .unwrap();
5129 }
5130
5131 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
5132 let content = std::fs::read_to_string(&dst).expect("read graphml");
5133
5134 assert!(
5135 content.contains("Tom & Jerry <says> "hi" 'bye'"),
5136 "special XML characters in string props must be escaped, got: {content}"
5137 );
5138 assert!(
5139 !content.contains("Tom & Jerry <says>"),
5140 "unescaped special characters must not appear verbatim"
5141 );
5142 assert!(
5143 content.contains(
5144 "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
5145 ),
5146 "list-valued props must declare attr.type=\"string\""
5147 );
5148 assert!(
5149 content.contains("<data key=\"n_tags\">["a","b"]</data>"),
5150 "list-valued props must render as XML-escaped JSON text, got: {content}"
5151 );
5152
5153 let _ = std::fs::remove_dir_all(&src);
5154 let _ = std::fs::remove_dir_all(&dst_dir);
5155 }
5156
5157 #[test]
5162 fn run_export_graphml_mixed_type_prop_declares_string() {
5163 use core_api::{GraphDb, Value};
5164 let src = tmp("cli-export-gml-mixed-src");
5165 let dst_dir = tmp("cli-export-gml-mixed-dst");
5166 let dst = dst_dir.join("graph.graphml");
5167
5168 {
5169 let mut db = GraphDb::open(&src).unwrap();
5170 db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
5171 .unwrap();
5172 db.insert_node(
5173 "Metric",
5174 "m2",
5175 vec![("score".into(), Value::Str("high".into()))],
5176 )
5177 .unwrap();
5178 }
5179
5180 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
5181 let content = std::fs::read_to_string(&dst).expect("read graphml");
5182
5183 assert!(
5184 content.contains(
5185 "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
5186 ),
5187 "a prop name with conflicting value types across nodes must declare \
5188 attr.type=\"string\", got: {content}"
5189 );
5190 assert!(
5191 !content.contains("attr.name=\"score\" attr.type=\"long\""),
5192 "must not declare a narrower type once a conflict is seen, got: {content}"
5193 );
5194 assert!(
5197 content.contains("<data key=\"n_score\">5</data>"),
5198 "the int-valued node must still render its literal int text, got: {content}"
5199 );
5200 assert!(
5201 content.contains("<data key=\"n_score\">high</data>"),
5202 "the string-valued node must still render its literal string text, got: {content}"
5203 );
5204
5205 let _ = std::fs::remove_dir_all(&src);
5206 let _ = std::fs::remove_dir_all(&dst_dir);
5207 }
5208
5209 #[test]
5210 fn parse_algo_degree_defaults_dir_both() {
5211 let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
5212 match cmd {
5213 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
5214 other => panic!("expected Algo, got {other:?}"),
5215 }
5216 }
5217
5218 #[test]
5219 fn parse_algo_degree_with_dir_flag() {
5220 for (arg, want) in [
5221 ("out", AlgoDir::Out),
5222 ("in", AlgoDir::In),
5223 ("both", AlgoDir::Both),
5224 ] {
5225 let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
5226 match cmd {
5227 Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
5228 other => panic!("expected Algo, got {other:?}"),
5229 }
5230 }
5231 let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
5233 match cmd {
5234 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
5235 other => panic!("expected Algo, got {other:?}"),
5236 }
5237 }
5238
5239 #[test]
5240 fn parse_algo_rejects_unknown_dir() {
5241 assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
5242 }
5243
5244 #[test]
5245 fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
5246 let cmd = parse_args(&[
5247 "algo",
5248 "communities",
5249 "/db",
5250 "--edge-type",
5251 "IMPORTS",
5252 "--edge-type=CO_CHANGED",
5253 "--weight-prop",
5254 "score",
5255 "--min-weight",
5256 "0.3",
5257 "--top",
5258 "5",
5259 ])
5260 .unwrap();
5261 match cmd {
5262 Command::Algo {
5263 subcmd,
5264 top,
5265 edge_types,
5266 weight_prop,
5267 min_weight,
5268 ..
5269 } => {
5270 assert_eq!(subcmd, AlgoSubcmd::Communities);
5271 assert_eq!(top, 5);
5272 assert_eq!(
5273 edge_types,
5274 vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
5275 );
5276 assert_eq!(weight_prop, Some("score".to_string()));
5277 assert_eq!(min_weight, Some(0.3));
5278 }
5279 other => panic!("expected Algo, got {other:?}"),
5280 }
5281 }
5282
5283 #[test]
5284 fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
5285 let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
5286 match cmd {
5287 Command::Algo {
5288 subcmd,
5289 edge_types,
5290 weight_prop,
5291 min_weight,
5292 ..
5293 } => {
5294 assert_eq!(subcmd, AlgoSubcmd::Communities);
5295 assert!(edge_types.is_empty());
5296 assert_eq!(weight_prop, None);
5297 assert_eq!(min_weight, None);
5298 }
5299 other => panic!("expected Algo, got {other:?}"),
5300 }
5301 }
5302
5303 #[test]
5306 fn run_export_jsonl_nan_float_becomes_null() {
5307 use core_api::{GraphDb, Value};
5308 let src = tmp("cli-export-nan-src");
5309 let dst = tmp("cli-export-nan-dst");
5310
5311 {
5313 let mut db = GraphDb::open(&src).unwrap();
5314 db.insert_node(
5315 "Sensor",
5316 "s1",
5317 vec![
5318 ("nan_val".into(), Value::Float(f64::NAN)),
5319 ("pos_inf".into(), Value::Float(f64::INFINITY)),
5320 ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
5321 ("normal".into(), Value::Float(1.5)),
5322 ],
5323 )
5324 .unwrap();
5325 }
5326
5327 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
5329
5330 let content =
5332 std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
5333 let row: serde_json::Value =
5334 serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
5335 assert_eq!(
5336 row["nan_val"],
5337 serde_json::Value::Null,
5338 "NaN must export as null"
5339 );
5340 assert_eq!(
5341 row["pos_inf"],
5342 serde_json::Value::Null,
5343 "+Inf must export as null"
5344 );
5345 assert_eq!(
5346 row["neg_inf"],
5347 serde_json::Value::Null,
5348 "-Inf must export as null"
5349 );
5350 assert_eq!(
5352 row["normal"],
5353 serde_json::json!(1.5),
5354 "normal float roundtrips"
5355 );
5356
5357 let _ = std::fs::remove_dir_all(&src);
5358 let _ = std::fs::remove_dir_all(&dst);
5359 }
5360
5361 #[test]
5362 fn serve_tls_flags_parse_both_forms() {
5363 match parse_args(&[
5365 "serve",
5366 "/tmp/db",
5367 "--tls-cert",
5368 "/a/cert.pem",
5369 "--tls-key",
5370 "/a/key.pem",
5371 ])
5372 .unwrap()
5373 {
5374 Command::Serve {
5375 tls_cert, tls_key, ..
5376 } => {
5377 assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
5378 assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
5379 }
5380 other => panic!("{other:?}"),
5381 }
5382 match parse_args(&[
5384 "serve",
5385 "/tmp/db",
5386 "--tls-cert=/b/cert.pem",
5387 "--tls-key=/b/key.pem",
5388 ])
5389 .unwrap()
5390 {
5391 Command::Serve {
5392 tls_cert, tls_key, ..
5393 } => {
5394 assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
5395 assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
5396 }
5397 other => panic!("{other:?}"),
5398 }
5399 match parse_args(&["serve", "/tmp/db"]).unwrap() {
5401 Command::Serve {
5402 tls_cert, tls_key, ..
5403 } => {
5404 assert_eq!(tls_cert, None);
5405 assert_eq!(tls_key, None);
5406 }
5407 other => panic!("{other:?}"),
5408 }
5409 }
5410
5411 #[test]
5412 fn serve_tls_flags_require_both() {
5413 let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
5415 assert!(
5416 err.contains("tls-key"),
5417 "--tls-cert alone must mention --tls-key in error, got {err}"
5418 );
5419 let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
5421 assert!(
5422 err.contains("tls-cert"),
5423 "--tls-key alone must mention --tls-cert in error, got {err}"
5424 );
5425 }
5426
5427 #[test]
5428 fn version_flag_parses() {
5429 assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
5430 assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
5431 assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
5432 }
5433
5434 #[test]
5435 fn recall_parses_one_dir_and_is_listed_in_usage() {
5436 assert_eq!(
5437 parse_args(&["recall", "/tmp/db"]).unwrap(),
5438 Command::Recall {
5439 db_dir: Some(PathBuf::from("/tmp/db")),
5440 auto: false,
5441 }
5442 );
5443 assert!(
5444 parse_args(&["recall"]).is_err(),
5445 "one of <db-dir> or --auto is required"
5446 );
5447 assert!(usage().contains("mushroomdb recall <db-dir>"));
5448 }
5449
5450 #[test]
5451 fn map_parses_a_dir_and_an_optional_json_flag() {
5452 assert_eq!(
5453 parse_args(&["map", "/tmp/db"]).unwrap(),
5454 Command::Map {
5455 db_dir: PathBuf::from("/tmp/db"),
5456 json: false,
5457 }
5458 );
5459 let want = Command::Map {
5461 db_dir: PathBuf::from("/tmp/db"),
5462 json: true,
5463 };
5464 assert_eq!(parse_args(&["map", "/tmp/db", "--json"]).unwrap(), want);
5465 assert_eq!(parse_args(&["map", "--json", "/tmp/db"]).unwrap(), want);
5466 assert!(parse_args(&["map"]).is_err(), "<db-dir> is required");
5467 assert!(parse_args(&["map", "/tmp/db", "/tmp/other"]).is_err());
5468 assert!(parse_args(&["map", "/tmp/db", "--nope"]).is_err());
5469 assert!(usage().contains("mushroomdb map <db-dir> [--json]"));
5470 }
5471
5472 #[test]
5473 fn the_graph_tools_take_a_dir_and_their_keys() {
5474 assert_eq!(
5475 parse_args(&["context", "/tmp/db", "src/db.rs#open"]).unwrap(),
5476 Command::Context {
5477 db_dir: PathBuf::from("/tmp/db"),
5478 target: "src/db.rs#open".to_string(),
5479 full: false,
5480 }
5481 );
5482 assert_eq!(
5483 parse_args(&["explore", "/tmp/db", "open"]).unwrap(),
5484 Command::Explore {
5485 db_dir: PathBuf::from("/tmp/db"),
5486 target: "open".to_string(),
5487 depth: repograph::Depth::Context,
5488 full: false,
5489 },
5490 "the default depth is the cheapest one"
5491 );
5492 assert_eq!(
5493 parse_args(&["explore", "/tmp/db", "open", "--depth", "all", "--full"]).unwrap(),
5494 Command::Explore {
5495 db_dir: PathBuf::from("/tmp/db"),
5496 target: "open".to_string(),
5497 depth: repograph::Depth::All,
5498 full: true,
5499 }
5500 );
5501 assert_eq!(
5502 parse_args(&["impact", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
5503 Command::Impact {
5504 db_dir: PathBuf::from("/tmp/db"),
5505 files: vec!["a.rs".to_string(), "b.rs".to_string()],
5506 }
5507 );
5508 assert_eq!(
5509 parse_args(&["owners", "/tmp/db", "a.rs"]).unwrap(),
5510 Command::Owners {
5511 db_dir: PathBuf::from("/tmp/db"),
5512 path: "a.rs".to_string(),
5513 }
5514 );
5515 assert_eq!(
5516 parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
5517 Command::Why {
5518 db_dir: PathBuf::from("/tmp/db"),
5519 a: "a.rs".to_string(),
5520 b: "b.rs".to_string(),
5521 }
5522 );
5523
5524 for args in [
5526 vec!["context", "/tmp/db"],
5527 vec!["context", "/tmp/db", "a", "b"],
5528 vec!["impact", "/tmp/db"],
5529 vec!["owners", "/tmp/db"],
5530 vec!["why", "/tmp/db", "a"],
5531 vec!["why", "/tmp/db", "a", "b", "c"],
5532 vec!["why", "/tmp/db", "-a", "b"],
5533 vec!["context"],
5534 vec!["explore"],
5535 vec!["explore", "/tmp/db"],
5536 vec!["explore", "/tmp/db", "a", "b"],
5537 vec!["explore", "/tmp/db", "a", "--depth"],
5538 vec!["explore", "/tmp/db", "a", "--depth", "everything"],
5539 vec!["explore", "/tmp/db", "a", "--nope"],
5540 ] {
5541 assert!(parse_args(&args).is_err(), "{args:?} must not parse");
5542 }
5543 for line in [
5544 "mushroomdb explore <db-dir> <target>",
5545 "mushroomdb context <db-dir> <target>",
5546 "mushroomdb impact <db-dir> <file>...",
5547 "mushroomdb owners <db-dir> <path>",
5548 "mushroomdb why <db-dir> <a> <b>",
5549 ] {
5550 assert!(usage().contains(line), "usage is missing {line:?}");
5551 }
5552 }
5553
5554 #[test]
5559 fn usage_marks_the_deprecated_subcommands() {
5560 let text = usage();
5561 for prefix in [
5562 "mushroomdb explore <db-dir> <target>",
5563 "mushroomdb map <db-dir> [--json]",
5564 "mushroomdb context <db-dir> <target>",
5565 "mushroomdb impact <db-dir> <file>...",
5566 "mushroomdb owners <db-dir> <path>",
5567 "mushroomdb why <db-dir> <a> <b>",
5568 "mushroomdb sync <db-dir>|--auto",
5569 ] {
5570 let start = text
5571 .find(prefix)
5572 .unwrap_or_else(|| panic!("usage is missing {prefix:?}"));
5573 let rest = &text[start..];
5574 let block_end = rest.find("\n mushroomdb ").unwrap_or(rest.len());
5575 assert!(
5576 rest[..block_end].contains("(deprecated, removed in 0.7)"),
5577 "usage does not mark {prefix:?} deprecated"
5578 );
5579 }
5580 }
5581
5582 #[test]
5585 fn hook_commands_take_a_dir_or_auto() {
5586 assert_eq!(
5587 parse_args(&["mcp", "--auto"]).unwrap(),
5588 Command::Mcp {
5589 db_dir: None,
5590 auto: true,
5591 all_tools: false
5592 }
5593 );
5594 assert_eq!(
5595 parse_args(&["recall", "--auto"]).unwrap(),
5596 Command::Recall {
5597 db_dir: None,
5598 auto: true
5599 }
5600 );
5601 assert_eq!(
5602 parse_args(&["brief", "--auto"]).unwrap(),
5603 Command::Brief {
5604 db_dir: None,
5605 auto: true
5606 }
5607 );
5608 assert_eq!(
5609 parse_args(&["brief", "/tmp/db"]).unwrap(),
5610 Command::Brief {
5611 db_dir: Some(PathBuf::from("/tmp/db")),
5612 auto: false
5613 }
5614 );
5615 for cmd in ["mcp", "recall", "touch", "brief"] {
5616 assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
5617 assert!(
5618 parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
5619 "{cmd} with both"
5620 );
5621 }
5622 assert!(usage().contains("--auto"));
5623 }
5624
5625 #[test]
5628 fn mcp_takes_all_tools() {
5629 for args in [
5630 &["mcp", "/tmp/db", "--all-tools"][..],
5631 &["mcp", "--all-tools", "/tmp/db"][..],
5632 ] {
5633 assert_eq!(
5634 parse_args(args).unwrap(),
5635 Command::Mcp {
5636 db_dir: Some(PathBuf::from("/tmp/db")),
5637 auto: false,
5638 all_tools: true
5639 },
5640 "{args:?}"
5641 );
5642 }
5643 assert_eq!(
5644 parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
5645 Command::Mcp {
5646 db_dir: None,
5647 auto: true,
5648 all_tools: true
5649 }
5650 );
5651 assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
5652 assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
5653 assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
5654 assert!(usage().contains("--all-tools"));
5655 }
5656
5657 #[test]
5658 fn sync_and_touch_parse() {
5659 assert_eq!(
5660 parse_args(&["sync", "/tmp/db"]).unwrap(),
5661 Command::Sync {
5662 db_dir: Some(PathBuf::from("/tmp/db")),
5663 auto: false,
5664 json: false,
5665 }
5666 );
5667 assert_eq!(
5668 parse_args(&["sync", "/tmp/db", "--json"]).unwrap(),
5669 Command::Sync {
5670 db_dir: Some(PathBuf::from("/tmp/db")),
5671 auto: false,
5672 json: true,
5673 }
5674 );
5675 assert_eq!(
5678 parse_args(&["sync", "--auto"]).unwrap(),
5679 Command::Sync {
5680 db_dir: None,
5681 auto: true,
5682 json: false,
5683 }
5684 );
5685 assert_eq!(
5686 parse_args(&["sync", "--auto", "--json"]).unwrap(),
5687 Command::Sync {
5688 db_dir: None,
5689 auto: true,
5690 json: true,
5691 }
5692 );
5693 assert!(
5694 parse_args(&["sync"]).is_err(),
5695 "one of <db-dir> or --auto is required"
5696 );
5697 assert!(
5698 parse_args(&["sync", "/tmp/db", "--auto"]).is_err(),
5699 "--auto and a path contradict each other"
5700 );
5701
5702 assert_eq!(
5704 parse_args(&["touch", "/tmp/db", "src/a.rs", "src/b.rs"]).unwrap(),
5705 Command::Touch {
5706 db_dir: Some(PathBuf::from("/tmp/db")),
5707 auto: false,
5708 files: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
5709 }
5710 );
5711 assert_eq!(
5713 parse_args(&["touch", "--auto", "src/a.rs"]).unwrap(),
5714 Command::Touch {
5715 db_dir: None,
5716 auto: true,
5717 files: vec![PathBuf::from("src/a.rs")],
5718 }
5719 );
5720 assert_eq!(
5722 parse_args(&["touch", "--auto"]).unwrap(),
5723 Command::Touch {
5724 db_dir: None,
5725 auto: true,
5726 files: vec![],
5727 }
5728 );
5729 assert!(usage().contains("mushroomdb sync <db-dir>"));
5730 assert!(usage().contains("mushroomdb touch"));
5731 }
5732
5733 #[test]
5734 fn ingest_git_parses_excludes() {
5735 let cmd = parse_args(&[
5736 "ingest-git",
5737 "/tmp/db",
5738 "/tmp/repo",
5739 "--exclude",
5740 "target/",
5741 "--exclude=*.lock",
5742 "--max-commits-per-file",
5743 "50",
5744 "--recurse-submodules",
5745 "--prs",
5746 "--ensure-gitignore",
5747 ])
5748 .unwrap();
5749 assert_eq!(
5750 cmd,
5751 Command::IngestGit {
5752 db_dir: PathBuf::from("/tmp/db"),
5753 opts: ingest_git::IngestGitOpts {
5754 repo: PathBuf::from("/tmp/repo"),
5755 exclude: vec!["target/".into(), "*.lock".into()],
5756 max_commits_per_file: 50,
5757 recurse_submodules: true,
5758 prs: true,
5759 structure: true,
5760 docs: true,
5761 ensure_gitignore: true,
5762 },
5763 }
5764 );
5765 let Command::IngestGit { opts, .. } =
5767 parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
5768 else {
5769 panic!("expected IngestGit");
5770 };
5771 assert_eq!(
5772 opts.exclude,
5773 ingest_git::DEFAULT_EXCLUDES
5774 .iter()
5775 .map(|p| (*p).to_string())
5776 .collect::<Vec<_>>(),
5777 "with no --exclude the defaults apply"
5778 );
5779 assert_eq!(
5780 opts.max_commits_per_file,
5781 ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
5782 );
5783 assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
5784 assert!(
5785 opts.structure && opts.docs,
5786 "structure and docs default on and are recorded on the marker"
5787 );
5788 let Command::IngestGit { opts, .. } = parse_args(&[
5789 "ingest-git",
5790 "/tmp/db",
5791 "/tmp/repo",
5792 "--no-structure",
5793 "--no-docs",
5794 ])
5795 .unwrap() else {
5796 panic!("expected IngestGit");
5797 };
5798 assert!(!opts.structure && !opts.docs);
5799 assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
5800 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
5801 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
5802 assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
5803 }
5804
5805 #[test]
5806 fn version_constant_matches_cargo() {
5807 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
5808 assert!(usage().contains("--version"));
5809 }
5810 fn ns_store(name: &str) -> PathBuf {
5814 let dir = tmp(name);
5815 let mut db = GraphDb::open(&dir).expect("open");
5816 for (key, ns) in [
5817 ("d1", None),
5818 ("a1", Some("tenant-a")),
5819 ("a2", Some("tenant-a")),
5820 ("b1", Some("tenant-b")),
5821 ] {
5822 let mut props = vec![("id".into(), Value::Str(key.into()))];
5823 if let Some(ns) = ns {
5824 props.push(("ns".into(), Value::Str(ns.into())));
5825 }
5826 db.insert_node("Doc", key, props).expect("insert");
5827 }
5828 db.apply_schema(&Schema {
5829 roles: vec![core_api::RoleDef {
5830 name: "a-reader".into(),
5831 keys: vec![],
5832 labels: vec!["Doc".into()],
5833 visible_where: None,
5834 namespaces: Some(vec!["tenant-a".into()]),
5835 write: None,
5836 }],
5837 ..Default::default()
5838 })
5839 .expect("schema");
5840 dir
5841 }
5842
5843 #[test]
5846 fn format_stats_lists_namespaces() {
5847 let dir = ns_store("stats-namespaces");
5848 let text = format_stats(&read_stats(&dir).expect("stats"));
5849 assert!(
5850 text.contains("namespaces: default (1), tenant-a (2), tenant-b (1)"),
5851 "got:\n{text}"
5852 );
5853 let _ = std::fs::remove_dir_all(&dir);
5854 }
5855
5856 #[test]
5859 fn format_stats_omits_the_namespaces_line_on_one_namespace() {
5860 let dir = tmp("stats-one-namespace");
5861 {
5862 let mut db = GraphDb::open(&dir).expect("open");
5863 db.insert_node("Person", "a", vec![]).expect("insert");
5864 }
5865 let text = format_stats(&read_stats(&dir).expect("stats"));
5866 assert!(
5867 !text.contains("namespaces"),
5868 "a store with only `default` says nothing about namespaces, got:\n{text}"
5869 );
5870 let _ = std::fs::remove_dir_all(&dir);
5871 }
5872
5873 #[test]
5875 fn parse_query_takes_a_role_and_a_namespace() {
5876 let Ok(Command::Query {
5877 role, namespace, ..
5878 }) = parse_args(&[
5879 "query",
5880 "/tmp/db",
5881 "--role",
5882 "a-reader",
5883 "--namespace",
5884 "tenant-a",
5885 "MATCH (n) RETURN n",
5886 ])
5887 else {
5888 panic!("expected Query");
5889 };
5890 assert_eq!(role.as_deref(), Some("a-reader"));
5891 assert_eq!(namespace.as_deref(), Some("tenant-a"));
5892
5893 let Ok(Command::Query {
5894 role, namespace, ..
5895 }) = parse_args(&[
5896 "query",
5897 "/tmp/db",
5898 "--namespace=tenant-b",
5899 "MATCH (n) RETURN n",
5900 ])
5901 else {
5902 panic!("expected Query");
5903 };
5904 assert_eq!(role, None);
5905 assert_eq!(namespace.as_deref(), Some("tenant-b"));
5906
5907 assert!(parse_args(&["query", "/tmp/db", "--namespace"]).is_err());
5908 assert!(parse_args(&["query", "/tmp/db", "--role"]).is_err());
5909 assert!(usage().contains("--namespace <ns>"));
5910 }
5911
5912 #[test]
5915 fn run_query_with_a_role_and_a_namespace_never_widens() {
5916 let dir = ns_store("query-namespace");
5917 let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
5918
5919 let all = run_query(&dir, q, None, None).expect("query");
5920 assert!(all.contains("id=a1") && all.contains("id=b1") && all.contains("id=d1"));
5921
5922 let ns = run_query(&dir, q, None, Some("tenant-a")).expect("query");
5923 assert!(ns.contains("id=a1") && ns.contains("id=a2"), "got {ns}");
5924 assert!(!ns.contains("id=b1") && !ns.contains("id=d1"), "got {ns}");
5925
5926 let role = run_query(&dir, q, Some("a-reader"), None).expect("query");
5927 assert!(
5928 role.contains("id=a1") && !role.contains("id=b1"),
5929 "got {role}"
5930 );
5931
5932 let both = run_query(&dir, q, Some("a-reader"), Some("tenant-b")).expect("query");
5933 assert!(
5934 !both.contains("id=a1") && !both.contains("id=b1"),
5935 "role ∩ namespace, never role ∪ namespace: {both}"
5936 );
5937
5938 for (role, namespace) in [
5941 (None, Some("tenant-a")),
5942 (Some("a-reader"), None),
5943 (Some("a-reader"), Some("tenant-a")),
5944 ] {
5945 let write = run_query(&dir, "CREATE (n:Doc {id: 'z1'})", role, namespace);
5946 assert!(
5947 write
5948 .as_ref()
5949 .err()
5950 .is_some_and(|e| e.0.contains("read-only")),
5951 "a restricted write must be refused, got {write:?}"
5952 );
5953 assert!(
5954 !GraphDb::open(&dir).expect("reopen").has_node("z1"),
5955 "the write must not have landed"
5956 );
5957 }
5958
5959 let unknown = run_query(&dir, q, Some("nobody"), None);
5960 assert!(unknown.is_err(), "an unknown role is an error");
5961 let invalid = run_query(&dir, q, None, Some("no spaces"));
5962 assert!(
5963 invalid
5964 .as_ref()
5965 .err()
5966 .is_some_and(|e| e.0.contains("valid namespace name")),
5967 "{invalid:?}"
5968 );
5969 let _ = std::fs::remove_dir_all(&dir);
5970 }
5971
5972 #[test]
5974 fn run_asof_in_a_namespace() {
5975 let dir = ns_store("asof-namespace");
5976 let at = {
5977 let db = GraphDb::open(&dir).expect("open");
5978 db.wal_total_commits().expect("commits") - 1
5979 };
5980 {
5981 let mut db = GraphDb::open(&dir).expect("open");
5982 db.insert_node(
5983 "Doc",
5984 "a3",
5985 vec![
5986 ("id".into(), Value::Str("a3".into())),
5987 ("ns".into(), Value::Str("tenant-a".into())),
5988 ],
5989 )
5990 .expect("insert");
5991 }
5992 let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
5993 let then = run_asof(&dir, Some(at), None, Some(q), Some("tenant-a")).expect("asof");
5994 assert!(
5995 then.contains("id=a1") && then.contains("id=a2"),
5996 "got {then}"
5997 );
5998 assert!(
5999 !then.contains("id=a3") && !then.contains("id=b1"),
6000 "a3 did not exist then and b1 is another namespace: {then}"
6001 );
6002 let now = run_asof(&dir, Some(at + 1), None, Some(q), Some("tenant-a")).expect("asof");
6003 assert!(now.contains("id=a3"), "got {now}");
6004 let _ = std::fs::remove_dir_all(&dir);
6005 }
6006
6007 #[test]
6010 fn schema_apply_accepts_v4_roles_with_namespaces() {
6011 let dir = tmp("schema-v4");
6012 {
6013 let mut db = GraphDb::open(&dir).expect("open");
6014 db.insert_node(
6015 "Doc",
6016 "a1",
6017 vec![("ns".into(), Value::Str("tenant-a".into()))],
6018 )
6019 .expect("insert");
6020 }
6021 let file = dir.join("schema.json");
6022 std::fs::write(
6023 &file,
6024 r#"{"roles": [{"name": "a-reader", "labels": ["Doc"], "keys": [],
6025 "namespaces": ["tenant-a"]}]}"#,
6026 )
6027 .expect("write schema");
6028 let out = run_schema_apply(&dir, &file).expect("apply");
6029 assert!(out.contains("a-reader"), "got {out}");
6030 let sidecar = std::fs::read_to_string(dir.join("roles.json")).expect("roles.json");
6031 assert!(
6032 sidecar.contains("\"version\": 4") || sidecar.contains("\"version\":4"),
6033 "a role with namespaces writes version 4: {sidecar}"
6034 );
6035 assert!(sidecar.contains("tenant-a"), "{sidecar}");
6036 let _ = std::fs::remove_dir_all(&dir);
6037 }
6038}