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