wekan-cli 2.1.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use crate::client::admin::problems as wire;
use serde::Serialize;
use serde_json::Number;
use std::collections::BTreeMap;

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ProgressProblem {
    pub kind: String,
    pub active: bool,
    pub message: String,
}

impl From<wire::ProgressProblem> for ProgressProblem {
    fn from(problem: wire::ProgressProblem) -> Self {
        Self {
            kind: problem.kind,
            active: problem.active,
            message: problem.message,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct DetectedProblem {
    pub id: String,
    pub severity: String,
    pub title: String,
    pub detail: String,
    pub count: Option<Number>,
}

impl From<wire::DetectedProblem> for DetectedProblem {
    fn from(problem: wire::DetectedProblem) -> Self {
        Self {
            id: problem.id,
            severity: problem.severity,
            title: problem.title,
            detail: problem.detail,
            count: problem.count,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct NewProblemCount {
    pub stream: String,
    pub count: u64,
}

impl From<wire::NewProblemCount> for NewProblemCount {
    fn from(problem: wire::NewProblemCount) -> Self {
        Self {
            stream: problem.stream,
            count: problem.count,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ProblemsOverview {
    pub any_in_progress: bool,
    pub in_progress: Vec<ProgressProblem>,
    pub any_problems: bool,
    pub problems: Vec<DetectedProblem>,
    pub new_problems: Vec<NewProblemCount>,
    pub streams: Vec<String>,
}

impl From<wire::ProblemsOverview> for ProblemsOverview {
    fn from(overview: wire::ProblemsOverview) -> Self {
        Self {
            any_in_progress: overview.any_in_progress,
            in_progress: overview.in_progress.into_iter().map(Into::into).collect(),
            any_problems: overview.any_problems,
            problems: overview.problems.into_iter().map(Into::into).collect(),
            new_problems: overview.new_problems.into_iter().map(Into::into).collect(),
            streams: overview.streams,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct EventActor {
    pub kind: String,
    pub value: String,
    pub count: Number,
    pub at: String,
    pub family: Option<String>,
}

impl From<wire::EventActor> for EventActor {
    fn from(actor: wire::EventActor) -> Self {
        Self {
            kind: actor.kind,
            value: actor.value,
            count: actor.count,
            at: actor.at,
            family: actor.family,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct EventLocation {
    pub country: Option<String>,
    pub city: Option<String>,
    pub region: Option<String>,
    pub latitude: Option<Number>,
    pub longitude: Option<Number>,
    pub via: Option<String>,
}

impl From<wire::EventLocation> for EventLocation {
    fn from(location: wire::EventLocation) -> Self {
        Self {
            country: location.country,
            city: location.city,
            region: location.region,
            latitude: location.latitude,
            longitude: location.longitude,
            via: location.via,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ProblemEvent {
    pub event_id: String,
    pub stream: String,
    pub at: String,
    pub severity: Option<String>,
    pub category: Option<String>,
    pub bleed: Option<String>,
    pub action: Option<String>,
    pub source: Option<String>,
    pub cwe: Option<String>,
    pub user_id: Option<String>,
    pub username: Option<String>,
    pub ip: Option<String>,
    pub ipv4: Option<String>,
    pub ipv6: Option<String>,
    pub location: Option<EventLocation>,
    pub count: Option<Number>,
    pub first_at: Option<String>,
    pub actors: Option<BTreeMap<String, EventActor>>,
    pub actors_overflow: Option<Number>,
    pub detail: Option<String>,
    pub event_type: Option<String>,
    pub db: Option<String>,
    pub kind: Option<String>,
    pub message: Option<String>,
    pub api: Option<String>,
    pub api_user_id: Option<String>,
}

impl From<wire::ProblemEvent> for ProblemEvent {
    fn from(event: wire::ProblemEvent) -> Self {
        Self {
            event_id: event.event_id,
            stream: event.stream,
            at: event.at,
            severity: event.severity,
            category: event.category,
            bleed: event.bleed,
            action: event.action,
            source: event.source,
            cwe: event.cwe,
            user_id: event.user_id,
            username: event.username,
            ip: event.ip,
            ipv4: event.ipv4,
            ipv6: event.ipv6,
            location: event.location.map(Into::into),
            count: event.count,
            first_at: event.first_at,
            actors: event.actors.map(|actors| {
                actors
                    .into_iter()
                    .map(|(key, actor)| (key, actor.into()))
                    .collect()
            }),
            actors_overflow: event.actors_overflow,
            detail: event.detail,
            event_type: event.event_type,
            db: event.db,
            kind: event.kind,
            message: event.message,
            api: event.api,
            api_user_id: event.api_user_id,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct ProblemPage {
    pub stream: String,
    pub total: u64,
    pub limit: u64,
    pub skip: u64,
    pub rows: Vec<ProblemEvent>,
}

impl From<wire::ProblemPage> for ProblemPage {
    fn from(page: wire::ProblemPage) -> Self {
        Self {
            stream: page.stream,
            total: page.total,
            limit: page.limit,
            skip: page.skip,
            rows: page.rows.into_iter().map(Into::into).collect(),
        }
    }
}

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

impl From<wire::ProblemAcknowledgment> for ProblemAcknowledgment {
    fn from(response: wire::ProblemAcknowledgment) -> Self {
        Self {
            acknowledged: response.acknowledged,
        }
    }
}

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

    #[test]
    fn overview_and_acknowledgment_keep_stable_output_names_and_value_types() {
        let response: wire::ProblemsOverview = serde_json::from_value(json!({
            "anyInProgress":true,
            "inProgress":[{"kind":"schema","active":false,"message":"Checking"}],
            "anyProblems":true,
            "problems":[
                {"id":"p1","severity":"warning","title":"Fractional","detail":"Details","count":1.5},
                {"id":"p2","severity":"info","title":"Uncounted","detail":"Details"}
            ],
            "newProblems":[{"stream":"integrity","count":0}],
            "streams":["integrity"]
        }))
        .unwrap();
        assert_eq!(
            serde_json::to_value(ProblemsOverview::from(response)).unwrap(),
            json!({
                "any_in_progress":true,
                "in_progress":[{"kind":"schema","active":false,"message":"Checking"}],
                "any_problems":true,
                "problems":[
                    {"id":"p1","severity":"warning","title":"Fractional","detail":"Details","count":1.5},
                    {"id":"p2","severity":"info","title":"Uncounted","detail":"Details","count":null}
                ],
                "new_problems":[{"stream":"integrity","count":0}],
                "streams":["integrity"]
            })
        );
        assert_eq!(
            serde_json::to_value(ProblemAcknowledgment::from(wire::ProblemAcknowledgment {
                acknowledged: "integrity".into(),
            }))
            .unwrap(),
            json!({"acknowledged":"integrity"})
        );
    }

    #[test]
    fn stream_results_preserve_event_names_nested_maps_numbers_and_nulls() {
        let at = "2026-10-21T08:00:00Z";
        let response: wire::ProblemPage = serde_json::from_value(json!({
            "stream":"security","total":2,"limit":50,"skip":0,
            "rows":[{
                "_id":"event1","stream":"security","at":at,
                "severity":"warning","category":"auth","bleed":"none","action":"read",
                "source":"api","cwe":"CWE-1","userId":"user1","username":"alice",
                "ip":"127.0.0.1","ipv4":"127.0.0.1","ipv6":"::1",
                "location":{"country":"KE","city":"Nairobi","latitude":-1.5,"longitude":36,"via":"fixture"},
                "count":2,"firstAt":at,
                "actors":{"actor1":{"kind":"user","value":"user1","count":1.25,"at":at}},
                "actorsOverflow":0,"detail":"Details","type":"attempt","db":"wekan",
                "kind":"access","message":"Message","api":"/api/user","apiUserId":"user1"
            },{"_id":"event2","stream":"security","at":at}]
        }))
        .unwrap();
        let mut absent_event = json!({"event_id":"event2","stream":"security","at":at});
        for field in [
            "severity",
            "category",
            "bleed",
            "action",
            "source",
            "cwe",
            "user_id",
            "username",
            "ip",
            "ipv4",
            "ipv6",
            "location",
            "count",
            "first_at",
            "actors",
            "actors_overflow",
            "detail",
            "event_type",
            "db",
            "kind",
            "message",
            "api",
            "api_user_id",
        ] {
            absent_event[field] = serde_json::Value::Null;
        }
        assert_eq!(
            serde_json::to_value(ProblemPage::from(response)).unwrap(),
            json!({
                "stream":"security","total":2,"limit":50,"skip":0,
                "rows":[{
                    "event_id":"event1","stream":"security","at":at,
                    "severity":"warning","category":"auth","bleed":"none","action":"read",
                    "source":"api","cwe":"CWE-1","user_id":"user1","username":"alice",
                    "ip":"127.0.0.1","ipv4":"127.0.0.1","ipv6":"::1",
                    "location":{"country":"KE","city":"Nairobi","region":null,"latitude":-1.5,"longitude":36,"via":"fixture"},
                    "count":2,"first_at":at,
                    "actors":{"actor1":{"kind":"user","value":"user1","count":1.25,"at":at,"family":null}},
                    "actors_overflow":0,"detail":"Details","event_type":"attempt","db":"wekan",
                    "kind":"access","message":"Message","api":"/api/user","api_user_id":"user1"
                },absent_event]
            })
        );
    }
}