Skip to main content

things_mcp/core/writer/operation/
update_project.rs

1//! `UpdateProjectSpec` and its JSON render. Renders a Things project
2//! "update" operation.
3
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize)]
8pub struct UpdateProjectSpec {
9    pub id: String,
10    pub title: Option<String>,
11    pub notes: Option<String>,
12    pub when: Option<String>,
13    pub deadline: Option<String>,
14    pub tags: Option<Vec<String>>,
15    /// Parent area UUID. `Some("inbox")` not meaningful for projects — pass an
16    /// area UUID or omit.
17    pub area_id: Option<String>,
18    pub completed: Option<bool>,
19    pub canceled: Option<bool>,
20}
21
22pub(crate) fn render_update_project(spec: &UpdateProjectSpec) -> Value {
23    let mut attributes = serde_json::Map::new();
24    if let Some(v) = spec.title.as_ref() {
25        attributes.insert("title".into(), Value::String(v.clone()));
26    }
27    if let Some(v) = spec.notes.as_ref() {
28        attributes.insert("notes".into(), Value::String(v.clone()));
29    }
30    if let Some(v) = spec.when.as_ref() {
31        attributes.insert("when".into(), Value::String(v.clone()));
32    }
33    if let Some(v) = spec.deadline.as_ref() {
34        attributes.insert("deadline".into(), Value::String(v.clone()));
35    }
36    if let Some(tags) = spec.tags.as_ref() {
37        attributes.insert(
38            "tags".into(),
39            Value::Array(tags.iter().map(|t| Value::String(t.clone())).collect()),
40        );
41    }
42    if let Some(v) = spec.area_id.as_ref() {
43        attributes.insert("area-id".into(), Value::String(v.clone()));
44    }
45    if let Some(v) = spec.completed {
46        attributes.insert("completed".into(), Value::Bool(v));
47    }
48    if let Some(v) = spec.canceled {
49        attributes.insert("canceled".into(), Value::Bool(v));
50    }
51
52    json!({
53        "type": "project",
54        "operation": "update",
55        "id": spec.id,
56        "attributes": Value::Object(attributes),
57    })
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use crate::core::writer::operation::Operation;
64
65    #[test]
66    fn update_project_minimal_only_id() {
67        let op = Operation::UpdateProject(UpdateProjectSpec {
68            id: "proj-1".into(),
69            ..Default::default()
70        });
71        let v = op.render_json();
72        assert_eq!(v["type"], "project");
73        assert_eq!(v["operation"], "update");
74        assert_eq!(v["id"], "proj-1");
75        assert_eq!(v["attributes"].as_object().unwrap().len(), 0);
76    }
77
78    #[test]
79    fn update_project_full_renders_all_populated_fields() {
80        let op = Operation::UpdateProject(UpdateProjectSpec {
81            id: "proj-1".into(),
82            title: Some("Renamed".into()),
83            notes: Some("Updated notes".into()),
84            when: Some("today".into()),
85            deadline: Some("2026-12-31".into()),
86            tags: Some(vec!["Work".into()]),
87            area_id: Some("area-2".into()),
88            completed: Some(true),
89            canceled: None,
90        });
91        let v = op.render_json();
92        assert_eq!(v["id"], "proj-1");
93        let attrs = v["attributes"].as_object().unwrap();
94        assert_eq!(attrs["title"], "Renamed");
95        assert_eq!(attrs["notes"], "Updated notes");
96        assert_eq!(attrs["when"], "today");
97        assert_eq!(attrs["deadline"], "2026-12-31");
98        assert_eq!(attrs["tags"], serde_json::json!(["Work"]));
99        assert_eq!(attrs["area-id"], "area-2");
100        assert_eq!(attrs["completed"], true);
101        assert!(!attrs.contains_key("canceled"), "None should not render");
102    }
103}