mod error;
use crate::{
fs_utils::{DEFAULT_MAX_FILE_BYTES, FilesystemConfig},
plugin::{PluginRegistryConfig, ValidatedPluginRegistry},
presentation::{ResolvedTheme, TransparencyMode},
vim::{
LeaderConfig, VimConfig,
config::{KeymapSet, MotionConfig, VimOptions},
},
};
use bevy::prelude::Resource;
use schemars::{JsonSchema, schema_for};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
env,
path::{Path, PathBuf},
};
pub use error::{ConfigLoadError, ConfigProjectionError};
pub const CONFIG_ENV_VAR: &str = "ALMA_CONFIG";
pub const WORKSPACE_CONFIG_PATH: &str = ".alma/config.json";
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Resource, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AppConfig {
pub window: WindowConfig,
pub filesystem: AppFilesystemConfig,
pub vim: AppVimConfig,
#[serde(rename = "plugins")]
plugin_registry: PluginRegistryConfig,
#[serde(skip)]
#[schemars(skip)]
plugins: ValidatedPluginRegistry,
}
impl AppConfig {
pub fn load_or_default() -> Result<Self, ConfigLoadError> {
let current_dir =
env::current_dir().map_err(|source| ConfigLoadError::CurrentDir { source })?;
Self::load_or_default_from(¤t_dir)
}
pub fn load_or_default_from(current_dir: &Path) -> Result<Self, ConfigLoadError> {
if let Some(path) = env::var_os(CONFIG_ENV_VAR).map(PathBuf::from) {
return Self::load_path(&path);
}
let workspace_path = current_dir.join(WORKSPACE_CONFIG_PATH);
if workspace_path.exists() {
return Self::load_path(&workspace_path);
}
Ok(Self::default())
}
pub fn load_path(path: &Path) -> Result<Self, ConfigLoadError> {
let bytes = std::fs::read(path).map_err(|source| ConfigLoadError::Read {
path: path.to_owned(),
source,
})?;
Self::from_json_slice(&bytes, path)
}
pub fn from_json_slice(bytes: &[u8], path: &Path) -> Result<Self, ConfigLoadError> {
let value =
serde_json::from_slice::<Value>(bytes).map_err(|source| ConfigLoadError::Parse {
path: path.to_owned(),
source,
})?;
validate_config_value(&value, path)?;
let mut config = serde_json::from_value::<Self>(value).map_err(|source| {
ConfigLoadError::Deserialize {
path: path.to_owned(),
source,
}
})?;
config.plugins = config.plugin_registry.validate().map_err(|source| {
ConfigLoadError::PluginRegistry {
path: path.to_owned(),
source,
}
})?;
Ok(config)
}
#[must_use]
pub fn json_schema() -> Value {
serde_json::to_value(schema_for!(Self)).expect("generated config schema is JSON")
}
pub fn filesystem_config(&self) -> Result<FilesystemConfig, ConfigProjectionError> {
self.filesystem.to_filesystem_config()
}
#[must_use]
pub const fn plugins(&self) -> &ValidatedPluginRegistry {
&self.plugins
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct WindowConfig {
pub fullscreen_mode: FullscreenMode,
pub transparency_mode: TransparencyMode,
}
impl Default for WindowConfig {
fn default() -> Self {
Self {
fullscreen_mode: FullscreenMode::BorderlessWindowedFullscreen,
transparency_mode: TransparencyMode::Opaque,
}
}
}
impl WindowConfig {
#[must_use]
pub fn resolved_theme(&self) -> ResolvedTheme {
ResolvedTheme::default().with_transparency_mode(self.transparency_mode)
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FullscreenMode {
NativeFullscreen,
#[default]
BorderlessWindowedFullscreen,
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AppFilesystemConfig {
pub workspace_root: Option<PathBuf>,
pub max_file_bytes: u64,
}
impl AppFilesystemConfig {
pub fn to_filesystem_config(&self) -> Result<FilesystemConfig, ConfigProjectionError> {
let mut config = self
.workspace_root
.as_ref()
.map_or_else(
FilesystemConfig::discover,
FilesystemConfig::from_workspace_root,
)
.map_err(ConfigProjectionError::Filesystem)?;
config.max_file_bytes = self.max_file_bytes;
Ok(config)
}
}
impl Default for AppFilesystemConfig {
fn default() -> Self {
Self {
workspace_root: None,
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AppVimConfig {
pub options: AppVimOptions,
}
impl From<AppVimConfig> for VimConfig {
fn from(config: AppVimConfig) -> Self {
Self {
keymaps: KeymapSet::default(),
options: config.options.into(),
motions: MotionConfig::default(),
leader: LeaderConfig::default(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AppVimOptions {
pub ignore_case: bool,
pub smart_case: bool,
pub wrap_scan: bool,
pub timeout_len_ms: u64,
}
impl Default for AppVimOptions {
fn default() -> Self {
let defaults = VimOptions::default();
Self {
ignore_case: defaults.ignore_case,
smart_case: defaults.smart_case,
wrap_scan: defaults.wrap_scan,
timeout_len_ms: defaults.timeout_len_ms,
}
}
}
impl From<AppVimOptions> for VimOptions {
fn from(options: AppVimOptions) -> Self {
Self {
ignore_case: options.ignore_case,
smart_case: options.smart_case,
wrap_scan: options.wrap_scan,
timeout_len_ms: options.timeout_len_ms,
}
}
}
fn validate_config_value(value: &Value, path: &Path) -> Result<(), ConfigLoadError> {
let schema = AppConfig::json_schema();
let validator =
jsonschema::validator_for(&schema).map_err(|error| ConfigLoadError::SchemaCompile {
message: error.to_string(),
})?;
if let Err(error) = validator.validate(value) {
return Err(ConfigLoadError::Schema {
path: path.to_owned(),
message: error.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{AppConfig, ConfigLoadError, FullscreenMode, WORKSPACE_CONFIG_PATH};
use crate::{
fs_utils::DEFAULT_MAX_FILE_BYTES, plugin::PluginIdentity, presentation::TransparencyMode,
vim::VimConfig,
};
use proptest::prelude::*;
use serde_json::json;
use std::{error::Error as _, path::Path};
#[test]
fn default_config_projects_to_current_runtime_defaults() {
let config = AppConfig::default();
let vim_config = VimConfig::from(config.vim.clone());
assert_eq!(
config.window.fullscreen_mode,
FullscreenMode::BorderlessWindowedFullscreen
);
assert_eq!(config.window.transparency_mode, TransparencyMode::Opaque);
assert_eq!(config.filesystem.max_file_bytes, DEFAULT_MAX_FILE_BYTES);
assert_eq!(vim_config.options, VimConfig::default().options);
}
#[test]
fn checked_in_config_matches_typed_defaults() {
let config = AppConfig::from_json_slice(
include_bytes!("../../.alma/config.json"),
Path::new(".alma/config.json"),
)
.expect("checked-in config should be valid");
assert_eq!(config, AppConfig::default());
}
#[test]
fn generated_schema_contains_root_sections() {
let schema = AppConfig::json_schema();
assert!(schema.pointer("/properties/window").is_some());
assert!(schema.pointer("/properties/filesystem").is_some());
assert!(schema.pointer("/properties/vim").is_some());
assert!(schema.pointer("/properties/plugins").is_some());
}
#[test]
fn unknown_fields_are_rejected_by_schema_validation() {
let error = AppConfig::from_json_slice(br#"{"unknown": true}"#, Path::new("config.json"))
.expect_err("unknown root field should fail");
assert!(matches!(error, ConfigLoadError::Schema { .. }));
}
#[test]
fn invalid_enum_values_are_rejected_by_schema_validation() {
let error = AppConfig::from_json_slice(
br#"{"window": {"fullscreen_mode": "not_fullscreen"}}"#,
Path::new("config.json"),
)
.expect_err("invalid enum should fail");
assert!(matches!(error, ConfigLoadError::Schema { .. }));
}
#[test]
fn malformed_json_preserves_parse_source() {
let error = AppConfig::from_json_slice(br#"{"window": "#, Path::new("config.json"))
.expect_err("malformed JSON should fail");
assert!(matches!(error, ConfigLoadError::Parse { .. }));
assert!(error.source().is_some());
}
#[test]
fn invalid_plugin_registry_fails_config_load() {
let error = AppConfig::from_json_slice(
br#"{"plugins": {"plugins": [{"identity": "dup"}, {"identity": "dup"}]}}"#,
Path::new("config.json"),
)
.expect_err("duplicate plugin identities should fail");
assert!(matches!(error, ConfigLoadError::PluginRegistry { .. }));
assert!(error.source().is_some());
}
#[test]
fn config_exposes_validated_plugin_registry() {
let config = AppConfig::from_json_slice(
br#"{"plugins": {"plugins": [{"identity": "formatter", "enabled": true, "component_path": "plugins/formatter.wasm"}]}}"#,
Path::new("config.json"),
)
.expect("valid plugin registry should load");
assert!(config.plugins().plugin(&identity("formatter")).is_some());
assert_eq!(config.plugins().enabled_plugins().count(), 1);
}
fn identity(identity: &str) -> PluginIdentity {
PluginIdentity::try_new(identity).expect("test identity should validate")
}
#[test]
fn explicit_unreadable_config_preserves_read_source() {
let path =
std::env::temp_dir().join(format!("alma-missing-config-{}.json", std::process::id()));
let error = AppConfig::load_path(&path).expect_err("missing explicit config should fail");
assert!(matches!(error, ConfigLoadError::Read { .. }));
assert!(error.source().is_some());
}
#[test]
fn invalid_workspace_root_fails_projection() {
let config = AppConfig::from_json_slice(
br#"{"filesystem": {"workspace_root": "/definitely/not/alma/workspace"}}"#,
Path::new("config.json"),
)
.expect("schema-valid config should deserialize");
assert!(config.filesystem_config().is_err());
}
#[test]
fn missing_workspace_config_uses_defaults() {
let temp = std::env::temp_dir().join(format!("alma-config-missing-{}", std::process::id()));
std::fs::create_dir_all(&temp).expect("temp dir should be created");
let config = AppConfig::load_or_default_from(&temp).expect("missing config should default");
assert_eq!(config, AppConfig::default());
assert!(!temp.join(WORKSPACE_CONFIG_PATH).exists());
std::fs::remove_dir_all(temp).expect("temp dir should be removed");
}
proptest! {
#[test]
fn valid_config_json_round_trips_through_schema(
fullscreen_mode in prop::sample::select(vec![
"native_fullscreen",
"borderless_windowed_fullscreen",
]),
transparency_mode in prop::sample::select(vec![
"opaque",
"transparent",
]),
ignore_case in any::<bool>(),
smart_case in any::<bool>(),
wrap_scan in any::<bool>(),
timeout_len_ms in 0_u64..=10_000,
max_file_bytes in 1_u64..=(64 * 1024 * 1024),
) {
let value = json!({
"window": {
"fullscreen_mode": fullscreen_mode,
"transparency_mode": transparency_mode,
},
"filesystem": {
"max_file_bytes": max_file_bytes,
},
"vim": {
"options": {
"ignore_case": ignore_case,
"smart_case": smart_case,
"wrap_scan": wrap_scan,
"timeout_len_ms": timeout_len_ms,
},
},
});
let bytes = serde_json::to_vec(&value).expect("generated JSON should serialize");
let config = AppConfig::from_json_slice(&bytes, Path::new("config.json"))
.expect("generated config should validate");
prop_assert_eq!(config.filesystem.max_file_bytes, max_file_bytes);
prop_assert_eq!(config.vim.options.ignore_case, ignore_case);
prop_assert_eq!(config.vim.options.smart_case, smart_case);
prop_assert_eq!(config.vim.options.wrap_scan, wrap_scan);
prop_assert_eq!(config.vim.options.timeout_len_ms, timeout_len_ms);
}
#[test]
fn serialized_typed_config_round_trips(
ignore_case in any::<bool>(),
smart_case in any::<bool>(),
wrap_scan in any::<bool>(),
timeout_len_ms in 0_u64..=10_000,
max_file_bytes in 1_u64..=(64 * 1024 * 1024),
use_native_fullscreen in any::<bool>(),
transparent_window in any::<bool>(),
) {
let config = AppConfig {
window: super::WindowConfig {
fullscreen_mode: if use_native_fullscreen {
FullscreenMode::NativeFullscreen
} else {
FullscreenMode::BorderlessWindowedFullscreen
},
transparency_mode: if transparent_window {
TransparencyMode::Transparent
} else {
TransparencyMode::Opaque
},
},
filesystem: super::AppFilesystemConfig {
workspace_root: None,
max_file_bytes,
},
vim: super::AppVimConfig {
options: super::AppVimOptions {
ignore_case,
smart_case,
wrap_scan,
timeout_len_ms,
},
},
plugin_registry: crate::plugin::PluginRegistryConfig::default(),
plugins: crate::plugin::ValidatedPluginRegistry::default(),
};
let bytes = serde_json::to_vec(&config).expect("typed config should serialize");
let round_trip = AppConfig::from_json_slice(&bytes, Path::new("config.json"))
.expect("serialized typed config should validate");
prop_assert_eq!(round_trip, config);
}
#[test]
fn window_config_projects_transparency_into_resolved_theme(
mode in prop::sample::select(vec![
TransparencyMode::Opaque,
TransparencyMode::Transparent,
]),
use_native_fullscreen in any::<bool>(),
) {
let window = super::WindowConfig {
fullscreen_mode: if use_native_fullscreen {
FullscreenMode::NativeFullscreen
} else {
FullscreenMode::BorderlessWindowedFullscreen
},
transparency_mode: mode,
};
prop_assert_eq!(window.resolved_theme().transparency_mode, mode);
}
#[test]
fn unknown_root_fields_fail_schema_validation(
unknown_key in "[a-z][a-z0-9_]{0,24}",
value in any::<bool>(),
) {
prop_assume!(unknown_key != "window");
prop_assume!(unknown_key != "filesystem");
prop_assume!(unknown_key != "vim");
let value = json!({ unknown_key: value });
let bytes = serde_json::to_vec(&value).expect("generated JSON should serialize");
let error = AppConfig::from_json_slice(&bytes, Path::new("config.json"))
.expect_err("unknown root key should fail schema validation");
prop_assert!(
matches!(error, ConfigLoadError::Schema { .. }),
"unknown root key should produce schema error"
);
}
#[test]
fn unknown_nested_fields_fail_schema_validation(
section in prop::sample::select(vec!["window", "filesystem", "vim"]),
unknown_key in "[a-z][a-z0-9_]{0,24}",
value in any::<u64>(),
) {
let known_keys = match section {
"window" => &["fullscreen_mode", "transparency_mode"][..],
"filesystem" => &["workspace_root", "max_file_bytes"][..],
"vim" => &["options"][..],
_ => unreachable!("sampled section should be known"),
};
prop_assume!(!known_keys.contains(&unknown_key.as_str()));
let value = json!({ section: { unknown_key: value } });
let bytes = serde_json::to_vec(&value).expect("generated JSON should serialize");
let error = AppConfig::from_json_slice(&bytes, Path::new("config.json"))
.expect_err("unknown nested key should fail schema validation");
prop_assert!(
matches!(error, ConfigLoadError::Schema { .. }),
"unknown nested key should produce schema error"
);
}
}
}