Skip to main content

things_mcp/core/writer/operation/
add_project.rs

1//! `AddProjectSpec` and its JSON render. Creates a Things project, optionally
2//! with initial headings and to-dos nested inside via Things' `items` array.
3
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6
7use crate::core::writer::operation::add_todo::AddTodoSpec;
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize)]
10pub struct AddProjectSpec {
11    pub title: String,
12    pub notes: Option<String>,
13    pub when: Option<String>,
14    pub deadline: Option<String>,
15    pub tags: Vec<String>,
16    /// Parent area UUID. Optional — projects live in "no area" if omitted.
17    pub area_id: Option<String>,
18    /// Initial to-dos to nest inside the project. Order preserved.
19    pub todos: Vec<AddTodoSpec>,
20    /// Initial heading titles. Order preserved. Renders before todos in the
21    /// items[] array — a Things UX convention, not a hard rule.
22    pub headings: Vec<String>,
23}
24
25pub(crate) fn render_add_project(spec: &AddProjectSpec) -> Value {
26    let mut attributes = serde_json::Map::new();
27    attributes.insert("title".into(), Value::String(spec.title.clone()));
28    if let Some(notes) = spec.notes.as_ref() {
29        attributes.insert("notes".into(), Value::String(notes.clone()));
30    }
31    if let Some(when) = spec.when.as_ref() {
32        attributes.insert("when".into(), Value::String(when.clone()));
33    }
34    if let Some(deadline) = spec.deadline.as_ref() {
35        attributes.insert("deadline".into(), Value::String(deadline.clone()));
36    }
37    if !spec.tags.is_empty() {
38        attributes.insert(
39            "tags".into(),
40            Value::Array(spec.tags.iter().map(|t| Value::String(t.clone())).collect()),
41        );
42    }
43    if let Some(id) = spec.area_id.as_ref() {
44        attributes.insert("area-id".into(), Value::String(id.clone()));
45    }
46
47    // items[] = headings first, then to-dos. Order matches the Things app's
48    // typical project layout.
49    if !spec.headings.is_empty() || !spec.todos.is_empty() {
50        let mut items: Vec<Value> = Vec::with_capacity(spec.headings.len() + spec.todos.len());
51        for h in &spec.headings {
52            items.push(json!({
53                "type": "heading",
54                "attributes": { "title": h }
55            }));
56        }
57        for t in &spec.todos {
58            // Reuse AddTodoSpec's render via Operation dispatch — but we only
59            // need the element shape, not the wrapped Operation. Inline the
60            // render here to avoid coupling enum dispatch into the project
61            // render.
62            items.push(crate::core::writer::operation::add_todo::render_add_todo(t));
63        }
64        attributes.insert("items".into(), Value::Array(items));
65    }
66
67    json!({
68        "type": "project",
69        "attributes": Value::Object(attributes),
70    })
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::core::writer::operation::Operation;
77
78    #[test]
79    fn add_project_minimal_renders_title_only() {
80        let op = Operation::AddProject(AddProjectSpec {
81            title: "Launch website".into(),
82            ..Default::default()
83        });
84        let v = op.render_json();
85        assert_eq!(v["type"], "project");
86        assert_eq!(v["attributes"]["title"], "Launch website");
87        let attrs = v["attributes"].as_object().unwrap();
88        assert_eq!(attrs.len(), 1, "only `title` should be set for minimal project");
89        assert!(!attrs.contains_key("items"));
90        assert!(!attrs.contains_key("area-id"));
91    }
92
93    #[test]
94    fn add_project_full_with_nested_items() {
95        let op = Operation::AddProject(AddProjectSpec {
96            title: "Q3 launch".into(),
97            notes: Some("Coordinate with marketing".into()),
98            when: Some("anytime".into()),
99            deadline: Some("2026-09-30".into()),
100            tags: vec!["Work".into()],
101            area_id: Some("area-2".into()),
102            todos: vec![
103                AddTodoSpec {
104                    title: "Draft press release".into(),
105                    ..Default::default()
106                },
107            ],
108            headings: vec!["Design".into(), "QA".into()],
109        });
110        let v = op.render_json();
111        let attrs = v["attributes"].as_object().unwrap();
112        assert_eq!(attrs["title"], "Q3 launch");
113        assert_eq!(attrs["notes"], "Coordinate with marketing");
114        assert_eq!(attrs["area-id"], "area-2");
115        assert_eq!(attrs["tags"], serde_json::json!(["Work"]));
116        let items = attrs["items"].as_array().unwrap();
117        // 2 headings + 1 to-do, headings first.
118        assert_eq!(items.len(), 3);
119        assert_eq!(items[0]["type"], "heading");
120        assert_eq!(items[0]["attributes"]["title"], "Design");
121        assert_eq!(items[1]["type"], "heading");
122        assert_eq!(items[1]["attributes"]["title"], "QA");
123        assert_eq!(items[2]["type"], "to-do");
124        assert_eq!(items[2]["attributes"]["title"], "Draft press release");
125    }
126}