heatsync-tui 0.3.1

heat-sorted live multichat in the terminal — twitch, kick + youtube (read-only), real emotes, vim keys
//! tiny persisted config at ~/.config/heatsync/config (KEY=value lines). just
//! the tab-bar position for now. no toml dep — one key, hand-parsed.

use std::fs;
use std::path::PathBuf;

use heatsync_core::Platform;

/// where the channel tab bar lives. left/right are vertical (tabs stacked).
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TabPos {
    Top,
    Bottom,
    Left,
    Right,
}

impl TabPos {
    pub fn is_vertical(self) -> bool {
        matches!(self, TabPos::Left | TabPos::Right)
    }

    /// cycle order for the toggle key: top → right → bottom → left → top.
    pub fn next(self) -> TabPos {
        match self {
            TabPos::Top => TabPos::Right,
            TabPos::Right => TabPos::Bottom,
            TabPos::Bottom => TabPos::Left,
            TabPos::Left => TabPos::Top,
        }
    }

    fn as_str(self) -> &'static str {
        match self {
            TabPos::Top => "top",
            TabPos::Bottom => "bottom",
            TabPos::Left => "left",
            TabPos::Right => "right",
        }
    }

    fn parse(s: &str) -> Option<TabPos> {
        match s.trim() {
            "top" => Some(TabPos::Top),
            "bottom" => Some(TabPos::Bottom),
            "left" => Some(TabPos::Left),
            "right" => Some(TabPos::Right),
            _ => None,
        }
    }
}

pub struct Config {
    pub tab_pos: TabPos,
    /// dim `HH:MM` before each chat line (`timestamps=on`).
    pub timestamps: bool,
    /// open tabs, in order — restored on next launch (unless CLI args
    /// override). each tab is one or more `+`-merged (platform, channel) subs.
    pub channels: Vec<Vec<(Platform, String)>>,
    /// open virtual tabs (mentions / feed / whispers), in pin order.
    pub special: Vec<heatsync_core::TabKind>,
    /// blocked emotes: (key, name the block was filed under).
    pub blocks: Vec<(String, String)>,
}

/// serialize a channel as `twitch:name` / `kick:name` / `yt:videoid`.
fn chan_str(p: Platform, name: &str) -> String {
    let pfx = match p {
        Platform::Twitch => "twitch",
        Platform::Kick => "kick",
        Platform::Youtube => "yt",
    };
    format!("{pfx}:{name}")
}

/// comma-separated tabs; within a tab, '+' or whitespace both separate subs —
/// heals configs saved from a space-typed join prompt.
fn parse_channels(v: &str) -> Vec<Vec<(Platform, String)>> {
    v.split(',')
        .map(|tab| {
            tab.split(|c: char| c == '+' || c.is_whitespace())
                .filter_map(parse_chan)
                .collect()
        })
        .filter(|t: &Vec<_>| !t.is_empty())
        .collect()
}

/// comma-separated virtual tab names, pin order, no repeats; unknown names drop.
fn parse_special(v: &str) -> Vec<heatsync_core::TabKind> {
    let mut out: Vec<heatsync_core::TabKind> = Vec::new();
    for k in v.split(',').filter_map(heatsync_core::TabKind::parse) {
        if !out.contains(&k) {
            out.push(k);
        }
    }
    out.sort();
    out
}

/// `block=<key> <name>` — the name is optional (a block learned from the account).
fn parse_block(v: &str) -> Option<(String, String)> {
    let v = v.trim();
    let (k, n) = v.split_once(char::is_whitespace).unwrap_or((v, ""));
    (!k.is_empty()).then(|| (k.to_string(), n.trim().to_string()))
}

fn block_lines(blocks: &[(String, String)]) -> String {
    blocks
        .iter()
        .map(|(k, n)| format!("block={k} {n}\n"))
        .collect()
}

/// rewrite only the `block=` lines of the config, leaving the rest as it is.
/// for changes that land off the main thread (the account pull).
pub fn save_blocks(blocks: &[(String, String)]) {
    let Some(p) = path() else { return };
    let text = fs::read_to_string(&p).unwrap_or_default();
    let mut out: String = text
        .lines()
        .filter(|l| !l.starts_with("block="))
        .map(|l| format!("{l}\n"))
        .collect();
    out.push_str(&block_lines(blocks));
    if let Some(d) = p.parent() {
        let _ = fs::create_dir_all(d);
    }
    let _ = fs::write(&p, out);
}

/// parse one `twitch:name` / `kick:name` / `yt:id` / `name` token (bare = twitch).
fn parse_chan(s: &str) -> Option<(Platform, String)> {
    let s = s.trim();
    if s.is_empty() {
        return None;
    }
    if let Some(r) = s.strip_prefix("kick:") {
        Some((Platform::Kick, r.trim().to_string()))
    } else if let Some(r) = s.strip_prefix("yt:").or_else(|| s.strip_prefix("youtube:")) {
        Some((Platform::Youtube, r.trim().to_string()))
    } else if let Some(r) = s.strip_prefix("twitch:") {
        Some((Platform::Twitch, r.trim().to_string()))
    } else {
        Some((Platform::Twitch, s.to_string()))
    }
}

/// optional own-token fallbacks for sending without a heatsync login: twitch
/// direct IRC (TWITCH_USER / TWITCH_OAUTH, or ~/.config/heatsync/token) and a
/// kick token (KICK_TOKEN). `heatsync-tui login` is the way in; these stay for
/// people who already have them.
pub struct Auth {
    pub twitch_user: Option<String>,
    pub twitch_oauth: Option<String>,
    pub kick_token: Option<String>,
    /// admin session JWT for `heatsync status` (mellen's own account only).
    pub admin_token: Option<String>,
}

pub fn dir() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
    Some(base.join("heatsync"))
}

fn path() -> Option<PathBuf> {
    Some(dir()?.join("config"))
}

fn token_path() -> Option<PathBuf> {
    Some(dir()?.join("token"))
}

/// load twitch creds: env wins, then the token file. oauth `oauth:`/`#` prefixes
/// are stripped so the user can paste whatever a generator gives them.
pub fn load_auth() -> Auth {
    let mut user = std::env::var("TWITCH_USER").ok().filter(|s| !s.is_empty());
    let mut oauth = std::env::var("TWITCH_OAUTH").ok().filter(|s| !s.is_empty());
    let mut kick = std::env::var("KICK_TOKEN").ok().filter(|s| !s.is_empty());
    let mut admin = std::env::var("HEATSYNC_ADMIN_TOKEN")
        .ok()
        .filter(|s| !s.is_empty());
    if let Some(p) = token_path() {
        if let Ok(text) = fs::read_to_string(&p) {
            for line in text.lines() {
                if let Some((k, v)) = line.split_once('=') {
                    let v = v.trim().to_string();
                    match k.trim() {
                        "twitch_user" if user.is_none() && !v.is_empty() => user = Some(v),
                        "twitch_oauth" if oauth.is_none() && !v.is_empty() => oauth = Some(v),
                        "kick_token" if kick.is_none() && !v.is_empty() => kick = Some(v),
                        "admin_token" if admin.is_none() && !v.is_empty() => admin = Some(v),
                        _ => {}
                    }
                }
            }
        }
    }
    let oauth = oauth.map(|o| {
        o.trim_start_matches("oauth:")
            .trim_start_matches('#')
            .to_string()
    });
    Auth {
        twitch_user: user,
        twitch_oauth: oauth,
        kick_token: kick,
        admin_token: admin,
    }
}

pub fn load() -> Config {
    let mut cfg = Config {
        tab_pos: TabPos::Top,
        timestamps: false,
        channels: Vec::new(),
        special: Vec::new(),
        blocks: Vec::new(),
    };
    if let Some(p) = path() {
        if let Ok(text) = fs::read_to_string(&p) {
            for line in text.lines() {
                if let Some((k, v)) = line.split_once('=') {
                    match k.trim() {
                        "tab_pos" => {
                            if let Some(tp) = TabPos::parse(v) {
                                cfg.tab_pos = tp;
                            }
                        }
                        "timestamps" => cfg.timestamps = matches!(v.trim(), "on" | "true" | "1"),
                        "channels" => cfg.channels = parse_channels(v),
                        "special" => cfg.special = parse_special(v),
                        "block" => cfg.blocks.extend(parse_block(v)),
                        _ => {}
                    }
                }
            }
        }
    }
    cfg
}

/// sent-line history, one per line, oldest first.
fn history_path() -> Option<PathBuf> {
    Some(dir()?.join("history"))
}

/// whispers and dms are private: they never reach the disk.
fn private_line(l: &str) -> bool {
    let w = l.split_whitespace().next().unwrap_or("");
    matches!(w, "/w" | "/dm" | "/whisper" | "/msg")
}

pub fn load_history() -> Vec<String> {
    let text = history_path()
        .and_then(|p| fs::read_to_string(p).ok())
        .unwrap_or_default();
    text.lines()
        .filter(|l| !l.trim().is_empty() && !private_line(l))
        .map(String::from)
        .collect()
}

/// best-effort, newest `HISTORY_CAP` only, owner-only file.
pub fn save_history(lines: &[String]) {
    let Some(p) = history_path() else { return };
    if let Some(d) = p.parent() {
        let _ = fs::create_dir_all(d);
    }
    let keep: Vec<&str> = lines
        .iter()
        .filter(|l| !private_line(l))
        .map(String::as_str)
        .collect();
    let from = keep.len().saturating_sub(heatsync_core::edit::HISTORY_CAP);
    let mut out = keep[from..].join("\n");
    out.push('\n');
    use std::io::Write;
    let mut o = fs::OpenOptions::new();
    o.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        o.mode(0o600);
    }
    if let Ok(mut f) = o.open(&p) {
        let _ = f.write_all(out.as_bytes());
    }
}

/// best-effort persist (creates the dir). failures are non-fatal.
pub fn save(cfg: &Config) {
    if let Some(p) = path() {
        if let Some(dir) = p.parent() {
            let _ = fs::create_dir_all(dir);
        }
        let mut out = format!("tab_pos={}\n", cfg.tab_pos.as_str());
        if cfg.timestamps {
            out.push_str("timestamps=on\n");
        }
        if !cfg.channels.is_empty() {
            let joined = cfg
                .channels
                .iter()
                .map(|tab| {
                    tab.iter()
                        .map(|(p, n)| chan_str(*p, n))
                        .collect::<Vec<_>>()
                        .join("+")
                })
                .collect::<Vec<_>>()
                .join(",");
            out.push_str(&format!("channels={joined}\n"));
        }
        if !cfg.special.is_empty() {
            let names: Vec<&str> = cfg.special.iter().map(|k| k.name()).collect();
            out.push_str(&format!("special={}\n", names.join(",")));
        }
        out.push_str(&block_lines(&cfg.blocks));
        let _ = fs::write(&p, out);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn block_lines_round_trip_with_and_without_a_name() {
        let blocks = vec![
            ("aHR0cHM6Ly94".to_string(), "Kappa".to_string()),
            ("c3luY2Vk".to_string(), String::new()),
        ];
        let text = block_lines(&blocks);
        let back: Vec<_> = text
            .lines()
            .filter_map(|l| parse_block(l.strip_prefix("block=")?))
            .collect();
        assert_eq!(back, blocks);
        assert_eq!(parse_block("  "), None);
    }

    #[test]
    fn whispers_and_dms_never_reach_history_on_disk() {
        assert!(private_line("/w bob hi"));
        assert!(private_line("/dm bob hi"));
        assert!(!private_line("/me waves"));
        assert!(!private_line("hello /w"));
    }

    #[test]
    fn special_tabs_parse_sorted_deduped_and_drop_unknowns() {
        use heatsync_core::TabKind::*;
        assert_eq!(
            parse_special("feed, mentions,feed,bogus,"),
            vec![Mentions, Feed]
        );
        assert_eq!(parse_special("whispers,mentions"), vec![Mentions, Whispers]);
        assert!(parse_special("").is_empty());
        assert!(parse_special("channel").is_empty());
    }

    #[test]
    fn space_typed_spec_heals_into_merge() {
        let got =
            parse_channels("kick:xqc+twitch:xqc,twitch:nl_kripp kick:nl_kripp yt:4tDC0sKhTnA");
        assert_eq!(
            got,
            vec![
                vec![
                    (Platform::Kick, "xqc".to_string()),
                    (Platform::Twitch, "xqc".to_string()),
                ],
                vec![
                    (Platform::Twitch, "nl_kripp".to_string()),
                    (Platform::Kick, "nl_kripp".to_string()),
                    (Platform::Youtube, "4tDC0sKhTnA".to_string()),
                ],
            ]
        );
    }
}