1pub mod doctor;
7pub mod export;
8pub(crate) mod hook;
9pub mod import;
10pub mod ingest_git;
11pub mod install;
12pub mod recall;
13pub mod serve_bolt;
14pub mod structure;
15
16use export::ExportFormat;
17#[cfg(test)]
18use mushroomdb::restore::holds_a_store;
19pub use mushroomdb::restore::RestoreOutcome;
21use mushroomdb::schema::Schema;
22use mushroomdb::{
23 default_max_edges, is_write_query, valid_namespace, AlgoDir, BackupReport, DegreeConfig,
24 Explanation, GraphDb, IngestOptions, LouvainConfig, OpenOptions, PageRankConfig, Predicate,
25 RealFs, ResultSet, RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig,
26 WriteGuard, NS_MAX_LEN,
27};
28use std::collections::BTreeMap;
29use std::fmt::Write as _;
30use std::net::SocketAddr;
31use std::num::NonZeroU64;
32use std::path::{Path, PathBuf};
33use std::time::Duration;
34
35pub const AUTOMATIC_SNAPSHOT: SnapshotOptions = SnapshotOptions {
46 keep_wal: false,
47 archive_wal: true,
48};
49
50pub const AUTO_SNAPSHOT_RETENTION: Option<u32> = None;
80
81pub fn snapshot_automatically(db: &mut WriteGuard<'_>) -> Result<(), mushroomdb::GraphError> {
91 db.set_wal_archive_retention(AUTO_SNAPSHOT_RETENTION);
92 db.snapshot_with(AUTOMATIC_SNAPSHOT)
93}
94
95pub const SNAPSHOT_LOCK_WAIT: Duration = Duration::from_millis(500);
103
104pub fn snapshot_shared(db: &SharedDb) -> Result<(), mushroomdb::GraphError> {
115 snapshot_automatically(&mut db.write_with_wait(SNAPSHOT_LOCK_WAIT)?)
116}
117
118pub const NO_AUTO_SNAPSHOT_ENV: &str = "MUSHROOMDB_NO_AUTO_SNAPSHOT";
126
127pub fn snapshot_if_wal_grew(db: &SharedDb, db_dir: &Path) {
141 if std::env::var_os(NO_AUTO_SNAPSHOT_ENV).is_some_and(|v| !v.is_empty()) {
142 return;
143 }
144 let grown = std::fs::metadata(db_dir.join("wal.bin"))
145 .is_ok_and(|m| m.len() > crate::ingest_git::SNAPSHOT_WAL_BYTES);
146 if !grown {
147 return;
148 }
149 match snapshot_shared(db) {
150 Ok(()) | Err(mushroomdb::GraphError::Busy { .. }) => {}
151 Err(e) => eprintln!("snapshot skipped: {e}"),
152 }
153}
154
155pub const N_ORGS: usize = 10;
157pub const N_PROJECTS: usize = 20;
158pub const N_PEOPLE: usize = 30;
159
160pub const SAMPLE_QUERY: &str = "\
165MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
166RETURN p, proj, r.score AS score
167ORDER BY score DESC, proj";
168
169const SAMPLE_EXPLAIN_A: &str = "person-01";
170const SAMPLE_EXPLAIN_B: &str = "proj-01";
171
172pub const VERSION: &str = env!("CARGO_PKG_VERSION");
174
175#[must_use]
177pub fn version_string() -> String {
178 format!("mushroomdb {VERSION}")
179}
180
181#[must_use]
193pub fn resolve_auto_db(
194 env_project_dir: Option<&std::ffi::OsStr>,
195 cwd: &Path,
196 home: &Path,
197) -> PathBuf {
198 if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
199 return Path::new(dir).join("mushroom-memory");
200 }
201 if let Some(root) = worktree_root(cwd) {
202 return root.join("mushroom-memory");
203 }
204 home.join(".mushroomdb").join("memory")
205}
206
207#[must_use]
221pub fn worktree_root(dir: &Path) -> Option<&Path> {
222 dir.ancestors().find(|d| d.join(".git").exists())
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum ServeUi {
228 Filesystem(PathBuf),
229 Embedded,
230 None,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum AlgoSubcmd {
236 Pagerank,
237 Wcc,
238 Degree,
239 Communities,
240}
241
242#[derive(Debug, Clone, PartialEq)]
246pub enum Command {
247 RetiredHook,
254 Serve {
255 db_dir: PathBuf,
256 addr: SocketAddr,
257 ui: ServeUi,
258 demo_if_empty: bool,
261 token: Option<String>,
263 role_tokens: Vec<(String, String)>,
266 snapshot_every: Option<Duration>,
268 shutdown_timeout: Duration,
272 restore_from: Option<PathBuf>,
275 tls_cert: Option<PathBuf>,
277 tls_key: Option<PathBuf>,
279 bolt: serve_bolt::BoltFlags,
282 tx: TxFlags,
286 },
287 Mcp {
288 db_dir: Option<PathBuf>,
290 auto: bool,
291 all_tools: bool,
296 },
297 Stats {
298 db_dir: PathBuf,
299 },
300 Demo {
301 db_dir: PathBuf,
302 },
303 AsOf {
305 db_dir: PathBuf,
306 commit: Option<u64>,
313 at: Option<String>,
316 query: Option<String>,
318 file: Option<PathBuf>,
320 stdin: bool,
322 namespace: Option<String>,
324 },
325 Suggest {
327 db_dir: PathBuf,
328 },
329 Algo {
331 db_dir: PathBuf,
332 subcmd: AlgoSubcmd,
333 top: usize,
335 dir: AlgoDir,
338 edge_types: Vec<String>,
341 weight_prop: Option<String>,
343 min_weight: Option<f64>,
345 },
346 Query {
348 db_dir: PathBuf,
349 cypher: String,
351 file: Option<PathBuf>,
353 stdin: bool,
355 role: Option<String>,
357 namespace: Option<String>,
359 },
360 Snapshot {
362 db_dir: PathBuf,
363 wal: WalDisposition,
364 retention: Option<u32>,
367 },
368 BuildIndex {
370 db_dir: PathBuf,
371 rule: Option<String>,
373 },
374 SchemaApply {
378 db_dir: PathBuf,
379 schema_file: Option<PathBuf>,
381 memory_defaults: bool,
382 memory_identity: bool,
383 },
384 SchemaList {
387 db_dir: PathBuf,
388 what: SchemaListing,
389 },
390 Migrate {
392 db_dir: PathBuf,
393 },
394 KeysAdd {
396 db_dir: PathBuf,
397 name: String,
398 tier: String,
399 role: Option<String>,
400 },
401 KeysHash,
403 Verify {
405 db_dir: PathBuf,
406 },
407 Backup {
409 db_dir: PathBuf,
410 dest: PathBuf,
411 },
412 RestoreS3 {
415 from: String,
417 at: Option<String>,
420 into: Option<PathBuf>,
422 },
423 Export {
425 db_dir: PathBuf,
426 dest: PathBuf,
427 format: ExportFormat,
428 },
429 Import {
431 db_dir: PathBuf,
432 inputs: import::ImportInputs,
433 opts: import::ImportOptions,
434 },
435 IngestGit {
437 db_dir: PathBuf,
438 opts: ingest_git::IngestGitOpts,
439 },
440 Install(install::InstallOpts),
442 Uninstall(install::InstallOpts),
444 Disable(install::ToggleOpts),
448 Enable(install::ToggleOpts),
451 Doctor(doctor::DoctorOpts),
453 Recall {
456 db_dir: Option<PathBuf>,
457 auto: bool,
458 },
459 Brief {
462 db_dir: Option<PathBuf>,
463 auto: bool,
464 },
465 Why {
467 db_dir: PathBuf,
468 a: String,
469 b: String,
470 },
471 Version,
472 Help,
473}
474
475#[derive(Debug)]
477pub struct DemoOutcome {
478 pub auto_fk_rules: Vec<String>,
479 pub sample_query: String,
480 pub sample_result: ResultSet,
481 pub explanations: Vec<Explanation>,
482 pub stats: Stats,
483 pub suggestion: Option<RuleSuggestion>,
485}
486
487#[derive(Debug)]
489pub struct CliError(pub String);
490
491impl std::fmt::Display for CliError {
492 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493 f.write_str(&self.0)
494 }
495}
496
497impl std::error::Error for CliError {}
498
499impl From<mushroomdb::GraphError> for CliError {
500 fn from(e: mushroomdb::GraphError) -> Self {
501 CliError(e.to_string())
502 }
503}
504
505impl From<std::io::Error> for CliError {
506 fn from(e: std::io::Error) -> Self {
507 CliError(e.to_string())
508 }
509}
510
511pub fn usage() -> &'static str {
513 "\
514mushroomdb — embedded graph database
515
516Usage:
517 mushroomdb query <db-dir> [--query \"MATCH ...\"|--file <path>|--stdin] [--role <name>] [--namespace <ns>] <cypher…>
518 --file reads the statement from a file and --stdin from standard input,
519 so a quote or a backslash needs no shell escaping; a trailing newline
520 and one trailing `;` are dropped, and the statement comes from only one of the three places
521 --role answers as one of the store's roles and --namespace from one
522 namespace; together they intersect, so neither ever widens the other
523 and either one makes the query a read
524 mushroomdb asof <db-dir> --commit N|--at <date> [--query \"MATCH ...\"|--file <path>|--stdin] [--namespace <ns>]
525 --at takes an RFC 3339 date (2026-06-19, or 2026-06-19T12:00:00Z)
526 and resolves to the last commit at or before it; exactly one of
527 --commit and --at
528 mushroomdb stats <db-dir>
529 mushroomdb why <db-dir> <a> <b>
530 every rule edge between two keys with the evidence that
531 derived it, or a note that there is none
532 mushroomdb suggest <db-dir>
533 mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
534 mushroomdb algo wcc <db-dir> [--top N]
535 mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
536 mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
537 mushroomdb verify <db-dir> validate CRC32 integrity of every snapshot section
538 mushroomdb snapshot <db-dir> [--keep-wal|--truncate] [--retention N]
539 folds the WAL into snapshot.bin and archives it as wal.<N>.archive,
540 so node_history, edge_history, was_linked and asof keep reaching it;
541 --truncate discards it instead, --keep-wal leaves wal.bin whole
542 mushroomdb keys add <db-dir> <name> --tier admin|write|read [--role <name>]
543 issues an access key: prints a new random token once and stores only its
544 blake3 hash in <db-dir>/keys.json (created private, mode 0600); --tier read
545 needs --role, and reads that role's view without ever writing
546 mushroomdb keys hash
547 reads a token (at least 32 bytes) from stdin and prints the hash keys.json holds
548 mushroomdb build-index <db-dir> [--rule <name>]
549 drives a rule's vector index to completion a slice at a time, for an
550 operator who wants the build finished before traffic arrives; a rule
551 created over a large corpus derives no edges until its index is whole
552 mushroomdb migrate <db-dir>
553 mushroomdb schema indexes <db-dir>
554 mushroomdb schema constraints <db-dir>
555 mushroomdb schema apply <db-dir> <schema.json>
556 mushroomdb schema apply <db-dir> --memory-defaults
557 applies the built-in memory schema to an existing store;
558 full-text on a populated store builds the index at each open until the next snapshot carries it
559 mushroomdb schema apply <db-dir> --memory-identity
560 adds the identity preset: sixteen SAME_AS rules over each entity's aliases;
561 says what it will backfill, then backfills
562 mushroomdb backup <db-dir> <dest> process-local consistent copy of the database to <dest>
563 WARNING: unsafe against a concurrently running serve process;
564 use POST /backup on the HTTP server for live-serve backups
565 mushroomdb restore --from s3://bucket/prefix [--at <commit | timestamp>] [--into <dir>]
566 restores a continuous backup (MUSHROOMDB_BACKUP_S3_URL) into <dir>, default ./restored,
567 which must be new or empty; every object is checked against its manifest first
568 --at N restores the store as it was right after commit N (as asof --commit counts);
569 --at 2026-10-01T12:00:00Z the last commit at or before that instant, from the
570 backup's commit times; an instant past the newest recorded time is refused
571 when later commits have none
572 the restored store starts a new backup lineage: back it up to a new prefix
573 r2:// and minio:// need AWS_ENDPOINT_URL; credentials come from the AWS_* variables
574 mushroomdb export <db-dir> <dest> --format jsonl|csv|cypher|parquet|graphml [--single-file] export all data
575 --single-file (jsonl only) writes one graph.jsonl of typed node, edge and rule lines
576 csv writes nodes.csv and relationships.csv (plus rules.jsonl when the store has rules);
577 cypher writes graph.cypher, one statement per line; both load back with `mushroomdb import` / `mushroomdb query`
578 graphml writes one file: <dest>/graph.graphml if <dest> is an
579 existing directory, otherwise <dest> is the file path itself
580 (nodes + edges only; rules have no GraphML analogue)
581 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>] [--shutdown-timeout-secs <secs>] [--restore-from <dir>] [--tls-cert <pem> --tls-key <pem>]
582 [--bolt-port <port>] [--bolt-tls | --bolt-plaintext] [--bolt-advertised-host <host>] [--bolt-advertised-port <port>]
583 [--bolt-database <name>] [--bolt-max-connections <n>] [--bolt-tx-timeout-secs <secs>] [--bookmark-timeout-secs <secs>]
584 [--tx-max <n>] [--tx-timeout-secs <secs>] [--tx-max-lifetime-secs <secs>]
585 --restore-from seeds an empty <db-dir> from the newest backup under <dir>
586 (or from <dir> itself if it is one); a no-op when <db-dir> already holds a store
587 MUSHROOMDB_RESTORE_FROM_S3=s3://bucket/prefix restores the newest backup there into an
588 empty <db-dir> before the port is bound, as a new lineage that keeps backing up beside
589 it; a prefix with no backup starts an empty store, any other failure exits 1
590 --tls-cert and --tls-key go together and need a build with the `tls` feature
591 --bolt-port (or MUSHROOMDB_BOLT_PORT) also serves Bolt on --addr's IP; off when unset
592 Bolt is TLS when --tls-cert is given, unless --bolt-plaintext (MUSHROOMDB_BOLT_TLS=on|off)
593 --bolt-advertised-host/-port (MUSHROOMDB_BOLT_ADVERTISED_HOST/_PORT) are the address ROUTE
594 hands drivers; a wildcard --addr needs the host. --bolt-max-connections (default 256,
595 MUSHROOMDB_BOLT_MAX_CONNECTIONS); --bolt-database names the store (default mushroomdb);
596 --bolt-tx-timeout-secs (default 30) ends an idle transaction; --bookmark-timeout-secs
597 (default 5) bounds a bookmark wait
598 --tx-max (default 100) open HTTP transactions at once; --tx-timeout-secs (default 30)
599 ends an idle one and --tx-max-lifetime-secs (default 300) any one; each flag wins over
600 MUSHROOMDB_TX_MAX, MUSHROOMDB_TX_TIMEOUT_SECS and MUSHROOMDB_TX_MAX_LIFETIME_SECS
601 mushroomdb import [--nodes <file>] [--relationships <file>] [--db <dir>] [--delimiter <c>]
602 [--nodes-jsonl <file|->] [--edges-jsonl <file|->] [--rules-jsonl <file|->] [--jsonl <file|->]
603 [--array-delimiter <c>] [--ignore-errors] [--batch-size N]
604 bulk-load CSV or JSONL files into <dir> (default ./mushroomdb-data), then write one snapshot
605 the JSONL files are the ones `export --format jsonl` writes; --jsonl reads the one file
606 `export --format jsonl --single-file` writes (typed node, edge and rule lines, nodes before the edges that use them);
607 `-` reads one of them from standard input
608 nodes need an :ID and a :LABEL column, relationships :START_ID, :END_ID and :TYPE;
609 other columns are properties, typed by a suffix: age:int weight:float active:boolean tags:string[]
610 one label per node: Person;Employee is refused; relationship properties are not supported
611 rules the store already has fire per batch; import first, then declare rules, to backfill once
612 an error stops the import at its file and line, batches already committed stay; --ignore-errors skips bad rows
613 mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
614 [--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
615 graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
616 --recurse-submodules also walks each initialised submodule
617 --prs links merged pull requests via gh (skipped when gh is unavailable)
618 --no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
619 --no-docs skips Markdown bodies, headings and mentions
620 --ensure-gitignore adds the database directory to the repo's .gitignore
621 with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
622 mushroomdb demo <db-dir>
623 mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
624 [--command <path>] [--no-prewarm] [--delivery cli|mcp|both]
625 [--always-load|--no-always-load]
626 --delivery cli writes the skill and the hooks and registers no MCP
627 server: the skill teaches `mushroomdb <command>` instead (claude-code
628 only; cursor and codex are always registered as MCP servers)
629 --always-load marks the registered MCP server alwaysLoad, so the host
630 keeps its tools in context instead of deferring them; already the
631 default when --db names a store and a server is registered
632 (--delivery mcp|both) — --no-always-load opts out
633 mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
634 mushroomdb disable [--platform claude-code|cursor|codex|all] [--project|--user]
635 turn an install off without removing it: hooks and MCP entry are
636 removed; the skill, the store and .gitignore stay
637 mushroomdb enable [--platform claude-code|cursor|codex|all] [--project|--user]
638 turn a disabled install back on
639 mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
640 verify an install: config entry, store, hooks, and a real stdio
641 handshake with the configured MCP command; exits 1 on any `fail`
642 mushroomdb mcp <db-dir>|--auto [--all-tools]
643 --all-tools lists every served tool; the default lists the association tools on every store,
644 a store `ingest-git` built included (the rest stay callable, just unlisted)
645 mushroomdb recall <db-dir>|--auto hook body: reads a prompt payload on stdin, prints related graph facts
646 mushroomdb brief <db-dir>|--auto hook body: the store's schema in one block — labels,
647 edge types, history depth, roles and one worked call per
648 question kind; byte-stable, so a session host caches it once
649 mushroomdb --version
650 mushroomdb --help
651
652Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token, MUSHROOMDB_TOKEN, or an `admin` key in `<db-dir>/keys.json` (read and write keys alone do not count).
653install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
654the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
655--command names one (a relative --command or --db is anchored to the current directory).
656--no-prewarm means no network and no resolution: neither the one-off package fetch nor locating the
657package's binary, so every hook keeps the slower `npx` form.
658install and uninstall take out the code-graph hooks and git hook blocks a 0.6 install wrote.
659uninstall resolves the same scope and falls back to the other one when the inferred scope has no
660manifest; undoing a Codex install needs --platform codex.
661A project install inside a git checkout writes --auto rather than a store path, so each `git
662worktree` gets its own store; outside a checkout, and with --db, the store is pinned to an absolute
663path instead.
664--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else mushroom-memory at the
665root of the working tree the current directory is in, else ~/.mushroomdb/memory.
666"
667}
668
669fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
670 let mut platform: Option<install::Platform> = None;
671 let mut scope: Option<install::Scope> = None;
672 let mut db: Option<PathBuf> = None;
673 let mut command: Option<PathBuf> = None;
674 let mut prewarm = true;
675 let mut delivery = install::Delivery::default();
676 let mut always_load: Option<bool> = None;
679 let mut i = 0;
680 while i < args.len() {
681 let a = args[i];
682 if a == "--delivery" {
683 let val = args
684 .get(i + 1)
685 .copied()
686 .ok_or_else(|| "missing value for --delivery".to_string())?;
687 delivery = install::Delivery::parse(val)?;
688 i += 2;
689 } else if let Some(val) = a.strip_prefix("--delivery=") {
690 delivery = install::Delivery::parse(val)?;
691 i += 1;
692 } else if a == "--platform" {
693 let val = args
694 .get(i + 1)
695 .copied()
696 .ok_or_else(|| "missing value for --platform".to_string())?;
697 platform = Some(install::Platform::parse(val)?);
698 i += 2;
699 } else if let Some(val) = a.strip_prefix("--platform=") {
700 platform = Some(install::Platform::parse(val)?);
701 i += 1;
702 } else if a == "--project" || a == "--user" {
703 let want = if a == "--project" {
704 install::Scope::Project
705 } else {
706 install::Scope::User
707 };
708 if scope.is_some_and(|s| s != want) {
711 return Err("--project and --user are mutually exclusive".to_string());
712 }
713 scope = Some(want);
714 i += 1;
715 } else if a == "--no-git-hooks" {
716 i += 1;
720 } else if a == "--intercept-grep" || a == "--impact-before-edit" || a == "--enrich-grep" {
721 return Err(format!(
722 "{a} was removed in 0.7 along with the code graph it fed. \
723 Nothing replaces it. Pin `mushroomdb@0.6.x` to keep it."
724 ));
725 } else if a == "--always-load" {
726 always_load = Some(true);
727 i += 1;
728 } else if a == "--no-always-load" {
729 always_load = Some(false);
730 i += 1;
731 } else if a == "--no-prewarm" {
732 prewarm = false;
733 i += 1;
734 } else if a == "--command" {
735 let val = args
736 .get(i + 1)
737 .copied()
738 .ok_or_else(|| "missing value for --command".to_string())?;
739 command = Some(PathBuf::from(val));
740 i += 2;
741 } else if let Some(val) = a.strip_prefix("--command=") {
742 command = Some(PathBuf::from(val));
743 i += 1;
744 } else if a == "--db" {
745 let val = args
746 .get(i + 1)
747 .copied()
748 .ok_or_else(|| "missing value for --db".to_string())?;
749 db = Some(PathBuf::from(val));
750 i += 2;
751 } else if let Some(val) = a.strip_prefix("--db=") {
752 db = Some(PathBuf::from(val));
753 i += 1;
754 } else if a.starts_with('-') {
755 return Err(format!("unexpected flag: {a}"));
756 } else {
757 return Err(format!("unexpected argument: {a}"));
758 }
759 }
760 let always_load =
772 always_load.unwrap_or(db.is_some() && !matches!(delivery, install::Delivery::Cli));
773 Ok(install::InstallOpts {
774 platform,
775 scope,
776 db,
777 command,
778 prewarm,
779 delivery,
780 always_load,
781 })
782}
783
784fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
785 let mut platform: Option<install::Platform> = None;
786 let mut scope: Option<install::Scope> = None;
787 let mut i = 0;
788 while i < args.len() {
789 let a = args[i];
790 if a == "--platform" {
791 let val = args
792 .get(i + 1)
793 .copied()
794 .ok_or_else(|| "missing value for --platform".to_string())?;
795 platform = Some(install::Platform::parse(val)?);
796 i += 2;
797 } else if let Some(val) = a.strip_prefix("--platform=") {
798 platform = Some(install::Platform::parse(val)?);
799 i += 1;
800 } else if a == "--project" || a == "--user" {
801 let want = if a == "--project" {
802 install::Scope::Project
803 } else {
804 install::Scope::User
805 };
806 if scope.is_some_and(|s| s != want) {
807 return Err("--project and --user are mutually exclusive".to_string());
808 }
809 scope = Some(want);
810 i += 1;
811 } else if a.starts_with('-') {
812 return Err(format!("unexpected flag: {a}"));
813 } else {
814 return Err(format!("unexpected argument: {a}"));
815 }
816 }
817 Ok(doctor::DoctorOpts { platform, scope })
818}
819
820fn parse_toggle_cmd(args: &[&str]) -> Result<install::ToggleOpts, String> {
825 let mut platform: Option<install::Platform> = None;
826 let mut scope: Option<install::Scope> = None;
827 let mut i = 0;
828 while i < args.len() {
829 let a = args[i];
830 if a == "--platform" {
831 let val = args
832 .get(i + 1)
833 .copied()
834 .ok_or_else(|| "missing value for --platform".to_string())?;
835 platform = Some(install::Platform::parse(val)?);
836 i += 2;
837 } else if let Some(val) = a.strip_prefix("--platform=") {
838 platform = Some(install::Platform::parse(val)?);
839 i += 1;
840 } else if a == "--project" || a == "--user" {
841 let want = if a == "--project" {
842 install::Scope::Project
843 } else {
844 install::Scope::User
845 };
846 if scope.is_some_and(|s| s != want) {
847 return Err("--project and --user are mutually exclusive".to_string());
848 }
849 scope = Some(want);
850 i += 1;
851 } else if a.starts_with('-') {
852 return Err(format!("unexpected flag: {a}"));
853 } else {
854 return Err(format!("unexpected argument: {a}"));
855 }
856 }
857 Ok(install::ToggleOpts { platform, scope })
858}
859
860fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
861 let mut positional = Vec::new();
862 let mut exclude = Vec::new();
863 let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
864 let mut recurse_submodules = false;
865 let mut prs = false;
866 let mut structure = true;
867 let mut docs = true;
868 let mut ensure_gitignore = false;
869 let mut i = 0;
870 while i < args.len() {
871 let a = args[i];
872 if a == "--recurse-submodules" {
873 recurse_submodules = true;
874 i += 1;
875 } else if a == "--prs" {
876 prs = true;
877 i += 1;
878 } else if a == "--no-structure" {
879 structure = false;
880 i += 1;
881 } else if a == "--no-docs" {
882 docs = false;
883 i += 1;
884 } else if a == "--ensure-gitignore" {
885 ensure_gitignore = true;
886 i += 1;
887 } else if a == "--exclude" {
888 exclude.push(
889 args.get(i + 1)
890 .copied()
891 .ok_or_else(|| "missing value for --exclude".to_string())?
892 .to_string(),
893 );
894 i += 2;
895 } else if let Some(val) = a.strip_prefix("--exclude=") {
896 exclude.push(val.to_string());
897 i += 1;
898 } else if a == "--max-commits-per-file" {
899 let val = args
900 .get(i + 1)
901 .copied()
902 .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
903 max_commits_per_file = val
904 .parse()
905 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
906 i += 2;
907 } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
908 max_commits_per_file = val
909 .parse()
910 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
911 i += 1;
912 } else if a.starts_with('-') {
913 return Err(format!("unexpected flag: {a}"));
914 } else {
915 positional.push(a);
916 i += 1;
917 }
918 }
919 let [db_dir, repo] = positional.as_slice() else {
920 return Err("ingest-git requires <db-dir> <repo-dir>".into());
921 };
922 if exclude.is_empty() {
926 exclude = ingest_git::DEFAULT_EXCLUDES
927 .iter()
928 .map(|p| (*p).to_string())
929 .collect();
930 }
931 Ok(Command::IngestGit {
932 db_dir: PathBuf::from(db_dir),
933 opts: ingest_git::IngestGitOpts {
934 repo: PathBuf::from(repo),
935 exclude,
936 max_commits_per_file,
937 recurse_submodules,
938 prs,
939 structure,
940 docs,
941 ensure_gitignore,
942 },
943 })
944}
945
946pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
948 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
949 if args.is_empty() {
950 return Ok(Command::Help);
951 }
952 match args[0] {
953 "--help" | "-h" | "help" => Ok(Command::Help),
954 "--version" | "-V" | "version" => Ok(Command::Version),
955 "serve" => parse_serve(&args[1..]),
956 "mcp" => parse_mcp(&args[1..]),
957 "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
958 "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
959 "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
960 "asof" => parse_asof(&args[1..]),
961 "algo" => parse_algo(&args[1..]),
962 "query" => parse_query(&args[1..]),
963 "snapshot" => parse_snapshot(&args[1..]),
964 "build-index" => parse_build_index(&args[1..]),
965 "schema" => parse_schema(&args[1..]),
966 "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
967 "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
968 "keys" => parse_keys(&args[1..]),
969 "backup" => parse_backup(&args[1..]),
970 "restore" => parse_restore(&args[1..]),
971 "export" => parse_export(&args[1..]),
972 "import" => parse_import(&args[1..]),
973 "recall" => parse_dir_or_auto("recall", &args[1..])
974 .map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
975 "brief" => parse_dir_or_auto("brief", &args[1..])
976 .map(|(db_dir, auto)| Command::Brief { db_dir, auto }),
977 "why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
978 db_dir,
979 a: rest[0].clone(),
980 b: rest[1].clone(),
981 }),
982 "ingest-git" => parse_ingest_git(&args[1..]),
983 "install" => parse_install_cmd(&args[1..]).map(Command::Install),
984 "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
985 "disable" => parse_toggle_cmd(&args[1..]).map(Command::Disable),
986 "enable" => parse_toggle_cmd(&args[1..]).map(Command::Enable),
987 "doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
988 "touch" | "intercept" | "impact-hook" | "enrich" | "sync" => Ok(Command::RetiredHook),
991 other => Err(format!("unknown command: {other}")),
992 }
993}
994
995fn default_addr() -> SocketAddr {
996 SocketAddr::from(([127, 0, 0, 1], 8080))
997}
998
999fn parse_serve(args: &[&str]) -> Result<Command, String> {
1000 let mut db_dir = None;
1001 let mut addr = default_addr();
1002 let mut ui = ServeUi::Embedded;
1003 let mut saw_ui = false;
1004 let mut saw_no_ui = false;
1005 let mut demo_if_empty = false;
1006 let mut token = None;
1007 let mut role_tokens: Vec<(String, String)> = Vec::new();
1008 let mut snapshot_every = None;
1009 let mut shutdown_timeout = Duration::from_secs(DEFAULT_SHUTDOWN_TIMEOUT_SECS);
1010 let mut restore_from: Option<PathBuf> = None;
1011 let mut tls_cert: Option<PathBuf> = None;
1012 let mut tls_key: Option<PathBuf> = None;
1013 let mut bolt = serve_bolt::BoltFlags::default();
1014 let mut tx = TxFlags::default();
1015 let mut i = 0;
1016 while i < args.len() {
1017 let a = args[i];
1018 if a == "--addr" {
1019 let val = args
1020 .get(i + 1)
1021 .copied()
1022 .ok_or_else(|| "missing value for --addr".to_string())?;
1023 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
1024 i += 2;
1025 } else if let Some(val) = a.strip_prefix("--addr=") {
1026 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
1027 i += 1;
1028 } else if a == "--ui" {
1029 let val = args
1030 .get(i + 1)
1031 .copied()
1032 .ok_or_else(|| "missing value for --ui".to_string())?;
1033 ui = ServeUi::Filesystem(PathBuf::from(val));
1034 saw_ui = true;
1035 i += 2;
1036 } else if let Some(val) = a.strip_prefix("--ui=") {
1037 ui = ServeUi::Filesystem(PathBuf::from(val));
1038 saw_ui = true;
1039 i += 1;
1040 } else if a == "--no-ui" {
1041 ui = ServeUi::None;
1042 saw_no_ui = true;
1043 i += 1;
1044 } else if a == "--demo-if-empty" {
1045 demo_if_empty = true;
1046 i += 1;
1047 } else if a == "--token" {
1048 let val = args
1049 .get(i + 1)
1050 .copied()
1051 .ok_or_else(|| "missing value for --token".to_string())?;
1052 token = Some(val.to_string());
1053 i += 2;
1054 } else if let Some(val) = a.strip_prefix("--token=") {
1055 token = Some(val.to_string());
1056 i += 1;
1057 } else if a == "--role-token" {
1058 let val = args
1059 .get(i + 1)
1060 .copied()
1061 .ok_or_else(|| "missing value for --role-token".to_string())?;
1062 let (tok, role) = parse_role_token(val)?;
1063 role_tokens.push((tok, role));
1064 i += 2;
1065 } else if let Some(val) = a.strip_prefix("--role-token=") {
1066 let (tok, role) = parse_role_token(val)?;
1067 role_tokens.push((tok, role));
1068 i += 1;
1069 } else if a == "--snapshot-every" {
1070 let val = args
1071 .get(i + 1)
1072 .copied()
1073 .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
1074 snapshot_every = Some(parse_snapshot_every(val)?);
1075 i += 2;
1076 } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
1077 snapshot_every = Some(parse_snapshot_every(val)?);
1078 i += 1;
1079 } else if a == "--shutdown-timeout-secs" {
1080 let val = args
1081 .get(i + 1)
1082 .copied()
1083 .ok_or_else(|| "missing value for --shutdown-timeout-secs".to_string())?;
1084 shutdown_timeout = parse_shutdown_timeout(val)?;
1085 i += 2;
1086 } else if let Some(val) = a.strip_prefix("--shutdown-timeout-secs=") {
1087 shutdown_timeout = parse_shutdown_timeout(val)?;
1088 i += 1;
1089 } else if a == "--restore-from" {
1090 let val = args
1091 .get(i + 1)
1092 .copied()
1093 .ok_or_else(|| "missing value for --restore-from".to_string())?;
1094 restore_from = Some(PathBuf::from(val));
1095 i += 2;
1096 } else if let Some(val) = a.strip_prefix("--restore-from=") {
1097 restore_from = Some(PathBuf::from(val));
1098 i += 1;
1099 } else if a == "--tls-cert" {
1100 let val = args
1101 .get(i + 1)
1102 .copied()
1103 .ok_or_else(|| "missing value for --tls-cert".to_string())?;
1104 tls_cert = Some(PathBuf::from(val));
1105 i += 2;
1106 } else if let Some(val) = a.strip_prefix("--tls-cert=") {
1107 tls_cert = Some(PathBuf::from(val));
1108 i += 1;
1109 } else if a == "--tls-key" {
1110 let val = args
1111 .get(i + 1)
1112 .copied()
1113 .ok_or_else(|| "missing value for --tls-key".to_string())?;
1114 tls_key = Some(PathBuf::from(val));
1115 i += 2;
1116 } else if let Some(val) = a.strip_prefix("--tls-key=") {
1117 tls_key = Some(PathBuf::from(val));
1118 i += 1;
1119 } else if let Some(used) = parse_bolt_flag(args, i, &mut bolt)? {
1120 i += used;
1121 } else if let Some(used) = parse_tx_flag(args, i, &mut tx)? {
1122 i += used;
1123 } else if a.starts_with('-') {
1124 return Err(format!("unexpected flag: {a}"));
1125 } else if db_dir.is_none() {
1126 db_dir = Some(PathBuf::from(a));
1127 i += 1;
1128 } else {
1129 return Err(format!("unexpected extra argument: {a}"));
1130 }
1131 }
1132 if saw_ui && saw_no_ui {
1133 return Err("cannot combine --ui and --no-ui".to_string());
1134 }
1135 match (&tls_cert, &tls_key) {
1136 (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
1137 (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
1138 _ => {}
1139 }
1140 let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
1141 Ok(Command::Serve {
1142 db_dir,
1143 addr,
1144 ui,
1145 demo_if_empty,
1146 token,
1147 role_tokens,
1148 snapshot_every,
1149 shutdown_timeout,
1150 restore_from,
1151 tls_cert,
1152 tls_key,
1153 bolt,
1154 tx,
1155 })
1156}
1157
1158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1162pub struct TxFlags {
1163 pub max_open: Option<NonZeroU64>,
1165 pub idle_timeout_secs: Option<NonZeroU64>,
1167 pub max_lifetime_secs: Option<NonZeroU64>,
1169}
1170
1171pub fn resolve_tx_limits(
1175 flags: &TxFlags,
1176 env: &dyn Fn(&str) -> Option<String>,
1177) -> server::TxLimits {
1178 let mut limits = server::TxLimits::from_lookup(|name| match name {
1179 server::TX_MAX_ENV if flags.max_open.is_some() => None,
1182 server::TX_TIMEOUT_ENV if flags.idle_timeout_secs.is_some() => None,
1183 server::TX_MAX_LIFETIME_ENV if flags.max_lifetime_secs.is_some() => None,
1184 _ => env(name),
1185 });
1186 if let Some(n) = flags.max_open {
1187 limits.max_open = usize::try_from(n.get()).unwrap_or(usize::MAX);
1188 }
1189 if let Some(secs) = flags.idle_timeout_secs {
1190 limits.idle_timeout = Duration::from_secs(secs.get());
1191 }
1192 if let Some(secs) = flags.max_lifetime_secs {
1193 limits.max_lifetime = Duration::from_secs(secs.get());
1194 }
1195 limits
1196}
1197
1198fn parse_tx_flag(args: &[&str], i: usize, tx: &mut TxFlags) -> Result<Option<usize>, String> {
1202 const VALUED: [&str; 3] = ["--tx-max", "--tx-timeout-secs", "--tx-max-lifetime-secs"];
1203 let arg = args[i];
1204 let (flag, value, used) = match arg.split_once('=') {
1205 Some((flag, value)) if VALUED.contains(&flag) => (flag, value, 1),
1206 _ if VALUED.contains(&arg) => {
1207 let value = args
1208 .get(i + 1)
1209 .copied()
1210 .ok_or_else(|| format!("missing value for {arg}"))?;
1211 (arg, value, 2)
1212 }
1213 _ => return Ok(None),
1214 };
1215 let n = value
1216 .parse::<NonZeroU64>()
1217 .map_err(|_| format!("{flag} must be a positive integer, got: {value}"))?;
1218 match flag {
1219 "--tx-max" => tx.max_open = Some(n),
1220 "--tx-timeout-secs" => tx.idle_timeout_secs = Some(n),
1221 _ => tx.max_lifetime_secs = Some(n),
1222 }
1223 Ok(Some(used))
1224}
1225
1226fn parse_bolt_flag(
1230 args: &[&str],
1231 i: usize,
1232 bolt: &mut serve_bolt::BoltFlags,
1233) -> Result<Option<usize>, String> {
1234 let arg = args[i];
1235 match arg {
1236 "--bolt-tls" | "--bolt-plaintext" => {
1237 let tls = arg == "--bolt-tls";
1238 if bolt.tls == Some(!tls) {
1239 return Err("cannot combine --bolt-tls and --bolt-plaintext".into());
1240 }
1241 bolt.tls = Some(tls);
1242 return Ok(Some(1));
1243 }
1244 _ => {}
1245 }
1246 const VALUED: [&str; 7] = [
1247 "--bolt-port",
1248 "--bolt-advertised-host",
1249 "--bolt-advertised-port",
1250 "--bolt-database",
1251 "--bolt-max-connections",
1252 "--bolt-tx-timeout-secs",
1253 "--bookmark-timeout-secs",
1254 ];
1255 let (flag, value, used) = match arg.split_once('=') {
1256 Some((flag, value)) if VALUED.contains(&flag) => (flag, value, 1),
1257 _ if VALUED.contains(&arg) => {
1258 let value = args
1259 .get(i + 1)
1260 .copied()
1261 .ok_or_else(|| format!("missing value for {arg}"))?;
1262 (arg, value, 2)
1263 }
1264 _ => return Ok(None),
1265 };
1266 let invalid = || format!("invalid {flag}: {value}");
1267 let port = || value.parse::<u16>().map_err(|_| invalid());
1268 let seconds = || match value.parse::<u64>() {
1269 Ok(0) | Err(_) => Err(format!("{flag} must be a positive number of seconds")),
1270 Ok(secs) => Ok(Duration::from_secs(secs)),
1271 };
1272 match flag {
1273 "--bolt-port" => bolt.port = Some(port()?),
1274 "--bolt-advertised-host" => bolt.advertised_host = Some(value.to_string()),
1275 "--bolt-advertised-port" => bolt.advertised_port = Some(port()?),
1276 "--bolt-database" => bolt.database = Some(value.to_string()),
1277 "--bolt-max-connections" => {
1278 bolt.max_connections = Some(value.parse().map_err(|_| invalid())?)
1279 }
1280 "--bolt-tx-timeout-secs" => bolt.tx_timeout = Some(seconds()?),
1281 _ => bolt.bookmark_timeout = Some(seconds()?),
1282 }
1283 Ok(Some(used))
1284}
1285
1286fn parse_role_token(val: &str) -> Result<(String, String), String> {
1287 let (tok, role) = val
1288 .split_once(':')
1289 .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
1290 if tok.is_empty() {
1291 return Err("--role-token: TOKEN must not be empty".to_string());
1292 }
1293 if role.is_empty() {
1294 return Err("--role-token: ROLE must not be empty".to_string());
1295 }
1296 Ok((tok.to_string(), role.to_string()))
1297}
1298
1299pub const DEFAULT_SHUTDOWN_TIMEOUT_SECS: u64 = 55;
1303pub const SHUTDOWN_DRAIN_SECS: u64 = 25;
1305const SHUTDOWN_SNAPSHOT_NOMINAL: Duration = Duration::from_secs(8);
1308const SHUTDOWN_BACKUP_FLOOR: Duration = Duration::from_secs(2);
1311
1312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1315pub struct ShutdownPlan {
1316 pub drain: Duration,
1318 pub snapshot: Duration,
1320 pub backup: Duration,
1322}
1323
1324pub fn shutdown_plan(total: Duration, backup_bound: Duration) -> ShutdownPlan {
1333 let cap = Duration::from_secs(SHUTDOWN_DRAIN_SECS);
1334 let nominal = cap + SHUTDOWN_SNAPSHOT_NOMINAL + backup_bound;
1335 if total >= nominal {
1336 return ShutdownPlan {
1337 drain: cap,
1338 snapshot: total - cap - backup_bound,
1339 backup: backup_bound,
1340 };
1341 }
1342 let scaled = |d: Duration| d.mul_f64(total.as_secs_f64() / nominal.as_secs_f64());
1343 let backup = if backup_bound.is_zero() {
1344 Duration::ZERO
1345 } else {
1346 scaled(backup_bound)
1347 .max(SHUTDOWN_BACKUP_FLOOR.min(backup_bound))
1348 .min(total)
1349 };
1350 let drain = scaled(cap).min(total - backup);
1351 ShutdownPlan {
1352 drain,
1353 snapshot: total - backup - drain,
1354 backup,
1355 }
1356}
1357
1358fn parse_shutdown_timeout(val: &str) -> Result<Duration, String> {
1359 let secs: u64 = val
1360 .parse()
1361 .map_err(|_| format!("invalid --shutdown-timeout-secs: {val}"))?;
1362 if secs == 0 {
1363 return Err("--shutdown-timeout-secs must be a positive number of seconds".into());
1364 }
1365 Ok(Duration::from_secs(secs))
1366}
1367
1368fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
1369 let secs: u64 = val
1370 .parse()
1371 .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
1372 if secs == 0 {
1373 return Err("--snapshot-every must be a positive number of seconds".into());
1374 }
1375 Ok(Duration::from_secs(secs))
1376}
1377
1378pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
1380 if !dir.is_dir() {
1381 return Err(format!("--ui directory does not exist: {}", dir.display()));
1382 }
1383 let index = dir.join("index.html");
1384 if !index.is_file() {
1385 return Err(format!(
1386 "--ui directory is missing index.html: {}",
1387 dir.display()
1388 ));
1389 }
1390 Ok(dir.to_path_buf())
1391}
1392
1393fn parse_asof(args: &[&str]) -> Result<Command, String> {
1394 let mut db_dir = None;
1395 let mut commit: Option<u64> = None;
1396 let mut at: Option<String> = None;
1397 let mut query: Option<String> = None;
1398 let mut file: Option<PathBuf> = None;
1399 let mut stdin = false;
1400 let mut namespace: Option<String> = None;
1401 let mut i = 0;
1402 while i < args.len() {
1403 let a = args[i];
1404 if a == "--file" {
1405 let val = args
1406 .get(i + 1)
1407 .copied()
1408 .ok_or_else(|| "missing value for --file".to_string())?;
1409 file = Some(PathBuf::from(val));
1410 i += 2;
1411 } else if let Some(val) = a.strip_prefix("--file=") {
1412 file = Some(PathBuf::from(val));
1413 i += 1;
1414 } else if a == "--stdin" {
1415 stdin = true;
1416 i += 1;
1417 } else if a == "--at" {
1418 let val = args
1419 .get(i + 1)
1420 .copied()
1421 .ok_or_else(|| "missing value for --at".to_string())?;
1422 at = Some(val.to_string());
1423 i += 2;
1424 } else if let Some(val) = a.strip_prefix("--at=") {
1425 at = Some(val.to_string());
1426 i += 1;
1427 } else if a == "--commit" {
1428 let val = args
1429 .get(i + 1)
1430 .copied()
1431 .ok_or_else(|| "missing value for --commit".to_string())?;
1432 commit = Some(
1433 val.parse()
1434 .map_err(|_| format!("invalid commit index: {val}"))?,
1435 );
1436 i += 2;
1437 } else if let Some(val) = a.strip_prefix("--commit=") {
1438 commit = Some(
1439 val.parse()
1440 .map_err(|_| format!("invalid commit index: {val}"))?,
1441 );
1442 i += 1;
1443 } else if a == "--query" {
1444 let val = args
1445 .get(i + 1)
1446 .copied()
1447 .ok_or_else(|| "missing value for --query".to_string())?;
1448 query = Some(val.to_string());
1449 i += 2;
1450 } else if let Some(val) = a.strip_prefix("--query=") {
1451 query = Some(val.to_string());
1452 i += 1;
1453 } else if a == "--namespace" {
1454 let val = args
1455 .get(i + 1)
1456 .copied()
1457 .ok_or_else(|| "missing value for --namespace".to_string())?;
1458 namespace = Some(val.to_string());
1459 i += 2;
1460 } else if let Some(val) = a.strip_prefix("--namespace=") {
1461 namespace = Some(val.to_string());
1462 i += 1;
1463 } else if a.starts_with('-') {
1464 return Err(format!("unexpected flag: {a}"));
1465 } else if db_dir.is_none() {
1466 db_dir = Some(PathBuf::from(a));
1467 i += 1;
1468 } else {
1469 return Err(format!("unexpected extra argument: {a}"));
1470 }
1471 }
1472 let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
1473 match (commit.is_some(), at.is_some()) {
1474 (false, false) => {
1475 return Err("asof requires --commit N or --at <date>".to_string());
1476 }
1477 (true, true) => {
1478 return Err(
1479 "asof takes --commit or --at, not both: a precedence rule between a \
1480 commit index and a date is how a caller ends up believing it asked \
1481 for one and got the other"
1482 .to_string(),
1483 );
1484 }
1485 _ => {}
1486 }
1487 check_statement_sources("asof", query.is_some(), file.is_some(), stdin)?;
1488 Ok(Command::AsOf {
1489 db_dir,
1490 commit,
1491 at,
1492 query,
1493 file,
1494 stdin,
1495 namespace,
1496 })
1497}
1498
1499pub fn run_asof(
1505 db_dir: &Path,
1506 commit: Option<u64>,
1507 at: Option<&str>,
1508 query: Option<&str>,
1509 namespace: Option<&str>,
1510) -> Result<String, CliError> {
1511 let namespace = check_namespace(namespace)?;
1512 let commit = match (commit, at) {
1516 (Some(c), _) => c,
1517 (None, Some(date)) => GraphDb::open(db_dir)?.resolve_date(date)?,
1518 (None, None) => {
1519 return Err(CliError(
1520 "asof requires --commit N or --at <date>".to_string(),
1521 ))
1522 }
1523 };
1524 let db = GraphDb::open_at(db_dir, commit)?;
1527 let total = db.wal_total_commits()?;
1528 let floor = db.wal_horizon_floor();
1529 let mut out = String::new();
1530 if floor == 0 {
1531 let _ = writeln!(out, "as-of commit {} of {}", commit, total);
1532 } else {
1533 let _ = writeln!(
1534 out,
1535 "as-of commit {} of {} (history reaches back to commit {})",
1536 commit, total, floor
1537 );
1538 }
1539 if let Some(cypher) = query {
1540 let params = BTreeMap::new();
1541 let rs = match namespace {
1542 Some(ns) => db.query_masked(cypher, ¶ms, &db.mask_for_namespace(ns))?,
1543 None => db.query(cypher, ¶ms)?,
1544 };
1545 out.push_str(&format_result_set(&rs));
1546 }
1547 Ok(out)
1548}
1549
1550fn check_namespace(namespace: Option<&str>) -> Result<Option<&str>, CliError> {
1553 match namespace {
1554 Some(ns) if !valid_namespace(ns) => Err(CliError(format!(
1555 "namespace {ns:?} is not a valid namespace name — 1 to {NS_MAX_LEN} characters of \
1556 [A-Za-z0-9_.-]"
1557 ))),
1558 other => Ok(other),
1559 }
1560}
1561
1562fn check_statement_sources(
1565 command: &str,
1566 inline: bool,
1567 file: bool,
1568 stdin: bool,
1569) -> Result<(), String> {
1570 if [inline, file, stdin].iter().filter(|given| **given).count() > 1 {
1571 return Err(format!(
1572 "{command}: give the Cypher statement one way — on the command line, \
1573 with --file <path>, or with --stdin — not several"
1574 ));
1575 }
1576 Ok(())
1577}
1578
1579pub fn resolve_statement(
1584 inline: Option<&str>,
1585 file: Option<&Path>,
1586 stdin: bool,
1587 input: &mut dyn std::io::Read,
1588) -> Result<Option<String>, CliError> {
1589 let text = match (file, stdin) {
1590 (Some(path), _) => std::fs::read_to_string(path)
1591 .map_err(|e| CliError(format!("cannot read --file {}: {e}", path.display())))?,
1592 (None, true) => {
1593 let mut text = String::new();
1594 input
1595 .read_to_string(&mut text)
1596 .map_err(|e| CliError(format!("cannot read the statement from stdin: {e}")))?;
1597 text
1598 }
1599 (None, false) => match inline {
1600 Some(statement) => return Ok(Some(tidy_statement(statement).to_string())),
1601 None => return Ok(None),
1602 },
1603 };
1604 let statement = tidy_statement(&text);
1605 if statement.trim().is_empty() {
1606 return Err(CliError("the Cypher statement is empty".to_string()));
1607 }
1608 Ok(Some(statement.to_string()))
1609}
1610
1611fn tidy_statement(text: &str) -> &str {
1615 let text = text.strip_prefix('\u{feff}').unwrap_or(text).trim_end();
1616 text.strip_suffix(';').unwrap_or(text).trim_end()
1617}
1618
1619fn parse_query(args: &[&str]) -> Result<Command, String> {
1620 let mut db_dir = None;
1621 let mut query_flag: Option<String> = None;
1622 let mut role: Option<String> = None;
1623 let mut namespace: Option<String> = None;
1624 let mut file: Option<PathBuf> = None;
1625 let mut stdin = false;
1626 let mut cypher_parts: Vec<&str> = Vec::new();
1627 let mut i = 0;
1628 while i < args.len() {
1629 let a = args[i];
1630 if a == "--file" {
1631 let val = args
1632 .get(i + 1)
1633 .copied()
1634 .ok_or_else(|| "missing value for --file".to_string())?;
1635 file = Some(PathBuf::from(val));
1636 i += 2;
1637 } else if let Some(val) = a.strip_prefix("--file=") {
1638 file = Some(PathBuf::from(val));
1639 i += 1;
1640 } else if a == "--stdin" {
1641 stdin = true;
1642 i += 1;
1643 } else if a == "--query" {
1644 let val = args
1645 .get(i + 1)
1646 .copied()
1647 .ok_or_else(|| "missing value for --query".to_string())?;
1648 query_flag = Some(val.to_string());
1649 i += 2;
1650 } else if let Some(val) = a.strip_prefix("--query=") {
1651 query_flag = Some(val.to_string());
1652 i += 1;
1653 } else if a == "--role" {
1654 let val = args
1655 .get(i + 1)
1656 .copied()
1657 .ok_or_else(|| "missing value for --role".to_string())?;
1658 role = Some(val.to_string());
1659 i += 2;
1660 } else if let Some(val) = a.strip_prefix("--role=") {
1661 role = Some(val.to_string());
1662 i += 1;
1663 } else if a == "--namespace" {
1664 let val = args
1665 .get(i + 1)
1666 .copied()
1667 .ok_or_else(|| "missing value for --namespace".to_string())?;
1668 namespace = Some(val.to_string());
1669 i += 2;
1670 } else if let Some(val) = a.strip_prefix("--namespace=") {
1671 namespace = Some(val.to_string());
1672 i += 1;
1673 } else if a.starts_with('-') {
1674 return Err(format!("unexpected flag: {a}"));
1675 } else if db_dir.is_none() {
1676 db_dir = Some(PathBuf::from(a));
1677 i += 1;
1678 } else {
1679 cypher_parts.push(a);
1680 i += 1;
1681 }
1682 }
1683 let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
1684 check_statement_sources(
1685 "query",
1686 query_flag.is_some() || !cypher_parts.is_empty(),
1687 file.is_some(),
1688 stdin,
1689 )?;
1690 let cypher = if file.is_some() || stdin {
1691 String::new()
1692 } else if let Some(q) = query_flag {
1693 if !cypher_parts.is_empty() {
1694 return Err(
1695 "query: pass Cypher as remaining arguments or --query, not both".to_string(),
1696 );
1697 }
1698 q
1699 } else {
1700 if cypher_parts.is_empty() {
1701 return Err("query requires a Cypher string".to_string());
1702 }
1703 cypher_parts.join(" ")
1704 };
1705 Ok(Command::Query {
1706 db_dir,
1707 cypher,
1708 file,
1709 stdin,
1710 role,
1711 namespace,
1712 })
1713}
1714
1715pub fn run_query(
1722 db_dir: &Path,
1723 cypher: &str,
1724 role: Option<&str>,
1725 namespace: Option<&str>,
1726) -> Result<String, CliError> {
1727 let namespace = check_namespace(namespace)?;
1728 let params = BTreeMap::new();
1729 if role.is_some() || namespace.is_some() {
1730 let db = GraphDb::open(db_dir)?;
1731 let mask = match (role, namespace) {
1734 (Some(role), Some(ns)) => db
1735 .mask_for_role(role)?
1736 .intersect(&db.mask_for_namespace(ns)),
1737 (Some(role), None) => db.mask_for_role(role)?,
1738 (None, Some(ns)) => db.mask_for_namespace(ns),
1739 (None, None) => unreachable!("one of the two is Some in this branch"),
1740 };
1741 return Ok(format_result_set(&db.query_masked(cypher, ¶ms, &mask)?));
1742 }
1743 let is_write = is_write_query(cypher).map_err(CliError)?;
1744 let rs = if is_write {
1745 let mut db = GraphDb::open(db_dir)?;
1746 db.query_write(cypher, ¶ms)?
1747 } else {
1748 let db = GraphDb::open(db_dir)?;
1749 db.query(cypher, ¶ms)?
1750 };
1751 Ok(format_result_set(&rs))
1752}
1753
1754fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
1755 let mut db_dir = None;
1756 let mut wal = WalDisposition::Archive;
1759 let mut retention: Option<u32> = None;
1760 let mut i = 0;
1761 while i < args.len() {
1762 let a = args[i];
1763 if a == "--keep-wal" {
1764 wal = WalDisposition::Keep;
1765 i += 1;
1766 } else if a == "--truncate" {
1767 wal = WalDisposition::Truncate;
1768 i += 1;
1769 } else if a == "--archive-wal" {
1770 wal = WalDisposition::Archive;
1772 i += 1;
1773 } else if a.starts_with("--retention=") {
1774 let v = a.trim_start_matches("--retention=");
1775 retention = Some(
1776 v.parse::<u32>()
1777 .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
1778 );
1779 i += 1;
1780 } else if a == "--retention" {
1781 i += 1;
1782 let v = args
1783 .get(i)
1784 .ok_or_else(|| "--retention requires a value".to_string())?;
1785 retention = Some(
1786 v.parse::<u32>()
1787 .map_err(|e| format!("--retention value error: {e}"))?,
1788 );
1789 i += 1;
1790 } else if a.starts_with('-') {
1791 return Err(format!("unexpected flag: {a}"));
1792 } else if db_dir.is_none() {
1793 db_dir = Some(PathBuf::from(a));
1794 i += 1;
1795 } else {
1796 return Err(format!("unexpected extra argument: {a}"));
1797 }
1798 }
1799 let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
1800 Ok(Command::Snapshot {
1801 db_dir,
1802 wal,
1803 retention,
1804 })
1805}
1806
1807pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
1816 let current = mushroomdb::SNAPSHOT_VERSION;
1817 let from_ver = mushroomdb::snapshot_version_at(db_dir)?;
1818
1819 if let Some(ver) = from_ver {
1823 if ver >= current {
1824 return Ok(format!("already current (V{ver})\n"));
1825 }
1826 }
1827
1828 if from_ver.is_some() {
1833 std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
1834 }
1835
1836 let mut db = GraphDb::open_with_options(
1839 db_dir,
1840 mushroomdb::OpenOptions {
1841 auto_migrate: false,
1842 ..Default::default()
1843 },
1844 )?;
1845 db.snapshot()?;
1846 let to_ver = mushroomdb::snapshot_version_at(db_dir)?.unwrap_or(current);
1849
1850 let msg = match from_ver {
1851 Some(ver) => format!("migrated V{ver} -> V{to_ver}\n"),
1852 None => format!("migrated WAL-only -> V{to_ver}\n"),
1853 };
1854 Ok(msg)
1855}
1856
1857pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
1863 if !db_dir.join("snapshot.bin").exists() {
1866 return Err(CliError(format!(
1867 "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
1868 db_dir.display(),
1869 db_dir.display()
1870 )));
1871 }
1872 let results = mushroomdb::verify_snapshot(db_dir)
1873 .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
1874 let mut any_fail = false;
1875 let mut out = String::new();
1876 for (id, section_name, byte_len, result) in &results {
1877 match result {
1878 Ok(()) => {
1879 let _ = writeln!(
1880 out,
1881 " section {:2} ({:<12}) {:>10} bytes OK",
1882 id, section_name, byte_len
1883 );
1884 }
1885 Err(msg) => {
1886 let _ = writeln!(
1887 out,
1888 " section {:2} ({:<12}) {:>10} bytes CORRUPT: {msg}",
1889 id, section_name, byte_len
1890 );
1891 any_fail = true;
1892 }
1893 }
1894 }
1895 if any_fail {
1896 Err(CliError(format!("integrity check FAILED:\n{out}")))
1897 } else {
1898 Ok(format!(
1899 "integrity check OK ({} sections):\n{out}",
1900 results.len()
1901 ))
1902 }
1903}
1904
1905#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1907pub enum WalDisposition {
1908 #[default]
1912 Archive,
1913 Keep,
1916 Truncate,
1921}
1922
1923impl WalDisposition {
1924 fn options(self) -> SnapshotOptions {
1925 match self {
1926 WalDisposition::Archive => AUTOMATIC_SNAPSHOT,
1927 WalDisposition::Keep => SnapshotOptions {
1928 keep_wal: true,
1929 archive_wal: false,
1930 },
1931 WalDisposition::Truncate => SnapshotOptions {
1932 keep_wal: false,
1933 archive_wal: false,
1934 },
1935 }
1936 }
1937}
1938
1939pub fn run_snapshot(
1941 db_dir: &Path,
1942 wal: WalDisposition,
1943 retention: Option<u32>,
1944) -> Result<String, CliError> {
1945 let mut db = GraphDb::open(db_dir)?;
1946 if wal == WalDisposition::Archive {
1947 db.set_wal_archive_retention(retention);
1948 }
1949 db.snapshot_with(wal.options())?;
1950 Ok(format!(
1951 "snapshot written: {}\n",
1952 db_dir.join("snapshot.bin").display()
1953 ))
1954}
1955
1956fn parse_build_index(args: &[&str]) -> Result<Command, String> {
1957 let mut db_dir: Option<PathBuf> = None;
1958 let mut rule: Option<String> = None;
1959 let mut i = 0;
1960 while i < args.len() {
1961 let a = args[i];
1962 if let Some(v) = a.strip_prefix("--rule=") {
1963 if v.is_empty() {
1964 return Err("--rule requires a value".to_string());
1965 }
1966 rule = Some(v.to_string());
1967 i += 1;
1968 } else if a == "--rule" {
1969 i += 1;
1970 let v = args
1971 .get(i)
1972 .ok_or_else(|| "--rule requires a value".to_string())?;
1973 rule = Some((*v).to_string());
1974 i += 1;
1975 } else if a.starts_with('-') {
1976 return Err(format!("unexpected flag: {a}"));
1977 } else if db_dir.is_none() {
1978 db_dir = Some(PathBuf::from(a));
1979 i += 1;
1980 } else {
1981 return Err(format!("unexpected extra argument: {a}"));
1982 }
1983 }
1984 let db_dir = db_dir.ok_or_else(|| "build-index requires <db-dir>".to_string())?;
1985 Ok(Command::BuildIndex { db_dir, rule })
1986}
1987
1988pub fn run_build_index(db_dir: &Path, rule: Option<&str>) -> Result<String, CliError> {
1995 let mut db = GraphDb::open(db_dir)?;
1996 build_index_on(&mut db, rule)
1997}
1998
1999pub fn build_index_on(db: &mut GraphDb<RealFs>, rule: Option<&str>) -> Result<String, CliError> {
2011 let mut out = String::new();
2012 let interesting = |name: &str| rule.is_none_or(|r| r == name);
2013 let known_rules: Vec<String> = db.stats().rules.iter().map(|r| r.name.clone()).collect();
2016
2017 loop {
2021 let (finished, outstanding) = db.pump_index_build_reporting()?;
2022 for b in &outstanding {
2023 if interesting(&b.rule) {
2024 out.push_str(&format!("building {}: {}/{}\n", b.rule, b.indexed, b.total));
2025 }
2026 }
2027 for b in &finished {
2031 if interesting(&b.rule) {
2032 out.push_str(&format!("built {}: {} vectors\n", b.rule, b.total));
2033 }
2034 }
2035 if outstanding.is_empty() {
2036 break;
2037 }
2038 }
2039 if out.is_empty() {
2040 match rule {
2041 Some(r) if !known_rules.iter().any(|k| k == r) => out.push_str(&format!(
2045 "no rule named {r:?} in this store; nothing to build\n"
2046 )),
2047 Some(r) => out.push_str(&format!("nothing to build for rule {r:?}\n")),
2048 None => out.push_str("nothing to build\n"),
2049 }
2050 }
2051 Ok(out)
2052}
2053
2054fn parse_schema(args: &[&str]) -> Result<Command, String> {
2055 if args.is_empty() {
2056 return Err("schema requires a subcommand: apply | indexes | constraints".to_string());
2057 }
2058 match args[0] {
2059 "apply" => parse_schema_apply(&args[1..]),
2060 "indexes" => {
2061 parse_one_dir("schema indexes", &args[1..]).map(|db_dir| Command::SchemaList {
2062 db_dir,
2063 what: SchemaListing::Indexes,
2064 })
2065 }
2066 "constraints" => {
2067 parse_one_dir("schema constraints", &args[1..]).map(|db_dir| Command::SchemaList {
2068 db_dir,
2069 what: SchemaListing::Constraints,
2070 })
2071 }
2072 other => Err(format!(
2073 "unknown schema subcommand: {other}; expected apply | indexes | constraints"
2074 )),
2075 }
2076}
2077
2078#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2080pub enum SchemaListing {
2081 Indexes,
2082 Constraints,
2083}
2084
2085pub fn run_schema_list(db_dir: &Path, what: SchemaListing) -> Result<String, CliError> {
2089 let db = GraphDb::open_with_options(
2090 db_dir,
2091 OpenOptions {
2092 auto_migrate: false,
2093 repair_wal: false,
2094 read_only: true,
2095 },
2096 )?;
2097 let statement = match what {
2098 SchemaListing::Indexes => "SHOW INDEXES",
2099 SchemaListing::Constraints => "SHOW CONSTRAINTS",
2100 };
2101 let rs = db.query(statement, &BTreeMap::new())?;
2102 let columns = rs.columns();
2103 let rows: Vec<Vec<String>> = (0..rs.len())
2104 .map(|i| {
2105 columns
2106 .iter()
2107 .map(|c| fmt_cell_plain(rs.get(i, c)))
2108 .collect()
2109 })
2110 .collect();
2111 let widths: Vec<usize> = columns
2112 .iter()
2113 .enumerate()
2114 .map(|(n, c)| {
2115 rows.iter()
2116 .map(|r| r[n].chars().count())
2117 .chain([c.chars().count()])
2118 .max()
2119 .unwrap_or(0)
2120 })
2121 .collect();
2122 let line = |cells: &[String]| {
2123 let padded: Vec<String> = cells
2124 .iter()
2125 .zip(&widths)
2126 .map(|(c, w)| format!("{c:<w$}"))
2127 .collect();
2128 format!("{}\n", padded.join(" | ").trim_end())
2129 };
2130 let mut out = line(columns);
2131 for row in &rows {
2132 out.push_str(&line(row));
2133 }
2134 Ok(out)
2135}
2136
2137fn fmt_cell_plain(cell: Option<&Value>) -> String {
2139 match cell {
2140 Some(Value::Str(s)) => s.clone(),
2141 other => fmt_cell(other),
2142 }
2143}
2144
2145fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
2146 let mut db_dir = None;
2147 let mut schema_file = None;
2148 let mut memory_defaults = false;
2149 let mut memory_identity = false;
2150 for a in args {
2151 if *a == "--memory-defaults" {
2152 memory_defaults = true;
2153 } else if *a == "--memory-identity" {
2154 memory_identity = true;
2155 } else if a.starts_with('-') {
2156 return Err(format!("unexpected flag: {a}"));
2157 } else if db_dir.is_none() {
2158 db_dir = Some(PathBuf::from(*a));
2159 } else if schema_file.is_none() {
2160 schema_file = Some(PathBuf::from(*a));
2161 } else {
2162 return Err(format!("unexpected extra argument: {a}"));
2163 }
2164 }
2165 let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
2166 if memory_identity {
2167 if memory_defaults || schema_file.is_some() {
2168 return Err(
2169 "schema apply --memory-identity takes no schema file and no other preset"
2170 .to_string(),
2171 );
2172 }
2173 return Ok(Command::SchemaApply {
2174 db_dir,
2175 schema_file: None,
2176 memory_defaults: false,
2177 memory_identity: true,
2178 });
2179 }
2180 if memory_defaults {
2181 if schema_file.is_some() {
2182 return Err("schema apply --memory-defaults takes no schema file argument".to_string());
2183 }
2184 return Ok(Command::SchemaApply {
2185 db_dir,
2186 schema_file: None,
2187 memory_defaults: true,
2188 memory_identity: false,
2189 });
2190 }
2191 let schema_file =
2192 schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
2193 Ok(Command::SchemaApply {
2194 db_dir,
2195 schema_file: Some(schema_file),
2196 memory_defaults: false,
2197 memory_identity: false,
2198 })
2199}
2200
2201pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
2204 let json = std::fs::read_to_string(schema_file)
2205 .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
2206 let schema: Schema = serde_json::from_str(&json).map_err(|e| {
2207 CliError(format!(
2208 "invalid schema JSON in {}: {e}",
2209 schema_file.display()
2210 ))
2211 })?;
2212 let mut db = GraphDb::open(db_dir)?;
2213 let diff = db.apply_schema(&schema)?;
2214 let mut out = String::new();
2215 for entry in &diff.created {
2216 let _ = writeln!(out, "created {entry}");
2217 }
2218 for entry in &diff.updated {
2219 let _ = writeln!(out, "updated {entry}");
2220 }
2221 for entry in &diff.unchanged {
2222 let _ = writeln!(out, "unchanged {entry}");
2223 }
2224 if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
2225 let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
2226 }
2227 Ok(out)
2228}
2229
2230pub fn run_schema_apply_memory_defaults(db_dir: &Path) -> Result<String, CliError> {
2237 let mut db = GraphDb::open(db_dir)?;
2238 let before = db.stats();
2239 let diff = db.apply_schema(&mushroomdb::memory_schema::memory_defaults())?;
2240 let mut out = String::new();
2241 if diff.created.is_empty() && diff.updated.is_empty() {
2242 let _ = writeln!(out, "schema apply: already current, nothing to do");
2243 return Ok(out);
2244 }
2245 let _ = writeln!(
2246 out,
2247 "schema apply: {} created, {} updated, {} unchanged",
2248 diff.created.len(),
2249 diff.updated.len(),
2250 diff.unchanged.len()
2251 );
2252 for name in diff.created.iter().chain(diff.updated.iter()) {
2253 let _ = writeln!(out, " {name}");
2254 }
2255 let _ = writeln!(
2256 out,
2257 "note: {} live nodes are now full-text indexed; the index rebuilds at \
2258 every open from here on.",
2259 before.nodes_live
2260 );
2261 Ok(out)
2262}
2263
2264pub fn run_schema_apply_memory_identity(db_dir: &Path) -> Result<String, CliError> {
2281 use mushroomdb::memory::identity::{
2282 aliases_to_backfill, entity_node_count, write_aliases, ALIASES_FIELD, ALIAS_KEYS_FIELD,
2283 SAME_AS_EDGE,
2284 };
2285 let mut db = GraphDb::open(db_dir)?;
2286 let preset = mushroomdb::memory_schema::memory_identity();
2287 let entity_nodes = entity_node_count(&db);
2288 let backfill = aliases_to_backfill(&db)?;
2289 let mut out = String::new();
2290 let _ = writeln!(
2291 out,
2292 "identity preset: {} SAME_AS rules over each entity's aliases, global — they link \
2293 across namespaces",
2294 preset.rules.len()
2295 );
2296 let writes = |field: &str| backfill.iter().filter(|node| node.sets(field)).count();
2297 let _ = writeln!(
2298 out,
2299 "before applying: {entity_nodes} entity node(s); {} need an aliases list written \
2300 first, from the key and the name; each rule then compares aliases across its labels \
2301 once, and every later write to an entity is re-checked",
2302 writes(ALIASES_FIELD)
2303 );
2304 let moved = writes(ALIAS_KEYS_FIELD);
2305 if moved == 0 {
2306 let _ = writeln!(
2307 out,
2308 "alias_keys: nothing to move — it holds only declared aliases; an entity that \
2309 declares one equal to a provisional stub's key links that stub"
2310 );
2311 } else {
2312 let _ = writeln!(
2313 out,
2314 "alias_keys: {moved} node(s) carry aliases their key and name do not imply; those \
2315 are kept, moved to alias_keys as declared aliases — one equal to a provisional \
2316 stub's key links that stub"
2317 );
2318 }
2319 write_aliases(&mut db, &backfill)?;
2323 let diff = db.apply_schema(&preset)?;
2324 let _ = writeln!(
2325 out,
2326 "schema apply: {} created, {} updated, {} unchanged",
2327 diff.created.len(),
2328 diff.updated.len(),
2329 diff.unchanged.len()
2330 );
2331 let edges = db.weighted_edges(SAME_AS_EDGE, None);
2334 let pairs: std::collections::BTreeSet<(&str, &str)> = edges
2335 .iter()
2336 .map(|(a, b, _)| {
2337 if a <= b {
2338 (a.as_str(), b.as_str())
2339 } else {
2340 (b.as_str(), a.as_str())
2341 }
2342 })
2343 .collect();
2344 let _ = writeln!(
2345 out,
2346 "SAME_AS edges now: {} ({} pair(s))",
2347 edges.len(),
2348 pairs.len()
2349 );
2350 Ok(out)
2351}
2352
2353fn parse_backup(args: &[&str]) -> Result<Command, String> {
2354 let mut db_dir = None;
2355 let mut dest = None;
2356 for a in args {
2357 if a.starts_with('-') {
2358 return Err(format!("unexpected flag: {a}"));
2359 }
2360 if db_dir.is_none() {
2361 db_dir = Some(PathBuf::from(*a));
2362 } else if dest.is_none() {
2363 dest = Some(PathBuf::from(*a));
2364 } else {
2365 return Err(format!("unexpected extra argument: {a}"));
2366 }
2367 }
2368 let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
2369 let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
2370 Ok(Command::Backup { db_dir, dest })
2371}
2372
2373fn parse_restore(args: &[&str]) -> Result<Command, String> {
2375 let (mut from, mut at, mut into) = (None, None, None);
2376 let mut i = 0;
2377 while i < args.len() {
2378 if let Some(v) = flag_value(args, &mut i, "--from")? {
2379 from = Some(v.to_string());
2380 } else if let Some(v) = flag_value(args, &mut i, "--at")? {
2381 backup::RestoreTarget::parse(v)?;
2383 at = Some(v.to_string());
2384 } else if let Some(v) = flag_value(args, &mut i, "--into")? {
2385 into = Some(PathBuf::from(v));
2386 } else if args[i].starts_with('-') {
2387 return Err(format!("unexpected flag: {}", args[i]));
2388 } else {
2389 return Err(format!(
2390 "unexpected argument: {} (restore takes --from, --at and --into)",
2391 args[i]
2392 ));
2393 }
2394 }
2395 let from = from
2396 .filter(|f| !f.trim().is_empty())
2397 .ok_or_else(|| "restore requires --from s3://bucket/prefix".to_string())?;
2398 Ok(Command::RestoreS3 { from, at, into })
2399}
2400
2401fn flag_value<'a>(args: &[&'a str], i: &mut usize, flag: &str) -> Result<Option<&'a str>, String> {
2403 let a = args[*i];
2404 if a == flag {
2405 let val = args
2406 .get(*i + 1)
2407 .copied()
2408 .ok_or_else(|| format!("missing value for {flag}"))?;
2409 *i += 2;
2410 Ok(Some(val))
2411 } else if let Some(val) = a.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
2412 *i += 1;
2413 Ok(Some(val))
2414 } else {
2415 Ok(None)
2416 }
2417}
2418
2419fn single_char(flag: &str, val: &str) -> Result<char, String> {
2421 let val = if val == "tab" || val == "\\t" {
2422 "\t"
2423 } else {
2424 val
2425 };
2426 let mut chars = val.chars();
2427 match (chars.next(), chars.next()) {
2428 (Some(c), None) => Ok(c),
2429 _ => Err(format!("{flag} takes one character, got '{val}'")),
2430 }
2431}
2432
2433fn parse_import(args: &[&str]) -> Result<Command, String> {
2434 let mut db_dir = PathBuf::from("mushroomdb-data");
2435 let mut inputs = import::ImportInputs::default();
2436 let mut opts = import::ImportOptions::default();
2437 let mut i = 0;
2438 while i < args.len() {
2439 let a = args[i];
2440 if a == "--ignore-errors" {
2441 opts.ignore_errors = true;
2442 i += 1;
2443 } else if let Some(v) = flag_value(args, &mut i, "--nodes")? {
2444 inputs.nodes_csv = Some(PathBuf::from(v));
2445 } else if let Some(v) = flag_value(args, &mut i, "--relationships")? {
2446 inputs.rels_csv = Some(PathBuf::from(v));
2447 } else if let Some(v) = flag_value(args, &mut i, "--nodes-jsonl")? {
2448 inputs.nodes_jsonl = Some(PathBuf::from(v));
2449 } else if let Some(v) = flag_value(args, &mut i, "--edges-jsonl")? {
2450 inputs.edges_jsonl = Some(PathBuf::from(v));
2451 } else if let Some(v) = flag_value(args, &mut i, "--jsonl")? {
2452 inputs.stream_jsonl = Some(PathBuf::from(v));
2453 } else if let Some(v) = flag_value(args, &mut i, "--rules-jsonl")? {
2454 inputs.rules_jsonl = Some(PathBuf::from(v));
2455 } else if let Some(v) = flag_value(args, &mut i, "--db")? {
2456 db_dir = PathBuf::from(v);
2457 } else if let Some(v) = flag_value(args, &mut i, "--delimiter")? {
2458 let c = single_char("--delimiter", v)?;
2459 opts.delimiter = u8::try_from(u32::from(c))
2460 .ok()
2461 .filter(u8::is_ascii)
2462 .ok_or_else(|| format!("--delimiter must be an ASCII character, got '{v}'"))?;
2463 } else if let Some(v) = flag_value(args, &mut i, "--array-delimiter")? {
2464 opts.array_delimiter = single_char("--array-delimiter", v)?;
2465 } else if let Some(v) = flag_value(args, &mut i, "--batch-size")? {
2466 opts.batch_size = v
2467 .parse::<usize>()
2468 .ok()
2469 .filter(|n| *n > 0)
2470 .ok_or_else(|| format!("--batch-size must be a positive integer, got '{v}'"))?;
2471 } else if a.starts_with('-') {
2472 return Err(format!("unexpected flag: {a}"));
2473 } else {
2474 return Err(format!("unexpected argument: {a}"));
2475 }
2476 }
2477 if inputs == import::ImportInputs::default() {
2478 return Err(
2479 "import needs --nodes, --relationships, --nodes-jsonl, --edges-jsonl, --rules-jsonl or --jsonl"
2480 .to_string(),
2481 );
2482 }
2483 Ok(Command::Import {
2484 db_dir,
2485 inputs,
2486 opts,
2487 })
2488}
2489
2490fn parse_export(args: &[&str]) -> Result<Command, String> {
2491 let mut db_dir = None;
2492 let mut dest = None;
2493 let mut format = ExportFormat::Jsonl;
2494 let mut single_file = false;
2495 let mut i = 0;
2496 while i < args.len() {
2497 let a = args[i];
2498 if a == "--single-file" {
2499 single_file = true;
2500 i += 1;
2501 } else if a == "--format" {
2502 let val = args
2503 .get(i + 1)
2504 .copied()
2505 .ok_or_else(|| "missing value for --format".to_string())?;
2506 format = ExportFormat::parse(val).ok_or_else(|| {
2507 format!("unknown format '{val}'; expected jsonl, csv, cypher, parquet, or graphml")
2508 })?;
2509 i += 2;
2510 } else if let Some(val) = a.strip_prefix("--format=") {
2511 format = ExportFormat::parse(val).ok_or_else(|| {
2512 format!("unknown format '{val}'; expected jsonl, csv, cypher, parquet, or graphml")
2513 })?;
2514 i += 1;
2515 } else if a.starts_with('-') {
2516 return Err(format!("unexpected flag: {a}"));
2517 } else if db_dir.is_none() {
2518 db_dir = Some(PathBuf::from(a));
2519 i += 1;
2520 } else if dest.is_none() {
2521 dest = Some(PathBuf::from(a));
2522 i += 1;
2523 } else {
2524 return Err(format!("unexpected extra argument: {a}"));
2525 }
2526 }
2527 let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
2528 let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
2529 if single_file {
2530 if format != ExportFormat::Jsonl {
2531 return Err("--single-file goes with --format jsonl".to_string());
2532 }
2533 format = ExportFormat::JsonlSingle;
2534 }
2535 Ok(Command::Export {
2536 db_dir,
2537 dest,
2538 format,
2539 })
2540}
2541
2542pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
2544 let db = GraphDb::open(db_dir)?;
2545 Ok(db.backup_to(dest)?)
2546}
2547
2548pub const DEFAULT_RESTORE_INTO: &str = "restored";
2550
2551pub fn run_restore_s3(
2555 from: &str,
2556 at: Option<&str>,
2557 into: Option<&Path>,
2558) -> Result<String, CliError> {
2559 let target = at
2560 .map(backup::RestoreTarget::parse)
2561 .transpose()
2562 .map_err(CliError)?;
2563 let into = into.map_or_else(|| PathBuf::from(DEFAULT_RESTORE_INTO), Path::to_path_buf);
2564 let rt = tokio::runtime::Builder::new_multi_thread()
2565 .enable_all()
2566 .build()?;
2567 let out = rt
2568 .block_on(backup::restore_from_s3(from, target, &into))
2569 .map_err(|e| CliError(format!("restore from {from} failed: {e}")))?;
2570 Ok(format_restore_s3(from, target, &out))
2571}
2572
2573pub fn format_restore_s3(
2575 from: &str,
2576 at: Option<backup::RestoreTarget>,
2577 out: &backup::RestoreS3Outcome,
2578) -> String {
2579 let mut s = String::new();
2580 let point = at.map_or_else(|| "the newest restore point".to_string(), |t| t.to_string());
2581 writeln!(s, "restored {from} at {point} into {}", out.into.display()).unwrap();
2582 writeln!(s, " manifest: {}", out.manifest).unwrap();
2583 writeln!(s, " commits: {}", out.commits).unwrap();
2584 writeln!(s, " files: {}", out.files.join(", ")).unwrap();
2585 writeln!(s, " segments: {}", out.segments).unwrap();
2586 writeln!(s, " bytes: {}", out.bytes).unwrap();
2587 for path in &out.swept {
2588 writeln!(
2589 s,
2590 " removed a killed restore's leftover: {}",
2591 path.display()
2592 )
2593 .unwrap();
2594 }
2595 if let Some(note) = &out.install_note {
2596 writeln!(s, " warning: {note}").unwrap();
2597 }
2598 if let Some(note) = &out.tail_note {
2599 writeln!(s, " warning: {note}").unwrap();
2600 }
2601 writeln!(
2602 s,
2603 " lineage: {} (new; restored from {})",
2604 out.lineage, out.source_lineage
2605 )
2606 .unwrap();
2607 writeln!(
2608 s,
2609 "back this store up to a new prefix: the one it came from belongs to the source"
2610 )
2611 .unwrap();
2612 s
2613}
2614
2615pub fn restore_if_empty(db_dir: &Path, from: &Path) -> Result<RestoreOutcome, CliError> {
2623 mushroomdb::restore::restore_if_empty(db_dir, from).map_err(|e| match e {
2624 mushroomdb::GraphError::Io(io) => CliError(io.to_string()),
2625 other => CliError(other.to_string()),
2626 })
2627}
2628
2629pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
2631 let mut out = String::new();
2632 writeln!(out, "backup to: {}", dest.display()).unwrap();
2633 writeln!(out, " files: {}", report.files.join(", ")).unwrap();
2634 writeln!(out, " bytes: {}", report.bytes).unwrap();
2635 writeln!(out, " verified: {}", report.verified).unwrap();
2636 out
2637}
2638
2639pub fn run_import(
2641 db_dir: &Path,
2642 inputs: &import::ImportInputs,
2643 opts: &import::ImportOptions,
2644) -> Result<import::ImportReport, CliError> {
2645 import::run_import_inputs(db_dir, inputs, opts, &mut |_| {})
2646}
2647
2648fn non_finite_note(stats: &export::ExportStats) -> String {
2651 match stats.non_finite_floats {
2652 0 => String::new(),
2653 n => format!(
2654 "warning: {n} NaN or infinite float values cannot be written in this format and were left out\n"
2655 ),
2656 }
2657}
2658
2659fn export_summary(
2660 dest: &Path,
2661 format: &ExportFormat,
2662 stats: &export::ExportStats,
2663 rule_count: usize,
2664 extra: &str,
2665) -> String {
2666 let manual = stats.edges - stats.derived_edges;
2667 let mut out = format!(
2668 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
2669 dest.display(),
2670 format.name(),
2671 stats.nodes,
2672 manual,
2673 rule_count
2674 );
2675 if stats.derived_edges > 0 {
2676 let _ = writeln!(
2677 out,
2678 "{} derived edges are not written; they belong to their rules",
2679 stats.derived_edges
2680 );
2681 }
2682 out.push_str(extra);
2683 out
2684}
2685
2686pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
2692 let db = GraphDb::open(db_dir)?;
2693 let mut rules = db.rules();
2694 rules.sort_by(|a, b| a.name.cmp(&b.name));
2695 let rule_count = rules.len();
2696 match format {
2697 ExportFormat::Jsonl => {
2698 let stats = export::write_jsonl(&db, &rules, dest)?;
2699 Ok(format!(
2701 "exported to {} (format={}): {} nodes, {} edges, {} rules\n{}",
2702 dest.display(),
2703 format.name(),
2704 stats.nodes,
2705 stats.edges,
2706 rule_count,
2707 non_finite_note(&stats)
2708 ))
2709 }
2710 ExportFormat::JsonlSingle => {
2711 let stats = export::write_jsonl_single(&db, &rules, dest)?;
2712 Ok(format!(
2713 "exported to {} (format={}, single file): {} nodes, {} edges, {} rules\n{}",
2714 dest.display(),
2715 format.name(),
2716 stats.nodes,
2717 stats.edges,
2718 rule_count,
2719 non_finite_note(&stats)
2720 ))
2721 }
2722 ExportFormat::Csv => {
2723 let stats = export::write_csv(&db, &rules, dest)?;
2724 let note = non_finite_note(&stats);
2725 Ok(export_summary(dest, format, &stats, rule_count, ¬e))
2726 }
2727 ExportFormat::Cypher => {
2728 let (stats, loss) = export::write_cypher(&db, dest)?;
2729 let mut extra = String::new();
2730 if rule_count > 0 {
2731 extra.push_str(&format!(
2732 "{rule_count} rules are not written: Cypher has no statement for one\n"
2733 ));
2734 }
2735 if loss.ids_added > 0 {
2736 extra.push_str(&format!(
2737 "warning: {} nodes had no id property and gain one, equal to their key, when the file is run\n",
2738 loss.ids_added
2739 ));
2740 }
2741 if loss.typed_properties + loss.unwritable_numbers + loss.differing_ids > 0 {
2742 extra.push_str(&format!(
2743 "warning: not written to the Cypher file: {} properties of the date, time, duration or binary types, \
2744 {} NaN, infinite or -2^63 numbers or names with a line break, {} nodes whose id property is not their key\n",
2745 loss.typed_properties, loss.unwritable_numbers, loss.differing_ids
2746 ));
2747 }
2748 Ok(export_summary(dest, format, &stats, 0, &extra))
2749 }
2750 ExportFormat::Parquet => {
2751 let nodes = db.all_nodes_for_export();
2752 let edges = db.all_edges_for_export();
2753 export::write_parquet(&nodes, &edges, &rules, dest)?;
2754 Ok(format!(
2755 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
2756 dest.display(),
2757 format.name(),
2758 nodes.len(),
2759 edges.len(),
2760 rule_count
2761 ))
2762 }
2763 ExportFormat::Graphml => {
2765 let nodes = db.all_nodes_for_export();
2766 let edges = db.all_edges_for_export();
2767 let file_path = export::write_graphml(&nodes, &edges, dest)?;
2768 Ok(format!(
2769 "exported to {} (format={}): {} nodes, {} edges\n",
2770 file_path.display(),
2771 format.name(),
2772 nodes.len(),
2773 edges.len(),
2774 ))
2775 }
2776 }
2777}
2778
2779fn format_result_set(rs: &ResultSet) -> String {
2780 let mut out = String::new();
2781 let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
2782 for i in 0..rs.len() {
2783 let cells: Vec<String> = rs
2784 .columns()
2785 .iter()
2786 .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
2787 .collect();
2788 let _ = writeln!(out, " {}", cells.join(" "));
2789 }
2790 out
2791}
2792
2793fn parse_algo(args: &[&str]) -> Result<Command, String> {
2794 if args.is_empty() {
2795 return Err(
2796 "algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
2797 );
2798 }
2799 let subcmd = match args[0] {
2800 "pagerank" => AlgoSubcmd::Pagerank,
2801 "wcc" => AlgoSubcmd::Wcc,
2802 "degree" => AlgoSubcmd::Degree,
2803 "communities" => AlgoSubcmd::Communities,
2804 other => {
2805 return Err(format!(
2806 "unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
2807 ))
2808 }
2809 };
2810 let rest = &args[1..];
2811 let mut db_dir = None;
2812 let mut top: usize = 20;
2813 let mut dir = AlgoDir::Both;
2814 let mut edge_types: Vec<String> = Vec::new();
2815 let mut weight_prop: Option<String> = None;
2816 let mut min_weight: Option<f64> = None;
2817 let mut i = 0;
2818 while i < rest.len() {
2819 let a = rest[i];
2820 if a == "--top" {
2821 let val = rest
2822 .get(i + 1)
2823 .copied()
2824 .ok_or_else(|| "missing value for --top".to_string())?;
2825 top = val
2826 .parse()
2827 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
2828 i += 2;
2829 } else if let Some(val) = a.strip_prefix("--top=") {
2830 top = val
2831 .parse()
2832 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
2833 i += 1;
2834 } else if a == "--dir" {
2835 let val = rest
2836 .get(i + 1)
2837 .copied()
2838 .ok_or_else(|| "missing value for --dir".to_string())?;
2839 dir = parse_algo_dir(val)?;
2840 i += 2;
2841 } else if let Some(val) = a.strip_prefix("--dir=") {
2842 dir = parse_algo_dir(val)?;
2843 i += 1;
2844 } else if a == "--edge-type" {
2845 let val = rest
2846 .get(i + 1)
2847 .copied()
2848 .ok_or_else(|| "missing value for --edge-type".to_string())?;
2849 edge_types.push(val.to_string());
2850 i += 2;
2851 } else if let Some(val) = a.strip_prefix("--edge-type=") {
2852 edge_types.push(val.to_string());
2853 i += 1;
2854 } else if a == "--weight-prop" {
2855 let val = rest
2856 .get(i + 1)
2857 .copied()
2858 .ok_or_else(|| "missing value for --weight-prop".to_string())?;
2859 weight_prop = Some(val.to_string());
2860 i += 2;
2861 } else if let Some(val) = a.strip_prefix("--weight-prop=") {
2862 weight_prop = Some(val.to_string());
2863 i += 1;
2864 } else if a == "--min-weight" {
2865 let val = rest
2866 .get(i + 1)
2867 .copied()
2868 .ok_or_else(|| "missing value for --min-weight".to_string())?;
2869 min_weight = Some(
2870 val.parse()
2871 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
2872 );
2873 i += 2;
2874 } else if let Some(val) = a.strip_prefix("--min-weight=") {
2875 min_weight = Some(
2876 val.parse()
2877 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
2878 );
2879 i += 1;
2880 } else if a.starts_with('-') {
2881 return Err(format!("unexpected flag: {a}"));
2882 } else if db_dir.is_none() {
2883 db_dir = Some(PathBuf::from(a));
2884 i += 1;
2885 } else {
2886 return Err(format!("unexpected extra argument: {a}"));
2887 }
2888 }
2889 let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
2890 Ok(Command::Algo {
2891 db_dir,
2892 subcmd,
2893 top,
2894 dir,
2895 edge_types,
2896 weight_prop,
2897 min_weight,
2898 })
2899}
2900
2901fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
2903 match val.to_ascii_lowercase().as_str() {
2904 "out" => Ok(AlgoDir::Out),
2905 "in" => Ok(AlgoDir::In),
2906 "both" => Ok(AlgoDir::Both),
2907 other => Err(format!("--dir must be one of out | in | both, got {other}")),
2908 }
2909}
2910
2911pub fn run_brief(db_dir: &Path) -> Result<String, CliError> {
2917 let db = open_for_reading(db_dir)?;
2918 let reach = reach_line(db_dir);
2919 let b =
2920 mushroomdb::memory::brief::brief(&db, &mushroomdb::memory::brief::BriefOptions::default());
2921 let mut text = mushroomdb::memory::brief::render(&b, &reach);
2922 if text == mushroomdb::memory::brief::EMPTY_BRIEF
2925 && matches!(install::delivery_for_store(db_dir), install::Delivery::Cli)
2926 {
2927 text = empty_brief_cli(db_dir);
2928 }
2929 let store = mushroomdb::digest::sanitize(&install::sh_quote(&db_dir.to_string_lossy()));
2950 let notice = if db.fulltext_pairs().is_empty() {
2951 Some(format!(
2952 "no text index: recall cannot match anything here. \
2953 Run: mushroomdb schema apply {store} --memory-defaults\n"
2954 ))
2955 } else {
2956 let unindexed = mushroomdb::memory::recall::unindexed_memory_fields(&db);
2957 (!unindexed.is_empty()).then(|| {
2958 format!(
2959 "text index incomplete: recall does not search {}. \
2960 Run: mushroomdb schema apply {store} --memory-defaults\n",
2961 mushroomdb::memory::recall::unindexed_fields_list(&unindexed)
2962 )
2963 })
2964 };
2965 if let Some(notice) = notice {
2966 let at = text
2967 .rfind("\nreach the graph: ")
2968 .map_or(text.len(), |i| i + 1);
2969 text.insert_str(at, ¬ice);
2970 }
2971 Ok(text)
2972}
2973
2974const SHELL_QUOTING_RULE: &str = "single-quote Cypher strings; inside the double quotes \
2984 backslash every dollar sign, double quote and backtick";
2985
2986fn reach_line(db_dir: &Path) -> String {
3009 let bin = install::detect_mcp_command(None).shell();
3010 let db = install::sh_quote(&db_dir.to_string_lossy());
3011 let tools = format!(
3012 "explain_association <a> <b>{sep}query '<cypher>' (MCP tools; add role: <name> \
3013 or namespace: <ns> to narrow what it sees){sep}or:",
3014 sep = mushroomdb::digest::SEP
3015 );
3016 let shell = format!("{bin} query {db} \"<cypher>\" ({SHELL_QUOTING_RULE})");
3017 match install::delivery_for_store(db_dir) {
3018 install::Delivery::Cli => shell,
3019 _ => format!("{tools} {shell}"),
3020 }
3021}
3022
3023fn empty_brief_cli(db_dir: &Path) -> String {
3042 let bin = mushroomdb::digest::sanitize(&install::detect_mcp_command(None).shell());
3045 let db = mushroomdb::digest::sanitize(&install::sh_quote(&db_dir.to_string_lossy()));
3046 format!(
3047 "mushroomdb brief — empty store; offer to fill it: {bin} query {db} \
3048 \"CREATE (n:Note {{id: 'note:<fresh-id>', text: '<the fact>'}})\" writes a fact; \
3049 fill in both, with an id no note has yet; {SHELL_QUOTING_RULE}\n"
3050 )
3051}
3052
3053fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
3063 if !db_dir.exists() {
3064 return Err(CliError(format!("no store at {}", db_dir.display())));
3065 }
3066 Ok(GraphDb::open_with_options(
3067 db_dir,
3068 mushroomdb::OpenOptions {
3069 auto_migrate: false,
3070 repair_wal: false,
3071 read_only: true,
3072 },
3073 )?)
3074}
3075
3076pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
3084 let db = open_for_reading(db_dir)?;
3085 let explained = mushroomdb::explain_digest::explain_with_evidence(&db, a, b)?;
3086 Ok(format!(
3087 "{}{}",
3088 mushroomdb::digest::UNTRUSTED_FRAMING,
3089 mushroomdb::explain_digest::render_explain(a, b, &explained)
3090 ))
3091}
3092
3093#[allow(clippy::too_many_arguments)]
3099pub fn run_algo(
3100 db_dir: &Path,
3101 subcmd: &AlgoSubcmd,
3102 top: usize,
3103 dir: AlgoDir,
3104 edge_types: Vec<String>,
3105 weight_prop: Option<String>,
3106 min_weight: Option<f64>,
3107) -> Result<String, CliError> {
3108 let db = GraphDb::open(db_dir)?;
3109 match subcmd {
3110 AlgoSubcmd::Pagerank => {
3111 let config = PageRankConfig {
3112 direction: dir,
3113 ..PageRankConfig::default()
3114 };
3115 let report = db.pagerank(&config);
3116 Ok(format_pagerank(&report, top))
3117 }
3118 AlgoSubcmd::Wcc => {
3119 let config = WccConfig::default();
3120 let report = db.connected_components(&config);
3121 Ok(format_wcc(&report, top))
3122 }
3123 AlgoSubcmd::Degree => {
3124 let config = DegreeConfig {
3125 direction: dir,
3126 ..DegreeConfig::default()
3127 };
3128 let report = db.degree_centrality(&config);
3129 Ok(format_degree(&report, top))
3130 }
3131 AlgoSubcmd::Communities => {
3132 let config = LouvainConfig {
3133 edge_types,
3134 weight_prop,
3135 min_weight,
3136 ..LouvainConfig::default()
3137 };
3138 let report = db.communities(&config);
3139 Ok(format_communities(&report, top))
3140 }
3141 }
3142}
3143
3144fn format_pagerank(report: &mushroomdb::PageRankReport, top: usize) -> String {
3145 let mut buf = String::new();
3146 let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
3147 let rows = if top == 0 {
3148 report.scores.as_slice()
3149 } else {
3150 &report.scores[..top.min(report.scores.len())]
3151 };
3152 for (i, (key, score)) in rows.iter().enumerate() {
3153 let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
3154 }
3155 buf
3156}
3157
3158fn format_wcc(report: &mushroomdb::WccReport, top: usize) -> String {
3159 let mut buf = String::new();
3160 let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
3161 let rows = if top == 0 {
3162 report.components.as_slice()
3163 } else {
3164 &report.components[..top.min(report.components.len())]
3165 };
3166 for (key, comp_id) in rows {
3167 let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
3168 }
3169 buf
3170}
3171
3172fn format_degree(report: &mushroomdb::DegreeReport, top: usize) -> String {
3173 let mut buf = String::new();
3174 let _ = writeln!(
3175 buf,
3176 "== degree centrality (truncated={}) ==",
3177 report.truncated
3178 );
3179 let rows = if top == 0 {
3180 report.scores.as_slice()
3181 } else {
3182 &report.scores[..top.min(report.scores.len())]
3183 };
3184 for (i, (key, deg)) in rows.iter().enumerate() {
3185 let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
3186 }
3187 buf
3188}
3189
3190fn format_communities(report: &mushroomdb::CommunityReport, top: usize) -> String {
3193 let mut buf = String::new();
3194 let trunc = if report.truncated { " (truncated)" } else { "" };
3195 let _ = writeln!(
3196 buf,
3197 "== communities (modularity={:.2}){trunc} ==",
3198 report.modularity
3199 );
3200 let rows = if top == 0 {
3201 report.communities.as_slice()
3202 } else {
3203 &report.communities[..top.min(report.communities.len())]
3204 };
3205 for c in rows {
3206 let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
3207 let _ = writeln!(
3208 buf,
3209 " {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
3210 c.id,
3211 c.members.len(),
3212 c.cohesion,
3213 preview.join(", ")
3214 );
3215 }
3216 buf
3217}
3218
3219fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
3224 let mut db_dir = None;
3225 let mut auto = false;
3226 for a in args {
3227 if *a == "--auto" {
3228 auto = true;
3229 } else if a.starts_with('-') {
3230 return Err(format!("unexpected flag: {a}"));
3231 } else if db_dir.is_some() {
3232 return Err(format!("unexpected extra argument: {a}"));
3233 } else {
3234 db_dir = Some(PathBuf::from(*a));
3235 }
3236 }
3237 match (&db_dir, auto) {
3238 (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
3239 (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
3240 _ => Ok((db_dir, auto)),
3241 }
3242}
3243
3244fn parse_mcp(args: &[&str]) -> Result<Command, String> {
3248 let all_tools = args.contains(&"--all-tools");
3249 let rest: Vec<&str> = args
3250 .iter()
3251 .copied()
3252 .filter(|a| *a != "--all-tools")
3253 .collect();
3254 parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
3255 db_dir,
3256 auto,
3257 all_tools,
3258 })
3259}
3260
3261fn parse_positional(
3268 cmd: &str,
3269 args: &[&str],
3270 min: usize,
3271 max: usize,
3272) -> Result<(PathBuf, Vec<String>), String> {
3273 let mut rest: Vec<String> = Vec::new();
3274 let mut db_dir: Option<PathBuf> = None;
3275 for a in args {
3276 if a.starts_with('-') {
3277 return Err(format!("unexpected flag: {a}"));
3278 }
3279 match db_dir {
3280 None => db_dir = Some(PathBuf::from(*a)),
3281 Some(_) => rest.push((*a).to_string()),
3282 }
3283 }
3284 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
3285 if rest.len() < min {
3286 return Err(format!(
3287 "{cmd} requires <db-dir> and {min} more argument{}",
3288 if min == 1 { "" } else { "s" }
3289 ));
3290 }
3291 if rest.len() > max {
3292 return Err(format!("unexpected extra argument: {}", rest[max]));
3293 }
3294 Ok((db_dir, rest))
3295}
3296
3297fn parse_keys(args: &[&str]) -> Result<Command, String> {
3298 match args.first().copied() {
3299 Some("hash") if args.len() == 1 => Ok(Command::KeysHash),
3300 Some("hash") => Err("keys hash takes no arguments; it reads the token from stdin".into()),
3301 Some("add") => {
3302 let mut positional: Vec<&str> = Vec::new();
3303 let mut tier: Option<String> = None;
3304 let mut role: Option<String> = None;
3305 let mut i = 1;
3306 while i < args.len() {
3307 match args[i] {
3308 "--tier" | "--role" => {
3309 let flag = args[i];
3310 i += 1;
3311 let value = args
3312 .get(i)
3313 .ok_or_else(|| format!("{flag} requires a value"))?;
3314 let slot = if flag == "--tier" {
3315 &mut tier
3316 } else {
3317 &mut role
3318 };
3319 if slot.replace((*value).to_string()).is_some() {
3320 return Err(format!("{flag} given twice"));
3321 }
3322 }
3323 flag if flag.starts_with('-') => {
3324 return Err(format!("unexpected flag: {flag}"))
3325 }
3326 other => positional.push(other),
3327 }
3328 i += 1;
3329 }
3330 let [db_dir, name] = positional.as_slice() else {
3331 return Err("keys add requires <db-dir> <name>".into());
3332 };
3333 let tier = tier.ok_or("keys add requires --tier admin|write|read")?;
3334 match (tier.as_str(), role.is_some()) {
3335 ("admin" | "write", false) => {}
3336 ("admin" | "write", true) => return Err("--role goes only with --tier read".into()),
3337 ("read", true) => {}
3338 ("read", false) => return Err("--tier read requires --role <name>".into()),
3339 _ => return Err("--tier must be admin, write or read".into()),
3340 }
3341 Ok(Command::KeysAdd {
3342 db_dir: PathBuf::from(*db_dir),
3343 name: (*name).to_string(),
3344 tier,
3345 role,
3346 })
3347 }
3348 _ => Err("keys requires a subcommand: add or hash".into()),
3349 }
3350}
3351
3352fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
3353 let mut db_dir = None;
3354 for a in args {
3355 if a.starts_with('-') {
3356 return Err(format!("unexpected flag: {a}"));
3357 }
3358 if db_dir.is_some() {
3359 return Err(format!("unexpected extra argument: {a}"));
3360 }
3361 db_dir = Some(PathBuf::from(*a));
3362 }
3363 db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
3364}
3365
3366pub fn format_stats(stats: &Stats) -> String {
3368 let mut out = String::new();
3369 let _ = writeln!(
3370 out,
3371 "nodes: {} live, {} tombstoned",
3372 stats.nodes_live, stats.nodes_tombstoned
3373 );
3374 let _ = writeln!(out, "edges: {}", stats.edges);
3375 if stats.history_floor == 0 {
3376 let _ = writeln!(out, "history: complete (nothing pruned)");
3377 } else {
3378 let _ = writeln!(
3379 out,
3380 "history: reaches back to commit {}",
3381 stats.history_floor
3382 );
3383 }
3384 if stats.namespaces.len() > 1 {
3389 let names: Vec<String> = stats
3390 .namespaces
3391 .iter()
3392 .map(|n| format!("{} ({})", n.name, n.nodes_live))
3393 .collect();
3394 let _ = writeln!(out, "namespaces: {}", names.join(", "));
3395 }
3396 let _ = writeln!(out, "rules: {}", stats.rules.len());
3397 for r in &stats.rules {
3398 let _ = writeln!(
3399 out,
3400 " {:<28} edges={} tripped={}",
3401 r.name, r.edges, r.tripped
3402 );
3403 }
3404 out
3405}
3406
3407pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
3409 let db = SharedDb::open(dir)?;
3410 let stats = db.read().stats();
3411 Ok(stats)
3412}
3413
3414pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
3420 refuse_non_empty(dir)?;
3421
3422 let db = SharedDb::open(dir)?;
3423 let opts = IngestOptions::default();
3424 let mut auto_fk_rules = Vec::new();
3425
3426 {
3427 let mut w = db.write();
3428 w.apply_schema(&mushroomdb::memory_schema::memory_defaults())?;
3431 for (label, json) in [
3432 ("Org", org_json()),
3433 ("Project", project_json()),
3434 ("Person", person_json()),
3435 ] {
3436 let report = w.ingest_json(label, &json, &opts)?;
3437 if !report.row_errors.is_empty() {
3438 return Err(CliError(format!(
3439 "demo ingest of {label} had row errors: {:?}",
3440 report.row_errors
3441 )));
3442 }
3443 auto_fk_rules.extend(report.rules_created);
3444 }
3445 let skill_fit = Predicate::Overlap {
3446 field: "skills".into(),
3447 min: 0.5,
3448 };
3449 let skill_fit_k = Some(default_max_edges(&skill_fit));
3450 w.create_rule(RuleDef {
3451 name: "skill_fit".into(),
3452 src_label: "Person".into(),
3453 dst_label: "Project".into(),
3454 predicate: skill_fit,
3455 edge_type: "FIT".into(),
3456 weight_prop: Some("score".into()),
3457 max_edges: skill_fit_k,
3458 approximate: false,
3459 via_label: None,
3460 via_edge: None,
3461 via_dir: None,
3462 namespace: None,
3463 })?;
3464 let founded_within = Predicate::NumericWithin {
3465 field: "founded_year".into(),
3466 tolerance: 2.0,
3467 };
3468 let founded_within_k = Some(default_max_edges(&founded_within));
3469 w.create_rule(RuleDef {
3470 name: "founded_within".into(),
3471 src_label: "Org".into(),
3472 dst_label: "Org".into(),
3473 predicate: founded_within,
3474 edge_type: "FOUNDED_WITHIN".into(),
3475 weight_prop: Some("score".into()),
3476 max_edges: founded_within_k,
3477 approximate: false,
3478 via_label: None,
3479 via_edge: None,
3480 via_dir: None,
3481 namespace: None,
3482 })?;
3483 let nearby_office = Predicate::GeoRadius {
3484 field: "office".into(),
3485 km: 50.0,
3486 };
3487 let nearby_office_k = Some(default_max_edges(&nearby_office));
3488 w.create_rule(RuleDef {
3489 name: "nearby_office".into(),
3490 src_label: "Org".into(),
3491 dst_label: "Org".into(),
3492 predicate: nearby_office,
3493 edge_type: "NEARBY_OFFICE".into(),
3494 weight_prop: Some("score".into()),
3495 max_edges: nearby_office_k,
3496 approximate: false,
3497 via_label: None,
3498 via_edge: None,
3499 via_dir: None,
3500 namespace: None,
3501 })?;
3502 let similar_interests = Predicate::VectorSimilar {
3503 field: "embedding".into(),
3504 min: 0.8,
3505 };
3506 let similar_interests_k = Some(default_max_edges(&similar_interests));
3507 w.create_rule(RuleDef {
3508 name: "similar_interests".into(),
3509 src_label: "Person".into(),
3510 dst_label: "Person".into(),
3511 predicate: similar_interests,
3512 edge_type: "SIMILAR".into(),
3513 weight_prop: Some("score".into()),
3514 max_edges: similar_interests_k,
3515 approximate: false,
3516 via_label: None,
3517 via_edge: None,
3518 via_dir: None,
3519 namespace: None,
3520 })?;
3521 }
3522
3523 let r = db.read();
3524 let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
3525 let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
3526 let stats = r.stats();
3527 let suggestion = r.suggest_rules().into_iter().next();
3529
3530 Ok(DemoOutcome {
3531 auto_fk_rules,
3532 sample_query: SAMPLE_QUERY.to_string(),
3533 sample_result,
3534 explanations,
3535 stats,
3536 suggestion,
3537 })
3538}
3539
3540fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
3541 if dir.is_file() {
3542 return Err(CliError(format!(
3543 "demo refuses a non-empty directory: {} is a file",
3544 dir.display()
3545 )));
3546 }
3547 if !dir.exists() {
3548 return Ok(true);
3549 }
3550 Ok(std::fs::read_dir(dir)?.next().is_none())
3551}
3552
3553fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
3554 if dir_is_empty_or_absent(dir)? {
3555 Ok(())
3556 } else {
3557 Err(CliError(format!(
3558 "demo refuses a non-empty directory: {} \
3559 (directory must be empty — including hidden files)",
3560 dir.display()
3561 )))
3562 }
3563}
3564
3565pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
3567 if dir_is_empty_or_absent(dir)? {
3568 Ok(Some(run_demo(dir)?))
3569 } else {
3570 Ok(None)
3571 }
3572}
3573
3574fn json_array(rows: impl IntoIterator<Item = String>) -> String {
3575 let mut out = String::from("[");
3576 let mut first = true;
3577 for row in rows {
3578 if !first {
3579 out.push(',');
3580 }
3581 first = false;
3582 out.push_str(&row);
3583 }
3584 out.push(']');
3585 out
3586}
3587
3588fn wrap_proj(i: usize) -> usize {
3590 (i - 1) % N_PROJECTS + 1
3591}
3592
3593fn skill_window_json(start: usize, len: usize) -> String {
3595 let parts: Vec<String> = (0..len)
3596 .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
3597 .collect();
3598 format!("[{}]", parts.join(","))
3599}
3600
3601fn org_office(i: usize) -> (f64, f64) {
3605 match i {
3606 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"),
3617 }
3618}
3619
3620fn person_embedding_json(i: usize) -> String {
3624 let mut v = [0.0_f64; 8];
3625 match i {
3626 9 | 19 | 29 => {
3627 v[0] = 0.8;
3628 v[1] = 0.6;
3629 }
3630 10 | 20 | 30 => {
3631 v[0] = 0.6;
3632 v[1] = 0.8;
3633 }
3634 _ => {
3635 let axis = (i - 1) % 10;
3636 debug_assert!(axis < 8);
3637 v[axis] = 1.0;
3638 }
3639 }
3640 let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
3641 format!("[{}]", parts.join(","))
3642}
3643
3644fn org_json() -> String {
3645 json_array((1..=N_ORGS).map(|i| {
3646 let year = 2010 + (i as i64 - 1);
3647 let (lat, lon) = org_office(i);
3648 format!(
3649 r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
3650 skill_window_json(i, 3)
3651 )
3652 }))
3653}
3654
3655fn project_json() -> String {
3656 json_array((1..=N_PROJECTS).map(|i| {
3657 let org = (i - 1) % N_ORGS + 1;
3658 format!(
3659 r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
3660 skill_window_json(i, 3)
3661 )
3662 }))
3663}
3664
3665fn person_json() -> String {
3666 json_array((1..=N_PEOPLE).map(|i| {
3667 let org = (i - 1) % N_ORGS + 1;
3668 let proj = (i - 1) % N_PROJECTS + 1;
3669 format!(
3670 r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
3671 person_embedding_json(i),
3672 skill_window_json(proj, 3)
3673 )
3674 }))
3675}
3676
3677pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
3679 let mut buf = String::new();
3680 let _ = writeln!(buf, "== demo ==");
3681 let _ = writeln!(
3682 buf,
3683 "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
3684 );
3685 let _ = writeln!(
3686 buf,
3687 "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
3688 );
3689 let _ = writeln!(
3690 buf,
3691 "numeric rule: founded_within (Org.founded_year, tolerance 2)"
3692 );
3693 let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
3694 let _ = writeln!(
3695 buf,
3696 "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
3697 );
3698 let _ = writeln!(buf);
3699 let _ = writeln!(buf, "== auto-FK rules ==");
3700 let mut names = out.auto_fk_rules.clone();
3701 names.sort();
3702 for name in names {
3703 let _ = writeln!(buf, " {name}");
3704 }
3705 let _ = writeln!(buf);
3706 let _ = writeln!(buf, "== query ==");
3707 let _ = writeln!(buf, "{}", out.sample_query);
3708 let _ = writeln!(buf);
3709 let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
3710 for i in 0..out.sample_result.len() {
3711 let cells: Vec<String> = out
3712 .sample_result
3713 .columns()
3714 .iter()
3715 .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
3716 .collect();
3717 let _ = writeln!(buf, " {}", cells.join(" "));
3718 }
3719 let _ = writeln!(buf);
3720 let _ = writeln!(
3721 buf,
3722 "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
3723 );
3724 for e in &out.explanations {
3725 let weight = e
3726 .weight
3727 .map(|w| fmt_value(&Value::Float(w)))
3728 .unwrap_or_else(|| "none".into());
3729 let _ = writeln!(
3730 buf,
3731 " rule={} type={} {}→{} weight={}",
3732 e.rule, e.edge_type, e.src_key, e.dst_key, weight
3733 );
3734 }
3735 let _ = writeln!(buf);
3736 let _ = writeln!(buf, "== serve ==");
3737 let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
3738
3739 if let Some(s) = &out.suggestion {
3741 let _ = writeln!(buf);
3742 let _ = writeln!(buf, "== suggested rule (teaser) ==");
3743 let _ = writeln!(buf, " {}", s.def.name);
3744 let _ = writeln!(
3745 buf,
3746 " {} → {} via {:?}",
3747 s.def.src_label, s.def.dst_label, s.def.predicate
3748 );
3749 let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
3750 let _ = writeln!(buf, " {}", s.rationale);
3751 let _ = writeln!(
3752 buf,
3753 " (run `mushroomdb suggest {}` for full analysis)",
3754 dir.display()
3755 );
3756 }
3757
3758 buf
3759}
3760
3761pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
3763 let db = GraphDb::open(dir)?;
3764 Ok(db.suggest_rules())
3765}
3766
3767pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
3769 let mut buf = String::new();
3770 if suggestions.is_empty() {
3771 let _ = writeln!(
3772 buf,
3773 "no rule suggestions (database may be empty or rules already cover all patterns)"
3774 );
3775 return buf;
3776 }
3777 let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
3778 for (i, s) in suggestions.iter().enumerate() {
3779 let _ = writeln!(buf);
3780 let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
3781 let _ = writeln!(
3782 buf,
3783 " {} → {} via {:?}",
3784 s.def.src_label, s.def.dst_label, s.def.predicate
3785 );
3786 let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
3787 let _ = writeln!(buf, " rationale : {}", s.rationale);
3788 if !s.examples.is_empty() {
3789 let _ = writeln!(buf, " examples :");
3790 for (src, dst, score) in &s.examples {
3791 let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
3792 }
3793 }
3794 let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
3795 let _ = writeln!(
3796 buf,
3797 " to apply : POST /rules or db.create_rule(suggestion.def)"
3798 );
3799 }
3800 buf
3801}
3802
3803fn fmt_value(v: &Value) -> String {
3804 match v {
3805 Value::Int(i) => i.to_string(),
3806 Value::Float(f) => {
3807 let s = format!("{f}");
3808 if s.contains('.') || s.contains('e') || s.contains('E') {
3809 s
3810 } else {
3811 format!("{s}.0")
3812 }
3813 }
3814 Value::Str(s) => s.clone(),
3815 Value::Bool(b) => b.to_string(),
3816 Value::List(xs) => {
3817 let inner: Vec<String> = xs.iter().map(fmt_value).collect();
3818 format!("[{}]", inner.join(", "))
3819 }
3820 Value::Map(m) => {
3821 let inner: Vec<String> = m
3822 .iter()
3823 .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
3824 .collect();
3825 format!("{{{}}}", inner.join(", "))
3826 }
3827 Value::Date(_)
3828 | Value::LocalDateTime(_)
3829 | Value::DateTime { .. }
3830 | Value::Duration { .. }
3831 | Value::Binary(_) => mushroomdb::v11_display_text(v).unwrap_or_default(),
3832 Value::Null => "null".to_string(),
3834 }
3835}
3836
3837fn fmt_cell(cell: Option<&Value>) -> String {
3838 match cell {
3839 None => "null".into(),
3840 Some(v) => fmt_value(v),
3841 }
3842}
3843
3844#[cfg(test)]
3845mod tests {
3846 use super::*;
3847 use std::collections::BTreeSet;
3848 use std::net::SocketAddr;
3849 use std::path::PathBuf;
3850
3851 fn tmp(name: &str) -> PathBuf {
3852 let nanos = std::time::SystemTime::now()
3853 .duration_since(std::time::UNIX_EPOCH)
3854 .expect("clock")
3855 .as_nanos();
3856 let d = std::env::temp_dir().join(format!(
3857 "graphdb-cli-{}-{}-{}",
3858 name,
3859 std::process::id(),
3860 nanos
3861 ));
3862 let _ = std::fs::remove_dir_all(&d);
3863 d
3864 }
3865
3866 fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
3867 let g = db.read();
3868 let mut out = BTreeSet::new();
3869 for i in 1..=N_ORGS {
3870 let src = format!("org-{i:02}");
3871 if let Ok(nbrs) = g.neighbors(&src, etype, mushroomdb::Direction::Out) {
3872 for dst in nbrs {
3873 out.insert((src.clone(), dst));
3874 }
3875 }
3876 }
3877 for i in 1..=N_PEOPLE {
3878 let src = format!("person-{i:02}");
3879 if let Ok(nbrs) = g.neighbors(&src, etype, mushroomdb::Direction::Out) {
3880 for dst in nbrs {
3881 out.insert((src.clone(), dst));
3882 }
3883 }
3884 }
3885 out
3886 }
3887
3888 fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
3889 let hits: Vec<_> = db
3890 .read()
3891 .explain(a, b)
3892 .expect("explain")
3893 .into_iter()
3894 .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
3895 .collect();
3896 assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
3897 let got = hits[0].weight.expect("weighted");
3898 assert!(
3899 (got - want).abs() < 1e-12,
3900 "{rule} {a}→{b}: got {got} want {want}"
3901 );
3902 }
3903
3904 fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
3905 const R: f64 = 6371.0088;
3906 let phi1 = lat1.to_radians();
3907 let phi2 = lat2.to_radians();
3908 let dphi = (lat2 - lat1).to_radians();
3909 let dlam = (lon2 - lon1).to_radians();
3910 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
3911 .clamp(0.0, 1.0);
3912 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
3913 R * c
3914 }
3915
3916 fn default_bind() -> SocketAddr {
3917 SocketAddr::from(([127, 0, 0, 1], 8080))
3918 }
3919
3920 #[test]
3921 fn parse_args_table() {
3922 struct Case {
3923 args: &'static [&'static str],
3924 check: fn(Result<Command, String>),
3925 }
3926
3927 let cases = [
3928 Case {
3929 args: &[],
3930 check: |r| match r {
3931 Ok(Command::Help) => {}
3932 other => panic!("no-args → Help, got {other:?}"),
3933 },
3934 },
3935 Case {
3936 args: &["--help"],
3937 check: |r| match r {
3938 Ok(Command::Help) => {}
3939 other => panic!("--help → Help, got {other:?}"),
3940 },
3941 },
3942 Case {
3943 args: &["-h"],
3944 check: |r| match r {
3945 Ok(Command::Help) => {}
3946 other => panic!("-h → Help, got {other:?}"),
3947 },
3948 },
3949 Case {
3950 args: &["serve", "/tmp/demo-db"],
3951 check: |r| match r {
3952 Ok(Command::Serve {
3953 db_dir,
3954 addr,
3955 ui,
3956 demo_if_empty,
3957 token,
3958 role_tokens,
3959 snapshot_every,
3960 shutdown_timeout: _,
3961 restore_from,
3962 tls_cert,
3963 tls_key,
3964 bolt,
3965 tx,
3966 }) => {
3967 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
3968 assert_eq!(addr, default_bind());
3969 assert_eq!(ui, super::ServeUi::Embedded);
3970 assert!(!demo_if_empty);
3971 assert_eq!(token, None);
3972 assert!(role_tokens.is_empty());
3973 assert_eq!(snapshot_every, None);
3974 assert_eq!(restore_from, None);
3975 assert_eq!(tls_cert, None);
3976 assert_eq!(tls_key, None);
3977 assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
3978 assert_eq!(tx, super::TxFlags::default());
3979 }
3980 other => panic!("serve <dir> → Serve default addr, got {other:?}"),
3981 },
3982 },
3983 Case {
3984 args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
3985 check: |r| match r {
3986 Ok(Command::Serve {
3987 db_dir,
3988 addr,
3989 ui,
3990 demo_if_empty,
3991 token,
3992 role_tokens,
3993 snapshot_every,
3994 shutdown_timeout: _,
3995 restore_from,
3996 tls_cert,
3997 tls_key,
3998 bolt,
3999 tx,
4000 }) => {
4001 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4002 assert_eq!(
4003 addr,
4004 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
4005 );
4006 assert_eq!(ui, super::ServeUi::Embedded);
4007 assert!(!demo_if_empty);
4008 assert_eq!(token, None);
4009 assert!(role_tokens.is_empty());
4010 assert_eq!(snapshot_every, None);
4011 assert_eq!(restore_from, None);
4012 assert_eq!(tls_cert, None);
4013 assert_eq!(tls_key, None);
4014 assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4015 assert_eq!(tx, super::TxFlags::default());
4016 }
4017 other => panic!("serve --addr after dir, got {other:?}"),
4018 },
4019 },
4020 Case {
4021 args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
4022 check: |r| match r {
4023 Ok(Command::Serve {
4024 db_dir,
4025 addr,
4026 ui,
4027 demo_if_empty,
4028 token,
4029 role_tokens,
4030 snapshot_every,
4031 shutdown_timeout: _,
4032 restore_from,
4033 tls_cert,
4034 tls_key,
4035 bolt,
4036 tx,
4037 }) => {
4038 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4039 assert_eq!(
4040 addr,
4041 "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
4042 );
4043 assert_eq!(ui, super::ServeUi::Embedded);
4044 assert!(!demo_if_empty);
4045 assert_eq!(token, None);
4046 let _ = role_tokens; assert_eq!(snapshot_every, None);
4048 assert_eq!(restore_from, None);
4049 assert_eq!(tls_cert, None);
4050 assert_eq!(tls_key, None);
4051 assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4052 assert_eq!(tx, super::TxFlags::default());
4053 }
4054 other => panic!("serve --addr=VALUE, got {other:?}"),
4055 },
4056 },
4057 Case {
4058 args: &["mcp", "/tmp/demo-db"],
4059 check: |r| match r {
4060 Ok(Command::Mcp {
4061 db_dir,
4062 auto,
4063 all_tools,
4064 }) => {
4065 assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
4066 assert!(!auto);
4067 assert!(!all_tools, "the short list is the default");
4068 }
4069 other => panic!("mcp <dir>, got {other:?}"),
4070 },
4071 },
4072 Case {
4073 args: &["stats", "/tmp/demo-db"],
4074 check: |r| match r {
4075 Ok(Command::Stats { db_dir }) => {
4076 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4077 }
4078 other => panic!("stats <dir>, got {other:?}"),
4079 },
4080 },
4081 Case {
4082 args: &["demo", "/tmp/demo-db"],
4083 check: |r| match r {
4084 Ok(Command::Demo { db_dir }) => {
4085 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4086 }
4087 other => panic!("demo <dir>, got {other:?}"),
4088 },
4089 },
4090 Case {
4091 args: &["serve"],
4092 check: |r| {
4093 let e = r.expect_err("serve without dir");
4094 assert!(
4095 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4096 "missing-dir error should mention dir, got {e}"
4097 );
4098 },
4099 },
4100 Case {
4101 args: &["mcp"],
4102 check: |r| {
4103 let e = r.expect_err("mcp without dir");
4104 assert!(
4105 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4106 "missing-dir error should mention dir, got {e}"
4107 );
4108 },
4109 },
4110 Case {
4111 args: &["stats"],
4112 check: |r| {
4113 let e = r.expect_err("stats without dir");
4114 assert!(
4115 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4116 "missing-dir error should mention dir, got {e}"
4117 );
4118 },
4119 },
4120 Case {
4121 args: &["demo"],
4122 check: |r| {
4123 let e = r.expect_err("demo without dir");
4124 assert!(
4125 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4126 "missing-dir error should mention dir, got {e}"
4127 );
4128 },
4129 },
4130 Case {
4131 args: &["serve", "/tmp/demo-db", "--addr"],
4132 check: |r| {
4133 let e = r.expect_err("--addr missing value");
4134 assert!(
4135 e.to_lowercase().contains("addr"),
4136 "--addr missing value should mention addr, got {e}"
4137 );
4138 },
4139 },
4140 Case {
4141 args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
4142 check: |r| {
4143 let e = r.expect_err("invalid addr");
4144 assert!(
4145 e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
4146 "invalid addr should mention address, got {e}"
4147 );
4148 },
4149 },
4150 Case {
4151 args: &["frobnicate", "/tmp/demo-db"],
4152 check: |r| {
4153 let e = r.expect_err("unknown command");
4154 assert!(
4155 e.to_lowercase().contains("unknown")
4156 || e.to_lowercase().contains("frobnicate"),
4157 "unknown command should name it, got {e}"
4158 );
4159 },
4160 },
4161 Case {
4162 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
4163 check: |r| match r {
4164 Ok(Command::Serve { ui, .. }) => {
4165 assert_eq!(
4166 ui,
4167 super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
4168 );
4169 }
4170 other => panic!("serve --ui <dir>, got {other:?}"),
4171 },
4172 },
4173 Case {
4174 args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
4175 check: |r| match r {
4176 Ok(Command::Serve { ui, .. }) => {
4177 assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
4178 }
4179 other => panic!("serve --ui=VALUE, got {other:?}"),
4180 },
4181 },
4182 Case {
4183 args: &["serve", "/tmp/demo-db", "--ui"],
4184 check: |r| {
4185 let e = r.expect_err("--ui missing value");
4186 assert!(
4187 e.to_lowercase().contains("ui"),
4188 "--ui missing value should mention ui, got {e}"
4189 );
4190 },
4191 },
4192 Case {
4193 args: &["serve", "/tmp/demo-db", "--no-ui"],
4194 check: |r| match r {
4195 Ok(Command::Serve { ui, .. }) => {
4196 assert_eq!(ui, super::ServeUi::None);
4197 }
4198 other => panic!("serve --no-ui, got {other:?}"),
4199 },
4200 },
4201 Case {
4202 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
4203 check: |r| {
4204 let e = r.expect_err("combine --ui and --no-ui");
4205 assert!(
4206 e.contains("--ui") && e.contains("--no-ui"),
4207 "conflict should name both flags, got {e}"
4208 );
4209 },
4210 },
4211 Case {
4212 args: &["serve", "/tmp/demo-db", "extra"],
4213 check: |r| {
4214 let e = r.expect_err("extra positional");
4215 assert!(
4216 e.to_lowercase().contains("unexpected")
4217 || e.to_lowercase().contains("extra"),
4218 "extra arg should be rejected, got {e}"
4219 );
4220 },
4221 },
4222 Case {
4223 args: &[
4224 "serve",
4225 "/data",
4226 "--addr",
4227 "0.0.0.0:8080",
4228 "--demo-if-empty",
4229 ],
4230 check: |r| match r {
4231 Ok(Command::Serve {
4232 db_dir,
4233 addr,
4234 demo_if_empty,
4235 ui,
4236 token,
4237 snapshot_every,
4238 ..
4239 }) => {
4240 assert_eq!(db_dir, PathBuf::from("/data"));
4241 assert_eq!(
4242 addr,
4243 "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
4244 );
4245 assert!(demo_if_empty);
4246 assert_eq!(ui, super::ServeUi::Embedded);
4247 assert_eq!(token, None);
4248 assert_eq!(snapshot_every, None);
4249 }
4250 other => panic!("serve --demo-if-empty docker default, got {other:?}"),
4251 },
4252 },
4253 Case {
4254 args: &["install", "--project", "--delivery", "cli"],
4255 check: |r| match r {
4256 Ok(Command::Install(opts)) => {
4257 assert_eq!(opts.scope, Some(install::Scope::Project));
4258 assert_eq!(opts.delivery, install::Delivery::Cli);
4259 }
4260 other => panic!("install --delivery cli, got {other:?}"),
4261 },
4262 },
4263 Case {
4264 args: &["install", "--delivery=mcp"],
4265 check: |r| match r {
4266 Ok(Command::Install(opts)) => {
4267 assert_eq!(opts.delivery, install::Delivery::Mcp)
4268 }
4269 other => panic!("install --delivery=mcp, got {other:?}"),
4270 },
4271 },
4272 Case {
4273 args: &["install"],
4276 check: |r| match r {
4277 Ok(Command::Install(opts)) => {
4278 assert_eq!(opts.delivery, install::Delivery::Both)
4279 }
4280 other => panic!("install, got {other:?}"),
4281 },
4282 },
4283 Case {
4284 args: &["install", "--delivery", "sideways"],
4285 check: |r| match r {
4286 Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
4287 other => panic!("a bad --delivery must be refused, got {other:?}"),
4288 },
4289 },
4290 Case {
4291 args: &["install", "--always-load"],
4292 check: |r| match r {
4293 Ok(Command::Install(opts)) => assert!(opts.always_load),
4294 other => panic!("install --always-load, got {other:?}"),
4295 },
4296 },
4297 Case {
4298 args: &["install", "--no-git-hooks"],
4301 check: |r| match r {
4302 Ok(Command::Install(_)) => {}
4303 other => panic!("install --no-git-hooks, got {other:?}"),
4304 },
4305 },
4306 Case {
4307 args: &["install", "--delivery", "mcp", "--db", "./mem"],
4311 check: |r| match r {
4312 Ok(Command::Install(opts)) => assert!(opts.always_load),
4313 other => panic!("install --delivery mcp --db, got {other:?}"),
4314 },
4315 },
4316 Case {
4317 args: &["install", "--delivery", "both", "--db=./mem"],
4319 check: |r| match r {
4320 Ok(Command::Install(opts)) => assert!(opts.always_load),
4321 other => panic!("install --delivery both --db, got {other:?}"),
4322 },
4323 },
4324 Case {
4325 args: &[
4327 "install",
4328 "--delivery",
4329 "mcp",
4330 "--db",
4331 "./mem",
4332 "--no-always-load",
4333 ],
4334 check: |r| match r {
4335 Ok(Command::Install(opts)) => assert!(!opts.always_load),
4336 other => panic!("install --no-always-load, got {other:?}"),
4337 },
4338 },
4339 Case {
4340 args: &["install", "--delivery", "cli", "--db", "./mem"],
4344 check: |r| match r {
4345 Ok(Command::Install(opts)) => assert!(!opts.always_load),
4346 other => panic!("install --delivery cli --db, got {other:?}"),
4347 },
4348 },
4349 Case {
4350 args: &["install", "--always-load"],
4354 check: |r| match r {
4355 Ok(Command::Install(opts)) => assert!(opts.always_load),
4356 other => panic!("install --always-load, got {other:?}"),
4357 },
4358 },
4359 Case {
4360 args: &["install"],
4362 check: |r| match r {
4363 Ok(Command::Install(opts)) => assert!(!opts.always_load),
4364 other => panic!("install, got {other:?}"),
4365 },
4366 },
4367 ];
4368
4369 for case in &cases {
4370 (case.check)(parse_args(case.args));
4371 }
4372 }
4373
4374 #[test]
4379 fn the_retired_code_graph_subcommands_are_unknown() {
4380 for sub in ["map", "explore", "context", "impact", "owners"] {
4381 match parse_args(&[sub, "/tmp/db"]) {
4382 Err(e) => assert_eq!(e, format!("unknown command: {sub}")),
4383 other => panic!("{sub} still parses: {other:?}"),
4384 }
4385 assert!(
4386 !usage().contains(&format!("mushroomdb {sub} ")),
4387 "the help still names {sub}"
4388 );
4389 }
4390 }
4391
4392 #[test]
4397 fn the_retired_hook_bodies_parse_as_inert() {
4398 for sub in ["touch", "intercept", "impact-hook", "enrich", "sync"] {
4399 for args in [
4400 vec![sub],
4401 vec![sub, "--auto"],
4402 vec![sub, "/tmp/db", "--whatever", "x"],
4403 ] {
4404 assert_eq!(parse_args(&args), Ok(Command::RetiredHook), "{args:?}");
4405 }
4406 assert!(
4407 !usage().contains(&format!("mushroomdb {sub}")),
4408 "the help still names {sub}"
4409 );
4410 }
4411 }
4412
4413 #[test]
4414 fn serve_default_addr_is_loopback_8080() {
4415 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4416 Command::Serve { addr, .. } => {
4417 assert_eq!(
4418 addr,
4419 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
4420 );
4421 }
4422 other => panic!("{other:?}"),
4423 }
4424 }
4425
4426 #[test]
4427 fn the_shutdown_plan_uses_the_nominal_split_with_room_and_scales_without() {
4428 let s = Duration::from_secs;
4429 let p = shutdown_plan(s(55), s(22));
4431 assert_eq!((p.drain, p.snapshot, p.backup), (s(25), s(8), s(22)));
4432 let p = shutdown_plan(s(100), s(22));
4434 assert_eq!((p.drain, p.snapshot, p.backup), (s(25), s(53), s(22)));
4435 for total in [1, 2, 5, 10, 25, 40, 54] {
4437 for bound in [0, 1, 3, 22, 60] {
4438 let p = shutdown_plan(s(total), s(bound));
4439 assert!(
4440 p.drain + p.snapshot + p.backup <= s(total),
4441 "{total}/{bound}: {p:?}"
4442 );
4443 assert!(p.drain <= s(SHUTDOWN_DRAIN_SECS));
4444 if bound == 0 {
4445 assert_eq!(p.backup, Duration::ZERO);
4446 } else {
4447 assert!(p.backup <= s(bound).min(s(total)));
4448 assert!(p.backup >= s(2).min(s(bound)).min(s(total)), "{p:?}");
4449 }
4450 }
4451 }
4452 let p = shutdown_plan(s(25), s(22));
4454 assert!(p.backup >= s(9) && p.drain >= s(10), "{p:?}");
4455 }
4456
4457 #[test]
4458 fn serve_parses_the_bolt_flags_in_both_spellings() {
4459 let bolt = |args: &[&str]| match parse_args(args).unwrap() {
4460 Command::Serve { bolt, .. } => bolt,
4461 other => panic!("{other:?}"),
4462 };
4463 let flags = bolt(&[
4464 "serve",
4465 "/tmp/db",
4466 "--bolt-port",
4467 "7687",
4468 "--bolt-tls",
4469 "--bolt-advertised-host=db.example.test",
4470 "--bolt-advertised-port",
4471 "17687",
4472 "--bolt-database=orders",
4473 "--bolt-max-connections",
4474 "8",
4475 "--bolt-tx-timeout-secs=12",
4476 "--bookmark-timeout-secs",
4477 "3",
4478 ]);
4479 assert_eq!(
4480 flags,
4481 serve_bolt::BoltFlags {
4482 port: Some(7687),
4483 tls: Some(true),
4484 advertised_host: Some("db.example.test".into()),
4485 advertised_port: Some(17687),
4486 database: Some("orders".into()),
4487 max_connections: Some(8),
4488 tx_timeout: Some(Duration::from_secs(12)),
4489 bookmark_timeout: Some(Duration::from_secs(3)),
4490 }
4491 );
4492 assert_eq!(
4493 bolt(&["serve", "/tmp/db", "--bolt-port=0", "--bolt-plaintext"]).tls,
4494 Some(false)
4495 );
4496 for bad in [
4497 &["serve", "/tmp/db", "--bolt-port", "70000"][..],
4498 &["serve", "/tmp/db", "--bolt-port"],
4499 &["serve", "/tmp/db", "--bolt-tls", "--bolt-plaintext"],
4500 &["serve", "/tmp/db", "--bolt-tx-timeout-secs", "0"],
4501 &["serve", "/tmp/db", "--bookmark-timeout-secs=x"],
4502 &["serve", "/tmp/db", "--bolt-max-connections", "-1"],
4503 ] {
4504 assert!(parse_args(bad).is_err(), "{bad:?}");
4505 }
4506 }
4507
4508 #[test]
4509 fn serve_parses_the_tx_flags_in_both_spellings_and_refuses_zero() {
4510 let tx = |args: &[&str]| match parse_args(args).unwrap() {
4511 Command::Serve { tx, .. } => tx,
4512 other => panic!("{other:?}"),
4513 };
4514 assert_eq!(tx(&["serve", "/tmp/db"]), TxFlags::default());
4515 assert_eq!(
4516 tx(&[
4517 "serve",
4518 "/tmp/db",
4519 "--tx-max",
4520 "7",
4521 "--tx-timeout-secs=4",
4522 "--tx-max-lifetime-secs",
4523 "60",
4524 ]),
4525 TxFlags {
4526 max_open: NonZeroU64::new(7),
4527 idle_timeout_secs: NonZeroU64::new(4),
4528 max_lifetime_secs: NonZeroU64::new(60),
4529 }
4530 );
4531 for bad in [
4532 &["serve", "/tmp/db", "--tx-max", "0"][..],
4533 &["serve", "/tmp/db", "--tx-max"],
4534 &["serve", "/tmp/db", "--tx-timeout-secs=0"],
4535 &["serve", "/tmp/db", "--tx-max-lifetime-secs", "-5"],
4536 &["serve", "/tmp/db", "--tx-timeout-secs", "soon"],
4537 ] {
4538 assert!(parse_args(bad).is_err(), "{bad:?}");
4539 }
4540 }
4541
4542 #[test]
4543 fn a_tx_flag_wins_over_its_variable_and_an_unset_flag_leaves_the_variable() {
4544 let env = |name: &str| match name {
4545 server::TX_MAX_ENV => Some("5".to_string()),
4546 server::TX_TIMEOUT_ENV => Some("9".to_string()),
4547 server::TX_MAX_LIFETIME_ENV => Some("99".to_string()),
4548 _ => None,
4549 };
4550 let s = Duration::from_secs;
4551 let from_env = resolve_tx_limits(&TxFlags::default(), &env);
4552 assert_eq!(
4553 (
4554 from_env.max_open,
4555 from_env.idle_timeout,
4556 from_env.max_lifetime
4557 ),
4558 (5, s(9), s(99))
4559 );
4560 let flags = TxFlags {
4561 max_open: NonZeroU64::new(2),
4562 idle_timeout_secs: None,
4563 max_lifetime_secs: NonZeroU64::new(40),
4564 };
4565 let mixed = resolve_tx_limits(&flags, &env);
4566 assert_eq!(
4567 (mixed.max_open, mixed.idle_timeout, mixed.max_lifetime),
4568 (2, s(9), s(40))
4569 );
4570 assert_eq!(
4572 resolve_tx_limits(&TxFlags::default(), &|_| None),
4573 server::TxLimits::default()
4574 );
4575 let invalid = |name: &str| (name == server::TX_MAX_ENV).then(|| "lots".to_string());
4577 let given = TxFlags {
4578 max_open: NonZeroU64::new(3),
4579 ..TxFlags::default()
4580 };
4581 assert_eq!(resolve_tx_limits(&given, &invalid).max_open, 3);
4582 }
4583
4584 #[test]
4585 fn serve_shutdown_timeout_parses_seconds_and_defaults_to_55() {
4586 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4587 Command::Serve {
4588 shutdown_timeout, ..
4589 } => assert_eq!(shutdown_timeout, Duration::from_secs(55)),
4590 other => panic!("{other:?}"),
4591 }
4592 match parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs", "90"]).unwrap() {
4593 Command::Serve {
4594 shutdown_timeout, ..
4595 } => assert_eq!(shutdown_timeout, Duration::from_secs(90)),
4596 other => panic!("{other:?}"),
4597 }
4598 match parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs=7"]).unwrap() {
4599 Command::Serve {
4600 shutdown_timeout, ..
4601 } => assert_eq!(shutdown_timeout, Duration::from_secs(7)),
4602 other => panic!("{other:?}"),
4603 }
4604 for bad in ["0", "nope"] {
4605 let err =
4606 parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs", bad]).unwrap_err();
4607 assert!(err.contains("shutdown-timeout-secs"), "{err}");
4608 }
4609 assert!(parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs"]).is_err());
4610 }
4611
4612 #[test]
4613 fn serve_snapshot_every_parses_seconds() {
4614 match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
4615 Command::Serve { snapshot_every, .. } => {
4616 assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
4617 }
4618 other => panic!("{other:?}"),
4619 }
4620 match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
4621 Command::Serve { snapshot_every, .. } => {
4622 assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
4623 }
4624 other => panic!("{other:?}"),
4625 }
4626 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4627 Command::Serve { snapshot_every, .. } => {
4628 assert_eq!(snapshot_every, None);
4629 }
4630 other => panic!("{other:?}"),
4631 }
4632 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
4633 assert!(
4634 err.contains("snapshot-every"),
4635 "missing value should name the flag, got {err}"
4636 );
4637 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
4638 assert!(
4639 err.contains("snapshot-every"),
4640 "zero should be rejected, got {err}"
4641 );
4642 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
4643 assert!(
4644 err.contains("snapshot-every"),
4645 "invalid value should name the flag, got {err}"
4646 );
4647 }
4648
4649 #[test]
4650 fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
4651 match parse_args(&[
4653 "serve",
4654 "/tmp/db",
4655 "--addr",
4656 "0.0.0.0:8080",
4657 "--token",
4658 "s3cret",
4659 ])
4660 .unwrap()
4661 {
4662 Command::Serve { token, addr, .. } => {
4663 assert_eq!(token.as_deref(), Some("s3cret"));
4664 assert_eq!(addr.ip().to_string(), "0.0.0.0");
4665 }
4666 other => panic!("{other:?}"),
4667 }
4668 }
4669
4670 #[test]
4671 fn parse_build_index_both_forms_and_a_missing_value() {
4672 match parse_args(&["build-index", "/tmp/db"]).unwrap() {
4673 Command::BuildIndex { db_dir, rule } => {
4674 assert_eq!(db_dir, PathBuf::from("/tmp/db"));
4675 assert_eq!(rule, None);
4676 }
4677 other => panic!("{other:?}"),
4678 }
4679 for args in [
4680 vec!["build-index", "/tmp/db", "--rule", "sim"],
4681 vec!["build-index", "/tmp/db", "--rule=sim"],
4682 ] {
4683 match parse_args(&args).unwrap() {
4684 Command::BuildIndex { db_dir, rule } => {
4685 assert_eq!(db_dir, PathBuf::from("/tmp/db"));
4686 assert_eq!(rule.as_deref(), Some("sim"), "{args:?}");
4687 }
4688 other => panic!("{other:?}"),
4689 }
4690 }
4691 assert_eq!(
4692 parse_args(&["build-index", "/tmp/db", "--rule"]).unwrap_err(),
4693 "--rule requires a value"
4694 );
4695 assert_eq!(
4696 parse_args(&["build-index", "/tmp/db", "--rule="]).unwrap_err(),
4697 "--rule requires a value"
4698 );
4699 assert_eq!(
4700 parse_args(&["build-index"]).unwrap_err(),
4701 "build-index requires <db-dir>"
4702 );
4703 assert!(usage().contains("mushroomdb build-index <db-dir> [--rule <name>]"));
4704 }
4705
4706 #[test]
4707 fn parse_snapshot_and_query() {
4708 for (args, want) in [
4710 (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
4711 (
4712 vec!["snapshot", "/tmp/db", "--archive-wal"],
4713 WalDisposition::Archive,
4714 ),
4715 (
4716 vec!["snapshot", "/tmp/db", "--keep-wal"],
4717 WalDisposition::Keep,
4718 ),
4719 (
4720 vec!["snapshot", "/tmp/db", "--truncate"],
4721 WalDisposition::Truncate,
4722 ),
4723 ] {
4724 match parse_args(&args).unwrap() {
4725 Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
4726 other => panic!("{other:?}"),
4727 }
4728 }
4729 match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
4730 Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
4731 other => panic!("{other:?}"),
4732 }
4733 match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
4734 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
4735 other => panic!("{other:?}"),
4736 }
4737 match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
4738 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
4739 other => panic!("{other:?}"),
4740 }
4741 let text = usage();
4742 assert!(
4743 text.contains("query"),
4744 "usage should mention query, got:\n{text}"
4745 );
4746 assert!(
4747 text.contains("snapshot"),
4748 "usage should mention snapshot, got:\n{text}"
4749 );
4750 }
4751
4752 #[test]
4756 fn usage_describes_one_surface_and_the_memory_brief() {
4757 let text = usage();
4758 assert!(
4759 text.contains(
4760 "--all-tools lists every served tool; the default lists the association tools on every store,\n\
4761 \x20 a store `ingest-git` built included"
4762 ),
4763 "mcp help line, got:\n{text}"
4764 );
4765 assert!(
4766 text.contains(
4767 "mushroomdb brief <db-dir>|--auto hook body: the store's schema in one block — labels,\n\
4768 \x20 edge types, history depth, roles and one worked call per\n\
4769 \x20 question kind; byte-stable, so a session host caches it once"
4770 ),
4771 "brief help line, got:\n{text}"
4772 );
4773 for stale in [
4774 "28 tools",
4775 "explore, query, stats",
4776 "most called symbols",
4777 "synced sha",
4778 ] {
4779 assert!(!text.contains(stale), "usage still says {stale:?}");
4780 }
4781 }
4782
4783 #[test]
4784 fn parse_keys_subcommands() {
4785 match parse_args(&["keys", "add", "/d", "app", "--tier", "write"]).unwrap() {
4786 Command::KeysAdd {
4787 name, tier, role, ..
4788 } => assert_eq!((name.as_str(), tier.as_str(), role), ("app", "write", None)),
4789 other => panic!("{other:?}"),
4790 }
4791 match parse_args(&["keys", "add", "/d", "t", "--role", "r", "--tier", "read"]).unwrap() {
4792 Command::KeysAdd { tier, role, .. } => {
4793 assert_eq!((tier.as_str(), role.as_deref()), ("read", Some("r")))
4794 }
4795 other => panic!("{other:?}"),
4796 }
4797 assert!(matches!(
4798 parse_args(&["keys", "hash"]),
4799 Ok(Command::KeysHash)
4800 ));
4801 for bad in [
4802 vec!["keys"],
4803 vec!["keys", "hash", "x"],
4804 vec!["keys", "add", "/d", "app"],
4805 vec!["keys", "add", "/d", "--tier", "write"],
4806 vec!["keys", "add", "/d", "app", "--tier", "read"],
4807 vec!["keys", "add", "/d", "app", "--tier", "write", "--role", "r"],
4808 vec!["keys", "add", "/d", "app", "--tier", "root"],
4809 vec![
4810 "keys", "add", "/d", "app", "--tier", "write", "--tier", "read",
4811 ],
4812 vec!["keys", "add", "/d", "app", "--tier", "write", "--bogus"],
4813 ] {
4814 assert!(parse_args(&bad).is_err(), "{bad:?}");
4815 }
4816 }
4817
4818 #[test]
4819 fn usage_lists_every_subcommand() {
4820 let text = usage();
4821 for word in [
4822 "serve",
4823 "mcp",
4824 "stats",
4825 "demo",
4826 "query",
4827 "snapshot",
4828 "--keep-wal",
4829 "mushroomdb",
4830 "--ui",
4831 "--no-ui",
4832 "--demo-if-empty",
4833 "--token",
4834 "--role-token TOKEN:ROLE",
4835 "--tls-cert <pem> --tls-key <pem>",
4836 "a build with the `tls` feature",
4837 "--snapshot-every",
4838 ] {
4839 assert!(
4840 text.contains(word),
4841 "usage should mention {word}, got:\n{text}"
4842 );
4843 }
4844 }
4845
4846 #[test]
4847 fn validate_ui_dir_requires_index_html() {
4848 let missing = tmp("ui-missing");
4849 let err = super::validate_ui_dir(&missing).expect_err("missing dir");
4850 assert!(
4851 err.contains("does not exist"),
4852 "missing dir error, got {err}"
4853 );
4854
4855 let empty = tmp("ui-empty");
4856 std::fs::create_dir_all(&empty).unwrap();
4857 let err = super::validate_ui_dir(&empty).expect_err("no index");
4858 assert!(
4859 err.contains("index.html"),
4860 "missing index.html error, got {err}"
4861 );
4862
4863 let ok = tmp("ui-ok");
4864 std::fs::create_dir_all(&ok).unwrap();
4865 std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
4866 let got = super::validate_ui_dir(&ok).expect("valid ui dir");
4867 assert_eq!(got, ok);
4868 }
4869
4870 #[test]
4871 fn maybe_run_demo_if_empty_seeds_then_skips() {
4872 let dir = tmp("boot-empty");
4873 let first = super::maybe_run_demo_if_empty(&dir)
4874 .expect("empty dir demos")
4875 .expect("Some(DemoOutcome)");
4876 assert_eq!(first.stats.nodes_live, 60);
4877 let db = SharedDb::open(&dir).expect("reopen");
4878 assert!(db.read().has_node("person-01"));
4879 let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
4880 assert!(
4881 second.is_none(),
4882 "second boot must not re-demo a populated volume"
4883 );
4884
4885 let occupied = tmp("boot-occupied");
4886 std::fs::create_dir_all(&occupied).unwrap();
4887 std::fs::write(occupied.join("keep-me"), b"x").unwrap();
4888 let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
4889 assert!(skipped.is_none());
4890 assert_eq!(
4891 std::fs::read(occupied.join("keep-me")).unwrap(),
4892 b"x",
4893 "existing volume contents must be untouched"
4894 );
4895 }
4896
4897 #[test]
4898 fn demo_builder_is_deterministic_and_refuses_second_run() {
4899 let dir = tmp("demo");
4900 let out = run_demo(&dir).expect("first demo run");
4901
4902 assert_eq!(
4903 out.stats.nodes_live, 60,
4904 "10 orgs + 20 projects + 30 people"
4905 );
4906 assert_eq!(out.stats.nodes_tombstoned, 0);
4907 assert_eq!(out.stats.edges, 334);
4915 assert_eq!(
4916 out.stats.rules.len(),
4917 7,
4918 "3 auto-FK + overlap + numeric + geo + vector"
4919 );
4920 let fit = out
4921 .stats
4922 .rules
4923 .iter()
4924 .find(|r| r.name == "skill_fit")
4925 .expect("skill_fit");
4926 assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
4927 let founded = out
4928 .stats
4929 .rules
4930 .iter()
4931 .find(|r| r.name == "founded_within")
4932 .expect("founded_within");
4933 assert_eq!(founded.edges, 34);
4934 let nearby = out
4935 .stats
4936 .rules
4937 .iter()
4938 .find(|r| r.name == "nearby_office")
4939 .expect("nearby_office");
4940 assert_eq!(nearby.edges, 16);
4941 let similar = out
4942 .stats
4943 .rules
4944 .iter()
4945 .find(|r| r.name == "similar_interests")
4946 .expect("similar_interests");
4947 assert_eq!(similar.edges, 114);
4948
4949 let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
4950 names.sort_unstable();
4951 assert_eq!(
4952 names,
4953 vec![
4954 "auto_fk_person_org_id",
4955 "auto_fk_person_project_id",
4956 "auto_fk_project_org_id",
4957 "founded_within",
4958 "nearby_office",
4959 "similar_interests",
4960 "skill_fit",
4961 ]
4962 );
4963
4964 let db = SharedDb::open(&dir).expect("reopen demo");
4968 assert_eq!(
4969 db.read().fulltext_pairs(),
4970 vec![
4971 ("Concept".to_string(), "name".to_string()),
4972 ("Concept".to_string(), "summary".to_string()),
4973 ("Entity".to_string(), "name".to_string()),
4974 ("Event".to_string(), "name".to_string()),
4975 ("Note".to_string(), "text".to_string()),
4976 ("Org".to_string(), "name".to_string()),
4977 ("Person".to_string(), "name".to_string()),
4978 ("Project".to_string(), "name".to_string()),
4979 ]
4980 );
4981
4982 let mut auto = out.auto_fk_rules.clone();
4983 auto.sort();
4984 assert_eq!(
4985 auto,
4986 vec![
4987 "auto_fk_person_org_id".to_string(),
4988 "auto_fk_person_project_id".to_string(),
4989 "auto_fk_project_org_id".to_string(),
4990 ]
4991 );
4992
4993 assert!(
4994 !out.sample_result.is_empty(),
4995 "sample Cypher query must return rows"
4996 );
4997 assert!(
4998 out.sample_query.contains("ORDER BY score DESC"),
4999 "sample query must rank by score, got {}",
5000 out.sample_query
5001 );
5002 let scores: Vec<f64> = (0..out.sample_result.len())
5003 .map(|i| match out.sample_result.get(i, "score") {
5004 Some(Value::Float(f)) => *f,
5005 other => panic!("score col should be Float, got {other:?}"),
5006 })
5007 .collect();
5008 let distinct: std::collections::BTreeSet<u64> =
5009 scores.iter().map(|s| s.to_bits()).collect();
5010 assert!(
5011 distinct.len() >= 2,
5012 "sample results must be visibly ranked, got {scores:?}"
5013 );
5014 for w in scores.windows(2) {
5015 assert!(
5016 w[0] >= w[1],
5017 "scores must be non-increasing, got {scores:?}"
5018 );
5019 }
5020 assert!(
5021 !out.explanations.is_empty(),
5022 "explain(person-01, proj-01) must find the derived edges"
5023 );
5024
5025 let db = SharedDb::open(&dir).expect("reopen demo");
5026 assert_eq!(
5027 directed_pairs(&db, "FOUNDED_WITHIN"),
5028 [
5029 ("org-01", "org-02"),
5030 ("org-01", "org-03"),
5031 ("org-02", "org-01"),
5032 ("org-02", "org-03"),
5033 ("org-02", "org-04"),
5034 ("org-03", "org-01"),
5035 ("org-03", "org-02"),
5036 ("org-03", "org-04"),
5037 ("org-03", "org-05"),
5038 ("org-04", "org-02"),
5039 ("org-04", "org-03"),
5040 ("org-04", "org-05"),
5041 ("org-04", "org-06"),
5042 ("org-05", "org-03"),
5043 ("org-05", "org-04"),
5044 ("org-05", "org-06"),
5045 ("org-05", "org-07"),
5046 ("org-06", "org-04"),
5047 ("org-06", "org-05"),
5048 ("org-06", "org-07"),
5049 ("org-06", "org-08"),
5050 ("org-07", "org-05"),
5051 ("org-07", "org-06"),
5052 ("org-07", "org-08"),
5053 ("org-07", "org-09"),
5054 ("org-08", "org-06"),
5055 ("org-08", "org-07"),
5056 ("org-08", "org-09"),
5057 ("org-08", "org-10"),
5058 ("org-09", "org-07"),
5059 ("org-09", "org-08"),
5060 ("org-09", "org-10"),
5061 ("org-10", "org-08"),
5062 ("org-10", "org-09"),
5063 ]
5064 .into_iter()
5065 .map(|(a, b)| (a.to_string(), b.to_string()))
5066 .collect::<BTreeSet<_>>()
5067 );
5068 assert_eq!(
5069 directed_pairs(&db, "NEARBY_OFFICE"),
5070 [
5071 ("org-01", "org-07"),
5072 ("org-01", "org-10"),
5073 ("org-02", "org-09"),
5074 ("org-03", "org-08"),
5075 ("org-04", "org-05"),
5076 ("org-04", "org-06"),
5077 ("org-05", "org-04"),
5078 ("org-05", "org-06"),
5079 ("org-06", "org-04"),
5080 ("org-06", "org-05"),
5081 ("org-07", "org-01"),
5082 ("org-07", "org-10"),
5083 ("org-08", "org-03"),
5084 ("org-09", "org-02"),
5085 ("org-10", "org-01"),
5086 ("org-10", "org-07"),
5087 ]
5088 .into_iter()
5089 .map(|(a, b)| (a.to_string(), b.to_string()))
5090 .collect::<BTreeSet<_>>()
5091 );
5092 assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
5093 let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
5094 assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
5095 assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
5096 assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
5097
5098 let err = run_demo(&dir).expect_err("second run into the same dir");
5099 let msg = err.to_string().to_lowercase();
5100 assert!(
5101 msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
5102 "refuse message must mention non-empty dir, got {err}"
5103 );
5104 assert!(
5105 msg.contains("hidden"),
5106 "refuse message must mention hidden files, got {err}"
5107 );
5108
5109 let _ = std::fs::remove_dir_all(&dir);
5110 }
5111
5112 #[test]
5113 fn run_snapshot_writes_snapshot_bin() {
5114 let dir = tmp("snapshot-cli");
5115 {
5116 let mut db = GraphDb::open(&dir).expect("open");
5117 db.insert_node("Person", "alice", vec![]).expect("insert");
5118 }
5119 assert!(
5120 !dir.join("snapshot.bin").exists(),
5121 "GraphDb Drop must not snapshot"
5122 );
5123 let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
5124 assert!(
5125 dir.join("snapshot.bin").is_file(),
5126 "run_snapshot must write snapshot.bin"
5127 );
5128 assert!(
5129 out.contains("snapshot.bin"),
5130 "snapshot output should mention snapshot.bin, got {out}"
5131 );
5132 let db = GraphDb::open(&dir).expect("reopen");
5133 assert!(db.has_node("alice"), "reopen after snapshot must recover");
5134 let _ = std::fs::remove_dir_all(&dir);
5135 }
5136
5137 #[test]
5142 fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
5143 let dir = tmp("snapshot-archive");
5144 {
5145 let mut db = GraphDb::open(&dir).expect("open");
5146 db.insert_node("Person", "alice", vec![]).expect("insert");
5147 }
5148 let before = mushroomdb::wal_commit_count_at(&dir).expect("count");
5149 assert!(before > 0, "the insert is a commit");
5150
5151 {
5153 let shared = SharedDb::open(&dir).expect("open");
5154 snapshot_shared(&shared).expect("snapshot");
5155 }
5156
5157 let archives = || {
5158 std::fs::read_dir(&dir)
5159 .expect("read dir")
5160 .filter_map(Result::ok)
5161 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5162 .count()
5163 };
5164 assert_eq!(archives(), 1, "the WAL was archived, not dropped");
5165 assert!(
5166 dir.join("wal.genesis").is_file(),
5167 "the genesis marker is what lets asof reach an archived commit"
5168 );
5169 {
5170 let db = GraphDb::open(&dir).expect("reopen");
5171 assert!(db.has_node("alice"));
5172 assert!(
5173 !db.node_history("alice").expect("history").items.is_empty(),
5174 "the insert is still explainable"
5175 );
5176 }
5177 assert!(
5178 GraphDb::open_at(&dir, before - 1).is_ok(),
5179 "asof still reaches a commit the snapshot folded in"
5180 );
5181
5182 run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
5185 assert!(
5186 !dir.join("wal.genesis").exists(),
5187 "truncating ends asof's reach into the archives"
5188 );
5189 let db = GraphDb::open(&dir).expect("reopen");
5190 assert!(
5191 db.has_node("alice"),
5192 "the data survives; only the past goes"
5193 );
5194 let _ = std::fs::remove_dir_all(&dir);
5195 }
5196
5197 #[test]
5201 fn automatic_snapshots_keep_every_archive() {
5202 let dir = tmp("snapshot-retention");
5203 let archives = |d: &Path| {
5204 std::fs::read_dir(d)
5205 .expect("read dir")
5206 .filter_map(Result::ok)
5207 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5208 .count()
5209 };
5210
5211 let rounds = 10;
5214 for i in 0..rounds {
5215 {
5216 let mut db = GraphDb::open(&dir).expect("open");
5217 db.insert_node("Person", &format!("p{i}"), vec![])
5218 .expect("insert");
5219 }
5220 let shared = SharedDb::open(&dir).expect("open shared");
5221 snapshot_shared(&shared).expect("snapshot");
5222 }
5223
5224 assert_eq!(
5225 archives(&dir),
5226 rounds,
5227 "an automatic snapshot must not delete an archive"
5228 );
5229
5230 let db = GraphDb::open(&dir).expect("reopen");
5231 assert_eq!(
5232 db.wal_horizon_floor(),
5233 0,
5234 "nothing was pruned, so the floor stays at 0"
5235 );
5236 for i in 0..rounds {
5237 assert!(db.has_node(&format!("p{i}")), "p{i} survived");
5238 }
5239 assert!(
5240 !db.node_history("p0").expect("history").items.is_empty(),
5241 "the oldest history is still there: that is the point of the default"
5242 );
5243 assert_eq!(db.node_history("p0").expect("history").horizon, 0);
5244 drop(db);
5245
5246 let manual = tmp("snapshot-retention-manual");
5249 for i in 0..3 {
5250 {
5251 let mut db = GraphDb::open(&manual).expect("open");
5252 db.insert_node("Person", &format!("p{i}"), vec![])
5253 .expect("insert");
5254 }
5255 run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
5256 }
5257 assert_eq!(
5258 archives(&manual),
5259 3,
5260 "`mushroomdb snapshot` with no --retention keeps every archive"
5261 );
5262
5263 let _ = std::fs::remove_dir_all(&dir);
5264 let _ = std::fs::remove_dir_all(&manual);
5265 }
5266
5267 #[test]
5270 fn retention_is_still_available_when_configured() {
5271 let dir = tmp("retention-configured");
5272 for i in 0..5 {
5273 {
5274 let mut db = GraphDb::open(&dir).unwrap();
5275 db.insert_node("Person", &format!("p{i}"), vec![]).unwrap();
5276 }
5277 run_snapshot(&dir, WalDisposition::Archive, Some(2)).unwrap();
5278 }
5279 let archives = std::fs::read_dir(&dir)
5280 .expect("read dir")
5281 .filter_map(Result::ok)
5282 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5283 .count();
5284 assert_eq!(archives, 2, "--retention 2 still prunes to two");
5285 assert!(GraphDb::open(&dir).unwrap().wal_horizon_floor() > 0);
5286 let _ = std::fs::remove_dir_all(&dir);
5287 }
5288
5289 #[test]
5290 fn run_query_formats_like_asof() {
5291 let dir = tmp("query-cli");
5292 {
5293 let mut db = GraphDb::open(&dir).expect("open");
5294 db.insert_node(
5295 "Person",
5296 "alice",
5297 vec![("id".into(), Value::Str("alice".into()))],
5298 )
5299 .expect("insert");
5300 }
5301 let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id", None, None).expect("query");
5302 assert!(out.contains("columns:"), "got {out}");
5303 assert!(out.contains("id=alice"), "got {out}");
5304 let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})", None, None).expect("write");
5305 let db = GraphDb::open(&dir).expect("reopen");
5306 assert!(db.has_node("bob"), "query_write must persist CREATE");
5307 let _ = std::fs::remove_dir_all(&dir);
5308 }
5309
5310 #[test]
5314 fn asof_header_names_the_horizon() {
5315 let dir = tmp("asof-horizon");
5316 for i in 0..10 {
5321 {
5322 let mut db = GraphDb::open(&dir).expect("open");
5323 db.insert_node("Person", &format!("p{i}"), vec![])
5324 .expect("insert");
5325 }
5326 run_snapshot(&dir, WalDisposition::Archive, Some(2)).expect("snapshot");
5327 }
5328 {
5331 let mut db = GraphDb::open(&dir).expect("open");
5332 db.insert_node("Person", "after", vec![]).expect("insert");
5333 }
5334 let (floor, total) = {
5335 let db = GraphDb::open(&dir).expect("reopen");
5336 (
5337 db.wal_horizon_floor(),
5338 db.wal_total_commits().expect("total"),
5339 )
5340 };
5341 assert!(floor > 0, "the retention must have pruned something");
5342
5343 let out = run_asof(&dir, Some(total - 1), None, None, None).expect("asof");
5344 assert_eq!(
5345 out.trim(),
5346 format!(
5347 "as-of commit {} of {total} (history reaches back to commit {floor})",
5348 total - 1
5349 ),
5350 "the header must name the horizon it can reach"
5351 );
5352 let _ = std::fs::remove_dir_all(&dir);
5353
5354 let clean = tmp("asof-clean");
5356 {
5357 let mut db = GraphDb::open(&clean).expect("open");
5358 db.insert_node("Person", "a", vec![]).expect("insert");
5359 }
5360 assert_eq!(
5361 run_asof(&clean, Some(0), None, None, None)
5362 .expect("asof")
5363 .trim(),
5364 "as-of commit 0 of 1"
5365 );
5366 let _ = std::fs::remove_dir_all(&clean);
5367 }
5368
5369 #[test]
5372 fn format_stats_says_how_far_back_history_reaches() {
5373 let dir = tmp("stats-horizon");
5374 {
5375 let mut db = GraphDb::open(&dir).expect("open");
5376 db.insert_node("Person", "a", vec![]).expect("insert");
5377 }
5378 let text = format_stats(&read_stats(&dir).expect("stats"));
5379 assert!(
5380 text.contains("history: complete (nothing pruned)"),
5381 "an unpruned store says so, got:\n{text}"
5382 );
5383 let _ = std::fs::remove_dir_all(&dir);
5384
5385 let pruned = tmp("stats-horizon-pruned");
5386 for i in 0..10 {
5387 {
5388 let mut db = GraphDb::open(&pruned).expect("open");
5389 db.insert_node("Person", &format!("p{i}"), vec![])
5390 .expect("insert");
5391 }
5392 run_snapshot(&pruned, WalDisposition::Archive, Some(2)).expect("snapshot");
5393 }
5394 let stats = read_stats(&pruned).expect("stats");
5395 assert!(stats.history_floor > 0, "the retention must have pruned");
5396 assert!(
5397 format_stats(&stats).contains(&format!(
5398 "history: reaches back to commit {}",
5399 stats.history_floor
5400 )),
5401 "a pruned store names its floor"
5402 );
5403 let _ = std::fs::remove_dir_all(&pruned);
5404
5405 let truncated = tmp("stats-horizon-truncated");
5407 {
5408 let mut db = GraphDb::open(&truncated).expect("open");
5409 db.insert_node("Person", "a", vec![]).expect("insert");
5410 db.insert_node("Person", "b", vec![]).expect("insert");
5411 }
5412 run_snapshot(&truncated, WalDisposition::Truncate, None).expect("truncate");
5413 let stats = read_stats(&truncated).expect("stats");
5414 assert_eq!(stats.history_floor, 2, "two commits were discarded");
5415 assert!(
5416 format_stats(&stats).contains("history: reaches back to commit 2"),
5417 "a truncated store names its floor, got:\n{}",
5418 format_stats(&stats)
5419 );
5420 let _ = std::fs::remove_dir_all(&truncated);
5421 }
5422
5423 #[test]
5424 fn format_stats_contains_counts() {
5425 let dir = tmp("stats-smoke");
5426 let out = run_demo(&dir).expect("demo for stats smoke");
5427 let text = format_stats(&out.stats);
5428 assert!(
5429 text.contains("60"),
5430 "stats output should include live node count, got:\n{text}"
5431 );
5432 assert!(
5433 text.contains("334"),
5434 "stats output should include edge count, got:\n{text}"
5435 );
5436 assert!(
5437 text.to_lowercase().contains("node"),
5438 "stats output should mention nodes, got:\n{text}"
5439 );
5440 assert!(
5441 text.to_lowercase().contains("edge"),
5442 "stats output should mention edges, got:\n{text}"
5443 );
5444 let _ = std::fs::remove_dir_all(&dir);
5445 }
5446
5447 #[test]
5450 fn parse_backup_round_trip() {
5451 let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
5452 match r {
5453 Ok(Command::Backup { db_dir, dest }) => {
5454 assert_eq!(db_dir, PathBuf::from("/db/dir"));
5455 assert_eq!(dest, PathBuf::from("/backup/dest"));
5456 }
5457 other => panic!("backup parse, got {other:?}"),
5458 }
5459 }
5460
5461 #[test]
5462 fn parse_restore_takes_from_at_and_into() {
5463 assert_eq!(
5464 parse_args(&["restore", "--from", "s3://b/p"]),
5465 Ok(Command::RestoreS3 {
5466 from: "s3://b/p".into(),
5467 at: None,
5468 into: None,
5469 })
5470 );
5471 assert_eq!(
5472 parse_args(&[
5473 "restore",
5474 "--from=minio://b/p",
5475 "--at",
5476 "75",
5477 "--into",
5478 "/tmp/r",
5479 ]),
5480 Ok(Command::RestoreS3 {
5481 from: "minio://b/p".into(),
5482 at: Some("75".into()),
5483 into: Some(PathBuf::from("/tmp/r")),
5484 })
5485 );
5486 match parse_args(&["restore", "--from", "s3://b", "--at=2026-10-01T12:00:00Z"]) {
5487 Ok(Command::RestoreS3 { at, .. }) => {
5488 assert_eq!(at.as_deref(), Some("2026-10-01T12:00:00Z"))
5489 }
5490 other => panic!("restore parse, got {other:?}"),
5491 }
5492 }
5493
5494 #[test]
5495 fn parse_restore_refuses_what_it_cannot_use() {
5496 for (args, needle) in [
5497 (vec!["restore"], "--from"),
5498 (vec!["restore", "--into", "x"], "--from"),
5499 (
5500 vec!["restore", "--from", "s3://b", "--at", "yesterday"],
5501 "RFC 3339",
5502 ),
5503 (vec!["restore", "--from", "s3://b", "--at"], "missing value"),
5504 (
5505 vec!["restore", "--from", "s3://b", "extra"],
5506 "unexpected argument",
5507 ),
5508 (
5509 vec!["restore", "--from", "s3://b", "--force"],
5510 "unexpected flag",
5511 ),
5512 ] {
5513 let e = parse_args(&args).unwrap_err();
5514 assert!(e.contains(needle), "{args:?}: {e}");
5515 }
5516 }
5517
5518 #[test]
5519 fn restore_with_a_bad_url_fails_before_touching_anything() {
5520 let parent = tmp("restore-bad-url");
5521 let into = parent.join("restored");
5522 let e = run_restore_s3("gs://b/p", None, Some(&into)).unwrap_err();
5523 assert!(e.0.contains("--from"), "{e}");
5524 assert!(!into.exists());
5525 }
5526
5527 #[test]
5528 fn parse_backup_missing_dest_errors() {
5529 let r = parse_args(&["backup", "/db/dir"]);
5530 assert!(r.is_err(), "backup without <dest> should error");
5531 let e = r.unwrap_err();
5532 assert!(
5533 e.to_lowercase().contains("dest"),
5534 "error should mention dest, got: {e}"
5535 );
5536 }
5537
5538 #[test]
5539 fn parse_export_defaults_to_jsonl() {
5540 let r = parse_args(&["export", "/db/dir", "/export/dest"]);
5541 match r {
5542 Ok(Command::Export { format, .. }) => {
5543 assert_eq!(format, ExportFormat::Jsonl);
5544 }
5545 other => panic!("export parse, got {other:?}"),
5546 }
5547 }
5548
5549 #[test]
5550 fn parse_export_parquet_flag() {
5551 let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
5552 match r {
5553 Ok(Command::Export { format, .. }) => {
5554 assert_eq!(format, ExportFormat::Parquet);
5555 }
5556 other => panic!("export --format parquet parse, got {other:?}"),
5557 }
5558 }
5559
5560 #[test]
5561 fn parse_export_parquet_flag_eq() {
5562 let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
5563 match r {
5564 Ok(Command::Export { format, .. }) => {
5565 assert_eq!(format, ExportFormat::Parquet);
5566 }
5567 other => panic!("export --format=parquet parse, got {other:?}"),
5568 }
5569 }
5570
5571 #[test]
5572 fn run_backup_cli_produces_verified_report() {
5573 let src = tmp("cli-backup-src");
5574 let dst = tmp("cli-backup-dst");
5575 let _ = run_demo(&src).expect("demo");
5576 let report = run_backup(&src, &dst).expect("run_backup");
5577 assert!(report.verified, "backup must be verified");
5578 assert!(!report.files.is_empty());
5579 assert!(report.bytes > 0);
5580 let _ = std::fs::remove_dir_all(&src);
5581 let _ = std::fs::remove_dir_all(&dst);
5582 }
5583
5584 fn seed_store(dir: &Path, key: &str) {
5587 let mut db = GraphDb::open(dir).expect("open seed store");
5588 db.insert_node("N", key, vec![]).expect("insert seed node");
5589 db.snapshot().expect("snapshot seed store");
5590 }
5591
5592 #[test]
5593 fn restore_from_seeds_an_empty_dir() {
5594 let src = tmp("restore-src");
5595 seed_store(&src, "a");
5596 let vault = tmp("restore-vault");
5597 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5598
5599 let fresh = tmp("restore-fresh");
5600 match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
5601 RestoreOutcome::Restored { files, bytes, .. } => {
5602 assert!(
5603 files.contains(&"snapshot.bin".to_string()),
5604 "expected snapshot.bin among {files:?}"
5605 );
5606 assert!(bytes > 0, "expected a non-zero byte count");
5607 }
5608 other => panic!("expected Restored, got {other:?}"),
5609 }
5610 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5611
5612 let _ = std::fs::remove_dir_all(&src);
5613 let _ = std::fs::remove_dir_all(&vault);
5614 let _ = std::fs::remove_dir_all(&fresh);
5615 }
5616
5617 #[test]
5618 fn restore_from_a_backup_dir_itself() {
5619 let src = tmp("restore-direct-src");
5620 seed_store(&src, "a");
5621 let backup = tmp("restore-direct-backup");
5622 run_backup(&src, &backup).expect("backup");
5623
5624 let fresh = tmp("restore-direct-fresh");
5625 match restore_if_empty(&fresh, &backup).expect("restore_if_empty") {
5626 RestoreOutcome::Restored { from, .. } => assert_eq!(from, backup),
5627 other => panic!("expected Restored, got {other:?}"),
5628 }
5629 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5630
5631 let _ = std::fs::remove_dir_all(&src);
5632 let _ = std::fs::remove_dir_all(&backup);
5633 let _ = std::fs::remove_dir_all(&fresh);
5634 }
5635
5636 #[test]
5642 fn restore_from_a_wal_only_backup() {
5643 let src = tmp("restore-walonly-src");
5644 {
5645 let mut db = GraphDb::open(&src).expect("open src");
5646 db.insert_node("N", "a", vec![]).expect("insert");
5647 }
5648 assert!(
5649 !src.join("snapshot.bin").exists(),
5650 "test setup: src must not have snapshotted"
5651 );
5652 let vault = tmp("restore-walonly-vault");
5653 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5654
5655 let fresh = tmp("restore-walonly-fresh");
5656 match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
5657 RestoreOutcome::Restored { files, .. } => assert!(
5658 files.contains(&"wal.bin".to_string()),
5659 "expected wal.bin among {files:?}"
5660 ),
5661 other => panic!("expected Restored, got {other:?}"),
5662 }
5663 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5664
5665 let _ = std::fs::remove_dir_all(&src);
5666 let _ = std::fs::remove_dir_all(&vault);
5667 let _ = std::fs::remove_dir_all(&fresh);
5668 }
5669
5670 #[test]
5671 fn restore_from_picks_latest_then_newest() {
5672 let older_src = tmp("restore-rank-old-src");
5673 seed_store(&older_src, "old");
5674 let newer_src = tmp("restore-rank-new-src");
5675 seed_store(&newer_src, "new");
5676 let latest_src = tmp("restore-rank-latest-src");
5677 seed_store(&latest_src, "named-latest");
5678
5679 let vault = tmp("restore-rank-vault");
5681 run_backup(&older_src, &vault.join("2026-09-01")).expect("backup old");
5682 std::thread::sleep(std::time::Duration::from_millis(1100));
5683 run_backup(&newer_src, &vault.join("2026-09-02")).expect("backup new");
5684
5685 let fresh = tmp("restore-rank-fresh");
5686 match restore_if_empty(&fresh, &vault).expect("restore by mtime") {
5687 RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("2026-09-02")),
5688 other => panic!("expected Restored, got {other:?}"),
5689 }
5690 assert!(GraphDb::open(&fresh)
5691 .expect("open restored")
5692 .has_node("new"));
5693
5694 run_backup(&latest_src, &vault.join("latest")).expect("backup latest");
5696 let older_than_latest = std::fs::metadata(vault.join("2026-09-02").join("snapshot.bin"))
5697 .expect("stat newest")
5698 .modified()
5699 .expect("mtime");
5700 let latest_mtime = std::fs::metadata(vault.join("latest").join("snapshot.bin"))
5701 .expect("stat latest")
5702 .modified()
5703 .expect("mtime");
5704 assert!(
5705 latest_mtime >= older_than_latest,
5706 "test setup: latest/ should not be older here"
5707 );
5708
5709 let fresh2 = tmp("restore-rank-fresh2");
5710 match restore_if_empty(&fresh2, &vault).expect("restore by name") {
5711 RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("latest")),
5712 other => panic!("expected Restored, got {other:?}"),
5713 }
5714 assert!(GraphDb::open(&fresh2)
5715 .expect("open restored")
5716 .has_node("named-latest"));
5717
5718 for d in [&older_src, &newer_src, &latest_src, &vault, &fresh, &fresh2] {
5719 let _ = std::fs::remove_dir_all(d);
5720 }
5721 }
5722
5723 #[test]
5724 fn restore_from_is_a_no_op_when_a_store_exists() {
5725 let src = tmp("restore-noop-src");
5726 seed_store(&src, "a");
5727 let vault = tmp("restore-noop-vault");
5728 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5729
5730 let existing = tmp("restore-noop-existing");
5731 seed_store(&existing, "b");
5732
5733 assert_eq!(
5734 restore_if_empty(&existing, &vault).expect("restore_if_empty"),
5735 RestoreOutcome::AlreadyPresent
5736 );
5737 let db = GraphDb::open(&existing).expect("open existing");
5738 assert!(db.has_node("b"), "the existing store must survive");
5739 assert!(!db.has_node("a"), "the backup must not have been copied in");
5740
5741 let _ = std::fs::remove_dir_all(&src);
5742 let _ = std::fs::remove_dir_all(&vault);
5743 let _ = std::fs::remove_dir_all(&existing);
5744 }
5745
5746 #[test]
5747 fn restore_from_an_empty_vault_is_not_an_error() {
5748 let vault = tmp("restore-empty-vault");
5749 std::fs::create_dir_all(&vault).expect("mkdir vault");
5750 let fresh = tmp("restore-empty-fresh");
5751 assert_eq!(
5752 restore_if_empty(&fresh, &vault).expect("restore_if_empty"),
5753 RestoreOutcome::Empty
5754 );
5755 assert_eq!(
5757 restore_if_empty(&fresh, &vault.join("nope")).expect("restore_if_empty"),
5758 RestoreOutcome::Empty
5759 );
5760
5761 let _ = std::fs::remove_dir_all(&vault);
5762 let _ = std::fs::remove_dir_all(&fresh);
5763 }
5764
5765 #[test]
5766 fn restore_from_a_corrupt_backup_fails_loudly() {
5767 let src = tmp("restore-corrupt-src");
5768 seed_store(&src, "a");
5769 let vault = tmp("restore-corrupt-vault");
5770 let backup = vault.join("2026-09-10T00-00Z");
5771 run_backup(&src, &backup).expect("backup");
5772
5773 let snap = backup.join("snapshot.bin");
5775 let bytes = std::fs::read(&snap).expect("read snapshot");
5776 std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
5777
5778 let fresh = tmp("restore-corrupt-fresh");
5779 let err = restore_if_empty(&fresh, &vault).expect_err("expected a hard failure");
5780 let msg = err.to_string();
5781 assert!(
5782 msg.contains(&fresh.display().to_string()),
5783 "error must name the restored dir, got: {msg}"
5784 );
5785 assert!(
5786 msg.contains(&backup.display().to_string()),
5787 "error must name the backup, got: {msg}"
5788 );
5789
5790 let _ = std::fs::remove_dir_all(&src);
5791 let _ = std::fs::remove_dir_all(&vault);
5792 let _ = std::fs::remove_dir_all(&fresh);
5793 }
5794
5795 #[test]
5799 fn a_failed_restore_leaves_the_db_dir_untouched() {
5800 let src = tmp("restore-atomic-src");
5801 seed_store(&src, "a");
5802
5803 let bad_vault = tmp("restore-atomic-bad-vault");
5804 let bad = bad_vault.join("2026-09-10T00-00Z");
5805 run_backup(&src, &bad).expect("backup the bad one");
5806 let snap = bad.join("snapshot.bin");
5807 let bytes = std::fs::read(&snap).expect("read snapshot");
5808 std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
5809
5810 let good_vault = tmp("restore-atomic-good-vault");
5811 run_backup(&src, &good_vault.join("2026-09-11T00-00Z")).expect("backup the good one");
5812
5813 let fresh = tmp("restore-atomic-fresh");
5814 let err = restore_if_empty(&fresh, &bad_vault).expect_err("expected a hard failure");
5815 let msg = err.to_string();
5816 assert!(
5817 msg.contains(&fresh.display().to_string()) && msg.contains(&bad.display().to_string()),
5818 "error must name both paths, got: {msg}"
5819 );
5820
5821 let leftovers: Vec<String> = std::fs::read_dir(&fresh)
5823 .expect("read fresh")
5824 .flatten()
5825 .filter_map(|e| e.file_name().into_string().ok())
5826 .collect();
5827 assert!(
5828 leftovers.is_empty(),
5829 "a failed restore must leave nothing behind, found: {leftovers:?}"
5830 );
5831 assert!(!holds_a_store(&fresh), "the dir must not hold a store");
5832
5833 match restore_if_empty(&fresh, &good_vault).expect("retry must restore") {
5836 RestoreOutcome::Restored { from, .. } => {
5837 assert_eq!(from, good_vault.join("2026-09-11T00-00Z"))
5838 }
5839 other => panic!("expected Restored on retry, got {other:?}"),
5840 }
5841 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5842
5843 for d in [&src, &bad_vault, &good_vault, &fresh] {
5844 let _ = std::fs::remove_dir_all(d);
5845 }
5846 }
5847
5848 #[test]
5851 fn a_successful_restore_leaves_no_staging_dir() {
5852 let src = tmp("restore-staging-src");
5853 seed_store(&src, "a");
5854 let vault = tmp("restore-staging-vault");
5855 run_backup(&src, &vault.join("2026-09-11T00-00Z")).expect("backup");
5856
5857 let fresh = tmp("restore-staging-fresh");
5858 restore_if_empty(&fresh, &vault).expect("restore");
5859
5860 let stray: Vec<String> = std::fs::read_dir(&fresh)
5861 .expect("read fresh")
5862 .flatten()
5863 .filter_map(|e| e.file_name().into_string().ok())
5864 .filter(|n| n.starts_with(".restore-"))
5865 .collect();
5866 assert!(
5867 stray.is_empty(),
5868 "staging dir must be gone, found: {stray:?}"
5869 );
5870
5871 for d in [&src, &vault, &fresh] {
5872 let _ = std::fs::remove_dir_all(d);
5873 }
5874 }
5875
5876 #[test]
5877 fn serve_parses_restore_from() {
5878 match parse_args(&["serve", "/tmp/db", "--restore-from", "/vol/backups"]) {
5879 Ok(Command::Serve { restore_from, .. }) => {
5880 assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
5881 }
5882 other => panic!("--restore-from parse, got {other:?}"),
5883 }
5884 match parse_args(&["serve", "/tmp/db", "--restore-from=/vol/backups"]) {
5885 Ok(Command::Serve { restore_from, .. }) => {
5886 assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
5887 }
5888 other => panic!("--restore-from= parse, got {other:?}"),
5889 }
5890 match parse_args(&["serve", "/tmp/db"]) {
5891 Ok(Command::Serve { restore_from, .. }) => assert_eq!(restore_from, None),
5892 other => panic!("default restore_from, got {other:?}"),
5893 }
5894 let err = parse_args(&["serve", "/tmp/db", "--restore-from"])
5895 .expect_err("missing value must be an error");
5896 assert!(
5897 err.contains("--restore-from"),
5898 "error must name the flag, got: {err}"
5899 );
5900 }
5901
5902 #[test]
5903 fn run_export_jsonl_two_runs_byte_identical() {
5904 let src = tmp("cli-export-src");
5905 let dst1 = tmp("cli-export-dst1");
5906 let dst2 = tmp("cli-export-dst2");
5907 let _ = run_demo(&src).expect("demo");
5908
5909 run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
5910 run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
5911
5912 for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
5913 let f1 = std::fs::read(dst1.join(filename)).expect("read first");
5914 let f2 = std::fs::read(dst2.join(filename)).expect("read second");
5915 assert_eq!(
5916 f1, f2,
5917 "{filename} must be byte-identical across two export runs"
5918 );
5919 }
5920 let _ = std::fs::remove_dir_all(&src);
5921 let _ = std::fs::remove_dir_all(&dst1);
5922 let _ = std::fs::remove_dir_all(&dst2);
5923 }
5924
5925 #[test]
5926 fn run_export_jsonl_nodes_are_sorted() {
5927 let src = tmp("cli-export-sorted");
5928 let dst = tmp("cli-export-sorted-dst");
5929 let _ = run_demo(&src).expect("demo");
5930 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
5931
5932 let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
5933 let keys: Vec<String> = content
5934 .lines()
5935 .filter(|l| !l.is_empty())
5936 .map(|l| {
5937 let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
5938 v["key"].as_str().unwrap_or("").to_string()
5939 })
5940 .collect();
5941 let mut sorted = keys.clone();
5942 sorted.sort();
5943 assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
5944 let _ = std::fs::remove_dir_all(&src);
5945 let _ = std::fs::remove_dir_all(&dst);
5946 }
5947
5948 #[test]
5949 fn run_export_jsonl_derived_edges_have_rule() {
5950 let src = tmp("cli-export-derived");
5951 let dst = tmp("cli-export-derived-dst");
5952 let _ = run_demo(&src).expect("demo");
5953 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
5954
5955 let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
5956 let derived_lines: Vec<serde_json::Value> = content
5957 .lines()
5958 .filter(|l| !l.is_empty())
5959 .map(|l| serde_json::from_str(l).expect("parse line"))
5960 .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
5961 .collect();
5962 assert!(
5963 !derived_lines.is_empty(),
5964 "demo store should have derived edges"
5965 );
5966 for edge in &derived_lines {
5967 assert!(
5968 !edge["rule"].is_null(),
5969 "derived edge must have non-null rule: {edge}"
5970 );
5971 }
5972 let _ = std::fs::remove_dir_all(&src);
5973 let _ = std::fs::remove_dir_all(&dst);
5974 }
5975
5976 #[test]
5977 fn run_export_parquet_produces_files() {
5978 let src = tmp("cli-export-parq-src");
5979 let dst = tmp("cli-export-parq-dst");
5980 let _ = run_demo(&src).expect("demo");
5981 run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
5982
5983 assert!(
5984 dst.join("nodes.parquet").exists(),
5985 "nodes.parquet must exist"
5986 );
5987 assert!(
5988 dst.join("edges.parquet").exists(),
5989 "edges.parquet must exist"
5990 );
5991 assert!(
5992 dst.join("rules.parquet").exists(),
5993 "rules.parquet must exist"
5994 );
5995 for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
5997 let meta = std::fs::metadata(dst.join(f)).expect("metadata");
5998 assert!(meta.len() > 0, "{f} must be non-empty");
5999 }
6000 let _ = std::fs::remove_dir_all(&src);
6001 let _ = std::fs::remove_dir_all(&dst);
6002 }
6003
6004 #[test]
6005 fn parse_export_graphml_flag() {
6006 let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
6007 match r {
6008 Ok(Command::Export { format, .. }) => {
6009 assert_eq!(format, ExportFormat::Graphml);
6010 }
6011 other => panic!("export --format graphml parse, got {other:?}"),
6012 }
6013 }
6014
6015 #[test]
6016 fn run_export_graphml_structure() {
6017 let src = tmp("cli-export-gml-src");
6018 let dst_dir = tmp("cli-export-gml-dst");
6019 let dst = dst_dir.join("graph.graphml");
6020 let _ = run_demo(&src).expect("demo");
6021 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6022
6023 let content = std::fs::read_to_string(&dst).expect("read graphml");
6024
6025 assert!(
6026 content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
6027 "must start with an XML declaration"
6028 );
6029 assert!(
6030 content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
6031 "must use the standard GraphML namespace"
6032 );
6033 assert!(
6034 content.contains(
6035 "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
6036 ),
6037 "must declare the node label key"
6038 );
6039 assert!(
6040 content.contains(
6041 "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
6042 ),
6043 "must declare the edge type key"
6044 );
6045 assert!(
6046 content.contains(
6047 "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
6048 ),
6049 "must declare the edge derived key"
6050 );
6051 assert!(
6052 content.contains(
6053 "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
6054 ),
6055 "must declare the edge rule key"
6056 );
6057 assert!(
6058 content.contains(
6059 "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
6060 ),
6061 "must declare the edge weight key"
6062 );
6063 assert!(
6067 content.contains(
6068 "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
6069 ),
6070 "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
6071 );
6072 assert!(
6073 content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
6074 "must declare a single directed graph element"
6075 );
6076 assert!(content.contains("<node id="), "must contain node elements");
6077 assert!(
6078 content.contains("<edge id=\"e0\" source=\""),
6079 "must contain a sequentially-numbered edge starting at e0"
6080 );
6081 assert!(
6082 content.trim_end().ends_with("</graphml>"),
6083 "must close the root element"
6084 );
6085
6086 assert!(
6089 content.contains("<data key=\"e_rule\">skill_fit</data>")
6090 || content.contains("<data key=\"e_rule\">founded_within</data>"),
6091 "at least one derived edge must carry its rule name"
6092 );
6093 assert!(
6094 content.contains(&format!(
6095 "<data key=\"{}\">",
6096 "e_weight" )),
6098 "at least one derived edge must carry a weight value"
6099 );
6100
6101 let _ = std::fs::remove_dir_all(&src);
6102 let _ = std::fs::remove_dir_all(&dst_dir);
6103 }
6104
6105 #[test]
6106 fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
6107 let src = tmp("cli-export-gml-dir-src");
6108 let dst_dir = tmp("cli-export-gml-dir-dst");
6109 std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
6110 let _ = run_demo(&src).expect("demo");
6111
6112 let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
6113
6114 assert!(
6115 dst_dir.join("graph.graphml").exists(),
6116 "an existing directory dest must produce dest/graph.graphml"
6117 );
6118 assert!(
6119 msg.contains("graph.graphml"),
6120 "report must name the file actually written, got: {msg}"
6121 );
6122
6123 let _ = std::fs::remove_dir_all(&src);
6124 let _ = std::fs::remove_dir_all(&dst_dir);
6125 }
6126
6127 #[test]
6130 fn run_export_graphml_is_well_formed_xml() {
6131 let has_python3 = std::process::Command::new("python3")
6132 .arg("--version")
6133 .output()
6134 .map(|o| o.status.success())
6135 .unwrap_or(false);
6136 if !has_python3 {
6137 eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
6138 return;
6139 }
6140
6141 let src = tmp("cli-export-gml-wf-src");
6142 let dst_dir = tmp("cli-export-gml-wf-dst");
6143 let dst = dst_dir.join("graph.graphml");
6144 let _ = run_demo(&src).expect("demo");
6145 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6146
6147 let status = std::process::Command::new("python3")
6148 .arg("-c")
6149 .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
6150 .arg(&dst)
6151 .status()
6152 .expect("run python3");
6153 assert!(
6154 status.success(),
6155 "python3's XML parser must accept the exported GraphML file"
6156 );
6157
6158 let _ = std::fs::remove_dir_all(&src);
6159 let _ = std::fs::remove_dir_all(&dst_dir);
6160 }
6161
6162 #[test]
6163 fn run_export_graphml_two_runs_byte_identical() {
6164 let src = tmp("cli-export-gml-bi-src");
6165 let dst_dir1 = tmp("cli-export-gml-bi-dst1");
6166 let dst_dir2 = tmp("cli-export-gml-bi-dst2");
6167 let dst1 = dst_dir1.join("graph.graphml");
6168 let dst2 = dst_dir2.join("graph.graphml");
6169 let _ = run_demo(&src).expect("demo");
6170
6171 run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
6172 run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
6173
6174 let f1 = std::fs::read(&dst1).expect("read first");
6175 let f2 = std::fs::read(&dst2).expect("read second");
6176 assert_eq!(
6177 f1, f2,
6178 "graph.graphml must be byte-identical across two export runs"
6179 );
6180
6181 let _ = std::fs::remove_dir_all(&src);
6182 let _ = std::fs::remove_dir_all(&dst_dir1);
6183 let _ = std::fs::remove_dir_all(&dst_dir2);
6184 }
6185
6186 #[test]
6187 fn run_export_graphml_escapes_and_lists() {
6188 use mushroomdb::{GraphDb, Value};
6189 let src = tmp("cli-export-gml-esc-src");
6190 let dst_dir = tmp("cli-export-gml-esc-dst");
6191 let dst = dst_dir.join("graph.graphml");
6192
6193 {
6194 let mut db = GraphDb::open(&src).unwrap();
6195 db.insert_node(
6196 "Widget",
6197 "w1",
6198 vec![
6199 (
6200 "title".into(),
6201 Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
6202 ),
6203 (
6204 "tags".into(),
6205 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
6206 ),
6207 ],
6208 )
6209 .unwrap();
6210 }
6211
6212 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6213 let content = std::fs::read_to_string(&dst).expect("read graphml");
6214
6215 assert!(
6216 content.contains("Tom & Jerry <says> "hi" 'bye'"),
6217 "special XML characters in string props must be escaped, got: {content}"
6218 );
6219 assert!(
6220 !content.contains("Tom & Jerry <says>"),
6221 "unescaped special characters must not appear verbatim"
6222 );
6223 assert!(
6224 content.contains(
6225 "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
6226 ),
6227 "list-valued props must declare attr.type=\"string\""
6228 );
6229 assert!(
6230 content.contains("<data key=\"n_tags\">["a","b"]</data>"),
6231 "list-valued props must render as XML-escaped JSON text, got: {content}"
6232 );
6233
6234 let _ = std::fs::remove_dir_all(&src);
6235 let _ = std::fs::remove_dir_all(&dst_dir);
6236 }
6237
6238 #[test]
6243 fn run_export_graphml_mixed_type_prop_declares_string() {
6244 use mushroomdb::{GraphDb, Value};
6245 let src = tmp("cli-export-gml-mixed-src");
6246 let dst_dir = tmp("cli-export-gml-mixed-dst");
6247 let dst = dst_dir.join("graph.graphml");
6248
6249 {
6250 let mut db = GraphDb::open(&src).unwrap();
6251 db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
6252 .unwrap();
6253 db.insert_node(
6254 "Metric",
6255 "m2",
6256 vec![("score".into(), Value::Str("high".into()))],
6257 )
6258 .unwrap();
6259 }
6260
6261 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6262 let content = std::fs::read_to_string(&dst).expect("read graphml");
6263
6264 assert!(
6265 content.contains(
6266 "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
6267 ),
6268 "a prop name with conflicting value types across nodes must declare \
6269 attr.type=\"string\", got: {content}"
6270 );
6271 assert!(
6272 !content.contains("attr.name=\"score\" attr.type=\"long\""),
6273 "must not declare a narrower type once a conflict is seen, got: {content}"
6274 );
6275 assert!(
6278 content.contains("<data key=\"n_score\">5</data>"),
6279 "the int-valued node must still render its literal int text, got: {content}"
6280 );
6281 assert!(
6282 content.contains("<data key=\"n_score\">high</data>"),
6283 "the string-valued node must still render its literal string text, got: {content}"
6284 );
6285
6286 let _ = std::fs::remove_dir_all(&src);
6287 let _ = std::fs::remove_dir_all(&dst_dir);
6288 }
6289
6290 #[test]
6291 fn parse_algo_degree_defaults_dir_both() {
6292 let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
6293 match cmd {
6294 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
6295 other => panic!("expected Algo, got {other:?}"),
6296 }
6297 }
6298
6299 #[test]
6300 fn parse_algo_degree_with_dir_flag() {
6301 for (arg, want) in [
6302 ("out", AlgoDir::Out),
6303 ("in", AlgoDir::In),
6304 ("both", AlgoDir::Both),
6305 ] {
6306 let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
6307 match cmd {
6308 Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
6309 other => panic!("expected Algo, got {other:?}"),
6310 }
6311 }
6312 let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
6314 match cmd {
6315 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
6316 other => panic!("expected Algo, got {other:?}"),
6317 }
6318 }
6319
6320 #[test]
6321 fn parse_algo_rejects_unknown_dir() {
6322 assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
6323 }
6324
6325 #[test]
6326 fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
6327 let cmd = parse_args(&[
6328 "algo",
6329 "communities",
6330 "/db",
6331 "--edge-type",
6332 "IMPORTS",
6333 "--edge-type=CO_CHANGED",
6334 "--weight-prop",
6335 "score",
6336 "--min-weight",
6337 "0.3",
6338 "--top",
6339 "5",
6340 ])
6341 .unwrap();
6342 match cmd {
6343 Command::Algo {
6344 subcmd,
6345 top,
6346 edge_types,
6347 weight_prop,
6348 min_weight,
6349 ..
6350 } => {
6351 assert_eq!(subcmd, AlgoSubcmd::Communities);
6352 assert_eq!(top, 5);
6353 assert_eq!(
6354 edge_types,
6355 vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
6356 );
6357 assert_eq!(weight_prop, Some("score".to_string()));
6358 assert_eq!(min_weight, Some(0.3));
6359 }
6360 other => panic!("expected Algo, got {other:?}"),
6361 }
6362 }
6363
6364 #[test]
6365 fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
6366 let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
6367 match cmd {
6368 Command::Algo {
6369 subcmd,
6370 edge_types,
6371 weight_prop,
6372 min_weight,
6373 ..
6374 } => {
6375 assert_eq!(subcmd, AlgoSubcmd::Communities);
6376 assert!(edge_types.is_empty());
6377 assert_eq!(weight_prop, None);
6378 assert_eq!(min_weight, None);
6379 }
6380 other => panic!("expected Algo, got {other:?}"),
6381 }
6382 }
6383
6384 #[test]
6387 fn run_export_jsonl_nan_float_becomes_null() {
6388 use mushroomdb::{GraphDb, Value};
6389 let src = tmp("cli-export-nan-src");
6390 let dst = tmp("cli-export-nan-dst");
6391
6392 {
6394 let mut db = GraphDb::open(&src).unwrap();
6395 db.insert_node(
6396 "Sensor",
6397 "s1",
6398 vec![
6399 ("nan_val".into(), Value::Float(f64::NAN)),
6400 ("pos_inf".into(), Value::Float(f64::INFINITY)),
6401 ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
6402 ("normal".into(), Value::Float(1.5)),
6403 ],
6404 )
6405 .unwrap();
6406 }
6407
6408 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
6410
6411 let content =
6413 std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
6414 let row: serde_json::Value =
6415 serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
6416 assert_eq!(
6417 row["nan_val"],
6418 serde_json::Value::Null,
6419 "NaN must export as null"
6420 );
6421 assert_eq!(
6422 row["pos_inf"],
6423 serde_json::Value::Null,
6424 "+Inf must export as null"
6425 );
6426 assert_eq!(
6427 row["neg_inf"],
6428 serde_json::Value::Null,
6429 "-Inf must export as null"
6430 );
6431 assert_eq!(
6433 row["normal"],
6434 serde_json::json!(1.5),
6435 "normal float roundtrips"
6436 );
6437
6438 let _ = std::fs::remove_dir_all(&src);
6439 let _ = std::fs::remove_dir_all(&dst);
6440 }
6441
6442 #[test]
6443 fn serve_tls_flags_parse_both_forms() {
6444 match parse_args(&[
6446 "serve",
6447 "/tmp/db",
6448 "--tls-cert",
6449 "/a/cert.pem",
6450 "--tls-key",
6451 "/a/key.pem",
6452 ])
6453 .unwrap()
6454 {
6455 Command::Serve {
6456 tls_cert, tls_key, ..
6457 } => {
6458 assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
6459 assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
6460 }
6461 other => panic!("{other:?}"),
6462 }
6463 match parse_args(&[
6465 "serve",
6466 "/tmp/db",
6467 "--tls-cert=/b/cert.pem",
6468 "--tls-key=/b/key.pem",
6469 ])
6470 .unwrap()
6471 {
6472 Command::Serve {
6473 tls_cert, tls_key, ..
6474 } => {
6475 assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
6476 assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
6477 }
6478 other => panic!("{other:?}"),
6479 }
6480 match parse_args(&["serve", "/tmp/db"]).unwrap() {
6482 Command::Serve {
6483 tls_cert, tls_key, ..
6484 } => {
6485 assert_eq!(tls_cert, None);
6486 assert_eq!(tls_key, None);
6487 }
6488 other => panic!("{other:?}"),
6489 }
6490 }
6491
6492 #[test]
6493 fn serve_tls_flags_require_both() {
6494 let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
6496 assert!(
6497 err.contains("tls-key"),
6498 "--tls-cert alone must mention --tls-key in error, got {err}"
6499 );
6500 let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
6502 assert!(
6503 err.contains("tls-cert"),
6504 "--tls-key alone must mention --tls-cert in error, got {err}"
6505 );
6506 }
6507
6508 #[test]
6509 fn version_flag_parses() {
6510 assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
6511 assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
6512 assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
6513 }
6514
6515 #[test]
6516 fn recall_parses_one_dir_and_is_listed_in_usage() {
6517 assert_eq!(
6518 parse_args(&["recall", "/tmp/db"]).unwrap(),
6519 Command::Recall {
6520 db_dir: Some(PathBuf::from("/tmp/db")),
6521 auto: false,
6522 }
6523 );
6524 assert!(
6525 parse_args(&["recall"]).is_err(),
6526 "one of <db-dir> or --auto is required"
6527 );
6528 assert!(usage().contains("mushroomdb recall <db-dir>"));
6529 }
6530
6531 #[test]
6532 fn why_takes_a_dir_and_two_keys() {
6533 assert_eq!(
6534 parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
6535 Command::Why {
6536 db_dir: PathBuf::from("/tmp/db"),
6537 a: "a.rs".to_string(),
6538 b: "b.rs".to_string(),
6539 }
6540 );
6541
6542 for args in [
6544 vec!["why", "/tmp/db", "a"],
6545 vec!["why", "/tmp/db", "a", "b", "c"],
6546 vec!["why", "/tmp/db", "-a", "b"],
6547 ] {
6548 assert!(parse_args(&args).is_err(), "{args:?} must not parse");
6549 }
6550 assert!(usage().contains("mushroomdb why <db-dir> <a> <b>"));
6551 }
6552
6553 #[test]
6556 fn hook_commands_take_a_dir_or_auto() {
6557 assert_eq!(
6558 parse_args(&["mcp", "--auto"]).unwrap(),
6559 Command::Mcp {
6560 db_dir: None,
6561 auto: true,
6562 all_tools: false
6563 }
6564 );
6565 assert_eq!(
6566 parse_args(&["recall", "--auto"]).unwrap(),
6567 Command::Recall {
6568 db_dir: None,
6569 auto: true
6570 }
6571 );
6572 assert_eq!(
6573 parse_args(&["brief", "--auto"]).unwrap(),
6574 Command::Brief {
6575 db_dir: None,
6576 auto: true
6577 }
6578 );
6579 assert_eq!(
6580 parse_args(&["brief", "/tmp/db"]).unwrap(),
6581 Command::Brief {
6582 db_dir: Some(PathBuf::from("/tmp/db")),
6583 auto: false
6584 }
6585 );
6586 for cmd in ["mcp", "recall", "brief"] {
6587 assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
6588 assert!(
6589 parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
6590 "{cmd} with both"
6591 );
6592 }
6593 assert!(usage().contains("--auto"));
6594 }
6595
6596 #[test]
6599 fn mcp_takes_all_tools() {
6600 for args in [
6601 &["mcp", "/tmp/db", "--all-tools"][..],
6602 &["mcp", "--all-tools", "/tmp/db"][..],
6603 ] {
6604 assert_eq!(
6605 parse_args(args).unwrap(),
6606 Command::Mcp {
6607 db_dir: Some(PathBuf::from("/tmp/db")),
6608 auto: false,
6609 all_tools: true
6610 },
6611 "{args:?}"
6612 );
6613 }
6614 assert_eq!(
6615 parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
6616 Command::Mcp {
6617 db_dir: None,
6618 auto: true,
6619 all_tools: true
6620 }
6621 );
6622 assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
6623 assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
6624 assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
6625 assert!(usage().contains("--all-tools"));
6626 }
6627
6628 #[test]
6629 fn ingest_git_parses_excludes() {
6630 let cmd = parse_args(&[
6631 "ingest-git",
6632 "/tmp/db",
6633 "/tmp/repo",
6634 "--exclude",
6635 "target/",
6636 "--exclude=*.lock",
6637 "--max-commits-per-file",
6638 "50",
6639 "--recurse-submodules",
6640 "--prs",
6641 "--ensure-gitignore",
6642 ])
6643 .unwrap();
6644 assert_eq!(
6645 cmd,
6646 Command::IngestGit {
6647 db_dir: PathBuf::from("/tmp/db"),
6648 opts: ingest_git::IngestGitOpts {
6649 repo: PathBuf::from("/tmp/repo"),
6650 exclude: vec!["target/".into(), "*.lock".into()],
6651 max_commits_per_file: 50,
6652 recurse_submodules: true,
6653 prs: true,
6654 structure: true,
6655 docs: true,
6656 ensure_gitignore: true,
6657 },
6658 }
6659 );
6660 let Command::IngestGit { opts, .. } =
6662 parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
6663 else {
6664 panic!("expected IngestGit");
6665 };
6666 assert_eq!(
6667 opts.exclude,
6668 ingest_git::DEFAULT_EXCLUDES
6669 .iter()
6670 .map(|p| (*p).to_string())
6671 .collect::<Vec<_>>(),
6672 "with no --exclude the defaults apply"
6673 );
6674 assert_eq!(
6675 opts.max_commits_per_file,
6676 ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
6677 );
6678 assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
6679 assert!(
6680 opts.structure && opts.docs,
6681 "structure and docs default on and are recorded on the marker"
6682 );
6683 let Command::IngestGit { opts, .. } = parse_args(&[
6684 "ingest-git",
6685 "/tmp/db",
6686 "/tmp/repo",
6687 "--no-structure",
6688 "--no-docs",
6689 ])
6690 .unwrap() else {
6691 panic!("expected IngestGit");
6692 };
6693 assert!(!opts.structure && !opts.docs);
6694 assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
6695 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
6696 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
6697 assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
6698 }
6699
6700 #[test]
6701 fn version_constant_matches_cargo() {
6702 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
6703 assert!(usage().contains("--version"));
6704 }
6705 fn ns_store(name: &str) -> PathBuf {
6709 let dir = tmp(name);
6710 let mut db = GraphDb::open(&dir).expect("open");
6711 for (key, ns) in [
6712 ("d1", None),
6713 ("a1", Some("tenant-a")),
6714 ("a2", Some("tenant-a")),
6715 ("b1", Some("tenant-b")),
6716 ] {
6717 let mut props = vec![("id".into(), Value::Str(key.into()))];
6718 if let Some(ns) = ns {
6719 props.push(("ns".into(), Value::Str(ns.into())));
6720 }
6721 db.insert_node("Doc", key, props).expect("insert");
6722 }
6723 db.apply_schema(&Schema {
6724 roles: vec![mushroomdb::RoleDef {
6725 name: "a-reader".into(),
6726 keys: vec![],
6727 labels: vec!["Doc".into()],
6728 visible_where: None,
6729 namespaces: Some(vec!["tenant-a".into()]),
6730 write: None,
6731 }],
6732 ..Default::default()
6733 })
6734 .expect("schema");
6735 dir
6736 }
6737
6738 #[test]
6741 fn format_stats_lists_namespaces() {
6742 let dir = ns_store("stats-namespaces");
6743 let text = format_stats(&read_stats(&dir).expect("stats"));
6744 assert!(
6745 text.contains("namespaces: default (1), tenant-a (2), tenant-b (1)"),
6746 "got:\n{text}"
6747 );
6748 let _ = std::fs::remove_dir_all(&dir);
6749 }
6750
6751 #[test]
6754 fn format_stats_omits_the_namespaces_line_on_one_namespace() {
6755 let dir = tmp("stats-one-namespace");
6756 {
6757 let mut db = GraphDb::open(&dir).expect("open");
6758 db.insert_node("Person", "a", vec![]).expect("insert");
6759 }
6760 let text = format_stats(&read_stats(&dir).expect("stats"));
6761 assert!(
6762 !text.contains("namespaces"),
6763 "a store with only `default` says nothing about namespaces, got:\n{text}"
6764 );
6765 let _ = std::fs::remove_dir_all(&dir);
6766 }
6767
6768 #[test]
6770 fn parse_query_takes_a_role_and_a_namespace() {
6771 let Ok(Command::Query {
6772 role, namespace, ..
6773 }) = parse_args(&[
6774 "query",
6775 "/tmp/db",
6776 "--role",
6777 "a-reader",
6778 "--namespace",
6779 "tenant-a",
6780 "MATCH (n) RETURN n",
6781 ])
6782 else {
6783 panic!("expected Query");
6784 };
6785 assert_eq!(role.as_deref(), Some("a-reader"));
6786 assert_eq!(namespace.as_deref(), Some("tenant-a"));
6787
6788 let Ok(Command::Query {
6789 role, namespace, ..
6790 }) = parse_args(&[
6791 "query",
6792 "/tmp/db",
6793 "--namespace=tenant-b",
6794 "MATCH (n) RETURN n",
6795 ])
6796 else {
6797 panic!("expected Query");
6798 };
6799 assert_eq!(role, None);
6800 assert_eq!(namespace.as_deref(), Some("tenant-b"));
6801
6802 assert!(parse_args(&["query", "/tmp/db", "--namespace"]).is_err());
6803 assert!(parse_args(&["query", "/tmp/db", "--role"]).is_err());
6804 assert!(usage().contains("--namespace <ns>"));
6805 }
6806
6807 #[test]
6810 fn parse_query_and_asof_take_a_file_or_stdin() {
6811 let Ok(Command::Query {
6812 cypher,
6813 file,
6814 stdin,
6815 ..
6816 }) = parse_args(&["query", "/tmp/db", "--file", "/tmp/q.cyp"])
6817 else {
6818 panic!("expected Query");
6819 };
6820 assert_eq!(cypher, "");
6821 assert_eq!(file, Some(PathBuf::from("/tmp/q.cyp")));
6822 assert!(!stdin);
6823
6824 let Ok(Command::Query { file, stdin, .. }) = parse_args(&["query", "/tmp/db", "--stdin"])
6825 else {
6826 panic!("expected Query");
6827 };
6828 assert_eq!(file, None);
6829 assert!(stdin);
6830
6831 let Ok(Command::AsOf {
6832 commit,
6833 query,
6834 file,
6835 stdin,
6836 ..
6837 }) = parse_args(&["asof", "/tmp/db", "--commit", "1", "--file=/tmp/q.cyp"])
6838 else {
6839 panic!("expected AsOf");
6840 };
6841 assert_eq!(commit, Some(1));
6842 assert_eq!(query, None);
6843 assert_eq!(file, Some(PathBuf::from("/tmp/q.cyp")));
6844 assert!(!stdin);
6845
6846 let Ok(Command::AsOf { stdin, .. }) =
6847 parse_args(&["asof", "/tmp/db", "--commit", "1", "--stdin"])
6848 else {
6849 panic!("expected AsOf");
6850 };
6851 assert!(stdin);
6852
6853 for args in [
6854 &["query", "/tmp/db", "--stdin", "--file", "/tmp/q"][..],
6855 &["query", "/tmp/db", "--stdin", "MATCH (n) RETURN n"],
6856 &[
6857 "query",
6858 "/tmp/db",
6859 "--file",
6860 "/tmp/q",
6861 "--query",
6862 "MATCH (n) RETURN n",
6863 ],
6864 &[
6865 "asof",
6866 "/tmp/db",
6867 "--commit",
6868 "1",
6869 "--stdin",
6870 "--query",
6871 "MATCH (n) RETURN n",
6872 ],
6873 &[
6874 "asof", "/tmp/db", "--commit", "1", "--stdin", "--file", "/tmp/q",
6875 ],
6876 &["query", "/tmp/db", "--file"],
6877 ] {
6878 assert!(parse_args(args).is_err(), "{args:?} should be refused");
6879 }
6880 assert!(usage().contains("--file <path>|--stdin"));
6881 }
6882
6883 #[test]
6886 fn resolve_statement_drops_the_trailing_newline() {
6887 let mut piped: &[u8] = b"MATCH (n) RETURN count(n)\n\n";
6888 let got = resolve_statement(None, None, true, &mut piped).expect("stdin");
6889 assert_eq!(got.as_deref(), Some("MATCH (n) RETURN count(n)"));
6890
6891 let mut unused: &[u8] = b"";
6892 let inline = resolve_statement(Some("MATCH (n) RETURN n"), None, false, &mut unused)
6893 .expect("inline");
6894 assert_eq!(inline.as_deref(), Some("MATCH (n) RETURN n"));
6895 assert_eq!(
6896 resolve_statement(None, None, false, &mut unused).unwrap(),
6897 None
6898 );
6899
6900 let mut bom: &[u8] = b"\xEF\xBB\xBFMATCH (n) RETURN n; \n";
6903 let got = resolve_statement(None, None, true, &mut bom).expect("bom");
6904 assert_eq!(got.as_deref(), Some("MATCH (n) RETURN n"));
6905 let got = resolve_statement(Some("MATCH (n) RETURN n ; "), None, false, &mut unused)
6906 .expect("inline semicolon");
6907 assert_eq!(got.as_deref(), Some("MATCH (n) RETURN n"));
6908 let got = resolve_statement(Some("RETURN ';;'"), None, false, &mut unused).unwrap();
6909 assert_eq!(got.as_deref(), Some("RETURN ';;'"));
6910
6911 let mut blank: &[u8] = b" \n";
6912 assert!(resolve_statement(None, None, true, &mut blank).is_err());
6913 assert!(resolve_statement(
6914 None,
6915 Some(Path::new("/nonexistent/q.cyp")),
6916 false,
6917 &mut unused
6918 )
6919 .is_err());
6920 }
6921
6922 #[test]
6925 fn run_query_with_a_role_and_a_namespace_never_widens() {
6926 let dir = ns_store("query-namespace");
6927 let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
6928
6929 let all = run_query(&dir, q, None, None).expect("query");
6930 assert!(all.contains("id=a1") && all.contains("id=b1") && all.contains("id=d1"));
6931
6932 let ns = run_query(&dir, q, None, Some("tenant-a")).expect("query");
6933 assert!(ns.contains("id=a1") && ns.contains("id=a2"), "got {ns}");
6934 assert!(!ns.contains("id=b1") && !ns.contains("id=d1"), "got {ns}");
6935
6936 let role = run_query(&dir, q, Some("a-reader"), None).expect("query");
6937 assert!(
6938 role.contains("id=a1") && !role.contains("id=b1"),
6939 "got {role}"
6940 );
6941
6942 let both = run_query(&dir, q, Some("a-reader"), Some("tenant-b")).expect("query");
6943 assert!(
6944 !both.contains("id=a1") && !both.contains("id=b1"),
6945 "role ∩ namespace, never role ∪ namespace: {both}"
6946 );
6947
6948 for (role, namespace) in [
6951 (None, Some("tenant-a")),
6952 (Some("a-reader"), None),
6953 (Some("a-reader"), Some("tenant-a")),
6954 ] {
6955 let write = run_query(&dir, "CREATE (n:Doc {id: 'z1'})", role, namespace);
6956 assert!(
6957 write
6958 .as_ref()
6959 .err()
6960 .is_some_and(|e| e.0.contains("read-only")),
6961 "a restricted write must be refused, got {write:?}"
6962 );
6963 assert!(
6964 !GraphDb::open(&dir).expect("reopen").has_node("z1"),
6965 "the write must not have landed"
6966 );
6967 }
6968
6969 let unknown = run_query(&dir, q, Some("nobody"), None);
6970 assert!(unknown.is_err(), "an unknown role is an error");
6971 let invalid = run_query(&dir, q, None, Some("no spaces"));
6972 assert!(
6973 invalid
6974 .as_ref()
6975 .err()
6976 .is_some_and(|e| e.0.contains("valid namespace name")),
6977 "{invalid:?}"
6978 );
6979 let _ = std::fs::remove_dir_all(&dir);
6980 }
6981
6982 #[test]
6984 fn run_asof_in_a_namespace() {
6985 let dir = ns_store("asof-namespace");
6986 let at = {
6987 let db = GraphDb::open(&dir).expect("open");
6988 db.wal_total_commits().expect("commits") - 1
6989 };
6990 {
6991 let mut db = GraphDb::open(&dir).expect("open");
6992 db.insert_node(
6993 "Doc",
6994 "a3",
6995 vec![
6996 ("id".into(), Value::Str("a3".into())),
6997 ("ns".into(), Value::Str("tenant-a".into())),
6998 ],
6999 )
7000 .expect("insert");
7001 }
7002 let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
7003 let then = run_asof(&dir, Some(at), None, Some(q), Some("tenant-a")).expect("asof");
7004 assert!(
7005 then.contains("id=a1") && then.contains("id=a2"),
7006 "got {then}"
7007 );
7008 assert!(
7009 !then.contains("id=a3") && !then.contains("id=b1"),
7010 "a3 did not exist then and b1 is another namespace: {then}"
7011 );
7012 let now = run_asof(&dir, Some(at + 1), None, Some(q), Some("tenant-a")).expect("asof");
7013 assert!(now.contains("id=a3"), "got {now}");
7014 let _ = std::fs::remove_dir_all(&dir);
7015 }
7016
7017 #[test]
7020 fn schema_apply_accepts_v4_roles_with_namespaces() {
7021 let dir = tmp("schema-v4");
7022 {
7023 let mut db = GraphDb::open(&dir).expect("open");
7024 db.insert_node(
7025 "Doc",
7026 "a1",
7027 vec![("ns".into(), Value::Str("tenant-a".into()))],
7028 )
7029 .expect("insert");
7030 }
7031 let file = dir.join("schema.json");
7032 std::fs::write(
7033 &file,
7034 r#"{"roles": [{"name": "a-reader", "labels": ["Doc"], "keys": [],
7035 "namespaces": ["tenant-a"]}]}"#,
7036 )
7037 .expect("write schema");
7038 let out = run_schema_apply(&dir, &file).expect("apply");
7039 assert!(out.contains("a-reader"), "got {out}");
7040 let sidecar = std::fs::read_to_string(dir.join("roles.json")).expect("roles.json");
7041 assert!(
7042 sidecar.contains("\"version\": 4") || sidecar.contains("\"version\":4"),
7043 "a role with namespaces writes version 4: {sidecar}"
7044 );
7045 assert!(sidecar.contains("tenant-a"), "{sidecar}");
7046 let _ = std::fs::remove_dir_all(&dir);
7047 }
7048}