//! New typed-item manifest. Lives next to the old entry.rs Manifest //! during this rewrite; entry.rs is deleted in Task 25. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use crate::attachment::AttachmentSummary; use crate::ids::ItemId; use crate::item::Item; use crate::item_types::ItemType; pub const MANIFEST_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { pub schema_version: u32, pub items: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestEntry { pub id: ItemId, pub r#type: ItemType, pub title: String, #[serde(default)] pub tags: Vec, #[serde(default)] pub favorite: bool, #[serde(skip_serializing_if = "Option::is_none")] pub group: Option, #[serde(skip_serializing_if = "Option::is_none")] pub icon_hint: Option, pub modified: i64, #[serde(skip_serializing_if = "Option::is_none")] pub trashed_at: Option, #[serde(default)] pub attachment_summaries: Vec, } impl Manifest { pub fn new() -> Self { Self { schema_version: MANIFEST_SCHEMA_VERSION, items: HashMap::new() } } pub fn upsert(&mut self, item: &Item) { let entry = ManifestEntry::from_item(item); self.items.insert(item.id.clone(), entry); } pub fn remove(&mut self, id: &ItemId) -> Option { self.items.remove(id) } pub fn get(&self, id: &ItemId) -> Option<&ManifestEntry> { self.items.get(id) } /// Case-insensitive substring match on title and tags. pub fn search(&self, query: &str) -> Vec<&ManifestEntry> { let q = query.to_lowercase(); self.items .values() .filter(|e| { e.title.to_lowercase().contains(&q) || e.tags.iter().any(|t| t.to_lowercase().contains(&q)) }) .collect() } } impl Default for Manifest { fn default() -> Self { Self::new() } } impl ManifestEntry { pub fn from_item(item: &Item) -> Self { Self { id: item.id.clone(), r#type: item.r#type, title: item.title.clone(), tags: item.tags.clone(), favorite: item.favorite, group: item.group.clone(), icon_hint: derive_icon_hint(item), modified: item.modified, trashed_at: item.trashed_at, attachment_summaries: item.attachments.iter().map(Into::into).collect(), } } } /// Derive an icon hint string from an item — for Login items, this is the URL hostname. fn derive_icon_hint(item: &Item) -> Option { use crate::item_types::ItemCore; match &item.core { ItemCore::Login(l) => l.url.as_ref().and_then(|u| u.host_str().map(str::to_owned)), _ => None, } } #[cfg(test)] mod tests { use super::*; use crate::item_types::{ItemCore, LoginCore, SecureNoteCore}; #[test] fn empty_manifest_has_schema_v2() { let m = Manifest::new(); assert_eq!(m.schema_version, MANIFEST_SCHEMA_VERSION); assert!(m.items.is_empty()); } #[test] fn upsert_and_search() { let mut m = Manifest::new(); let mut item = Item::new("GitHub".into(), ItemCore::Login(LoginCore::default())); item.tags = vec!["work".into()]; m.upsert(&item); let results = m.search("github"); assert_eq!(results.len(), 1); let by_tag = m.search("work"); assert_eq!(by_tag.len(), 1); } #[test] fn icon_hint_is_login_url_host() { use url::Url; let mut m = Manifest::new(); let core = ItemCore::Login(LoginCore { url: Some(Url::parse("https://api.github.com/login").unwrap()), ..Default::default() }); let item = Item::new("X".into(), core); m.upsert(&item); let entry = m.items.values().next().unwrap(); assert_eq!(entry.icon_hint.as_deref(), Some("api.github.com")); } #[test] fn icon_hint_is_none_for_non_login() { let mut m = Manifest::new(); let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore::default())); m.upsert(&item); let entry = m.items.values().next().unwrap(); assert!(entry.icon_hint.is_none()); } #[test] fn manifest_round_trips() { let mut m = Manifest::new(); let item = Item::new("X".into(), ItemCore::SecureNote(SecureNoteCore::default())); m.upsert(&item); let json = serde_json::to_string(&m).unwrap(); let parsed: Manifest = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.schema_version, MANIFEST_SCHEMA_VERSION); assert_eq!(parsed.items.len(), 1); } }