use std::io::{BufRead, IsTerminal, Write};
use std::time::{Duration, Instant};
use heatsync_core::Platform;
use crate::config;
use crate::hsauth;
use crate::http;
use crate::net::{self, ChatEvent};
fn split_channel(tok: &str) -> (Platform, &str) {
if let Some(rest) = tok.strip_prefix("kick:") {
(Platform::Kick, rest)
} else if let Some(rest) = tok.strip_prefix("twitch:") {
(Platform::Twitch, rest)
} else {
(Platform::Twitch, tok)
}
}
pub fn log(args: &[String]) -> std::io::Result<()> {
let (channel, date) = match args {
[c, d, ..] => (c.as_str(), d.as_str()),
_ => {
eprintln!("usage: heatsync log <channel> <YYYY-MM-DD>");
std::process::exit(2);
}
};
let (_plat, name) = split_channel(channel);
let from = format!("{date}T00:00:00Z");
let to = format!("{date}T23:59:59Z");
match http::channel_log(name, &from, &to, 100) {
Some(page) if !page.results.is_empty() => {
for m in &page.results {
let who = m.display_name.as_deref().unwrap_or(&m.username);
println!("{} {}: {}", short_ts(&m.timestamp), who, m.message);
}
}
Some(_) => eprintln!("no logs for {name} on {date}"),
None => {
eprintln!("request failed");
std::process::exit(1);
}
}
Ok(())
}
pub fn search(args: &[String]) -> std::io::Result<()> {
let q = match args.first() {
Some(q) => q.as_str(),
None => {
eprintln!("usage: heatsync search <query> [channel]");
std::process::exit(2);
}
};
let channel = args.get(1).map(|s| split_channel(s).1);
match http::search(q, channel, 50) {
Some(page) if !page.results.is_empty() => {
for m in &page.results {
let who = m.display_name.as_deref().unwrap_or(&m.username);
println!(
"{} [{}/{}] {}: {}",
short_ts(&m.timestamp),
m.platform,
m.channel,
who,
m.message
);
}
}
Some(_) => eprintln!("no matches for {q:?}"),
None => {
eprintln!("request failed");
std::process::exit(1);
}
}
Ok(())
}
pub fn hot(args: &[String]) -> std::io::Result<()> {
let hours: u32 = args.first().and_then(|s| s.parse().ok()).unwrap_or(720);
match http::hot(25, hours) {
Some(page) if !page.messages.is_empty() => {
for m in &page.messages {
let who = m.display_name.as_deref().unwrap_or(&m.username);
let subj = m.subject.as_deref().unwrap_or("");
println!(
"{:>6.0} (peak {:>6.0}) {}: {} {}",
m.heat, m.max_heat, who, m.content, subj
);
}
}
Some(_) => eprintln!("nothing hot in the last {hours}h"),
None => {
eprintln!("request failed");
std::process::exit(1);
}
}
Ok(())
}
pub fn login(kick: bool) -> std::io::Result<bool> {
let base = match hsauth::base_url() {
Ok(b) => b,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
let t = hsauth::UreqTransport;
let pair = match hsauth::pair_start(&t, &base) {
Ok(p) => p,
Err(e) => {
eprintln!("{e}");
std::process::exit(1);
}
};
println!("log in to heatsync\n");
println!(" 1. open {}", pair.url);
println!(" 2. log in there (twitch or kick) if it asks");
println!(" 3. type {} and press approve\n", pair.user_code);
open_browser(&pair.url);
println!("waiting for you… (ctrl-c to cancel)");
let mut interval = pair.interval;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(pair.expires_in);
let session = loop {
std::thread::sleep(std::time::Duration::from_secs(interval));
if std::time::Instant::now() > deadline {
eprintln!("that code expired — run the login again");
std::process::exit(1);
}
match hsauth::poll_once(&t, &base, &pair.device_code) {
hsauth::Poll::Pending => {}
hsauth::Poll::SlowDown => interval = (interval + 2).min(30),
hsauth::Poll::Approved(s) => break s,
hsauth::Poll::Failed(e) => {
eprintln!("{e}");
std::process::exit(1);
}
}
};
if let Err(e) = hsauth::save_session(&session) {
eprintln!("logged in, but could not save the login: {e}");
std::process::exit(1);
}
let client = hsauth::Client::new(&session, std::sync::Arc::new(hsauth::UreqTransport));
let tty = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
let stdin = std::io::stdin();
Ok(finish_login(
&client,
&base,
kick,
tty,
&mut stdin.lock(),
&mut open_browser,
))
}
fn finish_login(
client: &hsauth::Client,
base: &str,
kick: bool,
tty: bool,
input: &mut dyn BufRead,
open: &mut dyn FnMut(&str),
) -> bool {
let me = client.me();
if !tty {
println!("logged in ✓\n");
}
match &me {
Ok(me) => {
if !tty {
print_me(me, base, kick, false);
}
offer_scopes(&missing_scopes(me, base), tty, input, open);
if tty && kick {
print_kick(me, base, true);
}
if tty {
println!("logged in as {} — opening chat…", account_name(me));
}
}
Err(e) => {
println!("{e}");
if tty {
println!("logged in — opening chat…");
}
}
}
tty
}
fn account_name(me: &serde_json::Value) -> String {
let s = |v: &serde_json::Value| v.as_str().map(str::to_string);
s(&me["user"]["display_name"])
.or_else(|| s(&me["user"]["username"]))
.unwrap_or_else(|| "?".into())
}
fn missing_scopes(me: &serde_json::Value, base: &str) -> Vec<(&'static str, String)> {
let tw = &me["twitch"];
let mut v = vec![];
if tw["linked"].as_bool() != Some(true) {
return v;
}
if tw["can_send"].as_bool() == Some(false) {
v.push(("chat", format!("{base}/api/auth/login?scopes=chatsend")));
}
if tw["can_mod"].as_bool() == Some(false) {
v.push(("mod", format!("{base}/api/auth/login?scopes=mod")));
}
v
}
fn offer_scopes(
need: &[(&str, String)],
tty: bool,
input: &mut dyn BufRead,
open: &mut dyn FnMut(&str),
) {
if need.is_empty() {
return;
}
let what: Vec<&str> = need.iter().map(|n| n.0).collect();
println!("twitch needs one more ok for {}:", what.join(" + "));
for (_, url) in need {
println!(" {url}");
}
if !tty {
return;
}
print!(
"open {}? [y/N] ",
if need.len() > 1 { "them" } else { "it" }
);
let _ = std::io::stdout().flush();
let mut ans = String::new();
if input.read_line(&mut ans).is_ok() && matches!(ans.trim(), "y" | "Y" | "yes") {
for (_, url) in need {
open(url);
}
}
}
fn open_browser(url: &str) {
let launcher = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
let _ = std::process::Command::new(launcher)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
fn print_me(me: &serde_json::Value, base: &str, want_kick: bool, link_hints: bool) {
let s = |v: &serde_json::Value| v.as_str().map(str::to_string);
println!(" account {}", account_name(me));
let scopes: Vec<String> = me["scopes"]
.as_array()
.map(|a| a.iter().filter_map(s).collect())
.unwrap_or_default();
println!(" this login can: {}", scopes.join(", "));
if !scopes.iter().any(|x| x == "mod") {
println!(" no mod tools — run `heatsync-tui login` again and tick mod");
}
if !scopes.iter().any(|x| x == "account") {
println!(" no whispers, dms or mention hits — run `heatsync-tui login` again and tick account");
}
if let Some(e) = s(&me["expires_at"]) {
println!(" renews itself; valid until {e}");
}
let tw = &me["twitch"];
if tw["linked"].as_bool() == Some(true) {
println!(" twitch {}", s(&tw["login"]).unwrap_or_default());
if link_hints && tw["can_mod"].as_bool() == Some(false) {
println!(" to mod: open {base}/api/auth/login?scopes=mod");
}
if link_hints && tw["can_send"].as_bool() == Some(false) {
println!(" to chat: open {base}/api/auth/login?scopes=chatsend");
}
} else {
println!(" twitch not linked — open {base}/api/auth/login");
}
print_kick(me, base, want_kick);
}
fn print_kick(me: &serde_json::Value, base: &str, want_kick: bool) {
let s = |v: &serde_json::Value| v.as_str().map(str::to_string);
let k = &me["kick"];
if k["linked"].as_bool() == Some(true) {
println!(" kick {}", s(&k["login"]).unwrap_or_default());
} else if want_kick {
println!(" kick not linked yet — open {base}/api/auth/kick/login, then you can chat on kick from here");
} else {
println!(" kick not linked (heatsync-tui login kick shows how)");
}
}
pub fn logout() -> std::io::Result<()> {
let Some(s) = hsauth::load_session() else {
println!("not logged in");
return Ok(());
};
let client = hsauth::Client::new(&s, std::sync::Arc::new(hsauth::UreqTransport));
let revoked = client.revoke();
let removed = hsauth::delete_session();
match (revoked, removed) {
(true, true) => println!("logged out ✓"),
(false, true) => println!(
"logged out here. heatsync couldn't be reached, so also run: heatsync.org/cli → \"log all out\""
),
(_, false) => {
eprintln!("could not delete the saved login");
std::process::exit(1);
}
}
Ok(())
}
const ADMIN_EXPIRED: &str =
"admin glance: admin token expired (~/.config/heatsync/token) — skip or refresh it";
pub fn status() -> std::io::Result<()> {
let base = hsauth::base_url().unwrap_or_else(|e| {
eprintln!("{e}");
std::process::exit(1);
});
match hsauth::load_session() {
None => println!("not logged in — run: heatsync-tui login"),
Some(s) => {
let client = hsauth::Client::new(&s, std::sync::Arc::new(hsauth::UreqTransport));
println!("logged in to {}", s.base);
match client.me() {
Ok(me) => print_me(&me, &base, false, true),
Err(e) => {
if hsauth::is_logged_out(&e) {
hsauth::delete_session();
}
println!(" {e}");
std::process::exit(1);
}
}
}
}
let auth = config::load_auth();
let Some(token) = auth.admin_token else {
return Ok(());
};
println!();
let health = http::admin_health(&token);
if health.is_none() && http::admin_token_rejected(http::BASE, &token) {
println!("{ADMIN_EXPIRED}");
return Ok(());
}
let mut ok = true;
let mod_queue = http::admin_mod_queue_total(&token);
let reports = http::admin_pending_reports(&token);
let ncmec = http::admin_ncmec_backlog(&token);
println!("heatsync — prod status");
match &health {
Some(h) => {
println!(
" postgres: {} redis: {}",
h.postgres_status, h.redis_status
);
println!(
" cpu {:>5.1}% ram {:>5.1}% disk {:>5.1}% errors/min {:.1}",
h.cpu_usage, h.ram_usage, h.disk_usage, h.error_rate
);
println!(
" ws conns: {} msgs/min: {:.0}",
h.ws_connections, h.messages_per_minute
);
}
None => {
println!(" health: unreachable (auth expired? check ~/.config/heatsync/token)");
ok = false;
}
}
match mod_queue {
Some(n) => println!(" mod queue: {n:>4} pending"),
None => {
println!(" mod queue: ? (request failed)");
ok = false;
}
}
match reports {
Some(n) => println!(" reports: {n:>4} pending"),
None => {
println!(" reports: ? (request failed)");
ok = false;
}
}
match ncmec {
Some(n) if n > 0 => println!(" ncmec: {n:>4} STUCK — needs a look"),
Some(_) => println!(" ncmec: 0 clear"),
None => {
println!(" ncmec: ? (request failed)");
ok = false;
}
}
if !ok {
std::process::exit(1);
}
Ok(())
}
pub fn probe(args: &[String]) -> std::io::Result<()> {
let subs = if args.is_empty() {
vec![(Platform::Twitch, "xqc".to_string())]
} else {
args.iter()
.map(|a| {
let (p, n) = split_channel(a);
(p, n.to_string())
})
.collect()
};
eprintln!("probing {} channel(s) for 8s…", subs.len());
let (rx, _out) = net::spawn(subs, None, false);
let deadline = Instant::now() + Duration::from_secs(8);
let mut n = 0u32;
while let Some(rem) = deadline.checked_duration_since(Instant::now()) {
match rx.recv_timeout(rem) {
Ok(ChatEvent::Line(l)) => {
println!(
"[{}/{}] {}: {}",
plat_tag(l.platform),
l.channel,
l.user,
l.content
);
n += 1;
}
Ok(ChatEvent::Connected) => eprintln!("· connected"),
Ok(ChatEvent::Disconnected) => eprintln!("· disconnected"),
Ok(_) => {}
Err(_) => break,
}
}
eprintln!("{n} lines in 8s");
Ok(())
}
fn plat_tag(p: Platform) -> &'static str {
p.tag()
}
fn short_ts(ts: &str) -> &str {
ts.split('T')
.nth(1)
.map(|t| &t[..t.len().min(8)])
.unwrap_or(ts)
}
pub fn render_test(args: &[String]) -> std::io::Result<()> {
use ratatui_image::picker::ProtocolType;
if args.is_empty() {
eprintln!("usage: heatsync render-test <base-url> [overlay-url …]");
return Ok(());
}
let key = args.join("\n");
let Some(frames) = crate::emote::render::composite_frames(&key, 32) else {
eprintln!("render-test: composite failed (fetch/decode)");
return Ok(());
};
let (w, h) = (frames[0].0.width(), frames[0].0.height());
eprintln!(
"render-test: {} layer(s) → {} composited frame(s), {w}x{h}px",
args.len(),
frames.len()
);
if frames.len() > 1 {
let total: u32 = frames.iter().map(|f| f.1.max(20)).sum();
let fps = frames.len() as f64 * 1000.0 / total.max(1) as f64;
eprintln!(
" authored: {:.0}ms loop at {fps:.1}fps (a 100ms redraw cadence would \
show {:.0} of these {} frames)",
total,
(total as f64 / 100.0).floor(),
frames.len()
);
}
let idxs = if frames.len() > 1 {
vec![0usize, frames.len() / 2]
} else {
vec![0usize]
};
for i in &idxs {
let p = format!("/tmp/emote_composite_{i}.png");
if frames[*i].0.save(&p).is_ok() {
eprintln!(" composite frame {i} → {p}");
}
}
let (cw, ch) = (8u16, 16u16);
let mut picker = ratatui_image::picker::Picker::from_fontsize((cw, ch));
picker.set_protocol_type(ProtocolType::Sixel);
let block_h = crate::emote::render::EMOTE_H as u32 * ch as u32;
let w_cells = crate::emote::render::width_cells(w as f32 / h as f32, cw, ch);
eprintln!(
" footprint on an {cw}x{ch} cell: {w_cells}x{} cells = {}x{block_h}px (source {w}x{h})",
crate::emote::render::EMOTE_H,
w_cells as u32 * cw as u32,
);
let take = frames.len().min(24);
let mut canvases: Vec<image::RgbaImage> = frames[..take]
.iter()
.map(|(f, _)| crate::emote::render::fit_center(f, w_cells as u32 * cw as u32, block_h))
.collect();
for c in &mut canvases {
crate::emote::render::flatten_onto_black(c);
}
let raw = encode_all(&picker, &canvases, w_cells);
crate::emote::render::stabilize_palette(&mut canvases);
let stable = encode_all(&picker, &canvases, w_cells);
if let Some(first) = stable.first() {
std::fs::write("/tmp/emote.six", first)?;
let wrapped = first.contains("tmux;");
eprintln!(
" sixel bytes → /tmp/emote.six ({} bytes){}",
first.len(),
if wrapped {
" [tmux-wrapped — rerun with `env -u TMUX`]"
} else {
""
}
);
}
if take > 1 {
let px = (w_cells as u32 * cw as u32, block_h);
eprintln!(" pixels changing between consecutive frames (lower = less shimmer):");
eprintln!(
" per-frame palette (before): {:.1}%",
pixel_churn(&raw, px) * 100.0
);
eprintln!(
" shared palette (after): {:.1}%",
pixel_churn(&stable, px) * 100.0
);
eprintln!(
" source frames actually differ by: {:.1}% (the real motion)",
source_churn(&canvases_src(
&frames[..take],
w_cells as u32 * cw as u32,
block_h
)) * 100.0
);
}
Ok(())
}
fn canvases_src(frames: &[(image::RgbaImage, u32)], tw: u32, th: u32) -> Vec<image::RgbaImage> {
frames
.iter()
.map(|(f, _)| {
let mut c = crate::emote::render::fit_center(f, tw, th);
crate::emote::render::flatten_onto_black(&mut c);
c
})
.collect()
}
fn source_churn(frames: &[image::RgbaImage]) -> f64 {
if frames.len() < 2 {
return 0.0;
}
let mut total = 0.0;
for w in frames.windows(2) {
let n = w[0].pixels().len().max(1);
let diff = w[0]
.pixels()
.zip(w[1].pixels())
.filter(|(a, b)| a != b)
.count();
total += diff as f64 / n as f64;
}
total / (frames.len() - 1) as f64
}
fn pixel_churn(frames: &[String], (w, h): (u32, u32)) -> f64 {
let decoded: Vec<Vec<u32>> = frames.iter().map(|s| sixel_decode(s, w, h)).collect();
if decoded.len() < 2 {
return 0.0;
}
let mut total = 0.0;
for pair in decoded.windows(2) {
let n = (w * h) as usize;
let diff = pair[0].iter().zip(&pair[1]).filter(|(a, b)| a != b).count();
total += diff as f64 / n.max(1) as f64;
}
total / (decoded.len() - 1) as f64
}
fn sixel_decode(data: &str, w: u32, h: u32) -> Vec<u32> {
let mut out = vec![0u32; (w * h) as usize];
let mut palette = std::collections::HashMap::new();
let b: Vec<char> = data.chars().collect();
let mut i = 0usize;
while i < b.len() && b[i] != 'q' {
i += 1;
}
i += 1;
let (mut x, mut band, mut color) = (0u32, 0u32, 0u32);
let num = |b: &[char], i: &mut usize| -> u32 {
let mut n = 0u32;
while *i < b.len() && b[*i].is_ascii_digit() {
n = n
.saturating_mul(10)
.saturating_add(b[*i] as u32 - '0' as u32);
*i += 1;
}
n
};
while i < b.len() {
match b[i] {
'#' => {
i += 1;
let idx = num(&b, &mut i);
if i < b.len() && b[i] == ';' {
i += 1;
let _mode = num(&b, &mut i);
let mut c = [0u32; 3];
for slot in c.iter_mut() {
if i < b.len() && b[i] == ';' {
i += 1;
}
*slot = num(&b, &mut i);
}
palette.insert(idx, (c[0] << 16) | (c[1] << 8) | c[2] | 1 << 24);
}
color = idx;
}
'!' => {
i += 1;
let n = num(&b, &mut i);
if i < b.len() {
let ch = b[i];
i += 1;
for _ in 0..n {
put(
&mut out,
w,
h,
x,
band,
ch,
*palette.get(&color).unwrap_or(&0),
);
x += 1;
}
}
}
'$' => {
x = 0;
i += 1;
}
'-' => {
x = 0;
band += 1;
i += 1;
}
'"' => {
i += 1;
while i < b.len() && (b[i].is_ascii_digit() || b[i] == ';') {
i += 1;
}
}
c if ('?'..='~').contains(&c) => {
put(
&mut out,
w,
h,
x,
band,
c,
*palette.get(&color).unwrap_or(&0),
);
x += 1;
i += 1;
}
_ => i += 1,
}
}
out
}
fn put(out: &mut [u32], w: u32, h: u32, x: u32, band: u32, ch: char, rgb: u32) {
if x >= w {
return;
}
let bits = (ch as u32).wrapping_sub('?' as u32);
for bit in 0..6u32 {
if bits & (1 << bit) != 0 {
let y = band * 6 + bit;
if y < h {
out[(y * w + x) as usize] = rgb;
}
}
}
}
fn encode_all(
picker: &ratatui_image::picker::Picker,
canvases: &[image::RgbaImage],
w_cells: u16,
) -> Vec<String> {
let size = ratatui::layout::Rect::new(0, 0, w_cells, crate::emote::render::EMOTE_H);
canvases
.iter()
.filter_map(|c| {
match picker.new_protocol(
image::DynamicImage::ImageRgba8(c.clone()),
size,
ratatui_image::Resize::Fit(None),
) {
Ok(ratatui_image::protocol::Protocol::Sixel(s)) => Some(s.data),
_ => None,
}
})
.collect()
}
pub fn diag(_args: &[String]) -> std::io::Result<()> {
let env = |k: &str| std::env::var(k).unwrap_or_else(|_| "<unset>".into());
println!(
"TERM={} TMUX={} WAYLAND_DISPLAY={} DISPLAY={}",
env("TERM"),
if std::env::var_os("TMUX").is_some() {
"set"
} else {
"<unset>"
},
env("WAYLAND_DISPLAY"),
env("DISPLAY")
);
let (cols, rows) = match crossterm::terminal::size() {
Ok((c, r)) => {
println!("crossterm size = {c}x{r} cells");
(c, r)
}
Err(e) => {
println!("crossterm size = ERR {e}");
(0, 0)
}
};
println!("glyphs (blank = font can't map it): '\u{276f}' '\u{2588}' '\u{25cf}' '\u{25cb}' '\u{2500}' '\u{2502}' '\u{250c}'");
println!("--- tier decision ---");
let mut reasons: Vec<String> = Vec::new();
if std::env::var_os("TMUX").is_some() {
let client = std::process::Command::new("tmux")
.args(["display", "-p", "#{client_termname}"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
println!("tmux client_termname = {client:?}");
if client != "linux" {
reasons.push(format!("tmux client is {client:?}, not a kernel console"));
}
} else {
if std::env::var("TERM").ok().as_deref() != Some("linux") {
reasons.push("TERM != linux and not in tmux".into());
}
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
reasons.push("WAYLAND_DISPLAY set (fb0 is not the scanned-out surface)".into());
}
if std::env::var_os("DISPLAY").is_some() {
reasons.push("DISPLAY set (fb0 is not the scanned-out surface)".into());
}
}
if cols == 0 || rows == 0 {
reasons.push("terminal size reported 0 — fb tier needs a real cell grid".into());
}
#[cfg(target_os = "linux")]
{
match framebuffer::Framebuffer::new("/dev/fb0") {
Err(e) => reasons.push(format!("/dev/fb0 open failed: {e:?}")),
Ok(fb) => {
let v = fb.var_screen_info.clone();
let f = fb.fix_screen_info.clone();
println!(
"fb0: {}x{} bpp={} line_length={} smem_len={} xoff={} yoff={}",
v.xres,
v.yres,
v.bits_per_pixel,
f.line_length,
f.smem_len,
v.xoffset,
v.yoffset
);
println!(
"fb0 bitfields: r=({},{}) g=({},{}) b=({},{})",
v.red.offset,
v.red.length,
v.green.offset,
v.green.length,
v.blue.offset,
v.blue.length
);
println!("fb0 mmap len = {} bytes", fb.frame.len());
if cols > 0 && rows > 0 {
println!(
"cell = {}x{} px",
(v.xres / cols as u32).max(1),
(v.yres / rows as u32).max(1)
);
}
if reasons.is_empty() {
paint_test(
fb,
v.xres,
v.yres,
f.line_length,
(v.bits_per_pixel / 8).max(1),
);
}
}
}
}
if reasons.is_empty() {
println!("=> framebuffer tier ACTIVE");
} else {
println!("=> framebuffer tier REJECTED:");
for r in &reasons {
println!(" - {r}");
}
println!(" (falls back to the terminal-graphics tier, else text)");
}
Ok(())
}
#[cfg(target_os = "linux")]
fn paint_test(mut fb: framebuffer::Framebuffer, xres: u32, yres: u32, line_len: u32, bpp: u32) {
let (w, h) = (200u32.min(xres), 100u32.min(yres));
let buf = &mut fb.frame;
for y in 100..(100 + h) {
for x in 100..(100 + w) {
let off = (y as usize) * line_len as usize + (x as usize) * bpp as usize;
if off + bpp as usize > buf.len() {
continue;
}
for (i, b) in [0x00u8, 0x87, 0xff, 0x00]
.iter()
.take(bpp as usize)
.enumerate()
{
buf[off + i] = *b;
}
}
}
println!("painted an ORANGE block at px(100,100) size 200x100 — holding 5s");
std::thread::sleep(std::time::Duration::from_secs(5));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{json, Value};
use std::io::Read;
use std::sync::{Arc, Mutex};
struct Fake(Mutex<Vec<Result<(u16, Value), String>>>);
impl hsauth::Transport for Fake {
fn call(
&self,
_: &str,
_: &str,
_: Option<&str>,
_: Option<&Value>,
) -> Result<(u16, Value), String> {
self.0.lock().unwrap().remove(0)
}
}
fn client(me: Value) -> hsauth::Client {
let s = hsauth::Session::new("hscli_X".into(), "https://h.test".into(), String::new());
hsauth::Client::new(&s, Arc::new(Fake(Mutex::new(vec![Ok((200, me))]))))
}
fn me(can_send: bool, can_mod: bool) -> Value {
json!({"user": {"display_name": "Mellen"}, "scopes": ["chat"],
"twitch": {"linked": true, "login": "m", "can_send": can_send, "can_mod": can_mod},
"kick": {"linked": false}})
}
fn run(me_v: Value, tty: bool, answer: &str) -> (bool, Vec<String>) {
let mut opened = vec![];
let go = finish_login(
&client(me_v),
"https://h.test",
false,
tty,
&mut answer.as_bytes(),
&mut |u| opened.push(u.to_string()),
);
(go, opened)
}
#[test]
fn tty_goes_to_chat_scripts_exit() {
assert!(run(me(true, true), true, "").0);
assert!(!run(me(true, true), false, "").0);
}
#[test]
fn scope_prompt_defaults_to_no_and_opens_both_on_yes() {
let (_, o) = run(me(false, false), true, "\n");
assert!(o.is_empty());
let (_, o) = run(me(false, false), true, "y\n");
assert_eq!(
o,
[
"https://h.test/api/auth/login?scopes=chatsend",
"https://h.test/api/auth/login?scopes=mod"
]
);
}
#[test]
fn no_prompt_without_a_tty_and_nothing_when_complete() {
assert!(run(me(false, false), false, "y\n").1.is_empty());
assert!(missing_scopes(&me(true, true), "b").is_empty());
assert_eq!(missing_scopes(&me(true, false), "b").len(), 1);
}
fn serve(status: &'static str) -> String {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
std::thread::spawn(move || {
if let Ok((mut c, _)) = l.accept() {
let mut b = [0u8; 1024];
let _ = c.read(&mut b);
let _ = write!(
c,
"HTTP/1.1 {status}\r\ncontent-length: 2\r\nconnection: close\r\n\r\n{{}}"
);
}
});
format!("http://{addr}")
}
#[test]
fn only_a_clear_no_means_the_admin_token_expired() {
assert!(http::admin_token_rejected(&serve("401 Unauthorized"), "t"));
assert!(!http::admin_token_rejected(&serve("500 Oops"), "t"));
assert!(!http::admin_token_rejected(&serve("200 OK"), "t"));
assert!(!http::admin_token_rejected("http://127.0.0.1:1", "t"));
}
}