pub const MAX_TABS: usize = 8;
pub const MAX_LEN: usize = 400;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Tab {
pub twitch: Option<String>,
pub kick: Option<String>,
pub youtube: Option<String>,
}
impl Tab {
fn is_empty(&self) -> bool {
self.twitch.is_none() && self.kick.is_none() && self.youtube.is_none()
}
}
fn name_ok(s: &str) -> bool {
(1..=30).contains(&s.len())
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
fn yt_handle_ok(s: &str) -> bool {
(1..=40).contains(&s.len())
&& s.bytes().all(|b| {
b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.' | b'-')
})
}
fn uc_id_ok(s: &str) -> bool {
s.len() == 24
&& s.starts_with("UC")
&& s.bytes()
.skip(2)
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn extract(input: &str) -> &str {
let s = input.trim();
let s = s.split(['?', '#']).next().unwrap_or("");
let s = match s.find("/m/") {
Some(i) => &s[i + 3..],
None => s,
};
s.trim_matches('/')
}
pub fn parse(input: &str) -> Vec<Tab> {
let layout = extract(input);
if layout.is_empty() || layout.len() > MAX_LEN {
return Vec::new();
}
let mut tabs: Vec<Tab> = Vec::new();
for tab_str in layout.split('+').take(MAX_TABS) {
let mut tab = Tab::default();
for seg in tab_str.split(',').take(3) {
let Some((code, rest)) = seg.split_once(':') else {
continue;
};
let exact = rest.trim().trim_start_matches('@');
let low = exact.to_ascii_lowercase();
match code.trim().to_ascii_lowercase().as_str() {
"t" if name_ok(&low) => tab.twitch = Some(low),
"k" if name_ok(&low) => tab.kick = Some(low),
"y" if uc_id_ok(exact) => tab.youtube = Some(exact.to_string()),
"y" if yt_handle_ok(&low) => tab.youtube = Some(low),
_ => {}
}
}
if !tab.is_empty() && !tabs.contains(&tab) {
tabs.push(tab);
}
}
tabs
}
fn one(t: &Tab) -> String {
let mut segs: Vec<String> = Vec::new();
if let Some(n) = t.twitch.as_deref().filter(|n| name_ok(n)) {
segs.push(format!("t:{n}"));
}
if let Some(n) = t.kick.as_deref().filter(|n| name_ok(n)) {
segs.push(format!("k:{n}"));
}
if let Some(n) = t
.youtube
.as_deref()
.filter(|n| yt_handle_ok(n) || uc_id_ok(n))
{
segs.push(format!("y:{n}"));
}
segs.join(",")
}
pub fn build(tabs: &[Tab]) -> String {
let mut parts: Vec<String> = tabs
.iter()
.map(one)
.filter(|s| !s.is_empty())
.take(MAX_TABS)
.collect();
while !parts.is_empty() && parts.join("+").len() > MAX_LEN {
parts.pop();
}
parts.join("+")
}
#[cfg(test)]
mod tests {
use super::*;
fn t(tw: &str, ki: &str, yt: &str) -> Tab {
let o = |s: &str| (!s.is_empty()).then(|| s.to_string());
Tab {
twitch: o(tw),
kick: o(ki),
youtube: o(yt),
}
}
#[test]
fn merged_tab_and_round_trip() {
let l = "t:xqc,k:xqc,y:lofigirl+t:asmongold+k:trainwreckstv";
let tabs = parse(l);
assert_eq!(tabs.len(), 3);
assert_eq!(tabs[0], t("xqc", "xqc", "lofigirl"));
assert_eq!(build(&tabs), l);
}
#[test]
fn accepts_url_path_and_bare() {
for s in [
"https://heatsync.org/m/t:a+k:b",
"https://heatsync.org/m/t:a+k:b?utm=1#x",
"/m/t:a+k:b",
"t:a+k:b",
] {
assert_eq!(parse(s), vec![t("a", "", ""), t("", "b", "")], "{s}");
}
}
#[test]
fn junk_dropped() {
assert_eq!(
parse("junk+t:ok,x:bad,k:BAD NAME+t:"),
vec![t("ok", "", "")]
);
assert!(parse("").is_empty());
assert!(parse("t:").is_empty());
assert_eq!(parse("t:UPPER"), vec![t("upper", "", "")]);
}
#[test]
fn youtube_handle_and_uc_id() {
let uc = "UCabcdefghijklmnopqrstuv";
assert_eq!(uc.len(), 24);
assert_eq!(parse(&format!("y:{uc}")), vec![t("", "", uc)]);
assert_eq!(parse("y:@Lo.fi"), vec![t("", "", "lo.fi")]);
assert_eq!(parse("y:UCshort"), vec![t("", "", "ucshort")]);
}
#[test]
fn ninth_tab_dropped_and_dupes_collapse() {
let l = (1..=9)
.map(|i| format!("t:c{i}"))
.collect::<Vec<_>>()
.join("+");
let tabs = parse(&l);
assert_eq!(tabs.len(), 8);
assert_eq!(tabs[7], t("c8", "", ""));
assert_eq!(parse("t:a+t:a").len(), 1);
let nine: Vec<Tab> = (1..=9).map(|i| t(&format!("c{i}"), "", "")).collect();
assert_eq!(parse(&build(&nine)).len(), 8);
}
#[test]
fn length_cap() {
assert!(parse(&format!("t:{}", "a".repeat(401))).is_empty());
let name = "a".repeat(30);
let tabs: Vec<Tab> = (0..8).map(|_| t(&name, &name, "")).collect();
let tabs: Vec<Tab> = tabs
.into_iter()
.enumerate()
.map(|(i, mut x)| {
x.twitch = Some(format!("{}{i}", &name[..29]));
x
})
.collect();
let out = build(&tabs);
assert!(out.len() <= MAX_LEN);
let back = parse(&out);
assert!(!back.is_empty() && back.len() < 8);
assert_eq!(back[..], tabs[..back.len()]);
}
}