use super::funejson::Value; use super::todo_item_validate_draft::{ draft_errors, is_iso_date, recurrence_from_value, recurrence_to_value, texts_from_value, todo_validation_to_value, trim_space, }; const FIELD_ORDER: [&str; 10] = [ "id", "title", "notes", "priority", "due", "tags", "recurrence", "createdAt", "completedAt", "order", ]; /// YYYY-MM-DDTHH:MM:SS, an optional 1-9 digit fraction, then Z. pub fn is_utc_timestamp(text: &str) -> bool { let b = text.as_bytes(); if b.len() < 20 || !text.is_char_boundary(10) || !is_iso_date(&text[0..10]) { return false; } if b[10] != b'T' || b[13] != b':' || b[16] != b':' { return false; } let two = |at: usize, most: u32| { b[at].is_ascii_digit() && b[at + 1].is_ascii_digit() && ((b[at] - b'0') as u32 * 10 + (b[at + 1] - b'0') as u32) <= most }; if !two(11, 23) || !two(14, 59) || !two(17, 59) { return false; } let mut rest = &b[19..]; if rest.first() == Some(&b'.') { let mut n = 1; while n < rest.len() && rest[n].is_ascii_digit() { n += 1; } if n == 1 || n > 10 { return false; } rest = &rest[n..]; } rest == b"Z" } /// Check a stored todo: the draft rules plus id, trimmed title, timestamps, /// completion and order. pub fn validate_todo(todo: &Todo) -> TodoValidation { let mut found = draft_errors( &todo.title, todo.notes.as_deref(), &todo.priority, todo.due.as_deref(), &todo.tags, todo.recurrence.as_ref(), ); let has_title_error = found.iter().any(|(f, _)| f == "title"); let mut set = |field: &str, message: &str| { found.retain(|(f, _)| f != field); found.push((field.to_string(), message.to_string())); }; if trim_space(&todo.id).is_empty() { set("id", "Every todo needs an id."); } if !has_title_error && trim_space(&todo.title) != todo.title { set("title", "Remove the spaces around the title."); } if !is_utc_timestamp(&todo.created_at) { set("createdAt", "Record when it was created as a UTC timestamp, e.g. 2026-09-28T09:30:00Z."); } match (todo.done, todo.completed_at.as_deref()) { (true, None) => set("completedAt", "A done todo needs the time it was completed."), (false, Some(_)) => set("completedAt", "An open todo cannot have a completion time."), (_, Some(t)) if !is_utc_timestamp(t) => { set("completedAt", "Record when it was completed as a UTC timestamp, e.g. 2026-09-28T09:30:00Z.") } _ => {} } if todo.order < 0 { set("order", "Order must be a whole number, 0 or more."); } let mut errors: Vec<(String, String)> = Vec::new(); for field in FIELD_ORDER { if let Some((f, m)) = found.iter().find(|(f, _)| f == field) { errors.push((f.clone(), m.clone())); } } TodoValidation { valid: errors.is_empty(), errors } } fn opt_text(v: &Value) -> Option { if v.is_null() { None } else { Some(v.as_str().to_string()) } } fn opt_value(v: &Option) -> Value { v.as_deref().map(Value::str).unwrap_or(Value::Null) } pub fn todo_from_value(v: &Value) -> Todo { Todo { id: v.get("id").as_str().to_string(), title: v.get("title").as_str().to_string(), notes: opt_text(v.get("notes")), done: v.get("done").as_bool(), priority: v.get("priority").as_str().to_string(), due: opt_text(v.get("due")), tags: texts_from_value(v.get("tags")), recurrence: recurrence_from_value(v.get("recurrence")), created_at: v.get("createdAt").as_str().to_string(), completed_at: opt_text(v.get("completedAt")), order: v.get("order").as_i64(), } } pub fn todos_from_value(v: &Value) -> Vec { v.as_arr().iter().map(todo_from_value).collect() } pub fn todo_to_value(todo: &Todo) -> Value { Value::obj(vec![ ("id", Value::str(&todo.id)), ("title", Value::str(&todo.title)), ("notes", opt_value(&todo.notes)), ("done", Value::Bool(todo.done)), ("priority", Value::str(&todo.priority)), ("due", opt_value(&todo.due)), ("tags", Value::Arr(todo.tags.iter().map(|t| Value::str(t)).collect())), ("recurrence", recurrence_to_value(&todo.recurrence)), ("createdAt", Value::str(&todo.created_at)), ("completedAt", opt_value(&todo.completed_at)), ("order", Value::Int(todo.order)), ]) } pub fn todos_to_value(todos: &[Todo]) -> Value { Value::Arr(todos.iter().map(todo_to_value).collect()) } pub fn fune_vector(args: &[Value]) -> Value { todo_validation_to_value(&validate_todo(&todo_from_value(&args[0]))) }