1pub mod doctor;
7pub mod export;
8pub mod ingest_git;
9pub mod install;
10pub mod intercept;
11pub mod recall;
12pub mod structure;
13
14use core_api::repograph;
15use core_api::schema::Schema;
16use core_api::{
17 default_max_edges, is_write_query, wal_commit_count_at, AlgoDir, BackupReport, DegreeConfig,
18 Explanation, GraphDb, IngestOptions, LouvainConfig, PageRankConfig, Predicate, ResultSet,
19 RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig, WriteGuard,
20};
21use export::ExportFormat;
22use std::collections::{BTreeMap, BTreeSet};
23use std::fmt::Write as _;
24use std::net::SocketAddr;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28pub const AUTOMATIC_SNAPSHOT: SnapshotOptions = SnapshotOptions {
39 keep_wal: false,
40 archive_wal: true,
41};
42
43pub const AUTO_SNAPSHOT_RETENTION: u32 = 8;
72
73pub fn snapshot_automatically(db: &mut WriteGuard<'_>) -> Result<(), core_api::GraphError> {
83 db.set_wal_archive_retention(Some(AUTO_SNAPSHOT_RETENTION));
84 db.snapshot_with(AUTOMATIC_SNAPSHOT)
85}
86
87pub const SNAPSHOT_LOCK_WAIT: Duration = Duration::from_millis(500);
95
96pub fn snapshot_shared(db: &SharedDb) -> Result<(), core_api::GraphError> {
107 snapshot_automatically(&mut db.write_with_wait(SNAPSHOT_LOCK_WAIT)?)
108}
109
110pub const N_ORGS: usize = 10;
112pub const N_PROJECTS: usize = 20;
113pub const N_PEOPLE: usize = 30;
114
115pub const SAMPLE_QUERY: &str = "\
120MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
121RETURN p, proj, r.score AS score
122ORDER BY score DESC, proj";
123
124const SAMPLE_EXPLAIN_A: &str = "person-01";
125const SAMPLE_EXPLAIN_B: &str = "proj-01";
126
127pub const VERSION: &str = env!("CARGO_PKG_VERSION");
129
130#[must_use]
132pub fn version_string() -> String {
133 format!("mushroomdb {VERSION}")
134}
135
136#[must_use]
148pub fn resolve_auto_db(
149 env_project_dir: Option<&std::ffi::OsStr>,
150 cwd: &Path,
151 home: &Path,
152) -> PathBuf {
153 if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
154 return Path::new(dir).join("mushroom-memory");
155 }
156 if let Some(root) = worktree_root(cwd) {
157 return root.join("mushroom-memory");
158 }
159 home.join(".mushroomdb").join("memory")
160}
161
162#[must_use]
176pub fn worktree_root(dir: &Path) -> Option<&Path> {
177 dir.ancestors().find(|d| d.join(".git").exists())
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum ServeUi {
183 Filesystem(PathBuf),
184 Embedded,
185 None,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
190pub enum AlgoSubcmd {
191 Pagerank,
192 Wcc,
193 Degree,
194 Communities,
195}
196
197#[derive(Debug, Clone, PartialEq)]
201pub enum Command {
202 Serve {
203 db_dir: PathBuf,
204 addr: SocketAddr,
205 ui: ServeUi,
206 demo_if_empty: bool,
209 token: Option<String>,
211 role_tokens: Vec<(String, String)>,
214 snapshot_every: Option<Duration>,
216 tls_cert: Option<PathBuf>,
218 tls_key: Option<PathBuf>,
220 },
221 Mcp {
222 db_dir: Option<PathBuf>,
224 auto: bool,
225 all_tools: bool,
231 },
232 Stats {
233 db_dir: PathBuf,
234 },
235 Demo {
236 db_dir: PathBuf,
237 },
238 AsOf {
240 db_dir: PathBuf,
241 commit: u64,
243 query: Option<String>,
245 },
246 Suggest {
248 db_dir: PathBuf,
249 },
250 Algo {
252 db_dir: PathBuf,
253 subcmd: AlgoSubcmd,
254 top: usize,
256 dir: AlgoDir,
259 edge_types: Vec<String>,
262 weight_prop: Option<String>,
264 min_weight: Option<f64>,
266 },
267 Query {
269 db_dir: PathBuf,
270 cypher: String,
272 },
273 Snapshot {
275 db_dir: PathBuf,
276 wal: WalDisposition,
277 retention: Option<u32>,
280 },
281 SchemaApply {
283 db_dir: PathBuf,
284 schema_file: PathBuf,
285 },
286 Migrate {
288 db_dir: PathBuf,
289 },
290 Verify {
292 db_dir: PathBuf,
293 },
294 Backup {
296 db_dir: PathBuf,
297 dest: PathBuf,
298 },
299 Export {
301 db_dir: PathBuf,
302 dest: PathBuf,
303 format: ExportFormat,
304 },
305 IngestGit {
307 db_dir: PathBuf,
308 opts: ingest_git::IngestGitOpts,
309 },
310 Install(install::InstallOpts),
312 Uninstall(install::InstallOpts),
314 Disable(install::ToggleOpts),
318 Enable(install::ToggleOpts),
321 Doctor(doctor::DoctorOpts),
323 Recall {
326 db_dir: Option<PathBuf>,
327 auto: bool,
328 },
329 Brief {
332 db_dir: Option<PathBuf>,
333 auto: bool,
334 },
335 Sync {
338 db_dir: Option<PathBuf>,
343 auto: bool,
344 json: bool,
348 },
349 Intercept {
354 db_dir: Option<PathBuf>,
355 auto: bool,
356 },
357 Touch {
360 db_dir: Option<PathBuf>,
361 auto: bool,
362 files: Vec<PathBuf>,
363 },
364 Map {
367 db_dir: PathBuf,
368 json: bool,
371 },
372 Explore {
375 db_dir: PathBuf,
376 target: String,
377 depth: repograph::Depth,
378 full: bool,
380 },
381 Context {
383 db_dir: PathBuf,
384 target: String,
385 full: bool,
388 },
389 Impact {
392 db_dir: PathBuf,
393 files: Vec<String>,
394 },
395 Owners {
397 db_dir: PathBuf,
398 path: String,
399 },
400 Why {
402 db_dir: PathBuf,
403 a: String,
404 b: String,
405 },
406 Version,
407 Help,
408}
409
410#[derive(Debug)]
412pub struct DemoOutcome {
413 pub auto_fk_rules: Vec<String>,
414 pub sample_query: String,
415 pub sample_result: ResultSet,
416 pub explanations: Vec<Explanation>,
417 pub stats: Stats,
418 pub suggestion: Option<RuleSuggestion>,
420}
421
422#[derive(Debug)]
424pub struct CliError(pub String);
425
426impl std::fmt::Display for CliError {
427 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428 f.write_str(&self.0)
429 }
430}
431
432impl std::error::Error for CliError {}
433
434impl From<core_api::GraphError> for CliError {
435 fn from(e: core_api::GraphError) -> Self {
436 CliError(e.to_string())
437 }
438}
439
440impl From<std::io::Error> for CliError {
441 fn from(e: std::io::Error) -> Self {
442 CliError(e.to_string())
443 }
444}
445
446pub fn usage() -> &'static str {
448 "\
449mushroomdb — embedded graph database
450
451Usage:
452 mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
453 [--command <path>] [--no-git-hooks] [--no-prewarm]
454 [--delivery cli|mcp|both] [--intercept-grep]
455 --delivery cli writes the skill and the hooks and registers no MCP
456 server: the skill teaches `mushroomdb <command>` instead (claude-code
457 only; cursor and codex are always registered as MCP servers)
458 --intercept-grep adds an experimental PreToolUse hook (matcher Grep)
459 that redirects a search for a known symbol name to `explore`
460 mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
461 mushroomdb disable [--platform claude-code|cursor|codex|all] [--project|--user]
462 turn an install off without removing it: hooks, MCP entry and git hook
463 blocks are removed; the skill, the store and .gitignore stay
464 mushroomdb enable [--platform claude-code|cursor|codex|all] [--project|--user]
465 turn a disabled install back on
466 mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
467 verify an install: config entry, store, hooks, git hooks, and a real
468 stdio handshake with the configured MCP command; exits 1 on any `fail`
469 mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>]
470 mushroomdb mcp <db-dir>|--auto [--all-tools]
471 --all-tools lists all 25 tools; the default follows the store — 3 on a
472 store `ingest-git` built (explore, query, stats), 11 on any other
473 (the rest stay callable, just unlisted)
474 mushroomdb stats <db-dir>
475 mushroomdb demo <db-dir>
476 mushroomdb recall <db-dir>|--auto hook body: reads a prompt payload on stdin, prints related graph facts
477 mushroomdb brief <db-dir>|--auto hook body: the repository in one block — size, synced sha, the most
478 central files and the most called symbols; byte-stable, so a
479 session host caches it once
480 mushroomdb sync <db-dir>|--auto [--json]
481 re-sync the repo the store was built from: new commits, then the
482 dirty working tree (git hook body)
483 mushroomdb map <db-dir> [--json] summarise the graphed repository: clusters, key files, owners, hot files
484 --json prints the computed map instead of the rendered digest
485 mushroomdb explore <db-dir> <target> [--depth context|impact|history|all] [--full]
486 one target from as many sides as asked for: the definition and
487 its callers (context), the blast radius (impact), the owner and
488 what it changes with (history), or all three
489 <target> is a file path, a symbol key, or a bare symbol name
490 --full also quotes the body from the working tree
491 mushroomdb context <db-dir> <target> [--full]
492 one file or symbol from every side: where it is, signature, callers,
493 callees, importers, co-change partners, commits, notes
494 <target> is a file path, a symbol key, or a bare symbol name
495 --full also quotes the body from the working tree
496 mushroomdb impact <db-dir> <file>... what changing these files reaches: partners, importers,
497 and the symbols other files call
498 mushroomdb owners <db-dir> <path> top author and share, who else knows it, last touch, last 4 quarters
499 mushroomdb why <db-dir> <a> <b> every rule edge between two nodes with its evidence, or the
500 shortest path between them
501 mushroomdb touch <db-dir>|--auto [<file>...]
502 re-extract just these files; with no <file> reads them from a
503 PostToolUse payload on stdin (hook body)
504 mushroomdb intercept <db-dir>|--auto
505 hook body: reads a PreToolUse Grep payload on stdin; exits 2
506 with a one-line pointer to `explore` when the pattern names a
507 symbol the graph holds, else exits 0 in silence
508 mushroomdb suggest <db-dir>
509 mushroomdb asof <db-dir> --commit N [--query \"MATCH ...\"]
510 mushroomdb query <db-dir> [--query \"MATCH ...\"] <cypher…>
511 mushroomdb snapshot <db-dir> [--keep-wal|--truncate] [--retention N]
512 folds the WAL into snapshot.bin and archives it as wal.<N>.archive,
513 so node_history, edge_history, was_linked and asof keep reaching it;
514 --truncate discards it instead, --keep-wal leaves wal.bin whole
515 mushroomdb migrate <db-dir>
516 mushroomdb verify <db-dir> validate CRC32 integrity of every snapshot section
517 mushroomdb backup <db-dir> <dest> process-local consistent copy of the database to <dest>
518 WARNING: unsafe against a concurrently running serve process;
519 use POST /backup on the HTTP server for live-serve backups
520 mushroomdb export <db-dir> <dest> --format jsonl|parquet|graphml export all data
521 graphml writes one file: <dest>/graph.graphml if <dest> is an
522 existing directory, otherwise <dest> is the file path itself
523 (nodes + edges only; rules have no GraphML analogue)
524 mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
525 [--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
526 graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
527 --recurse-submodules also walks each initialised submodule
528 --prs links merged pull requests via gh (skipped when gh is unavailable)
529 --no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
530 --no-docs skips Markdown bodies, headings and mentions
531 --ensure-gitignore adds the database directory to the repo's .gitignore
532 with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
533 mushroomdb schema apply <db-dir> <schema.json>
534 mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
535 mushroomdb algo wcc <db-dir> [--top N]
536 mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
537 mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
538 mushroomdb --version
539 mushroomdb --help
540
541Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
542install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
543the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
544--command names one (a relative --command or --db is anchored to the current directory).
545--no-git-hooks skips the post-commit/checkout/merge sync hooks. --no-prewarm means no network and no
546resolution: neither the one-off package fetch nor locating the package's binary, so every hook keeps
547the slower `npx` form.
548uninstall resolves the same scope and falls back to the other one when the inferred scope has no
549manifest; undoing a Codex install needs --platform codex.
550A project install inside a git checkout writes --auto rather than a store path, so each `git
551worktree` gets its own store; outside a checkout, and with --db, the store is pinned to an absolute
552path instead.
553--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else mushroom-memory at the
554root of the working tree the current directory is in, else ~/.mushroomdb/memory.
555"
556}
557
558fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
559 let mut platform: Option<install::Platform> = None;
560 let mut scope: Option<install::Scope> = None;
561 let mut db: Option<PathBuf> = None;
562 let mut command: Option<PathBuf> = None;
563 let mut git_hooks = true;
564 let mut prewarm = true;
565 let mut delivery = install::Delivery::default();
566 let mut intercept_grep = false;
567 let mut i = 0;
568 while i < args.len() {
569 let a = args[i];
570 if a == "--delivery" {
571 let val = args
572 .get(i + 1)
573 .copied()
574 .ok_or_else(|| "missing value for --delivery".to_string())?;
575 delivery = install::Delivery::parse(val)?;
576 i += 2;
577 } else if let Some(val) = a.strip_prefix("--delivery=") {
578 delivery = install::Delivery::parse(val)?;
579 i += 1;
580 } else if a == "--platform" {
581 let val = args
582 .get(i + 1)
583 .copied()
584 .ok_or_else(|| "missing value for --platform".to_string())?;
585 platform = Some(install::Platform::parse(val)?);
586 i += 2;
587 } else if let Some(val) = a.strip_prefix("--platform=") {
588 platform = Some(install::Platform::parse(val)?);
589 i += 1;
590 } else if a == "--project" || a == "--user" {
591 let want = if a == "--project" {
592 install::Scope::Project
593 } else {
594 install::Scope::User
595 };
596 if scope.is_some_and(|s| s != want) {
599 return Err("--project and --user are mutually exclusive".to_string());
600 }
601 scope = Some(want);
602 i += 1;
603 } else if a == "--no-git-hooks" {
604 git_hooks = false;
605 i += 1;
606 } else if a == "--intercept-grep" {
607 intercept_grep = true;
608 i += 1;
609 } else if a == "--no-prewarm" {
610 prewarm = false;
611 i += 1;
612 } else if a == "--command" {
613 let val = args
614 .get(i + 1)
615 .copied()
616 .ok_or_else(|| "missing value for --command".to_string())?;
617 command = Some(PathBuf::from(val));
618 i += 2;
619 } else if let Some(val) = a.strip_prefix("--command=") {
620 command = Some(PathBuf::from(val));
621 i += 1;
622 } else if a == "--db" {
623 let val = args
624 .get(i + 1)
625 .copied()
626 .ok_or_else(|| "missing value for --db".to_string())?;
627 db = Some(PathBuf::from(val));
628 i += 2;
629 } else if let Some(val) = a.strip_prefix("--db=") {
630 db = Some(PathBuf::from(val));
631 i += 1;
632 } else if a.starts_with('-') {
633 return Err(format!("unexpected flag: {a}"));
634 } else {
635 return Err(format!("unexpected argument: {a}"));
636 }
637 }
638 Ok(install::InstallOpts {
639 platform,
640 scope,
641 db,
642 command,
643 git_hooks,
644 prewarm,
645 delivery,
646 intercept_grep,
647 })
648}
649
650fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
651 let mut platform: Option<install::Platform> = None;
652 let mut scope: Option<install::Scope> = None;
653 let mut i = 0;
654 while i < args.len() {
655 let a = args[i];
656 if a == "--platform" {
657 let val = args
658 .get(i + 1)
659 .copied()
660 .ok_or_else(|| "missing value for --platform".to_string())?;
661 platform = Some(install::Platform::parse(val)?);
662 i += 2;
663 } else if let Some(val) = a.strip_prefix("--platform=") {
664 platform = Some(install::Platform::parse(val)?);
665 i += 1;
666 } else if a == "--project" || a == "--user" {
667 let want = if a == "--project" {
668 install::Scope::Project
669 } else {
670 install::Scope::User
671 };
672 if scope.is_some_and(|s| s != want) {
673 return Err("--project and --user are mutually exclusive".to_string());
674 }
675 scope = Some(want);
676 i += 1;
677 } else if a.starts_with('-') {
678 return Err(format!("unexpected flag: {a}"));
679 } else {
680 return Err(format!("unexpected argument: {a}"));
681 }
682 }
683 Ok(doctor::DoctorOpts { platform, scope })
684}
685
686fn parse_toggle_cmd(args: &[&str]) -> Result<install::ToggleOpts, String> {
691 let mut platform: Option<install::Platform> = None;
692 let mut scope: Option<install::Scope> = None;
693 let mut i = 0;
694 while i < args.len() {
695 let a = args[i];
696 if a == "--platform" {
697 let val = args
698 .get(i + 1)
699 .copied()
700 .ok_or_else(|| "missing value for --platform".to_string())?;
701 platform = Some(install::Platform::parse(val)?);
702 i += 2;
703 } else if let Some(val) = a.strip_prefix("--platform=") {
704 platform = Some(install::Platform::parse(val)?);
705 i += 1;
706 } else if a == "--project" || a == "--user" {
707 let want = if a == "--project" {
708 install::Scope::Project
709 } else {
710 install::Scope::User
711 };
712 if scope.is_some_and(|s| s != want) {
713 return Err("--project and --user are mutually exclusive".to_string());
714 }
715 scope = Some(want);
716 i += 1;
717 } else if a.starts_with('-') {
718 return Err(format!("unexpected flag: {a}"));
719 } else {
720 return Err(format!("unexpected argument: {a}"));
721 }
722 }
723 Ok(install::ToggleOpts { platform, scope })
724}
725
726fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
727 let mut positional = Vec::new();
728 let mut exclude = Vec::new();
729 let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
730 let mut recurse_submodules = false;
731 let mut prs = false;
732 let mut structure = true;
733 let mut docs = true;
734 let mut ensure_gitignore = false;
735 let mut i = 0;
736 while i < args.len() {
737 let a = args[i];
738 if a == "--recurse-submodules" {
739 recurse_submodules = true;
740 i += 1;
741 } else if a == "--prs" {
742 prs = true;
743 i += 1;
744 } else if a == "--no-structure" {
745 structure = false;
746 i += 1;
747 } else if a == "--no-docs" {
748 docs = false;
749 i += 1;
750 } else if a == "--ensure-gitignore" {
751 ensure_gitignore = true;
752 i += 1;
753 } else if a == "--exclude" {
754 exclude.push(
755 args.get(i + 1)
756 .copied()
757 .ok_or_else(|| "missing value for --exclude".to_string())?
758 .to_string(),
759 );
760 i += 2;
761 } else if let Some(val) = a.strip_prefix("--exclude=") {
762 exclude.push(val.to_string());
763 i += 1;
764 } else if a == "--max-commits-per-file" {
765 let val = args
766 .get(i + 1)
767 .copied()
768 .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
769 max_commits_per_file = val
770 .parse()
771 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
772 i += 2;
773 } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
774 max_commits_per_file = val
775 .parse()
776 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
777 i += 1;
778 } else if a.starts_with('-') {
779 return Err(format!("unexpected flag: {a}"));
780 } else {
781 positional.push(a);
782 i += 1;
783 }
784 }
785 let [db_dir, repo] = positional.as_slice() else {
786 return Err("ingest-git requires <db-dir> <repo-dir>".into());
787 };
788 if exclude.is_empty() {
792 exclude = ingest_git::DEFAULT_EXCLUDES
793 .iter()
794 .map(|p| (*p).to_string())
795 .collect();
796 }
797 Ok(Command::IngestGit {
798 db_dir: PathBuf::from(db_dir),
799 opts: ingest_git::IngestGitOpts {
800 repo: PathBuf::from(repo),
801 exclude,
802 max_commits_per_file,
803 recurse_submodules,
804 prs,
805 structure,
806 docs,
807 ensure_gitignore,
808 },
809 })
810}
811
812pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
814 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
815 if args.is_empty() {
816 return Ok(Command::Help);
817 }
818 match args[0] {
819 "--help" | "-h" | "help" => Ok(Command::Help),
820 "--version" | "-V" | "version" => Ok(Command::Version),
821 "serve" => parse_serve(&args[1..]),
822 "mcp" => parse_mcp(&args[1..]),
823 "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
824 "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
825 "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
826 "asof" => parse_asof(&args[1..]),
827 "algo" => parse_algo(&args[1..]),
828 "query" => parse_query(&args[1..]),
829 "snapshot" => parse_snapshot(&args[1..]),
830 "schema" => parse_schema(&args[1..]),
831 "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
832 "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
833 "backup" => parse_backup(&args[1..]),
834 "export" => parse_export(&args[1..]),
835 "recall" => parse_dir_or_auto("recall", &args[1..])
836 .map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
837 "brief" => parse_dir_or_auto("brief", &args[1..])
838 .map(|(db_dir, auto)| Command::Brief { db_dir, auto }),
839 "intercept" => parse_dir_or_auto("intercept", &args[1..])
840 .map(|(db_dir, auto)| Command::Intercept { db_dir, auto }),
841 "sync" => parse_sync(&args[1..]),
842 "map" => parse_dir_with_json("map", &args[1..])
843 .map(|(db_dir, json)| Command::Map { db_dir, json }),
844 "explore" => parse_explore(&args[1..]),
845 "context" => parse_context(&args[1..]),
846 "impact" => parse_positional("impact", &args[1..], 1, usize::MAX)
847 .map(|(db_dir, files)| Command::Impact { db_dir, files }),
848 "owners" => {
849 parse_positional("owners", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Owners {
850 db_dir,
851 path: rest[0].clone(),
852 })
853 }
854 "why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
855 db_dir,
856 a: rest[0].clone(),
857 b: rest[1].clone(),
858 }),
859 "touch" => parse_touch(&args[1..]),
860 "ingest-git" => parse_ingest_git(&args[1..]),
861 "install" => parse_install_cmd(&args[1..]).map(Command::Install),
862 "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
863 "disable" => parse_toggle_cmd(&args[1..]).map(Command::Disable),
864 "enable" => parse_toggle_cmd(&args[1..]).map(Command::Enable),
865 "doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
866 other => Err(format!("unknown command: {other}")),
867 }
868}
869
870fn default_addr() -> SocketAddr {
871 SocketAddr::from(([127, 0, 0, 1], 8080))
872}
873
874fn parse_serve(args: &[&str]) -> Result<Command, String> {
875 let mut db_dir = None;
876 let mut addr = default_addr();
877 let mut ui = ServeUi::Embedded;
878 let mut saw_ui = false;
879 let mut saw_no_ui = false;
880 let mut demo_if_empty = false;
881 let mut token = None;
882 let mut role_tokens: Vec<(String, String)> = Vec::new();
883 let mut snapshot_every = None;
884 let mut tls_cert: Option<PathBuf> = None;
885 let mut tls_key: Option<PathBuf> = None;
886 let mut i = 0;
887 while i < args.len() {
888 let a = args[i];
889 if a == "--addr" {
890 let val = args
891 .get(i + 1)
892 .copied()
893 .ok_or_else(|| "missing value for --addr".to_string())?;
894 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
895 i += 2;
896 } else if let Some(val) = a.strip_prefix("--addr=") {
897 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
898 i += 1;
899 } else if a == "--ui" {
900 let val = args
901 .get(i + 1)
902 .copied()
903 .ok_or_else(|| "missing value for --ui".to_string())?;
904 ui = ServeUi::Filesystem(PathBuf::from(val));
905 saw_ui = true;
906 i += 2;
907 } else if let Some(val) = a.strip_prefix("--ui=") {
908 ui = ServeUi::Filesystem(PathBuf::from(val));
909 saw_ui = true;
910 i += 1;
911 } else if a == "--no-ui" {
912 ui = ServeUi::None;
913 saw_no_ui = true;
914 i += 1;
915 } else if a == "--demo-if-empty" {
916 demo_if_empty = true;
917 i += 1;
918 } else if a == "--token" {
919 let val = args
920 .get(i + 1)
921 .copied()
922 .ok_or_else(|| "missing value for --token".to_string())?;
923 token = Some(val.to_string());
924 i += 2;
925 } else if let Some(val) = a.strip_prefix("--token=") {
926 token = Some(val.to_string());
927 i += 1;
928 } else if a == "--role-token" {
929 let val = args
930 .get(i + 1)
931 .copied()
932 .ok_or_else(|| "missing value for --role-token".to_string())?;
933 let (tok, role) = parse_role_token(val)?;
934 role_tokens.push((tok, role));
935 i += 2;
936 } else if let Some(val) = a.strip_prefix("--role-token=") {
937 let (tok, role) = parse_role_token(val)?;
938 role_tokens.push((tok, role));
939 i += 1;
940 } else if a == "--snapshot-every" {
941 let val = args
942 .get(i + 1)
943 .copied()
944 .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
945 snapshot_every = Some(parse_snapshot_every(val)?);
946 i += 2;
947 } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
948 snapshot_every = Some(parse_snapshot_every(val)?);
949 i += 1;
950 } else if a == "--tls-cert" {
951 let val = args
952 .get(i + 1)
953 .copied()
954 .ok_or_else(|| "missing value for --tls-cert".to_string())?;
955 tls_cert = Some(PathBuf::from(val));
956 i += 2;
957 } else if let Some(val) = a.strip_prefix("--tls-cert=") {
958 tls_cert = Some(PathBuf::from(val));
959 i += 1;
960 } else if a == "--tls-key" {
961 let val = args
962 .get(i + 1)
963 .copied()
964 .ok_or_else(|| "missing value for --tls-key".to_string())?;
965 tls_key = Some(PathBuf::from(val));
966 i += 2;
967 } else if let Some(val) = a.strip_prefix("--tls-key=") {
968 tls_key = Some(PathBuf::from(val));
969 i += 1;
970 } else if a.starts_with('-') {
971 return Err(format!("unexpected flag: {a}"));
972 } else if db_dir.is_none() {
973 db_dir = Some(PathBuf::from(a));
974 i += 1;
975 } else {
976 return Err(format!("unexpected extra argument: {a}"));
977 }
978 }
979 if saw_ui && saw_no_ui {
980 return Err("cannot combine --ui and --no-ui".to_string());
981 }
982 match (&tls_cert, &tls_key) {
983 (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
984 (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
985 _ => {}
986 }
987 let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
988 Ok(Command::Serve {
989 db_dir,
990 addr,
991 ui,
992 demo_if_empty,
993 token,
994 role_tokens,
995 snapshot_every,
996 tls_cert,
997 tls_key,
998 })
999}
1000
1001fn parse_role_token(val: &str) -> Result<(String, String), String> {
1002 let (tok, role) = val
1003 .split_once(':')
1004 .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
1005 if tok.is_empty() {
1006 return Err("--role-token: TOKEN must not be empty".to_string());
1007 }
1008 if role.is_empty() {
1009 return Err("--role-token: ROLE must not be empty".to_string());
1010 }
1011 Ok((tok.to_string(), role.to_string()))
1012}
1013
1014fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
1015 let secs: u64 = val
1016 .parse()
1017 .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
1018 if secs == 0 {
1019 return Err("--snapshot-every must be a positive number of seconds".into());
1020 }
1021 Ok(Duration::from_secs(secs))
1022}
1023
1024pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
1026 if !dir.is_dir() {
1027 return Err(format!("--ui directory does not exist: {}", dir.display()));
1028 }
1029 let index = dir.join("index.html");
1030 if !index.is_file() {
1031 return Err(format!(
1032 "--ui directory is missing index.html: {}",
1033 dir.display()
1034 ));
1035 }
1036 Ok(dir.to_path_buf())
1037}
1038
1039fn parse_asof(args: &[&str]) -> Result<Command, String> {
1040 let mut db_dir = None;
1041 let mut commit: Option<u64> = None;
1042 let mut query: Option<String> = None;
1043 let mut i = 0;
1044 while i < args.len() {
1045 let a = args[i];
1046 if a == "--commit" {
1047 let val = args
1048 .get(i + 1)
1049 .copied()
1050 .ok_or_else(|| "missing value for --commit".to_string())?;
1051 commit = Some(
1052 val.parse()
1053 .map_err(|_| format!("invalid commit index: {val}"))?,
1054 );
1055 i += 2;
1056 } else if let Some(val) = a.strip_prefix("--commit=") {
1057 commit = Some(
1058 val.parse()
1059 .map_err(|_| format!("invalid commit index: {val}"))?,
1060 );
1061 i += 1;
1062 } else if a == "--query" {
1063 let val = args
1064 .get(i + 1)
1065 .copied()
1066 .ok_or_else(|| "missing value for --query".to_string())?;
1067 query = Some(val.to_string());
1068 i += 2;
1069 } else if let Some(val) = a.strip_prefix("--query=") {
1070 query = Some(val.to_string());
1071 i += 1;
1072 } else if a.starts_with('-') {
1073 return Err(format!("unexpected flag: {a}"));
1074 } else if db_dir.is_none() {
1075 db_dir = Some(PathBuf::from(a));
1076 i += 1;
1077 } else {
1078 return Err(format!("unexpected extra argument: {a}"));
1079 }
1080 }
1081 let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
1082 let commit = commit.ok_or_else(|| "asof requires --commit N".to_string())?;
1083 Ok(Command::AsOf {
1084 db_dir,
1085 commit,
1086 query,
1087 })
1088}
1089
1090pub fn run_asof(db_dir: &Path, commit: u64, query: Option<&str>) -> Result<String, CliError> {
1092 let total = wal_commit_count_at(db_dir)?;
1093 let db = GraphDb::open_at(db_dir, commit)?;
1094 let mut out = String::new();
1095 let _ = writeln!(out, "as-of commit {} of {}", commit, total);
1096 if let Some(cypher) = query {
1097 let params = BTreeMap::new();
1098 let rs = db.query(cypher, ¶ms)?;
1099 out.push_str(&format_result_set(&rs));
1100 }
1101 Ok(out)
1102}
1103
1104fn parse_query(args: &[&str]) -> Result<Command, String> {
1105 let mut db_dir = None;
1106 let mut query_flag: Option<String> = None;
1107 let mut cypher_parts: Vec<&str> = Vec::new();
1108 let mut i = 0;
1109 while i < args.len() {
1110 let a = args[i];
1111 if a == "--query" {
1112 let val = args
1113 .get(i + 1)
1114 .copied()
1115 .ok_or_else(|| "missing value for --query".to_string())?;
1116 query_flag = Some(val.to_string());
1117 i += 2;
1118 } else if let Some(val) = a.strip_prefix("--query=") {
1119 query_flag = Some(val.to_string());
1120 i += 1;
1121 } else if a.starts_with('-') {
1122 return Err(format!("unexpected flag: {a}"));
1123 } else if db_dir.is_none() {
1124 db_dir = Some(PathBuf::from(a));
1125 i += 1;
1126 } else {
1127 cypher_parts.push(a);
1128 i += 1;
1129 }
1130 }
1131 let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
1132 let cypher = if let Some(q) = query_flag {
1133 if !cypher_parts.is_empty() {
1134 return Err(
1135 "query: pass Cypher as remaining arguments or --query, not both".to_string(),
1136 );
1137 }
1138 q
1139 } else {
1140 if cypher_parts.is_empty() {
1141 return Err("query requires a Cypher string".to_string());
1142 }
1143 cypher_parts.join(" ")
1144 };
1145 Ok(Command::Query { db_dir, cypher })
1146}
1147
1148pub fn run_query(db_dir: &Path, cypher: &str) -> Result<String, CliError> {
1150 let params = BTreeMap::new();
1151 let is_write = is_write_query(cypher).map_err(CliError)?;
1152 let rs = if is_write {
1153 let mut db = GraphDb::open(db_dir)?;
1154 db.query_write(cypher, ¶ms)?
1155 } else {
1156 let db = GraphDb::open(db_dir)?;
1157 db.query(cypher, ¶ms)?
1158 };
1159 Ok(format_result_set(&rs))
1160}
1161
1162fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
1163 let mut db_dir = None;
1164 let mut wal = WalDisposition::Archive;
1167 let mut retention: Option<u32> = None;
1168 let mut i = 0;
1169 while i < args.len() {
1170 let a = args[i];
1171 if a == "--keep-wal" {
1172 wal = WalDisposition::Keep;
1173 i += 1;
1174 } else if a == "--truncate" {
1175 wal = WalDisposition::Truncate;
1176 i += 1;
1177 } else if a == "--archive-wal" {
1178 wal = WalDisposition::Archive;
1180 i += 1;
1181 } else if a.starts_with("--retention=") {
1182 let v = a.trim_start_matches("--retention=");
1183 retention = Some(
1184 v.parse::<u32>()
1185 .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
1186 );
1187 i += 1;
1188 } else if a == "--retention" {
1189 i += 1;
1190 let v = args
1191 .get(i)
1192 .ok_or_else(|| "--retention requires a value".to_string())?;
1193 retention = Some(
1194 v.parse::<u32>()
1195 .map_err(|e| format!("--retention value error: {e}"))?,
1196 );
1197 i += 1;
1198 } else if a.starts_with('-') {
1199 return Err(format!("unexpected flag: {a}"));
1200 } else if db_dir.is_none() {
1201 db_dir = Some(PathBuf::from(a));
1202 i += 1;
1203 } else {
1204 return Err(format!("unexpected extra argument: {a}"));
1205 }
1206 }
1207 let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
1208 Ok(Command::Snapshot {
1209 db_dir,
1210 wal,
1211 retention,
1212 })
1213}
1214
1215pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
1224 let current = core_api::SNAPSHOT_VERSION;
1225 let from_ver = core_api::snapshot_version_at(db_dir)?;
1226
1227 if from_ver == Some(current) {
1228 return Ok(format!("already current (V{current})\n"));
1229 }
1230
1231 if from_ver.is_some() {
1236 std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
1237 }
1238
1239 let mut db = GraphDb::open_with_options(
1242 db_dir,
1243 core_api::OpenOptions {
1244 auto_migrate: false,
1245 ..Default::default()
1246 },
1247 )?;
1248 db.snapshot()?;
1249
1250 let msg = match from_ver {
1251 Some(ver) => format!("migrated V{ver} -> V{current}\n"),
1252 None => format!("migrated WAL-only -> V{current}\n"),
1253 };
1254 Ok(msg)
1255}
1256
1257pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
1263 if !db_dir.join("snapshot.bin").exists() {
1266 return Err(CliError(format!(
1267 "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
1268 db_dir.display(),
1269 db_dir.display()
1270 )));
1271 }
1272 let results = core_api::verify_snapshot(db_dir)
1273 .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
1274 let mut any_fail = false;
1275 let mut out = String::new();
1276 for (id, section_name, byte_len, result) in &results {
1277 match result {
1278 Ok(()) => {
1279 let _ = writeln!(
1280 out,
1281 " section {:2} ({:<12}) {:>10} bytes OK",
1282 id, section_name, byte_len
1283 );
1284 }
1285 Err(msg) => {
1286 let _ = writeln!(
1287 out,
1288 " section {:2} ({:<12}) {:>10} bytes CORRUPT: {msg}",
1289 id, section_name, byte_len
1290 );
1291 any_fail = true;
1292 }
1293 }
1294 }
1295 if any_fail {
1296 Err(CliError(format!("integrity check FAILED:\n{out}")))
1297 } else {
1298 Ok(format!(
1299 "integrity check OK ({} sections):\n{out}",
1300 results.len()
1301 ))
1302 }
1303}
1304
1305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1307pub enum WalDisposition {
1308 #[default]
1312 Archive,
1313 Keep,
1316 Truncate,
1321}
1322
1323impl WalDisposition {
1324 fn options(self) -> SnapshotOptions {
1325 match self {
1326 WalDisposition::Archive => AUTOMATIC_SNAPSHOT,
1327 WalDisposition::Keep => SnapshotOptions {
1328 keep_wal: true,
1329 archive_wal: false,
1330 },
1331 WalDisposition::Truncate => SnapshotOptions {
1332 keep_wal: false,
1333 archive_wal: false,
1334 },
1335 }
1336 }
1337}
1338
1339pub fn run_snapshot(
1341 db_dir: &Path,
1342 wal: WalDisposition,
1343 retention: Option<u32>,
1344) -> Result<String, CliError> {
1345 let mut db = GraphDb::open(db_dir)?;
1346 if wal == WalDisposition::Archive {
1347 db.set_wal_archive_retention(retention);
1348 }
1349 db.snapshot_with(wal.options())?;
1350 Ok(format!(
1351 "snapshot written: {}\n",
1352 db_dir.join("snapshot.bin").display()
1353 ))
1354}
1355
1356fn parse_schema(args: &[&str]) -> Result<Command, String> {
1357 if args.is_empty() {
1358 return Err("schema requires a subcommand: apply".to_string());
1359 }
1360 match args[0] {
1361 "apply" => parse_schema_apply(&args[1..]),
1362 other => Err(format!(
1363 "unknown schema subcommand: {other}; expected apply"
1364 )),
1365 }
1366}
1367
1368fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
1369 let mut db_dir = None;
1370 let mut schema_file = None;
1371 for a in args {
1372 if a.starts_with('-') {
1373 return Err(format!("unexpected flag: {a}"));
1374 }
1375 if db_dir.is_none() {
1376 db_dir = Some(PathBuf::from(*a));
1377 } else if schema_file.is_none() {
1378 schema_file = Some(PathBuf::from(*a));
1379 } else {
1380 return Err(format!("unexpected extra argument: {a}"));
1381 }
1382 }
1383 let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
1384 let schema_file =
1385 schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
1386 Ok(Command::SchemaApply {
1387 db_dir,
1388 schema_file,
1389 })
1390}
1391
1392pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
1395 let json = std::fs::read_to_string(schema_file)
1396 .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
1397 let schema: Schema = serde_json::from_str(&json).map_err(|e| {
1398 CliError(format!(
1399 "invalid schema JSON in {}: {e}",
1400 schema_file.display()
1401 ))
1402 })?;
1403 let mut db = GraphDb::open(db_dir)?;
1404 let diff = db.apply_schema(&schema)?;
1405 let mut out = String::new();
1406 for entry in &diff.created {
1407 let _ = writeln!(out, "created {entry}");
1408 }
1409 for entry in &diff.updated {
1410 let _ = writeln!(out, "updated {entry}");
1411 }
1412 for entry in &diff.unchanged {
1413 let _ = writeln!(out, "unchanged {entry}");
1414 }
1415 if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
1416 let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
1417 }
1418 Ok(out)
1419}
1420
1421fn parse_backup(args: &[&str]) -> Result<Command, String> {
1422 let mut db_dir = None;
1423 let mut dest = None;
1424 for a in args {
1425 if a.starts_with('-') {
1426 return Err(format!("unexpected flag: {a}"));
1427 }
1428 if db_dir.is_none() {
1429 db_dir = Some(PathBuf::from(*a));
1430 } else if dest.is_none() {
1431 dest = Some(PathBuf::from(*a));
1432 } else {
1433 return Err(format!("unexpected extra argument: {a}"));
1434 }
1435 }
1436 let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
1437 let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
1438 Ok(Command::Backup { db_dir, dest })
1439}
1440
1441fn parse_export(args: &[&str]) -> Result<Command, String> {
1442 let mut db_dir = None;
1443 let mut dest = None;
1444 let mut format = ExportFormat::Jsonl;
1445 let mut i = 0;
1446 while i < args.len() {
1447 let a = args[i];
1448 if a == "--format" {
1449 let val = args
1450 .get(i + 1)
1451 .copied()
1452 .ok_or_else(|| "missing value for --format".to_string())?;
1453 format = ExportFormat::parse(val).ok_or_else(|| {
1454 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1455 })?;
1456 i += 2;
1457 } else if let Some(val) = a.strip_prefix("--format=") {
1458 format = ExportFormat::parse(val).ok_or_else(|| {
1459 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1460 })?;
1461 i += 1;
1462 } else if a.starts_with('-') {
1463 return Err(format!("unexpected flag: {a}"));
1464 } else if db_dir.is_none() {
1465 db_dir = Some(PathBuf::from(a));
1466 i += 1;
1467 } else if dest.is_none() {
1468 dest = Some(PathBuf::from(a));
1469 i += 1;
1470 } else {
1471 return Err(format!("unexpected extra argument: {a}"));
1472 }
1473 }
1474 let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
1475 let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
1476 Ok(Command::Export {
1477 db_dir,
1478 dest,
1479 format,
1480 })
1481}
1482
1483pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
1485 let db = GraphDb::open(db_dir)?;
1486 Ok(db.backup_to(dest)?)
1487}
1488
1489pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
1491 let mut out = String::new();
1492 writeln!(out, "backup to: {}", dest.display()).unwrap();
1493 writeln!(out, " files: {}", report.files.join(", ")).unwrap();
1494 writeln!(out, " bytes: {}", report.bytes).unwrap();
1495 writeln!(out, " verified: {}", report.verified).unwrap();
1496 out
1497}
1498
1499pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
1501 let db = GraphDb::open(db_dir)?;
1502 let nodes = db.all_nodes_for_export();
1503 let edges = db.all_edges_for_export();
1504 let mut rules = db.rules();
1505 rules.sort_by(|a, b| a.name.cmp(&b.name));
1506 let node_count = nodes.len();
1507 let edge_count = edges.len();
1508 let rule_count = rules.len();
1509 match format {
1510 ExportFormat::Jsonl => {
1511 export::write_jsonl(&nodes, &edges, &rules, dest)?;
1512 Ok(format!(
1513 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1514 dest.display(),
1515 format.name(),
1516 node_count,
1517 edge_count,
1518 rule_count
1519 ))
1520 }
1521 ExportFormat::Parquet => {
1522 export::write_parquet(&nodes, &edges, &rules, dest)?;
1523 Ok(format!(
1524 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1525 dest.display(),
1526 format.name(),
1527 node_count,
1528 edge_count,
1529 rule_count
1530 ))
1531 }
1532 ExportFormat::Graphml => {
1534 let file_path = export::write_graphml(&nodes, &edges, dest)?;
1535 Ok(format!(
1536 "exported to {} (format={}): {} nodes, {} edges\n",
1537 file_path.display(),
1538 format.name(),
1539 node_count,
1540 edge_count,
1541 ))
1542 }
1543 }
1544}
1545
1546fn format_result_set(rs: &ResultSet) -> String {
1547 let mut out = String::new();
1548 let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
1549 for i in 0..rs.len() {
1550 let cells: Vec<String> = rs
1551 .columns()
1552 .iter()
1553 .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
1554 .collect();
1555 let _ = writeln!(out, " {}", cells.join(" "));
1556 }
1557 out
1558}
1559
1560fn parse_algo(args: &[&str]) -> Result<Command, String> {
1561 if args.is_empty() {
1562 return Err(
1563 "algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
1564 );
1565 }
1566 let subcmd = match args[0] {
1567 "pagerank" => AlgoSubcmd::Pagerank,
1568 "wcc" => AlgoSubcmd::Wcc,
1569 "degree" => AlgoSubcmd::Degree,
1570 "communities" => AlgoSubcmd::Communities,
1571 other => {
1572 return Err(format!(
1573 "unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
1574 ))
1575 }
1576 };
1577 let rest = &args[1..];
1578 let mut db_dir = None;
1579 let mut top: usize = 20;
1580 let mut dir = AlgoDir::Both;
1581 let mut edge_types: Vec<String> = Vec::new();
1582 let mut weight_prop: Option<String> = None;
1583 let mut min_weight: Option<f64> = None;
1584 let mut i = 0;
1585 while i < rest.len() {
1586 let a = rest[i];
1587 if a == "--top" {
1588 let val = rest
1589 .get(i + 1)
1590 .copied()
1591 .ok_or_else(|| "missing value for --top".to_string())?;
1592 top = val
1593 .parse()
1594 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1595 i += 2;
1596 } else if let Some(val) = a.strip_prefix("--top=") {
1597 top = val
1598 .parse()
1599 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1600 i += 1;
1601 } else if a == "--dir" {
1602 let val = rest
1603 .get(i + 1)
1604 .copied()
1605 .ok_or_else(|| "missing value for --dir".to_string())?;
1606 dir = parse_algo_dir(val)?;
1607 i += 2;
1608 } else if let Some(val) = a.strip_prefix("--dir=") {
1609 dir = parse_algo_dir(val)?;
1610 i += 1;
1611 } else if a == "--edge-type" {
1612 let val = rest
1613 .get(i + 1)
1614 .copied()
1615 .ok_or_else(|| "missing value for --edge-type".to_string())?;
1616 edge_types.push(val.to_string());
1617 i += 2;
1618 } else if let Some(val) = a.strip_prefix("--edge-type=") {
1619 edge_types.push(val.to_string());
1620 i += 1;
1621 } else if a == "--weight-prop" {
1622 let val = rest
1623 .get(i + 1)
1624 .copied()
1625 .ok_or_else(|| "missing value for --weight-prop".to_string())?;
1626 weight_prop = Some(val.to_string());
1627 i += 2;
1628 } else if let Some(val) = a.strip_prefix("--weight-prop=") {
1629 weight_prop = Some(val.to_string());
1630 i += 1;
1631 } else if a == "--min-weight" {
1632 let val = rest
1633 .get(i + 1)
1634 .copied()
1635 .ok_or_else(|| "missing value for --min-weight".to_string())?;
1636 min_weight = Some(
1637 val.parse()
1638 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1639 );
1640 i += 2;
1641 } else if let Some(val) = a.strip_prefix("--min-weight=") {
1642 min_weight = Some(
1643 val.parse()
1644 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1645 );
1646 i += 1;
1647 } else if a.starts_with('-') {
1648 return Err(format!("unexpected flag: {a}"));
1649 } else if db_dir.is_none() {
1650 db_dir = Some(PathBuf::from(a));
1651 i += 1;
1652 } else {
1653 return Err(format!("unexpected extra argument: {a}"));
1654 }
1655 }
1656 let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
1657 Ok(Command::Algo {
1658 db_dir,
1659 subcmd,
1660 top,
1661 dir,
1662 edge_types,
1663 weight_prop,
1664 min_weight,
1665 })
1666}
1667
1668fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
1670 match val.to_ascii_lowercase().as_str() {
1671 "out" => Ok(AlgoDir::Out),
1672 "in" => Ok(AlgoDir::In),
1673 "both" => Ok(AlgoDir::Both),
1674 other => Err(format!("--dir must be one of out | in | both, got {other}")),
1675 }
1676}
1677
1678pub fn run_map(db_dir: &Path, json: bool) -> Result<String, CliError> {
1684 let db = open_for_reading(db_dir)?;
1685 let map = repograph::repo_map(&db, &repograph::MapOptions::default());
1686 if json {
1687 let mut out = serde_json::to_string_pretty(&map)
1688 .map_err(|e| CliError(format!("serialise map: {e}")))?;
1689 out.push('\n');
1690 return Ok(out);
1691 }
1692 Ok(repograph::render_map(&map))
1693}
1694
1695pub fn run_brief(db_dir: &Path) -> Result<String, CliError> {
1701 let db = open_for_reading(db_dir)?;
1702 let report = repograph::brief(&db, &repograph::BriefOptions::default());
1703 let tool = if db.has_node(ingest_git::SYNC_KEY) {
1704 "explore"
1705 } else {
1706 "context"
1707 };
1708 Ok(repograph::render_brief(&report, &reach_line(db_dir, tool)))
1709}
1710
1711fn reach_line(db_dir: &Path, tool: &str) -> String {
1725 let shell = format!(
1726 "{} {tool} {} <target>",
1727 install::detect_mcp_command(None).shell(),
1728 install::sh_quote(&db_dir.to_string_lossy())
1729 );
1730 match install::delivery_for_store(db_dir) {
1731 install::Delivery::Cli => shell,
1732 _ => format!(
1733 "{tool} <target> (MCP tool){}or: {shell}",
1734 repograph::render::SEP
1735 ),
1736 }
1737}
1738
1739fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
1749 if !db_dir.exists() {
1750 return Err(CliError(format!("no store at {}", db_dir.display())));
1751 }
1752 Ok(GraphDb::open_with_options(
1753 db_dir,
1754 core_api::OpenOptions {
1755 auto_migrate: false,
1756 repair_wal: false,
1757 read_only: true,
1758 },
1759 )?)
1760}
1761
1762pub fn run_explore(
1768 db_dir: &Path,
1769 target: &str,
1770 depth: repograph::Depth,
1771 full: bool,
1772) -> Result<String, CliError> {
1773 let db = open_for_reading(db_dir)?;
1774 let report = repograph::explore(&db, None, target, depth, full);
1775 Ok(repograph::render_explore(
1776 &report,
1777 repograph::DEFAULT_EXPLORE_BYTES,
1778 ))
1779}
1780
1781pub fn run_context(db_dir: &Path, target: &str, full: bool) -> Result<String, CliError> {
1787 let db = open_for_reading(db_dir)?;
1788 Ok(repograph::render_context(&repograph::context_with(
1789 &db,
1790 None,
1791 target,
1792 &repograph::ContextOptions { source: full },
1793 )))
1794}
1795
1796pub fn run_impact(db_dir: &Path, files: &[String]) -> Result<String, CliError> {
1801 let db = open_for_reading(db_dir)?;
1802 let modified: BTreeSet<String> = files.iter().cloned().collect();
1803 let report = repograph::impact(&db, files, &modified, &repograph::ImpactOptions::default());
1804 Ok(repograph::render_impact(&report))
1805}
1806
1807pub fn run_owners(db_dir: &Path, path: &str) -> Result<String, CliError> {
1809 let db = open_for_reading(db_dir)?;
1810 match repograph::owners(&db, path, None) {
1811 Some(report) => Ok(repograph::render_owners(&report)),
1812 None => Err(CliError(format!("no file in the store at {path}"))),
1813 }
1814}
1815
1816pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
1818 let db = open_for_reading(db_dir)?;
1819 Ok(repograph::render_why(&repograph::why(&db, a, b)))
1820}
1821
1822#[allow(clippy::too_many_arguments)]
1828pub fn run_algo(
1829 db_dir: &Path,
1830 subcmd: &AlgoSubcmd,
1831 top: usize,
1832 dir: AlgoDir,
1833 edge_types: Vec<String>,
1834 weight_prop: Option<String>,
1835 min_weight: Option<f64>,
1836) -> Result<String, CliError> {
1837 let db = GraphDb::open(db_dir)?;
1838 match subcmd {
1839 AlgoSubcmd::Pagerank => {
1840 let config = PageRankConfig {
1841 direction: dir,
1842 ..PageRankConfig::default()
1843 };
1844 let report = db.pagerank(&config);
1845 Ok(format_pagerank(&report, top))
1846 }
1847 AlgoSubcmd::Wcc => {
1848 let config = WccConfig::default();
1849 let report = db.connected_components(&config);
1850 Ok(format_wcc(&report, top))
1851 }
1852 AlgoSubcmd::Degree => {
1853 let config = DegreeConfig {
1854 direction: dir,
1855 ..DegreeConfig::default()
1856 };
1857 let report = db.degree_centrality(&config);
1858 Ok(format_degree(&report, top))
1859 }
1860 AlgoSubcmd::Communities => {
1861 let config = LouvainConfig {
1862 edge_types,
1863 weight_prop,
1864 min_weight,
1865 ..LouvainConfig::default()
1866 };
1867 let report = db.communities(&config);
1868 Ok(format_communities(&report, top))
1869 }
1870 }
1871}
1872
1873fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
1874 let mut buf = String::new();
1875 let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
1876 let rows = if top == 0 {
1877 report.scores.as_slice()
1878 } else {
1879 &report.scores[..top.min(report.scores.len())]
1880 };
1881 for (i, (key, score)) in rows.iter().enumerate() {
1882 let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
1883 }
1884 buf
1885}
1886
1887fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
1888 let mut buf = String::new();
1889 let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
1890 let rows = if top == 0 {
1891 report.components.as_slice()
1892 } else {
1893 &report.components[..top.min(report.components.len())]
1894 };
1895 for (key, comp_id) in rows {
1896 let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
1897 }
1898 buf
1899}
1900
1901fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
1902 let mut buf = String::new();
1903 let _ = writeln!(
1904 buf,
1905 "== degree centrality (truncated={}) ==",
1906 report.truncated
1907 );
1908 let rows = if top == 0 {
1909 report.scores.as_slice()
1910 } else {
1911 &report.scores[..top.min(report.scores.len())]
1912 };
1913 for (i, (key, deg)) in rows.iter().enumerate() {
1914 let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
1915 }
1916 buf
1917}
1918
1919fn format_communities(report: &core_api::CommunityReport, top: usize) -> String {
1922 let mut buf = String::new();
1923 let trunc = if report.truncated { " (truncated)" } else { "" };
1924 let _ = writeln!(
1925 buf,
1926 "== communities (modularity={:.2}){trunc} ==",
1927 report.modularity
1928 );
1929 let rows = if top == 0 {
1930 report.communities.as_slice()
1931 } else {
1932 &report.communities[..top.min(report.communities.len())]
1933 };
1934 for c in rows {
1935 let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
1936 let _ = writeln!(
1937 buf,
1938 " {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
1939 c.id,
1940 c.members.len(),
1941 c.cohesion,
1942 preview.join(", ")
1943 );
1944 }
1945 buf
1946}
1947
1948fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
1953 let mut db_dir = None;
1954 let mut auto = false;
1955 for a in args {
1956 if *a == "--auto" {
1957 auto = true;
1958 } else if a.starts_with('-') {
1959 return Err(format!("unexpected flag: {a}"));
1960 } else if db_dir.is_some() {
1961 return Err(format!("unexpected extra argument: {a}"));
1962 } else {
1963 db_dir = Some(PathBuf::from(*a));
1964 }
1965 }
1966 match (&db_dir, auto) {
1967 (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
1968 (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
1969 _ => Ok((db_dir, auto)),
1970 }
1971}
1972
1973fn parse_mcp(args: &[&str]) -> Result<Command, String> {
1977 let all_tools = args.contains(&"--all-tools");
1978 let rest: Vec<&str> = args
1979 .iter()
1980 .copied()
1981 .filter(|a| *a != "--all-tools")
1982 .collect();
1983 parse_dir_or_auto("mcp", &rest).map(|(db_dir, auto)| Command::Mcp {
1984 db_dir,
1985 auto,
1986 all_tools,
1987 })
1988}
1989
1990fn parse_sync(args: &[&str]) -> Result<Command, String> {
1995 let json = args.contains(&"--json");
1996 let rest: Vec<&str> = args.iter().copied().filter(|a| *a != "--json").collect();
1997 parse_dir_or_auto("sync", &rest).map(|(db_dir, auto)| Command::Sync { db_dir, auto, json })
1998}
1999
2000fn parse_touch(args: &[&str]) -> Result<Command, String> {
2003 let mut db_dir = None;
2004 let mut auto = false;
2005 let mut files = Vec::new();
2006 for a in args {
2007 if *a == "--auto" {
2008 auto = true;
2009 } else if a.starts_with('-') {
2010 return Err(format!("unexpected flag: {a}"));
2011 } else if db_dir.is_none() && !auto {
2012 db_dir = Some(PathBuf::from(*a));
2013 } else {
2014 files.push(PathBuf::from(*a));
2015 }
2016 }
2017 if db_dir.is_none() && !auto {
2018 return Err("touch requires <db-dir> or --auto".into());
2019 }
2020 if db_dir.is_some() && auto {
2021 return Err("touch: --auto takes no <db-dir>".into());
2022 }
2023 Ok(Command::Touch {
2024 db_dir,
2025 auto,
2026 files,
2027 })
2028}
2029
2030fn parse_positional(
2037 cmd: &str,
2038 args: &[&str],
2039 min: usize,
2040 max: usize,
2041) -> Result<(PathBuf, Vec<String>), String> {
2042 let mut rest: Vec<String> = Vec::new();
2043 let mut db_dir: Option<PathBuf> = None;
2044 for a in args {
2045 if a.starts_with('-') {
2046 return Err(format!("unexpected flag: {a}"));
2047 }
2048 match db_dir {
2049 None => db_dir = Some(PathBuf::from(*a)),
2050 Some(_) => rest.push((*a).to_string()),
2051 }
2052 }
2053 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
2054 if rest.len() < min {
2055 return Err(format!(
2056 "{cmd} requires <db-dir> and {min} more argument{}",
2057 if min == 1 { "" } else { "s" }
2058 ));
2059 }
2060 if rest.len() > max {
2061 return Err(format!("unexpected extra argument: {}", rest[max]));
2062 }
2063 Ok((db_dir, rest))
2064}
2065
2066fn parse_explore(args: &[&str]) -> Result<Command, String> {
2071 let mut rest: Vec<String> = Vec::new();
2072 let mut db_dir: Option<PathBuf> = None;
2073 let mut depth = repograph::Depth::Context;
2074 let mut full = false;
2075 let mut want_depth = false;
2076 for a in args {
2077 if want_depth {
2078 depth = repograph::Depth::parse(a).ok_or_else(|| {
2079 format!(
2080 "--depth must be one of {}, got {a}",
2081 repograph::Depth::NAMES.join(" | ")
2082 )
2083 })?;
2084 want_depth = false;
2085 } else if *a == "--depth" {
2086 want_depth = true;
2087 } else if *a == "--full" {
2088 full = true;
2089 } else if a.starts_with('-') {
2090 return Err(format!("unexpected flag: {a}"));
2091 } else if db_dir.is_none() {
2092 db_dir = Some(PathBuf::from(*a));
2093 } else {
2094 rest.push((*a).to_string());
2095 }
2096 }
2097 if want_depth {
2098 return Err("--depth requires a value".to_string());
2099 }
2100 let db_dir = db_dir.ok_or_else(|| "explore requires <db-dir>".to_string())?;
2101 match rest.len() {
2102 0 => Err("explore requires <db-dir> and 1 more argument".to_string()),
2103 1 => Ok(Command::Explore {
2104 db_dir,
2105 target: rest.remove(0),
2106 depth,
2107 full,
2108 }),
2109 _ => Err(format!("unexpected extra argument: {}", rest[1])),
2110 }
2111}
2112
2113fn parse_context(args: &[&str]) -> Result<Command, String> {
2119 let mut rest: Vec<String> = Vec::new();
2120 let mut db_dir: Option<PathBuf> = None;
2121 let mut full = false;
2122 for a in args {
2123 if *a == "--full" {
2124 full = true;
2125 } else if a.starts_with('-') {
2126 return Err(format!("unexpected flag: {a}"));
2127 } else if db_dir.is_none() {
2128 db_dir = Some(PathBuf::from(*a));
2129 } else {
2130 rest.push((*a).to_string());
2131 }
2132 }
2133 let db_dir = db_dir.ok_or_else(|| "context requires <db-dir>".to_string())?;
2134 match rest.len() {
2135 0 => Err("context requires <db-dir> and 1 more argument".to_string()),
2136 1 => Ok(Command::Context {
2137 db_dir,
2138 target: rest.remove(0),
2139 full,
2140 }),
2141 _ => Err(format!("unexpected extra argument: {}", rest[1])),
2142 }
2143}
2144
2145fn parse_dir_with_json(cmd: &str, args: &[&str]) -> Result<(PathBuf, bool), String> {
2147 let mut db_dir = None;
2148 let mut json = false;
2149 for a in args {
2150 if *a == "--json" {
2151 json = true;
2152 } else if a.starts_with('-') {
2153 return Err(format!("unexpected flag: {a}"));
2154 } else if db_dir.is_some() {
2155 return Err(format!("unexpected extra argument: {a}"));
2156 } else {
2157 db_dir = Some(PathBuf::from(*a));
2158 }
2159 }
2160 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
2161 Ok((db_dir, json))
2162}
2163
2164fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
2165 let mut db_dir = None;
2166 for a in args {
2167 if a.starts_with('-') {
2168 return Err(format!("unexpected flag: {a}"));
2169 }
2170 if db_dir.is_some() {
2171 return Err(format!("unexpected extra argument: {a}"));
2172 }
2173 db_dir = Some(PathBuf::from(*a));
2174 }
2175 db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
2176}
2177
2178pub fn format_stats(stats: &Stats) -> String {
2180 let mut out = String::new();
2181 let _ = writeln!(
2182 out,
2183 "nodes: {} live, {} tombstoned",
2184 stats.nodes_live, stats.nodes_tombstoned
2185 );
2186 let _ = writeln!(out, "edges: {}", stats.edges);
2187 let _ = writeln!(out, "rules: {}", stats.rules.len());
2188 for r in &stats.rules {
2189 let _ = writeln!(
2190 out,
2191 " {:<28} edges={} tripped={}",
2192 r.name, r.edges, r.tripped
2193 );
2194 }
2195 out
2196}
2197
2198pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
2200 let db = SharedDb::open(dir)?;
2201 let stats = db.read().stats();
2202 Ok(stats)
2203}
2204
2205pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
2211 refuse_non_empty(dir)?;
2212
2213 let db = SharedDb::open(dir)?;
2214 let opts = IngestOptions::default();
2215 let mut auto_fk_rules = Vec::new();
2216
2217 {
2218 let mut w = db.write();
2219 for (label, json) in [
2220 ("Org", org_json()),
2221 ("Project", project_json()),
2222 ("Person", person_json()),
2223 ] {
2224 let report = w.ingest_json(label, &json, &opts)?;
2225 if !report.row_errors.is_empty() {
2226 return Err(CliError(format!(
2227 "demo ingest of {label} had row errors: {:?}",
2228 report.row_errors
2229 )));
2230 }
2231 auto_fk_rules.extend(report.rules_created);
2232 }
2233 let skill_fit = Predicate::Overlap {
2234 field: "skills".into(),
2235 min: 0.5,
2236 };
2237 let skill_fit_k = Some(default_max_edges(&skill_fit));
2238 w.create_rule(RuleDef {
2239 name: "skill_fit".into(),
2240 src_label: "Person".into(),
2241 dst_label: "Project".into(),
2242 predicate: skill_fit,
2243 edge_type: "FIT".into(),
2244 weight_prop: Some("score".into()),
2245 max_edges: skill_fit_k,
2246 approximate: false,
2247 via_label: None,
2248 via_edge: None,
2249 via_dir: None,
2250 })?;
2251 let founded_within = Predicate::NumericWithin {
2252 field: "founded_year".into(),
2253 tolerance: 2.0,
2254 };
2255 let founded_within_k = Some(default_max_edges(&founded_within));
2256 w.create_rule(RuleDef {
2257 name: "founded_within".into(),
2258 src_label: "Org".into(),
2259 dst_label: "Org".into(),
2260 predicate: founded_within,
2261 edge_type: "FOUNDED_WITHIN".into(),
2262 weight_prop: Some("score".into()),
2263 max_edges: founded_within_k,
2264 approximate: false,
2265 via_label: None,
2266 via_edge: None,
2267 via_dir: None,
2268 })?;
2269 let nearby_office = Predicate::GeoRadius {
2270 field: "office".into(),
2271 km: 50.0,
2272 };
2273 let nearby_office_k = Some(default_max_edges(&nearby_office));
2274 w.create_rule(RuleDef {
2275 name: "nearby_office".into(),
2276 src_label: "Org".into(),
2277 dst_label: "Org".into(),
2278 predicate: nearby_office,
2279 edge_type: "NEARBY_OFFICE".into(),
2280 weight_prop: Some("score".into()),
2281 max_edges: nearby_office_k,
2282 approximate: false,
2283 via_label: None,
2284 via_edge: None,
2285 via_dir: None,
2286 })?;
2287 let similar_interests = Predicate::VectorSimilar {
2288 field: "embedding".into(),
2289 min: 0.8,
2290 };
2291 let similar_interests_k = Some(default_max_edges(&similar_interests));
2292 w.create_rule(RuleDef {
2293 name: "similar_interests".into(),
2294 src_label: "Person".into(),
2295 dst_label: "Person".into(),
2296 predicate: similar_interests,
2297 edge_type: "SIMILAR".into(),
2298 weight_prop: Some("score".into()),
2299 max_edges: similar_interests_k,
2300 approximate: false,
2301 via_label: None,
2302 via_edge: None,
2303 via_dir: None,
2304 })?;
2305 for (label, field) in [("Org", "name"), ("Project", "name"), ("Person", "name")] {
2307 w.enable_fulltext(label, field)?;
2308 }
2309 }
2310
2311 let r = db.read();
2312 let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
2313 let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
2314 let stats = r.stats();
2315 let suggestion = r.suggest_rules().into_iter().next();
2317
2318 Ok(DemoOutcome {
2319 auto_fk_rules,
2320 sample_query: SAMPLE_QUERY.to_string(),
2321 sample_result,
2322 explanations,
2323 stats,
2324 suggestion,
2325 })
2326}
2327
2328fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
2329 if dir.is_file() {
2330 return Err(CliError(format!(
2331 "demo refuses a non-empty directory: {} is a file",
2332 dir.display()
2333 )));
2334 }
2335 if !dir.exists() {
2336 return Ok(true);
2337 }
2338 Ok(std::fs::read_dir(dir)?.next().is_none())
2339}
2340
2341fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
2342 if dir_is_empty_or_absent(dir)? {
2343 Ok(())
2344 } else {
2345 Err(CliError(format!(
2346 "demo refuses a non-empty directory: {} \
2347 (directory must be empty — including hidden files)",
2348 dir.display()
2349 )))
2350 }
2351}
2352
2353pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
2355 if dir_is_empty_or_absent(dir)? {
2356 Ok(Some(run_demo(dir)?))
2357 } else {
2358 Ok(None)
2359 }
2360}
2361
2362fn json_array(rows: impl IntoIterator<Item = String>) -> String {
2363 let mut out = String::from("[");
2364 let mut first = true;
2365 for row in rows {
2366 if !first {
2367 out.push(',');
2368 }
2369 first = false;
2370 out.push_str(&row);
2371 }
2372 out.push(']');
2373 out
2374}
2375
2376fn wrap_proj(i: usize) -> usize {
2378 (i - 1) % N_PROJECTS + 1
2379}
2380
2381fn skill_window_json(start: usize, len: usize) -> String {
2383 let parts: Vec<String> = (0..len)
2384 .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
2385 .collect();
2386 format!("[{}]", parts.join(","))
2387}
2388
2389fn org_office(i: usize) -> (f64, f64) {
2393 match i {
2394 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"),
2405 }
2406}
2407
2408fn person_embedding_json(i: usize) -> String {
2412 let mut v = [0.0_f64; 8];
2413 match i {
2414 9 | 19 | 29 => {
2415 v[0] = 0.8;
2416 v[1] = 0.6;
2417 }
2418 10 | 20 | 30 => {
2419 v[0] = 0.6;
2420 v[1] = 0.8;
2421 }
2422 _ => {
2423 let axis = (i - 1) % 10;
2424 debug_assert!(axis < 8);
2425 v[axis] = 1.0;
2426 }
2427 }
2428 let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
2429 format!("[{}]", parts.join(","))
2430}
2431
2432fn org_json() -> String {
2433 json_array((1..=N_ORGS).map(|i| {
2434 let year = 2010 + (i as i64 - 1);
2435 let (lat, lon) = org_office(i);
2436 format!(
2437 r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
2438 skill_window_json(i, 3)
2439 )
2440 }))
2441}
2442
2443fn project_json() -> String {
2444 json_array((1..=N_PROJECTS).map(|i| {
2445 let org = (i - 1) % N_ORGS + 1;
2446 format!(
2447 r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
2448 skill_window_json(i, 3)
2449 )
2450 }))
2451}
2452
2453fn person_json() -> String {
2454 json_array((1..=N_PEOPLE).map(|i| {
2455 let org = (i - 1) % N_ORGS + 1;
2456 let proj = (i - 1) % N_PROJECTS + 1;
2457 format!(
2458 r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
2459 person_embedding_json(i),
2460 skill_window_json(proj, 3)
2461 )
2462 }))
2463}
2464
2465pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
2467 let mut buf = String::new();
2468 let _ = writeln!(buf, "== demo ==");
2469 let _ = writeln!(
2470 buf,
2471 "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
2472 );
2473 let _ = writeln!(
2474 buf,
2475 "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
2476 );
2477 let _ = writeln!(
2478 buf,
2479 "numeric rule: founded_within (Org.founded_year, tolerance 2)"
2480 );
2481 let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
2482 let _ = writeln!(
2483 buf,
2484 "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
2485 );
2486 let _ = writeln!(buf);
2487 let _ = writeln!(buf, "== auto-FK rules ==");
2488 let mut names = out.auto_fk_rules.clone();
2489 names.sort();
2490 for name in names {
2491 let _ = writeln!(buf, " {name}");
2492 }
2493 let _ = writeln!(buf);
2494 let _ = writeln!(buf, "== query ==");
2495 let _ = writeln!(buf, "{}", out.sample_query);
2496 let _ = writeln!(buf);
2497 let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
2498 for i in 0..out.sample_result.len() {
2499 let cells: Vec<String> = out
2500 .sample_result
2501 .columns()
2502 .iter()
2503 .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
2504 .collect();
2505 let _ = writeln!(buf, " {}", cells.join(" "));
2506 }
2507 let _ = writeln!(buf);
2508 let _ = writeln!(
2509 buf,
2510 "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
2511 );
2512 for e in &out.explanations {
2513 let weight = e
2514 .weight
2515 .map(|w| fmt_value(&Value::Float(w)))
2516 .unwrap_or_else(|| "none".into());
2517 let _ = writeln!(
2518 buf,
2519 " rule={} type={} {}→{} weight={}",
2520 e.rule, e.edge_type, e.src_key, e.dst_key, weight
2521 );
2522 }
2523 let _ = writeln!(buf);
2524 let _ = writeln!(buf, "== serve ==");
2525 let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
2526
2527 if let Some(s) = &out.suggestion {
2529 let _ = writeln!(buf);
2530 let _ = writeln!(buf, "== suggested rule (teaser) ==");
2531 let _ = writeln!(buf, " {}", s.def.name);
2532 let _ = writeln!(
2533 buf,
2534 " {} → {} via {:?}",
2535 s.def.src_label, s.def.dst_label, s.def.predicate
2536 );
2537 let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
2538 let _ = writeln!(buf, " {}", s.rationale);
2539 let _ = writeln!(
2540 buf,
2541 " (run `mushroomdb suggest {}` for full analysis)",
2542 dir.display()
2543 );
2544 }
2545
2546 buf
2547}
2548
2549pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
2551 let db = GraphDb::open(dir)?;
2552 Ok(db.suggest_rules())
2553}
2554
2555pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
2557 let mut buf = String::new();
2558 if suggestions.is_empty() {
2559 let _ = writeln!(
2560 buf,
2561 "no rule suggestions (database may be empty or rules already cover all patterns)"
2562 );
2563 return buf;
2564 }
2565 let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
2566 for (i, s) in suggestions.iter().enumerate() {
2567 let _ = writeln!(buf);
2568 let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
2569 let _ = writeln!(
2570 buf,
2571 " {} → {} via {:?}",
2572 s.def.src_label, s.def.dst_label, s.def.predicate
2573 );
2574 let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
2575 let _ = writeln!(buf, " rationale : {}", s.rationale);
2576 if !s.examples.is_empty() {
2577 let _ = writeln!(buf, " examples :");
2578 for (src, dst, score) in &s.examples {
2579 let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
2580 }
2581 }
2582 let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
2583 let _ = writeln!(
2584 buf,
2585 " to apply : POST /rules or db.create_rule(suggestion.def)"
2586 );
2587 }
2588 buf
2589}
2590
2591fn fmt_value(v: &Value) -> String {
2592 match v {
2593 Value::Int(i) => i.to_string(),
2594 Value::Float(f) => {
2595 let s = format!("{f}");
2596 if s.contains('.') || s.contains('e') || s.contains('E') {
2597 s
2598 } else {
2599 format!("{s}.0")
2600 }
2601 }
2602 Value::Str(s) => s.clone(),
2603 Value::Bool(b) => b.to_string(),
2604 Value::List(xs) => {
2605 let inner: Vec<String> = xs.iter().map(fmt_value).collect();
2606 format!("[{}]", inner.join(", "))
2607 }
2608 Value::Map(m) => {
2609 let inner: Vec<String> = m
2610 .iter()
2611 .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
2612 .collect();
2613 format!("{{{}}}", inner.join(", "))
2614 }
2615 }
2616}
2617
2618fn fmt_cell(cell: Option<&Value>) -> String {
2619 match cell {
2620 None => "null".into(),
2621 Some(v) => fmt_value(v),
2622 }
2623}
2624
2625#[cfg(test)]
2626mod tests {
2627 use super::*;
2628 use std::collections::BTreeSet;
2629 use std::net::SocketAddr;
2630 use std::path::PathBuf;
2631
2632 fn tmp(name: &str) -> PathBuf {
2633 let nanos = std::time::SystemTime::now()
2634 .duration_since(std::time::UNIX_EPOCH)
2635 .expect("clock")
2636 .as_nanos();
2637 let d = std::env::temp_dir().join(format!(
2638 "graphdb-cli-{}-{}-{}",
2639 name,
2640 std::process::id(),
2641 nanos
2642 ));
2643 let _ = std::fs::remove_dir_all(&d);
2644 d
2645 }
2646
2647 fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
2648 let g = db.read();
2649 let mut out = BTreeSet::new();
2650 for i in 1..=N_ORGS {
2651 let src = format!("org-{i:02}");
2652 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2653 for dst in nbrs {
2654 out.insert((src.clone(), dst));
2655 }
2656 }
2657 }
2658 for i in 1..=N_PEOPLE {
2659 let src = format!("person-{i:02}");
2660 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2661 for dst in nbrs {
2662 out.insert((src.clone(), dst));
2663 }
2664 }
2665 }
2666 out
2667 }
2668
2669 fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
2670 let hits: Vec<_> = db
2671 .read()
2672 .explain(a, b)
2673 .expect("explain")
2674 .into_iter()
2675 .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
2676 .collect();
2677 assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
2678 let got = hits[0].weight.expect("weighted");
2679 assert!(
2680 (got - want).abs() < 1e-12,
2681 "{rule} {a}→{b}: got {got} want {want}"
2682 );
2683 }
2684
2685 fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
2686 const R: f64 = 6371.0088;
2687 let phi1 = lat1.to_radians();
2688 let phi2 = lat2.to_radians();
2689 let dphi = (lat2 - lat1).to_radians();
2690 let dlam = (lon2 - lon1).to_radians();
2691 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
2692 .clamp(0.0, 1.0);
2693 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
2694 R * c
2695 }
2696
2697 fn default_bind() -> SocketAddr {
2698 SocketAddr::from(([127, 0, 0, 1], 8080))
2699 }
2700
2701 #[test]
2702 fn parse_args_table() {
2703 struct Case {
2704 args: &'static [&'static str],
2705 check: fn(Result<Command, String>),
2706 }
2707
2708 let cases = [
2709 Case {
2710 args: &[],
2711 check: |r| match r {
2712 Ok(Command::Help) => {}
2713 other => panic!("no-args → Help, got {other:?}"),
2714 },
2715 },
2716 Case {
2717 args: &["--help"],
2718 check: |r| match r {
2719 Ok(Command::Help) => {}
2720 other => panic!("--help → Help, got {other:?}"),
2721 },
2722 },
2723 Case {
2724 args: &["-h"],
2725 check: |r| match r {
2726 Ok(Command::Help) => {}
2727 other => panic!("-h → Help, got {other:?}"),
2728 },
2729 },
2730 Case {
2731 args: &["serve", "/tmp/demo-db"],
2732 check: |r| match r {
2733 Ok(Command::Serve {
2734 db_dir,
2735 addr,
2736 ui,
2737 demo_if_empty,
2738 token,
2739 role_tokens,
2740 snapshot_every,
2741 tls_cert,
2742 tls_key,
2743 }) => {
2744 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2745 assert_eq!(addr, default_bind());
2746 assert_eq!(ui, super::ServeUi::Embedded);
2747 assert!(!demo_if_empty);
2748 assert_eq!(token, None);
2749 assert!(role_tokens.is_empty());
2750 assert_eq!(snapshot_every, None);
2751 assert_eq!(tls_cert, None);
2752 assert_eq!(tls_key, None);
2753 }
2754 other => panic!("serve <dir> → Serve default addr, got {other:?}"),
2755 },
2756 },
2757 Case {
2758 args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
2759 check: |r| match r {
2760 Ok(Command::Serve {
2761 db_dir,
2762 addr,
2763 ui,
2764 demo_if_empty,
2765 token,
2766 role_tokens,
2767 snapshot_every,
2768 tls_cert,
2769 tls_key,
2770 }) => {
2771 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2772 assert_eq!(
2773 addr,
2774 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
2775 );
2776 assert_eq!(ui, super::ServeUi::Embedded);
2777 assert!(!demo_if_empty);
2778 assert_eq!(token, None);
2779 assert!(role_tokens.is_empty());
2780 assert_eq!(snapshot_every, None);
2781 assert_eq!(tls_cert, None);
2782 assert_eq!(tls_key, None);
2783 }
2784 other => panic!("serve --addr after dir, got {other:?}"),
2785 },
2786 },
2787 Case {
2788 args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
2789 check: |r| match r {
2790 Ok(Command::Serve {
2791 db_dir,
2792 addr,
2793 ui,
2794 demo_if_empty,
2795 token,
2796 role_tokens,
2797 snapshot_every,
2798 tls_cert,
2799 tls_key,
2800 }) => {
2801 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2802 assert_eq!(
2803 addr,
2804 "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
2805 );
2806 assert_eq!(ui, super::ServeUi::Embedded);
2807 assert!(!demo_if_empty);
2808 assert_eq!(token, None);
2809 let _ = role_tokens; assert_eq!(snapshot_every, None);
2811 assert_eq!(tls_cert, None);
2812 assert_eq!(tls_key, None);
2813 }
2814 other => panic!("serve --addr=VALUE, got {other:?}"),
2815 },
2816 },
2817 Case {
2818 args: &["mcp", "/tmp/demo-db"],
2819 check: |r| match r {
2820 Ok(Command::Mcp {
2821 db_dir,
2822 auto,
2823 all_tools,
2824 }) => {
2825 assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
2826 assert!(!auto);
2827 assert!(!all_tools, "the short list is the default");
2828 }
2829 other => panic!("mcp <dir>, got {other:?}"),
2830 },
2831 },
2832 Case {
2833 args: &["stats", "/tmp/demo-db"],
2834 check: |r| match r {
2835 Ok(Command::Stats { db_dir }) => {
2836 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2837 }
2838 other => panic!("stats <dir>, got {other:?}"),
2839 },
2840 },
2841 Case {
2842 args: &["demo", "/tmp/demo-db"],
2843 check: |r| match r {
2844 Ok(Command::Demo { db_dir }) => {
2845 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2846 }
2847 other => panic!("demo <dir>, got {other:?}"),
2848 },
2849 },
2850 Case {
2851 args: &["context", "db", "x"],
2852 check: |r| match r {
2853 Ok(Command::Context {
2854 db_dir,
2855 target,
2856 full,
2857 }) => {
2858 assert_eq!(db_dir, PathBuf::from("db"));
2859 assert_eq!(target, "x");
2860 assert!(!full, "the default answer is a pointer, not a body");
2861 }
2862 other => panic!("context <dir> <target>, got {other:?}"),
2863 },
2864 },
2865 Case {
2866 args: &["context", "db", "x", "--full"],
2867 check: |r| {
2868 assert_eq!(
2869 r.unwrap(),
2870 Command::Context {
2871 db_dir: PathBuf::from("db"),
2872 target: "x".into(),
2873 full: true,
2874 }
2875 );
2876 },
2877 },
2878 Case {
2879 args: &["explore", "db", "x", "--depth", "impact"],
2880 check: |r| {
2881 assert_eq!(
2882 r.unwrap(),
2883 Command::Explore {
2884 db_dir: PathBuf::from("db"),
2885 target: "x".into(),
2886 depth: repograph::Depth::Impact,
2887 full: false,
2888 }
2889 );
2890 },
2891 },
2892 Case {
2893 args: &["serve"],
2894 check: |r| {
2895 let e = r.expect_err("serve without dir");
2896 assert!(
2897 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2898 "missing-dir error should mention dir, got {e}"
2899 );
2900 },
2901 },
2902 Case {
2903 args: &["mcp"],
2904 check: |r| {
2905 let e = r.expect_err("mcp without dir");
2906 assert!(
2907 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2908 "missing-dir error should mention dir, got {e}"
2909 );
2910 },
2911 },
2912 Case {
2913 args: &["stats"],
2914 check: |r| {
2915 let e = r.expect_err("stats without dir");
2916 assert!(
2917 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2918 "missing-dir error should mention dir, got {e}"
2919 );
2920 },
2921 },
2922 Case {
2923 args: &["demo"],
2924 check: |r| {
2925 let e = r.expect_err("demo without dir");
2926 assert!(
2927 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2928 "missing-dir error should mention dir, got {e}"
2929 );
2930 },
2931 },
2932 Case {
2933 args: &["serve", "/tmp/demo-db", "--addr"],
2934 check: |r| {
2935 let e = r.expect_err("--addr missing value");
2936 assert!(
2937 e.to_lowercase().contains("addr"),
2938 "--addr missing value should mention addr, got {e}"
2939 );
2940 },
2941 },
2942 Case {
2943 args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
2944 check: |r| {
2945 let e = r.expect_err("invalid addr");
2946 assert!(
2947 e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
2948 "invalid addr should mention address, got {e}"
2949 );
2950 },
2951 },
2952 Case {
2953 args: &["frobnicate", "/tmp/demo-db"],
2954 check: |r| {
2955 let e = r.expect_err("unknown command");
2956 assert!(
2957 e.to_lowercase().contains("unknown")
2958 || e.to_lowercase().contains("frobnicate"),
2959 "unknown command should name it, got {e}"
2960 );
2961 },
2962 },
2963 Case {
2964 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
2965 check: |r| match r {
2966 Ok(Command::Serve { ui, .. }) => {
2967 assert_eq!(
2968 ui,
2969 super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
2970 );
2971 }
2972 other => panic!("serve --ui <dir>, got {other:?}"),
2973 },
2974 },
2975 Case {
2976 args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
2977 check: |r| match r {
2978 Ok(Command::Serve { ui, .. }) => {
2979 assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
2980 }
2981 other => panic!("serve --ui=VALUE, got {other:?}"),
2982 },
2983 },
2984 Case {
2985 args: &["serve", "/tmp/demo-db", "--ui"],
2986 check: |r| {
2987 let e = r.expect_err("--ui missing value");
2988 assert!(
2989 e.to_lowercase().contains("ui"),
2990 "--ui missing value should mention ui, got {e}"
2991 );
2992 },
2993 },
2994 Case {
2995 args: &["serve", "/tmp/demo-db", "--no-ui"],
2996 check: |r| match r {
2997 Ok(Command::Serve { ui, .. }) => {
2998 assert_eq!(ui, super::ServeUi::None);
2999 }
3000 other => panic!("serve --no-ui, got {other:?}"),
3001 },
3002 },
3003 Case {
3004 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
3005 check: |r| {
3006 let e = r.expect_err("combine --ui and --no-ui");
3007 assert!(
3008 e.contains("--ui") && e.contains("--no-ui"),
3009 "conflict should name both flags, got {e}"
3010 );
3011 },
3012 },
3013 Case {
3014 args: &["serve", "/tmp/demo-db", "extra"],
3015 check: |r| {
3016 let e = r.expect_err("extra positional");
3017 assert!(
3018 e.to_lowercase().contains("unexpected")
3019 || e.to_lowercase().contains("extra"),
3020 "extra arg should be rejected, got {e}"
3021 );
3022 },
3023 },
3024 Case {
3025 args: &[
3026 "serve",
3027 "/data",
3028 "--addr",
3029 "0.0.0.0:8080",
3030 "--demo-if-empty",
3031 ],
3032 check: |r| match r {
3033 Ok(Command::Serve {
3034 db_dir,
3035 addr,
3036 demo_if_empty,
3037 ui,
3038 token,
3039 snapshot_every,
3040 ..
3041 }) => {
3042 assert_eq!(db_dir, PathBuf::from("/data"));
3043 assert_eq!(
3044 addr,
3045 "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
3046 );
3047 assert!(demo_if_empty);
3048 assert_eq!(ui, super::ServeUi::Embedded);
3049 assert_eq!(token, None);
3050 assert_eq!(snapshot_every, None);
3051 }
3052 other => panic!("serve --demo-if-empty docker default, got {other:?}"),
3053 },
3054 },
3055 Case {
3056 args: &["install", "--project", "--delivery", "cli"],
3057 check: |r| match r {
3058 Ok(Command::Install(opts)) => {
3059 assert_eq!(opts.scope, Some(install::Scope::Project));
3060 assert_eq!(opts.delivery, install::Delivery::Cli);
3061 }
3062 other => panic!("install --delivery cli, got {other:?}"),
3063 },
3064 },
3065 Case {
3066 args: &["install", "--delivery=mcp"],
3067 check: |r| match r {
3068 Ok(Command::Install(opts)) => {
3069 assert_eq!(opts.delivery, install::Delivery::Mcp)
3070 }
3071 other => panic!("install --delivery=mcp, got {other:?}"),
3072 },
3073 },
3074 Case {
3075 args: &["install"],
3078 check: |r| match r {
3079 Ok(Command::Install(opts)) => {
3080 assert_eq!(opts.delivery, install::Delivery::Both)
3081 }
3082 other => panic!("install, got {other:?}"),
3083 },
3084 },
3085 Case {
3086 args: &["install", "--delivery", "sideways"],
3087 check: |r| match r {
3088 Err(e) => assert!(e.contains("--delivery must be cli | mcp | both"), "{e}"),
3089 other => panic!("a bad --delivery must be refused, got {other:?}"),
3090 },
3091 },
3092 Case {
3093 args: &["install", "--intercept-grep"],
3094 check: |r| match r {
3095 Ok(Command::Install(opts)) => assert!(opts.intercept_grep),
3096 other => panic!("install --intercept-grep, got {other:?}"),
3097 },
3098 },
3099 Case {
3100 args: &["install"],
3102 check: |r| match r {
3103 Ok(Command::Install(opts)) => assert!(!opts.intercept_grep),
3104 other => panic!("install, got {other:?}"),
3105 },
3106 },
3107 Case {
3108 args: &["intercept", "--auto"],
3109 check: |r| match r {
3110 Ok(Command::Intercept { db_dir, auto }) => {
3111 assert_eq!(db_dir, None);
3112 assert!(auto);
3113 }
3114 other => panic!("intercept --auto, got {other:?}"),
3115 },
3116 },
3117 Case {
3118 args: &["intercept", "/tmp/db"],
3119 check: |r| match r {
3120 Ok(Command::Intercept { db_dir, auto }) => {
3121 assert_eq!(db_dir, Some(PathBuf::from("/tmp/db")));
3122 assert!(!auto);
3123 }
3124 other => panic!("intercept /tmp/db, got {other:?}"),
3125 },
3126 },
3127 Case {
3128 args: &["intercept"],
3129 check: |r| match r {
3130 Err(e) => assert!(e.contains("intercept requires <db-dir> or --auto"), "{e}"),
3131 other => panic!("intercept with no store, got {other:?}"),
3132 },
3133 },
3134 ];
3135
3136 for case in &cases {
3137 (case.check)(parse_args(case.args));
3138 }
3139 }
3140
3141 #[test]
3142 fn serve_default_addr_is_loopback_8080() {
3143 match parse_args(&["serve", "/tmp/db"]).unwrap() {
3144 Command::Serve { addr, .. } => {
3145 assert_eq!(
3146 addr,
3147 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
3148 );
3149 }
3150 other => panic!("{other:?}"),
3151 }
3152 }
3153
3154 #[test]
3155 fn serve_snapshot_every_parses_seconds() {
3156 match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
3157 Command::Serve { snapshot_every, .. } => {
3158 assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
3159 }
3160 other => panic!("{other:?}"),
3161 }
3162 match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
3163 Command::Serve { snapshot_every, .. } => {
3164 assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
3165 }
3166 other => panic!("{other:?}"),
3167 }
3168 match parse_args(&["serve", "/tmp/db"]).unwrap() {
3169 Command::Serve { snapshot_every, .. } => {
3170 assert_eq!(snapshot_every, None);
3171 }
3172 other => panic!("{other:?}"),
3173 }
3174 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
3175 assert!(
3176 err.contains("snapshot-every"),
3177 "missing value should name the flag, got {err}"
3178 );
3179 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
3180 assert!(
3181 err.contains("snapshot-every"),
3182 "zero should be rejected, got {err}"
3183 );
3184 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
3185 assert!(
3186 err.contains("snapshot-every"),
3187 "invalid value should name the flag, got {err}"
3188 );
3189 }
3190
3191 #[test]
3192 fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
3193 match parse_args(&[
3195 "serve",
3196 "/tmp/db",
3197 "--addr",
3198 "0.0.0.0:8080",
3199 "--token",
3200 "s3cret",
3201 ])
3202 .unwrap()
3203 {
3204 Command::Serve { token, addr, .. } => {
3205 assert_eq!(token.as_deref(), Some("s3cret"));
3206 assert_eq!(addr.ip().to_string(), "0.0.0.0");
3207 }
3208 other => panic!("{other:?}"),
3209 }
3210 }
3211
3212 #[test]
3213 fn parse_snapshot_and_query() {
3214 for (args, want) in [
3216 (vec!["snapshot", "/tmp/db"], WalDisposition::Archive),
3217 (
3218 vec!["snapshot", "/tmp/db", "--archive-wal"],
3219 WalDisposition::Archive,
3220 ),
3221 (
3222 vec!["snapshot", "/tmp/db", "--keep-wal"],
3223 WalDisposition::Keep,
3224 ),
3225 (
3226 vec!["snapshot", "/tmp/db", "--truncate"],
3227 WalDisposition::Truncate,
3228 ),
3229 ] {
3230 match parse_args(&args).unwrap() {
3231 Command::Snapshot { wal, .. } => assert_eq!(wal, want, "{args:?}"),
3232 other => panic!("{other:?}"),
3233 }
3234 }
3235 match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
3236 Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
3237 other => panic!("{other:?}"),
3238 }
3239 match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
3240 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3241 other => panic!("{other:?}"),
3242 }
3243 match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
3244 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
3245 other => panic!("{other:?}"),
3246 }
3247 let text = usage();
3248 assert!(
3249 text.contains("query"),
3250 "usage should mention query, got:\n{text}"
3251 );
3252 assert!(
3253 text.contains("snapshot"),
3254 "usage should mention snapshot, got:\n{text}"
3255 );
3256 }
3257
3258 #[test]
3259 fn usage_lists_every_subcommand() {
3260 let text = usage();
3261 for word in [
3262 "serve",
3263 "mcp",
3264 "stats",
3265 "demo",
3266 "query",
3267 "snapshot",
3268 "--keep-wal",
3269 "mushroomdb",
3270 "--ui",
3271 "--no-ui",
3272 "--demo-if-empty",
3273 "--token",
3274 "--snapshot-every",
3275 ] {
3276 assert!(
3277 text.contains(word),
3278 "usage should mention {word}, got:\n{text}"
3279 );
3280 }
3281 }
3282
3283 #[test]
3284 fn validate_ui_dir_requires_index_html() {
3285 let missing = tmp("ui-missing");
3286 let err = super::validate_ui_dir(&missing).expect_err("missing dir");
3287 assert!(
3288 err.contains("does not exist"),
3289 "missing dir error, got {err}"
3290 );
3291
3292 let empty = tmp("ui-empty");
3293 std::fs::create_dir_all(&empty).unwrap();
3294 let err = super::validate_ui_dir(&empty).expect_err("no index");
3295 assert!(
3296 err.contains("index.html"),
3297 "missing index.html error, got {err}"
3298 );
3299
3300 let ok = tmp("ui-ok");
3301 std::fs::create_dir_all(&ok).unwrap();
3302 std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
3303 let got = super::validate_ui_dir(&ok).expect("valid ui dir");
3304 assert_eq!(got, ok);
3305 }
3306
3307 #[test]
3308 fn maybe_run_demo_if_empty_seeds_then_skips() {
3309 let dir = tmp("boot-empty");
3310 let first = super::maybe_run_demo_if_empty(&dir)
3311 .expect("empty dir demos")
3312 .expect("Some(DemoOutcome)");
3313 assert_eq!(first.stats.nodes_live, 60);
3314 let db = SharedDb::open(&dir).expect("reopen");
3315 assert!(db.read().has_node("person-01"));
3316 let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
3317 assert!(
3318 second.is_none(),
3319 "second boot must not re-demo a populated volume"
3320 );
3321
3322 let occupied = tmp("boot-occupied");
3323 std::fs::create_dir_all(&occupied).unwrap();
3324 std::fs::write(occupied.join("keep-me"), b"x").unwrap();
3325 let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
3326 assert!(skipped.is_none());
3327 assert_eq!(
3328 std::fs::read(occupied.join("keep-me")).unwrap(),
3329 b"x",
3330 "existing volume contents must be untouched"
3331 );
3332 }
3333
3334 #[test]
3335 fn demo_builder_is_deterministic_and_refuses_second_run() {
3336 let dir = tmp("demo");
3337 let out = run_demo(&dir).expect("first demo run");
3338
3339 assert_eq!(
3340 out.stats.nodes_live, 60,
3341 "10 orgs + 20 projects + 30 people"
3342 );
3343 assert_eq!(out.stats.nodes_tombstoned, 0);
3344 assert_eq!(out.stats.edges, 334);
3352 assert_eq!(
3353 out.stats.rules.len(),
3354 7,
3355 "3 auto-FK + overlap + numeric + geo + vector"
3356 );
3357 let fit = out
3358 .stats
3359 .rules
3360 .iter()
3361 .find(|r| r.name == "skill_fit")
3362 .expect("skill_fit");
3363 assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
3364 let founded = out
3365 .stats
3366 .rules
3367 .iter()
3368 .find(|r| r.name == "founded_within")
3369 .expect("founded_within");
3370 assert_eq!(founded.edges, 34);
3371 let nearby = out
3372 .stats
3373 .rules
3374 .iter()
3375 .find(|r| r.name == "nearby_office")
3376 .expect("nearby_office");
3377 assert_eq!(nearby.edges, 16);
3378 let similar = out
3379 .stats
3380 .rules
3381 .iter()
3382 .find(|r| r.name == "similar_interests")
3383 .expect("similar_interests");
3384 assert_eq!(similar.edges, 114);
3385
3386 let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
3387 names.sort_unstable();
3388 assert_eq!(
3389 names,
3390 vec![
3391 "auto_fk_person_org_id",
3392 "auto_fk_person_project_id",
3393 "auto_fk_project_org_id",
3394 "founded_within",
3395 "nearby_office",
3396 "similar_interests",
3397 "skill_fit",
3398 ]
3399 );
3400
3401 let db = SharedDb::open(&dir).expect("reopen demo");
3403 assert_eq!(
3404 db.read().fulltext_pairs(),
3405 vec![
3406 ("Org".to_string(), "name".to_string()),
3407 ("Person".to_string(), "name".to_string()),
3408 ("Project".to_string(), "name".to_string()),
3409 ]
3410 );
3411
3412 let mut auto = out.auto_fk_rules.clone();
3413 auto.sort();
3414 assert_eq!(
3415 auto,
3416 vec![
3417 "auto_fk_person_org_id".to_string(),
3418 "auto_fk_person_project_id".to_string(),
3419 "auto_fk_project_org_id".to_string(),
3420 ]
3421 );
3422
3423 assert!(
3424 !out.sample_result.is_empty(),
3425 "sample Cypher query must return rows"
3426 );
3427 assert!(
3428 out.sample_query.contains("ORDER BY score DESC"),
3429 "sample query must rank by score, got {}",
3430 out.sample_query
3431 );
3432 let scores: Vec<f64> = (0..out.sample_result.len())
3433 .map(|i| match out.sample_result.get(i, "score") {
3434 Some(Value::Float(f)) => *f,
3435 other => panic!("score col should be Float, got {other:?}"),
3436 })
3437 .collect();
3438 let distinct: std::collections::BTreeSet<u64> =
3439 scores.iter().map(|s| s.to_bits()).collect();
3440 assert!(
3441 distinct.len() >= 2,
3442 "sample results must be visibly ranked, got {scores:?}"
3443 );
3444 for w in scores.windows(2) {
3445 assert!(
3446 w[0] >= w[1],
3447 "scores must be non-increasing, got {scores:?}"
3448 );
3449 }
3450 assert!(
3451 !out.explanations.is_empty(),
3452 "explain(person-01, proj-01) must find the derived edges"
3453 );
3454
3455 let db = SharedDb::open(&dir).expect("reopen demo");
3456 assert_eq!(
3457 directed_pairs(&db, "FOUNDED_WITHIN"),
3458 [
3459 ("org-01", "org-02"),
3460 ("org-01", "org-03"),
3461 ("org-02", "org-01"),
3462 ("org-02", "org-03"),
3463 ("org-02", "org-04"),
3464 ("org-03", "org-01"),
3465 ("org-03", "org-02"),
3466 ("org-03", "org-04"),
3467 ("org-03", "org-05"),
3468 ("org-04", "org-02"),
3469 ("org-04", "org-03"),
3470 ("org-04", "org-05"),
3471 ("org-04", "org-06"),
3472 ("org-05", "org-03"),
3473 ("org-05", "org-04"),
3474 ("org-05", "org-06"),
3475 ("org-05", "org-07"),
3476 ("org-06", "org-04"),
3477 ("org-06", "org-05"),
3478 ("org-06", "org-07"),
3479 ("org-06", "org-08"),
3480 ("org-07", "org-05"),
3481 ("org-07", "org-06"),
3482 ("org-07", "org-08"),
3483 ("org-07", "org-09"),
3484 ("org-08", "org-06"),
3485 ("org-08", "org-07"),
3486 ("org-08", "org-09"),
3487 ("org-08", "org-10"),
3488 ("org-09", "org-07"),
3489 ("org-09", "org-08"),
3490 ("org-09", "org-10"),
3491 ("org-10", "org-08"),
3492 ("org-10", "org-09"),
3493 ]
3494 .into_iter()
3495 .map(|(a, b)| (a.to_string(), b.to_string()))
3496 .collect::<BTreeSet<_>>()
3497 );
3498 assert_eq!(
3499 directed_pairs(&db, "NEARBY_OFFICE"),
3500 [
3501 ("org-01", "org-07"),
3502 ("org-01", "org-10"),
3503 ("org-02", "org-09"),
3504 ("org-03", "org-08"),
3505 ("org-04", "org-05"),
3506 ("org-04", "org-06"),
3507 ("org-05", "org-04"),
3508 ("org-05", "org-06"),
3509 ("org-06", "org-04"),
3510 ("org-06", "org-05"),
3511 ("org-07", "org-01"),
3512 ("org-07", "org-10"),
3513 ("org-08", "org-03"),
3514 ("org-09", "org-02"),
3515 ("org-10", "org-01"),
3516 ("org-10", "org-07"),
3517 ]
3518 .into_iter()
3519 .map(|(a, b)| (a.to_string(), b.to_string()))
3520 .collect::<BTreeSet<_>>()
3521 );
3522 assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
3523 let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
3524 assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
3525 assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
3526 assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
3527
3528 let err = run_demo(&dir).expect_err("second run into the same dir");
3529 let msg = err.to_string().to_lowercase();
3530 assert!(
3531 msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
3532 "refuse message must mention non-empty dir, got {err}"
3533 );
3534 assert!(
3535 msg.contains("hidden"),
3536 "refuse message must mention hidden files, got {err}"
3537 );
3538
3539 let _ = std::fs::remove_dir_all(&dir);
3540 }
3541
3542 #[test]
3543 fn run_snapshot_writes_snapshot_bin() {
3544 let dir = tmp("snapshot-cli");
3545 {
3546 let mut db = GraphDb::open(&dir).expect("open");
3547 db.insert_node("Person", "alice", vec![]).expect("insert");
3548 }
3549 assert!(
3550 !dir.join("snapshot.bin").exists(),
3551 "GraphDb Drop must not snapshot"
3552 );
3553 let out = run_snapshot(&dir, WalDisposition::Archive, None).expect("snapshot");
3554 assert!(
3555 dir.join("snapshot.bin").is_file(),
3556 "run_snapshot must write snapshot.bin"
3557 );
3558 assert!(
3559 out.contains("snapshot.bin"),
3560 "snapshot output should mention snapshot.bin, got {out}"
3561 );
3562 let db = GraphDb::open(&dir).expect("reopen");
3563 assert!(db.has_node("alice"), "reopen after snapshot must recover");
3564 let _ = std::fs::remove_dir_all(&dir);
3565 }
3566
3567 #[test]
3572 fn an_automatic_snapshot_keeps_history_reachable_and_truncate_ends_it() {
3573 let dir = tmp("snapshot-archive");
3574 {
3575 let mut db = GraphDb::open(&dir).expect("open");
3576 db.insert_node("Person", "alice", vec![]).expect("insert");
3577 }
3578 let before = wal_commit_count_at(&dir).expect("count");
3579 assert!(before > 0, "the insert is a commit");
3580
3581 {
3583 let shared = SharedDb::open(&dir).expect("open");
3584 snapshot_shared(&shared).expect("snapshot");
3585 }
3586
3587 let archives = || {
3588 std::fs::read_dir(&dir)
3589 .expect("read dir")
3590 .filter_map(Result::ok)
3591 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
3592 .count()
3593 };
3594 assert_eq!(archives(), 1, "the WAL was archived, not dropped");
3595 assert!(
3596 dir.join("wal.genesis").is_file(),
3597 "the genesis marker is what lets asof reach an archived commit"
3598 );
3599 {
3600 let db = GraphDb::open(&dir).expect("reopen");
3601 assert!(db.has_node("alice"));
3602 assert!(
3603 !db.node_history("alice").expect("history").is_empty(),
3604 "the insert is still explainable"
3605 );
3606 }
3607 assert!(
3608 GraphDb::open_at(&dir, before - 1).is_ok(),
3609 "asof still reaches a commit the snapshot folded in"
3610 );
3611
3612 run_snapshot(&dir, WalDisposition::Truncate, None).expect("truncate");
3615 assert!(
3616 !dir.join("wal.genesis").exists(),
3617 "truncating ends asof's reach into the archives"
3618 );
3619 let db = GraphDb::open(&dir).expect("reopen");
3620 assert!(
3621 db.has_node("alice"),
3622 "the data survives; only the past goes"
3623 );
3624 let _ = std::fs::remove_dir_all(&dir);
3625 }
3626
3627 #[test]
3635 fn automatic_snapshots_keep_a_bounded_number_of_archives() {
3636 let dir = tmp("snapshot-retention");
3637 let archives = |d: &Path| {
3638 std::fs::read_dir(d)
3639 .expect("read dir")
3640 .filter_map(Result::ok)
3641 .filter(|e| e.file_name().to_string_lossy().ends_with(".archive"))
3642 .count()
3643 };
3644
3645 let rounds = 10;
3648 for i in 0..rounds {
3649 {
3650 let mut db = GraphDb::open(&dir).expect("open");
3651 db.insert_node("Person", &format!("p{i}"), vec![])
3652 .expect("insert");
3653 }
3654 let shared = SharedDb::open(&dir).expect("open shared");
3655 snapshot_shared(&shared).expect("snapshot");
3656 }
3657
3658 assert_eq!(
3659 archives(&dir),
3660 AUTO_SNAPSHOT_RETENTION as usize,
3661 "{rounds} automatic snapshots must not leave {rounds} archives"
3662 );
3663
3664 let db = GraphDb::open(&dir).expect("reopen");
3667 for i in 0..rounds {
3668 assert!(db.has_node(&format!("p{i}")), "p{i} survived the pruning");
3669 }
3670 assert!(
3674 db.node_history("p0").expect("history").is_empty(),
3675 "the pruned archives take their history with them"
3676 );
3677 assert!(
3678 !db.node_history("p9").expect("history").is_empty(),
3679 "the retained window is still explainable"
3680 );
3681 drop(db);
3682
3683 {
3686 let mut db = GraphDb::open(&dir).expect("open");
3687 db.insert_node("Person", "after", vec![]).expect("insert");
3688 }
3689 let latest = GraphDb::open(&dir).expect("reopen").commit_seq();
3690 assert!(
3691 GraphDb::open_at(&dir, latest - 1).is_ok(),
3692 "asof still reaches commits past the last snapshot"
3693 );
3694
3695 let manual = tmp("snapshot-retention-manual");
3698 for i in 0..3 {
3699 {
3700 let mut db = GraphDb::open(&manual).expect("open");
3701 db.insert_node("Person", &format!("p{i}"), vec![])
3702 .expect("insert");
3703 }
3704 run_snapshot(&manual, WalDisposition::Archive, None).expect("snapshot");
3705 }
3706 assert_eq!(
3707 archives(&manual),
3708 3,
3709 "`mushroomdb snapshot` with no --retention keeps every archive"
3710 );
3711
3712 let _ = std::fs::remove_dir_all(&dir);
3713 let _ = std::fs::remove_dir_all(&manual);
3714 }
3715
3716 #[test]
3717 fn run_query_formats_like_asof() {
3718 let dir = tmp("query-cli");
3719 {
3720 let mut db = GraphDb::open(&dir).expect("open");
3721 db.insert_node(
3722 "Person",
3723 "alice",
3724 vec![("id".into(), Value::Str("alice".into()))],
3725 )
3726 .expect("insert");
3727 }
3728 let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id").expect("query");
3729 assert!(out.contains("columns:"), "got {out}");
3730 assert!(out.contains("id=alice"), "got {out}");
3731 let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})").expect("write");
3732 let db = GraphDb::open(&dir).expect("reopen");
3733 assert!(db.has_node("bob"), "query_write must persist CREATE");
3734 let _ = std::fs::remove_dir_all(&dir);
3735 }
3736
3737 #[test]
3738 fn format_stats_contains_counts() {
3739 let dir = tmp("stats-smoke");
3740 let out = run_demo(&dir).expect("demo for stats smoke");
3741 let text = format_stats(&out.stats);
3742 assert!(
3743 text.contains("60"),
3744 "stats output should include live node count, got:\n{text}"
3745 );
3746 assert!(
3747 text.contains("334"),
3748 "stats output should include edge count, got:\n{text}"
3749 );
3750 assert!(
3751 text.to_lowercase().contains("node"),
3752 "stats output should mention nodes, got:\n{text}"
3753 );
3754 assert!(
3755 text.to_lowercase().contains("edge"),
3756 "stats output should mention edges, got:\n{text}"
3757 );
3758 let _ = std::fs::remove_dir_all(&dir);
3759 }
3760
3761 #[test]
3764 fn parse_backup_round_trip() {
3765 let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
3766 match r {
3767 Ok(Command::Backup { db_dir, dest }) => {
3768 assert_eq!(db_dir, PathBuf::from("/db/dir"));
3769 assert_eq!(dest, PathBuf::from("/backup/dest"));
3770 }
3771 other => panic!("backup parse, got {other:?}"),
3772 }
3773 }
3774
3775 #[test]
3776 fn parse_backup_missing_dest_errors() {
3777 let r = parse_args(&["backup", "/db/dir"]);
3778 assert!(r.is_err(), "backup without <dest> should error");
3779 let e = r.unwrap_err();
3780 assert!(
3781 e.to_lowercase().contains("dest"),
3782 "error should mention dest, got: {e}"
3783 );
3784 }
3785
3786 #[test]
3787 fn parse_export_defaults_to_jsonl() {
3788 let r = parse_args(&["export", "/db/dir", "/export/dest"]);
3789 match r {
3790 Ok(Command::Export { format, .. }) => {
3791 assert_eq!(format, ExportFormat::Jsonl);
3792 }
3793 other => panic!("export parse, got {other:?}"),
3794 }
3795 }
3796
3797 #[test]
3798 fn parse_export_parquet_flag() {
3799 let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
3800 match r {
3801 Ok(Command::Export { format, .. }) => {
3802 assert_eq!(format, ExportFormat::Parquet);
3803 }
3804 other => panic!("export --format parquet parse, got {other:?}"),
3805 }
3806 }
3807
3808 #[test]
3809 fn parse_export_parquet_flag_eq() {
3810 let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
3811 match r {
3812 Ok(Command::Export { format, .. }) => {
3813 assert_eq!(format, ExportFormat::Parquet);
3814 }
3815 other => panic!("export --format=parquet parse, got {other:?}"),
3816 }
3817 }
3818
3819 #[test]
3820 fn run_backup_cli_produces_verified_report() {
3821 let src = tmp("cli-backup-src");
3822 let dst = tmp("cli-backup-dst");
3823 let _ = run_demo(&src).expect("demo");
3824 let report = run_backup(&src, &dst).expect("run_backup");
3825 assert!(report.verified, "backup must be verified");
3826 assert!(!report.files.is_empty());
3827 assert!(report.bytes > 0);
3828 let _ = std::fs::remove_dir_all(&src);
3829 let _ = std::fs::remove_dir_all(&dst);
3830 }
3831
3832 #[test]
3833 fn run_export_jsonl_two_runs_byte_identical() {
3834 let src = tmp("cli-export-src");
3835 let dst1 = tmp("cli-export-dst1");
3836 let dst2 = tmp("cli-export-dst2");
3837 let _ = run_demo(&src).expect("demo");
3838
3839 run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
3840 run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
3841
3842 for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
3843 let f1 = std::fs::read(dst1.join(filename)).expect("read first");
3844 let f2 = std::fs::read(dst2.join(filename)).expect("read second");
3845 assert_eq!(
3846 f1, f2,
3847 "{filename} must be byte-identical across two export runs"
3848 );
3849 }
3850 let _ = std::fs::remove_dir_all(&src);
3851 let _ = std::fs::remove_dir_all(&dst1);
3852 let _ = std::fs::remove_dir_all(&dst2);
3853 }
3854
3855 #[test]
3856 fn run_export_jsonl_nodes_are_sorted() {
3857 let src = tmp("cli-export-sorted");
3858 let dst = tmp("cli-export-sorted-dst");
3859 let _ = run_demo(&src).expect("demo");
3860 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
3861
3862 let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
3863 let keys: Vec<String> = content
3864 .lines()
3865 .filter(|l| !l.is_empty())
3866 .map(|l| {
3867 let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
3868 v["key"].as_str().unwrap_or("").to_string()
3869 })
3870 .collect();
3871 let mut sorted = keys.clone();
3872 sorted.sort();
3873 assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
3874 let _ = std::fs::remove_dir_all(&src);
3875 let _ = std::fs::remove_dir_all(&dst);
3876 }
3877
3878 #[test]
3879 fn run_export_jsonl_derived_edges_have_rule() {
3880 let src = tmp("cli-export-derived");
3881 let dst = tmp("cli-export-derived-dst");
3882 let _ = run_demo(&src).expect("demo");
3883 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
3884
3885 let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
3886 let derived_lines: Vec<serde_json::Value> = content
3887 .lines()
3888 .filter(|l| !l.is_empty())
3889 .map(|l| serde_json::from_str(l).expect("parse line"))
3890 .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
3891 .collect();
3892 assert!(
3893 !derived_lines.is_empty(),
3894 "demo store should have derived edges"
3895 );
3896 for edge in &derived_lines {
3897 assert!(
3898 !edge["rule"].is_null(),
3899 "derived edge must have non-null rule: {edge}"
3900 );
3901 }
3902 let _ = std::fs::remove_dir_all(&src);
3903 let _ = std::fs::remove_dir_all(&dst);
3904 }
3905
3906 #[test]
3907 fn run_export_parquet_produces_files() {
3908 let src = tmp("cli-export-parq-src");
3909 let dst = tmp("cli-export-parq-dst");
3910 let _ = run_demo(&src).expect("demo");
3911 run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
3912
3913 assert!(
3914 dst.join("nodes.parquet").exists(),
3915 "nodes.parquet must exist"
3916 );
3917 assert!(
3918 dst.join("edges.parquet").exists(),
3919 "edges.parquet must exist"
3920 );
3921 assert!(
3922 dst.join("rules.parquet").exists(),
3923 "rules.parquet must exist"
3924 );
3925 for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
3927 let meta = std::fs::metadata(dst.join(f)).expect("metadata");
3928 assert!(meta.len() > 0, "{f} must be non-empty");
3929 }
3930 let _ = std::fs::remove_dir_all(&src);
3931 let _ = std::fs::remove_dir_all(&dst);
3932 }
3933
3934 #[test]
3935 fn parse_export_graphml_flag() {
3936 let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
3937 match r {
3938 Ok(Command::Export { format, .. }) => {
3939 assert_eq!(format, ExportFormat::Graphml);
3940 }
3941 other => panic!("export --format graphml parse, got {other:?}"),
3942 }
3943 }
3944
3945 #[test]
3946 fn run_export_graphml_structure() {
3947 let src = tmp("cli-export-gml-src");
3948 let dst_dir = tmp("cli-export-gml-dst");
3949 let dst = dst_dir.join("graph.graphml");
3950 let _ = run_demo(&src).expect("demo");
3951 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3952
3953 let content = std::fs::read_to_string(&dst).expect("read graphml");
3954
3955 assert!(
3956 content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
3957 "must start with an XML declaration"
3958 );
3959 assert!(
3960 content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
3961 "must use the standard GraphML namespace"
3962 );
3963 assert!(
3964 content.contains(
3965 "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
3966 ),
3967 "must declare the node label key"
3968 );
3969 assert!(
3970 content.contains(
3971 "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
3972 ),
3973 "must declare the edge type key"
3974 );
3975 assert!(
3976 content.contains(
3977 "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
3978 ),
3979 "must declare the edge derived key"
3980 );
3981 assert!(
3982 content.contains(
3983 "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
3984 ),
3985 "must declare the edge rule key"
3986 );
3987 assert!(
3988 content.contains(
3989 "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
3990 ),
3991 "must declare the edge weight key"
3992 );
3993 assert!(
3997 content.contains(
3998 "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
3999 ),
4000 "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
4001 );
4002 assert!(
4003 content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
4004 "must declare a single directed graph element"
4005 );
4006 assert!(content.contains("<node id="), "must contain node elements");
4007 assert!(
4008 content.contains("<edge id=\"e0\" source=\""),
4009 "must contain a sequentially-numbered edge starting at e0"
4010 );
4011 assert!(
4012 content.trim_end().ends_with("</graphml>"),
4013 "must close the root element"
4014 );
4015
4016 assert!(
4019 content.contains("<data key=\"e_rule\">skill_fit</data>")
4020 || content.contains("<data key=\"e_rule\">founded_within</data>"),
4021 "at least one derived edge must carry its rule name"
4022 );
4023 assert!(
4024 content.contains(&format!(
4025 "<data key=\"{}\">",
4026 "e_weight" )),
4028 "at least one derived edge must carry a weight value"
4029 );
4030
4031 let _ = std::fs::remove_dir_all(&src);
4032 let _ = std::fs::remove_dir_all(&dst_dir);
4033 }
4034
4035 #[test]
4036 fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
4037 let src = tmp("cli-export-gml-dir-src");
4038 let dst_dir = tmp("cli-export-gml-dir-dst");
4039 std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
4040 let _ = run_demo(&src).expect("demo");
4041
4042 let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
4043
4044 assert!(
4045 dst_dir.join("graph.graphml").exists(),
4046 "an existing directory dest must produce dest/graph.graphml"
4047 );
4048 assert!(
4049 msg.contains("graph.graphml"),
4050 "report must name the file actually written, got: {msg}"
4051 );
4052
4053 let _ = std::fs::remove_dir_all(&src);
4054 let _ = std::fs::remove_dir_all(&dst_dir);
4055 }
4056
4057 #[test]
4060 fn run_export_graphml_is_well_formed_xml() {
4061 let has_python3 = std::process::Command::new("python3")
4062 .arg("--version")
4063 .output()
4064 .map(|o| o.status.success())
4065 .unwrap_or(false);
4066 if !has_python3 {
4067 eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
4068 return;
4069 }
4070
4071 let src = tmp("cli-export-gml-wf-src");
4072 let dst_dir = tmp("cli-export-gml-wf-dst");
4073 let dst = dst_dir.join("graph.graphml");
4074 let _ = run_demo(&src).expect("demo");
4075 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4076
4077 let status = std::process::Command::new("python3")
4078 .arg("-c")
4079 .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
4080 .arg(&dst)
4081 .status()
4082 .expect("run python3");
4083 assert!(
4084 status.success(),
4085 "python3's XML parser must accept the exported GraphML file"
4086 );
4087
4088 let _ = std::fs::remove_dir_all(&src);
4089 let _ = std::fs::remove_dir_all(&dst_dir);
4090 }
4091
4092 #[test]
4093 fn run_export_graphml_two_runs_byte_identical() {
4094 let src = tmp("cli-export-gml-bi-src");
4095 let dst_dir1 = tmp("cli-export-gml-bi-dst1");
4096 let dst_dir2 = tmp("cli-export-gml-bi-dst2");
4097 let dst1 = dst_dir1.join("graph.graphml");
4098 let dst2 = dst_dir2.join("graph.graphml");
4099 let _ = run_demo(&src).expect("demo");
4100
4101 run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
4102 run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
4103
4104 let f1 = std::fs::read(&dst1).expect("read first");
4105 let f2 = std::fs::read(&dst2).expect("read second");
4106 assert_eq!(
4107 f1, f2,
4108 "graph.graphml must be byte-identical across two export runs"
4109 );
4110
4111 let _ = std::fs::remove_dir_all(&src);
4112 let _ = std::fs::remove_dir_all(&dst_dir1);
4113 let _ = std::fs::remove_dir_all(&dst_dir2);
4114 }
4115
4116 #[test]
4117 fn run_export_graphml_escapes_and_lists() {
4118 use core_api::{GraphDb, Value};
4119 let src = tmp("cli-export-gml-esc-src");
4120 let dst_dir = tmp("cli-export-gml-esc-dst");
4121 let dst = dst_dir.join("graph.graphml");
4122
4123 {
4124 let mut db = GraphDb::open(&src).unwrap();
4125 db.insert_node(
4126 "Widget",
4127 "w1",
4128 vec![
4129 (
4130 "title".into(),
4131 Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
4132 ),
4133 (
4134 "tags".into(),
4135 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
4136 ),
4137 ],
4138 )
4139 .unwrap();
4140 }
4141
4142 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4143 let content = std::fs::read_to_string(&dst).expect("read graphml");
4144
4145 assert!(
4146 content.contains("Tom & Jerry <says> "hi" 'bye'"),
4147 "special XML characters in string props must be escaped, got: {content}"
4148 );
4149 assert!(
4150 !content.contains("Tom & Jerry <says>"),
4151 "unescaped special characters must not appear verbatim"
4152 );
4153 assert!(
4154 content.contains(
4155 "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
4156 ),
4157 "list-valued props must declare attr.type=\"string\""
4158 );
4159 assert!(
4160 content.contains("<data key=\"n_tags\">["a","b"]</data>"),
4161 "list-valued props must render as XML-escaped JSON text, got: {content}"
4162 );
4163
4164 let _ = std::fs::remove_dir_all(&src);
4165 let _ = std::fs::remove_dir_all(&dst_dir);
4166 }
4167
4168 #[test]
4173 fn run_export_graphml_mixed_type_prop_declares_string() {
4174 use core_api::{GraphDb, Value};
4175 let src = tmp("cli-export-gml-mixed-src");
4176 let dst_dir = tmp("cli-export-gml-mixed-dst");
4177 let dst = dst_dir.join("graph.graphml");
4178
4179 {
4180 let mut db = GraphDb::open(&src).unwrap();
4181 db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
4182 .unwrap();
4183 db.insert_node(
4184 "Metric",
4185 "m2",
4186 vec![("score".into(), Value::Str("high".into()))],
4187 )
4188 .unwrap();
4189 }
4190
4191 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
4192 let content = std::fs::read_to_string(&dst).expect("read graphml");
4193
4194 assert!(
4195 content.contains(
4196 "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
4197 ),
4198 "a prop name with conflicting value types across nodes must declare \
4199 attr.type=\"string\", got: {content}"
4200 );
4201 assert!(
4202 !content.contains("attr.name=\"score\" attr.type=\"long\""),
4203 "must not declare a narrower type once a conflict is seen, got: {content}"
4204 );
4205 assert!(
4208 content.contains("<data key=\"n_score\">5</data>"),
4209 "the int-valued node must still render its literal int text, got: {content}"
4210 );
4211 assert!(
4212 content.contains("<data key=\"n_score\">high</data>"),
4213 "the string-valued node must still render its literal string text, got: {content}"
4214 );
4215
4216 let _ = std::fs::remove_dir_all(&src);
4217 let _ = std::fs::remove_dir_all(&dst_dir);
4218 }
4219
4220 #[test]
4221 fn parse_algo_degree_defaults_dir_both() {
4222 let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
4223 match cmd {
4224 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
4225 other => panic!("expected Algo, got {other:?}"),
4226 }
4227 }
4228
4229 #[test]
4230 fn parse_algo_degree_with_dir_flag() {
4231 for (arg, want) in [
4232 ("out", AlgoDir::Out),
4233 ("in", AlgoDir::In),
4234 ("both", AlgoDir::Both),
4235 ] {
4236 let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
4237 match cmd {
4238 Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
4239 other => panic!("expected Algo, got {other:?}"),
4240 }
4241 }
4242 let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
4244 match cmd {
4245 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
4246 other => panic!("expected Algo, got {other:?}"),
4247 }
4248 }
4249
4250 #[test]
4251 fn parse_algo_rejects_unknown_dir() {
4252 assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
4253 }
4254
4255 #[test]
4256 fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
4257 let cmd = parse_args(&[
4258 "algo",
4259 "communities",
4260 "/db",
4261 "--edge-type",
4262 "IMPORTS",
4263 "--edge-type=CO_CHANGED",
4264 "--weight-prop",
4265 "score",
4266 "--min-weight",
4267 "0.3",
4268 "--top",
4269 "5",
4270 ])
4271 .unwrap();
4272 match cmd {
4273 Command::Algo {
4274 subcmd,
4275 top,
4276 edge_types,
4277 weight_prop,
4278 min_weight,
4279 ..
4280 } => {
4281 assert_eq!(subcmd, AlgoSubcmd::Communities);
4282 assert_eq!(top, 5);
4283 assert_eq!(
4284 edge_types,
4285 vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
4286 );
4287 assert_eq!(weight_prop, Some("score".to_string()));
4288 assert_eq!(min_weight, Some(0.3));
4289 }
4290 other => panic!("expected Algo, got {other:?}"),
4291 }
4292 }
4293
4294 #[test]
4295 fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
4296 let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
4297 match cmd {
4298 Command::Algo {
4299 subcmd,
4300 edge_types,
4301 weight_prop,
4302 min_weight,
4303 ..
4304 } => {
4305 assert_eq!(subcmd, AlgoSubcmd::Communities);
4306 assert!(edge_types.is_empty());
4307 assert_eq!(weight_prop, None);
4308 assert_eq!(min_weight, None);
4309 }
4310 other => panic!("expected Algo, got {other:?}"),
4311 }
4312 }
4313
4314 #[test]
4317 fn run_export_jsonl_nan_float_becomes_null() {
4318 use core_api::{GraphDb, Value};
4319 let src = tmp("cli-export-nan-src");
4320 let dst = tmp("cli-export-nan-dst");
4321
4322 {
4324 let mut db = GraphDb::open(&src).unwrap();
4325 db.insert_node(
4326 "Sensor",
4327 "s1",
4328 vec![
4329 ("nan_val".into(), Value::Float(f64::NAN)),
4330 ("pos_inf".into(), Value::Float(f64::INFINITY)),
4331 ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
4332 ("normal".into(), Value::Float(1.5)),
4333 ],
4334 )
4335 .unwrap();
4336 }
4337
4338 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
4340
4341 let content =
4343 std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
4344 let row: serde_json::Value =
4345 serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
4346 assert_eq!(
4347 row["nan_val"],
4348 serde_json::Value::Null,
4349 "NaN must export as null"
4350 );
4351 assert_eq!(
4352 row["pos_inf"],
4353 serde_json::Value::Null,
4354 "+Inf must export as null"
4355 );
4356 assert_eq!(
4357 row["neg_inf"],
4358 serde_json::Value::Null,
4359 "-Inf must export as null"
4360 );
4361 assert_eq!(
4363 row["normal"],
4364 serde_json::json!(1.5),
4365 "normal float roundtrips"
4366 );
4367
4368 let _ = std::fs::remove_dir_all(&src);
4369 let _ = std::fs::remove_dir_all(&dst);
4370 }
4371
4372 #[test]
4373 fn serve_tls_flags_parse_both_forms() {
4374 match parse_args(&[
4376 "serve",
4377 "/tmp/db",
4378 "--tls-cert",
4379 "/a/cert.pem",
4380 "--tls-key",
4381 "/a/key.pem",
4382 ])
4383 .unwrap()
4384 {
4385 Command::Serve {
4386 tls_cert, tls_key, ..
4387 } => {
4388 assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
4389 assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
4390 }
4391 other => panic!("{other:?}"),
4392 }
4393 match parse_args(&[
4395 "serve",
4396 "/tmp/db",
4397 "--tls-cert=/b/cert.pem",
4398 "--tls-key=/b/key.pem",
4399 ])
4400 .unwrap()
4401 {
4402 Command::Serve {
4403 tls_cert, tls_key, ..
4404 } => {
4405 assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
4406 assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
4407 }
4408 other => panic!("{other:?}"),
4409 }
4410 match parse_args(&["serve", "/tmp/db"]).unwrap() {
4412 Command::Serve {
4413 tls_cert, tls_key, ..
4414 } => {
4415 assert_eq!(tls_cert, None);
4416 assert_eq!(tls_key, None);
4417 }
4418 other => panic!("{other:?}"),
4419 }
4420 }
4421
4422 #[test]
4423 fn serve_tls_flags_require_both() {
4424 let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
4426 assert!(
4427 err.contains("tls-key"),
4428 "--tls-cert alone must mention --tls-key in error, got {err}"
4429 );
4430 let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
4432 assert!(
4433 err.contains("tls-cert"),
4434 "--tls-key alone must mention --tls-cert in error, got {err}"
4435 );
4436 }
4437
4438 #[test]
4439 fn version_flag_parses() {
4440 assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
4441 assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
4442 assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
4443 }
4444
4445 #[test]
4446 fn recall_parses_one_dir_and_is_listed_in_usage() {
4447 assert_eq!(
4448 parse_args(&["recall", "/tmp/db"]).unwrap(),
4449 Command::Recall {
4450 db_dir: Some(PathBuf::from("/tmp/db")),
4451 auto: false,
4452 }
4453 );
4454 assert!(
4455 parse_args(&["recall"]).is_err(),
4456 "one of <db-dir> or --auto is required"
4457 );
4458 assert!(usage().contains("mushroomdb recall <db-dir>"));
4459 }
4460
4461 #[test]
4462 fn map_parses_a_dir_and_an_optional_json_flag() {
4463 assert_eq!(
4464 parse_args(&["map", "/tmp/db"]).unwrap(),
4465 Command::Map {
4466 db_dir: PathBuf::from("/tmp/db"),
4467 json: false,
4468 }
4469 );
4470 let want = Command::Map {
4472 db_dir: PathBuf::from("/tmp/db"),
4473 json: true,
4474 };
4475 assert_eq!(parse_args(&["map", "/tmp/db", "--json"]).unwrap(), want);
4476 assert_eq!(parse_args(&["map", "--json", "/tmp/db"]).unwrap(), want);
4477 assert!(parse_args(&["map"]).is_err(), "<db-dir> is required");
4478 assert!(parse_args(&["map", "/tmp/db", "/tmp/other"]).is_err());
4479 assert!(parse_args(&["map", "/tmp/db", "--nope"]).is_err());
4480 assert!(usage().contains("mushroomdb map <db-dir> [--json]"));
4481 }
4482
4483 #[test]
4484 fn the_graph_tools_take_a_dir_and_their_keys() {
4485 assert_eq!(
4486 parse_args(&["context", "/tmp/db", "src/db.rs#open"]).unwrap(),
4487 Command::Context {
4488 db_dir: PathBuf::from("/tmp/db"),
4489 target: "src/db.rs#open".to_string(),
4490 full: false,
4491 }
4492 );
4493 assert_eq!(
4494 parse_args(&["explore", "/tmp/db", "open"]).unwrap(),
4495 Command::Explore {
4496 db_dir: PathBuf::from("/tmp/db"),
4497 target: "open".to_string(),
4498 depth: repograph::Depth::Context,
4499 full: false,
4500 },
4501 "the default depth is the cheapest one"
4502 );
4503 assert_eq!(
4504 parse_args(&["explore", "/tmp/db", "open", "--depth", "all", "--full"]).unwrap(),
4505 Command::Explore {
4506 db_dir: PathBuf::from("/tmp/db"),
4507 target: "open".to_string(),
4508 depth: repograph::Depth::All,
4509 full: true,
4510 }
4511 );
4512 assert_eq!(
4513 parse_args(&["impact", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
4514 Command::Impact {
4515 db_dir: PathBuf::from("/tmp/db"),
4516 files: vec!["a.rs".to_string(), "b.rs".to_string()],
4517 }
4518 );
4519 assert_eq!(
4520 parse_args(&["owners", "/tmp/db", "a.rs"]).unwrap(),
4521 Command::Owners {
4522 db_dir: PathBuf::from("/tmp/db"),
4523 path: "a.rs".to_string(),
4524 }
4525 );
4526 assert_eq!(
4527 parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
4528 Command::Why {
4529 db_dir: PathBuf::from("/tmp/db"),
4530 a: "a.rs".to_string(),
4531 b: "b.rs".to_string(),
4532 }
4533 );
4534
4535 for args in [
4537 vec!["context", "/tmp/db"],
4538 vec!["context", "/tmp/db", "a", "b"],
4539 vec!["impact", "/tmp/db"],
4540 vec!["owners", "/tmp/db"],
4541 vec!["why", "/tmp/db", "a"],
4542 vec!["why", "/tmp/db", "a", "b", "c"],
4543 vec!["why", "/tmp/db", "-a", "b"],
4544 vec!["context"],
4545 vec!["explore"],
4546 vec!["explore", "/tmp/db"],
4547 vec!["explore", "/tmp/db", "a", "b"],
4548 vec!["explore", "/tmp/db", "a", "--depth"],
4549 vec!["explore", "/tmp/db", "a", "--depth", "everything"],
4550 vec!["explore", "/tmp/db", "a", "--nope"],
4551 ] {
4552 assert!(parse_args(&args).is_err(), "{args:?} must not parse");
4553 }
4554 for line in [
4555 "mushroomdb explore <db-dir> <target>",
4556 "mushroomdb context <db-dir> <target>",
4557 "mushroomdb impact <db-dir> <file>...",
4558 "mushroomdb owners <db-dir> <path>",
4559 "mushroomdb why <db-dir> <a> <b>",
4560 ] {
4561 assert!(usage().contains(line), "usage is missing {line:?}");
4562 }
4563 }
4564
4565 #[test]
4568 fn hook_commands_take_a_dir_or_auto() {
4569 assert_eq!(
4570 parse_args(&["mcp", "--auto"]).unwrap(),
4571 Command::Mcp {
4572 db_dir: None,
4573 auto: true,
4574 all_tools: false
4575 }
4576 );
4577 assert_eq!(
4578 parse_args(&["recall", "--auto"]).unwrap(),
4579 Command::Recall {
4580 db_dir: None,
4581 auto: true
4582 }
4583 );
4584 assert_eq!(
4585 parse_args(&["brief", "--auto"]).unwrap(),
4586 Command::Brief {
4587 db_dir: None,
4588 auto: true
4589 }
4590 );
4591 assert_eq!(
4592 parse_args(&["brief", "/tmp/db"]).unwrap(),
4593 Command::Brief {
4594 db_dir: Some(PathBuf::from("/tmp/db")),
4595 auto: false
4596 }
4597 );
4598 for cmd in ["mcp", "recall", "touch", "brief"] {
4599 assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
4600 assert!(
4601 parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
4602 "{cmd} with both"
4603 );
4604 }
4605 assert!(usage().contains("--auto"));
4606 }
4607
4608 #[test]
4611 fn mcp_takes_all_tools() {
4612 for args in [
4613 &["mcp", "/tmp/db", "--all-tools"][..],
4614 &["mcp", "--all-tools", "/tmp/db"][..],
4615 ] {
4616 assert_eq!(
4617 parse_args(args).unwrap(),
4618 Command::Mcp {
4619 db_dir: Some(PathBuf::from("/tmp/db")),
4620 auto: false,
4621 all_tools: true
4622 },
4623 "{args:?}"
4624 );
4625 }
4626 assert_eq!(
4627 parse_args(&["mcp", "--auto", "--all-tools"]).unwrap(),
4628 Command::Mcp {
4629 db_dir: None,
4630 auto: true,
4631 all_tools: true
4632 }
4633 );
4634 assert!(parse_args(&["mcp", "--all-tools"]).is_err(), "no target");
4635 assert!(parse_args(&["mcp", "/tmp/db", "--nope"]).is_err());
4636 assert!(parse_args(&["recall", "/tmp/db", "--all-tools"]).is_err());
4637 assert!(usage().contains("--all-tools"));
4638 }
4639
4640 #[test]
4641 fn sync_and_touch_parse() {
4642 assert_eq!(
4643 parse_args(&["sync", "/tmp/db"]).unwrap(),
4644 Command::Sync {
4645 db_dir: Some(PathBuf::from("/tmp/db")),
4646 auto: false,
4647 json: false,
4648 }
4649 );
4650 assert_eq!(
4651 parse_args(&["sync", "/tmp/db", "--json"]).unwrap(),
4652 Command::Sync {
4653 db_dir: Some(PathBuf::from("/tmp/db")),
4654 auto: false,
4655 json: true,
4656 }
4657 );
4658 assert_eq!(
4661 parse_args(&["sync", "--auto"]).unwrap(),
4662 Command::Sync {
4663 db_dir: None,
4664 auto: true,
4665 json: false,
4666 }
4667 );
4668 assert_eq!(
4669 parse_args(&["sync", "--auto", "--json"]).unwrap(),
4670 Command::Sync {
4671 db_dir: None,
4672 auto: true,
4673 json: true,
4674 }
4675 );
4676 assert!(
4677 parse_args(&["sync"]).is_err(),
4678 "one of <db-dir> or --auto is required"
4679 );
4680 assert!(
4681 parse_args(&["sync", "/tmp/db", "--auto"]).is_err(),
4682 "--auto and a path contradict each other"
4683 );
4684
4685 assert_eq!(
4687 parse_args(&["touch", "/tmp/db", "src/a.rs", "src/b.rs"]).unwrap(),
4688 Command::Touch {
4689 db_dir: Some(PathBuf::from("/tmp/db")),
4690 auto: false,
4691 files: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
4692 }
4693 );
4694 assert_eq!(
4696 parse_args(&["touch", "--auto", "src/a.rs"]).unwrap(),
4697 Command::Touch {
4698 db_dir: None,
4699 auto: true,
4700 files: vec![PathBuf::from("src/a.rs")],
4701 }
4702 );
4703 assert_eq!(
4705 parse_args(&["touch", "--auto"]).unwrap(),
4706 Command::Touch {
4707 db_dir: None,
4708 auto: true,
4709 files: vec![],
4710 }
4711 );
4712 assert!(usage().contains("mushroomdb sync <db-dir>"));
4713 assert!(usage().contains("mushroomdb touch"));
4714 }
4715
4716 #[test]
4717 fn ingest_git_parses_excludes() {
4718 let cmd = parse_args(&[
4719 "ingest-git",
4720 "/tmp/db",
4721 "/tmp/repo",
4722 "--exclude",
4723 "target/",
4724 "--exclude=*.lock",
4725 "--max-commits-per-file",
4726 "50",
4727 "--recurse-submodules",
4728 "--prs",
4729 "--ensure-gitignore",
4730 ])
4731 .unwrap();
4732 assert_eq!(
4733 cmd,
4734 Command::IngestGit {
4735 db_dir: PathBuf::from("/tmp/db"),
4736 opts: ingest_git::IngestGitOpts {
4737 repo: PathBuf::from("/tmp/repo"),
4738 exclude: vec!["target/".into(), "*.lock".into()],
4739 max_commits_per_file: 50,
4740 recurse_submodules: true,
4741 prs: true,
4742 structure: true,
4743 docs: true,
4744 ensure_gitignore: true,
4745 },
4746 }
4747 );
4748 let Command::IngestGit { opts, .. } =
4750 parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
4751 else {
4752 panic!("expected IngestGit");
4753 };
4754 assert_eq!(
4755 opts.exclude,
4756 ingest_git::DEFAULT_EXCLUDES
4757 .iter()
4758 .map(|p| (*p).to_string())
4759 .collect::<Vec<_>>(),
4760 "with no --exclude the defaults apply"
4761 );
4762 assert_eq!(
4763 opts.max_commits_per_file,
4764 ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
4765 );
4766 assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
4767 assert!(
4768 opts.structure && opts.docs,
4769 "structure and docs default on and are recorded on the marker"
4770 );
4771 let Command::IngestGit { opts, .. } = parse_args(&[
4772 "ingest-git",
4773 "/tmp/db",
4774 "/tmp/repo",
4775 "--no-structure",
4776 "--no-docs",
4777 ])
4778 .unwrap() else {
4779 panic!("expected IngestGit");
4780 };
4781 assert!(!opts.structure && !opts.docs);
4782 assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
4783 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
4784 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
4785 assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
4786 }
4787
4788 #[test]
4789 fn version_constant_matches_cargo() {
4790 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
4791 assert!(usage().contains("--version"));
4792 }
4793}