use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Result, anyhow};
use colored::Colorize;
use serde_json::{Value, json};
use rigg_core::registry::{self, X_RIGG_PIN};
use rigg_core::resources::{ResourceKind, ResourceRef};
use rigg_core::store::Store;
use rigg_diff::output::SideLabels;
use rigg_diff::semantic::DiffResult;
use crate::cli::PromoteArgs;
use crate::commands::{CommandError, GlobalContext, interactive, load_workspace};
struct Item {
kind: ResourceKind,
stem: String,
target: Option<Value>,
merged: Value,
pinned: Vec<String>,
diff: DiffResult,
}
impl Item {
fn label(&self) -> String {
format!("{}/{}", self.kind.directory_name(), self.stem)
}
}
struct Plan {
changed: Vec<Item>,
new: Vec<Item>,
unchanged: Vec<Item>,
kept_only_in_to: Vec<(ResourceKind, String)>,
}
pub fn run(ctx: &GlobalContext, args: PromoteArgs) -> Result<()> {
let ws = load_workspace()?;
if !ws.config.environments.contains_key(&args.from) {
return Err(anyhow!(CommandError::Usage(format!(
"unknown environment '{}' (see `rigg env list`)",
args.from
))));
}
if !ws.config.environments.contains_key(&args.to) {
return Err(anyhow!(CommandError::Usage(format!(
"unknown environment '{}' (see `rigg env list`)",
args.to
))));
}
if args.from == args.to {
return Err(anyhow!(CommandError::Usage(
"--from and --to must name different environments".to_string()
)));
}
let project = ws.project(&args.project)?;
let store_from = Store::new(project, &args.from);
let store_to = Store::new(project, &args.to);
let plan = build_plan(&store_from, &store_to)?;
if !ctx.json() {
print_preview(&args, &plan);
}
let nothing_to_do = plan.changed.is_empty() && plan.new.is_empty();
if args.dry_run {
if !ctx.json() {
println!();
println!("(dry run — nothing written)");
} else {
print_json(&plan, true);
}
return Ok(());
}
if nothing_to_do {
if ctx.json() {
print_json(&plan, false);
} else {
println!();
println!(
"nothing to promote — '{}' already matches '{}'",
args.to, args.from
);
}
return Ok(());
}
if ctx.interactive() && !ctx.json() {
if !interactive::confirm_default_yes("Proceed?", ctx.no_color)? {
println!("aborted");
return Ok(());
}
} else if !ctx.yes {
return Err(anyhow!(CommandError::Usage(
"non-interactive promote requires --yes".to_string()
)));
}
for item in &plan.changed {
let name = item
.merged
.get("name")
.and_then(Value::as_str)
.unwrap_or(&item.stem)
.to_string();
store_to.write(&ResourceRef::new(item.kind, name), &item.merged)?;
}
for item in &plan.new {
store_to.write_at(&item.stem, item.kind, &item.merged)?;
}
if ctx.json() {
print_json(&plan, false);
} else {
println!();
println!("hint: rigg diff {} -e {}", args.project, args.to);
println!(" rigg push {} -e {}", args.project, args.to);
print_new_file_hints(&args, &plan);
}
Ok(())
}
fn build_plan(store_from: &Store, store_to: &Store) -> Result<Plan> {
let from_map = list_by_stem(store_from)?;
let to_map = list_by_stem(store_to)?;
let mut changed = Vec::new();
let mut new = Vec::new();
let mut unchanged = Vec::new();
for ((kind, stem), from_path) in &from_map {
let source = store_from.read_path(from_path)?;
let target = match to_map.get(&(*kind, stem.clone())) {
Some(to_path) => Some(store_to.read_path(to_path)?),
None => None,
};
let pinned = pinned_paths(*kind, target.as_ref());
let merged = merge_promote(&source, target.as_ref(), &pinned);
let diff =
rigg_diff::semantic::diff(target.as_ref().unwrap_or(&Value::Null), &merged, "name");
let item = Item {
kind: *kind,
stem: stem.clone(),
target: target.clone(),
merged,
pinned,
diff,
};
match &item.target {
None => new.push(item),
Some(_) if item.diff.is_equal => unchanged.push(item),
Some(_) => changed.push(item),
}
}
let mut kept_only_in_to: Vec<(ResourceKind, String)> = to_map
.keys()
.filter(|k| !from_map.contains_key(*k))
.cloned()
.collect();
kept_only_in_to.sort();
Ok(Plan {
changed,
new,
unchanged,
kept_only_in_to,
})
}
fn list_by_stem(store: &Store) -> Result<BTreeMap<(ResourceKind, String), PathBuf>> {
let mut out = BTreeMap::new();
for (r, path) in store.list()? {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
out.insert((r.kind, stem), path);
}
Ok(out)
}
fn pinned_paths(kind: ResourceKind, target: Option<&Value>) -> Vec<String> {
let mut pinned: Vec<String> = vec!["name".to_string()];
pinned.extend(registry::env_pinned(kind).into_iter().map(String::from));
if let Some(target) = target {
if let Some(extra) = target.get(X_RIGG_PIN).and_then(Value::as_array) {
for p in extra {
if let Some(s) = p.as_str() {
pinned.push(s.to_string());
}
}
}
pinned.push(X_RIGG_PIN.to_string());
}
pinned.sort();
pinned.dedup();
pinned
}
fn merge_promote(source: &Value, target: Option<&Value>, pinned: &[String]) -> Value {
let mut merged = source.clone();
if let Some(map) = merged.as_object_mut() {
map.remove(X_RIGG_PIN);
}
if let Some(target) = target {
for path in pinned {
registry::restore_path(&mut merged, target, path);
}
}
merged
}
fn print_preview(args: &PromoteArgs, plan: &Plan) {
println!(
"{} project '{}': {} {} {}",
"Promote".bold(),
args.project,
args.from,
"→".dimmed(),
args.to
);
println!(
" {} changed, {} new, {} unchanged, {} kept (only in '{}')",
plan.changed.len(),
plan.new.len(),
plan.unchanged.len(),
plan.kept_only_in_to.len(),
args.to
);
if !plan.changed.is_empty() {
println!();
let labels = SideLabels {
new_side: format!("{} (incoming)", args.from),
old_side: args.to.clone(),
};
for item in &plan.changed {
print!(
"{}",
rigg_diff::output::format_text(&item.diff, &item.label(), &labels)
);
}
}
if !plan.new.is_empty() {
println!();
println!("new (will be created in '{}'):", args.to);
for item in &plan.new {
println!(" {}", item.label());
}
}
if !plan.kept_only_in_to.is_empty() {
println!();
println!("kept (only in '{}' — never touched by promote):", args.to);
for (kind, stem) in &plan.kept_only_in_to {
println!(" {}/{}", kind.directory_name(), stem);
}
}
}
fn print_new_file_hints(args: &PromoteArgs, plan: &Plan) {
let with_pins: Vec<&Item> = plan
.new
.iter()
.filter(|i| !registry::env_pinned(i.kind).is_empty())
.collect();
if with_pins.is_empty() {
return;
}
println!();
println!(
"New files were created verbatim from '{}'. Fields worth reviewing (env-pinned by default):",
args.from
);
for item in with_pins {
println!(
" {}: {}",
item.label(),
registry::env_pinned(item.kind).join(", ")
);
}
}
fn print_json(plan: &Plan, dry_run: bool) {
let mut pinned_kept: serde_json::Map<String, Value> = serde_json::Map::new();
for item in &plan.changed {
pinned_kept.insert(item.label(), json!(item.pinned));
}
let value = json!({
"dry_run": dry_run,
"promoted": plan.changed.iter().map(Item::label).collect::<Vec<_>>(),
"created": plan.new.iter().map(Item::label).collect::<Vec<_>>(),
"kept_only_in_to": plan
.kept_only_in_to
.iter()
.map(|(k, s)| format!("{}/{}", k.directory_name(), s))
.collect::<Vec<_>>(),
"pinned_kept": pinned_kept,
});
println!(
"{}",
serde_json::to_string_pretty(&value).unwrap_or_default()
);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn merge_promote_keeps_name_from_target() {
let source = json!({"name": "a-name", "model": "m"});
let target = json!({"name": "b-name", "model": "old"});
let merged = merge_promote(&source, Some(&target), &["name".to_string()]);
assert_eq!(merged["name"], json!("b-name"));
assert_eq!(
merged["model"],
json!("m"),
"non-pinned field comes from source"
);
}
#[test]
fn merge_promote_keeps_registry_pinned_path() {
let source = json!({
"name": "agent",
"model": "gpt-5-mini",
"tools": [{"type": "mcp", "server_url": "https://dev.search.windows.net/x"}]
});
let target = json!({
"name": "agent",
"model": "gpt-5-mini",
"tools": [{"type": "mcp", "server_url": "https://prod.search.windows.net/x"}]
});
let pinned = pinned_paths(ResourceKind::Agent, Some(&target));
assert!(pinned.iter().any(|p| p == "tools[].server_url"));
let merged = merge_promote(&source, Some(&target), &pinned);
assert_eq!(
merged["tools"][0]["server_url"],
json!("https://prod.search.windows.net/x"),
"target's server_url kept, not source's"
);
}
#[test]
fn merge_promote_preserves_target_only_tools() {
let source = json!({
"name": "agent",
"model": "gpt-5-mini",
"tools": [{"type": "mcp", "server_url": "https://dev.search.windows.net/x"}]
});
let target = json!({
"name": "agent",
"model": "gpt-4o-old",
"tools": [
{"type": "mcp", "server_url": "https://prod.search.windows.net/x"},
{"type": "file_search", "vector_store_ids": ["vs-prod"]},
{"type": "mcp", "server_url": "https://prod.search.windows.net/y",
"project_connection_id": "conn-prod-2"}
]
});
let pinned = pinned_paths(ResourceKind::Agent, Some(&target));
let merged = merge_promote(&source, Some(&target), &pinned);
let tools = merged["tools"].as_array().unwrap();
assert_eq!(tools.len(), 3, "target-only tools survive: {tools:?}");
assert_eq!(
tools[0]["server_url"],
json!("https://prod.search.windows.net/x"),
"paired tool keeps target's pinned field"
);
assert_eq!(
tools[1],
json!({"type": "file_search", "vector_store_ids": ["vs-prod"]}),
"target-only tool survives wholesale"
);
assert_eq!(
tools[2]["project_connection_id"],
json!("conn-prod-2"),
"second target-only tool survives with all fields"
);
assert_eq!(merged["model"], json!("gpt-5-mini"), "non-pinned promoted");
}
#[test]
fn merge_promote_source_only_tools_stay() {
let source = json!({
"name": "agent",
"tools": [
{"type": "mcp", "server_url": "https://dev/x"},
{"type": "code_interpreter"}
]
});
let target = json!({
"name": "agent",
"tools": [{"type": "mcp", "server_url": "https://prod/x"}]
});
let pinned = pinned_paths(ResourceKind::Agent, Some(&target));
let merged = merge_promote(&source, Some(&target), &pinned);
let tools = merged["tools"].as_array().unwrap();
assert_eq!(tools.len(), 2, "source's extra tool is promoted: {tools:?}");
assert_eq!(tools[0]["server_url"], json!("https://prod/x"), "paired");
assert_eq!(tools[1], json!({"type": "code_interpreter"}));
}
#[test]
fn merge_promote_keeps_x_rigg_pin_listed_extra_path() {
let source = json!({"name": "conn", "properties": {"description": "new description"}});
let target = json!({
"name": "conn",
"properties": {"description": "prod description"},
"x-rigg-pin": ["properties.description"]
});
let pinned = pinned_paths(ResourceKind::Connection, Some(&target));
let merged = merge_promote(&source, Some(&target), &pinned);
assert_eq!(
merged["properties"]["description"],
json!("prod description"),
"x-rigg-pin-listed path kept from target"
);
}
#[test]
fn merge_promote_target_none_is_source_verbatim() {
let source = json!({"name": "a", "model": "m", "tools": []});
let merged = merge_promote(&source, None, &["name".to_string(), "model".to_string()]);
assert_eq!(merged, source);
}
#[test]
fn merge_promote_x_rigg_pin_annotation_itself_survives() {
let source = json!({"name": "conn", "properties": {"target": "https://dev"}});
let target = json!({
"name": "conn",
"properties": {"target": "https://prod"},
"x-rigg-pin": ["properties.description"]
});
let pinned = pinned_paths(ResourceKind::Connection, Some(&target));
let merged = merge_promote(&source, Some(&target), &pinned);
assert_eq!(
merged["x-rigg-pin"],
json!(["properties.description"]),
"the annotation itself travels with the target, unmerged from source"
);
assert_eq!(
merged["properties"]["target"],
json!("https://prod"),
"properties.target is env_pinned_extra for Connection — kept from target too"
);
}
#[test]
fn merge_promote_strips_source_side_x_rigg_pin() {
let source = json!({
"name": "conn",
"properties": {"target": "https://dev"},
"x-rigg-pin": ["properties.description"]
});
let target = json!({"name": "conn", "properties": {"target": "https://prod"}});
let pinned = pinned_paths(ResourceKind::Connection, Some(&target));
let merged = merge_promote(&source, Some(&target), &pinned);
assert!(
merged.get(X_RIGG_PIN).is_none(),
"source's annotation must not leak: {merged:?}"
);
let created = merge_promote(&source, None, &pinned);
assert!(created.get(X_RIGG_PIN).is_none());
}
#[test]
fn pinned_paths_has_no_target_annotation_key_when_target_is_none() {
let pinned = pinned_paths(ResourceKind::Agent, None);
assert!(!pinned.iter().any(|p| p == X_RIGG_PIN));
assert!(pinned.iter().any(|p| p == "tools[].server_url"));
}
}