feat: add group field to Entry and ManifestEntry
Add optional group: Option<String> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -457,6 +457,7 @@ fn cmd_add() -> Result<()> {
|
|||||||
password,
|
password,
|
||||||
notes,
|
notes,
|
||||||
totp_secret,
|
totp_secret,
|
||||||
|
group: None,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now.clone(),
|
updated_at: now.clone(),
|
||||||
};
|
};
|
||||||
@@ -476,6 +477,7 @@ fn cmd_add() -> Result<()> {
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
url,
|
url,
|
||||||
username,
|
username,
|
||||||
|
group: None,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -660,6 +662,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
|||||||
password,
|
password,
|
||||||
notes,
|
notes,
|
||||||
totp_secret,
|
totp_secret,
|
||||||
|
group: entry.group,
|
||||||
created_at: entry.created_at,
|
created_at: entry.created_at,
|
||||||
updated_at: now.clone(),
|
updated_at: now.clone(),
|
||||||
};
|
};
|
||||||
@@ -678,6 +681,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
|||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
url,
|
url,
|
||||||
username,
|
username,
|
||||||
|
group: None,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ use std::collections::HashMap;
|
|||||||
/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional.
|
/// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional.
|
||||||
/// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created.
|
/// - `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.
|
/// - `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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -55,6 +56,9 @@ pub struct Entry {
|
|||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub totp_secret: Option<String>,
|
pub totp_secret: Option<String>,
|
||||||
|
/// Optional group for organizing entries (e.g. "work", "personal").
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub group: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -75,6 +79,9 @@ pub struct ManifestEntry {
|
|||||||
/// Account username for display in entry listings.
|
/// Account username for display in entry listings.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub username: Option<String>,
|
pub username: Option<String>,
|
||||||
|
/// Optional group for organizing entries (e.g. "work", "personal").
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub group: Option<String>,
|
||||||
/// Timestamp of last modification, used for sorting and display.
|
/// Timestamp of last modification, used for sorting and display.
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
@@ -168,6 +175,7 @@ mod tests {
|
|||||||
password: "s3cr3t".to_string(),
|
password: "s3cr3t".to_string(),
|
||||||
notes: None,
|
notes: None,
|
||||||
totp_secret: None,
|
totp_secret: None,
|
||||||
|
group: None,
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
updated_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(),
|
name: "GitHub".to_string(),
|
||||||
url: Some("https://github.com".to_string()),
|
url: Some("https://github.com".to_string()),
|
||||||
username: Some("alice".to_string()),
|
username: Some("alice".to_string()),
|
||||||
|
group: None,
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
};
|
};
|
||||||
manifest.add_entry("abc12345".to_string(), me);
|
manifest.add_entry("abc12345".to_string(), me);
|
||||||
@@ -210,6 +219,7 @@ mod tests {
|
|||||||
name: "Gmail".to_string(),
|
name: "Gmail".to_string(),
|
||||||
url: Some("https://mail.google.com".to_string()),
|
url: Some("https://mail.google.com".to_string()),
|
||||||
username: Some("user@gmail.com".to_string()),
|
username: Some("user@gmail.com".to_string()),
|
||||||
|
group: None,
|
||||||
updated_at: "2024-06-01T00:00:00Z".to_string(),
|
updated_at: "2024-06-01T00:00:00Z".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -238,6 +248,7 @@ mod tests {
|
|||||||
name: "GitHub Account".to_string(),
|
name: "GitHub Account".to_string(),
|
||||||
url: Some("https://github.com".to_string()),
|
url: Some("https://github.com".to_string()),
|
||||||
username: None,
|
username: None,
|
||||||
|
group: None,
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -247,6 +258,7 @@ mod tests {
|
|||||||
name: "Work Email".to_string(),
|
name: "Work Email".to_string(),
|
||||||
url: Some("https://mail.example.com".to_string()),
|
url: Some("https://mail.example.com".to_string()),
|
||||||
username: None,
|
username: None,
|
||||||
|
group: None,
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -265,4 +277,59 @@ mod tests {
|
|||||||
let results = manifest.search("nonexistent");
|
let results = manifest.search("nonexistent");
|
||||||
assert_eq!(results.len(), 0);
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ mod tests {
|
|||||||
password: "secret123".to_string(),
|
password: "secret123".to_string(),
|
||||||
notes: None,
|
notes: None,
|
||||||
totp_secret: None,
|
totp_secret: None,
|
||||||
|
group: None,
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
updated_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(),
|
name: "GitHub".to_string(),
|
||||||
url: Some("https://github.com".to_string()),
|
url: Some("https://github.com".to_string()),
|
||||||
username: Some("alice".to_string()),
|
username: Some("alice".to_string()),
|
||||||
|
group: None,
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ fn full_vault_workflow() {
|
|||||||
password: "supersecret123!".to_string(),
|
password: "supersecret123!".to_string(),
|
||||||
notes: Some("my main account".to_string()),
|
notes: Some("my main account".to_string()),
|
||||||
totp_secret: None,
|
totp_secret: None,
|
||||||
|
group: None,
|
||||||
created_at: "2024-01-01T00:00:00Z".to_string(),
|
created_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
updated_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(),
|
name: "GitHub".to_string(),
|
||||||
url: Some("https://github.com".to_string()),
|
url: Some("https://github.com".to_string()),
|
||||||
username: Some("alice".to_string()),
|
username: Some("alice".to_string()),
|
||||||
|
group: None,
|
||||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user