Skip to main content

wekan_cli/client/
cards.rs

1use reqwest::{StatusCode, header::ACCEPT};
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Deserializer, Serialize};
4use serde_json::Number;
5
6use super::{ClientError, WekanClient, transport::decode_success};
7
8mod archival;
9pub use archival::{ArchiveCardResult, RestoreCardResult};
10mod inputs;
11pub use inputs::{
12    CardCustomFieldInput, CardLocationInput, CardPokerInput, CardStickerInput, CardVoteInput,
13};
14
15pub const CARD_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, Eq, PartialEq, Serialize)]
44#[serde(untagged)]
45pub enum CardDateInput {
46    Text(String),
47    EpochMilliseconds(Number),
48}
49
50impl From<String> for CardDateInput {
51    fn from(value: String) -> Self {
52        Self::Text(value)
53    }
54}
55
56#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
57#[serde(
58    deny_unknown_fields,
59    rename_all(deserialize = "camelCase", serialize = "snake_case")
60)]
61pub struct CardSummary {
62    #[serde(rename(deserialize = "_id", serialize = "card_id"))]
63    pub card_id: String,
64    pub title: Option<String>,
65    pub description: Option<String>,
66    pub swimlane_id: Option<String>,
67    pub received_at: Option<String>,
68    pub start_at: Option<String>,
69    pub due_at: Option<String>,
70    pub end_at: Option<String>,
71    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
72    pub assignees: Vec<String>,
73    pub sort: Option<Number>,
74}
75
76#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
77#[serde(untagged)]
78pub enum CardCustomFieldValue {
79    String(String),
80    Number(Number),
81    Boolean(bool),
82    Strings(Vec<Option<String>>),
83}
84
85#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86#[serde(
87    deny_unknown_fields,
88    rename_all(deserialize = "camelCase", serialize = "snake_case")
89)]
90pub struct CardCustomField {
91    #[serde(rename(deserialize = "_id", serialize = "custom_field_id"))]
92    pub custom_field_id: Option<String>,
93    pub value: Option<CardCustomFieldValue>,
94}
95
96#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum CardStickerHighlight {
99    Underline,
100    Round,
101}
102
103#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
104#[serde(deny_unknown_fields, rename_all = "snake_case")]
105pub struct CardSticker {
106    pub icon: Option<String>,
107    pub name: Option<String>,
108    pub highlight: Option<CardStickerHighlight>,
109    pub position: Option<Number>,
110}
111
112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
113#[serde(
114    deny_unknown_fields,
115    rename_all(deserialize = "camelCase", serialize = "snake_case")
116)]
117pub struct CardLocation {
118    #[serde(rename(deserialize = "_id", serialize = "location_id"))]
119    pub location_id: String,
120    pub name: Option<String>,
121    pub address: Option<String>,
122    pub latitude: Option<Number>,
123    pub longitude: Option<Number>,
124}
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127#[serde(
128    deny_unknown_fields,
129    rename_all(deserialize = "camelCase", serialize = "snake_case")
130)]
131pub struct CardDependency {
132    pub card_id: String,
133    #[serde(rename(deserialize = "type", serialize = "dependency_type"))]
134    pub dependency_type: Option<String>,
135    pub color: Option<String>,
136    pub icon: Option<String>,
137}
138
139#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
140#[serde(
141    deny_unknown_fields,
142    rename_all(deserialize = "camelCase", serialize = "snake_case")
143)]
144pub struct CardVote {
145    pub question: String,
146    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
147    pub positive: Vec<String>,
148    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
149    pub negative: Vec<String>,
150    pub end: Option<String>,
151    pub public: bool,
152    pub allow_non_board_members: bool,
153}
154
155#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
156#[serde(
157    deny_unknown_fields,
158    rename_all(deserialize = "camelCase", serialize = "snake_case")
159)]
160pub struct CardPoker {
161    pub question: Option<bool>,
162    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
163    pub one: Vec<String>,
164    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
165    pub two: Vec<String>,
166    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
167    pub three: Vec<String>,
168    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
169    pub five: Vec<String>,
170    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
171    pub eight: Vec<String>,
172    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
173    pub thirteen: Vec<String>,
174    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
175    pub twenty: Vec<String>,
176    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
177    pub forty: Vec<String>,
178    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
179    pub one_hundred: Vec<String>,
180    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
181    pub unsure: Vec<String>,
182    pub end: Option<String>,
183    pub allow_non_board_members: Option<bool>,
184    pub estimation: Option<Number>,
185}
186
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
188#[serde(
189    deny_unknown_fields,
190    rename_all(deserialize = "camelCase", serialize = "snake_case")
191)]
192pub struct CardDocument {
193    #[serde(rename(deserialize = "_id", serialize = "card_id"))]
194    pub card_id: String,
195    pub title: Option<String>,
196    pub archived: bool,
197    pub archived_at: Option<String>,
198    pub deleted_at: Option<String>,
199    pub deleted_by: Option<String>,
200    pub delete_batch_id: Option<String>,
201    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
202    pub parent_id: Option<String>,
203    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
204    pub list_id: Option<String>,
205    pub swimlane_id: String,
206    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
207    pub board_id: Option<String>,
208    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
209    pub cover_id: Option<String>,
210    pub color: Option<String>,
211    pub created_at: String,
212    pub modified_at: String,
213    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
214    pub custom_fields: Vec<CardCustomField>,
215    pub date_last_activity: String,
216    pub description: Option<String>,
217    pub requested_by: Option<String>,
218    pub assigned_by: Option<String>,
219    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
220    pub label_ids: Vec<Option<String>>,
221    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
222    pub members: Vec<String>,
223    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
224    pub assignees: Vec<String>,
225    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
226    pub requesters: Vec<String>,
227    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
228    pub assigners: Vec<String>,
229    pub received_at: Option<String>,
230    pub start_at: Option<String>,
231    pub due_at: Option<String>,
232    pub end_at: Option<String>,
233    pub due_complete: Option<bool>,
234    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
235    pub stickers: Vec<CardSticker>,
236    pub location_name: Option<String>,
237    pub location_address: Option<String>,
238    pub location_latitude: Option<Number>,
239    pub location_longitude: Option<Number>,
240    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
241    pub locations: Vec<CardLocation>,
242    pub spent_time: Option<Number>,
243    pub is_overtime: Option<bool>,
244    pub user_id: String,
245    pub sort: Option<Number>,
246    pub subtask_sort: Option<Number>,
247    #[serde(rename(deserialize = "type", serialize = "card_type"))]
248    pub card_type: String,
249    #[serde(default, deserialize_with = "deserialize_optional_non_empty_string")]
250    pub linked_id: Option<String>,
251    #[serde(default, deserialize_with = "deserialize_nullable_vec")]
252    pub card_dependencies: Vec<CardDependency>,
253    pub vote: Option<CardVote>,
254    pub poker: Option<CardPoker>,
255    #[serde(
256        rename(deserialize = "targetId_gantt", serialize = "target_id_gantt"),
257        default,
258        deserialize_with = "deserialize_nullable_vec"
259    )]
260    pub target_id_gantt: Vec<String>,
261    #[serde(
262        rename(deserialize = "linkType_gantt", serialize = "link_type_gantt"),
263        default,
264        deserialize_with = "deserialize_nullable_vec"
265    )]
266    pub link_type_gantt: Vec<Number>,
267    #[serde(
268        rename(deserialize = "linkId_gantt", serialize = "link_id_gantt"),
269        default,
270        deserialize_with = "deserialize_nullable_vec"
271    )]
272    pub link_id_gantt: Vec<String>,
273    pub card_number: Option<Number>,
274    pub show_activities: bool,
275    pub show_list_on_minicard: Option<bool>,
276    pub show_checklist_at_minicard: Option<bool>,
277    pub hide_finished_checklist_if_items_are_hidden: Option<bool>,
278}
279
280#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
281#[serde(rename_all = "camelCase")]
282pub struct CreateCardRequest {
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub title: Option<Option<String>>,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub linked_id: Option<String>,
287    pub swimlane_id: String,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub description: Option<Option<String>>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub members: Option<Vec<String>>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub assignees: Option<Vec<String>>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub received_at: Option<CardDateInput>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub start_at: Option<CardDateInput>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub due_at: Option<CardDateInput>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub end_at: Option<CardDateInput>,
302}
303
304#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
305#[serde(rename_all = "camelCase")]
306pub struct UpdateCardRequest {
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub title: Option<String>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub sort: Option<Number>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub parent_id: Option<String>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub description: Option<String>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub color: Option<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub label_ids: Option<Vec<Option<String>>>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub requested_by: Option<String>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub assigned_by: Option<String>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub received_at: Option<CardDateInput>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub start_at: Option<CardDateInput>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub due_at: Option<CardDateInput>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub end_at: Option<CardDateInput>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub spent_time: Option<Number>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub is_over_time: Option<bool>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub members: Option<Vec<String>>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub assignees: Option<Vec<String>>,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub due_complete: Option<bool>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub swimlane_id: Option<String>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub list_id: Option<String>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub new_board_id: Option<String>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub new_swimlane_id: Option<String>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub new_list_id: Option<String>,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub archive: Option<bool>,
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub vote: Option<CardVoteInput>,
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub poker: Option<CardPokerInput>,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub custom_fields: Option<Vec<CardCustomFieldInput>>,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub stickers: Option<Vec<CardStickerInput>>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub locations: Option<Vec<CardLocationInput>>,
363}
364
365#[derive(Clone, Debug, Eq, PartialEq)]
366pub struct CreateCardResult {
367    pub card_id: String,
368}
369
370#[derive(Clone, Debug, Eq, PartialEq)]
371pub struct UpdateCardResult {
372    pub card_id: String,
373}
374
375#[derive(Clone, Debug, Eq, PartialEq)]
376pub struct DeleteCardResult {
377    pub card_id: String,
378}
379
380#[derive(Deserialize)]
381#[serde(deny_unknown_fields)]
382struct IdResponse {
383    #[serde(rename = "_id")]
384    id: String,
385}
386
387impl WekanClient {
388    pub async fn board_list_cards(
389        &self,
390        board_id: &str,
391        list_id: &str,
392        token: &SecretString,
393    ) -> Result<Vec<CardSummary>, ClientError> {
394        let body = self
395            .execute_success_request(
396                self.http
397                    .get(self.card_endpoint(board_id, list_id, None)?)
398                    .header(ACCEPT, "application/json")
399                    .bearer_auth(token.expose_secret()),
400            )
401            .await?;
402        let cards = decode_success(&body, "board list card collection", true)?;
403        validate_card_summaries(cards)
404    }
405
406    pub async fn card(
407        &self,
408        board_id: &str,
409        list_id: &str,
410        card_id: &str,
411        token: &SecretString,
412    ) -> Result<CardDocument, ClientError> {
413        let body = self
414            .execute_success_request(
415                self.http
416                    .get(self.card_endpoint(board_id, list_id, Some(card_id))?)
417                    .header(ACCEPT, "application/json")
418                    .bearer_auth(token.expose_secret()),
419            )
420            .await?;
421        if body.is_empty() {
422            return Err(ClientError::EmbeddedServer {
423                http_status: StatusCode::OK,
424                wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
425                server_error: Some("card-not-found".to_owned()),
426                server_reason: Some("Card not found".to_owned()),
427            });
428        }
429        let card = decode_success(&body, "card", true)?;
430        validate_card_document(card)
431    }
432
433    pub async fn create_card(
434        &self,
435        board_id: &str,
436        list_id: &str,
437        request: &CreateCardRequest,
438        token: &SecretString,
439    ) -> Result<CreateCardResult, ClientError> {
440        let body = self
441            .execute_success_request(
442                self.http
443                    .post(self.card_endpoint(board_id, list_id, None)?)
444                    .header(ACCEPT, "application/json")
445                    .bearer_auth(token.expose_secret())
446                    .json(request),
447            )
448            .await?;
449        let response: IdResponse = decode_success(&body, "card creation", true)?;
450        require_non_empty(&response.id, "card creation", "card id")?;
451        Ok(CreateCardResult {
452            card_id: response.id,
453        })
454    }
455
456    pub async fn update_card(
457        &self,
458        board_id: &str,
459        list_id: &str,
460        card_id: &str,
461        request: &UpdateCardRequest,
462        token: &SecretString,
463    ) -> Result<UpdateCardResult, ClientError> {
464        let body = self
465            .execute_success_request(
466                self.http
467                    .put(self.card_endpoint(board_id, list_id, Some(card_id))?)
468                    .header(ACCEPT, "application/json")
469                    .bearer_auth(token.expose_secret())
470                    .json(request),
471            )
472            .await?;
473        let response: IdResponse = decode_success(&body, "card update", true)?;
474        require_non_empty(&response.id, "card update", "card id")?;
475        Ok(UpdateCardResult {
476            card_id: response.id,
477        })
478    }
479
480    pub async fn delete_card(
481        &self,
482        board_id: &str,
483        list_id: &str,
484        card_id: &str,
485        token: &SecretString,
486    ) -> Result<DeleteCardResult, ClientError> {
487        let body = self
488            .execute_success_request(
489                self.http
490                    .delete(self.card_endpoint(board_id, list_id, Some(card_id))?)
491                    .header(ACCEPT, "application/json")
492                    .bearer_auth(token.expose_secret()),
493            )
494            .await?;
495        let response: IdResponse = decode_success(&body, "card deletion", true)?;
496        require_non_empty(&response.id, "card deletion", "card id")?;
497        Ok(DeleteCardResult {
498            card_id: response.id,
499        })
500    }
501
502    fn card_endpoint(
503        &self,
504        board_id: &str,
505        list_id: &str,
506        card_id: Option<&str>,
507    ) -> Result<reqwest::Url, ClientError> {
508        let mut endpoint =
509            self.server()
510                .join("api/boards")
511                .map_err(|error| ClientError::Protocol {
512                    diagnostic: None,
513                    message: format!("could not build the card endpoint: {error}"),
514                    success_status_received: false,
515                })?;
516        let mut segments = endpoint
517            .path_segments_mut()
518            .map_err(|()| ClientError::Protocol {
519                diagnostic: None,
520                message: "could not add identifiers to the card endpoint".to_owned(),
521                success_status_received: false,
522            })?;
523        segments.push(board_id);
524        segments.push("lists");
525        segments.push(list_id);
526        segments.push("cards");
527        if let Some(card_id) = card_id {
528            segments.push(card_id);
529        }
530        drop(segments);
531        Ok(endpoint)
532    }
533}
534
535pub fn is_valid_card_color(value: &str) -> bool {
536    CARD_COLORS.contains(&value)
537        || value.len() == 7
538            && value.starts_with('#')
539            && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
540}
541
542fn validate_card_summaries(cards: Vec<CardSummary>) -> Result<Vec<CardSummary>, ClientError> {
543    for card in &cards {
544        require_non_empty(&card.card_id, "board list card collection", "card id")?;
545        validate_optional_id(
546            card.swimlane_id.as_deref(),
547            "board list card collection",
548            "swimlane id",
549        )?;
550        validate_ids(&card.assignees, "board list card collection", "assignee id")?;
551        for (field, value) in [
552            ("receivedAt", card.received_at.as_deref()),
553            ("startAt", card.start_at.as_deref()),
554            ("dueAt", card.due_at.as_deref()),
555            ("endAt", card.end_at.as_deref()),
556        ] {
557            validate_optional_date_time(value, field, "board list card collection")?;
558        }
559    }
560    Ok(cards)
561}
562
563fn validate_card_document(card: CardDocument) -> Result<CardDocument, ClientError> {
564    require_non_empty(&card.card_id, "card", "card id")?;
565    require_non_empty(&card.swimlane_id, "card", "swimlane id")?;
566    require_non_empty(&card.user_id, "card", "user id")?;
567    if !matches!(
568        card.card_type.as_str(),
569        "cardType-card" | "cardType-linkedCard" | "cardType-linkedBoard" | "template-card"
570    ) {
571        return Err(protocol_error(format!(
572            "the card response contained an invalid type `{}`",
573            card.card_type
574        )));
575    }
576    validate_date_time(&card.created_at, "createdAt", "card")?;
577    validate_date_time(&card.modified_at, "modifiedAt", "card")?;
578    validate_date_time(&card.date_last_activity, "dateLastActivity", "card")?;
579    for (field, value) in [
580        ("archivedAt", card.archived_at.as_deref()),
581        ("deletedAt", card.deleted_at.as_deref()),
582        ("receivedAt", card.received_at.as_deref()),
583        ("startAt", card.start_at.as_deref()),
584        ("dueAt", card.due_at.as_deref()),
585        ("endAt", card.end_at.as_deref()),
586        (
587            "vote.end",
588            card.vote.as_ref().and_then(|vote| vote.end.as_deref()),
589        ),
590        (
591            "poker.end",
592            card.poker.as_ref().and_then(|poker| poker.end.as_deref()),
593        ),
594    ] {
595        validate_optional_date_time(value, field, "card")?;
596    }
597    for (field, value) in [
598        ("parent id", card.parent_id.as_deref()),
599        ("list id", card.list_id.as_deref()),
600        ("board id", card.board_id.as_deref()),
601        ("cover id", card.cover_id.as_deref()),
602        ("linked id", card.linked_id.as_deref()),
603    ] {
604        validate_optional_id(value, "card", field)?;
605    }
606    if let Some(color) = &card.color
607        && !color.is_empty()
608        && !is_valid_card_color(color)
609    {
610        return Err(protocol_error(format!(
611            "the card response contained an invalid color `{color}`"
612        )));
613    }
614    for location in &card.locations {
615        require_non_empty(&location.location_id, "card", "location id")?;
616    }
617    for dependency in &card.card_dependencies {
618        require_non_empty(&dependency.card_id, "card", "dependency card id")?;
619        if let Some(dependency_type) = &dependency.dependency_type
620            && !matches!(
621                dependency_type.as_str(),
622                "related-to" | "blocks" | "is-blocked-by" | "fixes" | "is-fixed-by"
623            )
624        {
625            return Err(protocol_error(format!(
626                "the card response contained an invalid dependency type `{dependency_type}`"
627            )));
628        }
629    }
630    for (values, field) in [
631        (&card.members, "member id"),
632        (&card.assignees, "assignee id"),
633        (&card.requesters, "requester id"),
634        (&card.assigners, "assigner id"),
635        (&card.target_id_gantt, "Gantt target id"),
636        (&card.link_id_gantt, "Gantt link id"),
637    ] {
638        validate_ids(values, "card", field)?;
639    }
640    for label_id in card.label_ids.iter().flatten() {
641        require_non_empty(label_id, "card", "label id")?;
642    }
643    if let Some(vote) = &card.vote {
644        validate_ids(&vote.positive, "card", "positive voter id")?;
645        validate_ids(&vote.negative, "card", "negative voter id")?;
646    }
647    if let Some(poker) = &card.poker {
648        for (values, field) in [
649            (&poker.one, "poker voter id"),
650            (&poker.two, "poker voter id"),
651            (&poker.three, "poker voter id"),
652            (&poker.five, "poker voter id"),
653            (&poker.eight, "poker voter id"),
654            (&poker.thirteen, "poker voter id"),
655            (&poker.twenty, "poker voter id"),
656            (&poker.forty, "poker voter id"),
657            (&poker.one_hundred, "poker voter id"),
658            (&poker.unsure, "poker voter id"),
659        ] {
660            validate_ids(values, "card", field)?;
661        }
662    }
663    Ok(card)
664}
665
666fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
667where
668    D: Deserializer<'de>,
669    T: Deserialize<'de>,
670{
671    Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
672}
673
674fn deserialize_optional_non_empty_string<'de, D>(
675    deserializer: D,
676) -> Result<Option<String>, D::Error>
677where
678    D: Deserializer<'de>,
679{
680    Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
681}
682
683fn validate_ids(values: &[String], operation: &str, field: &str) -> Result<(), ClientError> {
684    for value in values {
685        require_non_empty(value, operation, field)?;
686    }
687    Ok(())
688}
689
690fn validate_optional_id(
691    value: Option<&str>,
692    operation: &str,
693    field: &str,
694) -> Result<(), ClientError> {
695    if let Some(value) = value {
696        require_non_empty(value, operation, field)?;
697    }
698    Ok(())
699}
700
701fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
702    if value.trim().is_empty() {
703        return Err(protocol_error(format!(
704            "the {operation} response contained an empty {field}"
705        )));
706    }
707    Ok(())
708}
709
710fn validate_optional_date_time(
711    value: Option<&str>,
712    field: &str,
713    operation: &str,
714) -> Result<(), ClientError> {
715    if let Some(value) = value {
716        validate_date_time(value, field, operation)?;
717    }
718    Ok(())
719}
720
721fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
722    super::datetime::validate_response_date(value).map_err(|_| {
723        protocol_error(format!(
724            "the {operation} response contained an invalid {field} date-time"
725        ))
726    })
727}
728
729fn protocol_error(message: String) -> ClientError {
730    ClientError::Protocol {
731        diagnostic: None,
732        message,
733        success_status_received: true,
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use serde_json::json;
740
741    use super::{CardDocument, CardSummary, is_valid_card_color};
742
743    #[test]
744    fn color_validation_matches_wekan_v11_06() {
745        for value in ["white", "silver", "indigo", "#12aBcF"] {
746            assert!(is_valid_card_color(value));
747        }
748        for value in ["", "belize", "#12345", "#12345g", "123456"] {
749            assert!(!is_valid_card_color(value));
750        }
751    }
752
753    #[test]
754    fn response_objects_reject_unmapped_fields() {
755        assert!(
756            serde_json::from_value::<CardSummary>(json!({
757                "_id": "card-1",
758                "future": true
759            }))
760            .is_err()
761        );
762        assert!(
763            serde_json::from_value::<CardDocument>(json!({
764                "_id": "card-1",
765                "archived": false,
766                "swimlaneId": "swimlane-1",
767                "createdAt": "2030-01-02T03:04:05Z",
768                "modifiedAt": "2030-01-02T03:04:05Z",
769                "dateLastActivity": "2030-01-02T03:04:05Z",
770                "userId": "user-1",
771                "type": "cardType-card",
772                "showActivities": false,
773                "vote": {"future": true}
774            }))
775            .is_err()
776        );
777    }
778}