use super::*;
pub fn diff_field_ignored(field: &str, ignore_keys: &[String]) -> bool {
if ignore_keys.is_empty() {
return false;
}
let f = field.to_ascii_lowercase();
ignore_keys
.iter()
.any(|k| *k == f || (f == "version" && k == "version_label"))
}
pub(crate) fn diff_envs(
left: &Environment,
right: &Environment,
redact_on: bool,
ignore_keys: &[String],
) -> String {
let cn = |s: &str| {
if redact_on {
redact_block(s)
} else {
s.to_string()
}
};
let updated = |e: &Environment| {
e.updated
.map(|u| u.to_rfc3339())
.unwrap_or_else(|| "—".into())
};
let mut rows: Vec<(&str, String, String)> = vec![
("Name", left.name.clone(), right.name.clone()),
(
"Application",
left.application.clone(),
right.application.clone(),
),
("Tier", left.tier.clone(), right.tier.clone()),
("Status", left.status.clone(), right.status.clone()),
("Health", left.health.clone(), right.health.clone()),
("Platform", left.platform.clone(), right.platform.clone()),
(
"Version",
left.version_label.clone(),
right.version_label.clone(),
),
("CNAME", cn(&left.cname), cn(&right.cname)),
("Updated", updated(left), updated(right)),
];
rows.retain(|(field, _, _)| !diff_field_ignored(field, ignore_keys));
let width: usize = 28;
let truncate = |s: &str| -> String {
if s.chars().count() > width {
let mut t: String = s.chars().take(width.saturating_sub(1)).collect();
t.push('…');
t
} else {
s.to_string()
}
};
let left_label = truncate(&format!("◄ {}", left.name));
let right_label = truncate(&format!("{} ►", right.name));
let mut out = String::new();
out.push_str(&format!(
"{:<14} {:<width$} {}\n",
"", left_label, right_label,
));
out.push_str(&"─".repeat(14 + 4 + width + 4 + width));
out.push('\n');
for (field, l, r) in rows {
let differs = l != r;
let marker = if differs { "≠" } else { " " };
out.push_str(&format!(
"{marker} {:<12} {:<width$} {}\n",
field,
truncate(&l),
truncate(&r),
));
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiff {
pub namespace: String,
pub name: String,
pub left: Option<String>,
pub right: Option<String>,
}
pub fn parse_ignore_keys(csv: Option<&str>) -> Vec<String> {
let Some(csv) = csv else {
return Vec::new();
};
csv.split(',')
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty())
.collect()
}
pub fn filter_config_diffs(diffs: Vec<ConfigDiff>, ignore_keys: &[String]) -> Vec<ConfigDiff> {
if ignore_keys.is_empty() {
return diffs;
}
diffs
.into_iter()
.filter(|d| {
let name_lower = d.name.to_lowercase();
let qualified_lower = format!("{}:{}", d.namespace.to_lowercase(), name_lower);
!ignore_keys
.iter()
.any(|k| k == &name_lower || k == &qualified_lower)
})
.collect()
}
pub fn diff_config_options(
left: &[crate::aws::ConfigOption],
right: &[crate::aws::ConfigOption],
) -> Vec<ConfigDiff> {
use std::collections::{BTreeMap, BTreeSet};
let norm = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
let to_map = |opts: &[crate::aws::ConfigOption]| -> BTreeMap<(String, String), Option<String>> {
opts.iter()
.map(|o| ((o.namespace.clone(), o.name.clone()), norm(&o.value)))
.collect()
};
let lmap = to_map(left);
let rmap = to_map(right);
let mut keys: BTreeSet<(String, String)> = lmap.keys().cloned().collect();
keys.extend(rmap.keys().cloned());
keys.into_iter()
.filter_map(|k| {
let l = lmap.get(&k).cloned().flatten();
let r = rmap.get(&k).cloned().flatten();
if l == r {
None
} else {
Some(ConfigDiff {
namespace: k.0,
name: k.1,
left: l,
right: r,
})
}
})
.collect()
}
pub(crate) fn render_config_diff_overlay(
left_env: &str,
right_env: &str,
diffs: &[ConfigDiff],
) -> String {
if diffs.is_empty() {
return format!(
"Config diff — {left_env} ↔ {right_env}\n\n\
✓ identical: every operator-set option-setting matches.\n\n\
esc / q to close"
);
}
let mut body = format!(
"Config diff — {left_env} ↔ {right_env}\n\
{n} option-setting(s) differ. L = {left_env} R = {right_env}\n\
(unset = at the platform default)\n\n",
n = diffs.len()
);
let mut current_ns: Option<&str> = None;
let show = |v: &Option<String>| v.clone().unwrap_or_else(|| "(unset)".into());
for d in diffs {
if Some(d.namespace.as_str()) != current_ns {
if current_ns.is_some() {
body.push('\n');
}
body.push_str(&format!("── {} ──\n", d.namespace));
current_ns = Some(d.namespace.as_str());
}
body.push_str(&format!(
" {}\n L: {}\n R: {}\n",
d.name,
show(&d.left),
show(&d.right),
));
}
body.push_str("\nesc / q to close");
body
}
pub(crate) fn render_options_overlay(
rows: &[crate::aws::ConfigOption],
filter_ns: Option<&str>,
env_name: &str,
) -> String {
let filtered: Vec<&crate::aws::ConfigOption> = rows
.iter()
.filter(|r| filter_ns.is_none_or(|ns| r.namespace == ns))
.collect();
if filtered.is_empty() {
return match filter_ns {
Some(ns) => format!(
"No options found for namespace '{ns}' on env '{env_name}'.\n\n\
Spelling? Try `:options` (no arg) to see the full list of\n\
namespaces available for this env's platform.\n\n\
esc / q to close"
),
None => format!(
"No configuration options returned for env '{env_name}'.\n\n\
This usually means the env's platform doesn't expose an option\n\
vocabulary (custom platform or stale solution-stack). Try\n\
`:set-option` directly if you know what you want to change.\n\n\
esc / q to close"
),
};
}
let mut max_name_per_ns: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for r in &filtered {
let e = max_name_per_ns.entry(r.namespace.as_str()).or_insert(0);
*e = (*e).max(r.name.chars().count()).min(38);
}
let user_set = filtered.iter().filter(|r| r.value.is_some()).count();
let mut body = String::new();
body.push_str(&format!(
"Configuration vocabulary for {env_name}\n\
{user_set}/{total} options are operator-set; the rest are at default.\n\n\
▸ = operator-set • = default severity warns when changing rolls instances\n\n",
total = filtered.len()
));
let mut current_ns: Option<&str> = None;
for r in &filtered {
if Some(r.namespace.as_str()) != current_ns {
if current_ns.is_some() {
body.push('\n');
}
body.push_str(&format!("── {} ──\n", r.namespace));
current_ns = Some(r.namespace.as_str());
}
let marker = if r.value.is_some() { "▸" } else { "•" };
let name_width = max_name_per_ns
.get(r.namespace.as_str())
.copied()
.unwrap_or(20);
let name_padded = if r.name.chars().count() < name_width {
format!("{name:<width$}", name = r.name, width = name_width)
} else {
r.name.clone()
};
let value_str = match &r.value {
Some(v) => format!(" = {v}"),
None => String::new(),
};
let mut meta: Vec<String> = Vec::new();
if let Some(d) = &r.default_value {
if !d.is_empty() {
meta.push(format!("default: {d}"));
}
}
if !r.value_type.is_empty() && r.value_type != "Scalar" {
meta.push(format!("type: {}", r.value_type));
}
if let Some(s) = &r.change_severity {
if s != "NoInterruption" && s != "Unknown" {
meta.push(format!("severity: {s}"));
}
}
match (r.min_value, r.max_value) {
(Some(min), Some(max)) => meta.push(format!("range: {min}-{max}")),
(Some(min), None) => meta.push(format!("min: {min}")),
(None, Some(max)) => meta.push(format!("max: {max}")),
(None, None) => {}
}
if let Some(maxlen) = r.max_length {
meta.push(format!("max_len: {maxlen}"));
}
if !r.value_options.is_empty() {
let preview: Vec<&str> = r.value_options.iter().take(5).map(String::as_str).collect();
let more = r.value_options.len().saturating_sub(5);
let suffix = if more > 0 {
format!(", … +{more}")
} else {
String::new()
};
meta.push(format!("oneof: {}{suffix}", preview.join(", ")));
}
let meta_str = if meta.is_empty() {
String::new()
} else {
format!(" ({})", meta.join(", "))
};
body.push_str(&format!(" {marker} {name_padded}{value_str}{meta_str}\n"));
}
body.push_str(
"\n`:set-option NAMESPACE NAME VALUE` to change a setting.\n\
`:options NAMESPACE` to filter to one family.\n\
esc / q to close",
);
body
}