1pub mod doctor;
7pub mod export;
8pub mod ingest_git;
9pub mod install;
10pub mod recall;
11pub mod structure;
12
13use core_api::repograph;
14use core_api::schema::Schema;
15use core_api::{
16 default_max_edges, is_write_query, wal_commit_count_at, AlgoDir, BackupReport, DegreeConfig,
17 Explanation, GraphDb, IngestOptions, LouvainConfig, PageRankConfig, Predicate, ResultSet,
18 RuleDef, RuleSuggestion, SharedDb, SnapshotOptions, Stats, Value, WccConfig,
19};
20use export::ExportFormat;
21use std::collections::{BTreeMap, BTreeSet};
22use std::fmt::Write as _;
23use std::net::SocketAddr;
24use std::path::{Path, PathBuf};
25use std::time::Duration;
26
27pub const N_ORGS: usize = 10;
29pub const N_PROJECTS: usize = 20;
30pub const N_PEOPLE: usize = 30;
31
32pub const SAMPLE_QUERY: &str = "\
37MATCH (p:Person {id: 'person-01'})-[r:FIT]->(proj:Project)
38RETURN p, proj, r.score AS score
39ORDER BY score DESC, proj";
40
41const SAMPLE_EXPLAIN_A: &str = "person-01";
42const SAMPLE_EXPLAIN_B: &str = "proj-01";
43
44pub const VERSION: &str = env!("CARGO_PKG_VERSION");
46
47#[must_use]
49pub fn version_string() -> String {
50 format!("mushroomdb {VERSION}")
51}
52
53#[must_use]
65pub fn resolve_auto_db(
66 env_project_dir: Option<&std::ffi::OsStr>,
67 cwd: &Path,
68 home: &Path,
69) -> PathBuf {
70 if let Some(dir) = env_project_dir.filter(|d| !d.is_empty()) {
71 return Path::new(dir).join("mushroom-memory");
72 }
73 if cwd.join(".git").exists() {
74 return cwd.join("mushroom-memory");
75 }
76 home.join(".mushroomdb").join("memory")
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum ServeUi {
82 Filesystem(PathBuf),
83 Embedded,
84 None,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum AlgoSubcmd {
90 Pagerank,
91 Wcc,
92 Degree,
93 Communities,
94}
95
96#[derive(Debug, Clone, PartialEq)]
100pub enum Command {
101 Serve {
102 db_dir: PathBuf,
103 addr: SocketAddr,
104 ui: ServeUi,
105 demo_if_empty: bool,
108 token: Option<String>,
110 role_tokens: Vec<(String, String)>,
113 snapshot_every: Option<Duration>,
115 tls_cert: Option<PathBuf>,
117 tls_key: Option<PathBuf>,
119 },
120 Mcp {
121 db_dir: Option<PathBuf>,
123 auto: bool,
124 },
125 Stats {
126 db_dir: PathBuf,
127 },
128 Demo {
129 db_dir: PathBuf,
130 },
131 AsOf {
133 db_dir: PathBuf,
134 commit: u64,
136 query: Option<String>,
138 },
139 Suggest {
141 db_dir: PathBuf,
142 },
143 Algo {
145 db_dir: PathBuf,
146 subcmd: AlgoSubcmd,
147 top: usize,
149 dir: AlgoDir,
152 edge_types: Vec<String>,
155 weight_prop: Option<String>,
157 min_weight: Option<f64>,
159 },
160 Query {
162 db_dir: PathBuf,
163 cypher: String,
165 },
166 Snapshot {
168 db_dir: PathBuf,
169 keep_wal: bool,
170 archive_wal: bool,
172 retention: Option<u32>,
175 },
176 SchemaApply {
178 db_dir: PathBuf,
179 schema_file: PathBuf,
180 },
181 Migrate {
183 db_dir: PathBuf,
184 },
185 Verify {
187 db_dir: PathBuf,
188 },
189 Backup {
191 db_dir: PathBuf,
192 dest: PathBuf,
193 },
194 Export {
196 db_dir: PathBuf,
197 dest: PathBuf,
198 format: ExportFormat,
199 },
200 IngestGit {
202 db_dir: PathBuf,
203 opts: ingest_git::IngestGitOpts,
204 },
205 Install(install::InstallOpts),
207 Uninstall(install::InstallOpts),
209 Doctor(doctor::DoctorOpts),
211 Recall {
214 db_dir: Option<PathBuf>,
215 auto: bool,
216 },
217 Sync {
220 db_dir: PathBuf,
221 json: bool,
225 },
226 Touch {
229 db_dir: Option<PathBuf>,
230 auto: bool,
231 files: Vec<PathBuf>,
232 },
233 Map {
236 db_dir: PathBuf,
237 json: bool,
240 },
241 Context {
243 db_dir: PathBuf,
244 target: String,
245 },
246 Impact {
249 db_dir: PathBuf,
250 files: Vec<String>,
251 },
252 Owners {
254 db_dir: PathBuf,
255 path: String,
256 },
257 Why {
259 db_dir: PathBuf,
260 a: String,
261 b: String,
262 },
263 Version,
264 Help,
265}
266
267#[derive(Debug)]
269pub struct DemoOutcome {
270 pub auto_fk_rules: Vec<String>,
271 pub sample_query: String,
272 pub sample_result: ResultSet,
273 pub explanations: Vec<Explanation>,
274 pub stats: Stats,
275 pub suggestion: Option<RuleSuggestion>,
277}
278
279#[derive(Debug)]
281pub struct CliError(pub String);
282
283impl std::fmt::Display for CliError {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.write_str(&self.0)
286 }
287}
288
289impl std::error::Error for CliError {}
290
291impl From<core_api::GraphError> for CliError {
292 fn from(e: core_api::GraphError) -> Self {
293 CliError(e.to_string())
294 }
295}
296
297impl From<std::io::Error> for CliError {
298 fn from(e: std::io::Error) -> Self {
299 CliError(e.to_string())
300 }
301}
302
303pub fn usage() -> &'static str {
305 "\
306mushroomdb — embedded graph database
307
308Usage:
309 mushroomdb install [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
310 [--command <path>] [--no-git-hooks] [--no-prewarm]
311 mushroomdb uninstall [--platform claude-code|cursor|codex|all] [--project|--user] [--db <path>]
312 mushroomdb doctor [--project|--user] [--platform claude-code|cursor|codex|all]
313 verify an install: config entry, store, hooks, git hooks, and a real
314 stdio handshake with the configured MCP command; exits 1 on any `fail`
315 mushroomdb serve <db-dir> [--addr 127.0.0.1:8080] [--token <secret>] [--ui <dist-dir>] [--no-ui] [--demo-if-empty] [--snapshot-every <secs>]
316 mushroomdb mcp <db-dir>|--auto
317 mushroomdb stats <db-dir>
318 mushroomdb demo <db-dir>
319 mushroomdb recall <db-dir>|--auto hook body: reads a prompt payload on stdin, prints related graph facts
320 mushroomdb sync <db-dir> [--json] re-sync the repo the store was built from: new commits, then the dirty working tree
321 mushroomdb map <db-dir> [--json] summarise the graphed repository: clusters, key files, owners, hot files
322 --json prints the computed map instead of the rendered digest
323 mushroomdb context <db-dir> <target> one file or symbol from every side: signature, source, callers,
324 callees, importers, co-change partners, commits, notes
325 <target> is a file path, a symbol key, or a bare symbol name
326 mushroomdb impact <db-dir> <file>... what changing these files reaches: partners, importers,
327 and the symbols other files call
328 mushroomdb owners <db-dir> <path> top author and share, who else knows it, last touch, last 4 quarters
329 mushroomdb why <db-dir> <a> <b> every rule edge between two nodes with its evidence, or the
330 shortest path between them
331 mushroomdb touch <db-dir>|--auto [<file>...]
332 re-extract just these files; with no <file> reads them from a
333 PostToolUse payload on stdin (hook body)
334 mushroomdb suggest <db-dir>
335 mushroomdb asof <db-dir> --commit N [--query \"MATCH ...\"]
336 mushroomdb query <db-dir> [--query \"MATCH ...\"] <cypher…>
337 mushroomdb snapshot <db-dir> [--keep-wal]
338 mushroomdb migrate <db-dir>
339 mushroomdb verify <db-dir> validate CRC32 integrity of every snapshot section
340 mushroomdb backup <db-dir> <dest> process-local consistent copy of the database to <dest>
341 WARNING: unsafe against a concurrently running serve process;
342 use POST /backup on the HTTP server for live-serve backups
343 mushroomdb export <db-dir> <dest> --format jsonl|parquet|graphml export all data
344 graphml writes one file: <dest>/graph.graphml if <dest> is an
345 existing directory, otherwise <dest> is the file path itself
346 (nodes + edges only; rules have no GraphML analogue)
347 mushroomdb ingest-git <db-dir> <repo-dir> [--exclude <pattern>]... [--max-commits-per-file N]
348 [--recurse-submodules] [--prs] [--no-structure] [--no-docs] [--ensure-gitignore]
349 graph a git repo (authors, commits, files, symbols, imports, calls, mentions); re-run to sync
350 --recurse-submodules also walks each initialised submodule
351 --prs links merged pull requests via gh (skipped when gh is unavailable)
352 --no-structure skips the working-tree pass (no hashes, symbols, imports or calls)
353 --no-docs skips Markdown bodies, headings and mentions
354 --ensure-gitignore adds the database directory to the repo's .gitignore
355 with no --exclude the defaults apply: target/ node_modules/ dist/ .git/ *.lock *.min.js
356 mushroomdb schema apply <db-dir> <schema.json>
357 mushroomdb algo pagerank <db-dir> [--top N] [--dir out|in|both]
358 mushroomdb algo wcc <db-dir> [--top N]
359 mushroomdb algo degree <db-dir> [--top N] [--dir out|in|both]
360 mushroomdb algo communities <db-dir> [--edge-type T]... [--weight-prop P] [--min-weight X] [--top N]
361 mushroomdb --version
362 mushroomdb --help
363
364Default serve address is 127.0.0.1:8080. Non-loopback --addr requires --token or MUSHROOMDB_TOKEN.
365install defaults: --platform auto-detect; scope auto (project inside a git checkout, else user);
366the MCP entry runs `npx -y mushroomdb@<version>` unless a `mushroomdb` on PATH is this binary, or
367--command names one (a relative --command or --db is anchored to the current directory).
368--no-git-hooks skips the post-commit/checkout/merge sync hooks; --no-prewarm skips fetching the
369pinned package once. uninstall resolves the same scope and falls back to the other one when the
370inferred scope has no manifest; undoing a Codex install needs --platform codex.
371--auto resolves the database as $CLAUDE_PROJECT_DIR/mushroom-memory, else ./mushroom-memory in a
372git checkout, else ~/.mushroomdb/memory.
373"
374}
375
376fn parse_install_cmd(args: &[&str]) -> Result<install::InstallOpts, String> {
377 let mut platform: Option<install::Platform> = None;
378 let mut scope: Option<install::Scope> = None;
379 let mut db: Option<PathBuf> = None;
380 let mut command: Option<PathBuf> = None;
381 let mut git_hooks = true;
382 let mut prewarm = true;
383 let mut i = 0;
384 while i < args.len() {
385 let a = args[i];
386 if a == "--platform" {
387 let val = args
388 .get(i + 1)
389 .copied()
390 .ok_or_else(|| "missing value for --platform".to_string())?;
391 platform = Some(install::Platform::parse(val)?);
392 i += 2;
393 } else if let Some(val) = a.strip_prefix("--platform=") {
394 platform = Some(install::Platform::parse(val)?);
395 i += 1;
396 } else if a == "--project" || a == "--user" {
397 let want = if a == "--project" {
398 install::Scope::Project
399 } else {
400 install::Scope::User
401 };
402 if scope.is_some_and(|s| s != want) {
405 return Err("--project and --user are mutually exclusive".to_string());
406 }
407 scope = Some(want);
408 i += 1;
409 } else if a == "--no-git-hooks" {
410 git_hooks = false;
411 i += 1;
412 } else if a == "--no-prewarm" {
413 prewarm = false;
414 i += 1;
415 } else if a == "--command" {
416 let val = args
417 .get(i + 1)
418 .copied()
419 .ok_or_else(|| "missing value for --command".to_string())?;
420 command = Some(PathBuf::from(val));
421 i += 2;
422 } else if let Some(val) = a.strip_prefix("--command=") {
423 command = Some(PathBuf::from(val));
424 i += 1;
425 } else if a == "--db" {
426 let val = args
427 .get(i + 1)
428 .copied()
429 .ok_or_else(|| "missing value for --db".to_string())?;
430 db = Some(PathBuf::from(val));
431 i += 2;
432 } else if let Some(val) = a.strip_prefix("--db=") {
433 db = Some(PathBuf::from(val));
434 i += 1;
435 } else if a.starts_with('-') {
436 return Err(format!("unexpected flag: {a}"));
437 } else {
438 return Err(format!("unexpected argument: {a}"));
439 }
440 }
441 Ok(install::InstallOpts {
442 platform,
443 scope,
444 db,
445 command,
446 git_hooks,
447 prewarm,
448 })
449}
450
451fn parse_doctor_cmd(args: &[&str]) -> Result<doctor::DoctorOpts, String> {
452 let mut platform: Option<install::Platform> = None;
453 let mut scope: Option<install::Scope> = None;
454 let mut i = 0;
455 while i < args.len() {
456 let a = args[i];
457 if a == "--platform" {
458 let val = args
459 .get(i + 1)
460 .copied()
461 .ok_or_else(|| "missing value for --platform".to_string())?;
462 platform = Some(install::Platform::parse(val)?);
463 i += 2;
464 } else if let Some(val) = a.strip_prefix("--platform=") {
465 platform = Some(install::Platform::parse(val)?);
466 i += 1;
467 } else if a == "--project" || a == "--user" {
468 let want = if a == "--project" {
469 install::Scope::Project
470 } else {
471 install::Scope::User
472 };
473 if scope.is_some_and(|s| s != want) {
474 return Err("--project and --user are mutually exclusive".to_string());
475 }
476 scope = Some(want);
477 i += 1;
478 } else if a.starts_with('-') {
479 return Err(format!("unexpected flag: {a}"));
480 } else {
481 return Err(format!("unexpected argument: {a}"));
482 }
483 }
484 Ok(doctor::DoctorOpts { platform, scope })
485}
486
487fn parse_ingest_git(args: &[&str]) -> Result<Command, String> {
488 let mut positional = Vec::new();
489 let mut exclude = Vec::new();
490 let mut max_commits_per_file = ingest_git::DEFAULT_MAX_COMMITS_PER_FILE;
491 let mut recurse_submodules = false;
492 let mut prs = false;
493 let mut structure = true;
494 let mut docs = true;
495 let mut ensure_gitignore = false;
496 let mut i = 0;
497 while i < args.len() {
498 let a = args[i];
499 if a == "--recurse-submodules" {
500 recurse_submodules = true;
501 i += 1;
502 } else if a == "--prs" {
503 prs = true;
504 i += 1;
505 } else if a == "--no-structure" {
506 structure = false;
507 i += 1;
508 } else if a == "--no-docs" {
509 docs = false;
510 i += 1;
511 } else if a == "--ensure-gitignore" {
512 ensure_gitignore = true;
513 i += 1;
514 } else if a == "--exclude" {
515 exclude.push(
516 args.get(i + 1)
517 .copied()
518 .ok_or_else(|| "missing value for --exclude".to_string())?
519 .to_string(),
520 );
521 i += 2;
522 } else if let Some(val) = a.strip_prefix("--exclude=") {
523 exclude.push(val.to_string());
524 i += 1;
525 } else if a == "--max-commits-per-file" {
526 let val = args
527 .get(i + 1)
528 .copied()
529 .ok_or_else(|| "missing value for --max-commits-per-file".to_string())?;
530 max_commits_per_file = val
531 .parse()
532 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
533 i += 2;
534 } else if let Some(val) = a.strip_prefix("--max-commits-per-file=") {
535 max_commits_per_file = val
536 .parse()
537 .map_err(|e| format!("bad --max-commits-per-file: {e}"))?;
538 i += 1;
539 } else if a.starts_with('-') {
540 return Err(format!("unexpected flag: {a}"));
541 } else {
542 positional.push(a);
543 i += 1;
544 }
545 }
546 let [db_dir, repo] = positional.as_slice() else {
547 return Err("ingest-git requires <db-dir> <repo-dir>".into());
548 };
549 if exclude.is_empty() {
553 exclude = ingest_git::DEFAULT_EXCLUDES
554 .iter()
555 .map(|p| (*p).to_string())
556 .collect();
557 }
558 Ok(Command::IngestGit {
559 db_dir: PathBuf::from(db_dir),
560 opts: ingest_git::IngestGitOpts {
561 repo: PathBuf::from(repo),
562 exclude,
563 max_commits_per_file,
564 recurse_submodules,
565 prs,
566 structure,
567 docs,
568 ensure_gitignore,
569 },
570 })
571}
572
573pub fn parse_args<S: AsRef<str>>(args: &[S]) -> Result<Command, String> {
575 let args: Vec<&str> = args.iter().map(AsRef::as_ref).collect();
576 if args.is_empty() {
577 return Ok(Command::Help);
578 }
579 match args[0] {
580 "--help" | "-h" | "help" => Ok(Command::Help),
581 "--version" | "-V" | "version" => Ok(Command::Version),
582 "serve" => parse_serve(&args[1..]),
583 "mcp" => {
584 parse_dir_or_auto("mcp", &args[1..]).map(|(db_dir, auto)| Command::Mcp { db_dir, auto })
585 }
586 "stats" => parse_one_dir("stats", &args[1..]).map(|db_dir| Command::Stats { db_dir }),
587 "demo" => parse_one_dir("demo", &args[1..]).map(|db_dir| Command::Demo { db_dir }),
588 "suggest" => parse_one_dir("suggest", &args[1..]).map(|db_dir| Command::Suggest { db_dir }),
589 "asof" => parse_asof(&args[1..]),
590 "algo" => parse_algo(&args[1..]),
591 "query" => parse_query(&args[1..]),
592 "snapshot" => parse_snapshot(&args[1..]),
593 "schema" => parse_schema(&args[1..]),
594 "migrate" => parse_one_dir("migrate", &args[1..]).map(|db_dir| Command::Migrate { db_dir }),
595 "verify" => parse_one_dir("verify", &args[1..]).map(|db_dir| Command::Verify { db_dir }),
596 "backup" => parse_backup(&args[1..]),
597 "export" => parse_export(&args[1..]),
598 "recall" => parse_dir_or_auto("recall", &args[1..])
599 .map(|(db_dir, auto)| Command::Recall { db_dir, auto }),
600 "sync" => parse_dir_with_json("sync", &args[1..])
601 .map(|(db_dir, json)| Command::Sync { db_dir, json }),
602 "map" => parse_dir_with_json("map", &args[1..])
603 .map(|(db_dir, json)| Command::Map { db_dir, json }),
604 "context" => {
605 parse_positional("context", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Context {
606 db_dir,
607 target: rest[0].clone(),
608 })
609 }
610 "impact" => parse_positional("impact", &args[1..], 1, usize::MAX)
611 .map(|(db_dir, files)| Command::Impact { db_dir, files }),
612 "owners" => {
613 parse_positional("owners", &args[1..], 1, 1).map(|(db_dir, rest)| Command::Owners {
614 db_dir,
615 path: rest[0].clone(),
616 })
617 }
618 "why" => parse_positional("why", &args[1..], 2, 2).map(|(db_dir, rest)| Command::Why {
619 db_dir,
620 a: rest[0].clone(),
621 b: rest[1].clone(),
622 }),
623 "touch" => parse_touch(&args[1..]),
624 "ingest-git" => parse_ingest_git(&args[1..]),
625 "install" => parse_install_cmd(&args[1..]).map(Command::Install),
626 "uninstall" => parse_install_cmd(&args[1..]).map(Command::Uninstall),
627 "doctor" => parse_doctor_cmd(&args[1..]).map(Command::Doctor),
628 other => Err(format!("unknown command: {other}")),
629 }
630}
631
632fn default_addr() -> SocketAddr {
633 SocketAddr::from(([127, 0, 0, 1], 8080))
634}
635
636fn parse_serve(args: &[&str]) -> Result<Command, String> {
637 let mut db_dir = None;
638 let mut addr = default_addr();
639 let mut ui = ServeUi::Embedded;
640 let mut saw_ui = false;
641 let mut saw_no_ui = false;
642 let mut demo_if_empty = false;
643 let mut token = None;
644 let mut role_tokens: Vec<(String, String)> = Vec::new();
645 let mut snapshot_every = None;
646 let mut tls_cert: Option<PathBuf> = None;
647 let mut tls_key: Option<PathBuf> = None;
648 let mut i = 0;
649 while i < args.len() {
650 let a = args[i];
651 if a == "--addr" {
652 let val = args
653 .get(i + 1)
654 .copied()
655 .ok_or_else(|| "missing value for --addr".to_string())?;
656 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
657 i += 2;
658 } else if let Some(val) = a.strip_prefix("--addr=") {
659 addr = val.parse().map_err(|_| format!("invalid address: {val}"))?;
660 i += 1;
661 } else if a == "--ui" {
662 let val = args
663 .get(i + 1)
664 .copied()
665 .ok_or_else(|| "missing value for --ui".to_string())?;
666 ui = ServeUi::Filesystem(PathBuf::from(val));
667 saw_ui = true;
668 i += 2;
669 } else if let Some(val) = a.strip_prefix("--ui=") {
670 ui = ServeUi::Filesystem(PathBuf::from(val));
671 saw_ui = true;
672 i += 1;
673 } else if a == "--no-ui" {
674 ui = ServeUi::None;
675 saw_no_ui = true;
676 i += 1;
677 } else if a == "--demo-if-empty" {
678 demo_if_empty = true;
679 i += 1;
680 } else if a == "--token" {
681 let val = args
682 .get(i + 1)
683 .copied()
684 .ok_or_else(|| "missing value for --token".to_string())?;
685 token = Some(val.to_string());
686 i += 2;
687 } else if let Some(val) = a.strip_prefix("--token=") {
688 token = Some(val.to_string());
689 i += 1;
690 } else if a == "--role-token" {
691 let val = args
692 .get(i + 1)
693 .copied()
694 .ok_or_else(|| "missing value for --role-token".to_string())?;
695 let (tok, role) = parse_role_token(val)?;
696 role_tokens.push((tok, role));
697 i += 2;
698 } else if let Some(val) = a.strip_prefix("--role-token=") {
699 let (tok, role) = parse_role_token(val)?;
700 role_tokens.push((tok, role));
701 i += 1;
702 } else if a == "--snapshot-every" {
703 let val = args
704 .get(i + 1)
705 .copied()
706 .ok_or_else(|| "missing value for --snapshot-every".to_string())?;
707 snapshot_every = Some(parse_snapshot_every(val)?);
708 i += 2;
709 } else if let Some(val) = a.strip_prefix("--snapshot-every=") {
710 snapshot_every = Some(parse_snapshot_every(val)?);
711 i += 1;
712 } else if a == "--tls-cert" {
713 let val = args
714 .get(i + 1)
715 .copied()
716 .ok_or_else(|| "missing value for --tls-cert".to_string())?;
717 tls_cert = Some(PathBuf::from(val));
718 i += 2;
719 } else if let Some(val) = a.strip_prefix("--tls-cert=") {
720 tls_cert = Some(PathBuf::from(val));
721 i += 1;
722 } else if a == "--tls-key" {
723 let val = args
724 .get(i + 1)
725 .copied()
726 .ok_or_else(|| "missing value for --tls-key".to_string())?;
727 tls_key = Some(PathBuf::from(val));
728 i += 2;
729 } else if let Some(val) = a.strip_prefix("--tls-key=") {
730 tls_key = Some(PathBuf::from(val));
731 i += 1;
732 } else if a.starts_with('-') {
733 return Err(format!("unexpected flag: {a}"));
734 } else if db_dir.is_none() {
735 db_dir = Some(PathBuf::from(a));
736 i += 1;
737 } else {
738 return Err(format!("unexpected extra argument: {a}"));
739 }
740 }
741 if saw_ui && saw_no_ui {
742 return Err("cannot combine --ui and --no-ui".to_string());
743 }
744 match (&tls_cert, &tls_key) {
745 (Some(_), None) => return Err("--tls-cert requires --tls-key".to_string()),
746 (None, Some(_)) => return Err("--tls-key requires --tls-cert".to_string()),
747 _ => {}
748 }
749 let db_dir = db_dir.ok_or_else(|| "serve requires <db-dir>".to_string())?;
750 Ok(Command::Serve {
751 db_dir,
752 addr,
753 ui,
754 demo_if_empty,
755 token,
756 role_tokens,
757 snapshot_every,
758 tls_cert,
759 tls_key,
760 })
761}
762
763fn parse_role_token(val: &str) -> Result<(String, String), String> {
764 let (tok, role) = val
765 .split_once(':')
766 .ok_or_else(|| format!("--role-token requires TOKEN:ROLE format, got: {val}"))?;
767 if tok.is_empty() {
768 return Err("--role-token: TOKEN must not be empty".to_string());
769 }
770 if role.is_empty() {
771 return Err("--role-token: ROLE must not be empty".to_string());
772 }
773 Ok((tok.to_string(), role.to_string()))
774}
775
776fn parse_snapshot_every(val: &str) -> Result<Duration, String> {
777 let secs: u64 = val
778 .parse()
779 .map_err(|_| format!("invalid --snapshot-every: {val}"))?;
780 if secs == 0 {
781 return Err("--snapshot-every must be a positive number of seconds".into());
782 }
783 Ok(Duration::from_secs(secs))
784}
785
786pub fn validate_ui_dir(dir: &Path) -> Result<PathBuf, String> {
788 if !dir.is_dir() {
789 return Err(format!("--ui directory does not exist: {}", dir.display()));
790 }
791 let index = dir.join("index.html");
792 if !index.is_file() {
793 return Err(format!(
794 "--ui directory is missing index.html: {}",
795 dir.display()
796 ));
797 }
798 Ok(dir.to_path_buf())
799}
800
801fn parse_asof(args: &[&str]) -> Result<Command, String> {
802 let mut db_dir = None;
803 let mut commit: Option<u64> = None;
804 let mut query: Option<String> = None;
805 let mut i = 0;
806 while i < args.len() {
807 let a = args[i];
808 if a == "--commit" {
809 let val = args
810 .get(i + 1)
811 .copied()
812 .ok_or_else(|| "missing value for --commit".to_string())?;
813 commit = Some(
814 val.parse()
815 .map_err(|_| format!("invalid commit index: {val}"))?,
816 );
817 i += 2;
818 } else if let Some(val) = a.strip_prefix("--commit=") {
819 commit = Some(
820 val.parse()
821 .map_err(|_| format!("invalid commit index: {val}"))?,
822 );
823 i += 1;
824 } else if a == "--query" {
825 let val = args
826 .get(i + 1)
827 .copied()
828 .ok_or_else(|| "missing value for --query".to_string())?;
829 query = Some(val.to_string());
830 i += 2;
831 } else if let Some(val) = a.strip_prefix("--query=") {
832 query = Some(val.to_string());
833 i += 1;
834 } else if a.starts_with('-') {
835 return Err(format!("unexpected flag: {a}"));
836 } else if db_dir.is_none() {
837 db_dir = Some(PathBuf::from(a));
838 i += 1;
839 } else {
840 return Err(format!("unexpected extra argument: {a}"));
841 }
842 }
843 let db_dir = db_dir.ok_or_else(|| "asof requires <db-dir>".to_string())?;
844 let commit = commit.ok_or_else(|| "asof requires --commit N".to_string())?;
845 Ok(Command::AsOf {
846 db_dir,
847 commit,
848 query,
849 })
850}
851
852pub fn run_asof(db_dir: &Path, commit: u64, query: Option<&str>) -> Result<String, CliError> {
854 let total = wal_commit_count_at(db_dir)?;
855 let db = GraphDb::open_at(db_dir, commit)?;
856 let mut out = String::new();
857 let _ = writeln!(out, "as-of commit {} of {}", commit, total);
858 if let Some(cypher) = query {
859 let params = BTreeMap::new();
860 let rs = db.query(cypher, ¶ms)?;
861 out.push_str(&format_result_set(&rs));
862 }
863 Ok(out)
864}
865
866fn parse_query(args: &[&str]) -> Result<Command, String> {
867 let mut db_dir = None;
868 let mut query_flag: Option<String> = None;
869 let mut cypher_parts: Vec<&str> = Vec::new();
870 let mut i = 0;
871 while i < args.len() {
872 let a = args[i];
873 if a == "--query" {
874 let val = args
875 .get(i + 1)
876 .copied()
877 .ok_or_else(|| "missing value for --query".to_string())?;
878 query_flag = Some(val.to_string());
879 i += 2;
880 } else if let Some(val) = a.strip_prefix("--query=") {
881 query_flag = Some(val.to_string());
882 i += 1;
883 } else if a.starts_with('-') {
884 return Err(format!("unexpected flag: {a}"));
885 } else if db_dir.is_none() {
886 db_dir = Some(PathBuf::from(a));
887 i += 1;
888 } else {
889 cypher_parts.push(a);
890 i += 1;
891 }
892 }
893 let db_dir = db_dir.ok_or_else(|| "query requires <db-dir>".to_string())?;
894 let cypher = if let Some(q) = query_flag {
895 if !cypher_parts.is_empty() {
896 return Err(
897 "query: pass Cypher as remaining arguments or --query, not both".to_string(),
898 );
899 }
900 q
901 } else {
902 if cypher_parts.is_empty() {
903 return Err("query requires a Cypher string".to_string());
904 }
905 cypher_parts.join(" ")
906 };
907 Ok(Command::Query { db_dir, cypher })
908}
909
910pub fn run_query(db_dir: &Path, cypher: &str) -> Result<String, CliError> {
912 let params = BTreeMap::new();
913 let is_write = is_write_query(cypher).map_err(CliError)?;
914 let rs = if is_write {
915 let mut db = GraphDb::open(db_dir)?;
916 db.query_write(cypher, ¶ms)?
917 } else {
918 let db = GraphDb::open(db_dir)?;
919 db.query(cypher, ¶ms)?
920 };
921 Ok(format_result_set(&rs))
922}
923
924fn parse_snapshot(args: &[&str]) -> Result<Command, String> {
925 let mut db_dir = None;
926 let mut keep_wal = false;
927 let mut archive_wal = false;
928 let mut retention: Option<u32> = None;
929 let mut i = 0;
930 while i < args.len() {
931 let a = args[i];
932 if a == "--keep-wal" {
933 keep_wal = true;
934 i += 1;
935 } else if a == "--archive-wal" {
936 archive_wal = true;
937 i += 1;
938 } else if a.starts_with("--retention=") {
939 let v = a.trim_start_matches("--retention=");
940 retention = Some(
941 v.parse::<u32>()
942 .map_err(|_| format!("--retention= expects a u32, got: {v}"))?,
943 );
944 i += 1;
945 } else if a == "--retention" {
946 i += 1;
947 let v = args
948 .get(i)
949 .ok_or_else(|| "--retention requires a value".to_string())?;
950 retention = Some(
951 v.parse::<u32>()
952 .map_err(|e| format!("--retention value error: {e}"))?,
953 );
954 i += 1;
955 } else if a.starts_with('-') {
956 return Err(format!("unexpected flag: {a}"));
957 } else if db_dir.is_none() {
958 db_dir = Some(PathBuf::from(a));
959 i += 1;
960 } else {
961 return Err(format!("unexpected extra argument: {a}"));
962 }
963 }
964 let db_dir = db_dir.ok_or_else(|| "snapshot requires <db-dir>".to_string())?;
965 Ok(Command::Snapshot {
966 db_dir,
967 keep_wal,
968 archive_wal,
969 retention,
970 })
971}
972
973pub fn run_migrate(db_dir: &Path) -> Result<String, CliError> {
982 let current = core_api::SNAPSHOT_VERSION;
983 let from_ver = core_api::snapshot_version_at(db_dir)?;
984
985 if from_ver == Some(current) {
986 return Ok(format!("already current (V{current})\n"));
987 }
988
989 if from_ver.is_some() {
994 std::fs::copy(db_dir.join("snapshot.bin"), db_dir.join("snapshot.bin.bak"))?;
995 }
996
997 let mut db = GraphDb::open_with_options(
1000 db_dir,
1001 core_api::OpenOptions {
1002 auto_migrate: false,
1003 ..Default::default()
1004 },
1005 )?;
1006 db.snapshot()?;
1007
1008 let msg = match from_ver {
1009 Some(ver) => format!("migrated V{ver} -> V{current}\n"),
1010 None => format!("migrated WAL-only -> V{current}\n"),
1011 };
1012 Ok(msg)
1013}
1014
1015pub fn run_verify(db_dir: &Path) -> Result<String, CliError> {
1021 if !db_dir.join("snapshot.bin").exists() {
1024 return Err(CliError(format!(
1025 "verify: no snapshot found in {} — take one first with `mushroomdb snapshot {}`",
1026 db_dir.display(),
1027 db_dir.display()
1028 )));
1029 }
1030 let results = core_api::verify_snapshot(db_dir)
1031 .map_err(|e| CliError(format!("verify: cannot open snapshot: {e}")))?;
1032 let mut any_fail = false;
1033 let mut out = String::new();
1034 for (id, section_name, byte_len, result) in &results {
1035 match result {
1036 Ok(()) => {
1037 let _ = writeln!(
1038 out,
1039 " section {:2} ({:<12}) {:>10} bytes OK",
1040 id, section_name, byte_len
1041 );
1042 }
1043 Err(msg) => {
1044 let _ = writeln!(
1045 out,
1046 " section {:2} ({:<12}) {:>10} bytes CORRUPT: {msg}",
1047 id, section_name, byte_len
1048 );
1049 any_fail = true;
1050 }
1051 }
1052 }
1053 if any_fail {
1054 Err(CliError(format!("integrity check FAILED:\n{out}")))
1055 } else {
1056 Ok(format!(
1057 "integrity check OK ({} sections):\n{out}",
1058 results.len()
1059 ))
1060 }
1061}
1062
1063pub fn run_snapshot(
1065 db_dir: &Path,
1066 keep_wal: bool,
1067 archive_wal: bool,
1068 retention: Option<u32>,
1069) -> Result<String, CliError> {
1070 let mut db = GraphDb::open(db_dir)?;
1071 if archive_wal {
1072 db.set_wal_archive_retention(retention);
1073 db.snapshot_with(SnapshotOptions {
1074 archive_wal: true,
1075 keep_wal: false,
1076 })?;
1077 } else if keep_wal {
1078 db.snapshot_with(SnapshotOptions {
1079 keep_wal: true,
1080 archive_wal: false,
1081 })?;
1082 } else {
1083 db.snapshot()?;
1084 }
1085 Ok(format!(
1086 "snapshot written: {}\n",
1087 db_dir.join("snapshot.bin").display()
1088 ))
1089}
1090
1091fn parse_schema(args: &[&str]) -> Result<Command, String> {
1092 if args.is_empty() {
1093 return Err("schema requires a subcommand: apply".to_string());
1094 }
1095 match args[0] {
1096 "apply" => parse_schema_apply(&args[1..]),
1097 other => Err(format!(
1098 "unknown schema subcommand: {other}; expected apply"
1099 )),
1100 }
1101}
1102
1103fn parse_schema_apply(args: &[&str]) -> Result<Command, String> {
1104 let mut db_dir = None;
1105 let mut schema_file = None;
1106 for a in args {
1107 if a.starts_with('-') {
1108 return Err(format!("unexpected flag: {a}"));
1109 }
1110 if db_dir.is_none() {
1111 db_dir = Some(PathBuf::from(*a));
1112 } else if schema_file.is_none() {
1113 schema_file = Some(PathBuf::from(*a));
1114 } else {
1115 return Err(format!("unexpected extra argument: {a}"));
1116 }
1117 }
1118 let db_dir = db_dir.ok_or_else(|| "schema apply requires <db-dir>".to_string())?;
1119 let schema_file =
1120 schema_file.ok_or_else(|| "schema apply requires <schema.json>".to_string())?;
1121 Ok(Command::SchemaApply {
1122 db_dir,
1123 schema_file,
1124 })
1125}
1126
1127pub fn run_schema_apply(db_dir: &Path, schema_file: &Path) -> Result<String, CliError> {
1130 let json = std::fs::read_to_string(schema_file)
1131 .map_err(|e| CliError(format!("cannot read {}: {e}", schema_file.display())))?;
1132 let schema: Schema = serde_json::from_str(&json).map_err(|e| {
1133 CliError(format!(
1134 "invalid schema JSON in {}: {e}",
1135 schema_file.display()
1136 ))
1137 })?;
1138 let mut db = GraphDb::open(db_dir)?;
1139 let diff = db.apply_schema(&schema)?;
1140 let mut out = String::new();
1141 for entry in &diff.created {
1142 let _ = writeln!(out, "created {entry}");
1143 }
1144 for entry in &diff.updated {
1145 let _ = writeln!(out, "updated {entry}");
1146 }
1147 for entry in &diff.unchanged {
1148 let _ = writeln!(out, "unchanged {entry}");
1149 }
1150 if diff.created.is_empty() && diff.updated.is_empty() && diff.unchanged.is_empty() {
1151 let _ = writeln!(out, "schema applied: nothing to do (empty schema)");
1152 }
1153 Ok(out)
1154}
1155
1156fn parse_backup(args: &[&str]) -> Result<Command, String> {
1157 let mut db_dir = None;
1158 let mut dest = None;
1159 for a in args {
1160 if a.starts_with('-') {
1161 return Err(format!("unexpected flag: {a}"));
1162 }
1163 if db_dir.is_none() {
1164 db_dir = Some(PathBuf::from(*a));
1165 } else if dest.is_none() {
1166 dest = Some(PathBuf::from(*a));
1167 } else {
1168 return Err(format!("unexpected extra argument: {a}"));
1169 }
1170 }
1171 let db_dir = db_dir.ok_or_else(|| "backup requires <db-dir>".to_string())?;
1172 let dest = dest.ok_or_else(|| "backup requires <dest>".to_string())?;
1173 Ok(Command::Backup { db_dir, dest })
1174}
1175
1176fn parse_export(args: &[&str]) -> Result<Command, String> {
1177 let mut db_dir = None;
1178 let mut dest = None;
1179 let mut format = ExportFormat::Jsonl;
1180 let mut i = 0;
1181 while i < args.len() {
1182 let a = args[i];
1183 if a == "--format" {
1184 let val = args
1185 .get(i + 1)
1186 .copied()
1187 .ok_or_else(|| "missing value for --format".to_string())?;
1188 format = ExportFormat::parse(val).ok_or_else(|| {
1189 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1190 })?;
1191 i += 2;
1192 } else if let Some(val) = a.strip_prefix("--format=") {
1193 format = ExportFormat::parse(val).ok_or_else(|| {
1194 format!("unknown format '{val}'; expected jsonl, parquet, or graphml")
1195 })?;
1196 i += 1;
1197 } else if a.starts_with('-') {
1198 return Err(format!("unexpected flag: {a}"));
1199 } else if db_dir.is_none() {
1200 db_dir = Some(PathBuf::from(a));
1201 i += 1;
1202 } else if dest.is_none() {
1203 dest = Some(PathBuf::from(a));
1204 i += 1;
1205 } else {
1206 return Err(format!("unexpected extra argument: {a}"));
1207 }
1208 }
1209 let db_dir = db_dir.ok_or_else(|| "export requires <db-dir>".to_string())?;
1210 let dest = dest.ok_or_else(|| "export requires <dest>".to_string())?;
1211 Ok(Command::Export {
1212 db_dir,
1213 dest,
1214 format,
1215 })
1216}
1217
1218pub fn run_backup(db_dir: &Path, dest: &Path) -> Result<BackupReport, CliError> {
1220 let db = GraphDb::open(db_dir)?;
1221 Ok(db.backup_to(dest)?)
1222}
1223
1224pub fn format_backup(dest: &Path, report: &BackupReport) -> String {
1226 let mut out = String::new();
1227 writeln!(out, "backup to: {}", dest.display()).unwrap();
1228 writeln!(out, " files: {}", report.files.join(", ")).unwrap();
1229 writeln!(out, " bytes: {}", report.bytes).unwrap();
1230 writeln!(out, " verified: {}", report.verified).unwrap();
1231 out
1232}
1233
1234pub fn run_export(db_dir: &Path, dest: &Path, format: &ExportFormat) -> Result<String, CliError> {
1236 let db = GraphDb::open(db_dir)?;
1237 let nodes = db.all_nodes_for_export();
1238 let edges = db.all_edges_for_export();
1239 let mut rules = db.rules();
1240 rules.sort_by(|a, b| a.name.cmp(&b.name));
1241 let node_count = nodes.len();
1242 let edge_count = edges.len();
1243 let rule_count = rules.len();
1244 match format {
1245 ExportFormat::Jsonl => {
1246 export::write_jsonl(&nodes, &edges, &rules, dest)?;
1247 Ok(format!(
1248 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1249 dest.display(),
1250 format.name(),
1251 node_count,
1252 edge_count,
1253 rule_count
1254 ))
1255 }
1256 ExportFormat::Parquet => {
1257 export::write_parquet(&nodes, &edges, &rules, dest)?;
1258 Ok(format!(
1259 "exported to {} (format={}): {} nodes, {} edges, {} rules\n",
1260 dest.display(),
1261 format.name(),
1262 node_count,
1263 edge_count,
1264 rule_count
1265 ))
1266 }
1267 ExportFormat::Graphml => {
1269 let file_path = export::write_graphml(&nodes, &edges, dest)?;
1270 Ok(format!(
1271 "exported to {} (format={}): {} nodes, {} edges\n",
1272 file_path.display(),
1273 format.name(),
1274 node_count,
1275 edge_count,
1276 ))
1277 }
1278 }
1279}
1280
1281fn format_result_set(rs: &ResultSet) -> String {
1282 let mut out = String::new();
1283 let _ = writeln!(out, "columns: {}", rs.columns().join(", "));
1284 for i in 0..rs.len() {
1285 let cells: Vec<String> = rs
1286 .columns()
1287 .iter()
1288 .map(|c| format!("{c}={}", fmt_cell(rs.get(i, c))))
1289 .collect();
1290 let _ = writeln!(out, " {}", cells.join(" "));
1291 }
1292 out
1293}
1294
1295fn parse_algo(args: &[&str]) -> Result<Command, String> {
1296 if args.is_empty() {
1297 return Err(
1298 "algo requires a subcommand: pagerank | wcc | degree | communities".to_string(),
1299 );
1300 }
1301 let subcmd = match args[0] {
1302 "pagerank" => AlgoSubcmd::Pagerank,
1303 "wcc" => AlgoSubcmd::Wcc,
1304 "degree" => AlgoSubcmd::Degree,
1305 "communities" => AlgoSubcmd::Communities,
1306 other => {
1307 return Err(format!(
1308 "unknown algo subcommand: {other}; expected pagerank | wcc | degree | communities"
1309 ))
1310 }
1311 };
1312 let rest = &args[1..];
1313 let mut db_dir = None;
1314 let mut top: usize = 20;
1315 let mut dir = AlgoDir::Both;
1316 let mut edge_types: Vec<String> = Vec::new();
1317 let mut weight_prop: Option<String> = None;
1318 let mut min_weight: Option<f64> = None;
1319 let mut i = 0;
1320 while i < rest.len() {
1321 let a = rest[i];
1322 if a == "--top" {
1323 let val = rest
1324 .get(i + 1)
1325 .copied()
1326 .ok_or_else(|| "missing value for --top".to_string())?;
1327 top = val
1328 .parse()
1329 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1330 i += 2;
1331 } else if let Some(val) = a.strip_prefix("--top=") {
1332 top = val
1333 .parse()
1334 .map_err(|_| format!("--top must be a non-negative integer, got {val}"))?;
1335 i += 1;
1336 } else if a == "--dir" {
1337 let val = rest
1338 .get(i + 1)
1339 .copied()
1340 .ok_or_else(|| "missing value for --dir".to_string())?;
1341 dir = parse_algo_dir(val)?;
1342 i += 2;
1343 } else if let Some(val) = a.strip_prefix("--dir=") {
1344 dir = parse_algo_dir(val)?;
1345 i += 1;
1346 } else if a == "--edge-type" {
1347 let val = rest
1348 .get(i + 1)
1349 .copied()
1350 .ok_or_else(|| "missing value for --edge-type".to_string())?;
1351 edge_types.push(val.to_string());
1352 i += 2;
1353 } else if let Some(val) = a.strip_prefix("--edge-type=") {
1354 edge_types.push(val.to_string());
1355 i += 1;
1356 } else if a == "--weight-prop" {
1357 let val = rest
1358 .get(i + 1)
1359 .copied()
1360 .ok_or_else(|| "missing value for --weight-prop".to_string())?;
1361 weight_prop = Some(val.to_string());
1362 i += 2;
1363 } else if let Some(val) = a.strip_prefix("--weight-prop=") {
1364 weight_prop = Some(val.to_string());
1365 i += 1;
1366 } else if a == "--min-weight" {
1367 let val = rest
1368 .get(i + 1)
1369 .copied()
1370 .ok_or_else(|| "missing value for --min-weight".to_string())?;
1371 min_weight = Some(
1372 val.parse()
1373 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1374 );
1375 i += 2;
1376 } else if let Some(val) = a.strip_prefix("--min-weight=") {
1377 min_weight = Some(
1378 val.parse()
1379 .map_err(|_| format!("--min-weight must be a number, got {val}"))?,
1380 );
1381 i += 1;
1382 } else if a.starts_with('-') {
1383 return Err(format!("unexpected flag: {a}"));
1384 } else if db_dir.is_none() {
1385 db_dir = Some(PathBuf::from(a));
1386 i += 1;
1387 } else {
1388 return Err(format!("unexpected extra argument: {a}"));
1389 }
1390 }
1391 let db_dir = db_dir.ok_or_else(|| format!("algo {} requires <db-dir>", args[0]))?;
1392 Ok(Command::Algo {
1393 db_dir,
1394 subcmd,
1395 top,
1396 dir,
1397 edge_types,
1398 weight_prop,
1399 min_weight,
1400 })
1401}
1402
1403fn parse_algo_dir(val: &str) -> Result<AlgoDir, String> {
1405 match val.to_ascii_lowercase().as_str() {
1406 "out" => Ok(AlgoDir::Out),
1407 "in" => Ok(AlgoDir::In),
1408 "both" => Ok(AlgoDir::Both),
1409 other => Err(format!("--dir must be one of out | in | both, got {other}")),
1410 }
1411}
1412
1413pub fn run_map(db_dir: &Path, json: bool) -> Result<String, CliError> {
1419 let db = open_for_reading(db_dir)?;
1420 let map = repograph::repo_map(&db, &repograph::MapOptions::default());
1421 if json {
1422 let mut out = serde_json::to_string_pretty(&map)
1423 .map_err(|e| CliError(format!("serialise map: {e}")))?;
1424 out.push('\n');
1425 return Ok(out);
1426 }
1427 Ok(repograph::render_map(&map))
1428}
1429
1430fn open_for_reading(db_dir: &Path) -> Result<structure::Db, CliError> {
1434 Ok(GraphDb::open_with_options(
1435 db_dir,
1436 core_api::OpenOptions {
1437 auto_migrate: false,
1438 repair_wal: false,
1439 read_only: true,
1440 },
1441 )?)
1442}
1443
1444pub fn run_context(db_dir: &Path, target: &str) -> Result<String, CliError> {
1449 let db = open_for_reading(db_dir)?;
1450 Ok(repograph::render_context(&repograph::context(
1451 &db, None, target,
1452 )))
1453}
1454
1455pub fn run_impact(db_dir: &Path, files: &[String]) -> Result<String, CliError> {
1460 let db = open_for_reading(db_dir)?;
1461 let modified: BTreeSet<String> = files.iter().cloned().collect();
1462 let report = repograph::impact(&db, files, &modified, &repograph::ImpactOptions::default());
1463 Ok(repograph::render_impact(&report))
1464}
1465
1466pub fn run_owners(db_dir: &Path, path: &str) -> Result<String, CliError> {
1468 let db = open_for_reading(db_dir)?;
1469 match repograph::owners(&db, path, None) {
1470 Some(report) => Ok(repograph::render_owners(&report)),
1471 None => Err(CliError(format!("no file in the store at {path}"))),
1472 }
1473}
1474
1475pub fn run_why(db_dir: &Path, a: &str, b: &str) -> Result<String, CliError> {
1477 let db = open_for_reading(db_dir)?;
1478 Ok(repograph::render_why(&repograph::why(&db, a, b)))
1479}
1480
1481#[allow(clippy::too_many_arguments)]
1487pub fn run_algo(
1488 db_dir: &Path,
1489 subcmd: &AlgoSubcmd,
1490 top: usize,
1491 dir: AlgoDir,
1492 edge_types: Vec<String>,
1493 weight_prop: Option<String>,
1494 min_weight: Option<f64>,
1495) -> Result<String, CliError> {
1496 let db = GraphDb::open(db_dir)?;
1497 match subcmd {
1498 AlgoSubcmd::Pagerank => {
1499 let config = PageRankConfig {
1500 direction: dir,
1501 ..PageRankConfig::default()
1502 };
1503 let report = db.pagerank(&config);
1504 Ok(format_pagerank(&report, top))
1505 }
1506 AlgoSubcmd::Wcc => {
1507 let config = WccConfig::default();
1508 let report = db.connected_components(&config);
1509 Ok(format_wcc(&report, top))
1510 }
1511 AlgoSubcmd::Degree => {
1512 let config = DegreeConfig {
1513 direction: dir,
1514 ..DegreeConfig::default()
1515 };
1516 let report = db.degree_centrality(&config);
1517 Ok(format_degree(&report, top))
1518 }
1519 AlgoSubcmd::Communities => {
1520 let config = LouvainConfig {
1521 edge_types,
1522 weight_prop,
1523 min_weight,
1524 ..LouvainConfig::default()
1525 };
1526 let report = db.communities(&config);
1527 Ok(format_communities(&report, top))
1528 }
1529 }
1530}
1531
1532fn format_pagerank(report: &core_api::PageRankReport, top: usize) -> String {
1533 let mut buf = String::new();
1534 let _ = writeln!(buf, "== pagerank (converged={}) ==", report.converged);
1535 let rows = if top == 0 {
1536 report.scores.as_slice()
1537 } else {
1538 &report.scores[..top.min(report.scores.len())]
1539 };
1540 for (i, (key, score)) in rows.iter().enumerate() {
1541 let _ = writeln!(buf, " {:>4} {:<40} {:.6}", i + 1, key, score);
1542 }
1543 buf
1544}
1545
1546fn format_wcc(report: &core_api::WccReport, top: usize) -> String {
1547 let mut buf = String::new();
1548 let _ = writeln!(buf, "== wcc (truncated={}) ==", report.truncated);
1549 let rows = if top == 0 {
1550 report.components.as_slice()
1551 } else {
1552 &report.components[..top.min(report.components.len())]
1553 };
1554 for (key, comp_id) in rows {
1555 let _ = writeln!(buf, " {:<40} component={}", key, comp_id);
1556 }
1557 buf
1558}
1559
1560fn format_degree(report: &core_api::DegreeReport, top: usize) -> String {
1561 let mut buf = String::new();
1562 let _ = writeln!(
1563 buf,
1564 "== degree centrality (truncated={}) ==",
1565 report.truncated
1566 );
1567 let rows = if top == 0 {
1568 report.scores.as_slice()
1569 } else {
1570 &report.scores[..top.min(report.scores.len())]
1571 };
1572 for (i, (key, deg)) in rows.iter().enumerate() {
1573 let _ = writeln!(buf, " {:>4} {:<40} degree={}", i + 1, key, deg);
1574 }
1575 buf
1576}
1577
1578fn format_communities(report: &core_api::CommunityReport, top: usize) -> String {
1581 let mut buf = String::new();
1582 let trunc = if report.truncated { " (truncated)" } else { "" };
1583 let _ = writeln!(
1584 buf,
1585 "== communities (modularity={:.2}){trunc} ==",
1586 report.modularity
1587 );
1588 let rows = if top == 0 {
1589 report.communities.as_slice()
1590 } else {
1591 &report.communities[..top.min(report.communities.len())]
1592 };
1593 for c in rows {
1594 let preview: Vec<&str> = c.members.iter().take(3).map(String::as_str).collect();
1595 let _ = writeln!(
1596 buf,
1597 " {:>4} size={:<6} cohesion={:<6.2} members=[{}]",
1598 c.id,
1599 c.members.len(),
1600 c.cohesion,
1601 preview.join(", ")
1602 );
1603 }
1604 buf
1605}
1606
1607fn parse_dir_or_auto(cmd: &str, args: &[&str]) -> Result<(Option<PathBuf>, bool), String> {
1612 let mut db_dir = None;
1613 let mut auto = false;
1614 for a in args {
1615 if *a == "--auto" {
1616 auto = true;
1617 } else if a.starts_with('-') {
1618 return Err(format!("unexpected flag: {a}"));
1619 } else if db_dir.is_some() {
1620 return Err(format!("unexpected extra argument: {a}"));
1621 } else {
1622 db_dir = Some(PathBuf::from(*a));
1623 }
1624 }
1625 match (&db_dir, auto) {
1626 (Some(_), true) => Err(format!("{cmd}: --auto takes no <db-dir>")),
1627 (None, false) => Err(format!("{cmd} requires <db-dir> or --auto")),
1628 _ => Ok((db_dir, auto)),
1629 }
1630}
1631
1632fn parse_touch(args: &[&str]) -> Result<Command, String> {
1635 let mut db_dir = None;
1636 let mut auto = false;
1637 let mut files = Vec::new();
1638 for a in args {
1639 if *a == "--auto" {
1640 auto = true;
1641 } else if a.starts_with('-') {
1642 return Err(format!("unexpected flag: {a}"));
1643 } else if db_dir.is_none() && !auto {
1644 db_dir = Some(PathBuf::from(*a));
1645 } else {
1646 files.push(PathBuf::from(*a));
1647 }
1648 }
1649 if db_dir.is_none() && !auto {
1650 return Err("touch requires <db-dir> or --auto".into());
1651 }
1652 if db_dir.is_some() && auto {
1653 return Err("touch: --auto takes no <db-dir>".into());
1654 }
1655 Ok(Command::Touch {
1656 db_dir,
1657 auto,
1658 files,
1659 })
1660}
1661
1662fn parse_positional(
1669 cmd: &str,
1670 args: &[&str],
1671 min: usize,
1672 max: usize,
1673) -> Result<(PathBuf, Vec<String>), String> {
1674 let mut rest: Vec<String> = Vec::new();
1675 let mut db_dir: Option<PathBuf> = None;
1676 for a in args {
1677 if a.starts_with('-') {
1678 return Err(format!("unexpected flag: {a}"));
1679 }
1680 match db_dir {
1681 None => db_dir = Some(PathBuf::from(*a)),
1682 Some(_) => rest.push((*a).to_string()),
1683 }
1684 }
1685 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
1686 if rest.len() < min {
1687 return Err(format!(
1688 "{cmd} requires <db-dir> and {min} more argument{}",
1689 if min == 1 { "" } else { "s" }
1690 ));
1691 }
1692 if rest.len() > max {
1693 return Err(format!("unexpected extra argument: {}", rest[max]));
1694 }
1695 Ok((db_dir, rest))
1696}
1697
1698fn parse_dir_with_json(cmd: &str, args: &[&str]) -> Result<(PathBuf, bool), String> {
1700 let mut db_dir = None;
1701 let mut json = false;
1702 for a in args {
1703 if *a == "--json" {
1704 json = true;
1705 } else if a.starts_with('-') {
1706 return Err(format!("unexpected flag: {a}"));
1707 } else if db_dir.is_some() {
1708 return Err(format!("unexpected extra argument: {a}"));
1709 } else {
1710 db_dir = Some(PathBuf::from(*a));
1711 }
1712 }
1713 let db_dir = db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))?;
1714 Ok((db_dir, json))
1715}
1716
1717fn parse_one_dir(cmd: &str, args: &[&str]) -> Result<PathBuf, String> {
1718 let mut db_dir = None;
1719 for a in args {
1720 if a.starts_with('-') {
1721 return Err(format!("unexpected flag: {a}"));
1722 }
1723 if db_dir.is_some() {
1724 return Err(format!("unexpected extra argument: {a}"));
1725 }
1726 db_dir = Some(PathBuf::from(*a));
1727 }
1728 db_dir.ok_or_else(|| format!("{cmd} requires <db-dir>"))
1729}
1730
1731pub fn format_stats(stats: &Stats) -> String {
1733 let mut out = String::new();
1734 let _ = writeln!(
1735 out,
1736 "nodes: {} live, {} tombstoned",
1737 stats.nodes_live, stats.nodes_tombstoned
1738 );
1739 let _ = writeln!(out, "edges: {}", stats.edges);
1740 let _ = writeln!(out, "rules: {}", stats.rules.len());
1741 for r in &stats.rules {
1742 let _ = writeln!(
1743 out,
1744 " {:<28} edges={} tripped={}",
1745 r.name, r.edges, r.tripped
1746 );
1747 }
1748 out
1749}
1750
1751pub fn read_stats(dir: &Path) -> Result<Stats, CliError> {
1753 let db = SharedDb::open(dir)?;
1754 let stats = db.read().stats();
1755 Ok(stats)
1756}
1757
1758pub fn run_demo(dir: &Path) -> Result<DemoOutcome, CliError> {
1764 refuse_non_empty(dir)?;
1765
1766 let db = SharedDb::open(dir)?;
1767 let opts = IngestOptions::default();
1768 let mut auto_fk_rules = Vec::new();
1769
1770 {
1771 let mut w = db.write();
1772 for (label, json) in [
1773 ("Org", org_json()),
1774 ("Project", project_json()),
1775 ("Person", person_json()),
1776 ] {
1777 let report = w.ingest_json(label, &json, &opts)?;
1778 if !report.row_errors.is_empty() {
1779 return Err(CliError(format!(
1780 "demo ingest of {label} had row errors: {:?}",
1781 report.row_errors
1782 )));
1783 }
1784 auto_fk_rules.extend(report.rules_created);
1785 }
1786 let skill_fit = Predicate::Overlap {
1787 field: "skills".into(),
1788 min: 0.5,
1789 };
1790 let skill_fit_k = Some(default_max_edges(&skill_fit));
1791 w.create_rule(RuleDef {
1792 name: "skill_fit".into(),
1793 src_label: "Person".into(),
1794 dst_label: "Project".into(),
1795 predicate: skill_fit,
1796 edge_type: "FIT".into(),
1797 weight_prop: Some("score".into()),
1798 max_edges: skill_fit_k,
1799 approximate: false,
1800 via_label: None,
1801 via_edge: None,
1802 via_dir: None,
1803 })?;
1804 let founded_within = Predicate::NumericWithin {
1805 field: "founded_year".into(),
1806 tolerance: 2.0,
1807 };
1808 let founded_within_k = Some(default_max_edges(&founded_within));
1809 w.create_rule(RuleDef {
1810 name: "founded_within".into(),
1811 src_label: "Org".into(),
1812 dst_label: "Org".into(),
1813 predicate: founded_within,
1814 edge_type: "FOUNDED_WITHIN".into(),
1815 weight_prop: Some("score".into()),
1816 max_edges: founded_within_k,
1817 approximate: false,
1818 via_label: None,
1819 via_edge: None,
1820 via_dir: None,
1821 })?;
1822 let nearby_office = Predicate::GeoRadius {
1823 field: "office".into(),
1824 km: 50.0,
1825 };
1826 let nearby_office_k = Some(default_max_edges(&nearby_office));
1827 w.create_rule(RuleDef {
1828 name: "nearby_office".into(),
1829 src_label: "Org".into(),
1830 dst_label: "Org".into(),
1831 predicate: nearby_office,
1832 edge_type: "NEARBY_OFFICE".into(),
1833 weight_prop: Some("score".into()),
1834 max_edges: nearby_office_k,
1835 approximate: false,
1836 via_label: None,
1837 via_edge: None,
1838 via_dir: None,
1839 })?;
1840 let similar_interests = Predicate::VectorSimilar {
1841 field: "embedding".into(),
1842 min: 0.8,
1843 };
1844 let similar_interests_k = Some(default_max_edges(&similar_interests));
1845 w.create_rule(RuleDef {
1846 name: "similar_interests".into(),
1847 src_label: "Person".into(),
1848 dst_label: "Person".into(),
1849 predicate: similar_interests,
1850 edge_type: "SIMILAR".into(),
1851 weight_prop: Some("score".into()),
1852 max_edges: similar_interests_k,
1853 approximate: false,
1854 via_label: None,
1855 via_edge: None,
1856 via_dir: None,
1857 })?;
1858 for (label, field) in [("Org", "name"), ("Project", "name"), ("Person", "name")] {
1860 w.enable_fulltext(label, field)?;
1861 }
1862 }
1863
1864 let r = db.read();
1865 let sample_result = r.query(SAMPLE_QUERY, &BTreeMap::new())?;
1866 let explanations = r.explain(SAMPLE_EXPLAIN_A, SAMPLE_EXPLAIN_B)?;
1867 let stats = r.stats();
1868 let suggestion = r.suggest_rules().into_iter().next();
1870
1871 Ok(DemoOutcome {
1872 auto_fk_rules,
1873 sample_query: SAMPLE_QUERY.to_string(),
1874 sample_result,
1875 explanations,
1876 stats,
1877 suggestion,
1878 })
1879}
1880
1881fn dir_is_empty_or_absent(dir: &Path) -> Result<bool, CliError> {
1882 if dir.is_file() {
1883 return Err(CliError(format!(
1884 "demo refuses a non-empty directory: {} is a file",
1885 dir.display()
1886 )));
1887 }
1888 if !dir.exists() {
1889 return Ok(true);
1890 }
1891 Ok(std::fs::read_dir(dir)?.next().is_none())
1892}
1893
1894fn refuse_non_empty(dir: &Path) -> Result<(), CliError> {
1895 if dir_is_empty_or_absent(dir)? {
1896 Ok(())
1897 } else {
1898 Err(CliError(format!(
1899 "demo refuses a non-empty directory: {} \
1900 (directory must be empty — including hidden files)",
1901 dir.display()
1902 )))
1903 }
1904}
1905
1906pub fn maybe_run_demo_if_empty(dir: &Path) -> Result<Option<DemoOutcome>, CliError> {
1908 if dir_is_empty_or_absent(dir)? {
1909 Ok(Some(run_demo(dir)?))
1910 } else {
1911 Ok(None)
1912 }
1913}
1914
1915fn json_array(rows: impl IntoIterator<Item = String>) -> String {
1916 let mut out = String::from("[");
1917 let mut first = true;
1918 for row in rows {
1919 if !first {
1920 out.push(',');
1921 }
1922 first = false;
1923 out.push_str(&row);
1924 }
1925 out.push(']');
1926 out
1927}
1928
1929fn wrap_proj(i: usize) -> usize {
1931 (i - 1) % N_PROJECTS + 1
1932}
1933
1934fn skill_window_json(start: usize, len: usize) -> String {
1936 let parts: Vec<String> = (0..len)
1937 .map(|k| format!(r#""s{:02}""#, wrap_proj(start + k)))
1938 .collect();
1939 format!("[{}]", parts.join(","))
1940}
1941
1942fn org_office(i: usize) -> (f64, f64) {
1946 match i {
1947 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"),
1958 }
1959}
1960
1961fn person_embedding_json(i: usize) -> String {
1965 let mut v = [0.0_f64; 8];
1966 match i {
1967 9 | 19 | 29 => {
1968 v[0] = 0.8;
1969 v[1] = 0.6;
1970 }
1971 10 | 20 | 30 => {
1972 v[0] = 0.6;
1973 v[1] = 0.8;
1974 }
1975 _ => {
1976 let axis = (i - 1) % 10;
1977 debug_assert!(axis < 8);
1978 v[axis] = 1.0;
1979 }
1980 }
1981 let parts: Vec<String> = v.iter().map(|x| format!("{x}")).collect();
1982 format!("[{}]", parts.join(","))
1983}
1984
1985fn org_json() -> String {
1986 json_array((1..=N_ORGS).map(|i| {
1987 let year = 2010 + (i as i64 - 1);
1988 let (lat, lon) = org_office(i);
1989 format!(
1990 r#"{{"id":"org-{i:02}","name":"Org {i}","founded_year":{year},"office":[{lat},{lon}],"skills":{}}}"#,
1991 skill_window_json(i, 3)
1992 )
1993 }))
1994}
1995
1996fn project_json() -> String {
1997 json_array((1..=N_PROJECTS).map(|i| {
1998 let org = (i - 1) % N_ORGS + 1;
1999 format!(
2000 r#"{{"id":"proj-{i:02}","name":"Project {i}","org_id":"org-{org:02}","skills":{}}}"#,
2001 skill_window_json(i, 3)
2002 )
2003 }))
2004}
2005
2006fn person_json() -> String {
2007 json_array((1..=N_PEOPLE).map(|i| {
2008 let org = (i - 1) % N_ORGS + 1;
2009 let proj = (i - 1) % N_PROJECTS + 1;
2010 format!(
2011 r#"{{"id":"person-{i:02}","name":"Person {i}","org_id":"org-{org:02}","project_id":"proj-{proj:02}","embedding":{},"skills":{}}}"#,
2012 person_embedding_json(i),
2013 skill_window_json(proj, 3)
2014 )
2015 }))
2016}
2017
2018pub fn format_demo(dir: &Path, out: &DemoOutcome) -> String {
2020 let mut buf = String::new();
2021 let _ = writeln!(buf, "== demo ==");
2022 let _ = writeln!(
2023 buf,
2024 "ingested {N_ORGS} Orgs, {N_PROJECTS} Projects, {N_PEOPLE} People"
2025 );
2026 let _ = writeln!(
2027 buf,
2028 "overlap rule: skill_fit (Person.skills ∩ Project.skills, min 0.5)"
2029 );
2030 let _ = writeln!(
2031 buf,
2032 "numeric rule: founded_within (Org.founded_year, tolerance 2)"
2033 );
2034 let _ = writeln!(buf, "geo rule: nearby_office (Org.office [lat,lon], 50 km)");
2035 let _ = writeln!(
2036 buf,
2037 "vector rule: similar_interests (Person.embedding dim 8, min 0.8)"
2038 );
2039 let _ = writeln!(buf);
2040 let _ = writeln!(buf, "== auto-FK rules ==");
2041 let mut names = out.auto_fk_rules.clone();
2042 names.sort();
2043 for name in names {
2044 let _ = writeln!(buf, " {name}");
2045 }
2046 let _ = writeln!(buf);
2047 let _ = writeln!(buf, "== query ==");
2048 let _ = writeln!(buf, "{}", out.sample_query);
2049 let _ = writeln!(buf);
2050 let _ = writeln!(buf, "columns: {}", out.sample_result.columns().join(", "));
2051 for i in 0..out.sample_result.len() {
2052 let cells: Vec<String> = out
2053 .sample_result
2054 .columns()
2055 .iter()
2056 .map(|c| format!("{c}={}", fmt_cell(out.sample_result.get(i, c))))
2057 .collect();
2058 let _ = writeln!(buf, " {}", cells.join(" "));
2059 }
2060 let _ = writeln!(buf);
2061 let _ = writeln!(
2062 buf,
2063 "== explain ({SAMPLE_EXPLAIN_A}, {SAMPLE_EXPLAIN_B}) =="
2064 );
2065 for e in &out.explanations {
2066 let weight = e
2067 .weight
2068 .map(|w| fmt_value(&Value::Float(w)))
2069 .unwrap_or_else(|| "none".into());
2070 let _ = writeln!(
2071 buf,
2072 " rule={} type={} {}→{} weight={}",
2073 e.rule, e.edge_type, e.src_key, e.dst_key, weight
2074 );
2075 }
2076 let _ = writeln!(buf);
2077 let _ = writeln!(buf, "== serve ==");
2078 let _ = writeln!(buf, " mushroomdb serve {}", dir.display());
2079
2080 if let Some(s) = &out.suggestion {
2082 let _ = writeln!(buf);
2083 let _ = writeln!(buf, "== suggested rule (teaser) ==");
2084 let _ = writeln!(buf, " {}", s.def.name);
2085 let _ = writeln!(
2086 buf,
2087 " {} → {} via {:?}",
2088 s.def.src_label, s.def.dst_label, s.def.predicate
2089 );
2090 let _ = writeln!(buf, " est_edges: ~{}", s.est_edges);
2091 let _ = writeln!(buf, " {}", s.rationale);
2092 let _ = writeln!(
2093 buf,
2094 " (run `mushroomdb suggest {}` for full analysis)",
2095 dir.display()
2096 );
2097 }
2098
2099 buf
2100}
2101
2102pub fn run_suggest(dir: &Path) -> Result<Vec<RuleSuggestion>, CliError> {
2104 let db = GraphDb::open(dir)?;
2105 Ok(db.suggest_rules())
2106}
2107
2108pub fn format_suggest(suggestions: &[RuleSuggestion]) -> String {
2110 let mut buf = String::new();
2111 if suggestions.is_empty() {
2112 let _ = writeln!(
2113 buf,
2114 "no rule suggestions (database may be empty or rules already cover all patterns)"
2115 );
2116 return buf;
2117 }
2118 let _ = writeln!(buf, "== rule suggestions ({}) ==", suggestions.len());
2119 for (i, s) in suggestions.iter().enumerate() {
2120 let _ = writeln!(buf);
2121 let _ = writeln!(buf, "[{}] {}", i + 1, s.def.name);
2122 let _ = writeln!(
2123 buf,
2124 " {} → {} via {:?}",
2125 s.def.src_label, s.def.dst_label, s.def.predicate
2126 );
2127 let _ = writeln!(buf, " est_edges : ~{}", s.est_edges);
2128 let _ = writeln!(buf, " rationale : {}", s.rationale);
2129 if !s.examples.is_empty() {
2130 let _ = writeln!(buf, " examples :");
2131 for (src, dst, score) in &s.examples {
2132 let _ = writeln!(buf, " {src} → {dst} score={score:.4}");
2133 }
2134 }
2135 let _ = writeln!(buf, " predicate : {:?}", s.def.predicate);
2136 let _ = writeln!(
2137 buf,
2138 " to apply : POST /rules or db.create_rule(suggestion.def)"
2139 );
2140 }
2141 buf
2142}
2143
2144fn fmt_value(v: &Value) -> String {
2145 match v {
2146 Value::Int(i) => i.to_string(),
2147 Value::Float(f) => {
2148 let s = format!("{f}");
2149 if s.contains('.') || s.contains('e') || s.contains('E') {
2150 s
2151 } else {
2152 format!("{s}.0")
2153 }
2154 }
2155 Value::Str(s) => s.clone(),
2156 Value::Bool(b) => b.to_string(),
2157 Value::List(xs) => {
2158 let inner: Vec<String> = xs.iter().map(fmt_value).collect();
2159 format!("[{}]", inner.join(", "))
2160 }
2161 Value::Map(m) => {
2162 let inner: Vec<String> = m
2163 .iter()
2164 .map(|(k, v)| format!("{k}: {}", fmt_value(v)))
2165 .collect();
2166 format!("{{{}}}", inner.join(", "))
2167 }
2168 }
2169}
2170
2171fn fmt_cell(cell: Option<&Value>) -> String {
2172 match cell {
2173 None => "null".into(),
2174 Some(v) => fmt_value(v),
2175 }
2176}
2177
2178#[cfg(test)]
2179mod tests {
2180 use super::*;
2181 use std::collections::BTreeSet;
2182 use std::net::SocketAddr;
2183 use std::path::PathBuf;
2184
2185 fn tmp(name: &str) -> PathBuf {
2186 let nanos = std::time::SystemTime::now()
2187 .duration_since(std::time::UNIX_EPOCH)
2188 .expect("clock")
2189 .as_nanos();
2190 let d = std::env::temp_dir().join(format!(
2191 "graphdb-cli-{}-{}-{}",
2192 name,
2193 std::process::id(),
2194 nanos
2195 ));
2196 let _ = std::fs::remove_dir_all(&d);
2197 d
2198 }
2199
2200 fn directed_pairs(db: &SharedDb, etype: &str) -> BTreeSet<(String, String)> {
2201 let g = db.read();
2202 let mut out = BTreeSet::new();
2203 for i in 1..=N_ORGS {
2204 let src = format!("org-{i:02}");
2205 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2206 for dst in nbrs {
2207 out.insert((src.clone(), dst));
2208 }
2209 }
2210 }
2211 for i in 1..=N_PEOPLE {
2212 let src = format!("person-{i:02}");
2213 if let Ok(nbrs) = g.neighbors(&src, etype, core_api::Direction::Out) {
2214 for dst in nbrs {
2215 out.insert((src.clone(), dst));
2216 }
2217 }
2218 }
2219 out
2220 }
2221
2222 fn assert_weight(db: &SharedDb, a: &str, b: &str, rule: &str, want: f64) {
2223 let hits: Vec<_> = db
2224 .read()
2225 .explain(a, b)
2226 .expect("explain")
2227 .into_iter()
2228 .filter(|e| e.rule == rule && e.src_key == a && e.dst_key == b)
2229 .collect();
2230 assert_eq!(hits.len(), 1, "explain {a}/{b} rule={rule}: {hits:?}");
2231 let got = hits[0].weight.expect("weighted");
2232 assert!(
2233 (got - want).abs() < 1e-12,
2234 "{rule} {a}→{b}: got {got} want {want}"
2235 );
2236 }
2237
2238 fn haversine_km(lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
2239 const R: f64 = 6371.0088;
2240 let phi1 = lat1.to_radians();
2241 let phi2 = lat2.to_radians();
2242 let dphi = (lat2 - lat1).to_radians();
2243 let dlam = (lon2 - lon1).to_radians();
2244 let a = ((dphi / 2.0).sin().powi(2) + phi1.cos() * phi2.cos() * (dlam / 2.0).sin().powi(2))
2245 .clamp(0.0, 1.0);
2246 let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
2247 R * c
2248 }
2249
2250 fn default_bind() -> SocketAddr {
2251 SocketAddr::from(([127, 0, 0, 1], 8080))
2252 }
2253
2254 #[test]
2255 fn parse_args_table() {
2256 struct Case {
2257 args: &'static [&'static str],
2258 check: fn(Result<Command, String>),
2259 }
2260
2261 let cases = [
2262 Case {
2263 args: &[],
2264 check: |r| match r {
2265 Ok(Command::Help) => {}
2266 other => panic!("no-args → Help, got {other:?}"),
2267 },
2268 },
2269 Case {
2270 args: &["--help"],
2271 check: |r| match r {
2272 Ok(Command::Help) => {}
2273 other => panic!("--help → Help, got {other:?}"),
2274 },
2275 },
2276 Case {
2277 args: &["-h"],
2278 check: |r| match r {
2279 Ok(Command::Help) => {}
2280 other => panic!("-h → Help, got {other:?}"),
2281 },
2282 },
2283 Case {
2284 args: &["serve", "/tmp/demo-db"],
2285 check: |r| match r {
2286 Ok(Command::Serve {
2287 db_dir,
2288 addr,
2289 ui,
2290 demo_if_empty,
2291 token,
2292 role_tokens,
2293 snapshot_every,
2294 tls_cert,
2295 tls_key,
2296 }) => {
2297 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2298 assert_eq!(addr, default_bind());
2299 assert_eq!(ui, super::ServeUi::Embedded);
2300 assert!(!demo_if_empty);
2301 assert_eq!(token, None);
2302 assert!(role_tokens.is_empty());
2303 assert_eq!(snapshot_every, None);
2304 assert_eq!(tls_cert, None);
2305 assert_eq!(tls_key, None);
2306 }
2307 other => panic!("serve <dir> → Serve default addr, got {other:?}"),
2308 },
2309 },
2310 Case {
2311 args: &["serve", "/tmp/demo-db", "--addr", "127.0.0.1:8080"],
2312 check: |r| match r {
2313 Ok(Command::Serve {
2314 db_dir,
2315 addr,
2316 ui,
2317 demo_if_empty,
2318 token,
2319 role_tokens,
2320 snapshot_every,
2321 tls_cert,
2322 tls_key,
2323 }) => {
2324 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2325 assert_eq!(
2326 addr,
2327 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
2328 );
2329 assert_eq!(ui, super::ServeUi::Embedded);
2330 assert!(!demo_if_empty);
2331 assert_eq!(token, None);
2332 assert!(role_tokens.is_empty());
2333 assert_eq!(snapshot_every, None);
2334 assert_eq!(tls_cert, None);
2335 assert_eq!(tls_key, None);
2336 }
2337 other => panic!("serve --addr after dir, got {other:?}"),
2338 },
2339 },
2340 Case {
2341 args: &["serve", "/tmp/demo-db", "--addr=127.0.0.1:9090"],
2342 check: |r| match r {
2343 Ok(Command::Serve {
2344 db_dir,
2345 addr,
2346 ui,
2347 demo_if_empty,
2348 token,
2349 role_tokens,
2350 snapshot_every,
2351 tls_cert,
2352 tls_key,
2353 }) => {
2354 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2355 assert_eq!(
2356 addr,
2357 "127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
2358 );
2359 assert_eq!(ui, super::ServeUi::Embedded);
2360 assert!(!demo_if_empty);
2361 assert_eq!(token, None);
2362 let _ = role_tokens; assert_eq!(snapshot_every, None);
2364 assert_eq!(tls_cert, None);
2365 assert_eq!(tls_key, None);
2366 }
2367 other => panic!("serve --addr=VALUE, got {other:?}"),
2368 },
2369 },
2370 Case {
2371 args: &["mcp", "/tmp/demo-db"],
2372 check: |r| match r {
2373 Ok(Command::Mcp { db_dir, auto }) => {
2374 assert_eq!(db_dir, Some(PathBuf::from("/tmp/demo-db")));
2375 assert!(!auto);
2376 }
2377 other => panic!("mcp <dir>, got {other:?}"),
2378 },
2379 },
2380 Case {
2381 args: &["stats", "/tmp/demo-db"],
2382 check: |r| match r {
2383 Ok(Command::Stats { db_dir }) => {
2384 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2385 }
2386 other => panic!("stats <dir>, got {other:?}"),
2387 },
2388 },
2389 Case {
2390 args: &["demo", "/tmp/demo-db"],
2391 check: |r| match r {
2392 Ok(Command::Demo { db_dir }) => {
2393 assert_eq!(db_dir, PathBuf::from("/tmp/demo-db"));
2394 }
2395 other => panic!("demo <dir>, got {other:?}"),
2396 },
2397 },
2398 Case {
2399 args: &["serve"],
2400 check: |r| {
2401 let e = r.expect_err("serve without dir");
2402 assert!(
2403 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2404 "missing-dir error should mention dir, got {e}"
2405 );
2406 },
2407 },
2408 Case {
2409 args: &["mcp"],
2410 check: |r| {
2411 let e = r.expect_err("mcp without dir");
2412 assert!(
2413 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2414 "missing-dir error should mention dir, got {e}"
2415 );
2416 },
2417 },
2418 Case {
2419 args: &["stats"],
2420 check: |r| {
2421 let e = r.expect_err("stats without dir");
2422 assert!(
2423 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2424 "missing-dir error should mention dir, got {e}"
2425 );
2426 },
2427 },
2428 Case {
2429 args: &["demo"],
2430 check: |r| {
2431 let e = r.expect_err("demo without dir");
2432 assert!(
2433 e.to_lowercase().contains("db-dir") || e.to_lowercase().contains("dir"),
2434 "missing-dir error should mention dir, got {e}"
2435 );
2436 },
2437 },
2438 Case {
2439 args: &["serve", "/tmp/demo-db", "--addr"],
2440 check: |r| {
2441 let e = r.expect_err("--addr missing value");
2442 assert!(
2443 e.to_lowercase().contains("addr"),
2444 "--addr missing value should mention addr, got {e}"
2445 );
2446 },
2447 },
2448 Case {
2449 args: &["serve", "/tmp/demo-db", "--addr", "not-an-addr"],
2450 check: |r| {
2451 let e = r.expect_err("invalid addr");
2452 assert!(
2453 e.to_lowercase().contains("addr") || e.to_lowercase().contains("address"),
2454 "invalid addr should mention address, got {e}"
2455 );
2456 },
2457 },
2458 Case {
2459 args: &["frobnicate", "/tmp/demo-db"],
2460 check: |r| {
2461 let e = r.expect_err("unknown command");
2462 assert!(
2463 e.to_lowercase().contains("unknown")
2464 || e.to_lowercase().contains("frobnicate"),
2465 "unknown command should name it, got {e}"
2466 );
2467 },
2468 },
2469 Case {
2470 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/ui-dist"],
2471 check: |r| match r {
2472 Ok(Command::Serve { ui, .. }) => {
2473 assert_eq!(
2474 ui,
2475 super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-dist"))
2476 );
2477 }
2478 other => panic!("serve --ui <dir>, got {other:?}"),
2479 },
2480 },
2481 Case {
2482 args: &["serve", "/tmp/demo-db", "--ui=/tmp/ui-eq"],
2483 check: |r| match r {
2484 Ok(Command::Serve { ui, .. }) => {
2485 assert_eq!(ui, super::ServeUi::Filesystem(PathBuf::from("/tmp/ui-eq")));
2486 }
2487 other => panic!("serve --ui=VALUE, got {other:?}"),
2488 },
2489 },
2490 Case {
2491 args: &["serve", "/tmp/demo-db", "--ui"],
2492 check: |r| {
2493 let e = r.expect_err("--ui missing value");
2494 assert!(
2495 e.to_lowercase().contains("ui"),
2496 "--ui missing value should mention ui, got {e}"
2497 );
2498 },
2499 },
2500 Case {
2501 args: &["serve", "/tmp/demo-db", "--no-ui"],
2502 check: |r| match r {
2503 Ok(Command::Serve { ui, .. }) => {
2504 assert_eq!(ui, super::ServeUi::None);
2505 }
2506 other => panic!("serve --no-ui, got {other:?}"),
2507 },
2508 },
2509 Case {
2510 args: &["serve", "/tmp/demo-db", "--ui", "/tmp/x", "--no-ui"],
2511 check: |r| {
2512 let e = r.expect_err("combine --ui and --no-ui");
2513 assert!(
2514 e.contains("--ui") && e.contains("--no-ui"),
2515 "conflict should name both flags, got {e}"
2516 );
2517 },
2518 },
2519 Case {
2520 args: &["serve", "/tmp/demo-db", "extra"],
2521 check: |r| {
2522 let e = r.expect_err("extra positional");
2523 assert!(
2524 e.to_lowercase().contains("unexpected")
2525 || e.to_lowercase().contains("extra"),
2526 "extra arg should be rejected, got {e}"
2527 );
2528 },
2529 },
2530 Case {
2531 args: &[
2532 "serve",
2533 "/data",
2534 "--addr",
2535 "0.0.0.0:8080",
2536 "--demo-if-empty",
2537 ],
2538 check: |r| match r {
2539 Ok(Command::Serve {
2540 db_dir,
2541 addr,
2542 demo_if_empty,
2543 ui,
2544 token,
2545 snapshot_every,
2546 ..
2547 }) => {
2548 assert_eq!(db_dir, PathBuf::from("/data"));
2549 assert_eq!(
2550 addr,
2551 "0.0.0.0:8080".parse::<std::net::SocketAddr>().unwrap()
2552 );
2553 assert!(demo_if_empty);
2554 assert_eq!(ui, super::ServeUi::Embedded);
2555 assert_eq!(token, None);
2556 assert_eq!(snapshot_every, None);
2557 }
2558 other => panic!("serve --demo-if-empty docker default, got {other:?}"),
2559 },
2560 },
2561 ];
2562
2563 for case in &cases {
2564 (case.check)(parse_args(case.args));
2565 }
2566 }
2567
2568 #[test]
2569 fn serve_default_addr_is_loopback_8080() {
2570 match parse_args(&["serve", "/tmp/db"]).unwrap() {
2571 Command::Serve { addr, .. } => {
2572 assert_eq!(
2573 addr,
2574 "127.0.0.1:8080".parse::<std::net::SocketAddr>().unwrap()
2575 );
2576 }
2577 other => panic!("{other:?}"),
2578 }
2579 }
2580
2581 #[test]
2582 fn serve_snapshot_every_parses_seconds() {
2583 match parse_args(&["serve", "/tmp/db", "--snapshot-every", "30"]).unwrap() {
2584 Command::Serve { snapshot_every, .. } => {
2585 assert_eq!(snapshot_every, Some(Duration::from_secs(30)));
2586 }
2587 other => panic!("{other:?}"),
2588 }
2589 match parse_args(&["serve", "/tmp/db", "--snapshot-every=5"]).unwrap() {
2590 Command::Serve { snapshot_every, .. } => {
2591 assert_eq!(snapshot_every, Some(Duration::from_secs(5)));
2592 }
2593 other => panic!("{other:?}"),
2594 }
2595 match parse_args(&["serve", "/tmp/db"]).unwrap() {
2596 Command::Serve { snapshot_every, .. } => {
2597 assert_eq!(snapshot_every, None);
2598 }
2599 other => panic!("{other:?}"),
2600 }
2601 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every"]).unwrap_err();
2602 assert!(
2603 err.contains("snapshot-every"),
2604 "missing value should name the flag, got {err}"
2605 );
2606 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "0"]).unwrap_err();
2607 assert!(
2608 err.contains("snapshot-every"),
2609 "zero should be rejected, got {err}"
2610 );
2611 let err = parse_args(&["serve", "/tmp/db", "--snapshot-every", "nope"]).unwrap_err();
2612 assert!(
2613 err.contains("snapshot-every"),
2614 "invalid value should name the flag, got {err}"
2615 );
2616 }
2617
2618 #[test]
2619 fn serve_token_flag_and_non_loopback_without_token_is_parsed() {
2620 match parse_args(&[
2622 "serve",
2623 "/tmp/db",
2624 "--addr",
2625 "0.0.0.0:8080",
2626 "--token",
2627 "s3cret",
2628 ])
2629 .unwrap()
2630 {
2631 Command::Serve { token, addr, .. } => {
2632 assert_eq!(token.as_deref(), Some("s3cret"));
2633 assert_eq!(addr.ip().to_string(), "0.0.0.0");
2634 }
2635 other => panic!("{other:?}"),
2636 }
2637 }
2638
2639 #[test]
2640 fn parse_snapshot_and_query() {
2641 match parse_args(&["snapshot", "/tmp/db"]).unwrap() {
2642 Command::Snapshot { keep_wal, .. } => assert!(!keep_wal),
2643 other => panic!("{other:?}"),
2644 }
2645 match parse_args(&["snapshot", "/tmp/db", "--keep-wal"]).unwrap() {
2646 Command::Snapshot { keep_wal, .. } => assert!(keep_wal),
2647 other => panic!("{other:?}"),
2648 }
2649 match parse_args(&["query", "/tmp/db", "MATCH (n) RETURN n LIMIT 1"]).unwrap() {
2650 Command::Query { cypher, .. } => assert!(cypher.contains("MATCH")),
2651 other => panic!("{other:?}"),
2652 }
2653 match parse_args(&["query", "/tmp/db", "MATCH", "(n)", "RETURN", "n"]).unwrap() {
2654 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
2655 other => panic!("{other:?}"),
2656 }
2657 match parse_args(&["query", "/tmp/db", "--query", "MATCH (n) RETURN n"]).unwrap() {
2658 Command::Query { cypher, .. } => assert_eq!(cypher, "MATCH (n) RETURN n"),
2659 other => panic!("{other:?}"),
2660 }
2661 let text = usage();
2662 assert!(
2663 text.contains("query"),
2664 "usage should mention query, got:\n{text}"
2665 );
2666 assert!(
2667 text.contains("snapshot"),
2668 "usage should mention snapshot, got:\n{text}"
2669 );
2670 }
2671
2672 #[test]
2673 fn usage_lists_every_subcommand() {
2674 let text = usage();
2675 for word in [
2676 "serve",
2677 "mcp",
2678 "stats",
2679 "demo",
2680 "query",
2681 "snapshot",
2682 "--keep-wal",
2683 "mushroomdb",
2684 "--ui",
2685 "--no-ui",
2686 "--demo-if-empty",
2687 "--token",
2688 "--snapshot-every",
2689 ] {
2690 assert!(
2691 text.contains(word),
2692 "usage should mention {word}, got:\n{text}"
2693 );
2694 }
2695 }
2696
2697 #[test]
2698 fn validate_ui_dir_requires_index_html() {
2699 let missing = tmp("ui-missing");
2700 let err = super::validate_ui_dir(&missing).expect_err("missing dir");
2701 assert!(
2702 err.contains("does not exist"),
2703 "missing dir error, got {err}"
2704 );
2705
2706 let empty = tmp("ui-empty");
2707 std::fs::create_dir_all(&empty).unwrap();
2708 let err = super::validate_ui_dir(&empty).expect_err("no index");
2709 assert!(
2710 err.contains("index.html"),
2711 "missing index.html error, got {err}"
2712 );
2713
2714 let ok = tmp("ui-ok");
2715 std::fs::create_dir_all(&ok).unwrap();
2716 std::fs::write(ok.join("index.html"), "<!doctype html>").unwrap();
2717 let got = super::validate_ui_dir(&ok).expect("valid ui dir");
2718 assert_eq!(got, ok);
2719 }
2720
2721 #[test]
2722 fn maybe_run_demo_if_empty_seeds_then_skips() {
2723 let dir = tmp("boot-empty");
2724 let first = super::maybe_run_demo_if_empty(&dir)
2725 .expect("empty dir demos")
2726 .expect("Some(DemoOutcome)");
2727 assert_eq!(first.stats.nodes_live, 60);
2728 let db = SharedDb::open(&dir).expect("reopen");
2729 assert!(db.read().has_node("person-01"));
2730 let second = super::maybe_run_demo_if_empty(&dir).expect("non-empty is ok");
2731 assert!(
2732 second.is_none(),
2733 "second boot must not re-demo a populated volume"
2734 );
2735
2736 let occupied = tmp("boot-occupied");
2737 std::fs::create_dir_all(&occupied).unwrap();
2738 std::fs::write(occupied.join("keep-me"), b"x").unwrap();
2739 let skipped = super::maybe_run_demo_if_empty(&occupied).expect("occupied skip");
2740 assert!(skipped.is_none());
2741 assert_eq!(
2742 std::fs::read(occupied.join("keep-me")).unwrap(),
2743 b"x",
2744 "existing volume contents must be untouched"
2745 );
2746 }
2747
2748 #[test]
2749 fn demo_builder_is_deterministic_and_refuses_second_run() {
2750 let dir = tmp("demo");
2751 let out = run_demo(&dir).expect("first demo run");
2752
2753 assert_eq!(
2754 out.stats.nodes_live, 60,
2755 "10 orgs + 20 projects + 30 people"
2756 );
2757 assert_eq!(out.stats.nodes_tombstoned, 0);
2758 assert_eq!(out.stats.edges, 334);
2766 assert_eq!(
2767 out.stats.rules.len(),
2768 7,
2769 "3 auto-FK + overlap + numeric + geo + vector"
2770 );
2771 let fit = out
2772 .stats
2773 .rules
2774 .iter()
2775 .find(|r| r.name == "skill_fit")
2776 .expect("skill_fit");
2777 assert_eq!(fit.edges, 90, "30 people × 3 FIT edges");
2778 let founded = out
2779 .stats
2780 .rules
2781 .iter()
2782 .find(|r| r.name == "founded_within")
2783 .expect("founded_within");
2784 assert_eq!(founded.edges, 34);
2785 let nearby = out
2786 .stats
2787 .rules
2788 .iter()
2789 .find(|r| r.name == "nearby_office")
2790 .expect("nearby_office");
2791 assert_eq!(nearby.edges, 16);
2792 let similar = out
2793 .stats
2794 .rules
2795 .iter()
2796 .find(|r| r.name == "similar_interests")
2797 .expect("similar_interests");
2798 assert_eq!(similar.edges, 114);
2799
2800 let mut names: Vec<&str> = out.stats.rules.iter().map(|r| r.name.as_str()).collect();
2801 names.sort_unstable();
2802 assert_eq!(
2803 names,
2804 vec![
2805 "auto_fk_person_org_id",
2806 "auto_fk_person_project_id",
2807 "auto_fk_project_org_id",
2808 "founded_within",
2809 "nearby_office",
2810 "similar_interests",
2811 "skill_fit",
2812 ]
2813 );
2814
2815 let db = SharedDb::open(&dir).expect("reopen demo");
2817 assert_eq!(
2818 db.read().fulltext_pairs(),
2819 vec![
2820 ("Org".to_string(), "name".to_string()),
2821 ("Person".to_string(), "name".to_string()),
2822 ("Project".to_string(), "name".to_string()),
2823 ]
2824 );
2825
2826 let mut auto = out.auto_fk_rules.clone();
2827 auto.sort();
2828 assert_eq!(
2829 auto,
2830 vec![
2831 "auto_fk_person_org_id".to_string(),
2832 "auto_fk_person_project_id".to_string(),
2833 "auto_fk_project_org_id".to_string(),
2834 ]
2835 );
2836
2837 assert!(
2838 !out.sample_result.is_empty(),
2839 "sample Cypher query must return rows"
2840 );
2841 assert!(
2842 out.sample_query.contains("ORDER BY score DESC"),
2843 "sample query must rank by score, got {}",
2844 out.sample_query
2845 );
2846 let scores: Vec<f64> = (0..out.sample_result.len())
2847 .map(|i| match out.sample_result.get(i, "score") {
2848 Some(Value::Float(f)) => *f,
2849 other => panic!("score col should be Float, got {other:?}"),
2850 })
2851 .collect();
2852 let distinct: std::collections::BTreeSet<u64> =
2853 scores.iter().map(|s| s.to_bits()).collect();
2854 assert!(
2855 distinct.len() >= 2,
2856 "sample results must be visibly ranked, got {scores:?}"
2857 );
2858 for w in scores.windows(2) {
2859 assert!(
2860 w[0] >= w[1],
2861 "scores must be non-increasing, got {scores:?}"
2862 );
2863 }
2864 assert!(
2865 !out.explanations.is_empty(),
2866 "explain(person-01, proj-01) must find the derived edges"
2867 );
2868
2869 let db = SharedDb::open(&dir).expect("reopen demo");
2870 assert_eq!(
2871 directed_pairs(&db, "FOUNDED_WITHIN"),
2872 [
2873 ("org-01", "org-02"),
2874 ("org-01", "org-03"),
2875 ("org-02", "org-01"),
2876 ("org-02", "org-03"),
2877 ("org-02", "org-04"),
2878 ("org-03", "org-01"),
2879 ("org-03", "org-02"),
2880 ("org-03", "org-04"),
2881 ("org-03", "org-05"),
2882 ("org-04", "org-02"),
2883 ("org-04", "org-03"),
2884 ("org-04", "org-05"),
2885 ("org-04", "org-06"),
2886 ("org-05", "org-03"),
2887 ("org-05", "org-04"),
2888 ("org-05", "org-06"),
2889 ("org-05", "org-07"),
2890 ("org-06", "org-04"),
2891 ("org-06", "org-05"),
2892 ("org-06", "org-07"),
2893 ("org-06", "org-08"),
2894 ("org-07", "org-05"),
2895 ("org-07", "org-06"),
2896 ("org-07", "org-08"),
2897 ("org-07", "org-09"),
2898 ("org-08", "org-06"),
2899 ("org-08", "org-07"),
2900 ("org-08", "org-09"),
2901 ("org-08", "org-10"),
2902 ("org-09", "org-07"),
2903 ("org-09", "org-08"),
2904 ("org-09", "org-10"),
2905 ("org-10", "org-08"),
2906 ("org-10", "org-09"),
2907 ]
2908 .into_iter()
2909 .map(|(a, b)| (a.to_string(), b.to_string()))
2910 .collect::<BTreeSet<_>>()
2911 );
2912 assert_eq!(
2913 directed_pairs(&db, "NEARBY_OFFICE"),
2914 [
2915 ("org-01", "org-07"),
2916 ("org-01", "org-10"),
2917 ("org-02", "org-09"),
2918 ("org-03", "org-08"),
2919 ("org-04", "org-05"),
2920 ("org-04", "org-06"),
2921 ("org-05", "org-04"),
2922 ("org-05", "org-06"),
2923 ("org-06", "org-04"),
2924 ("org-06", "org-05"),
2925 ("org-07", "org-01"),
2926 ("org-07", "org-10"),
2927 ("org-08", "org-03"),
2928 ("org-09", "org-02"),
2929 ("org-10", "org-01"),
2930 ("org-10", "org-07"),
2931 ]
2932 .into_iter()
2933 .map(|(a, b)| (a.to_string(), b.to_string()))
2934 .collect::<BTreeSet<_>>()
2935 );
2936 assert_weight(&db, "org-01", "org-02", "founded_within", 0.5);
2937 let nyc_jc = 1.0 - haversine_km(40.7128, -74.0060, 40.7178, -74.0431) / 50.0;
2938 assert_weight(&db, "org-01", "org-07", "nearby_office", nyc_jc);
2939 assert_weight(&db, "person-01", "person-11", "similar_interests", 1.0);
2940 assert_weight(&db, "person-01", "person-09", "similar_interests", 0.8);
2941
2942 let err = run_demo(&dir).expect_err("second run into the same dir");
2943 let msg = err.to_string().to_lowercase();
2944 assert!(
2945 msg.contains("not empty") || msg.contains("non-empty") || msg.contains("non empty"),
2946 "refuse message must mention non-empty dir, got {err}"
2947 );
2948 assert!(
2949 msg.contains("hidden"),
2950 "refuse message must mention hidden files, got {err}"
2951 );
2952
2953 let _ = std::fs::remove_dir_all(&dir);
2954 }
2955
2956 #[test]
2957 fn run_snapshot_writes_snapshot_bin() {
2958 let dir = tmp("snapshot-cli");
2959 {
2960 let mut db = GraphDb::open(&dir).expect("open");
2961 db.insert_node("Person", "alice", vec![]).expect("insert");
2962 }
2963 assert!(
2964 !dir.join("snapshot.bin").exists(),
2965 "GraphDb Drop must not snapshot"
2966 );
2967 let out = run_snapshot(&dir, false, false, None).expect("snapshot");
2968 assert!(
2969 dir.join("snapshot.bin").is_file(),
2970 "run_snapshot must write snapshot.bin"
2971 );
2972 assert!(
2973 out.contains("snapshot.bin"),
2974 "snapshot output should mention snapshot.bin, got {out}"
2975 );
2976 let db = GraphDb::open(&dir).expect("reopen");
2977 assert!(db.has_node("alice"), "reopen after snapshot must recover");
2978 let _ = std::fs::remove_dir_all(&dir);
2979 }
2980
2981 #[test]
2982 fn run_query_formats_like_asof() {
2983 let dir = tmp("query-cli");
2984 {
2985 let mut db = GraphDb::open(&dir).expect("open");
2986 db.insert_node(
2987 "Person",
2988 "alice",
2989 vec![("id".into(), Value::Str("alice".into()))],
2990 )
2991 .expect("insert");
2992 }
2993 let out = run_query(&dir, "MATCH (n:Person) RETURN n.id AS id").expect("query");
2994 assert!(out.contains("columns:"), "got {out}");
2995 assert!(out.contains("id=alice"), "got {out}");
2996 let _ = run_query(&dir, "CREATE (n:Person {id: 'bob'})").expect("write");
2997 let db = GraphDb::open(&dir).expect("reopen");
2998 assert!(db.has_node("bob"), "query_write must persist CREATE");
2999 let _ = std::fs::remove_dir_all(&dir);
3000 }
3001
3002 #[test]
3003 fn format_stats_contains_counts() {
3004 let dir = tmp("stats-smoke");
3005 let out = run_demo(&dir).expect("demo for stats smoke");
3006 let text = format_stats(&out.stats);
3007 assert!(
3008 text.contains("60"),
3009 "stats output should include live node count, got:\n{text}"
3010 );
3011 assert!(
3012 text.contains("334"),
3013 "stats output should include edge count, got:\n{text}"
3014 );
3015 assert!(
3016 text.to_lowercase().contains("node"),
3017 "stats output should mention nodes, got:\n{text}"
3018 );
3019 assert!(
3020 text.to_lowercase().contains("edge"),
3021 "stats output should mention edges, got:\n{text}"
3022 );
3023 let _ = std::fs::remove_dir_all(&dir);
3024 }
3025
3026 #[test]
3029 fn parse_backup_round_trip() {
3030 let r = parse_args(&["backup", "/db/dir", "/backup/dest"]);
3031 match r {
3032 Ok(Command::Backup { db_dir, dest }) => {
3033 assert_eq!(db_dir, PathBuf::from("/db/dir"));
3034 assert_eq!(dest, PathBuf::from("/backup/dest"));
3035 }
3036 other => panic!("backup parse, got {other:?}"),
3037 }
3038 }
3039
3040 #[test]
3041 fn parse_backup_missing_dest_errors() {
3042 let r = parse_args(&["backup", "/db/dir"]);
3043 assert!(r.is_err(), "backup without <dest> should error");
3044 let e = r.unwrap_err();
3045 assert!(
3046 e.to_lowercase().contains("dest"),
3047 "error should mention dest, got: {e}"
3048 );
3049 }
3050
3051 #[test]
3052 fn parse_export_defaults_to_jsonl() {
3053 let r = parse_args(&["export", "/db/dir", "/export/dest"]);
3054 match r {
3055 Ok(Command::Export { format, .. }) => {
3056 assert_eq!(format, ExportFormat::Jsonl);
3057 }
3058 other => panic!("export parse, got {other:?}"),
3059 }
3060 }
3061
3062 #[test]
3063 fn parse_export_parquet_flag() {
3064 let r = parse_args(&["export", "/db/dir", "/export/dest", "--format", "parquet"]);
3065 match r {
3066 Ok(Command::Export { format, .. }) => {
3067 assert_eq!(format, ExportFormat::Parquet);
3068 }
3069 other => panic!("export --format parquet parse, got {other:?}"),
3070 }
3071 }
3072
3073 #[test]
3074 fn parse_export_parquet_flag_eq() {
3075 let r = parse_args(&["export", "/db/dir", "/dest", "--format=parquet"]);
3076 match r {
3077 Ok(Command::Export { format, .. }) => {
3078 assert_eq!(format, ExportFormat::Parquet);
3079 }
3080 other => panic!("export --format=parquet parse, got {other:?}"),
3081 }
3082 }
3083
3084 #[test]
3085 fn run_backup_cli_produces_verified_report() {
3086 let src = tmp("cli-backup-src");
3087 let dst = tmp("cli-backup-dst");
3088 let _ = run_demo(&src).expect("demo");
3089 let report = run_backup(&src, &dst).expect("run_backup");
3090 assert!(report.verified, "backup must be verified");
3091 assert!(!report.files.is_empty());
3092 assert!(report.bytes > 0);
3093 let _ = std::fs::remove_dir_all(&src);
3094 let _ = std::fs::remove_dir_all(&dst);
3095 }
3096
3097 #[test]
3098 fn run_export_jsonl_two_runs_byte_identical() {
3099 let src = tmp("cli-export-src");
3100 let dst1 = tmp("cli-export-dst1");
3101 let dst2 = tmp("cli-export-dst2");
3102 let _ = run_demo(&src).expect("demo");
3103
3104 run_export(&src, &dst1, &ExportFormat::Jsonl).expect("first export");
3105 run_export(&src, &dst2, &ExportFormat::Jsonl).expect("second export");
3106
3107 for filename in &["nodes.jsonl", "edges.jsonl", "rules.jsonl"] {
3108 let f1 = std::fs::read(dst1.join(filename)).expect("read first");
3109 let f2 = std::fs::read(dst2.join(filename)).expect("read second");
3110 assert_eq!(
3111 f1, f2,
3112 "{filename} must be byte-identical across two export runs"
3113 );
3114 }
3115 let _ = std::fs::remove_dir_all(&src);
3116 let _ = std::fs::remove_dir_all(&dst1);
3117 let _ = std::fs::remove_dir_all(&dst2);
3118 }
3119
3120 #[test]
3121 fn run_export_jsonl_nodes_are_sorted() {
3122 let src = tmp("cli-export-sorted");
3123 let dst = tmp("cli-export-sorted-dst");
3124 let _ = run_demo(&src).expect("demo");
3125 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
3126
3127 let content = std::fs::read_to_string(dst.join("nodes.jsonl")).expect("read nodes");
3128 let keys: Vec<String> = content
3129 .lines()
3130 .filter(|l| !l.is_empty())
3131 .map(|l| {
3132 let v: serde_json::Value = serde_json::from_str(l).expect("parse line");
3133 v["key"].as_str().unwrap_or("").to_string()
3134 })
3135 .collect();
3136 let mut sorted = keys.clone();
3137 sorted.sort();
3138 assert_eq!(keys, sorted, "nodes.jsonl must be sorted by key");
3139 let _ = std::fs::remove_dir_all(&src);
3140 let _ = std::fs::remove_dir_all(&dst);
3141 }
3142
3143 #[test]
3144 fn run_export_jsonl_derived_edges_have_rule() {
3145 let src = tmp("cli-export-derived");
3146 let dst = tmp("cli-export-derived-dst");
3147 let _ = run_demo(&src).expect("demo");
3148 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export");
3149
3150 let content = std::fs::read_to_string(dst.join("edges.jsonl")).expect("read edges");
3151 let derived_lines: Vec<serde_json::Value> = content
3152 .lines()
3153 .filter(|l| !l.is_empty())
3154 .map(|l| serde_json::from_str(l).expect("parse line"))
3155 .filter(|v: &serde_json::Value| v["derived"].as_bool().unwrap_or(false))
3156 .collect();
3157 assert!(
3158 !derived_lines.is_empty(),
3159 "demo store should have derived edges"
3160 );
3161 for edge in &derived_lines {
3162 assert!(
3163 !edge["rule"].is_null(),
3164 "derived edge must have non-null rule: {edge}"
3165 );
3166 }
3167 let _ = std::fs::remove_dir_all(&src);
3168 let _ = std::fs::remove_dir_all(&dst);
3169 }
3170
3171 #[test]
3172 fn run_export_parquet_produces_files() {
3173 let src = tmp("cli-export-parq-src");
3174 let dst = tmp("cli-export-parq-dst");
3175 let _ = run_demo(&src).expect("demo");
3176 run_export(&src, &dst, &ExportFormat::Parquet).expect("parquet export");
3177
3178 assert!(
3179 dst.join("nodes.parquet").exists(),
3180 "nodes.parquet must exist"
3181 );
3182 assert!(
3183 dst.join("edges.parquet").exists(),
3184 "edges.parquet must exist"
3185 );
3186 assert!(
3187 dst.join("rules.parquet").exists(),
3188 "rules.parquet must exist"
3189 );
3190 for f in &["nodes.parquet", "edges.parquet", "rules.parquet"] {
3192 let meta = std::fs::metadata(dst.join(f)).expect("metadata");
3193 assert!(meta.len() > 0, "{f} must be non-empty");
3194 }
3195 let _ = std::fs::remove_dir_all(&src);
3196 let _ = std::fs::remove_dir_all(&dst);
3197 }
3198
3199 #[test]
3200 fn parse_export_graphml_flag() {
3201 let r = parse_args(&["export", "/db/dir", "/dest", "--format", "graphml"]);
3202 match r {
3203 Ok(Command::Export { format, .. }) => {
3204 assert_eq!(format, ExportFormat::Graphml);
3205 }
3206 other => panic!("export --format graphml parse, got {other:?}"),
3207 }
3208 }
3209
3210 #[test]
3211 fn run_export_graphml_structure() {
3212 let src = tmp("cli-export-gml-src");
3213 let dst_dir = tmp("cli-export-gml-dst");
3214 let dst = dst_dir.join("graph.graphml");
3215 let _ = run_demo(&src).expect("demo");
3216 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3217
3218 let content = std::fs::read_to_string(&dst).expect("read graphml");
3219
3220 assert!(
3221 content.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"),
3222 "must start with an XML declaration"
3223 );
3224 assert!(
3225 content.contains("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">"),
3226 "must use the standard GraphML namespace"
3227 );
3228 assert!(
3229 content.contains(
3230 "<key id=\"n_label\" for=\"node\" attr.name=\"label\" attr.type=\"string\"/>"
3231 ),
3232 "must declare the node label key"
3233 );
3234 assert!(
3235 content.contains(
3236 "<key id=\"e_type\" for=\"edge\" attr.name=\"type\" attr.type=\"string\"/>"
3237 ),
3238 "must declare the edge type key"
3239 );
3240 assert!(
3241 content.contains(
3242 "<key id=\"e_derived\" for=\"edge\" attr.name=\"derived\" attr.type=\"boolean\"/>"
3243 ),
3244 "must declare the edge derived key"
3245 );
3246 assert!(
3247 content.contains(
3248 "<key id=\"e_rule\" for=\"edge\" attr.name=\"rule\" attr.type=\"string\"/>"
3249 ),
3250 "must declare the edge rule key"
3251 );
3252 assert!(
3253 content.contains(
3254 "<key id=\"e_weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>"
3255 ),
3256 "must declare the edge weight key"
3257 );
3258 assert!(
3262 content.contains(
3263 "<key id=\"n_founded_year\" for=\"node\" attr.name=\"founded_year\" attr.type=\"long\"/>"
3264 ),
3265 "an int-valued prop must declare attr.type=\"long\", not \"int\", got: {content}"
3266 );
3267 assert!(
3268 content.contains("<graph id=\"G\" edgedefault=\"directed\">"),
3269 "must declare a single directed graph element"
3270 );
3271 assert!(content.contains("<node id="), "must contain node elements");
3272 assert!(
3273 content.contains("<edge id=\"e0\" source=\""),
3274 "must contain a sequentially-numbered edge starting at e0"
3275 );
3276 assert!(
3277 content.trim_end().ends_with("</graphml>"),
3278 "must close the root element"
3279 );
3280
3281 assert!(
3284 content.contains("<data key=\"e_rule\">skill_fit</data>")
3285 || content.contains("<data key=\"e_rule\">founded_within</data>"),
3286 "at least one derived edge must carry its rule name"
3287 );
3288 assert!(
3289 content.contains(&format!(
3290 "<data key=\"{}\">",
3291 "e_weight" )),
3293 "at least one derived edge must carry a weight value"
3294 );
3295
3296 let _ = std::fs::remove_dir_all(&src);
3297 let _ = std::fs::remove_dir_all(&dst_dir);
3298 }
3299
3300 #[test]
3301 fn run_export_graphml_dest_dir_writes_graph_dot_graphml() {
3302 let src = tmp("cli-export-gml-dir-src");
3303 let dst_dir = tmp("cli-export-gml-dir-dst");
3304 std::fs::create_dir_all(&dst_dir).expect("mkdir dest");
3305 let _ = run_demo(&src).expect("demo");
3306
3307 let msg = run_export(&src, &dst_dir, &ExportFormat::Graphml).expect("graphml export");
3308
3309 assert!(
3310 dst_dir.join("graph.graphml").exists(),
3311 "an existing directory dest must produce dest/graph.graphml"
3312 );
3313 assert!(
3314 msg.contains("graph.graphml"),
3315 "report must name the file actually written, got: {msg}"
3316 );
3317
3318 let _ = std::fs::remove_dir_all(&src);
3319 let _ = std::fs::remove_dir_all(&dst_dir);
3320 }
3321
3322 #[test]
3325 fn run_export_graphml_is_well_formed_xml() {
3326 let has_python3 = std::process::Command::new("python3")
3327 .arg("--version")
3328 .output()
3329 .map(|o| o.status.success())
3330 .unwrap_or(false);
3331 if !has_python3 {
3332 eprintln!("skipping run_export_graphml_is_well_formed_xml: python3 not found");
3333 return;
3334 }
3335
3336 let src = tmp("cli-export-gml-wf-src");
3337 let dst_dir = tmp("cli-export-gml-wf-dst");
3338 let dst = dst_dir.join("graph.graphml");
3339 let _ = run_demo(&src).expect("demo");
3340 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3341
3342 let status = std::process::Command::new("python3")
3343 .arg("-c")
3344 .arg("import sys, xml.etree.ElementTree as E; E.parse(sys.argv[1])")
3345 .arg(&dst)
3346 .status()
3347 .expect("run python3");
3348 assert!(
3349 status.success(),
3350 "python3's XML parser must accept the exported GraphML file"
3351 );
3352
3353 let _ = std::fs::remove_dir_all(&src);
3354 let _ = std::fs::remove_dir_all(&dst_dir);
3355 }
3356
3357 #[test]
3358 fn run_export_graphml_two_runs_byte_identical() {
3359 let src = tmp("cli-export-gml-bi-src");
3360 let dst_dir1 = tmp("cli-export-gml-bi-dst1");
3361 let dst_dir2 = tmp("cli-export-gml-bi-dst2");
3362 let dst1 = dst_dir1.join("graph.graphml");
3363 let dst2 = dst_dir2.join("graph.graphml");
3364 let _ = run_demo(&src).expect("demo");
3365
3366 run_export(&src, &dst1, &ExportFormat::Graphml).expect("first export");
3367 run_export(&src, &dst2, &ExportFormat::Graphml).expect("second export");
3368
3369 let f1 = std::fs::read(&dst1).expect("read first");
3370 let f2 = std::fs::read(&dst2).expect("read second");
3371 assert_eq!(
3372 f1, f2,
3373 "graph.graphml must be byte-identical across two export runs"
3374 );
3375
3376 let _ = std::fs::remove_dir_all(&src);
3377 let _ = std::fs::remove_dir_all(&dst_dir1);
3378 let _ = std::fs::remove_dir_all(&dst_dir2);
3379 }
3380
3381 #[test]
3382 fn run_export_graphml_escapes_and_lists() {
3383 use core_api::{GraphDb, Value};
3384 let src = tmp("cli-export-gml-esc-src");
3385 let dst_dir = tmp("cli-export-gml-esc-dst");
3386 let dst = dst_dir.join("graph.graphml");
3387
3388 {
3389 let mut db = GraphDb::open(&src).unwrap();
3390 db.insert_node(
3391 "Widget",
3392 "w1",
3393 vec![
3394 (
3395 "title".into(),
3396 Value::Str("Tom & Jerry <says> \"hi\" 'bye'".into()),
3397 ),
3398 (
3399 "tags".into(),
3400 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
3401 ),
3402 ],
3403 )
3404 .unwrap();
3405 }
3406
3407 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3408 let content = std::fs::read_to_string(&dst).expect("read graphml");
3409
3410 assert!(
3411 content.contains("Tom & Jerry <says> "hi" 'bye'"),
3412 "special XML characters in string props must be escaped, got: {content}"
3413 );
3414 assert!(
3415 !content.contains("Tom & Jerry <says>"),
3416 "unescaped special characters must not appear verbatim"
3417 );
3418 assert!(
3419 content.contains(
3420 "<key id=\"n_tags\" for=\"node\" attr.name=\"tags\" attr.type=\"string\"/>"
3421 ),
3422 "list-valued props must declare attr.type=\"string\""
3423 );
3424 assert!(
3425 content.contains("<data key=\"n_tags\">["a","b"]</data>"),
3426 "list-valued props must render as XML-escaped JSON text, got: {content}"
3427 );
3428
3429 let _ = std::fs::remove_dir_all(&src);
3430 let _ = std::fs::remove_dir_all(&dst_dir);
3431 }
3432
3433 #[test]
3438 fn run_export_graphml_mixed_type_prop_declares_string() {
3439 use core_api::{GraphDb, Value};
3440 let src = tmp("cli-export-gml-mixed-src");
3441 let dst_dir = tmp("cli-export-gml-mixed-dst");
3442 let dst = dst_dir.join("graph.graphml");
3443
3444 {
3445 let mut db = GraphDb::open(&src).unwrap();
3446 db.insert_node("Metric", "m1", vec![("score".into(), Value::Int(5))])
3447 .unwrap();
3448 db.insert_node(
3449 "Metric",
3450 "m2",
3451 vec![("score".into(), Value::Str("high".into()))],
3452 )
3453 .unwrap();
3454 }
3455
3456 run_export(&src, &dst, &ExportFormat::Graphml).expect("graphml export");
3457 let content = std::fs::read_to_string(&dst).expect("read graphml");
3458
3459 assert!(
3460 content.contains(
3461 "<key id=\"n_score\" for=\"node\" attr.name=\"score\" attr.type=\"string\"/>"
3462 ),
3463 "a prop name with conflicting value types across nodes must declare \
3464 attr.type=\"string\", got: {content}"
3465 );
3466 assert!(
3467 !content.contains("attr.name=\"score\" attr.type=\"long\""),
3468 "must not declare a narrower type once a conflict is seen, got: {content}"
3469 );
3470 assert!(
3473 content.contains("<data key=\"n_score\">5</data>"),
3474 "the int-valued node must still render its literal int text, got: {content}"
3475 );
3476 assert!(
3477 content.contains("<data key=\"n_score\">high</data>"),
3478 "the string-valued node must still render its literal string text, got: {content}"
3479 );
3480
3481 let _ = std::fs::remove_dir_all(&src);
3482 let _ = std::fs::remove_dir_all(&dst_dir);
3483 }
3484
3485 #[test]
3486 fn parse_algo_degree_defaults_dir_both() {
3487 let cmd = parse_args(&["algo", "degree", "/db"]).unwrap();
3488 match cmd {
3489 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::Both),
3490 other => panic!("expected Algo, got {other:?}"),
3491 }
3492 }
3493
3494 #[test]
3495 fn parse_algo_degree_with_dir_flag() {
3496 for (arg, want) in [
3497 ("out", AlgoDir::Out),
3498 ("in", AlgoDir::In),
3499 ("both", AlgoDir::Both),
3500 ] {
3501 let cmd = parse_args(&["algo", "degree", "/db", "--dir", arg]).unwrap();
3502 match cmd {
3503 Command::Algo { dir, .. } => assert_eq!(dir, want, "--dir {arg}"),
3504 other => panic!("expected Algo, got {other:?}"),
3505 }
3506 }
3507 let cmd = parse_args(&["algo", "degree", "/db", "--dir=in"]).unwrap();
3509 match cmd {
3510 Command::Algo { dir, .. } => assert_eq!(dir, AlgoDir::In),
3511 other => panic!("expected Algo, got {other:?}"),
3512 }
3513 }
3514
3515 #[test]
3516 fn parse_algo_rejects_unknown_dir() {
3517 assert!(parse_args(&["algo", "degree", "/db", "--dir", "sideways"]).is_err());
3518 }
3519
3520 #[test]
3521 fn parse_algo_communities_parses_edge_type_weight_prop_min_weight() {
3522 let cmd = parse_args(&[
3523 "algo",
3524 "communities",
3525 "/db",
3526 "--edge-type",
3527 "IMPORTS",
3528 "--edge-type=CO_CHANGED",
3529 "--weight-prop",
3530 "score",
3531 "--min-weight",
3532 "0.3",
3533 "--top",
3534 "5",
3535 ])
3536 .unwrap();
3537 match cmd {
3538 Command::Algo {
3539 subcmd,
3540 top,
3541 edge_types,
3542 weight_prop,
3543 min_weight,
3544 ..
3545 } => {
3546 assert_eq!(subcmd, AlgoSubcmd::Communities);
3547 assert_eq!(top, 5);
3548 assert_eq!(
3549 edge_types,
3550 vec!["IMPORTS".to_string(), "CO_CHANGED".to_string()]
3551 );
3552 assert_eq!(weight_prop, Some("score".to_string()));
3553 assert_eq!(min_weight, Some(0.3));
3554 }
3555 other => panic!("expected Algo, got {other:?}"),
3556 }
3557 }
3558
3559 #[test]
3560 fn parse_algo_communities_defaults_have_no_edge_type_or_weight_filter() {
3561 let cmd = parse_args(&["algo", "communities", "/db"]).unwrap();
3562 match cmd {
3563 Command::Algo {
3564 subcmd,
3565 edge_types,
3566 weight_prop,
3567 min_weight,
3568 ..
3569 } => {
3570 assert_eq!(subcmd, AlgoSubcmd::Communities);
3571 assert!(edge_types.is_empty());
3572 assert_eq!(weight_prop, None);
3573 assert_eq!(min_weight, None);
3574 }
3575 other => panic!("expected Algo, got {other:?}"),
3576 }
3577 }
3578
3579 #[test]
3582 fn run_export_jsonl_nan_float_becomes_null() {
3583 use core_api::{GraphDb, Value};
3584 let src = tmp("cli-export-nan-src");
3585 let dst = tmp("cli-export-nan-dst");
3586
3587 {
3589 let mut db = GraphDb::open(&src).unwrap();
3590 db.insert_node(
3591 "Sensor",
3592 "s1",
3593 vec![
3594 ("nan_val".into(), Value::Float(f64::NAN)),
3595 ("pos_inf".into(), Value::Float(f64::INFINITY)),
3596 ("neg_inf".into(), Value::Float(f64::NEG_INFINITY)),
3597 ("normal".into(), Value::Float(1.5)),
3598 ],
3599 )
3600 .unwrap();
3601 }
3602
3603 run_export(&src, &dst, &ExportFormat::Jsonl).expect("export with NaN must succeed");
3605
3606 let content =
3608 std::fs::read_to_string(dst.join("nodes.jsonl")).expect("nodes.jsonl missing");
3609 let row: serde_json::Value =
3610 serde_json::from_str(content.lines().next().unwrap()).expect("valid json line");
3611 assert_eq!(
3612 row["nan_val"],
3613 serde_json::Value::Null,
3614 "NaN must export as null"
3615 );
3616 assert_eq!(
3617 row["pos_inf"],
3618 serde_json::Value::Null,
3619 "+Inf must export as null"
3620 );
3621 assert_eq!(
3622 row["neg_inf"],
3623 serde_json::Value::Null,
3624 "-Inf must export as null"
3625 );
3626 assert_eq!(
3628 row["normal"],
3629 serde_json::json!(1.5),
3630 "normal float roundtrips"
3631 );
3632
3633 let _ = std::fs::remove_dir_all(&src);
3634 let _ = std::fs::remove_dir_all(&dst);
3635 }
3636
3637 #[test]
3638 fn serve_tls_flags_parse_both_forms() {
3639 match parse_args(&[
3641 "serve",
3642 "/tmp/db",
3643 "--tls-cert",
3644 "/a/cert.pem",
3645 "--tls-key",
3646 "/a/key.pem",
3647 ])
3648 .unwrap()
3649 {
3650 Command::Serve {
3651 tls_cert, tls_key, ..
3652 } => {
3653 assert_eq!(tls_cert, Some(PathBuf::from("/a/cert.pem")));
3654 assert_eq!(tls_key, Some(PathBuf::from("/a/key.pem")));
3655 }
3656 other => panic!("{other:?}"),
3657 }
3658 match parse_args(&[
3660 "serve",
3661 "/tmp/db",
3662 "--tls-cert=/b/cert.pem",
3663 "--tls-key=/b/key.pem",
3664 ])
3665 .unwrap()
3666 {
3667 Command::Serve {
3668 tls_cert, tls_key, ..
3669 } => {
3670 assert_eq!(tls_cert, Some(PathBuf::from("/b/cert.pem")));
3671 assert_eq!(tls_key, Some(PathBuf::from("/b/key.pem")));
3672 }
3673 other => panic!("{other:?}"),
3674 }
3675 match parse_args(&["serve", "/tmp/db"]).unwrap() {
3677 Command::Serve {
3678 tls_cert, tls_key, ..
3679 } => {
3680 assert_eq!(tls_cert, None);
3681 assert_eq!(tls_key, None);
3682 }
3683 other => panic!("{other:?}"),
3684 }
3685 }
3686
3687 #[test]
3688 fn serve_tls_flags_require_both() {
3689 let err = parse_args(&["serve", "/tmp/db", "--tls-cert", "/a/cert.pem"]).unwrap_err();
3691 assert!(
3692 err.contains("tls-key"),
3693 "--tls-cert alone must mention --tls-key in error, got {err}"
3694 );
3695 let err = parse_args(&["serve", "/tmp/db", "--tls-key", "/a/key.pem"]).unwrap_err();
3697 assert!(
3698 err.contains("tls-cert"),
3699 "--tls-key alone must mention --tls-cert in error, got {err}"
3700 );
3701 }
3702
3703 #[test]
3704 fn version_flag_parses() {
3705 assert_eq!(parse_args(&["--version"]).unwrap(), Command::Version);
3706 assert_eq!(parse_args(&["-V"]).unwrap(), Command::Version);
3707 assert_eq!(parse_args(&["version"]).unwrap(), Command::Version);
3708 }
3709
3710 #[test]
3711 fn recall_parses_one_dir_and_is_listed_in_usage() {
3712 assert_eq!(
3713 parse_args(&["recall", "/tmp/db"]).unwrap(),
3714 Command::Recall {
3715 db_dir: Some(PathBuf::from("/tmp/db")),
3716 auto: false,
3717 }
3718 );
3719 assert!(
3720 parse_args(&["recall"]).is_err(),
3721 "one of <db-dir> or --auto is required"
3722 );
3723 assert!(usage().contains("mushroomdb recall <db-dir>"));
3724 }
3725
3726 #[test]
3727 fn map_parses_a_dir_and_an_optional_json_flag() {
3728 assert_eq!(
3729 parse_args(&["map", "/tmp/db"]).unwrap(),
3730 Command::Map {
3731 db_dir: PathBuf::from("/tmp/db"),
3732 json: false,
3733 }
3734 );
3735 let want = Command::Map {
3737 db_dir: PathBuf::from("/tmp/db"),
3738 json: true,
3739 };
3740 assert_eq!(parse_args(&["map", "/tmp/db", "--json"]).unwrap(), want);
3741 assert_eq!(parse_args(&["map", "--json", "/tmp/db"]).unwrap(), want);
3742 assert!(parse_args(&["map"]).is_err(), "<db-dir> is required");
3743 assert!(parse_args(&["map", "/tmp/db", "/tmp/other"]).is_err());
3744 assert!(parse_args(&["map", "/tmp/db", "--nope"]).is_err());
3745 assert!(usage().contains("mushroomdb map <db-dir> [--json]"));
3746 }
3747
3748 #[test]
3749 fn the_graph_tools_take_a_dir_and_their_keys() {
3750 assert_eq!(
3751 parse_args(&["context", "/tmp/db", "src/db.rs#open"]).unwrap(),
3752 Command::Context {
3753 db_dir: PathBuf::from("/tmp/db"),
3754 target: "src/db.rs#open".to_string(),
3755 }
3756 );
3757 assert_eq!(
3758 parse_args(&["impact", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
3759 Command::Impact {
3760 db_dir: PathBuf::from("/tmp/db"),
3761 files: vec!["a.rs".to_string(), "b.rs".to_string()],
3762 }
3763 );
3764 assert_eq!(
3765 parse_args(&["owners", "/tmp/db", "a.rs"]).unwrap(),
3766 Command::Owners {
3767 db_dir: PathBuf::from("/tmp/db"),
3768 path: "a.rs".to_string(),
3769 }
3770 );
3771 assert_eq!(
3772 parse_args(&["why", "/tmp/db", "a.rs", "b.rs"]).unwrap(),
3773 Command::Why {
3774 db_dir: PathBuf::from("/tmp/db"),
3775 a: "a.rs".to_string(),
3776 b: "b.rs".to_string(),
3777 }
3778 );
3779
3780 for args in [
3782 vec!["context", "/tmp/db"],
3783 vec!["context", "/tmp/db", "a", "b"],
3784 vec!["impact", "/tmp/db"],
3785 vec!["owners", "/tmp/db"],
3786 vec!["why", "/tmp/db", "a"],
3787 vec!["why", "/tmp/db", "a", "b", "c"],
3788 vec!["why", "/tmp/db", "-a", "b"],
3789 vec!["context"],
3790 ] {
3791 assert!(parse_args(&args).is_err(), "{args:?} must not parse");
3792 }
3793 for line in [
3794 "mushroomdb context <db-dir> <target>",
3795 "mushroomdb impact <db-dir> <file>...",
3796 "mushroomdb owners <db-dir> <path>",
3797 "mushroomdb why <db-dir> <a> <b>",
3798 ] {
3799 assert!(usage().contains(line), "usage is missing {line:?}");
3800 }
3801 }
3802
3803 #[test]
3806 fn hook_commands_take_a_dir_or_auto() {
3807 assert_eq!(
3808 parse_args(&["mcp", "--auto"]).unwrap(),
3809 Command::Mcp {
3810 db_dir: None,
3811 auto: true
3812 }
3813 );
3814 assert_eq!(
3815 parse_args(&["recall", "--auto"]).unwrap(),
3816 Command::Recall {
3817 db_dir: None,
3818 auto: true
3819 }
3820 );
3821 for cmd in ["mcp", "recall", "touch"] {
3822 assert!(parse_args(&[cmd]).is_err(), "{cmd} with no target");
3823 assert!(
3824 parse_args(&[cmd, "/tmp/db", "--auto"]).is_err(),
3825 "{cmd} with both"
3826 );
3827 }
3828 assert!(usage().contains("--auto"));
3829 }
3830
3831 #[test]
3832 fn sync_and_touch_parse() {
3833 assert_eq!(
3834 parse_args(&["sync", "/tmp/db"]).unwrap(),
3835 Command::Sync {
3836 db_dir: PathBuf::from("/tmp/db"),
3837 json: false,
3838 }
3839 );
3840 assert_eq!(
3841 parse_args(&["sync", "/tmp/db", "--json"]).unwrap(),
3842 Command::Sync {
3843 db_dir: PathBuf::from("/tmp/db"),
3844 json: true,
3845 }
3846 );
3847 assert!(parse_args(&["sync"]).is_err(), "db-dir is required");
3848
3849 assert_eq!(
3851 parse_args(&["touch", "/tmp/db", "src/a.rs", "src/b.rs"]).unwrap(),
3852 Command::Touch {
3853 db_dir: Some(PathBuf::from("/tmp/db")),
3854 auto: false,
3855 files: vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")],
3856 }
3857 );
3858 assert_eq!(
3860 parse_args(&["touch", "--auto", "src/a.rs"]).unwrap(),
3861 Command::Touch {
3862 db_dir: None,
3863 auto: true,
3864 files: vec![PathBuf::from("src/a.rs")],
3865 }
3866 );
3867 assert_eq!(
3869 parse_args(&["touch", "--auto"]).unwrap(),
3870 Command::Touch {
3871 db_dir: None,
3872 auto: true,
3873 files: vec![],
3874 }
3875 );
3876 assert!(usage().contains("mushroomdb sync <db-dir>"));
3877 assert!(usage().contains("mushroomdb touch"));
3878 }
3879
3880 #[test]
3881 fn ingest_git_parses_excludes() {
3882 let cmd = parse_args(&[
3883 "ingest-git",
3884 "/tmp/db",
3885 "/tmp/repo",
3886 "--exclude",
3887 "target/",
3888 "--exclude=*.lock",
3889 "--max-commits-per-file",
3890 "50",
3891 "--recurse-submodules",
3892 "--prs",
3893 "--ensure-gitignore",
3894 ])
3895 .unwrap();
3896 assert_eq!(
3897 cmd,
3898 Command::IngestGit {
3899 db_dir: PathBuf::from("/tmp/db"),
3900 opts: ingest_git::IngestGitOpts {
3901 repo: PathBuf::from("/tmp/repo"),
3902 exclude: vec!["target/".into(), "*.lock".into()],
3903 max_commits_per_file: 50,
3904 recurse_submodules: true,
3905 prs: true,
3906 structure: true,
3907 docs: true,
3908 ensure_gitignore: true,
3909 },
3910 }
3911 );
3912 let Command::IngestGit { opts, .. } =
3914 parse_args(&["ingest-git", "/tmp/db", "/tmp/repo"]).unwrap()
3915 else {
3916 panic!("expected IngestGit");
3917 };
3918 assert_eq!(
3919 opts.exclude,
3920 ingest_git::DEFAULT_EXCLUDES
3921 .iter()
3922 .map(|p| (*p).to_string())
3923 .collect::<Vec<_>>(),
3924 "with no --exclude the defaults apply"
3925 );
3926 assert_eq!(
3927 opts.max_commits_per_file,
3928 ingest_git::DEFAULT_MAX_COMMITS_PER_FILE
3929 );
3930 assert!(!opts.recurse_submodules && !opts.prs && !opts.ensure_gitignore);
3931 assert!(
3932 opts.structure && opts.docs,
3933 "structure and docs default on and are recorded on the marker"
3934 );
3935 let Command::IngestGit { opts, .. } = parse_args(&[
3936 "ingest-git",
3937 "/tmp/db",
3938 "/tmp/repo",
3939 "--no-structure",
3940 "--no-docs",
3941 ])
3942 .unwrap() else {
3943 panic!("expected IngestGit");
3944 };
3945 assert!(!opts.structure && !opts.docs);
3946 assert!(parse_args(&["ingest-git", "/tmp/db"]).is_err());
3947 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--nope"]).is_err());
3948 assert!(parse_args(&["ingest-git", "/tmp/db", "/tmp/repo", "--exclude"]).is_err());
3949 assert!(usage().contains("mushroomdb ingest-git <db-dir> <repo-dir>"));
3950 }
3951
3952 #[test]
3953 fn version_constant_matches_cargo() {
3954 assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
3955 assert!(usage().contains("--version"));
3956 }
3957}