use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Instant;
use anyhow::Result;
use chrono::{DateTime, Local};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::DefaultTerminal;
use ratatui::widgets::TableState;
use tokio::sync::Notify;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use crate::alphai::{self, Article, InsiderSummary, SentimentSummary};
use crate::config::{self, Config};
use crate::domain::{Interval, Range, TickerData};
use crate::poller::{SharedParams, SharedSource, SourceEvent};
use crate::source::make_source;
use crate::ui;
pub struct NewsBundle {
pub articles: Vec<Article>,
pub sentiment: Option<SentimentSummary>,
pub next_cursor: Option<String>,
pub page_error: Option<String>,
pub gated: bool,
pub fetched: Instant,
}
impl NewsBundle {
pub fn new(
articles: Vec<Article>,
sentiment: Option<SentimentSummary>,
next_cursor: Option<String>,
) -> Self {
Self {
articles,
sentiment,
next_cursor,
page_error: None,
gated: false,
fetched: Instant::now(),
}
}
}
pub struct InsiderBundle {
pub articles: Vec<Article>,
pub summary: Option<InsiderSummary>,
pub next_cursor: Option<String>,
pub page_error: Option<String>,
pub gated: bool,
pub fetched: Instant,
}
impl InsiderBundle {
pub fn new(
articles: Vec<Article>,
summary: Option<InsiderSummary>,
next_cursor: Option<String>,
) -> Self {
Self {
articles,
summary,
next_cursor,
page_error: None,
gated: false,
fetched: Instant::now(),
}
}
}
#[derive(Default)]
pub struct SettingsState {
pub open: bool,
pub first_run: bool,
pub cursor: usize,
pub editing: bool,
pub input: String,
pub source_choice: String,
pub finnhub_key: String,
pub alphai_key: String,
pub alpaca_key_id: String,
pub alpaca_secret: String,
pub news_open_choice: String,
pub message: Option<String>,
}
pub const SETTINGS_ROWS: usize = 7;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChartStyle {
Candles,
Line,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NewsScope {
#[default]
Ticker,
Market,
Trending,
}
impl NewsScope {
pub fn next(self) -> Self {
match self {
Self::Ticker => Self::Market,
Self::Market => Self::Trending,
Self::Trending => Self::Ticker,
}
}
pub fn label(self, symbol: &str) -> &str {
match self {
Self::Ticker => symbol,
Self::Market => "market",
Self::Trending => "trending",
}
}
}
#[derive(Default)]
pub struct ArticleOverlay {
pub open: bool,
pub scroll: u16,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NewsLayout {
#[default]
Side,
Stacked,
}
impl NewsLayout {
pub fn next(self) -> Self {
match self {
Self::Side => Self::Stacked,
Self::Stacked => Self::Side,
}
}
}
pub const RANGE_PRESETS: [(Range, Interval); 5] = [
(Range::D1, Interval::M5),
(Range::D5, Interval::M15),
(Range::Mo1, Interval::M60),
(Range::Mo6, Interval::D1),
(Range::Y1, Interval::D1),
];
fn next_preset(cur: (Range, Interval), dir: isize) -> (Range, Interval) {
let n = RANGE_PRESETS.len() as isize;
match RANGE_PRESETS.iter().position(|&p| p == cur) {
Some(i) => RANGE_PRESETS[((i as isize + dir).rem_euclid(n)) as usize],
None if dir > 0 => RANGE_PRESETS[0],
None => RANGE_PRESETS[RANGE_PRESETS.len() - 1],
}
}
pub struct AppInit {
pub symbols: Vec<String>,
pub source: SharedSource,
pub source_name: &'static str,
pub range: Range,
pub interval: Interval,
pub params: SharedParams,
pub rx: UnboundedReceiver<SourceEvent>,
pub refresh: Arc<Notify>,
pub alphai_tx: UnboundedSender<alphai::Cmd>,
pub config: Config,
pub alphai_enabled: bool,
pub first_run: bool,
}
pub struct App {
pub symbols: Vec<String>,
pub data: HashMap<String, TickerData>,
pub errors: HashMap<String, String>,
pub selected: usize,
pub view_idx: usize,
pub source_name: &'static str,
pub range: Range,
pub interval: Interval,
pub last_update: Option<DateTime<Local>>,
pub table_state: TableState,
pub chart_style: ChartStyle,
pub show_sma: bool,
pub show_rsi: bool,
pub news: HashMap<String, NewsBundle>,
pub insider: HashMap<String, InsiderBundle>,
pub alphai_errors: HashMap<String, String>,
pub alphai_enabled: bool,
pub news_selected: usize,
pub news_scope: NewsScope,
pub news_layout: NewsLayout,
pub card_scroll: u16,
pub news_table_state: TableState,
pub article_overlay: ArticleOverlay,
pub settings: SettingsState,
pub config: Config,
source: SharedSource,
params: SharedParams,
inflight: HashSet<String>,
alphai_tx: UnboundedSender<alphai::Cmd>,
rx: UnboundedReceiver<SourceEvent>,
refresh: Arc<Notify>,
}
impl App {
pub fn new(init: AppInit) -> Self {
let mut app = Self {
symbols: init.symbols,
data: HashMap::new(),
errors: HashMap::new(),
selected: 0,
view_idx: 0,
source_name: init.source_name,
range: init.range,
interval: init.interval,
last_update: None,
table_state: TableState::default(),
chart_style: ChartStyle::Candles,
show_sma: true,
show_rsi: true,
news: HashMap::new(),
insider: HashMap::new(),
alphai_errors: HashMap::new(),
alphai_enabled: init.alphai_enabled,
news_selected: 0,
news_scope: NewsScope::default(),
news_layout: NewsLayout::default(),
card_scroll: 0,
news_table_state: TableState::default(),
article_overlay: ArticleOverlay::default(),
settings: SettingsState::default(),
config: init.config,
source: init.source,
params: init.params,
inflight: HashSet::new(),
alphai_tx: init.alphai_tx,
rx: init.rx,
refresh: init.refresh,
};
if init.first_run {
app.open_settings();
app.settings.first_run = true;
}
app
}
pub fn selected_symbol(&self) -> &str {
&self.symbols[self.selected]
}
pub fn news_cache_key(&self) -> String {
match self.news_scope {
NewsScope::Ticker => self.selected_symbol().to_string(),
NewsScope::Market => alphai::MARKET_KEY.to_string(),
NewsScope::Trending => alphai::TRENDING_KEY.to_string(),
}
}
pub(crate) fn is_loading(&self, key: &str) -> bool {
self.inflight.contains(key)
}
pub fn visible_articles(&self) -> Option<&[Article]> {
match self.view_idx {
ui::VIEW_NEWS => self
.news
.get(&self.news_cache_key())
.map(|b| b.articles.as_slice()),
ui::VIEW_INSIDER => self
.insider
.get(self.selected_symbol())
.map(|b| b.articles.as_slice()),
_ => None,
}
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
loop {
while let Ok(ev) = self.rx.try_recv() {
self.apply(ev);
}
self.ensure_alphai_data();
terminal.draw(|f| ui::draw(f, self))?;
if event::poll(std::time::Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& self.handle_key(key)
{
return Ok(());
}
}
}
fn apply(&mut self, event: SourceEvent) {
match event {
SourceEvent::Data { symbol, data } => {
self.errors.remove(&symbol);
self.data.insert(symbol, data);
self.last_update = Some(Local::now());
}
SourceEvent::Error { symbol, error } => {
self.errors.insert(symbol, error);
self.last_update = Some(Local::now());
}
SourceEvent::Alphai(ev) => self.apply_alphai(ev),
}
}
pub(crate) fn apply_alphai(&mut self, event: alphai::Event) {
match event {
alphai::Event::News { key, articles, sentiment, next_cursor, append } => {
self.inflight.remove(&key);
self.alphai_errors.remove(&key);
match self.news.get_mut(&key) {
Some(b) if append => {
b.next_cursor = next_cursor;
b.page_error = None;
append_page(&mut b.articles, articles);
}
_ => {
self.news.insert(key, NewsBundle::new(articles, sentiment, next_cursor));
}
}
}
alphai::Event::Insider { symbol, articles, summary, next_cursor, append } => {
let key = alphai::insider_key(&symbol);
self.inflight.remove(&key);
self.alphai_errors.remove(&key);
match self.insider.get_mut(&symbol) {
Some(b) if append => {
b.next_cursor = next_cursor;
b.page_error = None;
append_page(&mut b.articles, articles);
}
_ => {
self.insider
.insert(symbol, InsiderBundle::new(articles, summary, next_cursor));
}
}
}
alphai::Event::PageError { key, error, gated } => {
self.inflight.remove(&key);
let (page_error, bundle_gated, next_cursor) =
match key.strip_prefix("ins:") {
Some(symbol) => match self.insider.get_mut(symbol) {
Some(b) => (&mut b.page_error, &mut b.gated, &mut b.next_cursor),
None => return,
},
None => match self.news.get_mut(&key) {
Some(b) => (&mut b.page_error, &mut b.gated, &mut b.next_cursor),
None => return,
},
};
*page_error = Some(error);
if gated {
*bundle_gated = true;
*next_cursor = None;
}
}
alphai::Event::Error { key, error } => {
self.inflight.remove(&key);
self.alphai_errors.insert(key, error);
}
}
}
pub(crate) fn ensure_alphai_data(&mut self) {
if !self.alphai_enabled
|| self.settings.open
|| self.article_overlay.open
|| self.symbols.is_empty()
{
return;
}
let at_top = self.news_selected == 0;
match self.view_idx {
ui::VIEW_NEWS | ui::VIEW_SPLIT => {
let key = self.news_cache_key();
let stale = match self.news.get(&key) {
None => true,
Some(b) => at_top && b.fetched.elapsed() > alphai::CACHE_TTL,
};
if stale && !self.inflight.contains(&key) && !self.alphai_errors.contains_key(&key)
{
self.inflight.insert(key);
let cmd = match self.news_scope {
NewsScope::Ticker => alphai::Cmd::FetchNews {
symbol: Some(self.selected_symbol().to_string()),
cursor: None,
},
NewsScope::Market => {
alphai::Cmd::FetchNews { symbol: None, cursor: None }
}
NewsScope::Trending => alphai::Cmd::FetchTrending,
};
let _ = self.alphai_tx.send(cmd);
}
}
ui::VIEW_INSIDER => {
let symbol = self.selected_symbol().to_string();
let key = alphai::insider_key(&symbol);
let stale = match self.insider.get(&symbol) {
None => true,
Some(b) => at_top && b.fetched.elapsed() > alphai::CACHE_TTL,
};
if stale && !self.inflight.contains(&key) && !self.alphai_errors.contains_key(&key)
{
self.inflight.insert(key);
let _ = self
.alphai_tx
.send(alphai::Cmd::FetchInsider { symbol, cursor: None });
}
}
_ => {}
}
}
fn request_more_articles(&mut self) {
let (key, cmd) = match self.view_idx {
ui::VIEW_NEWS => {
let key = self.news_cache_key();
let Some(cursor) = self.news.get(&key).and_then(|b| b.next_cursor.clone()) else {
return;
};
let symbol = (self.news_scope == NewsScope::Ticker)
.then(|| self.selected_symbol().to_string());
(key, alphai::Cmd::FetchNews { symbol, cursor: Some(cursor) })
}
ui::VIEW_INSIDER => {
let symbol = self.selected_symbol().to_string();
let key = alphai::insider_key(&symbol);
let Some(cursor) = self
.insider
.get(&symbol)
.and_then(|b| b.next_cursor.clone())
else {
return;
};
(key, alphai::Cmd::FetchInsider { symbol, cursor: Some(cursor) })
}
_ => return,
};
if self.inflight.contains(&key) {
return;
}
self.inflight.insert(key);
let _ = self.alphai_tx.send(cmd);
}
pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
if self.settings.open {
return self.handle_settings_key(key);
}
if self.article_overlay.open {
return self.handle_overlay_key(key);
}
let news_view = matches!(self.view_idx, ui::VIEW_NEWS | ui::VIEW_INSIDER);
let chart_view = matches!(self.view_idx, ui::VIEW_CHART | ui::VIEW_SPLIT);
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return true,
KeyCode::Tab => self.switch_view((self.view_idx + 1) % ui::VIEWS.len()),
KeyCode::BackTab => {
self.switch_view((self.view_idx + ui::VIEWS.len() - 1) % ui::VIEWS.len())
}
KeyCode::Char(c @ '1'..='9') => {
let idx = c as usize - '1' as usize;
if idx < ui::VIEWS.len() {
self.switch_view(idx);
}
}
KeyCode::Char('s') => self.open_settings(),
KeyCode::Char('r') => self.manual_refresh(),
KeyCode::Up | KeyCode::Char('k') if news_view => {
self.news_selected = self.news_selected.saturating_sub(1);
self.card_scroll = 0;
}
KeyCode::Down | KeyCode::Char('j') if news_view => {
let max = self.visible_articles().map_or(0, <[Article]>::len);
if self.news_selected + 1 < max {
self.news_selected += 1;
self.card_scroll = 0;
} else {
self.request_more_articles();
}
}
KeyCode::Left | KeyCode::Char('h') if news_view => {
self.selected = self.selected.saturating_sub(1);
self.news_selected = 0;
self.card_scroll = 0;
}
KeyCode::Right | KeyCode::Char('l') if news_view => {
self.selected = (self.selected + 1).min(self.symbols.len() - 1);
self.news_selected = 0;
self.card_scroll = 0;
}
KeyCode::PageUp if self.view_idx == ui::VIEW_NEWS => {
self.card_scroll = self.card_scroll.saturating_sub(10)
}
KeyCode::PageDown if self.view_idx == ui::VIEW_NEWS => {
self.card_scroll = self.card_scroll.saturating_add(10)
}
KeyCode::Char('x') if self.view_idx == ui::VIEW_NEWS => {
self.news_layout = self.news_layout.next();
self.card_scroll = 0;
}
KeyCode::Enter | KeyCode::Char('o') if news_view => {
if let Some(a) = self
.visible_articles()
.and_then(|list| list.get(self.news_selected))
{
open_url(&self.article_url(a));
}
}
KeyCode::Char('v')
if news_view && self.visible_articles().is_some_and(|list| !list.is_empty()) =>
{
self.article_overlay = ArticleOverlay { open: true, scroll: 0 };
}
KeyCode::Char('f') if matches!(self.view_idx, ui::VIEW_NEWS | ui::VIEW_SPLIT) => {
self.news_scope = self.news_scope.next();
self.news_selected = 0;
self.card_scroll = 0;
}
KeyCode::Char('c') if chart_view => {
self.chart_style = match self.chart_style {
ChartStyle::Candles => ChartStyle::Line,
ChartStyle::Line => ChartStyle::Candles,
}
}
KeyCode::Char('m') if chart_view => self.show_sma = !self.show_sma,
KeyCode::Char('i') if chart_view => self.show_rsi = !self.show_rsi,
KeyCode::Char('t') => self.cycle_range(1),
KeyCode::Char('T') => self.cycle_range(-1),
KeyCode::Up | KeyCode::Char('k') => self.selected = self.selected.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => {
self.selected = (self.selected + 1).min(self.symbols.len() - 1)
}
_ => {}
}
false
}
fn handle_overlay_key(&mut self, key: KeyEvent) -> bool {
match key.code {
KeyCode::Char('q') => return true,
KeyCode::Esc | KeyCode::Char('v') => self.article_overlay = ArticleOverlay::default(),
KeyCode::Up | KeyCode::Char('k') => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_add(1)
}
KeyCode::PageUp => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_sub(10)
}
KeyCode::PageDown => {
self.article_overlay.scroll = self.article_overlay.scroll.saturating_add(10)
}
KeyCode::Enter | KeyCode::Char('o') => {
if let Some(a) = self
.visible_articles()
.and_then(|list| list.get(self.news_selected))
{
open_url(&self.article_url(a));
}
}
_ => {}
}
false
}
fn article_url(&self, a: &Article) -> String {
let url = if self.view_idx == ui::VIEW_NEWS && !self.config.news_open_original() {
a.alphai_url().unwrap_or_else(|| a.original.url.clone())
} else {
a.original.url.clone()
};
with_utm(&url)
}
fn switch_view(&mut self, idx: usize) {
if idx != self.view_idx {
self.view_idx = idx;
self.news_selected = 0;
self.card_scroll = 0;
}
}
fn cycle_range(&mut self, dir: isize) {
let (range, interval) = next_preset((self.range, self.interval), dir);
self.range = range;
self.interval = interval;
*self.params.write().unwrap() = (range, interval);
self.refresh.notify_one();
}
fn manual_refresh(&mut self) {
self.refresh.notify_one();
match self.view_idx {
ui::VIEW_NEWS | ui::VIEW_SPLIT => {
let key = self.news_cache_key();
self.news.remove(&key);
self.alphai_errors.remove(&key);
}
ui::VIEW_INSIDER => {
let symbol = self.selected_symbol().to_string();
self.insider.remove(&symbol);
self.alphai_errors.remove(&alphai::insider_key(&symbol));
}
_ => {}
}
}
pub fn open_settings(&mut self) {
let s = &mut self.settings;
s.open = true;
s.cursor = 0;
s.editing = false;
s.message = None;
s.source_choice = self.source_name.to_string();
s.finnhub_key = self.config.keys.finnhub.clone().unwrap_or_default();
s.alphai_key = self.config.keys.alphai.clone().unwrap_or_default();
s.alpaca_key_id = self.config.keys.alpaca_key_id.clone().unwrap_or_default();
s.alpaca_secret = self.config.keys.alpaca_secret.clone().unwrap_or_default();
s.news_open_choice = if self.config.news_open_original() {
"original".to_string()
} else {
"alphai".to_string()
};
}
fn handle_settings_key(&mut self, key: KeyEvent) -> bool {
if self.settings.editing {
let s = &mut self.settings;
match key.code {
KeyCode::Enter => {
let value = s.input.trim().to_string();
match s.cursor {
1 => s.finnhub_key = value,
2 => s.alphai_key = value,
3 => s.alpaca_key_id = value,
4 => s.alpaca_secret = value,
_ => {}
}
s.editing = false;
}
KeyCode::Esc => s.editing = false,
KeyCode::Backspace => {
s.input.pop();
}
KeyCode::Char(c) if !c.is_control() && !c.is_whitespace() => s.input.push(c),
_ => {}
}
return false;
}
match key.code {
KeyCode::Char('q') => return true,
KeyCode::Esc => {
self.settings.open = false;
self.settings.first_run = false;
}
KeyCode::Up | KeyCode::Char('k') => {
self.settings.cursor = self.settings.cursor.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
self.settings.cursor = (self.settings.cursor + 1).min(SETTINGS_ROWS - 1)
}
KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')
if self.settings.cursor == 0 =>
{
self.toggle_source_choice()
}
KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')
if self.settings.cursor == 5 =>
{
self.toggle_news_open_choice()
}
KeyCode::Enter => match self.settings.cursor {
0 => self.toggle_source_choice(),
5 => self.toggle_news_open_choice(),
1..=4 => {
let s = &mut self.settings;
s.input = match s.cursor {
1 => s.finnhub_key.clone(),
2 => s.alphai_key.clone(),
3 => s.alpaca_key_id.clone(),
_ => s.alpaca_secret.clone(),
};
s.editing = true;
}
_ => self.settings_save(),
},
_ => {}
}
false
}
fn toggle_source_choice(&mut self) {
let s = &mut self.settings;
s.source_choice = next_source(&s.source_choice).to_string();
}
fn toggle_news_open_choice(&mut self) {
let s = &mut self.settings;
s.news_open_choice = if s.news_open_choice == "original" {
"alphai".to_string()
} else {
"original".to_string()
};
}
fn settings_save(&mut self) {
let mut cfg = self.config.clone();
cfg.source = Some(self.settings.source_choice.clone());
cfg.keys.finnhub = non_empty(&self.settings.finnhub_key);
cfg.keys.alphai = non_empty(&self.settings.alphai_key);
cfg.keys.alpaca_key_id = non_empty(&self.settings.alpaca_key_id);
cfg.keys.alpaca_secret = non_empty(&self.settings.alpaca_secret);
cfg.news_open = Some(self.settings.news_open_choice.clone());
cfg.watchlist = self.symbols.clone();
let source_changed = !self
.settings
.source_choice
.eq_ignore_ascii_case(self.source_name)
|| (self.settings.source_choice == "finnhub"
&& cfg.keys.finnhub != self.config.keys.finnhub)
|| (self.settings.source_choice == "alpaca"
&& (cfg.keys.alpaca_key_id != self.config.keys.alpaca_key_id
|| cfg.keys.alpaca_secret != self.config.keys.alpaca_secret));
if source_changed {
match make_source(
&self.settings.source_choice,
cfg.finnhub_key().as_deref(),
cfg.alpaca_keys(),
) {
Ok(src) => {
self.source_name = src.name();
*self.source.write().unwrap() = src;
self.data.clear();
self.errors.clear();
self.refresh.notify_one();
}
Err(e) => {
self.settings.message = Some(format!("{e:#}"));
return;
}
}
}
if cfg.alphai_key() != self.config.alphai_key() {
let key = cfg.alphai_key();
self.alphai_enabled = key.is_some();
let _ = self.alphai_tx.send(alphai::Cmd::SetKey(key));
self.news.clear();
self.insider.clear();
self.alphai_errors.clear();
self.inflight.clear();
}
match config::save(&cfg) {
Ok(_) => {
self.config = cfg;
self.settings.open = false;
self.settings.first_run = false;
}
Err(e) => {
self.config = cfg;
self.settings.message = Some(format!("could not write config: {e:#}"));
}
}
}
}
fn append_page(articles: &mut Vec<Article>, page: Vec<Article>) {
let seen: HashSet<String> = articles
.iter()
.map(|a| a.original.uid.clone())
.filter(|uid| !uid.is_empty())
.collect();
articles.extend(
page.into_iter()
.filter(|a| a.original.uid.is_empty() || !seen.contains(&a.original.uid)),
);
}
fn next_source(cur: &str) -> &'static str {
match cur {
"yahoo" => "finnhub",
"finnhub" => "alpaca",
_ => "yahoo",
}
}
fn non_empty(v: &str) -> Option<String> {
let v = v.trim();
(!v.is_empty()).then(|| v.to_string())
}
fn with_utm(url: &str) -> String {
if url.contains("utm_source=") {
return url.to_string();
}
let (base, frag) = match url.split_once('#') {
Some((base, frag)) => (base, Some(frag)),
None => (url, None),
};
let sep = if base.contains('?') { '&' } else { '?' };
match frag {
Some(frag) => format!("{base}{sep}utm_source=alphai-tui#{frag}"),
None => format!("{base}{sep}utm_source=alphai-tui"),
}
}
pub fn open_url(url: &str) {
if !(url.starts_with("https://") || url.starts_with("http://")) {
return;
}
#[cfg(target_os = "macos")]
let mut cmd = std::process::Command::new("open");
#[cfg(all(unix, not(target_os = "macos")))]
let mut cmd = std::process::Command::new("xdg-open");
#[cfg(target_os = "windows")]
let mut cmd = {
let mut c = std::process::Command::new("cmd");
c.args(["/C", "start", ""]);
c
};
let _ = cmd
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
}
#[cfg(test)]
mod tests {
use super::{RANGE_PRESETS, next_preset, next_source, with_utm};
use crate::domain::{Interval, Range};
#[test]
fn source_cycle_covers_all_and_wraps() {
assert_eq!(next_source("yahoo"), "finnhub");
assert_eq!(next_source("finnhub"), "alpaca");
assert_eq!(next_source("alpaca"), "yahoo");
assert_eq!(next_source("weird"), "yahoo");
}
#[test]
fn range_presets_wrap_both_ways() {
let first = RANGE_PRESETS[0];
let last = RANGE_PRESETS[RANGE_PRESETS.len() - 1];
assert_eq!(next_preset(first, 1), RANGE_PRESETS[1]);
assert_eq!(next_preset(last, 1), first);
assert_eq!(next_preset(first, -1), last);
}
#[test]
fn utm_tag_appended_to_plain_and_query_urls() {
assert_eq!(
with_utm("https://alphai.io/news/article/07-10/abc/slug"),
"https://alphai.io/news/article/07-10/abc/slug?utm_source=alphai-tui"
);
assert_eq!(
with_utm("https://example.com/story?id=7"),
"https://example.com/story?id=7&utm_source=alphai-tui"
);
}
#[test]
fn utm_tag_respects_existing_source_and_fragment() {
let tagged = "https://example.com/story?utm_source=newsletter";
assert_eq!(with_utm(tagged), tagged);
assert_eq!(
with_utm("https://example.com/story#section"),
"https://example.com/story?utm_source=alphai-tui#section"
);
}
#[test]
fn range_presets_absorb_unknown_startup_combo() {
let odd = (Range::Mo3, Interval::M5);
assert_eq!(next_preset(odd, 1), RANGE_PRESETS[0]);
assert_eq!(next_preset(odd, -1), RANGE_PRESETS[RANGE_PRESETS.len() - 1]);
}
}