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;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ReportKind {
Generic,
Status,
}
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')
);
}
}