1pub 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 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
48fn 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
106pub 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 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}