pub mod doctor;
pub mod enrich;
pub mod export;
pub(crate) mod hook;
pub mod impact_hook;
pub mod ingest_git;
pub mod install;
pub mod intercept;
pub mod recall;
pub mod structure;
use core_api::repograph;
use core_api::schema::Schema;
use core_api::{
default_max_edges, is_write_query, valid_namespace, AlgoDir, BackupReport, DegreeConfig,
Explanation, GraphDb, IngestOptions, LouvainConfig, PageRankConfig, Predicate, RealFs,
ResultSet, RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig,
WriteGuard, NS_MAX_LEN,
};
use export::ExportFormat;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub const AUTOMATIC_SNAPSHOT: SnapshotOptions = SnapshotOptions {
keep_wal: false,
archive_wal: true,
};
pub const AUTO_SNAPSHOT_RETENTION: Option<u32> = None;
pub fn snapshot_automatically(db: &mut WriteGuard<'_>) -> Result<(), core_api::GraphError> {
db.set_wal_archive_retention(AUTO_SNAPSHOT_RETENTION);
db.snapshot_with(AUTOMATIC_SNAPSHOT)
}
pub const SNAPSHOT_LOCK_WAIT: Duration = Duration::from_millis(500);
pub fn snapshot_shared(db: &SharedDb) -> Result<(), core_api::GraphError> {
snapshot_automatically(&mut db.write_with_wait(SNAPSHOT_LOCK_WAIT)?)
}
pub const N_ORGS: usize = 10;
pub const N_PROJECTS: usize = 20;
pub const N_PEOPLE: usize = 30;
pub const SAMPLE_QUERY: &str = "\
MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
RETURN p, proj, r.score AS score
ORDER BY score DESC, proj";
const SAMPLE_EXPLAIN_A: &str = "person-01";
const SAMPLE_EXPLAIN_B: &str = "proj-01";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[must_use]
pub fn version_string() -> String {
format!("mushroomdb {VERSION}")
}
#[must_use]
pub fn resolve_auto_db(
env_project_dir: Option<&std::ffi::OsStr>,
cwd: &Path,
home: &Path,
) -> PathBuf {
if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
return Path::new(dir).join("mushroom-memory");
}
if let Some(root) = worktree_root(cwd) {
return root.join("mushroom-memory");
}
home.join(".mushroomdb").join("memory")
}
#[must_use]
pub fn worktree_root(dir: &Path) -> Option<&Path> {
dir.ancestors().find(|d| d.join(".git").exists())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServeUi {
Filesystem(PathBuf),
Embedded,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AlgoSubcmd {
Pagerank,
Wcc,
Degree,
Communities,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Serve {
db_dir: PathBuf,
addr: SocketAddr,
ui: ServeUi,
demo_if_empty: bool,
token: Option<String>,
role_tokens: Vec<(String, String)>,
snapshot_every: Option<Duration>,
restore_from: Option<PathBuf>,
tls_cert: Option<PathBuf>,
tls_key: Option<PathBuf>,
},
Mcp {
db_dir: Option<PathBuf>,
auto: bool,
all_tools: bool,
},
Stats {
db_dir: PathBuf,
},
Demo {
db_dir: PathBuf,
},
AsOf {
db_dir: PathBuf,
commit: u64,
query: Option<String>,
namespace: Option<String>,
},
Suggest {
db_dir: PathBuf,
},
Algo {
db_dir: PathBuf,
subcmd: AlgoSubcmd,
top: usize,
dir: AlgoDir,
edge_types: Vec<String>,
weight_prop: Option<String>,
min_weight: Option<f64>,
},
Query {
db_dir: PathBuf,
cypher: String,
role: Option<String>,
namespace: Option<String>,
},
Snapshot {
db_dir: PathBuf,
wal: WalDisposition,
retention: Option<u32>,
},
BuildIndex {
db_dir: PathBuf,
rule: Option<String>,
},
SchemaApply {
db_dir: PathBuf,
schema_file: PathBuf,
},
Migrate {
db_dir: PathBuf,
},
Verify {
db_dir: PathBuf,
},
Backup {
db_dir: PathBuf,
dest: PathBuf,
},
Export {
db_dir: PathBuf,
dest: PathBuf,
format: ExportFormat,
},
IngestGit {
db_dir: PathBuf,
opts: ingest_git::IngestGitOpts,
},
Install(install::InstallOpts),
Uninstall(install::InstallOpts),
Disable(install::ToggleOpts),
Enable(install::ToggleOpts),
Doctor(doctor::DoctorOpts),
Recall {
db_dir: Option<PathBuf>,
auto: bool,
},
Brief {
db_dir: Option<PathBuf>,
auto: bool,
},
Sync {
db_dir: Option<PathBuf>,
auto: bool,
json: bool,
},
Intercept {
db_dir: Option<PathBuf>,
auto: bool,
},
ImpactHook {
db_dir: Option<PathBuf>,
auto: bool,
},
Enrich {
db_dir: Option<PathBuf>,
auto: bool,
},
Touch {
db_dir: Option<PathBuf>,
auto: bool,
files: Vec<PathBuf>,
},
Map {
db_dir: PathBuf,
json: bool,
},
Explore {
db_dir: PathBuf,
target: String,
depth: repograph::Depth,
full: bool,
},
Context {
db_dir: PathBuf,
target: String,
full: bool,
},
Impact {
db_dir: PathBuf,
files: Vec<String>,
},
Owners {
db_dir: PathBuf,
path: String,
},
Why {
db_dir: PathBuf,
a: String,
b: String,
},
Version,
Help,
}
#[derive(Debug)]
pub struct DemoOutcome {
pub auto_fk_rules: Vec<String>,
pub sample_query: String,
pub sample_result: ResultSet,
pub explanations: Vec<Explanation>,
pub stats: Stats,
pub suggestion: Option<RuleSuggestion>,
}
#[derive(Debug)]
pub struct CliError(pub String);
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for CliError {}
impl From<core_api::GraphError> for CliError {
fn from(e: core_api::GraphError) -> Self {
CliError(e.to_string())
}
}
impl From<std::io::Error> for CliError {
fn from(e: std::io::Error) -> Self {
CliError(e.to_string())
}
}
pub fn usage() -> &'static str {
"\
mushroomdb — embedded graph database
Usage:
mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
[--command <path>] [--no-git-hooks] [--no-prewarm]
[--delivery cli|mcp|both] [--intercept-grep]
[--impact-before-edit] [--enrich-grep]
[--always-load|--no-always-load]
--delivery cli writes the skill and the hooks and registers no MCP
server: the skill teaches `mushroomdb <command>` instead (claude-code
only; cursor and codex are always registered as MCP servers)
--intercept-grep (deprecated, removed in 0.7) adds an experimental
PreToolUse hook (matcher Grep) that redirects a search for a known
symbol name to `explore`
--impact-before-edit (deprecated, removed in 0.7) adds an experimental
PreToolUse hook (matcher Edit|Write|MultiEdit) that injects the file's
blast radius before the edit
--enrich-grep (deprecated, removed in 0.7) adds an experimental
PostToolUse hook (matcher Grep) that appends what the graph knows
about the symbols the search matched
--always-load marks the registered MCP server alwaysLoad, so the host
keeps its tools in context instead of deferring them; already the
default when --db names a store and a server is registered
(--delivery mcp|both) — --no-always-load opts out
mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
mushroomdb disable [--platform claude-code|cursor|codex|all] [--project|--user]
turn an install off without removing it: hooks, MCP entry and git hook
blocks are removed; the skill, the store and .gitignore stay
mushroomdb enable [--platform claude-code|cursor|codex|all] [--project|--user]
turn a disabled install back on
mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
verify an install: config entry, store, hooks, git hooks, and a real
stdio handshake with the configured MCP command; exits 1 on any `fail`
mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>] [--restore-from <dir>]
--restore-from seeds an empty <db-dir> from the newest backup under <dir>
(or from <dir> itself if it is one); a no-op when <db-dir> already holds a store
mushroomdb mcp <db-dir>|--auto [--all-tools]
--all-tools lists all 27 tools; the default follows the store — 3 on a
store `ingest-git` built (explore, query, stats), 15 on any other
(the rest stay callable, just unlisted)
mushroomdb stats <db-dir>
mushroomdb demo <db-dir>
mushroomdb recall <db-dir>|--auto hook body: reads a prompt payload on stdin, prints related graph facts
mushroomdb brief <db-dir>|--auto hook body: the repository in one block — size, synced sha, the most
central files and the most called symbols; byte-stable, so a
session host caches it once
mushroomdb sync <db-dir>|--auto [--json] (deprecated, removed in 0.7)
re-sync the repo the store was built from: new commits, then the
dirty working tree (git hook body)
mushroomdb map <db-dir> [--json] (deprecated, removed in 0.7)
summarise the graphed repository: clusters, key files, owners, hot files
--json prints the computed map instead of the rendered digest
mushroomdb explore <db-dir> <target> [--depth context|impact|history|all] [--full]
(deprecated, removed in 0.7)
one target from as many sides as asked for: the definition and
its callers (context), the blast radius (impact), the owner and
what it changes with (history), or all three
<target> is a file path, a symbol key, or a bare symbol name
--full also quotes the body from the working tree
mushroomdb context <db-dir> <target> [--full] (deprecated, removed in 0.7)
one file or symbol from every side: where it is, signature, callers,
callees, importers, co-change partners, commits, notes
<target> is a file path, a symbol key, or a bare symbol name
--full also quotes the body from the working tree
mushroomdb impact <db-dir> <file>... (deprecated, removed in 0.7)
what changing these files reaches: partners, importers,
and the symbols other files call
mushroomdb owners <db-dir> <path> (deprecated, removed in 0.7)
top author and share, who else knows it, last touch, last 4 quarters
mushroomdb why <db-dir> <a> <b> (deprecated, removed in 0.7)
every rule edge between two nodes with its evidence, or the
shortest path between them
mushroomdb touch <db-dir>|--auto [<file>...]
re-extract just these files; with no <file> reads them from a
PostToolUse payload on stdin (hook body)
mushroomdb intercept <db-dir>|--auto
hook body: reads a PreToolUse Grep payload on stdin; exits 2
with a one-line pointer to `explore` when the pattern names a
symbol the graph holds, else exits 0 in silence
mushroomdb impact-hook <db-dir>|--auto
hook body: reads a PreToolUse edit payload on stdin; prints the
edited file's blast radius as additionalContext, else nothing
mushroomdb enrich <db-dir>|--auto
hook body: reads a PostToolUse Grep payload on stdin; prints what
the graph knows about the symbols it matched, else nothing
mushroomdb suggest <db-dir>
mushroomdb asof <db-dir> --commit N [--query \"MATCH ...\"] [--namespace <ns>]
mushroomdb query <db-dir> [--query \"MATCH ...\"] [--role <name>] [--namespace <ns>] <cypher…>
--role answers as one of the store's roles and --namespace from one
namespace; together they intersect, so neither ever widens the other
and either one makes the query a read
mushroomdb snapshot <db-dir> [--keep-wal|--truncate] [--retention N]
folds the WAL into snapshot.bin and archives it as wal.<N>.archive,
so node_history, edge_history, was_linked and asof keep reaching it;
--truncate discards it instead, --keep-wal leaves wal.bin whole
mushroomdb build-index <db-dir> [--rule <name>]
drives a rule's vector index to completion a slice at a time, for an
operator who wants the build finished before traffic arrives; a rule
created over a large corpus derives no edges until its index is whole
mushroomdb migrate <db-dir>
mushroomdb verify <db-dir> validate CRC32 integrity of every snapshot section
mushroomdb backup <db-dir> <dest> process-local consistent copy of the database to <dest>
WARNING: unsafe against a concurrently running serve process;
use POST /backup on the HTTP server for live-serve backups
mushroomdb export <db-dir> <dest> --format jsonl|parquet|graphml export all data
graphml writes one file: <dest>/graph.graphml if <dest> is an
existing directory, otherwise <dest> is the file path itself
(nodes + edges only; rules have no GraphML analogue)
mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
[--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
--recurse-submodules also walks each initialised submodule
--prs links merged pull requests via gh (skipped when gh is unavailable)
--no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
--no-docs skips Markdown bodies, headings and mentions
--ensure-gitignore adds the database directory to the repo's .gitignore
with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
mushroomdb schema apply <db-dir> <schema.json>
mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
mushroomdb algo wcc <db-dir> [--top N]
mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
mushroomdb --version
mushroomdb --help
Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
--command names one (a relative --command or --db is anchored to the current directory).
--no-git-hooks skips the post-commit/checkout/merge sync hooks. --no-prewarm means no network and no
resolution: neither the one-off package fetch nor locating the package's binary, so every hook keeps
the slower `npx` form.
uninstall resolves the same scope and falls back to the other one when the inferred scope has no
manifest; undoing a Codex install needs --platform codex.
A project install inside a git checkout writes --auto rather than a store path, so each `git
worktree` gets its own store; outside a checkout, and with --db, the store is pinned to an absolute
path instead.
--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else mushroom-memory at the
root of the working tree the current directory is in, else ~/.mushroomdb/memory.
"
}
fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
let mut platform: Option<install::Platform> = None;
let mut scope: Option<install::Scope> = None;
let mut db: Option<PathBuf> = None;
let mut command: Option<PathBuf> = None;
let mut git_hooks = true;
let mut prewarm = true;
let mut delivery = install::Delivery::default();
let mut intercept_grep = false;
let mut impact_before_edit = false;
let mut enrich_grep = false;
let mut always_load: Option<bool> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--delivery" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --delivery".to_string())?;
delivery = install::Delivery::parse(val)?;
i += 2;
} else if let Some(val) = a.strip_prefix("--delivery=") {
delivery = install::Delivery::parse(val)?;
i += 1;
} else if a == "--platform" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --platform".to_string())?;
platform = Some(install::Platform::parse(val)?);
i += 2;
} else if let Some(val) = a.strip_prefix("--platform=") {
platform = Some(install::Platform::parse(val)?);
i += 1;
} else if a == "--project" || a == "--user" {
let want = if a == "--project" {
install::Scope::Project
} else {
install::Scope::User
};
if scope.is_some_and(|s| s != want) {
return Err("--project and --user are mutually exclusive".to_string());
}
scope = Some(want);
i += 1;
} else if a == "--no-git-hooks" {
git_hooks = false;
i += 1;
} else if a == "--intercept-grep" {
intercept_grep = true;
i += 1;
} else if a == "--impact-before-edit" {
impact_before_edit = true;
i += 1;
} else if a == "--enrich-grep" {
enrich_grep = true;
i += 1;
} else if a == "--always-load" {
always_load = Some(true);
i += 1;
} else if a == "--no-always-load" {
always_load = Some(false);
i += 1;
} else if a == "--no-prewarm" {
prewarm = false;
i += 1;
} else if a == "--command" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --command".to_string())?;
command = Some(PathBuf::from(val));
i += 2;
} else if let Some(val) = a.strip_prefix("--command=") {
command = Some(PathBuf::from(val));
i += 1;
} else if a == "--db" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --db".to_string())?;
db = Some(PathBuf::from(val));
i += 2;
} else if let Some(val) = a.strip_prefix("--db=") {
db = Some(PathBuf::from(val));
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else {
return Err(format!("unexpected argument: {a}"));
}
}
let always_load =
always_load.unwrap_or(db.is_some() && !matches!(delivery, install::Delivery::Cli));
Ok(install::InstallOpts {
platform,
scope,
db,
command,
git_hooks,
prewarm,
delivery,
intercept_grep,
impact_before_edit,
enrich_grep,
always_load,
})
}
fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
let mut platform: Option<install::Platform> = None;
let mut scope: Option<install::Scope> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--platform" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --platform".to_string())?;
platform = Some(install::Platform::parse(val)?);
i += 2;
} else if let Some(val) = a.strip_prefix("--platform=") {
platform = Some(install::Platform::parse(val)?);
i += 1;
} else if a == "--project" || a == "--user" {
let want = if a == "--project" {
install::Scope::Project
} else {
install::Scope::User
};
if scope.is_some_and(|s| s != want) {
return Err("--project and --user are mutually exclusive".to_string());
}
scope = Some(want);
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else {
return Err(format!("unexpected argument: {a}"));
}
}
Ok(doctor::DoctorOpts { platform, scope })
}
fn parse_toggle_cmd(args: &[&str]) -> Result<install::ToggleOpts, String> {
let mut platform: Option<install::Platform> = None;
let mut scope: Option<install::Scope> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--platform" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --platform".to_string())?;
platform = Some(install::Platform::parse(val)?);
i += 2;
} else if let Some(val) = a.strip_prefix("--platform=") {
platform = Some(install::Platform::parse(val)?);
i += 1;
} else if a == "--project" || a == "--user" {
let want = if a == "--project" {
install::Scope::Project
} else {
install::Scope::User
};
if scope.is_some_and(|s| s != want) {
return Err("--project and --user are mutually exclusive".to_string());
}
scope = Some(want);
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else {
return Err(format!("unexpected argument: {a}"));
}
}
Ok(install::ToggleOpts { platform, scope })
}
fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
let mut positional = Vec::new();
let mut exclude = Vec::new();
let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
let mut recurse_submodules = false;
let mut prs = false;
let mut structure = true;
let mut docs = true;
let mut ensure_gitignore = false;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--recurse-submodules" {
recurse_submodules = true;
i += 1;
} else if a == "--prs" {
prs = true;
i += 1;
} else if a == "--no-structure" {
structure = false;
i += 1;
} else if a == "--no-docs" {
docs = false;
i += 1;
} else if a == "--ensure-gitignore" {
ensure_gitignore = true;
i += 1;
} else if a == "--exclude" {
exclude.push(
args.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --exclude".to_string())?
.to_string(),
);
i += 2;
} else if let Some(val) = a.strip_prefix("--exclude=") {
exclude.push(val.to_string());
i += 1;
} else if a == "--max-commits-per-file" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
max_commits_per_file = val
.parse()
.map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
i += 2;
} else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
max_commits_per_file = val
.parse()
.map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else {
positional.push(a);
i += 1;
}
}
let [db_dir, repo] = positional.as_slice() else {
return Err("ingest-git requires <db-dir> <repo-dir>".into());
};
if exclude.is_empty() {
exclude = ingest_git::DEFAULT_EXCLUDES
.iter()
.map(|p| (*p).to_string())
.collect();
}
Ok(Command::IngestGit {
db_dir: PathBuf::from(db_dir),
opts: ingest_git::IngestGitOpts {
repo: PathBuf::from(repo),
exclude,
max_commits_per_file,
recurse_submodules,
prs,
structure,
docs,
ensure_gitignore,
},
})
}
pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
if args.is_empty() {
return Ok(Command::Help);
}
match args[0] {
"--help" | "-h" | "help" => Ok(Command::Help),
"--version" | "-V" | "version" => Ok(Command::Version),
"serve" => parse_serve(&args[1..]),
"mcp" => parse_mcp(&args[1..]),
"stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
"demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
"suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
"asof" => parse_asof(&args[1..]),
"algo" => parse_algo(&args[1..]),
"query" => parse_query(&args[1..]),
"snapshot" => parse_snapshot(&args[1..]),
"build-index" => parse_build_index(&args[1..]),
"schema" => parse_schema(&args[1..]),
"migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
"verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
"backup" => parse_backup(&args[1..]),
"export" => parse_export(&args[1..]),
"recall" => parse_dir_or_auto("recall", &args[1..])
.map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
"brief" => parse_dir_or_auto("brief", &args[1..])
.map(|(db_dir, auto)| Command::Brief { db_dir, auto }),
"intercept" => parse_dir_or_auto("intercept", &args[1..])
.map(|(db_dir, auto)| Command::Intercept { db_dir, auto }),
"impact-hook" => parse_dir_or_auto("impact-hook", &args[1..])
.map(|(db_dir, auto)| Command::ImpactHook { db_dir, auto }),
"enrich" => parse_dir_or_auto("enrich", &args[1..])
.map(|(db_dir, auto)| Command::Enrich { db_dir, auto }),
"sync" => parse_sync(&args[1..]),
"map" => parse_dir_with_json("map", &args[1..])
.map(|(db_dir, json)| Command::Map { db_dir, json }),
"explore" => parse_explore(&args[1..]),
"context" => parse_context(&args[1..]),
"impact" => parse_positional("impact", &args[1..], 1, usize::MAX)
.map(|(db_dir, files)| Command::Impact { db_dir, files }),
"owners" => {
parse_positional("owners", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Owners {
db_dir,
path: rest[0].clone(),
})
}
"why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
db_dir,
a: rest[0].clone(),
b: rest[1].clone(),
}),
"touch" => parse_touch(&args[1..]),
"ingest-git" => parse_ingest_git(&args[1..]),
"install" => parse_install_cmd(&args[1..]).map(Command::Install),
"uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
"disable" => parse_toggle_cmd(&args[1..]).map(Command::Disable),
"enable" => parse_toggle_cmd(&args[1..]).map(Command::Enable),
"doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
other => Err(format!("unknown command: {other}")),
}
}
fn default_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], 8080))
}
fn parse_serve(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut addr = default_addr();
let mut ui = ServeUi::Embedded;
let mut saw_ui = false;
let mut saw_no_ui = false;
let mut demo_if_empty = false;
let mut token = None;
let mut role_tokens: Vec<(String, String)> = Vec::new();
let mut snapshot_every = None;
let mut restore_from: Option<PathBuf> = None;
let mut tls_cert: Option<PathBuf> = None;
let mut tls_key: Option<PathBuf> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--addr" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --addr".to_string())?;
addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
i += 2;
} else if let Some(val) = a.strip_prefix("--addr=") {
addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
i += 1;
} else if a == "--ui" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --ui".to_string())?;
ui = ServeUi::Filesystem(PathBuf::from(val));
saw_ui = true;
i += 2;
} else if let Some(val) = a.strip_prefix("--ui=") {
ui = ServeUi::Filesystem(PathBuf::from(val));
saw_ui = true;
i += 1;
} else if a == "--no-ui" {
ui = ServeUi::None;
saw_no_ui = true;
i += 1;
} else if a == "--demo-if-empty" {
demo_if_empty = true;
i += 1;
} else if a == "--token" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --token".to_string())?;
token = Some(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--token=") {
token = Some(val.to_string());
i += 1;
} else if a == "--role-token" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --role-token".to_string())?;
let (tok, role) = parse_role_token(val)?;
role_tokens.push((tok, role));
i += 2;
} else if let Some(val) = a.strip_prefix("--role-token=") {
let (tok, role) = parse_role_token(val)?;
role_tokens.push((tok, role));
i += 1;
} else if a == "--snapshot-every" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --snapshot-every".to_string())?;
snapshot_every = Some(parse_snapshot_every(val)?);
i += 2;
} else if let Some(val) = a.strip_prefix("--snapshot-every=") {
snapshot_every = Some(parse_snapshot_every(val)?);
i += 1;
} else if a == "--restore-from" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --restore-from".to_string())?;
restore_from = Some(PathBuf::from(val));
i += 2;
} else if let Some(val) = a.strip_prefix("--restore-from=") {
restore_from = Some(PathBuf::from(val));
i += 1;
} else if a == "--tls-cert" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --tls-cert".to_string())?;
tls_cert = Some(PathBuf::from(val));
i += 2;
} else if let Some(val) = a.strip_prefix("--tls-cert=") {
tls_cert = Some(PathBuf::from(val));
i += 1;
} else if a == "--tls-key" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --tls-key".to_string())?;
tls_key = Some(PathBuf::from(val));
i += 2;
} else if let Some(val) = a.strip_prefix("--tls-key=") {
tls_key = Some(PathBuf::from(val));
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(a));
i += 1;
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
if saw_ui && saw_no_ui {
return Err("cannot combine --ui and --no-ui".to_string());
}
match (&tls_cert, &tls_key) {
(Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
(None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
_ => {}
}
let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
Ok(Command::Serve {
db_dir,
addr,
ui,
demo_if_empty,
token,
role_tokens,
snapshot_every,
restore_from,
tls_cert,
tls_key,
})
}
fn parse_role_token(val: &str) -> Result<(String, String), String> {
let (tok, role) = val
.split_once(':')
.ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
if tok.is_empty() {
return Err("--role-token: TOKEN must not be empty".to_string());
}
if role.is_empty() {
return Err("--role-token: ROLE must not be empty".to_string());
}
Ok((tok.to_string(), role.to_string()))
}
fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
let secs: u64 = val
.parse()
.map_err(|_| format!("invalid --snapshot-every: {val}"))?;
if secs == 0 {
return Err("--snapshot-every must be a positive number of seconds".into());
}
Ok(Duration::from_secs(secs))
}
pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
if !dir.is_dir() {
return Err(format!("--ui directory does not exist: {}", dir.display()));
}
let index = dir.join("index.html");
if !index.is_file() {
return Err(format!(
"--ui directory is missing index.html: {}",
dir.display()
));
}
Ok(dir.to_path_buf())
}
fn parse_asof(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut commit: Option<u64> = None;
let mut query: Option<String> = None;
let mut namespace: Option<String> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--commit" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --commit".to_string())?;
commit = Some(
val.parse()
.map_err(|_| format!("invalid commit index: {val}"))?,
);
i += 2;
} else if let Some(val) = a.strip_prefix("--commit=") {
commit = Some(
val.parse()
.map_err(|_| format!("invalid commit index: {val}"))?,
);
i += 1;
} else if a == "--query" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --query".to_string())?;
query = Some(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--query=") {
query = Some(val.to_string());
i += 1;
} else if a == "--namespace" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --namespace".to_string())?;
namespace = Some(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--namespace=") {
namespace = Some(val.to_string());
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(a));
i += 1;
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
let commit = commit.ok_or_else(|| "asof requires --commit N".to_string())?;
Ok(Command::AsOf {
db_dir,
commit,
query,
namespace,
})
}
pub fn run_asof(
db_dir: &Path,
commit: u64,
query: Option<&str>,
namespace: Option<&str>,
) -> Result<String, CliError> {
let namespace = check_namespace(namespace)?;
let db = GraphDb::open_at(db_dir, commit)?;
let total = db.wal_total_commits()?;
let floor = db.wal_horizon_floor();
let mut out = String::new();
if floor == 0 {
let _ = writeln!(out, "as-of commit {} of {}", commit, total);
} else {
let _ = writeln!(
out,
"as-of commit {} of {} (history reaches back to commit {})",
commit, total, floor
);
}
if let Some(cypher) = query {
let params = BTreeMap::new();
let rs = match namespace {
Some(ns) => db.query_masked(cypher, ¶ms, &db.mask_for_namespace(ns))?,
None => db.query(cypher, ¶ms)?,
};
out.push_str(&format_result_set(&rs));
}
Ok(out)
}
fn check_namespace(namespace: Option<&str>) -> Result<Option<&str>, CliError> {
match namespace {
Some(ns) if !valid_namespace(ns) => Err(CliError(format!(
"namespace {ns:?} is not a valid namespace name — 1 to {NS_MAX_LEN} characters of \
[A-Za-z0-9_.-]"
))),
other => Ok(other),
}
}
fn parse_query(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut query_flag: Option<String> = None;
let mut role: Option<String> = None;
let mut namespace: Option<String> = None;
let mut cypher_parts: Vec<&str> = Vec::new();
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--query" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --query".to_string())?;
query_flag = Some(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--query=") {
query_flag = Some(val.to_string());
i += 1;
} else if a == "--role" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --role".to_string())?;
role = Some(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--role=") {
role = Some(val.to_string());
i += 1;
} else if a == "--namespace" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --namespace".to_string())?;
namespace = Some(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--namespace=") {
namespace = Some(val.to_string());
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(a));
i += 1;
} else {
cypher_parts.push(a);
i += 1;
}
}
let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
let cypher = if let Some(q) = query_flag {
if !cypher_parts.is_empty() {
return Err(
"query: pass Cypher as remaining arguments or --query, not both".to_string(),
);
}
q
} else {
if cypher_parts.is_empty() {
return Err("query requires a Cypher string".to_string());
}
cypher_parts.join(" ")
};
Ok(Command::Query {
db_dir,
cypher,
role,
namespace,
})
}
pub fn run_query(
db_dir: &Path,
cypher: &str,
role: Option<&str>,
namespace: Option<&str>,
) -> Result<String, CliError> {
let namespace = check_namespace(namespace)?;
let params = BTreeMap::new();
if role.is_some() || namespace.is_some() {
let db = GraphDb::open(db_dir)?;
let mask = match (role, namespace) {
(Some(role), Some(ns)) => db
.mask_for_role(role)?
.intersect(&db.mask_for_namespace(ns)),
(Some(role), None) => db.mask_for_role(role)?,
(None, Some(ns)) => db.mask_for_namespace(ns),
(None, None) => unreachable!("one of the two is Some in this branch"),
};
return Ok(format_result_set(&db.query_masked(cypher, ¶ms, &mask)?));
}
let is_write = is_write_query(cypher).map_err(CliError)?;
let rs = if is_write {
let mut db = GraphDb::open(db_dir)?;
db.query_write(cypher, ¶ms)?
} else {
let db = GraphDb::open(db_dir)?;
db.query(cypher, ¶ms)?
};
Ok(format_result_set(&rs))
}
fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut wal = WalDisposition::Archive;
let mut retention: Option<u32> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--keep-wal" {
wal = WalDisposition::Keep;
i += 1;
} else if a == "--truncate" {
wal = WalDisposition::Truncate;
i += 1;
} else if a == "--archive-wal" {
wal = WalDisposition::Archive;
i += 1;
} else if a.starts_with("--retention=") {
let v = a.trim_start_matches("--retention=");
retention = Some(
v.parse::<u32>()
.map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
);
i += 1;
} else if a == "--retention" {
i += 1;
let v = args
.get(i)
.ok_or_else(|| "--retention requires a value".to_string())?;
retention = Some(
v.parse::<u32>()
.map_err(|e| format!("--retention value error: {e}"))?,
);
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(a));
i += 1;
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
Ok(Command::Snapshot {
db_dir,
wal,
retention,
})
}
pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
let current = core_api::SNAPSHOT_VERSION;
let from_ver = core_api::snapshot_version_at(db_dir)?;
if from_ver == Some(current) {
return Ok(format!("already current (V{current})\n"));
}
if from_ver.is_some() {
std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
}
let mut db = GraphDb::open_with_options(
db_dir,
core_api::OpenOptions {
auto_migrate: false,
..Default::default()
},
)?;
db.snapshot()?;
let msg = match from_ver {
Some(ver) => format!("migrated V{ver} -> V{current}\n"),
None => format!("migrated WAL-only -> V{current}\n"),
};
Ok(msg)
}
pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
if !db_dir.join("snapshot.bin").exists() {
return Err(CliError(format!(
"verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
db_dir.display(),
db_dir.display()
)));
}
let results = core_api::verify_snapshot(db_dir)
.map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
let mut any_fail = false;
let mut out = String::new();
for (id, section_name, byte_len, result) in &results {
match result {
Ok(()) => {
let _ = writeln!(
out,
" section {:2} ({:<12}) {:>10} bytes OK",
id, section_name, byte_len
);
}
Err(msg) => {
let _ = writeln!(
out,
" section {:2} ({:<12}) {:>10} bytes CORRUPT: {msg}",
id, section_name, byte_len
);
any_fail = true;
}
}
}
if any_fail {
Err(CliError(format!("integrity check FAILED:\n{out}")))
} else {
Ok(format!(
"integrity check OK ({} sections):\n{out}",
results.len()
))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WalDisposition {
#[default]
Archive,
Keep,
Truncate,
}
impl WalDisposition {
fn options(self) -> SnapshotOptions {
match self {
WalDisposition::Archive => AUTOMATIC_SNAPSHOT,
WalDisposition::Keep => SnapshotOptions {
keep_wal: true,
archive_wal: false,
},
WalDisposition::Truncate => SnapshotOptions {
keep_wal: false,
archive_wal: false,
},
}
}
}
pub fn run_snapshot(
db_dir: &Path,
wal: WalDisposition,
retention: Option<u32>,
) -> Result<String, CliError> {
let mut db = GraphDb::open(db_dir)?;
if wal == WalDisposition::Archive {
db.set_wal_archive_retention(retention);
}
db.snapshot_with(wal.options())?;
Ok(format!(
"snapshot written: {}\n",
db_dir.join("snapshot.bin").display()
))
}
fn parse_build_index(args: &[&str]) -> Result<Command, String> {
let mut db_dir: Option<PathBuf> = None;
let mut rule: Option<String> = None;
let mut i = 0;
while i < args.len() {
let a = args[i];
if let Some(v) = a.strip_prefix("--rule=") {
if v.is_empty() {
return Err("--rule requires a value".to_string());
}
rule = Some(v.to_string());
i += 1;
} else if a == "--rule" {
i += 1;
let v = args
.get(i)
.ok_or_else(|| "--rule requires a value".to_string())?;
rule = Some((*v).to_string());
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(a));
i += 1;
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
let db_dir = db_dir.ok_or_else(|| "build-index requires <db-dir>".to_string())?;
Ok(Command::BuildIndex { db_dir, rule })
}
pub fn run_build_index(db_dir: &Path, rule: Option<&str>) -> Result<String, CliError> {
let mut db = GraphDb::open(db_dir)?;
build_index_on(&mut db, rule)
}
pub fn build_index_on(db: &mut GraphDb<RealFs>, rule: Option<&str>) -> Result<String, CliError> {
let mut out = String::new();
let interesting = |name: &str| rule.is_none_or(|r| r == name);
let known_rules: Vec<String> = db.stats().rules.iter().map(|r| r.name.clone()).collect();
loop {
let (finished, outstanding) = db.pump_index_build_reporting()?;
for b in &outstanding {
if interesting(&b.rule) {
out.push_str(&format!("building {}: {}/{}\n", b.rule, b.indexed, b.total));
}
}
for b in &finished {
if interesting(&b.rule) {
out.push_str(&format!("built {}: {} vectors\n", b.rule, b.total));
}
}
if outstanding.is_empty() {
break;
}
}
if out.is_empty() {
match rule {
Some(r) if !known_rules.iter().any(|k| k == r) => out.push_str(&format!(
"no rule named {r:?} in this store; nothing to build\n"
)),
Some(r) => out.push_str(&format!("nothing to build for rule {r:?}\n")),
None => out.push_str("nothing to build\n"),
}
}
Ok(out)
}
fn parse_schema(args: &[&str]) -> Result<Command, String> {
if args.is_empty() {
return Err("schema requires a subcommand: apply".to_string());
}
match args[0] {
"apply" => parse_schema_apply(&args[1..]),
other => Err(format!(
"unknown schema subcommand: {other}; expected apply"
)),
}
}
fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut schema_file = None;
for a in args {
if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
}
if db_dir.is_none() {
db_dir = Some(PathBuf::from(*a));
} else if schema_file.is_none() {
schema_file = Some(PathBuf::from(*a));
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
let schema_file =
schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
Ok(Command::SchemaApply {
db_dir,
schema_file,
})
}
pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
let json = std::fs::read_to_string(schema_file)
.map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
let schema: Schema = serde_json::from_str(&json).map_err(|e| {
CliError(format!(
"invalid schema JSON in {}: {e}",
schema_file.display()
))
})?;
let mut db = GraphDb::open(db_dir)?;
let diff = db.apply_schema(&schema)?;
let mut out = String::new();
for entry in &diff.created {
let _ = writeln!(out, "created {entry}");
}
for entry in &diff.updated {
let _ = writeln!(out, "updated {entry}");
}
for entry in &diff.unchanged {
let _ = writeln!(out, "unchanged {entry}");
}
if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
}
Ok(out)
}
fn parse_backup(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut dest = None;
for a in args {
if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
}
if db_dir.is_none() {
db_dir = Some(PathBuf::from(*a));
} else if dest.is_none() {
dest = Some(PathBuf::from(*a));
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
Ok(Command::Backup { db_dir, dest })
}
fn parse_export(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut dest = None;
let mut format = ExportFormat::Jsonl;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--format" {
let val = args
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --format".to_string())?;
format = ExportFormat::parse(val).ok_or_else(|| {
format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
})?;
i += 2;
} else if let Some(val) = a.strip_prefix("--format=") {
format = ExportFormat::parse(val).ok_or_else(|| {
format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
})?;
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(a));
i += 1;
} else if dest.is_none() {
dest = Some(PathBuf::from(a));
i += 1;
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
Ok(Command::Export {
db_dir,
dest,
format,
})
}
pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
let db = GraphDb::open(db_dir)?;
Ok(db.backup_to(dest)?)
}
#[derive(Debug, PartialEq, Eq)]
pub enum RestoreOutcome {
AlreadyPresent,
Restored {
from: PathBuf,
files: Vec<String>,
bytes: u64,
},
Empty,
}
const RESTORE_FILES: [&str; 6] = [
"snapshot.bin",
"snapshot.bin.bak",
"wal.bin",
"wal.floor",
"wal.genesis",
"roles.json",
];
fn holds_a_store(dir: &Path) -> bool {
if dir.join("snapshot.bin").is_file() {
return true;
}
std::fs::metadata(dir.join("wal.bin"))
.map(|m| m.is_file() && m.len() > 0)
.unwrap_or(false)
}
fn backup_mtime(dir: &Path) -> Option<std::time::SystemTime> {
["snapshot.bin", "wal.bin"]
.iter()
.filter_map(|n| {
std::fs::metadata(dir.join(n))
.and_then(|m| m.modified())
.ok()
})
.max()
}
fn choose_backup(from: &Path) -> Option<PathBuf> {
if holds_a_store(from) {
return Some(from.to_path_buf());
}
let entries = std::fs::read_dir(from).ok()?;
let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in entries.flatten() {
let dir = entry.path();
if !holds_a_store(&dir) {
continue;
}
if dir.file_name().map(|n| n == "latest").unwrap_or(false) {
return Some(dir);
}
let Some(mtime) = backup_mtime(&dir) else {
continue;
};
let better = match &best {
None => true,
Some((best_mtime, best_dir)) => (mtime, &dir) > (*best_mtime, best_dir),
};
if better {
best = Some((mtime, dir));
}
}
best.map(|(_, dir)| dir)
}
pub fn restore_if_empty(db_dir: &Path, from: &Path) -> Result<RestoreOutcome, CliError> {
if holds_a_store(db_dir) {
return Ok(RestoreOutcome::AlreadyPresent);
}
let Some(backup) = choose_backup(from) else {
return Ok(RestoreOutcome::Empty);
};
std::fs::create_dir_all(db_dir)
.map_err(|e| CliError(format!("restore into {}: {e}", db_dir.display())))?;
let staging = db_dir.join(format!(".restore-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&staging); std::fs::create_dir_all(&staging)
.map_err(|e| CliError(format!("restore into {}: {e}", staging.display())))?;
let outcome = stage_and_install(db_dir, &staging, &backup);
let _ = std::fs::remove_dir_all(&staging);
outcome
}
fn stage_and_install(
db_dir: &Path,
staging: &Path,
backup: &Path,
) -> Result<RestoreOutcome, CliError> {
let mut names: Vec<String> = RESTORE_FILES.iter().map(|n| n.to_string()).collect();
let mut archives: Vec<String> = std::fs::read_dir(backup)
.map_err(|e| CliError(format!("restore from {}: {e}", backup.display())))?
.flatten()
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.starts_with("wal.") && n.ends_with(".archive"))
.collect();
archives.sort();
names.extend(archives);
let mut files = Vec::new();
let mut bytes = 0u64;
for name in names {
let src = backup.join(&name);
if !src.is_file() {
continue;
}
let n = std::fs::copy(&src, staging.join(&name))
.map_err(|e| CliError(format!("restore {} from {}: {e}", name, backup.display())))?;
bytes += n;
files.push(name);
}
GraphDb::open(staging).map_err(|e| {
CliError(format!(
"restore into {} from {} failed: the copy does not open: {e}",
db_dir.display(),
backup.display()
))
})?;
let mut moved: Vec<&String> = Vec::new();
for name in &files {
if let Err(e) = std::fs::rename(staging.join(name), db_dir.join(name)) {
for done in &moved {
let _ = std::fs::remove_file(db_dir.join(done));
}
return Err(CliError(format!(
"restore into {} from {}: installing {name}: {e}",
db_dir.display(),
backup.display()
)));
}
moved.push(name);
}
Ok(RestoreOutcome::Restored {
from: backup.to_path_buf(),
files,
bytes,
})
}
pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
let mut out = String::new();
writeln!(out, "backup to: {}", dest.display()).unwrap();
writeln!(out, " files: {}", report.files.join(", ")).unwrap();
writeln!(out, " bytes: {}", report.bytes).unwrap();
writeln!(out, " verified: {}", report.verified).unwrap();
out
}
pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
let db = GraphDb::open(db_dir)?;
let nodes = db.all_nodes_for_export();
let edges = db.all_edges_for_export();
let mut rules = db.rules();
rules.sort_by(|a, b| a.name.cmp(&b.name));
let node_count = nodes.len();
let edge_count = edges.len();
let rule_count = rules.len();
match format {
ExportFormat::Jsonl => {
export::write_jsonl(&nodes, &edges, &rules, dest)?;
Ok(format!(
"exported to {} (format={}): {} nodes, {} edges, {} rules\n",
dest.display(),
format.name(),
node_count,
edge_count,
rule_count
))
}
ExportFormat::Parquet => {
export::write_parquet(&nodes, &edges, &rules, dest)?;
Ok(format!(
"exported to {} (format={}): {} nodes, {} edges, {} rules\n",
dest.display(),
format.name(),
node_count,
edge_count,
rule_count
))
}
ExportFormat::Graphml => {
let file_path = export::write_graphml(&nodes, &edges, dest)?;
Ok(format!(
"exported to {} (format={}): {} nodes, {} edges\n",
file_path.display(),
format.name(),
node_count,
edge_count,
))
}
}
}
fn format_result_set(rs: &ResultSet) -> String {
let mut out = String::new();
let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
for i in 0..rs.len() {
let cells: Vec<String> = rs
.columns()
.iter()
.map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
.collect();
let _ = writeln!(out, " {}", cells.join(" "));
}
out
}
fn parse_algo(args: &[&str]) -> Result<Command, String> {
if args.is_empty() {
return Err(
"algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
);
}
let subcmd = match args[0] {
"pagerank" => AlgoSubcmd::Pagerank,
"wcc" => AlgoSubcmd::Wcc,
"degree" => AlgoSubcmd::Degree,
"communities" => AlgoSubcmd::Communities,
other => {
return Err(format!(
"unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
))
}
};
let rest = &args[1..];
let mut db_dir = None;
let mut top: usize = 20;
let mut dir = AlgoDir::Both;
let mut edge_types: Vec<String> = Vec::new();
let mut weight_prop: Option<String> = None;
let mut min_weight: Option<f64> = None;
let mut i = 0;
while i < rest.len() {
let a = rest[i];
if a == "--top" {
let val = rest
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --top".to_string())?;
top = val
.parse()
.map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
i += 2;
} else if let Some(val) = a.strip_prefix("--top=") {
top = val
.parse()
.map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
i += 1;
} else if a == "--dir" {
let val = rest
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --dir".to_string())?;
dir = parse_algo_dir(val)?;
i += 2;
} else if let Some(val) = a.strip_prefix("--dir=") {
dir = parse_algo_dir(val)?;
i += 1;
} else if a == "--edge-type" {
let val = rest
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --edge-type".to_string())?;
edge_types.push(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--edge-type=") {
edge_types.push(val.to_string());
i += 1;
} else if a == "--weight-prop" {
let val = rest
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --weight-prop".to_string())?;
weight_prop = Some(val.to_string());
i += 2;
} else if let Some(val) = a.strip_prefix("--weight-prop=") {
weight_prop = Some(val.to_string());
i += 1;
} else if a == "--min-weight" {
let val = rest
.get(i + 1)
.copied()
.ok_or_else(|| "missing value for --min-weight".to_string())?;
min_weight = Some(
val.parse()
.map_err(|_| format!("--min-weight must be a number, got {val}"))?,
);
i += 2;
} else if let Some(val) = a.strip_prefix("--min-weight=") {
min_weight = Some(
val.parse()
.map_err(|_| format!("--min-weight must be a number, got {val}"))?,
);
i += 1;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(a));
i += 1;
} else {
return Err(format!("unexpected extra argument: {a}"));
}
}
let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
Ok(Command::Algo {
db_dir,
subcmd,
top,
dir,
edge_types,
weight_prop,
min_weight,
})
}
fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
match val.to_ascii_lowercase().as_str() {
"out" => Ok(AlgoDir::Out),
"in" => Ok(AlgoDir::In),
"both" => Ok(AlgoDir::Both),
other => Err(format!("--dir must be one of out | in | both, got {other}")),
}
}
pub fn run_map(db_dir: &Path, json: bool) -> Result<String, CliError> {
let db = open_for_reading(db_dir)?;
let map = repograph::repo_map(&db, &repograph::MapOptions::default());
if json {
let mut out = serde_json::to_string_pretty(&map)
.map_err(|e| CliError(format!("serialise map: {e}")))?;
out.push('\n');
return Ok(out);
}
Ok(repograph::render_map(&map))
}
pub fn run_brief(db_dir: &Path) -> Result<String, CliError> {
let db = open_for_reading(db_dir)?;
let report = repograph::brief(&db, &repograph::BriefOptions::default());
let code_graph = db.has_node(ingest_git::SYNC_KEY);
Ok(repograph::render_brief(
&report,
&reach_line(db_dir, code_graph),
))
}
fn reach_line(db_dir: &Path, code_graph: bool) -> String {
let bin = install::detect_mcp_command(None).shell();
let db = install::sh_quote(&db_dir.to_string_lossy());
let (tools, shell) = if code_graph {
(
format!("explore <target> (MCP tool){}or:", repograph::render::SEP),
format!("{bin} explore {db} <target>"),
)
} else {
(
format!(
"explain_association <a> <b>{sep}query '<cypher>' (MCP tools; add role: <name> \
or namespace: <ns> to narrow what it sees){sep}or:",
sep = repograph::render::SEP
),
format!("{bin} query {db} '<cypher>'"),
)
};
match install::delivery_for_store(db_dir) {
install::Delivery::Cli => shell,
_ => format!("{tools} {shell}"),
}
}
fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
if !db_dir.exists() {
return Err(CliError(format!("no store at {}", db_dir.display())));
}
Ok(GraphDb::open_with_options(
db_dir,
core_api::OpenOptions {
auto_migrate: false,
repair_wal: false,
read_only: true,
},
)?)
}
pub fn run_explore(
db_dir: &Path,
target: &str,
depth: repograph::Depth,
full: bool,
) -> Result<String, CliError> {
let db = open_for_reading(db_dir)?;
let report = repograph::explore(&db, None, target, depth, full);
Ok(repograph::render_explore(
&report,
repograph::DEFAULT_EXPLORE_BYTES,
))
}
pub fn run_context(db_dir: &Path, target: &str, full: bool) -> Result<String, CliError> {
let db = open_for_reading(db_dir)?;
Ok(repograph::render_context(&repograph::context_with(
&db,
None,
target,
&repograph::ContextOptions { source: full },
)))
}
pub fn run_impact(db_dir: &Path, files: &[String]) -> Result<String, CliError> {
let db = open_for_reading(db_dir)?;
let modified: BTreeSet<String> = files.iter().cloned().collect();
let report = repograph::impact(&db, files, &modified, &repograph::ImpactOptions::default());
Ok(repograph::render_impact(&report))
}
pub fn run_owners(db_dir: &Path, path: &str) -> Result<String, CliError> {
let db = open_for_reading(db_dir)?;
match repograph::owners(&db, path, None) {
Some(report) => Ok(repograph::render_owners(&report)),
None => Err(CliError(format!("no file in the store at {path}"))),
}
}
pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
let db = open_for_reading(db_dir)?;
Ok(repograph::render_why(&repograph::why(&db, a, b)))
}
#[allow(clippy::too_many_arguments)]
pub fn run_algo(
db_dir: &Path,
subcmd: &AlgoSubcmd,
top: usize,
dir: AlgoDir,
edge_types: Vec<String>,
weight_prop: Option<String>,
min_weight: Option<f64>,
) -> Result<String, CliError> {
let db = GraphDb::open(db_dir)?;
match subcmd {
AlgoSubcmd::Pagerank => {
let config = PageRankConfig {
direction: dir,
..PageRankConfig::default()
};
let report = db.pagerank(&config);
Ok(format_pagerank(&report, top))
}
AlgoSubcmd::Wcc => {
let config = WccConfig::default();
let report = db.connected_components(&config);
Ok(format_wcc(&report, top))
}
AlgoSubcmd::Degree => {
let config = DegreeConfig {
direction: dir,
..DegreeConfig::default()
};
let report = db.degree_centrality(&config);
Ok(format_degree(&report, top))
}
AlgoSubcmd::Communities => {
let config = LouvainConfig {
edge_types,
weight_prop,
min_weight,
..LouvainConfig::default()
};
let report = db.communities(&config);
Ok(format_communities(&report, top))
}
}
}
fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
let mut buf = String::new();
let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
let rows = if top == 0 {
report.scores.as_slice()
} else {
&report.scores[..top.min(report.scores.len())]
};
for (i, (key, score)) in rows.iter().enumerate() {
let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
}
buf
}
fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
let mut buf = String::new();
let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
let rows = if top == 0 {
report.components.as_slice()
} else {
&report.components[..top.min(report.components.len())]
};
for (key, comp_id) in rows {
let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
}
buf
}
fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
let mut buf = String::new();
let _ = writeln!(
buf,
"== degree centrality (truncated={}) ==",
report.truncated
);
let rows = if top == 0 {
report.scores.as_slice()
} else {
&report.scores[..top.min(report.scores.len())]
};
for (i, (key, deg)) in rows.iter().enumerate() {
let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
}
buf
}
fn format_communities(report: &core_api::CommunityReport, top: usize) -> String {
let mut buf = String::new();
let trunc = if report.truncated { " (truncated)" } else { "" };
let _ = writeln!(
buf,
"== communities (modularity={:.2}){trunc} ==",
report.modularity
);
let rows = if top == 0 {
report.communities.as_slice()
} else {
&report.communities[..top.min(report.communities.len())]
};
for c in rows {
let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
let _ = writeln!(
buf,
" {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
c.id,
c.members.len(),
c.cohesion,
preview.join(", ")
);
}
buf
}
fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
let mut db_dir = None;
let mut auto = false;
for a in args {
if *a == "--auto" {
auto = true;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_some() {
return Err(format!("unexpected extra argument: {a}"));
} else {
db_dir = Some(PathBuf::from(*a));
}
}
match (&db_dir, auto) {
(Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
(None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
_ => Ok((db_dir, auto)),
}
}
fn parse_mcp(args: &[&str]) -> Result<Command, String> {
let all_tools = args.contains(&"--all-tools");
let rest: Vec<&str> = args
.iter()
.copied()
.filter(|a| *a != "--all-tools")
.collect();
parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
db_dir,
auto,
all_tools,
})
}
fn parse_sync(args: &[&str]) -> Result<Command, String> {
let json = args.contains(&"--json");
let rest: Vec<&str> = args.iter().copied().filter(|a| *a != "--json").collect();
parse_dir_or_auto("sync", &rest).map(|(db_dir, auto)| Command::Sync { db_dir, auto, json })
}
fn parse_touch(args: &[&str]) -> Result<Command, String> {
let mut db_dir = None;
let mut auto = false;
let mut files = Vec::new();
for a in args {
if *a == "--auto" {
auto = true;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() && !auto {
db_dir = Some(PathBuf::from(*a));
} else {
files.push(PathBuf::from(*a));
}
}
if db_dir.is_none() && !auto {
return Err("touch requires <db-dir> or --auto".into());
}
if db_dir.is_some() && auto {
return Err("touch: --auto takes no <db-dir>".into());
}
Ok(Command::Touch {
db_dir,
auto,
files,
})
}
fn parse_positional(
cmd: &str,
args: &[&str],
min: usize,
max: usize,
) -> Result<(PathBuf, Vec<String>), String> {
let mut rest: Vec<String> = Vec::new();
let mut db_dir: Option<PathBuf> = None;
for a in args {
if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
}
match db_dir {
None => db_dir = Some(PathBuf::from(*a)),
Some(_) => rest.push((*a).to_string()),
}
}
let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
if rest.len() < min {
return Err(format!(
"{cmd} requires <db-dir> and {min} more argument{}",
if min == 1 { "" } else { "s" }
));
}
if rest.len() > max {
return Err(format!("unexpected extra argument: {}", rest[max]));
}
Ok((db_dir, rest))
}
fn parse_explore(args: &[&str]) -> Result<Command, String> {
let mut rest: Vec<String> = Vec::new();
let mut db_dir: Option<PathBuf> = None;
let mut depth = repograph::Depth::Context;
let mut full = false;
let mut want_depth = false;
for a in args {
if want_depth {
depth = repograph::Depth::parse(a).ok_or_else(|| {
format!(
"--depth must be one of {}, got {a}",
repograph::Depth::NAMES.join(" | ")
)
})?;
want_depth = false;
} else if *a == "--depth" {
want_depth = true;
} else if *a == "--full" {
full = true;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(*a));
} else {
rest.push((*a).to_string());
}
}
if want_depth {
return Err("--depth requires a value".to_string());
}
let db_dir = db_dir.ok_or_else(|| "explore requires <db-dir>".to_string())?;
match rest.len() {
0 => Err("explore requires <db-dir> and 1 more argument".to_string()),
1 => Ok(Command::Explore {
db_dir,
target: rest.remove(0),
depth,
full,
}),
_ => Err(format!("unexpected extra argument: {}", rest[1])),
}
}
fn parse_context(args: &[&str]) -> Result<Command, String> {
let mut rest: Vec<String> = Vec::new();
let mut db_dir: Option<PathBuf> = None;
let mut full = false;
for a in args {
if *a == "--full" {
full = true;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_none() {
db_dir = Some(PathBuf::from(*a));
} else {
rest.push((*a).to_string());
}
}
let db_dir = db_dir.ok_or_else(|| "context requires <db-dir>".to_string())?;
match rest.len() {
0 => Err("context requires <db-dir> and 1 more argument".to_string()),
1 => Ok(Command::Context {
db_dir,
target: rest.remove(0),
full,
}),
_ => Err(format!("unexpected extra argument: {}", rest[1])),
}
}
fn parse_dir_with_json(cmd: &str, args: &[&str]) -> Result<(PathBuf, bool), String> {
let mut db_dir = None;
let mut json = false;
for a in args {
if *a == "--json" {
json = true;
} else if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
} else if db_dir.is_some() {
return Err(format!("unexpected extra argument: {a}"));
} else {
db_dir = Some(PathBuf::from(*a));
}
}
let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
Ok((db_dir, json))
}
fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
let mut db_dir = None;
for a in args {
if a.starts_with('-') {
return Err(format!("unexpected flag: {a}"));
}
if db_dir.is_some() {
return Err(format!("unexpected extra argument: {a}"));
}
db_dir = Some(PathBuf::from(*a));
}
db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
}
pub fn format_stats(stats: &Stats) -> String {
let mut out = String::new();
let _ = writeln!(
out,
"nodes: {} live, {} tombstoned",
stats.nodes_live, stats.nodes_tombstoned
);
let _ = writeln!(out, "edges: {}", stats.edges);
if stats.history_floor == 0 {
let _ = writeln!(out, "history: complete (nothing pruned)");
} else {
let _ = writeln!(
out,
"history: reaches back to commit {}",
stats.history_floor
);
}
if stats.namespaces.len() > 1 {
let names: Vec<String> = stats
.namespaces
.iter()
.map(|n| format!("{} ({})", n.name, n.nodes_live))
.collect();
let _ = writeln!(out, "namespaces: {}", names.join(", "));
}
let _ = writeln!(out, "rules: {}", stats.rules.len());
for r in &stats.rules {
let _ = writeln!(
out,
" {:<28} edges={} tripped={}",
r.name, r.edges, r.tripped
);
}
out
}
pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
let db = SharedDb::open(dir)?;
let stats = db.read().stats();
Ok(stats)
}
pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
refuse_non_empty(dir)?;
let db = SharedDb::open(dir)?;
let opts = IngestOptions::default();
let mut auto_fk_rules = Vec::new();
{
let mut w = db.write();
for (label, json) in [
("Org", org_json()),
("Project", project_json()),
("Person", person_json()),
] {
let report = w.ingest_json(label, &json, &opts)?;
if !report.row_errors.is_empty() {
return Err(CliError(format!(
"demo ingest of {label} had row errors: {:?}",
report.row_errors
)));
}
auto_fk_rules.extend(report.rules_created);
}
let skill_fit = Predicate::Overlap {
field: "skills".into(),
min: 0.5,
};
let skill_fit_k = Some(default_max_edges(&skill_fit));
w.create_rule(RuleDef {
name: "skill_fit".into(),
src_label: "Person".into(),
dst_label: "Project".into(),
predicate: skill_fit,
edge_type: "FIT".into(),
weight_prop: Some("score".into()),
max_edges: skill_fit_k,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})?;
let founded_within = Predicate::NumericWithin {
field: "founded_year".into(),
tolerance: 2.0,
};
let founded_within_k = Some(default_max_edges(&founded_within));
w.create_rule(RuleDef {
name: "founded_within".into(),
src_label: "Org".into(),
dst_label: "Org".into(),
predicate: founded_within,
edge_type: "FOUNDED_WITHIN".into(),
weight_prop: Some("score".into()),
max_edges: founded_within_k,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})?;
let nearby_office = Predicate::GeoRadius {
field: "office".into(),
km: 50.0,
};
let nearby_office_k = Some(default_max_edges(&nearby_office));
w.create_rule(RuleDef {
name: "nearby_office".into(),
src_label: "Org".into(),
dst_label: "Org".into(),
predicate: nearby_office,
edge_type: "NEARBY_OFFICE".into(),
weight_prop: Some("score".into()),
max_edges: nearby_office_k,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})?;
let similar_interests = Predicate::VectorSimilar {
field: "embedding".into(),
min: 0.8,
};
let similar_interests_k = Some(default_max_edges(&similar_interests));
w.create_rule(RuleDef {
name: "similar_interests".into(),
src_label: "Person".into(),
dst_label: "Person".into(),
predicate: similar_interests,
edge_type: "SIMILAR".into(),
weight_prop: Some("score".into()),
max_edges: similar_interests_k,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
})?;
for (label, field) in [("Org", "name"), ("Project", "name"), ("Person", "name")] {
w.enable_fulltext(label, field)?;
}
}
let r = db.read();
let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
let stats = r.stats();
let suggestion = r.suggest_rules().into_iter().next();
Ok(DemoOutcome {
auto_fk_rules,
sample_query: SAMPLE_QUERY.to_string(),
sample_result,
explanations,
stats,
suggestion,
})
}
fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
if dir.is_file() {
return Err(CliError(format!(
"demo refuses a non-empty directory: {} is a file",
dir.display()
)));
}
if !dir.exists() {
return Ok(true);
}
Ok(std::fs::read_dir(dir)?.next().is_none())
}
fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
if dir_is_empty_or_absent(dir)? {
Ok(())
} else {
Err(CliError(format!(
"demo refuses a non-empty directory: {} \
(directory must be empty — including hidden files)",
dir.display()
)))
}
}
pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
if dir_is_empty_or_absent(dir)? {
Ok(Some(run_demo(dir)?))
} else {
Ok(None)
}
}
fn json_array(rows: impl IntoIterator<Item = String>) -> String {
let mut out = String::from("[");
let mut first = true;
for row in rows {
if !first {
out.push(',');
}
first = false;
out.push_str(&row);
}
out.push(']');
out
}
fn wrap_proj(i: usize) -> usize {
(i - 1) % N_PROJECTS + 1
}
fn skill_window_json(start: usize, len: usize) -> String {
let parts: Vec<String> = (0..len)
.map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
.collect();
format!("[{}]", parts.join(","))
}
fn org_office(i: usize) -> (f64, f64) {
match i {
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"),
}
}
fn person_embedding_json(i: usize) -> String {
let mut v = [0.0_f64; 8];
match i {
9 | 19 | 29 => {
v[0] = 0.8;
v[1] = 0.6;
}
10 | 20 | 30 => {
v[0] = 0.6;
v[1] = 0.8;
}
_ => {
let axis = (i - 1) % 10;
debug_assert!(axis < 8);
v[axis] = 1.0;
}
}
let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
format!("[{}]", parts.join(","))
}
fn org_json() -> String {
json_array((1..=N_ORGS).map(|i| {
let year = 2010 + (i as i64 - 1);
let (lat, lon) = org_office(i);
format!(
r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
skill_window_json(i, 3)
)
}))
}
fn project_json() -> String {
json_array((1..=N_PROJECTS).map(|i| {
let org = (i - 1) % N_ORGS + 1;
format!(
r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
skill_window_json(i, 3)
)
}))
}
fn person_json() -> String {
json_array((1..=N_PEOPLE).map(|i| {
let org = (i - 1) % N_ORGS + 1;
let proj = (i - 1) % N_PROJECTS + 1;
format!(
r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
person_embedding_json(i),
skill_window_json(proj, 3)
)
}))
}
pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
let mut buf = String::new();
let _ = writeln!(buf, "== demo ==");
let _ = writeln!(
buf,
"ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
);
let _ = writeln!(
buf,
"overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
);
let _ = writeln!(
buf,
"numeric rule: founded_within (Org.founded_year, tolerance 2)"
);
let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
let _ = writeln!(
buf,
"vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
);
let _ = writeln!(buf);
let _ = writeln!(buf, "== auto-FK rules ==");
let mut names = out.auto_fk_rules.clone();
names.sort();
for name in names {
let _ = writeln!(buf, " {name}");
}
let _ = writeln!(buf);
let _ = writeln!(buf, "== query ==");
let _ = writeln!(buf, "{}", out.sample_query);
let _ = writeln!(buf);
let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
for i in 0..out.sample_result.len() {
let cells: Vec<String> = out
.sample_result
.columns()
.iter()
.map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
.collect();
let _ = writeln!(buf, " {}", cells.join(" "));
}
let _ = writeln!(buf);
let _ = writeln!(
buf,
"== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
);
for e in &out.explanations {
let weight = e
.weight
.map(|w| fmt_value(&Value::Float(w)))
.unwrap_or_else(|| "none".into());
let _ = writeln!(
buf,
" rule={} type={} {}→{} weight={}",
e.rule, e.edge_type, e.src_key, e.dst_key, weight
);
}
let _ = writeln!(buf);
let _ = writeln!(buf, "== serve ==");
let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
if let Some(s) = &out.suggestion {
let _ = writeln!(buf);
let _ = writeln!(buf, "== suggested rule (teaser) ==");
let _ = writeln!(buf, " {}", s.def.name);
let _ = writeln!(
buf,
" {} → {} via {:?}",
s.def.src_label, s.def.dst_label, s.def.predicate
);
let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
let _ = writeln!(buf, " {}", s.rationale);
let _ = writeln!(
buf,
" (run `mushroomdb suggest {}` for full analysis)",
dir.display()
);
}
buf
}
pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
let db = GraphDb::open(dir)?;
Ok(db.suggest_rules())
}
pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
let mut buf = String::new();
if suggestions.is_empty() {
let _ = writeln!(
buf,
"no rule suggestions (database may be empty or rules already cover all patterns)"
);
return buf;
}
let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
for (i, s) in suggestions.iter().enumerate() {
let _ = writeln!(buf);
let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
let _ = writeln!(
buf,
" {} → {} via {:?}",
s.def.src_label, s.def.dst_label, s.def.predicate
);
let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
let _ = writeln!(buf, " rationale : {}", s.rationale);
if !s.examples.is_empty() {
let _ = writeln!(buf, " examples :");
for (src, dst, score) in &s.examples {
let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
}
}
let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
let _ = writeln!(
buf,
" to apply : POST /rules or db.create_rule(suggestion.def)"
);
}
buf
}
fn fmt_value(v: &Value) -> String {
match v {
Value::Int(i) => i.to_string(),
Value::Float(f) => {
let s = format!("{f}");
if s.contains('.') || s.contains('e') || s.contains('E') {
s
} else {
format!("{s}.0")
}
}
Value::Str(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::List(xs) => {
let inner: Vec<String> = xs.iter().map(fmt_value).collect();
format!("[{}]", inner.join(", "))
}
Value::Map(m) => {
let inner: Vec<String> = m
.iter()
.map(|(k, v)| format!("{k}: {}", fmt_value(v)))
.collect();
format!("{{{}}}", inner.join(", "))
}
}
}
fn fmt_cell(cell: Option<&Value>) -> String {
match cell {
None => "null".into(),
Some(v) => fmt_value(v),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::net::SocketAddr;
use std::path::PathBuf;
fn tmp(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let d = std::env::temp_dir().join(format!(
"graphdb-cli-{}-{}-{}",
name,
std::process::id(),
nanos
));
let _ = std::fs::remove_dir_all(&d);
d
}
fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
let g = db.read();
let mut out = BTreeSet::new();
for i in 1..=N_ORGS {
let src = format!("org-{i:02}");
if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
for dst in nbrs {
out.insert((src.clone(), dst));
}
}
}
for i in 1..=N_PEOPLE {
let src = format!("person-{i:02}");
if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
for dst in nbrs {
out.insert((src.clone(), dst));
}
}
}
out
}
fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
let hits: Vec<_> = db
.read()
.explain(a, b)
.expect("explain")
.into_iter()
.filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
.collect();
assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
let got = hits[0].weight.expect("weighted");
assert!(
(got - want).abs() < 1e-12,
"{rule} {a}→{b}: got {got} want {want}"
);
}
fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
const R: f64 = 6371.0088;
let phi1 = lat1.to_radians();
let phi2 = lat2.to_radians();
let dphi = (lat2 - lat1).to_radians();
let dlam = (lon2 - lon1).to_radians();
let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
.clamp(0.0, 1.0);
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
R * c
}
fn default_bind() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], 8080))
}
#[test]
fn parse_args_table() {
struct Case {
args: &'static [&'static str],
check: fn(Result<Command, String>),
}
let cases = [
Case {
args: &[],
check: |r| match r {
Ok(Command::Help) => {}
other => panic!("no-args → Help, got {other:?}"),
},
},
Case {
args: &["--help"],
check: |r| match r {
Ok(Command::Help) => {}
other => panic!("--help → Help, got {other:?}"),
},
},
Case {
args: &["-h"],
check: |r| match r {
Ok(Command::Help) => {}
other => panic!("-h → Help, got {other:?}"),
},
},
Case {
args: &["serve", "/tmp/demo-db"],
check: |r| match r {
Ok(Command::Serve {
db_dir,
addr,
ui,
demo_if_empty,
token,
role_tokens,
snapshot_every,
restore_from,
tls_cert,
tls_key,
}) => {
assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
assert_eq!(addr, default_bind());
assert_eq!(ui, super::ServeUi::Embedded);
assert!(!demo_if_empty);
assert_eq!(token, None);
assert!(role_tokens.is_empty());
assert_eq!(snapshot_every, None);
assert_eq!(restore_from, None);
assert_eq!(tls_cert, None);
assert_eq!(tls_key, None);
}
other => panic!("serve <dir> → Serve default addr, got {other:?}"),
},
},
Case {
args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
check: |r| match r {
Ok(Command::Serve {
db_dir,
addr,
ui,
demo_if_empty,
token,
role_tokens,
snapshot_every,
restore_from,
tls_cert,
tls_key,
}) => {
assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
assert_eq!(
addr,
"127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
);
assert_eq!(ui, super::ServeUi::Embedded);
assert!(!demo_if_empty);
assert_eq!(token, None);
assert!(role_tokens.is_empty());
assert_eq!(snapshot_every, None);
assert_eq!(restore_from, None);
assert_eq!(tls_cert, None);
assert_eq!(tls_key, None);
}
other => panic!("serve --addr after dir, got {other:?}"),
},
},
Case {
args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
check: |r| match r {
Ok(Command::Serve {
db_dir,
addr,
ui,
demo_if_empty,
token,
role_tokens,
snapshot_every,
restore_from,
tls_cert,
tls_key,
}) => {
assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
assert_eq!(
addr,
"127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
);
assert_eq!(ui, super::ServeUi::Embedded);
assert!(!demo_if_empty);
assert_eq!(token, None);
let _ = role_tokens; assert_eq!(snapshot_every, None);
assert_eq!(restore_from, None);
assert_eq!(tls_cert, None);
assert_eq!(tls_key, None);
}
other => panic!("serve --addr=VALUE, got {other:?}"),
},
},
Case {
args: &["mcp", "/tmp/demo-db"],
check: |r| match r {
Ok(Command::Mcp {
db_dir,
auto,
all_tools,
}) => {
assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
assert!(!auto);
assert!(!all_tools, "the short list is the default");
}
other => panic!("mcp <dir>, got {other:?}"),
},
},
Case {
args: &["stats", "/tmp/demo-db"],
check: |r| match r {
Ok(Command::Stats { db_dir }) => {
assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
}
other => panic!("stats <dir>, got {other:?}"),
},
},
Case {
args: &["demo", "/tmp/demo-db"],
check: |r| match r {
Ok(Command::Demo { db_dir }) => {
assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
}
other => panic!("demo <dir>, got {other:?}"),
},
},
Case {
args: &["context", "db", "x"],
check: |r| match r {
Ok(Command::Context {
db_dir,
target,
full,
}) => {
assert_eq!(db_dir, PathBuf::from("db"));
assert_eq!(target, "x");
assert!(!full, "the default answer is a pointer, not a body");
}
other => panic!("context <dir> <target>, got {other:?}"),
},
},
Case {
args: &["context", "db", "x", "--full"],
check: |r| {
assert_eq!(
r.unwrap(),
Command::Context {
db_dir: PathBuf::from("db"),
target: "x".into(),
full: true,
}
);
},
},
Case {
args: &["explore", "db", "x", "--depth", "impact"],
check: |r| {
assert_eq!(
r.unwrap(),
Command::Explore {
db_dir: PathBuf::from("db"),
target: "x".into(),
depth: repograph::Depth::Impact,
full: false,
}
);
},
},
Case {
args: &["serve"],
check: |r| {
let e = r.expect_err("serve without dir");
assert!(
e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
"missing-dir error should mention dir, got {e}"
);
},
},
Case {
args: &["mcp"],
check: |r| {
let e = r.expect_err("mcp without dir");
assert!(
e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
"missing-dir error should mention dir, got {e}"
);
},
},
Case {
args: &["stats"],
check: |r| {
let e = r.expect_err("stats without dir");
assert!(
e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
"missing-dir error should mention dir, got {e}"
);
},
},
Case {
args: &["demo"],
check: |r| {
let e = r.expect_err("demo without dir");
assert!(
e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
"missing-dir error should mention dir, got {e}"
);
},
},
Case {
args: &["serve", "/tmp/demo-db", "--addr"],
check: |r| {
let e = r.expect_err("--addr missing value");
assert!(
e.to_lowercase().contains("addr"),
"--addr missing value should mention addr, got {e}"
);
},
},
Case {
args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
check: |r| {
let e = r.expect_err("invalid addr");
assert!(
e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
"invalid addr should mention address, got {e}"
);
},
},
Case {
args: &["frobnicate", "/tmp/demo-db"],
check: |r| {
let e = r.expect_err("unknown command");
assert!(
e.to_lowercase().contains("unknown")
|| e.to_lowercase().contains("frobnicate"),
"unknown command should name it, got {e}"
);
},
},
Case {
args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
check: |r| match r {
Ok(Command::Serve { ui, .. }) => {
assert_eq!(
ui,
super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
);
}
other => panic!("serve --ui <dir>, got {other:?}"),
},
},
Case {
args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
check: |r| match r {
Ok(Command::Serve { ui, .. }) => {
assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
}
other => panic!("serve --ui=VALUE, got {other:?}"),
},
},
Case {
args: &["serve", "/tmp/demo-db", "--ui"],
check: |r| {
let e = r.expect_err("--ui missing value");
assert!(
e.to_lowercase().contains("ui"),
"--ui missing value should mention ui, got {e}"
);
},
},
Case {
args: &["serve", "/tmp/demo-db", "--no-ui"],
check: |r| match r {
Ok(Command::Serve { ui, .. }) => {
assert_eq!(ui, super::ServeUi::None);
}
other => panic!("serve --no-ui, got {other:?}"),
},
},
Case {
args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
check: |r| {
let e = r.expect_err("combine --ui and --no-ui");
assert!(
e.contains("--ui") && e.contains("--no-ui"),
"conflict should name both flags, got {e}"
);
},
},
Case {
args: &["serve", "/tmp/demo-db", "extra"],
check: |r| {
let e = r.expect_err("extra positional");
assert!(
e.to_lowercase().contains("unexpected")
|| e.to_lowercase().contains("extra"),
"extra arg should be rejected, got {e}"
);
},
},
Case {
args: &[
"serve",
"/data",
"--addr",
"0.0.0.0:8080",
"--demo-if-empty",
],
check: |r| match r {
Ok(Command::Serve {
db_dir,
addr,
demo_if_empty,
ui,
token,
snapshot_every,
..
}) => {
assert_eq!(db_dir, PathBuf::from("/data"));
assert_eq!(
addr,
"0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
);
assert!(demo_if_empty);
assert_eq!(ui, super::ServeUi::Embedded);
assert_eq!(token, None);
assert_eq!(snapshot_every, None);
}
other => panic!("serve --demo-if-empty docker default, got {other:?}"),
},
},
Case {
args: &["install", "--project", "--delivery", "cli"],
check: |r| match r {
Ok(Command::Install(opts)) => {
assert_eq!(opts.scope, Some(install::Scope::Project));
assert_eq!(opts.delivery, install::Delivery::Cli);
}
other => panic!("install --delivery cli, got {other:?}"),
},
},
Case {
args: &["install", "--delivery=mcp"],
check: |r| match r {
Ok(Command::Install(opts)) => {
assert_eq!(opts.delivery, install::Delivery::Mcp)
}
other => panic!("install --delivery=mcp, got {other:?}"),
},
},
Case {
args: &["install"],
check: |r| match r {
Ok(Command::Install(opts)) => {
assert_eq!(opts.delivery, install::Delivery::Both)
}
other => panic!("install, got {other:?}"),
},
},
Case {
args: &["install", "--delivery", "sideways"],
check: |r| match r {
Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
other => panic!("a bad --delivery must be refused, got {other:?}"),
},
},
Case {
args: &["install", "--intercept-grep"],
check: |r| match r {
Ok(Command::Install(opts)) => assert!(opts.intercept_grep),
other => panic!("install --intercept-grep, got {other:?}"),
},
},
Case {
args: &[
"install",
"--impact-before-edit",
"--enrich-grep",
"--always-load",
],
check: |r| match r {
Ok(Command::Install(opts)) => {
assert!(opts.impact_before_edit);
assert!(opts.enrich_grep);
assert!(opts.always_load);
}
other => panic!("install with the code-door flags, got {other:?}"),
},
},
Case {
args: &["install", "--delivery", "mcp", "--db", "./mem"],
check: |r| match r {
Ok(Command::Install(opts)) => assert!(opts.always_load),
other => panic!("install --delivery mcp --db, got {other:?}"),
},
},
Case {
args: &["install", "--delivery", "both", "--db=./mem"],
check: |r| match r {
Ok(Command::Install(opts)) => assert!(opts.always_load),
other => panic!("install --delivery both --db, got {other:?}"),
},
},
Case {
args: &[
"install",
"--delivery",
"mcp",
"--db",
"./mem",
"--no-always-load",
],
check: |r| match r {
Ok(Command::Install(opts)) => assert!(!opts.always_load),
other => panic!("install --no-always-load, got {other:?}"),
},
},
Case {
args: &["install", "--delivery", "cli", "--db", "./mem"],
check: |r| match r {
Ok(Command::Install(opts)) => assert!(!opts.always_load),
other => panic!("install --delivery cli --db, got {other:?}"),
},
},
Case {
args: &["install", "--always-load"],
check: |r| match r {
Ok(Command::Install(opts)) => assert!(opts.always_load),
other => panic!("install --always-load, got {other:?}"),
},
},
Case {
args: &["install"],
check: |r| match r {
Ok(Command::Install(opts)) => {
assert!(!opts.intercept_grep);
assert!(!opts.impact_before_edit);
assert!(!opts.enrich_grep);
assert!(!opts.always_load);
}
other => panic!("install, got {other:?}"),
},
},
Case {
args: &["impact-hook", "--auto"],
check: |r| match r {
Ok(Command::ImpactHook { db_dir, auto }) => {
assert!(db_dir.is_none() && auto);
}
other => panic!("impact-hook --auto, got {other:?}"),
},
},
Case {
args: &["enrich", "/tmp/db"],
check: |r| match r {
Ok(Command::Enrich { db_dir, auto }) => {
assert_eq!(db_dir.as_deref(), Some(Path::new("/tmp/db")));
assert!(!auto);
}
other => panic!("enrich /tmp/db, got {other:?}"),
},
},
Case {
args: &["enrich"],
check: |r| match r {
Err(e) => assert!(e.contains("enrich requires <db-dir> or --auto"), "{e}"),
other => panic!("enrich with no store, got {other:?}"),
},
},
Case {
args: &["intercept", "--auto"],
check: |r| match r {
Ok(Command::Intercept { db_dir, auto }) => {
assert_eq!(db_dir, None);
assert!(auto);
}
other => panic!("intercept --auto, got {other:?}"),
},
},
Case {
args: &["intercept", "/tmp/db"],
check: |r| match r {
Ok(Command::Intercept { db_dir, auto }) => {
assert_eq!(db_dir, Some(PathBuf::from("/tmp/db")));
assert!(!auto);
}
other => panic!("intercept /tmp/db, got {other:?}"),
},
},
Case {
args: &["intercept"],
check: |r| match r {
Err(e) => assert!(e.contains("intercept requires <db-dir> or --auto"), "{e}"),
other => panic!("intercept with no store, got {other:?}"),
},
},
];
for case in &cases {
(case.check)(parse_args(case.args));
}
}
#[test]
fn serve_default_addr_is_loopback_8080() {
match parse_args(&["serve", "/tmp/db"]).unwrap() {
Command::Serve { addr, .. } => {
assert_eq!(
addr,
"127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn serve_snapshot_every_parses_seconds() {
match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
Command::Serve { snapshot_every, .. } => {
assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
}
other => panic!("{other:?}"),
}
match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
Command::Serve { snapshot_every, .. } => {
assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
}
other => panic!("{other:?}"),
}
match parse_args(&["serve", "/tmp/db"]).unwrap() {
Command::Serve { snapshot_every, .. } => {
assert_eq!(snapshot_every, None);
}
other => panic!("{other:?}"),
}
let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
assert!(
err.contains("snapshot-every"),
"missing value should name the flag, got {err}"
);
let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
assert!(
err.contains("snapshot-every"),
"zero should be rejected, got {err}"
);
let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
assert!(
err.contains("snapshot-every"),
"invalid value should name the flag, got {err}"
);
}
#[test]
fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
match parse_args(&[
"serve",
"/tmp/db",
"--addr",
"0.0.0.0:8080",
"--token",
"s3cret",
])
.unwrap()
{
Command::Serve { token, addr, .. } => {
assert_eq!(token.as_deref(), Some("s3cret"));
assert_eq!(addr.ip().to_string(), "0.0.0.0");
}
other => panic!("{other:?}"),
}
}
#[test]
fn parse_build_index_both_forms_and_a_missing_value() {
match parse_args(&["build-index", "/tmp/db"]).unwrap() {
Command::BuildIndex { db_dir, rule } => {
assert_eq!(db_dir, PathBuf::from("/tmp/db"));
assert_eq!(rule, None);
}
other => panic!("{other:?}"),
}
for args in [
vec!["build-index", "/tmp/db", "--rule", "sim"],
vec!["build-index", "/tmp/db", "--rule=sim"],
] {
match parse_args(&args).unwrap() {
Command::BuildIndex { db_dir, rule } => {
assert_eq!(db_dir, PathBuf::from("/tmp/db"));
assert_eq!(rule.as_deref(), Some("sim"), "{args:?}");
}
other => panic!("{other:?}"),
}
}
assert_eq!(
parse_args(&["build-index", "/tmp/db", "--rule"]).unwrap_err(),
"--rule requires a value"
);
assert_eq!(
parse_args(&["build-index", "/tmp/db", "--rule="]).unwrap_err(),
"--rule requires a value"
);
assert_eq!(
parse_args(&["build-index"]).unwrap_err(),
"build-index requires <db-dir>"
);
assert!(usage().contains("mushroomdb build-index <db-dir> [--rule <name>]"));
}
#[test]
fn parse_snapshot_and_query() {
for (args, want) in [
(vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
(
vec!["snapshot", "/tmp/db", "--archive-wal"],
WalDisposition::Archive,
),
(
vec!["snapshot", "/tmp/db", "--keep-wal"],
WalDisposition::Keep,
),
(
vec!["snapshot", "/tmp/db", "--truncate"],
WalDisposition::Truncate,
),
] {
match parse_args(&args).unwrap() {
Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
other => panic!("{other:?}"),
}
}
match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
other => panic!("{other:?}"),
}
match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
other => panic!("{other:?}"),
}
match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
other => panic!("{other:?}"),
}
let text = usage();
assert!(
text.contains("query"),
"usage should mention query, got:\n{text}"
);
assert!(
text.contains("snapshot"),
"usage should mention snapshot, got:\n{text}"
);
}
#[test]
fn usage_lists_every_subcommand() {
let text = usage();
for word in [
"serve",
"mcp",
"stats",
"demo",
"query",
"snapshot",
"--keep-wal",
"mushroomdb",
"--ui",
"--no-ui",
"--demo-if-empty",
"--token",
"--snapshot-every",
] {
assert!(
text.contains(word),
"usage should mention {word}, got:\n{text}"
);
}
}
#[test]
fn validate_ui_dir_requires_index_html() {
let missing = tmp("ui-missing");
let err = super::validate_ui_dir(&missing).expect_err("missing dir");
assert!(
err.contains("does not exist"),
"missing dir error, got {err}"
);
let empty = tmp("ui-empty");
std::fs::create_dir_all(&empty).unwrap();
let err = super::validate_ui_dir(&empty).expect_err("no index");
assert!(
err.contains("index.html"),
"missing index.html error, got {err}"
);
let ok = tmp("ui-ok");
std::fs::create_dir_all(&ok).unwrap();
std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
let got = super::validate_ui_dir(&ok).expect("valid ui dir");
assert_eq!(got, ok);
}
#[test]
fn maybe_run_demo_if_empty_seeds_then_skips() {
let dir = tmp("boot-empty");
let first = super::maybe_run_demo_if_empty(&dir)
.expect("empty dir demos")
.expect("Some(DemoOutcome)");
assert_eq!(first.stats.nodes_live, 60);
let db = SharedDb::open(&dir).expect("reopen");
assert!(db.read().has_node("person-01"));
let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
assert!(
second.is_none(),
"second boot must not re-demo a populated volume"
);
let occupied = tmp("boot-occupied");
std::fs::create_dir_all(&occupied).unwrap();
std::fs::write(occupied.join("keep-me"), b"x").unwrap();
let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
assert!(skipped.is_none());
assert_eq!(
std::fs::read(occupied.join("keep-me")).unwrap(),
b"x",
"existing volume contents must be untouched"
);
}
#[test]
fn demo_builder_is_deterministic_and_refuses_second_run() {
let dir = tmp("demo");
let out = run_demo(&dir).expect("first demo run");
assert_eq!(
out.stats.nodes_live, 60,
"10 orgs + 20 projects + 30 people"
);
assert_eq!(out.stats.nodes_tombstoned, 0);
assert_eq!(out.stats.edges, 334);
assert_eq!(
out.stats.rules.len(),
7,
"3 auto-FK + overlap + numeric + geo + vector"
);
let fit = out
.stats
.rules
.iter()
.find(|r| r.name == "skill_fit")
.expect("skill_fit");
assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
let founded = out
.stats
.rules
.iter()
.find(|r| r.name == "founded_within")
.expect("founded_within");
assert_eq!(founded.edges, 34);
let nearby = out
.stats
.rules
.iter()
.find(|r| r.name == "nearby_office")
.expect("nearby_office");
assert_eq!(nearby.edges, 16);
let similar = out
.stats
.rules
.iter()
.find(|r| r.name == "similar_interests")
.expect("similar_interests");
assert_eq!(similar.edges, 114);
let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
names.sort_unstable();
assert_eq!(
names,
vec![
"auto_fk_person_org_id",
"auto_fk_person_project_id",
"auto_fk_project_org_id",
"founded_within",
"nearby_office",
"similar_interests",
"skill_fit",
]
);
let db = SharedDb::open(&dir).expect("reopen demo");
assert_eq!(
db.read().fulltext_pairs(),
vec![
("Org".to_string(), "name".to_string()),
("Person".to_string(), "name".to_string()),
("Project".to_string(), "name".to_string()),
]
);
let mut auto = out.auto_fk_rules.clone();
auto.sort();
assert_eq!(
auto,
vec![
"auto_fk_person_org_id".to_string(),
"auto_fk_person_project_id".to_string(),
"auto_fk_project_org_id".to_string(),
]
);
assert!(
!out.sample_result.is_empty(),
"sample Cypher query must return rows"
);
assert!(
out.sample_query.contains("ORDER BY score DESC"),
"sample query must rank by score, got {}",
out.sample_query
);
let scores: Vec<f64> = (0..out.sample_result.len())
.map(|i| match out.sample_result.get(i, "score") {
Some(Value::Float(f)) => *f,
other => panic!("score col should be Float, got {other:?}"),
})
.collect();
let distinct: std::collections::BTreeSet<u64> =
scores.iter().map(|s| s.to_bits()).collect();
assert!(
distinct.len() >= 2,
"sample results must be visibly ranked, got {scores:?}"
);
for w in scores.windows(2) {
assert!(
w[0] >= w[1],
"scores must be non-increasing, got {scores:?}"
);
}
assert!(
!out.explanations.is_empty(),
"explain(person-01, proj-01) must find the derived edges"
);
let db = SharedDb::open(&dir).expect("reopen demo");
assert_eq!(
directed_pairs(&db, "FOUNDED_WITHIN"),
[
("org-01", "org-02"),
("org-01", "org-03"),
("org-02", "org-01"),
("org-02", "org-03"),
("org-02", "org-04"),
("org-03", "org-01"),
("org-03", "org-02"),
("org-03", "org-04"),
("org-03", "org-05"),
("org-04", "org-02"),
("org-04", "org-03"),
("org-04", "org-05"),
("org-04", "org-06"),
("org-05", "org-03"),
("org-05", "org-04"),
("org-05", "org-06"),
("org-05", "org-07"),
("org-06", "org-04"),
("org-06", "org-05"),
("org-06", "org-07"),
("org-06", "org-08"),
("org-07", "org-05"),
("org-07", "org-06"),
("org-07", "org-08"),
("org-07", "org-09"),
("org-08", "org-06"),
("org-08", "org-07"),
("org-08", "org-09"),
("org-08", "org-10"),
("org-09", "org-07"),
("org-09", "org-08"),
("org-09", "org-10"),
("org-10", "org-08"),
("org-10", "org-09"),
]
.into_iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect::<BTreeSet<_>>()
);
assert_eq!(
directed_pairs(&db, "NEARBY_OFFICE"),
[
("org-01", "org-07"),
("org-01", "org-10"),
("org-02", "org-09"),
("org-03", "org-08"),
("org-04", "org-05"),
("org-04", "org-06"),
("org-05", "org-04"),
("org-05", "org-06"),
("org-06", "org-04"),
("org-06", "org-05"),
("org-07", "org-01"),
("org-07", "org-10"),
("org-08", "org-03"),
("org-09", "org-02"),
("org-10", "org-01"),
("org-10", "org-07"),
]
.into_iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect::<BTreeSet<_>>()
);
assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
let err = run_demo(&dir).expect_err("second run into the same dir");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
"refuse message must mention non-empty dir, got {err}"
);
assert!(
msg.contains("hidden"),
"refuse message must mention hidden files, got {err}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_snapshot_writes_snapshot_bin() {
let dir = tmp("snapshot-cli");
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node("Person", "alice", vec![]).expect("insert");
}
assert!(
!dir.join("snapshot.bin").exists(),
"GraphDb Drop must not snapshot"
);
let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
assert!(
dir.join("snapshot.bin").is_file(),
"run_snapshot must write snapshot.bin"
);
assert!(
out.contains("snapshot.bin"),
"snapshot output should mention snapshot.bin, got {out}"
);
let db = GraphDb::open(&dir).expect("reopen");
assert!(db.has_node("alice"), "reopen after snapshot must recover");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
let dir = tmp("snapshot-archive");
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node("Person", "alice", vec![]).expect("insert");
}
let before = core_api::wal_commit_count_at(&dir).expect("count");
assert!(before > 0, "the insert is a commit");
{
let shared = SharedDb::open(&dir).expect("open");
snapshot_shared(&shared).expect("snapshot");
}
let archives = || {
std::fs::read_dir(&dir)
.expect("read dir")
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
.count()
};
assert_eq!(archives(), 1, "the WAL was archived, not dropped");
assert!(
dir.join("wal.genesis").is_file(),
"the genesis marker is what lets asof reach an archived commit"
);
{
let db = GraphDb::open(&dir).expect("reopen");
assert!(db.has_node("alice"));
assert!(
!db.node_history("alice").expect("history").items.is_empty(),
"the insert is still explainable"
);
}
assert!(
GraphDb::open_at(&dir, before - 1).is_ok(),
"asof still reaches a commit the snapshot folded in"
);
run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
assert!(
!dir.join("wal.genesis").exists(),
"truncating ends asof's reach into the archives"
);
let db = GraphDb::open(&dir).expect("reopen");
assert!(
db.has_node("alice"),
"the data survives; only the past goes"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn automatic_snapshots_keep_every_archive() {
let dir = tmp("snapshot-retention");
let archives = |d: &Path| {
std::fs::read_dir(d)
.expect("read dir")
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
.count()
};
let rounds = 10;
for i in 0..rounds {
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node("Person", &format!("p{i}"), vec![])
.expect("insert");
}
let shared = SharedDb::open(&dir).expect("open shared");
snapshot_shared(&shared).expect("snapshot");
}
assert_eq!(
archives(&dir),
rounds,
"an automatic snapshot must not delete an archive"
);
let db = GraphDb::open(&dir).expect("reopen");
assert_eq!(
db.wal_horizon_floor(),
0,
"nothing was pruned, so the floor stays at 0"
);
for i in 0..rounds {
assert!(db.has_node(&format!("p{i}")), "p{i} survived");
}
assert!(
!db.node_history("p0").expect("history").items.is_empty(),
"the oldest history is still there: that is the point of the default"
);
assert_eq!(db.node_history("p0").expect("history").horizon, 0);
drop(db);
let manual = tmp("snapshot-retention-manual");
for i in 0..3 {
{
let mut db = GraphDb::open(&manual).expect("open");
db.insert_node("Person", &format!("p{i}"), vec![])
.expect("insert");
}
run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
}
assert_eq!(
archives(&manual),
3,
"`mushroomdb snapshot` with no --retention keeps every archive"
);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&manual);
}
#[test]
fn retention_is_still_available_when_configured() {
let dir = tmp("retention-configured");
for i in 0..5 {
{
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node("Person", &format!("p{i}"), vec![]).unwrap();
}
run_snapshot(&dir, WalDisposition::Archive, Some(2)).unwrap();
}
let archives = std::fs::read_dir(&dir)
.expect("read dir")
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
.count();
assert_eq!(archives, 2, "--retention 2 still prunes to two");
assert!(GraphDb::open(&dir).unwrap().wal_horizon_floor() > 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_query_formats_like_asof() {
let dir = tmp("query-cli");
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node(
"Person",
"alice",
vec![("id".into(), Value::Str("alice".into()))],
)
.expect("insert");
}
let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id", None, None).expect("query");
assert!(out.contains("columns:"), "got {out}");
assert!(out.contains("id=alice"), "got {out}");
let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})", None, None).expect("write");
let db = GraphDb::open(&dir).expect("reopen");
assert!(db.has_node("bob"), "query_write must persist CREATE");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn asof_header_names_the_horizon() {
let dir = tmp("asof-horizon");
for i in 0..10 {
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node("Person", &format!("p{i}"), vec![])
.expect("insert");
}
run_snapshot(&dir, WalDisposition::Archive, Some(2)).expect("snapshot");
}
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node("Person", "after", vec![]).expect("insert");
}
let (floor, total) = {
let db = GraphDb::open(&dir).expect("reopen");
(
db.wal_horizon_floor(),
db.wal_total_commits().expect("total"),
)
};
assert!(floor > 0, "the retention must have pruned something");
let out = run_asof(&dir, total - 1, None, None).expect("asof");
assert_eq!(
out.trim(),
format!(
"as-of commit {} of {total} (history reaches back to commit {floor})",
total - 1
),
"the header must name the horizon it can reach"
);
let _ = std::fs::remove_dir_all(&dir);
let clean = tmp("asof-clean");
{
let mut db = GraphDb::open(&clean).expect("open");
db.insert_node("Person", "a", vec![]).expect("insert");
}
assert_eq!(
run_asof(&clean, 0, None, None).expect("asof").trim(),
"as-of commit 0 of 1"
);
let _ = std::fs::remove_dir_all(&clean);
}
#[test]
fn format_stats_says_how_far_back_history_reaches() {
let dir = tmp("stats-horizon");
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node("Person", "a", vec![]).expect("insert");
}
let text = format_stats(&read_stats(&dir).expect("stats"));
assert!(
text.contains("history: complete (nothing pruned)"),
"an unpruned store says so, got:\n{text}"
);
let _ = std::fs::remove_dir_all(&dir);
let pruned = tmp("stats-horizon-pruned");
for i in 0..10 {
{
let mut db = GraphDb::open(&pruned).expect("open");
db.insert_node("Person", &format!("p{i}"), vec![])
.expect("insert");
}
run_snapshot(&pruned, WalDisposition::Archive, Some(2)).expect("snapshot");
}
let stats = read_stats(&pruned).expect("stats");
assert!(stats.history_floor > 0, "the retention must have pruned");
assert!(
format_stats(&stats).contains(&format!(
"history: reaches back to commit {}",
stats.history_floor
)),
"a pruned store names its floor"
);
let _ = std::fs::remove_dir_all(&pruned);
}
#[test]
fn format_stats_contains_counts() {
let dir = tmp("stats-smoke");
let out = run_demo(&dir).expect("demo for stats smoke");
let text = format_stats(&out.stats);
assert!(
text.contains("60"),
"stats output should include live node count, got:\n{text}"
);
assert!(
text.contains("334"),
"stats output should include edge count, got:\n{text}"
);
assert!(
text.to_lowercase().contains("node"),
"stats output should mention nodes, got:\n{text}"
);
assert!(
text.to_lowercase().contains("edge"),
"stats output should mention edges, got:\n{text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_backup_round_trip() {
let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
match r {
Ok(Command::Backup { db_dir, dest }) => {
assert_eq!(db_dir, PathBuf::from("/db/dir"));
assert_eq!(dest, PathBuf::from("/backup/dest"));
}
other => panic!("backup parse, got {other:?}"),
}
}
#[test]
fn parse_backup_missing_dest_errors() {
let r = parse_args(&["backup", "/db/dir"]);
assert!(r.is_err(), "backup without <dest> should error");
let e = r.unwrap_err();
assert!(
e.to_lowercase().contains("dest"),
"error should mention dest, got: {e}"
);
}
#[test]
fn parse_export_defaults_to_jsonl() {
let r = parse_args(&["export", "/db/dir", "/export/dest"]);
match r {
Ok(Command::Export { format, .. }) => {
assert_eq!(format, ExportFormat::Jsonl);
}
other => panic!("export parse, got {other:?}"),
}
}
#[test]
fn parse_export_parquet_flag() {
let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
match r {
Ok(Command::Export { format, .. }) => {
assert_eq!(format, ExportFormat::Parquet);
}
other => panic!("export --format parquet parse, got {other:?}"),
}
}
#[test]
fn parse_export_parquet_flag_eq() {
let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
match r {
Ok(Command::Export { format, .. }) => {
assert_eq!(format, ExportFormat::Parquet);
}
other => panic!("export --format=parquet parse, got {other:?}"),
}
}
#[test]
fn run_backup_cli_produces_verified_report() {
let src = tmp("cli-backup-src");
let dst = tmp("cli-backup-dst");
let _ = run_demo(&src).expect("demo");
let report = run_backup(&src, &dst).expect("run_backup");
assert!(report.verified, "backup must be verified");
assert!(!report.files.is_empty());
assert!(report.bytes > 0);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
fn seed_store(dir: &Path, key: &str) {
let mut db = GraphDb::open(dir).expect("open seed store");
db.insert_node("N", key, vec![]).expect("insert seed node");
db.snapshot().expect("snapshot seed store");
}
#[test]
fn restore_from_seeds_an_empty_dir() {
let src = tmp("restore-src");
seed_store(&src, "a");
let vault = tmp("restore-vault");
run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
let fresh = tmp("restore-fresh");
match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
RestoreOutcome::Restored { files, bytes, .. } => {
assert!(
files.contains(&"snapshot.bin".to_string()),
"expected snapshot.bin among {files:?}"
);
assert!(bytes > 0, "expected a non-zero byte count");
}
other => panic!("expected Restored, got {other:?}"),
}
assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&vault);
let _ = std::fs::remove_dir_all(&fresh);
}
#[test]
fn restore_from_a_backup_dir_itself() {
let src = tmp("restore-direct-src");
seed_store(&src, "a");
let backup = tmp("restore-direct-backup");
run_backup(&src, &backup).expect("backup");
let fresh = tmp("restore-direct-fresh");
match restore_if_empty(&fresh, &backup).expect("restore_if_empty") {
RestoreOutcome::Restored { from, .. } => assert_eq!(from, backup),
other => panic!("expected Restored, got {other:?}"),
}
assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&backup);
let _ = std::fs::remove_dir_all(&fresh);
}
#[test]
fn restore_from_a_wal_only_backup() {
let src = tmp("restore-walonly-src");
{
let mut db = GraphDb::open(&src).expect("open src");
db.insert_node("N", "a", vec![]).expect("insert");
}
assert!(
!src.join("snapshot.bin").exists(),
"test setup: src must not have snapshotted"
);
let vault = tmp("restore-walonly-vault");
run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
let fresh = tmp("restore-walonly-fresh");
match restore_if_empty(&fresh, &vault).expect("restore_if_empty") {
RestoreOutcome::Restored { files, .. } => assert!(
files.contains(&"wal.bin".to_string()),
"expected wal.bin among {files:?}"
),
other => panic!("expected Restored, got {other:?}"),
}
assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&vault);
let _ = std::fs::remove_dir_all(&fresh);
}
#[test]
fn restore_from_picks_latest_then_newest() {
let older_src = tmp("restore-rank-old-src");
seed_store(&older_src, "old");
let newer_src = tmp("restore-rank-new-src");
seed_store(&newer_src, "new");
let latest_src = tmp("restore-rank-latest-src");
seed_store(&latest_src, "named-latest");
let vault = tmp("restore-rank-vault");
run_backup(&older_src, &vault.join("2026-09-01")).expect("backup old");
std::thread::sleep(std::time::Duration::from_millis(1100));
run_backup(&newer_src, &vault.join("2026-09-02")).expect("backup new");
let fresh = tmp("restore-rank-fresh");
match restore_if_empty(&fresh, &vault).expect("restore by mtime") {
RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("2026-09-02")),
other => panic!("expected Restored, got {other:?}"),
}
assert!(GraphDb::open(&fresh)
.expect("open restored")
.has_node("new"));
run_backup(&latest_src, &vault.join("latest")).expect("backup latest");
let older_than_latest = std::fs::metadata(vault.join("2026-09-02").join("snapshot.bin"))
.expect("stat newest")
.modified()
.expect("mtime");
let latest_mtime = std::fs::metadata(vault.join("latest").join("snapshot.bin"))
.expect("stat latest")
.modified()
.expect("mtime");
assert!(
latest_mtime >= older_than_latest,
"test setup: latest/ should not be older here"
);
let fresh2 = tmp("restore-rank-fresh2");
match restore_if_empty(&fresh2, &vault).expect("restore by name") {
RestoreOutcome::Restored { from, .. } => assert_eq!(from, vault.join("latest")),
other => panic!("expected Restored, got {other:?}"),
}
assert!(GraphDb::open(&fresh2)
.expect("open restored")
.has_node("named-latest"));
for d in [&older_src, &newer_src, &latest_src, &vault, &fresh, &fresh2] {
let _ = std::fs::remove_dir_all(d);
}
}
#[test]
fn restore_from_is_a_no_op_when_a_store_exists() {
let src = tmp("restore-noop-src");
seed_store(&src, "a");
let vault = tmp("restore-noop-vault");
run_backup(&src, &vault.join("2026-09-10T00-00Z")).expect("backup");
let existing = tmp("restore-noop-existing");
seed_store(&existing, "b");
assert_eq!(
restore_if_empty(&existing, &vault).expect("restore_if_empty"),
RestoreOutcome::AlreadyPresent
);
let db = GraphDb::open(&existing).expect("open existing");
assert!(db.has_node("b"), "the existing store must survive");
assert!(!db.has_node("a"), "the backup must not have been copied in");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&vault);
let _ = std::fs::remove_dir_all(&existing);
}
#[test]
fn restore_from_an_empty_vault_is_not_an_error() {
let vault = tmp("restore-empty-vault");
std::fs::create_dir_all(&vault).expect("mkdir vault");
let fresh = tmp("restore-empty-fresh");
assert_eq!(
restore_if_empty(&fresh, &vault).expect("restore_if_empty"),
RestoreOutcome::Empty
);
assert_eq!(
restore_if_empty(&fresh, &vault.join("nope")).expect("restore_if_empty"),
RestoreOutcome::Empty
);
let _ = std::fs::remove_dir_all(&vault);
let _ = std::fs::remove_dir_all(&fresh);
}
#[test]
fn restore_from_a_corrupt_backup_fails_loudly() {
let src = tmp("restore-corrupt-src");
seed_store(&src, "a");
let vault = tmp("restore-corrupt-vault");
let backup = vault.join("2026-09-10T00-00Z");
run_backup(&src, &backup).expect("backup");
let snap = backup.join("snapshot.bin");
let bytes = std::fs::read(&snap).expect("read snapshot");
std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
let fresh = tmp("restore-corrupt-fresh");
let err = restore_if_empty(&fresh, &vault).expect_err("expected a hard failure");
let msg = err.to_string();
assert!(
msg.contains(&fresh.display().to_string()),
"error must name the restored dir, got: {msg}"
);
assert!(
msg.contains(&backup.display().to_string()),
"error must name the backup, got: {msg}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&vault);
let _ = std::fs::remove_dir_all(&fresh);
}
#[test]
fn a_failed_restore_leaves_the_db_dir_untouched() {
let src = tmp("restore-atomic-src");
seed_store(&src, "a");
let bad_vault = tmp("restore-atomic-bad-vault");
let bad = bad_vault.join("2026-09-10T00-00Z");
run_backup(&src, &bad).expect("backup the bad one");
let snap = bad.join("snapshot.bin");
let bytes = std::fs::read(&snap).expect("read snapshot");
std::fs::write(&snap, &bytes[..bytes.len() / 2]).expect("truncate snapshot");
let good_vault = tmp("restore-atomic-good-vault");
run_backup(&src, &good_vault.join("2026-09-11T00-00Z")).expect("backup the good one");
let fresh = tmp("restore-atomic-fresh");
let err = restore_if_empty(&fresh, &bad_vault).expect_err("expected a hard failure");
let msg = err.to_string();
assert!(
msg.contains(&fresh.display().to_string()) && msg.contains(&bad.display().to_string()),
"error must name both paths, got: {msg}"
);
let leftovers: Vec<String> = std::fs::read_dir(&fresh)
.expect("read fresh")
.flatten()
.filter_map(|e| e.file_name().into_string().ok())
.collect();
assert!(
leftovers.is_empty(),
"a failed restore must leave nothing behind, found: {leftovers:?}"
);
assert!(!holds_a_store(&fresh), "the dir must not hold a store");
match restore_if_empty(&fresh, &good_vault).expect("retry must restore") {
RestoreOutcome::Restored { from, .. } => {
assert_eq!(from, good_vault.join("2026-09-11T00-00Z"))
}
other => panic!("expected Restored on retry, got {other:?}"),
}
assert!(GraphDb::open(&fresh).expect("open restored").has_node("a"));
for d in [&src, &bad_vault, &good_vault, &fresh] {
let _ = std::fs::remove_dir_all(d);
}
}
#[test]
fn a_successful_restore_leaves_no_staging_dir() {
let src = tmp("restore-staging-src");
seed_store(&src, "a");
let vault = tmp("restore-staging-vault");
run_backup(&src, &vault.join("2026-09-11T00-00Z")).expect("backup");
let fresh = tmp("restore-staging-fresh");
restore_if_empty(&fresh, &vault).expect("restore");
let stray: Vec<String> = std::fs::read_dir(&fresh)
.expect("read fresh")
.flatten()
.filter_map(|e| e.file_name().into_string().ok())
.filter(|n| n.starts_with(".restore-"))
.collect();
assert!(
stray.is_empty(),
"staging dir must be gone, found: {stray:?}"
);
for d in [&src, &vault, &fresh] {
let _ = std::fs::remove_dir_all(d);
}
}
#[test]
fn serve_parses_restore_from() {
match parse_args(&["serve", "/tmp/db", "--restore-from", "/vol/backups"]) {
Ok(Command::Serve { restore_from, .. }) => {
assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
}
other => panic!("--restore-from parse, got {other:?}"),
}
match parse_args(&["serve", "/tmp/db", "--restore-from=/vol/backups"]) {
Ok(Command::Serve { restore_from, .. }) => {
assert_eq!(restore_from, Some(PathBuf::from("/vol/backups")))
}
other => panic!("--restore-from= parse, got {other:?}"),
}
match parse_args(&["serve", "/tmp/db"]) {
Ok(Command::Serve { restore_from, .. }) => assert_eq!(restore_from, None),
other => panic!("default restore_from, got {other:?}"),
}
let err = parse_args(&["serve", "/tmp/db", "--restore-from"])
.expect_err("missing value must be an error");
assert!(
err.contains("--restore-from"),
"error must name the flag, got: {err}"
);
}
#[test]
fn run_export_jsonl_two_runs_byte_identical() {
let src = tmp("cli-export-src");
let dst1 = tmp("cli-export-dst1");
let dst2 = tmp("cli-export-dst2");
let _ = run_demo(&src).expect("demo");
run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
let f1 = std::fs::read(dst1.join(filename)).expect("read first");
let f2 = std::fs::read(dst2.join(filename)).expect("read second");
assert_eq!(
f1, f2,
"{filename} must be byte-identical across two export runs"
);
}
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst1);
let _ = std::fs::remove_dir_all(&dst2);
}
#[test]
fn run_export_jsonl_nodes_are_sorted() {
let src = tmp("cli-export-sorted");
let dst = tmp("cli-export-sorted-dst");
let _ = run_demo(&src).expect("demo");
run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
let keys: Vec<String> = content
.lines()
.filter(|l| !l.is_empty())
.map(|l| {
let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
v["key"].as_str().unwrap_or("").to_string()
})
.collect();
let mut sorted = keys.clone();
sorted.sort();
assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
#[test]
fn run_export_jsonl_derived_edges_have_rule() {
let src = tmp("cli-export-derived");
let dst = tmp("cli-export-derived-dst");
let _ = run_demo(&src).expect("demo");
run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
let derived_lines: Vec<serde_json::Value> = content
.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).expect("parse line"))
.filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
.collect();
assert!(
!derived_lines.is_empty(),
"demo store should have derived edges"
);
for edge in &derived_lines {
assert!(
!edge["rule"].is_null(),
"derived edge must have non-null rule: {edge}"
);
}
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
#[test]
fn run_export_parquet_produces_files() {
let src = tmp("cli-export-parq-src");
let dst = tmp("cli-export-parq-dst");
let _ = run_demo(&src).expect("demo");
run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
assert!(
dst.join("nodes.parquet").exists(),
"nodes.parquet must exist"
);
assert!(
dst.join("edges.parquet").exists(),
"edges.parquet must exist"
);
assert!(
dst.join("rules.parquet").exists(),
"rules.parquet must exist"
);
for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
let meta = std::fs::metadata(dst.join(f)).expect("metadata");
assert!(meta.len() > 0, "{f} must be non-empty");
}
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
#[test]
fn parse_export_graphml_flag() {
let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
match r {
Ok(Command::Export { format, .. }) => {
assert_eq!(format, ExportFormat::Graphml);
}
other => panic!("export --format graphml parse, got {other:?}"),
}
}
#[test]
fn run_export_graphml_structure() {
let src = tmp("cli-export-gml-src");
let dst_dir = tmp("cli-export-gml-dst");
let dst = dst_dir.join("graph.graphml");
let _ = run_demo(&src).expect("demo");
run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
let content = std::fs::read_to_string(&dst).expect("read graphml");
assert!(
content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
"must start with an XML declaration"
);
assert!(
content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
"must use the standard GraphML namespace"
);
assert!(
content.contains(
"<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
),
"must declare the node label key"
);
assert!(
content.contains(
"<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
),
"must declare the edge type key"
);
assert!(
content.contains(
"<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
),
"must declare the edge derived key"
);
assert!(
content.contains(
"<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
),
"must declare the edge rule key"
);
assert!(
content.contains(
"<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
),
"must declare the edge weight key"
);
assert!(
content.contains(
"<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
),
"an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
);
assert!(
content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
"must declare a single directed graph element"
);
assert!(content.contains("<node id="), "must contain node elements");
assert!(
content.contains("<edge id=\"e0\" source=\""),
"must contain a sequentially-numbered edge starting at e0"
);
assert!(
content.trim_end().ends_with("</graphml>"),
"must close the root element"
);
assert!(
content.contains("<data key=\"e_rule\">skill_fit</data>")
|| content.contains("<data key=\"e_rule\">founded_within</data>"),
"at least one derived edge must carry its rule name"
);
assert!(
content.contains(&format!(
"<data key=\"{}\">",
"e_weight"
)),
"at least one derived edge must carry a weight value"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst_dir);
}
#[test]
fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
let src = tmp("cli-export-gml-dir-src");
let dst_dir = tmp("cli-export-gml-dir-dst");
std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
let _ = run_demo(&src).expect("demo");
let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
assert!(
dst_dir.join("graph.graphml").exists(),
"an existing directory dest must produce dest/graph.graphml"
);
assert!(
msg.contains("graph.graphml"),
"report must name the file actually written, got: {msg}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst_dir);
}
#[test]
fn run_export_graphml_is_well_formed_xml() {
let has_python3 = std::process::Command::new("python3")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !has_python3 {
eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
return;
}
let src = tmp("cli-export-gml-wf-src");
let dst_dir = tmp("cli-export-gml-wf-dst");
let dst = dst_dir.join("graph.graphml");
let _ = run_demo(&src).expect("demo");
run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
let status = std::process::Command::new("python3")
.arg("-c")
.arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
.arg(&dst)
.status()
.expect("run python3");
assert!(
status.success(),
"python3's XML parser must accept the exported GraphML file"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst_dir);
}
#[test]
fn run_export_graphml_two_runs_byte_identical() {
let src = tmp("cli-export-gml-bi-src");
let dst_dir1 = tmp("cli-export-gml-bi-dst1");
let dst_dir2 = tmp("cli-export-gml-bi-dst2");
let dst1 = dst_dir1.join("graph.graphml");
let dst2 = dst_dir2.join("graph.graphml");
let _ = run_demo(&src).expect("demo");
run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
let f1 = std::fs::read(&dst1).expect("read first");
let f2 = std::fs::read(&dst2).expect("read second");
assert_eq!(
f1, f2,
"graph.graphml must be byte-identical across two export runs"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst_dir1);
let _ = std::fs::remove_dir_all(&dst_dir2);
}
#[test]
fn run_export_graphml_escapes_and_lists() {
use core_api::{GraphDb, Value};
let src = tmp("cli-export-gml-esc-src");
let dst_dir = tmp("cli-export-gml-esc-dst");
let dst = dst_dir.join("graph.graphml");
{
let mut db = GraphDb::open(&src).unwrap();
db.insert_node(
"Widget",
"w1",
vec![
(
"title".into(),
Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
),
(
"tags".into(),
Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
),
],
)
.unwrap();
}
run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
let content = std::fs::read_to_string(&dst).expect("read graphml");
assert!(
content.contains("Tom & Jerry <says> "hi" 'bye'"),
"special XML characters in string props must be escaped, got: {content}"
);
assert!(
!content.contains("Tom & Jerry <says>"),
"unescaped special characters must not appear verbatim"
);
assert!(
content.contains(
"<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
),
"list-valued props must declare attr.type=\"string\""
);
assert!(
content.contains("<data key=\"n_tags\">["a","b"]</data>"),
"list-valued props must render as XML-escaped JSON text, got: {content}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst_dir);
}
#[test]
fn run_export_graphml_mixed_type_prop_declares_string() {
use core_api::{GraphDb, Value};
let src = tmp("cli-export-gml-mixed-src");
let dst_dir = tmp("cli-export-gml-mixed-dst");
let dst = dst_dir.join("graph.graphml");
{
let mut db = GraphDb::open(&src).unwrap();
db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
.unwrap();
db.insert_node(
"Metric",
"m2",
vec![("score".into(), Value::Str("high".into()))],
)
.unwrap();
}
run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
let content = std::fs::read_to_string(&dst).expect("read graphml");
assert!(
content.contains(
"<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
),
"a prop name with conflicting value types across nodes must declare \
attr.type=\"string\", got: {content}"
);
assert!(
!content.contains("attr.name=\"score\" attr.type=\"long\""),
"must not declare a narrower type once a conflict is seen, got: {content}"
);
assert!(
content.contains("<data key=\"n_score\">5</data>"),
"the int-valued node must still render its literal int text, got: {content}"
);
assert!(
content.contains("<data key=\"n_score\">high</data>"),
"the string-valued node must still render its literal string text, got: {content}"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst_dir);
}
#[test]
fn parse_algo_degree_defaults_dir_both() {
let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
match cmd {
Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
other => panic!("expected Algo, got {other:?}"),
}
}
#[test]
fn parse_algo_degree_with_dir_flag() {
for (arg, want) in [
("out", AlgoDir::Out),
("in", AlgoDir::In),
("both", AlgoDir::Both),
] {
let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
match cmd {
Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
other => panic!("expected Algo, got {other:?}"),
}
}
let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
match cmd {
Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
other => panic!("expected Algo, got {other:?}"),
}
}
#[test]
fn parse_algo_rejects_unknown_dir() {
assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
}
#[test]
fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
let cmd = parse_args(&[
"algo",
"communities",
"/db",
"--edge-type",
"IMPORTS",
"--edge-type=CO_CHANGED",
"--weight-prop",
"score",
"--min-weight",
"0.3",
"--top",
"5",
])
.unwrap();
match cmd {
Command::Algo {
subcmd,
top,
edge_types,
weight_prop,
min_weight,
..
} => {
assert_eq!(subcmd, AlgoSubcmd::Communities);
assert_eq!(top, 5);
assert_eq!(
edge_types,
vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
);
assert_eq!(weight_prop, Some("score".to_string()));
assert_eq!(min_weight, Some(0.3));
}
other => panic!("expected Algo, got {other:?}"),
}
}
#[test]
fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
match cmd {
Command::Algo {
subcmd,
edge_types,
weight_prop,
min_weight,
..
} => {
assert_eq!(subcmd, AlgoSubcmd::Communities);
assert!(edge_types.is_empty());
assert_eq!(weight_prop, None);
assert_eq!(min_weight, None);
}
other => panic!("expected Algo, got {other:?}"),
}
}
#[test]
fn run_export_jsonl_nan_float_becomes_null() {
use core_api::{GraphDb, Value};
let src = tmp("cli-export-nan-src");
let dst = tmp("cli-export-nan-dst");
{
let mut db = GraphDb::open(&src).unwrap();
db.insert_node(
"Sensor",
"s1",
vec![
("nan_val".into(), Value::Float(f64::NAN)),
("pos_inf".into(), Value::Float(f64::INFINITY)),
("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
("normal".into(), Value::Float(1.5)),
],
)
.unwrap();
}
run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
let content =
std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
let row: serde_json::Value =
serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
assert_eq!(
row["nan_val"],
serde_json::Value::Null,
"NaN must export as null"
);
assert_eq!(
row["pos_inf"],
serde_json::Value::Null,
"+Inf must export as null"
);
assert_eq!(
row["neg_inf"],
serde_json::Value::Null,
"-Inf must export as null"
);
assert_eq!(
row["normal"],
serde_json::json!(1.5),
"normal float roundtrips"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dst);
}
#[test]
fn serve_tls_flags_parse_both_forms() {
match parse_args(&[
"serve",
"/tmp/db",
"--tls-cert",
"/a/cert.pem",
"--tls-key",
"/a/key.pem",
])
.unwrap()
{
Command::Serve {
tls_cert, tls_key, ..
} => {
assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
}
other => panic!("{other:?}"),
}
match parse_args(&[
"serve",
"/tmp/db",
"--tls-cert=/b/cert.pem",
"--tls-key=/b/key.pem",
])
.unwrap()
{
Command::Serve {
tls_cert, tls_key, ..
} => {
assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
}
other => panic!("{other:?}"),
}
match parse_args(&["serve", "/tmp/db"]).unwrap() {
Command::Serve {
tls_cert, tls_key, ..
} => {
assert_eq!(tls_cert, None);
assert_eq!(tls_key, None);
}
other => panic!("{other:?}"),
}
}
#[test]
fn serve_tls_flags_require_both() {
let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
assert!(
err.contains("tls-key"),
"--tls-cert alone must mention --tls-key in error, got {err}"
);
let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
assert!(
err.contains("tls-cert"),
"--tls-key alone must mention --tls-cert in error, got {err}"
);
}
#[test]
fn version_flag_parses() {
assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
}
#[test]
fn recall_parses_one_dir_and_is_listed_in_usage() {
assert_eq!(
parse_args(&["recall", "/tmp/db"]).unwrap(),
Command::Recall {
db_dir: Some(PathBuf::from("/tmp/db")),
auto: false,
}
);
assert!(
parse_args(&["recall"]).is_err(),
"one of <db-dir> or --auto is required"
);
assert!(usage().contains("mushroomdb recall <db-dir>"));
}
#[test]
fn map_parses_a_dir_and_an_optional_json_flag() {
assert_eq!(
parse_args(&["map", "/tmp/db"]).unwrap(),
Command::Map {
db_dir: PathBuf::from("/tmp/db"),
json: false,
}
);
let want = Command::Map {
db_dir: PathBuf::from("/tmp/db"),
json: true,
};
assert_eq!(parse_args(&["map", "/tmp/db", "--json"]).unwrap(), want);
assert_eq!(parse_args(&["map", "--json", "/tmp/db"]).unwrap(), want);
assert!(parse_args(&["map"]).is_err(), "<db-dir> is required");
assert!(parse_args(&["map", "/tmp/db", "/tmp/other"]).is_err());
assert!(parse_args(&["map", "/tmp/db", "--nope"]).is_err());
assert!(usage().contains("mushroomdb map <db-dir> [--json]"));
}
#[test]
fn the_graph_tools_take_a_dir_and_their_keys() {
assert_eq!(
parse_args(&["context", "/tmp/db", "src/db.rs#open"]).unwrap(),
Command::Context {
db_dir: PathBuf::from("/tmp/db"),
target: "src/db.rs#open".to_string(),
full: false,
}
);
assert_eq!(
parse_args(&["explore", "/tmp/db", "open"]).unwrap(),
Command::Explore {
db_dir: PathBuf::from("/tmp/db"),
target: "open".to_string(),
depth: repograph::Depth::Context,
full: false,
},
"the default depth is the cheapest one"
);
assert_eq!(
parse_args(&["explore", "/tmp/db", "open", "--depth", "all", "--full"]).unwrap(),
Command::Explore {
db_dir: PathBuf::from("/tmp/db"),
target: "open".to_string(),
depth: repograph::Depth::All,
full: true,
}
);
assert_eq!(
parse_args(&["impact", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
Command::Impact {
db_dir: PathBuf::from("/tmp/db"),
files: vec!["a.rs".to_string(), "b.rs".to_string()],
}
);
assert_eq!(
parse_args(&["owners", "/tmp/db", "a.rs"]).unwrap(),
Command::Owners {
db_dir: PathBuf::from("/tmp/db"),
path: "a.rs".to_string(),
}
);
assert_eq!(
parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
Command::Why {
db_dir: PathBuf::from("/tmp/db"),
a: "a.rs".to_string(),
b: "b.rs".to_string(),
}
);
for args in [
vec!["context", "/tmp/db"],
vec!["context", "/tmp/db", "a", "b"],
vec!["impact", "/tmp/db"],
vec!["owners", "/tmp/db"],
vec!["why", "/tmp/db", "a"],
vec!["why", "/tmp/db", "a", "b", "c"],
vec!["why", "/tmp/db", "-a", "b"],
vec!["context"],
vec!["explore"],
vec!["explore", "/tmp/db"],
vec!["explore", "/tmp/db", "a", "b"],
vec!["explore", "/tmp/db", "a", "--depth"],
vec!["explore", "/tmp/db", "a", "--depth", "everything"],
vec!["explore", "/tmp/db", "a", "--nope"],
] {
assert!(parse_args(&args).is_err(), "{args:?} must not parse");
}
for line in [
"mushroomdb explore <db-dir> <target>",
"mushroomdb context <db-dir> <target>",
"mushroomdb impact <db-dir> <file>...",
"mushroomdb owners <db-dir> <path>",
"mushroomdb why <db-dir> <a> <b>",
] {
assert!(usage().contains(line), "usage is missing {line:?}");
}
}
#[test]
fn usage_marks_the_deprecated_subcommands() {
let text = usage();
for prefix in [
"mushroomdb explore <db-dir> <target>",
"mushroomdb map <db-dir> [--json]",
"mushroomdb context <db-dir> <target>",
"mushroomdb impact <db-dir> <file>...",
"mushroomdb owners <db-dir> <path>",
"mushroomdb why <db-dir> <a> <b>",
"mushroomdb sync <db-dir>|--auto",
] {
let start = text
.find(prefix)
.unwrap_or_else(|| panic!("usage is missing {prefix:?}"));
let rest = &text[start..];
let block_end = rest.find("\n mushroomdb ").unwrap_or(rest.len());
assert!(
rest[..block_end].contains("(deprecated, removed in 0.7)"),
"usage does not mark {prefix:?} deprecated"
);
}
}
#[test]
fn hook_commands_take_a_dir_or_auto() {
assert_eq!(
parse_args(&["mcp", "--auto"]).unwrap(),
Command::Mcp {
db_dir: None,
auto: true,
all_tools: false
}
);
assert_eq!(
parse_args(&["recall", "--auto"]).unwrap(),
Command::Recall {
db_dir: None,
auto: true
}
);
assert_eq!(
parse_args(&["brief", "--auto"]).unwrap(),
Command::Brief {
db_dir: None,
auto: true
}
);
assert_eq!(
parse_args(&["brief", "/tmp/db"]).unwrap(),
Command::Brief {
db_dir: Some(PathBuf::from("/tmp/db")),
auto: false
}
);
for cmd in ["mcp", "recall", "touch", "brief"] {
assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
assert!(
parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
"{cmd} with both"
);
}
assert!(usage().contains("--auto"));
}
#[test]
fn mcp_takes_all_tools() {
for args in [
&["mcp", "/tmp/db", "--all-tools"][..],
&["mcp", "--all-tools", "/tmp/db"][..],
] {
assert_eq!(
parse_args(args).unwrap(),
Command::Mcp {
db_dir: Some(PathBuf::from("/tmp/db")),
auto: false,
all_tools: true
},
"{args:?}"
);
}
assert_eq!(
parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
Command::Mcp {
db_dir: None,
auto: true,
all_tools: true
}
);
assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
assert!(usage().contains("--all-tools"));
}
#[test]
fn sync_and_touch_parse() {
assert_eq!(
parse_args(&["sync", "/tmp/db"]).unwrap(),
Command::Sync {
db_dir: Some(PathBuf::from("/tmp/db")),
auto: false,
json: false,
}
);
assert_eq!(
parse_args(&["sync", "/tmp/db", "--json"]).unwrap(),
Command::Sync {
db_dir: Some(PathBuf::from("/tmp/db")),
auto: false,
json: true,
}
);
assert_eq!(
parse_args(&["sync", "--auto"]).unwrap(),
Command::Sync {
db_dir: None,
auto: true,
json: false,
}
);
assert_eq!(
parse_args(&["sync", "--auto", "--json"]).unwrap(),
Command::Sync {
db_dir: None,
auto: true,
json: true,
}
);
assert!(
parse_args(&["sync"]).is_err(),
"one of <db-dir> or --auto is required"
);
assert!(
parse_args(&["sync", "/tmp/db", "--auto"]).is_err(),
"--auto and a path contradict each other"
);
assert_eq!(
parse_args(&["touch", "/tmp/db", "src/a.rs", "src/b.rs"]).unwrap(),
Command::Touch {
db_dir: Some(PathBuf::from("/tmp/db")),
auto: false,
files: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
}
);
assert_eq!(
parse_args(&["touch", "--auto", "src/a.rs"]).unwrap(),
Command::Touch {
db_dir: None,
auto: true,
files: vec![PathBuf::from("src/a.rs")],
}
);
assert_eq!(
parse_args(&["touch", "--auto"]).unwrap(),
Command::Touch {
db_dir: None,
auto: true,
files: vec![],
}
);
assert!(usage().contains("mushroomdb sync <db-dir>"));
assert!(usage().contains("mushroomdb touch"));
}
#[test]
fn ingest_git_parses_excludes() {
let cmd = parse_args(&[
"ingest-git",
"/tmp/db",
"/tmp/repo",
"--exclude",
"target/",
"--exclude=*.lock",
"--max-commits-per-file",
"50",
"--recurse-submodules",
"--prs",
"--ensure-gitignore",
])
.unwrap();
assert_eq!(
cmd,
Command::IngestGit {
db_dir: PathBuf::from("/tmp/db"),
opts: ingest_git::IngestGitOpts {
repo: PathBuf::from("/tmp/repo"),
exclude: vec!["target/".into(), "*.lock".into()],
max_commits_per_file: 50,
recurse_submodules: true,
prs: true,
structure: true,
docs: true,
ensure_gitignore: true,
},
}
);
let Command::IngestGit { opts, .. } =
parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
else {
panic!("expected IngestGit");
};
assert_eq!(
opts.exclude,
ingest_git::DEFAULT_EXCLUDES
.iter()
.map(|p| (*p).to_string())
.collect::<Vec<_>>(),
"with no --exclude the defaults apply"
);
assert_eq!(
opts.max_commits_per_file,
ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
);
assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
assert!(
opts.structure && opts.docs,
"structure and docs default on and are recorded on the marker"
);
let Command::IngestGit { opts, .. } = parse_args(&[
"ingest-git",
"/tmp/db",
"/tmp/repo",
"--no-structure",
"--no-docs",
])
.unwrap() else {
panic!("expected IngestGit");
};
assert!(!opts.structure && !opts.docs);
assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
}
#[test]
fn version_constant_matches_cargo() {
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
assert!(usage().contains("--version"));
}
fn ns_store(name: &str) -> PathBuf {
let dir = tmp(name);
let mut db = GraphDb::open(&dir).expect("open");
for (key, ns) in [
("d1", None),
("a1", Some("tenant-a")),
("a2", Some("tenant-a")),
("b1", Some("tenant-b")),
] {
let mut props = vec![("id".into(), Value::Str(key.into()))];
if let Some(ns) = ns {
props.push(("ns".into(), Value::Str(ns.into())));
}
db.insert_node("Doc", key, props).expect("insert");
}
db.apply_schema(&Schema {
roles: vec![core_api::RoleDef {
name: "a-reader".into(),
keys: vec![],
labels: vec!["Doc".into()],
visible_where: None,
namespaces: Some(vec!["tenant-a".into()]),
write: None,
}],
..Default::default()
})
.expect("schema");
dir
}
#[test]
fn format_stats_lists_namespaces() {
let dir = ns_store("stats-namespaces");
let text = format_stats(&read_stats(&dir).expect("stats"));
assert!(
text.contains("namespaces: default (1), tenant-a (2), tenant-b (1)"),
"got:\n{text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn format_stats_omits_the_namespaces_line_on_one_namespace() {
let dir = tmp("stats-one-namespace");
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node("Person", "a", vec![]).expect("insert");
}
let text = format_stats(&read_stats(&dir).expect("stats"));
assert!(
!text.contains("namespaces"),
"a store with only `default` says nothing about namespaces, got:\n{text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parse_query_takes_a_role_and_a_namespace() {
let Ok(Command::Query {
role, namespace, ..
}) = parse_args(&[
"query",
"/tmp/db",
"--role",
"a-reader",
"--namespace",
"tenant-a",
"MATCH (n) RETURN n",
])
else {
panic!("expected Query");
};
assert_eq!(role.as_deref(), Some("a-reader"));
assert_eq!(namespace.as_deref(), Some("tenant-a"));
let Ok(Command::Query {
role, namespace, ..
}) = parse_args(&[
"query",
"/tmp/db",
"--namespace=tenant-b",
"MATCH (n) RETURN n",
])
else {
panic!("expected Query");
};
assert_eq!(role, None);
assert_eq!(namespace.as_deref(), Some("tenant-b"));
assert!(parse_args(&["query", "/tmp/db", "--namespace"]).is_err());
assert!(parse_args(&["query", "/tmp/db", "--role"]).is_err());
assert!(usage().contains("--namespace <ns>"));
}
#[test]
fn run_query_with_a_role_and_a_namespace_never_widens() {
let dir = ns_store("query-namespace");
let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
let all = run_query(&dir, q, None, None).expect("query");
assert!(all.contains("id=a1") && all.contains("id=b1") && all.contains("id=d1"));
let ns = run_query(&dir, q, None, Some("tenant-a")).expect("query");
assert!(ns.contains("id=a1") && ns.contains("id=a2"), "got {ns}");
assert!(!ns.contains("id=b1") && !ns.contains("id=d1"), "got {ns}");
let role = run_query(&dir, q, Some("a-reader"), None).expect("query");
assert!(
role.contains("id=a1") && !role.contains("id=b1"),
"got {role}"
);
let both = run_query(&dir, q, Some("a-reader"), Some("tenant-b")).expect("query");
assert!(
!both.contains("id=a1") && !both.contains("id=b1"),
"role ∩ namespace, never role ∪ namespace: {both}"
);
for (role, namespace) in [
(None, Some("tenant-a")),
(Some("a-reader"), None),
(Some("a-reader"), Some("tenant-a")),
] {
let write = run_query(&dir, "CREATE (n:Doc {id: 'z1'})", role, namespace);
assert!(
write
.as_ref()
.err()
.is_some_and(|e| e.0.contains("read-only")),
"a restricted write must be refused, got {write:?}"
);
assert!(
!GraphDb::open(&dir).expect("reopen").has_node("z1"),
"the write must not have landed"
);
}
let unknown = run_query(&dir, q, Some("nobody"), None);
assert!(unknown.is_err(), "an unknown role is an error");
let invalid = run_query(&dir, q, None, Some("no spaces"));
assert!(
invalid
.as_ref()
.err()
.is_some_and(|e| e.0.contains("valid namespace name")),
"{invalid:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_asof_in_a_namespace() {
let dir = ns_store("asof-namespace");
let at = {
let db = GraphDb::open(&dir).expect("open");
db.wal_total_commits().expect("commits") - 1
};
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node(
"Doc",
"a3",
vec![
("id".into(), Value::Str("a3".into())),
("ns".into(), Value::Str("tenant-a".into())),
],
)
.expect("insert");
}
let q = "MATCH (n) RETURN n.id AS id ORDER BY n.id";
let then = run_asof(&dir, at, Some(q), Some("tenant-a")).expect("asof");
assert!(
then.contains("id=a1") && then.contains("id=a2"),
"got {then}"
);
assert!(
!then.contains("id=a3") && !then.contains("id=b1"),
"a3 did not exist then and b1 is another namespace: {then}"
);
let now = run_asof(&dir, at + 1, Some(q), Some("tenant-a")).expect("asof");
assert!(now.contains("id=a3"), "got {now}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn schema_apply_accepts_v4_roles_with_namespaces() {
let dir = tmp("schema-v4");
{
let mut db = GraphDb::open(&dir).expect("open");
db.insert_node(
"Doc",
"a1",
vec![("ns".into(), Value::Str("tenant-a".into()))],
)
.expect("insert");
}
let file = dir.join("schema.json");
std::fs::write(
&file,
r#"{"roles": [{"name": "a-reader", "labels": ["Doc"], "keys": [],
"namespaces": ["tenant-a"]}]}"#,
)
.expect("write schema");
let out = run_schema_apply(&dir, &file).expect("apply");
assert!(out.contains("a-reader"), "got {out}");
let sidecar = std::fs::read_to_string(dir.join("roles.json")).expect("roles.json");
assert!(
sidecar.contains("\"version\": 4") || sidecar.contains("\"version\":4"),
"a role with namespaces writes version 4: {sidecar}"
);
assert!(sidecar.contains("tenant-a"), "{sidecar}");
let _ = std::fs::remove_dir_all(&dir);
}
}