use std::collections::HashSet;
use std::env::home_dir;
use std::fmt::Debug;
use std::path::Path;
use std::path::PathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use mago_php_version::PHPVersion;
use serde_json::Value;
use crate::config::analyzer::AnalyzerConfiguration;
use crate::config::formatter::FormatterConfiguration;
use crate::config::guard::GuardConfiguration;
use crate::config::linter::LinterConfiguration;
use crate::config::parser::ParserConfiguration;
use crate::config::source::SourceConfiguration;
use crate::consts::*;
use crate::error::Error;
pub mod analyzer;
pub mod formatter;
pub mod guard;
pub mod linter;
pub mod parser;
pub mod source;
fn default_threads() -> usize {
*LOGICAL_CPUS
}
fn default_stack_size() -> usize {
DEFAULT_STACK_SIZE
}
fn default_php_version() -> PHPVersion {
DEFAULT_PHP_VERSION
}
fn default_source_configuration() -> SourceConfiguration {
SourceConfiguration::from_workspace(CURRENT_DIR.clone())
}
const ENV_PHP_VERSION: &str = "MAGO_PHP_VERSION";
const ENV_THREADS: &str = "MAGO_THREADS";
const ENV_STACK_SIZE: &str = "MAGO_STACK_SIZE";
const ENV_ALLOW_UNSUPPORTED_PHP_VERSION: &str = "MAGO_ALLOW_UNSUPPORTED_PHP_VERSION";
const ENV_NO_VERSION_CHECK: &str = "MAGO_NO_VERSION_CHECK";
const ENV_EDITOR_URL: &str = "MAGO_EDITOR_URL";
const _: () = {
let bytes = ENVIRONMENT_PREFIX.as_bytes();
assert!(bytes.len() == 4 && bytes[0] == b'M' && bytes[1] == b'A' && bytes[2] == b'G' && bytes[3] == b'O');
};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct Configuration {
#[serde(default)]
pub version: Option<String>,
#[serde(default = "default_threads")]
pub threads: usize,
#[serde(default = "default_stack_size")]
pub stack_size: usize,
#[serde(default = "default_php_version")]
pub php_version: PHPVersion,
#[serde(default)]
pub allow_unsupported_php_version: bool,
#[serde(default)]
pub no_version_check: bool,
#[serde(default = "default_source_configuration")]
pub source: SourceConfiguration,
#[serde(default)]
pub linter: LinterConfiguration,
#[serde(default)]
pub parser: ParserConfiguration,
#[serde(default)]
pub formatter: FormatterConfiguration,
#[serde(default)]
pub analyzer: AnalyzerConfiguration,
#[serde(default)]
pub guard: GuardConfiguration,
#[serde(default)]
pub editor_url: Option<String>,
#[serde(default, skip_serializing)]
#[schemars(skip)]
pub config_file: Option<PathBuf>,
#[serde(default, skip_serializing)]
#[schemars(skip)]
pub config_file_is_explicit: bool,
}
impl Configuration {
pub fn load(
workspace: Option<PathBuf>,
file: Option<&Path>,
php_version: Option<PHPVersion>,
threads: Option<usize>,
allow_unsupported_php_version: bool,
no_version_check: bool,
) -> Result<Configuration, Error> {
let workspace_dir = workspace.clone().unwrap_or_else(|| CURRENT_DIR.to_path_buf());
let resolved_config_file: Option<(PathBuf, ConfigFormat)>;
let config_file_is_explicit;
if let Some(file) = file {
tracing::debug!("Sourcing configuration from {}.", file.display());
resolved_config_file = Some((
file.to_path_buf(),
ConfigFormat::for_path(file).ok_or_else(|| Error::UnsupportedConfigExtension(file.to_path_buf()))?,
));
config_file_is_explicit = true;
} else {
let fallback_roots = [
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from),
home_dir().map(|h| h.join(".config")),
home_dir(),
];
resolved_config_file = Self::find_config_files(&workspace_dir, &fallback_roots);
if let Some((config_file, _)) = &resolved_config_file {
tracing::debug!("Sourcing configuration from {}.", config_file.display());
} else {
tracing::debug!("No configuration file found, using defaults and environment variables.");
}
config_file_is_explicit = false;
}
let mut configuration: Configuration = if let Some((path, format)) = &resolved_config_file {
let mut visited: HashSet<PathBuf> = HashSet::new();
let merged = load_layer(path, *format, &mut visited)?;
serde_json::from_value::<Configuration>(merged)
.map_err(|e| Error::ParseConfigFile { path: path.clone(), source: Box::new(e) })?
} else {
Configuration::from_workspace(workspace_dir.clone())
};
configuration.apply_env_overrides()?;
configuration.config_file = resolved_config_file.as_ref().map(|(p, _)| p.clone());
configuration.config_file_is_explicit = config_file_is_explicit;
if allow_unsupported_php_version && !configuration.allow_unsupported_php_version {
tracing::warn!("Allowing unsupported PHP versions.");
configuration.allow_unsupported_php_version = true;
}
if no_version_check && !configuration.no_version_check {
tracing::info!("Silencing project version drift warning.");
configuration.no_version_check = true;
}
if let Some(php_version) = php_version {
tracing::info!("Overriding PHP version with {}.", php_version);
configuration.php_version = php_version;
}
if let Some(threads) = threads {
tracing::info!("Overriding thread count with {}.", threads);
configuration.threads = threads;
}
if let Some(workspace) = workspace {
tracing::info!("Overriding workspace directory with {}.", workspace.display());
configuration.source.workspace = workspace;
}
if configuration.editor_url.is_none() {
configuration.editor_url = detect_editor_url();
}
configuration.normalize()?;
Ok(configuration)
}
fn find_config_files(root_dir: &Path, fallback_roots: &[Option<PathBuf>]) -> Option<(PathBuf, ConfigFormat)> {
let config_files = [CONFIGURATION_FILE_NAME, CONFIGURATION_DIST_FILE_NAME];
for name in config_files.iter() {
let mut candidate = root_dir.join(name);
for format in ConfigFormat::ALL.iter() {
for ext in format.extensions() {
candidate.set_extension(ext);
if candidate.exists() {
return Some((candidate, *format));
}
}
}
}
for root in fallback_roots.iter().flatten() {
let mut candidate = root.join(CONFIGURATION_FILE_NAME);
for format in ConfigFormat::ALL.iter() {
for ext in format.extensions() {
candidate.set_extension(ext);
if candidate.exists() {
return Some((candidate, *format));
}
}
}
}
None
}
fn apply_env_overrides(&mut self) -> Result<(), Error> {
if let Ok(v) = std::env::var(ENV_PHP_VERSION) {
self.php_version =
v.parse().map_err(|e| Error::EnvVarParse { name: ENV_PHP_VERSION, source: Box::new(e) })?;
}
if let Ok(v) = std::env::var(ENV_THREADS) {
self.threads = v.parse().map_err(|e| Error::EnvVarParse { name: ENV_THREADS, source: Box::new(e) })?;
}
if let Ok(v) = std::env::var(ENV_STACK_SIZE) {
self.stack_size =
v.parse().map_err(|e| Error::EnvVarParse { name: ENV_STACK_SIZE, source: Box::new(e) })?;
}
if let Ok(v) = std::env::var(ENV_ALLOW_UNSUPPORTED_PHP_VERSION) {
self.allow_unsupported_php_version = parse_bool(&v)
.map_err(|e| Error::EnvVarParse { name: ENV_ALLOW_UNSUPPORTED_PHP_VERSION, source: Box::new(e) })?;
}
if let Ok(v) = std::env::var(ENV_NO_VERSION_CHECK) {
self.no_version_check =
parse_bool(&v).map_err(|e| Error::EnvVarParse { name: ENV_NO_VERSION_CHECK, source: Box::new(e) })?;
}
if let Ok(v) = std::env::var(ENV_EDITOR_URL) {
self.editor_url = Some(v);
}
Ok(())
}
pub fn from_workspace(workspace: PathBuf) -> Self {
Self {
version: None,
threads: *LOGICAL_CPUS,
stack_size: DEFAULT_STACK_SIZE,
php_version: DEFAULT_PHP_VERSION,
allow_unsupported_php_version: false,
no_version_check: false,
source: SourceConfiguration::from_workspace(workspace),
linter: LinterConfiguration::default(),
parser: ParserConfiguration::default(),
formatter: FormatterConfiguration::default(),
analyzer: AnalyzerConfiguration::default(),
guard: GuardConfiguration::default(),
editor_url: None,
config_file: None,
config_file_is_explicit: false,
}
}
}
impl Configuration {
#[must_use]
pub fn to_filtered_value(&self) -> Value {
serde_json::json!({
"version": self.version,
"threads": self.threads,
"stack-size": self.stack_size,
"php-version": self.php_version,
"allow-unsupported-php-version": self.allow_unsupported_php_version,
"no-version-check": self.no_version_check,
"source": self.source,
"linter": self.linter.to_filtered_value(self.php_version),
"parser": self.parser,
"formatter": self.formatter.to_value(),
"analyzer": self.analyzer,
"guard": self.guard,
})
}
fn normalize(&mut self) -> Result<(), Error> {
match self.threads {
0 => {
tracing::info!("Thread configuration is zero, using the number of logical CPUs: {}.", *LOGICAL_CPUS);
self.threads = *LOGICAL_CPUS;
}
_ => {
tracing::debug!("Configuration specifies {} threads.", self.threads);
}
}
match self.stack_size {
0 => {
tracing::info!(
"Stack size configuration is zero, using the maximum size of {} bytes.",
MAXIMUM_STACK_SIZE
);
self.stack_size = MAXIMUM_STACK_SIZE;
}
s if s > MAXIMUM_STACK_SIZE => {
tracing::warn!(
"Stack size configuration is too large, reducing to maximum size of {} bytes.",
MAXIMUM_STACK_SIZE
);
self.stack_size = MAXIMUM_STACK_SIZE;
}
s if s < MINIMUM_STACK_SIZE => {
tracing::warn!(
"Stack size configuration is too small, increasing to minimum size of {} bytes.",
MINIMUM_STACK_SIZE
);
self.stack_size = MINIMUM_STACK_SIZE;
}
_ => {
tracing::debug!("Configuration specifies a stack size of {} bytes.", self.stack_size);
}
}
self.source.normalize()?;
if let Some(b) = self.analyzer.baseline.take() {
let resolved = if b.is_relative() { self.source.workspace.join(&b) } else { b };
tracing::debug!("Analyzer baseline configuration from {}.", resolved.display());
self.analyzer.baseline = Some(resolved);
}
if let Some(b) = self.linter.baseline.take() {
let resolved = if b.is_relative() { self.source.workspace.join(&b) } else { b };
tracing::debug!("Linter baseline configuration from {}.", resolved.display());
self.linter.baseline = Some(resolved);
}
if let Some(b) = self.guard.baseline.take() {
let resolved = if b.is_relative() { self.source.workspace.join(&b) } else { b };
tracing::debug!("Guard baseline configuration from {}.", resolved.display());
self.guard.baseline = Some(resolved);
}
Ok(())
}
}
#[cfg(all(test, not(target_os = "windows")))]
mod tests {
use core::str;
use std::fs;
use pretty_assertions::assert_eq;
use tempfile::env::temp_dir;
use super::*;
#[test]
fn test_take_defaults() {
let workspace_path = temp_dir().join("workspace-0");
std::fs::create_dir_all(&workspace_path).unwrap();
let config = temp_env::with_vars(
[
("HOME", temp_dir().to_str()),
("MAGO_THREADS", None),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path), None, None, None, false, false).unwrap(),
);
assert_eq!(config.threads, *LOGICAL_CPUS)
}
#[test]
fn test_toml_has_precedence_when_multiple_configs_present() {
let workspace_path = temp_dir().join("workspace-with-multiple-configs");
std::fs::create_dir_all(&workspace_path).unwrap();
create_tmp_file("threads = 3", &workspace_path, "toml");
create_tmp_file("threads: 2\nphp-version: \"7.4.0\"", &workspace_path, "yaml");
create_tmp_file("{\"threads\": 1,\"php-version\":\"8.1.0\"}", &workspace_path, "json");
let config = Configuration::load(Some(workspace_path), None, None, None, false, false).unwrap();
assert_eq!(config.threads, 3);
assert_eq!(config.php_version.to_string(), DEFAULT_PHP_VERSION.to_string())
}
#[test]
fn test_env_config_override_all_others() {
let workspace_path = temp_dir().join("workspace-1");
let config_path = temp_dir().join("config-1");
std::fs::create_dir_all(&workspace_path).unwrap();
std::fs::create_dir_all(&config_path).unwrap();
let config_file_path = create_tmp_file("threads = 1", &config_path, "toml");
create_tmp_file("threads = 2", &workspace_path, "toml");
let config = temp_env::with_vars(
[
("HOME", None),
("MAGO_THREADS", Some("3")),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path), Some(&config_file_path), None, None, false, false).unwrap(),
);
assert_eq!(config.threads, 3);
}
#[test]
fn test_config_cancel_workspace() {
let workspace_path = temp_dir().join("workspace-2");
let config_path = temp_dir().join("config-2");
std::fs::create_dir_all(&workspace_path).unwrap();
std::fs::create_dir_all(&config_path).unwrap();
create_tmp_file("threads = 2\nphp-version = \"7.4.0\"", &workspace_path, "toml");
let config_file_path = create_tmp_file("threads = 1", &config_path, "toml");
let config = temp_env::with_vars(
[
("HOME", None::<&str>),
("MAGO_THREADS", None),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path), Some(&config_file_path), None, None, false, false).unwrap(),
);
assert_eq!(config.threads, 1);
assert_eq!(config.php_version.to_string(), DEFAULT_PHP_VERSION.to_string());
}
#[test]
fn test_workspace_has_precedence_over_global() {
let home_path = temp_dir().join("home-3");
let xdg_config_home_path = temp_dir().join("xdg-config-home-3");
let workspace_path = temp_dir().join("workspace-3");
let _ = std::fs::remove_dir_all(&home_path);
let _ = std::fs::remove_dir_all(&xdg_config_home_path);
let _ = std::fs::remove_dir_all(&workspace_path);
std::fs::create_dir_all(&home_path).unwrap();
std::fs::create_dir_all(&xdg_config_home_path).unwrap();
std::fs::create_dir_all(&workspace_path).unwrap();
create_tmp_file("threads: 2\nphp-version: \"8.1.0\"", &workspace_path.to_owned(), "yaml");
create_tmp_file("threads = 3\nphp-version = \"7.4.0\"", &home_path, "toml");
create_tmp_file("source.excludes = [\"yes\"]", &xdg_config_home_path, "toml");
let config = temp_env::with_vars(
[
("HOME", Some(home_path)),
("XDG_CONFIG_HOME", Some(xdg_config_home_path)),
("MAGO_THREADS", None),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(Some(workspace_path.clone()), None, None, None, false, false).unwrap(),
);
assert_eq!(config.threads, 2);
assert_eq!(config.php_version.to_string(), "8.1.0".to_string());
assert_eq!(config.source.excludes, Vec::<String>::new());
}
fn create_tmp_file(config_content: &str, folder: &PathBuf, extension: &str) -> PathBuf {
fs::create_dir_all(folder).unwrap();
let config_path = folder.join(CONFIGURATION_FILE_NAME).with_extension(extension);
fs::write(&config_path, config_content).unwrap();
config_path
}
fn write_file(path: &Path, content: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
fn load_isolated(file: &Path) -> Configuration {
temp_env::with_vars(
[
("HOME", None::<&str>),
("XDG_CONFIG_HOME", None),
("MAGO_THREADS", None),
("MAGO_PHP_VERSION", None),
("MAGO_ALLOW_UNSUPPORTED_PHP_VERSION", None),
],
|| Configuration::load(None, Some(file), None, None, false, false).unwrap(),
)
}
#[test]
fn test_extends_single_string() {
let dir = temp_dir().join("extends-single");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_file(&dir.join("base.toml"), "threads = 7\nphp-version = \"8.0.0\"\n");
write_file(&dir.join("mago.toml"), "extends = \"base.toml\"\nphp-version = \"8.3.0\"\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.threads, 7);
assert_eq!(config.php_version.to_string(), "8.3.0");
}
#[test]
fn test_extends_array_in_order() {
let dir = temp_dir().join("extends-array");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_file(&dir.join("a.toml"), "threads = 1\nphp-version = \"8.0.0\"\n");
write_file(&dir.join("b.toml"), "threads = 2\n");
write_file(&dir.join("mago.toml"), "extends = [\"a.toml\", \"b.toml\"]\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.threads, 2);
assert_eq!(config.php_version.to_string(), "8.0.0");
}
#[test]
fn test_extends_relative_to_config_file_not_cwd() {
let dir = temp_dir().join("extends-relative");
let _ = fs::remove_dir_all(&dir);
let nested = dir.join("nested");
fs::create_dir_all(&nested).unwrap();
write_file(&nested.join("base.toml"), "threads = 9\n");
write_file(&nested.join("mago.toml"), "extends = \"base.toml\"\n");
let config = load_isolated(&nested.join("mago.toml"));
assert_eq!(config.threads, 9);
}
#[test]
fn test_extends_directory_picks_up_mago_file() {
let dir = temp_dir().join("extends-dir");
let _ = fs::remove_dir_all(&dir);
let configs = dir.join("configs");
fs::create_dir_all(&configs).unwrap();
write_file(&configs.join("mago.toml"), "threads = 5\n");
write_file(&dir.join("mago.toml"), "extends = \"configs\"\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.threads, 5);
}
#[test]
fn test_extends_directory_without_config_warns_and_skips() {
let dir = temp_dir().join("extends-empty-dir");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("empty")).unwrap();
write_file(&dir.join("mago.toml"), "extends = \"empty\"\nthreads = 3\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.threads, 3);
}
#[test]
fn test_extends_array_excludes_concat() {
let dir = temp_dir().join("extends-array-concat");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_file(&dir.join("base.toml"), "[source]\nexcludes = [\"vendor\", \"node_modules\"]\n");
write_file(&dir.join("mago.toml"), "extends = \"base.toml\"\n[source]\nexcludes = [\"build\"]\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.source.excludes, vec!["vendor", "node_modules", "build"]);
}
#[test]
fn test_extends_cycle_is_detected() {
let dir = temp_dir().join("extends-cycle");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_file(&dir.join("a.toml"), "extends = \"b.toml\"\n");
write_file(&dir.join("b.toml"), "extends = \"a.toml\"\n");
let result = Configuration::load(None, Some(&dir.join("a.toml")), None, None, false, false);
assert!(result.is_err(), "expected cycle to be detected");
}
#[test]
fn test_extends_transitive_chain() {
let dir = temp_dir().join("extends-transitive");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_file(&dir.join("grandparent.toml"), "threads = 1\nphp-version = \"8.0.0\"\n");
write_file(&dir.join("parent.toml"), "extends = \"grandparent.toml\"\nthreads = 2\n");
write_file(&dir.join("mago.toml"), "extends = \"parent.toml\"\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.threads, 2);
assert_eq!(config.php_version.to_string(), "8.0.0");
}
#[test]
fn test_extends_mixed_formats() {
let dir = temp_dir().join("extends-mixed");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_file(&dir.join("base.json"), "{\"threads\": 4}\n");
write_file(&dir.join("middle.yaml"), "extends: \"base.json\"\nphp-version: \"8.2.0\"\n");
write_file(&dir.join("mago.toml"), "extends = \"middle.yaml\"\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.threads, 4);
assert_eq!(config.php_version.to_string(), "8.2.0");
}
#[test]
fn test_extends_long_mixed_format_chain() {
let dir = temp_dir().join("extends-long-chain");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
write_file(&dir.join("bottom.toml"), "stack-size = 16777216\n");
write_file(&dir.join("layer-yml.yml"), "extends: \"bottom.toml\"\nthreads: 7\n");
write_file(
&dir.join("layer-json.json"),
"{\n \"extends\": \"layer-yml.yml\",\n \"php-version\": \"8.1.0\"\n}\n",
);
write_file(&dir.join("layer-yaml.yaml"), "extends: \"layer-json.json\"\nallow-unsupported-php-version: true\n");
write_file(&dir.join("mago.toml"), "extends = \"layer-yaml.yaml\"\nphp-version = \"8.3.0\"\n");
let config = load_isolated(&dir.join("mago.toml"));
assert_eq!(config.stack_size, 16_777_216);
assert_eq!(config.threads, 7);
assert_eq!(config.php_version.to_string(), "8.3.0");
assert!(config.allow_unsupported_php_version);
}
}
fn detect_editor_url() -> Option<String> {
if let Ok(bundle_id) = std::env::var("__CFBundleIdentifier") {
let url = match bundle_id.as_str() {
"com.jetbrains.PhpStorm" | "com.jetbrains.PhpStorm-EAP" => {
"phpstorm://open?file=%file%&line=%line%&column=%column%"
}
"com.jetbrains.intellij" | "com.jetbrains.intellij.ce" => {
"idea://open?file=%file%&line=%line%&column=%column%"
}
"com.jetbrains.WebStorm" | "com.jetbrains.WebStorm-EAP" => {
"webstorm://open?file=%file%&line=%line%&column=%column%"
}
"dev.zed.Zed" | "dev.zed.Zed-Preview" => "zed://file/%file%:%line%:%column%",
"com.microsoft.VSCode" => "vscode://file/%file%:%line%:%column%",
"com.microsoft.VSCodeInsiders" => "vscode-insiders://file/%file%:%line%:%column%",
"com.sublimetext.4" | "com.sublimetext.3" => "subl://open?url=file://%file%&line=%line%&column=%column%",
_ => return None,
};
tracing::debug!("Auto-detected editor URL from __CFBundleIdentifier={bundle_id}");
return Some(url.to_string());
}
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
let url = match term_program.as_str() {
"vscode" => "vscode://file/%file%:%line%:%column%",
"zed" => "zed://file/%file%:%line%:%column%",
_ => return None,
};
tracing::debug!("Auto-detected editor URL from TERM_PROGRAM={term_program}");
return Some(url.to_string());
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfigFormat {
Toml,
Yaml,
Json,
}
impl ConfigFormat {
pub(crate) const ALL: &'static [ConfigFormat] = &[ConfigFormat::Toml, ConfigFormat::Yaml, ConfigFormat::Json];
pub(crate) fn extensions(&self) -> &'static [&'static str] {
match self {
ConfigFormat::Toml => &["toml"],
ConfigFormat::Yaml => &["yaml", "yml"],
ConfigFormat::Json => &["json"],
}
}
pub(crate) fn for_path(path: &Path) -> Option<ConfigFormat> {
let ext = path.extension().and_then(|e| e.to_str())?;
for f in Self::ALL {
if f.extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)) {
return Some(*f);
}
}
None
}
pub(crate) fn parse_to_value(&self, content: &str, path: &Path) -> Result<Value, Error> {
match self {
ConfigFormat::Toml => toml::from_str::<Value>(content)
.map_err(|e| Error::ParseConfigFile { path: path.to_path_buf(), source: Box::new(e) }),
ConfigFormat::Yaml => serde_norway::from_str::<Value>(content)
.map_err(|e| Error::ParseConfigFile { path: path.to_path_buf(), source: Box::new(e) }),
ConfigFormat::Json => serde_json::from_str::<Value>(content)
.map_err(|e| Error::ParseConfigFile { path: path.to_path_buf(), source: Box::new(e) }),
}
}
}
fn load_layer(path: &Path, format: ConfigFormat, visited: &mut HashSet<PathBuf>) -> Result<Value, Error> {
let canonical = path.canonicalize().map_err(|e| Error::ReadConfigFile { path: path.to_path_buf(), source: e })?;
if visited.contains(&canonical) {
return Err(Error::CircularExtends(canonical));
}
visited.insert(canonical);
let content =
std::fs::read_to_string(path).map_err(|e| Error::ReadConfigFile { path: path.to_path_buf(), source: e })?;
let mut value = format.parse_to_value(&content, path)?;
let extends = extract_extends(&mut value, path)?;
if extends.is_empty() {
return Ok(value);
}
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut accumulator = Value::Object(serde_json::Map::new());
for entry in &extends {
match resolve_extends_entry(entry, base_dir)? {
Some((resolved_path, resolved_format)) => {
tracing::debug!("Extending configuration from {}.", resolved_path.display());
let parent_value = load_layer(&resolved_path, resolved_format, visited)?;
merge_into(&mut accumulator, parent_value);
}
None => {
tracing::warn!(
"Configuration `extends` entry `{}` (resolved relative to `{}`) is a directory \
without a `mago.toml`/`mago.yaml`/`mago.yml`/`mago.json` — skipping.",
entry,
base_dir.display()
);
}
}
}
merge_into(&mut accumulator, value);
Ok(accumulator)
}
fn extract_extends(value: &mut Value, path: &Path) -> Result<Vec<String>, Error> {
let Some(obj) = value.as_object_mut() else {
return Ok(Vec::new());
};
let Some(raw) = obj.remove("extends") else {
return Ok(Vec::new());
};
match raw {
Value::String(s) => Ok(vec![s]),
Value::Array(arr) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
match v {
Value::String(s) => out.push(s),
other => {
return Err(Error::InvalidExtendsEntry {
path: path.to_path_buf(),
reason: format!("expected string, got {}", json_value_kind(&other)),
});
}
}
}
Ok(out)
}
other => Err(Error::InvalidExtendsEntry {
path: path.to_path_buf(),
reason: format!("expected string or array of strings, got {}", json_value_kind(&other)),
}),
}
}
fn resolve_extends_entry(entry: &str, base_dir: &Path) -> Result<Option<(PathBuf, ConfigFormat)>, Error> {
let entry_path = Path::new(entry);
let resolved = if entry_path.is_absolute() { entry_path.to_path_buf() } else { base_dir.join(entry_path) };
let metadata = std::fs::metadata(&resolved).map_err(|e| Error::ExtendsTargetNotFound {
entry: entry.to_string(),
resolved: resolved.clone(),
source: e,
})?;
if metadata.is_dir() {
let mut candidate = resolved.join(CONFIGURATION_FILE_NAME);
for format in ConfigFormat::ALL {
for ext in format.extensions() {
candidate.set_extension(ext);
if candidate.exists() {
return Ok(Some((candidate, *format)));
}
}
}
return Ok(None);
}
let format =
ConfigFormat::for_path(&resolved).ok_or_else(|| Error::UnsupportedConfigExtension(resolved.clone()))?;
Ok(Some((resolved, format)))
}
fn merge_into(target: &mut Value, source: Value) {
use serde_json::Value;
match (target, source) {
(Value::Object(t), Value::Object(s)) => {
for (k, v) in s {
match t.get_mut(&k) {
Some(existing) => merge_into(existing, v),
None => {
t.insert(k, v);
}
}
}
}
(Value::Array(t), Value::Array(s)) => {
t.extend(s);
}
(target, source) => {
*target = source;
}
}
}
fn json_value_kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn parse_bool(s: &str) -> Result<bool, std::io::Error> {
let trimmed = s.trim();
if trimmed == "1"
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
{
return Ok(true);
}
if trimmed.is_empty()
|| trimmed == "0"
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off")
{
return Ok(false);
}
Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("invalid boolean: `{s}`")))
}