use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Scope {
Project,
Global,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum TodoStatus {
Planned,
InProgress,
Done,
}
impl Default for TodoStatus {
fn default() -> Self {
Self::Planned
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Metadata {
pub id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub is_pinned: bool,
pub pinned_at: Option<DateTime<Utc>>,
pub is_deleted: bool,
pub deleted_at: Option<DateTime<Utc>>,
pub delete_protected: bool,
pub parent_id: Option<Uuid>,
pub title: String,
#[serde(default)]
pub status: TodoStatus,
#[serde(default)]
pub tags: Vec<String>,
}
impl<'de> Deserialize<'de> for Metadata {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let helper = MetadataHelper::deserialize(deserializer)?;
Ok(Metadata {
id: helper.id,
created_at: helper.created_at,
updated_at: helper.updated_at,
is_pinned: helper.is_pinned,
pinned_at: helper.pinned_at,
is_deleted: helper.is_deleted,
deleted_at: helper.deleted_at,
delete_protected: helper.delete_protected.unwrap_or(helper.is_pinned),
parent_id: helper.parent_id,
title: helper.title,
status: helper.status.unwrap_or(TodoStatus::Planned),
tags: helper.tags,
})
}
}
#[derive(Deserialize)]
struct MetadataHelper {
id: Uuid,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
is_pinned: bool,
pinned_at: Option<DateTime<Utc>>,
is_deleted: bool,
deleted_at: Option<DateTime<Utc>>,
#[serde(default)]
delete_protected: Option<bool>,
#[serde(default)]
parent_id: Option<Uuid>,
title: String,
#[serde(default)]
status: Option<TodoStatus>,
#[serde(default)]
tags: Vec<String>,
}
impl Metadata {
pub fn new(title: String) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4(),
created_at: now,
updated_at: now,
is_pinned: false,
pinned_at: None,
is_deleted: false,
deleted_at: None,
delete_protected: false,
parent_id: None,
title,
status: TodoStatus::Planned,
tags: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pad {
pub metadata: Metadata,
pub content: String,
}
impl Pad {
pub fn new(title: String, content: String) -> Self {
let (normalized_title, normalized_content) = normalize_pad_content(&title, &content);
Self {
metadata: Metadata::new(normalized_title),
content: normalized_content,
}
}
pub fn update_from_raw(&mut self, raw: &str) {
if let Some((title, content)) = parse_pad_content(raw) {
self.metadata.title = title;
self.content = content;
self.metadata.updated_at = Utc::now();
}
}
}
pub fn normalize_pad_content(title: &str, body: &str) -> (String, String) {
let clean_title = title.trim();
let display_title = if clean_title.chars().count() > 60 {
let truncated: String = clean_title.chars().take(59).collect();
format!("{}…", truncated)
} else {
clean_title.to_string()
};
let clean_body = body.trim();
let full_content = if clean_body.is_empty() {
clean_title.to_string()
} else {
format!("{}\n\n{}", clean_title, clean_body)
};
(display_title, full_content)
}
pub fn extract_title_and_body(raw: &str) -> Option<(String, String)> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut lines = trimmed.lines();
let title = lines.next().unwrap_or("").trim().to_string();
let rest_raw = lines.collect::<Vec<&str>>().join("\n");
let body = rest_raw.trim().to_string();
Some((title, body))
}
pub fn parse_pad_content(raw: &str) -> Option<(String, String)> {
let (title, body) = extract_title_and_body(raw)?;
Some(normalize_pad_content(&title, &body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_simple() {
let (title, content) = normalize_pad_content("My Title", "My Content");
assert_eq!(title, "My Title");
assert_eq!(content, "My Title\n\nMy Content");
}
#[test]
fn test_normalize_empty_body() {
let (title, content) = normalize_pad_content("Just Title", "");
assert_eq!(title, "Just Title");
assert_eq!(content, "Just Title");
}
#[test]
fn test_normalize_truncates_title_metadata() {
let long_title = "a".repeat(100);
let (title, content) = normalize_pad_content(&long_title, "Body");
assert_eq!(title.chars().count(), 60);
assert!(
title.ends_with('…'),
"Truncated title should end with ellipsis"
);
assert_eq!(content, format!("{}\n\nBody", long_title));
}
#[test]
fn test_parse_valid() {
let raw = "Title\n\nBody";
let (title, content) = parse_pad_content(raw).unwrap();
assert_eq!(title, "Title");
assert_eq!(content, "Title\n\nBody");
}
#[test]
fn test_parse_extra_blanks() {
let raw = "\n\nTitle\n\n\n\nBody\n\n";
let (title, content) = parse_pad_content(raw).unwrap();
assert_eq!(title, "Title");
assert_eq!(content, "Title\n\nBody");
}
#[test]
fn test_parse_empty_invalid() {
assert!(parse_pad_content(" \n ").is_none());
}
#[test]
fn test_parse_one_line() {
let (title, content) = parse_pad_content("OneLine").unwrap();
assert_eq!(title, "OneLine");
assert_eq!(content, "OneLine");
}
#[test]
fn test_metadata_serialization_roundtrip() {
let parent_id = Uuid::new_v4();
let mut meta = Metadata::new("Child Pad".to_string());
meta.parent_id = Some(parent_id);
let json = serde_json::to_string(&meta).unwrap();
let loaded: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.id, meta.id);
assert_eq!(loaded.parent_id, Some(parent_id));
assert_eq!(loaded.title, "Child Pad");
}
#[test]
fn test_legacy_metadata_deserialization() {
let id = Uuid::new_v4();
let json = format!(
r#"{{
"id": "{}",
"created_at": "2023-01-01T00:00:00Z",
"updated_at": "2023-01-01T00:00:00Z",
"is_pinned": false,
"pinned_at": null,
"is_deleted": false,
"deleted_at": null,
"delete_protected": false,
"title": "Legacy Pad"
}}"#,
id
);
let loaded: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.id, id);
assert_eq!(loaded.parent_id, None);
assert_eq!(loaded.title, "Legacy Pad");
}
#[test]
fn test_metadata_deserialization_with_explicit_delete_protected() {
let id = Uuid::new_v4();
let json = format!(
r#"{{
"id": "{}",
"created_at": "2023-01-01T00:00:00Z",
"updated_at": "2023-01-01T00:00:00Z",
"is_pinned": false,
"pinned_at": null,
"is_deleted": false,
"deleted_at": null,
"delete_protected": true,
"title": "Protected Pad"
}}"#,
id
);
let loaded: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.id, id);
assert!(loaded.delete_protected);
assert!(!loaded.is_pinned);
}
#[test]
fn test_update_from_raw() {
let mut pad = Pad::new("Old Title".to_string(), "Old Content".to_string());
let old_updated_at = pad.metadata.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10));
pad.update_from_raw("New Title\n\nNew Content");
assert_eq!(pad.metadata.title, "New Title");
assert_eq!(pad.content, "New Title\n\nNew Content");
assert!(pad.metadata.updated_at > old_updated_at);
}
#[test]
fn test_update_from_raw_ignores_empty() {
let mut pad = Pad::new("Old Title".to_string(), "Old Content".to_string());
let old_updated_at = pad.metadata.updated_at;
let old_content = pad.content.clone();
pad.update_from_raw(" ");
assert_eq!(pad.content, old_content);
assert_eq!(pad.metadata.updated_at, old_updated_at);
}
#[test]
fn test_legacy_metadata_without_tags() {
let id = Uuid::new_v4();
let json = format!(
r#"{{
"id": "{}",
"created_at": "2023-01-01T00:00:00Z",
"updated_at": "2023-01-01T00:00:00Z",
"is_pinned": false,
"pinned_at": null,
"is_deleted": false,
"deleted_at": null,
"delete_protected": false,
"title": "Legacy Pad Without Tags"
}}"#,
id
);
let loaded: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.id, id);
assert_eq!(loaded.title, "Legacy Pad Without Tags");
assert!(loaded.tags.is_empty());
}
#[test]
fn test_metadata_with_tags_roundtrip() {
let mut meta = Metadata::new("Tagged Pad".to_string());
meta.tags = vec!["work".to_string(), "rust".to_string()];
let json = serde_json::to_string(&meta).unwrap();
let loaded: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(loaded.id, meta.id);
assert_eq!(loaded.title, "Tagged Pad");
assert_eq!(loaded.tags, vec!["work", "rust"]);
}
#[test]
fn test_new_metadata_has_empty_tags() {
let meta = Metadata::new("New Pad".to_string());
assert!(meta.tags.is_empty());
}
}