1use 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#[derive(Debug, Clone, Subcommand)]
63pub enum LiveCmd {
64 Plan {
70 version: Option<String>,
72 #[arg(long)]
75 workspace: Option<PathBuf>,
76 },
77 Show {
80 plan_id: String,
83 #[arg(long)]
85 workspace: Option<PathBuf>,
86 },
87 Run {
91 plan_id: String,
92 #[arg(long, default_value_t = false)]
97 allow_destructive: bool,
98 #[arg(long)]
102 justify: Option<String>,
103 #[arg(long, default_value_t = false)]
107 allow_raw_dangerous: bool,
108 #[arg(long)]
110 workspace: Option<PathBuf>,
111 },
112 Resume {
115 plan_id: String,
116 #[arg(long, default_value_t = false)]
120 allow_destructive: bool,
121 #[arg(long)]
124 justify: Option<String>,
125 #[arg(long)]
127 workspace: Option<PathBuf>,
128 },
129 Finalize {
132 plan_id: String,
133 #[arg(long)]
136 justify: Option<String>,
137 #[arg(long)]
139 workspace: Option<PathBuf>,
140 },
141 Abandon {
146 plan_id: String,
147 #[arg(long, default_value_t = false)]
150 force: bool,
151 #[arg(long)]
153 workspace: Option<PathBuf>,
154 },
155 Daemon {
165 #[arg(long, default_value = "30s", value_parser = parse_humantime_duration)]
168 poll_interval: std::time::Duration,
169 #[arg(long, default_value = "10m", value_parser = parse_humantime_duration)]
173 claim_stale_after: std::time::Duration,
174 #[arg(long, default_value_t = false)]
180 allow_non_localhost: bool,
181 #[arg(long)]
183 workspace: Option<PathBuf>,
184 },
185}
186
187#[derive(Debug, thiserror::Error)]
195#[non_exhaustive]
196pub enum LiveCmdError {
197 #[error("{0}")]
199 Runtime(String),
200
201 #[error("classification refused: {0}")]
204 ClassificationRefused(String),
205
206 #[error("plan file checksum drift: {0}")]
208 ChecksumDrift(String),
209
210 #[error("plan state conflict: {0}")]
213 StateConflict(String),
214
215 #[error("argument refused: {0}")]
220 ArgRefused(String),
221
222 #[error("malformed plan_id: {0}")]
224 MalformedPlanId(String),
225
226 #[error("plan {0} not found in djogi_live_plans")]
228 PlanNotFound(HeerId),
229}
230
231impl LiveCmdError {
232 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
257pub 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
284async 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
340fn parse_plan_id(raw: &str) -> Result<HeerId, LiveCmdError> {
347 HeerId::from_str(raw).map_err(|e| LiveCmdError::MalformedPlanId(format!("`{raw}`: {e}")))
348}
349
350fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
354 workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
355}
356
357fn 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
372fn 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
388fn justify_is_empty(justify: Option<&str>) -> bool {
392 justify.map(|s| s.trim().is_empty()).unwrap_or(true)
393}
394
395fn 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
431fn 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
441fn 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 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
501fn 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
517async 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
530fn 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
536async fn fetch_row(ctx: &mut DjogiContext, plan_id: HeerId) -> Result<LivePlanRow, LiveCmdError> {
540 use djogi::live_migrate::state;
546 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
574async 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 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
624pub fn refuse_offline_only(reason: impl Into<String>) -> LiveCmdError {
633 LiveCmdError::ClassificationRefused(reason.into())
634}
635
636async 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
705async 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 require_destructive_gate_for_plan(&plan, allow_destructive, justify)?;
739
740 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
791async 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 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
858fn 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
891fn 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
921async 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 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 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
999fn 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
1011async 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
1067fn 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
1102async 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
1151fn hostname_for_claim() -> String {
1155 std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".to_string())
1156}
1157
1158fn interactive_confirm_abandon(plan_id: HeerId) -> std::io::Result<bool> {
1161 use std::io::{BufRead, Write};
1162 let stderr = std::io::stderr();
1163 let mut handle = stderr.lock();
1164 writeln!(
1165 handle,
1166 "WARNING: live abandon will mark plan {plan_id} as `abandoned`. Schema state \
1167 remains at the last completed step; the plan file stays on disk. Resume is \
1168 refused after abandonment — generate a fresh plan instead."
1169 )?;
1170 write!(handle, "Type `yes` to confirm, anything else to abort: ")?;
1171 handle.flush()?;
1172 let stdin = std::io::stdin();
1173 let mut line = String::new();
1174 stdin.lock().read_line(&mut line)?;
1175 Ok(matches!(
1176 line.trim().to_ascii_lowercase().as_str(),
1177 "y" | "yes"
1178 ))
1179}
1180
1181#[cfg(test)]
1182mod tests {
1183 use super::*;
1184 use clap::Parser;
1185
1186 #[derive(Parser, Debug)]
1191 struct LiveCli {
1192 #[command(subcommand)]
1193 cmd: LiveCmd,
1194 }
1195
1196 fn parse(argv: &[&str]) -> Result<LiveCli, clap::Error> {
1197 let mut full = vec!["live"];
1198 full.extend_from_slice(argv);
1199 LiveCli::try_parse_from(full)
1200 }
1201
1202 #[test]
1205 fn live_plan_parses_without_args() {
1206 let parsed = parse(&["plan"]).expect("plan parses");
1207 match parsed.cmd {
1208 LiveCmd::Plan { version, .. } => assert!(version.is_none()),
1209 other => panic!("expected Plan, got {other:?}"),
1210 }
1211 }
1212
1213 #[test]
1214 fn live_plan_accepts_optional_version() {
1215 let parsed = parse(&["plan", "V20260428000000__demo"]).expect("plan with version parses");
1216 match parsed.cmd {
1217 LiveCmd::Plan { version, .. } => {
1218 assert_eq!(version.as_deref(), Some("V20260428000000__demo"));
1219 }
1220 other => panic!("expected Plan, got {other:?}"),
1221 }
1222 }
1223
1224 #[test]
1225 fn live_show_requires_plan_id() {
1226 let err = parse(&["show"]).expect_err("show without plan_id must fail");
1227 let msg = err.to_string();
1228 assert!(
1229 msg.to_lowercase().contains("plan_id") || msg.to_lowercase().contains("required"),
1230 "expected plan_id requirement in clap message: {msg}",
1231 );
1232 }
1233
1234 #[test]
1235 fn live_show_parses_plan_id() {
1236 let parsed = parse(&["show", "12345"]).expect("show with plan_id parses");
1237 match parsed.cmd {
1238 LiveCmd::Show { plan_id, .. } => assert_eq!(plan_id, "12345"),
1239 other => panic!("expected Show, got {other:?}"),
1240 }
1241 }
1242
1243 #[test]
1244 fn live_run_accepts_allow_destructive_with_justify() {
1245 let parsed = parse(&[
1246 "run",
1247 "12345",
1248 "--allow-destructive",
1249 "--justify",
1250 "rotate keys for incident IR-7",
1251 ])
1252 .expect("run with destructive + justify parses");
1253 match parsed.cmd {
1254 LiveCmd::Run {
1255 plan_id,
1256 allow_destructive,
1257 justify,
1258 allow_raw_dangerous,
1259 ..
1260 } => {
1261 assert_eq!(plan_id, "12345");
1262 assert!(allow_destructive);
1263 assert_eq!(justify.as_deref(), Some("rotate keys for incident IR-7"));
1264 assert!(!allow_raw_dangerous);
1265 }
1266 other => panic!("expected Run, got {other:?}"),
1267 }
1268 }
1269
1270 #[test]
1271 fn live_run_accepts_allow_raw_dangerous_with_justify() {
1272 let parsed = parse(&[
1273 "run",
1274 "67890",
1275 "--allow-raw-dangerous",
1276 "--justify",
1277 "operator runbook RB-12",
1278 ])
1279 .expect("run with allow-raw-dangerous parses");
1280 match parsed.cmd {
1281 LiveCmd::Run {
1282 allow_raw_dangerous,
1283 justify,
1284 ..
1285 } => {
1286 assert!(allow_raw_dangerous);
1287 assert_eq!(justify.as_deref(), Some("operator runbook RB-12"));
1288 }
1289 other => panic!("expected Run, got {other:?}"),
1290 }
1291 }
1292
1293 #[test]
1294 fn live_resume_parses() {
1295 let parsed = parse(&["resume", "55"]).expect("resume parses");
1296 assert!(matches!(parsed.cmd, LiveCmd::Resume { .. }));
1297 }
1298
1299 #[test]
1300 fn live_finalize_accepts_justify() {
1301 let parsed = parse(&["finalize", "55", "--justify", "drop legacy"])
1302 .expect("finalize with justify parses");
1303 match parsed.cmd {
1304 LiveCmd::Finalize {
1305 justify, plan_id, ..
1306 } => {
1307 assert_eq!(plan_id, "55");
1308 assert_eq!(justify.as_deref(), Some("drop legacy"));
1309 }
1310 other => panic!("expected Finalize, got {other:?}"),
1311 }
1312 }
1313
1314 #[test]
1315 fn live_abandon_accepts_force() {
1316 let parsed = parse(&["abandon", "12345", "--force"]).expect("abandon with force parses");
1317 match parsed.cmd {
1318 LiveCmd::Abandon { force, plan_id, .. } => {
1319 assert!(force);
1320 assert_eq!(plan_id, "12345");
1321 }
1322 other => panic!("expected Abandon, got {other:?}"),
1323 }
1324 }
1325
1326 #[test]
1327 fn live_daemon_parses_with_default_intervals() {
1328 let parsed = parse(&["daemon"]).expect("daemon parses with no args");
1329 match parsed.cmd {
1330 LiveCmd::Daemon {
1331 poll_interval,
1332 claim_stale_after,
1333 allow_non_localhost,
1334 ..
1335 } => {
1336 assert_eq!(
1337 poll_interval,
1338 std::time::Duration::from_secs(30),
1339 "default poll interval is 30s",
1340 );
1341 assert_eq!(
1342 claim_stale_after,
1343 std::time::Duration::from_secs(600),
1344 "default stale threshold is 10 minutes",
1345 );
1346 assert!(
1347 !allow_non_localhost,
1348 "default refuses non-localhost connections",
1349 );
1350 }
1351 other => panic!("expected Daemon, got {other:?}"),
1352 }
1353 }
1354
1355 #[test]
1356 fn live_daemon_accepts_custom_intervals() {
1357 let parsed = parse(&[
1358 "daemon",
1359 "--poll-interval",
1360 "5s",
1361 "--claim-stale-after",
1362 "1m",
1363 "--allow-non-localhost",
1364 ])
1365 .expect("daemon with overrides parses");
1366 match parsed.cmd {
1367 LiveCmd::Daemon {
1368 poll_interval,
1369 claim_stale_after,
1370 allow_non_localhost,
1371 ..
1372 } => {
1373 assert_eq!(poll_interval, std::time::Duration::from_secs(5));
1374 assert_eq!(claim_stale_after, std::time::Duration::from_secs(60));
1375 assert!(allow_non_localhost);
1376 }
1377 other => panic!("expected Daemon, got {other:?}"),
1378 }
1379 }
1380
1381 #[test]
1382 fn live_daemon_accepts_humantime_minutes_and_hours() {
1383 let parsed = parse(&[
1386 "daemon",
1387 "--poll-interval",
1388 "10min",
1389 "--claim-stale-after",
1390 "2h",
1391 ])
1392 .expect("daemon with humantime durations parses");
1393 match parsed.cmd {
1394 LiveCmd::Daemon {
1395 poll_interval,
1396 claim_stale_after,
1397 ..
1398 } => {
1399 assert_eq!(poll_interval, std::time::Duration::from_secs(600));
1400 assert_eq!(claim_stale_after, std::time::Duration::from_secs(7200));
1401 }
1402 other => panic!("expected Daemon, got {other:?}"),
1403 }
1404 }
1405
1406 #[test]
1407 fn live_daemon_accepts_workspace_override() {
1408 let parsed = parse(&["daemon", "--workspace", "/tmp/example"])
1409 .expect("daemon with --workspace parses");
1410 match parsed.cmd {
1411 LiveCmd::Daemon { workspace, .. } => {
1412 assert_eq!(
1413 workspace.as_deref(),
1414 Some(std::path::Path::new("/tmp/example")),
1415 );
1416 }
1417 other => panic!("expected Daemon, got {other:?}"),
1418 }
1419 }
1420
1421 #[test]
1424 fn parse_humantime_duration_accepts_seconds() {
1425 assert_eq!(
1426 parse_humantime_duration("30s").unwrap(),
1427 std::time::Duration::from_secs(30),
1428 );
1429 assert_eq!(
1430 parse_humantime_duration("0s").unwrap(),
1431 std::time::Duration::from_secs(0),
1432 );
1433 }
1434
1435 #[test]
1436 fn parse_humantime_duration_accepts_minutes_and_hours_and_days() {
1437 assert_eq!(
1438 parse_humantime_duration("5m").unwrap(),
1439 std::time::Duration::from_secs(300),
1440 );
1441 assert_eq!(
1442 parse_humantime_duration("10min").unwrap(),
1443 std::time::Duration::from_secs(600),
1444 );
1445 assert_eq!(
1446 parse_humantime_duration("2h").unwrap(),
1447 std::time::Duration::from_secs(7_200),
1448 );
1449 assert_eq!(
1450 parse_humantime_duration("1d").unwrap(),
1451 std::time::Duration::from_secs(86_400),
1452 );
1453 }
1454
1455 #[test]
1456 fn parse_humantime_duration_rejects_empty_input() {
1457 let err = parse_humantime_duration("").unwrap_err();
1458 assert!(err.contains("empty"), "{err}");
1459 let err = parse_humantime_duration(" ").unwrap_err();
1460 assert!(err.contains("empty"), "{err}");
1461 }
1462
1463 #[test]
1464 fn parse_humantime_duration_rejects_missing_digits() {
1465 let err = parse_humantime_duration("s").unwrap_err();
1466 assert!(err.contains("ASCII digits"), "{err}");
1467 let err = parse_humantime_duration("min").unwrap_err();
1468 assert!(err.contains("ASCII digits"), "{err}");
1469 }
1470
1471 #[test]
1472 fn parse_humantime_duration_rejects_unknown_unit() {
1473 let err = parse_humantime_duration("30y").unwrap_err();
1474 assert!(err.contains("unknown unit"), "{err}");
1475 let err = parse_humantime_duration("1h30m").unwrap_err();
1477 assert!(err.contains("unknown unit"), "{err}");
1478 }
1479
1480 #[test]
1481 fn parse_humantime_duration_rejects_trailing_junk() {
1482 let err = parse_humantime_duration("30sX").unwrap_err();
1484 assert!(
1485 err.contains("unknown unit") || err.contains("expected"),
1486 "{err}"
1487 );
1488 let err = parse_humantime_duration("30 s").unwrap_err();
1491 assert!(
1492 err.contains("unknown unit") || err.contains("expected"),
1493 "{err}"
1494 );
1495 }
1496
1497 #[test]
1498 fn parse_humantime_duration_handles_outer_whitespace() {
1499 assert_eq!(
1502 parse_humantime_duration(" 30s ").unwrap(),
1503 std::time::Duration::from_secs(30),
1504 );
1505 }
1506
1507 #[test]
1508 fn hostname_for_claim_falls_back_to_unknown() {
1509 let prior = std::env::var("HOSTNAME").ok();
1511 unsafe { std::env::remove_var("HOSTNAME") };
1512 assert_eq!(hostname_for_claim(), "unknown");
1513 unsafe { std::env::set_var("HOSTNAME", "ci-runner-7") };
1514 assert_eq!(hostname_for_claim(), "ci-runner-7");
1515 match prior {
1516 Some(v) => unsafe { std::env::set_var("HOSTNAME", v) },
1517 None => unsafe { std::env::remove_var("HOSTNAME") },
1518 }
1519 }
1520
1521 #[test]
1524 fn justify_is_empty_handles_none_and_blank() {
1525 assert!(justify_is_empty(None));
1526 assert!(justify_is_empty(Some("")));
1527 assert!(justify_is_empty(Some(" ")));
1528 assert!(!justify_is_empty(Some("real reason")));
1529 }
1530
1531 #[test]
1532 fn require_justify_for_destructive_refuses_without_reason() {
1533 let err = require_justify_for_destructive(true, None).unwrap_err();
1534 assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1535 let err = require_justify_for_destructive(true, Some(" ")).unwrap_err();
1536 assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1537 require_justify_for_destructive(false, None).unwrap();
1539 require_justify_for_destructive(true, Some("rotate keys")).unwrap();
1541 }
1542
1543 #[test]
1544 fn require_justify_for_dangerous_refuses_without_reason() {
1545 let err = require_justify_for_dangerous(true, None).unwrap_err();
1546 assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1547 require_justify_for_dangerous(false, None).unwrap();
1548 require_justify_for_dangerous(true, Some("runbook")).unwrap();
1549 }
1550
1551 #[test]
1552 fn require_destructive_gate_passes_for_non_destructive_plan() {
1553 use djogi::live_migrate::{
1554 LivePlan, PlanClassification, PlanHeader, Step, StepKind, StepParameters,
1555 };
1556 let plan = LivePlan {
1557 header: PlanHeader {
1558 plan_id: HeerId::ZERO,
1559 slug: "demo".to_string(),
1560 classification: PlanClassification::ExpandContract,
1561 originating_migration: "V20260428000000__demo".to_string(),
1562 target_database: "main".to_string(),
1563 app_label: "".to_string(),
1564 },
1565 steps: vec![Step {
1566 kind: StepKind::ExpandSchema,
1567 ordinal: 0,
1568 parameters: StepParameters::ExpandSchema {
1569 sql_segments: vec!["ALTER TABLE foo ADD COLUMN bar INT".to_string()],
1570 },
1571 }],
1572 };
1573 require_destructive_gate_for_plan(&plan, false, None).unwrap();
1575 }
1576
1577 #[test]
1578 fn require_destructive_gate_refuses_destructive_plan_without_flag() {
1579 use djogi::live_migrate::{
1580 LivePlan, PlanClassification, PlanHeader, Step, StepKind, StepParameters,
1581 };
1582 let plan = LivePlan {
1583 header: PlanHeader {
1584 plan_id: HeerId::ZERO,
1585 slug: "demo".to_string(),
1586 classification: PlanClassification::ExpandContract,
1587 originating_migration: "V20260428000000__demo".to_string(),
1588 target_database: "main".to_string(),
1589 app_label: "".to_string(),
1590 },
1591 steps: vec![Step {
1592 kind: StepKind::CleanupLegacyState,
1593 ordinal: 0,
1594 parameters: StepParameters::CleanupLegacyState {
1595 sql_segments: vec!["ALTER TABLE foo DROP COLUMN baz".to_string()],
1596 },
1597 }],
1598 };
1599 let err = require_destructive_gate_for_plan(&plan, false, None).unwrap_err();
1601 assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1602 let err = require_destructive_gate_for_plan(&plan, true, None).unwrap_err();
1604 assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1605 let err = require_destructive_gate_for_plan(&plan, true, Some(" ")).unwrap_err();
1606 assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1607 require_destructive_gate_for_plan(&plan, true, Some("ops runbook RB-19")).unwrap();
1609 }
1610
1611 #[test]
1612 fn parse_plan_id_accepts_decimal() {
1613 let id = parse_plan_id("12345").unwrap();
1614 assert_eq!(id.as_i64(), 12345);
1615 }
1616
1617 #[test]
1618 fn parse_plan_id_rejects_garbage() {
1619 let err = parse_plan_id("not-a-number").unwrap_err();
1620 assert!(matches!(err, LiveCmdError::MalformedPlanId(_)));
1621 }
1622
1623 #[test]
1626 fn exit_code_runtime_maps_to_one() {
1627 assert_eq!(LiveCmdError::Runtime("x".to_string()).exit_code(), 1);
1628 assert_eq!(LiveCmdError::ArgRefused("x".to_string()).exit_code(), 1);
1629 assert_eq!(
1630 LiveCmdError::MalformedPlanId("x".to_string()).exit_code(),
1631 1
1632 );
1633 }
1634
1635 #[test]
1636 fn exit_code_classification_refused_maps_to_two() {
1637 assert_eq!(
1638 LiveCmdError::ClassificationRefused("offline only".to_string()).exit_code(),
1639 2,
1640 );
1641 }
1642 #[test]
1643 fn exit_code_checksum_drift_maps_to_four() {
1644 assert_eq!(
1645 LiveCmdError::ChecksumDrift("mismatch".to_string()).exit_code(),
1646 4,
1647 );
1648 }
1649
1650 #[test]
1651 fn exit_code_state_conflict_maps_to_five() {
1652 assert_eq!(
1653 LiveCmdError::StateConflict("complete".to_string()).exit_code(),
1654 5,
1655 );
1656 }
1657
1658 #[test]
1661 fn assert_run_status_accepts_pending_running() {
1662 assert!(assert_run_status_allows_progress(PlanStatus::Pending).is_ok());
1663 assert!(assert_run_status_allows_progress(PlanStatus::Running).is_ok());
1664 }
1665
1666 #[test]
1667 fn assert_run_status_refuses_paused_pointing_to_resume() {
1668 let err = assert_run_status_allows_progress(PlanStatus::Paused)
1672 .expect_err("paused must be a state conflict for `live run`");
1673 match err {
1674 LiveCmdError::StateConflict(msg) => {
1675 assert!(msg.contains("paused"), "{msg}");
1676 assert!(msg.contains("live resume"), "{msg}");
1677 }
1678 other => panic!("expected StateConflict, got {other:?}"),
1679 }
1680 }
1681
1682 #[test]
1683 fn assert_run_status_refuses_terminal_and_gates() {
1684 for status in [
1685 PlanStatus::Validating,
1686 PlanStatus::Cutover,
1687 PlanStatus::Finalizing,
1688 PlanStatus::Complete,
1689 PlanStatus::Abandoned,
1690 PlanStatus::Failed,
1691 ] {
1692 let err = assert_run_status_allows_progress(status)
1693 .expect_err("non-progressable status must refuse");
1694 assert!(matches!(err, LiveCmdError::StateConflict(_)));
1695 }
1696 }
1697
1698 #[test]
1699 fn assert_resume_status_distinguishes_pending_from_terminal() {
1700 let err = assert_resume_status_allows_progress(PlanStatus::Pending)
1702 .expect_err("pending must refuse");
1703 match err {
1704 LiveCmdError::StateConflict(msg) => assert!(msg.contains("pending")),
1705 other => panic!("expected StateConflict, got {other:?}"),
1706 }
1707 assert!(assert_resume_status_allows_progress(PlanStatus::Running).is_ok());
1709 assert!(assert_resume_status_allows_progress(PlanStatus::Paused).is_ok());
1710 for status in [
1712 PlanStatus::Validating,
1713 PlanStatus::Cutover,
1714 PlanStatus::Finalizing,
1715 PlanStatus::Complete,
1716 PlanStatus::Abandoned,
1717 PlanStatus::Failed,
1718 ] {
1719 let err = assert_resume_status_allows_progress(status)
1720 .expect_err("non-resumable status must refuse");
1721 assert!(matches!(err, LiveCmdError::StateConflict(_)));
1722 }
1723 }
1724
1725 #[test]
1726 fn assert_finalize_status_accepts_only_finalizing() {
1727 assert!(assert_finalize_status(PlanStatus::Finalizing).is_ok());
1728 for status in [
1729 PlanStatus::Pending,
1730 PlanStatus::Running,
1731 PlanStatus::Paused,
1732 PlanStatus::Validating,
1733 PlanStatus::Cutover,
1734 PlanStatus::Complete,
1735 PlanStatus::Abandoned,
1736 PlanStatus::Failed,
1737 ] {
1738 let err = assert_finalize_status(status).expect_err("non-finalizing must refuse");
1739 assert!(matches!(err, LiveCmdError::StateConflict(_)));
1740 }
1741 }
1742
1743 #[test]
1744 fn assert_abandon_status_refuses_every_terminal_state() {
1745 for status in [
1747 PlanStatus::Complete,
1748 PlanStatus::Abandoned,
1749 PlanStatus::Failed,
1750 ] {
1751 let err = assert_abandon_status(status)
1752 .expect_err("terminal status must be a state conflict for abandon");
1753 assert!(matches!(err, LiveCmdError::StateConflict(_)));
1754 }
1755 for status in [
1757 PlanStatus::Pending,
1758 PlanStatus::Running,
1759 PlanStatus::Paused,
1760 PlanStatus::Validating,
1761 PlanStatus::Cutover,
1762 PlanStatus::Finalizing,
1763 ] {
1764 assert!(assert_abandon_status(status).is_ok(), "{status:?} accepts");
1765 }
1766 }
1767
1768 #[test]
1769 fn assert_abandon_status_failed_message_points_to_fresh_plan() {
1770 let err = assert_abandon_status(PlanStatus::Failed).expect_err("failed must refuse");
1773 match err {
1774 LiveCmdError::StateConflict(msg) => {
1775 assert!(msg.contains("failed"), "{msg}");
1776 assert!(msg.contains("fresh plan") || msg.contains("audit"), "{msg}",);
1777 }
1778 other => panic!("expected StateConflict, got {other:?}"),
1779 }
1780 }
1781
1782 #[test]
1785 fn force_allowed_when_djogi_env_unset() {
1786 let prior = std::env::var("DJOGI_ENV").ok();
1788 unsafe { std::env::remove_var("DJOGI_ENV") };
1789 assert!(force_allowed_in_env());
1790 unsafe { std::env::set_var("DJOGI_ENV", "development") };
1791 assert!(force_allowed_in_env());
1792 unsafe { std::env::set_var("DJOGI_ENV", "PRODUCTION") };
1793 assert!(
1794 !force_allowed_in_env(),
1795 "case-insensitive production must refuse"
1796 );
1797 unsafe { std::env::set_var("DJOGI_ENV", "production") };
1798 assert!(!force_allowed_in_env());
1799 match prior {
1801 Some(v) => unsafe { std::env::set_var("DJOGI_ENV", v) },
1802 None => unsafe { std::env::remove_var("DJOGI_ENV") },
1803 }
1804 }
1805
1806 #[test]
1809 fn plan_file_checksum_mismatch_maps_to_drift() {
1810 let pfe = PlanFileError::ChecksumMismatch {
1811 path: PathBuf::from("/tmp/x.json"),
1812 expected: "V1:0".to_string(),
1813 actual: "V1:1".to_string(),
1814 };
1815 let err: LiveCmdError = pfe.into();
1816 assert_eq!(err.exit_code(), 4, "checksum mismatch must exit 4");
1817 }
1818
1819 #[test]
1820 fn plan_file_io_maps_to_runtime() {
1821 let pfe = PlanFileError::NotFound(PathBuf::from("/missing"));
1822 let err: LiveCmdError = pfe.into();
1823 assert_eq!(err.exit_code(), 1);
1824 }
1825}