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