From 7baec1cd6715cbb4a86018336da12a9bb0f234e3 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 12 Apr 2026 09:25:18 -0400 Subject: [PATCH] feat: add group field to Entry and ManifestEntry Add optional group: Option to both Entry and ManifestEntry for logical organization (e.g. "work", "personal"). Backwards-compatible via skip_serializing_if so existing vaults deserialize with group: None. Includes three new tests verifying round-trip and legacy deserialization. Co-Authored-By: Claude Sonnet 4.6 --- crates/idfoto-cli/src/main.rs | 4 ++ crates/idfoto-core/src/entry.rs | 67 +++++++++++++++++++++++++ crates/idfoto-core/src/vault.rs | 2 + crates/idfoto-core/tests/integration.rs | 2 + 4 files changed, 75 insertions(+) diff --git a/crates/idfoto-cli/src/main.rs b/crates/idfoto-cli/src/main.rs index a020c80..00659bc 100644 --- a/crates/idfoto-cli/src/main.rs +++ b/crates/idfoto-cli/src/main.rs @@ -457,6 +457,7 @@ fn cmd_add() -> Result<()> { password, notes, totp_secret, + group: None, created_at: now.clone(), updated_at: now.clone(), }; @@ -476,6 +477,7 @@ fn cmd_add() -> Result<()> { name: name.clone(), url, username, + group: None, updated_at: now, }, ); @@ -660,6 +662,7 @@ fn cmd_edit(query: String) -> Result<()> { password, notes, totp_secret, + group: entry.group, created_at: entry.created_at, updated_at: now.clone(), }; @@ -678,6 +681,7 @@ fn cmd_edit(query: String) -> Result<()> { name: name.clone(), url, username, + group: None, updated_at: now, }, ); diff --git a/crates/idfoto-core/src/entry.rs b/crates/idfoto-core/src/entry.rs index 1670305..228b0b6 100644 --- a/crates/idfoto-core/src/entry.rs +++ b/crates/idfoto-core/src/entry.rs @@ -43,6 +43,7 @@ use std::collections::HashMap; /// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional. /// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created. /// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification. +/// - `group`: optional group label for organizing entries (e.g. "work", "personal"). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { pub name: String, @@ -55,6 +56,9 @@ pub struct Entry { pub notes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub totp_secret: Option, + /// Optional group for organizing entries (e.g. "work", "personal"). + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, pub created_at: String, pub updated_at: String, } @@ -75,6 +79,9 @@ pub struct ManifestEntry { /// Account username for display in entry listings. #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, + /// Optional group for organizing entries (e.g. "work", "personal"). + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, /// Timestamp of last modification, used for sorting and display. pub updated_at: String, } @@ -168,6 +175,7 @@ mod tests { password: "s3cr3t".to_string(), notes: None, totp_secret: None, + group: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), }; @@ -189,6 +197,7 @@ mod tests { name: "GitHub".to_string(), url: Some("https://github.com".to_string()), username: Some("alice".to_string()), + group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }; manifest.add_entry("abc12345".to_string(), me); @@ -210,6 +219,7 @@ mod tests { name: "Gmail".to_string(), url: Some("https://mail.google.com".to_string()), username: Some("user@gmail.com".to_string()), + group: None, updated_at: "2024-06-01T00:00:00Z".to_string(), }, ); @@ -238,6 +248,7 @@ mod tests { name: "GitHub Account".to_string(), url: Some("https://github.com".to_string()), username: None, + group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, ); @@ -247,6 +258,7 @@ mod tests { name: "Work Email".to_string(), url: Some("https://mail.example.com".to_string()), username: None, + group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, ); @@ -265,4 +277,59 @@ mod tests { let results = manifest.search("nonexistent"); assert_eq!(results.len(), 0); } + + #[test] + fn entry_deserializes_without_group_field() { + // JSON from an older vault that has no "group" key — must deserialize with group: None + let json = r#"{ + "name": "OldEntry", + "url": "https://example.com", + "username": "bob", + "password": "hunter2", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + }"#; + let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field"); + assert_eq!(entry.name, "OldEntry"); + assert_eq!(entry.group, None); + } + + #[test] + fn manifest_entry_deserializes_without_group_field() { + // JSON from an older manifest that has no "group" key — must deserialize with group: None + let json = r#"{ + "name": "OldEntry", + "url": "https://example.com", + "username": "bob", + "updated_at": "2024-01-01T00:00:00Z" + }"#; + let me: ManifestEntry = serde_json::from_str(json) + .expect("should deserialize ManifestEntry without group field"); + assert_eq!(me.name, "OldEntry"); + assert_eq!(me.group, None); + } + + #[test] + fn entry_with_group_round_trips() { + let entry = Entry { + name: "Work Laptop".to_string(), + url: None, + username: Some("alice@corp.example".to_string()), + password: "p@ssw0rd".to_string(), + notes: None, + totp_secret: None, + group: Some("work".to_string()), + created_at: "2024-03-15T00:00:00Z".to_string(), + updated_at: "2024-03-15T00:00:00Z".to_string(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + // The group field should be present in the JSON output + assert!(json.contains("\"group\""), "serialized JSON should contain group field"); + assert!(json.contains("\"work\""), "serialized JSON should contain group value"); + + let decoded: Entry = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.name, "Work Laptop"); + assert_eq!(decoded.group, Some("work".to_string())); + } } diff --git a/crates/idfoto-core/src/vault.rs b/crates/idfoto-core/src/vault.rs index bbe7498..7d4debc 100644 --- a/crates/idfoto-core/src/vault.rs +++ b/crates/idfoto-core/src/vault.rs @@ -94,6 +94,7 @@ mod tests { password: "secret123".to_string(), notes: None, totp_secret: None, + group: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), } @@ -122,6 +123,7 @@ mod tests { name: "GitHub".to_string(), url: Some("https://github.com".to_string()), username: Some("alice".to_string()), + group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, ); diff --git a/crates/idfoto-core/tests/integration.rs b/crates/idfoto-core/tests/integration.rs index 29fb40e..992f558 100644 --- a/crates/idfoto-core/tests/integration.rs +++ b/crates/idfoto-core/tests/integration.rs @@ -59,6 +59,7 @@ fn full_vault_workflow() { password: "supersecret123!".to_string(), notes: Some("my main account".to_string()), totp_secret: None, + group: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), }; @@ -102,6 +103,7 @@ fn full_vault_workflow() { name: "GitHub".to_string(), url: Some("https://github.com".to_string()), username: Some("alice".to_string()), + group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, );