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