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