Skip to main content

djogi_cli/
live.rs

1//! `djogi live` — operator surface for live migrations.
2//! Six subcommands materialise the operator-facing contract for the
3//! expand → backfill → flip → contract sequence the live-plan layer
4//! drives:
5//! - `live plan` — generate plan files for pending schema deltas
6//!   classified [`OnlineSafetyClassification::ExpandContract`](djogi::live_migrate::OnlineSafetyClassification::ExpandContract).
7//! - `live show` — render plan-file metadata + persisted runtime state
8//!   side by side, plus the active hook snapshot at the current step.
9//! - `live run` — drive the plan forward until the next operator gate,
10//!   refusing destructive work without `--allow-destructive`
11//!   `--justify "<reason>"`.
12//! - `live resume` — pick up after an interruption; reads
13//!   `backfill_rows_done` from the row and continues at the same step.
14//! - `live finalize` — execute remaining
15//!   [`StepKind::FinalizeConstraints`](djogi::live_migrate::StepKind::FinalizeConstraints)
16//!   /
17//!   [`StepKind::CleanupLegacyState`](djogi::live_migrate::StepKind::CleanupLegacyState)
18//!   steps, drop compatibility hooks, and promote the row to
19//!   [`PlanStatus::Complete`](djogi::live_migrate::PlanStatus::Complete).
20//! - `live abandon` — terminal opt-out; gated on confirmation OR
21//!   `--force` plus a non-production `DJOGI_ENV`.
22//! # Exit codes
23//! Per of the plan:
24//! | Code | Meaning |
25//! |------|---------|
26//! | 0 | Success — operator may invoke the next subcommand. |
27//! | 1 | Step failed — generic runtime error (config / network / SQL / I/O). |
28//! | 2 | Classification refused to plan — the delta is `OfflineOnly`. |
29//! | 3 | Validation checkpoint failed — gate query disagreed. |
30//! | 4 | Plan-file checksum drift — the file was edited after start. |
31//! | 5 | Plan state conflicts with request (e.g. `run` on `complete`). |
32//! # Out of scope
33//! - **Live-DB integration tests.** This module ships clap parsing,
34//!   helpers, and exit-code mapping; end-to-end coverage lives in the
35//!   integration suite.
36//! - **`djogi_codec_recode(...)`** — still a placeholder.
37//! - **`djogi_schema_migrations.justification` persistence.** Adding
38//!   the column requires a separate ALTER TABLE migration; this module
39//!   accepts `--justify` and routes the value through to the runner,
40//!   but the actual column write lands in a follow-up phase.
41
42use std::path::PathBuf;
43use std::process::ExitCode;
44use std::str::FromStr;
45
46use clap::Subcommand;
47use djogi::__bypass::RawAccessExt as _;
48use djogi::config::DjogiConfig;
49use djogi::context::DjogiContext;
50use djogi::live_migrate::compose::StepResult;
51use djogi::live_migrate::{
52    DaemonConfig, DaemonError, LivePlanRow, PlanFileError, PlanStatus, active_hooks_at_step,
53    plan_path, read_plan, run_daemon, verify_checksum,
54};
55use djogi::pg::pool::DjogiPool;
56use djogi::types::HeerId;
57
58// ── Subcommand surface ────────────────────────────────────────────────
59
60/// Operator surface for the live-migration runner. Every subcommand
61/// resolves to an [`ExitCode`] via [`dispatch`].
62#[derive(Debug, Clone, Subcommand)]
63pub enum LiveCmd {
64    /// Generate plan file(s) for pending schema deltas classified
65    /// `ExpandContract`. Refuses (exit 2) when the delta is
66    /// `OfflineOnly`; reports "no live plan needed" (exit 0) when the
67    /// delta is fully `OnlineSafe` and the regular runner can
68    /// handle it directly.
69    Plan {
70        /// Optional explicit migration version to materialize.
71        version: Option<String>,
72        /// Workspace root override. Defaults to the current working
73        /// directory.
74        #[arg(long)]
75        workspace: Option<PathBuf>,
76    },
77    /// Show plan-file metadata, runtime state, and active hooks at the
78    /// current step.
79    Show {
80        /// HeerId rendered as decimal — same shape the plan file's
81        /// filename embeds.
82        plan_id: String,
83        /// Workspace root override.
84        #[arg(long)]
85        workspace: Option<PathBuf>,
86    },
87    /// Drive the plan forward until the next operator checkpoint
88    /// (validate / cutover / finalize). Returns 0 at every checkpoint
89    /// so the operator script can branch cleanly.
90    Run {
91        plan_id: String,
92        /// Allow destructive steps to execute. Without this flag the
93        /// runner refuses any
94        /// [`StepKind::CleanupLegacyState`](djogi::live_migrate::StepKind::CleanupLegacyState)
95        /// step with exit code 1.
96        #[arg(long, default_value_t = false)]
97        allow_destructive: bool,
98        /// Operator-supplied justification recorded alongside any
99        /// destructive step. Required when `--allow-destructive` is
100        /// set, and required when `--allow-raw-dangerous` is set.
101        #[arg(long)]
102        justify: Option<String>,
103        /// Opt-in to running plans whose source migration was
104        /// hand-edited (per amendment). Always paired with
105        /// `--justify`.
106        #[arg(long, default_value_t = false)]
107        allow_raw_dangerous: bool,
108        /// Workspace root override.
109        #[arg(long)]
110        workspace: Option<PathBuf>,
111    },
112    /// Continue after an interruption. Reads `backfill_rows_done`
113    /// from the row and resumes at the same step.
114    Resume {
115        plan_id: String,
116        /// Allow destructive steps to execute on resume. Required when
117        /// the plan contains a DROP / TRUNCATE-class step that the
118        /// resume would reach. Pairs with `--justify`.
119        #[arg(long, default_value_t = false)]
120        allow_destructive: bool,
121        /// Operator justification recorded alongside any destructive
122        /// step. Required when `--allow-destructive` is set.
123        #[arg(long)]
124        justify: Option<String>,
125        /// Workspace root override.
126        #[arg(long)]
127        workspace: Option<PathBuf>,
128    },
129    /// Complete cleanup + drop compatibility hooks. Gated on the row
130    /// being in [`PlanStatus::Finalizing`].
131    Finalize {
132        plan_id: String,
133        /// Operator justification recorded if any executed step is
134        /// destructive (e.g. dropping the legacy column).
135        #[arg(long)]
136        justify: Option<String>,
137        /// Workspace root override.
138        #[arg(long)]
139        workspace: Option<PathBuf>,
140    },
141    /// Mark a plan abandoned. Schema state stays at the last completed
142    /// step; resuming an abandoned plan is refused. Confirmation
143    /// required; `--force` requires `DJOGI_ENV` to be unset or set to
144    /// any value other than `production`.
145    Abandon {
146        plan_id: String,
147        /// Skip the interactive confirmation prompt. Requires
148        /// `DJOGI_ENV != production`; refuses otherwise.
149        #[arg(long, default_value_t = false)]
150        force: bool,
151        /// Workspace root override.
152        #[arg(long)]
153        workspace: Option<PathBuf>,
154    },
155    /// Long-running daemon that resumes stale `BackfillChunked` /
156    /// `ValidateBackfill` steps. Triple-gated — refuses on
157    /// `DJOGI_ENV=production`, refuses on a non-localhost
158    /// `DATABASE_URL` unless `--allow-non-localhost` is set, and never
159    /// auto-advances past an operator gate (`CutoverReads` /
160    /// `CutoverWrites` / `FinalizeConstraints` stay manual via
161    /// `live run` / `live finalize`).
162    /// Exits cleanly on SIGTERM / SIGINT; per-plan failures inside the
163    /// loop are logged and the daemon continues.
164    Daemon {
165        /// Interval between candidate-row scans. Accepts humantime
166        /// single-unit durations: `30s`, `5m`, `2h`, `1d`. Default 30s.
167        #[arg(long, default_value = "30s", value_parser = parse_humantime_duration)]
168        poll_interval: std::time::Duration,
169        /// A row is treated as stale (and therefore claimable) when
170        /// its `last_progress_at` is older than this duration. Accepts
171        /// the same humantime forms as `--poll-interval`. Default 10m.
172        #[arg(long, default_value = "10m", value_parser = parse_humantime_duration)]
173        claim_stale_after: std::time::Duration,
174        /// Allow the daemon to drive a non-localhost database. Mirrors
175        /// the seed gate; the production-environment gate
176        /// (`DJOGI_ENV=production`) and production-profile gate
177        /// (`Djogi.toml::profile = "production"`) still refuse
178        /// regardless.
179        #[arg(long, default_value_t = false)]
180        allow_non_localhost: bool,
181        /// Workspace root override.
182        #[arg(long)]
183        workspace: Option<PathBuf>,
184    },
185}
186
187// ── Errors ────────────────────────────────────────────────────────────
188
189/// Errors raised by the `live` subcommand. Each variant carries a
190/// dedicated exit-code mapping; conversion lives in
191/// [`LiveCmdError::exit_code`].
192/// `#[non_exhaustive]` so future failure modes (e.g. a daemon-mode
193/// claim conflict) can land without breaking downstream matches.
194#[derive(Debug, thiserror::Error)]
195#[non_exhaustive]
196pub enum LiveCmdError {
197    /// Configuration / I/O / database error. Maps to exit code 1.
198    #[error("{0}")]
199    Runtime(String),
200
201    /// Classification refused to plan — the delta is `OfflineOnly`.
202    /// Maps to exit code 2.
203    #[error("classification refused: {0}")]
204    ClassificationRefused(String),
205
206    /// Plan-file checksum drift detected. Maps to exit code 4.
207    #[error("plan file checksum drift: {0}")]
208    ChecksumDrift(String),
209
210    /// Plan state conflicts with the requested operation (e.g. `run`
211    /// against a `complete` plan). Maps to exit code 5.
212    #[error("plan state conflict: {0}")]
213    StateConflict(String),
214
215    /// Argument or gate validation failed before any side effect
216    /// (missing `--justify`, `DJOGI_ENV=production` blocked `--force`,
217    /// …). Maps to exit code 1 — surfaced as a runtime refusal rather
218    /// than the clap default `2` so scripts can distinguish the two.
219    #[error("argument refused: {0}")]
220    ArgRefused(String),
221
222    /// The plan file's HeerId could not be parsed.
223    #[error("malformed plan_id: {0}")]
224    MalformedPlanId(String),
225
226    /// The plan row could not be located in `djogi_live_plans`.
227    #[error("plan {0} not found in djogi_live_plans")]
228    PlanNotFound(HeerId),
229}
230
231impl LiveCmdError {
232    /// Map the error to the inline-decision exit code.
233    pub fn exit_code(&self) -> i32 {
234        match self {
235            LiveCmdError::Runtime(_)
236            | LiveCmdError::ArgRefused(_)
237            | LiveCmdError::MalformedPlanId(_)
238            | LiveCmdError::PlanNotFound(_) => 1,
239            LiveCmdError::ClassificationRefused(_) => 2,
240            LiveCmdError::ChecksumDrift(_) => 4,
241            LiveCmdError::StateConflict(_) => 5,
242        }
243    }
244}
245
246impl From<PlanFileError> for LiveCmdError {
247    fn from(value: PlanFileError) -> Self {
248        match value {
249            PlanFileError::ChecksumMismatch { .. } => {
250                LiveCmdError::ChecksumDrift(value.to_string())
251            }
252            other => LiveCmdError::Runtime(other.to_string()),
253        }
254    }
255}
256
257// ── Top-level dispatch ────────────────────────────────────────────────
258
259/// Top-level entry point invoked by [`crate::main`]. Builds a runtime,
260/// drives the async dispatch, and folds the resulting [`LiveCmdError`]
261/// to an [`ExitCode`] via [`LiveCmdError::exit_code`].
262pub fn dispatch(cmd: LiveCmd) -> ExitCode {
263    let runtime = match tokio::runtime::Builder::new_current_thread()
264        .enable_all()
265        .build()
266    {
267        Ok(r) => r,
268        Err(e) => {
269            eprintln!("djogi live: tokio runtime: {e}");
270            return ExitCode::from(1);
271        }
272    };
273    let exit = runtime.block_on(async { run(cmd).await });
274    let code = match exit {
275        Ok(c) => c,
276        Err(e) => {
277            eprintln!("djogi live: {e}");
278            e.exit_code()
279        }
280    };
281    ExitCode::from(code as u8)
282}
283
284/// Async dispatch over [`LiveCmd`]. Returns either a success exit code
285/// (`0`) or a typed error whose [`LiveCmdError::exit_code`] gives the
286/// non-zero return.
287async fn run(cmd: LiveCmd) -> Result<i32, LiveCmdError> {
288    match cmd {
289        LiveCmd::Plan { version, workspace } => plan_cmd(version.as_deref(), workspace).await,
290        LiveCmd::Show { plan_id, workspace } => show_cmd(&plan_id, workspace).await,
291        LiveCmd::Run {
292            plan_id,
293            allow_destructive,
294            justify,
295            allow_raw_dangerous,
296            workspace,
297        } => {
298            run_cmd(
299                &plan_id,
300                allow_destructive,
301                justify.as_deref(),
302                allow_raw_dangerous,
303                workspace,
304            )
305            .await
306        }
307        LiveCmd::Resume {
308            plan_id,
309            allow_destructive,
310            justify,
311            workspace,
312        } => resume_cmd(&plan_id, allow_destructive, justify.as_deref(), workspace).await,
313        LiveCmd::Finalize {
314            plan_id,
315            justify,
316            workspace,
317        } => finalize_cmd(&plan_id, justify.as_deref(), workspace).await,
318        LiveCmd::Abandon {
319            plan_id,
320            force,
321            workspace,
322        } => abandon_cmd(&plan_id, force, workspace).await,
323        LiveCmd::Daemon {
324            poll_interval,
325            claim_stale_after,
326            allow_non_localhost,
327            workspace,
328        } => {
329            daemon_cmd(
330                poll_interval,
331                claim_stale_after,
332                allow_non_localhost,
333                workspace,
334            )
335            .await
336        }
337    }
338}
339
340// ── Argument helpers ──────────────────────────────────────────────────
341
342/// Parse an operator-supplied `plan_id` decimal string into a
343/// [`HeerId`]. Returns [`LiveCmdError::MalformedPlanId`] on any
344/// parse / validation failure so the operator sees an actionable
345/// message instead of a panic.
346fn parse_plan_id(raw: &str) -> Result<HeerId, LiveCmdError> {
347    HeerId::from_str(raw).map_err(|e| LiveCmdError::MalformedPlanId(format!("`{raw}`: {e}")))
348}
349
350/// Resolve the workspace root from the `--workspace` flag, falling
351/// back to the current working directory. Mirrors the helper in
352/// [`crate::migrations`] / [`crate::db`].
353fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
354    workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
355}
356
357/// Reject a `live run --allow-raw-dangerous` invocation that lacks a
358/// `--justify "<reason>"` value. The pairing rule comes from
359/// (the amendment that introduced `--allow-raw-dangerous`).
360fn require_justify_for_dangerous(
361    allow_raw_dangerous: bool,
362    justify: Option<&str>,
363) -> Result<(), LiveCmdError> {
364    if allow_raw_dangerous && justify_is_empty(justify) {
365        return Err(LiveCmdError::ArgRefused(
366            "--allow-raw-dangerous requires --justify \"<reason>\"".to_string(),
367        ));
368    }
369    Ok(())
370}
371
372/// Reject an `--allow-destructive` invocation that lacks `--justify`.
373/// Operators must record why they're routing through the destructive
374/// path; the reason flows into `djogi_schema_migrations.justification`
375/// when that column lands in a follow-up migration.
376fn require_justify_for_destructive(
377    allow_destructive: bool,
378    justify: Option<&str>,
379) -> Result<(), LiveCmdError> {
380    if allow_destructive && justify_is_empty(justify) {
381        return Err(LiveCmdError::ArgRefused(
382            "--allow-destructive requires --justify \"<reason>\"".to_string(),
383        ));
384    }
385    Ok(())
386}
387
388/// Pure helper: `true` when `justify` is `None` or the empty / all-
389/// whitespace string. Lifted into a free function so the arg-refusal
390/// guards stay symmetric.
391fn justify_is_empty(justify: Option<&str>) -> bool {
392    justify.map(|s| s.trim().is_empty()).unwrap_or(true)
393}
394
395/// Pre-flight scan over a loaded plan. Refuses with
396/// [`LivePlanGate::DestructiveWithoutJustify`] when the plan contains
397/// at least one step whose execution would emit destructive SQL but
398/// the operator did not pass BOTH `--allow-destructive` and a
399/// non-empty `--justify "<reason>"`.
400/// The scan is conservative: when the plan's destructive steps all
401/// pre-date the row's `current_step_index`, the gate still fires. This
402/// is intentional — a re-run that resumes past a destructive step into
403/// non-destructive territory still required `--allow-destructive` on
404/// the original invocation, and the audit trail benefits from the gate
405/// being uniformly recorded for any plan that ever contained one.
406fn require_destructive_gate_for_plan(
407    plan: &djogi::live_migrate::LivePlan,
408    allow_destructive: bool,
409    justify: Option<&str>,
410) -> Result<(), LiveCmdError> {
411    if !plan.has_destructive_steps() {
412        return Ok(());
413    }
414    if !allow_destructive {
415        return Err(LiveCmdError::ArgRefused(
416            "plan contains a destructive step (DROP / TRUNCATE class); \
417             pass `--allow-destructive --justify \"<reason>\"` to proceed"
418                .to_string(),
419        ));
420    }
421    if justify_is_empty(justify) {
422        return Err(LiveCmdError::ArgRefused(
423            "plan contains a destructive step; `--allow-destructive` requires \
424             `--justify \"<reason>\"`"
425                .to_string(),
426        ));
427    }
428    Ok(())
429}
430
431/// Decide whether `live abandon --force` is permitted in the current
432/// environment. Refuses when `DJOGI_ENV == "production"` (case-
433/// insensitive). All other values — including unset — pass.
434fn force_allowed_in_env() -> bool {
435    match std::env::var("DJOGI_ENV") {
436        Ok(v) => !v.eq_ignore_ascii_case("production"),
437        Err(_) => true,
438    }
439}
440
441/// Parse a humantime-style single-unit duration string into a
442/// [`std::time::Duration`]. Supported units (suffix character):
443/// | Suffix | Meaning | Multiplier |
444/// |--------|----------|------------|
445/// | `s` | seconds | 1 |
446/// | `m` | minutes | 60 |
447/// | `h` | hours | 3600 |
448/// | `d` | days | 86_400 |
449/// `min` is also accepted as an alias for `m` so operators familiar
450/// with `humantime`-style strings can write `10min`. The numeric prefix
451/// must be one or more ASCII digits; the unit is one of the suffixes
452/// above. Trailing whitespace is rejected — the input is the entire
453/// flag value.
454/// Implementation note: no regex engine, no third-party humantime
455/// crate. Byte-level walk per project policy.
456/// Returns the input echoed back inside the error string so operators
457/// see what they typed.
458fn parse_humantime_duration(s: &str) -> Result<std::time::Duration, String> {
459    let trimmed = s.trim();
460    if trimmed.is_empty() {
461        return Err(format!(
462            "empty duration string `{s}`; expected e.g. `30s` / `5m` / `2h` / `1d` / `10min`"
463        ));
464    }
465    let bytes = trimmed.as_bytes();
466    // Walk leading ASCII digits.
467    let mut i = 0usize;
468    while i < bytes.len() && bytes[i].is_ascii_digit() {
469        i += 1;
470    }
471    if i == 0 {
472        return Err(format!(
473            "duration `{s}` must start with one or more ASCII digits"
474        ));
475    }
476    let digits = &trimmed[..i];
477    let unit = &trimmed[i..];
478    let value: u64 = digits
479        .parse()
480        .map_err(|e| format!("duration `{s}`: numeric prefix `{digits}` overflows u64: {e}"))?;
481    let secs: u64 = match unit {
482        "s" => value,
483        "m" | "min" => value
484            .checked_mul(60)
485            .ok_or_else(|| format!("duration `{s}` overflows u64 seconds"))?,
486        "h" => value
487            .checked_mul(3_600)
488            .ok_or_else(|| format!("duration `{s}` overflows u64 seconds"))?,
489        "d" => value
490            .checked_mul(86_400)
491            .ok_or_else(|| format!("duration `{s}` overflows u64 seconds"))?,
492        other => {
493            return Err(format!(
494                "duration `{s}`: unknown unit `{other}`; expected `s` / `m` / `min` / `h` / `d`"
495            ));
496        }
497    };
498    Ok(std::time::Duration::from_secs(secs))
499}
500
501// ── Plan-file location helpers ────────────────────────────────────────
502
503/// Build the plan-file path the way [`djogi::live_migrate::plan_path`]
504/// does — `<workspace>/migrations/<target_database>/live/<plan_id>_<slug>.json`.
505/// Lifted into a free function so the show / run / resume / finalize
506/// commands all share one path resolution.
507fn resolve_plan_file_path(workspace: &std::path::Path, row: &LivePlanRow) -> std::path::PathBuf {
508    let migrations_root = djogi::migrate::migrations_root(workspace);
509    plan_path(
510        &migrations_root,
511        &row.target_database,
512        row.plan_id,
513        &row.slug,
514    )
515}
516
517/// Best-effort connection helper. Encapsulates the repeating
518/// `DjogiPool::connect` + `DjogiContext::from_pool` dance so each
519/// command body stays linear.
520async fn connect(database_url: &str) -> Result<DjogiContext, LiveCmdError> {
521    let pool = DjogiPool::connect(database_url)
522        .await
523        .map_err(|e| LiveCmdError::Runtime(format!("connect: {e}")))?;
524    djogi::pg::preflight::check_postgres_version(&pool)
525        .await
526        .map_err(|e| LiveCmdError::Runtime(format!("support boundary: {e}")))?;
527    Ok(DjogiContext::from_pool(pool))
528}
529
530/// Load `Djogi.toml` from the resolved workspace.
531fn load_config(workspace: &std::path::Path) -> Result<DjogiConfig, LiveCmdError> {
532    DjogiConfig::load_from_workspace(workspace)
533        .map_err(|e| LiveCmdError::Runtime(format!("config load: {e}")))
534}
535
536/// Locate the row for `plan_id` in `djogi_live_plans`. Walks every
537/// `(target_database, app_label)` bucket the row could live in by
538/// querying directly on the primary key.
539async fn fetch_row(ctx: &mut DjogiContext, plan_id: HeerId) -> Result<LivePlanRow, LiveCmdError> {
540    // The bucket-keyed `fetch_row_by_id` requires the bucket fields up
541    // front; the CLI only has plan_id at this entry point. We look up
542    // by primary key first via raw_query (the table's PRIMARY KEY is
543    // plan_id), then drop into the bucketed helper for the parsed
544    // shape.
545    use djogi::live_migrate::state;
546    // A raw lookup against the unique PRIMARY KEY: we don't have
547    // bucket fields yet, so issue a single SQL probe to read them.
548    let bucket_row = ctx
549        .raw_rows(
550            "SELECT target_database, app_label FROM djogi_live_plans WHERE plan_id = $1",
551            &[&plan_id.as_i64()],
552        )
553        .await
554        .map_err(|e| LiveCmdError::Runtime(format!("plan lookup: {e}")))?;
555    let bucket = match bucket_row.first() {
556        Some(row) => {
557            let target_database: String = row
558                .try_get(0)
559                .map_err(|e| LiveCmdError::Runtime(format!("plan lookup decode: {e}")))?;
560            let app_label: String = row
561                .try_get(1)
562                .map_err(|e| LiveCmdError::Runtime(format!("plan lookup decode: {e}")))?;
563            (target_database, app_label)
564        }
565        None => return Err(LiveCmdError::PlanNotFound(plan_id)),
566    };
567    let row = state::fetch_row_by_id(ctx, plan_id, &bucket.0, &bucket.1)
568        .await
569        .map_err(|e| LiveCmdError::Runtime(format!("plan fetch: {e}")))?
570        .ok_or(LiveCmdError::PlanNotFound(plan_id))?;
571    Ok(row)
572}
573
574// ── live plan ─────────────────────────────────────────────────────────
575
576/// `djogi live plan` body.
577/// Builds the descriptor projection, diffs against the persisted
578/// snapshots, and routes every `ExpandContract`-classified operation
579/// through [`djogi::live_migrate::dispatch_pattern`]. Emits a plan
580/// file plus a `djogi_live_plans` row per bucket that needed one.
581/// Refuses with exit code 2 when the delta carries any `OfflineOnly`
582/// classification — the operator must hand-edit those rather than
583/// route through `live`. Reports "no live plan needed" with exit code
584/// 0 when every classification is `OnlineSafe` (the regular
585/// runner handles them directly).
586async fn plan_cmd(version: Option<&str>, workspace: Option<PathBuf>) -> Result<i32, LiveCmdError> {
587    let workspace = resolve_workspace(workspace);
588    let _config = load_config(&workspace)?;
589
590    // Plan file generation requires the descriptor projection +
591    // snapshot diff + classifier + dispatch — a full pipeline whose
592    // public entry point (`djogi::live_migrate::compose_live_plans`)
593    // lands in a later task. This dispatch ships the operator-facing
594    // CLI shape; until the compose-side glue lands, the body refuses
595    // with an actionable message instead of half-running and leaving
596    // plan files behind.
597    // When the engine lands, the body becomes:
598    // 1. project_from_inventory + load_snapshot per bucket
599    // 2. diff_bucket_maps → Vec<SchemaDelta>
600    // 3. classify_delta per bucket; route OfflineOnly through
601    // [`refuse_offline_only`] (exit 2)
602    // 4. for each ExpandContract op: dispatch_pattern → Vec<Step>
603    // 5. write_plan + insert_row per bucket
604    // 6. print summary
605    // The `--version` filter narrows the bucket walk to a single
606    // committed migration version, surfacing here once the engine
607    // lands.
608    if let Some(v) = version
609        && !v.is_empty()
610    {
611        return Err(refuse_offline_only(format!(
612            "live plan: explicit version filter `{v}` requires the live-plan compose engine; \
613             this CLI build ships the dispatch + parsing surface only"
614        )));
615    }
616    Err(LiveCmdError::Runtime(
617        "live plan: descriptor → snapshot → classify → dispatch pipeline lands in a follow-up task; \
618         this CLI build shipped the dispatch + parsing surface only. Use `djogi migrations compose` \
619         today; the live-plan emitter wraps that in a forthcoming task"
620            .to_string(),
621    ))
622}
623
624/// Refuse the live-plan compose path when the delta carries an
625/// `OfflineOnly` operation. Hoisted into a free function so the future
626/// `live plan` engine wires it in by importing this helper rather than
627/// re-deriving the message shape; the wider call surface here surfaces
628/// the variant in the public API for the `LiveCmdError::exit_code()`
629/// contract (exit 2).
630/// Returns [`LiveCmdError::ClassificationRefused`] which maps to exit
631/// code 2 per of the plan.
632pub fn refuse_offline_only(reason: impl Into<String>) -> LiveCmdError {
633    LiveCmdError::ClassificationRefused(reason.into())
634}
635
636// ── live show ─────────────────────────────────────────────────────────
637
638/// `djogi live show` body. Resolves the plan row, reads + checksum-
639/// verifies the on-disk plan file, and prints metadata + active hooks.
640async fn show_cmd(plan_id_raw: &str, workspace: Option<PathBuf>) -> Result<i32, LiveCmdError> {
641    let plan_id = parse_plan_id(plan_id_raw)?;
642    let workspace = resolve_workspace(workspace);
643    let config = load_config(&workspace)?;
644    let mut ctx = connect(&config.database.url).await?;
645    let row = fetch_row(&mut ctx, plan_id).await?;
646    let path = resolve_plan_file_path(&workspace, &row);
647    verify_checksum(&path, &row.plan_file_checksum)?;
648    let plan = read_plan(&path)?;
649
650    let current_index = u32::try_from(row.current_step_index).unwrap_or(0);
651    let hooks = active_hooks_at_step(&plan, current_index)
652        .map_err(|e| LiveCmdError::Runtime(format!("hook walker: {e}")))?;
653
654    println!("plan_id        : {}", row.plan_id);
655    println!("slug           : {}", row.slug);
656    println!("classification : {}", row.classification.as_db_str());
657    println!("status         : {}", row.status.as_db_str());
658    println!(
659        "current_step   : {} (index {})",
660        row.current_step.as_deref().unwrap_or("<none>"),
661        row.current_step_index,
662    );
663    let total = row
664        .backfill_rows_total
665        .map(|n| n.to_string())
666        .unwrap_or_else(|| "<unknown>".to_string());
667    println!(
668        "backfill_rows  : {} done / {} total",
669        row.backfill_rows_done, total,
670    );
671    println!("originating    : {}", row.originating_migration.as_str(),);
672    if let Some(progress) = row.last_progress_at.as_ref() {
673        println!("last_progress  : {progress}");
674    }
675    if let Some(err) = row.last_error.as_deref() {
676        println!("last_error     : {err}");
677    }
678    println!("plan_file      : {}", path.display());
679    println!();
680    println!("steps ({} total):", plan.steps.len(),);
681    for step in &plan.steps {
682        let marker = if (step.ordinal as i32) < row.current_step_index {
683            "[done]"
684        } else if (step.ordinal as i32) == row.current_step_index {
685            "[curr]"
686        } else {
687            "[ todo]"
688        };
689        println!(
690            "  {marker} {ordinal:>3}: {kind:?}",
691            ordinal = step.ordinal,
692            kind = step.kind,
693        );
694    }
695    println!();
696    println!(
697        "active hooks   : dual_read={}, dual_write={}, suppress_events={}",
698        hooks.dual_read.len(),
699        hooks.dual_write.len(),
700        hooks.side_effects_suppressed,
701    );
702    Ok(0)
703}
704
705// ── live run / resume ─────────────────────────────────────────────────
706
707/// `djogi live run` body. Executes all steps in the plan starting
708/// from step 0. Pauses at the first OperatorGate step and records
709/// progress via checkpoint(). Refuses destructive steps without
710/// `--allow-destructive --justify "<reason>"`.
711async fn run_cmd(
712    plan_id_raw: &str,
713    allow_destructive: bool,
714    justify: Option<&str>,
715    allow_raw_dangerous: bool,
716    workspace: Option<PathBuf>,
717) -> Result<i32, LiveCmdError> {
718    require_justify_for_destructive(allow_destructive, justify)?;
719    require_justify_for_dangerous(allow_raw_dangerous, justify)?;
720    let plan_id = parse_plan_id(plan_id_raw)?;
721    let workspace = resolve_workspace(workspace);
722    let config = load_config(&workspace)?;
723    let mut ctx = connect(&config.database.url).await?;
724    let row = fetch_row(&mut ctx, plan_id).await?;
725    assert_run_status_allows_progress(row.status)?;
726    let path = resolve_plan_file_path(&workspace, &row);
727    verify_checksum(&path, &row.plan_file_checksum)?;
728    let plan = read_plan(&path)?;
729
730    // Walk the plan ahead of time and refuse any destructive step
731    // (CleanupLegacyState, or RunReversibleSchemaOp whose up_sql drops
732    // state) unless the operator passed BOTH `--allow-destructive` and a
733    // non-empty `--justify`. Without this proactive scan, a plan whose
734    // tail contains a DROP COLUMN would silently execute as soon as the
735    // runner reached that step. The check fires before any side effect
736    // so the operator sees the refusal and can re-invoke with the gate
737    // pair.
738    require_destructive_gate_for_plan(&plan, allow_destructive, justify)?;
739
740    // The actual step-walking executor lives behind the live-plan
741    // engine entry point that ships alongside the `live plan` compose
742    // body. This dispatch wires the CLI surface, checksum verify, and
743    // state-conflict gates; the per-step execution loop (DDL emission,
744    // gate pause / promote, transactional progress writes) is the
745    // engine's job and lands in the same follow-up.
746    // Execute the plan via the live-plan engine. The operator-supplied
747    // `allow_destructive` / `justify` flow through to the engine-side
748    // gate as well as the CLI pre-flight above (belt and suspenders).
749    match djogi::live_migrate::executor::run_plan(
750        &mut ctx,
751        path,
752        0,
753        false,
754        allow_destructive,
755        justify,
756    )
757    .await
758    {
759        Ok(result) => match result {
760            StepResult::Completed => {
761                println!("live run: plan {plan_id} completed successfully");
762                Ok(0)
763            }
764            StepResult::Paused => {
765                println!(
766                    "live run: paused at operator gate; resume with `djogi live run {plan_id}`"
767                );
768                Ok(0)
769            }
770            StepResult::Partial {
771                rows_done,
772                rows_total,
773            } => {
774                if rows_total > 0 {
775                    let pct = (rows_done as f64 / rows_total as f64) * 100.0;
776                    println!(
777                        "live run: backfill progress {rows_done}/{rows_total} ({pct:.1}%); resume with `djogi live resume {plan_id}`"
778                    );
779                } else {
780                    println!(
781                        "live run: backfill interrupted after {rows_done} rows; resume with `djogi live resume {plan_id}`"
782                    );
783                }
784                Ok(0)
785            }
786        },
787        Err(e) => Err(LiveCmdError::Runtime(format!("executor error: {e}"))),
788    }
789}
790
791/// `djogi live resume` body. Same shape as `run`, but additionally
792/// refuses (exit 5) when the plan is at a validation / cutover /
793/// finalize gate — those need `live run` (or `live finalize`).
794/// A resumed plan can reach a destructive cleanup step, so it carries
795/// the same `--allow-destructive` / `--justify` gate as `live run`:
796/// the CLI pre-flight pairs the two flags, and the engine enforces the
797/// destructive gate before executing any DROP-class step.
798async fn resume_cmd(
799    plan_id_raw: &str,
800    allow_destructive: bool,
801    justify: Option<&str>,
802    workspace: Option<PathBuf>,
803) -> Result<i32, LiveCmdError> {
804    require_justify_for_destructive(allow_destructive, justify)?;
805    let plan_id = parse_plan_id(plan_id_raw)?;
806    let workspace = resolve_workspace(workspace);
807    let config = load_config(&workspace)?;
808    let mut ctx = connect(&config.database.url).await?;
809    let row = fetch_row(&mut ctx, plan_id).await?;
810    assert_resume_status_allows_progress(row.status)?;
811    let path = resolve_plan_file_path(&workspace, &row);
812    verify_checksum(&path, &row.plan_file_checksum)?;
813    let _plan = read_plan(&path)?;
814    // Resume execution from current step
815    let start_idx = u32::try_from(row.current_step_index).unwrap_or(0);
816    match djogi::live_migrate::executor::run_plan(
817        &mut ctx,
818        path,
819        start_idx,
820        true,
821        allow_destructive,
822        justify,
823    )
824    .await
825    {
826        Ok(result) => match result {
827            StepResult::Completed => {
828                println!("live resume: plan {plan_id} completed successfully");
829                Ok(0)
830            }
831            StepResult::Paused => {
832                println!(
833                    "live resume: paused at operator gate; resume with `djogi live run {plan_id}`"
834                );
835                Ok(0)
836            }
837            StepResult::Partial {
838                rows_done,
839                rows_total,
840            } => {
841                if rows_total > 0 {
842                    let pct = (rows_done as f64 / rows_total as f64) * 100.0;
843                    println!(
844                        "live resume: backfill progress {rows_done}/{rows_total} ({pct:.1}%); resume with `djogi live resume {plan_id}`"
845                    );
846                } else {
847                    println!(
848                        "live resume: backfill interrupted after {rows_done} rows; resume with `djogi live resume {plan_id}`"
849                    );
850                }
851                Ok(0)
852            }
853        },
854        Err(e) => Err(LiveCmdError::Runtime(format!("executor error: {e}"))),
855    }
856}
857
858/// Reject statuses where `live run` would be a state-machine error.
859/// Only `Pending` and `Running` advance — `Paused` is the operator's
860/// explicit checkpoint state and requires `live resume` to re-enter
861/// the run loop. Every other state is a state conflict (exit 5).
862/// `PlanStatus` is `#[non_exhaustive]`; the trailing wildcard arm
863/// classifies any future-added variant as a state conflict by default.
864/// That is the conservative choice — a new variant the CLI does not
865/// yet understand should refuse rather than silently advance.
866fn assert_run_status_allows_progress(status: PlanStatus) -> Result<(), LiveCmdError> {
867    match status {
868        PlanStatus::Pending | PlanStatus::Running => Ok(()),
869        PlanStatus::Paused => Err(LiveCmdError::StateConflict(
870            "plan is in `paused`; use `live resume` to re-enter the run loop \
871             (paused is an explicit operator checkpoint and `live run` does \
872             not auto-advance through it)"
873                .to_string(),
874        )),
875        PlanStatus::Validating
876        | PlanStatus::Cutover
877        | PlanStatus::Finalizing
878        | PlanStatus::Complete
879        | PlanStatus::Abandoned
880        | PlanStatus::Failed => Err(LiveCmdError::StateConflict(format!(
881            "plan is in `{}`; `live run` advances only Pending / Running plans",
882            status.as_db_str()
883        ))),
884        _ => Err(LiveCmdError::StateConflict(format!(
885            "plan is in `{}`; this CLI build does not recognise the status",
886            status.as_db_str()
887        ))),
888    }
889}
890
891/// Reject statuses where `live resume` would be a state-machine error.
892/// Resume is more restrictive than run — it only accepts `Running` or
893/// `Paused`. Operator gates (Validating / Cutover / Finalizing) need
894/// `live run` to push them past the gate; terminal states refuse.
895/// `PlanStatus` is `#[non_exhaustive]`; the trailing wildcard arm
896/// classifies any future-added variant as a state conflict by default.
897fn assert_resume_status_allows_progress(status: PlanStatus) -> Result<(), LiveCmdError> {
898    match status {
899        PlanStatus::Running | PlanStatus::Paused => Ok(()),
900        PlanStatus::Pending => Err(LiveCmdError::StateConflict(
901            "plan is in `pending`; use `live run` to start it (resume is for an interrupted run)"
902                .to_string(),
903        )),
904        PlanStatus::Validating
905        | PlanStatus::Cutover
906        | PlanStatus::Finalizing
907        | PlanStatus::Complete
908        | PlanStatus::Abandoned
909        | PlanStatus::Failed => Err(LiveCmdError::StateConflict(format!(
910            "plan is in `{}`; resume is for interrupted Running / Paused plans \
911             (use `live run` past gates, `live finalize` to complete, or `live abandon` to walk away)",
912            status.as_db_str()
913        ))),
914        _ => Err(LiveCmdError::StateConflict(format!(
915            "plan is in `{}`; this CLI build does not recognise the status",
916            status.as_db_str()
917        ))),
918    }
919}
920
921// ── live finalize ─────────────────────────────────────────────────────
922
923/// `djogi live finalize` body. Gated on the row being in
924/// [`PlanStatus::Finalizing`]; advances every remaining
925/// [`StepKind::FinalizeConstraints`](djogi::live_migrate::StepKind::FinalizeConstraints)
926/// /
927/// [`StepKind::CleanupLegacyState`](djogi::live_migrate::StepKind::CleanupLegacyState)
928/// step, drops compatibility hooks, and promotes the row to
929/// [`PlanStatus::Complete`].
930/// Requires `--justify "<reason>"`; the cleanup phase is destructive by
931/// nature. The `live finalize` invocation is itself the operator's
932/// destructive opt-in, so it passes `allow_destructive = true` to the
933/// engine but still demands a non-empty justification.
934async fn finalize_cmd(
935    plan_id_raw: &str,
936    justify: Option<&str>,
937    workspace: Option<PathBuf>,
938) -> Result<i32, LiveCmdError> {
939    let plan_id = parse_plan_id(plan_id_raw)?;
940    let workspace = resolve_workspace(workspace);
941    let config = load_config(&workspace)?;
942    let mut ctx = connect(&config.database.url).await?;
943    let row = fetch_row(&mut ctx, plan_id).await?;
944    assert_finalize_status(row.status)?;
945    // `live finalize` runs the contract (cleanup) phase, which is
946    // destructive by nature. Require a justification just as `live run
947    // --allow-destructive` does; treat the finalize invocation itself as
948    // the operator's destructive opt-in.
949    let justify_present = justify.map(|s| !s.trim().is_empty()).unwrap_or(false);
950    if !justify_present {
951        return Err(LiveCmdError::ArgRefused(
952            "live finalize runs destructive cleanup steps; pass \
953             --justify \"<reason>\""
954                .to_string(),
955        ));
956    }
957    let path = resolve_plan_file_path(&workspace, &row);
958    verify_checksum(&path, &row.plan_file_checksum)?;
959    let _plan = read_plan(&path)?;
960    // Resume execution from current step. `live finalize` implies the
961    // destructive opt-in (5th arg `true`); the engine still requires the
962    // non-empty `justify` checked above.
963    let start_idx = u32::try_from(row.current_step_index).unwrap_or(0);
964    match djogi::live_migrate::executor::run_plan(&mut ctx, path, start_idx, true, true, justify)
965        .await
966    {
967        Ok(result) => match result {
968            StepResult::Completed => {
969                println!("live finalize: plan {plan_id} completed successfully");
970                Ok(0)
971            }
972            StepResult::Paused => {
973                println!(
974                    "live finalize: paused at operator gate; resume with `djogi live run {plan_id}`"
975                );
976                Ok(0)
977            }
978            StepResult::Partial {
979                rows_done,
980                rows_total,
981            } => {
982                if rows_total > 0 {
983                    let pct = (rows_done as f64 / rows_total as f64) * 100.0;
984                    println!(
985                        "live finalize: backfill progress {rows_done}/{rows_total} ({pct:.1}%); resume with `djogi live finalize {plan_id}`"
986                    );
987                } else {
988                    println!(
989                        "live finalize: backfill interrupted after {rows_done} rows; resume with `djogi live finalize {plan_id}`"
990                    );
991                }
992                Ok(0)
993            }
994        },
995        Err(e) => Err(LiveCmdError::Runtime(format!("executor error: {e}"))),
996    }
997}
998
999/// Reject statuses where `live finalize` would be a state-machine
1000/// error. Only `Finalizing` advances.
1001fn assert_finalize_status(status: PlanStatus) -> Result<(), LiveCmdError> {
1002    match status {
1003        PlanStatus::Finalizing => Ok(()),
1004        other => Err(LiveCmdError::StateConflict(format!(
1005            "plan is in `{}`; `live finalize` runs only against the `finalizing` state",
1006            other.as_db_str()
1007        ))),
1008    }
1009}
1010
1011// ── live abandon ──────────────────────────────────────────────────────
1012
1013/// `djogi live abandon` body. Confirmation prompt unless `--force`;
1014/// `--force` requires `DJOGI_ENV != production`.
1015async fn abandon_cmd(
1016    plan_id_raw: &str,
1017    force: bool,
1018    workspace: Option<PathBuf>,
1019) -> Result<i32, LiveCmdError> {
1020    let plan_id = parse_plan_id(plan_id_raw)?;
1021    let workspace = resolve_workspace(workspace);
1022    let config = load_config(&workspace)?;
1023    if force && !force_allowed_in_env() {
1024        return Err(LiveCmdError::ArgRefused(
1025            "--force refused under DJOGI_ENV=production".to_string(),
1026        ));
1027    }
1028    let confirmed = if force {
1029        true
1030    } else {
1031        match interactive_confirm_abandon(plan_id) {
1032            Ok(c) => c,
1033            Err(_) => {
1034                return Err(LiveCmdError::ArgRefused(
1035                    "failed to read confirmation; refusing without an explicit `--force`"
1036                        .to_string(),
1037                ));
1038            }
1039        }
1040    };
1041    if !confirmed {
1042        eprintln!("djogi live abandon: aborted; plan {plan_id} unchanged");
1043        return Ok(0);
1044    }
1045
1046    let mut ctx = connect(&config.database.url).await?;
1047    let row = fetch_row(&mut ctx, plan_id).await?;
1048    assert_abandon_status(row.status)?;
1049    djogi::live_migrate::state::update_status(
1050        &mut ctx,
1051        plan_id,
1052        &row.target_database,
1053        &row.app_label,
1054        PlanStatus::Abandoned,
1055    )
1056    .await
1057    .map_err(|e| LiveCmdError::Runtime(format!("abandon update_status: {e}")))?;
1058
1059    println!(
1060        "live abandon: plan {plan_id} marked abandoned (was `{}`); plan file \
1061         preserved on disk for audit",
1062        row.status.as_db_str(),
1063    );
1064    Ok(0)
1065}
1066
1067/// Reject statuses where `live abandon` would be a no-op or rewrite
1068/// terminal state. `Complete`, `Abandoned`, and `Failed` are all
1069/// terminal per the v3 plan §3 state machine; the rest (Pending,
1070/// Running, Paused, Validating, Cutover, Finalizing) can be abandoned.
1071/// `Failed` is terminal: a failed plan documents the failure for the
1072/// audit trail and should not be silently overwritten with `abandoned`
1073/// by the CLI. Operators recovering from a failed plan generate a
1074/// fresh plan after addressing the underlying cause.
1075fn assert_abandon_status(status: PlanStatus) -> Result<(), LiveCmdError> {
1076    match status {
1077        PlanStatus::Complete => Err(LiveCmdError::StateConflict(
1078            "plan is `complete`; nothing to abandon".to_string(),
1079        )),
1080        PlanStatus::Abandoned => Err(LiveCmdError::StateConflict(
1081            "plan is already `abandoned`".to_string(),
1082        )),
1083        PlanStatus::Failed => Err(LiveCmdError::StateConflict(
1084            "plan is `failed`; the failure is recorded for audit and the \
1085             plan is terminal — generate a fresh plan after addressing the \
1086             underlying cause"
1087                .to_string(),
1088        )),
1089        PlanStatus::Pending
1090        | PlanStatus::Running
1091        | PlanStatus::Paused
1092        | PlanStatus::Validating
1093        | PlanStatus::Cutover
1094        | PlanStatus::Finalizing => Ok(()),
1095        _ => Err(LiveCmdError::StateConflict(format!(
1096            "plan is in `{}`; this CLI build does not recognise the status",
1097            status.as_db_str()
1098        ))),
1099    }
1100}
1101
1102// ── live daemon ───────────────────────────────────────────────────────
1103
1104/// `djogi live daemon` body. Builds the [`DaemonConfig`] from operator
1105/// flags and routes through [`run_daemon`]. The daemon's triple-gate
1106/// (production env + localhost) fires inside `run_daemon`; this body
1107/// only translates the result back to a CLI exit code.
1108/// Returns `0` on a clean SIGTERM / SIGINT shutdown ([`DaemonError::Shutdown`])
1109/// the daemon's only successful exit path. Production / localhost
1110/// gate refusals map to [`LiveCmdError::ArgRefused`] (exit 1 — the
1111/// gate fired before any side effect, but the operator-visible
1112/// difference between "gate refused" and "ran but failed" is captured
1113/// in the error message rather than the exit code).
1114async fn daemon_cmd(
1115    poll_interval: std::time::Duration,
1116    claim_stale_after: std::time::Duration,
1117    allow_non_localhost: bool,
1118    workspace: Option<PathBuf>,
1119) -> Result<i32, LiveCmdError> {
1120    let workspace = resolve_workspace(workspace);
1121    let config = load_config(&workspace)?;
1122    let cfg = DaemonConfig {
1123        poll_interval,
1124        claim_stale_after,
1125        allow_non_localhost,
1126        database_url: config.database.url.clone(),
1127        host: hostname_for_claim(),
1128        pid: i64::from(std::process::id()),
1129        profile: config.profile.clone(),
1130        workspace_root: workspace.to_path_buf(),
1131    };
1132    let mut ctx = connect(&config.database.url).await?;
1133    match run_daemon(&mut ctx, cfg).await {
1134        Ok(()) => Ok(0),
1135        Err(DaemonError::Shutdown) => Ok(0),
1136        Err(DaemonError::NotLocalhost) => Err(LiveCmdError::ArgRefused(
1137            "live daemon refused: not running on localhost (pass --allow-non-localhost to override)"
1138                .to_string(),
1139        )),
1140        Err(DaemonError::Production) => Err(LiveCmdError::ArgRefused(
1141            "live daemon refused: DJOGI_ENV=production".to_string(),
1142        )),
1143        Err(DaemonError::Backfill(e)) => {
1144            Err(LiveCmdError::Runtime(format!("daemon backfill: {e}")))
1145        }
1146        Err(DaemonError::Database(e)) => Err(LiveCmdError::Runtime(format!("daemon db: {e}"))),
1147        Err(other) => Err(LiveCmdError::Runtime(format!("daemon: {other}"))),
1148    }
1149}
1150
1151/// Read the running host's name for the daemon's claim columns. Falls
1152/// back to `"unknown"` so a missing `HOSTNAME` env var produces a
1153/// recognisable diagnostic in `live show` rather than an empty string.
1154fn hostname_for_claim() -> String {
1155    std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".to_string())
1156}
1157
1158/// Interactive confirmation prompt for `live abandon`. Reads stdin;
1159/// only `y` / `yes` (case-insensitive ASCII) returns `Ok(true)`.
1160fn interactive_confirm_abandon(plan_id: HeerId) -> std::io::Result<bool> {
1161    use std::io::{BufRead, Write};
1162    let stderr = std::io::stderr();
1163    let mut handle = stderr.lock();
1164    writeln!(
1165        handle,
1166        "WARNING: live abandon will mark plan {plan_id} as `abandoned`. Schema state \
1167         remains at the last completed step; the plan file stays on disk. Resume is \
1168         refused after abandonment — generate a fresh plan instead."
1169    )?;
1170    write!(handle, "Type `yes` to confirm, anything else to abort: ")?;
1171    handle.flush()?;
1172    let stdin = std::io::stdin();
1173    let mut line = String::new();
1174    stdin.lock().read_line(&mut line)?;
1175    Ok(matches!(
1176        line.trim().to_ascii_lowercase().as_str(),
1177        "y" | "yes"
1178    ))
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184    use clap::Parser;
1185
1186    /// Test driver — clap derive macros emit a parser per top-level
1187    /// command; the live subcommand sits under a fixture with one
1188    /// nested variant so the unit tests can exercise the arg parsing
1189    /// without depending on the full top-level CLI shape.
1190    #[derive(Parser, Debug)]
1191    struct LiveCli {
1192        #[command(subcommand)]
1193        cmd: LiveCmd,
1194    }
1195
1196    fn parse(argv: &[&str]) -> Result<LiveCli, clap::Error> {
1197        let mut full = vec!["live"];
1198        full.extend_from_slice(argv);
1199        LiveCli::try_parse_from(full)
1200    }
1201
1202    // ── parsing ──────────────────────────────────────────────────────
1203
1204    #[test]
1205    fn live_plan_parses_without_args() {
1206        let parsed = parse(&["plan"]).expect("plan parses");
1207        match parsed.cmd {
1208            LiveCmd::Plan { version, .. } => assert!(version.is_none()),
1209            other => panic!("expected Plan, got {other:?}"),
1210        }
1211    }
1212
1213    #[test]
1214    fn live_plan_accepts_optional_version() {
1215        let parsed = parse(&["plan", "V20260428000000__demo"]).expect("plan with version parses");
1216        match parsed.cmd {
1217            LiveCmd::Plan { version, .. } => {
1218                assert_eq!(version.as_deref(), Some("V20260428000000__demo"));
1219            }
1220            other => panic!("expected Plan, got {other:?}"),
1221        }
1222    }
1223
1224    #[test]
1225    fn live_show_requires_plan_id() {
1226        let err = parse(&["show"]).expect_err("show without plan_id must fail");
1227        let msg = err.to_string();
1228        assert!(
1229            msg.to_lowercase().contains("plan_id") || msg.to_lowercase().contains("required"),
1230            "expected plan_id requirement in clap message: {msg}",
1231        );
1232    }
1233
1234    #[test]
1235    fn live_show_parses_plan_id() {
1236        let parsed = parse(&["show", "12345"]).expect("show with plan_id parses");
1237        match parsed.cmd {
1238            LiveCmd::Show { plan_id, .. } => assert_eq!(plan_id, "12345"),
1239            other => panic!("expected Show, got {other:?}"),
1240        }
1241    }
1242
1243    #[test]
1244    fn live_run_accepts_allow_destructive_with_justify() {
1245        let parsed = parse(&[
1246            "run",
1247            "12345",
1248            "--allow-destructive",
1249            "--justify",
1250            "rotate keys for incident IR-7",
1251        ])
1252        .expect("run with destructive + justify parses");
1253        match parsed.cmd {
1254            LiveCmd::Run {
1255                plan_id,
1256                allow_destructive,
1257                justify,
1258                allow_raw_dangerous,
1259                ..
1260            } => {
1261                assert_eq!(plan_id, "12345");
1262                assert!(allow_destructive);
1263                assert_eq!(justify.as_deref(), Some("rotate keys for incident IR-7"));
1264                assert!(!allow_raw_dangerous);
1265            }
1266            other => panic!("expected Run, got {other:?}"),
1267        }
1268    }
1269
1270    #[test]
1271    fn live_run_accepts_allow_raw_dangerous_with_justify() {
1272        let parsed = parse(&[
1273            "run",
1274            "67890",
1275            "--allow-raw-dangerous",
1276            "--justify",
1277            "operator runbook RB-12",
1278        ])
1279        .expect("run with allow-raw-dangerous parses");
1280        match parsed.cmd {
1281            LiveCmd::Run {
1282                allow_raw_dangerous,
1283                justify,
1284                ..
1285            } => {
1286                assert!(allow_raw_dangerous);
1287                assert_eq!(justify.as_deref(), Some("operator runbook RB-12"));
1288            }
1289            other => panic!("expected Run, got {other:?}"),
1290        }
1291    }
1292
1293    #[test]
1294    fn live_resume_parses() {
1295        let parsed = parse(&["resume", "55"]).expect("resume parses");
1296        assert!(matches!(parsed.cmd, LiveCmd::Resume { .. }));
1297    }
1298
1299    #[test]
1300    fn live_finalize_accepts_justify() {
1301        let parsed = parse(&["finalize", "55", "--justify", "drop legacy"])
1302            .expect("finalize with justify parses");
1303        match parsed.cmd {
1304            LiveCmd::Finalize {
1305                justify, plan_id, ..
1306            } => {
1307                assert_eq!(plan_id, "55");
1308                assert_eq!(justify.as_deref(), Some("drop legacy"));
1309            }
1310            other => panic!("expected Finalize, got {other:?}"),
1311        }
1312    }
1313
1314    #[test]
1315    fn live_abandon_accepts_force() {
1316        let parsed = parse(&["abandon", "12345", "--force"]).expect("abandon with force parses");
1317        match parsed.cmd {
1318            LiveCmd::Abandon { force, plan_id, .. } => {
1319                assert!(force);
1320                assert_eq!(plan_id, "12345");
1321            }
1322            other => panic!("expected Abandon, got {other:?}"),
1323        }
1324    }
1325
1326    #[test]
1327    fn live_daemon_parses_with_default_intervals() {
1328        let parsed = parse(&["daemon"]).expect("daemon parses with no args");
1329        match parsed.cmd {
1330            LiveCmd::Daemon {
1331                poll_interval,
1332                claim_stale_after,
1333                allow_non_localhost,
1334                ..
1335            } => {
1336                assert_eq!(
1337                    poll_interval,
1338                    std::time::Duration::from_secs(30),
1339                    "default poll interval is 30s",
1340                );
1341                assert_eq!(
1342                    claim_stale_after,
1343                    std::time::Duration::from_secs(600),
1344                    "default stale threshold is 10 minutes",
1345                );
1346                assert!(
1347                    !allow_non_localhost,
1348                    "default refuses non-localhost connections",
1349                );
1350            }
1351            other => panic!("expected Daemon, got {other:?}"),
1352        }
1353    }
1354
1355    #[test]
1356    fn live_daemon_accepts_custom_intervals() {
1357        let parsed = parse(&[
1358            "daemon",
1359            "--poll-interval",
1360            "5s",
1361            "--claim-stale-after",
1362            "1m",
1363            "--allow-non-localhost",
1364        ])
1365        .expect("daemon with overrides parses");
1366        match parsed.cmd {
1367            LiveCmd::Daemon {
1368                poll_interval,
1369                claim_stale_after,
1370                allow_non_localhost,
1371                ..
1372            } => {
1373                assert_eq!(poll_interval, std::time::Duration::from_secs(5));
1374                assert_eq!(claim_stale_after, std::time::Duration::from_secs(60));
1375                assert!(allow_non_localhost);
1376            }
1377            other => panic!("expected Daemon, got {other:?}"),
1378        }
1379    }
1380
1381    #[test]
1382    fn live_daemon_accepts_humantime_minutes_and_hours() {
1383        // The humantime parser accepts `m` / `min` / `h` / `d`. Pin the
1384        // common operator-runbook forms.
1385        let parsed = parse(&[
1386            "daemon",
1387            "--poll-interval",
1388            "10min",
1389            "--claim-stale-after",
1390            "2h",
1391        ])
1392        .expect("daemon with humantime durations parses");
1393        match parsed.cmd {
1394            LiveCmd::Daemon {
1395                poll_interval,
1396                claim_stale_after,
1397                ..
1398            } => {
1399                assert_eq!(poll_interval, std::time::Duration::from_secs(600));
1400                assert_eq!(claim_stale_after, std::time::Duration::from_secs(7200));
1401            }
1402            other => panic!("expected Daemon, got {other:?}"),
1403        }
1404    }
1405
1406    #[test]
1407    fn live_daemon_accepts_workspace_override() {
1408        let parsed = parse(&["daemon", "--workspace", "/tmp/example"])
1409            .expect("daemon with --workspace parses");
1410        match parsed.cmd {
1411            LiveCmd::Daemon { workspace, .. } => {
1412                assert_eq!(
1413                    workspace.as_deref(),
1414                    Some(std::path::Path::new("/tmp/example")),
1415                );
1416            }
1417            other => panic!("expected Daemon, got {other:?}"),
1418        }
1419    }
1420
1421    // ── parse_humantime_duration (Finding 7) ──────────────────────────
1422
1423    #[test]
1424    fn parse_humantime_duration_accepts_seconds() {
1425        assert_eq!(
1426            parse_humantime_duration("30s").unwrap(),
1427            std::time::Duration::from_secs(30),
1428        );
1429        assert_eq!(
1430            parse_humantime_duration("0s").unwrap(),
1431            std::time::Duration::from_secs(0),
1432        );
1433    }
1434
1435    #[test]
1436    fn parse_humantime_duration_accepts_minutes_and_hours_and_days() {
1437        assert_eq!(
1438            parse_humantime_duration("5m").unwrap(),
1439            std::time::Duration::from_secs(300),
1440        );
1441        assert_eq!(
1442            parse_humantime_duration("10min").unwrap(),
1443            std::time::Duration::from_secs(600),
1444        );
1445        assert_eq!(
1446            parse_humantime_duration("2h").unwrap(),
1447            std::time::Duration::from_secs(7_200),
1448        );
1449        assert_eq!(
1450            parse_humantime_duration("1d").unwrap(),
1451            std::time::Duration::from_secs(86_400),
1452        );
1453    }
1454
1455    #[test]
1456    fn parse_humantime_duration_rejects_empty_input() {
1457        let err = parse_humantime_duration("").unwrap_err();
1458        assert!(err.contains("empty"), "{err}");
1459        let err = parse_humantime_duration("   ").unwrap_err();
1460        assert!(err.contains("empty"), "{err}");
1461    }
1462
1463    #[test]
1464    fn parse_humantime_duration_rejects_missing_digits() {
1465        let err = parse_humantime_duration("s").unwrap_err();
1466        assert!(err.contains("ASCII digits"), "{err}");
1467        let err = parse_humantime_duration("min").unwrap_err();
1468        assert!(err.contains("ASCII digits"), "{err}");
1469    }
1470
1471    #[test]
1472    fn parse_humantime_duration_rejects_unknown_unit() {
1473        let err = parse_humantime_duration("30y").unwrap_err();
1474        assert!(err.contains("unknown unit"), "{err}");
1475        // Mixed-unit forms are rejected (V1 is single-unit only).
1476        let err = parse_humantime_duration("1h30m").unwrap_err();
1477        assert!(err.contains("unknown unit"), "{err}");
1478    }
1479
1480    #[test]
1481    fn parse_humantime_duration_rejects_trailing_junk() {
1482        // Garbage after the unit fails the unit-match arm.
1483        let err = parse_humantime_duration("30sX").unwrap_err();
1484        assert!(
1485            err.contains("unknown unit") || err.contains("expected"),
1486            "{err}"
1487        );
1488        // Whitespace between digits and unit is also rejected — the
1489        // unit slice begins right after the trailing digit.
1490        let err = parse_humantime_duration("30 s").unwrap_err();
1491        assert!(
1492            err.contains("unknown unit") || err.contains("expected"),
1493            "{err}"
1494        );
1495    }
1496
1497    #[test]
1498    fn parse_humantime_duration_handles_outer_whitespace() {
1499        // Outer whitespace is trimmed so `--poll-interval " 30s "`
1500        // (e.g. from a quoted shell var) round-trips.
1501        assert_eq!(
1502            parse_humantime_duration("  30s  ").unwrap(),
1503            std::time::Duration::from_secs(30),
1504        );
1505    }
1506
1507    #[test]
1508    fn hostname_for_claim_falls_back_to_unknown() {
1509        // SAFETY: tests run with --test-threads=1.
1510        let prior = std::env::var("HOSTNAME").ok();
1511        unsafe { std::env::remove_var("HOSTNAME") };
1512        assert_eq!(hostname_for_claim(), "unknown");
1513        unsafe { std::env::set_var("HOSTNAME", "ci-runner-7") };
1514        assert_eq!(hostname_for_claim(), "ci-runner-7");
1515        match prior {
1516            Some(v) => unsafe { std::env::set_var("HOSTNAME", v) },
1517            None => unsafe { std::env::remove_var("HOSTNAME") },
1518        }
1519    }
1520
1521    // ── helpers ──────────────────────────────────────────────────────
1522
1523    #[test]
1524    fn justify_is_empty_handles_none_and_blank() {
1525        assert!(justify_is_empty(None));
1526        assert!(justify_is_empty(Some("")));
1527        assert!(justify_is_empty(Some("   ")));
1528        assert!(!justify_is_empty(Some("real reason")));
1529    }
1530
1531    #[test]
1532    fn require_justify_for_destructive_refuses_without_reason() {
1533        let err = require_justify_for_destructive(true, None).unwrap_err();
1534        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1535        let err = require_justify_for_destructive(true, Some("   ")).unwrap_err();
1536        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1537        // Without `--allow-destructive`, missing `--justify` is fine.
1538        require_justify_for_destructive(false, None).unwrap();
1539        // With both set, accepts.
1540        require_justify_for_destructive(true, Some("rotate keys")).unwrap();
1541    }
1542
1543    #[test]
1544    fn require_justify_for_dangerous_refuses_without_reason() {
1545        let err = require_justify_for_dangerous(true, None).unwrap_err();
1546        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1547        require_justify_for_dangerous(false, None).unwrap();
1548        require_justify_for_dangerous(true, Some("runbook")).unwrap();
1549    }
1550
1551    #[test]
1552    fn require_destructive_gate_passes_for_non_destructive_plan() {
1553        use djogi::live_migrate::{
1554            LivePlan, PlanClassification, PlanHeader, Step, StepKind, StepParameters,
1555        };
1556        let plan = LivePlan {
1557            header: PlanHeader {
1558                plan_id: HeerId::ZERO,
1559                slug: "demo".to_string(),
1560                classification: PlanClassification::ExpandContract,
1561                originating_migration: "V20260428000000__demo".to_string(),
1562                target_database: "main".to_string(),
1563                app_label: "".to_string(),
1564            },
1565            steps: vec![Step {
1566                kind: StepKind::ExpandSchema,
1567                ordinal: 0,
1568                parameters: StepParameters::ExpandSchema {
1569                    sql_segments: vec!["ALTER TABLE foo ADD COLUMN bar INT".to_string()],
1570                },
1571            }],
1572        };
1573        // Without --allow-destructive: passes (no destructive step).
1574        require_destructive_gate_for_plan(&plan, false, None).unwrap();
1575    }
1576
1577    #[test]
1578    fn require_destructive_gate_refuses_destructive_plan_without_flag() {
1579        use djogi::live_migrate::{
1580            LivePlan, PlanClassification, PlanHeader, Step, StepKind, StepParameters,
1581        };
1582        let plan = LivePlan {
1583            header: PlanHeader {
1584                plan_id: HeerId::ZERO,
1585                slug: "demo".to_string(),
1586                classification: PlanClassification::ExpandContract,
1587                originating_migration: "V20260428000000__demo".to_string(),
1588                target_database: "main".to_string(),
1589                app_label: "".to_string(),
1590            },
1591            steps: vec![Step {
1592                kind: StepKind::CleanupLegacyState,
1593                ordinal: 0,
1594                parameters: StepParameters::CleanupLegacyState {
1595                    sql_segments: vec!["ALTER TABLE foo DROP COLUMN baz".to_string()],
1596                },
1597            }],
1598        };
1599        // No `--allow-destructive` → refuse.
1600        let err = require_destructive_gate_for_plan(&plan, false, None).unwrap_err();
1601        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1602        // `--allow-destructive` without `--justify` → refuse.
1603        let err = require_destructive_gate_for_plan(&plan, true, None).unwrap_err();
1604        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1605        let err = require_destructive_gate_for_plan(&plan, true, Some("   ")).unwrap_err();
1606        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1607        // Both set → accepts.
1608        require_destructive_gate_for_plan(&plan, true, Some("ops runbook RB-19")).unwrap();
1609    }
1610
1611    #[test]
1612    fn parse_plan_id_accepts_decimal() {
1613        let id = parse_plan_id("12345").unwrap();
1614        assert_eq!(id.as_i64(), 12345);
1615    }
1616
1617    #[test]
1618    fn parse_plan_id_rejects_garbage() {
1619        let err = parse_plan_id("not-a-number").unwrap_err();
1620        assert!(matches!(err, LiveCmdError::MalformedPlanId(_)));
1621    }
1622
1623    // ── exit-code mapping ─────────────────────────────────────────────
1624
1625    #[test]
1626    fn exit_code_runtime_maps_to_one() {
1627        assert_eq!(LiveCmdError::Runtime("x".to_string()).exit_code(), 1);
1628        assert_eq!(LiveCmdError::ArgRefused("x".to_string()).exit_code(), 1);
1629        assert_eq!(
1630            LiveCmdError::MalformedPlanId("x".to_string()).exit_code(),
1631            1
1632        );
1633    }
1634
1635    #[test]
1636    fn exit_code_classification_refused_maps_to_two() {
1637        assert_eq!(
1638            LiveCmdError::ClassificationRefused("offline only".to_string()).exit_code(),
1639            2,
1640        );
1641    }
1642    #[test]
1643    fn exit_code_checksum_drift_maps_to_four() {
1644        assert_eq!(
1645            LiveCmdError::ChecksumDrift("mismatch".to_string()).exit_code(),
1646            4,
1647        );
1648    }
1649
1650    #[test]
1651    fn exit_code_state_conflict_maps_to_five() {
1652        assert_eq!(
1653            LiveCmdError::StateConflict("complete".to_string()).exit_code(),
1654            5,
1655        );
1656    }
1657
1658    // ── status-gate helpers ──────────────────────────────────────────
1659
1660    #[test]
1661    fn assert_run_status_accepts_pending_running() {
1662        assert!(assert_run_status_allows_progress(PlanStatus::Pending).is_ok());
1663        assert!(assert_run_status_allows_progress(PlanStatus::Running).is_ok());
1664    }
1665
1666    #[test]
1667    fn assert_run_status_refuses_paused_pointing_to_resume() {
1668        // `Paused` is the operator's explicit checkpoint state — `live run`
1669        // does not auto-advance through it; the operator must invoke
1670        // `live resume` to re-enter the run loop. This pins the gate.
1671        let err = assert_run_status_allows_progress(PlanStatus::Paused)
1672            .expect_err("paused must be a state conflict for `live run`");
1673        match err {
1674            LiveCmdError::StateConflict(msg) => {
1675                assert!(msg.contains("paused"), "{msg}");
1676                assert!(msg.contains("live resume"), "{msg}");
1677            }
1678            other => panic!("expected StateConflict, got {other:?}"),
1679        }
1680    }
1681
1682    #[test]
1683    fn assert_run_status_refuses_terminal_and_gates() {
1684        for status in [
1685            PlanStatus::Validating,
1686            PlanStatus::Cutover,
1687            PlanStatus::Finalizing,
1688            PlanStatus::Complete,
1689            PlanStatus::Abandoned,
1690            PlanStatus::Failed,
1691        ] {
1692            let err = assert_run_status_allows_progress(status)
1693                .expect_err("non-progressable status must refuse");
1694            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1695        }
1696    }
1697
1698    #[test]
1699    fn assert_resume_status_distinguishes_pending_from_terminal() {
1700        // Pending is a state conflict for `resume` (you'd use `run`).
1701        let err = assert_resume_status_allows_progress(PlanStatus::Pending)
1702            .expect_err("pending must refuse");
1703        match err {
1704            LiveCmdError::StateConflict(msg) => assert!(msg.contains("pending")),
1705            other => panic!("expected StateConflict, got {other:?}"),
1706        }
1707        // Running and Paused accept.
1708        assert!(assert_resume_status_allows_progress(PlanStatus::Running).is_ok());
1709        assert!(assert_resume_status_allows_progress(PlanStatus::Paused).is_ok());
1710        // Everything else refuses.
1711        for status in [
1712            PlanStatus::Validating,
1713            PlanStatus::Cutover,
1714            PlanStatus::Finalizing,
1715            PlanStatus::Complete,
1716            PlanStatus::Abandoned,
1717            PlanStatus::Failed,
1718        ] {
1719            let err = assert_resume_status_allows_progress(status)
1720                .expect_err("non-resumable status must refuse");
1721            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1722        }
1723    }
1724
1725    #[test]
1726    fn assert_finalize_status_accepts_only_finalizing() {
1727        assert!(assert_finalize_status(PlanStatus::Finalizing).is_ok());
1728        for status in [
1729            PlanStatus::Pending,
1730            PlanStatus::Running,
1731            PlanStatus::Paused,
1732            PlanStatus::Validating,
1733            PlanStatus::Cutover,
1734            PlanStatus::Complete,
1735            PlanStatus::Abandoned,
1736            PlanStatus::Failed,
1737        ] {
1738            let err = assert_finalize_status(status).expect_err("non-finalizing must refuse");
1739            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1740        }
1741    }
1742
1743    #[test]
1744    fn assert_abandon_status_refuses_every_terminal_state() {
1745        // All three terminal states refuse: Complete, Abandoned, Failed.
1746        for status in [
1747            PlanStatus::Complete,
1748            PlanStatus::Abandoned,
1749            PlanStatus::Failed,
1750        ] {
1751            let err = assert_abandon_status(status)
1752                .expect_err("terminal status must be a state conflict for abandon");
1753            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1754        }
1755        // Every non-terminal status is a valid abandonment target.
1756        for status in [
1757            PlanStatus::Pending,
1758            PlanStatus::Running,
1759            PlanStatus::Paused,
1760            PlanStatus::Validating,
1761            PlanStatus::Cutover,
1762            PlanStatus::Finalizing,
1763        ] {
1764            assert!(assert_abandon_status(status).is_ok(), "{status:?} accepts");
1765        }
1766    }
1767
1768    #[test]
1769    fn assert_abandon_status_failed_message_points_to_fresh_plan() {
1770        // The Failed-refusal message is the operator's signpost — pin
1771        // the wording so refactors notice if the audit trail story drifts.
1772        let err = assert_abandon_status(PlanStatus::Failed).expect_err("failed must refuse");
1773        match err {
1774            LiveCmdError::StateConflict(msg) => {
1775                assert!(msg.contains("failed"), "{msg}");
1776                assert!(msg.contains("fresh plan") || msg.contains("audit"), "{msg}",);
1777            }
1778            other => panic!("expected StateConflict, got {other:?}"),
1779        }
1780    }
1781
1782    // ── env gate ─────────────────────────────────────────────────────
1783
1784    #[test]
1785    fn force_allowed_when_djogi_env_unset() {
1786        // SAFETY: tests run with --test-threads=1.
1787        let prior = std::env::var("DJOGI_ENV").ok();
1788        unsafe { std::env::remove_var("DJOGI_ENV") };
1789        assert!(force_allowed_in_env());
1790        unsafe { std::env::set_var("DJOGI_ENV", "development") };
1791        assert!(force_allowed_in_env());
1792        unsafe { std::env::set_var("DJOGI_ENV", "PRODUCTION") };
1793        assert!(
1794            !force_allowed_in_env(),
1795            "case-insensitive production must refuse"
1796        );
1797        unsafe { std::env::set_var("DJOGI_ENV", "production") };
1798        assert!(!force_allowed_in_env());
1799        // Restore.
1800        match prior {
1801            Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
1802            None => unsafe { std::env::remove_var("DJOGI_ENV") },
1803        }
1804    }
1805
1806    // ── PlanFileError → LiveCmdError mapping ─────────────────────────
1807
1808    #[test]
1809    fn plan_file_checksum_mismatch_maps_to_drift() {
1810        let pfe = PlanFileError::ChecksumMismatch {
1811            path: PathBuf::from("/tmp/x.json"),
1812            expected: "V1:0".to_string(),
1813            actual: "V1:1".to_string(),
1814        };
1815        let err: LiveCmdError = pfe.into();
1816        assert_eq!(err.exit_code(), 4, "checksum mismatch must exit 4");
1817    }
1818
1819    #[test]
1820    fn plan_file_io_maps_to_runtime() {
1821        let pfe = PlanFileError::NotFound(PathBuf::from("/missing"));
1822        let err: LiveCmdError = pfe.into();
1823        assert_eq!(err.exit_code(), 1);
1824    }
1825}