use std::collections::BTreeSet;
use zeroize::Zeroize;
use crate::cli::output;
use crate::cli::{load_keyfile, prompt_password_for_vault, Cli};
use crate::errors::{EnvVaultError, Result};
use crate::vault::VaultStore;
pub struct DiffResult {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<String>,
pub unchanged: Vec<String>,
}
pub fn execute(cli: &Cli, target_env: &str, show_values: bool) -> Result<()> {
let cwd = std::env::current_dir()?;
let vault_dir = cwd.join(&cli.vault_dir);
let env = &cli.env;
let source_path = vault_dir.join(format!("{env}.vault"));
let target_path = vault_dir.join(format!("{target_env}.vault"));
if !source_path.exists() {
return Err(EnvVaultError::EnvironmentNotFound(cli.env.clone()));
}
if !target_path.exists() {
return Err(EnvVaultError::EnvironmentNotFound(target_env.to_string()));
}
let keyfile = load_keyfile(cli)?;
let vault_id = source_path.to_string_lossy();
let password = prompt_password_for_vault(Some(&vault_id))?;
let source = VaultStore::open(&source_path, password.as_bytes(), keyfile.as_deref())?;
let mut source_secrets = source.get_all_secrets()?;
let mut target_secrets =
match VaultStore::open(&target_path, password.as_bytes(), keyfile.as_deref()) {
Ok(target) => target.get_all_secrets()?,
Err(EnvVaultError::HmacMismatch | EnvVaultError::DecryptionFailed) => {
output::info(&format!(
"Target vault '{target_env}' uses a different password."
));
let target_vault_id = target_path.to_string_lossy();
let target_pw = prompt_password_for_vault(Some(&target_vault_id))?;
let target =
VaultStore::open(&target_path, target_pw.as_bytes(), keyfile.as_deref())?;
target.get_all_secrets()?
}
Err(e) => return Err(e),
};
let diff = compute_diff(&source_secrets, &target_secrets);
crate::audit::log_audit(
cli,
"diff",
None,
Some(&format!("compared {env} vs {target_env}")),
);
print_diff(
cli,
target_env,
&diff,
&source_secrets,
&target_secrets,
show_values,
);
for v in source_secrets.values_mut() {
v.zeroize();
}
for v in target_secrets.values_mut() {
v.zeroize();
}
Ok(())
}
pub fn compute_diff(
source: &std::collections::HashMap<String, String>,
target: &std::collections::HashMap<String, String>,
) -> DiffResult {
let source_keys: BTreeSet<&String> = source.keys().collect();
let target_keys: BTreeSet<&String> = target.keys().collect();
let added: Vec<String> = target_keys
.difference(&source_keys)
.map(|k| (*k).clone())
.collect();
let removed: Vec<String> = source_keys
.difference(&target_keys)
.map(|k| (*k).clone())
.collect();
let (mut changed, mut unchanged): (Vec<String>, Vec<String>) = source_keys
.intersection(&target_keys)
.map(|k| (*k).clone())
.partition(|key| source[key] != target[key]);
changed.sort();
unchanged.sort();
DiffResult {
added,
removed,
changed,
unchanged,
}
}
fn print_diff(
cli: &Cli,
target_env: &str,
diff: &DiffResult,
source: &std::collections::HashMap<String, String>,
target: &std::collections::HashMap<String, String>,
show_values: bool,
) {
use console::style;
println!(
"\n{} {} vs {}",
style("Diff:").bold(),
style(&cli.env).cyan(),
style(target_env).cyan()
);
println!();
for key in &diff.added {
if show_values {
println!(
" {} {} = {}",
style("+").green().bold(),
style(key).green(),
style(&target[key]).green()
);
} else {
println!(" {} {}", style("+").green().bold(), style(key).green());
}
}
for key in &diff.removed {
if show_values {
println!(
" {} {} = {}",
style("-").red().bold(),
style(key).red(),
style(&source[key]).red()
);
} else {
println!(" {} {}", style("-").red().bold(), style(key).red());
}
}
for key in &diff.changed {
if show_values {
println!(
" {} {} = {} → {}",
style("~").yellow().bold(),
style(key).yellow(),
style(&source[key]).red(),
style(&target[key]).green()
);
} else {
println!(
" {} {} {}",
style("~").yellow().bold(),
style(key).yellow(),
style("(changed)").dim()
);
}
}
println!();
println!(
" {} added, {} removed, {} changed, {} unchanged",
style(diff.added.len()).green().bold(),
style(diff.removed.len()).red().bold(),
style(diff.changed.len()).yellow().bold(),
style(diff.unchanged.len()).dim()
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn diff_identical_vaults() {
let mut a = HashMap::new();
a.insert("KEY".into(), "value".into());
let diff = compute_diff(&a, &a);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert!(diff.changed.is_empty());
assert_eq!(diff.unchanged, vec!["KEY"]);
}
#[test]
fn diff_added_keys() {
let a = HashMap::new();
let mut b = HashMap::new();
b.insert("NEW_KEY".into(), "value".into());
let diff = compute_diff(&a, &b);
assert_eq!(diff.added, vec!["NEW_KEY"]);
assert!(diff.removed.is_empty());
assert!(diff.changed.is_empty());
}
#[test]
fn diff_removed_keys() {
let mut a = HashMap::new();
a.insert("OLD_KEY".into(), "value".into());
let b = HashMap::new();
let diff = compute_diff(&a, &b);
assert!(diff.added.is_empty());
assert_eq!(diff.removed, vec!["OLD_KEY"]);
assert!(diff.changed.is_empty());
}
#[test]
fn diff_changed_values() {
let mut a = HashMap::new();
a.insert("KEY".into(), "old_value".into());
let mut b = HashMap::new();
b.insert("KEY".into(), "new_value".into());
let diff = compute_diff(&a, &b);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert_eq!(diff.changed, vec!["KEY"]);
assert!(diff.unchanged.is_empty());
}
#[test]
fn diff_mixed_changes() {
let mut source = HashMap::new();
source.insert("KEEP".into(), "same".into());
source.insert("MODIFY".into(), "old".into());
source.insert("REMOVE".into(), "gone".into());
let mut target = HashMap::new();
target.insert("KEEP".into(), "same".into());
target.insert("MODIFY".into(), "new".into());
target.insert("ADD".into(), "fresh".into());
let diff = compute_diff(&source, &target);
assert_eq!(diff.added, vec!["ADD"]);
assert_eq!(diff.removed, vec!["REMOVE"]);
assert_eq!(diff.changed, vec!["MODIFY"]);
assert_eq!(diff.unchanged, vec!["KEEP"]);
}
#[test]
fn diff_empty_vaults() {
let a: HashMap<String, String> = HashMap::new();
let b: HashMap<String, String> = HashMap::new();
let diff = compute_diff(&a, &b);
assert!(diff.added.is_empty());
assert!(diff.removed.is_empty());
assert!(diff.changed.is_empty());
assert!(diff.unchanged.is_empty());
}
#[test]
fn diff_results_are_sorted() {
let mut source = HashMap::new();
source.insert("Z_KEY".into(), "v".into());
source.insert("A_KEY".into(), "v".into());
let mut target = HashMap::new();
target.insert("M_KEY".into(), "v".into());
target.insert("B_KEY".into(), "v".into());
let diff = compute_diff(&source, &target);
assert_eq!(diff.added, vec!["B_KEY", "M_KEY"]);
assert_eq!(diff.removed, vec!["A_KEY", "Z_KEY"]);
}
#[test]
fn diff_same_key_same_value_is_unchanged() {
let mut a = HashMap::new();
a.insert("DB_URL".into(), "postgres://localhost".into());
let mut b = HashMap::new();
b.insert("DB_URL".into(), "postgres://localhost".into());
let diff = compute_diff(&a, &b);
assert!(diff.changed.is_empty());
assert_eq!(diff.unchanged, vec!["DB_URL"]);
}
}