use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::attributes::{AttrSideEffect, AttrValue};
#[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 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,
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>>,
#[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,
delete_protected: false,
parent_id: None,
title,
status: TodoStatus::Planned,
tags: Vec::new(),
}
}
pub fn get_attr(&self, name: &str) -> Option<AttrValue> {
match name {
"pinned" => Some(AttrValue::BoolWithTimestamp {
value: self.is_pinned,
timestamp: self.pinned_at,
}),
"protected" => Some(AttrValue::Bool(self.delete_protected)),
"status" => Some(AttrValue::Enum(format!("{:?}", self.status))),
"tags" => Some(AttrValue::List(self.tags.clone())),
"parent" => Some(AttrValue::Ref(self.parent_id)),
_ => None,
}
}
pub fn set_attr(&mut self, name: &str, value: AttrValue) -> Option<AttrSideEffect> {
match name {
"pinned" => {
let flag = value.as_bool()?;
self.is_pinned = flag;
self.pinned_at = if flag { Some(Utc::now()) } else { None };
self.delete_protected = flag;
Some(AttrSideEffect::None)
}
"protected" => {
let flag = value.as_bool()?;
self.delete_protected = flag;
Some(AttrSideEffect::None)
}
"status" => {
let status_str = value.as_enum()?;
self.status = match status_str {
"Planned" => TodoStatus::Planned,
"InProgress" => TodoStatus::InProgress,
"Done" => TodoStatus::Done,
_ => return None, };
Some(AttrSideEffect::PropagateStatusUp)
}
"tags" => {
let tags = value.as_list()?.to_vec();
let tags_for_validation = tags.clone();
self.tags = tags;
Some(AttrSideEffect::ValidateTags(tags_for_validation))
}
"parent" => {
let parent_id = value.as_ref()?;
self.parent_id = parent_id;
Some(AttrSideEffect::PropagateStatusUp)
}
_ => None,
}
}
}
#[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,
"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,
"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,
"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());
}
#[test]
fn test_get_attr_pinned_default() {
let meta = Metadata::new("Test".into());
let value = meta.get_attr("pinned").unwrap();
match value {
crate::attributes::AttrValue::BoolWithTimestamp { value, timestamp } => {
assert!(!value);
assert!(timestamp.is_none());
}
_ => panic!("Expected BoolWithTimestamp"),
}
}
#[test]
fn test_get_attr_pinned_when_set() {
let mut meta = Metadata::new("Test".into());
meta.is_pinned = true;
meta.pinned_at = Some(Utc::now());
let value = meta.get_attr("pinned").unwrap();
match value {
crate::attributes::AttrValue::BoolWithTimestamp { value, timestamp } => {
assert!(value);
assert!(timestamp.is_some());
}
_ => panic!("Expected BoolWithTimestamp"),
}
}
#[test]
fn test_get_attr_protected_default() {
let meta = Metadata::new("Test".into());
let value = meta.get_attr("protected").unwrap();
assert_eq!(value.as_bool(), Some(false));
}
#[test]
fn test_get_attr_protected_when_set() {
let mut meta = Metadata::new("Test".into());
meta.delete_protected = true;
let value = meta.get_attr("protected").unwrap();
assert_eq!(value.as_bool(), Some(true));
}
#[test]
fn test_get_attr_status_default() {
let meta = Metadata::new("Test".into());
let value = meta.get_attr("status").unwrap();
assert_eq!(value.as_enum(), Some("Planned"));
}
#[test]
fn test_get_attr_status_variants() {
let mut meta = Metadata::new("Test".into());
meta.status = TodoStatus::InProgress;
assert_eq!(
meta.get_attr("status").unwrap().as_enum(),
Some("InProgress")
);
meta.status = TodoStatus::Done;
assert_eq!(meta.get_attr("status").unwrap().as_enum(), Some("Done"));
}
#[test]
fn test_get_attr_tags_empty() {
let meta = Metadata::new("Test".into());
let value = meta.get_attr("tags").unwrap();
assert_eq!(value.as_list(), Some(&[][..]));
}
#[test]
fn test_get_attr_tags_with_values() {
let mut meta = Metadata::new("Test".into());
meta.tags = vec!["work".into(), "rust".into()];
let value = meta.get_attr("tags").unwrap();
let expected: Vec<String> = vec!["work".into(), "rust".into()];
assert_eq!(value.as_list(), Some(expected.as_slice()));
}
#[test]
fn test_get_attr_parent_none() {
let meta = Metadata::new("Test".into());
let value = meta.get_attr("parent").unwrap();
assert_eq!(value.as_ref(), Some(None));
}
#[test]
fn test_get_attr_parent_some() {
let mut meta = Metadata::new("Test".into());
let parent_id = Uuid::new_v4();
meta.parent_id = Some(parent_id);
let value = meta.get_attr("parent").unwrap();
assert_eq!(value.as_ref(), Some(Some(parent_id)));
}
#[test]
fn test_get_attr_unknown_returns_none() {
let meta = Metadata::new("Test".into());
assert!(meta.get_attr("unknown").is_none());
assert!(meta.get_attr("").is_none());
assert!(meta.get_attr("is_pinned").is_none()); }
#[test]
fn test_set_attr_pinned_true() {
let mut meta = Metadata::new("Test".into());
let effect = meta
.set_attr("pinned", crate::attributes::AttrValue::Bool(true))
.unwrap();
assert!(meta.is_pinned);
assert!(meta.pinned_at.is_some());
assert!(meta.delete_protected); assert_eq!(effect, crate::attributes::AttrSideEffect::None);
}
#[test]
fn test_set_attr_pinned_false() {
let mut meta = Metadata::new("Test".into());
meta.is_pinned = true;
meta.pinned_at = Some(Utc::now());
meta.delete_protected = true;
let effect = meta
.set_attr("pinned", crate::attributes::AttrValue::Bool(false))
.unwrap();
assert!(!meta.is_pinned);
assert!(meta.pinned_at.is_none());
assert!(!meta.delete_protected); assert_eq!(effect, crate::attributes::AttrSideEffect::None);
}
#[test]
fn test_set_attr_protected() {
let mut meta = Metadata::new("Test".into());
meta.set_attr("protected", crate::attributes::AttrValue::Bool(true))
.unwrap();
assert!(meta.delete_protected);
meta.set_attr("protected", crate::attributes::AttrValue::Bool(false))
.unwrap();
assert!(!meta.delete_protected);
}
#[test]
fn test_set_attr_status_all_variants() {
let mut meta = Metadata::new("Test".into());
let effect = meta
.set_attr("status", crate::attributes::AttrValue::Enum("Done".into()))
.unwrap();
assert_eq!(meta.status, TodoStatus::Done);
assert_eq!(effect, crate::attributes::AttrSideEffect::PropagateStatusUp);
meta.set_attr(
"status",
crate::attributes::AttrValue::Enum("InProgress".into()),
)
.unwrap();
assert_eq!(meta.status, TodoStatus::InProgress);
meta.set_attr(
"status",
crate::attributes::AttrValue::Enum("Planned".into()),
)
.unwrap();
assert_eq!(meta.status, TodoStatus::Planned);
}
#[test]
fn test_set_attr_status_invalid() {
let mut meta = Metadata::new("Test".into());
let result = meta.set_attr(
"status",
crate::attributes::AttrValue::Enum("Invalid".into()),
);
assert!(result.is_none());
assert_eq!(meta.status, TodoStatus::Planned); }
#[test]
fn test_set_attr_tags() {
let mut meta = Metadata::new("Test".into());
let tags = vec!["work".to_string(), "rust".to_string()];
let effect = meta
.set_attr("tags", crate::attributes::AttrValue::List(tags.clone()))
.unwrap();
assert_eq!(meta.tags, tags);
match effect {
crate::attributes::AttrSideEffect::ValidateTags(t) => {
assert_eq!(t, vec!["work".to_string(), "rust".to_string()]);
}
_ => panic!("Expected ValidateTags"),
}
}
#[test]
fn test_set_attr_parent() {
let mut meta = Metadata::new("Test".into());
let parent_id = Uuid::new_v4();
let effect = meta
.set_attr("parent", crate::attributes::AttrValue::Ref(Some(parent_id)))
.unwrap();
assert_eq!(meta.parent_id, Some(parent_id));
assert_eq!(effect, crate::attributes::AttrSideEffect::PropagateStatusUp);
}
#[test]
fn test_set_attr_parent_none() {
let mut meta = Metadata::new("Test".into());
meta.parent_id = Some(Uuid::new_v4());
let effect = meta
.set_attr("parent", crate::attributes::AttrValue::Ref(None))
.unwrap();
assert_eq!(meta.parent_id, None);
assert_eq!(effect, crate::attributes::AttrSideEffect::PropagateStatusUp);
}
#[test]
fn test_set_attr_unknown_returns_none() {
let mut meta = Metadata::new("Test".into());
let result = meta.set_attr("unknown", crate::attributes::AttrValue::Bool(true));
assert!(result.is_none());
}
#[test]
fn test_set_attr_wrong_type_returns_none() {
let mut meta = Metadata::new("Test".into());
let result = meta.set_attr("pinned", crate::attributes::AttrValue::Enum("yes".into()));
assert!(result.is_none());
assert!(!meta.is_pinned);
let result = meta.set_attr("status", crate::attributes::AttrValue::Bool(true));
assert!(result.is_none());
assert_eq!(meta.status, TodoStatus::Planned); }
}