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