use std::fmt;
use std::io;
use std::path::Path;
use serde::de::DeserializeOwned;
use super::schema;
use super::types::{FlowConfig, WindowRulesConfig};
use crate::common::{FlowError, FlowResult};
pub const DEFAULT_CONFIG_EXAMPLE: &str = include_str!("../../default-config.toml");
pub const DEFAULT_RULES_TOML: &str = include_str!("../../default-flow-rules.toml");
pub const DEFAULT_AHK_TEMPLATE: &str = include_str!("../../flow.ahk");
const FLOW_RULES_SCHEMA_HEADER: &str = "#:schema ./schemas/flow-rules.schema.json\n\n";
enum ConfigLoadError {
Missing,
Io(io::Error),
Parse(toml::de::Error),
}
impl fmt::Display for ConfigLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Missing => write!(f, "file not found"),
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Parse(e) => write!(f, "parse error: {e}"),
}
}
}
fn load_toml<T: DeserializeOwned>(path: &Path) -> Result<T, ConfigLoadError> {
let contents = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
return Err(ConfigLoadError::Missing);
}
Err(e) => return Err(ConfigLoadError::Io(e)),
};
toml::from_str(&contents).map_err(ConfigLoadError::Parse)
}
pub fn load_app_config(path: &Path) -> FlowResult<FlowConfig> {
match load_toml::<FlowConfig>(path) {
Ok(config) => {
if let Err(warning) = config.validate() {
log::warn!("config validation warning for {:?}: {warning}", path);
}
log::info!("loaded app config from {:?}", path);
Ok(config)
}
Err(ConfigLoadError::Missing) => {
log::debug!("app config not found at {:?}; using defaults", path);
Ok(FlowConfig::default())
}
Err(e) => Err(FlowError::Config(format!(
"failed to load config {}: {e}",
path.display()
))),
}
}
pub fn load_rules_config(path: &Path) -> FlowResult<WindowRulesConfig> {
match load_toml::<WindowRulesConfig>(path) {
Ok(config) => {
log::info!("loaded window rules from {:?}", path);
Ok(config)
}
Err(ConfigLoadError::Missing) => {
log::debug!("rules config not found at {:?}; using defaults", path);
Ok(WindowRulesConfig::default())
}
Err(e) => Err(FlowError::Config(format!(
"failed to load config {}: {e}",
path.display()
))),
}
}
#[must_use]
pub fn load_default_rules() -> WindowRulesConfig {
match toml::from_str::<WindowRulesConfig>(DEFAULT_RULES_TOML) {
Ok(config) => {
log::info!(
"loaded bundled default window rules ({} rules)",
config.rules.len()
);
config
}
Err(e) => {
log::error!("failed to parse bundled default rules: {e}; using empty defaults");
WindowRulesConfig::default()
}
}
}
#[must_use = "initialization errors must be handled"]
pub fn init_config_dir(dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("failed to create config directory {:?}: {e}", dir))?;
log::info!("ensuring config directory exists: {:?}", dir);
let schemas_dir = dir.join("schemas");
std::fs::create_dir_all(&schemas_dir)
.map_err(|e| format!("failed to create schemas directory {:?}: {e}", schemas_dir))?;
let flow_toml_content = DEFAULT_CONFIG_EXAMPLE;
let default_rules_toml = toml::to_string(&WindowRulesConfig::default())
.map_err(|e| format!("failed to serialize default WindowRulesConfig: {e}"))?;
let flow_toml = dir.join("flow.toml");
match write_default_config_file(&flow_toml, flow_toml_content) {
Ok(written) => {
if written {
log::info!("wrote example config to {:?}", flow_toml);
}
}
Err(e) => {
return Err(format!(
"failed to write default config {:?}: {e}",
flow_toml
));
}
}
let rules_toml = dir.join("flow-rules.toml");
let rules_content = format!("{FLOW_RULES_SCHEMA_HEADER}{default_rules_toml}");
match write_default_config_file(&rules_toml, &rules_content) {
Ok(written) => {
if written {
log::info!("wrote default rules to {:?}", rules_toml);
}
}
Err(e) => {
return Err(format!(
"failed to write default rules {:?}: {e}",
rules_toml
));
}
}
let config_schema_path = schemas_dir.join("flow-config.schema.json");
match schema::generate_config_schema() {
Ok(schema_json) => match write_default_config_file(&config_schema_path, &schema_json) {
Ok(true) => log::info!("wrote config schema to {:?}", config_schema_path),
Ok(false) => log::debug!(
"config schema already exists, skipping {:?}",
config_schema_path
),
Err(e) => log::warn!(
"failed to write config schema {:?}: {e}",
config_schema_path
),
},
Err(e) => {
log::warn!("failed to generate config schema: {e}");
}
}
let rules_schema_path = schemas_dir.join("flow-rules.schema.json");
match schema::generate_rules_schema() {
Ok(schema_json) => match write_default_config_file(&rules_schema_path, &schema_json) {
Ok(true) => log::info!("wrote rules schema to {:?}", rules_schema_path),
Ok(false) => log::debug!(
"rules schema already exists, skipping {:?}",
rules_schema_path
),
Err(e) => {
log::warn!("failed to write rules schema {:?}: {e}", rules_schema_path);
}
},
Err(e) => {
log::warn!("failed to generate rules schema: {e}");
}
}
Ok(())
}
#[must_use = "initialization errors must be handled"]
pub fn write_ahk_template(dir: &Path) -> Result<bool, String> {
let ahk_path = dir.join("flow.ahk");
match write_default_config_file(&ahk_path, DEFAULT_AHK_TEMPLATE) {
Ok(true) => {
log::info!("wrote AutoHotkey template to {:?}", ahk_path);
Ok(true)
}
Ok(false) => {
log::debug!("flow.ahk already exists, skipping {:?}", ahk_path);
Ok(false)
}
Err(e) => Err(format!(
"failed to write AutoHotkey template {:?}: {e}",
ahk_path
)),
}
}
#[must_use = "validation errors must be handled"]
pub fn check_config(dir: &Path) -> Result<(), String> {
let flow_path = dir.join("flow.toml");
if flow_path.exists() {
let contents = std::fs::read_to_string(&flow_path)
.map_err(|e| format!("failed to read {flow_path:?}: {e}"))?;
let config: FlowConfig =
toml::from_str(&contents).map_err(|e| format!("flow.toml parse error: {e}"))?;
config
.validate()
.map_err(|e| format!("flow.toml validation error: {e}"))?;
}
let rules_path = dir.join("flow-rules.toml");
if rules_path.exists() {
let contents = std::fs::read_to_string(&rules_path)
.map_err(|e| format!("failed to read {:?}: {e}", rules_path))?;
let _: WindowRulesConfig =
toml::from_str(&contents).map_err(|e| format!("flow-rules.toml parse error: {e}"))?;
}
Ok(())
}
fn write_default_config_file(path: &Path, content: &str) -> std::io::Result<bool> {
if path.exists() {
return Ok(false);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, content)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::types::WindowAction;
use std::io::Write;
use tempfile::NamedTempFile;
use tempfile::TempDir;
#[test]
fn load_app_config_valid_file_parses_correctly() {
let toml_content = 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 = 200
easing = "linear"
[minimize_restore]
strategy = "original_slot"
"#;
let mut f = NamedTempFile::new().unwrap();
f.write_all(toml_content.as_bytes()).unwrap();
let config = load_app_config(f.path()).unwrap();
assert_eq!(config.columns_per_screen, 3);
assert_eq!(config.column_width, Some(1200));
assert_eq!(config.min_column_width_px, 400);
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 load_app_config_missing_file_returns_default() {
let path = std::path::PathBuf::from("C:\\__nonexistent_test_path__\\flow.toml");
let config = load_app_config(&path).unwrap();
assert_eq!(config, FlowConfig::default());
assert_eq!(config.min_column_width_px, 640);
assert_eq!(config.padding.window_gap, 16);
assert_eq!(config.padding.up, 0);
assert_eq!(config.padding.down, 0);
}
#[test]
fn load_app_config_malformed_toml_returns_error() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"this is = not = valid = toml = [[[[").unwrap();
let result = load_app_config(f.path());
assert!(
result.is_err(),
"malformed flow.toml should propagate an error"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to load config"),
"error should describe the failure: {err}"
);
}
#[test]
fn load_app_config_empty_toml_returns_default() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"").unwrap();
let config = load_app_config(f.path()).unwrap();
assert_eq!(config, FlowConfig::default());
assert_eq!(config.padding.window_gap, 16);
}
#[test]
fn load_app_config_partial_toml_merges_with_defaults() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"columns_per_screen = 2\n").unwrap();
let config = load_app_config(f.path()).unwrap();
assert_eq!(
config.columns_per_screen, 2,
"user-specified field must be preserved"
);
assert_eq!(config.min_column_width_px, 640);
assert_eq!(config.padding.window_gap, 16);
assert_eq!(config.animation.duration_ms, 240);
}
#[test]
fn load_app_config_directory_path_propagates_io_error() {
let tmp = TempDir::new().unwrap();
let dir_path = tmp.path();
let result = load_app_config(dir_path);
assert!(
result.is_err(),
"reading a directory should propagate an I/O error, not return default"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to load config"),
"error should describe the failure: {err}"
);
assert!(
err.contains(&dir_path.display().to_string()),
"error should name the offending path: {err}"
);
assert!(
!err.contains("parse error"),
"directory read should surface as I/O, not parse: {err}"
);
}
#[test]
fn load_app_config_parse_error_message_includes_path() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"this is = not = valid = toml = [[[[").unwrap();
let path_str = f.path().display().to_string();
let result = load_app_config(f.path());
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to load config"),
"error should describe the failure: {err}"
);
assert!(
err.contains(&path_str),
"error should name the offending file path ({path_str}): {err}"
);
assert!(
err.contains("parse error"),
"malformed TOML should surface as a parse error: {err}"
);
}
#[test]
fn init_config_dir_creates_directory_and_files() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let result = init_config_dir(dir);
assert!(result.is_ok(), "init_config_dir failed: {result:?}");
assert!(dir.is_dir());
assert!(
dir.join("schemas").is_dir(),
"schemas/ subdirectory should be created"
);
assert!(
dir.join("flow.toml").exists(),
"flow.toml should be created"
);
assert!(
dir.join("flow-rules.toml").exists(),
"flow-rules.toml should be created"
);
assert!(
dir.join("schemas/flow-config.schema.json").exists(),
"schemas/flow-config.schema.json should be created"
);
assert!(
dir.join("schemas/flow-rules.schema.json").exists(),
"schemas/flow-rules.schema.json should be created"
);
let contents = std::fs::read_to_string(dir.join("flow.toml")).unwrap();
assert!(
contents.contains("#:schema"),
"flow.toml should contain taplo schema header"
);
let parsed: FlowConfig = toml::from_str(&contents).expect("flow.toml should parse");
assert_eq!(
parsed,
FlowConfig::default(),
"init should write an example that matches compiled defaults"
);
assert!(
contents.contains("min_column_width_px = 640"),
"flow.toml should be the full example, not an empty stub"
);
let contents = std::fs::read_to_string(dir.join("flow-rules.toml")).unwrap();
assert!(
contents.contains("#:schema"),
"flow-rules.toml should contain taplo schema header"
);
let rules: WindowRulesConfig = toml::from_str(&contents).unwrap();
assert_eq!(rules.default_action, WindowAction::Float);
}
#[test]
fn init_config_dir_does_not_overwrite_existing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let custom_content = "column_width = 9999\n";
std::fs::write(dir.join("flow.toml"), custom_content).unwrap();
let result = init_config_dir(dir);
assert!(result.is_ok(), "init_config_dir failed: {result:?}");
let contents = std::fs::read_to_string(dir.join("flow.toml")).unwrap();
assert_eq!(contents, custom_content);
assert!(contents.contains("column_width = 9999"));
}
#[test]
fn write_ahk_template_creates_file() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let result = write_ahk_template(dir);
assert_eq!(result, Ok(true), "should report newly written");
let ahk = dir.join("flow.ahk");
assert!(ahk.exists(), "flow.ahk should be created");
let contents = std::fs::read_to_string(&ahk).unwrap();
assert_eq!(contents, DEFAULT_AHK_TEMPLATE);
assert!(
contents.contains("#Requires AutoHotkey v2.0.2"),
"flow.ahk should be the AutoHotkey v2 template"
);
}
#[test]
fn write_ahk_template_does_not_overwrite_existing() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let custom = "# custom user script\n";
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("flow.ahk"), custom).unwrap();
let result = write_ahk_template(dir);
assert_eq!(result, Ok(false), "should report already-existed");
let contents = std::fs::read_to_string(dir.join("flow.ahk")).unwrap();
assert_eq!(contents, custom);
}
#[test]
fn check_config_valid_directory_returns_ok() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
init_config_dir(dir).expect("init should succeed");
let result = check_config(dir);
assert!(result.is_ok(), "check_config failed: {result:?}");
}
#[test]
fn check_config_invalid_app_config_returns_err() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("flow.toml"), "[padding]\nwindow_gap = -1\n").unwrap();
let result = check_config(dir);
assert!(
result.is_err(),
"check_config should reject negative padding"
);
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("padding.window_gap"),
"error should mention the invalid field: {err_msg}"
);
}
#[test]
fn check_config_empty_directory_returns_ok() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let result = check_config(dir);
assert!(result.is_ok(), "empty directory should pass check_config");
}
#[test]
fn check_config_partial_toml_returns_ok() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("flow.toml"), "column_width = 800\n").unwrap();
let result = check_config(dir);
assert!(
result.is_ok(),
"partial flow.toml should pass check_config (merged with defaults): {result:?}"
);
}
#[test]
fn check_config_malformed_app_toml_returns_err() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("flow.toml"),
b"this is = not = valid = toml = [[[[",
)
.unwrap();
let result = check_config(dir);
assert!(
result.is_err(),
"malformed flow.toml should cause check_config to fail"
);
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("flow.toml parse error"),
"error message should identify parse failure: {err_msg}"
);
}
#[test]
fn check_config_malformed_rules_returns_err() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(
dir.join("flow-rules.toml"),
b"this is = not = valid = toml = [[[[",
)
.unwrap();
let result = check_config(dir);
assert!(
result.is_err(),
"malformed flow-rules.toml should cause check_config to fail"
);
let err_msg = result.unwrap_err();
assert!(
err_msg.contains("flow-rules.toml"),
"error message should identify the rules file: {err_msg}"
);
}
#[test]
fn load_rules_config_valid_file_parses_correctly() {
let toml_content = 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 mut f = NamedTempFile::new().unwrap();
f.write_all(toml_content.as_bytes()).unwrap();
let config = load_rules_config(f.path()).unwrap();
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 load_rules_config_missing_file_returns_default() {
let path = std::path::PathBuf::from("C:\\__nonexistent_test_path__\\flow-rules.toml");
let config = load_rules_config(&path).unwrap();
assert_eq!(config.default_action, WindowAction::Float);
assert!(config.rules.is_empty());
}
#[test]
fn load_rules_config_malformed_toml_returns_error() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"this is = not = valid = toml = [[[[").unwrap();
let result = load_rules_config(f.path());
assert!(
result.is_err(),
"malformed flow-rules.toml should propagate an error"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to load config"),
"error should describe the failure: {err}"
);
}
#[test]
fn load_rules_config_empty_toml_returns_error() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"").unwrap();
let result = load_rules_config(f.path());
assert!(
result.is_err(),
"empty flow-rules.toml should propagate an error (default_action is required)"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to load config"),
"error should describe the failure: {err}"
);
}
#[test]
fn load_rules_config_roundtrips_regex_fields() {
use crate::config::types::{MatchRule, WindowRule, WindowRulesConfig};
let config = WindowRulesConfig {
default_action: WindowAction::Ignore,
rules: vec![WindowRule {
match_: MatchRule {
exe_regex: Some("chrome\\.exe".into()),
class_regex: Some("Chrome.*".into()),
process_path_regex: Some(".*\\\\Chrome\\\\.*".into()),
..Default::default()
},
action: WindowAction::Tile,
initial_width_px: None,
override_persist: false,
}],
};
let toml_str = toml::to_string(&config).unwrap();
let mut f = NamedTempFile::new().unwrap();
f.write_all(toml_str.as_bytes()).unwrap();
let loaded = load_rules_config(f.path()).unwrap();
assert_eq!(loaded.default_action, WindowAction::Ignore);
assert_eq!(loaded.rules.len(), 1);
assert_eq!(
loaded.rules[0].match_.exe_regex,
Some("chrome\\.exe".into())
);
}
#[test]
fn load_rules_config_directory_path_propagates_io_error() {
let tmp = TempDir::new().unwrap();
let dir_path = tmp.path();
let result = load_rules_config(dir_path);
assert!(
result.is_err(),
"reading a directory should propagate an I/O error, not return default"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to load config"),
"error should describe the failure: {err}"
);
assert!(
err.contains(&dir_path.display().to_string()),
"error should name the offending path: {err}"
);
assert!(
!err.contains("parse error"),
"directory read should surface as I/O, not parse: {err}"
);
}
#[test]
fn load_rules_config_parse_error_message_includes_path() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(b"this is = not = valid = toml = [[[[").unwrap();
let path_str = f.path().display().to_string();
let result = load_rules_config(f.path());
let err = result.unwrap_err().to_string();
assert!(
err.contains("failed to load config"),
"error should describe the failure: {err}"
);
assert!(
err.contains(&path_str),
"error should name the offending file path ({path_str}): {err}"
);
assert!(
err.contains("parse error"),
"malformed TOML should surface as a parse error: {err}"
);
}
#[test]
fn load_default_rules_returns_embedded_rules() {
let config = load_default_rules();
assert_eq!(config.default_action, WindowAction::Float);
assert!(
!config.rules.is_empty(),
"embedded default rules should not be empty"
);
}
#[test]
fn embedded_rules_toml_matches_disk_file() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR should be set during tests");
let disk_path = std::path::PathBuf::from(manifest_dir).join("default-flow-rules.toml");
if !disk_path.exists() {
eprintln!("skipping: default-flow-rules.toml not found at {disk_path:?}");
return;
}
let disk_content = std::fs::read_to_string(&disk_path)
.unwrap_or_else(|e| panic!("failed to read {disk_path:?}: {e}"));
assert_eq!(
DEFAULT_RULES_TOML, disk_content,
"DEFAULT_RULES_TOML (include_str!) has drifted from the on-disk file; \
update one to match the other"
);
}
#[test]
fn embedded_ahk_template_matches_disk_file() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR should be set during tests");
let disk_path = std::path::PathBuf::from(manifest_dir).join("flow.ahk");
if !disk_path.exists() {
eprintln!("skipping: flow.ahk not found at {disk_path:?}");
return;
}
let disk_content = std::fs::read_to_string(&disk_path)
.unwrap_or_else(|e| panic!("failed to read {disk_path:?}: {e}"));
assert_eq!(
DEFAULT_AHK_TEMPLATE, disk_content,
"DEFAULT_AHK_TEMPLATE (include_str!) has drifted from the on-disk file; \
update one to match the other"
);
}
#[test]
fn load_default_rules_resilient_fallback_on_bad_input() {
let bad_toml = "this is = not = valid = toml = [[[[[";
let result = toml::from_str::<WindowRulesConfig>(bad_toml);
assert!(result.is_err(), "malformed TOML should fail to parse");
let fallback = WindowRulesConfig::default();
assert_eq!(fallback.default_action, WindowAction::Float);
assert!(fallback.rules.is_empty(), "fallback should have no rules");
}
#[test]
fn default_flow_rules_toml_parses_correctly() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR should be set during tests");
let path = std::path::PathBuf::from(manifest_dir).join("default-flow-rules.toml");
if !path.exists() {
eprintln!("skipping: default-flow-rules.toml not found at {path:?}");
return;
}
let config = load_rules_config(&path).unwrap();
assert_eq!(config.default_action, WindowAction::Float);
assert!(
!config.rules.is_empty(),
"bundled rules should not be empty"
);
}
#[test]
fn default_config_toml_parses_correctly() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR should be set during tests");
let path = std::path::PathBuf::from(manifest_dir).join("default-config.toml");
if !path.exists() {
eprintln!("skipping: default-config.toml not found at {path:?}");
return;
}
let config = load_app_config(&path).unwrap();
assert!(
config.validate().is_ok(),
"default-config.toml should pass validation"
);
assert!(
config.columns_per_screen > 0,
"columns_per_screen should be positive"
);
assert!(
config.min_column_width_px > 0,
"min_column_width_px should be positive"
);
}
}