use serde::Serialize;
use super::command_catalog::{CommandSideEffectFlags, command_side_effect_flags};
use crate::cli::{Cli, render::write_json_stdout, should_output_json, style};
pub const DRY_RUN_PLAN_OUTPUT_KIND: &str = "dry_run_plan";
#[derive(Debug, Clone, Serialize)]
pub struct RefUpdatePreview {
pub name: String,
pub from: Option<String>,
pub to: Option<String>,
pub requires_force: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct DryRunVerdict {
pub source: String,
pub status: String,
pub detail: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct IntegrationPreview {
pub thread: String,
pub target: Option<String>,
pub merge_relation: String,
pub freshness: String,
pub conflict_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub conflicts: Vec<String>,
pub changed_path_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub would_transition_to: Option<String>,
pub would_capture_dirty: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct DryRunPlan {
pub output_kind: &'static str,
pub command: String,
pub summary: String,
pub side_effects: CommandSideEffectFlags,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub ref_updates: Vec<RefUpdatePreview>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub integrations: Vec<IntegrationPreview>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub verdicts: Vec<DryRunVerdict>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blockers: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
pub performed_mutation: bool,
}
impl DryRunPlan {
pub fn new(command: &str, summary: impl Into<String>) -> Self {
let side_effects = command_side_effect_flags(command).unwrap_or(CommandSideEffectFlags {
may_move_ref: false,
destructive_requires_force: false,
network_io: false,
writes_heddle_refs: false,
writes_git_refs: false,
});
Self {
output_kind: DRY_RUN_PLAN_OUTPUT_KIND,
command: command.to_string(),
summary: summary.into(),
side_effects,
ref_updates: Vec::new(),
integrations: Vec::new(),
verdicts: Vec::new(),
blockers: Vec::new(),
notes: Vec::new(),
performed_mutation: false,
}
}
pub fn note(&mut self, note: impl Into<String>) -> &mut Self {
self.notes.push(note.into());
self
}
pub fn emit(&self, cli: &Cli, config: Option<&repo::Config>) -> anyhow::Result<()> {
if should_output_json(cli, config) {
write_json_stdout(self)?;
} else {
self.render_text();
}
Ok(())
}
fn render_text(&self) {
println!(
"{} {}",
style::working_marker(),
style::bold(&format!("dry run: {}", self.summary))
);
println!(
"{}",
style::dim("No changes were made. This is a preview of what would happen.")
);
let se = &self.side_effects;
let mut surface = Vec::new();
if se.may_move_ref {
surface.push("moves refs");
}
if se.writes_heddle_refs {
surface.push("writes Heddle refs");
}
if se.writes_git_refs {
surface.push("writes Git refs");
}
if se.network_io {
surface.push("network I/O");
}
if se.destructive_requires_force {
surface.push("destructive (needs --force)");
}
if !surface.is_empty() {
println!(" {}", style::field("would", &surface.join(", ")));
}
for update in &self.ref_updates {
let from = update.from.as_deref().unwrap_or("(remote — not fetched)");
let to = update.to.as_deref().unwrap_or("(unresolved)");
let force = if update.requires_force {
style::warn(" [requires --force]")
} else {
String::new()
};
println!(" ref {}: {} -> {}{}", update.name, from, to, force);
}
for integration in &self.integrations {
let target = integration.target.as_deref().unwrap_or("(no target)");
println!(
" integrate {} -> {} ({}, {})",
integration.thread, target, integration.merge_relation, integration.freshness
);
if integration.conflict_count > 0 {
println!(
" {}",
style::warn(&format!(
"{} conflict(s): {}",
integration.conflict_count,
integration.conflicts.join(", ")
))
);
}
if integration.would_capture_dirty {
println!(
" {}",
style::dim("would first capture outstanding worktree changes")
);
}
if let Some(next) = &integration.would_transition_to {
println!(" thread would become: {}", style::thread_state(next));
}
}
for verdict in &self.verdicts {
let marker = match verdict.status.as_str() {
"pass" | "clean" | "ready" | "verified" => style::ok_marker(),
"blocked" | "failed" => style::error_marker(),
_ => style::warn_marker(),
};
println!(
" {} {}: {}",
marker,
style::bold(&verdict.source),
verdict.detail
);
}
if !self.blockers.is_empty() {
println!(" {}", style::warn("blockers:"));
for blocker in &self.blockers {
println!(" - {blocker}");
}
}
for note in &self.notes {
println!(" {}", style::dim(note));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dry_run_plan_new_seeds_catalog_side_effects_and_stable_kind() {
let plan = DryRunPlan::new("ready", "evaluate readiness");
assert_eq!(plan.output_kind, DRY_RUN_PLAN_OUTPUT_KIND);
assert_eq!(plan.command, "ready");
assert_eq!(plan.summary, "evaluate readiness");
assert!(!plan.performed_mutation);
assert!(plan.ref_updates.is_empty());
assert!(plan.integrations.is_empty());
assert!(plan.verdicts.is_empty());
assert!(plan.blockers.is_empty());
assert!(plan.notes.is_empty());
let unknown = DryRunPlan::new("not-a-catalog-command", "noop");
assert!(!unknown.side_effects.may_move_ref);
assert!(!unknown.side_effects.network_io);
}
#[test]
fn dry_run_plan_note_appends_and_chains() {
let mut plan = DryRunPlan::new("push", "push preview");
plan.note("first").note("second");
assert_eq!(plan.notes, vec!["first".to_string(), "second".to_string()]);
}
#[test]
fn dry_run_plan_render_text_covers_every_section() {
let mut plan = DryRunPlan::new("land", "land preview");
plan.side_effects = CommandSideEffectFlags {
may_move_ref: true,
destructive_requires_force: true,
network_io: true,
writes_heddle_refs: true,
writes_git_refs: true,
};
plan.ref_updates.push(RefUpdatePreview {
name: "feature/x".into(),
from: Some("abc".into()),
to: Some("def".into()),
requires_force: true,
});
plan.ref_updates.push(RefUpdatePreview {
name: "remote-only".into(),
from: None,
to: None,
requires_force: false,
});
plan.integrations.push(IntegrationPreview {
thread: "feature/x".into(),
target: Some("main".into()),
merge_relation: "ahead".into(),
freshness: "fresh".into(),
conflict_count: 1,
conflicts: vec!["src/lib.rs".into()],
changed_path_count: 3,
would_transition_to: Some("landed".into()),
would_capture_dirty: true,
});
plan.integrations.push(IntegrationPreview {
thread: "orphan".into(),
target: None,
merge_relation: "unknown".into(),
freshness: "stale".into(),
conflict_count: 0,
conflicts: vec![],
changed_path_count: 0,
would_transition_to: None,
would_capture_dirty: false,
});
plan.verdicts.push(DryRunVerdict {
source: "verification".into(),
status: "pass".into(),
detail: "clean".into(),
});
plan.verdicts.push(DryRunVerdict {
source: "integration".into(),
status: "blocked".into(),
detail: "conflicts".into(),
});
plan.verdicts.push(DryRunVerdict {
source: "policy".into(),
status: "warn".into(),
detail: "review pending".into(),
});
plan.blockers.push("needs attention".into());
plan.note("skipped server round-trip");
plan.render_text();
}
#[test]
fn dry_run_plan_serializes_with_stable_output_kind() {
let mut plan = DryRunPlan::new("push", "push preview");
plan.ref_updates.push(RefUpdatePreview {
name: "main".into(),
from: None,
to: Some("deadbeef".into()),
requires_force: false,
});
let json = serde_json::to_value(&plan).expect("serialize");
assert_eq!(json["output_kind"], DRY_RUN_PLAN_OUTPUT_KIND);
assert_eq!(json["performed_mutation"], false);
assert_eq!(json["command"], "push");
assert_eq!(json["ref_updates"][0]["name"], "main");
assert!(json.get("blockers").is_none());
assert!(json.get("notes").is_none());
}
}