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