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
3093pub fn why_for_stdout(out: String, styled: bool) -> String {
3097 let framing = mushroomdb::digest::UNTRUSTED_FRAMING;
3098 match out.strip_prefix(framing) {
3099 Some(rest) if styled => {
3100 format!("\x1b[2m{}\x1b[0m\n{rest}", framing.trim_end_matches('\n'))
3101 }
3102 _ => out,
3103 }
3104}
3105
3106#[allow(clippy::too_many_arguments)]
3112pub fn run_algo(
3113 db_dir: &Path,
3114 subcmd: &AlgoSubcmd,
3115 top: usize,
3116 dir: AlgoDir,
3117 edge_types: Vec<String>,
3118 weight_prop: Option<String>,
3119 min_weight: Option<f64>,
3120) -> Result<String, CliError> {
3121 let db = GraphDb::open(db_dir)?;
3122 match subcmd {
3123 AlgoSubcmd::Pagerank => {
3124 let config = PageRankConfig {
3125 direction: dir,
3126 ..PageRankConfig::default()
3127 };
3128 let report = db.pagerank(&config);
3129 Ok(format_pagerank(&report, top))
3130 }
3131 AlgoSubcmd::Wcc => {
3132 let config = WccConfig::default();
3133 let report = db.connected_components(&config);
3134 Ok(format_wcc(&report, top))
3135 }
3136 AlgoSubcmd::Degree => {
3137 let config = DegreeConfig {
3138 direction: dir,
3139 ..DegreeConfig::default()
3140 };
3141 let report = db.degree_centrality(&config);
3142 Ok(format_degree(&report, top))
3143 }
3144 AlgoSubcmd::Communities => {
3145 let config = LouvainConfig {
3146 edge_types,
3147 weight_prop,
3148 min_weight,
3149 ..LouvainConfig::default()
3150 };
3151 let report = db.communities(&config);
3152 Ok(format_communities(&report, top))
3153 }
3154 }
3155}
3156
3157fn format_pagerank(report: &mushroomdb::PageRankReport, top: usize) -> String {
3158 let mut buf = String::new();
3159 let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
3160 let rows = if top == 0 {
3161 report.scores.as_slice()
3162 } else {
3163 &report.scores[..top.min(report.scores.len())]
3164 };
3165 for (i, (key, score)) in rows.iter().enumerate() {
3166 let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
3167 }
3168 buf
3169}
3170
3171fn format_wcc(report: &mushroomdb::WccReport, top: usize) -> String {
3172 let mut buf = String::new();
3173 let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
3174 let rows = if top == 0 {
3175 report.components.as_slice()
3176 } else {
3177 &report.components[..top.min(report.components.len())]
3178 };
3179 for (key, comp_id) in rows {
3180 let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
3181 }
3182 buf
3183}
3184
3185fn format_degree(report: &mushroomdb::DegreeReport, top: usize) -> String {
3186 let mut buf = String::new();
3187 let _ = writeln!(
3188 buf,
3189 "== degree centrality (truncated={}) ==",
3190 report.truncated
3191 );
3192 let rows = if top == 0 {
3193 report.scores.as_slice()
3194 } else {
3195 &report.scores[..top.min(report.scores.len())]
3196 };
3197 for (i, (key, deg)) in rows.iter().enumerate() {
3198 let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
3199 }
3200 buf
3201}
3202
3203fn format_communities(report: &mushroomdb::CommunityReport, top: usize) -> String {
3206 let mut buf = String::new();
3207 let trunc = if report.truncated { " (truncated)" } else { "" };
3208 let _ = writeln!(
3209 buf,
3210 "== communities (modularity={:.2}){trunc} ==",
3211 report.modularity
3212 );
3213 let rows = if top == 0 {
3214 report.communities.as_slice()
3215 } else {
3216 &report.communities[..top.min(report.communities.len())]
3217 };
3218 for c in rows {
3219 let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
3220 let _ = writeln!(
3221 buf,
3222 " {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
3223 c.id,
3224 c.members.len(),
3225 c.cohesion,
3226 preview.join(", ")
3227 );
3228 }
3229 buf
3230}
3231
3232fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
3237 let mut db_dir = None;
3238 let mut auto = false;
3239 for a in args {
3240 if *a == "--auto" {
3241 auto = true;
3242 } else if a.starts_with('-') {
3243 return Err(format!("unexpected flag: {a}"));
3244 } else if db_dir.is_some() {
3245 return Err(format!("unexpected extra argument: {a}"));
3246 } else {
3247 db_dir = Some(PathBuf::from(*a));
3248 }
3249 }
3250 match (&db_dir, auto) {
3251 (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
3252 (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
3253 _ => Ok((db_dir, auto)),
3254 }
3255}
3256
3257fn parse_mcp(args: &[&str]) -> Result<Command, String> {
3261 let all_tools = args.contains(&"--all-tools");
3262 let rest: Vec<&str> = args
3263 .iter()
3264 .copied()
3265 .filter(|a| *a != "--all-tools")
3266 .collect();
3267 parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
3268 db_dir,
3269 auto,
3270 all_tools,
3271 })
3272}
3273
3274fn parse_positional(
3281 cmd: &str,
3282 args: &[&str],
3283 min: usize,
3284 max: usize,
3285) -> Result<(PathBuf, Vec<String>), String> {
3286 let mut rest: Vec<String> = Vec::new();
3287 let mut db_dir: Option<PathBuf> = None;
3288 for a in args {
3289 if a.starts_with('-') {
3290 return Err(format!("unexpected flag: {a}"));
3291 }
3292 match db_dir {
3293 None => db_dir = Some(PathBuf::from(*a)),
3294 Some(_) => rest.push((*a).to_string()),
3295 }
3296 }
3297 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
3298 if rest.len() < min {
3299 return Err(format!(
3300 "{cmd} requires <db-dir> and {min} more argument{}",
3301 if min == 1 { "" } else { "s" }
3302 ));
3303 }
3304 if rest.len() > max {
3305 return Err(format!("unexpected extra argument: {}", rest[max]));
3306 }
3307 Ok((db_dir, rest))
3308}
3309
3310fn parse_keys(args: &[&str]) -> Result<Command, String> {
3311 match args.first().copied() {
3312 Some("hash") if args.len() == 1 => Ok(Command::KeysHash),
3313 Some("hash") => Err("keys hash takes no arguments; it reads the token from stdin".into()),
3314 Some("add") => {
3315 let mut positional: Vec<&str> = Vec::new();
3316 let mut tier: Option<String> = None;
3317 let mut role: Option<String> = None;
3318 let mut i = 1;
3319 while i < args.len() {
3320 match args[i] {
3321 "--tier" | "--role" => {
3322 let flag = args[i];
3323 i += 1;
3324 let value = args
3325 .get(i)
3326 .ok_or_else(|| format!("{flag} requires a value"))?;
3327 let slot = if flag == "--tier" {
3328 &mut tier
3329 } else {
3330 &mut role
3331 };
3332 if slot.replace((*value).to_string()).is_some() {
3333 return Err(format!("{flag} given twice"));
3334 }
3335 }
3336 flag if flag.starts_with('-') => {
3337 return Err(format!("unexpected flag: {flag}"))
3338 }
3339 other => positional.push(other),
3340 }
3341 i += 1;
3342 }
3343 let [db_dir, name] = positional.as_slice() else {
3344 return Err("keys add requires <db-dir> <name>".into());
3345 };
3346 let tier = tier.ok_or("keys add requires --tier admin|write|read")?;
3347 match (tier.as_str(), role.is_some()) {
3348 ("admin" | "write", false) => {}
3349 ("admin" | "write", true) => return Err("--role goes only with --tier read".into()),
3350 ("read", true) => {}
3351 ("read", false) => return Err("--tier read requires --role <name>".into()),
3352 _ => return Err("--tier must be admin, write or read".into()),
3353 }
3354 Ok(Command::KeysAdd {
3355 db_dir: PathBuf::from(*db_dir),
3356 name: (*name).to_string(),
3357 tier,
3358 role,
3359 })
3360 }
3361 _ => Err("keys requires a subcommand: add or hash".into()),
3362 }
3363}
3364
3365fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
3366 let mut db_dir = None;
3367 for a in args {
3368 if a.starts_with('-') {
3369 return Err(format!("unexpected flag: {a}"));
3370 }
3371 if db_dir.is_some() {
3372 return Err(format!("unexpected extra argument: {a}"));
3373 }
3374 db_dir = Some(PathBuf::from(*a));
3375 }
3376 db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
3377}
3378
3379pub fn format_stats(stats: &Stats) -> String {
3381 let mut out = String::new();
3382 let _ = writeln!(
3383 out,
3384 "nodes: {} live, {} tombstoned",
3385 stats.nodes_live, stats.nodes_tombstoned
3386 );
3387 let _ = writeln!(out, "edges: {}", stats.edges);
3388 if stats.history_floor == 0 {
3389 let _ = writeln!(out, "history: complete (nothing pruned)");
3390 } else {
3391 let _ = writeln!(
3392 out,
3393 "history: reaches back to commit {}",
3394 stats.history_floor
3395 );
3396 }
3397 if stats.namespaces.len() > 1 {
3402 let names: Vec<String> = stats
3403 .namespaces
3404 .iter()
3405 .map(|n| format!("{} ({})", n.name, n.nodes_live))
3406 .collect();
3407 let _ = writeln!(out, "namespaces: {}", names.join(", "));
3408 }
3409 let _ = writeln!(out, "rules: {}", stats.rules.len());
3410 for r in &stats.rules {
3411 let _ = writeln!(
3412 out,
3413 " {:<28} edges={} tripped={}",
3414 r.name, r.edges, r.tripped
3415 );
3416 }
3417 out
3418}
3419
3420pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
3422 let db = SharedDb::open(dir)?;
3423 let stats = db.read().stats();
3424 Ok(stats)
3425}
3426
3427pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
3433 refuse_non_empty(dir)?;
3434
3435 let db = SharedDb::open(dir)?;
3436 let opts = IngestOptions::default();
3437 let mut auto_fk_rules = Vec::new();
3438
3439 {
3440 let mut w = db.write();
3441 w.apply_schema(&mushroomdb::memory_schema::memory_defaults())?;
3444 for (label, json) in [
3445 ("Org", org_json()),
3446 ("Project", project_json()),
3447 ("Person", person_json()),
3448 ] {
3449 let report = w.ingest_json(label, &json, &opts)?;
3450 if !report.row_errors.is_empty() {
3451 return Err(CliError(format!(
3452 "demo ingest of {label} had row errors: {:?}",
3453 report.row_errors
3454 )));
3455 }
3456 auto_fk_rules.extend(report.rules_created);
3457 }
3458 let skill_fit = Predicate::Overlap {
3459 field: "skills".into(),
3460 min: 0.5,
3461 };
3462 let skill_fit_k = Some(default_max_edges(&skill_fit));
3463 w.create_rule(RuleDef {
3464 name: "skill_fit".into(),
3465 src_label: "Person".into(),
3466 dst_label: "Project".into(),
3467 predicate: skill_fit,
3468 edge_type: "FIT".into(),
3469 weight_prop: Some("score".into()),
3470 max_edges: skill_fit_k,
3471 approximate: false,
3472 via_label: None,
3473 via_edge: None,
3474 via_dir: None,
3475 namespace: None,
3476 })?;
3477 let founded_within = Predicate::NumericWithin {
3478 field: "founded_year".into(),
3479 tolerance: 2.0,
3480 };
3481 let founded_within_k = Some(default_max_edges(&founded_within));
3482 w.create_rule(RuleDef {
3483 name: "founded_within".into(),
3484 src_label: "Org".into(),
3485 dst_label: "Org".into(),
3486 predicate: founded_within,
3487 edge_type: "FOUNDED_WITHIN".into(),
3488 weight_prop: Some("score".into()),
3489 max_edges: founded_within_k,
3490 approximate: false,
3491 via_label: None,
3492 via_edge: None,
3493 via_dir: None,
3494 namespace: None,
3495 })?;
3496 let nearby_office = Predicate::GeoRadius {
3497 field: "office".into(),
3498 km: 50.0,
3499 };
3500 let nearby_office_k = Some(default_max_edges(&nearby_office));
3501 w.create_rule(RuleDef {
3502 name: "nearby_office".into(),
3503 src_label: "Org".into(),
3504 dst_label: "Org".into(),
3505 predicate: nearby_office,
3506 edge_type: "NEARBY_OFFICE".into(),
3507 weight_prop: Some("score".into()),
3508 max_edges: nearby_office_k,
3509 approximate: false,
3510 via_label: None,
3511 via_edge: None,
3512 via_dir: None,
3513 namespace: None,
3514 })?;
3515 let similar_interests = Predicate::VectorSimilar {
3516 field: "embedding".into(),
3517 min: 0.8,
3518 };
3519 let similar_interests_k = Some(default_max_edges(&similar_interests));
3520 w.create_rule(RuleDef {
3521 name: "similar_interests".into(),
3522 src_label: "Person".into(),
3523 dst_label: "Person".into(),
3524 predicate: similar_interests,
3525 edge_type: "SIMILAR".into(),
3526 weight_prop: Some("score".into()),
3527 max_edges: similar_interests_k,
3528 approximate: false,
3529 via_label: None,
3530 via_edge: None,
3531 via_dir: None,
3532 namespace: None,
3533 })?;
3534 }
3535
3536 let r = db.read();
3537 let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
3538 let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
3539 let stats = r.stats();
3540 let suggestion = r.suggest_rules().into_iter().next();
3542
3543 Ok(DemoOutcome {
3544 auto_fk_rules,
3545 sample_query: SAMPLE_QUERY.to_string(),
3546 sample_result,
3547 explanations,
3548 stats,
3549 suggestion,
3550 })
3551}
3552
3553fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
3554 if dir.is_file() {
3555 return Err(CliError(format!(
3556 "demo refuses a non-empty directory: {} is a file",
3557 dir.display()
3558 )));
3559 }
3560 if !dir.exists() {
3561 return Ok(true);
3562 }
3563 Ok(std::fs::read_dir(dir)?.next().is_none())
3564}
3565
3566fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
3567 if dir_is_empty_or_absent(dir)? {
3568 Ok(())
3569 } else {
3570 Err(CliError(format!(
3571 "demo refuses a non-empty directory: {} \
3572 (directory must be empty — including hidden files)",
3573 dir.display()
3574 )))
3575 }
3576}
3577
3578pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
3580 if dir_is_empty_or_absent(dir)? {
3581 Ok(Some(run_demo(dir)?))
3582 } else {
3583 Ok(None)
3584 }
3585}
3586
3587fn json_array(rows: impl IntoIterator<Item = String>) -> String {
3588 let mut out = String::from("[");
3589 let mut first = true;
3590 for row in rows {
3591 if !first {
3592 out.push(',');
3593 }
3594 first = false;
3595 out.push_str(&row);
3596 }
3597 out.push(']');
3598 out
3599}
3600
3601fn wrap_proj(i: usize) -> usize {
3603 (i - 1) % N_PROJECTS + 1
3604}
3605
3606fn skill_window_json(start: usize, len: usize) -> String {
3608 let parts: Vec<String> = (0..len)
3609 .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
3610 .collect();
3611 format!("[{}]", parts.join(","))
3612}
3613
3614fn org_office(i: usize) -> (f64, f64) {
3618 match i {
3619 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"),
3630 }
3631}
3632
3633fn person_embedding_json(i: usize) -> String {
3637 let mut v = [0.0_f64; 8];
3638 match i {
3639 9 | 19 | 29 => {
3640 v[0] = 0.8;
3641 v[1] = 0.6;
3642 }
3643 10 | 20 | 30 => {
3644 v[0] = 0.6;
3645 v[1] = 0.8;
3646 }
3647 _ => {
3648 let axis = (i - 1) % 10;
3649 debug_assert!(axis < 8);
3650 v[axis] = 1.0;
3651 }
3652 }
3653 let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
3654 format!("[{}]", parts.join(","))
3655}
3656
3657fn org_json() -> String {
3658 json_array((1..=N_ORGS).map(|i| {
3659 let year = 2010 + (i as i64 - 1);
3660 let (lat, lon) = org_office(i);
3661 format!(
3662 r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
3663 skill_window_json(i, 3)
3664 )
3665 }))
3666}
3667
3668fn project_json() -> String {
3669 json_array((1..=N_PROJECTS).map(|i| {
3670 let org = (i - 1) % N_ORGS + 1;
3671 format!(
3672 r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
3673 skill_window_json(i, 3)
3674 )
3675 }))
3676}
3677
3678fn person_json() -> String {
3679 json_array((1..=N_PEOPLE).map(|i| {
3680 let org = (i - 1) % N_ORGS + 1;
3681 let proj = (i - 1) % N_PROJECTS + 1;
3682 format!(
3683 r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
3684 person_embedding_json(i),
3685 skill_window_json(proj, 3)
3686 )
3687 }))
3688}
3689
3690pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
3692 let mut buf = String::new();
3693 let _ = writeln!(buf, "== demo ==");
3694 let _ = writeln!(
3695 buf,
3696 "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
3697 );
3698 let _ = writeln!(
3699 buf,
3700 "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
3701 );
3702 let _ = writeln!(
3703 buf,
3704 "numeric rule: founded_within (Org.founded_year, tolerance 2)"
3705 );
3706 let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
3707 let _ = writeln!(
3708 buf,
3709 "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
3710 );
3711 let _ = writeln!(buf);
3712 let _ = writeln!(buf, "== auto-FK rules ==");
3713 let mut names = out.auto_fk_rules.clone();
3714 names.sort();
3715 for name in names {
3716 let _ = writeln!(buf, " {name}");
3717 }
3718 let _ = writeln!(buf);
3719 let _ = writeln!(buf, "== query ==");
3720 let _ = writeln!(buf, "{}", out.sample_query);
3721 let _ = writeln!(buf);
3722 let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
3723 for i in 0..out.sample_result.len() {
3724 let cells: Vec<String> = out
3725 .sample_result
3726 .columns()
3727 .iter()
3728 .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
3729 .collect();
3730 let _ = writeln!(buf, " {}", cells.join(" "));
3731 }
3732 let _ = writeln!(buf);
3733 let _ = writeln!(
3734 buf,
3735 "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
3736 );
3737 for e in &out.explanations {
3738 let weight = e
3739 .weight
3740 .map(|w| fmt_value(&Value::Float(w)))
3741 .unwrap_or_else(|| "none".into());
3742 let _ = writeln!(
3743 buf,
3744 " rule={} type={} {}→{} weight={}",
3745 e.rule, e.edge_type, e.src_key, e.dst_key, weight
3746 );
3747 }
3748 let _ = writeln!(buf);
3749 let _ = writeln!(buf, "== serve ==");
3750 let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
3751
3752 if let Some(s) = &out.suggestion {
3754 let _ = writeln!(buf);
3755 let _ = writeln!(buf, "== suggested rule (teaser) ==");
3756 let _ = writeln!(buf, " {}", s.def.name);
3757 let _ = writeln!(
3758 buf,
3759 " {} → {} via {:?}",
3760 s.def.src_label, s.def.dst_label, s.def.predicate
3761 );
3762 let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
3763 let _ = writeln!(buf, " {}", s.rationale);
3764 let _ = writeln!(
3765 buf,
3766 " (run `mushroomdb suggest {}` for full analysis)",
3767 dir.display()
3768 );
3769 }
3770
3771 buf
3772}
3773
3774pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
3776 let db = GraphDb::open(dir)?;
3777 Ok(db.suggest_rules())
3778}
3779
3780pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
3782 let mut buf = String::new();
3783 if suggestions.is_empty() {
3784 let _ = writeln!(
3785 buf,
3786 "no rule suggestions (database may be empty or rules already cover all patterns)"
3787 );
3788 return buf;
3789 }
3790 let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
3791 for (i, s) in suggestions.iter().enumerate() {
3792 let _ = writeln!(buf);
3793 let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
3794 let _ = writeln!(
3795 buf,
3796 " {} → {} via {:?}",
3797 s.def.src_label, s.def.dst_label, s.def.predicate
3798 );
3799 let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
3800 let _ = writeln!(buf, " rationale : {}", s.rationale);
3801 if !s.examples.is_empty() {
3802 let _ = writeln!(buf, " examples :");
3803 for (src, dst, score) in &s.examples {
3804 let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
3805 }
3806 }
3807 let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
3808 let _ = writeln!(
3809 buf,
3810 " to apply : POST /rules or db.create_rule(suggestion.def)"
3811 );
3812 }
3813 buf
3814}
3815
3816fn fmt_value(v: &Value) -> String {
3817 match v {
3818 Value::Int(i) => i.to_string(),
3819 Value::Float(f) => {
3820 let s = format!("{f}");
3821 if s.contains('.') || s.contains('e') || s.contains('E') {
3822 s
3823 } else {
3824 format!("{s}.0")
3825 }
3826 }
3827 Value::Str(s) => s.clone(),
3828 Value::Bool(b) => b.to_string(),
3829 Value::List(xs) => {
3830 let inner: Vec<String> = xs.iter().map(fmt_value).collect();
3831 format!("[{}]", inner.join(", "))
3832 }
3833 Value::Map(m) => {
3834 let inner: Vec<String> = m
3835 .iter()
3836 .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
3837 .collect();
3838 format!("{{{}}}", inner.join(", "))
3839 }
3840 Value::Date(_)
3841 | Value::LocalDateTime(_)
3842 | Value::DateTime { .. }
3843 | Value::Duration { .. }
3844 | Value::Binary(_) => mushroomdb::v11_display_text(v).unwrap_or_default(),
3845 Value::Null => "null".to_string(),
3847 }
3848}
3849
3850fn fmt_cell(cell: Option<&Value>) -> String {
3851 match cell {
3852 None => "null".into(),
3853 Some(v) => fmt_value(v),
3854 }
3855}
3856
3857#[cfg(test)]
3858mod tests {
3859 use super::*;
3860 use std::collections::BTreeSet;
3861 use std::net::SocketAddr;
3862 use std::path::PathBuf;
3863
3864 fn tmp(name: &str) -> PathBuf {
3865 let nanos = std::time::SystemTime::now()
3866 .duration_since(std::time::UNIX_EPOCH)
3867 .expect("clock")
3868 .as_nanos();
3869 let d = std::env::temp_dir().join(format!(
3870 "graphdb-cli-{}-{}-{}",
3871 name,
3872 std::process::id(),
3873 nanos
3874 ));
3875 let _ = std::fs::remove_dir_all(&d);
3876 d
3877 }
3878
3879 #[test]
3880 fn why_for_stdout_dims_the_framing_only_when_styled() {
3881 let framing = mushroomdb::digest::UNTRUSTED_FRAMING;
3882 let out = format!("{framing}mushroomdb explain — a ↔ b: 0 relationship(s)\n");
3883 assert_eq!(why_for_stdout(out.clone(), false), out);
3884 let styled = why_for_stdout(out.clone(), true);
3885 assert_eq!(
3886 styled,
3887 format!(
3888 "\x1b[2m{}\x1b[0m\nmushroomdb explain — a ↔ b: 0 relationship(s)\n",
3889 framing.trim_end_matches('\n')
3890 )
3891 );
3892 assert_eq!(styled.replace("\x1b[2m", "").replace("\x1b[0m", ""), out);
3894 assert_eq!(why_for_stdout("plain\n".into(), true), "plain\n");
3896 }
3897
3898 fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
3899 let g = db.read();
3900 let mut out = BTreeSet::new();
3901 for i in 1..=N_ORGS {
3902 let src = format!("org-{i:02}");
3903 if let Ok(nbrs) = g.neighbors(&src, etype, mushroomdb::Direction::Out) {
3904 for dst in nbrs {
3905 out.insert((src.clone(), dst));
3906 }
3907 }
3908 }
3909 for i in 1..=N_PEOPLE {
3910 let src = format!("person-{i:02}");
3911 if let Ok(nbrs) = g.neighbors(&src, etype, mushroomdb::Direction::Out) {
3912 for dst in nbrs {
3913 out.insert((src.clone(), dst));
3914 }
3915 }
3916 }
3917 out
3918 }
3919
3920 fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
3921 let hits: Vec<_> = db
3922 .read()
3923 .explain(a, b)
3924 .expect("explain")
3925 .into_iter()
3926 .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
3927 .collect();
3928 assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
3929 let got = hits[0].weight.expect("weighted");
3930 assert!(
3931 (got - want).abs() < 1e-12,
3932 "{rule} {a}→{b}: got {got} want {want}"
3933 );
3934 }
3935
3936 fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
3937 const R: f64 = 6371.0088;
3938 let phi1 = lat1.to_radians();
3939 let phi2 = lat2.to_radians();
3940 let dphi = (lat2 - lat1).to_radians();
3941 let dlam = (lon2 - lon1).to_radians();
3942 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
3943 .clamp(0.0, 1.0);
3944 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
3945 R * c
3946 }
3947
3948 fn default_bind() -> SocketAddr {
3949 SocketAddr::from(([127, 0, 0, 1], 8080))
3950 }
3951
3952 #[test]
3953 fn parse_args_table() {
3954 struct Case {
3955 args: &'static [&'static str],
3956 check: fn(Result<Command, String>),
3957 }
3958
3959 let cases = [
3960 Case {
3961 args: &[],
3962 check: |r| match r {
3963 Ok(Command::Help) => {}
3964 other => panic!("no-args → Help, got {other:?}"),
3965 },
3966 },
3967 Case {
3968 args: &["--help"],
3969 check: |r| match r {
3970 Ok(Command::Help) => {}
3971 other => panic!("--help → Help, got {other:?}"),
3972 },
3973 },
3974 Case {
3975 args: &["-h"],
3976 check: |r| match r {
3977 Ok(Command::Help) => {}
3978 other => panic!("-h → Help, got {other:?}"),
3979 },
3980 },
3981 Case {
3982 args: &["serve", "/tmp/demo-db"],
3983 check: |r| match r {
3984 Ok(Command::Serve {
3985 db_dir,
3986 addr,
3987 ui,
3988 demo_if_empty,
3989 token,
3990 role_tokens,
3991 snapshot_every,
3992 shutdown_timeout: _,
3993 restore_from,
3994 tls_cert,
3995 tls_key,
3996 bolt,
3997 tx,
3998 }) => {
3999 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4000 assert_eq!(addr, default_bind());
4001 assert_eq!(ui, super::ServeUi::Embedded);
4002 assert!(!demo_if_empty);
4003 assert_eq!(token, None);
4004 assert!(role_tokens.is_empty());
4005 assert_eq!(snapshot_every, None);
4006 assert_eq!(restore_from, None);
4007 assert_eq!(tls_cert, None);
4008 assert_eq!(tls_key, None);
4009 assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4010 assert_eq!(tx, super::TxFlags::default());
4011 }
4012 other => panic!("serve <dir> → Serve default addr, got {other:?}"),
4013 },
4014 },
4015 Case {
4016 args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
4017 check: |r| match r {
4018 Ok(Command::Serve {
4019 db_dir,
4020 addr,
4021 ui,
4022 demo_if_empty,
4023 token,
4024 role_tokens,
4025 snapshot_every,
4026 shutdown_timeout: _,
4027 restore_from,
4028 tls_cert,
4029 tls_key,
4030 bolt,
4031 tx,
4032 }) => {
4033 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4034 assert_eq!(
4035 addr,
4036 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
4037 );
4038 assert_eq!(ui, super::ServeUi::Embedded);
4039 assert!(!demo_if_empty);
4040 assert_eq!(token, None);
4041 assert!(role_tokens.is_empty());
4042 assert_eq!(snapshot_every, None);
4043 assert_eq!(restore_from, None);
4044 assert_eq!(tls_cert, None);
4045 assert_eq!(tls_key, None);
4046 assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4047 assert_eq!(tx, super::TxFlags::default());
4048 }
4049 other => panic!("serve --addr after dir, got {other:?}"),
4050 },
4051 },
4052 Case {
4053 args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
4054 check: |r| match r {
4055 Ok(Command::Serve {
4056 db_dir,
4057 addr,
4058 ui,
4059 demo_if_empty,
4060 token,
4061 role_tokens,
4062 snapshot_every,
4063 shutdown_timeout: _,
4064 restore_from,
4065 tls_cert,
4066 tls_key,
4067 bolt,
4068 tx,
4069 }) => {
4070 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4071 assert_eq!(
4072 addr,
4073 "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
4074 );
4075 assert_eq!(ui, super::ServeUi::Embedded);
4076 assert!(!demo_if_empty);
4077 assert_eq!(token, None);
4078 let _ = role_tokens; assert_eq!(snapshot_every, None);
4080 assert_eq!(restore_from, None);
4081 assert_eq!(tls_cert, None);
4082 assert_eq!(tls_key, None);
4083 assert_eq!(bolt, super::serve_bolt::BoltFlags::default());
4084 assert_eq!(tx, super::TxFlags::default());
4085 }
4086 other => panic!("serve --addr=VALUE, got {other:?}"),
4087 },
4088 },
4089 Case {
4090 args: &["mcp", "/tmp/demo-db"],
4091 check: |r| match r {
4092 Ok(Command::Mcp {
4093 db_dir,
4094 auto,
4095 all_tools,
4096 }) => {
4097 assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
4098 assert!(!auto);
4099 assert!(!all_tools, "the short list is the default");
4100 }
4101 other => panic!("mcp <dir>, got {other:?}"),
4102 },
4103 },
4104 Case {
4105 args: &["stats", "/tmp/demo-db"],
4106 check: |r| match r {
4107 Ok(Command::Stats { db_dir }) => {
4108 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4109 }
4110 other => panic!("stats <dir>, got {other:?}"),
4111 },
4112 },
4113 Case {
4114 args: &["demo", "/tmp/demo-db"],
4115 check: |r| match r {
4116 Ok(Command::Demo { db_dir }) => {
4117 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
4118 }
4119 other => panic!("demo <dir>, got {other:?}"),
4120 },
4121 },
4122 Case {
4123 args: &["serve"],
4124 check: |r| {
4125 let e = r.expect_err("serve without dir");
4126 assert!(
4127 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4128 "missing-dir error should mention dir, got {e}"
4129 );
4130 },
4131 },
4132 Case {
4133 args: &["mcp"],
4134 check: |r| {
4135 let e = r.expect_err("mcp without dir");
4136 assert!(
4137 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4138 "missing-dir error should mention dir, got {e}"
4139 );
4140 },
4141 },
4142 Case {
4143 args: &["stats"],
4144 check: |r| {
4145 let e = r.expect_err("stats without dir");
4146 assert!(
4147 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4148 "missing-dir error should mention dir, got {e}"
4149 );
4150 },
4151 },
4152 Case {
4153 args: &["demo"],
4154 check: |r| {
4155 let e = r.expect_err("demo without dir");
4156 assert!(
4157 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
4158 "missing-dir error should mention dir, got {e}"
4159 );
4160 },
4161 },
4162 Case {
4163 args: &["serve", "/tmp/demo-db", "--addr"],
4164 check: |r| {
4165 let e = r.expect_err("--addr missing value");
4166 assert!(
4167 e.to_lowercase().contains("addr"),
4168 "--addr missing value should mention addr, got {e}"
4169 );
4170 },
4171 },
4172 Case {
4173 args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
4174 check: |r| {
4175 let e = r.expect_err("invalid addr");
4176 assert!(
4177 e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
4178 "invalid addr should mention address, got {e}"
4179 );
4180 },
4181 },
4182 Case {
4183 args: &["frobnicate", "/tmp/demo-db"],
4184 check: |r| {
4185 let e = r.expect_err("unknown command");
4186 assert!(
4187 e.to_lowercase().contains("unknown")
4188 || e.to_lowercase().contains("frobnicate"),
4189 "unknown command should name it, got {e}"
4190 );
4191 },
4192 },
4193 Case {
4194 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
4195 check: |r| match r {
4196 Ok(Command::Serve { ui, .. }) => {
4197 assert_eq!(
4198 ui,
4199 super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
4200 );
4201 }
4202 other => panic!("serve --ui <dir>, got {other:?}"),
4203 },
4204 },
4205 Case {
4206 args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
4207 check: |r| match r {
4208 Ok(Command::Serve { ui, .. }) => {
4209 assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
4210 }
4211 other => panic!("serve --ui=VALUE, got {other:?}"),
4212 },
4213 },
4214 Case {
4215 args: &["serve", "/tmp/demo-db", "--ui"],
4216 check: |r| {
4217 let e = r.expect_err("--ui missing value");
4218 assert!(
4219 e.to_lowercase().contains("ui"),
4220 "--ui missing value should mention ui, got {e}"
4221 );
4222 },
4223 },
4224 Case {
4225 args: &["serve", "/tmp/demo-db", "--no-ui"],
4226 check: |r| match r {
4227 Ok(Command::Serve { ui, .. }) => {
4228 assert_eq!(ui, super::ServeUi::None);
4229 }
4230 other => panic!("serve --no-ui, got {other:?}"),
4231 },
4232 },
4233 Case {
4234 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
4235 check: |r| {
4236 let e = r.expect_err("combine --ui and --no-ui");
4237 assert!(
4238 e.contains("--ui") && e.contains("--no-ui"),
4239 "conflict should name both flags, got {e}"
4240 );
4241 },
4242 },
4243 Case {
4244 args: &["serve", "/tmp/demo-db", "extra"],
4245 check: |r| {
4246 let e = r.expect_err("extra positional");
4247 assert!(
4248 e.to_lowercase().contains("unexpected")
4249 || e.to_lowercase().contains("extra"),
4250 "extra arg should be rejected, got {e}"
4251 );
4252 },
4253 },
4254 Case {
4255 args: &[
4256 "serve",
4257 "/data",
4258 "--addr",
4259 "0.0.0.0:8080",
4260 "--demo-if-empty",
4261 ],
4262 check: |r| match r {
4263 Ok(Command::Serve {
4264 db_dir,
4265 addr,
4266 demo_if_empty,
4267 ui,
4268 token,
4269 snapshot_every,
4270 ..
4271 }) => {
4272 assert_eq!(db_dir, PathBuf::from("/data"));
4273 assert_eq!(
4274 addr,
4275 "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
4276 );
4277 assert!(demo_if_empty);
4278 assert_eq!(ui, super::ServeUi::Embedded);
4279 assert_eq!(token, None);
4280 assert_eq!(snapshot_every, None);
4281 }
4282 other => panic!("serve --demo-if-empty docker default, got {other:?}"),
4283 },
4284 },
4285 Case {
4286 args: &["install", "--project", "--delivery", "cli"],
4287 check: |r| match r {
4288 Ok(Command::Install(opts)) => {
4289 assert_eq!(opts.scope, Some(install::Scope::Project));
4290 assert_eq!(opts.delivery, install::Delivery::Cli);
4291 }
4292 other => panic!("install --delivery cli, got {other:?}"),
4293 },
4294 },
4295 Case {
4296 args: &["install", "--delivery=mcp"],
4297 check: |r| match r {
4298 Ok(Command::Install(opts)) => {
4299 assert_eq!(opts.delivery, install::Delivery::Mcp)
4300 }
4301 other => panic!("install --delivery=mcp, got {other:?}"),
4302 },
4303 },
4304 Case {
4305 args: &["install"],
4308 check: |r| match r {
4309 Ok(Command::Install(opts)) => {
4310 assert_eq!(opts.delivery, install::Delivery::Both)
4311 }
4312 other => panic!("install, got {other:?}"),
4313 },
4314 },
4315 Case {
4316 args: &["install", "--delivery", "sideways"],
4317 check: |r| match r {
4318 Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
4319 other => panic!("a bad --delivery must be refused, got {other:?}"),
4320 },
4321 },
4322 Case {
4323 args: &["install", "--always-load"],
4324 check: |r| match r {
4325 Ok(Command::Install(opts)) => assert!(opts.always_load),
4326 other => panic!("install --always-load, got {other:?}"),
4327 },
4328 },
4329 Case {
4330 args: &["install", "--no-git-hooks"],
4333 check: |r| match r {
4334 Ok(Command::Install(_)) => {}
4335 other => panic!("install --no-git-hooks, got {other:?}"),
4336 },
4337 },
4338 Case {
4339 args: &["install", "--delivery", "mcp", "--db", "./mem"],
4343 check: |r| match r {
4344 Ok(Command::Install(opts)) => assert!(opts.always_load),
4345 other => panic!("install --delivery mcp --db, got {other:?}"),
4346 },
4347 },
4348 Case {
4349 args: &["install", "--delivery", "both", "--db=./mem"],
4351 check: |r| match r {
4352 Ok(Command::Install(opts)) => assert!(opts.always_load),
4353 other => panic!("install --delivery both --db, got {other:?}"),
4354 },
4355 },
4356 Case {
4357 args: &[
4359 "install",
4360 "--delivery",
4361 "mcp",
4362 "--db",
4363 "./mem",
4364 "--no-always-load",
4365 ],
4366 check: |r| match r {
4367 Ok(Command::Install(opts)) => assert!(!opts.always_load),
4368 other => panic!("install --no-always-load, got {other:?}"),
4369 },
4370 },
4371 Case {
4372 args: &["install", "--delivery", "cli", "--db", "./mem"],
4376 check: |r| match r {
4377 Ok(Command::Install(opts)) => assert!(!opts.always_load),
4378 other => panic!("install --delivery cli --db, got {other:?}"),
4379 },
4380 },
4381 Case {
4382 args: &["install", "--always-load"],
4386 check: |r| match r {
4387 Ok(Command::Install(opts)) => assert!(opts.always_load),
4388 other => panic!("install --always-load, got {other:?}"),
4389 },
4390 },
4391 Case {
4392 args: &["install"],
4394 check: |r| match r {
4395 Ok(Command::Install(opts)) => assert!(!opts.always_load),
4396 other => panic!("install, got {other:?}"),
4397 },
4398 },
4399 ];
4400
4401 for case in &cases {
4402 (case.check)(parse_args(case.args));
4403 }
4404 }
4405
4406 #[test]
4411 fn the_retired_code_graph_subcommands_are_unknown() {
4412 for sub in ["map", "explore", "context", "impact", "owners"] {
4413 match parse_args(&[sub, "/tmp/db"]) {
4414 Err(e) => assert_eq!(e, format!("unknown command: {sub}")),
4415 other => panic!("{sub} still parses: {other:?}"),
4416 }
4417 assert!(
4418 !usage().contains(&format!("mushroomdb {sub} ")),
4419 "the help still names {sub}"
4420 );
4421 }
4422 }
4423
4424 #[test]
4429 fn the_retired_hook_bodies_parse_as_inert() {
4430 for sub in ["touch", "intercept", "impact-hook", "enrich", "sync"] {
4431 for args in [
4432 vec![sub],
4433 vec![sub, "--auto"],
4434 vec![sub, "/tmp/db", "--whatever", "x"],
4435 ] {
4436 assert_eq!(parse_args(&args), Ok(Command::RetiredHook), "{args:?}");
4437 }
4438 assert!(
4439 !usage().contains(&format!("mushroomdb {sub}")),
4440 "the help still names {sub}"
4441 );
4442 }
4443 }
4444
4445 #[test]
4446 fn serve_default_addr_is_loopback_8080() {
4447 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4448 Command::Serve { addr, .. } => {
4449 assert_eq!(
4450 addr,
4451 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
4452 );
4453 }
4454 other => panic!("{other:?}"),
4455 }
4456 }
4457
4458 #[test]
4459 fn the_shutdown_plan_uses_the_nominal_split_with_room_and_scales_without() {
4460 let s = Duration::from_secs;
4461 let p = shutdown_plan(s(55), s(22));
4463 assert_eq!((p.drain, p.snapshot, p.backup), (s(25), s(8), s(22)));
4464 let p = shutdown_plan(s(100), s(22));
4466 assert_eq!((p.drain, p.snapshot, p.backup), (s(25), s(53), s(22)));
4467 for total in [1, 2, 5, 10, 25, 40, 54] {
4469 for bound in [0, 1, 3, 22, 60] {
4470 let p = shutdown_plan(s(total), s(bound));
4471 assert!(
4472 p.drain + p.snapshot + p.backup <= s(total),
4473 "{total}/{bound}: {p:?}"
4474 );
4475 assert!(p.drain <= s(SHUTDOWN_DRAIN_SECS));
4476 if bound == 0 {
4477 assert_eq!(p.backup, Duration::ZERO);
4478 } else {
4479 assert!(p.backup <= s(bound).min(s(total)));
4480 assert!(p.backup >= s(2).min(s(bound)).min(s(total)), "{p:?}");
4481 }
4482 }
4483 }
4484 let p = shutdown_plan(s(25), s(22));
4486 assert!(p.backup >= s(9) && p.drain >= s(10), "{p:?}");
4487 }
4488
4489 #[test]
4490 fn serve_parses_the_bolt_flags_in_both_spellings() {
4491 let bolt = |args: &[&str]| match parse_args(args).unwrap() {
4492 Command::Serve { bolt, .. } => bolt,
4493 other => panic!("{other:?}"),
4494 };
4495 let flags = bolt(&[
4496 "serve",
4497 "/tmp/db",
4498 "--bolt-port",
4499 "7687",
4500 "--bolt-tls",
4501 "--bolt-advertised-host=db.example.test",
4502 "--bolt-advertised-port",
4503 "17687",
4504 "--bolt-database=orders",
4505 "--bolt-max-connections",
4506 "8",
4507 "--bolt-tx-timeout-secs=12",
4508 "--bookmark-timeout-secs",
4509 "3",
4510 ]);
4511 assert_eq!(
4512 flags,
4513 serve_bolt::BoltFlags {
4514 port: Some(7687),
4515 tls: Some(true),
4516 advertised_host: Some("db.example.test".into()),
4517 advertised_port: Some(17687),
4518 database: Some("orders".into()),
4519 max_connections: Some(8),
4520 tx_timeout: Some(Duration::from_secs(12)),
4521 bookmark_timeout: Some(Duration::from_secs(3)),
4522 }
4523 );
4524 assert_eq!(
4525 bolt(&["serve", "/tmp/db", "--bolt-port=0", "--bolt-plaintext"]).tls,
4526 Some(false)
4527 );
4528 for bad in [
4529 &["serve", "/tmp/db", "--bolt-port", "70000"][..],
4530 &["serve", "/tmp/db", "--bolt-port"],
4531 &["serve", "/tmp/db", "--bolt-tls", "--bolt-plaintext"],
4532 &["serve", "/tmp/db", "--bolt-tx-timeout-secs", "0"],
4533 &["serve", "/tmp/db", "--bookmark-timeout-secs=x"],
4534 &["serve", "/tmp/db", "--bolt-max-connections", "-1"],
4535 ] {
4536 assert!(parse_args(bad).is_err(), "{bad:?}");
4537 }
4538 }
4539
4540 #[test]
4541 fn serve_parses_the_tx_flags_in_both_spellings_and_refuses_zero() {
4542 let tx = |args: &[&str]| match parse_args(args).unwrap() {
4543 Command::Serve { tx, .. } => tx,
4544 other => panic!("{other:?}"),
4545 };
4546 assert_eq!(tx(&["serve", "/tmp/db"]), TxFlags::default());
4547 assert_eq!(
4548 tx(&[
4549 "serve",
4550 "/tmp/db",
4551 "--tx-max",
4552 "7",
4553 "--tx-timeout-secs=4",
4554 "--tx-max-lifetime-secs",
4555 "60",
4556 ]),
4557 TxFlags {
4558 max_open: NonZeroU64::new(7),
4559 idle_timeout_secs: NonZeroU64::new(4),
4560 max_lifetime_secs: NonZeroU64::new(60),
4561 }
4562 );
4563 for bad in [
4564 &["serve", "/tmp/db", "--tx-max", "0"][..],
4565 &["serve", "/tmp/db", "--tx-max"],
4566 &["serve", "/tmp/db", "--tx-timeout-secs=0"],
4567 &["serve", "/tmp/db", "--tx-max-lifetime-secs", "-5"],
4568 &["serve", "/tmp/db", "--tx-timeout-secs", "soon"],
4569 ] {
4570 assert!(parse_args(bad).is_err(), "{bad:?}");
4571 }
4572 }
4573
4574 #[test]
4575 fn a_tx_flag_wins_over_its_variable_and_an_unset_flag_leaves_the_variable() {
4576 let env = |name: &str| match name {
4577 server::TX_MAX_ENV => Some("5".to_string()),
4578 server::TX_TIMEOUT_ENV => Some("9".to_string()),
4579 server::TX_MAX_LIFETIME_ENV => Some("99".to_string()),
4580 _ => None,
4581 };
4582 let s = Duration::from_secs;
4583 let from_env = resolve_tx_limits(&TxFlags::default(), &env);
4584 assert_eq!(
4585 (
4586 from_env.max_open,
4587 from_env.idle_timeout,
4588 from_env.max_lifetime
4589 ),
4590 (5, s(9), s(99))
4591 );
4592 let flags = TxFlags {
4593 max_open: NonZeroU64::new(2),
4594 idle_timeout_secs: None,
4595 max_lifetime_secs: NonZeroU64::new(40),
4596 };
4597 let mixed = resolve_tx_limits(&flags, &env);
4598 assert_eq!(
4599 (mixed.max_open, mixed.idle_timeout, mixed.max_lifetime),
4600 (2, s(9), s(40))
4601 );
4602 assert_eq!(
4604 resolve_tx_limits(&TxFlags::default(), &|_| None),
4605 server::TxLimits::default()
4606 );
4607 let invalid = |name: &str| (name == server::TX_MAX_ENV).then(|| "lots".to_string());
4609 let given = TxFlags {
4610 max_open: NonZeroU64::new(3),
4611 ..TxFlags::default()
4612 };
4613 assert_eq!(resolve_tx_limits(&given, &invalid).max_open, 3);
4614 }
4615
4616 #[test]
4617 fn serve_shutdown_timeout_parses_seconds_and_defaults_to_55() {
4618 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4619 Command::Serve {
4620 shutdown_timeout, ..
4621 } => assert_eq!(shutdown_timeout, Duration::from_secs(55)),
4622 other => panic!("{other:?}"),
4623 }
4624 match parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs", "90"]).unwrap() {
4625 Command::Serve {
4626 shutdown_timeout, ..
4627 } => assert_eq!(shutdown_timeout, Duration::from_secs(90)),
4628 other => panic!("{other:?}"),
4629 }
4630 match parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs=7"]).unwrap() {
4631 Command::Serve {
4632 shutdown_timeout, ..
4633 } => assert_eq!(shutdown_timeout, Duration::from_secs(7)),
4634 other => panic!("{other:?}"),
4635 }
4636 for bad in ["0", "nope"] {
4637 let err =
4638 parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs", bad]).unwrap_err();
4639 assert!(err.contains("shutdown-timeout-secs"), "{err}");
4640 }
4641 assert!(parse_args(&["serve", "/tmp/db", "--shutdown-timeout-secs"]).is_err());
4642 }
4643
4644 #[test]
4645 fn serve_snapshot_every_parses_seconds() {
4646 match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
4647 Command::Serve { snapshot_every, .. } => {
4648 assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
4649 }
4650 other => panic!("{other:?}"),
4651 }
4652 match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
4653 Command::Serve { snapshot_every, .. } => {
4654 assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
4655 }
4656 other => panic!("{other:?}"),
4657 }
4658 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4659 Command::Serve { snapshot_every, .. } => {
4660 assert_eq!(snapshot_every, None);
4661 }
4662 other => panic!("{other:?}"),
4663 }
4664 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
4665 assert!(
4666 err.contains("snapshot-every"),
4667 "missing value should name the flag, got {err}"
4668 );
4669 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
4670 assert!(
4671 err.contains("snapshot-every"),
4672 "zero should be rejected, got {err}"
4673 );
4674 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
4675 assert!(
4676 err.contains("snapshot-every"),
4677 "invalid value should name the flag, got {err}"
4678 );
4679 }
4680
4681 #[test]
4682 fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
4683 match parse_args(&[
4685 "serve",
4686 "/tmp/db",
4687 "--addr",
4688 "0.0.0.0:8080",
4689 "--token",
4690 "s3cret",
4691 ])
4692 .unwrap()
4693 {
4694 Command::Serve { token, addr, .. } => {
4695 assert_eq!(token.as_deref(), Some("s3cret"));
4696 assert_eq!(addr.ip().to_string(), "0.0.0.0");
4697 }
4698 other => panic!("{other:?}"),
4699 }
4700 }
4701
4702 #[test]
4703 fn parse_build_index_both_forms_and_a_missing_value() {
4704 match parse_args(&["build-index", "/tmp/db"]).unwrap() {
4705 Command::BuildIndex { db_dir, rule } => {
4706 assert_eq!(db_dir, PathBuf::from("/tmp/db"));
4707 assert_eq!(rule, None);
4708 }
4709 other => panic!("{other:?}"),
4710 }
4711 for args in [
4712 vec!["build-index", "/tmp/db", "--rule", "sim"],
4713 vec!["build-index", "/tmp/db", "--rule=sim"],
4714 ] {
4715 match parse_args(&args).unwrap() {
4716 Command::BuildIndex { db_dir, rule } => {
4717 assert_eq!(db_dir, PathBuf::from("/tmp/db"));
4718 assert_eq!(rule.as_deref(), Some("sim"), "{args:?}");
4719 }
4720 other => panic!("{other:?}"),
4721 }
4722 }
4723 assert_eq!(
4724 parse_args(&["build-index", "/tmp/db", "--rule"]).unwrap_err(),
4725 "--rule requires a value"
4726 );
4727 assert_eq!(
4728 parse_args(&["build-index", "/tmp/db", "--rule="]).unwrap_err(),
4729 "--rule requires a value"
4730 );
4731 assert_eq!(
4732 parse_args(&["build-index"]).unwrap_err(),
4733 "build-index requires <db-dir>"
4734 );
4735 assert!(usage().contains("mushroomdb build-index <db-dir> [--rule <name>]"));
4736 }
4737
4738 #[test]
4739 fn parse_snapshot_and_query() {
4740 for (args, want) in [
4742 (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
4743 (
4744 vec!["snapshot", "/tmp/db", "--archive-wal"],
4745 WalDisposition::Archive,
4746 ),
4747 (
4748 vec!["snapshot", "/tmp/db", "--keep-wal"],
4749 WalDisposition::Keep,
4750 ),
4751 (
4752 vec!["snapshot", "/tmp/db", "--truncate"],
4753 WalDisposition::Truncate,
4754 ),
4755 ] {
4756 match parse_args(&args).unwrap() {
4757 Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
4758 other => panic!("{other:?}"),
4759 }
4760 }
4761 match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
4762 Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
4763 other => panic!("{other:?}"),
4764 }
4765 match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
4766 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
4767 other => panic!("{other:?}"),
4768 }
4769 match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
4770 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
4771 other => panic!("{other:?}"),
4772 }
4773 let text = usage();
4774 assert!(
4775 text.contains("query"),
4776 "usage should mention query, got:\n{text}"
4777 );
4778 assert!(
4779 text.contains("snapshot"),
4780 "usage should mention snapshot, got:\n{text}"
4781 );
4782 }
4783
4784 #[test]
4788 fn usage_describes_one_surface_and_the_memory_brief() {
4789 let text = usage();
4790 assert!(
4791 text.contains(
4792 "--all-tools lists every served tool; the default lists the association tools on every store,\n\
4793 \x20 a store `ingest-git` built included"
4794 ),
4795 "mcp help line, got:\n{text}"
4796 );
4797 assert!(
4798 text.contains(
4799 "mushroomdb brief <db-dir>|--auto hook body: the store's schema in one block — labels,\n\
4800 \x20 edge types, history depth, roles and one worked call per\n\
4801 \x20 question kind; byte-stable, so a session host caches it once"
4802 ),
4803 "brief help line, got:\n{text}"
4804 );
4805 for stale in [
4806 "28 tools",
4807 "explore, query, stats",
4808 "most called symbols",
4809 "synced sha",
4810 ] {
4811 assert!(!text.contains(stale), "usage still says {stale:?}");
4812 }
4813 }
4814
4815 #[test]
4816 fn parse_keys_subcommands() {
4817 match parse_args(&["keys", "add", "/d", "app", "--tier", "write"]).unwrap() {
4818 Command::KeysAdd {
4819 name, tier, role, ..
4820 } => assert_eq!((name.as_str(), tier.as_str(), role), ("app", "write", None)),
4821 other => panic!("{other:?}"),
4822 }
4823 match parse_args(&["keys", "add", "/d", "t", "--role", "r", "--tier", "read"]).unwrap() {
4824 Command::KeysAdd { tier, role, .. } => {
4825 assert_eq!((tier.as_str(), role.as_deref()), ("read", Some("r")))
4826 }
4827 other => panic!("{other:?}"),
4828 }
4829 assert!(matches!(
4830 parse_args(&["keys", "hash"]),
4831 Ok(Command::KeysHash)
4832 ));
4833 for bad in [
4834 vec!["keys"],
4835 vec!["keys", "hash", "x"],
4836 vec!["keys", "add", "/d", "app"],
4837 vec!["keys", "add", "/d", "--tier", "write"],
4838 vec!["keys", "add", "/d", "app", "--tier", "read"],
4839 vec!["keys", "add", "/d", "app", "--tier", "write", "--role", "r"],
4840 vec!["keys", "add", "/d", "app", "--tier", "root"],
4841 vec![
4842 "keys", "add", "/d", "app", "--tier", "write", "--tier", "read",
4843 ],
4844 vec!["keys", "add", "/d", "app", "--tier", "write", "--bogus"],
4845 ] {
4846 assert!(parse_args(&bad).is_err(), "{bad:?}");
4847 }
4848 }
4849
4850 #[test]
4851 fn usage_lists_every_subcommand() {
4852 let text = usage();
4853 for word in [
4854 "serve",
4855 "mcp",
4856 "stats",
4857 "demo",
4858 "query",
4859 "snapshot",
4860 "--keep-wal",
4861 "mushroomdb",
4862 "--ui",
4863 "--no-ui",
4864 "--demo-if-empty",
4865 "--token",
4866 "--role-token TOKEN:ROLE",
4867 "--tls-cert <pem> --tls-key <pem>",
4868 "a build with the `tls` feature",
4869 "--snapshot-every",
4870 ] {
4871 assert!(
4872 text.contains(word),
4873 "usage should mention {word}, got:\n{text}"
4874 );
4875 }
4876 }
4877
4878 #[test]
4879 fn validate_ui_dir_requires_index_html() {
4880 let missing = tmp("ui-missing");
4881 let err = super::validate_ui_dir(&missing).expect_err("missing dir");
4882 assert!(
4883 err.contains("does not exist"),
4884 "missing dir error, got {err}"
4885 );
4886
4887 let empty = tmp("ui-empty");
4888 std::fs::create_dir_all(&empty).unwrap();
4889 let err = super::validate_ui_dir(&empty).expect_err("no index");
4890 assert!(
4891 err.contains("index.html"),
4892 "missing index.html error, got {err}"
4893 );
4894
4895 let ok = tmp("ui-ok");
4896 std::fs::create_dir_all(&ok).unwrap();
4897 std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
4898 let got = super::validate_ui_dir(&ok).expect("valid ui dir");
4899 assert_eq!(got, ok);
4900 }
4901
4902 #[test]
4903 fn maybe_run_demo_if_empty_seeds_then_skips() {
4904 let dir = tmp("boot-empty");
4905 let first = super::maybe_run_demo_if_empty(&dir)
4906 .expect("empty dir demos")
4907 .expect("Some(DemoOutcome)");
4908 assert_eq!(first.stats.nodes_live, 60);
4909 let db = SharedDb::open(&dir).expect("reopen");
4910 assert!(db.read().has_node("person-01"));
4911 let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
4912 assert!(
4913 second.is_none(),
4914 "second boot must not re-demo a populated volume"
4915 );
4916
4917 let occupied = tmp("boot-occupied");
4918 std::fs::create_dir_all(&occupied).unwrap();
4919 std::fs::write(occupied.join("keep-me"), b"x").unwrap();
4920 let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
4921 assert!(skipped.is_none());
4922 assert_eq!(
4923 std::fs::read(occupied.join("keep-me")).unwrap(),
4924 b"x",
4925 "existing volume contents must be untouched"
4926 );
4927 }
4928
4929 #[test]
4930 fn demo_builder_is_deterministic_and_refuses_second_run() {
4931 let dir = tmp("demo");
4932 let out = run_demo(&dir).expect("first demo run");
4933
4934 assert_eq!(
4935 out.stats.nodes_live, 60,
4936 "10 orgs + 20 projects + 30 people"
4937 );
4938 assert_eq!(out.stats.nodes_tombstoned, 0);
4939 assert_eq!(out.stats.edges, 334);
4947 assert_eq!(
4948 out.stats.rules.len(),
4949 7,
4950 "3 auto-FK + overlap + numeric + geo + vector"
4951 );
4952 let fit = out
4953 .stats
4954 .rules
4955 .iter()
4956 .find(|r| r.name == "skill_fit")
4957 .expect("skill_fit");
4958 assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
4959 let founded = out
4960 .stats
4961 .rules
4962 .iter()
4963 .find(|r| r.name == "founded_within")
4964 .expect("founded_within");
4965 assert_eq!(founded.edges, 34);
4966 let nearby = out
4967 .stats
4968 .rules
4969 .iter()
4970 .find(|r| r.name == "nearby_office")
4971 .expect("nearby_office");
4972 assert_eq!(nearby.edges, 16);
4973 let similar = out
4974 .stats
4975 .rules
4976 .iter()
4977 .find(|r| r.name == "similar_interests")
4978 .expect("similar_interests");
4979 assert_eq!(similar.edges, 114);
4980
4981 let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
4982 names.sort_unstable();
4983 assert_eq!(
4984 names,
4985 vec![
4986 "auto_fk_person_org_id",
4987 "auto_fk_person_project_id",
4988 "auto_fk_project_org_id",
4989 "founded_within",
4990 "nearby_office",
4991 "similar_interests",
4992 "skill_fit",
4993 ]
4994 );
4995
4996 let db = SharedDb::open(&dir).expect("reopen demo");
5000 assert_eq!(
5001 db.read().fulltext_pairs(),
5002 vec![
5003 ("Concept".to_string(), "name".to_string()),
5004 ("Concept".to_string(), "summary".to_string()),
5005 ("Entity".to_string(), "name".to_string()),
5006 ("Event".to_string(), "name".to_string()),
5007 ("Note".to_string(), "text".to_string()),
5008 ("Org".to_string(), "name".to_string()),
5009 ("Person".to_string(), "name".to_string()),
5010 ("Project".to_string(), "name".to_string()),
5011 ]
5012 );
5013
5014 let mut auto = out.auto_fk_rules.clone();
5015 auto.sort();
5016 assert_eq!(
5017 auto,
5018 vec![
5019 "auto_fk_person_org_id".to_string(),
5020 "auto_fk_person_project_id".to_string(),
5021 "auto_fk_project_org_id".to_string(),
5022 ]
5023 );
5024
5025 assert!(
5026 !out.sample_result.is_empty(),
5027 "sample Cypher query must return rows"
5028 );
5029 assert!(
5030 out.sample_query.contains("ORDER BY score DESC"),
5031 "sample query must rank by score, got {}",
5032 out.sample_query
5033 );
5034 let scores: Vec<f64> = (0..out.sample_result.len())
5035 .map(|i| match out.sample_result.get(i, "score") {
5036 Some(Value::Float(f)) => *f,
5037 other => panic!("score col should be Float, got {other:?}"),
5038 })
5039 .collect();
5040 let distinct: std::collections::BTreeSet<u64> =
5041 scores.iter().map(|s| s.to_bits()).collect();
5042 assert!(
5043 distinct.len() >= 2,
5044 "sample results must be visibly ranked, got {scores:?}"
5045 );
5046 for w in scores.windows(2) {
5047 assert!(
5048 w[0] >= w[1],
5049 "scores must be non-increasing, got {scores:?}"
5050 );
5051 }
5052 assert!(
5053 !out.explanations.is_empty(),
5054 "explain(person-01, proj-01) must find the derived edges"
5055 );
5056
5057 let db = SharedDb::open(&dir).expect("reopen demo");
5058 assert_eq!(
5059 directed_pairs(&db, "FOUNDED_WITHIN"),
5060 [
5061 ("org-01", "org-02"),
5062 ("org-01", "org-03"),
5063 ("org-02", "org-01"),
5064 ("org-02", "org-03"),
5065 ("org-02", "org-04"),
5066 ("org-03", "org-01"),
5067 ("org-03", "org-02"),
5068 ("org-03", "org-04"),
5069 ("org-03", "org-05"),
5070 ("org-04", "org-02"),
5071 ("org-04", "org-03"),
5072 ("org-04", "org-05"),
5073 ("org-04", "org-06"),
5074 ("org-05", "org-03"),
5075 ("org-05", "org-04"),
5076 ("org-05", "org-06"),
5077 ("org-05", "org-07"),
5078 ("org-06", "org-04"),
5079 ("org-06", "org-05"),
5080 ("org-06", "org-07"),
5081 ("org-06", "org-08"),
5082 ("org-07", "org-05"),
5083 ("org-07", "org-06"),
5084 ("org-07", "org-08"),
5085 ("org-07", "org-09"),
5086 ("org-08", "org-06"),
5087 ("org-08", "org-07"),
5088 ("org-08", "org-09"),
5089 ("org-08", "org-10"),
5090 ("org-09", "org-07"),
5091 ("org-09", "org-08"),
5092 ("org-09", "org-10"),
5093 ("org-10", "org-08"),
5094 ("org-10", "org-09"),
5095 ]
5096 .into_iter()
5097 .map(|(a, b)| (a.to_string(), b.to_string()))
5098 .collect::<BTreeSet<_>>()
5099 );
5100 assert_eq!(
5101 directed_pairs(&db, "NEARBY_OFFICE"),
5102 [
5103 ("org-01", "org-07"),
5104 ("org-01", "org-10"),
5105 ("org-02", "org-09"),
5106 ("org-03", "org-08"),
5107 ("org-04", "org-05"),
5108 ("org-04", "org-06"),
5109 ("org-05", "org-04"),
5110 ("org-05", "org-06"),
5111 ("org-06", "org-04"),
5112 ("org-06", "org-05"),
5113 ("org-07", "org-01"),
5114 ("org-07", "org-10"),
5115 ("org-08", "org-03"),
5116 ("org-09", "org-02"),
5117 ("org-10", "org-01"),
5118 ("org-10", "org-07"),
5119 ]
5120 .into_iter()
5121 .map(|(a, b)| (a.to_string(), b.to_string()))
5122 .collect::<BTreeSet<_>>()
5123 );
5124 assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
5125 let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
5126 assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
5127 assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
5128 assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
5129
5130 let err = run_demo(&dir).expect_err("second run into the same dir");
5131 let msg = err.to_string().to_lowercase();
5132 assert!(
5133 msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
5134 "refuse message must mention non-empty dir, got {err}"
5135 );
5136 assert!(
5137 msg.contains("hidden"),
5138 "refuse message must mention hidden files, got {err}"
5139 );
5140
5141 let _ = std::fs::remove_dir_all(&dir);
5142 }
5143
5144 #[test]
5145 fn run_snapshot_writes_snapshot_bin() {
5146 let dir = tmp("snapshot-cli");
5147 {
5148 let mut db = GraphDb::open(&dir).expect("open");
5149 db.insert_node("Person", "alice", vec![]).expect("insert");
5150 }
5151 assert!(
5152 !dir.join("snapshot.bin").exists(),
5153 "GraphDb Drop must not snapshot"
5154 );
5155 let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
5156 assert!(
5157 dir.join("snapshot.bin").is_file(),
5158 "run_snapshot must write snapshot.bin"
5159 );
5160 assert!(
5161 out.contains("snapshot.bin"),
5162 "snapshot output should mention snapshot.bin, got {out}"
5163 );
5164 let db = GraphDb::open(&dir).expect("reopen");
5165 assert!(db.has_node("alice"), "reopen after snapshot must recover");
5166 let _ = std::fs::remove_dir_all(&dir);
5167 }
5168
5169 #[test]
5174 fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
5175 let dir = tmp("snapshot-archive");
5176 {
5177 let mut db = GraphDb::open(&dir).expect("open");
5178 db.insert_node("Person", "alice", vec![]).expect("insert");
5179 }
5180 let before = mushroomdb::wal_commit_count_at(&dir).expect("count");
5181 assert!(before > 0, "the insert is a commit");
5182
5183 {
5185 let shared = SharedDb::open(&dir).expect("open");
5186 snapshot_shared(&shared).expect("snapshot");
5187 }
5188
5189 let archives = || {
5190 std::fs::read_dir(&dir)
5191 .expect("read dir")
5192 .filter_map(Result::ok)
5193 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5194 .count()
5195 };
5196 assert_eq!(archives(), 1, "the WAL was archived, not dropped");
5197 assert!(
5198 dir.join("wal.genesis").is_file(),
5199 "the genesis marker is what lets asof reach an archived commit"
5200 );
5201 {
5202 let db = GraphDb::open(&dir).expect("reopen");
5203 assert!(db.has_node("alice"));
5204 assert!(
5205 !db.node_history("alice").expect("history").items.is_empty(),
5206 "the insert is still explainable"
5207 );
5208 }
5209 assert!(
5210 GraphDb::open_at(&dir, before - 1).is_ok(),
5211 "asof still reaches a commit the snapshot folded in"
5212 );
5213
5214 run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
5217 assert!(
5218 !dir.join("wal.genesis").exists(),
5219 "truncating ends asof's reach into the archives"
5220 );
5221 let db = GraphDb::open(&dir).expect("reopen");
5222 assert!(
5223 db.has_node("alice"),
5224 "the data survives; only the past goes"
5225 );
5226 let _ = std::fs::remove_dir_all(&dir);
5227 }
5228
5229 #[test]
5233 fn automatic_snapshots_keep_every_archive() {
5234 let dir = tmp("snapshot-retention");
5235 let archives = |d: &Path| {
5236 std::fs::read_dir(d)
5237 .expect("read dir")
5238 .filter_map(Result::ok)
5239 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5240 .count()
5241 };
5242
5243 let rounds = 10;
5246 for i in 0..rounds {
5247 {
5248 let mut db = GraphDb::open(&dir).expect("open");
5249 db.insert_node("Person", &format!("p{i}"), vec![])
5250 .expect("insert");
5251 }
5252 let shared = SharedDb::open(&dir).expect("open shared");
5253 snapshot_shared(&shared).expect("snapshot");
5254 }
5255
5256 assert_eq!(
5257 archives(&dir),
5258 rounds,
5259 "an automatic snapshot must not delete an archive"
5260 );
5261
5262 let db = GraphDb::open(&dir).expect("reopen");
5263 assert_eq!(
5264 db.wal_horizon_floor(),
5265 0,
5266 "nothing was pruned, so the floor stays at 0"
5267 );
5268 for i in 0..rounds {
5269 assert!(db.has_node(&format!("p{i}")), "p{i} survived");
5270 }
5271 assert!(
5272 !db.node_history("p0").expect("history").items.is_empty(),
5273 "the oldest history is still there: that is the point of the default"
5274 );
5275 assert_eq!(db.node_history("p0").expect("history").horizon, 0);
5276 drop(db);
5277
5278 let manual = tmp("snapshot-retention-manual");
5281 for i in 0..3 {
5282 {
5283 let mut db = GraphDb::open(&manual).expect("open");
5284 db.insert_node("Person", &format!("p{i}"), vec![])
5285 .expect("insert");
5286 }
5287 run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
5288 }
5289 assert_eq!(
5290 archives(&manual),
5291 3,
5292 "`mushroomdb snapshot` with no --retention keeps every archive"
5293 );
5294
5295 let _ = std::fs::remove_dir_all(&dir);
5296 let _ = std::fs::remove_dir_all(&manual);
5297 }
5298
5299 #[test]
5302 fn retention_is_still_available_when_configured() {
5303 let dir = tmp("retention-configured");
5304 for i in 0..5 {
5305 {
5306 let mut db = GraphDb::open(&dir).unwrap();
5307 db.insert_node("Person", &format!("p{i}"), vec![]).unwrap();
5308 }
5309 run_snapshot(&dir, WalDisposition::Archive, Some(2)).unwrap();
5310 }
5311 let archives = std::fs::read_dir(&dir)
5312 .expect("read dir")
5313 .filter_map(Result::ok)
5314 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
5315 .count();
5316 assert_eq!(archives, 2, "--retention 2 still prunes to two");
5317 assert!(GraphDb::open(&dir).unwrap().wal_horizon_floor() > 0);
5318 let _ = std::fs::remove_dir_all(&dir);
5319 }
5320
5321 #[test]
5322 fn run_query_formats_like_asof() {
5323 let dir = tmp("query-cli");
5324 {
5325 let mut db = GraphDb::open(&dir).expect("open");
5326 db.insert_node(
5327 "Person",
5328 "alice",
5329 vec![("id".into(), Value::Str("alice".into()))],
5330 )
5331 .expect("insert");
5332 }
5333 let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id", None, None).expect("query");
5334 assert!(out.contains("columns:"), "got {out}");
5335 assert!(out.contains("id=alice"), "got {out}");
5336 let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})", None, None).expect("write");
5337 let db = GraphDb::open(&dir).expect("reopen");
5338 assert!(db.has_node("bob"), "query_write must persist CREATE");
5339 let _ = std::fs::remove_dir_all(&dir);
5340 }
5341
5342 #[test]
5346 fn asof_header_names_the_horizon() {
5347 let dir = tmp("asof-horizon");
5348 for i in 0..10 {
5353 {
5354 let mut db = GraphDb::open(&dir).expect("open");
5355 db.insert_node("Person", &format!("p{i}"), vec![])
5356 .expect("insert");
5357 }
5358 run_snapshot(&dir, WalDisposition::Archive, Some(2)).expect("snapshot");
5359 }
5360 {
5363 let mut db = GraphDb::open(&dir).expect("open");
5364 db.insert_node("Person", "after", vec![]).expect("insert");
5365 }
5366 let (floor, total) = {
5367 let db = GraphDb::open(&dir).expect("reopen");
5368 (
5369 db.wal_horizon_floor(),
5370 db.wal_total_commits().expect("total"),
5371 )
5372 };
5373 assert!(floor > 0, "the retention must have pruned something");
5374
5375 let out = run_asof(&dir, Some(total - 1), None, None, None).expect("asof");
5376 assert_eq!(
5377 out.trim(),
5378 format!(
5379 "as-of commit {} of {total} (history reaches back to commit {floor})",
5380 total - 1
5381 ),
5382 "the header must name the horizon it can reach"
5383 );
5384 let _ = std::fs::remove_dir_all(&dir);
5385
5386 let clean = tmp("asof-clean");
5388 {
5389 let mut db = GraphDb::open(&clean).expect("open");
5390 db.insert_node("Person", "a", vec![]).expect("insert");
5391 }
5392 assert_eq!(
5393 run_asof(&clean, Some(0), None, None, None)
5394 .expect("asof")
5395 .trim(),
5396 "as-of commit 0 of 1"
5397 );
5398 let _ = std::fs::remove_dir_all(&clean);
5399 }
5400
5401 #[test]
5404 fn format_stats_says_how_far_back_history_reaches() {
5405 let dir = tmp("stats-horizon");
5406 {
5407 let mut db = GraphDb::open(&dir).expect("open");
5408 db.insert_node("Person", "a", vec![]).expect("insert");
5409 }
5410 let text = format_stats(&read_stats(&dir).expect("stats"));
5411 assert!(
5412 text.contains("history: complete (nothing pruned)"),
5413 "an unpruned store says so, got:\n{text}"
5414 );
5415 let _ = std::fs::remove_dir_all(&dir);
5416
5417 let pruned = tmp("stats-horizon-pruned");
5418 for i in 0..10 {
5419 {
5420 let mut db = GraphDb::open(&pruned).expect("open");
5421 db.insert_node("Person", &format!("p{i}"), vec![])
5422 .expect("insert");
5423 }
5424 run_snapshot(&pruned, WalDisposition::Archive, Some(2)).expect("snapshot");
5425 }
5426 let stats = read_stats(&pruned).expect("stats");
5427 assert!(stats.history_floor > 0, "the retention must have pruned");
5428 assert!(
5429 format_stats(&stats).contains(&format!(
5430 "history: reaches back to commit {}",
5431 stats.history_floor
5432 )),
5433 "a pruned store names its floor"
5434 );
5435 let _ = std::fs::remove_dir_all(&pruned);
5436
5437 let truncated = tmp("stats-horizon-truncated");
5439 {
5440 let mut db = GraphDb::open(&truncated).expect("open");
5441 db.insert_node("Person", "a", vec![]).expect("insert");
5442 db.insert_node("Person", "b", vec![]).expect("insert");
5443 }
5444 run_snapshot(&truncated, WalDisposition::Truncate, None).expect("truncate");
5445 let stats = read_stats(&truncated).expect("stats");
5446 assert_eq!(stats.history_floor, 2, "two commits were discarded");
5447 assert!(
5448 format_stats(&stats).contains("history: reaches back to commit 2"),
5449 "a truncated store names its floor, got:\n{}",
5450 format_stats(&stats)
5451 );
5452 let _ = std::fs::remove_dir_all(&truncated);
5453 }
5454
5455 #[test]
5456 fn format_stats_contains_counts() {
5457 let dir = tmp("stats-smoke");
5458 let out = run_demo(&dir).expect("demo for stats smoke");
5459 let text = format_stats(&out.stats);
5460 assert!(
5461 text.contains("60"),
5462 "stats output should include live node count, got:\n{text}"
5463 );
5464 assert!(
5465 text.contains("334"),
5466 "stats output should include edge count, got:\n{text}"
5467 );
5468 assert!(
5469 text.to_lowercase().contains("node"),
5470 "stats output should mention nodes, got:\n{text}"
5471 );
5472 assert!(
5473 text.to_lowercase().contains("edge"),
5474 "stats output should mention edges, got:\n{text}"
5475 );
5476 let _ = std::fs::remove_dir_all(&dir);
5477 }
5478
5479 #[test]
5482 fn parse_backup_round_trip() {
5483 let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
5484 match r {
5485 Ok(Command::Backup { db_dir, dest }) => {
5486 assert_eq!(db_dir, PathBuf::from("/db/dir"));
5487 assert_eq!(dest, PathBuf::from("/backup/dest"));
5488 }
5489 other => panic!("backup parse, got {other:?}"),
5490 }
5491 }
5492
5493 #[test]
5494 fn parse_restore_takes_from_at_and_into() {
5495 assert_eq!(
5496 parse_args(&["restore", "--from", "s3://b/p"]),
5497 Ok(Command::RestoreS3 {
5498 from: "s3://b/p".into(),
5499 at: None,
5500 into: None,
5501 })
5502 );
5503 assert_eq!(
5504 parse_args(&[
5505 "restore",
5506 "--from=minio://b/p",
5507 "--at",
5508 "75",
5509 "--into",
5510 "/tmp/r",
5511 ]),
5512 Ok(Command::RestoreS3 {
5513 from: "minio://b/p".into(),
5514 at: Some("75".into()),
5515 into: Some(PathBuf::from("/tmp/r")),
5516 })
5517 );
5518 match parse_args(&["restore", "--from", "s3://b", "--at=2026-10-01T12:00:00Z"]) {
5519 Ok(Command::RestoreS3 { at, .. }) => {
5520 assert_eq!(at.as_deref(), Some("2026-10-01T12:00:00Z"))
5521 }
5522 other => panic!("restore parse, got {other:?}"),
5523 }
5524 }
5525
5526 #[test]
5527 fn parse_restore_refuses_what_it_cannot_use() {
5528 for (args, needle) in [
5529 (vec!["restore"], "--from"),
5530 (vec!["restore", "--into", "x"], "--from"),
5531 (
5532 vec!["restore", "--from", "s3://b", "--at", "yesterday"],
5533 "RFC 3339",
5534 ),
5535 (vec!["restore", "--from", "s3://b", "--at"], "missing value"),
5536 (
5537 vec!["restore", "--from", "s3://b", "extra"],
5538 "unexpected argument",
5539 ),
5540 (
5541 vec!["restore", "--from", "s3://b", "--force"],
5542 "unexpected flag",
5543 ),
5544 ] {
5545 let e = parse_args(&args).unwrap_err();
5546 assert!(e.contains(needle), "{args:?}: {e}");
5547 }
5548 }
5549
5550 #[test]
5551 fn restore_with_a_bad_url_fails_before_touching_anything() {
5552 let parent = tmp("restore-bad-url");
5553 let into = parent.join("restored");
5554 let e = run_restore_s3("gs://b/p", None, Some(&into)).unwrap_err();
5555 assert!(e.0.contains("--from"), "{e}");
5556 assert!(!into.exists());
5557 }
5558
5559 #[test]
5560 fn parse_backup_missing_dest_errors() {
5561 let r = parse_args(&["backup", "/db/dir"]);
5562 assert!(r.is_err(), "backup without <dest> should error");
5563 let e = r.unwrap_err();
5564 assert!(
5565 e.to_lowercase().contains("dest"),
5566 "error should mention dest, got: {e}"
5567 );
5568 }
5569
5570 #[test]
5571 fn parse_export_defaults_to_jsonl() {
5572 let r = parse_args(&["export", "/db/dir", "/export/dest"]);
5573 match r {
5574 Ok(Command::Export { format, .. }) => {
5575 assert_eq!(format, ExportFormat::Jsonl);
5576 }
5577 other => panic!("export parse, got {other:?}"),
5578 }
5579 }
5580
5581 #[test]
5582 fn parse_export_parquet_flag() {
5583 let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
5584 match r {
5585 Ok(Command::Export { format, .. }) => {
5586 assert_eq!(format, ExportFormat::Parquet);
5587 }
5588 other => panic!("export --format parquet parse, got {other:?}"),
5589 }
5590 }
5591
5592 #[test]
5593 fn parse_export_parquet_flag_eq() {
5594 let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
5595 match r {
5596 Ok(Command::Export { format, .. }) => {
5597 assert_eq!(format, ExportFormat::Parquet);
5598 }
5599 other => panic!("export --format=parquet parse, got {other:?}"),
5600 }
5601 }
5602
5603 #[test]
5604 fn run_backup_cli_produces_verified_report() {
5605 let src = tmp("cli-backup-src");
5606 let dst = tmp("cli-backup-dst");
5607 let _ = run_demo(&src).expect("demo");
5608 let report = run_backup(&src, &dst).expect("run_backup");
5609 assert!(report.verified, "backup must be verified");
5610 assert!(!report.files.is_empty());
5611 assert!(report.bytes > 0);
5612 let _ = std::fs::remove_dir_all(&src);
5613 let _ = std::fs::remove_dir_all(&dst);
5614 }
5615
5616 fn seed_store(dir: &Path, key: &str) {
5619 let mut db = GraphDb::open(dir).expect("open seed store");
5620 db.insert_node("N", key, vec![]).expect("insert seed node");
5621 db.snapshot().expect("snapshot seed store");
5622 }
5623
5624 #[test]
5625 fn restore_from_seeds_an_empty_dir() {
5626 let src = tmp("restore-src");
5627 seed_store(&src, "a");
5628 let vault = tmp("restore-vault");
5629 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5630
5631 let fresh = tmp("restore-fresh");
5632 match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
5633 RestoreOutcome::Restored { files, bytes, .. } => {
5634 assert!(
5635 files.contains(&"snapshot.bin".to_string()),
5636 "expected snapshot.bin among {files:?}"
5637 );
5638 assert!(bytes > 0, "expected a non-zero byte count");
5639 }
5640 other => panic!("expected Restored, got {other:?}"),
5641 }
5642 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5643
5644 let _ = std::fs::remove_dir_all(&src);
5645 let _ = std::fs::remove_dir_all(&vault);
5646 let _ = std::fs::remove_dir_all(&fresh);
5647 }
5648
5649 #[test]
5650 fn restore_from_a_backup_dir_itself() {
5651 let src = tmp("restore-direct-src");
5652 seed_store(&src, "a");
5653 let backup = tmp("restore-direct-backup");
5654 run_backup(&src, &backup).expect("backup");
5655
5656 let fresh = tmp("restore-direct-fresh");
5657 match restore_if_empty(&fresh, &backup).expect("restore_if_empty") {
5658 RestoreOutcome::Restored { from, .. } => assert_eq!(from, backup),
5659 other => panic!("expected Restored, got {other:?}"),
5660 }
5661 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5662
5663 let _ = std::fs::remove_dir_all(&src);
5664 let _ = std::fs::remove_dir_all(&backup);
5665 let _ = std::fs::remove_dir_all(&fresh);
5666 }
5667
5668 #[test]
5674 fn restore_from_a_wal_only_backup() {
5675 let src = tmp("restore-walonly-src");
5676 {
5677 let mut db = GraphDb::open(&src).expect("open src");
5678 db.insert_node("N", "a", vec![]).expect("insert");
5679 }
5680 assert!(
5681 !src.join("snapshot.bin").exists(),
5682 "test setup: src must not have snapshotted"
5683 );
5684 let vault = tmp("restore-walonly-vault");
5685 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5686
5687 let fresh = tmp("restore-walonly-fresh");
5688 match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
5689 RestoreOutcome::Restored { files, .. } => assert!(
5690 files.contains(&"wal.bin".to_string()),
5691 "expected wal.bin among {files:?}"
5692 ),
5693 other => panic!("expected Restored, got {other:?}"),
5694 }
5695 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5696
5697 let _ = std::fs::remove_dir_all(&src);
5698 let _ = std::fs::remove_dir_all(&vault);
5699 let _ = std::fs::remove_dir_all(&fresh);
5700 }
5701
5702 #[test]
5703 fn restore_from_picks_latest_then_newest() {
5704 let older_src = tmp("restore-rank-old-src");
5705 seed_store(&older_src, "old");
5706 let newer_src = tmp("restore-rank-new-src");
5707 seed_store(&newer_src, "new");
5708 let latest_src = tmp("restore-rank-latest-src");
5709 seed_store(&latest_src, "named-latest");
5710
5711 let vault = tmp("restore-rank-vault");
5713 run_backup(&older_src, &vault.join("2026-09-01")).expect("backup old");
5714 std::thread::sleep(std::time::Duration::from_millis(1100));
5715 run_backup(&newer_src, &vault.join("2026-09-02")).expect("backup new");
5716
5717 let fresh = tmp("restore-rank-fresh");
5718 match restore_if_empty(&fresh, &vault).expect("restore by mtime") {
5719 RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("2026-09-02")),
5720 other => panic!("expected Restored, got {other:?}"),
5721 }
5722 assert!(GraphDb::open(&fresh)
5723 .expect("open restored")
5724 .has_node("new"));
5725
5726 run_backup(&latest_src, &vault.join("latest")).expect("backup latest");
5728 let older_than_latest = std::fs::metadata(vault.join("2026-09-02").join("snapshot.bin"))
5729 .expect("stat newest")
5730 .modified()
5731 .expect("mtime");
5732 let latest_mtime = std::fs::metadata(vault.join("latest").join("snapshot.bin"))
5733 .expect("stat latest")
5734 .modified()
5735 .expect("mtime");
5736 assert!(
5737 latest_mtime >= older_than_latest,
5738 "test setup: latest/ should not be older here"
5739 );
5740
5741 let fresh2 = tmp("restore-rank-fresh2");
5742 match restore_if_empty(&fresh2, &vault).expect("restore by name") {
5743 RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("latest")),
5744 other => panic!("expected Restored, got {other:?}"),
5745 }
5746 assert!(GraphDb::open(&fresh2)
5747 .expect("open restored")
5748 .has_node("named-latest"));
5749
5750 for d in [&older_src, &newer_src, &latest_src, &vault, &fresh, &fresh2] {
5751 let _ = std::fs::remove_dir_all(d);
5752 }
5753 }
5754
5755 #[test]
5756 fn restore_from_is_a_no_op_when_a_store_exists() {
5757 let src = tmp("restore-noop-src");
5758 seed_store(&src, "a");
5759 let vault = tmp("restore-noop-vault");
5760 run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
5761
5762 let existing = tmp("restore-noop-existing");
5763 seed_store(&existing, "b");
5764
5765 assert_eq!(
5766 restore_if_empty(&existing, &vault).expect("restore_if_empty"),
5767 RestoreOutcome::AlreadyPresent
5768 );
5769 let db = GraphDb::open(&existing).expect("open existing");
5770 assert!(db.has_node("b"), "the existing store must survive");
5771 assert!(!db.has_node("a"), "the backup must not have been copied in");
5772
5773 let _ = std::fs::remove_dir_all(&src);
5774 let _ = std::fs::remove_dir_all(&vault);
5775 let _ = std::fs::remove_dir_all(&existing);
5776 }
5777
5778 #[test]
5779 fn restore_from_an_empty_vault_is_not_an_error() {
5780 let vault = tmp("restore-empty-vault");
5781 std::fs::create_dir_all(&vault).expect("mkdir vault");
5782 let fresh = tmp("restore-empty-fresh");
5783 assert_eq!(
5784 restore_if_empty(&fresh, &vault).expect("restore_if_empty"),
5785 RestoreOutcome::Empty
5786 );
5787 assert_eq!(
5789 restore_if_empty(&fresh, &vault.join("nope")).expect("restore_if_empty"),
5790 RestoreOutcome::Empty
5791 );
5792
5793 let _ = std::fs::remove_dir_all(&vault);
5794 let _ = std::fs::remove_dir_all(&fresh);
5795 }
5796
5797 #[test]
5798 fn restore_from_a_corrupt_backup_fails_loudly() {
5799 let src = tmp("restore-corrupt-src");
5800 seed_store(&src, "a");
5801 let vault = tmp("restore-corrupt-vault");
5802 let backup = vault.join("2026-09-10T00-00Z");
5803 run_backup(&src, &backup).expect("backup");
5804
5805 let snap = backup.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 fresh = tmp("restore-corrupt-fresh");
5811 let err = restore_if_empty(&fresh, &vault).expect_err("expected a hard failure");
5812 let msg = err.to_string();
5813 assert!(
5814 msg.contains(&fresh.display().to_string()),
5815 "error must name the restored dir, got: {msg}"
5816 );
5817 assert!(
5818 msg.contains(&backup.display().to_string()),
5819 "error must name the backup, got: {msg}"
5820 );
5821
5822 let _ = std::fs::remove_dir_all(&src);
5823 let _ = std::fs::remove_dir_all(&vault);
5824 let _ = std::fs::remove_dir_all(&fresh);
5825 }
5826
5827 #[test]
5831 fn a_failed_restore_leaves_the_db_dir_untouched() {
5832 let src = tmp("restore-atomic-src");
5833 seed_store(&src, "a");
5834
5835 let bad_vault = tmp("restore-atomic-bad-vault");
5836 let bad = bad_vault.join("2026-09-10T00-00Z");
5837 run_backup(&src, &bad).expect("backup the bad one");
5838 let snap = bad.join("snapshot.bin");
5839 let bytes = std::fs::read(&snap).expect("read snapshot");
5840 std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
5841
5842 let good_vault = tmp("restore-atomic-good-vault");
5843 run_backup(&src, &good_vault.join("2026-09-11T00-00Z")).expect("backup the good one");
5844
5845 let fresh = tmp("restore-atomic-fresh");
5846 let err = restore_if_empty(&fresh, &bad_vault).expect_err("expected a hard failure");
5847 let msg = err.to_string();
5848 assert!(
5849 msg.contains(&fresh.display().to_string()) && msg.contains(&bad.display().to_string()),
5850 "error must name both paths, got: {msg}"
5851 );
5852
5853 let leftovers: Vec<String> = std::fs::read_dir(&fresh)
5855 .expect("read fresh")
5856 .flatten()
5857 .filter_map(|e| e.file_name().into_string().ok())
5858 .collect();
5859 assert!(
5860 leftovers.is_empty(),
5861 "a failed restore must leave nothing behind, found: {leftovers:?}"
5862 );
5863 assert!(!holds_a_store(&fresh), "the dir must not hold a store");
5864
5865 match restore_if_empty(&fresh, &good_vault).expect("retry must restore") {
5868 RestoreOutcome::Restored { from, .. } => {
5869 assert_eq!(from, good_vault.join("2026-09-11T00-00Z"))
5870 }
5871 other => panic!("expected Restored on retry, got {other:?}"),
5872 }
5873 assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
5874
5875 for d in [&src, &bad_vault, &good_vault, &fresh] {
5876 let _ = std::fs::remove_dir_all(d);
5877 }
5878 }
5879
5880 #[test]
5883 fn a_successful_restore_leaves_no_staging_dir() {
5884 let src = tmp("restore-staging-src");
5885 seed_store(&src, "a");
5886 let vault = tmp("restore-staging-vault");
5887 run_backup(&src, &vault.join("2026-09-11T00-00Z")).expect("backup");
5888
5889 let fresh = tmp("restore-staging-fresh");
5890 restore_if_empty(&fresh, &vault).expect("restore");
5891
5892 let stray: Vec<String> = std::fs::read_dir(&fresh)
5893 .expect("read fresh")
5894 .flatten()
5895 .filter_map(|e| e.file_name().into_string().ok())
5896 .filter(|n| n.starts_with(".restore-"))
5897 .collect();
5898 assert!(
5899 stray.is_empty(),
5900 "staging dir must be gone, found: {stray:?}"
5901 );
5902
5903 for d in [&src, &vault, &fresh] {
5904 let _ = std::fs::remove_dir_all(d);
5905 }
5906 }
5907
5908 #[test]
5909 fn serve_parses_restore_from() {
5910 match parse_args(&["serve", "/tmp/db", "--restore-from", "/vol/backups"]) {
5911 Ok(Command::Serve { restore_from, .. }) => {
5912 assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
5913 }
5914 other => panic!("--restore-from parse, got {other:?}"),
5915 }
5916 match parse_args(&["serve", "/tmp/db", "--restore-from=/vol/backups"]) {
5917 Ok(Command::Serve { restore_from, .. }) => {
5918 assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
5919 }
5920 other => panic!("--restore-from= parse, got {other:?}"),
5921 }
5922 match parse_args(&["serve", "/tmp/db"]) {
5923 Ok(Command::Serve { restore_from, .. }) => assert_eq!(restore_from, None),
5924 other => panic!("default restore_from, got {other:?}"),
5925 }
5926 let err = parse_args(&["serve", "/tmp/db", "--restore-from"])
5927 .expect_err("missing value must be an error");
5928 assert!(
5929 err.contains("--restore-from"),
5930 "error must name the flag, got: {err}"
5931 );
5932 }
5933
5934 #[test]
5935 fn run_export_jsonl_two_runs_byte_identical() {
5936 let src = tmp("cli-export-src");
5937 let dst1 = tmp("cli-export-dst1");
5938 let dst2 = tmp("cli-export-dst2");
5939 let _ = run_demo(&src).expect("demo");
5940
5941 run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
5942 run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
5943
5944 for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
5945 let f1 = std::fs::read(dst1.join(filename)).expect("read first");
5946 let f2 = std::fs::read(dst2.join(filename)).expect("read second");
5947 assert_eq!(
5948 f1, f2,
5949 "{filename} must be byte-identical across two export runs"
5950 );
5951 }
5952 let _ = std::fs::remove_dir_all(&src);
5953 let _ = std::fs::remove_dir_all(&dst1);
5954 let _ = std::fs::remove_dir_all(&dst2);
5955 }
5956
5957 #[test]
5958 fn run_export_jsonl_nodes_are_sorted() {
5959 let src = tmp("cli-export-sorted");
5960 let dst = tmp("cli-export-sorted-dst");
5961 let _ = run_demo(&src).expect("demo");
5962 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
5963
5964 let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
5965 let keys: Vec<String> = content
5966 .lines()
5967 .filter(|l| !l.is_empty())
5968 .map(|l| {
5969 let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
5970 v["key"].as_str().unwrap_or("").to_string()
5971 })
5972 .collect();
5973 let mut sorted = keys.clone();
5974 sorted.sort();
5975 assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
5976 let _ = std::fs::remove_dir_all(&src);
5977 let _ = std::fs::remove_dir_all(&dst);
5978 }
5979
5980 #[test]
5981 fn run_export_jsonl_derived_edges_have_rule() {
5982 let src = tmp("cli-export-derived");
5983 let dst = tmp("cli-export-derived-dst");
5984 let _ = run_demo(&src).expect("demo");
5985 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
5986
5987 let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
5988 let derived_lines: Vec<serde_json::Value> = content
5989 .lines()
5990 .filter(|l| !l.is_empty())
5991 .map(|l| serde_json::from_str(l).expect("parse line"))
5992 .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
5993 .collect();
5994 assert!(
5995 !derived_lines.is_empty(),
5996 "demo store should have derived edges"
5997 );
5998 for edge in &derived_lines {
5999 assert!(
6000 !edge["rule"].is_null(),
6001 "derived edge must have non-null rule: {edge}"
6002 );
6003 }
6004 let _ = std::fs::remove_dir_all(&src);
6005 let _ = std::fs::remove_dir_all(&dst);
6006 }
6007
6008 #[test]
6009 fn run_export_parquet_produces_files() {
6010 let src = tmp("cli-export-parq-src");
6011 let dst = tmp("cli-export-parq-dst");
6012 let _ = run_demo(&src).expect("demo");
6013 run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
6014
6015 assert!(
6016 dst.join("nodes.parquet").exists(),
6017 "nodes.parquet must exist"
6018 );
6019 assert!(
6020 dst.join("edges.parquet").exists(),
6021 "edges.parquet must exist"
6022 );
6023 assert!(
6024 dst.join("rules.parquet").exists(),
6025 "rules.parquet must exist"
6026 );
6027 for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
6029 let meta = std::fs::metadata(dst.join(f)).expect("metadata");
6030 assert!(meta.len() > 0, "{f} must be non-empty");
6031 }
6032 let _ = std::fs::remove_dir_all(&src);
6033 let _ = std::fs::remove_dir_all(&dst);
6034 }
6035
6036 #[test]
6037 fn parse_export_graphml_flag() {
6038 let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
6039 match r {
6040 Ok(Command::Export { format, .. }) => {
6041 assert_eq!(format, ExportFormat::Graphml);
6042 }
6043 other => panic!("export --format graphml parse, got {other:?}"),
6044 }
6045 }
6046
6047 #[test]
6048 fn run_export_graphml_structure() {
6049 let src = tmp("cli-export-gml-src");
6050 let dst_dir = tmp("cli-export-gml-dst");
6051 let dst = dst_dir.join("graph.graphml");
6052 let _ = run_demo(&src).expect("demo");
6053 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6054
6055 let content = std::fs::read_to_string(&dst).expect("read graphml");
6056
6057 assert!(
6058 content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
6059 "must start with an XML declaration"
6060 );
6061 assert!(
6062 content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
6063 "must use the standard GraphML namespace"
6064 );
6065 assert!(
6066 content.contains(
6067 "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
6068 ),
6069 "must declare the node label key"
6070 );
6071 assert!(
6072 content.contains(
6073 "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
6074 ),
6075 "must declare the edge type key"
6076 );
6077 assert!(
6078 content.contains(
6079 "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
6080 ),
6081 "must declare the edge derived key"
6082 );
6083 assert!(
6084 content.contains(
6085 "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
6086 ),
6087 "must declare the edge rule key"
6088 );
6089 assert!(
6090 content.contains(
6091 "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
6092 ),
6093 "must declare the edge weight key"
6094 );
6095 assert!(
6099 content.contains(
6100 "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
6101 ),
6102 "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
6103 );
6104 assert!(
6105 content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
6106 "must declare a single directed graph element"
6107 );
6108 assert!(content.contains("<node id="), "must contain node elements");
6109 assert!(
6110 content.contains("<edge id=\"e0\" source=\""),
6111 "must contain a sequentially-numbered edge starting at e0"
6112 );
6113 assert!(
6114 content.trim_end().ends_with("</graphml>"),
6115 "must close the root element"
6116 );
6117
6118 assert!(
6121 content.contains("<data key=\"e_rule\">skill_fit</data>")
6122 || content.contains("<data key=\"e_rule\">founded_within</data>"),
6123 "at least one derived edge must carry its rule name"
6124 );
6125 assert!(
6126 content.contains(&format!(
6127 "<data key=\"{}\">",
6128 "e_weight" )),
6130 "at least one derived edge must carry a weight value"
6131 );
6132
6133 let _ = std::fs::remove_dir_all(&src);
6134 let _ = std::fs::remove_dir_all(&dst_dir);
6135 }
6136
6137 #[test]
6138 fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
6139 let src = tmp("cli-export-gml-dir-src");
6140 let dst_dir = tmp("cli-export-gml-dir-dst");
6141 std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
6142 let _ = run_demo(&src).expect("demo");
6143
6144 let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
6145
6146 assert!(
6147 dst_dir.join("graph.graphml").exists(),
6148 "an existing directory dest must produce dest/graph.graphml"
6149 );
6150 assert!(
6151 msg.contains("graph.graphml"),
6152 "report must name the file actually written, got: {msg}"
6153 );
6154
6155 let _ = std::fs::remove_dir_all(&src);
6156 let _ = std::fs::remove_dir_all(&dst_dir);
6157 }
6158
6159 #[test]
6162 fn run_export_graphml_is_well_formed_xml() {
6163 let has_python3 = std::process::Command::new("python3")
6164 .arg("--version")
6165 .output()
6166 .map(|o| o.status.success())
6167 .unwrap_or(false);
6168 if !has_python3 {
6169 eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
6170 return;
6171 }
6172
6173 let src = tmp("cli-export-gml-wf-src");
6174 let dst_dir = tmp("cli-export-gml-wf-dst");
6175 let dst = dst_dir.join("graph.graphml");
6176 let _ = run_demo(&src).expect("demo");
6177 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6178
6179 let status = std::process::Command::new("python3")
6180 .arg("-c")
6181 .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
6182 .arg(&dst)
6183 .status()
6184 .expect("run python3");
6185 assert!(
6186 status.success(),
6187 "python3's XML parser must accept the exported GraphML file"
6188 );
6189
6190 let _ = std::fs::remove_dir_all(&src);
6191 let _ = std::fs::remove_dir_all(&dst_dir);
6192 }
6193
6194 #[test]
6195 fn run_export_graphml_two_runs_byte_identical() {
6196 let src = tmp("cli-export-gml-bi-src");
6197 let dst_dir1 = tmp("cli-export-gml-bi-dst1");
6198 let dst_dir2 = tmp("cli-export-gml-bi-dst2");
6199 let dst1 = dst_dir1.join("graph.graphml");
6200 let dst2 = dst_dir2.join("graph.graphml");
6201 let _ = run_demo(&src).expect("demo");
6202
6203 run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
6204 run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
6205
6206 let f1 = std::fs::read(&dst1).expect("read first");
6207 let f2 = std::fs::read(&dst2).expect("read second");
6208 assert_eq!(
6209 f1, f2,
6210 "graph.graphml must be byte-identical across two export runs"
6211 );
6212
6213 let _ = std::fs::remove_dir_all(&src);
6214 let _ = std::fs::remove_dir_all(&dst_dir1);
6215 let _ = std::fs::remove_dir_all(&dst_dir2);
6216 }
6217
6218 #[test]
6219 fn run_export_graphml_escapes_and_lists() {
6220 use mushroomdb::{GraphDb, Value};
6221 let src = tmp("cli-export-gml-esc-src");
6222 let dst_dir = tmp("cli-export-gml-esc-dst");
6223 let dst = dst_dir.join("graph.graphml");
6224
6225 {
6226 let mut db = GraphDb::open(&src).unwrap();
6227 db.insert_node(
6228 "Widget",
6229 "w1",
6230 vec![
6231 (
6232 "title".into(),
6233 Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
6234 ),
6235 (
6236 "tags".into(),
6237 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
6238 ),
6239 ],
6240 )
6241 .unwrap();
6242 }
6243
6244 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6245 let content = std::fs::read_to_string(&dst).expect("read graphml");
6246
6247 assert!(
6248 content.contains("Tom & Jerry <says> "hi" 'bye'"),
6249 "special XML characters in string props must be escaped, got: {content}"
6250 );
6251 assert!(
6252 !content.contains("Tom & Jerry <says>"),
6253 "unescaped special characters must not appear verbatim"
6254 );
6255 assert!(
6256 content.contains(
6257 "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
6258 ),
6259 "list-valued props must declare attr.type=\"string\""
6260 );
6261 assert!(
6262 content.contains("<data key=\"n_tags\">["a","b"]</data>"),
6263 "list-valued props must render as XML-escaped JSON text, got: {content}"
6264 );
6265
6266 let _ = std::fs::remove_dir_all(&src);
6267 let _ = std::fs::remove_dir_all(&dst_dir);
6268 }
6269
6270 #[test]
6275 fn run_export_graphml_mixed_type_prop_declares_string() {
6276 use mushroomdb::{GraphDb, Value};
6277 let src = tmp("cli-export-gml-mixed-src");
6278 let dst_dir = tmp("cli-export-gml-mixed-dst");
6279 let dst = dst_dir.join("graph.graphml");
6280
6281 {
6282 let mut db = GraphDb::open(&src).unwrap();
6283 db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
6284 .unwrap();
6285 db.insert_node(
6286 "Metric",
6287 "m2",
6288 vec![("score".into(), Value::Str("high".into()))],
6289 )
6290 .unwrap();
6291 }
6292
6293 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
6294 let content = std::fs::read_to_string(&dst).expect("read graphml");
6295
6296 assert!(
6297 content.contains(
6298 "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
6299 ),
6300 "a prop name with conflicting value types across nodes must declare \
6301 attr.type=\"string\", got: {content}"
6302 );
6303 assert!(
6304 !content.contains("attr.name=\"score\" attr.type=\"long\""),
6305 "must not declare a narrower type once a conflict is seen, got: {content}"
6306 );
6307 assert!(
6310 content.contains("<data key=\"n_score\">5</data>"),
6311 "the int-valued node must still render its literal int text, got: {content}"
6312 );
6313 assert!(
6314 content.contains("<data key=\"n_score\">high</data>"),
6315 "the string-valued node must still render its literal string text, got: {content}"
6316 );
6317
6318 let _ = std::fs::remove_dir_all(&src);
6319 let _ = std::fs::remove_dir_all(&dst_dir);
6320 }
6321
6322 #[test]
6323 fn parse_algo_degree_defaults_dir_both() {
6324 let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
6325 match cmd {
6326 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
6327 other => panic!("expected Algo, got {other:?}"),
6328 }
6329 }
6330
6331 #[test]
6332 fn parse_algo_degree_with_dir_flag() {
6333 for (arg, want) in [
6334 ("out", AlgoDir::Out),
6335 ("in", AlgoDir::In),
6336 ("both", AlgoDir::Both),
6337 ] {
6338 let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
6339 match cmd {
6340 Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
6341 other => panic!("expected Algo, got {other:?}"),
6342 }
6343 }
6344 let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
6346 match cmd {
6347 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
6348 other => panic!("expected Algo, got {other:?}"),
6349 }
6350 }
6351
6352 #[test]
6353 fn parse_algo_rejects_unknown_dir() {
6354 assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
6355 }
6356
6357 #[test]
6358 fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
6359 let cmd = parse_args(&[
6360 "algo",
6361 "communities",
6362 "/db",
6363 "--edge-type",
6364 "IMPORTS",
6365 "--edge-type=CO_CHANGED",
6366 "--weight-prop",
6367 "score",
6368 "--min-weight",
6369 "0.3",
6370 "--top",
6371 "5",
6372 ])
6373 .unwrap();
6374 match cmd {
6375 Command::Algo {
6376 subcmd,
6377 top,
6378 edge_types,
6379 weight_prop,
6380 min_weight,
6381 ..
6382 } => {
6383 assert_eq!(subcmd, AlgoSubcmd::Communities);
6384 assert_eq!(top, 5);
6385 assert_eq!(
6386 edge_types,
6387 vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
6388 );
6389 assert_eq!(weight_prop, Some("score".to_string()));
6390 assert_eq!(min_weight, Some(0.3));
6391 }
6392 other => panic!("expected Algo, got {other:?}"),
6393 }
6394 }
6395
6396 #[test]
6397 fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
6398 let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
6399 match cmd {
6400 Command::Algo {
6401 subcmd,
6402 edge_types,
6403 weight_prop,
6404 min_weight,
6405 ..
6406 } => {
6407 assert_eq!(subcmd, AlgoSubcmd::Communities);
6408 assert!(edge_types.is_empty());
6409 assert_eq!(weight_prop, None);
6410 assert_eq!(min_weight, None);
6411 }
6412 other => panic!("expected Algo, got {other:?}"),
6413 }
6414 }
6415
6416 #[test]
6419 fn run_export_jsonl_nan_float_becomes_null() {
6420 use mushroomdb::{GraphDb, Value};
6421 let src = tmp("cli-export-nan-src");
6422 let dst = tmp("cli-export-nan-dst");
6423
6424 {
6426 let mut db = GraphDb::open(&src).unwrap();
6427 db.insert_node(
6428 "Sensor",
6429 "s1",
6430 vec![
6431 ("nan_val".into(), Value::Float(f64::NAN)),
6432 ("pos_inf".into(), Value::Float(f64::INFINITY)),
6433 ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
6434 ("normal".into(), Value::Float(1.5)),
6435 ],
6436 )
6437 .unwrap();
6438 }
6439
6440 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
6442
6443 let content =
6445 std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
6446 let row: serde_json::Value =
6447 serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
6448 assert_eq!(
6449 row["nan_val"],
6450 serde_json::Value::Null,
6451 "NaN must export as null"
6452 );
6453 assert_eq!(
6454 row["pos_inf"],
6455 serde_json::Value::Null,
6456 "+Inf must export as null"
6457 );
6458 assert_eq!(
6459 row["neg_inf"],
6460 serde_json::Value::Null,
6461 "-Inf must export as null"
6462 );
6463 assert_eq!(
6465 row["normal"],
6466 serde_json::json!(1.5),
6467 "normal float roundtrips"
6468 );
6469
6470 let _ = std::fs::remove_dir_all(&src);
6471 let _ = std::fs::remove_dir_all(&dst);
6472 }
6473
6474 #[test]
6475 fn serve_tls_flags_parse_both_forms() {
6476 match parse_args(&[
6478 "serve",
6479 "/tmp/db",
6480 "--tls-cert",
6481 "/a/cert.pem",
6482 "--tls-key",
6483 "/a/key.pem",
6484 ])
6485 .unwrap()
6486 {
6487 Command::Serve {
6488 tls_cert, tls_key, ..
6489 } => {
6490 assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
6491 assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
6492 }
6493 other => panic!("{other:?}"),
6494 }
6495 match parse_args(&[
6497 "serve",
6498 "/tmp/db",
6499 "--tls-cert=/b/cert.pem",
6500 "--tls-key=/b/key.pem",
6501 ])
6502 .unwrap()
6503 {
6504 Command::Serve {
6505 tls_cert, tls_key, ..
6506 } => {
6507 assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
6508 assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
6509 }
6510 other => panic!("{other:?}"),
6511 }
6512 match parse_args(&["serve", "/tmp/db"]).unwrap() {
6514 Command::Serve {
6515 tls_cert, tls_key, ..
6516 } => {
6517 assert_eq!(tls_cert, None);
6518 assert_eq!(tls_key, None);
6519 }
6520 other => panic!("{other:?}"),
6521 }
6522 }
6523
6524 #[test]
6525 fn serve_tls_flags_require_both() {
6526 let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
6528 assert!(
6529 err.contains("tls-key"),
6530 "--tls-cert alone must mention --tls-key in error, got {err}"
6531 );
6532 let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
6534 assert!(
6535 err.contains("tls-cert"),
6536 "--tls-key alone must mention --tls-cert in error, got {err}"
6537 );
6538 }
6539
6540 #[test]
6541 fn version_flag_parses() {
6542 assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
6543 assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
6544 assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
6545 }
6546
6547 #[test]
6548 fn recall_parses_one_dir_and_is_listed_in_usage() {
6549 assert_eq!(
6550 parse_args(&["recall", "/tmp/db"]).unwrap(),
6551 Command::Recall {
6552 db_dir: Some(PathBuf::from("/tmp/db")),
6553 auto: false,
6554 }
6555 );
6556 assert!(
6557 parse_args(&["recall"]).is_err(),
6558 "one of <db-dir> or --auto is required"
6559 );
6560 assert!(usage().contains("mushroomdb recall <db-dir>"));
6561 }
6562
6563 #[test]
6564 fn why_takes_a_dir_and_two_keys() {
6565 assert_eq!(
6566 parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
6567 Command::Why {
6568 db_dir: PathBuf::from("/tmp/db"),
6569 a: "a.rs".to_string(),
6570 b: "b.rs".to_string(),
6571 }
6572 );
6573
6574 for args in [
6576 vec!["why", "/tmp/db", "a"],
6577 vec!["why", "/tmp/db", "a", "b", "c"],
6578 vec!["why", "/tmp/db", "-a", "b"],
6579 ] {
6580 assert!(parse_args(&args).is_err(), "{args:?} must not parse");
6581 }
6582 assert!(usage().contains("mushroomdb why <db-dir> <a> <b>"));
6583 }
6584
6585 #[test]
6588 fn hook_commands_take_a_dir_or_auto() {
6589 assert_eq!(
6590 parse_args(&["mcp", "--auto"]).unwrap(),
6591 Command::Mcp {
6592 db_dir: None,
6593 auto: true,
6594 all_tools: false
6595 }
6596 );
6597 assert_eq!(
6598 parse_args(&["recall", "--auto"]).unwrap(),
6599 Command::Recall {
6600 db_dir: None,
6601 auto: true
6602 }
6603 );
6604 assert_eq!(
6605 parse_args(&["brief", "--auto"]).unwrap(),
6606 Command::Brief {
6607 db_dir: None,
6608 auto: true
6609 }
6610 );
6611 assert_eq!(
6612 parse_args(&["brief", "/tmp/db"]).unwrap(),
6613 Command::Brief {
6614 db_dir: Some(PathBuf::from("/tmp/db")),
6615 auto: false
6616 }
6617 );
6618 for cmd in ["mcp", "recall", "brief"] {
6619 assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
6620 assert!(
6621 parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
6622 "{cmd} with both"
6623 );
6624 }
6625 assert!(usage().contains("--auto"));
6626 }
6627
6628 #[test]
6631 fn mcp_takes_all_tools() {
6632 for args in [
6633 &["mcp", "/tmp/db", "--all-tools"][..],
6634 &["mcp", "--all-tools", "/tmp/db"][..],
6635 ] {
6636 assert_eq!(
6637 parse_args(args).unwrap(),
6638 Command::Mcp {
6639 db_dir: Some(PathBuf::from("/tmp/db")),
6640 auto: false,
6641 all_tools: true
6642 },
6643 "{args:?}"
6644 );
6645 }
6646 assert_eq!(
6647 parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
6648 Command::Mcp {
6649 db_dir: None,
6650 auto: true,
6651 all_tools: true
6652 }
6653 );
6654 assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
6655 assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
6656 assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
6657 assert!(usage().contains("--all-tools"));
6658 }
6659
6660 #[test]
6661 fn ingest_git_parses_excludes() {
6662 let cmd = parse_args(&[
6663 "ingest-git",
6664 "/tmp/db",
6665 "/tmp/repo",
6666 "--exclude",
6667 "target/",
6668 "--exclude=*.lock",
6669 "--max-commits-per-file",
6670 "50",
6671 "--recurse-submodules",
6672 "--prs",
6673 "--ensure-gitignore",
6674 ])
6675 .unwrap();
6676 assert_eq!(
6677 cmd,
6678 Command::IngestGit {
6679 db_dir: PathBuf::from("/tmp/db"),
6680 opts: ingest_git::IngestGitOpts {
6681 repo: PathBuf::from("/tmp/repo"),
6682 exclude: vec!["target/".into(), "*.lock".into()],
6683 max_commits_per_file: 50,
6684 recurse_submodules: true,
6685 prs: true,
6686 structure: true,
6687 docs: true,
6688 ensure_gitignore: true,
6689 },
6690 }
6691 );
6692 let Command::IngestGit { opts, .. } =
6694 parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
6695 else {
6696 panic!("expected IngestGit");
6697 };
6698 assert_eq!(
6699 opts.exclude,
6700 ingest_git::DEFAULT_EXCLUDES
6701 .iter()
6702 .map(|p| (*p).to_string())
6703 .collect::<Vec<_>>(),
6704 "with no --exclude the defaults apply"
6705 );
6706 assert_eq!(
6707 opts.max_commits_per_file,
6708 ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
6709 );
6710 assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
6711 assert!(
6712 opts.structure && opts.docs,
6713 "structure and docs default on and are recorded on the marker"
6714 );
6715 let Command::IngestGit { opts, .. } = parse_args(&[
6716 "ingest-git",
6717 "/tmp/db",
6718 "/tmp/repo",
6719 "--no-structure",
6720 "--no-docs",
6721 ])
6722 .unwrap() else {
6723 panic!("expected IngestGit");
6724 };
6725 assert!(!opts.structure && !opts.docs);
6726 assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
6727 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
6728 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
6729 assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
6730 }
6731
6732 #[test]
6733 fn version_constant_matches_cargo() {
6734 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
6735 assert!(usage().contains("--version"));
6736 }
6737 fn ns_store(name: &str) -> PathBuf {
6741 let dir = tmp(name);
6742 let mut db = GraphDb::open(&dir).expect("open");
6743 for (key, ns) in [
6744 ("d1", None),
6745 ("a1", Some("tenant-a")),
6746 ("a2", Some("tenant-a")),
6747 ("b1", Some("tenant-b")),
6748 ] {
6749 let mut props = vec![("id".into(), Value::Str(key.into()))];
6750 if let Some(ns) = ns {
6751 props.push(("ns".into(), Value::Str(ns.into())));
6752 }
6753 db.insert_node("Doc", key, props).expect("insert");
6754 }
6755 db.apply_schema(&Schema {
6756 roles: vec![mushroomdb::RoleDef {
6757 name: "a-reader".into(),
6758 keys: vec![],
6759 labels: vec!["Doc".into()],
6760 visible_where: None,
6761 namespaces: Some(vec!["tenant-a".into()]),
6762 write: None,
6763 }],
6764 ..Default::default()
6765 })
6766 .expect("schema");
6767 dir
6768 }
6769
6770 #[test]
6773 fn format_stats_lists_namespaces() {
6774 let dir = ns_store("stats-namespaces");
6775 let text = format_stats(&read_stats(&dir).expect("stats"));
6776 assert!(
6777 text.contains("namespaces: default (1), tenant-a (2), tenant-b (1)"),
6778 "got:\n{text}"
6779 );
6780 let _ = std::fs::remove_dir_all(&dir);
6781 }
6782
6783 #[test]
6786 fn format_stats_omits_the_namespaces_line_on_one_namespace() {
6787 let dir = tmp("stats-one-namespace");
6788 {
6789 let mut db = GraphDb::open(&dir).expect("open");
6790 db.insert_node("Person", "a", vec![]).expect("insert");
6791 }
6792 let text = format_stats(&read_stats(&dir).expect("stats"));
6793 assert!(
6794 !text.contains("namespaces"),
6795 "a store with only `default` says nothing about namespaces, got:\n{text}"
6796 );
6797 let _ = std::fs::remove_dir_all(&dir);
6798 }
6799
6800 #[test]
6802 fn parse_query_takes_a_role_and_a_namespace() {
6803 let Ok(Command::Query {
6804 role, namespace, ..
6805 }) = parse_args(&[
6806 "query",
6807 "/tmp/db",
6808 "--role",
6809 "a-reader",
6810 "--namespace",
6811 "tenant-a",
6812 "MATCH (n) RETURN n",
6813 ])
6814 else {
6815 panic!("expected Query");
6816 };
6817 assert_eq!(role.as_deref(), Some("a-reader"));
6818 assert_eq!(namespace.as_deref(), Some("tenant-a"));
6819
6820 let Ok(Command::Query {
6821 role, namespace, ..
6822 }) = parse_args(&[
6823 "query",
6824 "/tmp/db",
6825 "--namespace=tenant-b",
6826 "MATCH (n) RETURN n",
6827 ])
6828 else {
6829 panic!("expected Query");
6830 };
6831 assert_eq!(role, None);
6832 assert_eq!(namespace.as_deref(), Some("tenant-b"));
6833
6834 assert!(parse_args(&["query", "/tmp/db", "--namespace"]).is_err());
6835 assert!(parse_args(&["query", "/tmp/db", "--role"]).is_err());
6836 assert!(usage().contains("--namespace <ns>"));
6837 }
6838
6839 #[test]
6842 fn parse_query_and_asof_take_a_file_or_stdin() {
6843 let Ok(Command::Query {
6844 cypher,
6845 file,
6846 stdin,
6847 ..
6848 }) = parse_args(&["query", "/tmp/db", "--file", "/tmp/q.cyp"])
6849 else {
6850 panic!("expected Query");
6851 };
6852 assert_eq!(cypher, "");
6853 assert_eq!(file, Some(PathBuf::from("/tmp/q.cyp")));
6854 assert!(!stdin);
6855
6856 let Ok(Command::Query { file, stdin, .. }) = parse_args(&["query", "/tmp/db", "--stdin"])
6857 else {
6858 panic!("expected Query");
6859 };
6860 assert_eq!(file, None);
6861 assert!(stdin);
6862
6863 let Ok(Command::AsOf {
6864 commit,
6865 query,
6866 file,
6867 stdin,
6868 ..
6869 }) = parse_args(&["asof", "/tmp/db", "--commit", "1", "--file=/tmp/q.cyp"])
6870 else {
6871 panic!("expected AsOf");
6872 };
6873 assert_eq!(commit, Some(1));
6874 assert_eq!(query, None);
6875 assert_eq!(file, Some(PathBuf::from("/tmp/q.cyp")));
6876 assert!(!stdin);
6877
6878 let Ok(Command::AsOf { stdin, .. }) =
6879 parse_args(&["asof", "/tmp/db", "--commit", "1", "--stdin"])
6880 else {
6881 panic!("expected AsOf");
6882 };
6883 assert!(stdin);
6884
6885 for args in [
6886 &["query", "/tmp/db", "--stdin", "--file", "/tmp/q"][..],
6887 &["query", "/tmp/db", "--stdin", "MATCH (n) RETURN n"],
6888 &[
6889 "query",
6890 "/tmp/db",
6891 "--file",
6892 "/tmp/q",
6893 "--query",
6894 "MATCH (n) RETURN n",
6895 ],
6896 &[
6897 "asof",
6898 "/tmp/db",
6899 "--commit",
6900 "1",
6901 "--stdin",
6902 "--query",
6903 "MATCH (n) RETURN n",
6904 ],
6905 &[
6906 "asof", "/tmp/db", "--commit", "1", "--stdin", "--file", "/tmp/q",
6907 ],
6908 &["query", "/tmp/db", "--file"],
6909 ] {
6910 assert!(parse_args(args).is_err(), "{args:?} should be refused");
6911 }
6912 assert!(usage().contains("--file <path>|--stdin"));
6913 }
6914
6915 #[test]
6918 fn resolve_statement_drops_the_trailing_newline() {
6919 let mut piped: &[u8] = b"MATCH (n) RETURN count(n)\n\n";
6920 let got = resolve_statement(None, None, true, &mut piped).expect("stdin");
6921 assert_eq!(got.as_deref(), Some("MATCH (n) RETURN count(n)"));
6922
6923 let mut unused: &[u8] = b"";
6924 let inline = resolve_statement(Some("MATCH (n) RETURN n"), None, false, &mut unused)
6925 .expect("inline");
6926 assert_eq!(inline.as_deref(), Some("MATCH (n) RETURN n"));
6927 assert_eq!(
6928 resolve_statement(None, None, false, &mut unused).unwrap(),
6929 None
6930 );
6931
6932 let mut bom: &[u8] = b"\xEF\xBB\xBFMATCH (n) RETURN n; \n";
6935 let got = resolve_statement(None, None, true, &mut bom).expect("bom");
6936 assert_eq!(got.as_deref(), Some("MATCH (n) RETURN n"));
6937 let got = resolve_statement(Some("MATCH (n) RETURN n ; "), None, false, &mut unused)
6938 .expect("inline semicolon");
6939 assert_eq!(got.as_deref(), Some("MATCH (n) RETURN n"));
6940 let got = resolve_statement(Some("RETURN ';;'"), None, false, &mut unused).unwrap();
6941 assert_eq!(got.as_deref(), Some("RETURN ';;'"));
6942
6943 let mut blank: &[u8] = b" \n";
6944 assert!(resolve_statement(None, None, true, &mut blank).is_err());
6945 assert!(resolve_statement(
6946 None,
6947 Some(Path::new("/nonexistent/q.cyp")),
6948 false,
6949 &mut unused
6950 )
6951 .is_err());
6952 }
6953
6954 #[test]
6957 fn run_query_with_a_role_and_a_namespace_never_widens() {
6958 let dir = ns_store("query-namespace");
6959 let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
6960
6961 let all = run_query(&dir, q, None, None).expect("query");
6962 assert!(all.contains("id=a1") && all.contains("id=b1") && all.contains("id=d1"));
6963
6964 let ns = run_query(&dir, q, None, Some("tenant-a")).expect("query");
6965 assert!(ns.contains("id=a1") && ns.contains("id=a2"), "got {ns}");
6966 assert!(!ns.contains("id=b1") && !ns.contains("id=d1"), "got {ns}");
6967
6968 let role = run_query(&dir, q, Some("a-reader"), None).expect("query");
6969 assert!(
6970 role.contains("id=a1") && !role.contains("id=b1"),
6971 "got {role}"
6972 );
6973
6974 let both = run_query(&dir, q, Some("a-reader"), Some("tenant-b")).expect("query");
6975 assert!(
6976 !both.contains("id=a1") && !both.contains("id=b1"),
6977 "role ∩ namespace, never role ∪ namespace: {both}"
6978 );
6979
6980 for (role, namespace) in [
6983 (None, Some("tenant-a")),
6984 (Some("a-reader"), None),
6985 (Some("a-reader"), Some("tenant-a")),
6986 ] {
6987 let write = run_query(&dir, "CREATE (n:Doc {id: 'z1'})", role, namespace);
6988 assert!(
6989 write
6990 .as_ref()
6991 .err()
6992 .is_some_and(|e| e.0.contains("read-only")),
6993 "a restricted write must be refused, got {write:?}"
6994 );
6995 assert!(
6996 !GraphDb::open(&dir).expect("reopen").has_node("z1"),
6997 "the write must not have landed"
6998 );
6999 }
7000
7001 let unknown = run_query(&dir, q, Some("nobody"), None);
7002 assert!(unknown.is_err(), "an unknown role is an error");
7003 let invalid = run_query(&dir, q, None, Some("no spaces"));
7004 assert!(
7005 invalid
7006 .as_ref()
7007 .err()
7008 .is_some_and(|e| e.0.contains("valid namespace name")),
7009 "{invalid:?}"
7010 );
7011 let _ = std::fs::remove_dir_all(&dir);
7012 }
7013
7014 #[test]
7016 fn run_asof_in_a_namespace() {
7017 let dir = ns_store("asof-namespace");
7018 let at = {
7019 let db = GraphDb::open(&dir).expect("open");
7020 db.wal_total_commits().expect("commits") - 1
7021 };
7022 {
7023 let mut db = GraphDb::open(&dir).expect("open");
7024 db.insert_node(
7025 "Doc",
7026 "a3",
7027 vec![
7028 ("id".into(), Value::Str("a3".into())),
7029 ("ns".into(), Value::Str("tenant-a".into())),
7030 ],
7031 )
7032 .expect("insert");
7033 }
7034 let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
7035 let then = run_asof(&dir, Some(at), None, Some(q), Some("tenant-a")).expect("asof");
7036 assert!(
7037 then.contains("id=a1") && then.contains("id=a2"),
7038 "got {then}"
7039 );
7040 assert!(
7041 !then.contains("id=a3") && !then.contains("id=b1"),
7042 "a3 did not exist then and b1 is another namespace: {then}"
7043 );
7044 let now = run_asof(&dir, Some(at + 1), None, Some(q), Some("tenant-a")).expect("asof");
7045 assert!(now.contains("id=a3"), "got {now}");
7046 let _ = std::fs::remove_dir_all(&dir);
7047 }
7048
7049 #[test]
7052 fn schema_apply_accepts_v4_roles_with_namespaces() {
7053 let dir = tmp("schema-v4");
7054 {
7055 let mut db = GraphDb::open(&dir).expect("open");
7056 db.insert_node(
7057 "Doc",
7058 "a1",
7059 vec![("ns".into(), Value::Str("tenant-a".into()))],
7060 )
7061 .expect("insert");
7062 }
7063 let file = dir.join("schema.json");
7064 std::fs::write(
7065 &file,
7066 r#"{"roles": [{"name": "a-reader", "labels": ["Doc"], "keys": [],
7067 "namespaces": ["tenant-a"]}]}"#,
7068 )
7069 .expect("write schema");
7070 let out = run_schema_apply(&dir, &file).expect("apply");
7071 assert!(out.contains("a-reader"), "got {out}");
7072 let sidecar = std::fs::read_to_string(dir.join("roles.json")).expect("roles.json");
7073 assert!(
7074 sidecar.contains("\"version\": 4") || sidecar.contains("\"version\":4"),
7075 "a role with namespaces writes version 4: {sidecar}"
7076 );
7077 assert!(sidecar.contains("tenant-a"), "{sidecar}");
7078 let _ = std::fs::remove_dir_all(&dir);
7079 }
7080}