1use reqwest::{StatusCode, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Deserializer, Serialize};
4use serde_json::Number;
5use time::{OffsetDateTime, format_description::well_known::Rfc3339};
6
7use super::{ClientError, WekanClient, transport::decode_success};
8
9pub const LIST_COLORS: &[&str] = &[
10 "white",
11 "green",
12 "yellow",
13 "orange",
14 "red",
15 "purple",
16 "blue",
17 "sky",
18 "lime",
19 "pink",
20 "black",
21 "silver",
22 "peachpuff",
23 "crimson",
24 "plum",
25 "darkgreen",
26 "slateblue",
27 "magenta",
28 "gold",
29 "navy",
30 "gray",
31 "saddlebrown",
32 "paleturquoise",
33 "mistyrose",
34 "indigo",
35];
36
37#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(deny_unknown_fields, rename_all = "camelCase")]
39pub struct ListSummary {
40 #[serde(rename = "_id")]
41 pub list_id: String,
42 pub title: String,
43 #[serde(deserialize_with = "deserialize_required_nullable")]
44 pub modified_at: Option<String>,
45 #[serde(deserialize_with = "deserialize_required_nullable")]
46 pub cards_modified_at: Option<String>,
47}
48
49#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(deny_unknown_fields, rename_all = "camelCase")]
51pub struct ListWipLimit {
52 pub value: Number,
53 pub enabled: bool,
54 pub soft: bool,
55}
56
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(
59 deny_unknown_fields,
60 rename_all(deserialize = "camelCase", serialize = "snake_case")
61)]
62pub struct ListDocument {
63 #[serde(rename(deserialize = "_id", serialize = "list_id"))]
64 pub list_id: String,
65 pub title: String,
66 pub starred: Option<bool>,
67 pub archived: bool,
68 pub archived_at: Option<String>,
69 pub deleted_at: Option<String>,
70 pub deleted_by: Option<String>,
71 pub delete_batch_id: Option<String>,
72 pub board_id: String,
73 pub swimlane_id: Option<String>,
74 pub created_at: String,
75 pub sort: Option<Number>,
76 pub updated_at: Option<String>,
77 pub modified_at: String,
78 #[serde(
79 rename(deserialize = "_updatedAt", serialize = "position_updated_at"),
80 default
81 )]
82 pub position_updated_at: Option<String>,
83 pub wip_limit: Option<ListWipLimit>,
84 pub color: Option<String>,
85 #[serde(rename(deserialize = "type", serialize = "list_type"))]
86 pub list_type: String,
87 pub width: Option<Number>,
88}
89
90#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct CreateListRequest {
93 pub title: String,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub swimlane_id: Option<String>,
96}
97
98#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct UpdateListRequest {
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub title: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub color: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub starred: Option<bool>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub wip_limit: Option<ListWipLimit>,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct CreateListResult {
113 pub list_id: String,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct UpdateListResult {
118 pub list_id: String,
119}
120
121#[derive(Clone, Debug, Eq, PartialEq)]
122pub struct DeleteListResult {
123 pub list_id: String,
124}
125
126#[derive(Deserialize)]
127#[serde(deny_unknown_fields)]
128struct IdResponse {
129 #[serde(rename = "_id")]
130 id: String,
131}
132
133impl WekanClient {
134 pub async fn board_lists(
135 &self,
136 board_id: &str,
137 token: &SecretString,
138 ) -> Result<Vec<ListSummary>, ClientError> {
139 let body = self
140 .execute_success_request(
141 self.http
142 .get(self.list_endpoint(board_id, None)?)
143 .header(ACCEPT, "application/json")
144 .bearer_auth(token.expose_secret()),
145 )
146 .await?;
147 let lists = decode_success(&body, "board list collection", true)?;
148 validate_list_summaries(lists)
149 }
150
151 pub async fn list(
152 &self,
153 board_id: &str,
154 list_id: &str,
155 token: &SecretString,
156 ) -> Result<ListDocument, ClientError> {
157 let body = self
158 .execute_success_request(
159 self.http
160 .get(self.list_endpoint(board_id, Some(list_id))?)
161 .header(ACCEPT, "application/json")
162 .bearer_auth(token.expose_secret()),
163 )
164 .await?;
165 if body.is_empty() {
166 return Err(ClientError::EmbeddedServer {
167 http_status: StatusCode::OK,
168 wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
169 server_error: Some("list-not-found".to_owned()),
170 server_reason: Some("List not found".to_owned()),
171 });
172 }
173 let list = decode_success(&body, "list", true)?;
174 validate_list_document(list)
175 }
176
177 pub async fn create_list(
178 &self,
179 board_id: &str,
180 request: &CreateListRequest,
181 token: &SecretString,
182 ) -> Result<CreateListResult, ClientError> {
183 let body = self
184 .execute_success_request(
185 self.http
186 .post(self.list_endpoint(board_id, None)?)
187 .header(ACCEPT, "application/json")
188 .bearer_auth(token.expose_secret())
189 .json(request),
190 )
191 .await?;
192 let response: IdResponse = decode_success(&body, "list creation", true)?;
193 require_non_empty(&response.id, "list creation", "list id")?;
194 Ok(CreateListResult {
195 list_id: response.id,
196 })
197 }
198
199 pub async fn update_list(
200 &self,
201 board_id: &str,
202 list_id: &str,
203 request: &UpdateListRequest,
204 token: &SecretString,
205 ) -> Result<UpdateListResult, ClientError> {
206 let body = self
207 .execute_success_request(
208 self.http
209 .put(self.list_endpoint(board_id, Some(list_id))?)
210 .header(ACCEPT, "application/json")
211 .bearer_auth(token.expose_secret())
212 .json(request),
213 )
214 .await?;
215 let response: IdResponse = decode_success(&body, "list update", true)?;
216 require_non_empty(&response.id, "list update", "list id")?;
217 Ok(UpdateListResult {
218 list_id: response.id,
219 })
220 }
221
222 pub async fn delete_list(
223 &self,
224 board_id: &str,
225 list_id: &str,
226 token: &SecretString,
227 ) -> Result<DeleteListResult, ClientError> {
228 let body = self
229 .execute_success_request(
230 self.http
231 .delete(self.list_endpoint(board_id, Some(list_id))?)
232 .header(ACCEPT, "application/json")
233 .bearer_auth(token.expose_secret()),
234 )
235 .await?;
236 let response: IdResponse = decode_success(&body, "list deletion", true)?;
237 require_non_empty(&response.id, "list deletion", "list id")?;
238 Ok(DeleteListResult {
239 list_id: response.id,
240 })
241 }
242
243 fn list_endpoint(
244 &self,
245 board_id: &str,
246 list_id: Option<&str>,
247 ) -> Result<reqwest::Url, ClientError> {
248 let mut endpoint =
249 self.server()
250 .join("api/boards")
251 .map_err(|error| ClientError::Protocol {
252 message: format!("could not build the list endpoint: {error}"),
253 success_status_received: false,
254 })?;
255 let mut segments = endpoint
256 .path_segments_mut()
257 .map_err(|()| ClientError::Protocol {
258 message: "could not add identifiers to the list endpoint".to_owned(),
259 success_status_received: false,
260 })?;
261 segments.push(board_id);
262 segments.push("lists");
263 if let Some(list_id) = list_id {
264 segments.push(list_id);
265 }
266 drop(segments);
267 Ok(endpoint)
268 }
269}
270
271pub fn is_valid_list_color(value: &str) -> bool {
272 LIST_COLORS.contains(&value)
273 || value.len() == 7
274 && value.starts_with('#')
275 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
276}
277
278fn validate_list_summaries(lists: Vec<ListSummary>) -> Result<Vec<ListSummary>, ClientError> {
279 for list in &lists {
280 require_non_empty(&list.list_id, "board list collection", "list id")?;
281 require_non_empty(&list.title, "board list collection", "title")?;
282 validate_optional_date_time(
283 list.modified_at.as_deref(),
284 "modifiedAt",
285 "board list collection",
286 )?;
287 validate_optional_date_time(
288 list.cards_modified_at.as_deref(),
289 "cardsModifiedAt",
290 "board list collection",
291 )?;
292 }
293 Ok(lists)
294}
295
296fn deserialize_required_nullable<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
297where
298 D: Deserializer<'de>,
299{
300 Option::<String>::deserialize(deserializer)
301}
302
303fn validate_list_document(list: ListDocument) -> Result<ListDocument, ClientError> {
304 require_non_empty(&list.list_id, "list", "list id")?;
305 require_non_empty(&list.title, "list", "title")?;
306 require_non_empty(&list.board_id, "list", "board id")?;
307 validate_date_time(&list.created_at, "createdAt", "list")?;
308 validate_date_time(&list.modified_at, "modifiedAt", "list")?;
309 for (field, value) in [
310 ("archivedAt", list.archived_at.as_deref()),
311 ("deletedAt", list.deleted_at.as_deref()),
312 ("updatedAt", list.updated_at.as_deref()),
313 ("_updatedAt", list.position_updated_at.as_deref()),
314 ] {
315 validate_optional_date_time(value, field, "list")?;
316 }
317 if let Some(color) = &list.color
318 && !color.is_empty()
319 && !is_valid_list_color(color)
320 {
321 return Err(protocol_error(format!(
322 "the list response contained an invalid color `{color}`"
323 )));
324 }
325 if let Some(width) = &list.width {
326 let valid = width
327 .as_f64()
328 .is_some_and(|width| (100.0..=1000.0).contains(&width));
329 if !valid {
330 return Err(protocol_error(
331 "the list response contained a width outside 100 through 1000".to_owned(),
332 ));
333 }
334 }
335 Ok(list)
336}
337
338fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
339 if value.is_empty() {
340 return Err(protocol_error(format!(
341 "the {operation} response contained an empty {field}"
342 )));
343 }
344 Ok(())
345}
346
347fn validate_optional_date_time(
348 value: Option<&str>,
349 field: &str,
350 operation: &str,
351) -> Result<(), ClientError> {
352 if let Some(value) = value {
353 validate_date_time(value, field, operation)?;
354 }
355 Ok(())
356}
357
358fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
359 OffsetDateTime::parse(value, &Rfc3339)
360 .map(|_| ())
361 .map_err(|_| {
362 protocol_error(format!(
363 "the {operation} response contained an invalid {field} date-time"
364 ))
365 })
366}
367
368fn protocol_error(message: String) -> ClientError {
369 ClientError::Protocol {
370 message,
371 success_status_received: true,
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use serde_json::json;
378
379 use super::{ListDocument, ListSummary, is_valid_list_color, validate_list_document};
380
381 #[test]
382 fn color_validation_matches_wekan_v11_06() {
383 for value in ["white", "silver", "indigo", "#12aBcF"] {
384 assert!(is_valid_list_color(value));
385 }
386 for value in ["", "belize", "#12345", "#12345g", "123456"] {
387 assert!(!is_valid_list_color(value));
388 }
389 }
390
391 #[test]
392 fn list_response_preserves_blank_unset_color() {
393 let list = serde_json::from_value::<ListDocument>(json!({
394 "_id": "list-1",
395 "title": "Todo",
396 "archived": false,
397 "boardId": "board-1",
398 "createdAt": "2030-01-02T03:04:05Z",
399 "modifiedAt": "2030-01-02T03:04:05Z",
400 "type": "list",
401 "color": ""
402 }))
403 .unwrap();
404
405 let validated = validate_list_document(list).unwrap();
406 assert_eq!(validated.color.as_deref(), Some(""));
407 }
408
409 #[test]
410 fn response_objects_reject_unmapped_fields() {
411 assert!(
412 serde_json::from_value::<ListSummary>(json!({
413 "_id": "list-1",
414 "title": "Todo",
415 "modifiedAt": null,
416 "cardsModifiedAt": null,
417 "unexpected": true
418 }))
419 .is_err()
420 );
421 assert!(
422 serde_json::from_value::<ListDocument>(json!({
423 "_id": "list-1",
424 "title": "Todo",
425 "archived": false,
426 "boardId": "board-1",
427 "createdAt": "2030-01-02T03:04:05Z",
428 "modifiedAt": "2030-01-02T03:04:05Z",
429 "type": "list",
430 "wipLimit": {
431 "value": 2,
432 "enabled": true,
433 "soft": false,
434 "unexpected": true
435 }
436 }))
437 .is_err()
438 );
439 }
440
441 #[test]
442 fn list_summary_requires_nullable_timestamp_fields_to_be_present() {
443 let missing_modified_at = serde_json::from_value::<ListSummary>(json!({
444 "_id": "list-1",
445 "title": "Todo",
446 "cardsModifiedAt": null
447 }));
448 let missing_cards_modified_at = serde_json::from_value::<ListSummary>(json!({
449 "_id": "list-1",
450 "title": "Todo",
451 "modifiedAt": null
452 }));
453
454 assert!(missing_modified_at.is_err());
455 assert!(missing_cards_modified_at.is_err());
456 }
457}