Skip to main content

heatsync_core/
layout.rs

1//! the heatsync.org `/m/<layout>` share-link grammar, parse + build.
2//!
3//!   layout := tab ('+' tab)*      tabs, one chat column each (8 max)
4//!   tab    := pc (',' pc)*        simulcast: platforms merged into one tab
5//!   pc     := ('t'|'k'|'y') ':' name
6//!
7//! mirrors server/lib/multichat-layout.ts. parsing is total: junk is dropped,
8//! never an error. an over-long layout parses to nothing, like the site.
9
10pub const MAX_TABS: usize = 8;
11pub const MAX_LEN: usize = 400;
12
13#[derive(Clone, Debug, Default, PartialEq, Eq)]
14pub struct Tab {
15    pub twitch: Option<String>,
16    pub kick: Option<String>,
17    /// bare handle (no `@`) or a `UC…` channel id.
18    pub youtube: Option<String>,
19}
20
21impl Tab {
22    fn is_empty(&self) -> bool {
23        self.twitch.is_none() && self.kick.is_none() && self.youtube.is_none()
24    }
25}
26
27fn name_ok(s: &str) -> bool {
28    (1..=30).contains(&s.len())
29        && s.bytes()
30            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
31}
32
33fn yt_handle_ok(s: &str) -> bool {
34    (1..=40).contains(&s.len())
35        && s.bytes().all(|b| {
36            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
37        })
38}
39
40fn uc_id_ok(s: &str) -> bool {
41    s.len() == 24
42        && s.starts_with("UC")
43        && s.bytes()
44            .skip(2)
45            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
46}
47
48/// pull the layout out of a full url, a `/m/...` path, or a bare layout.
49fn extract(input: &str) -> &str {
50    let s = input.trim();
51    let s = s.split(['?', '#']).next().unwrap_or("");
52    let s = match s.find("/m/") {
53        Some(i) => &s[i + 3..],
54        None => s,
55    };
56    s.trim_matches('/')
57}
58
59pub fn parse(input: &str) -> Vec<Tab> {
60    let layout = extract(input);
61    if layout.is_empty() || layout.len() > MAX_LEN {
62        return Vec::new();
63    }
64    let mut tabs: Vec<Tab> = Vec::new();
65    for tab_str in layout.split('+').take(MAX_TABS) {
66        let mut tab = Tab::default();
67        for seg in tab_str.split(',').take(3) {
68            let Some((code, rest)) = seg.split_once(':') else {
69                continue;
70            };
71            let exact = rest.trim().trim_start_matches('@');
72            let low = exact.to_ascii_lowercase();
73            match code.trim().to_ascii_lowercase().as_str() {
74                "t" if name_ok(&low) => tab.twitch = Some(low),
75                "k" if name_ok(&low) => tab.kick = Some(low),
76                "y" if uc_id_ok(exact) => tab.youtube = Some(exact.to_string()),
77                "y" if yt_handle_ok(&low) => tab.youtube = Some(low),
78                _ => {}
79            }
80        }
81        if !tab.is_empty() && !tabs.contains(&tab) {
82            tabs.push(tab);
83        }
84    }
85    tabs
86}
87
88fn one(t: &Tab) -> String {
89    let mut segs: Vec<String> = Vec::new();
90    if let Some(n) = t.twitch.as_deref().filter(|n| name_ok(n)) {
91        segs.push(format!("t:{n}"));
92    }
93    if let Some(n) = t.kick.as_deref().filter(|n| name_ok(n)) {
94        segs.push(format!("k:{n}"));
95    }
96    if let Some(n) = t
97        .youtube
98        .as_deref()
99        .filter(|n| yt_handle_ok(n) || uc_id_ok(n))
100    {
101        segs.push(format!("y:{n}"));
102    }
103    segs.join(",")
104}
105
106/// serialise tabs. invalid legs are skipped, at most 8 tabs, and tail tabs are
107/// dropped until the whole thing fits the 400-char cap (so it parses intact).
108pub fn build(tabs: &[Tab]) -> String {
109    let mut parts: Vec<String> = tabs
110        .iter()
111        .map(one)
112        .filter(|s| !s.is_empty())
113        .take(MAX_TABS)
114        .collect();
115    while !parts.is_empty() && parts.join("+").len() > MAX_LEN {
116        parts.pop();
117    }
118    parts.join("+")
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn t(tw: &str, ki: &str, yt: &str) -> Tab {
126        let o = |s: &str| (!s.is_empty()).then(|| s.to_string());
127        Tab {
128            twitch: o(tw),
129            kick: o(ki),
130            youtube: o(yt),
131        }
132    }
133
134    #[test]
135    fn merged_tab_and_round_trip() {
136        let l = "t:xqc,k:xqc,y:lofigirl+t:asmongold+k:trainwreckstv";
137        let tabs = parse(l);
138        assert_eq!(tabs.len(), 3);
139        assert_eq!(tabs[0], t("xqc", "xqc", "lofigirl"));
140        assert_eq!(build(&tabs), l);
141    }
142
143    #[test]
144    fn accepts_url_path_and_bare() {
145        for s in [
146            "https://heatsync.org/m/t:a+k:b",
147            "https://heatsync.org/m/t:a+k:b?utm=1#x",
148            "/m/t:a+k:b",
149            "t:a+k:b",
150        ] {
151            assert_eq!(parse(s), vec![t("a", "", ""), t("", "b", "")], "{s}");
152        }
153    }
154
155    #[test]
156    fn junk_dropped() {
157        assert_eq!(
158            parse("junk+t:ok,x:bad,k:BAD NAME+t:"),
159            vec![t("ok", "", "")]
160        );
161        assert!(parse("").is_empty());
162        assert!(parse("t:").is_empty());
163        assert_eq!(parse("t:UPPER"), vec![t("upper", "", "")]);
164    }
165
166    #[test]
167    fn youtube_handle_and_uc_id() {
168        let uc = "UCabcdefghijklmnopqrstuv";
169        assert_eq!(uc.len(), 24);
170        assert_eq!(parse(&format!("y:{uc}")), vec![t("", "", uc)]);
171        assert_eq!(parse("y:@Lo.fi"), vec![t("", "", "lo.fi")]);
172        // not a UC id (wrong length) -> treated as a lowercased handle
173        assert_eq!(parse("y:UCshort"), vec![t("", "", "ucshort")]);
174    }
175
176    #[test]
177    fn ninth_tab_dropped_and_dupes_collapse() {
178        let l = (1..=9)
179            .map(|i| format!("t:c{i}"))
180            .collect::<Vec<_>>()
181            .join("+");
182        let tabs = parse(&l);
183        assert_eq!(tabs.len(), 8);
184        assert_eq!(tabs[7], t("c8", "", ""));
185        assert_eq!(parse("t:a+t:a").len(), 1);
186        let nine: Vec<Tab> = (1..=9).map(|i| t(&format!("c{i}"), "", "")).collect();
187        assert_eq!(parse(&build(&nine)).len(), 8);
188    }
189
190    #[test]
191    fn length_cap() {
192        assert!(parse(&format!("t:{}", "a".repeat(401))).is_empty());
193        let name = "a".repeat(30);
194        let tabs: Vec<Tab> = (0..8).map(|_| t(&name, &name, "")).collect();
195        let tabs: Vec<Tab> = tabs
196            .into_iter()
197            .enumerate()
198            .map(|(i, mut x)| {
199                x.twitch = Some(format!("{}{i}", &name[..29]));
200                x
201            })
202            .collect();
203        let out = build(&tabs);
204        assert!(out.len() <= MAX_LEN);
205        let back = parse(&out);
206        assert!(!back.is_empty() && back.len() < 8);
207        assert_eq!(back[..], tabs[..back.len()]);
208    }
209}