Skip to main content

envvault/cli/commands/
diff.rs

1//! `envvault diff` — compare secrets between two environments.
2//!
3//! Usage:
4//!   envvault diff staging              # compare dev (default) vs staging
5//!   envvault --env prod diff staging --show-values
6
7use std::collections::BTreeSet;
8
9use crate::cli::output;
10use crate::cli::{load_keyfile, prompt_password_for_vault, Cli};
11use crate::errors::{EnvVaultError, Result};
12use crate::vault::VaultStore;
13
14/// Outcome of comparing two vaults.
15pub struct DiffResult {
16    pub added: Vec<String>,
17    pub removed: Vec<String>,
18    pub changed: Vec<String>,
19    pub unchanged: Vec<String>,
20}
21
22/// Execute the `diff` command.
23pub fn execute(cli: &Cli, target_env: &str, show_values: bool) -> Result<()> {
24    let cwd = std::env::current_dir()?;
25    let vault_dir = cwd.join(&cli.vault_dir);
26
27    let env = &cli.env;
28    let source_path = vault_dir.join(format!("{env}.vault"));
29    let target_path = vault_dir.join(format!("{target_env}.vault"));
30
31    if !source_path.exists() {
32        return Err(EnvVaultError::EnvironmentNotFound(cli.env.clone()));
33    }
34    if !target_path.exists() {
35        return Err(EnvVaultError::EnvironmentNotFound(target_env.to_string()));
36    }
37
38    // Open source vault.
39    let keyfile = load_keyfile(cli)?;
40    let vault_id = source_path.to_string_lossy();
41    let password = prompt_password_for_vault(Some(&vault_id))?;
42    let source = VaultStore::open(&source_path, password.as_bytes(), keyfile.as_deref())?;
43    let source_secrets = source.get_all_secrets()?;
44
45    // Try opening target with the same password first.
46    let target_secrets =
47        match VaultStore::open(&target_path, password.as_bytes(), keyfile.as_deref()) {
48            Ok(target) => target.get_all_secrets()?,
49            Err(EnvVaultError::HmacMismatch | EnvVaultError::DecryptionFailed) => {
50                // Different password — prompt for target.
51                output::info(&format!(
52                    "Target vault '{target_env}' uses a different password."
53                ));
54                let target_vault_id = target_path.to_string_lossy();
55                let target_pw = prompt_password_for_vault(Some(&target_vault_id))?;
56                let target =
57                    VaultStore::open(&target_path, target_pw.as_bytes(), keyfile.as_deref())?;
58                target.get_all_secrets()?
59            }
60            Err(e) => return Err(e),
61        };
62
63    // Compute diff.
64    let diff = compute_diff(&source_secrets, &target_secrets);
65
66    crate::audit::log_audit(
67        cli,
68        "diff",
69        None,
70        Some(&format!("compared {env} vs {target_env}")),
71    );
72
73    // Print results.
74    print_diff(
75        cli,
76        target_env,
77        &diff,
78        &source_secrets,
79        &target_secrets,
80        show_values,
81    );
82
83    Ok(())
84}
85
86/// Compare two secret maps and categorize keys.
87pub fn compute_diff(
88    source: &std::collections::HashMap<String, String>,
89    target: &std::collections::HashMap<String, String>,
90) -> DiffResult {
91    let source_keys: BTreeSet<&String> = source.keys().collect();
92    let target_keys: BTreeSet<&String> = target.keys().collect();
93
94    // Keys only in target = added (already sorted by BTreeSet).
95    let added: Vec<String> = target_keys
96        .difference(&source_keys)
97        .map(|k| (*k).clone())
98        .collect();
99
100    // Keys only in source = removed (already sorted by BTreeSet).
101    let removed: Vec<String> = source_keys
102        .difference(&target_keys)
103        .map(|k| (*k).clone())
104        .collect();
105
106    // Keys in both — partition into changed vs unchanged.
107    let (mut changed, mut unchanged): (Vec<String>, Vec<String>) = source_keys
108        .intersection(&target_keys)
109        .map(|k| (*k).clone())
110        .partition(|key| source[key] != target[key]);
111
112    changed.sort();
113    unchanged.sort();
114
115    DiffResult {
116        added,
117        removed,
118        changed,
119        unchanged,
120    }
121}
122
123/// Print the diff results with colored output.
124fn print_diff(
125    cli: &Cli,
126    target_env: &str,
127    diff: &DiffResult,
128    source: &std::collections::HashMap<String, String>,
129    target: &std::collections::HashMap<String, String>,
130    show_values: bool,
131) {
132    use console::style;
133
134    println!(
135        "\n{} {} vs {}",
136        style("Diff:").bold(),
137        style(&cli.env).cyan(),
138        style(target_env).cyan()
139    );
140    println!();
141
142    for key in &diff.added {
143        if show_values {
144            println!(
145                "  {} {} = {}",
146                style("+").green().bold(),
147                style(key).green(),
148                style(&target[key]).green()
149            );
150        } else {
151            println!("  {} {}", style("+").green().bold(), style(key).green());
152        }
153    }
154
155    for key in &diff.removed {
156        if show_values {
157            println!(
158                "  {} {} = {}",
159                style("-").red().bold(),
160                style(key).red(),
161                style(&source[key]).red()
162            );
163        } else {
164            println!("  {} {}", style("-").red().bold(), style(key).red());
165        }
166    }
167
168    for key in &diff.changed {
169        if show_values {
170            println!(
171                "  {} {} = {} → {}",
172                style("~").yellow().bold(),
173                style(key).yellow(),
174                style(&source[key]).red(),
175                style(&target[key]).green()
176            );
177        } else {
178            println!(
179                "  {} {} {}",
180                style("~").yellow().bold(),
181                style(key).yellow(),
182                style("(changed)").dim()
183            );
184        }
185    }
186
187    println!();
188    println!(
189        "  {} added, {} removed, {} changed, {} unchanged",
190        style(diff.added.len()).green().bold(),
191        style(diff.removed.len()).red().bold(),
192        style(diff.changed.len()).yellow().bold(),
193        style(diff.unchanged.len()).dim()
194    );
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use std::collections::HashMap;
201
202    #[test]
203    fn diff_identical_vaults() {
204        let mut a = HashMap::new();
205        a.insert("KEY".into(), "value".into());
206
207        let diff = compute_diff(&a, &a);
208        assert!(diff.added.is_empty());
209        assert!(diff.removed.is_empty());
210        assert!(diff.changed.is_empty());
211        assert_eq!(diff.unchanged, vec!["KEY"]);
212    }
213
214    #[test]
215    fn diff_added_keys() {
216        let a = HashMap::new();
217        let mut b = HashMap::new();
218        b.insert("NEW_KEY".into(), "value".into());
219
220        let diff = compute_diff(&a, &b);
221        assert_eq!(diff.added, vec!["NEW_KEY"]);
222        assert!(diff.removed.is_empty());
223        assert!(diff.changed.is_empty());
224    }
225
226    #[test]
227    fn diff_removed_keys() {
228        let mut a = HashMap::new();
229        a.insert("OLD_KEY".into(), "value".into());
230        let b = HashMap::new();
231
232        let diff = compute_diff(&a, &b);
233        assert!(diff.added.is_empty());
234        assert_eq!(diff.removed, vec!["OLD_KEY"]);
235        assert!(diff.changed.is_empty());
236    }
237
238    #[test]
239    fn diff_changed_values() {
240        let mut a = HashMap::new();
241        a.insert("KEY".into(), "old_value".into());
242        let mut b = HashMap::new();
243        b.insert("KEY".into(), "new_value".into());
244
245        let diff = compute_diff(&a, &b);
246        assert!(diff.added.is_empty());
247        assert!(diff.removed.is_empty());
248        assert_eq!(diff.changed, vec!["KEY"]);
249        assert!(diff.unchanged.is_empty());
250    }
251
252    #[test]
253    fn diff_mixed_changes() {
254        let mut source = HashMap::new();
255        source.insert("KEEP".into(), "same".into());
256        source.insert("MODIFY".into(), "old".into());
257        source.insert("REMOVE".into(), "gone".into());
258
259        let mut target = HashMap::new();
260        target.insert("KEEP".into(), "same".into());
261        target.insert("MODIFY".into(), "new".into());
262        target.insert("ADD".into(), "fresh".into());
263
264        let diff = compute_diff(&source, &target);
265        assert_eq!(diff.added, vec!["ADD"]);
266        assert_eq!(diff.removed, vec!["REMOVE"]);
267        assert_eq!(diff.changed, vec!["MODIFY"]);
268        assert_eq!(diff.unchanged, vec!["KEEP"]);
269    }
270
271    #[test]
272    fn diff_empty_vaults() {
273        let a: HashMap<String, String> = HashMap::new();
274        let b: HashMap<String, String> = HashMap::new();
275
276        let diff = compute_diff(&a, &b);
277        assert!(diff.added.is_empty());
278        assert!(diff.removed.is_empty());
279        assert!(diff.changed.is_empty());
280        assert!(diff.unchanged.is_empty());
281    }
282
283    #[test]
284    fn diff_results_are_sorted() {
285        let mut source = HashMap::new();
286        source.insert("Z_KEY".into(), "v".into());
287        source.insert("A_KEY".into(), "v".into());
288
289        let mut target = HashMap::new();
290        target.insert("M_KEY".into(), "v".into());
291        target.insert("B_KEY".into(), "v".into());
292
293        let diff = compute_diff(&source, &target);
294        assert_eq!(diff.added, vec!["B_KEY", "M_KEY"]);
295        assert_eq!(diff.removed, vec!["A_KEY", "Z_KEY"]);
296    }
297
298    #[test]
299    fn diff_same_key_same_value_is_unchanged() {
300        let mut a = HashMap::new();
301        a.insert("DB_URL".into(), "postgres://localhost".into());
302        let mut b = HashMap::new();
303        b.insert("DB_URL".into(), "postgres://localhost".into());
304
305        let diff = compute_diff(&a, &b);
306        assert!(diff.changed.is_empty());
307        assert_eq!(diff.unchanged, vec!["DB_URL"]);
308    }
309}