1use std::sync::mpsc::{channel, Receiver, TryRecvError};
8
9use serde::Deserialize;
10
11#[derive(Debug, Clone)]
13pub struct WorldEntry {
14 pub title: String,
15 pub locator: String,
16 pub description: String,
17}
18
19#[derive(Deserialize)]
20struct WorldsResponse {
21 #[serde(default)]
22 worlds: Vec<RawEntry>,
23}
24
25#[derive(Deserialize)]
26struct RawEntry {
27 #[serde(default)]
28 title: String,
29 #[serde(default)]
30 locator: String,
31 #[serde(default)]
32 description: String,
33}
34
35pub struct Directory {
37 base: String,
38 open: bool,
39 entries: Option<Vec<WorldEntry>>,
40 error: Option<String>,
41 pending: Option<Receiver<Result<Vec<WorldEntry>, String>>>,
42 search_query: String,
46 search_results: Option<Vec<SearchHit>>,
47 search_pending: Option<(String, Receiver<Result<Vec<SearchHit>, String>>)>,
48}
49
50#[derive(Debug, Clone)]
52pub struct SearchHit {
53 pub title: String,
54 pub locator: String,
55 pub snippet: String,
57 pub veils_in: u32,
59}
60
61impl Directory {
62 pub fn from_env() -> Self {
63 let base = std::env::var("INFINITE_THREAD_BASE")
64 .unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
65 Self {
66 base,
67 open: false,
68 entries: None,
69 error: None,
70 pending: None,
71 search_query: String::new(),
72 search_results: None,
73 search_pending: None,
74 }
75 }
76
77 pub fn is_open(&self) -> bool {
78 self.open
79 }
80 pub fn close(&mut self) {
81 self.open = false;
82 }
83
84 pub fn toggle(&mut self) {
86 self.open = !self.open;
87 self.ensure_loaded();
88 }
89
90 pub fn ensure_loaded(&mut self) {
93 if self.entries.is_some() || self.pending.is_some() {
94 return;
95 }
96 self.error = None;
97 let url = format!("{}/thread/worlds", self.base.trim_end_matches('/'));
98 let (tx, rx) = channel();
99 std::thread::spawn(move || {
100 let _ = tx.send(fetch_worlds(&url));
101 });
102 self.pending = Some(rx);
103 }
104
105 pub fn lookup(&self, locator: &str) -> Option<&WorldEntry> {
107 let key = normalize_locator(locator);
108 self.entries
109 .as_ref()?
110 .iter()
111 .find(|e| normalize_locator(&e.locator) == key)
112 }
113
114 pub fn search(&mut self, query: &str) {
120 let q = query.trim().to_string();
121 if q == self.search_query {
122 return;
123 }
124 self.search_query = q.clone();
125 if q.is_empty() {
126 self.search_results = None;
127 self.search_pending = None;
128 return;
129 }
130 let url = format!(
131 "{}/thread/search?q={}",
132 self.base.trim_end_matches('/'),
133 urlencode(&q)
134 );
135 let (tx, rx) = channel();
136 std::thread::spawn(move || {
137 let _ = tx.send(fetch_search(&url));
138 });
139 self.search_pending = Some((q, rx));
140 }
141
142 pub fn search_results(&self) -> Option<&[SearchHit]> {
144 self.search_results.as_deref()
145 }
146 pub fn search_loading(&self) -> bool {
148 self.search_pending.is_some()
149 }
150
151 pub fn poll(&mut self) {
153 if let Some((q, rx)) = &self.search_pending {
155 match rx.try_recv() {
156 Ok(Ok(hits)) => {
157 if *q == self.search_query {
158 self.search_results = Some(hits);
159 }
160 self.search_pending = None;
161 }
162 Ok(Err(_)) | Err(TryRecvError::Disconnected) => {
163 self.search_pending = None;
166 }
167 Err(TryRecvError::Empty) => {}
168 }
169 }
170 self.poll_worlds();
171 }
172
173 fn poll_worlds(&mut self) {
174 let Some(rx) = &self.pending else { return };
175 match rx.try_recv() {
176 Ok(Ok(list)) => {
177 self.entries = Some(list);
178 self.pending = None;
179 }
180 Ok(Err(e)) => {
181 self.error = Some(e);
182 self.pending = None;
183 }
184 Err(TryRecvError::Empty) => {}
185 Err(TryRecvError::Disconnected) => {
186 self.error = Some("fetch stopped".into());
187 self.pending = None;
188 }
189 }
190 }
191
192 pub fn entries(&self) -> Option<&[WorldEntry]> {
193 self.entries.as_deref()
194 }
195 pub fn is_loading(&self) -> bool {
196 self.pending.is_some()
197 }
198 pub fn error(&self) -> Option<&str> {
199 self.error.as_deref()
200 }
201}
202
203fn fetch_worlds(url: &str) -> Result<Vec<WorldEntry>, String> {
204 let rt = tokio::runtime::Builder::new_current_thread()
205 .enable_all()
206 .build()
207 .map_err(|e| e.to_string())?;
208 rt.block_on(async {
209 let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
210 if !resp.status().is_success() {
211 return Err(format!("HTTP {}", resp.status().as_u16()));
212 }
213 let text = resp.text().await.map_err(|e| e.to_string())?;
214 parse_worlds(&text)
215 })
216}
217
218fn fetch_search(url: &str) -> Result<Vec<SearchHit>, String> {
219 let rt = tokio::runtime::Builder::new_current_thread()
220 .enable_all()
221 .build()
222 .map_err(|e| e.to_string())?;
223 rt.block_on(async {
224 let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
225 if !resp.status().is_success() {
226 return Err(format!("HTTP {}", resp.status().as_u16()));
227 }
228 let text = resp.text().await.map_err(|e| e.to_string())?;
229 parse_search(&text)
230 })
231}
232
233fn urlencode(s: &str) -> String {
235 let mut out = String::with_capacity(s.len());
236 for b in s.bytes() {
237 match b {
238 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
239 out.push(b as char)
240 }
241 b' ' => out.push('+'),
242 _ => out.push_str(&format!("%{b:02X}")),
243 }
244 }
245 out
246}
247
248#[derive(Deserialize)]
249struct SearchResponse {
250 #[serde(default)]
251 places: Vec<RawHit>,
252}
253
254#[derive(Deserialize)]
255struct RawHit {
256 #[serde(default)]
257 title: String,
258 #[serde(default)]
259 locator: String,
260 #[serde(default)]
261 snippet: String,
262 #[serde(default, rename = "veilsIn")]
263 veils_in: u32,
264}
265
266fn parse_search(json: &str) -> Result<Vec<SearchHit>, String> {
267 let parsed: SearchResponse = serde_json::from_str(json).map_err(|e| e.to_string())?;
268 Ok(parsed
269 .places
270 .into_iter()
271 .filter(|h| !h.locator.is_empty())
272 .map(|h| SearchHit {
273 title: h.title,
274 locator: h.locator,
275 snippet: h.snippet,
276 veils_in: h.veils_in,
277 })
278 .collect())
279}
280
281fn normalize_locator(loc: &str) -> String {
283 loc.split(['#', '@'])
284 .next()
285 .unwrap_or(loc)
286 .trim_end_matches('/')
287 .to_string()
288}
289
290fn parse_worlds(json: &str) -> Result<Vec<WorldEntry>, String> {
291 let parsed: WorldsResponse = serde_json::from_str(json).map_err(|e| e.to_string())?;
292 Ok(parsed
293 .worlds
294 .into_iter()
295 .filter(|e| !e.locator.is_empty())
296 .map(|e| WorldEntry {
297 title: e.title,
298 locator: e.locator,
299 description: e.description,
300 })
301 .collect())
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn parses_ranked_search_hits() {
310 let json = r#"{"query":"weft","total":17,"count":2,"places":[
311 {"locator":"thread://weft.pixygon.io","title":"The Weavery","snippet":"packages","veilsIn":3,"score":1.5},
312 {"title":"No Locator","snippet":"dropped"},
313 {"locator":"thread://wiki.pixygon.io","title":"The Atrium","snippet":"clock","veilsIn":2}
314 ]}"#;
315 let hits = parse_search(json).unwrap();
316 assert_eq!(hits.len(), 2, "hits without a locator are dropped");
317 assert_eq!(hits[0].title, "The Weavery");
318 assert_eq!(hits[0].veils_in, 3);
319 assert_eq!(hits[1].snippet, "clock");
320 }
321
322 #[test]
323 fn urlencode_covers_query_text() {
324 assert_eq!(urlencode("the weavery"), "the+weavery");
325 assert_eq!(urlencode("a/b?c"), "a%2Fb%3Fc");
326 assert_eq!(urlencode("plain-text_1.0~x"), "plain-text_1.0~x");
327 }
328
329 #[test]
330 fn parses_the_worlds_list() {
331 let json = r#"{"worlds":[
332 {"id":"46000003","title":"The Nexus","locator":"thread://pixygon.io","description":"front door"},
333 {"title":"No Locator","description":"skipped"},
334 {"title":"The Forge","locator":"thread://pixiel.ai/forge","description":"workshop"}
335 ]}"#;
336 let list = parse_worlds(json).unwrap();
337 assert_eq!(list.len(), 2, "entries without a locator are dropped");
338 assert_eq!(list[0].title, "The Nexus");
339 assert_eq!(list[1].locator, "thread://pixiel.ai/forge");
340 }
341}