wekan-cli 0.11.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use reqwest::StatusCode;

use crate::{
    client::ClientError,
    error::{AppError, ErrorCode, ErrorDetails},
    exit_code::StableExitCode,
    redaction::Redactor,
};

#[derive(Clone, Copy)]
enum MutationOutcomePolicy {
    ReadOnly,
    Standard,
    PartialEffectsPossible,
}

pub(crate) fn protocol_diagnostic_details(
    success_status_received: bool,
    diagnostic: Option<crate::client::ResponseDiagnostic>,
    redactor: &Redactor<'_>,
) -> ErrorDetails {
    let mut details = protocol_error_details(success_status_received);
    if let Some(diagnostic) = diagnostic {
        details.response_path = Some(redactor.redact(&diagnostic.path));
        details.response_error = Some(diagnostic.kind);
    }
    details
}

pub(crate) fn map_client_error(
    error: ClientError,
    redactor: &Redactor<'_>,
    operation: &str,
    mutation: bool,
) -> AppError {
    let mutation_policy = if mutation {
        MutationOutcomePolicy::Standard
    } else {
        MutationOutcomePolicy::ReadOnly
    };
    map_client_error_with_optional_not_found(error, redactor, operation, mutation_policy, None)
}

pub(crate) fn map_client_error_with_not_found(
    error: ClientError,
    redactor: &Redactor<'_>,
    operation: &str,
    not_found_message: &str,
) -> AppError {
    map_client_error_with_optional_not_found(
        error,
        redactor,
        operation,
        MutationOutcomePolicy::ReadOnly,
        Some(not_found_message),
    )
}

pub(crate) fn map_mutation_client_error_with_not_found(
    error: ClientError,
    redactor: &Redactor<'_>,
    operation: &str,
    not_found_message: &str,
) -> AppError {
    map_client_error_with_optional_not_found(
        error,
        redactor,
        operation,
        MutationOutcomePolicy::Standard,
        Some(not_found_message),
    )
}

pub(crate) fn map_partial_mutation_client_error_with_not_found(
    error: ClientError,
    redactor: &Redactor<'_>,
    operation: &str,
    not_found_message: &str,
) -> AppError {
    map_client_error_with_optional_not_found(
        error,
        redactor,
        operation,
        MutationOutcomePolicy::PartialEffectsPossible,
        Some(not_found_message),
    )
}

fn map_client_error_with_optional_not_found(
    error: ClientError,
    redactor: &Redactor<'_>,
    operation: &str,
    mutation_policy: MutationOutcomePolicy,
    not_found_message: Option<&str>,
) -> AppError {
    let (code, message, exit_code, mut details) = match error {
        ClientError::Build(_) => (
            ErrorCode::InternalError,
            format!("the {operation} HTTP client could not be initialized"),
            StableExitCode::Internal,
            ErrorDetails::default(),
        ),
        ClientError::Transport(error) => (
            ErrorCode::TransportError,
            format!("the {operation} request could not be completed: {error}"),
            StableExitCode::Transport,
            ErrorDetails::default(),
        ),
        ClientError::UnexpectedRedirect { status } => (
            ErrorCode::UnexpectedRedirect,
            format!("the {operation} request received an unexpected redirect"),
            StableExitCode::Transport,
            response_error_details(status, None),
        ),
        ClientError::ResponseTooLarge {
            limit_bytes,
            status,
            retry_after_seconds,
        } => response_body_error(
            format!("the {operation} response exceeded the {limit_bytes}-byte limit"),
            status,
            retry_after_seconds,
            operation,
            not_found_message,
        ),
        ClientError::ResponseBody {
            status,
            retry_after_seconds,
            ..
        } => response_body_error(
            format!("the {operation} response body could not be read"),
            status,
            retry_after_seconds,
            operation,
            not_found_message,
        ),
        ClientError::Protocol {
            diagnostic,
            message,
            success_status_received,
        } => (
            ErrorCode::ProtocolError,
            message,
            StableExitCode::Transport,
            crate::commands::client_error::protocol_diagnostic_details(
                success_status_received,
                diagnostic,
                redactor,
            ),
        ),
        ClientError::EmbeddedProtocol {
            http_status,
            server_error,
            server_reason,
            server_message,
            server_error_type,
            server_is_client_safe,
        } => {
            let is_not_found =
                not_found_message.is_some() && server_error.as_deref() == Some("not-found");
            let code = if is_not_found {
                ErrorCode::NotFound
            } else {
                ErrorCode::ProtocolError
            };
            (
                code,
                if is_not_found {
                    status_error_message(code, operation, not_found_message)
                } else {
                    format!("the {operation} response contained a Wekan error without statusCode")
                },
                StableExitCode::Server,
                embedded_protocol_error_details(
                    http_status,
                    server_error,
                    server_reason,
                    server_message,
                    server_error_type,
                    server_is_client_safe,
                    redactor,
                ),
            )
        }
        ClientError::Server {
            status,
            server_error,
            server_reason,
            retry_after_seconds,
        } => {
            let code = status_error_code(status.as_u16(), not_found_message.is_some());
            let details = server_error_details(
                status,
                server_error,
                server_reason,
                retry_after_seconds,
                redactor,
            );
            (
                code,
                status_error_message(code, operation, not_found_message),
                StableExitCode::Server,
                details,
            )
        }
        ClientError::EmbeddedServer {
            http_status,
            wekan_status_code,
            server_error,
            server_reason,
        } => {
            let code = status_error_code(wekan_status_code, not_found_message.is_some());
            let details = embedded_server_error_details(
                http_status,
                wekan_status_code,
                server_error,
                server_reason,
                redactor,
            );
            (
                code,
                status_error_message(code, operation, not_found_message),
                StableExitCode::Server,
                details,
            )
        }
    };

    if mutation_outcome_is_unknown(mutation_policy, code, &details) {
        details.outcome_unknown = Some(true);
    }
    AppError::new(code, redactor.redact(&message), exit_code).with_details(details)
}

fn response_body_error(
    message: String,
    status: StatusCode,
    retry_after_seconds: Option<u64>,
    operation: &str,
    not_found_message: Option<&str>,
) -> (ErrorCode, String, StableExitCode, ErrorDetails) {
    if status == StatusCode::OK {
        return (
            ErrorCode::ProtocolError,
            message,
            StableExitCode::Transport,
            response_error_details(status, retry_after_seconds),
        );
    }

    let code = status_error_code(status.as_u16(), not_found_message.is_some());
    (
        code,
        status_error_message(code, operation, not_found_message),
        StableExitCode::Server,
        response_error_details(status, retry_after_seconds),
    )
}

const fn status_error_code(status: u16, map_not_found: bool) -> ErrorCode {
    match status {
        401 => ErrorCode::AuthenticationRejected,
        403 => ErrorCode::PermissionDenied,
        404 if map_not_found => ErrorCode::NotFound,
        _ => ErrorCode::ServerError,
    }
}

fn status_error_message(
    code: ErrorCode,
    operation: &str,
    not_found_message: Option<&str>,
) -> String {
    match code {
        ErrorCode::AuthenticationRejected => {
            "Wekan rejected the stored credential; remove it with `wekan auth logout --local-only --yes`, then log in again"
                .to_owned()
        }
        ErrorCode::PermissionDenied => format!("Wekan denied permission for {operation}"),
        ErrorCode::NotFound => not_found_message
            .expect("not-found mapping requires a dedicated message")
            .to_owned(),
        _ => format!("Wekan rejected the {operation} request"),
    }
}

fn mutation_outcome_is_unknown(
    policy: MutationOutcomePolicy,
    code: ErrorCode,
    details: &ErrorDetails,
) -> bool {
    match policy {
        MutationOutcomePolicy::ReadOnly => false,
        MutationOutcomePolicy::Standard => match code {
            ErrorCode::AuthenticationRejected
            | ErrorCode::PermissionDenied
            | ErrorCode::NotFound => false,
            ErrorCode::ServerError => details
                .wekan_status_code
                .or(details.http_status)
                .is_none_or(|status| status >= StatusCode::INTERNAL_SERVER_ERROR.as_u16()),
            ErrorCode::TransportError
            | ErrorCode::UnexpectedRedirect
            | ErrorCode::ProtocolError => true,
            _ => false,
        },
        MutationOutcomePolicy::PartialEffectsPossible => {
            // Authentication and client construction fail before Wekan can run
            // the handler; every later failure can follow an earlier field write.
            !matches!(
                code,
                ErrorCode::AuthenticationRejected | ErrorCode::InternalError
            )
        }
    }
}

pub(crate) fn protocol_error_details(success_status_received: bool) -> ErrorDetails {
    ErrorDetails {
        http_status: success_status_received.then_some(StatusCode::OK.as_u16()),
        ..ErrorDetails::default()
    }
}

pub(crate) fn response_error_details(
    status: StatusCode,
    retry_after_seconds: Option<u64>,
) -> ErrorDetails {
    ErrorDetails {
        http_status: Some(status.as_u16()),
        retry_after_seconds: retry_after_for_status(status, retry_after_seconds),
        ..ErrorDetails::default()
    }
}

fn retry_after_for_status(status: StatusCode, retry_after_seconds: Option<u64>) -> Option<u64> {
    (status == StatusCode::TOO_MANY_REQUESTS)
        .then_some(retry_after_seconds)
        .flatten()
}

pub(crate) fn server_error_details(
    status: StatusCode,
    server_error: Option<String>,
    server_reason: Option<String>,
    retry_after_seconds: Option<u64>,
    redactor: &Redactor<'_>,
) -> ErrorDetails {
    let mut details = response_error_details(status, retry_after_seconds);
    details.server_error = server_error.map(|value| redactor.redact(&value));
    details.server_reason = server_reason.map(|value| redactor.redact(&value));
    details
}

pub(crate) fn embedded_server_error_details(
    http_status: StatusCode,
    wekan_status_code: u16,
    server_error: Option<String>,
    server_reason: Option<String>,
    redactor: &Redactor<'_>,
) -> ErrorDetails {
    ErrorDetails {
        http_status: Some(http_status.as_u16()),
        wekan_status_code: Some(wekan_status_code),
        server_error: server_error.map(|value| redactor.redact(&value)),
        server_reason: server_reason.map(|value| redactor.redact(&value)),
        ..ErrorDetails::default()
    }
}

pub(crate) fn embedded_protocol_error_details(
    http_status: StatusCode,
    server_error: Option<String>,
    server_reason: Option<String>,
    server_message: Option<String>,
    server_error_type: Option<String>,
    server_is_client_safe: Option<bool>,
    redactor: &Redactor<'_>,
) -> ErrorDetails {
    ErrorDetails {
        http_status: Some(http_status.as_u16()),
        server_error: server_error.map(|value| redactor.redact(&value)),
        server_reason: server_reason.map(|value| redactor.redact(&value)),
        server_message: server_message.map(|value| redactor.redact(&value)),
        server_error_type: server_error_type.map(|value| redactor.redact(&value)),
        server_is_client_safe,
        ..ErrorDetails::default()
    }
}