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