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