use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ItemType {
Password,
Note,
Card,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ItemData {
Password {
username: String,
password: String,
url: String,
totp_secret: String,
notes: String,
},
Note {
format: String,
content: String,
},
Card {
holder: String,
number: String,
expiry: String,
cvv: String,
bank: String,
notes: String,
},
}
impl ItemData {
pub fn search_text(&self) -> String {
let mut parts: Vec<String> = Vec::new();
match self {
ItemData::Password {
username,
password,
url,
totp_secret,
notes,
} => {
parts.push(username.clone());
parts.push(password.clone());
parts.push(url.clone());
parts.push(totp_secret.clone());
parts.push(notes.clone());
}
ItemData::Note { format, content } => {
parts.push(format.clone());
parts.push(content.clone());
}
ItemData::Card {
holder,
number,
expiry,
cvv,
bank,
notes,
} => {
parts.push(holder.clone());
parts.push(number.clone());
parts.push(expiry.clone());
parts.push(cvv.clone());
parts.push(bank.clone());
parts.push(notes.clone());
}
}
parts.into_iter().filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" ")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Item {
pub id: Option<i64>,
pub item_type: ItemType,
pub title: String,
pub category_id: Option<i64>,
pub data: ItemData,
pub favorite: bool,
pub tags: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Category {
pub id: Option<i64>,
pub name: String,
pub parent_id: Option<i64>,
pub sort_order: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tag {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attachment {
pub id: Option<i64>,
pub item_id: i64,
pub filename: String,
pub mime_type: Option<String>,
pub size: i64,
pub blob: Vec<u8>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_type_serde_renames() {
assert_eq!(
serde_json::to_string(&ItemType::Password).unwrap(),
"\"password\""
);
assert_eq!(
serde_json::to_string(&ItemType::Note).unwrap(),
"\"note\""
);
assert_eq!(serde_json::to_string(&ItemType::Card).unwrap(), "\"card\"");
let t: ItemType = serde_json::from_str("\"card\"").unwrap();
assert_eq!(t, ItemType::Card);
}
#[test]
fn password_data_roundtrip() {
let data = ItemData::Password {
username: "alice".into(),
password: "s3cret".into(),
url: "https://example.com".into(),
totp_secret: "JBSWY3DPEHPK3PXP".into(),
notes: "main account".into(),
};
let json = serde_json::to_string(&data).unwrap();
let back: ItemData = serde_json::from_str(&json).unwrap();
assert_eq!(data, back);
assert!(json.contains("\"username\":\"alice\""));
}
#[test]
fn note_data_roundtrip() {
let data = ItemData::Note {
format: "markdown".into(),
content: "# Title\nsome **bold** text".into(),
};
let json = serde_json::to_string(&data).unwrap();
let back: ItemData = serde_json::from_str(&json).unwrap();
assert_eq!(data, back);
}
#[test]
fn card_data_roundtrip() {
let data = ItemData::Card {
holder: "BOB SMITH".into(),
number: "4111111111111111".into(),
expiry: "12/29".into(),
cvv: "123".into(),
bank: "Acme Bank".into(),
notes: "".into(),
};
let json = serde_json::to_string(&data).unwrap();
let back: ItemData = serde_json::from_str(&json).unwrap();
assert_eq!(data, back);
}
#[test]
fn search_text_nonempty_for_each_variant() {
let pw = ItemData::Password {
username: "u".into(),
password: "p".into(),
url: "https://x".into(),
totp_secret: "T".into(),
notes: "n".into(),
};
assert!(!pw.search_text().is_empty());
assert!(pw.search_text().contains("u"));
assert!(pw.search_text().contains("https://x"));
let note = ItemData::Note {
format: "text".into(),
content: "hello world".into(),
};
assert!(!note.search_text().is_empty());
assert!(note.search_text().contains("hello world"));
let card = ItemData::Card {
holder: "H".into(),
number: "1234".into(),
expiry: "01/30".into(),
cvv: "9".into(),
bank: "B".into(),
notes: "cn".into(),
};
assert!(!card.search_text().is_empty());
assert!(card.search_text().contains("1234"));
}
#[test]
fn search_text_skips_empty_fields() {
let pw = ItemData::Password {
username: "alice".into(),
password: "".into(),
url: "".into(),
totp_secret: "".into(),
notes: "".into(),
};
assert_eq!(pw.search_text(), "alice");
}
#[test]
fn item_struct_roundtrip() {
let item = Item {
id: Some(42),
item_type: ItemType::Note,
title: "My Note".into(),
category_id: Some(7),
data: ItemData::Note {
format: "markdown".into(),
content: "body".into(),
},
favorite: true,
tags: vec!["work".into(), "todo".into()],
created_at: 1_700_000_000,
updated_at: 1_700_000_100,
};
let json = serde_json::to_string(&item).unwrap();
let back: Item = serde_json::from_str(&json).unwrap();
assert_eq!(item, back);
}
#[test]
fn category_tag_attachment_roundtrip() {
let cat = Category {
id: Some(1),
name: "Personal".into(),
parent_id: None,
sort_order: 0,
};
let cat_back: Category = serde_json::from_str(&serde_json::to_string(&cat).unwrap()).unwrap();
assert_eq!(cat, cat_back);
let tag = Tag { id: 3, name: "vip".into() };
let tag_back: Tag = serde_json::from_str(&serde_json::to_string(&tag).unwrap()).unwrap();
assert_eq!(tag, tag_back);
let att = Attachment {
id: None,
item_id: 5,
filename: "a.png".into(),
mime_type: Some("image/png".into()),
size: 1024,
blob: vec![0xDE, 0xAD, 0xBE, 0xEF],
};
let att_back: Attachment =
serde_json::from_str(&serde_json::to_string(&att).unwrap()).unwrap();
assert_eq!(att, att_back);
}
}