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<Option<bool>>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub wip_limit: Option<ListWipLimitInput>,
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 diagnostic: None,
253 message: format!("could not build the list endpoint: {error}"),
254 success_status_received: false,
255 })?;
256 let mut segments = endpoint
257 .path_segments_mut()
258 .map_err(|()| ClientError::Protocol {
259 diagnostic: None,
260 message: "could not add identifiers to the list endpoint".to_owned(),
261 success_status_received: false,
262 })?;
263 segments.push(board_id);
264 segments.push("lists");
265 if let Some(list_id) = list_id {
266 segments.push(list_id);
267 }
268 drop(segments);
269 Ok(endpoint)
270 }
271}
272
273pub fn is_valid_list_color(value: &str) -> bool {
274 LIST_COLORS.contains(&value)
275 || value.len() == 7
276 && value.starts_with('#')
277 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
278}
279
280fn validate_list_summaries(lists: Vec<ListSummary>) -> Result<Vec<ListSummary>, ClientError> {
281 for list in &lists {
282 require_non_empty(&list.list_id, "board list collection", "list id")?;
283 require_non_empty(&list.title, "board list collection", "title")?;
284 validate_optional_date_time(
285 list.modified_at.as_deref(),
286 "modifiedAt",
287 "board list collection",
288 )?;
289 validate_optional_date_time(
290 list.cards_modified_at.as_deref(),
291 "cardsModifiedAt",
292 "board list collection",
293 )?;
294 }
295 Ok(lists)
296}
297
298fn deserialize_required_nullable<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
299where
300 D: Deserializer<'de>,
301{
302 Option::<String>::deserialize(deserializer)
303}
304
305fn validate_list_document(list: ListDocument) -> Result<ListDocument, ClientError> {
306 require_non_empty(&list.list_id, "list", "list id")?;
307 require_non_empty(&list.title, "list", "title")?;
308 require_non_empty(&list.board_id, "list", "board id")?;
309 validate_date_time(&list.created_at, "createdAt", "list")?;
310 validate_date_time(&list.modified_at, "modifiedAt", "list")?;
311 for (field, value) in [
312 ("archivedAt", list.archived_at.as_deref()),
313 ("deletedAt", list.deleted_at.as_deref()),
314 ("updatedAt", list.updated_at.as_deref()),
315 ("_updatedAt", list.position_updated_at.as_deref()),
316 ] {
317 validate_optional_date_time(value, field, "list")?;
318 }
319 if let Some(color) = &list.color
320 && !color.is_empty()
321 && !is_valid_list_color(color)
322 {
323 return Err(protocol_error(format!(
324 "the list response contained an invalid color `{color}`"
325 )));
326 }
327 if let Some(width) = &list.width {
328 let valid = width
329 .as_f64()
330 .is_some_and(|width| (100.0..=1000.0).contains(&width));
331 if !valid {
332 return Err(protocol_error(
333 "the list response contained a width outside 100 through 1000".to_owned(),
334 ));
335 }
336 }
337 Ok(list)
338}
339
340fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
341 if value.is_empty() {
342 return Err(protocol_error(format!(
343 "the {operation} response contained an empty {field}"
344 )));
345 }
346 Ok(())
347}
348
349fn validate_optional_date_time(
350 value: Option<&str>,
351 field: &str,
352 operation: &str,
353) -> Result<(), ClientError> {
354 if let Some(value) = value {
355 validate_date_time(value, field, operation)?;
356 }
357 Ok(())
358}
359
360fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
361 OffsetDateTime::parse(value, &Rfc3339)
362 .map(|_| ())
363 .map_err(|_| {
364 protocol_error(format!(
365 "the {operation} response contained an invalid {field} date-time"
366 ))
367 })
368}
369
370fn protocol_error(message: String) -> ClientError {
371 ClientError::Protocol {
372 diagnostic: None,
373 message,
374 success_status_received: true,
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use serde_json::json;
381
382 use super::{ListDocument, ListSummary, is_valid_list_color, validate_list_document};
383
384 #[test]
385 fn color_validation_matches_wekan_v11_06() {
386 for value in ["white", "silver", "indigo", "#12aBcF"] {
387 assert!(is_valid_list_color(value));
388 }
389 for value in ["", "belize", "#12345", "#12345g", "123456"] {
390 assert!(!is_valid_list_color(value));
391 }
392 }
393
394 #[test]
395 fn list_response_preserves_blank_unset_color() {
396 let list = serde_json::from_value::<ListDocument>(json!({
397 "_id": "list-1",
398 "title": "Todo",
399 "archived": false,
400 "boardId": "board-1",
401 "createdAt": "2030-01-02T03:04:05Z",
402 "modifiedAt": "2030-01-02T03:04:05Z",
403 "type": "list",
404 "color": ""
405 }))
406 .unwrap();
407
408 let validated = validate_list_document(list).unwrap();
409 assert_eq!(validated.color.as_deref(), Some(""));
410 }
411
412 #[test]
413 fn response_objects_reject_unmapped_fields() {
414 assert!(
415 serde_json::from_value::<ListSummary>(json!({
416 "_id": "list-1",
417 "title": "Todo",
418 "modifiedAt": null,
419 "cardsModifiedAt": null,
420 "unexpected": true
421 }))
422 .is_err()
423 );
424 assert!(
425 serde_json::from_value::<ListDocument>(json!({
426 "_id": "list-1",
427 "title": "Todo",
428 "archived": false,
429 "boardId": "board-1",
430 "createdAt": "2030-01-02T03:04:05Z",
431 "modifiedAt": "2030-01-02T03:04:05Z",
432 "type": "list",
433 "wipLimit": {
434 "value": 2,
435 "enabled": true,
436 "soft": false,
437 "unexpected": true
438 }
439 }))
440 .is_err()
441 );
442 }
443
444 #[test]
445 fn list_summary_requires_nullable_timestamp_fields_to_be_present() {
446 let missing_modified_at = serde_json::from_value::<ListSummary>(json!({
447 "_id": "list-1",
448 "title": "Todo",
449 "cardsModifiedAt": null
450 }));
451 let missing_cards_modified_at = serde_json::from_value::<ListSummary>(json!({
452 "_id": "list-1",
453 "title": "Todo",
454 "modifiedAt": null
455 }));
456
457 assert!(missing_modified_at.is_err());
458 assert!(missing_cards_modified_at.is_err());
459 }
460}
461
462#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
463pub struct ListWipLimitInput {
464 #[serde(skip_serializing_if = "Option::is_none")]
465 pub value: Option<Number>,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 pub enabled: Option<bool>,
468 #[serde(skip_serializing_if = "Option::is_none")]
469 pub soft: Option<bool>,
470}