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