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
1185    struct EnvGuard {
1186        _lock: std::sync::MutexGuard<'static, ()>,
1187        prior_hostname: Option<String>,
1188        prior_djogi_env: Option<String>,
1189    }
1190
1191    impl EnvGuard {
1192        fn new() -> Self {
1193            Self {
1194                _lock: crate::test_env_lock(),
1195                prior_hostname: std::env::var("HOSTNAME").ok(),
1196                prior_djogi_env: std::env::var("DJOGI_ENV").ok(),
1197            }
1198        }
1199    }
1200
1201    impl Drop for EnvGuard {
1202        fn drop(&mut self) {
1203            match &self.prior_hostname {
1204                Some(value) => unsafe { std::env::set_var("HOSTNAME", value) },
1205                None => unsafe { std::env::remove_var("HOSTNAME") },
1206            }
1207            match &self.prior_djogi_env {
1208                Some(value) => unsafe { std::env::set_var("DJOGI_ENV", value) },
1209                None => unsafe { std::env::remove_var("DJOGI_ENV") },
1210            }
1211        }
1212    }
1213    use clap::Parser;
1214
1215    /// Test driver — clap derive macros emit a parser per top-level
1216    /// command; the live subcommand sits under a fixture with one
1217    /// nested variant so the unit tests can exercise the arg parsing
1218    /// without depending on the full top-level CLI shape.
1219    #[derive(Parser, Debug)]
1220    struct LiveCli {
1221        #[command(subcommand)]
1222        cmd: LiveCmd,
1223    }
1224
1225    fn parse(argv: &[&str]) -> Result<LiveCli, clap::Error> {
1226        let mut full = vec!["live"];
1227        full.extend_from_slice(argv);
1228        LiveCli::try_parse_from(full)
1229    }
1230
1231    // ── parsing ──────────────────────────────────────────────────────
1232
1233    #[test]
1234    fn live_plan_parses_without_args() {
1235        let parsed = parse(&["plan"]).expect("plan parses");
1236        match parsed.cmd {
1237            LiveCmd::Plan { version, .. } => assert!(version.is_none()),
1238            other => panic!("expected Plan, got {other:?}"),
1239        }
1240    }
1241
1242    #[test]
1243    fn live_plan_accepts_optional_version() {
1244        let parsed = parse(&["plan", "V20260428000000__demo"]).expect("plan with version parses");
1245        match parsed.cmd {
1246            LiveCmd::Plan { version, .. } => {
1247                assert_eq!(version.as_deref(), Some("V20260428000000__demo"));
1248            }
1249            other => panic!("expected Plan, got {other:?}"),
1250        }
1251    }
1252
1253    #[test]
1254    fn live_show_requires_plan_id() {
1255        let err = parse(&["show"]).expect_err("show without plan_id must fail");
1256        let msg = err.to_string();
1257        assert!(
1258            msg.to_lowercase().contains("plan_id") || msg.to_lowercase().contains("required"),
1259            "expected plan_id requirement in clap message: {msg}",
1260        );
1261    }
1262
1263    #[test]
1264    fn live_show_parses_plan_id() {
1265        let parsed = parse(&["show", "12345"]).expect("show with plan_id parses");
1266        match parsed.cmd {
1267            LiveCmd::Show { plan_id, .. } => assert_eq!(plan_id, "12345"),
1268            other => panic!("expected Show, got {other:?}"),
1269        }
1270    }
1271
1272    #[test]
1273    fn live_run_accepts_allow_destructive_with_justify() {
1274        let parsed = parse(&[
1275            "run",
1276            "12345",
1277            "--allow-destructive",
1278            "--justify",
1279            "rotate keys for incident IR-7",
1280        ])
1281        .expect("run with destructive + justify parses");
1282        match parsed.cmd {
1283            LiveCmd::Run {
1284                plan_id,
1285                allow_destructive,
1286                justify,
1287                allow_raw_dangerous,
1288                ..
1289            } => {
1290                assert_eq!(plan_id, "12345");
1291                assert!(allow_destructive);
1292                assert_eq!(justify.as_deref(), Some("rotate keys for incident IR-7"));
1293                assert!(!allow_raw_dangerous);
1294            }
1295            other => panic!("expected Run, got {other:?}"),
1296        }
1297    }
1298
1299    #[test]
1300    fn live_run_accepts_allow_raw_dangerous_with_justify() {
1301        let parsed = parse(&[
1302            "run",
1303            "67890",
1304            "--allow-raw-dangerous",
1305            "--justify",
1306            "operator runbook RB-12",
1307        ])
1308        .expect("run with allow-raw-dangerous parses");
1309        match parsed.cmd {
1310            LiveCmd::Run {
1311                allow_raw_dangerous,
1312                justify,
1313                ..
1314            } => {
1315                assert!(allow_raw_dangerous);
1316                assert_eq!(justify.as_deref(), Some("operator runbook RB-12"));
1317            }
1318            other => panic!("expected Run, got {other:?}"),
1319        }
1320    }
1321
1322    #[test]
1323    fn live_resume_parses() {
1324        let parsed = parse(&["resume", "55"]).expect("resume parses");
1325        assert!(matches!(parsed.cmd, LiveCmd::Resume { .. }));
1326    }
1327
1328    #[test]
1329    fn live_finalize_accepts_justify() {
1330        let parsed = parse(&["finalize", "55", "--justify", "drop legacy"])
1331            .expect("finalize with justify parses");
1332        match parsed.cmd {
1333            LiveCmd::Finalize {
1334                justify, plan_id, ..
1335            } => {
1336                assert_eq!(plan_id, "55");
1337                assert_eq!(justify.as_deref(), Some("drop legacy"));
1338            }
1339            other => panic!("expected Finalize, got {other:?}"),
1340        }
1341    }
1342
1343    #[test]
1344    fn live_abandon_accepts_force() {
1345        let parsed = parse(&["abandon", "12345", "--force"]).expect("abandon with force parses");
1346        match parsed.cmd {
1347            LiveCmd::Abandon { force, plan_id, .. } => {
1348                assert!(force);
1349                assert_eq!(plan_id, "12345");
1350            }
1351            other => panic!("expected Abandon, got {other:?}"),
1352        }
1353    }
1354
1355    #[test]
1356    fn live_daemon_parses_with_default_intervals() {
1357        let parsed = parse(&["daemon"]).expect("daemon parses with no args");
1358        match parsed.cmd {
1359            LiveCmd::Daemon {
1360                poll_interval,
1361                claim_stale_after,
1362                allow_non_localhost,
1363                ..
1364            } => {
1365                assert_eq!(
1366                    poll_interval,
1367                    std::time::Duration::from_secs(30),
1368                    "default poll interval is 30s",
1369                );
1370                assert_eq!(
1371                    claim_stale_after,
1372                    std::time::Duration::from_secs(600),
1373                    "default stale threshold is 10 minutes",
1374                );
1375                assert!(
1376                    !allow_non_localhost,
1377                    "default refuses non-localhost connections",
1378                );
1379            }
1380            other => panic!("expected Daemon, got {other:?}"),
1381        }
1382    }
1383
1384    #[test]
1385    fn live_daemon_accepts_custom_intervals() {
1386        let parsed = parse(&[
1387            "daemon",
1388            "--poll-interval",
1389            "5s",
1390            "--claim-stale-after",
1391            "1m",
1392            "--allow-non-localhost",
1393        ])
1394        .expect("daemon with overrides parses");
1395        match parsed.cmd {
1396            LiveCmd::Daemon {
1397                poll_interval,
1398                claim_stale_after,
1399                allow_non_localhost,
1400                ..
1401            } => {
1402                assert_eq!(poll_interval, std::time::Duration::from_secs(5));
1403                assert_eq!(claim_stale_after, std::time::Duration::from_secs(60));
1404                assert!(allow_non_localhost);
1405            }
1406            other => panic!("expected Daemon, got {other:?}"),
1407        }
1408    }
1409
1410    #[test]
1411    fn live_daemon_accepts_humantime_minutes_and_hours() {
1412        // The humantime parser accepts `m` / `min` / `h` / `d`. Pin the
1413        // common operator-runbook forms.
1414        let parsed = parse(&[
1415            "daemon",
1416            "--poll-interval",
1417            "10min",
1418            "--claim-stale-after",
1419            "2h",
1420        ])
1421        .expect("daemon with humantime durations parses");
1422        match parsed.cmd {
1423            LiveCmd::Daemon {
1424                poll_interval,
1425                claim_stale_after,
1426                ..
1427            } => {
1428                assert_eq!(poll_interval, std::time::Duration::from_secs(600));
1429                assert_eq!(claim_stale_after, std::time::Duration::from_secs(7200));
1430            }
1431            other => panic!("expected Daemon, got {other:?}"),
1432        }
1433    }
1434
1435    #[test]
1436    fn live_daemon_accepts_workspace_override() {
1437        let parsed = parse(&["daemon", "--workspace", "/tmp/example"])
1438            .expect("daemon with --workspace parses");
1439        match parsed.cmd {
1440            LiveCmd::Daemon { workspace, .. } => {
1441                assert_eq!(
1442                    workspace.as_deref(),
1443                    Some(std::path::Path::new("/tmp/example")),
1444                );
1445            }
1446            other => panic!("expected Daemon, got {other:?}"),
1447        }
1448    }
1449
1450    // ── parse_humantime_duration (Finding 7) ──────────────────────────
1451
1452    #[test]
1453    fn parse_humantime_duration_accepts_seconds() {
1454        assert_eq!(
1455            parse_humantime_duration("30s").unwrap(),
1456            std::time::Duration::from_secs(30),
1457        );
1458        assert_eq!(
1459            parse_humantime_duration("0s").unwrap(),
1460            std::time::Duration::from_secs(0),
1461        );
1462    }
1463
1464    #[test]
1465    fn parse_humantime_duration_accepts_minutes_and_hours_and_days() {
1466        assert_eq!(
1467            parse_humantime_duration("5m").unwrap(),
1468            std::time::Duration::from_secs(300),
1469        );
1470        assert_eq!(
1471            parse_humantime_duration("10min").unwrap(),
1472            std::time::Duration::from_secs(600),
1473        );
1474        assert_eq!(
1475            parse_humantime_duration("2h").unwrap(),
1476            std::time::Duration::from_secs(7_200),
1477        );
1478        assert_eq!(
1479            parse_humantime_duration("1d").unwrap(),
1480            std::time::Duration::from_secs(86_400),
1481        );
1482    }
1483
1484    #[test]
1485    fn parse_humantime_duration_rejects_empty_input() {
1486        let err = parse_humantime_duration("").unwrap_err();
1487        assert!(err.contains("empty"), "{err}");
1488        let err = parse_humantime_duration("   ").unwrap_err();
1489        assert!(err.contains("empty"), "{err}");
1490    }
1491
1492    #[test]
1493    fn parse_humantime_duration_rejects_missing_digits() {
1494        let err = parse_humantime_duration("s").unwrap_err();
1495        assert!(err.contains("ASCII digits"), "{err}");
1496        let err = parse_humantime_duration("min").unwrap_err();
1497        assert!(err.contains("ASCII digits"), "{err}");
1498    }
1499
1500    #[test]
1501    fn parse_humantime_duration_rejects_unknown_unit() {
1502        let err = parse_humantime_duration("30y").unwrap_err();
1503        assert!(err.contains("unknown unit"), "{err}");
1504        // Mixed-unit forms are rejected (V1 is single-unit only).
1505        let err = parse_humantime_duration("1h30m").unwrap_err();
1506        assert!(err.contains("unknown unit"), "{err}");
1507    }
1508
1509    #[test]
1510    fn parse_humantime_duration_rejects_trailing_junk() {
1511        // Garbage after the unit fails the unit-match arm.
1512        let err = parse_humantime_duration("30sX").unwrap_err();
1513        assert!(
1514            err.contains("unknown unit") || err.contains("expected"),
1515            "{err}"
1516        );
1517        // Whitespace between digits and unit is also rejected — the
1518        // unit slice begins right after the trailing digit.
1519        let err = parse_humantime_duration("30 s").unwrap_err();
1520        assert!(
1521            err.contains("unknown unit") || err.contains("expected"),
1522            "{err}"
1523        );
1524    }
1525
1526    #[test]
1527    fn parse_humantime_duration_handles_outer_whitespace() {
1528        // Outer whitespace is trimmed so `--poll-interval " 30s "`
1529        // (e.g. from a quoted shell var) round-trips.
1530        assert_eq!(
1531            parse_humantime_duration("  30s  ").unwrap(),
1532            std::time::Duration::from_secs(30),
1533        );
1534    }
1535
1536    #[test]
1537    fn hostname_for_claim_falls_back_to_unknown() {
1538        let _env_guard = EnvGuard::new();
1539        unsafe { std::env::remove_var("HOSTNAME") };
1540        assert_eq!(hostname_for_claim(), "unknown");
1541        unsafe { std::env::set_var("HOSTNAME", "ci-runner-7") };
1542        assert_eq!(hostname_for_claim(), "ci-runner-7");
1543    }
1544
1545    #[test]
1546    fn env_guard_restores_prior_values() {
1547        let env_guard = EnvGuard::new();
1548        let expected_hostname = env_guard.prior_hostname.clone();
1549        let expected_djogi_env = env_guard.prior_djogi_env.clone();
1550        let next_hostname = if expected_hostname.as_deref() == Some("ci-runner-7") {
1551            "ci-runner-8"
1552        } else {
1553            "ci-runner-7"
1554        };
1555        let next_djogi_env = if expected_djogi_env.as_deref() == Some("staging") {
1556            "production"
1557        } else {
1558            "staging"
1559        };
1560        unsafe { std::env::set_var("HOSTNAME", next_hostname) };
1561        unsafe { std::env::set_var("DJOGI_ENV", next_djogi_env) };
1562        drop(env_guard);
1563        assert_eq!(std::env::var("HOSTNAME").ok(), expected_hostname);
1564        assert_eq!(std::env::var("DJOGI_ENV").ok(), expected_djogi_env);
1565    }
1566
1567    // ── helpers ──────────────────────────────────────────────────────
1568
1569    #[test]
1570    fn justify_is_empty_handles_none_and_blank() {
1571        assert!(justify_is_empty(None));
1572        assert!(justify_is_empty(Some("")));
1573        assert!(justify_is_empty(Some("   ")));
1574        assert!(!justify_is_empty(Some("real reason")));
1575    }
1576
1577    #[test]
1578    fn require_justify_for_destructive_refuses_without_reason() {
1579        let err = require_justify_for_destructive(true, None).unwrap_err();
1580        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1581        let err = require_justify_for_destructive(true, Some("   ")).unwrap_err();
1582        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1583        // Without `--allow-destructive`, missing `--justify` is fine.
1584        require_justify_for_destructive(false, None).unwrap();
1585        // With both set, accepts.
1586        require_justify_for_destructive(true, Some("rotate keys")).unwrap();
1587    }
1588
1589    #[test]
1590    fn require_justify_for_dangerous_refuses_without_reason() {
1591        let err = require_justify_for_dangerous(true, None).unwrap_err();
1592        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1593        require_justify_for_dangerous(false, None).unwrap();
1594        require_justify_for_dangerous(true, Some("runbook")).unwrap();
1595    }
1596
1597    #[test]
1598    fn require_destructive_gate_passes_for_non_destructive_plan() {
1599        use djogi::live_migrate::{
1600            LivePlan, PlanClassification, PlanHeader, Step, StepKind, StepParameters,
1601        };
1602        let plan = LivePlan {
1603            header: PlanHeader {
1604                plan_id: HeerId::ZERO,
1605                slug: "demo".to_string(),
1606                classification: PlanClassification::ExpandContract,
1607                originating_migration: "V20260428000000__demo".to_string(),
1608                target_database: "main".to_string(),
1609                app_label: "".to_string(),
1610            },
1611            steps: vec![Step {
1612                kind: StepKind::ExpandSchema,
1613                ordinal: 0,
1614                parameters: StepParameters::ExpandSchema {
1615                    sql_segments: vec!["ALTER TABLE foo ADD COLUMN bar INT".to_string()],
1616                },
1617            }],
1618        };
1619        // Without --allow-destructive: passes (no destructive step).
1620        require_destructive_gate_for_plan(&plan, false, None).unwrap();
1621    }
1622
1623    #[test]
1624    fn require_destructive_gate_refuses_destructive_plan_without_flag() {
1625        use djogi::live_migrate::{
1626            LivePlan, PlanClassification, PlanHeader, Step, StepKind, StepParameters,
1627        };
1628        let plan = LivePlan {
1629            header: PlanHeader {
1630                plan_id: HeerId::ZERO,
1631                slug: "demo".to_string(),
1632                classification: PlanClassification::ExpandContract,
1633                originating_migration: "V20260428000000__demo".to_string(),
1634                target_database: "main".to_string(),
1635                app_label: "".to_string(),
1636            },
1637            steps: vec![Step {
1638                kind: StepKind::CleanupLegacyState,
1639                ordinal: 0,
1640                parameters: StepParameters::CleanupLegacyState {
1641                    sql_segments: vec!["ALTER TABLE foo DROP COLUMN baz".to_string()],
1642                },
1643            }],
1644        };
1645        // No `--allow-destructive` → refuse.
1646        let err = require_destructive_gate_for_plan(&plan, false, None).unwrap_err();
1647        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1648        // `--allow-destructive` without `--justify` → refuse.
1649        let err = require_destructive_gate_for_plan(&plan, true, None).unwrap_err();
1650        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1651        let err = require_destructive_gate_for_plan(&plan, true, Some("   ")).unwrap_err();
1652        assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1653        // Both set → accepts.
1654        require_destructive_gate_for_plan(&plan, true, Some("ops runbook RB-19")).unwrap();
1655    }
1656
1657    #[test]
1658    fn parse_plan_id_accepts_decimal() {
1659        let id = parse_plan_id("12345").unwrap();
1660        assert_eq!(id.as_i64(), 12345);
1661    }
1662
1663    #[test]
1664    fn parse_plan_id_rejects_garbage() {
1665        let err = parse_plan_id("not-a-number").unwrap_err();
1666        assert!(matches!(err, LiveCmdError::MalformedPlanId(_)));
1667    }
1668
1669    // ── exit-code mapping ─────────────────────────────────────────────
1670
1671    #[test]
1672    fn exit_code_runtime_maps_to_one() {
1673        assert_eq!(LiveCmdError::Runtime("x".to_string()).exit_code(), 1);
1674        assert_eq!(LiveCmdError::ArgRefused("x".to_string()).exit_code(), 1);
1675        assert_eq!(
1676            LiveCmdError::MalformedPlanId("x".to_string()).exit_code(),
1677            1
1678        );
1679    }
1680
1681    #[test]
1682    fn exit_code_classification_refused_maps_to_two() {
1683        assert_eq!(
1684            LiveCmdError::ClassificationRefused("offline only".to_string()).exit_code(),
1685            2,
1686        );
1687    }
1688    #[test]
1689    fn exit_code_checksum_drift_maps_to_four() {
1690        assert_eq!(
1691            LiveCmdError::ChecksumDrift("mismatch".to_string()).exit_code(),
1692            4,
1693        );
1694    }
1695
1696    #[test]
1697    fn exit_code_state_conflict_maps_to_five() {
1698        assert_eq!(
1699            LiveCmdError::StateConflict("complete".to_string()).exit_code(),
1700            5,
1701        );
1702    }
1703
1704    // ── status-gate helpers ──────────────────────────────────────────
1705
1706    #[test]
1707    fn assert_run_status_accepts_pending_running() {
1708        assert!(assert_run_status_allows_progress(PlanStatus::Pending).is_ok());
1709        assert!(assert_run_status_allows_progress(PlanStatus::Running).is_ok());
1710    }
1711
1712    #[test]
1713    fn assert_run_status_refuses_paused_pointing_to_resume() {
1714        // `Paused` is the operator's explicit checkpoint state — `live run`
1715        // does not auto-advance through it; the operator must invoke
1716        // `live resume` to re-enter the run loop. This pins the gate.
1717        let err = assert_run_status_allows_progress(PlanStatus::Paused)
1718            .expect_err("paused must be a state conflict for `live run`");
1719        match err {
1720            LiveCmdError::StateConflict(msg) => {
1721                assert!(msg.contains("paused"), "{msg}");
1722                assert!(msg.contains("live resume"), "{msg}");
1723            }
1724            other => panic!("expected StateConflict, got {other:?}"),
1725        }
1726    }
1727
1728    #[test]
1729    fn assert_run_status_refuses_terminal_and_gates() {
1730        for status in [
1731            PlanStatus::Validating,
1732            PlanStatus::Cutover,
1733            PlanStatus::Finalizing,
1734            PlanStatus::Complete,
1735            PlanStatus::Abandoned,
1736            PlanStatus::Failed,
1737        ] {
1738            let err = assert_run_status_allows_progress(status)
1739                .expect_err("non-progressable status must refuse");
1740            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1741        }
1742    }
1743
1744    #[test]
1745    fn assert_resume_status_distinguishes_pending_from_terminal() {
1746        // Pending is a state conflict for `resume` (you'd use `run`).
1747        let err = assert_resume_status_allows_progress(PlanStatus::Pending)
1748            .expect_err("pending must refuse");
1749        match err {
1750            LiveCmdError::StateConflict(msg) => assert!(msg.contains("pending")),
1751            other => panic!("expected StateConflict, got {other:?}"),
1752        }
1753        // Running and Paused accept.
1754        assert!(assert_resume_status_allows_progress(PlanStatus::Running).is_ok());
1755        assert!(assert_resume_status_allows_progress(PlanStatus::Paused).is_ok());
1756        // Everything else refuses.
1757        for status in [
1758            PlanStatus::Validating,
1759            PlanStatus::Cutover,
1760            PlanStatus::Finalizing,
1761            PlanStatus::Complete,
1762            PlanStatus::Abandoned,
1763            PlanStatus::Failed,
1764        ] {
1765            let err = assert_resume_status_allows_progress(status)
1766                .expect_err("non-resumable status must refuse");
1767            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1768        }
1769    }
1770
1771    #[test]
1772    fn assert_finalize_status_accepts_only_finalizing() {
1773        assert!(assert_finalize_status(PlanStatus::Finalizing).is_ok());
1774        for status in [
1775            PlanStatus::Pending,
1776            PlanStatus::Running,
1777            PlanStatus::Paused,
1778            PlanStatus::Validating,
1779            PlanStatus::Cutover,
1780            PlanStatus::Complete,
1781            PlanStatus::Abandoned,
1782            PlanStatus::Failed,
1783        ] {
1784            let err = assert_finalize_status(status).expect_err("non-finalizing must refuse");
1785            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1786        }
1787    }
1788
1789    #[test]
1790    fn assert_abandon_status_refuses_every_terminal_state() {
1791        // All three terminal states refuse: Complete, Abandoned, Failed.
1792        for status in [
1793            PlanStatus::Complete,
1794            PlanStatus::Abandoned,
1795            PlanStatus::Failed,
1796        ] {
1797            let err = assert_abandon_status(status)
1798                .expect_err("terminal status must be a state conflict for abandon");
1799            assert!(matches!(err, LiveCmdError::StateConflict(_)));
1800        }
1801        // Every non-terminal status is a valid abandonment target.
1802        for status in [
1803            PlanStatus::Pending,
1804            PlanStatus::Running,
1805            PlanStatus::Paused,
1806            PlanStatus::Validating,
1807            PlanStatus::Cutover,
1808            PlanStatus::Finalizing,
1809        ] {
1810            assert!(assert_abandon_status(status).is_ok(), "{status:?} accepts");
1811        }
1812    }
1813
1814    #[test]
1815    fn assert_abandon_status_failed_message_points_to_fresh_plan() {
1816        // The Failed-refusal message is the operator's signpost — pin
1817        // the wording so refactors notice if the audit trail story drifts.
1818        let err = assert_abandon_status(PlanStatus::Failed).expect_err("failed must refuse");
1819        match err {
1820            LiveCmdError::StateConflict(msg) => {
1821                assert!(msg.contains("failed"), "{msg}");
1822                assert!(msg.contains("fresh plan") || msg.contains("audit"), "{msg}",);
1823            }
1824            other => panic!("expected StateConflict, got {other:?}"),
1825        }
1826    }
1827
1828    // ── env gate ─────────────────────────────────────────────────────
1829
1830    #[test]
1831    fn force_allowed_when_djogi_env_unset() {
1832        let _env_guard = EnvGuard::new();
1833        unsafe { std::env::remove_var("DJOGI_ENV") };
1834        assert!(force_allowed_in_env());
1835        unsafe { std::env::set_var("DJOGI_ENV", "development") };
1836        assert!(force_allowed_in_env());
1837        unsafe { std::env::set_var("DJOGI_ENV", "PRODUCTION") };
1838        assert!(
1839            !force_allowed_in_env(),
1840            "case-insensitive production must refuse"
1841        );
1842        unsafe { std::env::set_var("DJOGI_ENV", "production") };
1843        assert!(!force_allowed_in_env());
1844    }
1845
1846    // ── PlanFileError → LiveCmdError mapping ─────────────────────────
1847
1848    #[test]
1849    fn plan_file_checksum_mismatch_maps_to_drift() {
1850        let pfe = PlanFileError::ChecksumMismatch {
1851            path: PathBuf::from("/tmp/x.json"),
1852            expected: "V1:0".to_string(),
1853            actual: "V1:1".to_string(),
1854        };
1855        let err: LiveCmdError = pfe.into();
1856        assert_eq!(err.exit_code(), 4, "checksum mismatch must exit 4");
1857    }
1858
1859    #[test]
1860    fn plan_file_io_maps_to_runtime() {
1861        let pfe = PlanFileError::NotFound(PathBuf::from("/missing"));
1862        let err: LiveCmdError = pfe.into();
1863        assert_eq!(err.exit_code(), 1);
1864    }
1865}