use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::color::Color;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct FlowConfig {
pub columns_per_screen: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_width: Option<u32>,
pub min_column_width_px: u32,
pub min_window_height_px: u32,
pub padding: Padding,
pub animation: AnimationConfig,
pub minimize_restore: MinimizeRestore,
pub borders: BorderConfig,
pub floating: FloatingConfig,
pub focus: FocusConfig,
pub check_for_updates: bool,
}
fn default_window_action() -> WindowAction {
WindowAction::Float
}
impl Default for FlowConfig {
fn default() -> Self {
Self {
columns_per_screen: 4,
column_width: None,
min_column_width_px: 640,
min_window_height_px: 100,
padding: Padding::default(),
animation: AnimationConfig::default(),
minimize_restore: MinimizeRestore::default(),
borders: BorderConfig::default(),
floating: FloatingConfig::default(),
focus: FocusConfig::default(),
check_for_updates: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct Padding {
pub window_gap: i32,
pub up: i32,
pub down: i32,
}
impl Default for Padding {
fn default() -> Self {
Self {
window_gap: 16,
up: 0,
down: 0,
}
}
}
impl Padding {
pub fn validate(&self) -> Result<(), String> {
if self.window_gap < 0 {
return Err(format!(
"padding.window_gap must be non-negative, got {}",
self.window_gap
));
}
if self.up < 0 {
return Err(format!("padding.up must be non-negative, got {}", self.up));
}
if self.down < 0 {
return Err(format!(
"padding.down must be non-negative, got {}",
self.down
));
}
Ok(())
}
}
impl FlowConfig {
pub fn validate(&self) -> Result<(), String> {
self.padding.validate()?;
self.borders.validate()?;
if self.columns_per_screen == 0 {
return Err("columns_per_screen must be at least 1, got 0".into());
}
if self.min_column_width_px == 0 {
return Err("min_column_width_px must be positive, got 0".into());
}
if self.min_window_height_px == 0 {
return Err("min_window_height_px must be positive, got 0".into());
}
if let Some(cw) = self.column_width
&& self.min_column_width_px > cw
{
return Err(format!(
"min_column_width_px ({}) must not exceed column_width ({})",
self.min_column_width_px, cw
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WindowRule {
#[serde(rename = "match")]
pub match_: MatchRule,
pub action: WindowAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_width_px: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub override_persist: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct MatchRule {
#[serde(skip_serializing_if = "Option::is_none")]
pub exe: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title_contains: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title_regex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub process_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exe_regex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub class_regex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub process_path_regex: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum WindowAction {
Tile,
Float,
Ignore,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WindowRulesConfig {
pub default_action: WindowAction,
#[serde(default)]
pub rules: Vec<WindowRule>,
}
impl Default for WindowRulesConfig {
fn default() -> Self {
Self {
default_action: default_window_action(),
rules: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ConfigEasing {
Linear,
EaseInSine,
EaseOutSine,
EaseInOutSine,
EaseInQuad,
EaseOutQuad,
EaseInOutQuad,
EaseInCubic,
EaseOutCubic,
EaseInOutCubic,
EaseInQuart,
EaseOutQuart,
EaseInOutQuart,
EaseInQuint,
EaseOutQuint,
EaseInOutQuint,
EaseInExpo,
#[default]
EaseOutExpo,
EaseInOutExpo,
EaseInCirc,
EaseOutCirc,
EaseInOutCirc,
EaseInBack,
EaseOutBack,
EaseInOutBack,
EaseInElastic,
EaseOutElastic,
EaseInOutElastic,
EaseInBounce,
EaseOutBounce,
EaseInOutBounce,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct AnimationConfig {
pub enabled: bool,
pub duration_ms: u32,
pub easing: ConfigEasing,
}
impl Default for AnimationConfig {
fn default() -> Self {
Self {
enabled: true,
duration_ms: 240,
easing: ConfigEasing::EaseOutExpo,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct MinimizeRestore {
pub strategy: MinimizeRestoreStrategy,
}
impl Default for MinimizeRestore {
fn default() -> Self {
Self {
strategy: MinimizeRestoreStrategy::OriginalSlot,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MinimizeRestoreStrategy {
OriginalSlot,
RightOfFocused,
AppendRight,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct BorderConfig {
pub enabled: bool,
pub thickness: u32,
pub overlap: u32,
pub focused_color: Color,
pub unfocused_color: Color,
pub floating_color: Color,
}
impl Default for BorderConfig {
fn default() -> Self {
Self {
enabled: true,
thickness: 3,
overlap: 1,
focused_color: Color::rgb(0x00, 0xAA, 0xFF),
unfocused_color: Color::rgb(0x55, 0x55, 0x55),
floating_color: Color::rgb(0xAA, 0x00, 0xFF),
}
}
}
impl BorderConfig {
pub fn validate(&self) -> Result<(), String> {
const MAX_THICKNESS: u32 = 50;
if self.thickness > MAX_THICKNESS {
return Err(format!(
"borders.thickness must be at most {MAX_THICKNESS} px, got {}",
self.thickness
));
}
if self.overlap > self.thickness {
return Err(format!(
"borders.overlap must be at most borders.thickness ({}), got {}",
self.thickness, self.overlap
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct FocusConfig {
pub foreground_sync_interval_ms: u64,
}
impl Default for FocusConfig {
fn default() -> Self {
Self {
foreground_sync_interval_ms: 250,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct FloatingConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_width: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_height: Option<i32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_roundtrips_toml() {
let config = FlowConfig::default();
let toml_str = toml::to_string(&config).expect("serialize");
let parsed: FlowConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.columns_per_screen, 4);
assert_eq!(parsed.column_width, None);
assert_eq!(parsed.min_column_width_px, 640);
assert_eq!(parsed.min_window_height_px, 100);
assert_eq!(parsed.padding.window_gap, 16);
assert_eq!(parsed.padding.up, 0);
assert_eq!(parsed.padding.down, 0);
assert_eq!(parsed.animation.duration_ms, 240);
assert_eq!(parsed.animation.easing, ConfigEasing::EaseOutExpo);
}
#[test]
fn config_from_toml_with_settings() {
let toml_str = r#"
columns_per_screen = 3
column_width = 1200
min_column_width_px = 400
[padding]
window_gap = 8
up = 10
down = 40
[animation]
enabled = false
duration_ms = 180
easing = "ease-out-expo"
[minimize_restore]
strategy = "original_slot"
"#;
let config: FlowConfig = toml::from_str(toml_str).expect("parse");
assert_eq!(config.columns_per_screen, 3);
assert_eq!(config.column_width, Some(1200));
assert_eq!(config.padding.window_gap, 8);
assert_eq!(config.padding.up, 10);
assert_eq!(config.padding.down, 40);
assert!(!config.animation.enabled);
}
#[test]
fn config_from_empty_toml_uses_defaults() {
let config: FlowConfig = toml::from_str("").expect("empty TOML should use defaults");
assert_eq!(config, FlowConfig::default());
}
#[test]
fn config_from_partial_toml_uses_defaults() {
let config: FlowConfig =
toml::from_str("columns_per_screen = 3\n").expect("partial TOML should parse");
assert_eq!(config.columns_per_screen, 3);
assert_eq!(config.min_column_width_px, 640);
assert_eq!(config.padding.window_gap, 16);
assert_eq!(config.animation.duration_ms, 240);
}
#[test]
fn config_from_nested_partial_toml_uses_defaults() {
let toml_str = "[padding]\nwindow_gap = 20\n";
let config: FlowConfig = toml::from_str(toml_str).expect("nested-partial should parse");
assert_eq!(config.padding.window_gap, 20);
assert_eq!(config.padding.up, 0);
assert_eq!(config.padding.down, 0);
assert_eq!(config.columns_per_screen, 4);
}
#[test]
fn config_from_nested_partial_animation_uses_defaults() {
let toml_str = "[animation]\nduration_ms = 500\n";
let config: FlowConfig =
toml::from_str(toml_str).expect("nested-partial animation should parse");
assert_eq!(config.animation.duration_ms, 500);
assert!(
config.animation.enabled,
"animation.enabled should default to true"
);
assert_eq!(
config.animation.easing,
ConfigEasing::EaseOutExpo,
"animation.easing should default to ease-out-expo"
);
}
#[test]
fn default_config_toml_matches_compiled_defaults() {
let example: &str = include_str!("../../default-config.toml");
let parsed: FlowConfig =
toml::from_str(example).expect("default-config.toml must parse as FlowConfig");
assert_eq!(
parsed,
FlowConfig::default(),
"default-config.toml drifted from compiled defaults; \
update one to match the other"
);
}
#[test]
fn focus_config_default_interval_is_250ms() {
assert_eq!(FocusConfig::default().foreground_sync_interval_ms, 250);
}
#[test]
fn config_roundtrip_preserves_all_fields() {
let config = FlowConfig {
columns_per_screen: 3,
column_width: Some(1200),
min_column_width_px: 400,
min_window_height_px: 120,
padding: Padding {
window_gap: 6,
up: 10,
down: 40,
},
animation: AnimationConfig {
enabled: false,
duration_ms: 250,
easing: ConfigEasing::EaseInOutCubic,
},
minimize_restore: MinimizeRestore {
strategy: MinimizeRestoreStrategy::AppendRight,
},
borders: BorderConfig {
enabled: false,
thickness: 7,
overlap: 2,
focused_color: Color::rgb(0x10, 0x20, 0x30),
unfocused_color: Color::rgb(0x40, 0x50, 0x60),
floating_color: Color::rgb(0x70, 0x80, 0x90),
},
floating: FloatingConfig {
default_width: Some(1200),
default_height: Some(800),
},
focus: FocusConfig {
foreground_sync_interval_ms: 400,
},
check_for_updates: false,
};
let toml_str = toml::to_string(&config).expect("serialize all fields");
let parsed: FlowConfig = toml::from_str(&toml_str).expect("deserialize all fields");
assert_eq!(parsed.columns_per_screen, 3);
assert_eq!(parsed.column_width, Some(1200));
assert_eq!(parsed.min_column_width_px, 400);
assert_eq!(parsed.min_window_height_px, 120);
assert_eq!(parsed.padding.window_gap, 6);
assert_eq!(parsed.padding.up, 10);
assert_eq!(parsed.padding.down, 40);
assert!(!parsed.animation.enabled);
assert_eq!(parsed.animation.duration_ms, 250);
assert_eq!(parsed.animation.easing, ConfigEasing::EaseInOutCubic);
assert_eq!(
parsed.minimize_restore.strategy,
MinimizeRestoreStrategy::AppendRight
);
assert!(!parsed.borders.enabled);
assert_eq!(parsed.borders.thickness, 7);
assert_eq!(parsed.borders.overlap, 2);
assert_eq!(parsed.borders.focused_color, Color::rgb(0x10, 0x20, 0x30));
assert_eq!(parsed.borders.unfocused_color, Color::rgb(0x40, 0x50, 0x60));
assert_eq!(parsed.borders.floating_color, Color::rgb(0x70, 0x80, 0x90));
assert_eq!(parsed.floating.default_width, Some(1200));
assert_eq!(parsed.floating.default_height, Some(800));
assert_eq!(parsed.focus.foreground_sync_interval_ms, 400);
assert!(!parsed.check_for_updates);
}
#[test]
fn check_for_updates_defaults_to_true() {
assert!(FlowConfig::default().check_for_updates);
}
#[test]
fn config_validate_rejects_negative_window_padding() {
let config = FlowConfig {
padding: Padding {
window_gap: -1,
..Padding::default()
},
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(
config
.validate()
.unwrap_err()
.contains("padding.window_gap")
);
}
#[test]
fn config_validate_rejects_negative_up_padding() {
let config = FlowConfig {
padding: Padding {
up: -5,
..Padding::default()
},
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(config.validate().unwrap_err().contains("padding.up"));
}
#[test]
fn config_validate_rejects_negative_down_padding() {
let config = FlowConfig {
padding: Padding {
down: -10,
..Padding::default()
},
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(config.validate().unwrap_err().contains("padding.down"));
}
#[test]
fn config_validate_accepts_zero_padding() {
let config = FlowConfig {
padding: Padding {
window_gap: 0,
up: 0,
down: 0,
},
..FlowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn config_validate_accepts_default_config() {
assert!(FlowConfig::default().validate().is_ok());
}
#[test]
fn config_validate_rejects_overlap_exceeding_thickness() {
let config = FlowConfig {
borders: BorderConfig {
thickness: 3,
overlap: 4,
..BorderConfig::default()
},
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(config.validate().unwrap_err().contains("borders.overlap"));
}
#[test]
fn config_validate_accepts_overlap_equal_to_thickness() {
let config = FlowConfig {
borders: BorderConfig {
thickness: 3,
overlap: 3,
..BorderConfig::default()
},
..FlowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn border_config_default_overlap_is_one() {
assert_eq!(BorderConfig::default().overlap, 1);
}
#[test]
fn config_validate_accepts_overlap_zero() {
let config = FlowConfig {
borders: BorderConfig {
thickness: 3,
overlap: 0,
..BorderConfig::default()
},
..FlowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn config_overlap_zero_roundtrips_toml() {
let config = BorderConfig {
thickness: 5,
overlap: 0,
..BorderConfig::default()
};
let toml_str = toml::to_string(&config).expect("serialize");
let parsed: BorderConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.overlap, 0);
assert_eq!(parsed.thickness, 5);
}
#[test]
fn config_validate_rejects_zero_min_column_width() {
let config = FlowConfig {
min_column_width_px: 0,
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(
config
.validate()
.unwrap_err()
.contains("min_column_width_px")
);
}
#[test]
fn config_validate_rejects_zero_min_window_height() {
let config = FlowConfig {
min_window_height_px: 0,
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(
config
.validate()
.unwrap_err()
.contains("min_window_height_px"),
"error message must name the offending field"
);
}
#[test]
fn config_validate_rejects_min_exceeding_column_width() {
let config = FlowConfig {
min_column_width_px: 1000,
column_width: Some(960),
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(
config
.validate()
.unwrap_err()
.contains("min_column_width_px")
);
}
#[test]
fn config_validate_accepts_min_equal_to_column_width() {
let config = FlowConfig {
min_column_width_px: 960,
column_width: Some(960),
..FlowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn config_validate_rejects_zero_columns_per_screen() {
let config = FlowConfig {
columns_per_screen: 0,
..FlowConfig::default()
};
assert!(config.validate().is_err());
assert!(
config
.validate()
.unwrap_err()
.contains("columns_per_screen")
);
}
#[test]
fn config_validate_accepts_column_width_none() {
let config = FlowConfig {
column_width: None,
min_column_width_px: 9999,
..FlowConfig::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn window_rules_config_default_roundtrips() {
let config = WindowRulesConfig::default();
let toml_str = toml::to_string(&config).expect("serialize");
let parsed: WindowRulesConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.default_action, WindowAction::Float);
assert!(parsed.rules.is_empty());
}
#[test]
fn window_rules_config_from_toml() {
let toml_str = r#"
default_action = "float"
[[rules]]
match = { exe = "explorer.exe", title_contains = "Open" }
action = "ignore"
[[rules]]
match = { class = "Chrome_WidgetWin_1" }
action = "tile"
initial_width_px = 960
"#;
let config: WindowRulesConfig = toml::from_str(toml_str).expect("parse");
assert_eq!(config.default_action, WindowAction::Float);
assert_eq!(config.rules.len(), 2);
assert_eq!(config.rules[0].action, WindowAction::Ignore);
assert_eq!(config.rules[1].initial_width_px, Some(960));
}
#[test]
fn window_rules_config_minimal_toml() {
let toml_str = "default_action = \"float\"\n";
let config: WindowRulesConfig = toml::from_str(toml_str).expect("parse");
assert_eq!(config.default_action, WindowAction::Float);
assert!(config.rules.is_empty());
}
#[test]
fn window_rules_config_empty_toml_is_rejected() {
let toml_str = "";
let result = toml::from_str::<WindowRulesConfig>(toml_str);
assert!(
result.is_err(),
"empty TOML should fail without serde defaults on default_action"
);
}
#[test]
fn window_rules_config_with_regex_fields_roundtrips() {
let config = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![WindowRule {
match_: MatchRule {
exe: None,
title: None,
title_contains: None,
title_regex: Some("^Settings".into()),
class: Some("SettingsApp".into()),
class_regex: Some("Settings.*".into()),
process_path: None,
exe_regex: Some("chrome\\.exe".into()),
process_path_regex: Some(".*\\\\Google\\\\Chrome\\\\.*".into()),
},
action: WindowAction::Float,
initial_width_px: None,
override_persist: false,
}],
};
let toml_str = toml::to_string(&config).expect("serialize");
let parsed: WindowRulesConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.default_action, WindowAction::Ignore);
assert_eq!(parsed.rules.len(), 1);
assert_eq!(
parsed.rules[0].match_.exe_regex,
Some("chrome\\.exe".into())
);
assert_eq!(
parsed.rules[0].match_.class_regex,
Some("Settings.*".into())
);
assert_eq!(
parsed.rules[0].match_.process_path_regex,
Some(".*\\\\Google\\\\Chrome\\\\.*".into())
);
}
#[test]
fn window_rules_config_all_window_actions_parse() {
for action_str in ["tile", "float", "ignore"] {
let toml_str = format!("default_action = \"{action_str}\"");
let config: WindowRulesConfig = toml::from_str(&toml_str).expect(&toml_str);
assert_eq!(
config.default_action,
match action_str {
"tile" => WindowAction::Tile,
"float" => WindowAction::Float,
"ignore" => WindowAction::Ignore,
_ => unreachable!(),
},
"action mismatch for {action_str}"
);
}
}
#[test]
fn window_rules_config_invalid_enum_rejects() {
let toml_str = r#"
default_action = "foobar"
"#;
let result = toml::from_str::<WindowRulesConfig>(toml_str);
assert!(result.is_err(), "invalid enum value should reject");
}
#[test]
fn config_easing_roundtrips_all_variants() {
for (variant, expected_kebab) in [
(ConfigEasing::Linear, "linear"),
(ConfigEasing::EaseInSine, "ease-in-sine"),
(ConfigEasing::EaseOutSine, "ease-out-sine"),
(ConfigEasing::EaseInOutSine, "ease-in-out-sine"),
(ConfigEasing::EaseInQuad, "ease-in-quad"),
(ConfigEasing::EaseOutQuad, "ease-out-quad"),
(ConfigEasing::EaseInOutQuad, "ease-in-out-quad"),
(ConfigEasing::EaseInCubic, "ease-in-cubic"),
(ConfigEasing::EaseOutCubic, "ease-out-cubic"),
(ConfigEasing::EaseInOutCubic, "ease-in-out-cubic"),
(ConfigEasing::EaseInQuart, "ease-in-quart"),
(ConfigEasing::EaseOutQuart, "ease-out-quart"),
(ConfigEasing::EaseInOutQuart, "ease-in-out-quart"),
(ConfigEasing::EaseInQuint, "ease-in-quint"),
(ConfigEasing::EaseOutQuint, "ease-out-quint"),
(ConfigEasing::EaseInOutQuint, "ease-in-out-quint"),
(ConfigEasing::EaseInExpo, "ease-in-expo"),
(ConfigEasing::EaseOutExpo, "ease-out-expo"),
(ConfigEasing::EaseInOutExpo, "ease-in-out-expo"),
(ConfigEasing::EaseInCirc, "ease-in-circ"),
(ConfigEasing::EaseOutCirc, "ease-out-circ"),
(ConfigEasing::EaseInOutCirc, "ease-in-out-circ"),
(ConfigEasing::EaseInBack, "ease-in-back"),
(ConfigEasing::EaseOutBack, "ease-out-back"),
(ConfigEasing::EaseInOutBack, "ease-in-out-back"),
(ConfigEasing::EaseInElastic, "ease-in-elastic"),
(ConfigEasing::EaseOutElastic, "ease-out-elastic"),
(ConfigEasing::EaseInOutElastic, "ease-in-out-elastic"),
(ConfigEasing::EaseInBounce, "ease-in-bounce"),
(ConfigEasing::EaseOutBounce, "ease-out-bounce"),
(ConfigEasing::EaseInOutBounce, "ease-in-out-bounce"),
] {
let wrapper = AnimationConfig {
easing: variant,
..AnimationConfig::default()
};
let toml_str =
toml::to_string(&wrapper).unwrap_or_else(|_| panic!("serialize {expected_kebab}"));
assert!(
toml_str.contains(&format!("easing = \"{expected_kebab}\"")),
"serialization should contain 'easing = \"{expected_kebab}\"', got:\n{toml_str}"
);
let parsed: AnimationConfig = toml::from_str(&toml_str)
.unwrap_or_else(|_| panic!("deserialize {expected_kebab}"));
assert_eq!(
parsed.easing, variant,
"round-trip mismatch for {expected_kebab}"
);
}
}
#[test]
fn config_easing_invalid_string_is_rejected() {
let invalid_values = [
"ease-out-exp", "linear ", "Linear", "EASE-OUT-EXPO", "ease_out_expo", "foobar", ];
for bad in &invalid_values {
let result = toml::from_str::<ConfigEasing>(bad);
assert!(
result.is_err(),
"expected rejection for invalid easing value: {bad:?}"
);
}
}
#[test]
fn match_rule_with_new_regex_fields_serializes_correctly() {
let rule = MatchRule {
exe_regex: Some("msedge\\.exe".into()),
class_regex: Some("Edge.*".into()),
process_path_regex: Some(".*\\\\Microsoft\\\\Edge.*".into()),
..Default::default()
};
let toml_str = toml::to_string(&rule).expect("serialize");
let parsed: MatchRule = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(parsed.exe_regex, Some("msedge\\.exe".into()));
assert_eq!(parsed.class_regex, Some("Edge.*".into()));
assert_eq!(
parsed.process_path_regex,
Some(".*\\\\Microsoft\\\\Edge.*".into())
);
}
#[test]
fn floating_config_default_is_none_none() {
let cfg = FloatingConfig::default();
assert_eq!(cfg.default_width, None);
assert_eq!(cfg.default_height, None);
}
#[test]
fn floating_config_explicit_values_roundtrip() {
let toml_str = r#"
[floating]
default_width = 1200
default_height = 800
"#;
let parsed: FlowConfig = toml::from_str(toml_str).expect("parse");
assert_eq!(parsed.floating.default_width, Some(1200));
assert_eq!(parsed.floating.default_height, Some(800));
let reserialized = toml::to_string(&parsed).expect("serialize");
let reparsed: FlowConfig = toml::from_str(&reserialized).expect("deserialize");
assert_eq!(reparsed.floating.default_width, Some(1200));
assert_eq!(reparsed.floating.default_height, Some(800));
}
#[test]
fn floating_config_omitted_keys_parse_to_none() {
let toml_str = "[floating]\n";
let parsed: FlowConfig = toml::from_str(toml_str).expect("parse");
assert_eq!(parsed.floating.default_width, None);
assert_eq!(parsed.floating.default_height, None);
assert_eq!(parsed, FlowConfig::default());
}
#[test]
fn floating_config_partial_width_only_preserves_height_none() {
let toml_str = "[floating]\ndefault_width = 1000\n";
let parsed: FlowConfig = toml::from_str(toml_str).expect("parse");
assert_eq!(parsed.floating.default_width, Some(1000));
assert_eq!(parsed.floating.default_height, None);
}
}