1use crate::client::metadata::*;
2use reqwest::{StatusCode, header::ACCEPT};
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Serialize};
5use serde_json::Number;
6use time::{OffsetDateTime, format_description::well_known::Rfc3339};
7
8use super::{ClientError, WekanClient, transport::decode_success};
9
10mod transfer;
11pub use transfer::TransferSwimlaneRequest;
12
13pub const SWIMLANE_COLORS: &[&str] = &[
14 "white",
15 "green",
16 "yellow",
17 "orange",
18 "red",
19 "purple",
20 "blue",
21 "sky",
22 "lime",
23 "pink",
24 "black",
25 "silver",
26 "peachpuff",
27 "crimson",
28 "plum",
29 "darkgreen",
30 "slateblue",
31 "magenta",
32 "gold",
33 "navy",
34 "gray",
35 "saddlebrown",
36 "paleturquoise",
37 "mistyrose",
38 "indigo",
39];
40
41#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
42#[serde(deny_unknown_fields, rename_all = "camelCase")]
43pub struct SwimlaneSummary {
44 #[serde(rename = "_id")]
45 pub swimlane_id: String,
46 pub title: String,
47}
48
49#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(
51 deny_unknown_fields,
52 rename_all(deserialize = "camelCase", serialize = "snake_case")
53)]
54pub struct SwimlaneDocument {
55 pub scrum: Option<ScrumSwimlane>,
56
57 pub scrum_revision: Option<serde_json::Number>,
58 #[serde(rename(deserialize = "_id", serialize = "swimlane_id"))]
59 pub swimlane_id: String,
60 pub title: String,
61 pub archived: bool,
62 pub archived_at: Option<String>,
63 pub board_id: String,
64 pub created_at: String,
65 pub sort: Option<Number>,
66 pub color: Option<String>,
67 pub updated_at: Option<String>,
68 pub modified_at: String,
69 #[serde(rename(deserialize = "type", serialize = "swimlane_type"))]
70 pub swimlane_type: String,
71 pub height: Option<Number>,
72}
73
74#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
75#[serde(rename_all = "camelCase")]
76pub struct CreateSwimlaneRequest {
77 pub title: String,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub sort: Option<Number>,
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
83pub struct UpdateSwimlaneRequest {
84 pub title: String,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
88pub struct CreateSwimlaneResult {
89 pub swimlane_id: String,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct UpdateSwimlaneResult {
94 pub swimlane_id: String,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct DeleteSwimlaneResult {
99 pub swimlane_id: String,
100}
101
102#[derive(Deserialize)]
103#[serde(deny_unknown_fields)]
104struct IdResponse {
105 #[serde(rename = "_id")]
106 id: String,
107}
108
109impl WekanClient {
110 pub async fn board_swimlanes(
111 &self,
112 board_id: &str,
113 token: &SecretString,
114 ) -> Result<Vec<SwimlaneSummary>, ClientError> {
115 let body = self
116 .execute_success_request(
117 self.http
118 .get(self.swimlane_endpoint(board_id, None)?)
119 .header(ACCEPT, "application/json")
120 .bearer_auth(token.expose_secret()),
121 )
122 .await?;
123 let swimlanes = decode_success(&body, "board swimlane collection", true)?;
124 validate_swimlane_summaries(swimlanes)
125 }
126
127 pub async fn swimlane(
128 &self,
129 board_id: &str,
130 swimlane_id: &str,
131 token: &SecretString,
132 ) -> Result<SwimlaneDocument, ClientError> {
133 let body = self
134 .execute_success_request(
135 self.http
136 .get(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
137 .header(ACCEPT, "application/json")
138 .bearer_auth(token.expose_secret()),
139 )
140 .await?;
141 if body.is_empty() {
142 return Err(ClientError::EmbeddedServer {
143 http_status: StatusCode::OK,
144 wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
145 server_error: Some("swimlane-not-found".to_owned()),
146 server_reason: Some("Swimlane not found".to_owned()),
147 });
148 }
149 let swimlane = decode_success(&body, "swimlane", true)?;
150 validate_swimlane_document(swimlane)
151 }
152
153 pub async fn create_swimlane(
154 &self,
155 board_id: &str,
156 request: &CreateSwimlaneRequest,
157 token: &SecretString,
158 ) -> Result<CreateSwimlaneResult, ClientError> {
159 let body = self
160 .execute_success_request(
161 self.http
162 .post(self.swimlane_endpoint(board_id, None)?)
163 .header(ACCEPT, "application/json")
164 .bearer_auth(token.expose_secret())
165 .json(request),
166 )
167 .await?;
168 let response: IdResponse = decode_success(&body, "swimlane creation", true)?;
169 require_non_empty(&response.id, "swimlane creation", "swimlane id")?;
170 Ok(CreateSwimlaneResult {
171 swimlane_id: response.id,
172 })
173 }
174
175 pub async fn update_swimlane(
176 &self,
177 board_id: &str,
178 swimlane_id: &str,
179 request: &UpdateSwimlaneRequest,
180 token: &SecretString,
181 ) -> Result<UpdateSwimlaneResult, ClientError> {
182 let body = self
183 .execute_success_request(
184 self.http
185 .put(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
186 .header(ACCEPT, "application/json")
187 .bearer_auth(token.expose_secret())
188 .json(request),
189 )
190 .await?;
191 let response: IdResponse = decode_success(&body, "swimlane update", true)?;
192 require_non_empty(&response.id, "swimlane update", "swimlane id")?;
193 Ok(UpdateSwimlaneResult {
194 swimlane_id: response.id,
195 })
196 }
197
198 pub async fn delete_swimlane(
199 &self,
200 board_id: &str,
201 swimlane_id: &str,
202 token: &SecretString,
203 ) -> Result<DeleteSwimlaneResult, ClientError> {
204 let body = self
205 .execute_success_request(
206 self.http
207 .delete(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
208 .header(ACCEPT, "application/json")
209 .bearer_auth(token.expose_secret()),
210 )
211 .await?;
212 let response: IdResponse = decode_success(&body, "swimlane deletion", true)?;
213 require_non_empty(&response.id, "swimlane deletion", "swimlane id")?;
214 Ok(DeleteSwimlaneResult {
215 swimlane_id: response.id,
216 })
217 }
218
219 pub(super) fn swimlane_endpoint(
220 &self,
221 board_id: &str,
222 swimlane_id: Option<&str>,
223 ) -> Result<reqwest::Url, ClientError> {
224 let mut endpoint =
225 self.server()
226 .join("api/boards")
227 .map_err(|error| ClientError::Protocol {
228 diagnostic: None,
229 message: format!("could not build the swimlane endpoint: {error}"),
230 success_status_received: false,
231 })?;
232 let mut segments = endpoint
233 .path_segments_mut()
234 .map_err(|()| ClientError::Protocol {
235 diagnostic: None,
236 message: "could not add identifiers to the swimlane endpoint".to_owned(),
237 success_status_received: false,
238 })?;
239 segments.push(board_id);
240 segments.push("swimlanes");
241 if let Some(swimlane_id) = swimlane_id {
242 segments.push(swimlane_id);
243 }
244 drop(segments);
245 Ok(endpoint)
246 }
247}
248
249pub fn is_valid_swimlane_color(value: &str) -> bool {
250 SWIMLANE_COLORS.contains(&value)
251 || value.len() == 7
252 && value.starts_with('#')
253 && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
254}
255
256fn validate_swimlane_summaries(
257 swimlanes: Vec<SwimlaneSummary>,
258) -> Result<Vec<SwimlaneSummary>, ClientError> {
259 for swimlane in &swimlanes {
260 require_non_empty(
261 &swimlane.swimlane_id,
262 "board swimlane collection",
263 "swimlane id",
264 )?;
265 require_non_empty(&swimlane.title, "board swimlane collection", "title")?;
266 }
267 Ok(swimlanes)
268}
269
270fn validate_swimlane_document(swimlane: SwimlaneDocument) -> Result<SwimlaneDocument, ClientError> {
271 require_non_empty(&swimlane.swimlane_id, "swimlane", "swimlane id")?;
272 require_non_empty(&swimlane.title, "swimlane", "title")?;
273 require_non_empty(&swimlane.board_id, "swimlane", "board id")?;
274 require_non_empty(&swimlane.swimlane_type, "swimlane", "type")?;
275 validate_date_time(&swimlane.created_at, "createdAt", "swimlane")?;
276 validate_date_time(&swimlane.modified_at, "modifiedAt", "swimlane")?;
277 for (field, value) in [
278 ("archivedAt", swimlane.archived_at.as_deref()),
279 ("updatedAt", swimlane.updated_at.as_deref()),
280 ] {
281 validate_optional_date_time(value, field, "swimlane")?;
282 }
283 if let Some(color) = &swimlane.color
284 && !color.is_empty()
285 && !is_valid_swimlane_color(color)
286 {
287 return Err(protocol_error(format!(
288 "the swimlane response contained an invalid color `{color}`"
289 )));
290 }
291 if let Some(height) = &swimlane.height {
292 let valid = height
293 .as_f64()
294 .is_some_and(|height| height == -1.0 || (50.0..=2000.0).contains(&height));
295 if !valid {
296 return Err(protocol_error(
297 "the swimlane response contained a height outside -1 or 50 through 2000".to_owned(),
298 ));
299 }
300 }
301 Ok(swimlane)
302}
303
304fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
305 if value.is_empty() {
306 return Err(protocol_error(format!(
307 "the {operation} response contained an empty {field}"
308 )));
309 }
310 Ok(())
311}
312
313fn validate_optional_date_time(
314 value: Option<&str>,
315 field: &str,
316 operation: &str,
317) -> Result<(), ClientError> {
318 if let Some(value) = value {
319 validate_date_time(value, field, operation)?;
320 }
321 Ok(())
322}
323
324fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
325 OffsetDateTime::parse(value, &Rfc3339)
326 .map(|_| ())
327 .map_err(|_| {
328 protocol_error(format!(
329 "the {operation} response contained an invalid {field} date-time"
330 ))
331 })
332}
333
334fn protocol_error(message: String) -> ClientError {
335 ClientError::Protocol {
336 diagnostic: None,
337 message,
338 success_status_received: true,
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use serde_json::json;
345
346 use super::{SwimlaneDocument, SwimlaneSummary, is_valid_swimlane_color};
347
348 #[test]
349 fn color_validation_matches_wekan_v12_57() {
350 for value in ["white", "silver", "indigo", "#12aBcF"] {
351 assert!(is_valid_swimlane_color(value));
352 }
353 for value in ["", "belize", "#12345", "#12345g", "123456"] {
354 assert!(!is_valid_swimlane_color(value));
355 }
356 }
357
358 #[test]
359 fn response_objects_reject_unmapped_fields() {
360 assert!(
361 serde_json::from_value::<SwimlaneSummary>(json!({
362 "_id": "swimlane-1",
363 "title": "Delivery",
364 "unexpected": true
365 }))
366 .is_err()
367 );
368 assert!(
369 serde_json::from_value::<SwimlaneDocument>(json!({
370 "_id": "swimlane-1",
371 "title": "Delivery",
372 "archived": false,
373 "boardId": "board-1",
374 "createdAt": "2030-01-02T03:04:05Z",
375 "modifiedAt": "2030-01-02T03:04:05Z",
376 "type": "swimlane",
377 "unexpected": true
378 }))
379 .is_err()
380 );
381 }
382}