1use crate::ids::WorkspaceId;
2use anyhow::{Result, anyhow, ensure};
3use serde_json::{Value, json};
4
5use crate::choices::{TaskPriority, TaskStatus};
6use crate::types::Project;
7use crate::types::Task;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum TaskField {
11 Title,
12 Description,
13 Project,
14 Status,
15 Priority,
16 AvailableAt,
17 DueOn,
18 Deleted,
19 IsEpic,
20}
21
22impl TaskField {
23 pub const VERSIONED: [TaskField; 9] = [
24 TaskField::Title,
25 TaskField::Description,
26 TaskField::Project,
27 TaskField::Status,
28 TaskField::Priority,
29 TaskField::AvailableAt,
30 TaskField::Deleted,
31 TaskField::IsEpic,
32 TaskField::DueOn,
33 ];
34
35 pub fn parse(field: &str) -> Option<Self> {
36 match field {
37 "title" => Some(Self::Title),
38 "description" => Some(Self::Description),
39 "project" => Some(Self::Project),
40 "status" => Some(Self::Status),
41 "priority" => Some(Self::Priority),
42 "available_at" => Some(Self::AvailableAt),
43 "due_on" => Some(Self::DueOn),
44 "deleted" => Some(Self::Deleted),
45 "is_epic" => Some(Self::IsEpic),
46 _ => None,
47 }
48 }
49
50 pub fn is_project(self) -> bool {
51 matches!(self, Self::Project)
52 }
53
54 pub fn is_scalar(self) -> bool {
55 !self.is_project()
56 }
57
58 pub fn parse_or_unknown(field: &str) -> Result<Self> {
59 Self::parse(field).ok_or_else(|| anyhow!("error unknown-field field={field}"))
60 }
61
62 pub fn parse_for_sync(field: &str) -> Result<Self> {
63 Self::parse(field).ok_or_else(|| anyhow!("error invalid-sync-change field={field}"))
64 }
65
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Self::Title => "title",
69 Self::Description => "description",
70 Self::Project => "project",
71 Self::Status => "status",
72 Self::Priority => "priority",
73 Self::AvailableAt => "available_at",
74 Self::DueOn => "due_on",
75 Self::Deleted => "deleted",
76 Self::IsEpic => "is_epic",
77 }
78 }
79
80 pub fn validate_value(self, value: &str) -> Result<()> {
81 match self {
82 Self::Status => TaskStatus::parse(value).map(|_| ()).map_err(Into::into),
83 Self::Priority => TaskPriority::parse(value).map(|_| ()).map_err(Into::into),
84 Self::AvailableAt => crate::time_validation::validate_available_at_value(value),
85 Self::DueOn => crate::time_validation::validate_due_on_value(value),
86 Self::Deleted if matches!(value, "0" | "1") => Ok(()),
87 Self::Deleted => anyhow::bail!("error invalid-deleted value={value}"),
88 Self::IsEpic if matches!(value, "0" | "1") => Ok(()),
89 Self::IsEpic => anyhow::bail!("error invalid-is-epic value={value}"),
90 _ => Ok(()),
91 }
92 }
93
94 pub fn updates_queue_activity(self) -> bool {
95 matches!(self, Self::Status | Self::Priority)
96 }
97
98 pub fn scalar_payload(
99 self,
100 workspace_id: &WorkspaceId,
101 workspace_key: &str,
102 value: &str,
103 ) -> Result<Value> {
104 ensure!(self.is_scalar(), "error project-update-requires-project-id");
105 Ok(json!({
106 "workspace_id": workspace_id,
107 "workspace_key": workspace_key,
108 "value": value,
109 }))
110 }
111
112 pub fn project_payload(
113 workspace_id: &WorkspaceId,
114 workspace_key: &str,
115 project: &Project,
116 ) -> Value {
117 json!({
118 "workspace_id": workspace_id,
119 "workspace_key": workspace_key,
120 "value": &project.id,
121 "project_id": &project.id,
122 "project_key": &project.key,
123 "project_name": &project.name,
124 "project_prefix": &project.prefix,
125 })
126 }
127
128 pub fn current_value(self, task: &Task) -> String {
129 match self {
130 Self::Title => task.title.clone(),
131 Self::Description => task.description.clone(),
132 Self::Project => task.project_id.to_string(),
133 Self::Status => task.status.as_str().to_string(),
134 Self::Priority => task.priority.as_str().to_string(),
135 Self::AvailableAt => task.available_at.clone().unwrap_or_default(),
136 Self::DueOn => task.due_on.clone().unwrap_or_default(),
137 Self::Deleted => {
138 if task.deleted {
139 "1".to_string()
140 } else {
141 "0".to_string()
142 }
143 }
144 Self::IsEpic => {
145 if task.is_epic {
146 "1".to_string()
147 } else {
148 "0".to_string()
149 }
150 }
151 }
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::TaskField;
158 use crate::ids::WorkspaceId;
159 use crate::types::Project;
160
161 #[test]
162 fn versioned_fields_keep_protocol_order() {
163 let fields: Vec<_> = TaskField::VERSIONED
164 .into_iter()
165 .map(TaskField::as_str)
166 .collect();
167 assert_eq!(
168 fields,
169 [
170 "title",
171 "description",
172 "project",
173 "status",
174 "priority",
175 "available_at",
176 "deleted",
177 "is_epic",
178 "due_on"
179 ]
180 );
181 }
182
183 #[test]
184 fn parse_unknown_field_errors_are_canonicalized() {
185 assert_eq!(
186 TaskField::parse_or_unknown("missing")
187 .unwrap_err()
188 .to_string(),
189 "error unknown-field field=missing"
190 );
191 assert_eq!(
192 TaskField::parse_for_sync("missing")
193 .unwrap_err()
194 .to_string(),
195 "error invalid-sync-change field=missing"
196 );
197 }
198
199 #[test]
200 fn deleted_validation() {
201 assert!(TaskField::Deleted.validate_value("0").is_ok());
202 assert!(TaskField::Deleted.validate_value("1").is_ok());
203 assert_eq!(
204 TaskField::Deleted
205 .validate_value("x")
206 .unwrap_err()
207 .to_string(),
208 "error invalid-deleted value=x"
209 );
210 }
211
212 #[test]
213 fn scalar_payload_shape() {
214 let workspace_id: WorkspaceId = "0000000000000001".parse().unwrap();
215 let payload = TaskField::Title
216 .scalar_payload(&workspace_id, "key", "v")
217 .expect("valid scalar field");
218 assert_eq!(
219 payload,
220 serde_json::json!({
221 "workspace_id": "0000000000000001",
222 "workspace_key": "key",
223 "value": "v",
224 })
225 );
226 assert!(
227 TaskField::Project
228 .scalar_payload(&workspace_id, "key", "v")
229 .is_err()
230 );
231 }
232
233 #[test]
234 fn project_payload_shape() {
235 let workspace_id: WorkspaceId = "0000000000000001".parse().unwrap();
236 let project = Project {
237 id: "0000000000000001".parse().unwrap(),
238 workspace_id: workspace_id.clone(),
239 key: "proj".to_string(),
240 name: "Project".to_string(),
241 prefix: "pp".to_string(),
242 };
243 assert_eq!(
244 TaskField::project_payload(&workspace_id, "key", &project),
245 serde_json::json!({
246 "workspace_id": "0000000000000001",
247 "workspace_key": "key",
248 "value": "0000000000000001",
249 "project_id": "0000000000000001",
250 "project_key": "proj",
251 "project_name": "Project",
252 "project_prefix": "pp",
253 })
254 );
255 }
256}