use std::io::Read;
use base64::Engine;
use flate2::read::GzDecoder;
use serde_json::Value;
const GZIP_BASE64_PREFIX: &str = "H4sIA";
const LISTFORGE_MARKER: &str = "/listforge/";
#[derive(Debug)]
pub enum DecodeError {
Empty,
NotAPayload,
Base64(base64::DecodeError),
Gzip(std::io::Error),
Json(serde_json::Error),
}
impl std::fmt::Display for DecodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DecodeError::Empty => f.write_str("decode_listforge: empty input"),
DecodeError::NotAPayload => write!(
f,
"decode_listforge: input is not a ListForge payload (expected raw JSON, \
or a gzip+base64 segment beginning with \"{GZIP_BASE64_PREFIX}…\")"
),
DecodeError::Base64(e) => write!(f, "decode_listforge: base64 decode failed: {e}"),
DecodeError::Gzip(e) => write!(f, "decode_listforge: failed to gunzip payload: {e}"),
DecodeError::Json(e) => write!(f, "decode_listforge: invalid JSON: {e}"),
}
}
}
impl std::error::Error for DecodeError {}
fn extract_segment(input: &str) -> &str {
if let Some(idx) = input.find(LISTFORGE_MARKER) {
return &input[idx + LISTFORGE_MARKER.len()..];
}
let lower = input.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
return match input.rfind('/') {
Some(i) => &input[i + 1..],
None => input,
};
}
input
}
pub fn decode_listforge(input: &str) -> Result<Value, DecodeError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(DecodeError::Empty);
}
if trimmed.starts_with('{') {
return serde_json::from_str(trimmed).map_err(DecodeError::Json);
}
let segment = extract_segment(trimmed);
if !segment.starts_with(GZIP_BASE64_PREFIX) {
return Err(DecodeError::NotAPayload);
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(segment)
.map_err(DecodeError::Base64)?;
let mut decoder = GzDecoder::new(&bytes[..]);
let mut json = String::new();
decoder
.read_to_string(&mut json)
.map_err(DecodeError::Gzip)?;
serde_json::from_str(&json).map_err(DecodeError::Json)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use std::io::Write;
fn encode_payload(json: &str) -> String {
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(json.as_bytes()).unwrap();
let gz = encoder.finish().unwrap();
base64::engine::general_purpose::STANDARD.encode(gz)
}
#[test]
fn decodes_raw_json() {
let v = decode_listforge(r#"{"a":1}"#).unwrap();
assert_eq!(v["a"], 1);
}
#[test]
fn decodes_bare_base64_segment() {
let seg = encode_payload(r#"{"name":"X"}"#);
assert!(
seg.starts_with(GZIP_BASE64_PREFIX),
"sanity: gzip base64 prefix"
);
let v = decode_listforge(&seg).unwrap();
assert_eq!(v["name"], "X");
}
#[test]
fn decodes_full_url() {
let seg = encode_payload(r#"{"name":"Y"}"#);
let url = format!("https://listforge.app/#/listforge/{seg}");
let v = decode_listforge(&url).unwrap();
assert_eq!(v["name"], "Y");
}
#[test]
fn rejects_empty() {
assert!(matches!(decode_listforge(" "), Err(DecodeError::Empty)));
}
#[test]
fn rejects_non_payload() {
assert!(matches!(
decode_listforge("not-a-payload"),
Err(DecodeError::NotAPayload)
));
}
}