canic-cli 0.91.5

Operator CLI for Canic fleet setup, builds, evidence, catalog, backup, and restore workflows
Documentation
use crate::cycles::model::{
    CycleTopupEventPage, CycleTopupEventSample, CycleTopupStatus, CycleTrackerPage,
    CycleTrackerSample,
};
use canic_host::response_parse::{find_field, parse_json_u64, parse_json_u128};
use std::{error::Error, fmt};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum CycleResponseKind {
    Tracker,
    Topup,
}

impl CycleResponseKind {
    const fn label(self) -> &'static str {
        match self {
            Self::Tracker => "cycle tracker",
            Self::Topup => "cycle top-up",
        }
    }
}

#[derive(Debug, Eq, PartialEq)]
pub(super) enum CyclesParseError {
    InvalidJson {
        kind: CycleResponseKind,
        error: String,
    },
    MissingEntries(CycleResponseKind),
    InvalidEntries(CycleResponseKind),
    InvalidTotal(CycleResponseKind),
    InvalidEntryField {
        kind: CycleResponseKind,
        index: usize,
        field: &'static str,
    },
}

impl fmt::Display for CyclesParseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InvalidJson { kind, error } => {
                write!(
                    formatter,
                    "{} response has invalid JSON: {error}",
                    kind.label()
                )
            }
            Self::MissingEntries(kind) => {
                write!(formatter, "{} response is missing `entries`", kind.label())
            }
            Self::InvalidEntries(kind) => {
                write!(
                    formatter,
                    "{} response `entries` must be an array",
                    kind.label()
                )
            }
            Self::InvalidTotal(kind) => {
                write!(formatter, "{} response has invalid `total`", kind.label())
            }
            Self::InvalidEntryField { kind, index, field } => write!(
                formatter,
                "{} entry {index} has invalid `{field}`",
                kind.label()
            ),
        }
    }
}

impl Error for CyclesParseError {}

pub(super) fn parse_cycle_tracker_page(output: &str) -> Result<CycleTrackerPage, CyclesParseError> {
    let value = parse_json_response(output, CycleResponseKind::Tracker)?;
    parse_cycle_tracker_page_json(&value)
}

fn parse_cycle_tracker_page_json(
    value: &serde_json::Value,
) -> Result<CycleTrackerPage, CyclesParseError> {
    let kind = CycleResponseKind::Tracker;
    let entries_value =
        find_field(value, "entries").ok_or(CyclesParseError::MissingEntries(kind))?;
    let entries = entries_value
        .as_array()
        .ok_or(CyclesParseError::InvalidEntries(kind))?
        .iter()
        .enumerate()
        .map(|(index, value)| parse_cycle_tracker_sample_json(value, index))
        .collect::<Result<Vec<_>, _>>()?;
    let total = parse_total(value, kind, entries.len())?;

    Ok(CycleTrackerPage { entries, total })
}

fn parse_cycle_tracker_sample_json(
    value: &serde_json::Value,
    index: usize,
) -> Result<CycleTrackerSample, CyclesParseError> {
    let invalid_field = |field| CyclesParseError::InvalidEntryField {
        kind: CycleResponseKind::Tracker,
        index,
        field,
    };
    Ok(CycleTrackerSample {
        timestamp_secs: find_field(value, "timestamp_secs")
            .and_then(parse_json_u64)
            .ok_or_else(|| invalid_field("timestamp_secs"))?,
        cycles: find_field(value, "cycles")
            .and_then(parse_json_u128)
            .ok_or_else(|| invalid_field("cycles"))?,
    })
}

pub(super) fn parse_topup_event_page(
    output: &str,
) -> Result<CycleTopupEventPage, CyclesParseError> {
    let value = parse_json_response(output, CycleResponseKind::Topup)?;
    parse_topup_event_page_json(&value)
}

fn parse_topup_event_page_json(
    value: &serde_json::Value,
) -> Result<CycleTopupEventPage, CyclesParseError> {
    let kind = CycleResponseKind::Topup;
    let entries = find_field(value, "entries")
        .ok_or(CyclesParseError::MissingEntries(kind))?
        .as_array()
        .ok_or(CyclesParseError::InvalidEntries(kind))?
        .iter()
        .enumerate()
        .map(|(index, value)| parse_topup_event_json(value, index))
        .collect::<Result<Vec<_>, _>>()?;
    let total = parse_total(value, kind, entries.len())?;

    Ok(CycleTopupEventPage { entries, total })
}

fn parse_topup_event_json(
    value: &serde_json::Value,
    index: usize,
) -> Result<CycleTopupEventSample, CyclesParseError> {
    let invalid_field = |field| CyclesParseError::InvalidEntryField {
        kind: CycleResponseKind::Topup,
        index,
        field,
    };
    Ok(CycleTopupEventSample {
        timestamp_secs: find_field(value, "timestamp_secs")
            .and_then(parse_json_u64)
            .ok_or_else(|| invalid_field("timestamp_secs"))?,
        transferred_cycles: parse_optional_u128_field(find_field(value, "transferred_cycles"))
            .map_err(|()| invalid_field("transferred_cycles"))?,
        status: find_field(value, "status")
            .and_then(parse_topup_status_json)
            .ok_or_else(|| invalid_field("status"))?,
    })
}

fn parse_json_response(
    output: &str,
    kind: CycleResponseKind,
) -> Result<serde_json::Value, CyclesParseError> {
    serde_json::from_str(output).map_err(|error| CyclesParseError::InvalidJson {
        kind,
        error: error.to_string(),
    })
}

fn parse_total(
    value: &serde_json::Value,
    kind: CycleResponseKind,
    entries_len: usize,
) -> Result<u64, CyclesParseError> {
    let Some(total) = find_field(value, "total") else {
        return Ok(entries_len as u64);
    };
    parse_json_u64(total).ok_or(CyclesParseError::InvalidTotal(kind))
}

fn parse_optional_u128_field(value: Option<&serde_json::Value>) -> Result<Option<u128>, ()> {
    match value {
        None | Some(serde_json::Value::Null) => Ok(None),
        Some(serde_json::Value::Array(values)) if values.is_empty() => Ok(None),
        Some(value) => parse_optional_u128(value).map(Some).ok_or(()),
    }
}

fn parse_optional_u128(value: &serde_json::Value) -> Option<u128> {
    match value {
        serde_json::Value::Null => None,
        serde_json::Value::Object(map) => map.values().find_map(parse_json_u128),
        serde_json::Value::Array(values) => values.iter().find_map(parse_json_u128),
        _ => parse_json_u128(value),
    }
}

fn parse_topup_status_json(value: &serde_json::Value) -> Option<CycleTopupStatus> {
    match value {
        serde_json::Value::String(status) => parse_topup_status(status),
        serde_json::Value::Object(map) => map.keys().find_map(|key| parse_topup_status(key)),
        serde_json::Value::Array(values) => values.iter().find_map(parse_topup_status_json),
        _ => None,
    }
}

fn parse_topup_status(text: &str) -> Option<CycleTopupStatus> {
    match text {
        "RequestOk" | "request_ok" => Some(CycleTopupStatus::RequestOk),
        "RequestErr" | "request_err" => Some(CycleTopupStatus::RequestErr),
        "RequestScheduled" | "request_scheduled" => Some(CycleTopupStatus::RequestScheduled),
        _ => None,
    }
}