thread_engine/
resolver.rs1use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14use infinite_manifest::Locator;
15use serde::Deserialize;
16
17use crate::home::is_home;
18
19pub struct TravelResult {
21 pub manifest_text: String,
22 pub asset_base: PathBuf,
25 pub asset_base_url: Option<String>,
28}
29
30#[derive(Deserialize)]
32struct ResolveResponse {
33 #[serde(rename = "manifestUrl")]
34 manifest_url: String,
35 #[serde(rename = "assetBase", default)]
36 _asset_base: Option<String>,
37}
38
39type Synthesizer = Box<dyn Fn(&str) -> Option<TravelResult> + Send + Sync>;
44static SYNTHESIZER: OnceLock<Synthesizer> = OnceLock::new();
45
46pub fn register_synthesizer(f: impl Fn(&str) -> Option<TravelResult> + Send + Sync + 'static) {
48 let _ = SYNTHESIZER.set(Box::new(f));
49}
50
51pub fn fetch_world(
58 resolver_base: &str,
59 worlds_root: &Path,
60 locator: &str,
61) -> Result<TravelResult, String> {
62 let cache = || std::env::temp_dir().join("infinite_thread_assets");
63
64 if is_home(locator) {
69 let path = crate::home::world_path(&crate::home::default_dir());
70 let text = std::fs::read_to_string(&path)
71 .map_err(|e| format!("no home world at {} ({e})", path.display()))?;
72 return Ok(TravelResult {
73 manifest_text: text,
74 asset_base: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
75 asset_base_url: None,
76 });
77 }
78
79 let spec_only = std::env::var("INFINITE_NO_REGISTRY").is_ok_and(|v| !v.is_empty() && v != "0");
85 if spec_only {
86 return match resolve_well_known(locator) {
87 Ok((text, base_url)) => {
88 tracing::info!("resolved '{locator}' via .well-known (spec-only mode)");
89 Ok(TravelResult {
90 manifest_text: text,
91 asset_base: cache(),
92 asset_base_url: base_url,
93 })
94 }
95 Err(e) => Err(format!(
96 "no .well-known world for {locator} ({e}) — spec-only mode"
97 )),
98 };
99 }
100
101 let remote_err = match resolve_remote(resolver_base, locator) {
103 Ok((text, base_url)) => {
104 return Ok(TravelResult {
105 manifest_text: text,
106 asset_base: cache(),
107 asset_base_url: base_url,
108 })
109 }
110 Err(e) => e,
111 };
112
113 if let Ok((text, base_url)) = resolve_well_known(locator) {
115 tracing::info!("resolved '{locator}' via .well-known (no registry)");
116 return Ok(TravelResult {
117 manifest_text: text,
118 asset_base: cache(),
119 asset_base_url: base_url,
120 });
121 }
122
123 let path = local_path(worlds_root, locator)
125 .ok_or_else(|| format!("'{locator}' is not a Locator ({remote_err})"))?;
126 if let Ok(text) = std::fs::read_to_string(&path) {
127 tracing::info!("resolved '{locator}' locally (network: {remote_err})");
128 return Ok(TravelResult {
129 manifest_text: text,
130 asset_base: path.parent().unwrap_or(Path::new(".")).to_path_buf(),
131 asset_base_url: None,
132 });
133 }
134
135 if let Some(synth) = SYNTHESIZER.get() {
137 if let Some(tr) = synth(locator) {
138 tracing::info!("synthesized '{locator}' on approach");
139 return Ok(tr);
140 }
141 }
142 Err(format!("no world at {} ({remote_err})", path.display()))
143}
144
145fn resolve_well_known(locator: &str) -> Result<(String, Option<String>), String> {
149 let loc = Locator::parse(locator).ok_or_else(|| format!("'{locator}' is not a Locator"))?;
150 let manifest_url = infinite_manifest::well_known_url(&loc.host, &loc.path);
151 match http_get_text(&manifest_url) {
152 Ok(text) => Ok((text, dir_url(&manifest_url))),
153 Err(json_err) => {
154 let markup_url = format!(
158 "{}world.thread",
159 manifest_url.trim_end_matches("world.json")
160 );
161 let text = http_get_text(&markup_url).map_err(|_| json_err)?;
162 Ok((text, dir_url(&markup_url)))
163 }
164 }
165}
166
167fn resolve_remote(base: &str, locator: &str) -> Result<(String, Option<String>), String> {
171 let url = format!(
172 "{}/thread/resolve?loc={}",
173 base.trim_end_matches('/'),
174 urlencode(locator)
175 );
176 let body = http_get_text(&url)?;
177 let resolved: ResolveResponse = serde_json::from_str(&body).map_err(|e| e.to_string())?;
178 let text = http_get_text(&resolved.manifest_url)?;
179 let base_url = resolved
180 ._asset_base
181 .clone()
182 .or_else(|| dir_url(&resolved.manifest_url));
183 Ok((text, base_url))
184}
185
186fn dir_url(manifest_url: &str) -> Option<String> {
188 manifest_url
189 .rsplit_once('/')
190 .map(|(dir, _)| format!("{dir}/"))
191}
192
193fn local_path(worlds_root: &Path, locator: &str) -> Option<PathBuf> {
196 let loc = Locator::parse(locator)?;
197 let dir = if loc.path.is_empty() {
198 loc.host
199 } else {
200 loc.path
201 };
202 let json = worlds_root.join(&dir).join("world.json");
203 if !json.exists() {
204 let thread = worlds_root.join(&dir).join("world.thread");
205 if thread.exists() {
206 return Some(thread);
207 }
208 }
209 Some(json)
210}
211
212fn http_get_text(url: &str) -> Result<String, String> {
214 let rt = tokio::runtime::Builder::new_current_thread()
215 .enable_all()
216 .build()
217 .map_err(|e| e.to_string())?;
218 rt.block_on(async {
219 let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
220 let status = resp.status();
221 if !status.is_success() {
222 return Err(format!("HTTP {}", status.as_u16()));
223 }
224 resp.text().await.map_err(|e| e.to_string())
225 })
226}
227
228fn urlencode(s: &str) -> String {
230 let mut out = String::with_capacity(s.len());
231 for b in s.bytes() {
232 match b {
233 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
234 out.push(b as char)
235 }
236 _ => out.push_str(&format!("%{b:02X}")),
237 }
238 }
239 out
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn maps_locators_to_local_files() {
248 let root = Path::new("worlds");
249 assert_eq!(
250 local_path(root, "thread://market.pixygon.io/market"),
251 Some(PathBuf::from("worlds/market/world.json"))
252 );
253 assert_eq!(
254 local_path(root, "thread://archive.pixygon.io/codex-archive#entry"),
255 Some(PathBuf::from("worlds/codex-archive/world.json"))
256 );
257 assert!(local_path(root, "https://not-thread.com").is_none());
258 }
259
260 #[test]
261 fn local_resolution_falls_back_to_markup_when_only_that_exists() {
262 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../worlds");
266 if !root.is_dir() {
267 eprintln!("no reference corpus beside this crate — skipping");
268 return;
269 }
270 let meadow = local_path(&root, "thread://meadow").expect("locator parses");
272 assert!(
273 meadow.ends_with("meadow/world.thread"),
274 "got {}",
275 meadow.display()
276 );
277 let market = local_path(&root, "thread://market.pixygon.io/market").unwrap();
279 assert!(
280 market.ends_with("market/world.json"),
281 "got {}",
282 market.display()
283 );
284 }
285
286 #[test]
287 fn thread_home_is_recognized_in_all_its_spellings() {
288 assert!(is_home("thread://home"));
289 assert!(is_home("thread://home#entry"));
290 assert!(is_home("thread://home@1200"));
291 assert!(!is_home("thread://example.com/home"));
293 assert!(!is_home("thread://homestead.io"));
294 assert!(!is_home("not a locator"));
295 }
296
297 #[test]
298 fn percent_encodes_locators() {
299 assert_eq!(
300 urlencode("thread://market.pixygon.io/market#entry"),
301 "thread%3A%2F%2Fmarket.pixygon.io%2Fmarket%23entry"
302 );
303 }
304}