use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// A single password entry (stored encrypted in entries/.enc). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, pub password: String, #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub totp_secret: Option, pub created_at: String, pub updated_at: String, } /// Summary info about an entry (stored in the manifest). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestEntry { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, pub updated_at: String, } /// The vault manifest — maps entry IDs to their metadata. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { pub entries: HashMap, pub version: u32, } impl Manifest { pub fn new() -> Self { Manifest { entries: HashMap::new(), version: 1, } } pub fn add_entry(&mut self, id: String, entry: ManifestEntry) { self.entries.insert(id, entry); } pub fn remove_entry(&mut self, id: &str) -> Option { self.entries.remove(id) } pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> { let q = query.to_lowercase(); self.entries .iter() .filter(|(_, e)| { e.name.to_lowercase().contains(&q) || e.url .as_deref() .map(|u| u.to_lowercase().contains(&q)) .unwrap_or(false) }) .collect() } } impl Default for Manifest { fn default() -> Self { Self::new() } } /// Generate a random 8-character hex string to use as an entry ID. pub fn generate_entry_id() -> String { let mut rng = rand::thread_rng(); let bytes: [u8; 4] = rng.gen(); bytes.iter().map(|b| format!("{:02x}", b)).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn entry_serialization_round_trip() { let entry = Entry { name: "GitHub".to_string(), url: Some("https://github.com".to_string()), username: Some("alice".to_string()), password: "s3cr3t".to_string(), notes: None, totp_secret: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), }; let json = serde_json::to_string(&entry).unwrap(); let decoded: Entry = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.name, entry.name); assert_eq!(decoded.url, entry.url); assert_eq!(decoded.username, entry.username); assert_eq!(decoded.password, entry.password); assert_eq!(decoded.notes, entry.notes); } #[test] fn manifest_add_and_lookup() { let mut manifest = Manifest::new(); let me = ManifestEntry { name: "GitHub".to_string(), url: Some("https://github.com".to_string()), username: Some("alice".to_string()), updated_at: "2024-01-01T00:00:00Z".to_string(), }; manifest.add_entry("abc12345".to_string(), me); assert!(manifest.entries.contains_key("abc12345")); assert_eq!(manifest.entries["abc12345"].name, "GitHub"); let removed = manifest.remove_entry("abc12345"); assert!(removed.is_some()); assert!(!manifest.entries.contains_key("abc12345")); } #[test] fn manifest_serialization_round_trip() { let mut manifest = Manifest::new(); manifest.add_entry( "deadbeef".to_string(), ManifestEntry { name: "Gmail".to_string(), url: Some("https://mail.google.com".to_string()), username: Some("user@gmail.com".to_string()), updated_at: "2024-06-01T00:00:00Z".to_string(), }, ); let json = serde_json::to_string(&manifest).unwrap(); let decoded: Manifest = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.version, 1); assert!(decoded.entries.contains_key("deadbeef")); assert_eq!(decoded.entries["deadbeef"].name, "Gmail"); } #[test] fn generate_entry_id_is_8_hex_chars() { let id = generate_entry_id(); assert_eq!(id.len(), 8); assert!(id.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn manifest_search_case_insensitive() { let mut manifest = Manifest::new(); manifest.add_entry( "id001".to_string(), ManifestEntry { name: "GitHub Account".to_string(), url: Some("https://github.com".to_string()), username: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, ); manifest.add_entry( "id002".to_string(), ManifestEntry { name: "Work Email".to_string(), url: Some("https://mail.example.com".to_string()), username: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, ); // partial name match, case-insensitive let results = manifest.search("github"); assert_eq!(results.len(), 1); assert_eq!(results[0].1.name, "GitHub Account"); // partial URL match let results = manifest.search("mail.example"); assert_eq!(results.len(), 1); assert_eq!(results[0].1.name, "Work Email"); // no match let results = manifest.search("nonexistent"); assert_eq!(results.len(), 0); } }