use std::collections::HashMap;
use std::sync::mpsc::{channel, Receiver, TryRecvError};
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CodexEntry {
#[serde(default)]
pub name: String,
#[serde(default)]
pub slug: String,
#[serde(default)]
pub kind: String,
#[serde(default)]
pub subkind: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, rename = "accessTier")]
pub access_tier: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CodexPanel {
pub title: String,
pub subtitle: String,
pub body: String,
pub loading: bool,
}
enum State {
Idle,
Loading(String),
Loaded(Box<CodexEntry>),
Error(String, String),
}
pub struct CodexClient {
base: String,
token: Option<String>,
state: State,
cache: HashMap<String, CodexEntry>,
pending: Option<(String, Receiver<Result<CodexEntry, String>>)>,
}
impl CodexClient {
pub fn new(base: impl Into<String>) -> Self {
Self {
base: base.into(),
token: None,
state: State::Idle,
cache: HashMap::new(),
pending: None,
}
}
pub fn with_token(mut self, token: Option<String>) -> Self {
self.token = token.filter(|t| !t.trim().is_empty());
self
}
pub fn from_env() -> Self {
let base = std::env::var("INFINITE_CODEX_BASE")
.unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
Self::new(base)
}
pub fn is_open(&self) -> bool {
!matches!(self.state, State::Idle)
}
pub fn close(&mut self) {
self.state = State::Idle;
self.pending = None;
}
pub fn open(&mut self, slug: &str) {
if let Some(entry) = self.cache.get(slug) {
self.state = State::Loaded(Box::new(entry.clone()));
return;
}
let url = entry_url(&self.base, slug);
let token = self.token.clone();
let (tx, rx) = channel();
std::thread::spawn(move || {
let _ = tx.send(fetch_blocking(&url, token.as_deref()));
});
self.state = State::Loading(slug.to_string());
self.pending = Some((slug.to_string(), rx));
}
pub fn poll(&mut self) {
let Some((slug, rx)) = &self.pending else {
return;
};
match rx.try_recv() {
Ok(Ok(entry)) => {
self.cache.insert(slug.clone(), entry.clone());
self.state = State::Loaded(Box::new(entry));
self.pending = None;
}
Ok(Err(msg)) => {
self.state = State::Error(slug.clone(), msg);
self.pending = None;
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
self.state = State::Error(slug.clone(), "fetch thread stopped".into());
self.pending = None;
}
}
}
pub fn panel(&self) -> Option<CodexPanel> {
match &self.state {
State::Idle => None,
State::Loading(slug) => Some(CodexPanel {
title: slug.clone(),
subtitle: "Consulting the Codex…".into(),
body: String::new(),
loading: true,
}),
State::Loaded(e) => Some(CodexPanel {
title: if e.name.is_empty() {
e.slug.clone()
} else {
e.name.clone()
},
subtitle: match &e.subkind {
Some(s) if !s.is_empty() => format!("{} · {s}", e.kind),
_ => e.kind.clone(),
},
body: e
.description
.clone()
.filter(|s| !s.is_empty())
.or_else(|| e.summary.clone())
.unwrap_or_default(),
loading: false,
}),
State::Error(slug, msg) => Some(CodexPanel {
title: slug.clone(),
subtitle: "The Codex is out of reach".into(),
body: msg.clone(),
loading: false,
}),
}
}
}
fn entry_url(base: &str, slug: &str) -> String {
format!("{}/codex/entities/{}", base.trim_end_matches('/'), slug)
}
fn fetch_blocking(url: &str, token: Option<&str>) -> Result<CodexEntry, String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let client = reqwest::Client::new();
let mut req = client.get(url);
if let Some(t) = token {
req = req.bearer_auth(t);
}
let resp = req.send().await.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(
refusal_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16()))
);
}
parse_entry(&text)
})
}
fn parse_entry(json: &str) -> Result<CodexEntry, String> {
serde_json::from_str(json).map_err(|e| e.to_string())
}
fn refusal_message(body: &str) -> Option<String> {
#[derive(Deserialize)]
struct Refusal {
in_lore: Option<InLore>,
}
#[derive(Deserialize)]
struct InLore {
message: String,
}
serde_json::from_str::<Refusal>(body)
.ok()?
.in_lore
.map(|l| l.message)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_entry_url() {
assert_eq!(
entry_url("https://api.pixygon.io/v1", "the-veil"),
"https://api.pixygon.io/v1/codex/entities/the-veil"
);
assert_eq!(
entry_url("https://api.pixygon.io/v1/", "amebrak"),
"https://api.pixygon.io/v1/codex/entities/amebrak"
);
}
#[test]
fn parses_the_read_api_shape() {
let json = r#"{
"name": "The Veil", "slug": "the-veil", "kind": "concept",
"subkind": "membrane", "summary": "A temporary membrane.",
"description": "The magical, temporary membrane...", "tags": ["wardenship"],
"accessTier": "public", "extraFieldWeIgnore": 42
}"#;
let e = parse_entry(json).unwrap();
assert_eq!(e.name, "The Veil");
assert_eq!(e.kind, "concept");
assert_eq!(e.subkind.as_deref(), Some("membrane"));
assert_eq!(e.access_tier.as_deref(), Some("public"));
let panel = CodexClient {
base: String::new(),
token: None,
state: State::Loaded(Box::new(e)),
cache: HashMap::new(),
pending: None,
}
.panel()
.unwrap();
assert_eq!(panel.title, "The Veil");
assert_eq!(panel.subtitle, "concept · membrane");
assert!(!panel.loading);
}
#[test]
fn blank_tokens_read_as_anonymous() {
assert!(CodexClient::new("x").with_token(None).token.is_none());
assert!(CodexClient::new("x")
.with_token(Some(" ".into()))
.token
.is_none());
assert_eq!(
CodexClient::new("x")
.with_token(Some("tok".into()))
.token
.as_deref(),
Some("tok")
);
}
#[test]
fn surfaces_diegetic_refusal_from_a_gated_read() {
let body = r#"{"error":"access_denied","in_lore":{"message":"The Archivist meets your gaze. 'Walk on, traveler.'","seal":"oath-of-the-archive","tier":"archivist"}}"#;
assert_eq!(
refusal_message(body).as_deref(),
Some("The Archivist meets your gaze. 'Walk on, traveler.'")
);
assert!(refusal_message(r#"{"error":"nope"}"#).is_none());
}
}