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