Skip to main content

thread_manifest/
locator.rs

1//! The **Locator** — the address of a place on the Thread (the URL of the
2//! spatial web).
3//!
4//! Grammar (v0.1):
5//! ```text
6//! thread://<host>/<path>[@<when>][#<place>]
7//! ```
8//! - `host`  — the world host authority (a domain, like the web's).
9//! - `path`  — the world's path on that host (may be empty for the host root).
10//! - `@when` — OPTIONAL timeline year; time is a first-class navigation axis.
11//! - `#place`— OPTIONAL named anchor (a spawn or portal id) to arrive at.
12//!
13//! Examples:
14//! - `thread://archive.pixygon.io/codex-archive`
15//! - `thread://market.pixygon.io/market#entry`
16//! - `thread://amebrak.pixygon.io/caul@0`  (the same place, at planet-year 0)
17
18/// The URL scheme of the Thread.
19pub const SCHEME: &str = "thread://";
20
21/// A parsed [Locator]. Round-trips through [`Locator::parse`] / [`Locator::to_string`].
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Locator {
24    pub host: String,
25    pub path: String,
26    /// Timeline year to arrive at (`@when`), if specified.
27    pub when: Option<i64>,
28    /// Named anchor within the world (`#place`), if specified.
29    pub place: Option<String>,
30}
31
32impl Locator {
33    /// Parse a `thread://…` address. Returns `None` if the scheme or host is missing.
34    pub fn parse(s: &str) -> Option<Locator> {
35        let rest = s.strip_prefix(SCHEME)?;
36        // Split trailing #place first (a '#' can't appear in host/path).
37        let (rest, place) = match rest.split_once('#') {
38            Some((r, p)) => (r, Some(p.to_string())),
39            None => (rest, None),
40        };
41        // Then @when.
42        let (rest, when) = match rest.split_once('@') {
43            Some((r, w)) => (r, w.parse::<i64>().ok()),
44            None => (rest, None),
45        };
46        // host is the first path segment; the remainder is the world path.
47        let (host, path) = match rest.split_once('/') {
48            Some((h, p)) => (h, p),
49            None => (rest, ""),
50        };
51        if host.is_empty() {
52            return None;
53        }
54        Some(Locator {
55            host: host.to_string(),
56            path: path.trim_end_matches('/').to_string(),
57            when,
58            place,
59        })
60    }
61}
62
63/// The canonical `.well-known` manifest URL for a host + world path —
64/// `https://<host>/.well-known/thread[/<path>]/world.json`. This is the
65/// decentralized, zero-registry convention: serve a world here and any browser
66/// can reach it at `thread://<host>[/<path>]`. Single source of truth shared by
67/// the browser resolver and the `thread` CLI.
68pub fn well_known_url(host: &str, path: &str) -> String {
69    // A host carrying an explicit port is a direct invite address (a browser
70    // or LAN box hosting a place itself — e.g. a home with guests over); those
71    // serve plain HTTP. Bare hosts are web hosts and stay TLS-only.
72    let scheme = if host.contains(':') { "http" } else { "https" };
73    let mut base = format!("{scheme}://{host}/.well-known/thread");
74    let p = path.trim_matches('/');
75    if !p.is_empty() {
76        base.push('/');
77        base.push_str(p);
78    }
79    format!("{base}/world.json")
80}
81
82impl std::fmt::Display for Locator {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "{SCHEME}{}", self.host)?;
85        if !self.path.is_empty() {
86            write!(f, "/{}", self.path)?;
87        }
88        if let Some(w) = self.when {
89            write!(f, "@{w}")?;
90        }
91        if let Some(p) = &self.place {
92            write!(f, "#{p}")?;
93        }
94        Ok(())
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn parses_full_locator() {
104        let l = Locator::parse("thread://market.pixygon.io/shops/bazaar@0#entry").unwrap();
105        assert_eq!(l.host, "market.pixygon.io");
106        assert_eq!(l.path, "shops/bazaar");
107        assert_eq!(l.when, Some(0));
108        assert_eq!(l.place.as_deref(), Some("entry"));
109    }
110
111    #[test]
112    fn parses_host_only_and_roundtrips() {
113        let l = Locator::parse("thread://archive.pixygon.io").unwrap();
114        assert_eq!(l.host, "archive.pixygon.io");
115        assert_eq!(l.path, "");
116        assert_eq!(l.to_string(), "thread://archive.pixygon.io");
117
118        let full = "thread://a.b/c/d@1850#x";
119        assert_eq!(Locator::parse(full).unwrap().to_string(), full);
120    }
121
122    #[test]
123    fn rejects_non_thread() {
124        assert!(Locator::parse("https://example.com").is_none());
125        assert!(Locator::parse("thread://").is_none());
126    }
127
128    #[test]
129    fn builds_well_known_urls() {
130        assert_eq!(
131            well_known_url("mydomain.com", ""),
132            "https://mydomain.com/.well-known/thread/world.json"
133        );
134        assert_eq!(
135            well_known_url("studio.example.org", "gallery"),
136            "https://studio.example.org/.well-known/thread/gallery/world.json"
137        );
138    }
139}