wekan-cli 2.0.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use crate::client::metadata::*;
use reqwest::{StatusCode, header::ACCEPT};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use serde_json::Number;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};

use super::{ClientError, WekanClient, transport::decode_success};

mod transfer;
pub use transfer::TransferSwimlaneRequest;

pub const SWIMLANE_COLORS: &[&str] = &[
    "white",
    "green",
    "yellow",
    "orange",
    "red",
    "purple",
    "blue",
    "sky",
    "lime",
    "pink",
    "black",
    "silver",
    "peachpuff",
    "crimson",
    "plum",
    "darkgreen",
    "slateblue",
    "magenta",
    "gold",
    "navy",
    "gray",
    "saddlebrown",
    "paleturquoise",
    "mistyrose",
    "indigo",
];

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct SwimlaneSummary {
    #[serde(rename = "_id")]
    pub swimlane_id: String,
    pub title: String,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(
    deny_unknown_fields,
    rename_all(deserialize = "camelCase", serialize = "snake_case")
)]
pub struct SwimlaneDocument {
    pub scrum: Option<ScrumSwimlane>,

    pub scrum_revision: Option<serde_json::Number>,
    #[serde(rename(deserialize = "_id", serialize = "swimlane_id"))]
    pub swimlane_id: String,
    pub title: String,
    pub archived: bool,
    pub archived_at: Option<String>,
    pub board_id: String,
    pub created_at: String,
    pub sort: Option<Number>,
    pub color: Option<String>,
    pub updated_at: Option<String>,
    pub modified_at: String,
    #[serde(rename(deserialize = "type", serialize = "swimlane_type"))]
    pub swimlane_type: String,
    pub height: Option<Number>,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateSwimlaneRequest {
    pub title: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub sort: Option<Number>,
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct UpdateSwimlaneRequest {
    pub title: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CreateSwimlaneResult {
    pub swimlane_id: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpdateSwimlaneResult {
    pub swimlane_id: String,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DeleteSwimlaneResult {
    pub swimlane_id: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct IdResponse {
    #[serde(rename = "_id")]
    id: String,
}

impl WekanClient {
    pub async fn board_swimlanes(
        &self,
        board_id: &str,
        token: &SecretString,
    ) -> Result<Vec<SwimlaneSummary>, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .get(self.swimlane_endpoint(board_id, None)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        let swimlanes = decode_success(&body, "board swimlane collection", true)?;
        validate_swimlane_summaries(swimlanes)
    }

    pub async fn swimlane(
        &self,
        board_id: &str,
        swimlane_id: &str,
        token: &SecretString,
    ) -> Result<SwimlaneDocument, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .get(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        if body.is_empty() {
            return Err(ClientError::EmbeddedServer {
                http_status: StatusCode::OK,
                wekan_status_code: StatusCode::NOT_FOUND.as_u16(),
                server_error: Some("swimlane-not-found".to_owned()),
                server_reason: Some("Swimlane not found".to_owned()),
            });
        }
        let swimlane = decode_success(&body, "swimlane", true)?;
        validate_swimlane_document(swimlane)
    }

    pub async fn create_swimlane(
        &self,
        board_id: &str,
        request: &CreateSwimlaneRequest,
        token: &SecretString,
    ) -> Result<CreateSwimlaneResult, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .post(self.swimlane_endpoint(board_id, None)?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret())
                    .json(request),
            )
            .await?;
        let response: IdResponse = decode_success(&body, "swimlane creation", true)?;
        require_non_empty(&response.id, "swimlane creation", "swimlane id")?;
        Ok(CreateSwimlaneResult {
            swimlane_id: response.id,
        })
    }

    pub async fn update_swimlane(
        &self,
        board_id: &str,
        swimlane_id: &str,
        request: &UpdateSwimlaneRequest,
        token: &SecretString,
    ) -> Result<UpdateSwimlaneResult, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .put(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret())
                    .json(request),
            )
            .await?;
        let response: IdResponse = decode_success(&body, "swimlane update", true)?;
        require_non_empty(&response.id, "swimlane update", "swimlane id")?;
        Ok(UpdateSwimlaneResult {
            swimlane_id: response.id,
        })
    }

    pub async fn delete_swimlane(
        &self,
        board_id: &str,
        swimlane_id: &str,
        token: &SecretString,
    ) -> Result<DeleteSwimlaneResult, ClientError> {
        let body = self
            .execute_success_request(
                self.http
                    .delete(self.swimlane_endpoint(board_id, Some(swimlane_id))?)
                    .header(ACCEPT, "application/json")
                    .bearer_auth(token.expose_secret()),
            )
            .await?;
        let response: IdResponse = decode_success(&body, "swimlane deletion", true)?;
        require_non_empty(&response.id, "swimlane deletion", "swimlane id")?;
        Ok(DeleteSwimlaneResult {
            swimlane_id: response.id,
        })
    }

    pub(super) fn swimlane_endpoint(
        &self,
        board_id: &str,
        swimlane_id: Option<&str>,
    ) -> Result<reqwest::Url, ClientError> {
        let mut endpoint =
            self.server()
                .join("api/boards")
                .map_err(|error| ClientError::Protocol {
                    diagnostic: None,
                    message: format!("could not build the swimlane endpoint: {error}"),
                    success_status_received: false,
                })?;
        let mut segments = endpoint
            .path_segments_mut()
            .map_err(|()| ClientError::Protocol {
                diagnostic: None,
                message: "could not add identifiers to the swimlane endpoint".to_owned(),
                success_status_received: false,
            })?;
        segments.push(board_id);
        segments.push("swimlanes");
        if let Some(swimlane_id) = swimlane_id {
            segments.push(swimlane_id);
        }
        drop(segments);
        Ok(endpoint)
    }
}

pub fn is_valid_swimlane_color(value: &str) -> bool {
    SWIMLANE_COLORS.contains(&value)
        || value.len() == 7
            && value.starts_with('#')
            && value[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
}

fn validate_swimlane_summaries(
    swimlanes: Vec<SwimlaneSummary>,
) -> Result<Vec<SwimlaneSummary>, ClientError> {
    for swimlane in &swimlanes {
        require_non_empty(
            &swimlane.swimlane_id,
            "board swimlane collection",
            "swimlane id",
        )?;
        require_non_empty(&swimlane.title, "board swimlane collection", "title")?;
    }
    Ok(swimlanes)
}

fn validate_swimlane_document(swimlane: SwimlaneDocument) -> Result<SwimlaneDocument, ClientError> {
    require_non_empty(&swimlane.swimlane_id, "swimlane", "swimlane id")?;
    require_non_empty(&swimlane.title, "swimlane", "title")?;
    require_non_empty(&swimlane.board_id, "swimlane", "board id")?;
    require_non_empty(&swimlane.swimlane_type, "swimlane", "type")?;
    validate_date_time(&swimlane.created_at, "createdAt", "swimlane")?;
    validate_date_time(&swimlane.modified_at, "modifiedAt", "swimlane")?;
    for (field, value) in [
        ("archivedAt", swimlane.archived_at.as_deref()),
        ("updatedAt", swimlane.updated_at.as_deref()),
    ] {
        validate_optional_date_time(value, field, "swimlane")?;
    }
    if let Some(color) = &swimlane.color
        && !color.is_empty()
        && !is_valid_swimlane_color(color)
    {
        return Err(protocol_error(format!(
            "the swimlane response contained an invalid color `{color}`"
        )));
    }
    if let Some(height) = &swimlane.height {
        let valid = height
            .as_f64()
            .is_some_and(|height| height == -1.0 || (50.0..=2000.0).contains(&height));
        if !valid {
            return Err(protocol_error(
                "the swimlane response contained a height outside -1 or 50 through 2000".to_owned(),
            ));
        }
    }
    Ok(swimlane)
}

fn require_non_empty(value: &str, operation: &str, field: &str) -> Result<(), ClientError> {
    if value.is_empty() {
        return Err(protocol_error(format!(
            "the {operation} response contained an empty {field}"
        )));
    }
    Ok(())
}

fn validate_optional_date_time(
    value: Option<&str>,
    field: &str,
    operation: &str,
) -> Result<(), ClientError> {
    if let Some(value) = value {
        validate_date_time(value, field, operation)?;
    }
    Ok(())
}

fn validate_date_time(value: &str, field: &str, operation: &str) -> Result<(), ClientError> {
    OffsetDateTime::parse(value, &Rfc3339)
        .map(|_| ())
        .map_err(|_| {
            protocol_error(format!(
                "the {operation} response contained an invalid {field} date-time"
            ))
        })
}

fn protocol_error(message: String) -> ClientError {
    ClientError::Protocol {
        diagnostic: None,
        message,
        success_status_received: true,
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    use super::{SwimlaneDocument, SwimlaneSummary, is_valid_swimlane_color};

    #[test]
    fn color_validation_matches_wekan_v12_57() {
        for value in ["white", "silver", "indigo", "#12aBcF"] {
            assert!(is_valid_swimlane_color(value));
        }
        for value in ["", "belize", "#12345", "#12345g", "123456"] {
            assert!(!is_valid_swimlane_color(value));
        }
    }

    #[test]
    fn response_objects_reject_unmapped_fields() {
        assert!(
            serde_json::from_value::<SwimlaneSummary>(json!({
                "_id": "swimlane-1",
                "title": "Delivery",
                "unexpected": true
            }))
            .is_err()
        );
        assert!(
            serde_json::from_value::<SwimlaneDocument>(json!({
                "_id": "swimlane-1",
                "title": "Delivery",
                "archived": false,
                "boardId": "board-1",
                "createdAt": "2030-01-02T03:04:05Z",
                "modifiedAt": "2030-01-02T03:04:05Z",
                "type": "swimlane",
                "unexpected": true
            }))
            .is_err()
        );
    }
}