things_mcp/core/writer/operation/
add_project.rs1use 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 pub area_id: Option<String>,
18 pub todos: Vec<AddTodoSpec>,
20 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 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 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 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}