Ctrl K
ComunidadGuías…

src/config.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
use std::collections::BTreeMap;
use std::ops::RangeInclusive;
use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result};
use clap::ValueEnum;
use serde::{Deserialize, Serialize};

use crate::alphai;
use crate::app::{ChartStyle, InsiderChartWindow, NewsLayout, NewsScope, RANGE_PRESETS};
use crate::category::{self, Categories};
use crate::domain::{Interval, Range};
use crate::indicators::{self, MaType};
use crate::keymap::Keymap;
use crate::portfolio::Position;
use crate::theme::{Panels, Theme};
use crate::ui;

/// Used when neither the CLI nor the config file provides symbols.
pub const DEFAULT_WATCHLIST: [&str; 4] = ["AAPL", "MSFT", "NVDA", "BTC-USD"];

/// One credential a price source (or the app itself) needs. `config_name`
/// is the field name inside `[keys]` in config.toml; existing names are
/// frozen, renaming one would orphan the key in users' files. The env var
/// wins over the file at resolution time.
pub struct KeyField {
    pub config_name: &'static str,
    pub env_var: &'static str,
    /// Row label in the settings screen.
    pub label: &'static str,
}

/// The AlphAI news key: app-level rather than a price source, but it lives
/// in the same `[keys]` table and the same settings list.
pub const ALPHAI_KEY_FIELD: KeyField = KeyField {
    config_name: "alphai",
    env_var: "ALPHAI_API_KEY",
    label: "AlphAI key",
};

/// Persisted app settings. Precedence at use time: CLI args > env vars
/// (for API keys) > this file > built-in defaults.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
    pub source: Option<String>,
    pub watchlist: Vec<String>,
    pub every: Option<u64>,
    pub range: Option<String>,
    pub interval: Option<String>,
    /// Whether a source that stops answering is swapped for one that still
    /// does (default true). Yahoo blocks by IP for tens of minutes and no
    /// retry can shorten that, so the only repair is another provider; set
    /// it false to keep the errors and choose by hand.
    pub source_fallback: Option<bool>,
    /// Where pre and post market prices come from: "auto" (the default,
    /// also when absent), "same", "alpaca" or "yahoo". See
    /// `source::extended::ExtendedSource`.
    pub extended_source: Option<String>,
    /// Where Enter opens a news article: "alphai" (article page on
    /// alphai.io, the default) or "original" (the source site).
    pub news_open: Option<String>,
    /// API keys by `KeyField::config_name`. A map rather than a struct so a
    /// key this binary does not know (say, a config written by a newer
    /// version) survives a load -> save round trip instead of being dropped.
    pub keys: BTreeMap<String, String>,
    /// `[ui]` startup defaults (view, news layout and scope).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ui: Option<UiConfig>,
    /// `[chart]` startup defaults and indicator parameters.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub chart: Option<ChartConfig>,
    /// `[theme]` color overrides by slot name (see `theme::Theme`). Kept as
    /// raw strings: validation happens in `resolve`, per slot, so one typo
    /// never degrades the whole file.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub theme: Option<BTreeMap<String, String>>,
    /// `[keybindings]` overrides by action name (see `keymap::ACTIONS`).
    /// Raw specs; `resolve` validates per entry, so one bad key never
    /// degrades the whole file.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub keybindings: Option<BTreeMap<String, KeysSpec>>,
    /// `[[positions]]`: what the user holds, one entry per ticker, with
    /// the quantity and the average price paid. Raw entries, validated in
    /// `resolve` per entry like every other section. Declared last on
    /// purpose: `toml` serializes in field order, and a scalar written
    /// after an array of tables would be parsed back as part of it.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub positions: Vec<PositionEntry>,
}

impl Config {
    /// Writes one holding into `[[positions]]`, over the entry for the
    /// same ticker in whatever case it was typed, or at the end. Fields
    /// this binary does not know stay with the entry.
    pub fn set_position(&mut self, position: &Position) {
        let entry = PositionEntry::from(position);
        match self
            .positions
            .iter_mut()
            .find(|e| e.is_for(&position.symbol))
        {
            Some(slot) => {
                *slot = PositionEntry {
                    other: std::mem::take(&mut slot.other),
                    ..entry
                }
            }
            None => self.positions.push(entry),
        }
    }

    /// Drops every entry for the ticker: a duplicate left behind would
    /// bring the holding back on the next start.
    pub fn clear_position(&mut self, symbol: &str) {
        self.positions.retain(|e| !e.is_for(symbol));
    }
}

/// One change a keypress makes to the file: `a`, `d` or `p`. Kept as a
/// change rather than as the resulting list, so it can be applied to the
/// file as another process left it, and replayed after a failed write.
#[derive(Clone, Debug, PartialEq)]
pub enum Edit {
    Watch(String),
    Unwatch(String),
    Hold(Position),
    Clear(String),
}

impl Edit {
    /// Each edit sets the state of one ticker, so replaying a run of them
    /// over a file that already has some comes out the same. `live` is the
    /// watchlist on screen: an empty list in the file means the built-in
    /// default, so the screen's list is what gets written, and the same
    /// goes for a list an edit would empty, since the screen always holds
    /// at least one ticker.
    pub fn apply(&self, cfg: &mut Config, live: &[String]) {
        match self {
            Self::Hold(position) => return cfg.set_position(position),
            Self::Clear(symbol) => return cfg.clear_position(symbol),
            Self::Watch(_) | Self::Unwatch(_) if cfg.watchlist.is_empty() => {
                cfg.watchlist = live.to_vec();
                return;
            }
            Self::Watch(symbol) => {
                if !cfg
                    .watchlist
                    .iter()
                    .any(|s| s.trim().eq_ignore_ascii_case(symbol))
                {
                    cfg.watchlist.push(symbol.clone());
                }
            }
            Self::Unwatch(symbol) => cfg
                .watchlist
                .retain(|s| !s.trim().eq_ignore_ascii_case(symbol)),
        }
        if cfg.watchlist.is_empty() {
            cfg.watchlist = live.to_vec();
        }
    }
}

/// One `[[positions]]` entry as the file has it. Each field is a raw TOML
/// value, so a missing or mistyped one drops this entry with a warning in
/// `resolve` instead of failing the whole file and the keys with it, and
/// the entry is written back as typed until someone fixes it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PositionEntry {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub symbol: Option<toml::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub qty: Option<toml::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub avg_price: Option<toml::Value>,
    /// Whatever else the entry holds, a misspelled `avg_prcie` included:
    /// dropping it on the next save would lose the number with the typo.
    #[serde(flatten)]
    pub other: toml::Table,
}

impl PositionEntry {
    fn symbol(&self) -> Option<String> {
        let symbol = self.symbol.as_ref()?.as_str()?.trim().to_uppercase();
        (!symbol.is_empty()).then_some(symbol)
    }

    fn is_for(&self, symbol: &str) -> bool {
        self.symbol()
            .is_some_and(|own| own.eq_ignore_ascii_case(symbol))
    }

    /// A whole number reads as well as a float: `qty = 12` is how people
    /// write a share count.
    fn number(value: Option<&toml::Value>) -> Option<f64> {
        match value? {
            toml::Value::Float(f) => Some(*f),
            toml::Value::Integer(i) => Some(*i as f64),
            _ => None,
        }
    }
}

impl From<&Position> for PositionEntry {
    fn from(p: &Position) -> Self {
        Self {
            symbol: Some(toml::Value::String(p.symbol.clone())),
            qty: Some(toml::Value::Float(p.qty)),
            avg_price: Some(toml::Value::Float(p.avg_price)),
            other: toml::Table::new(),
        }
    }
}

/// Keys of one `[keybindings]` action: a bare string or a list of them.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum KeysSpec {
    One(String),
    Many(Vec<String>),
}

impl KeysSpec {
    fn as_list(&self) -> Vec<&str> {
        match self {
            Self::One(s) => vec![s.as_str()],
            Self::Many(v) => v.iter().map(String::as_str).collect(),
        }
    }
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiConfig {
    pub default_view: Option<String>,
    pub news_layout: Option<String>,
    pub news_scope: Option<String>,
    /// Group ticker and market news by story (default true).
    pub collapse_news: Option<bool>,
    /// Panel look: "rounded" (the default) or "plain" frame lines, or
    /// "none" for tinted panels without lines.
    pub borders: Option<String>,
    /// Raw i64 rather than u8: an out-of-range number must degrade to a
    /// warning in `resolve`, not fail deserializing the whole file.
    pub news_min_score: Option<i64>,
    pub insider_min_score: Option<i64>,
    /// Start the news feeds on only these categories, or on all but these:
    /// a list of names (a bare string works too). Raw values, so a number
    /// in the list degrades to a warning instead of failing the file.
    pub news_categories: Option<toml::Value>,
    pub news_exclude_categories: Option<toml::Value>,
    /// Whether the quote rail under the header is drawn (default true).
    pub quote_rail: Option<bool>,
    /// Start in bare mode: no header, no footer (default false).
    pub bare: Option<bool>,
    /// Price color fades and the refresh spinner (default true).
    pub animations: Option<bool>,
    /// Take the mouse: clicks and the wheel drive the views (default true).
    /// Off leaves it to the terminal, which then selects text as usual.
    pub mouse: Option<bool>,
    /// Allow synchronized frames when terminfo advertises support.
    pub synchronized_output: Option<bool>,
    /// Startup window of the Insider view's chart panel: "3m" (default),
    /// "12m" or "off"; the g key cycles it live.
    pub insider_chart: Option<String>,
    /// How long fetched AlphAI data (news, sentiment, insider) stays fresh
    /// before the visible view re-fetches it. Seconds; raw i64 for the same
    /// warning-not-error reason as the scores.
    pub alphai_ttl_secs: Option<i64>,
}

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ChartConfig {
    pub timezone: Option<String>,
    pub session_shading: Option<bool>,
    pub time_grid: Option<bool>,
    pub style: Option<String>,
    pub sma: Option<bool>,
    pub ma_type: Option<String>,
    pub rsi: Option<bool>,
    pub volume: Option<bool>,
    /// Draw pre and post market candles too (Yahoo and Alpaca).
    pub extended_hours: Option<bool>,
    /// Mark the ticker's news on the price chart.
    pub news_markers: Option<bool>,
    pub sma_fast: Option<usize>,
    pub sma_slow: Option<usize>,
    pub rsi_period: Option<usize>,
    /// Percent of the plot width kept free right of the newest candle (the
    /// last-price marker lives there). Raw i64 so an out-of-range number
    /// degrades to a warning in `resolve`, not a whole-file parse error.
    pub right_margin_pct: Option<i64>,
    /// The t/T cycle, as pairs like `["1d", "5m"]`.
    pub presets: Option<Vec<Vec<String>>>,
}

/// Everything derived and validated from the raw config. Semantic problems
/// in these sections warn and fall back per entry; only a TOML syntax error
/// degrades the whole file (in `load_at`).
pub struct Resolved {
    pub theme: Theme,
    /// Which preset `theme` was built from, for the settings row and the
    /// live cycle key.
    pub theme_name: &'static str,
    pub chart: ChartDefaults,
    pub ui: UiDefaults,
    pub keymap: Keymap,
    /// `[[positions]]`, validated; empty when the section is absent.
    pub positions: Vec<Position>,
}

/// Validated `[chart]` values; `Default` is the traditional look. The
/// session keys (c, m, i, t) still toggle everything live, these only seed
/// the startup state and the indicator math.
#[derive(Clone, Debug, PartialEq)]
pub struct ChartDefaults {
    pub timezone: ChartTimezone,
    pub session_shading: bool,
    pub time_grid: bool,
    pub style: ChartStyle,
    pub sma: bool,
    /// Simple or exponential; the periods below serve whichever is picked.
    pub ma_type: MaType,
    pub rsi: bool,
    pub volume: bool,
    /// Start with premarket and after-hours candles drawn.
    pub extended_hours: bool,
    /// Start with the news marks on the price chart. They are drawn from
    /// the news the app already holds for the ticker, so this costs no
    /// request either way.
    pub news_markers: bool,
    pub sma_fast: usize,
    pub sma_slow: usize,
    pub rsi_period: usize,
    /// 0 disables the margin (and the last-price marker drawn in it).
    pub right_margin_pct: u16,
    pub presets: Vec<(Range, Interval)>,
}

impl Default for ChartDefaults {
    fn default() -> Self {
        Self {
            timezone: ChartTimezone::Exchange,
            session_shading: true,
            time_grid: true,
            style: ChartStyle::Candles,
            sma: true,
            ma_type: MaType::Sma,
            rsi: true,
            volume: true,
            extended_hours: true,
            news_markers: true,
            sma_fast: indicators::SMA_FAST,
            sma_slow: indicators::SMA_SLOW,
            rsi_period: indicators::RSI_PERIOD,
            right_margin_pct: DEFAULT_RIGHT_MARGIN_PCT,
            presets: RANGE_PRESETS.to_vec(),
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChartTimezone {
    Exchange,
    Local,
    Utc,
}

/// Startup default of `[chart] right_margin_pct`: a fifth of the plot stays
/// free right of the newest candle, so it never sits glued to the border and
/// the last-price marker has room to live.
pub const DEFAULT_RIGHT_MARGIN_PCT: u16 = 20;

/// Accepted `[chart] right_margin_pct` values; anything past half the plot
/// would squeeze the candles more than it helps.
pub const RIGHT_MARGIN_RANGE: RangeInclusive<i64> = 0..=50;

/// Startup default of the news score filter: relevance 7 and up (the API
/// itself defaults to 4; +/- adjust it live, `[ui] news_min_score` seeds it).
pub const DEFAULT_NEWS_MIN_SCORE: u8 = 7;

/// Startup default of the insider score filter. Insider rows are scored from
/// the event's dollar value, so this is a trade-size filter; 4 matches the
/// server default and keeps every filing the feed used to show.
pub const DEFAULT_INSIDER_MIN_SCORE: u8 = 4;

/// Accepted `[ui] alphai_ttl_secs` values. The floor keeps a typo (or "5"
/// meant as minutes) from burning the free request budget; the ceiling is
/// one day, past which the value is surely a mistake.
pub const ALPHAI_TTL_RANGE: RangeInclusive<i64> = 30..=86_400;

/// Validated `[ui]` startup values.
#[derive(Clone, Debug, PartialEq)]
pub struct UiDefaults {
    pub view_idx: usize,
    /// The quote rail under the header; file-only, like the border style.
    pub quote_rail: bool,
    /// Start without the header and footer; `--bare` also turns it on.
    pub bare: bool,
    pub animations: bool,
    /// Whether the app takes the mouse; `M` toggles it live.
    pub mouse: bool,
    pub synchronized_output: bool,
    pub news_layout: NewsLayout,
    pub news_scope: NewsScope,
    pub collapse_news: bool,
    pub news_min_score: u8,
    pub insider_min_score: u8,
    pub news_categories: Categories,
    pub insider_chart: InsiderChartWindow,
    pub alphai_ttl: Duration,
}

impl Default for UiDefaults {
    fn default() -> Self {
        Self {
            view_idx: ui::view_index(ui::ViewId::Split),
            quote_rail: true,
            bare: false,
            animations: true,
            mouse: true,
            synchronized_output: true,
            news_layout: NewsLayout::default(),
            news_scope: NewsScope::default(),
            collapse_news: true,
            news_min_score: DEFAULT_NEWS_MIN_SCORE,
            insider_min_score: DEFAULT_INSIDER_MIN_SCORE,
            news_categories: Categories::default(),
            insider_chart: InsiderChartWindow::default(),
            alphai_ttl: alphai::CACHE_TTL,
        }
    }
}

/// Validate the raw config into ready-to-use values plus human-readable
/// warnings (printed to stderr before the TUI starts).
/// `cli_theme` is `--theme`; it wins over `[theme] preset`, like every
/// other CLI value.
pub fn resolve(cfg: &Config, cli_theme: Option<&str>) -> (Resolved, Vec<String>) {
    let mut warnings = Vec::new();
    let (mut theme, theme_name) = Theme::resolve(cfg.theme.as_ref(), cli_theme, &mut warnings);
    theme.panels = resolve_borders(
        cfg.ui.as_ref().and_then(|u| u.borders.as_deref()),
        &mut warnings,
    );
    let chart = resolve_chart(cfg.chart.as_ref(), &mut warnings);
    let ui = resolve_ui(cfg.ui.as_ref(), &mut warnings);
    let keymap = Keymap::from_config(
        cfg.keybindings
            .iter()
            .flatten()
            .map(|(name, spec)| (name.as_str(), spec.as_list())),
        &mut warnings,
    );
    let positions = resolve_positions(&cfg.positions, &mut warnings);
    resolve_extended_source(cfg, &mut warnings);
    (
        Resolved {
            theme,
            theme_name,
            chart,
            ui,
            keymap,
            positions,
        },
        warnings,
    )
}

/// `extended_source`: an unknown value warns and reads as "auto"; "alpaca"
/// without Alpaca keys warns and reads as the price source's own data,
/// rather than quietly asking another provider.
fn resolve_extended_source(cfg: &Config, warnings: &mut Vec<String>) {
    use crate::source::extended::ExtendedSource;
    let Some(raw) = cfg.extended_source.as_deref() else {
        return;
    };
    match ExtendedSource::parse(raw) {
        None => warnings.push(format!(
            "extended_source = \"{raw}\": expected one of {}, using auto",
            ExtendedSource::ALL.map(ExtendedSource::name).join(", ")
        )),
        Some(ExtendedSource::Alpaca) if crate::source::alpaca_keys(cfg).is_none() => warnings.push(
            "extended_source = \"alpaca\" needs the Alpaca keys, using the price source's own pre and post market"
                .to_string(),
        ),
        Some(_) => {}
    }
}

/// `[[positions]]`: a broken entry warns and is dropped, the rest stand.
/// Symbols are upper-cased the way the watchlist is, so a holding written
/// in lower case still meets its quote.
fn resolve_positions(raw: &[PositionEntry], warnings: &mut Vec<String>) -> Vec<Position> {
    let mut out: Vec<Position> = Vec::new();
    for entry in raw {
        let Some(symbol) = entry.symbol() else {
            warnings.push("[[positions]]: an entry has no symbol, skipping it".to_string());
            continue;
        };
        let symbol = match crate::domain::normalize_symbol(&symbol) {
            Ok(symbol) => symbol,
            Err(error) => {
                warnings.push(format!("[[positions]] {symbol}: {error}, skipping it"));
                continue;
            }
        };
        let Some(qty) =
            PositionEntry::number(entry.qty.as_ref()).filter(|q| q.is_finite() && *q != 0.0)
        else {
            warnings.push(format!(
                "[[positions]] {symbol}: qty must be a non-zero number, skipping it"
            ));
            continue;
        };
        let Some(avg_price) =
            PositionEntry::number(entry.avg_price.as_ref()).filter(|p| p.is_finite() && *p >= 0.0)
        else {
            warnings.push(format!(
                "[[positions]] {symbol}: avg_price must be a number, zero or more, skipping it"
            ));
            continue;
        };
        if out.iter().any(|kept| kept.symbol == symbol) {
            warnings.push(format!(
                "[[positions]] {symbol}: listed twice, keeping the first entry"
            ));
            continue;
        }
        out.push(Position {
            symbol,
            qty,
            avg_price,
        });
    }
    out
}

/// `[ui] borders`: the line set frames draw with (`rounded`, the default,
/// or `plain`), or `none` for tinted panels without lines. Lives in `[ui]` rather than `[theme]`
/// because it is not a color, but it resolves onto the theme, which every
/// renderer already has at hand.
fn resolve_borders(raw: Option<&str>, warnings: &mut Vec<String>) -> Panels {
    let Some(raw) = raw else {
        return Panels::default();
    };
    Panels::ALL
        .into_iter()
        .find(|p| p.name().eq_ignore_ascii_case(raw.trim()))
        .unwrap_or_else(|| {
            warnings.push(format!(
                "[ui] borders: unknown \"{raw}\" (rounded, plain or none), keeping rounded"
            ));
            Panels::default()
        })
}

fn resolve_chart(raw: Option<&ChartConfig>, warnings: &mut Vec<String>) -> ChartDefaults {
    let mut out = ChartDefaults::default();
    let Some(raw) = raw else { return out };
    if let Some(zone) = &raw.timezone {
        match zone.to_lowercase().as_str() {
            "exchange" | "et" => out.timezone = ChartTimezone::Exchange,
            "local" => out.timezone = ChartTimezone::Local,
            "utc" => out.timezone = ChartTimezone::Utc,
            _ => warnings.push(format!(
                "[chart] timezone: unknown \"{zone}\" (exchange, local or utc), keeping exchange"
            )),
        }
    }
    if let Some(v) = raw.session_shading {
        out.session_shading = v;
    }
    if let Some(v) = raw.time_grid {
        out.time_grid = v;
    }
    if let Some(style) = &raw.style {
        match style.to_lowercase().as_str() {
            "candles" => out.style = ChartStyle::Candles,
            "line" => out.style = ChartStyle::Line,
            other => warnings.push(format!(
                "[chart] style: unknown \"{other}\" (candles or line), keeping candles"
            )),
        }
    }
    if let Some(v) = raw.sma {
        out.sma = v;
    }
    if let Some(kind) = &raw.ma_type {
        match kind.to_lowercase().as_str() {
            "sma" => out.ma_type = MaType::Sma,
            "ema" => out.ma_type = MaType::Ema,
            other => warnings.push(format!(
                "[chart] ma_type: unknown \"{other}\" (sma or ema), keeping sma"
            )),
        }
    }
    if let Some(v) = raw.rsi {
        out.rsi = v;
    }
    if let Some(v) = raw.volume {
        out.volume = v;
    }
    if let Some(v) = raw.extended_hours {
        out.extended_hours = v;
    }
    if let Some(v) = raw.news_markers {
        out.news_markers = v;
    }
    out.sma_fast = period(raw.sma_fast, out.sma_fast, "sma_fast", 2..=250, warnings);
    out.sma_slow = period(raw.sma_slow, out.sma_slow, "sma_slow", 2..=250, warnings);
    out.rsi_period = period(
        raw.rsi_period,
        out.rsi_period,
        "rsi_period",
        2..=100,
        warnings,
    );
    if let Some(pct) = raw.right_margin_pct {
        if RIGHT_MARGIN_RANGE.contains(&pct) {
            out.right_margin_pct = pct as u16;
        } else {
            warnings.push(format!(
                "[chart] right_margin_pct: {pct} is outside {}..={}, keeping {}",
                RIGHT_MARGIN_RANGE.start(),
                RIGHT_MARGIN_RANGE.end(),
                DEFAULT_RIGHT_MARGIN_PCT
            ));
        }
    }
    if let Some(rows) = &raw.presets {
        let mut presets = Vec::new();
        for row in rows {
            match parse_preset(row) {
                Some(p) => presets.push(p),
                None => warnings.push(format!(
                    "[chart] presets: bad pair {row:?}, expected like [\"1d\", \"5m\"]"
                )),
            }
        }
        if presets.is_empty() {
            warnings.push("[chart] presets: no valid pairs, keeping the built-in cycle".into());
        } else {
            out.presets = presets;
        }
    }
    out
}

fn period(
    raw: Option<usize>,
    default: usize,
    name: &str,
    valid: RangeInclusive<usize>,
    warnings: &mut Vec<String>,
) -> usize {
    match raw {
        Some(v) if valid.contains(&v) => v,
        Some(v) => {
            warnings.push(format!(
                "[chart] {name}: {v} is outside {}..={}, keeping {default}",
                valid.start(),
                valid.end()
            ));
            default
        }
        None => default,
    }
}

fn parse_preset(row: &[String]) -> Option<(Range, Interval)> {
    let [range, interval] = row else { return None };
    Some((
        Range::from_str(range, true).ok()?,
        Interval::from_str(interval, true).ok()?,
    ))
}

fn resolve_ui(raw: Option<&UiConfig>, warnings: &mut Vec<String>) -> UiDefaults {
    let mut out = UiDefaults::default();
    let Some(raw) = raw else { return out };
    if let Some(name) = &raw.default_view {
        // Matched by title, so the list stays correct as views register.
        match ui::VIEWS
            .iter()
            .position(|v| v.title().eq_ignore_ascii_case(name))
        {
            Some(idx) => out.view_idx = idx,
            None => {
                let names: Vec<String> =
                    ui::VIEWS.iter().map(|v| v.title().to_lowercase()).collect();
                warnings.push(format!(
                    "[ui] default_view: unknown \"{name}\" (one of {}), keeping split",
                    names.join(", ")
                ));
            }
        }
    }
    if let Some(on) = raw.quote_rail {
        out.quote_rail = on;
    }
    if let Some(on) = raw.bare {
        out.bare = on;
    }
    if let Some(on) = raw.animations {
        out.animations = on;
    }
    if let Some(on) = raw.mouse {
        out.mouse = on;
    }
    if let Some(on) = raw.synchronized_output {
        out.synchronized_output = on;
    }
    if let Some(on) = raw.collapse_news {
        out.collapse_news = on;
    }
    if let Some(layout) = &raw.news_layout {
        match NewsLayout::from_name(layout) {
            Some(l) => out.news_layout = l,
            None => warnings.push(format!(
                "[ui] news_layout: unknown \"{layout}\" (chart, side or stacked), keeping chart"
            )),
        }
    }
    if let Some(scope) = &raw.news_scope {
        match scope.to_lowercase().as_str() {
            "ticker" => out.news_scope = NewsScope::Ticker,
            "market" => out.news_scope = NewsScope::Market,
            "trending" => out.news_scope = NewsScope::Trending,
            other => warnings.push(format!(
                "[ui] news_scope: unknown \"{other}\" (ticker, market or trending), keeping ticker"
            )),
        }
    }
    if let Some(window) = &raw.insider_chart {
        match window.to_lowercase().as_str() {
            "off" => out.insider_chart = InsiderChartWindow::Off,
            "3m" => out.insider_chart = InsiderChartWindow::M3,
            "12m" => out.insider_chart = InsiderChartWindow::M12,
            other => warnings.push(format!(
                "[ui] insider_chart: unknown \"{other}\" (3m, 12m or off), keeping 3m"
            )),
        }
    }
    out.news_min_score = min_score(
        raw.news_min_score,
        out.news_min_score,
        "news_min_score",
        warnings,
    );
    out.insider_min_score = min_score(
        raw.insider_min_score,
        out.insider_min_score,
        "insider_min_score",
        warnings,
    );
    let only = category_names(raw.news_categories.as_ref(), "news_categories", warnings);
    let without = category_names(
        raw.news_exclude_categories.as_ref(),
        "news_exclude_categories",
        warnings,
    );
    out.news_categories = match (only, without) {
        (Some(only), Some(_)) => {
            warnings.push(
                "[ui] news_categories and news_exclude_categories are both set, using news_categories"
                    .into(),
            );
            Categories::only(only)
        }
        (Some(only), None) => Categories::only(only),
        (None, Some(without)) => Categories::without(without),
        (None, None) => out.news_categories,
    };
    if let Some(secs) = raw.alphai_ttl_secs {
        if ALPHAI_TTL_RANGE.contains(&secs) {
            out.alphai_ttl = Duration::from_secs(secs as u64);
        } else {
            warnings.push(format!(
                "[ui] alphai_ttl_secs: {secs} is outside {}..={}, keeping {}",
                ALPHAI_TTL_RANGE.start(),
                ALPHAI_TTL_RANGE.end(),
                alphai::CACHE_TTL.as_secs()
            ));
        }
    }
    out
}

/// One `[ui] news_*categories` entry as indices into `category::ALL`: None
/// when absent or empty, so an empty list means "no filter" and never
/// clashes with the other key. Any spelling `category::find` knows is
/// accepted; an unknown name or a non-string is skipped with a warning.
fn category_names(
    raw: Option<&toml::Value>,
    name: &str,
    warnings: &mut Vec<String>,
) -> Option<Vec<usize>> {
    let items: Vec<&toml::Value> = match raw? {
        toml::Value::Array(items) => items.iter().collect(),
        one => vec![one],
    };
    if items.is_empty() {
        return None;
    }
    let mut found = Vec::new();
    for item in items {
        match item.as_str().and_then(category::find) {
            Some(i) => found.push(i),
            None => {
                let known: Vec<&str> = category::ALL.iter().map(|c| c.api).collect();
                warnings.push(format!(
                    "[ui] {name}: unknown category {item} (one of {}), skipping it",
                    known.join(", ")
                ));
            }
        }
    }
    Some(found)
}

/// One `[ui] *_min_score` entry: 1..=10 or a warning plus the default.
fn min_score(raw: Option<i64>, default: u8, name: &str, warnings: &mut Vec<String>) -> u8 {
    match raw {
        Some(n) if (1..=10).contains(&n) => n as u8,
        Some(n) => {
            warnings.push(format!(
                "[ui] {name}: {n} is outside 1..=10, keeping {default}"
            ));
            default
        }
        None => default,
    }
}

impl Config {
    /// Resolved credential for one key field: the env var wins so a
    /// shell-exported key keeps working after a config file appears; blank
    /// values count as unset.
    pub fn key_value(&self, field: &KeyField) -> Option<String> {
        env_key(field.env_var).or_else(|| {
            self.keys
                .get(field.config_name)
                .map(|v| v.trim().to_string())
                .filter(|v| !v.is_empty())
        })
    }

    pub fn alphai_key(&self) -> Option<String> {
        self.key_value(&ALPHAI_KEY_FIELD)
    }

    /// True when Enter on a news article should open the original source
    /// instead of the alphai.io article page.
    pub fn news_open_original(&self) -> bool {
        self.news_open.as_deref() == Some("original")
    }
}

fn env_key(name: &str) -> Option<String> {
    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
}

/// Unix (including macOS) follows the terminal-tool convention:
/// `$XDG_CONFIG_HOME` or `~/.config`. Windows uses the platform config dir.
pub fn path() -> Option<PathBuf> {
    let base = if cfg!(windows) {
        dirs::config_dir()?
    } else {
        std::env::var_os("XDG_CONFIG_HOME")
            .map(PathBuf::from)
            .filter(|p| p.is_absolute())
            .unwrap_or(dirs::home_dir()?.join(".config"))
    };
    Some(base.join("alphai-tui").join("config.toml"))
}

/// What `load_at` found at the config path.
#[derive(Clone, Debug, PartialEq)]
pub enum FileState {
    /// No file yet: a first run.
    Missing,
    Loaded,
    /// A file that does not load, with the reason. The session runs on the
    /// defaults, and nothing writes over the file while it stays that way
    /// (Save in the settings screen keeps a copy first): the keys typed
    /// into it would go with it.
    Broken(String),
}

/// Load from an explicit path (the `--config` flag) or the default
/// location. Returns the config, what was found, and the path Save must
/// write back to (None when the platform has no config dir). A missing
/// file is a normal first run; an unreadable one degrades to defaults with
/// a stderr warning rather than blocking startup.
pub fn load_at(override_path: Option<&Path>) -> (Config, FileState, Option<PathBuf>) {
    let Some(p) = override_path.map(Path::to_path_buf).or_else(path) else {
        return (Config::default(), FileState::Missing, None);
    };
    match load_from(&p) {
        Ok(Some(cfg)) => (cfg, FileState::Loaded, Some(p)),
        Ok(None) => (Config::default(), FileState::Missing, Some(p)),
        Err(e) => {
            eprintln!("warning: ignoring bad config at {}: {e:#}", p.display());
            (
                Config::default(),
                FileState::Broken(load_error(&e)),
                Some(p),
            )
        }
    }
}

pub fn load_from(p: &Path) -> Result<Option<Config>> {
    if !p.exists() {
        return Ok(None);
    }
    let raw = std::fs::read_to_string(p).context("read failed")?;
    Ok(Some(toml::from_str(&raw).context("parse failed")?))
}

/// One line on why `load_from` failed, short enough for the footer: the
/// TOML error's first line names the line and column, the rest of it is a
/// drawing of the spot.
fn load_error(e: &anyhow::Error) -> String {
    let cause = e.root_cause().to_string();
    cause.lines().next().unwrap_or_default().trim().to_string()
}

/// Copies a config that does not load to `<name>.broken` before an
/// explicit Save replaces it, so what was typed into it, the keys first,
/// is still on disk to copy back.
fn set_aside(p: &Path) -> Result<PathBuf> {
    let mut aside = p.as_os_str().to_owned();
    aside.push(".broken");
    let aside = PathBuf::from(aside);
    std::fs::copy(p, &aside).context("could not keep a copy of the config that did not load")?;
    Ok(aside)
}

/// The config file's name for a message, or a generic one without a path.
pub fn file_label(path: Option<&Path>) -> String {
    path.and_then(Path::file_name).map_or_else(
        || "The config file".to_string(),
        |name| name.to_string_lossy().into_owned(),
    )
}

/// Holds `<config>.lock` for one read, change and write of the config.
/// Two processes on one file (tmux panes) each read the same version
/// otherwise, and the later write drops what the earlier one added: the
/// atomic rename keeps the file whole, not the other change. The lock
/// file stays beside the config; released when this is dropped.
struct Lock {
    _file: std::fs::File,
}

/// A write holds the lock for milliseconds. One held much longer belongs
/// to a process that is stuck, and the keypress should say so rather than
/// freeze the screen.
const LOCK_WAIT: Duration = Duration::from_secs(2);

fn lock(p: &Path) -> Result<Lock> {
    lock_within(p, LOCK_WAIT)
}

fn lock_within(p: &Path, wait: Duration) -> Result<Lock> {
    if let Some(dir) = p.parent() {
        std::fs::create_dir_all(dir).context("create config dir failed")?;
    }
    let mut name = p.as_os_str().to_owned();
    name.push(".lock");
    let mut options = std::fs::OpenOptions::new();
    options.create(true).truncate(false).write(true);
    #[cfg(unix)]
    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
    let file = options
        .open(name)
        .context("could not open the config lock")?;
    let deadline = std::time::Instant::now() + wait;
    loop {
        match file.try_lock() {
            Ok(()) => return Ok(Lock { _file: file }),
            Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => {
                std::thread::sleep(Duration::from_millis(10));
            }
            Err(std::fs::TryLockError::WouldBlock) => {
                anyhow::bail!("another alphai-tui held {} too long", file_label(Some(p)))
            }
            // A filesystem without locks (some network mounts) saved fine
            // before there was a lock, and still does, just unguarded.
            Err(std::fs::TryLockError::Error(e)) if e.kind() == std::io::ErrorKind::Unsupported => {
                return Ok(Lock { _file: file });
            }
            Err(std::fs::TryLockError::Error(e)) => {
                return Err(e).context("could not lock the config");
            }
        }
    }
}

/// Applies `change` to the config as it is on disk and writes it back,
/// all under the lock, and returns what was written. `missing` stands in
/// when there is no file yet. A file that does not load is left alone
/// rather than replaced by the defaults a session runs on, keys and all.
pub fn update(p: &Path, missing: &Config, change: impl Fn(&mut Config)) -> Result<Config> {
    let _lock = lock(p)?;
    let mut on_disk = match load_from(p) {
        Ok(Some(cfg)) => cfg,
        Ok(None) => missing.clone(),
        Err(e) => anyhow::bail!("{} did not load ({})", file_label(Some(p)), load_error(&e)),
    };
    change(&mut on_disk);
    save_to(p, &on_disk)?;
    Ok(on_disk)
}

/// Save in the settings screen: writes `cfg` whole, under the lock. The
/// holdings are not on that screen, so they come from the file as it is
/// now, with `pending` (the keypress edits that never reached it) on top.
/// A file that does not load is copied aside first, and the copy's path
/// returned, since what is on screen is about to replace it.
/// `on_defaults` is a session that started on a file that did not load:
/// its `cfg` holds the defaults, so if the file loads now it was fixed
/// while the app ran, and writing would put those defaults over the fix,
/// keys and every section the settings screen does not show.
pub fn save_whole(
    p: &Path,
    cfg: &mut Config,
    on_defaults: bool,
    pending: impl Fn(&mut Config),
) -> Result<Option<PathBuf>> {
    let _lock = lock(p)?;
    let aside = match load_from(p) {
        Ok(Some(_)) if on_defaults => anyhow::bail!(
            "{} loads now, restart alphai-tui to pick it up",
            file_label(Some(p))
        ),
        Ok(Some(on_disk)) => {
            cfg.positions = on_disk.positions;
            pending(cfg);
            None
        }
        Ok(None) => None,
        Err(_) => Some(set_aside(p)?),
    };
    save_to(p, cfg)?;
    Ok(aside)
}

/// Save to the path `load_at` resolved (the settings screen passes it back).
pub fn save_at(path: Option<&Path>, cfg: &Config) -> Result<()> {
    let p = path.context("no config directory on this platform")?;
    save_to(p, cfg)
}

/// The file may hold API keys, so it is created user-only (0600) on unix.
pub fn save_to(p: &Path, cfg: &Config) -> Result<()> {
    if let Some(dir) = p.parent() {
        std::fs::create_dir_all(dir).context("create config dir failed")?;
    }
    let raw = toml::to_string_pretty(cfg).context("serialize failed")?;
    // Written beside the target and renamed over it. The position prompt
    // saves on every edit, so a torn write is no longer a once-a-session
    // risk, and this file also holds the API keys.
    let tmp = p.with_extension(format!("toml.tmp{}", std::process::id()));
    let saved = write_private(&tmp, &raw)
        .context("write failed")
        .and_then(|()| std::fs::rename(&tmp, p).context("replace failed"));
    if saved.is_err() {
        let _ = std::fs::remove_file(&tmp);
    }
    saved
}

/// Creates `path` user-only before a byte of it is written: a mode set
/// after the write left the keys readable to other local users in
/// between, and a failed chmod went unnoticed.
fn write_private(path: &Path, raw: &str) -> std::io::Result<()> {
    use std::io::Write;
    // A leftover from a run that died mid-save (its pid may come round
    // again) would keep its own mode, so it goes first.
    let _ = std::fs::remove_file(path);
    let mut options = std::fs::OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
    let mut file = options.open(path)?;
    file.write_all(raw.as_bytes())?;
    file.sync_all()
}

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

    /// A key field whose env var can never be set, so tests exercise the
    /// file side of the resolution deterministically.
    const TEST_FIELD: KeyField = KeyField {
        config_name: "finnhub",
        env_var: "ALPHAI_TUI_TEST_NEVER_SET",
        label: "test",
    };

    /// Every section validates per entry: a broken holding warns and is
    /// dropped, the rest of the list still loads.
    #[test]
    fn bad_positions_warn_and_are_dropped() {
        let cfg: Config = toml::from_str(
            r#"
[[positions]]
symbol = "aapl"
qty = 12
avg_price = 182.31

[[positions]]
symbol = "MSFT"
qty = 0
avg_price = 400

[[positions]]
symbol = "NVDA"
qty = 3
avg_price = -1

[[positions]]
symbol = "AAPL"
qty = 5
avg_price = 100

[[positions]]
symbol = "NASDAQ COMPOSITE"
qty = 1
avg_price = 100
"#,
        )
        .unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(resolved.positions.len(), 1);
        // Upper-cased like the watchlist, or the holding would never meet
        // its quote.
        assert_eq!(resolved.positions[0].symbol, "AAPL");
        assert_eq!(resolved.positions[0].qty, 12.0);
        assert_eq!(warnings.len(), 4, "{warnings:?}");
        assert!(warnings.iter().any(|w| w.contains("MSFT")), "{warnings:?}");
        assert!(warnings.iter().any(|w| w.contains("NVDA")), "{warnings:?}");
        assert!(warnings.iter().any(|w| w.contains("twice")), "{warnings:?}");
        assert!(
            warnings.iter().any(|w| w.contains("NASDAQ COMPOSITE")),
            "{warnings:?}"
        );
    }

    #[test]
    fn watchlist_edits_match_trimmed_tickers() {
        let mut cfg = Config {
            watchlist: vec![" aapl ".into(), "MSFT".into()],
            ..Default::default()
        };
        let live = vec!["AAPL".into(), "MSFT".into()];
        assert!(crate::app::watchlist_is_saved(&cfg.watchlist, &live));
        Edit::Watch("AAPL".into()).apply(&mut cfg, &live);
        assert_eq!(cfg.watchlist.len(), 2);
        Edit::Unwatch("AAPL".into()).apply(&mut cfg, &["MSFT".into()]);
        assert_eq!(cfg.watchlist, ["MSFT"]);
    }

    /// A typo reads as auto, and alpaca without its keys as the source's
    /// own data, each with a warning; a good value says nothing.
    #[test]
    fn extended_source_warns_per_value() {
        let warn = |value: &str, keys: &[(&str, &str)]| {
            let cfg = Config {
                extended_source: Some(value.into()),
                keys: keys
                    .iter()
                    .map(|(k, v)| (k.to_string(), v.to_string()))
                    .collect(),
                ..Config::default()
            };
            resolve(&cfg, None).1
        };
        assert!(warn("yahoo", &[]).is_empty());
        assert!(warn("Same", &[]).is_empty());
        let typo = warn("sip", &[]);
        assert!(
            typo.iter().any(|w| w.contains("auto, same, alpaca, yahoo")),
            "{typo:?}"
        );
        if std::env::var("APCA_API_KEY_ID").is_err() {
            let keyless = warn("alpaca", &[]);
            assert!(
                keyless.iter().any(|w| w.contains("needs the Alpaca keys")),
                "{keyless:?}"
            );
        }
        let keyed = [("alpaca_key_id", "PKTEST"), ("alpaca_secret", "secret")];
        assert!(warn("alpaca", &keyed).is_empty());
    }

    #[test]
    fn round_trip() {
        let dir = std::env::temp_dir().join(format!("alphai-tui-test-{}", std::process::id()));
        let p = dir.join("config.toml");
        let cfg = Config {
            source: Some("finnhub".into()),
            watchlist: vec!["AAPL".into(), "BTC-USD".into()],
            every: Some(30),
            range: Some("5d".into()),
            interval: Some("15m".into()),
            source_fallback: Some(false),
            extended_source: Some("yahoo".into()),
            news_open: Some("original".into()),
            positions: vec![PositionEntry::from(&Position {
                symbol: "AAPL".into(),
                qty: 12.0,
                avg_price: 182.31,
            })],
            keys: BTreeMap::from([
                ("finnhub".to_string(), "fh-key".to_string()),
                ("alphai".to_string(), "ak_live_x".to_string()),
                ("alpaca_key_id".to_string(), "PKTEST123".to_string()),
                ("alpaca_secret".to_string(), "alpaca-secret-x".to_string()),
            ]),
            ui: Some(UiConfig {
                default_view: Some("news".into()),
                news_layout: Some("stacked".into()),
                news_scope: None,
                collapse_news: Some(false),
                borders: Some("plain".into()),
                news_min_score: Some(6),
                insider_min_score: Some(5),
                news_categories: Some(toml::Value::Array(vec!["earnings".into(), "m&a".into()])),
                news_exclude_categories: None,
                insider_chart: Some("12m".into()),
                alphai_ttl_secs: Some(120),
                quote_rail: Some(false),
                bare: Some(true),
                animations: Some(false),
                mouse: Some(false),
                synchronized_output: Some(false),
            }),
            chart: Some(ChartConfig {
                style: Some("line".into()),
                sma_slow: Some(200),
                right_margin_pct: Some(25),
                presets: Some(vec![vec!["1d".into(), "5m".into()]]),
                ..Default::default()
            }),
            theme: Some(BTreeMap::from([(
                "accent".to_string(),
                "magenta".to_string(),
            )])),
            keybindings: Some(BTreeMap::from([
                ("quit".to_string(), KeysSpec::One("ctrl-q".into())),
                (
                    "open".to_string(),
                    KeysSpec::Many(vec!["enter".into(), "w".into()]),
                ),
            ])),
        };
        save_to(&p, &cfg).unwrap();
        let loaded = load_from(&p).unwrap().unwrap();
        assert_eq!(loaded, cfg);
        std::fs::remove_dir_all(&dir).ok();
    }

    fn scratch(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("alphai-tui-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir.join("config.toml")
    }

    /// A holding missing a field, or with a word for a number, used to fail
    /// the whole file: the keys and the watchlist went with it. Now that
    /// entry alone is skipped, and a save writes it back as it was typed.
    #[test]
    fn a_broken_position_is_skipped_alone_and_kept_in_the_file() {
        let raw = r#"
watchlist = ["AAPL"]

[keys]
alphai = "ak_live_x"

[[positions]]
symbol = "AAPL"
qty = 12
avg_prcie = 180

[[positions]]
symbol = "MSFT"
qty = "ten"
avg_price = 400

[[positions]]
symbol = "NVDA"
qty = 3
avg_price = 100.5
"#;
        let cfg: Config = toml::from_str(raw).unwrap();
        assert_eq!(
            cfg.keys.get("alphai").map(String::as_str),
            Some("ak_live_x")
        );
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(resolved.positions.len(), 1);
        assert_eq!(resolved.positions[0].symbol, "NVDA");
        assert_eq!(resolved.positions[0].qty, 3.0);
        assert!(
            warnings.iter().any(|w| w.contains("AAPL: avg_price")),
            "{warnings:?}"
        );
        assert!(
            warnings.iter().any(|w| w.contains("MSFT: qty")),
            "{warnings:?}"
        );

        let p = scratch("broken-entry");
        save_to(&p, &cfg).unwrap();
        let loaded = load_from(&p).unwrap().unwrap();
        assert_eq!(loaded.positions, cfg.positions);
        assert_eq!(loaded.positions[0].avg_price, None);
        // The misspelled field is the number someone typed: it stays.
        let written = std::fs::read_to_string(&p).unwrap();
        assert!(written.contains("avg_prcie = 180"), "file:\n{written}");

        // So does it when the prompt writes the holding over that entry.
        let mut fixed = loaded;
        fixed.set_position(&Position {
            symbol: "AAPL".into(),
            qty: 12.0,
            avg_price: 180.0,
        });
        assert_eq!(
            fixed.positions[0].avg_price,
            Some(toml::Value::Float(180.0))
        );
        assert_eq!(
            fixed.positions[0].other.get("avg_prcie"),
            Some(&toml::Value::Integer(180))
        );
        std::fs::remove_dir_all(p.parent().unwrap()).ok();
    }

    /// The prompt's write replaces the entry for its ticker however it was
    /// typed, a broken one included, and clearing takes out duplicates too.
    #[test]
    fn set_and_clear_position_find_the_ticker_in_any_case() {
        let mut cfg: Config = toml::from_str(
            r#"
[[positions]]
symbol = "aapl"
qty = 12

[[positions]]
symbol = "MSFT"
qty = 1
avg_price = 400

[[positions]]
symbol = "AAPL"
qty = 5
avg_price = 100
"#,
        )
        .unwrap();
        let held = Position {
            symbol: "AAPL".into(),
            qty: 10.0,
            avg_price: 180.0,
        };
        cfg.set_position(&held);
        assert_eq!(cfg.positions.len(), 3);
        assert_eq!(cfg.positions[0], PositionEntry::from(&held));
        cfg.clear_position("AAPL");
        assert_eq!(cfg.positions.len(), 1);
        assert!(cfg.positions[0].is_for("MSFT"));
    }

    /// A file that does not parse is not a first run: it says why, and it
    /// is copied aside rather than lost when Save replaces it.
    #[test]
    fn a_config_that_does_not_parse_is_broken_not_missing() {
        let p = scratch("broken-file");
        assert_eq!(load_at(Some(&p)).1, FileState::Missing);

        let raw = "every = \"often\"\n[keys]\nalphai = \"ak_live_x\"\n";
        std::fs::write(&p, raw).unwrap();
        let (cfg, state, path) = load_at(Some(&p));
        assert_eq!(cfg, Config::default());
        assert_eq!(path.as_deref(), Some(p.as_path()));
        let FileState::Broken(why) = state else {
            panic!("not reported as broken: {state:?}");
        };
        assert!(why.contains("line 1"), "{why}");
        assert!(!why.contains('\n'), "{why}");

        let aside = set_aside(&p).unwrap();
        assert_eq!(aside.file_name().unwrap(), "config.toml.broken");
        assert_eq!(std::fs::read_to_string(&aside).unwrap(), raw);
        std::fs::remove_dir_all(p.parent().unwrap()).ok();
    }

    /// The lock is what keeps two panes' changes apart: held, another
    /// taker waits and then gives up with a reason; let go, it is free.
    #[test]
    fn the_config_lock_is_held_until_dropped() {
        let p = scratch("lock");
        let held = lock(&p).unwrap();
        let refused = lock_within(&p, Duration::from_millis(30))
            .err()
            .expect("lock was shared");
        assert!(
            format!("{refused:#}").contains("held config.toml"),
            "{refused:#}"
        );
        drop(held);
        lock_within(&p, Duration::from_millis(30)).unwrap();
        std::fs::remove_dir_all(p.parent().unwrap()).ok();
    }

    /// Writers that each add their own tickers at the same time: every
    /// one of them ends up in the file. Without the lock two of them read
    /// the same version and the later write drops the earlier ticker.
    #[test]
    fn concurrent_updates_keep_every_change() {
        let p = scratch("concurrent");
        save_to(
            &p,
            &Config {
                watchlist: vec!["AAPL".into()],
                ..Config::default()
            },
        )
        .unwrap();
        std::thread::scope(|scope| {
            for writer in 0..4 {
                let p = &p;
                scope.spawn(move || {
                    for n in 0..10 {
                        let edit = Edit::Watch(format!("T{writer}X{n}"));
                        update(p, &Config::default(), |cfg| edit.apply(cfg, &[])).unwrap();
                    }
                });
            }
        });
        let saved = load_from(&p).unwrap().unwrap();
        assert_eq!(saved.watchlist.len(), 41, "{:?}", saved.watchlist);
        std::fs::remove_dir_all(p.parent().unwrap()).ok();
    }

    /// The file holds keys: it is user-only from its first byte, and a
    /// temp file a crashed save left behind does not lend it its mode.
    #[cfg(unix)]
    #[test]
    fn a_saved_config_is_private_from_the_first_byte() {
        use std::os::unix::fs::PermissionsExt;
        let p = scratch("private");
        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
        let tmp = p.with_extension(format!("toml.tmp{}", std::process::id()));
        let leave_over = || {
            std::fs::write(&tmp, "left over").unwrap();
            std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).unwrap();
        };
        // The temp file itself, before any rename: writing into a leftover
        // kept its 0644 for as long as the keys sat in it.
        leave_over();
        write_private(&tmp, "alphai = \"ak_live_x\"").unwrap();
        assert_eq!(mode(&tmp), 0o600);

        leave_over();

        let cfg = Config {
            keys: BTreeMap::from([("alphai".to_string(), "ak_live_x".to_string())]),
            ..Config::default()
        };
        save_to(&p, &cfg).unwrap();
        assert_eq!(mode(&p), 0o600);
        assert!(!tmp.exists(), "the temp file was left behind");
        assert_eq!(load_from(&p).unwrap().unwrap(), cfg);
        std::fs::remove_dir_all(p.parent().unwrap()).ok();
    }

    #[test]
    fn missing_file_is_none() {
        let p = Path::new("/nonexistent/alphai-tui/config.toml");
        assert!(load_from(p).unwrap().is_none());
    }

    #[test]
    fn partial_file_fills_defaults() {
        let cfg: Config = toml::from_str("source = \"yahoo\"").unwrap();
        assert_eq!(cfg.source.as_deref(), Some("yahoo"));
        assert!(cfg.watchlist.is_empty());
        assert!(cfg.keys.is_empty());
    }

    /// The exact `[keys]` shape written by pre-registry versions (and shown
    /// in the README) keeps parsing; those field names are frozen.
    #[test]
    fn old_keys_table_parses_verbatim() {
        let cfg: Config = toml::from_str(
            r#"
            source = "yahoo"
            [keys]
            alphai = "ak_live_x"
            finnhub = ""
            alpaca_key_id = "PK123"
            alpaca_secret = "sec"
            "#,
        )
        .unwrap();
        assert_eq!(
            cfg.keys.get("alphai").map(String::as_str),
            Some("ak_live_x")
        );
        assert_eq!(
            cfg.keys.get("alpaca_secret").map(String::as_str),
            Some("sec")
        );
        // A blank file value counts as unset at resolution time.
        assert_eq!(cfg.key_value(&TEST_FIELD), None);
    }

    #[test]
    fn key_value_reads_and_trims_file_values() {
        let cfg: Config = toml::from_str("[keys]\nfinnhub = \" fh-key \"").unwrap();
        assert_eq!(cfg.key_value(&TEST_FIELD).as_deref(), Some("fh-key"));
        assert_eq!(Config::default().key_value(&TEST_FIELD), None);
    }

    /// A `[keys]` entry this binary does not know must survive load -> save
    /// (a struct with named fields would silently drop it).
    #[test]
    fn unknown_key_survives_round_trip() {
        let cfg: Config = toml::from_str("[keys]\nnewsource = \"k\"").unwrap();
        let raw = toml::to_string_pretty(&cfg).unwrap();
        let again: Config = toml::from_str(&raw).unwrap();
        assert_eq!(again.keys.get("newsource").map(String::as_str), Some("k"));
    }

    /// Unknown sections and keys are tolerated on load: a typo never takes
    /// the whole file (and its API keys) down.
    #[test]
    fn unknown_sections_and_fields_are_tolerated() {
        let cfg: Config =
            toml::from_str("nonsense = 1\n[thme]\naccent = \"red\"").expect("must parse");
        assert_eq!(cfg, Config::default());
    }

    #[test]
    fn theme_section_resolves_and_stays_absent_by_default() {
        let cfg: Config = toml::from_str("[theme]\naccent = \"magenta\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.theme.accent, ratatui::style::Color::Magenta);

        let (_, warnings) = resolve(
            &toml::from_str::<Config>("[theme]\nup = \"banana\"").unwrap(),
            None,
        );
        assert_eq!(warnings.len(), 1, "{warnings:?}");

        // Absent sections must not serialize: Save would spray empty tables
        // into every config otherwise.
        let bare = toml::to_string_pretty(&Config::default()).unwrap();
        for section in ["[theme]", "[ui]", "[chart]", "[keybindings]"] {
            assert!(!bare.contains(section), "{bare}");
        }
    }

    /// --theme beats the file, like every other CLI value, and an unknown
    /// name warns instead of failing.
    #[test]
    fn cli_theme_overrides_the_config_preset() {
        let cfg: Config = toml::from_str("[theme]\npreset = \"nord\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.theme_name, "nord");

        let (resolved, warnings) = resolve(&cfg, Some("Dracula"));
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.theme_name, "dracula");

        let (resolved, warnings) = resolve(&cfg, Some("nosuchtheme"));
        assert_eq!(warnings.len(), 1, "{warnings:?}");
        assert!(warnings[0].contains("--theme"), "{warnings:?}");
        assert_eq!(resolved.theme_name, crate::theme::DEFAULT_PRESET);
    }

    #[test]
    fn borders_setting_resolves_onto_the_theme() {
        let cfg: Config = toml::from_str("[ui]\nborders = \"plain\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.theme.panels, Panels::Lines(BorderType::Plain));

        // Unknown value warns and keeps the rounded default; so does no
        // section at all (silently).
        let cfg: Config = toml::from_str("[ui]\nborders = \"fancy\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(warnings.len(), 1, "{warnings:?}");
        assert_eq!(resolved.theme.panels, Panels::Lines(BorderType::Rounded));
        let (resolved, warnings) = resolve(&Config::default(), None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.theme.panels, Panels::Lines(BorderType::Rounded));

        let cfg: Config = toml::from_str("[ui]\nborders = \"none\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.theme.panels, Panels::Surface);
    }

    #[test]
    fn keybindings_section_resolves_per_entry() {
        use crate::keymap::Action;
        use crossterm::event::{KeyCode, KeyEvent};

        // A bare string and a list both parse; the keymap follows.
        let cfg: Config =
            toml::from_str("[keybindings]\nrefresh = \"f5\"\nopen = [\"enter\", \"w\"]").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(
            resolved.keymap.resolve(&KeyEvent::from(KeyCode::F(5))),
            Some(Action::Refresh)
        );
        assert_eq!(
            resolved.keymap.resolve(&KeyEvent::from(KeyCode::Char('w'))),
            Some(Action::Open)
        );
        assert_eq!(
            resolved.keymap.resolve(&KeyEvent::from(KeyCode::Char('r'))),
            None
        );

        // A bad entry warns and keeps the default; the good one still lands.
        let cfg: Config = toml::from_str("[keybindings]\nquit = \"supr\"\ncard = \"u\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(warnings.len(), 2, "{warnings:?}");
        assert_eq!(
            resolved.keymap.resolve(&KeyEvent::from(KeyCode::Char('q'))),
            Some(Action::Quit)
        );
        assert_eq!(
            resolved.keymap.resolve(&KeyEvent::from(KeyCode::Char('u'))),
            Some(Action::Card)
        );

        // No section at all: the untouched defaults.
        let (resolved, warnings) = resolve(&Config::default(), None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(
            resolved.keymap.resolve(&KeyEvent::from(KeyCode::Char('q'))),
            Some(Action::Quit)
        );
    }

    #[test]
    fn chart_section_validates_per_entry() {
        let cfg: Config = toml::from_str(
            r#"
            [chart]
            style = "line"
            sma_fast = 50
            sma_slow = 9000
            rsi_period = 21
            presets = [["1d", "5m"], ["nope", "5m"], ["1y"]]
            "#,
        )
        .unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(resolved.chart.style, ChartStyle::Line);
        assert_eq!(resolved.chart.sma_fast, 50);
        // Out of range: warned, default kept.
        assert_eq!(resolved.chart.sma_slow, indicators::SMA_SLOW);
        assert_eq!(resolved.chart.rsi_period, 21);
        // One valid pair survives; the two bad ones warn.
        assert_eq!(resolved.chart.presets, vec![(Range::D1, Interval::M5)]);
        assert_eq!(warnings.len(), 3, "{warnings:?}");

        // All presets bad: fall back to the built-in cycle.
        let cfg: Config = toml::from_str("[chart]\npresets = [[\"x\", \"y\"]]").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(resolved.chart.presets, RANGE_PRESETS.to_vec());
        assert_eq!(warnings.len(), 2, "{warnings:?}");
    }

    /// The two panel switches and the average kind: a bad value warns and
    /// keeps the default, exactly like `style`.
    #[test]
    fn chart_panel_and_ma_type_validate_per_entry() {
        let cfg: Config = toml::from_str("[chart]\nvolume = false\nma_type = \"EMA\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert!(!resolved.chart.volume);
        assert_eq!(resolved.chart.ma_type, MaType::Ema);

        let cfg: Config = toml::from_str("[chart]\nma_type = \"wilder\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(resolved.chart.ma_type, MaType::Sma);
        assert_eq!(warnings.len(), 1, "{warnings:?}");

        // Absent section: panels on, averages simple.
        let (resolved, _) = resolve(&Config::default(), None);
        assert!(resolved.chart.volume);
        assert_eq!(resolved.chart.ma_type, MaType::Sma);
    }

    #[test]
    fn right_margin_pct_validates_per_entry() {
        // The full accepted span, including 0 (margin off).
        for (raw, want) in [("0", 0u16), ("20", 20), ("50", 50)] {
            let cfg: Config =
                toml::from_str(&format!("[chart]\nright_margin_pct = {raw}")).unwrap();
            let (resolved, warnings) = resolve(&cfg, None);
            assert!(warnings.is_empty(), "at {raw}: {warnings:?}");
            assert_eq!(resolved.chart.right_margin_pct, want, "at {raw}");
        }

        // Out of range (including negatives, which the raw i64 must survive):
        // warn and keep the default.
        for bad in ["-1", "51", "200"] {
            let cfg: Config =
                toml::from_str(&format!("[chart]\nright_margin_pct = {bad}")).expect("must parse");
            let (resolved, warnings) = resolve(&cfg, None);
            assert_eq!(
                resolved.chart.right_margin_pct, DEFAULT_RIGHT_MARGIN_PCT,
                "at {bad}"
            );
            assert_eq!(warnings.len(), 1, "at {bad}: {warnings:?}");
        }

        assert_eq!(
            ChartDefaults::default().right_margin_pct,
            DEFAULT_RIGHT_MARGIN_PCT
        );
    }

    #[test]
    fn ui_section_validates_per_entry() {
        let cfg: Config = toml::from_str(
            "[ui]\ndefault_view = \"News\"\nnews_layout = \"stacked\"\nnews_scope = \"market\"",
        )
        .unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.ui.view_idx, ui::view_index(ui::ViewId::News));
        assert_eq!(resolved.ui.news_layout, NewsLayout::Stacked);
        assert_eq!(resolved.ui.news_scope, NewsScope::Market);

        let cfg: Config = toml::from_str("[ui]\ndefault_view = \"nwes\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(resolved.ui.view_idx, ui::view_index(ui::ViewId::Split));
        assert_eq!(warnings.len(), 1, "{warnings:?}");
        assert!(warnings[0].contains("nwes"), "{warnings:?}");
    }

    #[test]
    fn ui_insider_chart_window_parses_and_warns() {
        for (raw, want) in [
            ("off", InsiderChartWindow::Off),
            ("3m", InsiderChartWindow::M3),
            ("12M", InsiderChartWindow::M12),
        ] {
            let cfg: Config = toml::from_str(&format!("[ui]\ninsider_chart = \"{raw}\"")).unwrap();
            let (resolved, warnings) = resolve(&cfg, None);
            assert!(warnings.is_empty(), "{warnings:?}");
            assert_eq!(resolved.ui.insider_chart, want);
        }
        // Junk warns and keeps the default window.
        let cfg: Config = toml::from_str("[ui]\ninsider_chart = \"6m\"").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert_eq!(warnings.len(), 1, "{warnings:?}");
        assert!(warnings[0].contains("insider_chart"), "{warnings:?}");
        assert_eq!(resolved.ui.insider_chart, InsiderChartWindow::M3);
    }

    #[test]
    fn ui_quote_rail_defaults_on_and_switches_off() {
        assert!(UiDefaults::default().quote_rail);
        let cfg: Config = toml::from_str("[ui]\nquote_rail = false").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert!(!resolved.ui.quote_rail);
    }

    #[test]
    fn ui_mouse_defaults_on_and_switches_off() {
        assert!(UiDefaults::default().mouse);
        let cfg: Config = toml::from_str("[ui]\nmouse = false").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert!(!resolved.ui.mouse);
    }

    #[test]
    fn ui_bare_defaults_off_and_switches_on() {
        assert!(!UiDefaults::default().bare);
        let cfg: Config = toml::from_str("[ui]\nbare = true").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert!(resolved.ui.bare);
    }

    #[test]
    fn news_categories_resolve_with_warnings_per_entry() {
        let resolve_ui = |toml_src: &str| {
            let cfg: Config = toml::from_str(toml_src).expect("must parse");
            let (resolved, warnings) = resolve(&cfg, None);
            (resolved.ui.news_categories, warnings)
        };
        let find = |n: &str| crate::category::find(n).unwrap();

        let (only, warnings) = resolve_ui("[ui]\nnews_categories = [\"earnings\", \"M&A\"]");
        assert_eq!(only, Categories::only([find("earnings"), find("m&a")]));
        assert!(warnings.is_empty(), "{warnings:?}");

        // A bare string is a list of one; any spelling of a name works.
        let (without, warnings) = resolve_ui("[ui]\nnews_exclude_categories = \"macro_economy\"");
        assert_eq!(without, Categories::without([find("macro")]));
        assert!(warnings.is_empty(), "{warnings:?}");

        // Unknown names and non-strings are skipped, the rest still applies.
        let (kept, warnings) =
            resolve_ui("[ui]\nnews_exclude_categories = [\"crypto\", \"weather\", 3]");
        assert_eq!(kept, Categories::without([find("crypto")]));
        assert_eq!(warnings.len(), 2, "{warnings:?}");
        assert!(warnings[0].contains("\"weather\""), "{warnings:?}");

        // Both keys: the narrower one wins, and the clash is reported.
        let (both, warnings) =
            resolve_ui("[ui]\nnews_categories = [\"ipo\"]\nnews_exclude_categories = [\"crypto\"]");
        assert_eq!(both, Categories::only([find("ipo")]));
        assert_eq!(warnings.len(), 1, "{warnings:?}");

        // Empty lists mean no filter, and never count as a clash.
        let (empty, warnings) =
            resolve_ui("[ui]\nnews_categories = []\nnews_exclude_categories = []");
        assert!(empty.is_all());
        assert!(warnings.is_empty(), "{warnings:?}");
    }

    #[test]
    fn news_min_score_validates_per_entry() {
        let cfg: Config =
            toml::from_str("[ui]\nnews_min_score = 4\ninsider_min_score = 8").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.ui.news_min_score, 4);
        assert_eq!(resolved.ui.insider_min_score, 8);

        // Out of range warns and keeps the default; a number outside u8 must
        // not fail the whole file (the raw field is i64 for exactly that).
        for bad in ["0", "11", "300", "-2"] {
            let cfg: Config =
                toml::from_str(&format!("[ui]\nnews_min_score = {bad}")).expect("must parse");
            let (resolved, warnings) = resolve(&cfg, None);
            assert_eq!(
                resolved.ui.news_min_score, DEFAULT_NEWS_MIN_SCORE,
                "at {bad}"
            );
            assert_eq!(warnings.len(), 1, "at {bad}: {warnings:?}");
        }

        let defaults = UiDefaults::default();
        assert_eq!(defaults.news_min_score, DEFAULT_NEWS_MIN_SCORE);
        assert_eq!(defaults.insider_min_score, DEFAULT_INSIDER_MIN_SCORE);
    }

    #[test]
    fn alphai_ttl_validates_per_entry() {
        let cfg: Config = toml::from_str("[ui]\nalphai_ttl_secs = 60").unwrap();
        let (resolved, warnings) = resolve(&cfg, None);
        assert!(warnings.is_empty(), "{warnings:?}");
        assert_eq!(resolved.ui.alphai_ttl, Duration::from_secs(60));

        // Below the budget floor, above a day, or negative: warn and keep
        // the default. "5" is the likely minutes-vs-seconds slip.
        for bad in ["5", "29", "86401", "-300"] {
            let cfg: Config =
                toml::from_str(&format!("[ui]\nalphai_ttl_secs = {bad}")).expect("must parse");
            let (resolved, warnings) = resolve(&cfg, None);
            assert_eq!(resolved.ui.alphai_ttl, alphai::CACHE_TTL, "at {bad}");
            assert_eq!(warnings.len(), 1, "at {bad}: {warnings:?}");
        }

        assert_eq!(UiDefaults::default().alphai_ttl, alphai::CACHE_TTL);
    }

    /// Every ```toml block in the README must parse as a Config and resolve
    /// without warnings, so the documentation cannot rot silently.
    #[test]
    fn readme_toml_examples_parse_and_resolve() {
        let readme = include_str!("../README.md");
        let mut in_block = false;
        let mut block = String::new();
        let mut checked = 0;
        for line in readme.lines() {
            if !in_block && line.trim_start().starts_with("```toml") {
                in_block = true;
                block.clear();
                continue;
            }
            if in_block && line.trim_start().starts_with("```") {
                in_block = false;
                let cfg: Config = toml::from_str(&block)
                    .unwrap_or_else(|e| panic!("README toml does not parse: {e}\n{block}"));
                let (_, warnings) = resolve(&cfg, None);
                assert!(
                    warnings.is_empty(),
                    "README example warns: {warnings:?}\n{block}"
                );
                checked += 1;
                continue;
            }
            if in_block {
                block.push_str(line);
                block.push('\n');
            }
        }
        assert!(
            checked >= 2,
            "expected at least two toml blocks in the README"
        );
    }
}