toko-feed-cli 0.3.3

Operator CLI for Toko Feed canister ingestion and catalog queries
Documentation
//! Canonical JSON backup, deliberately excluding provider evidence and operations.

use std::{fs, path::Path};

use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use toko_feed::{
    CANONICAL_RESTORE_MAX_RECORDS, CanonicalRestoreBatch, CanonicalRestoreReceipt, CollectionView,
    PokemonCardMetadataDetails, PokemonCardPrintingView, PokemonCardView, PokemonSetView,
};

use super::{
    CliError, DEFAULT_MAX_PAGES, ListOptions, MAX_QUERY_LIMIT, Target, call_one,
    collect_keyset_pages, fetch_card_page, fetch_card_printing_page, fetch_collection_page,
    fetch_set_page,
};

const CANONICAL_BACKUP_FORMAT_VERSION: u32 = 5;
const OLDEST_SUPPORTED_BACKUP_FORMAT_VERSION: u32 = 3;
const MAX_CANONICAL_BACKUP_BYTES: u64 = 64 * 1024 * 1024;
pub const DEFAULT_CANONICAL_BACKUP_PATH: &str = "data/canonical.json";

#[derive(Debug, Deserialize, Serialize)]
struct CanonicalBackup {
    format_version: u32,
    collections: Vec<CollectionView>,
    pokemon_sets: Vec<PokemonSetView>,
    pokemon_cards: Vec<PokemonCardView>,
    pokemon_card_metadata: Vec<PokemonCardMetadataDetails>,
    pokemon_card_printings: Vec<PokemonCardPrintingView>,
}

pub fn execute(target: &Target, path: &Path) -> Result<Value, CliError> {
    let options = ListOptions {
        limit: MAX_QUERY_LIMIT,
        after: None,
        all: true,
        max_pages: DEFAULT_MAX_PAGES,
    };
    let (collections, collection_pages) = collect_keyset_pages(&options, |after, limit| {
        let page = fetch_collection_page(target, after, limit)?;
        Ok((page.collections, page.next_after))
    })?;
    let (pokemon_sets, set_pages) = collect_keyset_pages(&options, |after, limit| {
        let page = fetch_set_page(target, after, limit)?;
        Ok((page.sets, page.next_after))
    })?;
    let (pokemon_cards, card_pages) = collect_keyset_pages(&options, |after, limit| {
        let page = fetch_card_page(target, after, limit)?;
        Ok((page.cards, page.next_after))
    })?;
    let (pokemon_card_printings, printing_pages) =
        collect_keyset_pages(&options, |after, limit| {
            let page = fetch_card_printing_page(target, after, limit)?;
            Ok((page.printings, page.next_after))
        })?;
    let mut pokemon_card_metadata = pokemon_cards
        .iter()
        .map(|card| {
            call_one::<_, Option<PokemonCardMetadataDetails>>(
                target,
                "toko_feed_card_metadata",
                card.id.clone(),
                true,
            )
        })
        .collect::<Result<Vec<_>, CliError>>()?
        .into_iter()
        .flatten()
        .collect::<Vec<_>>();
    for details in &mut pokemon_card_metadata {
        details.evidence.clear();
    }
    let backup = CanonicalBackup {
        format_version: CANONICAL_BACKUP_FORMAT_VERSION,
        collections,
        pokemon_sets,
        pokemon_cards,
        pokemon_card_metadata,
        pokemon_card_printings,
    };
    write_backup(path, &backup)?;
    Ok(json!({
        "path": path,
        "format_version": CANONICAL_BACKUP_FORMAT_VERSION,
        "collections": backup.collections.len(),
        "pokemon_sets": backup.pokemon_sets.len(),
        "pokemon_cards": backup.pokemon_cards.len(),
        "pokemon_card_metadata": backup.pokemon_card_metadata.len(),
        "pokemon_card_printings": backup.pokemon_card_printings.len(),
        "pages": collection_pages
            .saturating_add(set_pages)
            .saturating_add(card_pages)
            .saturating_add(printing_pages),
    }))
}

pub fn restore(target: &Target, path: &Path) -> Result<Value, CliError> {
    let metadata = fs::metadata(path).map_err(|source| CliError::BackupIo {
        action: "read metadata for",
        path: path.to_owned(),
        source,
    })?;
    if metadata.len() > MAX_CANONICAL_BACKUP_BYTES {
        return Err(CliError::Usage(format!(
            "canonical backup exceeds the {} MiB input limit",
            MAX_CANONICAL_BACKUP_BYTES / 1024 / 1024
        )));
    }
    let bytes = fs::read(path).map_err(|source| CliError::BackupIo {
        action: "read",
        path: path.to_owned(),
        source,
    })?;
    let mut backup = serde_json::from_slice::<CanonicalBackup>(&bytes)?;
    if !(OLDEST_SUPPORTED_BACKUP_FORMAT_VERSION..=CANONICAL_BACKUP_FORMAT_VERSION)
        .contains(&backup.format_version)
    {
        return Err(CliError::Usage(format!(
            "unsupported canonical backup format {}; expected {} through {}",
            backup.format_version,
            OLDEST_SUPPORTED_BACKUP_FORMAT_VERSION,
            CANONICAL_BACKUP_FORMAT_VERSION
        )));
    }
    for details in &mut backup.pokemon_card_metadata {
        details.evidence.clear();
    }

    let mut receipts = Vec::new();
    restore_chunks(
        target,
        &backup.collections,
        CanonicalRestoreBatch::Collections,
        &mut receipts,
    )?;
    restore_chunks(
        target,
        &backup.pokemon_sets,
        CanonicalRestoreBatch::PokemonSets,
        &mut receipts,
    )?;
    restore_chunks(
        target,
        &backup.pokemon_cards,
        CanonicalRestoreBatch::PokemonCards,
        &mut receipts,
    )?;
    restore_chunks(
        target,
        &backup.pokemon_card_printings,
        CanonicalRestoreBatch::PokemonCardPrintings,
        &mut receipts,
    )?;
    restore_chunks(
        target,
        &backup.pokemon_card_metadata,
        CanonicalRestoreBatch::PokemonCardMetadata,
        &mut receipts,
    )?;

    Ok(json!({
        "path": path,
        "format_version": backup.format_version,
        "batches": receipts.len(),
        "submitted": receipts.iter().map(|receipt| u64::from(receipt.submitted)).sum::<u64>(),
        "inserted": receipts.iter().map(|receipt| u64::from(receipt.inserted)).sum::<u64>(),
        "unchanged": receipts.iter().map(|receipt| u64::from(receipt.unchanged)).sum::<u64>(),
        "receipts": receipts,
    }))
}

fn restore_chunks<T, F>(
    target: &Target,
    records: &[T],
    batch: F,
    receipts: &mut Vec<CanonicalRestoreReceipt>,
) -> Result<(), CliError>
where
    T: Clone,
    F: Fn(Vec<T>) -> CanonicalRestoreBatch,
{
    for chunk in records.chunks(CANONICAL_RESTORE_MAX_RECORDS) {
        receipts.push(call_one(
            target,
            "toko_feed_restore_canonical",
            batch(chunk.to_vec()),
            false,
        )?);
    }
    Ok(())
}

fn write_backup(path: &Path, backup: &CanonicalBackup) -> Result<(), CliError> {
    let parent = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty());
    if let Some(parent) = parent {
        fs::create_dir_all(parent).map_err(|source| CliError::BackupIo {
            action: "create parent for",
            path: path.to_owned(),
            source,
        })?;
    }
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| {
            CliError::Usage("canonical backup path must name a UTF-8 JSON file".to_owned())
        })?;
    let temporary = path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id()));
    let mut bytes = serde_json::to_vec_pretty(backup)?;
    bytes.push(b'\n');
    fs::write(&temporary, bytes).map_err(|source| CliError::BackupIo {
        action: "write",
        path: temporary.clone(),
        source,
    })?;
    if let Err(source) = fs::rename(&temporary, path) {
        let _ = fs::remove_file(&temporary);
        return Err(CliError::BackupIo {
            action: "replace",
            path: path.to_owned(),
            source,
        });
    }
    Ok(())
}