1use 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
14pub struct DiffResult {
16 pub added: Vec<String>,
17 pub removed: Vec<String>,
18 pub changed: Vec<String>,
19 pub unchanged: Vec<String>,
20}
21
22pub 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 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 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 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 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_diff(
75 cli,
76 target_env,
77 &diff,
78 &source_secrets,
79 &target_secrets,
80 show_values,
81 );
82
83 Ok(())
84}
85
86pub 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 let added: Vec<String> = target_keys
96 .difference(&source_keys)
97 .map(|k| (*k).clone())
98 .collect();
99
100 let removed: Vec<String> = source_keys
102 .difference(&target_keys)
103 .map(|k| (*k).clone())
104 .collect();
105
106 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
123fn 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}