use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
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, Default)]
pub enum TodoStatus {
#[default]
Planned,
InProgress,
Done,
}
#[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,
}
}
}
pub enum ParentPolicy<'a> {
Trust,
OrphanUnknown(&'a HashSet<Uuid>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MetadataPatchWarning {
NotAnObject,
InvalidId,
InvalidField(&'static str),
NonStringTags(usize),
ParentOrphaned,
}
impl MetadataPatchWarning {
pub fn is_info(&self) -> bool {
matches!(self, Self::ParentOrphaned)
}
}
impl std::fmt::Display for MetadataPatchWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotAnObject => write!(f, "metadata is not an object, keeping existing fields"),
Self::InvalidId => write!(f, "invalid id field, keeping existing UUID"),
Self::InvalidField(name) => write!(f, "invalid {}", name),
Self::NonStringTags(n) => write!(f, "{} non-string tag entries ignored", n),
Self::ParentOrphaned => write!(f, "parent not in import set, orphaned to root"),
}
}
}
impl Metadata {
pub fn apply_json_patch(
&mut self,
value: &serde_json::Value,
parent_policy: &ParentPolicy<'_>,
) -> Vec<MetadataPatchWarning> {
use MetadataPatchWarning as W;
let mut warnings = Vec::new();
let Some(obj) = value.as_object() else {
warnings.push(W::NotAnObject);
return warnings;
};
if let Some(id_val) = obj.get("id") {
match id_val.as_str().and_then(|s| Uuid::parse_str(s).ok()) {
Some(u) => self.id = u,
None => warnings.push(W::InvalidId),
}
}
if let Some(v) = obj.get("created_at") {
match datetime_from_json(v) {
Some(dt) => self.created_at = dt,
None => warnings.push(W::InvalidField("created_at")),
}
}
if let Some(v) = obj.get("updated_at") {
match datetime_from_json(v) {
Some(dt) => self.updated_at = dt,
None => warnings.push(W::InvalidField("updated_at")),
}
}
if let Some(v) = obj.get("is_pinned") {
match v.as_bool() {
Some(b) => self.is_pinned = b,
None => warnings.push(W::InvalidField("is_pinned")),
}
}
if let Some(v) = obj.get("pinned_at") {
if v.is_null() {
self.pinned_at = None;
} else {
match datetime_from_json(v) {
Some(dt) => self.pinned_at = Some(dt),
None => warnings.push(W::InvalidField("pinned_at")),
}
}
}
if let Some(v) = obj.get("delete_protected") {
match v.as_bool() {
Some(b) => self.delete_protected = b,
None => warnings.push(W::InvalidField("delete_protected")),
}
}
if let Some(v) = obj.get("status") {
match v.as_str().and_then(parse_todo_status) {
Some(s) => self.status = s,
None => warnings.push(W::InvalidField("status")),
}
}
if let Some(v) = obj.get("tags") {
match v.as_array() {
Some(arr) => {
let mut tags = Vec::with_capacity(arr.len());
let mut bad = 0;
for t in arr {
match t.as_str() {
Some(s) => tags.push(s.to_string()),
None => bad += 1,
}
}
self.tags = tags;
if bad > 0 {
warnings.push(W::NonStringTags(bad));
}
}
None => warnings.push(W::InvalidField("tags")),
}
}
if let Some(v) = obj.get("title") {
if let Some(s) = v.as_str() {
if self.title.is_empty() {
self.title = s.to_string();
}
}
}
if let Some(v) = obj.get("parent_id") {
if v.is_null() {
self.parent_id = None;
} else {
match v.as_str().and_then(|s| Uuid::parse_str(s).ok()) {
Some(pid) => match parent_policy {
ParentPolicy::Trust => self.parent_id = Some(pid),
ParentPolicy::OrphanUnknown(known) => {
if known.contains(&pid) {
self.parent_id = Some(pid);
} else {
self.parent_id = None;
warnings.push(W::ParentOrphaned);
}
}
},
None => warnings.push(W::InvalidField("parent_id")),
}
}
}
warnings
}
}
fn datetime_from_json(v: &serde_json::Value) -> Option<DateTime<Utc>> {
v.as_str()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc))
}
fn parse_todo_status(s: &str) -> Option<TodoStatus> {
match s {
"Planned" => Some(TodoStatus::Planned),
"InProgress" => Some(TodoStatus::InProgress),
"Done" => Some(TodoStatus::Done),
_ => 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); }
use serde_json::json;
fn sample_meta() -> Metadata {
let mut m = Metadata::new("Sample".into());
m.id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
m
}
#[test]
fn test_apply_json_patch_non_object_returns_warning() {
let mut meta = sample_meta();
let warnings = meta.apply_json_patch(&json!("not an object"), &ParentPolicy::Trust);
assert_eq!(warnings, vec![MetadataPatchWarning::NotAnObject]);
}
#[test]
fn test_apply_json_patch_applies_known_fields() {
let mut meta = sample_meta();
let new_id = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
let patch = json!({
"id": new_id,
"created_at": "2026-01-01T00:00:00Z",
"is_pinned": true,
"status": "Done",
"tags": ["work", "rust"],
});
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert!(warnings.is_empty());
assert_eq!(meta.id.to_string(), new_id);
assert!(meta.is_pinned);
assert_eq!(meta.status, TodoStatus::Done);
assert_eq!(meta.tags, vec!["work".to_string(), "rust".to_string()]);
}
#[test]
fn test_apply_json_patch_unknown_keys_ignored() {
let mut meta = sample_meta();
let patch = json!({ "wibble": "future", "status": "InProgress" });
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert!(warnings.is_empty());
assert_eq!(meta.status, TodoStatus::InProgress);
}
#[test]
fn test_apply_json_patch_bad_status_warns_and_keeps_default() {
let mut meta = sample_meta();
let patch = json!({ "status": "NotAStatus" });
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert_eq!(warnings, vec![MetadataPatchWarning::InvalidField("status")]);
assert_eq!(meta.status, TodoStatus::Planned);
}
#[test]
fn test_apply_json_patch_bad_uuid_keeps_existing() {
let original = sample_meta().id;
let mut meta = sample_meta();
let patch = json!({ "id": "not-a-uuid" });
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert_eq!(warnings, vec![MetadataPatchWarning::InvalidId]);
assert_eq!(meta.id, original);
}
#[test]
fn test_apply_json_patch_non_string_tags_counted() {
let mut meta = sample_meta();
let patch = json!({ "tags": ["ok", 42, "alright", true] });
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert_eq!(warnings, vec![MetadataPatchWarning::NonStringTags(2)]);
assert_eq!(meta.tags, vec!["ok".to_string(), "alright".to_string()]);
}
#[test]
fn test_apply_json_patch_parent_policy_orphans_unknown() {
let mut meta = sample_meta();
let known: HashSet<Uuid> = HashSet::new();
let stranger = Uuid::new_v4();
let patch = json!({ "parent_id": stranger.to_string() });
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::OrphanUnknown(&known));
assert_eq!(warnings, vec![MetadataPatchWarning::ParentOrphaned]);
assert_eq!(meta.parent_id, None);
}
#[test]
fn test_apply_json_patch_parent_policy_trust_keeps_stranger() {
let mut meta = sample_meta();
let stranger = Uuid::new_v4();
let patch = json!({ "parent_id": stranger.to_string() });
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert!(warnings.is_empty());
assert_eq!(meta.parent_id, Some(stranger));
}
#[test]
fn test_apply_json_patch_null_parent_clears() {
let mut meta = sample_meta();
meta.parent_id = Some(Uuid::new_v4());
let patch = json!({ "parent_id": null });
let warnings = meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert!(warnings.is_empty());
assert_eq!(meta.parent_id, None);
}
#[test]
fn test_apply_json_patch_title_only_overrides_empty() {
let mut meta = sample_meta();
meta.title = "existing".into();
let patch = json!({ "title": "ignored" });
meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert_eq!(meta.title, "existing");
meta.title.clear();
meta.apply_json_patch(&patch, &ParentPolicy::Trust);
assert_eq!(meta.title, "ignored");
}
#[test]
fn test_metadata_patch_warning_display() {
assert_eq!(
MetadataPatchWarning::InvalidField("created_at").to_string(),
"invalid created_at"
);
assert_eq!(
MetadataPatchWarning::NonStringTags(3).to_string(),
"3 non-string tag entries ignored"
);
assert!(MetadataPatchWarning::ParentOrphaned.is_info());
assert!(!MetadataPatchWarning::InvalidId.is_info());
}
}