use anyhow::Result;
use serde::{Deserialize, Serialize};
pub const CONFIG_FILE_NAME: &str = ".wasm-slim.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFile {
#[serde(default = "default_template")]
pub template: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wasm_opt: Option<WasmOptSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_budget: Option<SizeBudget>,
}
fn default_template() -> String {
"balanced".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileSettings {
#[serde(rename = "opt-level", skip_serializing_if = "Option::is_none")]
pub opt_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lto: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strip: Option<bool>,
#[serde(rename = "codegen-units", skip_serializing_if = "Option::is_none")]
pub codegen_units: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub panic: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WasmOptSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SizeBudget {
#[serde(rename = "max-size-kb", skip_serializing_if = "Option::is_none")]
pub max_size_kb: Option<u64>,
#[serde(rename = "warn-threshold-kb", skip_serializing_if = "Option::is_none")]
pub warn_threshold_kb: Option<u64>,
#[serde(rename = "target-size-kb", skip_serializing_if = "Option::is_none")]
pub target_size_kb: Option<u64>,
}
impl SizeBudget {
pub fn validate(&self) -> Result<()> {
if let (Some(target), Some(warn)) = (self.target_size_kb, self.warn_threshold_kb) {
if target > warn {
anyhow::bail!(
"Target size ({} KB) cannot exceed warning threshold ({} KB)",
target,
warn
);
}
}
if let (Some(warn), Some(max)) = (self.warn_threshold_kb, self.max_size_kb) {
if warn > max {
anyhow::bail!(
"Warning threshold ({} KB) cannot exceed max size ({} KB)",
warn,
max
);
}
}
if let (Some(target), Some(max)) = (self.target_size_kb, self.max_size_kb) {
if target > max {
anyhow::bail!(
"Target size ({} KB) cannot exceed max size ({} KB)",
target,
max
);
}
}
Ok(())
}
}
impl Default for ConfigFile {
fn default() -> Self {
Self {
template: "balanced".to_string(),
profile: None,
wasm_opt: None,
size_budget: None,
}
}
}
#[cfg(test)]
mod tests {
use super::super::loader::ConfigLoader;
use super::super::resolver::TemplateResolver;
use super::super::template::{Template, TemplateType};
use super::*;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
#[test]
fn test_config_file_default_has_balanced_template() {
let config = ConfigFile::default();
assert_eq!(config.template, "balanced");
assert!(config.profile.is_none());
}
#[test]
fn test_config_file_save_and_load_preserves_values() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config = ConfigFile {
template: "minimal".to_string(),
profile: Some(ProfileSettings {
opt_level: Some("z".to_string()),
lto: Some("fat".to_string()),
strip: Some(true),
codegen_units: Some(1),
panic: Some("abort".to_string()),
}),
wasm_opt: None,
size_budget: None,
};
ConfigLoader::save(&config, project_root).unwrap();
let loaded = ConfigLoader::load(project_root).unwrap();
assert_eq!(loaded.template, "minimal");
assert!(loaded.profile.is_some());
assert_eq!(
loaded.profile.as_ref().unwrap().opt_level,
Some("z".to_string())
);
}
#[test]
fn test_config_file_from_template_creates_from_template() {
let template = Template::new(TemplateType::Balanced);
let config = TemplateResolver::from_template(&template);
assert_eq!(config.template, "balanced");
assert!(config.profile.is_some());
assert_eq!(
config.profile.as_ref().unwrap().opt_level,
Some("s".to_string())
);
}
#[test]
fn test_config_file_resolve_template_merges_overrides_with_template() {
let config = ConfigFile {
template: "balanced".to_string(),
profile: Some(ProfileSettings {
opt_level: Some("z".to_string()), lto: None,
strip: None,
codegen_units: None,
panic: None,
}),
wasm_opt: None,
size_budget: None,
};
let resolved = TemplateResolver::resolve(&config).unwrap();
assert_eq!(resolved.profile.opt_level, "z"); assert_eq!(resolved.profile.lto, "fat"); }
#[test]
fn test_config_file_exists_returns_true_after_save() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
assert!(!ConfigLoader::exists(project_root));
let config = ConfigFile::default();
ConfigLoader::save(&config, project_root).unwrap();
assert!(ConfigLoader::exists(project_root));
}
#[test]
fn test_size_budget_validate_with_correct_order_succeeds() {
let budget = SizeBudget {
target_size_kb: Some(100),
warn_threshold_kb: Some(150),
max_size_kb: Some(200),
};
assert!(budget.validate().is_ok());
}
#[test]
fn test_size_budget_validate_with_target_exceeds_warn_returns_error() {
let budget = SizeBudget {
target_size_kb: Some(200),
warn_threshold_kb: Some(100),
max_size_kb: None,
};
assert!(budget.validate().is_err());
}
#[test]
fn test_size_budget_validate_with_warn_exceeds_max_returns_error() {
let budget = SizeBudget {
target_size_kb: None,
warn_threshold_kb: Some(300),
max_size_kb: Some(200),
};
assert!(budget.validate().is_err());
}
#[test]
fn test_size_budget_validate_with_target_exceeds_max_returns_error() {
let budget = SizeBudget {
target_size_kb: Some(300),
warn_threshold_kb: None,
max_size_kb: Some(200),
};
assert!(budget.validate().is_err());
}
#[test]
#[cfg(unix)]
fn test_config_file_save_with_readonly_directory_returns_error() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("readonly");
fs::create_dir(&config_dir).unwrap();
let mut perms = fs::metadata(&config_dir).unwrap().permissions();
perms.set_mode(0o444); fs::set_permissions(&config_dir, perms).unwrap();
let config = ConfigFile::default();
let result = ConfigLoader::save(&config, &config_dir);
let mut perms = fs::metadata(&config_dir).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&config_dir, perms).unwrap();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("Failed to write") || err.to_string().contains("permission")
);
}
#[test]
#[cfg(windows)]
fn test_config_file_save_on_windows_succeeds() {
let _temp_dir = TempDir::new().unwrap();
let _config = ConfigFile::default();
assert!(true);
}
#[test]
fn test_config_file_load_with_missing_file_returns_default() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let result = ConfigLoader::load(project_root);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.template, "balanced");
}
#[test]
fn test_config_file_load_with_invalid_toml_returns_error() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
fs::write(&config_path, "[invalid toml\nthis is broken").unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Failed to parse") || err.to_string().contains("parse"));
}
#[test]
fn test_config_file_load_with_malformed_structure_returns_error() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
fs::write(
&config_path,
r#"
template = 123
profile = "not a table"
"#,
)
.unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_err());
}
#[test]
#[cfg(unix)]
fn test_config_file_load_with_unreadable_file_returns_error() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config = ConfigFile::default();
ConfigLoader::save(&config, project_root).unwrap();
let config_path = project_root.join(CONFIG_FILE_NAME);
let mut perms = fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o000); fs::set_permissions(&config_path, perms).unwrap();
let result = ConfigLoader::load(project_root);
let mut perms = fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o644);
fs::set_permissions(&config_path, perms).unwrap();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("Failed to read") || err.to_string().contains("permission")
);
}
#[test]
fn test_config_file_resolve_template_with_invalid_name_returns_error() {
let config = ConfigFile {
template: "nonexistent-template".to_string(),
profile: None,
wasm_opt: None,
size_budget: None,
};
let result = TemplateResolver::resolve(&config);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("not found") || err.to_string().contains("Template"));
}
#[test]
fn test_config_file_exists_with_nonexistent_path_returns_false() {
let nonexistent = Path::new("/nonexistent/directory/that/does/not/exist");
assert!(!ConfigLoader::exists(nonexistent));
}
#[test]
fn test_size_budget_default_has_no_limits() {
let budget = SizeBudget::default();
assert!(budget.max_size_kb.is_none());
assert!(budget.warn_threshold_kb.is_none());
assert!(budget.target_size_kb.is_none());
}
#[test]
fn test_config_file_serialize_deserialize_preserves_data() {
let config = ConfigFile {
template: "minimal".to_string(),
profile: Some(ProfileSettings {
opt_level: Some("z".to_string()),
lto: Some("fat".to_string()),
strip: Some(true),
codegen_units: Some(1),
panic: Some("abort".to_string()),
}),
wasm_opt: Some(WasmOptSettings {
flags: Some(vec!["-Oz".to_string()]),
}),
size_budget: Some(SizeBudget {
max_size_kb: Some(500),
warn_threshold_kb: Some(400),
target_size_kb: Some(300),
}),
};
let serialized = toml_edit::ser::to_string(&config).unwrap();
assert!(serialized.contains("minimal"));
let deserialized: ConfigFile = toml_edit::de::from_str(&serialized).unwrap();
assert_eq!(deserialized.template, "minimal");
assert!(deserialized.profile.is_some());
assert!(deserialized.size_budget.is_some());
}
#[test]
fn test_config_file_load_with_deeply_nested_toml_handles_gracefully() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
let mut nested_toml = String::new();
for i in 0..100 {
nested_toml.push_str(&format!("[level{}]\n", i));
}
nested_toml.push_str("value = 1\n");
fs::write(&config_path, nested_toml).unwrap();
let result = ConfigLoader::load(project_root);
let _ = result;
}
#[test]
fn test_config_file_load_with_extremely_large_file_handles_gracefully() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
let mut large_toml = String::from("# Valid but large TOML\ntemplate = \"balanced\"\n\n");
for i in 0..10000 {
large_toml.push_str(&format!("# Comment line {}\n", i));
}
fs::write(&config_path, large_toml).unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_ok());
}
#[test]
fn test_config_file_load_with_unicode_edge_cases_handles_correctly() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
let unicode_toml = r#"
# Unicode comment: 你好 🚀 مرحبا
template = "balanced"
[profile]
# Emoji in comment 🎯
opt_level = "z"
"#;
fs::write(&config_path, unicode_toml).unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.template, "balanced");
}
#[test]
fn test_config_file_load_with_duplicate_keys_returns_error() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
let duplicate_toml = r#"
template = "balanced"
template = "aggressive"
"#;
fs::write(&config_path, duplicate_toml).unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_err());
}
#[test]
fn test_config_file_load_with_circular_table_refs_handles_gracefully() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
let circular_toml = r#"
template = "balanced"
[profile]
opt_level = "z"
[[profile]]
lto = "thin"
"#;
fs::write(&config_path, circular_toml).unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_err());
}
#[test]
fn test_config_file_load_with_invalid_utf8_sequences_returns_error() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
use std::io::Write;
let mut file = std::fs::File::create(&config_path).unwrap();
file.write_all(b"template = \"").unwrap();
file.write_all(&[0xFF, 0xFE, 0xFD]).unwrap(); file.write_all(b"\"\n").unwrap();
drop(file);
let result = ConfigLoader::load(project_root);
assert!(result.is_err());
}
#[test]
fn test_config_file_load_with_only_whitespace_returns_default() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
let whitespace_toml = " \n\n \t\n# Just a comment\n \n";
fs::write(&config_path, whitespace_toml).unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_ok());
}
#[test]
fn test_config_file_load_with_trailing_commas_in_inline_tables_handles_correctly() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let config_path = project_root.join(CONFIG_FILE_NAME);
let trailing_comma_toml = r#"
template = "balanced"
profile = { opt_level = "z", lto = "thin", }
"#;
fs::write(&config_path, trailing_comma_toml).unwrap();
let result = ConfigLoader::load(project_root);
assert!(result.is_ok());
}
}