use std::path::Path;
#[derive(Debug)]
pub struct WhatIfReport {
pub creates: Vec<ResourceChange>,
pub modifies: Vec<ResourceChange>,
pub deletes: Vec<ResourceChange>,
pub unchanged: Vec<ResourceChange>,
pub raw_json: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct ResourceChange {
pub resource_type: String,
pub resource_id: String,
pub field_changes: Vec<FieldChange>,
}
#[derive(Debug, Clone)]
pub struct FieldChange {
pub path: String,
pub from: serde_json::Value,
pub to: serde_json::Value,
}
#[derive(Debug, thiserror::Error)]
pub enum WhatIfError {
#[error("az command failed (exit {code}): {stderr}")]
AzFailed { code: i32, stderr: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("parse: {0}")]
Parse(String),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
pub fn run(resource_group: &str, bicep_path: &Path) -> Result<WhatIfReport, WhatIfError> {
let output = std::process::Command::new("az")
.args([
"deployment",
"group",
"what-if",
"--resource-group",
resource_group,
"--template-file",
bicep_path.to_str().unwrap_or(""),
"--no-pretty-print",
])
.output()?;
if !output.status.success() {
return Err(WhatIfError::AzFailed {
code: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
parse_whatif_json(&json)
}
pub fn parse_whatif_json(json: &serde_json::Value) -> Result<WhatIfReport, WhatIfError> {
let changes = json
.get("changes")
.and_then(|v| v.as_array())
.ok_or_else(|| WhatIfError::Parse("missing top-level 'changes' array".to_string()))?;
let mut report = WhatIfReport {
creates: Vec::new(),
modifies: Vec::new(),
deletes: Vec::new(),
unchanged: Vec::new(),
raw_json: json.clone(),
};
for entry in changes {
let change_type = entry
.get("changeType")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
let resource_id_full = entry
.get("resourceId")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let resource_name = extract_resource_name(&resource_id_full);
let resource_type = extract_resource_type(&resource_id_full);
let field_changes = if change_type == "Modify" {
parse_delta(entry)
} else {
Vec::new()
};
let change = ResourceChange {
resource_type,
resource_id: resource_name,
field_changes,
};
match change_type {
"Create" => report.creates.push(change),
"Modify" => report.modifies.push(change),
"Delete" => report.deletes.push(change),
"NoChange" => report.unchanged.push(change),
_ => report.unchanged.push(change),
}
}
Ok(report)
}
fn parse_delta(entry: &serde_json::Value) -> Vec<FieldChange> {
let Some(delta) = entry.get("delta").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut changes = Vec::new();
for item in delta {
let path = item
.get("path")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if path.is_empty() {
continue;
}
let from = item
.get("before")
.cloned()
.unwrap_or(serde_json::Value::Null);
let to = item
.get("after")
.cloned()
.unwrap_or(serde_json::Value::Null);
changes.push(FieldChange { path, from, to });
}
changes
}
fn extract_resource_name(id: &str) -> String {
id.split('/').next_back().unwrap_or(id).to_string()
}
fn extract_resource_type(id: &str) -> String {
let parts: Vec<&str> = id.split('/').collect();
let providers_pos = parts
.iter()
.position(|&s| s.eq_ignore_ascii_case("providers"));
if let Some(pos) = providers_pos {
if pos + 2 < parts.len() {
return format!("{}/{}", parts[pos + 1], parts[pos + 2]);
}
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
fn load_fixture(name: &str) -> serde_json::Value {
let path = std::path::Path::new("tests/fixtures").join(name);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read fixture {name}: {e}"));
serde_json::from_str(&text).unwrap_or_else(|e| panic!("cannot parse fixture {name}: {e}"))
}
#[test]
fn parses_create_change() {
let json = load_fixture("whatif_sample.json");
let report = parse_whatif_json(&json).unwrap();
assert_eq!(report.creates.len(), 1, "expected one create");
assert_eq!(
report.creates[0].resource_type,
"Microsoft.App/containerApps"
);
assert_eq!(report.creates[0].resource_id, "quelch-prod-mcp");
assert!(
report.creates[0].field_changes.is_empty(),
"creates should have no field changes"
);
}
#[test]
fn parses_modify_with_field_changes() {
let json = load_fixture("whatif_sample.json");
let report = parse_whatif_json(&json).unwrap();
assert_eq!(report.modifies.len(), 1, "expected one modify");
let m = &report.modifies[0];
assert!(
!m.field_changes.is_empty(),
"modify should have field changes"
);
let fc = &m.field_changes[0];
assert_eq!(fc.path, "properties.throughput.mode");
}
#[test]
fn parses_delete_change() {
let json = load_fixture("whatif_sample.json");
let report = parse_whatif_json(&json).unwrap();
assert_eq!(report.deletes.len(), 1, "expected one delete");
assert_eq!(
report.deletes[0].resource_type,
"Microsoft.DocumentDB/databaseAccounts"
);
}
#[test]
fn parses_nochange_entry() {
let json = load_fixture("whatif_sample.json");
let report = parse_whatif_json(&json).unwrap();
assert_eq!(report.unchanged.len(), 1, "expected one unchanged");
assert_eq!(
report.unchanged[0].resource_type,
"Microsoft.Search/searchServices"
);
}
#[test]
fn propagates_parse_error_for_missing_changes_key() {
let json = serde_json::json!({ "notChanges": [] });
let err = parse_whatif_json(&json).unwrap_err();
assert!(matches!(err, WhatIfError::Parse(_)));
}
#[test]
fn extract_resource_name_from_arm_id() {
let id =
"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.App/containerApps/my-app";
assert_eq!(extract_resource_name(id), "my-app");
}
#[test]
fn extract_resource_type_from_arm_id() {
let id =
"/subscriptions/sub/resourceGroups/rg/providers/Microsoft.App/containerApps/my-app";
assert_eq!(extract_resource_type(id), "Microsoft.App/containerApps");
}
}