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