1use 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#[derive(Debug, Clone, Subcommand)]
69pub enum LiveCmd {
70 Plan {
76 version: Option<String>,
78 #[arg(long)]
81 workspace: Option<PathBuf>,
82 },
83 Show {
86 plan_id: String,
89 #[arg(long)]
91 workspace: Option<PathBuf>,
92 },
93 Run {
97 plan_id: String,
98 #[arg(long, default_value_t = false)]
103 allow_destructive: bool,
104 #[arg(long)]
108 justify: Option<String>,
109 #[arg(long, default_value_t = false)]
113 allow_raw_dangerous: bool,
114 #[arg(long)]
116 workspace: Option<PathBuf>,
117 },
118 Resume {
121 plan_id: String,
122 #[arg(long, default_value_t = false)]
126 allow_destructive: bool,
127 #[arg(long)]
130 justify: Option<String>,
131 #[arg(long)]
133 workspace: Option<PathBuf>,
134 },
135 Finalize {
138 plan_id: String,
139 #[arg(long)]
142 justify: Option<String>,
143 #[arg(long)]
145 workspace: Option<PathBuf>,
146 },
147 Abandon {
152 plan_id: String,
153 #[arg(long, default_value_t = false)]
156 force: bool,
157 #[arg(long)]
159 workspace: Option<PathBuf>,
160 },
161 Daemon {
172 #[arg(long, default_value = "30s", value_parser = parse_humantime_duration)]
175 poll_interval: std::time::Duration,
176 #[arg(long, default_value = "10m", value_parser = parse_humantime_duration)]
180 claim_stale_after: std::time::Duration,
181 #[arg(long, default_value_t = false)]
187 allow_non_localhost: bool,
188 #[arg(long)]
190 workspace: Option<PathBuf>,
191 },
192}
193
194#[derive(Debug, thiserror::Error)]
203#[non_exhaustive]
204pub enum LiveCmdError {
205 #[error("{0}")]
207 Runtime(String),
208
209 #[error("classification refused: {0}")]
212 ClassificationRefused(String),
213
214 #[error("plan file checksum drift: {0}")]
216 ChecksumDrift(String),
217
218 #[error("plan state conflict: {0}")]
221 StateConflict(String),
222
223 #[error("argument refused: {0}")]
228 ArgRefused(String),
229
230 #[error("malformed plan_id: {0}")]
232 MalformedPlanId(String),
233
234 #[error("plan {0} not found in djogi_live_plans")]
236 PlanNotFound(HeerId),
237}
238
239impl LiveCmdError {
240 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
265pub 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
292async 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
348fn parse_plan_id(raw: &str) -> Result<HeerId, LiveCmdError> {
355 HeerId::from_str(raw).map_err(|e| LiveCmdError::MalformedPlanId(format!("`{raw}`: {e}")))
356}
357
358fn resolve_workspace(workspace: Option<PathBuf>) -> PathBuf {
362 workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
363}
364
365fn 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
380fn 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
396fn justify_is_empty(justify: Option<&str>) -> bool {
400 justify.map(|s| s.trim().is_empty()).unwrap_or(true)
401}
402
403fn 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
440fn 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
450fn 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 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
514fn 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
530async 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
543fn 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
549async fn fetch_row(ctx: &mut DjogiContext, plan_id: HeerId) -> Result<LivePlanRow, LiveCmdError> {
553 use djogi::live_migrate::state;
559 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
587async 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 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
642pub fn refuse_offline_only(reason: impl Into<String>) -> LiveCmdError {
652 LiveCmdError::ClassificationRefused(reason.into())
653}
654
655async 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
724async 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 require_destructive_gate_for_plan(&plan, allow_destructive, justify)?;
758
759 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
811async 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 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
879fn 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
913fn 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
944async 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 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 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
1023fn 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
1035async 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
1091fn 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
1127async 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
1177fn hostname_for_claim() -> String {
1181 std::env::var("HOSTNAME").unwrap_or_else(|_| "unknown".to_string())
1182}
1183
1184fn 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 #[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 #[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 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 #[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 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 let err = parse_humantime_duration("30sX").unwrap_err();
1510 assert!(
1511 err.contains("unknown unit") || err.contains("expected"),
1512 "{err}"
1513 );
1514 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 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 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 #[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 require_justify_for_destructive(false, None).unwrap();
1565 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 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 let err = require_destructive_gate_for_plan(&plan, false, None).unwrap_err();
1627 assert!(matches!(err, LiveCmdError::ArgRefused(_)));
1628 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 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 #[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 #[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 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 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 assert!(assert_resume_status_allows_progress(PlanStatus::Running).is_ok());
1735 assert!(assert_resume_status_allows_progress(PlanStatus::Paused).is_ok());
1736 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 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 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 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 #[test]
1811 fn force_allowed_when_djogi_env_unset() {
1812 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 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 #[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}