Skip to main content

wekan_cli/client/
lists.rs

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