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