1use std::collections::HashMap;
15use std::sync::mpsc::{channel, Receiver, TryRecvError};
16
17use serde::Deserialize;
18
19#[derive(Debug, Clone, Default, Deserialize)]
21pub struct CodexEntry {
22 #[serde(default)]
23 pub name: String,
24 #[serde(default)]
25 pub slug: String,
26 #[serde(default)]
27 pub kind: String,
28 #[serde(default)]
29 pub subkind: Option<String>,
30 #[serde(default)]
31 pub summary: Option<String>,
32 #[serde(default)]
33 pub description: Option<String>,
34 #[serde(default)]
35 pub tags: Vec<String>,
36 #[serde(default, rename = "accessTier")]
37 pub access_tier: Option<String>,
38}
39
40#[derive(Debug, Clone)]
42pub struct CodexPanel {
43 pub title: String,
44 pub subtitle: String,
45 pub body: String,
46 pub loading: bool,
47}
48
49enum State {
50 Idle,
51 Loading(String),
52 Loaded(Box<CodexEntry>),
53 Error(String, String),
54}
55
56pub struct CodexClient {
58 base: String,
59 token: Option<String>,
62 state: State,
63 cache: HashMap<String, CodexEntry>,
64 pending: Option<(String, Receiver<Result<CodexEntry, String>>)>,
65}
66
67impl CodexClient {
68 pub fn new(base: impl Into<String>) -> Self {
69 Self {
70 base: base.into(),
71 token: None,
72 state: State::Idle,
73 cache: HashMap::new(),
74 pending: None,
75 }
76 }
77
78 pub fn with_token(mut self, token: Option<String>) -> Self {
81 self.token = token.filter(|t| !t.trim().is_empty());
82 self
83 }
84
85 pub fn from_env() -> Self {
87 let base = std::env::var("INFINITE_CODEX_BASE")
88 .unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
89 Self::new(base)
90 }
91
92 pub fn is_open(&self) -> bool {
94 !matches!(self.state, State::Idle)
95 }
96
97 pub fn close(&mut self) {
98 self.state = State::Idle;
99 self.pending = None;
100 }
101
102 pub fn open(&mut self, slug: &str) {
104 if let Some(entry) = self.cache.get(slug) {
105 self.state = State::Loaded(Box::new(entry.clone()));
106 return;
107 }
108 let url = entry_url(&self.base, slug);
109 let token = self.token.clone();
110 let (tx, rx) = channel();
111 std::thread::spawn(move || {
112 let _ = tx.send(fetch_blocking(&url, token.as_deref()));
113 });
114 self.state = State::Loading(slug.to_string());
115 self.pending = Some((slug.to_string(), rx));
116 }
117
118 pub fn poll(&mut self) {
120 let Some((slug, rx)) = &self.pending else {
121 return;
122 };
123 match rx.try_recv() {
124 Ok(Ok(entry)) => {
125 self.cache.insert(slug.clone(), entry.clone());
126 self.state = State::Loaded(Box::new(entry));
127 self.pending = None;
128 }
129 Ok(Err(msg)) => {
130 self.state = State::Error(slug.clone(), msg);
131 self.pending = None;
132 }
133 Err(TryRecvError::Empty) => {}
134 Err(TryRecvError::Disconnected) => {
135 self.state = State::Error(slug.clone(), "fetch thread stopped".into());
136 self.pending = None;
137 }
138 }
139 }
140
141 pub fn panel(&self) -> Option<CodexPanel> {
143 match &self.state {
144 State::Idle => None,
145 State::Loading(slug) => Some(CodexPanel {
146 title: slug.clone(),
147 subtitle: "Consulting the Codex…".into(),
148 body: String::new(),
149 loading: true,
150 }),
151 State::Loaded(e) => Some(CodexPanel {
152 title: if e.name.is_empty() {
153 e.slug.clone()
154 } else {
155 e.name.clone()
156 },
157 subtitle: match &e.subkind {
158 Some(s) if !s.is_empty() => format!("{} · {s}", e.kind),
159 _ => e.kind.clone(),
160 },
161 body: e
162 .description
163 .clone()
164 .filter(|s| !s.is_empty())
165 .or_else(|| e.summary.clone())
166 .unwrap_or_default(),
167 loading: false,
168 }),
169 State::Error(slug, msg) => Some(CodexPanel {
170 title: slug.clone(),
171 subtitle: "The Codex is out of reach".into(),
172 body: msg.clone(),
173 loading: false,
174 }),
175 }
176 }
177}
178
179fn entry_url(base: &str, slug: &str) -> String {
181 format!("{}/codex/entities/{}", base.trim_end_matches('/'), slug)
182}
183
184fn fetch_blocking(url: &str, token: Option<&str>) -> Result<CodexEntry, String> {
188 let rt = tokio::runtime::Builder::new_current_thread()
189 .enable_all()
190 .build()
191 .map_err(|e| e.to_string())?;
192 rt.block_on(async {
193 let client = reqwest::Client::new();
194 let mut req = client.get(url);
195 if let Some(t) = token {
196 req = req.bearer_auth(t);
197 }
198 let resp = req.send().await.map_err(|e| e.to_string())?;
199 let status = resp.status();
200 let text = resp.text().await.map_err(|e| e.to_string())?;
201 if !status.is_success() {
202 return Err(
205 refusal_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16()))
206 );
207 }
208 parse_entry(&text)
209 })
210}
211
212fn parse_entry(json: &str) -> Result<CodexEntry, String> {
213 serde_json::from_str(json).map_err(|e| e.to_string())
214}
215
216fn refusal_message(body: &str) -> Option<String> {
218 #[derive(Deserialize)]
219 struct Refusal {
220 in_lore: Option<InLore>,
221 }
222 #[derive(Deserialize)]
223 struct InLore {
224 message: String,
225 }
226 serde_json::from_str::<Refusal>(body)
227 .ok()?
228 .in_lore
229 .map(|l| l.message)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn builds_entry_url() {
238 assert_eq!(
239 entry_url("https://api.pixygon.io/v1", "the-veil"),
240 "https://api.pixygon.io/v1/codex/entities/the-veil"
241 );
242 assert_eq!(
244 entry_url("https://api.pixygon.io/v1/", "amebrak"),
245 "https://api.pixygon.io/v1/codex/entities/amebrak"
246 );
247 }
248
249 #[test]
250 fn parses_the_read_api_shape() {
251 let json = r#"{
252 "name": "The Veil", "slug": "the-veil", "kind": "concept",
253 "subkind": "membrane", "summary": "A temporary membrane.",
254 "description": "The magical, temporary membrane...", "tags": ["wardenship"],
255 "accessTier": "public", "extraFieldWeIgnore": 42
256 }"#;
257 let e = parse_entry(json).unwrap();
258 assert_eq!(e.name, "The Veil");
259 assert_eq!(e.kind, "concept");
260 assert_eq!(e.subkind.as_deref(), Some("membrane"));
261 assert_eq!(e.access_tier.as_deref(), Some("public"));
262
263 let panel = CodexClient {
264 base: String::new(),
265 token: None,
266 state: State::Loaded(Box::new(e)),
267 cache: HashMap::new(),
268 pending: None,
269 }
270 .panel()
271 .unwrap();
272 assert_eq!(panel.title, "The Veil");
273 assert_eq!(panel.subtitle, "concept · membrane");
274 assert!(!panel.loading);
275 }
276
277 #[test]
278 fn blank_tokens_read_as_anonymous() {
279 assert!(CodexClient::new("x").with_token(None).token.is_none());
280 assert!(CodexClient::new("x")
281 .with_token(Some(" ".into()))
282 .token
283 .is_none());
284 assert_eq!(
285 CodexClient::new("x")
286 .with_token(Some("tok".into()))
287 .token
288 .as_deref(),
289 Some("tok")
290 );
291 }
292
293 #[test]
294 fn surfaces_diegetic_refusal_from_a_gated_read() {
295 let body = r#"{"error":"access_denied","in_lore":{"message":"The Archivist meets your gaze. 'Walk on, traveler.'","seal":"oath-of-the-archive","tier":"archivist"}}"#;
296 assert_eq!(
297 refusal_message(body).as_deref(),
298 Some("The Archivist meets your gaze. 'Walk on, traveler.'")
299 );
300 assert!(refusal_message(r#"{"error":"nope"}"#).is_none());
302 }
303}