Skip to main content

memstead_cli/commands/
mem_repo.rs

1//! `memstead mem-repo ...` — mem-repo-git lifecycle commands.
2//!
3//! Today this module hosts `init [<path>]` — bootstrap a fresh
4//! mem-repo-git workspace at `<path>/mem-repo/.git/` with a working
5//! tree on `main`, an initial commit carrying the README template, and
6//! (optionally) an outer-repo `.gitignore` append so a surrounding git
7//! repo does not see the new `mem-repo/` as a gitlink.
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use clap::{Args, Subcommand};
13use gix::object::tree::EntryKind;
14
15use crate::CliError;
16use crate::outer_gitignore::{OuterRepoOutcome, apply_outer_gitignore};
17use crate::output::ExitKind;
18use crate::setup::CliContext;
19
20const README_TEMPLATE: &str = include_str!("../../templates/mem-repo-readme.md");
21
22/// Engine-required minimum for `.memstead/workspace.toml`. The two-layer
23/// file adapter loader treats `format` + `[persistence_adapter]` as
24/// the only mandatory keys; everything else (mem_management,
25/// cross_mem_links, mutations, plugin.*) is operator-opt-in and
26/// defaults to deny/empty. Matches the same baseline `memstead init`
27/// writes for filesystem-mem — keeping the two flavours symmetric.
28const DEFAULT_WORKSPACE_TOML: &str = "\
29format = \"memstead-git-branch-2\"\n\
30\n\
31[persistence_adapter]\n\
32name = \"file-two-layer\"\n";
33
34/// Subcommands under `memstead mem-repo`.
35#[derive(Subcommand, Debug)]
36pub enum MemRepoAction {
37    /// Bootstrap a fresh mem-repo-git workspace.
38    Init(InitArgs),
39
40    /// Configure (or re-point) a named git remote on the mem-repo, so
41    /// `memstead fetch` / `pull` / `push` have somewhere to go. Upsert:
42    /// re-running with a new URL re-points the remote.
43    #[command(name = "remote-add")]
44    RemoteAdd(RemoteAddArgs),
45}
46
47/// `memstead mem-repo remote-add <name> <url>` arguments.
48#[derive(Args, Debug)]
49pub struct RemoteAddArgs {
50    /// Remote name (e.g. `origin`).
51    pub name: String,
52    /// Remote URL (e.g. `git@github.com:you/mem-backup.git` or a local
53    /// bare-repo path).
54    pub url: String,
55}
56
57/// `memstead mem-repo init [<path>]` arguments.
58#[derive(Args, Debug)]
59pub struct InitArgs {
60    /// Workspace directory to bootstrap. Created if missing. Defaults to
61    /// the current directory.
62    #[arg(default_value = ".")]
63    pub path: PathBuf,
64
65    /// Skip outer-repo `.gitignore` auto-append. Useful when the user
66    /// intends to track `mem-repo/` as a git submodule, or when the
67    /// detection heuristic would pick the wrong outer repo.
68    #[arg(long)]
69    pub no_gitignore: bool,
70}
71
72pub fn run(ctx: &CliContext, action: MemRepoAction) -> anyhow::Result<()> {
73    match action {
74        MemRepoAction::Init(args) => init(ctx, args),
75        MemRepoAction::RemoteAdd(args) => remote_add(ctx, args),
76    }
77}
78
79fn remote_add(ctx: &CliContext, args: RemoteAddArgs) -> anyhow::Result<()> {
80    let outcome = match ctx.cli_engine()? {
81        crate::setup::CliEngine::MemRepo(engine) => engine
82            .remote_add(&args.name, &args.url)
83            .map_err(CliError::from_engine_op)?,
84        crate::setup::CliEngine::Filesystem(_) => {
85            return Err(CliError {
86                code: "INVALID_INPUT",
87                kind: ExitKind::Validation,
88                message: "this workspace is not git-backed — `memstead mem-repo remote-add` \
89                          requires a mem-repo workspace"
90                    .to_string(),
91                details: None,
92            }
93            .into());
94        }
95    };
96    if ctx.json {
97        crate::output::print_json(&outcome)?;
98    } else {
99        let verb = if outcome.updated {
100            "Re-pointed"
101        } else {
102            "Added"
103        };
104        crate::output::print_markdown(&format!(
105            "# {verb} remote `{}`\n\n- URL: `{}`",
106            outcome.remote, outcome.url,
107        ));
108    }
109    Ok(())
110}
111
112fn init(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
113    let outcome = run_init(&args.path, args.no_gitignore)?;
114
115    // `--json` stdout is machine-only: exactly one JSON document, the
116    // contract `--help` advertises and steers callers to pipe through
117    // `jq`. The primary result becomes a structured envelope; the
118    // human progress block is suppressed.
119    if ctx.json {
120        crate::output::print_json(&serde_json::json!({
121            "mem_repo_dir": outcome.mem_repo_dir.display().to_string(),
122            "workspace_toml": outcome.workspace_toml.display().to_string(),
123            "workspace_shape": crate::setup::WorkspaceShape::MemRepo.label(),
124            "workspace_shape_disclosure":
125                crate::setup::shape_disclosure(crate::setup::WorkspaceShape::MemRepo).to_json(),
126        }))?;
127    } else {
128        println!(
129            "Initialised mem-repo-git at {}",
130            outcome.mem_repo_dir.display(),
131        );
132        println!("  main: README.md (initial commit)");
133        println!(
134            "  __MEMSTEAD: empty (unified registry ref for workspace schemas + per-mem configs)"
135        );
136        println!("  config: {}", outcome.workspace_toml.display());
137        // Symmetric disclosure: whichever verb opened the workspace
138        // says which of the two shapes the user now has, what it costs,
139        // and the command for the other one. A shape statement attached
140        // only to the filesystem branch would read as a warning about
141        // that branch rather than as the fork it actually is.
142        println!();
143        println!(
144            "{}",
145            crate::setup::shape_disclosure_lines(crate::setup::WorkspaceShape::MemRepo).join("\n"),
146        );
147    }
148
149    // Outer-repo provenance is human-facing context, not part of the
150    // structured result — it always goes to stderr (never stdout) so a
151    // `--json` caller's stdout stays exactly one JSON document, and is
152    // suppressed under `--quiet`. A human still sees it on the terminal.
153    match outcome.gitignore {
154        OuterRepoOutcome::Appended { outer_root, rel } => {
155            if !ctx.quiet {
156                eprintln!(
157                    "  outer:    {} — added `{}` to .gitignore \
158                     (`.memstead/` is intentionally trackable — don't ignore it)",
159                    outer_root.display(),
160                    rel,
161                );
162                eprint_source_layout_hint();
163            }
164        }
165        OuterRepoOutcome::AlreadyIgnored { outer_root, rel } => {
166            if !ctx.quiet {
167                eprintln!(
168                    "  outer:    {} — `{}` already in .gitignore, no change \
169                     (`.memstead/` is intentionally trackable — don't ignore it)",
170                    outer_root.display(),
171                    rel,
172                );
173                eprint_source_layout_hint();
174            }
175        }
176        OuterRepoOutcome::NoOuter | OuterRepoOutcome::Skipped => {}
177    }
178    Ok(())
179}
180
181/// The source-layout hint, emitted when `mem-repo init` runs inside (or
182/// at the root of) a git repository — the moment the layout decision is
183/// made, and the layout where the classic multi-repo mistake happens:
184/// workspace inside one source repo, bindings pointing at siblings.
185/// Out-of-root sources are a supported shape — enumeration, change
186/// detection, and anchor resolution all work — but artifact ids render
187/// as workspace-relative `../…` chains and the workspace-to-source
188/// relative layout must stay fixed. Context, not a warning: nothing is
189/// wrong yet, so it rides stderr with the other provenance lines.
190fn eprint_source_layout_hint() {
191    eprintln!(
192        "  layout:   sources you later bind (`projection init`) resolve against this \
193         workspace root. A source outside it is supported — enumeration, change \
194         detection, and anchor resolution all work — but its artifact ids render as \
195         `../…` chains and the workspace-to-source layout must stay fixed. To model \
196         several sibling repos, root the workspace at their common parent directory."
197    );
198}
199
200/// Library-form entry point for `memstead mem-repo init`. Creates
201/// `<workspace>/mem-repo/.git/` with a working tree on `main`, makes
202/// the initial commit, seeds the unified `__MEMSTEAD` registry ref with an
203/// empty tree, and (unless suppressed) appends `mem-repo/` to an
204/// enclosing outer-repo's `.gitignore`. Idempotent on a fresh target;
205/// refuses to overwrite an existing `mem-repo/` directory.
206pub(crate) fn run_init(
207    workspace_path: &Path,
208    skip_outer_gitignore: bool,
209) -> anyhow::Result<InitOutcome> {
210    fs::create_dir_all(workspace_path)
211        .map_err(|e| generic_error(format!("create workspace directory: {e}")))?;
212
213    let workspace = fs::canonicalize(workspace_path)
214        .map_err(|e| generic_error(format!("canonicalize workspace path: {e}")))?;
215
216    let mem_repo_root = workspace.join("mem-repo");
217    if mem_repo_root.exists() {
218        return Err(CliError {
219            code: "MEM_DB_ALREADY_EXISTS",
220            kind: ExitKind::Validation,
221            message: format!(
222                "{} already exists — refusing to overwrite. Delete or move \
223                 the existing mem-repo before re-running `mem-repo init`.",
224                mem_repo_root.display()
225            ),
226            details: None,
227        }
228        .into());
229    }
230
231    fs::create_dir_all(&mem_repo_root)
232        .map_err(|e| generic_error(format!("create mem-repo directory: {e}")))?;
233
234    gix::init(&mem_repo_root).map_err(|e| generic_error(format!("init mem-repo gitdir: {e}")))?;
235
236    let repo = gix::open(mem_repo_root.join(".git"))
237        .map_err(|e| generic_error(format!("open mem-repo gitdir: {e}")))?;
238
239    // `main` carries operator-facing docs only (README.md). Schemas and
240    // per-mem configs live on the unified `__MEMSTEAD` registry ref.
241    let mut editor = repo
242        .empty_tree()
243        .edit()
244        .map_err(|e| generic_error(format!("init main tree editor: {e}")))?;
245
246    let readme_blob = repo
247        .write_blob(README_TEMPLATE.as_bytes())
248        .map_err(|e| generic_error(format!("write README blob: {e}")))?
249        .detach();
250    editor
251        .upsert("README.md", EntryKind::Blob, readme_blob)
252        .map_err(|e| generic_error(format!("upsert README.md: {e}")))?;
253
254    let main_tree = editor
255        .write()
256        .map_err(|e| generic_error(format!("write main tree: {e}")))?
257        .detach();
258
259    let actor = init_signature();
260    let mut buf = gix::date::parse::TimeBuf::default();
261    let actor_ref = actor.to_ref(&mut buf);
262    repo.commit_as(
263        actor_ref,
264        actor_ref,
265        "refs/heads/main",
266        "mem-repo init: initial main commit",
267        main_tree,
268        Vec::<gix::ObjectId>::new(),
269    )
270    .map_err(|e| generic_error(format!("commit main: {e}")))?;
271
272    // Seed the unified `__MEMSTEAD` registry ref with an empty tree. Schemas
273    // (`__MEMSTEAD:schemas/<name>/...`) and per-mem configs
274    // (`__MEMSTEAD:mems/<mem>/config.json`) are upserted by subsequent
275    // engine writes; the empty seed lets the engine's reader resolve
276    // the ref without surfacing a bootstrap error.
277    let empty_tree = repo.empty_tree().id().detach();
278    let mut buf = gix::date::parse::TimeBuf::default();
279    let actor_ref = actor.to_ref(&mut buf);
280    repo.commit_as(
281        actor_ref,
282        actor_ref,
283        "refs/heads/__MEMSTEAD",
284        "mem-repo init: seed __MEMSTEAD",
285        empty_tree,
286        Vec::<gix::ObjectId>::new(),
287    )
288    .map_err(|e| generic_error(format!("commit __MEMSTEAD: {e}")))?;
289
290    materialise_main_worktree(&mem_repo_root)?;
291    write_default_workspace_toml(&workspace)?;
292
293    // Outer-repo gitignore append: walk up from the workspace root
294    // itself looking for an enclosing `.git/`, append `mem-repo/` to
295    // its `.gitignore`. Starting AT the workspace (not its parent)
296    // covers the workspace-IS-the-repo-root layout — the case where the
297    // append matters most, since a nested `mem-repo/.git` can never be
298    // tracked normally. The new mem-repo's own gitdir sits a level
299    // below (`<workspace>/mem-repo/.git`), never on the walk path, so
300    // it cannot be rediscovered as the outer. Idempotent on re-run.
301    let gitignore = if skip_outer_gitignore {
302        OuterRepoOutcome::Skipped
303    } else {
304        apply_outer_gitignore(&workspace, &mem_repo_root)?
305    };
306
307    Ok(InitOutcome {
308        mem_repo_dir: mem_repo_root,
309        workspace_toml: workspace
310            .join(memstead_base::WORKSPACE_STORE_DIR)
311            .join("workspace.toml"),
312        gitignore,
313    })
314}
315
316/// Write `README.md` to the working tree. Mirrors the just-committed
317/// `main` tree so a human inspecting `<workspace>/mem-repo/` sees
318/// the file immediately. Schemas and per-mem configs live on the
319/// `__MEMSTEAD` registry ref and are not surfaced via the worktree.
320fn materialise_main_worktree(mem_repo_root: &Path) -> anyhow::Result<()> {
321    fs::write(mem_repo_root.join("README.md"), README_TEMPLATE)
322        .map_err(|e| generic_error(format!("write working-tree README.md: {e}")))?;
323    Ok(())
324}
325
326/// Materialise the minimum-viable `.memstead/workspace.toml`. Required by
327/// every subsequent CLI / MCP command — without the file the
328/// workspace-store loader bails with `StoreError::NotInitialised` and
329/// the freshly-init'd workspace is unusable. Idempotent: a
330/// pre-existing file is left untouched so operator-authored content
331/// survives a re-init under the same workspace path.
332fn write_default_workspace_toml(workspace_root: &Path) -> anyhow::Result<()> {
333    let memstead_dir = workspace_root.join(memstead_base::WORKSPACE_STORE_DIR);
334    fs::create_dir_all(&memstead_dir)
335        .map_err(|e| generic_error(format!("create .memstead directory: {e}")))?;
336    let toml_path = memstead_dir.join("workspace.toml");
337    if toml_path.exists() {
338        return Ok(());
339    }
340    fs::write(&toml_path, DEFAULT_WORKSPACE_TOML)
341        .map_err(|e| generic_error(format!("write .memstead/workspace.toml: {e}")))?;
342    Ok(())
343}
344
345/// Result of a successful `mem-repo init`. Useful in tests for
346/// asserting on the produced shape without re-walking the repo.
347#[derive(Debug)]
348pub(crate) struct InitOutcome {
349    pub mem_repo_dir: PathBuf,
350    pub workspace_toml: PathBuf,
351    pub gitignore: OuterRepoOutcome,
352}
353
354fn init_signature() -> gix::actor::Signature {
355    gix::actor::Signature {
356        name: "memstead-cli mem-repo init".into(),
357        email: "mem-repo-init@memstead".into(),
358        time: gix::date::Time {
359            seconds: 0,
360            offset: 0,
361        },
362    }
363}
364
365fn generic_error(msg: String) -> anyhow::Error {
366    CliError {
367        code: "MEM_REPO_INIT_FAILED",
368        kind: ExitKind::Generic,
369        message: msg,
370        details: None,
371    }
372    .into()
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use tempfile::TempDir;
379
380    #[test]
381    fn memstead_mem_repo_init_creates_layout() {
382        let tmp = TempDir::new().unwrap();
383        let workspace = tmp.path().join("ws");
384        let outcome = run_init(&workspace, true).unwrap();
385
386        assert!(outcome.mem_repo_dir.exists(), "mem-repo/ must exist");
387        assert!(
388            outcome.mem_repo_dir.join(".git").exists(),
389            "mem-repo/.git/ must exist"
390        );
391        assert!(
392            outcome.mem_repo_dir.join("README.md").is_file(),
393            "mem-repo/README.md must be checked out",
394        );
395        assert!(
396            !outcome.mem_repo_dir.join("schemas").exists(),
397            "mem-repo/schemas/ must NOT be materialised (schemas live on __MEMSTEAD)",
398        );
399
400        let repo = gix::open(outcome.mem_repo_dir.join(".git")).unwrap();
401        let id = repo
402            .find_reference("refs/heads/main")
403            .unwrap()
404            .into_fully_peeled_id()
405            .unwrap();
406        let tree = repo.find_object(id).unwrap().into_commit().tree().unwrap();
407        assert!(
408            tree.lookup_entry_by_path("README.md").unwrap().is_some(),
409            "main:README.md must be present"
410        );
411        assert!(
412            tree.lookup_entry_by_path("schemas").unwrap().is_none(),
413            "main must NOT carry schemas/"
414        );
415        assert!(
416            tree.lookup_entry_by_path("configs").unwrap().is_none(),
417            "main must NOT carry configs/"
418        );
419
420        assert!(
421            repo.find_reference("refs/heads/__MEMSTEAD").is_ok(),
422            "refs/heads/__MEMSTEAD must exist after init"
423        );
424        assert!(
425            repo.try_find_reference("refs/heads/__SYSTEM")
426                .unwrap()
427                .is_none(),
428            "refs/heads/__SYSTEM must NOT be written by init"
429        );
430        assert!(
431            repo.try_find_reference("refs/heads/__SCHEMAS")
432                .unwrap()
433                .is_none(),
434            "refs/heads/__SCHEMAS must NOT be written by init"
435        );
436
437        // `memstead mem-repo init` must leave the workspace in a state
438        // every subsequent command can boot from. Without
439        // `.memstead/workspace.toml` the engine's loader bails with
440        // `StoreError::NotInitialised` and `memstead status` fails.
441        let workspace_toml = workspace
442            .canonicalize()
443            .unwrap()
444            .join(".memstead")
445            .join("workspace.toml");
446        assert_eq!(outcome.workspace_toml, workspace_toml);
447        assert!(
448            workspace_toml.is_file(),
449            ".memstead/workspace.toml must be materialised by init",
450        );
451        let body = fs::read_to_string(&workspace_toml).unwrap();
452        assert!(
453            body.contains("format = \"memstead-git-branch-2\""),
454            "workspace.toml must declare the engine format, got:\n{body}",
455        );
456        assert!(
457            body.contains("name = \"file-two-layer\""),
458            "workspace.toml must declare the file-two-layer adapter, got:\n{body}",
459        );
460    }
461
462    #[test]
463    fn memstead_mem_repo_init_preserves_existing_workspace_toml() {
464        // Operator-authored `.memstead/workspace.toml` survives a re-init
465        // under the same workspace path: the init must not clobber
466        // hand-edited allowlist / cross-link / mutation policy.
467        let tmp = TempDir::new().unwrap();
468        let workspace = tmp.path().join("ws");
469        fs::create_dir_all(workspace.join(".memstead")).unwrap();
470        let toml_path = workspace.join(".memstead").join("workspace.toml");
471        let authored = "# operator-authored\n\
472format = \"memstead-git-branch-2\"\n\
473\n\
474[persistence_adapter]\n\
475name = \"file-two-layer\"\n\
476\n\
477[[mem_management.create]]\n\
478pattern = \"exec-*\"\n\
479schemas = [\"default@1.0.0\"]\n";
480        fs::write(&toml_path, authored).unwrap();
481
482        run_init(&workspace, true).unwrap();
483        let actual = fs::read_to_string(&toml_path).unwrap();
484        assert_eq!(
485            actual, authored,
486            "init must not overwrite hand-edited workspace.toml"
487        );
488    }
489
490    #[test]
491    fn memstead_mem_repo_init_handles_outer_repo_gitignore() {
492        let tmp = TempDir::new().unwrap();
493        let outer = tmp.path().join("outer");
494        fs::create_dir_all(&outer).unwrap();
495        gix::init(&outer).unwrap();
496        let workspace = outer.join("ws");
497
498        let outcome = run_init(&workspace, false).unwrap();
499        match outcome.gitignore {
500            OuterRepoOutcome::Appended { ref outer_root, .. } => {
501                assert_eq!(
502                    outer_root.canonicalize().unwrap(),
503                    outer.canonicalize().unwrap()
504                );
505            }
506            other => panic!("expected Appended, got {other:?}"),
507        }
508
509        let gitignore = fs::read_to_string(outer.join(".gitignore")).unwrap();
510        assert!(
511            gitignore.contains("ws/mem-repo/"),
512            "expected ws/mem-repo/ in outer .gitignore, got:\n{gitignore}",
513        );
514
515        let workspace2 = outer.join("ws2");
516        fs::remove_dir_all(workspace.join("mem-repo")).unwrap();
517        let outcome2 = run_init(&workspace, false).unwrap();
518        match outcome2.gitignore {
519            OuterRepoOutcome::AlreadyIgnored { .. } => {}
520            _ => panic!("re-init under same workspace must be idempotent"),
521        }
522        let gitignore2 = fs::read_to_string(outer.join(".gitignore")).unwrap();
523        let count = gitignore2.matches("ws/mem-repo/").count();
524        assert_eq!(
525            count, 1,
526            "outer .gitignore must carry exactly one `ws/mem-repo/` line, got {count}\n{gitignore2}",
527        );
528        let _ = workspace2;
529    }
530
531    /// Workspace AT the git-repo root — the layout the source-layout
532    /// recipe recommends — appends `mem-repo/` to that repo's own
533    /// `.gitignore`. The old parent-first walk skipped exactly this
534    /// case, the one where the append matters most (a nested
535    /// `mem-repo/.git` can never be tracked normally).
536    #[test]
537    fn memstead_mem_repo_init_workspace_at_repo_root_appends() {
538        let tmp = TempDir::new().unwrap();
539        let workspace = tmp.path().join("repo");
540        fs::create_dir_all(&workspace).unwrap();
541        gix::init(&workspace).unwrap();
542
543        let outcome = run_init(&workspace, false).unwrap();
544        match outcome.gitignore {
545            OuterRepoOutcome::Appended {
546                ref outer_root,
547                ref rel,
548            } => {
549                assert_eq!(
550                    outer_root.canonicalize().unwrap(),
551                    workspace.canonicalize().unwrap(),
552                    "the workspace itself is the outer repo"
553                );
554                assert_eq!(rel, "mem-repo/");
555            }
556            other => panic!("expected Appended, got {other:?}"),
557        }
558        let gitignore = fs::read_to_string(workspace.join(".gitignore")).unwrap();
559        assert!(
560            gitignore.contains("mem-repo/"),
561            "expected mem-repo/ in the repo's own .gitignore, got:\n{gitignore}",
562        );
563    }
564
565    /// Complement: a workspace under no git repo at all appends nowhere
566    /// and reports `NoOuter` — no `.gitignore` is invented.
567    #[test]
568    fn memstead_mem_repo_init_without_any_outer_repo_appends_nowhere() {
569        let tmp = TempDir::new().unwrap();
570        let workspace = tmp.path().join("free").join("ws");
571
572        let outcome = run_init(&workspace, false).unwrap();
573        assert!(
574            matches!(outcome.gitignore, OuterRepoOutcome::NoOuter),
575            "expected NoOuter, got {:?}",
576            outcome.gitignore
577        );
578        assert!(
579            !tmp.path().join(".gitignore").exists()
580                && !workspace.join(".gitignore").exists()
581                && !tmp.path().join("free").join(".gitignore").exists(),
582            "no .gitignore may be invented anywhere on the walk path"
583        );
584    }
585
586    #[test]
587    fn memstead_mem_repo_init_no_gitignore_flag() {
588        let tmp = TempDir::new().unwrap();
589        let outer = tmp.path().join("outer");
590        fs::create_dir_all(&outer).unwrap();
591        gix::init(&outer).unwrap();
592        let workspace = outer.join("ws");
593
594        run_init(&workspace, true).unwrap();
595        let gitignore_path = outer.join(".gitignore");
596        if gitignore_path.exists() {
597            let body = fs::read_to_string(&gitignore_path).unwrap();
598            assert!(
599                !body.contains("mem-repo"),
600                "with --no-gitignore the outer repo's .gitignore must be untouched, got:\n{body}",
601            );
602        }
603    }
604
605    #[test]
606    fn memstead_mem_repo_init_existing_fails() {
607        let tmp = TempDir::new().unwrap();
608        let workspace = tmp.path().join("ws");
609        run_init(&workspace, true).unwrap();
610        let err = run_init(&workspace, true).unwrap_err();
611        let cli_err = err.downcast_ref::<CliError>().expect("CliError expected");
612        assert_eq!(cli_err.kind, ExitKind::Validation);
613        // The typed code is a first-class field on `CliError` rather than a
614        // `details.code` breadcrumb.
615        assert_eq!(cli_err.code, "MEM_DB_ALREADY_EXISTS");
616    }
617}