use std::fs;
use std::path::PathBuf;
use heatsync_core::Platform;
#[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)
}
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,
pub timestamps: bool,
pub channels: Vec<Vec<(Platform, String)>>,
pub special: Vec<heatsync_core::TabKind>,
pub blocks: Vec<(String, String)>,
pub lang: String,
}
fn chan_str(p: Platform, name: &str) -> String {
let pfx = match p {
Platform::Twitch => "twitch",
Platform::Kick => "kick",
Platform::Youtube => "yt",
};
format!("{pfx}:{name}")
}
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()
}
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
}
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()
}
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);
}
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()))
}
}
pub struct Auth {
pub twitch_user: Option<String>,
pub twitch_oauth: Option<String>,
pub kick_token: Option<String>,
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"))
}
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(),
lang: "auto".into(),
};
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)),
"lang" => cfg.lang = v.trim().to_string(),
_ => {}
}
}
}
}
}
cfg
}
pub fn init_lang() {
let mut setting = "auto".to_string();
if let Some(text) = path().and_then(|p| fs::read_to_string(p).ok()) {
for l in text.lines() {
if let Some(("lang", v)) = l.split_once('=').map(|(k, v)| (k.trim(), v)) {
setting = v.trim().to_string();
}
}
}
crate::lang::apply(&setting);
}
pub fn lang_setting() -> String {
crate::lang::setting()
}
fn history_path() -> Option<PathBuf> {
Some(dir()?.join("history"))
}
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()
}
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());
}
}
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.lang.is_empty() && cfg.lang != "auto" {
out.push_str(&format!("lang={}\n", cfg.lang));
}
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()),
],
]
);
}
}