use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EntryStatus {
Synced,
Modified,
VaultOnly,
EnvOnly,
}
#[derive(Debug, Clone)]
pub struct DiffEntry {
key: String,
status: EntryStatus,
}
impl DiffEntry {
pub fn new(key: String, status: EntryStatus) -> Self {
Self { key, status }
}
pub fn key(&self) -> &str {
&self.key
}
pub fn status(&self) -> &EntryStatus {
&self.status
}
pub fn is_synced(&self) -> bool {
matches!(self.status, EntryStatus::Synced)
}
}
#[derive(Debug)]
pub struct Diff {
entries: Vec<DiffEntry>,
}
impl Diff {
pub fn compute(vault_secrets: &[(String, String)], env_secrets: &[(String, String)]) -> Self {
let vault_map: HashMap<_, _> = vault_secrets.iter().cloned().collect();
let env_map: HashMap<_, _> = env_secrets.iter().cloned().collect();
let vault_keys: HashSet<_> = vault_map.keys().collect();
let env_keys: HashSet<_> = env_map.keys().collect();
let mut entries = Vec::new();
let all_keys: HashSet<_> = vault_keys.union(&env_keys).collect();
for key in all_keys {
let vault_value = vault_map.get(*key);
let env_value = env_map.get(*key);
let status = match (vault_value, env_value) {
(Some(v), Some(e)) if v == e => EntryStatus::Synced,
(Some(_), Some(_)) => EntryStatus::Modified,
(Some(_), None) => EntryStatus::VaultOnly,
(None, Some(_)) => EntryStatus::EnvOnly,
(None, None) => unreachable!("key must exist in at least one map"),
};
entries.push(DiffEntry::new((*key).clone(), status));
}
entries.sort_by(|a, b| a.key.cmp(&b.key));
Self { entries }
}
pub fn entries(&self) -> &[DiffEntry] {
&self.entries
}
pub fn synced(&self) -> Vec<&DiffEntry> {
self.entries
.iter()
.filter(|e| matches!(e.status, EntryStatus::Synced))
.collect()
}
pub fn modified(&self) -> Vec<&DiffEntry> {
self.entries
.iter()
.filter(|e| matches!(e.status, EntryStatus::Modified))
.collect()
}
pub fn vault_only(&self) -> Vec<&DiffEntry> {
self.entries
.iter()
.filter(|e| matches!(e.status, EntryStatus::VaultOnly))
.collect()
}
pub fn env_only(&self) -> Vec<&DiffEntry> {
self.entries
.iter()
.filter(|e| matches!(e.status, EntryStatus::EnvOnly))
.collect()
}
pub fn is_synced(&self) -> bool {
self.entries.iter().all(|e| e.is_synced())
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diff_all_synced() {
let vault = vec![
("API_KEY".to_string(), "secret123".to_string()),
("DB_URL".to_string(), "postgres://".to_string()),
];
let env = vault.clone();
let diff = Diff::compute(&vault, &env);
assert_eq!(diff.len(), 2);
assert!(diff.is_synced());
assert_eq!(diff.synced().len(), 2);
assert_eq!(diff.modified().len(), 0);
assert_eq!(diff.vault_only().len(), 0);
assert_eq!(diff.env_only().len(), 0);
}
#[test]
fn test_diff_modified() {
let vault = vec![("API_KEY".to_string(), "secret123".to_string())];
let env = vec![("API_KEY".to_string(), "different".to_string())];
let diff = Diff::compute(&vault, &env);
assert_eq!(diff.len(), 1);
assert!(!diff.is_synced());
assert_eq!(diff.modified().len(), 1);
assert_eq!(diff.modified()[0].key(), "API_KEY");
}
#[test]
fn test_diff_vault_only() {
let vault = vec![
("API_KEY".to_string(), "secret123".to_string()),
("VAULT_SECRET".to_string(), "value".to_string()),
];
let env = vec![("API_KEY".to_string(), "secret123".to_string())];
let diff = Diff::compute(&vault, &env);
assert_eq!(diff.len(), 2);
assert!(!diff.is_synced());
assert_eq!(diff.vault_only().len(), 1);
assert_eq!(diff.vault_only()[0].key(), "VAULT_SECRET");
}
#[test]
fn test_diff_env_only() {
let vault = vec![("API_KEY".to_string(), "secret123".to_string())];
let env = vec![
("API_KEY".to_string(), "secret123".to_string()),
("UNTRACKED".to_string(), "value".to_string()),
];
let diff = Diff::compute(&vault, &env);
assert_eq!(diff.len(), 2);
assert!(!diff.is_synced());
assert_eq!(diff.env_only().len(), 1);
assert_eq!(diff.env_only()[0].key(), "UNTRACKED");
}
#[test]
fn test_diff_mixed() {
let vault = vec![
("SYNCED".to_string(), "same".to_string()),
("MODIFIED".to_string(), "old".to_string()),
("VAULT_ONLY".to_string(), "secret".to_string()),
];
let env = vec![
("SYNCED".to_string(), "same".to_string()),
("MODIFIED".to_string(), "new".to_string()),
("ENV_ONLY".to_string(), "local".to_string()),
];
let diff = Diff::compute(&vault, &env);
assert_eq!(diff.len(), 4);
assert!(!diff.is_synced());
assert_eq!(diff.synced().len(), 1);
assert_eq!(diff.modified().len(), 1);
assert_eq!(diff.vault_only().len(), 1);
assert_eq!(diff.env_only().len(), 1);
}
#[test]
fn test_diff_empty() {
let vault: Vec<(String, String)> = vec![];
let env: Vec<(String, String)> = vec![];
let diff = Diff::compute(&vault, &env);
assert!(diff.is_empty());
assert!(diff.is_synced());
assert_eq!(diff.len(), 0);
}
#[test]
fn test_diff_entry_is_synced() {
let synced = DiffEntry::new("KEY".to_string(), EntryStatus::Synced);
let modified = DiffEntry::new("KEY".to_string(), EntryStatus::Modified);
assert!(synced.is_synced());
assert!(!modified.is_synced());
}
}