use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use serde::Deserialize;
use crate::chain::ProviderId;
use crate::error::ConfigError;
pub const REFRESH_MIN: Duration = Duration::from_millis(250);
pub const REFRESH_MAX: Duration = Duration::from_secs(300);
pub const TICK_MIN: Duration = Duration::from_millis(50);
pub const TICK_MAX: Duration = Duration::from_secs(5);
pub const CHANNEL_CAP_MIN: i64 = 64;
pub const CHANNEL_CAP_MAX: i64 = 65_536;
const DEFAULT_REFRESH: Duration = Duration::from_secs(2);
const DEFAULT_TICK: Duration = Duration::from_millis(250);
const DEFAULT_CHANNEL_CAP: usize = 1024;
const DEFAULT_UNDERLYING: &str = "BTC";
const DEFAULT_PROVIDER: &str = "deribit";
const CREDENTIAL_KEYS: [&str; 7] = [
"token",
"username",
"password",
"api_key",
"secret",
"client_secret",
"refresh_token",
];
const ENV_PROVIDER: &str = "CHAINVIEW_PROVIDER";
const ENV_UNDERLYING: &str = "CHAINVIEW_UNDERLYING";
const ENV_REFRESH: &str = "CHAINVIEW_REFRESH";
const ENV_TICK: &str = "CHAINVIEW_TICK";
const ENV_CHANNEL_CAP: &str = "CHAINVIEW_CHANNEL_CAP";
const ENV_LOG_FILE: &str = "CHAINVIEW_LOG_FILE";
const ENV_THEME: &str = "CHAINVIEW_THEME";
const ENV_NO_COLOR: &str = "NO_COLOR";
pub trait EnvSource {
fn get(&self, key: &str) -> Option<String>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ProcessEnv;
impl EnvSource for ProcessEnv {
#[inline]
fn get(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret(***redacted***)")
}
}
#[must_use]
pub fn encode_segment(id: &str) -> String {
let mut out = String::with_capacity(id.len() + 2);
for c in id.chars() {
match c {
'_' => out.push_str("__"),
'-' => out.push('_'),
other => out.push(other.to_ascii_uppercase()),
}
}
out
}
#[must_use]
pub fn decode_segment(seg: &str) -> String {
let lower = seg.to_ascii_lowercase();
let mut out = String::with_capacity(lower.len());
let mut chars = lower.chars().peekable();
while let Some(c) = chars.next() {
if c == '_' {
if chars.peek() == Some(&'_') {
let _ = chars.next();
out.push('_');
} else {
out.push('-');
}
} else {
out.push(c);
}
}
out
}
#[must_use]
pub fn provider_env_var(id: &str, key: &str) -> String {
format!(
"CHAINVIEW_{}_{}",
encode_segment(id),
key.to_ascii_uppercase()
)
}
pub fn require_credentials(
env: &dyn EnvSource,
provider: &ProviderId,
keys: &[&str],
) -> Result<BTreeMap<String, Secret>, ConfigError> {
let mut out = BTreeMap::new();
for key in keys {
let var = provider_env_var(provider.as_str(), key);
match env.get(&var).filter(|v| !v.is_empty()) {
Some(value) => {
let _ = out.insert((*key).to_ascii_uppercase(), Secret::new(value));
}
None => return Err(ConfigError::MissingCredential(provider.clone())),
}
}
Ok(out)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum ThemeChoice {
#[default]
Auto,
Dark,
Light,
}
impl FromStr for ThemeChoice {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Ok(Self::Auto),
"dark" => Ok(Self::Dark),
"light" => Ok(Self::Light),
other => Err(ConfigError::InvalidValue {
field: "theme".to_owned(),
reason: format!("must be one of auto|dark|light, got `{other}`"),
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ModeSelect {
#[default]
Live,
Replay(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ProviderSettings {
pub endpoint: Option<String>,
pub refresh_interval: Option<Duration>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub provider: ProviderId,
pub underlying: String,
pub refresh_interval: Duration,
pub tick_interval: Duration,
pub channel_capacity: usize,
pub log_file: Option<PathBuf>,
pub theme: ThemeChoice,
pub no_color: bool,
pub providers: BTreeMap<ProviderId, ProviderSettings>,
pub mode: ModeSelect,
}
#[derive(Debug, Clone, Default)]
pub struct CliOverrides {
pub provider: Option<String>,
pub underlying: Option<String>,
pub refresh_interval: Option<String>,
pub tick_interval: Option<String>,
pub channel_capacity: Option<i64>,
pub log_file: Option<PathBuf>,
pub theme: Option<String>,
pub no_color: bool,
pub endpoint: Option<String>,
pub mode: ModeSelect,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawFile {
provider: Option<String>,
underlying: Option<String>,
refresh_interval: Option<String>,
tick_interval: Option<String>,
channel_capacity: Option<i64>,
log_file: Option<PathBuf>,
theme: Option<String>,
no_color: Option<bool>,
providers: Option<BTreeMap<String, RawProviderSettings>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawProviderSettings {
endpoint: Option<String>,
refresh_interval: Option<String>,
}
fn is_credential_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
CREDENTIAL_KEYS.contains(&lower.as_str())
}
#[cold]
fn credential_in_file_err(key: &str) -> ConfigError {
ConfigError::InvalidValue {
field: "config file".to_owned(),
reason: format!(
"credential key `{key}` must be set via the environment \
(CHAINVIEW_<ID>_*), never a config file"
),
}
}
fn reject_file_credentials(table: &toml::Table) -> Result<(), ConfigError> {
for key in table.keys() {
if is_credential_key(key) {
return Err(credential_in_file_err(key));
}
}
if let Some(toml::Value::Table(providers)) = table.get("providers") {
for settings in providers.values() {
if let toml::Value::Table(inner) = settings {
for key in inner.keys() {
if is_credential_key(key) {
return Err(credential_in_file_err(key));
}
}
}
}
}
Ok(())
}
fn parse_file(contents: &str) -> Result<RawFile, ConfigError> {
let table: toml::Table =
contents
.parse::<toml::Table>()
.map_err(|e| ConfigError::InvalidValue {
field: "config file".to_owned(),
reason: format!("invalid TOML: {e}"),
})?;
reject_file_credentials(&table)?;
let raw: RawFile = toml::from_str(contents).map_err(|e| ConfigError::InvalidValue {
field: "config file".to_owned(),
reason: e.message().to_owned(),
})?;
Ok(raw)
}
#[must_use = "the validated provider id must be used"]
pub fn validate_provider_id(id: &str) -> Result<ProviderId, ConfigError> {
ProviderId::new(id)
}
fn validate_underlying(raw: &str) -> Result<String, ConfigError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ConfigError::InvalidValue {
field: "underlying".to_owned(),
reason: "must be non-empty".to_owned(),
});
}
Ok(trimmed.to_ascii_uppercase())
}
fn parse_duration_in_range(
raw: &str,
field: &str,
min: Duration,
max: Duration,
) -> Result<Duration, ConfigError> {
let value = humantime::parse_duration(raw.trim()).map_err(|_| ConfigError::InvalidValue {
field: field.to_owned(),
reason: format!("`{raw}` is not a duration (use 250ms, 2s, 5m)"),
})?;
if value < min || value > max {
return Err(ConfigError::InvalidValue {
field: field.to_owned(),
reason: format!(
"must be within [{}, {}]",
humantime::format_duration(min),
humantime::format_duration(max)
),
});
}
Ok(value)
}
fn parse_i64(raw: &str, field: &str) -> Result<i64, ConfigError> {
raw.trim()
.parse::<i64>()
.map_err(|_| ConfigError::InvalidValue {
field: field.to_owned(),
reason: format!("`{raw}` is not an integer"),
})
}
fn validate_channel_capacity(n: i64) -> Result<usize, ConfigError> {
if !(CHANNEL_CAP_MIN..=CHANNEL_CAP_MAX).contains(&n) {
return Err(ConfigError::InvalidValue {
field: "channel_capacity".to_owned(),
reason: format!("must be within [{CHANNEL_CAP_MIN}, {CHANNEL_CAP_MAX}], got {n}"),
});
}
usize::try_from(n).map_err(|_| ConfigError::InvalidValue {
field: "channel_capacity".to_owned(),
reason: "capacity out of range".to_owned(),
})
}
fn validate_log_file(path: PathBuf) -> Result<PathBuf, ConfigError> {
let shown = path.to_string_lossy();
if shown.is_empty() {
return Err(ConfigError::InvalidValue {
field: "log_file".to_owned(),
reason: "must be a non-empty path".to_owned(),
});
}
if matches!(
shown.as_ref(),
"-" | "/dev/stdout" | "/dev/stderr" | "/dev/fd/1" | "/dev/fd/2"
) {
return Err(ConfigError::InvalidValue {
field: "log_file".to_owned(),
reason: "must be a file path, never stdout/stderr".to_owned(),
});
}
Ok(path)
}
fn validate_endpoint(raw: &str, field: &str) -> Result<String, ConfigError> {
let ok = match raw.split_once("://") {
Some((scheme, rest)) => {
!rest.is_empty()
&& matches!(scheme.chars().next(), Some(c) if c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
None => false,
};
if ok {
Ok(raw.to_owned())
} else {
Err(ConfigError::InvalidValue {
field: field.to_owned(),
reason: "must be an absolute URL (scheme://host); a bare host:port socket \
needs a scheme too, e.g. tcp://127.0.0.1:7497"
.to_owned(),
})
}
}
fn endpoint_field(provider: &str) -> String {
format!("providers.{provider}.endpoint")
}
fn assemble_providers(
file_providers: BTreeMap<String, RawProviderSettings>,
selected: &ProviderId,
cli_endpoint: Option<String>,
env: &dyn EnvSource,
) -> Result<BTreeMap<ProviderId, ProviderSettings>, ConfigError> {
let mut out = BTreeMap::new();
for (raw_id, raw) in file_providers {
let id = validate_provider_id(&raw_id)?;
let endpoint = match raw.endpoint {
Some(e) => Some(validate_endpoint(&e, &endpoint_field(id.as_str()))?),
None => None,
};
let refresh_interval = match raw.refresh_interval {
Some(d) => Some(parse_duration_in_range(
&d,
"providers.<id>.refresh_interval",
REFRESH_MIN,
REFRESH_MAX,
)?),
None => None,
};
let _ = out.insert(
id,
ProviderSettings {
endpoint,
refresh_interval,
},
);
}
let env_endpoint = env.get(&provider_env_var(selected.as_str(), "endpoint"));
let env_refresh = env.get(&provider_env_var(selected.as_str(), "refresh"));
let endpoint_override = cli_endpoint.or(env_endpoint);
if endpoint_override.is_some() || env_refresh.is_some() || out.contains_key(selected) {
let entry = out.entry(selected.clone()).or_default();
if let Some(e) = endpoint_override {
entry.endpoint = Some(validate_endpoint(&e, &endpoint_field(selected.as_str()))?);
}
if let Some(d) = env_refresh {
entry.refresh_interval = Some(parse_duration_in_range(
&d,
"providers.<id>.refresh_interval",
REFRESH_MIN,
REFRESH_MAX,
)?);
}
}
Ok(out)
}
impl Config {
pub fn assemble(
cli: CliOverrides,
env: &dyn EnvSource,
file_contents: Option<&str>,
) -> Result<Self, ConfigError> {
let file = match file_contents {
Some(contents) => parse_file(contents)?,
None => RawFile::default(),
};
let replay = matches!(cli.mode, ModeSelect::Replay(_));
let cli_provider = if replay { None } else { cli.provider.clone() };
let cli_underlying = if replay { None } else { cli.underlying.clone() };
let cli_refresh = if replay {
None
} else {
cli.refresh_interval.clone()
};
let cli_endpoint = if replay { None } else { cli.endpoint.clone() };
let provider_raw = cli_provider
.or_else(|| env.get(ENV_PROVIDER))
.or_else(|| file.provider.clone());
let provider = match provider_raw {
Some(s) => validate_provider_id(&s)?,
None => ProviderId::new(DEFAULT_PROVIDER)?,
};
let underlying_raw = cli_underlying
.or_else(|| env.get(ENV_UNDERLYING))
.or_else(|| file.underlying.clone())
.unwrap_or_else(|| DEFAULT_UNDERLYING.to_owned());
let underlying = validate_underlying(&underlying_raw)?;
let refresh_raw = cli_refresh
.or_else(|| env.get(ENV_REFRESH))
.or_else(|| file.refresh_interval.clone());
let refresh_interval = match refresh_raw {
Some(s) => parse_duration_in_range(&s, "refresh_interval", REFRESH_MIN, REFRESH_MAX)?,
None => DEFAULT_REFRESH,
};
let tick_raw = cli
.tick_interval
.clone()
.or_else(|| env.get(ENV_TICK))
.or_else(|| file.tick_interval.clone());
let tick_interval = match tick_raw {
Some(s) => parse_duration_in_range(&s, "tick_interval", TICK_MIN, TICK_MAX)?,
None => DEFAULT_TICK,
};
let cap_raw: Option<i64> = match cli.channel_capacity {
Some(n) => Some(n),
None => match env.get(ENV_CHANNEL_CAP) {
Some(s) => Some(parse_i64(&s, "channel_capacity")?),
None => file.channel_capacity,
},
};
let channel_capacity = match cap_raw {
Some(n) => validate_channel_capacity(n)?,
None => DEFAULT_CHANNEL_CAP,
};
let log_raw = cli
.log_file
.clone()
.or_else(|| env.get(ENV_LOG_FILE).map(PathBuf::from))
.or_else(|| file.log_file.clone());
let log_file = match log_raw {
Some(p) => Some(validate_log_file(p)?),
None => None,
};
let theme_raw = cli
.theme
.clone()
.or_else(|| env.get(ENV_THEME))
.or_else(|| file.theme.clone());
let theme = match theme_raw {
Some(s) => s.parse::<ThemeChoice>()?,
None => ThemeChoice::default(),
};
let no_color = cli.no_color
|| env.get(ENV_NO_COLOR).is_some_and(|v| !v.is_empty())
|| file.no_color.unwrap_or(false);
let providers = assemble_providers(
file.providers.unwrap_or_default(),
&provider,
cli_endpoint,
env,
)?;
Ok(Self {
provider,
underlying,
refresh_interval,
tick_interval,
channel_capacity,
log_file,
theme,
no_color,
providers,
mode: cli.mode,
})
}
pub fn load(cli: CliOverrides) -> Result<Self, ConfigError> {
let env = ProcessEnv;
let contents = read_config_file(&env)?;
Self::assemble(cli, &env, contents.as_deref())
}
}
#[must_use]
pub fn config_file_path(env: &dyn EnvSource) -> Option<PathBuf> {
if let Some(xdg) = env.get("XDG_CONFIG_HOME").filter(|s| !s.is_empty()) {
return Some(PathBuf::from(xdg).join("chainview").join("config.toml"));
}
let home = env.get("HOME").filter(|s| !s.is_empty())?;
Some(
PathBuf::from(home)
.join(".config")
.join("chainview")
.join("config.toml"),
)
}
fn read_config_file(env: &dyn EnvSource) -> Result<Option<String>, ConfigError> {
let Some(path) = config_file_path(env) else {
return Ok(None);
};
match std::fs::read_to_string(&path) {
Ok(contents) => Ok(Some(contents)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(ConfigError::InvalidValue {
field: "config file".to_owned(),
reason: "config file exists but could not be read".to_owned(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MapEnv(BTreeMap<String, String>);
impl EnvSource for MapEnv {
fn get(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
fn env(pairs: &[(&str, &str)]) -> MapEnv {
MapEnv(
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
)
}
fn empty_env() -> MapEnv {
MapEnv(BTreeMap::new())
}
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("expected a valid provider id `{id}`, got: {e}"),
}
}
#[track_caller]
fn assembled(cli: CliOverrides, env: &dyn EnvSource, file: Option<&str>) -> Config {
match Config::assemble(cli, env, file) {
Ok(config) => config,
Err(e) => panic!("expected a valid Config, got error: {e}"),
}
}
fn is_invalid_value(result: &Result<Config, ConfigError>) -> bool {
matches!(result, Err(ConfigError::InvalidValue { .. }))
}
#[test]
fn test_config_zero_config_resolves_deribit_btc() {
let config = assembled(CliOverrides::default(), &empty_env(), None);
assert_eq!(config.provider.as_str(), "deribit");
assert_eq!(config.underlying, "BTC");
assert_eq!(config.refresh_interval, Duration::from_secs(2));
assert_eq!(config.tick_interval, Duration::from_millis(250));
assert_eq!(config.channel_capacity, 1024);
assert_eq!(config.theme, ThemeChoice::Auto);
assert!(!config.no_color);
assert_eq!(config.mode, ModeSelect::Live);
assert!(config.log_file.is_none());
assert!(config.providers.is_empty());
}
#[test]
fn test_config_provider_precedence_cli_over_env_over_file() {
let cli = CliOverrides {
provider: Some("alpaca".to_owned()),
..Default::default()
};
let env = env(&[("CHAINVIEW_PROVIDER", "ig")]);
let file = "provider = \"deribit\"\n";
let config = assembled(cli, &env, Some(file));
assert_eq!(config.provider.as_str(), "alpaca");
}
#[test]
fn test_config_provider_precedence_env_over_file() {
let env = env(&[("CHAINVIEW_PROVIDER", "ig")]);
let file = "provider = \"deribit\"\n";
let config = assembled(CliOverrides::default(), &env, Some(file));
assert_eq!(config.provider.as_str(), "ig");
}
#[test]
fn test_config_provider_from_file_when_no_higher_source() {
let file = "provider = \"tastytrade\"\n";
let config = assembled(CliOverrides::default(), &empty_env(), Some(file));
assert_eq!(config.provider.as_str(), "tastytrade");
}
#[test]
fn test_config_underlying_precedence_and_uppercased() {
let cli = CliOverrides {
underlying: Some("eth".to_owned()),
..Default::default()
};
let env = env(&[("CHAINVIEW_UNDERLYING", "sol")]);
let config = assembled(cli, &env, Some("underlying = \"btc\"\n"));
assert_eq!(config.underlying, "ETH");
}
#[test]
fn test_config_refresh_precedence_cli_over_env_over_file() {
let cli = CliOverrides {
refresh_interval: Some("3s".to_owned()),
..Default::default()
};
let env = env(&[("CHAINVIEW_REFRESH", "10s")]);
let config = assembled(cli, &env, Some("refresh_interval = \"30s\"\n"));
assert_eq!(config.refresh_interval, Duration::from_secs(3));
}
#[test]
fn test_config_tick_precedence_env_over_file() {
let env = env(&[("CHAINVIEW_TICK", "500ms")]);
let config = assembled(
CliOverrides::default(),
&env,
Some("tick_interval = \"1s\"\n"),
);
assert_eq!(config.tick_interval, Duration::from_millis(500));
}
#[test]
fn test_config_channel_capacity_precedence_cli_over_env() {
let cli = CliOverrides {
channel_capacity: Some(2048),
..Default::default()
};
let env = env(&[("CHAINVIEW_CHANNEL_CAP", "4096")]);
let config = assembled(cli, &env, None);
assert_eq!(config.channel_capacity, 2048);
}
#[test]
fn test_config_channel_capacity_from_file() {
let config = assembled(
CliOverrides::default(),
&empty_env(),
Some("channel_capacity = 256\n"),
);
assert_eq!(config.channel_capacity, 256);
}
#[test]
fn test_config_theme_precedence_env_over_file() {
let env = env(&[("CHAINVIEW_THEME", "dark")]);
let config = assembled(CliOverrides::default(), &env, Some("theme = \"light\"\n"));
assert_eq!(config.theme, ThemeChoice::Dark);
}
#[test]
fn test_config_no_color_from_env_presence() {
let env = env(&[("NO_COLOR", "1")]);
let config = assembled(CliOverrides::default(), &env, None);
assert!(config.no_color);
}
#[test]
fn test_config_no_color_empty_env_value_does_not_enable() {
let env = env(&[("NO_COLOR", "")]);
let config = assembled(CliOverrides::default(), &env, None);
assert!(!config.no_color);
}
#[test]
fn test_config_no_color_cli_flag_enables() {
let cli = CliOverrides {
no_color: true,
..Default::default()
};
let config = assembled(cli, &empty_env(), None);
assert!(config.no_color);
}
#[test]
fn test_config_log_file_precedence_cli_over_env() {
let cli = CliOverrides {
log_file: Some(PathBuf::from("/tmp/cli.log")),
..Default::default()
};
let env = env(&[("CHAINVIEW_LOG_FILE", "/tmp/env.log")]);
let config = assembled(cli, &env, None);
assert_eq!(config.log_file, Some(PathBuf::from("/tmp/cli.log")));
}
#[test]
fn test_config_refresh_below_min_rejected() {
let cli = CliOverrides {
refresh_interval: Some("100ms".to_owned()),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_refresh_above_max_rejected() {
let cli = CliOverrides {
refresh_interval: Some("301s".to_owned()),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_tick_out_of_range_rejected() {
let cli = CliOverrides {
tick_interval: Some("10s".to_owned()),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_channel_capacity_out_of_range_rejected() {
let cli = CliOverrides {
channel_capacity: Some(10),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_channel_capacity_negative_rejected() {
let cli = CliOverrides {
channel_capacity: Some(-5),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_invalid_duration_string_rejected() {
let cli = CliOverrides {
refresh_interval: Some("soon".to_owned()),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_channel_capacity_bounds_inclusive() {
for cap in [CHANNEL_CAP_MIN, CHANNEL_CAP_MAX] {
let cli = CliOverrides {
channel_capacity: Some(cap),
..Default::default()
};
let config = assembled(cli, &empty_env(), None);
assert_eq!(config.channel_capacity as i64, cap);
}
}
#[test]
fn test_config_rejects_unknown_file_key() {
let file = "refresh_intervall = \"2s\"\n";
let result = Config::assemble(CliOverrides::default(), &empty_env(), Some(file));
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_rejects_unknown_provider_settings_key() {
let file = "[providers.deribit]\nunexpected = \"x\"\n";
let result = Config::assemble(CliOverrides::default(), &empty_env(), Some(file));
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_rejects_malformed_toml() {
let result = Config::assemble(
CliOverrides::default(),
&empty_env(),
Some("not = valid = toml"),
);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_rejects_credential_key_at_top_level() {
let file = "password = \"hunter2\"\n";
let result = Config::assemble(CliOverrides::default(), &empty_env(), Some(file));
match result {
Err(ConfigError::InvalidValue { field, reason }) => {
assert_eq!(field, "config file");
assert!(reason.contains("password"));
assert!(!reason.contains("hunter2"));
}
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn test_config_rejects_credential_key_in_provider_table() {
let file = "[providers.ig]\napi_key = \"topsecret-value\"\n";
let result = Config::assemble(CliOverrides::default(), &empty_env(), Some(file));
match result {
Err(ConfigError::InvalidValue { field, reason }) => {
assert_eq!(field, "config file");
assert!(reason.contains("api_key"));
assert!(!reason.contains("topsecret-value"));
}
other => panic!("expected InvalidValue, got {other:?}"),
}
}
#[test]
fn test_config_rejects_syntactically_invalid_provider_id() {
let cli = CliOverrides {
provider: Some("Deribit".to_owned()),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
match result {
Err(ConfigError::InvalidValue { field, .. }) => assert_eq!(field, "provider id"),
other => panic!("expected InvalidValue on provider id, got {other:?}"),
}
}
#[test]
fn test_config_accepts_valid_custom_provider_id() {
let cli = CliOverrides {
provider: Some("my-broker".to_owned()),
..Default::default()
};
let config = assembled(cli, &empty_env(), None);
assert_eq!(config.provider.as_str(), "my-broker");
}
#[test]
fn test_validate_provider_id_rejects_leading_digit() {
assert!(validate_provider_id("1broker").is_err());
}
#[test]
fn test_validate_provider_id_rejects_too_short() {
assert!(validate_provider_id("a").is_err());
}
#[test]
fn test_config_replay_subcommand_selects_replay_mode() {
let cli = CliOverrides {
mode: ModeSelect::Replay(PathBuf::from("./run-2026-07-01/")),
..Default::default()
};
let config = assembled(cli, &empty_env(), None);
assert_eq!(
config.mode,
ModeSelect::Replay(PathBuf::from("./run-2026-07-01/"))
);
}
#[test]
fn test_config_replay_ignores_live_only_flags() {
let cli = CliOverrides {
provider: Some("alpaca".to_owned()),
underlying: Some("spy".to_owned()),
refresh_interval: Some("5s".to_owned()),
endpoint: Some("https://example.test".to_owned()),
mode: ModeSelect::Replay(PathBuf::from("./bundle/")),
..Default::default()
};
let config = assembled(cli, &empty_env(), None);
assert_eq!(config.provider.as_str(), "deribit");
assert_eq!(config.underlying, "BTC");
assert_eq!(config.refresh_interval, Duration::from_secs(2));
assert!(config.providers.is_empty());
}
#[test]
fn test_config_endpoint_precedence_cli_over_env_over_file() {
let cli = CliOverrides {
provider: Some("deribit".to_owned()),
endpoint: Some("https://cli.example".to_owned()),
..Default::default()
};
let env = env(&[("CHAINVIEW_DERIBIT_ENDPOINT", "https://env.example")]);
let file = "[providers.deribit]\nendpoint = \"https://file.example\"\n";
let config = assembled(cli, &env, Some(file));
let settings = match config.providers.get(&pid("deribit")) {
Some(s) => s,
None => panic!("expected deribit provider settings"),
};
assert_eq!(settings.endpoint.as_deref(), Some("https://cli.example"));
}
#[test]
fn test_config_rejects_relative_endpoint() {
let cli = CliOverrides {
endpoint: Some("example.com/api".to_owned()),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
#[test]
fn test_config_accepts_socket_endpoint_in_absolute_url_form() {
let url_env = env(&[("CHAINVIEW_IBKR_ENDPOINT", "tcp://127.0.0.1:7497")]);
let cli = CliOverrides {
provider: Some("ibkr".to_owned()),
..Default::default()
};
let config = assembled(cli, &url_env, None);
let settings = match config.providers.get(&pid("ibkr")) {
Some(s) => s,
None => panic!("expected ibkr provider settings"),
};
assert_eq!(settings.endpoint.as_deref(), Some("tcp://127.0.0.1:7497"));
let bare = env(&[("CHAINVIEW_IBKR_ENDPOINT", "127.0.0.1:7497")]);
let cli = CliOverrides {
provider: Some("ibkr".to_owned()),
..Default::default()
};
match Config::assemble(cli, &bare, None) {
Err(ConfigError::InvalidValue { field, reason }) => {
assert_eq!(field, "providers.ibkr.endpoint");
assert!(reason.contains("tcp://127.0.0.1:7497"), "reason: {reason}");
assert!(reason.contains("host:port"), "reason: {reason}");
}
other => panic!("expected InvalidValue on the ibkr endpoint, got {other:?}"),
}
}
#[test]
fn test_config_per_provider_env_refresh_override() {
let env = env(&[("CHAINVIEW_DERIBIT_REFRESH", "45s")]);
let config = assembled(CliOverrides::default(), &env, None);
let settings = match config.providers.get(&pid("deribit")) {
Some(s) => s,
None => panic!("expected deribit provider settings"),
};
assert_eq!(settings.refresh_interval, Some(Duration::from_secs(45)));
}
#[test]
fn test_config_external_id_endpoint_from_env() {
let env = env(&[("CHAINVIEW_MY_BROKER_ENDPOINT", "https://mybroker.example")]);
let cli = CliOverrides {
provider: Some("my-broker".to_owned()),
..Default::default()
};
let config = assembled(cli, &env, None);
let settings = match config.providers.get(&pid("my-broker")) {
Some(s) => s,
None => panic!("expected my-broker provider settings"),
};
assert_eq!(
settings.endpoint.as_deref(),
Some("https://mybroker.example")
);
}
#[test]
fn test_config_external_id_refresh_from_env() {
let env = env(&[("CHAINVIEW_MY_BROKER_REFRESH", "12s")]);
let cli = CliOverrides {
provider: Some("my-broker".to_owned()),
..Default::default()
};
let config = assembled(cli, &env, None);
let settings = match config.providers.get(&pid("my-broker")) {
Some(s) => s,
None => panic!("expected my-broker provider settings"),
};
assert_eq!(settings.refresh_interval, Some(Duration::from_secs(12)));
}
#[test]
fn test_config_external_id_uses_bijection_env_var_name() {
assert_eq!(
provider_env_var("my-broker", "endpoint"),
"CHAINVIEW_MY_BROKER_ENDPOINT"
);
assert_eq!(
provider_env_var("my_broker", "refresh"),
"CHAINVIEW_MY__BROKER_REFRESH"
);
}
#[test]
fn test_require_credentials_external_id_resolves_through_bijection() {
let env = env(&[("CHAINVIEW_MY_BROKER_TOKEN", "ext-token-123")]);
let provider = pid("my-broker");
match require_credentials(&env, &provider, &["token"]) {
Ok(map) => assert_eq!(map.get("TOKEN").map(Secret::expose), Some("ext-token-123")),
Err(e) => panic!("expected external credentials, got {e}"),
}
}
#[test]
fn test_encode_segment_maps_hyphen_and_underscore() {
assert_eq!(encode_segment("deribit"), "DERIBIT");
assert_eq!(encode_segment("my-broker"), "MY_BROKER");
assert_eq!(encode_segment("my_broker"), "MY__BROKER");
}
#[test]
fn test_decode_segment_inverts_encode() {
assert_eq!(decode_segment("DERIBIT"), "deribit");
assert_eq!(decode_segment("MY_BROKER"), "my-broker");
assert_eq!(decode_segment("MY__BROKER"), "my_broker");
}
#[test]
fn test_encode_decode_no_collision_between_hyphen_and_underscore() {
assert_ne!(encode_segment("my-broker"), encode_segment("my_broker"));
}
#[test]
fn test_provider_id_grammar_resolves_adjacent_separator_collision() {
assert_eq!(encode_segment("a--"), encode_segment("a_")); assert!(ProviderId::new("a--").is_err()); assert!(ProviderId::new("a_").is_err());
for id in ["deribit", "my-broker", "my_broker", "td-ameritrade"] {
assert_eq!(decode_segment(&encode_segment(id)), id);
}
}
#[test]
fn test_provider_env_var_builds_shell_safe_name() {
assert_eq!(provider_env_var("ig", "username"), "CHAINVIEW_IG_USERNAME");
assert_eq!(
provider_env_var("my-broker", "token"),
"CHAINVIEW_MY_BROKER_TOKEN"
);
}
#[test]
fn test_require_credentials_present_reads_from_env() {
let env = env(&[
("CHAINVIEW_IG_USERNAME", "alice"),
("CHAINVIEW_IG_PASSWORD", "correct horse"),
("CHAINVIEW_IG_API_KEY", "key-123"),
]);
let provider = pid("ig");
let result = require_credentials(&env, &provider, &["username", "password", "api_key"]);
match result {
Ok(map) => {
assert_eq!(map.get("USERNAME").map(Secret::expose), Some("alice"));
assert_eq!(
map.get("PASSWORD").map(Secret::expose),
Some("correct horse")
);
assert_eq!(map.get("API_KEY").map(Secret::expose), Some("key-123"));
}
Err(e) => panic!("expected credentials, got {e}"),
}
}
#[test]
fn test_require_credentials_missing_returns_missing_credential() {
let env = env(&[("CHAINVIEW_IG_USERNAME", "alice")]);
let provider = pid("ig");
let result = require_credentials(&env, &provider, &["username", "password"]);
match result {
Err(ConfigError::MissingCredential(p)) => assert_eq!(p.as_str(), "ig"),
other => panic!("expected MissingCredential, got {other:?}"),
}
}
#[test]
fn test_require_credentials_empty_value_treated_as_missing() {
let env = env(&[("CHAINVIEW_IG_PASSWORD", "")]);
let provider = pid("ig");
let result = require_credentials(&env, &provider, &["password"]);
assert!(matches!(result, Err(ConfigError::MissingCredential(_))));
}
#[test]
fn test_secret_debug_is_redacted() {
let secret = Secret::new("super-secret-value");
let rendered = format!("{secret:?}");
assert_eq!(rendered, "Secret(***redacted***)");
assert!(!rendered.contains("super-secret-value"));
}
#[test]
fn test_no_credential_value_leaks_when_present() {
const SECRET_VALUE: &str = "tRoub4dor&3-do-not-log";
let env = env(&[
("CHAINVIEW_PROVIDER", "ig"),
("CHAINVIEW_IG_PASSWORD", SECRET_VALUE),
]);
let config = assembled(CliOverrides::default(), &env, None);
assert!(!format!("{config:?}").contains(SECRET_VALUE));
let provider = pid("ig");
match require_credentials(&env, &provider, &["password"]) {
Ok(map) => {
assert!(!format!("{map:?}").contains(SECRET_VALUE));
assert_eq!(map.get("PASSWORD").map(Secret::expose), Some(SECRET_VALUE));
}
Err(e) => panic!("expected credentials, got {e}"),
}
let missing = require_credentials(&env, &provider, &["username"]);
match missing {
Err(e) => {
let rendered = e.to_string();
assert!(rendered.contains("ig"));
assert!(!rendered.contains(SECRET_VALUE));
assert!(!format!("{e:?}").contains(SECRET_VALUE));
}
Ok(_) => panic!("expected MissingCredential"),
}
}
#[test]
fn test_config_file_path_prefers_xdg_config_home() {
let env = env(&[("XDG_CONFIG_HOME", "/xdg"), ("HOME", "/home/user")]);
assert_eq!(
config_file_path(&env),
Some(PathBuf::from("/xdg/chainview/config.toml"))
);
}
#[test]
fn test_config_file_path_falls_back_to_home() {
let env = env(&[("HOME", "/home/user")]);
assert_eq!(
config_file_path(&env),
Some(PathBuf::from("/home/user/.config/chainview/config.toml"))
);
}
#[test]
fn test_config_file_path_none_without_home() {
assert_eq!(config_file_path(&empty_env()), None);
}
#[test]
fn test_theme_choice_from_str_accepts_known() {
assert_eq!("auto".parse::<ThemeChoice>().ok(), Some(ThemeChoice::Auto));
assert_eq!("DARK".parse::<ThemeChoice>().ok(), Some(ThemeChoice::Dark));
assert_eq!(
" light ".parse::<ThemeChoice>().ok(),
Some(ThemeChoice::Light)
);
}
#[test]
fn test_theme_choice_from_str_rejects_unknown() {
assert!("neon".parse::<ThemeChoice>().is_err());
}
#[test]
fn test_config_rejects_stdout_log_file() {
let cli = CliOverrides {
log_file: Some(PathBuf::from("/dev/stdout")),
..Default::default()
};
let result = Config::assemble(cli, &empty_env(), None);
assert!(is_invalid_value(&result));
}
}