1use reqwest::header::ACCEPT;
2use secrecy::{ExposeSecret, SecretString};
3use serde::{Deserialize, Serialize};
4use serde_json::Number;
5
6use super::{ClientError, WekanClient, transport::decode_success};
7
8#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum DependencyType {
11 RelatedTo,
12 Blocks,
13 IsBlockedBy,
14 Fixes,
15 IsFixedBy,
16}
17
18impl DependencyType {
19 pub const fn as_str(self) -> &'static str {
20 match self {
21 Self::RelatedTo => "related-to",
22 Self::Blocks => "blocks",
23 Self::IsBlockedBy => "is-blocked-by",
24 Self::Fixes => "fixes",
25 Self::IsFixedBy => "is-fixed-by",
26 }
27 }
28}
29
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
32#[serde(deny_unknown_fields)]
33pub struct CardDependencySummary {
34 #[serde(rename = "cardId")]
35 pub target_card_id: String,
36 #[serde(rename = "type")]
37 pub dependency_type: DependencyType,
38 pub color: String,
39 pub icon: String,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
43#[serde(deny_unknown_fields)]
44pub struct BoardDependencyLine {
45 #[serde(rename = "from")]
46 pub source_card_id: String,
47 #[serde(rename = "fromTitle")]
48 pub source_title: Option<String>,
49 #[serde(rename = "fromCardNumber")]
50 pub source_card_number: Option<Number>,
51 #[serde(rename = "to")]
52 pub target_card_id: String,
53 #[serde(rename = "toTitle")]
54 pub target_title: Option<String>,
55 #[serde(rename = "toCardNumber")]
56 pub target_card_number: Option<Number>,
57 #[serde(rename = "type")]
58 pub dependency_type: DependencyType,
59 pub color: String,
60 pub icon: String,
61}
62
63#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
64#[serde(deny_unknown_fields)]
65pub struct CreateDependencyResponse {
66 #[serde(rename = "_id")]
67 pub card_id: String,
68 #[serde(rename = "cardId")]
69 pub target_card_id: String,
70 #[serde(rename = "type")]
71 pub dependency_type: DependencyType,
72 pub color: String,
73 pub icon: String,
74}
75
76#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
77#[serde(deny_unknown_fields)]
78pub struct DependencyMutationResponse {
79 #[serde(rename = "_id")]
80 pub card_id: String,
81}
82
83#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
85pub struct DependencyMetadata {
86 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
87 pub dependency_type: Option<Option<String>>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub color: Option<Option<String>>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub icon: Option<Option<String>>,
92}
93
94#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
95pub struct CreateDependencyRequest {
96 #[serde(rename = "cardId")]
97 pub target_card_id: String,
98 #[serde(flatten)]
99 pub metadata: DependencyMetadata,
100}
101
102impl WekanClient {
103 pub async fn card_dependencies(
104 &self,
105 board: &str,
106 card: &str,
107 token: &SecretString,
108 ) -> Result<Vec<CardDependencySummary>, ClientError> {
109 let body = self
110 .execute_success_request(
111 self.http
112 .get(self.dependency_endpoint(board, Some(card), None)?)
113 .header(ACCEPT, "application/json")
114 .bearer_auth(token.expose_secret()),
115 )
116 .await?;
117 let dependencies: Vec<CardDependencySummary> =
118 decode_success(&body, "card dependencies", true)?;
119 for dependency in &dependencies {
120 require_id(&dependency.target_card_id)?;
121 }
122 Ok(dependencies)
123 }
124
125 pub async fn board_dependencies(
126 &self,
127 board: &str,
128 token: &SecretString,
129 ) -> Result<Vec<BoardDependencyLine>, ClientError> {
130 let body = self
131 .execute_success_request(
132 self.http
133 .get(self.dependency_endpoint(board, None, None)?)
134 .header(ACCEPT, "application/json")
135 .bearer_auth(token.expose_secret()),
136 )
137 .await?;
138 let dependencies: Vec<BoardDependencyLine> =
139 decode_success(&body, "board dependencies", true)?;
140 for dependency in &dependencies {
141 require_id(&dependency.source_card_id)?;
142 require_id(&dependency.target_card_id)?;
143 }
144 Ok(dependencies)
145 }
146
147 pub async fn create_dependency(
148 &self,
149 board: &str,
150 card: &str,
151 request: &CreateDependencyRequest,
152 token: &SecretString,
153 ) -> Result<CreateDependencyResponse, ClientError> {
154 let body = self
155 .execute_success_request(
156 self.http
157 .post(self.dependency_endpoint(board, Some(card), None)?)
158 .header(ACCEPT, "application/json")
159 .bearer_auth(token.expose_secret())
160 .json(request),
161 )
162 .await?;
163 let response: CreateDependencyResponse =
164 decode_success(&body, "dependency creation", true)?;
165 require_id(&response.card_id)?;
166 require_id(&response.target_card_id)?;
167 if response.card_id != card || response.target_card_id != request.target_card_id {
168 return Err(protocol_error(
169 "Wekan acknowledged a dependency with different source or target IDs",
170 ));
171 }
172 Ok(response)
173 }
174
175 pub async fn update_dependency(
176 &self,
177 board: &str,
178 card: &str,
179 target: &str,
180 request: &DependencyMetadata,
181 token: &SecretString,
182 ) -> Result<DependencyMutationResponse, ClientError> {
183 let body = self
184 .execute_success_request(
185 self.http
186 .put(self.dependency_endpoint(board, Some(card), Some(target))?)
187 .header(ACCEPT, "application/json")
188 .bearer_auth(token.expose_secret())
189 .json(request),
190 )
191 .await?;
192 decode_acknowledgment(&body, card)
193 }
194
195 pub async fn delete_dependency(
196 &self,
197 board: &str,
198 card: &str,
199 target: &str,
200 token: &SecretString,
201 ) -> Result<DependencyMutationResponse, ClientError> {
202 let body = self
203 .execute_success_request(
204 self.http
205 .delete(self.dependency_endpoint(board, Some(card), Some(target))?)
206 .header(ACCEPT, "application/json")
207 .bearer_auth(token.expose_secret()),
208 )
209 .await?;
210 decode_acknowledgment(&body, card)
211 }
212
213 fn dependency_endpoint(
214 &self,
215 board: &str,
216 card: Option<&str>,
217 target: Option<&str>,
218 ) -> Result<reqwest::Url, ClientError> {
219 for id in [Some(board), card, target].into_iter().flatten() {
221 if id.trim().is_empty() || matches!(id, "." | "..") {
222 return Err(ClientError::Protocol { diagnostic: None,
223 message: "dependency endpoint IDs must be nonempty and not dot-only; no request was sent".to_owned(),
224 success_status_received: false });
225 }
226 }
227 let mut endpoint = self
228 .server()
229 .join("api/boards")
230 .map_err(|_| ClientError::Protocol {
231 diagnostic: None,
232 message: "could not build the dependency endpoint".to_owned(),
233 success_status_received: false,
234 })?;
235 let mut segments = endpoint
236 .path_segments_mut()
237 .map_err(|()| ClientError::Protocol {
238 diagnostic: None,
239 message: "could not add dependency endpoint identifiers".to_owned(),
240 success_status_received: false,
241 })?;
242 segments.push(board);
243 if let Some(card) = card {
244 segments.extend(["cards", card]);
245 }
246 segments.push("dependencies");
247 if let Some(target) = target {
248 segments.push(target);
249 }
250 drop(segments);
251 Ok(endpoint)
252 }
253}
254
255fn decode_acknowledgment(
256 body: &[u8],
257 card: &str,
258) -> Result<DependencyMutationResponse, ClientError> {
259 let response: DependencyMutationResponse = decode_success(body, "dependency mutation", true)?;
260 require_id(&response.card_id)?;
261 if response.card_id != card {
262 return Err(protocol_error(
263 "Wekan acknowledged a different source card ID than requested",
264 ));
265 }
266 Ok(response)
267}
268
269fn require_id(id: &str) -> Result<(), ClientError> {
270 if id.trim().is_empty() {
271 Err(protocol_error(
272 "the dependency response contained an empty card ID",
273 ))
274 } else {
275 Ok(())
276 }
277}
278
279fn protocol_error(message: &str) -> ClientError {
280 ClientError::Protocol {
281 diagnostic: None,
282 message: message.to_owned(),
283 success_status_received: true,
284 }
285}