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