Skip to main content

djogi_cli/
live.rs

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