use crate::curfew::WeekendMode;
use crate::types::{Currency, FailSafeMode, Impact};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RedFolderConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_currencies")]
pub currencies: Vec<Currency>,
#[serde(default = "default_impacts")]
pub impacts: Vec<Impact>,
#[serde(default = "default_30")]
pub before_min: i64,
#[serde(default = "default_30")]
pub after_min: i64,
#[serde(default = "default_30")]
pub merge_threshold_min: i64,
#[serde(default = "default_true", alias = "friday_night_enabled")]
pub weekend_enabled: bool,
#[serde(default = "default_weekend_start", alias = "friday_night_start")]
pub weekend_start: String,
#[serde(default = "default_weekend_end", alias = "friday_night_end")]
pub weekend_end: String,
#[serde(default = "default_weekend_mode", alias = "friday_night_mode")]
pub weekend_mode: WeekendMode,
#[serde(default)]
pub warning_before_min: Option<i64>,
#[serde(default)]
pub include_all_day: bool,
#[serde(default)]
pub include_tentative: bool,
#[serde(default)]
pub fail_safe_mode: FailSafeMode,
}
fn default_true() -> bool {
true
}
fn default_30() -> i64 {
30
}
fn default_currencies() -> Vec<Currency> {
vec![Currency::USD]
}
fn default_impacts() -> Vec<Impact> {
vec![Impact::High]
}
fn default_weekend_start() -> String {
"20:30".to_string()
}
fn default_weekend_end() -> String {
"21:00".to_string()
}
fn default_weekend_mode() -> WeekendMode {
WeekendMode::Short
}
impl Default for RedFolderConfig {
fn default() -> Self {
Self {
enabled: true,
currencies: default_currencies(),
impacts: default_impacts(),
before_min: 30,
after_min: 30,
merge_threshold_min: 30,
weekend_enabled: true,
weekend_start: default_weekend_start(),
weekend_end: default_weekend_end(),
weekend_mode: default_weekend_mode(),
warning_before_min: None,
include_all_day: false,
include_tentative: false,
fail_safe_mode: FailSafeMode::FailOpen,
}
}
}
impl RedFolderConfig {
#[must_use]
pub fn builder() -> RedFolderConfigBuilder {
RedFolderConfigBuilder::default()
}
#[must_use]
pub fn prop_firm_strict() -> Self {
Self {
enabled: true,
currencies: vec![
Currency::USD,
Currency::EUR,
Currency::GBP,
Currency::JPY,
Currency::CAD,
Currency::AUD,
Currency::NZD,
Currency::CHF,
],
impacts: vec![Impact::High],
before_min: 5,
after_min: 5,
merge_threshold_min: 15,
weekend_enabled: true,
weekend_start: "20:00".into(),
weekend_end: "21:00".into(),
weekend_mode: WeekendMode::Weekend,
warning_before_min: Some(15),
include_all_day: false,
include_tentative: false,
fail_safe_mode: FailSafeMode::FailClosed,
}
}
#[must_use]
pub fn conservative() -> Self {
Self {
enabled: true,
currencies: vec![Currency::USD, Currency::EUR, Currency::GBP],
impacts: vec![Impact::High, Impact::Medium],
before_min: 30,
after_min: 30,
merge_threshold_min: 30,
weekend_enabled: true,
weekend_start: "20:30".into(),
weekend_end: "21:00".into(),
weekend_mode: WeekendMode::Weekend,
warning_before_min: Some(30),
include_all_day: false,
include_tentative: false,
fail_safe_mode: FailSafeMode::FailClosed,
}
}
pub fn validate(&self) -> crate::error::Result<()> {
if self.currencies.is_empty() {
return Err(crate::error::RedFolderError::Config(
"currencies filter cannot be empty; specify at least one currency or 'All'"
.to_string(),
));
}
for c in &self.currencies {
if c.as_str().trim().is_empty() {
return Err(crate::error::RedFolderError::Config(
"currency filter cannot contain empty or blank strings".to_string(),
));
}
}
if self.impacts.is_empty() {
return Err(crate::error::RedFolderError::Config(
"impacts filter cannot be empty; specify at least one impact level (e.g. 'High')"
.to_string(),
));
}
for i in &self.impacts {
if i.to_string().trim().is_empty() {
return Err(crate::error::RedFolderError::Config(
"impact filter cannot contain empty or blank strings".to_string(),
));
}
}
if self.before_min < 0 {
return Err(crate::error::RedFolderError::Config(format!(
"before_min cannot be negative (got {})",
self.before_min
)));
}
if self.after_min < 0 {
return Err(crate::error::RedFolderError::Config(format!(
"after_min cannot be negative (got {})",
self.after_min
)));
}
if self.merge_threshold_min < 0 {
return Err(crate::error::RedFolderError::Config(format!(
"merge_threshold_min cannot be negative (got {})",
self.merge_threshold_min
)));
}
if let Some(warn) = self.warning_before_min {
if warn < 0 {
return Err(crate::error::RedFolderError::Config(format!(
"warning_before_min cannot be negative (got {})",
warn
)));
}
}
crate::curfew::parse_time(&self.weekend_start)?;
if self.weekend_mode == WeekendMode::Short || !self.weekend_end.is_empty() {
crate::curfew::parse_time(&self.weekend_end)?;
}
Ok(())
}
pub fn validate_strict(&self) -> crate::error::Result<()> {
self.validate()?;
for c in &self.currencies {
if let Currency::Custom(ref s) = c {
return Err(crate::error::RedFolderError::Config(format!(
"unknown non-standard currency '{}' rejected in strict mode",
s
)));
}
}
for i in &self.impacts {
if let Impact::Custom(ref s) = i {
return Err(crate::error::RedFolderError::Config(format!(
"unknown non-standard impact '{}' rejected in strict mode",
s
)));
}
}
Ok(())
}
#[must_use]
pub fn typed_currencies(&self) -> &[Currency] {
&self.currencies
}
#[must_use]
pub fn typed_impacts(&self) -> &[Impact] {
&self.impacts
}
pub fn typed_weekend_mode(&self) -> crate::error::Result<WeekendMode> {
Ok(self.weekend_mode)
}
#[must_use]
pub fn currencies_as_strings(&self) -> Vec<String> {
self.currencies.iter().map(|c| c.to_string()).collect()
}
#[must_use]
pub fn impacts_as_strings(&self) -> Vec<String> {
self.impacts.iter().map(|i| i.to_string()).collect()
}
}
pub trait IntoWeekendMode {
fn into_weekend_mode(self) -> crate::error::Result<WeekendMode>;
}
impl IntoWeekendMode for WeekendMode {
fn into_weekend_mode(self) -> crate::error::Result<WeekendMode> {
Ok(self)
}
}
impl IntoWeekendMode for &str {
fn into_weekend_mode(self) -> crate::error::Result<WeekendMode> {
self.parse()
}
}
impl IntoWeekendMode for &String {
fn into_weekend_mode(self) -> crate::error::Result<WeekendMode> {
self.as_str().parse()
}
}
impl IntoWeekendMode for String {
fn into_weekend_mode(self) -> crate::error::Result<WeekendMode> {
self.parse()
}
}
#[derive(Debug, Default)]
pub struct RedFolderConfigBuilder {
config: RedFolderConfig,
custom_currencies: bool,
custom_impacts: bool,
mode_err: Option<String>,
}
impl RedFolderConfigBuilder {
#[must_use]
pub fn enabled(mut self, enabled: bool) -> Self {
self.config.enabled = enabled;
self
}
#[must_use]
pub fn currencies<I, S>(mut self, currencies: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Currency>,
{
self.config.currencies = currencies.into_iter().map(Into::into).collect();
self.custom_currencies = true;
self
}
#[must_use]
pub fn currency(mut self, currency: impl Into<Currency>) -> Self {
if !self.custom_currencies {
self.config.currencies.clear();
self.custom_currencies = true;
}
self.config.currencies.push(currency.into());
self
}
#[must_use]
pub fn impacts<I, S>(mut self, impacts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Impact>,
{
self.config.impacts = impacts.into_iter().map(Into::into).collect();
self.custom_impacts = true;
self
}
#[must_use]
pub fn impact(mut self, impact: impl Into<Impact>) -> Self {
if !self.custom_impacts {
self.config.impacts.clear();
self.custom_impacts = true;
}
self.config.impacts.push(impact.into());
self
}
#[must_use]
pub fn buffer_minutes(mut self, before: i64, after: i64) -> Self {
self.config.before_min = before;
self.config.after_min = after;
self
}
#[must_use]
pub fn merge_threshold(mut self, minutes: i64) -> Self {
self.config.merge_threshold_min = minutes;
self
}
#[must_use]
pub fn weekend_curfew(
mut self,
enabled: bool,
start_utc: impl Into<String>,
end_utc: impl Into<String>,
mode: impl IntoWeekendMode,
) -> Self {
self.config.weekend_enabled = enabled;
self.config.weekend_start = start_utc.into();
self.config.weekend_end = end_utc.into();
match mode.into_weekend_mode() {
Ok(m) => {
self.config.weekend_mode = m;
self.mode_err = None;
}
Err(e) => {
self.mode_err = Some(e.to_string());
}
}
self
}
#[must_use]
pub fn weekend_mode_typed(mut self, mode: WeekendMode) -> Self {
self.config.weekend_mode = mode;
self.mode_err = None;
self
}
#[must_use]
pub fn warning_minutes(mut self, minutes: i64) -> Self {
self.config.warning_before_min = Some(minutes);
self
}
#[must_use]
pub fn include_all_day(mut self, include: bool) -> Self {
self.config.include_all_day = include;
self
}
#[must_use]
pub fn include_tentative(mut self, include: bool) -> Self {
self.config.include_tentative = include;
self
}
#[must_use]
pub fn fail_safe_mode(mut self, mode: FailSafeMode) -> Self {
self.config.fail_safe_mode = mode;
self
}
#[must_use]
pub fn build(self) -> RedFolderConfig {
self.try_build().expect(
"invalid RedFolderConfig parameters: use try_build() for fallible initialization",
)
}
pub fn try_build(self) -> crate::error::Result<RedFolderConfig> {
if let Some(err) = self.mode_err {
return Err(crate::error::RedFolderError::Curfew(err));
}
self.config.validate()?;
Ok(self.config)
}
pub fn try_build_strict(self) -> crate::error::Result<RedFolderConfig> {
if let Some(err) = self.mode_err {
return Err(crate::error::RedFolderError::Curfew(err));
}
self.config.validate_strict()?;
Ok(self.config)
}
#[must_use]
pub fn build_strict(self) -> RedFolderConfig {
self.try_build_strict().expect(
"invalid RedFolderConfig parameters in strict mode: use try_build_strict() for fallible initialization",
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_defaults() {
let cfg = RedFolderConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.currencies, vec!["USD"]);
assert_eq!(cfg.impacts, vec!["High"]);
assert_eq!(cfg.before_min, 30);
assert_eq!(cfg.after_min, 30);
assert!(cfg.weekend_enabled);
assert_eq!(cfg.weekend_mode, "short");
}
#[test]
fn test_deserialization_with_legacy_aliases() {
let legacy_json = r#"{
"enabled": true,
"friday_night_enabled": true,
"friday_night_start": "20:00",
"friday_night_end": "22:00",
"friday_night_mode": "weekend"
}"#;
let cfg: RedFolderConfig = serde_json::from_str(legacy_json).unwrap();
assert!(cfg.weekend_enabled);
assert_eq!(cfg.weekend_start, "20:00");
assert_eq!(cfg.weekend_end, "22:00");
assert_eq!(cfg.weekend_mode, "weekend");
}
#[test]
fn test_builder() {
let cfg = RedFolderConfig::builder()
.currencies(vec!["EUR", "USD"])
.impacts(vec!["High"])
.buffer_minutes(10, 10)
.weekend_curfew(true, "19:00", "23:00", "weekend")
.build();
assert_eq!(cfg.currencies, vec!["EUR", "USD"]);
assert_eq!(cfg.before_min, 10);
assert_eq!(cfg.after_min, 10);
assert_eq!(cfg.weekend_start, "19:00");
}
#[test]
fn test_typed_builder() {
use crate::curfew::WeekendMode;
let cfg = RedFolderConfig::builder()
.currency(Currency::USD)
.currency(Currency::EUR)
.impact(Impact::High)
.buffer_minutes(15, 15)
.weekend_curfew(true, "20:00", "21:00", WeekendMode::Weekend)
.build();
assert_eq!(cfg.currencies, vec!["USD", "EUR"]);
assert_eq!(cfg.impacts, vec!["High"]);
assert_eq!(cfg.weekend_mode, "weekend");
}
#[test]
fn test_semantic_validation_rejects_blank_items() {
let bad_cur = RedFolderConfig::builder()
.currencies(vec!["USD", " "])
.try_build();
assert!(bad_cur.is_err());
assert!(bad_cur
.unwrap_err()
.to_string()
.contains("currency filter cannot contain empty or blank strings"));
let bad_imp = RedFolderConfig::builder()
.impacts(vec!["High", ""])
.try_build();
assert!(bad_imp.is_err());
assert!(bad_imp
.unwrap_err()
.to_string()
.contains("impact filter cannot contain empty or blank strings"));
}
#[test]
fn test_validate_strict() {
let custom_cfg = RedFolderConfig::builder()
.currencies(vec!["USD", "XAU"])
.impacts(vec!["High"])
.build();
assert!(
custom_cfg.validate().is_ok(),
"custom currency should be allowed in default mode"
);
let strict_err = custom_cfg.validate_strict();
assert!(
strict_err.is_err(),
"custom currency should be rejected in strict mode"
);
assert!(strict_err
.unwrap_err()
.to_string()
.contains("rejected in strict mode"));
let standard_cfg = RedFolderConfig::builder()
.currencies(vec!["USD", "EUR"])
.impacts(vec!["High", "Medium"])
.build();
assert!(standard_cfg.validate_strict().is_ok());
}
#[test]
fn test_builder_strict_methods() {
let ok_cfg = RedFolderConfig::builder()
.currencies(vec!["USD", "EUR"])
.impacts(vec!["High"])
.try_build_strict();
assert!(ok_cfg.is_ok());
let err_cfg = RedFolderConfig::builder()
.currencies(vec!["USDD"])
.impacts(vec!["High"])
.try_build_strict();
assert!(err_cfg.is_err());
assert!(err_cfg
.unwrap_err()
.to_string()
.contains("unknown non-standard currency 'USDD'"));
let built = RedFolderConfig::builder()
.currencies(vec!["GBP"])
.impacts(vec!["High"])
.build_strict();
assert_eq!(built.currencies, vec!["GBP"]);
}
#[test]
fn test_typed_helpers() {
let cfg = RedFolderConfig::builder()
.currencies(vec!["USD", "EUR"])
.impacts(vec!["High", "Medium"])
.weekend_mode_typed(crate::curfew::WeekendMode::Weekend)
.build();
assert_eq!(cfg.typed_currencies(), vec![Currency::USD, Currency::EUR]);
assert_eq!(cfg.typed_impacts(), vec![Impact::High, Impact::Medium]);
assert_eq!(
cfg.typed_weekend_mode().unwrap(),
crate::curfew::WeekendMode::Weekend
);
}
#[test]
fn test_weekend_validation_when_disabled() {
let bad_start = RedFolderConfig::builder()
.weekend_curfew(false, "99:99", "21:00", "short")
.try_build();
assert!(
bad_start.is_err(),
"invalid start time must fail validation even when weekend_enabled is false"
);
let bad_mode = RedFolderConfig::builder()
.weekend_curfew(false, "20:00", "21:00", "bogus_mode")
.try_build();
assert!(
bad_mode.is_err(),
"invalid weekend mode must fail validation even when weekend_enabled is false"
);
let bad_end = RedFolderConfig::builder()
.weekend_curfew(false, "20:00", "25:99", "short")
.try_build();
assert!(
bad_end.is_err(),
"invalid end time must fail validation even when weekend_enabled is false"
);
}
#[test]
fn test_fail_safe_mode_config() {
let default_cfg = RedFolderConfig::default();
assert_eq!(
default_cfg.fail_safe_mode,
crate::types::FailSafeMode::FailOpen
);
let prop_firm = RedFolderConfig::prop_firm_strict();
assert_eq!(
prop_firm.fail_safe_mode,
crate::types::FailSafeMode::FailClosed
);
let conservative = RedFolderConfig::conservative();
assert_eq!(
conservative.fail_safe_mode,
crate::types::FailSafeMode::FailClosed
);
let custom = RedFolderConfig::builder()
.fail_safe_mode(crate::types::FailSafeMode::FailClosed)
.build();
assert_eq!(
custom.fail_safe_mode,
crate::types::FailSafeMode::FailClosed
);
}
}