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