Skip to main content

wekan_cli/client/
dependencies.rs

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/// Normalized endpoint response, distinct from raw cardDependencies entries.
31#[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/// None omits a property, Some(None) sends null, Some(Some(value)) sends the string.
84#[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        // URL path segments cannot represent dot-only IDs without normalization.
220        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}