things-mcp 0.2.4

Local-first MCP server bridging Claude to Things 3 on macOS — 29 tools for read, search, write, and tag CRUD.
Documentation
//! `AddTodoSpec` and its JSON render. One variant of `Operation`.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AddTodoSpec {
    pub title: String,
    pub notes: Option<String>,
    /// `"today"`, `"tomorrow"`, `"evening"`, `"anytime"`, `"someday"`,
    /// or an ISO date / timestamp.
    pub when: Option<String>,
    /// ISO `YYYY-MM-DD`.
    pub deadline: Option<String>,
    pub tags: Vec<String>,
    pub checklist_items: Vec<String>,
    /// Project or area UUID this to-do belongs to.
    pub list_id: Option<String>,
    /// Heading UUID, if the to-do should be filed under a specific heading
    /// inside a project.
    pub heading_id: Option<String>,
}

pub(crate) fn render_add_todo(spec: &AddTodoSpec) -> Value {
    let mut attributes = serde_json::Map::new();
    attributes.insert("title".into(), Value::String(spec.title.clone()));
    if let Some(notes) = spec.notes.as_ref() {
        attributes.insert("notes".into(), Value::String(notes.clone()));
    }
    if let Some(when) = spec.when.as_ref() {
        attributes.insert("when".into(), Value::String(when.clone()));
    }
    if let Some(deadline) = spec.deadline.as_ref() {
        attributes.insert("deadline".into(), Value::String(deadline.clone()));
    }
    if !spec.tags.is_empty() {
        attributes.insert(
            "tags".into(),
            Value::Array(spec.tags.iter().map(|t| Value::String(t.clone())).collect()),
        );
    }
    if !spec.checklist_items.is_empty() {
        attributes.insert(
            "checklist-items".into(),
            Value::Array(
                spec.checklist_items
                    .iter()
                    .map(|t| {
                        json!({
                            "type": "checklist-item",
                            "attributes": { "title": t }
                        })
                    })
                    .collect(),
            ),
        );
    }
    if let Some(id) = spec.list_id.as_ref() {
        attributes.insert("list-id".into(), Value::String(id.clone()));
    }
    if let Some(id) = spec.heading_id.as_ref() {
        attributes.insert("heading".into(), Value::String(id.clone()));
    }

    json!({
        "type": "to-do",
        "attributes": Value::Object(attributes),
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::writer::operation::Operation;

    #[test]
    fn add_todo_minimal_renders_title_only() {
        let op = Operation::AddTodo(AddTodoSpec {
            title: "Buy milk".into(),
            ..Default::default()
        });
        let v = op.render_json();
        assert_eq!(v["type"], "to-do");
        assert_eq!(v["attributes"]["title"], "Buy milk");
        let attrs = v["attributes"].as_object().unwrap();
        assert_eq!(attrs.len(), 1);
        assert!(!attrs.contains_key("notes"));
        assert!(!attrs.contains_key("tags"));
        assert!(!attrs.contains_key("checklist-items"));
    }

    #[test]
    fn add_todo_full_renders_every_field() {
        let op = Operation::AddTodo(AddTodoSpec {
            title: "Plan release".into(),
            notes: Some("Coordinate with QA".into()),
            when: Some("today".into()),
            deadline: Some("2026-06-01".into()),
            tags: vec!["Work".into(), "Urgent".into()],
            checklist_items: vec!["Draft notes".into(), "Cut RC".into()],
            list_id: Some("proj-42".into()),
            heading_id: Some("head-7".into()),
        });
        let v = op.render_json();
        let attrs = v["attributes"].as_object().unwrap();
        assert_eq!(attrs["title"], "Plan release");
        assert_eq!(attrs["notes"], "Coordinate with QA");
        assert_eq!(attrs["when"], "today");
        assert_eq!(attrs["deadline"], "2026-06-01");
        assert_eq!(attrs["tags"], serde_json::json!(["Work", "Urgent"]));
        assert_eq!(attrs["list-id"], "proj-42");
        assert_eq!(attrs["heading"], "head-7");
        let checklist = attrs["checklist-items"].as_array().unwrap();
        assert_eq!(checklist.len(), 2);
        assert_eq!(checklist[0]["type"], "checklist-item");
        assert_eq!(checklist[0]["attributes"]["title"], "Draft notes");
    }

    #[test]
    fn action_name_and_auth_requirement() {
        let op = Operation::AddTodo(AddTodoSpec {
            title: "x".into(),
            ..Default::default()
        });
        assert_eq!(op.action_name(), "add_todo");
        assert!(!op.requires_auth_token(), "creates do not require auth-token");
    }
}