thread_manifest/
locator.rs1pub const SCHEME: &str = "thread://";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct Locator {
24 pub host: String,
25 pub path: String,
26 pub when: Option<i64>,
28 pub place: Option<String>,
30}
31
32impl Locator {
33 pub fn parse(s: &str) -> Option<Locator> {
35 let rest = s.strip_prefix(SCHEME)?;
36 let (rest, place) = match rest.split_once('#') {
38 Some((r, p)) => (r, Some(p.to_string())),
39 None => (rest, None),
40 };
41 let (rest, when) = match rest.split_once('@') {
43 Some((r, w)) => (r, w.parse::<i64>().ok()),
44 None => (rest, None),
45 };
46 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
63pub fn well_known_url(host: &str, path: &str) -> String {
69 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}