singularity_cli/models/
project.rs1use chrono_tz::Tz;
2use serde::{Deserialize, Serialize};
3
4use crate::models::task::format_date;
5
6#[derive(Debug, Deserialize)]
7pub struct ProjectListResponse {
8 pub projects: Vec<Project>,
9}
10
11#[derive(Debug, Deserialize)]
12pub struct Project {
13 pub id: String,
14 pub title: String,
15 pub note: Option<String>,
16 pub start: Option<String>,
17 pub end: Option<String>,
18 pub emoji: Option<String>,
19 pub color: Option<String>,
20 pub parent: Option<String>,
21 #[serde(rename = "parentOrder")]
22 #[allow(dead_code)]
23 pub parent_order: Option<f64>,
24 #[serde(rename = "isNotebook")]
25 pub is_notebook: Option<bool>,
26 pub tags: Option<Vec<String>>,
27 #[serde(rename = "modificatedDate")]
28 #[allow(dead_code)]
29 pub modificated_date: Option<String>,
30}
31
32impl Project {
33 pub fn display_detail(&self, tz: Option<Tz>) -> String {
34 let mut lines = vec![
35 format!("**ID:** {}", self.id),
36 format!("**Title:** {}", self.title),
37 ];
38 if let Some(ref v) = self.note {
39 lines.push(format!("**Note:** {}", v));
40 }
41 if let Some(ref v) = self.parent {
42 lines.push(format!("**Parent:** {}", v));
43 }
44 if let Some(ref v) = self.emoji {
45 lines.push(format!("**Emoji:** {}", v));
46 }
47 if let Some(ref v) = self.color {
48 lines.push(format!("**Color:** {}", v));
49 }
50 if let Some(ref v) = self.start {
51 lines.push(format!("**Start:** {}", format_date(v, tz)));
52 }
53 if let Some(ref v) = self.end {
54 lines.push(format!("**End:** {}", format_date(v, tz)));
55 }
56 if let Some(ref v) = self.tags
57 && !v.is_empty()
58 {
59 lines.push(format!("**Tags:** {}", v.join(", ")));
60 }
61 if let Some(v) = self.is_notebook {
62 lines.push(format!("**Notebook:** {}", v));
63 }
64 lines.join("\n")
65 }
66
67 pub fn display_list_item(&self) -> String {
68 format!("- ID: {}\n Project: {}", self.id, self.title)
69 }
70}
71
72#[derive(Debug, Serialize, Default)]
73pub struct ProjectCreate {
74 pub title: String,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub note: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
78 pub parent: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub color: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub emoji: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub start: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub end: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none", rename = "isNotebook")]
88 pub is_notebook: Option<bool>,
89}
90
91#[derive(Debug, Serialize, Default)]
92pub struct ProjectUpdate {
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub title: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub note: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub parent: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub color: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub emoji: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub start: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub end: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none", rename = "isNotebook")]
108 pub is_notebook: Option<bool>,
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn deserialize_project_list_response() {
117 let json = r#"{
118 "projects": [
119 {
120 "id": "P-123",
121 "title": "My Project",
122 "note": "Some notes",
123 "parentOrder": 1.0,
124 "isNotebook": false,
125 "tags": ["tag1", "tag2"],
126 "modificatedDate": "2025-01-01T00:00:00Z"
127 }
128 ]
129 }"#;
130 let resp: ProjectListResponse = serde_json::from_str(json).unwrap();
131 assert_eq!(resp.projects.len(), 1);
132 let p = &resp.projects[0];
133 assert_eq!(p.id, "P-123");
134 assert_eq!(p.title, "My Project");
135 assert_eq!(p.note.as_deref(), Some("Some notes"));
136 assert_eq!(p.parent_order, Some(1.0));
137 assert_eq!(p.is_notebook, Some(false));
138 assert_eq!(p.tags.as_ref().unwrap().len(), 2);
139 }
140
141 #[test]
142 fn deserialize_project_minimal() {
143 let json = r#"{"id": "P-456", "title": "Bare"}"#;
144 let p: Project = serde_json::from_str(json).unwrap();
145 assert_eq!(p.id, "P-456");
146 assert_eq!(p.title, "Bare");
147 assert!(p.note.is_none());
148 assert!(p.parent.is_none());
149 assert!(p.tags.is_none());
150 }
151
152 #[test]
153 fn serialize_create_skips_none() {
154 let data = ProjectCreate {
155 title: "Test".to_string(),
156 ..Default::default()
157 };
158 let json = serde_json::to_value(&data).unwrap();
159 assert_eq!(json, serde_json::json!({"title": "Test"}));
160 }
161
162 #[test]
163 fn serialize_create_includes_set_fields() {
164 let data = ProjectCreate {
165 title: "Test".to_string(),
166 note: Some("A note".to_string()),
167 is_notebook: Some(true),
168 ..Default::default()
169 };
170 let json = serde_json::to_value(&data).unwrap();
171 assert_eq!(json["title"], "Test");
172 assert_eq!(json["note"], "A note");
173 assert_eq!(json["isNotebook"], true);
174 assert!(json.get("parent").is_none());
175 }
176
177 #[test]
178 fn serialize_update_empty() {
179 let data = ProjectUpdate::default();
180 let json = serde_json::to_value(&data).unwrap();
181 assert_eq!(json, serde_json::json!({}));
182 }
183
184 #[test]
185 fn serialize_update_partial() {
186 let data = ProjectUpdate {
187 title: Some("New Title".to_string()),
188 ..Default::default()
189 };
190 let json = serde_json::to_value(&data).unwrap();
191 assert_eq!(json, serde_json::json!({"title": "New Title"}));
192 }
193}