Skip to main content

ai_memory/cli/commands/
atomise.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 WT-1-F — `ai-memory atomise` CLI subcommand.
5//!
6//! Operator-side wrapper over [`crate::atomisation::Atomiser`]. Wraps
7//! tier gating, curator construction, keypair loading, and result /
8//! error rendering (human-readable by default, structured JSON with
9//! `--json`). Exit codes are stable wire and documented in the
10//! [`exit_code`] mapper below.
11//!
12//! ## Wire shape
13//!
14//! ```bash
15//! ai-memory atomise <memory_id> \
16//!     --max-atom-tokens 200 \
17//!     --force \
18//!     --json \
19//!     --quiet
20//! ```
21//!
22//! ## Exit codes
23//!
24//! | Code | Variant                  | Meaning                                       |
25//! |-----:|--------------------------|-----------------------------------------------|
26//! |   0  | success                  | atoms minted, archived_at stamped             |
27//! |   1  | informational            | `AlreadyAtomised` / `SourceTooSmall`          |
28//! |   2  | not_found                | source memory id does not exist               |
29//! |   3  | tier_locked              | daemon tier is `keyword`                      |
30//! |   4  | curator_failed           | LLM round-trip exhausted retries              |
31//! |   5  | GOVERNANCE_REFUSED       | pre_store hook refused atom mid-batch         |
32//! |   6  | db_error                 | DB / signer / I/O failure                     |
33//!
34//! ## Test injection
35//!
36//! [`run`] accepts an optional `curator_override` so the integration
37//! tests can plug in a deterministic [`MockCurator`]. Production paths
38//! pass `None` and the runner constructs an [`LlmCurator`] backed by
39//! `OllamaClient` from the resolved tier.
40
41use std::path::Path;
42use std::sync::Arc;
43
44use anyhow::Result;
45use clap::Args;
46use serde::Serialize;
47
48use crate::atomisation::curator::{Curator, LlmCurator};
49use crate::atomisation::{AtomiseError, Atomiser, AtomiserConfig};
50use crate::cli::CliOutput;
51use crate::config::{AppConfig, FeatureTier};
52use crate::db;
53use crate::identity::keypair as identity_keypair;
54use crate::llm::OllamaClient;
55
56/// Args for `ai-memory atomise`.
57#[derive(Args, Debug, Clone)]
58pub struct AtomiseArgs {
59    /// Source memory id (UUID string). ai-memory uses UUID strings for
60    /// memory ids, never integer rowids — accept the wire shape verbatim.
61    pub memory_id: String,
62
63    /// Per-atom token budget (cl100k). Defaults to 200, matching the
64    /// substrate's [`AtomiserConfig::default_max_atom_tokens`]. Pass
65    /// 0 to defer to the substrate default explicitly.
66    #[arg(long, default_value_t = 200)]
67    pub max_atom_tokens: u32,
68
69    /// Re-atomise even if the source already carries an atom set.
70    /// Old atoms are NOT deleted — their `atom_of` pointer remains
71    /// valid, and `atomised_into` is bumped to the new fresh count.
72    #[arg(long, default_value_t = false)]
73    pub force: bool,
74
75    /// Emit the result as a JSON envelope on stdout instead of the
76    /// human-readable summary. Errors land on stderr verbatim.
77    #[arg(long, default_value_t = false)]
78    pub json: bool,
79
80    /// Suppress per-step progress output. The final success / error
81    /// summary still prints — `--quiet` only silences interstitial
82    /// progress lines (currently a no-op; reserved for future stretch).
83    #[arg(long, default_value_t = false)]
84    pub quiet: bool,
85}
86
87/// JSON envelope emitted on success when `--json` is passed.
88///
89/// Field order is stable and mirrors the [`AtomiseResult`] struct so a
90/// downstream consumer can deserialise into it without aliasing.
91#[derive(Debug, Serialize)]
92struct SuccessEnvelope<'a> {
93    source_id: &'a str,
94    atom_ids: &'a [String],
95    atom_count: usize,
96    archived_at: &'a str,
97}
98
99/// JSON envelope emitted on error when `--json` is passed.
100#[derive(Debug, Serialize)]
101struct ErrorEnvelope<'a> {
102    /// Stable error code (matches the variant slug under [`exit_code`]).
103    error: &'static str,
104    /// Human-readable message — identical to the stderr line in the
105    /// non-`--json` path.
106    message: String,
107    /// Exit code the process will terminate with.
108    exit_code: i32,
109    /// Per-variant structured payload. Only populated for variants
110    /// that carry side data (existing atom ids, atom index, etc.).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    details: Option<serde_json::Value>,
113    /// Source id we attempted to atomise — useful for log post-processing.
114    source_id: &'a str,
115}
116
117/// Map an [`AtomiseError`] variant to its stable exit code.
118///
119/// Visible-for-test so the unit suite below can assert on the mapping
120/// without round-tripping through `run`.
121#[must_use]
122pub fn exit_code(err: &AtomiseError) -> i32 {
123    match err {
124        AtomiseError::AlreadyAtomised { .. } | AtomiseError::SourceTooSmall => 1,
125        AtomiseError::NotFound => 2,
126        AtomiseError::TierLocked => 3,
127        AtomiseError::CuratorFailed(_) => 4,
128        AtomiseError::GovernanceRefused(_) => 5,
129        AtomiseError::DbError(_) | AtomiseError::SignerError(_) => 6,
130        // ARCH-5 (FX-6) — recursive-primitive refusal. Distinct exit
131        // code (7) so operators wrapping the CLI can distinguish a
132        // depth-cap refusal from a curator / governance / DB failure.
133        AtomiseError::DepthExceeded { .. } => 7,
134    }
135}
136
137/// Stable error-code slug for the `--json` envelope. Mirrors the variant
138/// names lower-snake-cased so downstream consumers can switch on a
139/// fixed string set without parsing the prose message.
140#[must_use]
141pub fn error_slug(err: &AtomiseError) -> &'static str {
142    match err {
143        AtomiseError::AlreadyAtomised { .. } => "already_atomised",
144        AtomiseError::SourceTooSmall => "source_too_small",
145        AtomiseError::NotFound => "not_found",
146        AtomiseError::TierLocked => "tier_locked",
147        AtomiseError::CuratorFailed(_) => "curator_failed",
148        // v0.7.0 #1103 — case-standardisation: MCP wire uses
149        // `GOVERNANCE_REFUSED` and HTTP envelope's `code` field uses
150        // `GOVERNANCE_REFUSED` so the CLI slug now matches. Pre-#1103
151        // this site emitted lowercase `governance_refused` which
152        // diverged from the two other surfaces; operators parsing
153        // `--json` output couldn't grep `GOVERNANCE_REFUSED` uniformly.
154        AtomiseError::GovernanceRefused(_) => crate::errors::error_codes::GOVERNANCE_REFUSED,
155        AtomiseError::DbError(_) => "db_error",
156        AtomiseError::SignerError(_) => "signer_error",
157        // ARCH-5 (FX-6) — stable slug matches the
158        // `REFLECTION_DEPTH_EXCEEDED` / `SYNTHESIS_DEPTH_EXCEEDED`
159        // family casing (SCREAMING_SNAKE) used by the rest of the
160        // recursive-primitive refusal taxonomy.
161        AtomiseError::DepthExceeded { .. } => "ATOMISATION_DEPTH_EXCEEDED",
162    }
163}
164
165/// Render a human-readable error message for a given variant. Matches
166/// the [`AtomiseError::Display`] prose where the wire is stable, and
167/// enriches it with extra operator-facing context (e.g. existing atom
168/// ids, upgrade hint for the tier-locked path).
169#[must_use]
170pub fn human_error_message(err: &AtomiseError, source_id: &str) -> String {
171    match err {
172        AtomiseError::NotFound => format!("Memory ID {source_id} not found"),
173        AtomiseError::AlreadyAtomised {
174            source_id: sid,
175            existing_atom_ids,
176        } => {
177            let ids = existing_atom_ids.join(", ");
178            format!(
179                "Memory {sid} already atomised into {n} atoms. Use --force to re-atomise. \
180                 Existing atom IDs: {ids}",
181                n = existing_atom_ids.len()
182            )
183        }
184        AtomiseError::TierLocked => {
185            "memory_atomise requires smart tier or higher. Current tier: keyword. \
186             Upgrade your deployment or use --tier semantic when running ai-memory mcp."
187                .to_string()
188        }
189        AtomiseError::CuratorFailed(detail) => {
190            format!("Curator pass failed: {detail}. Check Ollama availability or retry.")
191        }
192        AtomiseError::SourceTooSmall => format!(
193            "Memory {source_id} body already at or under max_atom_tokens. \
194             No atomisation needed."
195        ),
196        AtomiseError::GovernanceRefused(detail) => {
197            format!("Atomisation refused: {detail}")
198        }
199        AtomiseError::SignerError(detail) => format!("Signer error: {detail}"),
200        AtomiseError::DbError(detail) => format!("Database error: {detail}"),
201        AtomiseError::DepthExceeded { attempted, cap } => format!(
202            "Atomisation refused: depth {attempted} would exceed compiled \
203             max_atomisation_depth {cap}. A recursive atomisation chain hit \
204             the cycle-depth cap — inspect the curator / pre_store hook stack \
205             that re-entered atomise."
206        ),
207    }
208}
209
210/// Render structured per-variant `details` for the `--json` envelope.
211///
212/// Returns `None` when the variant carries no side payload beyond the
213/// human message (the envelope's `details` field is then omitted).
214#[must_use]
215fn error_details(err: &AtomiseError) -> Option<serde_json::Value> {
216    match err {
217        AtomiseError::AlreadyAtomised {
218            existing_atom_ids, ..
219        } => Some(serde_json::json!({
220            "existing_atom_ids": existing_atom_ids,
221            "existing_atom_count": existing_atom_ids.len(),
222        })),
223        _ => None,
224    }
225}
226
227/// Dispatch entry-point for `ai-memory atomise`.
228///
229/// `curator_override` is only set by the integration tests — production
230/// passes `None` and we synthesise an [`LlmCurator`] from the resolved
231/// tier. Returns the process exit code so the caller (the
232/// `daemon_runtime::run` dispatcher) can `std::process::exit` cleanly
233/// without panicking through `Err` propagation and skipping the post-run
234/// WAL checkpoint.
235///
236/// # Errors
237///
238/// Propagates only fatal I/O errors (writing to stdout/stderr). Every
239/// atomisation failure is mapped to an exit code via [`exit_code`] and
240/// returned as `Ok(code)`.
241pub fn run(
242    db_path: &Path,
243    args: &AtomiseArgs,
244    app_config: &AppConfig,
245    cli_agent_id: Option<&str>,
246    out: &mut CliOutput<'_>,
247) -> Result<i32> {
248    run_with_curator(db_path, args, app_config, cli_agent_id, out, None)
249}
250
251/// Visible-for-test entry point. Production passes
252/// `curator_override = None`; the integration suite injects a mock.
253///
254/// # Errors
255///
256/// Propagates only fatal I/O errors. See [`run`] for the full contract.
257pub fn run_with_curator(
258    db_path: &Path,
259    args: &AtomiseArgs,
260    app_config: &AppConfig,
261    cli_agent_id: Option<&str>,
262    out: &mut CliOutput<'_>,
263    curator_override: Option<Box<dyn Curator>>,
264) -> Result<i32> {
265    // Resolve the effective tier from config (no per-call --tier flag
266    // on the atomise subcommand — daemon-level resolution is the
267    // source of truth, mirroring the `mcp --tier <x>` discipline).
268    let tier = app_config.effective_tier(None);
269
270    // Tier check at CLI layer: surface a clear operator-facing message
271    // before we even open the DB. Substrate also enforces this, but
272    // catching it here yields a better diagnostic.
273    if tier == FeatureTier::Keyword {
274        let err = AtomiseError::TierLocked;
275        return emit_error(&err, &args.memory_id, args.json, out);
276    }
277
278    // Resolve calling agent_id — same precedence as the rest of the
279    // CLI surface (explicit flag / env / synthesised host fallback).
280    let calling_agent_id = match crate::identity::resolve_agent_id(cli_agent_id, None) {
281        Ok(id) => id,
282        Err(e) => {
283            let err = AtomiseError::DbError(format!("agent_id resolution failed: {e}"));
284            return emit_error(&err, &args.memory_id, args.json, out);
285        }
286    };
287
288    // Open the DB. Failure here lands on the db_error track.
289    let conn = match db::open(db_path) {
290        Ok(c) => c,
291        Err(e) => {
292            let err = AtomiseError::DbError(format!("open {}: {e}", db_path.display()));
293            return emit_error(&err, &args.memory_id, args.json, out);
294        }
295    };
296
297    // Build the curator. Tests inject; production constructs an
298    // LlmCurator backed by the tier-resolved Ollama model.
299    //
300    // v0.7.0 (#1244) — also resolve the curator model name so the
301    // signed-event payload's `curator_model` field reflects what
302    // actually ran on this deployment.
303    let (curator, curator_model): (Box<dyn Curator>, String) = if let Some(c) = curator_override {
304        // Test path: caller injected a mock curator; we don't have a
305        // model name handy. Atomiser's "unknown" fallback applies.
306        (c, "unknown".to_string())
307    } else {
308        match build_llm_curator(tier) {
309            Ok((c, model)) => (c, model),
310            Err(e) => {
311                let err = AtomiseError::CuratorFailed(e);
312                return emit_error(&err, &args.memory_id, args.json, out);
313            }
314        }
315    };
316
317    // Best-effort keypair load — atoms can land unsigned if no key on
318    // disk, matching the curator-pass / reflection-pass discipline.
319    let keypair = load_keypair_best_effort(&calling_agent_id);
320
321    let atomiser = Atomiser::new(curator, keypair, AtomiserConfig::default(), tier)
322        .with_curator_model(curator_model);
323
324    match atomiser.atomise_sync(
325        &conn,
326        &args.memory_id,
327        args.max_atom_tokens,
328        args.force,
329        &calling_agent_id,
330    ) {
331        Ok(result) => emit_success(&result, args.json, out),
332        Err(e) => emit_error(&e, &args.memory_id, args.json, out),
333    }
334}
335
336/// Build an [`LlmCurator`] backed by the configured LLM for the
337/// supplied tier.
338///
339/// #1143: honors `AI_MEMORY_LLM_BACKEND` like the MCP / daemon
340/// paths. Resolution order matches
341/// [`OllamaClient::build_for_init`]:
342///   1. `AI_MEMORY_LLM_BACKEND` set → route via
343///      [`OllamaClient::from_env`] (xAI Grok, OpenAI, Anthropic,
344///      Gemini, …).
345///   2. Else → tier-default Ollama model at the default URL,
346///      preserving v0.6.x behavior.
347///
348/// Returns an error string when the env arm is configured but invalid
349/// (unknown alias, missing API key, missing base URL for the generic
350/// `openai-compatible` backend), when the legacy tier has no curator
351/// LLM configured (only `Keyword`, which the caller has already
352/// gated), or when the underlying client construction fails.
353fn build_llm_curator(tier: FeatureTier) -> std::result::Result<(Box<dyn Curator>, String), String> {
354    // v0.7.x (#1146) — route through the canonical resolver. The
355    // resolver folds CLI flags (none here), AI_MEMORY_LLM_* env vars,
356    // the [llm] config section, the legacy `llm_model`/`ollama_url`
357    // fields, and the compiled tier preset through the uniform
358    // precedence ladder. Atomise's tier gate already refused
359    // `Keyword`; for the other three tiers the resolver always
360    // produces a usable backend/model pair.
361    //
362    // v0.7.0 (#1244) — also return the resolved model id so the caller
363    // can thread it into the atomiser's `curator_model` provenance.
364    let _ = tier;
365    let app_config = AppConfig::load();
366    let resolved = app_config.resolve_llm(None, None, None);
367    match OllamaClient::build_from_resolved(&resolved) {
368        Ok(Some(client)) => {
369            let model = client.model_name().to_string();
370            Ok((Box::new(LlmCurator::new(client)), model))
371        }
372        Ok(None) => Err(format!(
373            "atomise: LLM resolver returned no client \
374             (backend={}, source={}); atomise requires a curator LLM",
375            resolved.backend,
376            resolved.source.as_str()
377        )),
378        Err(e) => Err(format!(
379            "atomise: LLM init failed (backend={}, source={}): {e}",
380            resolved.backend,
381            resolved.source.as_str()
382        )),
383    }
384}
385
386/// Best-effort keypair load — returns `None` if no key exists or the
387/// load fails. Atoms then land unsigned. The CLI never refuses to run
388/// solely because a keypair is missing; operators who want strict
389/// signing can run `ai-memory identity generate <agent_id>` first and
390/// re-invoke.
391fn load_keypair_best_effort(agent_id: &str) -> Option<Arc<crate::identity::keypair::AgentKeypair>> {
392    let dir = identity_keypair::default_key_dir().ok()?;
393    identity_keypair::load(agent_id, &dir).ok().map(Arc::new)
394}
395
396/// Emit a success result (human or JSON), return exit code 0.
397fn emit_success(
398    result: &crate::atomisation::AtomiseResult,
399    json: bool,
400    out: &mut CliOutput<'_>,
401) -> Result<i32> {
402    if json {
403        let env = SuccessEnvelope {
404            source_id: &result.source_id,
405            atom_ids: &result.atom_ids,
406            atom_count: result.atom_count,
407            archived_at: &result.archived_at,
408        };
409        writeln!(out.stdout, "{}", serde_json::to_string(&env)?)?;
410    } else {
411        let ids = result.atom_ids.join(", ");
412        writeln!(
413            out.stdout,
414            "Atomised memory {src} into {n} atoms. Source archived at {ts}. Atom IDs: {ids}",
415            src = result.source_id,
416            n = result.atom_count,
417            ts = result.archived_at,
418        )?;
419    }
420    Ok(0)
421}
422
423/// Emit an error variant (human or JSON), return the variant's exit code.
424fn emit_error(
425    err: &AtomiseError,
426    source_id: &str,
427    json: bool,
428    out: &mut CliOutput<'_>,
429) -> Result<i32> {
430    let code = exit_code(err);
431    let message = human_error_message(err, source_id);
432    if json {
433        let env = ErrorEnvelope {
434            error: error_slug(err),
435            message: message.clone(),
436            exit_code: code,
437            details: error_details(err),
438            source_id,
439        };
440        writeln!(out.stderr, "{}", serde_json::to_string(&env)?)?;
441    } else {
442        writeln!(out.stderr, "{message}")?;
443    }
444    Ok(code)
445}
446
447// ---------------------------------------------------------------------------
448// Unit tests — pure-logic surface that doesn't require a live DB / Ollama.
449// Full integration tests live at `tests/cli/atomise.rs`.
450// ---------------------------------------------------------------------------
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn exit_code_maps_every_variant() {
458        assert_eq!(exit_code(&AtomiseError::NotFound), 2);
459        assert_eq!(exit_code(&AtomiseError::TierLocked), 3);
460        assert_eq!(exit_code(&AtomiseError::CuratorFailed("x".into())), 4);
461        assert_eq!(exit_code(&AtomiseError::GovernanceRefused("x".into())), 5);
462        assert_eq!(exit_code(&AtomiseError::SourceTooSmall), 1);
463        assert_eq!(exit_code(&AtomiseError::DbError("x".into())), 6);
464        assert_eq!(exit_code(&AtomiseError::SignerError("x".into())), 6);
465        assert_eq!(
466            exit_code(&AtomiseError::AlreadyAtomised {
467                source_id: "s".into(),
468                existing_atom_ids: vec!["a".into()]
469            }),
470            1
471        );
472        // ARCH-5 (FX-6) — distinct exit code so operators wrapping the
473        // CLI can disambiguate the recursive-primitive refusal from
474        // curator / governance / DB failures.
475        assert_eq!(
476            exit_code(&AtomiseError::DepthExceeded {
477                attempted: 4,
478                cap: crate::atomisation::MAX_ATOMISATION_DEPTH,
479            }),
480            7
481        );
482    }
483
484    #[test]
485    fn error_slug_maps_every_variant() {
486        assert_eq!(error_slug(&AtomiseError::NotFound), "not_found");
487        assert_eq!(error_slug(&AtomiseError::TierLocked), "tier_locked");
488        assert_eq!(
489            error_slug(&AtomiseError::CuratorFailed("x".into())),
490            "curator_failed"
491        );
492        // v0.7.0 #1103 — uppercase tag matches the MCP wire shape
493        // (`GOVERNANCE_REFUSED: <reason>` per
494        // `src/mcp/tools/store/mod.rs`) + the HTTP `code` field
495        // (`{"code":"GOVERNANCE_REFUSED",...}` per
496        // `src/handlers/create.rs`). Pre-#1103 the CLI slug was
497        // lowercase and diverged from the other two surfaces.
498        assert_eq!(
499            error_slug(&AtomiseError::GovernanceRefused("x".into())),
500            "GOVERNANCE_REFUSED"
501        );
502        assert_eq!(
503            error_slug(&AtomiseError::SourceTooSmall),
504            "source_too_small"
505        );
506        assert_eq!(error_slug(&AtomiseError::DbError("x".into())), "db_error");
507        assert_eq!(
508            error_slug(&AtomiseError::SignerError("x".into())),
509            "signer_error"
510        );
511        assert_eq!(
512            error_slug(&AtomiseError::AlreadyAtomised {
513                source_id: "s".into(),
514                existing_atom_ids: vec!["a".into()]
515            }),
516            "already_atomised"
517        );
518        // ARCH-5 (FX-6) — SCREAMING_SNAKE_CASE slug to match the
519        // existing `REFLECTION_DEPTH_EXCEEDED` / `SYNTHESIS_DEPTH_EXCEEDED`
520        // family. Stable wire shape pinned across MCP / HTTP / CLI.
521        assert_eq!(
522            error_slug(&AtomiseError::DepthExceeded {
523                attempted: 4,
524                cap: crate::atomisation::MAX_ATOMISATION_DEPTH,
525            }),
526            "ATOMISATION_DEPTH_EXCEEDED"
527        );
528    }
529
530    #[test]
531    fn human_error_message_tier_locked_carries_upgrade_hint() {
532        let msg = human_error_message(&AtomiseError::TierLocked, "src");
533        assert!(msg.contains("requires smart tier"));
534        assert!(msg.contains("keyword"));
535        assert!(msg.contains("Upgrade your deployment"));
536    }
537
538    #[test]
539    fn human_error_message_not_found_carries_source_id() {
540        let msg = human_error_message(&AtomiseError::NotFound, "src-123");
541        assert!(msg.contains("src-123"), "got: {msg}");
542        assert!(msg.contains("not found"));
543    }
544
545    #[test]
546    fn human_error_message_already_atomised_lists_existing_ids() {
547        let err = AtomiseError::AlreadyAtomised {
548            source_id: "src-9".into(),
549            existing_atom_ids: vec!["a1".into(), "a2".into(), "a3".into()],
550        };
551        let msg = human_error_message(&err, "src-9");
552        assert!(msg.contains("src-9"));
553        assert!(msg.contains("3 atoms"));
554        assert!(msg.contains("--force"));
555        assert!(msg.contains("a1, a2, a3"));
556    }
557
558    #[test]
559    fn human_error_message_source_too_small_carries_source_id() {
560        let msg = human_error_message(&AtomiseError::SourceTooSmall, "src-x");
561        assert!(msg.contains("src-x"));
562        assert!(msg.contains("max_atom_tokens"));
563    }
564
565    #[test]
566    fn human_error_message_curator_failed_carries_detail() {
567        let msg = human_error_message(&AtomiseError::CuratorFailed("ollama down".into()), "src");
568        assert!(msg.contains("ollama down"));
569        assert!(msg.contains("Ollama"));
570    }
571
572    #[test]
573    fn human_error_message_governance_refused_carries_detail() {
574        let msg = human_error_message(
575            &AtomiseError::GovernanceRefused("atom[2]: policy".into()),
576            "src",
577        );
578        assert!(msg.contains("policy"));
579        assert!(msg.contains("atom[2]"));
580    }
581
582    #[test]
583    fn human_error_message_signer_error_and_db_error_carry_detail() {
584        // Drives uncovered lines 184-185 in `human_error_message`.
585        let sig = human_error_message(&AtomiseError::SignerError("key revoked".into()), "src");
586        assert!(sig.starts_with("Signer error:"));
587        assert!(sig.contains("key revoked"));
588        let db = human_error_message(&AtomiseError::DbError("disk full".into()), "src");
589        assert!(db.starts_with("Database error:"));
590        assert!(db.contains("disk full"));
591    }
592
593    #[test]
594    fn run_wrapper_delegates_to_run_with_curator_keyword_tier_short_circuits() {
595        // Drives the bare `run` function (line 220-228) plus the
596        // TierLocked early-out at the CLI layer (line 252-254) by
597        // passing an explicit `tier = "keyword"` config.
598        use crate::config::AppConfig;
599        let mut cfg = AppConfig::default();
600        cfg.tier = Some("keyword".to_string());
601        let args = AtomiseArgs {
602            memory_id: "src-id".to_string(),
603            max_atom_tokens: 100,
604            force: false,
605            json: false,
606            quiet: false,
607        };
608        let dir = tempfile::tempdir().unwrap();
609        let db_path = dir.path().join("atomise-cli.db");
610        let mut stdout = Vec::<u8>::new();
611        let mut stderr = Vec::<u8>::new();
612        let mut out = CliOutput {
613            stdout: &mut stdout,
614            stderr: &mut stderr,
615        };
616        let code = run(&db_path, &args, &cfg, None, &mut out).unwrap();
617        // TierLocked is exit_code 3.
618        assert_eq!(code, 3);
619        let s = String::from_utf8(stderr).unwrap();
620        assert!(
621            s.contains("requires smart tier") || s.contains("tier"),
622            "got stderr: {s}",
623        );
624    }
625
626    #[test]
627    fn error_details_already_atomised_carries_payload() {
628        let err = AtomiseError::AlreadyAtomised {
629            source_id: "s".into(),
630            existing_atom_ids: vec!["a".into(), "b".into()],
631        };
632        let det = error_details(&err).expect("details populated");
633        assert_eq!(det["existing_atom_ids"][0].as_str().unwrap(), "a");
634        assert_eq!(det["existing_atom_count"].as_i64().unwrap(), 2);
635    }
636
637    #[test]
638    fn error_details_other_variants_are_none() {
639        assert!(error_details(&AtomiseError::NotFound).is_none());
640        assert!(error_details(&AtomiseError::TierLocked).is_none());
641        assert!(error_details(&AtomiseError::SourceTooSmall).is_none());
642        assert!(error_details(&AtomiseError::CuratorFailed("x".into())).is_none());
643    }
644
645    #[test]
646    fn emit_error_writes_human_message_to_stderr() {
647        let mut stdout = Vec::<u8>::new();
648        let mut stderr = Vec::<u8>::new();
649        let mut out = CliOutput {
650            stdout: &mut stdout,
651            stderr: &mut stderr,
652        };
653        let code = emit_error(&AtomiseError::NotFound, "src-xyz", false, &mut out).unwrap();
654        assert_eq!(code, 2);
655        assert!(stdout.is_empty());
656        let s = String::from_utf8(stderr).unwrap();
657        assert!(s.contains("src-xyz"));
658        assert!(s.contains("not found"));
659    }
660
661    #[test]
662    fn emit_error_writes_json_envelope_to_stderr() {
663        let mut stdout = Vec::<u8>::new();
664        let mut stderr = Vec::<u8>::new();
665        let mut out = CliOutput {
666            stdout: &mut stdout,
667            stderr: &mut stderr,
668        };
669        let err = AtomiseError::AlreadyAtomised {
670            source_id: "src-1".into(),
671            existing_atom_ids: vec!["a".into(), "b".into()],
672        };
673        let code = emit_error(&err, "src-1", true, &mut out).unwrap();
674        assert_eq!(code, 1);
675        let s = String::from_utf8(stderr).unwrap();
676        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
677        assert_eq!(v["error"], "already_atomised");
678        assert_eq!(v["exit_code"], 1);
679        assert_eq!(v["source_id"], "src-1");
680        assert_eq!(v["details"]["existing_atom_count"], 2);
681    }
682
683    #[test]
684    fn emit_success_writes_human_summary_to_stdout() {
685        let mut stdout = Vec::<u8>::new();
686        let mut stderr = Vec::<u8>::new();
687        let mut out = CliOutput {
688            stdout: &mut stdout,
689            stderr: &mut stderr,
690        };
691        let r = crate::atomisation::AtomiseResult {
692            source_id: "src-1".into(),
693            atom_ids: vec!["a1".into(), "a2".into()],
694            atom_count: 2,
695            archived_at: "2026-05-14T00:00:00Z".into(),
696        };
697        let code = emit_success(&r, false, &mut out).unwrap();
698        assert_eq!(code, 0);
699        assert!(stderr.is_empty());
700        let s = String::from_utf8(stdout).unwrap();
701        assert!(s.contains("src-1"));
702        assert!(s.contains("2 atoms"));
703        assert!(s.contains("2026-05-14T00:00:00Z"));
704        assert!(s.contains("a1, a2"));
705    }
706
707    #[test]
708    fn emit_success_writes_json_envelope_to_stdout() {
709        let mut stdout = Vec::<u8>::new();
710        let mut stderr = Vec::<u8>::new();
711        let mut out = CliOutput {
712            stdout: &mut stdout,
713            stderr: &mut stderr,
714        };
715        let r = crate::atomisation::AtomiseResult {
716            source_id: "src-1".into(),
717            atom_ids: vec!["a1".into(), "a2".into()],
718            atom_count: 2,
719            archived_at: "2026-05-14T00:00:00Z".into(),
720        };
721        let code = emit_success(&r, true, &mut out).unwrap();
722        assert_eq!(code, 0);
723        assert!(stderr.is_empty());
724        let s = String::from_utf8(stdout).unwrap();
725        let v: serde_json::Value = serde_json::from_str(s.trim()).unwrap();
726        assert_eq!(v["source_id"], "src-1");
727        assert_eq!(v["atom_count"], 2);
728        assert_eq!(v["atom_ids"][0], "a1");
729        assert_eq!(v["archived_at"], "2026-05-14T00:00:00Z");
730    }
731}