toko-feed-cli 0.3.1

Operator CLI for Toko Feed canister ingestion and catalog queries
Documentation
//! Central CLI output selection and human-readable report rendering.

use crate::table::{ColumnAlign, render_table, sanitize_text};
use serde_json::{Map, Value};
use std::collections::BTreeSet;
use toko_feed::{
    CatalogNextAction, CatalogStatusPage, CurationCounts, CurationStatus, ResolutionCounts,
};

const GENERIC_CELL_CHAR_LIMIT: usize = 80;

/// User-selected output representation.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OutputFormat {
    Text,
    Json,
}

/// Text renderer selected for a command result.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ReportKind {
    Generic,
    Status,
}

/// Render one command result in the selected representation.
pub fn render(
    format: OutputFormat,
    kind: ReportKind,
    value: &Value,
    compact_json: bool,
) -> Result<String, serde_json::Error> {
    match format {
        OutputFormat::Json if compact_json => serde_json::to_string(value),
        OutputFormat::Json => serde_json::to_string_pretty(value),
        OutputFormat::Text => match kind {
            ReportKind::Generic => Ok(render_generic(value)),
            ReportKind::Status => render_status(value),
        },
    }
}

fn render_status(value: &Value) -> Result<String, serde_json::Error> {
    let page = serde_json::from_value::<CatalogStatusPage>(value.clone())?;
    let mut lines = vec![format!("sets: {}", page.sets.len())];
    if page.sets.is_empty() {
        lines.push("No collection/set progress found.".to_owned());
        return Ok(lines.join("\n"));
    }

    let headers = strings(&[
        "COLLECTION",
        "SET",
        "SET STATE",
        "CARDS T/P/V/L",
        "LOCKED",
        "SET SRC T/P/M/R",
        "CARD SRC T/P/M/R",
        "NEXT",
    ]);
    let rows = page
        .sets
        .iter()
        .map(|set| {
            vec![
                set.collection.clone(),
                set.set_name.clone(),
                curation_status_text(set.set_curation_status).to_owned(),
                curation_counts_text(&set.canonical_cards),
                set.locked_percent
                    .map_or_else(|| "-".to_owned(), |value| format!("{value}%")),
                resolution_counts_text(&set.set_sources),
                resolution_counts_text(&set.card_sources),
                next_action_text(set.next_action).to_owned(),
            ]
        })
        .collect::<Vec<_>>();
    lines.push(render_table(
        &headers,
        &rows,
        &[
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Left,
            ColumnAlign::Right,
            ColumnAlign::Right,
            ColumnAlign::Right,
            ColumnAlign::Right,
            ColumnAlign::Left,
        ],
    ));

    let provider_rows = page
        .sets
        .iter()
        .flat_map(|set| {
            set.providers.iter().map(|provider| {
                vec![
                    set.collection.clone(),
                    set.set_name.clone(),
                    provider.provider.clone(),
                    provider.provider_set_ids.join(", "),
                    provider.set_records.to_string(),
                    provider.card_records.to_string(),
                    provider
                        .reported_cards
                        .map_or_else(|| "-".to_owned(), |value| value.to_string()),
                ]
            })
        })
        .collect::<Vec<_>>();
    if !provider_rows.is_empty() {
        lines.push("provider coverage:".to_owned());
        lines.push(render_table(
            &strings(&[
                "COLLECTION",
                "SET",
                "PROVIDER",
                "PROVIDER SET IDS",
                "SET ROWS",
                "CARD ROWS",
                "REPORTED",
            ]),
            &provider_rows,
            &[
                ColumnAlign::Left,
                ColumnAlign::Left,
                ColumnAlign::Left,
                ColumnAlign::Left,
                ColumnAlign::Right,
                ColumnAlign::Right,
                ColumnAlign::Right,
            ],
        ));
    }
    if let Some(next_after) = page.next_after {
        lines.push(format!("next_after: {}", sanitize_text(&next_after)));
    }
    Ok(lines.join("\n"))
}

fn render_generic(value: &Value) -> String {
    match value {
        Value::Object(object) => render_object(object),
        Value::Array(values) => render_array(values),
        _ => value_text(value),
    }
}

fn render_object(object: &Map<String, Value>) -> String {
    let mut lines = Vec::new();
    let scalar_rows = object
        .iter()
        .filter(|(_, value)| !matches!(value, Value::Array(_) | Value::Object(_)))
        .map(|(key, value)| vec![key.clone(), value_text(value)])
        .collect::<Vec<_>>();
    if !scalar_rows.is_empty() {
        lines.push(render_table(
            &strings(&["FIELD", "VALUE"]),
            &scalar_rows,
            &[ColumnAlign::Left, ColumnAlign::Left],
        ));
    }
    for (key, value) in object
        .iter()
        .filter(|(_, value)| matches!(value, Value::Array(_) | Value::Object(_)))
    {
        if !lines.is_empty() {
            lines.push(String::new());
        }
        lines.push(format!("{}:", sanitize_text(key)));
        lines.push(render_generic(value));
    }
    if lines.is_empty() {
        "(empty)".to_owned()
    } else {
        lines.join("\n")
    }
}

fn render_array(values: &[Value]) -> String {
    if values.is_empty() {
        return "(none)".to_owned();
    }
    if values.iter().all(Value::is_object) {
        let keys = values
            .iter()
            .filter_map(Value::as_object)
            .flat_map(Map::keys)
            .cloned()
            .collect::<BTreeSet<_>>()
            .into_iter()
            .collect::<Vec<_>>();
        let rows = values
            .iter()
            .filter_map(Value::as_object)
            .map(|object| {
                keys.iter()
                    .map(|key| object.get(key).map_or_else(|| "-".to_owned(), value_text))
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>();
        let alignments = keys
            .iter()
            .map(|key| {
                if values.iter().all(|value| {
                    value
                        .as_object()
                        .and_then(|object| object.get(key))
                        .is_none_or(|value| matches!(value, Value::Null | Value::Number(_)))
                }) {
                    ColumnAlign::Right
                } else {
                    ColumnAlign::Left
                }
            })
            .collect::<Vec<_>>();
        return render_table(&keys, &rows, &alignments);
    }
    values.iter().map(value_text).collect::<Vec<_>>().join("\n")
}

fn value_text(value: &Value) -> String {
    let text = match value {
        Value::Null => "-".to_owned(),
        Value::Bool(value) => value.to_string(),
        Value::Number(value) => value.to_string(),
        Value::String(value) => sanitize_text(value),
        Value::Array(values) if values.iter().all(Value::is_string) => values
            .iter()
            .filter_map(Value::as_str)
            .map(sanitize_text)
            .collect::<Vec<_>>()
            .join(", "),
        Value::Array(_) | Value::Object(_) => {
            serde_json::to_string(value).unwrap_or_else(|_| "<unrenderable>".to_owned())
        }
    };
    truncate_text(&text, GENERIC_CELL_CHAR_LIMIT)
}

fn truncate_text(value: &str, limit: usize) -> String {
    if value.chars().count() <= limit {
        return value.to_owned();
    }
    let mut truncated = value.chars().take(limit).collect::<String>();
    truncated.push_str("...");
    truncated
}

fn strings(values: &[&str]) -> Vec<String> {
    values.iter().map(|value| (*value).to_owned()).collect()
}

fn curation_counts_text(counts: &CurationCounts) -> String {
    format!(
        "{}/{}/{}/{}",
        counts.total, counts.provisional, counts.verified, counts.locked
    )
}

fn resolution_counts_text(counts: &ResolutionCounts) -> String {
    format!(
        "{}/{}/{}/{}",
        counts.total, counts.pending, counts.mapped, counts.rejected
    )
}

const fn curation_status_text(status: Option<CurationStatus>) -> &'static str {
    match status {
        None => "-",
        Some(CurationStatus::Provisional) => "provisional",
        Some(CurationStatus::Verified) => "verified",
        Some(CurationStatus::Locked) => "locked",
    }
}

const fn next_action_text(action: CatalogNextAction) -> &'static str {
    match action {
        CatalogNextAction::ReconcileSet => "reconcile set",
        CatalogNextAction::VerifySet => "verify set",
        CatalogNextAction::LockSet => "lock set",
        CatalogNextAction::AcquireCards => "acquire cards",
        CatalogNextAction::ReconcileCards => "reconcile cards",
        CatalogNextAction::VerifyCards => "verify cards",
        CatalogNextAction::LockCards => "lock cards",
        CatalogNextAction::Complete => "complete",
    }
}

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

    #[test]
    fn status_text_separates_progress_and_provider_coverage() {
        let value = json!({
            "sets": [{
                "cursor": "01KZGN0R26C13000000000000B",
                "collection_id": "00000000000000000000000001",
                "collection": "pokemon",
                "collection_name": "Pokemon",
                "set_id": null,
                "set_name": "Aquapolis",
                "release_date": "2003-01-15",
                "set_curation_status": null,
                "canonical_cards": {"total": 0, "provisional": 0, "verified": 0, "locked": 0},
                "locked_percent": null,
                "set_sources": {"total": 3, "pending": 3, "mapped": 0, "rejected": 0},
                "card_sources": {"total": 545, "pending": 545, "mapped": 0, "rejected": 0},
                "providers": [{
                    "provider": "tcgdex",
                    "provider_set_ids": ["ecard2"],
                    "set_records": 1,
                    "card_records": 177,
                    "reported_cards": 185
                }],
                "next_action": "ReconcileSet"
            }],
            "next_after": null
        });

        let rendered = render(OutputFormat::Text, ReportKind::Status, &value, false)
            .expect("status text should render");
        assert!(rendered.contains("CARDS T/P/V/L"));
        assert!(rendered.contains("Aquapolis"));
        assert!(rendered.contains("0/0/0/0"));
        assert!(rendered.contains("545/545/0/0"));
        assert!(rendered.contains("provider coverage:"));
        assert!(rendered.contains("tcgdex"));
        assert!(!rendered.contains("{\"sets\""));
    }

    #[test]
    fn json_output_is_explicit_and_lossless() {
        let value = json!({"answer": 42});
        assert_eq!(
            render(OutputFormat::Json, ReportKind::Generic, &value, true).expect("compact JSON"),
            r#"{"answer":42}"#
        );
        assert!(
            render(OutputFormat::Json, ReportKind::Generic, &value, false)
                .expect("pretty JSON")
                .contains('\n')
        );
    }
}