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(())
}