use std::path::Path;
use subst::VariableMap;
use tracing::warn;
use super::Config;
use crate::config::file::{ConfigFileError, ConfigFileResult};
pub fn read_config<'a, M>(file_name: &Path, env: &'a M) -> ConfigFileResult<Config>
where
M: VariableMap<'a>,
M::Value: AsRef<str>,
{
let contents = std::fs::read_to_string(file_name)
.map_err(|e| ConfigFileError::ConfigLoadError(e, file_name.into()))?;
parse_config(&contents, env, file_name)
}
pub fn parse_config<'a, M>(contents: &str, env: &'a M, file_name: &Path) -> ConfigFileResult<Config>
where
M: VariableMap<'a>,
M::Value: AsRef<str>,
{
let substituted = subst::substitute(contents, env)
.map_err(|e| ConfigFileError::substitution(e, contents.to_string(), file_name))?;
let migrated = if needs_deprecated_migration(&substituted) {
match serde_yaml::from_str::<serde_yaml::Value>(&substituted) {
Ok(mut value) => {
migrate_deprecated_config(&mut value);
serde_yaml::to_string(&value).unwrap_or(substituted)
}
Err(_) => substituted,
}
} else {
substituted
};
let options = serde_saphyr::options! {
with_snippet: false,
};
serde_saphyr::from_str_with_options::<Config>(&migrated, options)
.map_err(|e| ConfigFileError::yaml_parse(e, migrated, file_name))
}
fn needs_deprecated_migration(yaml: &str) -> bool {
yaml.contains("cache_size_mb")
|| yaml.contains("tile_cache_size_mb")
|| yaml.contains("directory_cache_size_mb")
}
fn migrate_deprecated_config(value: &mut serde_yaml::Value) {
let Some(root) = value.as_mapping_mut() else {
return;
};
migrate_yaml_key(root, "cache_size_mb", &["cache", "size_mb"]);
migrate_yaml_key(root, "tile_cache_size_mb", &["cache", "tile_size_mb"]);
for section in ["sprites", "fonts"] {
if let Some(mapping) = root
.get_mut(serde_yaml::Value::String(section.into()))
.and_then(|v| v.as_mapping_mut())
{
migrate_yaml_key(mapping, "cache_size_mb", &["cache", "size_mb"]);
}
}
if let Some(mapping) = root
.get_mut(serde_yaml::Value::String("pmtiles".into()))
.and_then(|v| v.as_mapping_mut())
{
migrate_yaml_key(
mapping,
"directory_cache_size_mb",
&["directory_cache", "size_mb"],
);
}
}
fn migrate_yaml_key(mapping: &mut serde_yaml::Mapping, old_key: &str, new_path: &[&str]) {
debug_assert!(!new_path.is_empty(), "new_path must not be empty");
let old_yaml_key = serde_yaml::Value::String(old_key.into());
let Some(old_value) = mapping.remove(&old_yaml_key) else {
return;
};
let new_key_display = new_path.join(".");
let [parents @ .., leaf] = new_path else {
return;
};
let mut current = &mut *mapping;
for &segment in parents {
if !current.contains_key(segment) {
current.insert(
serde_yaml::Value::String(segment.into()),
serde_yaml::Value::Mapping(serde_yaml::Mapping::default()),
);
}
let Some(nested) = current.get_mut(segment).and_then(|v| v.as_mapping_mut()) else {
warn!(
"deprecated config: `{old_key}` is ignored because `{segment}` is already set. \
Please remove `{old_key}` from your configuration"
);
return;
};
current = nested;
}
if current.contains_key(leaf) {
warn!(
"deprecated config: `{old_key}` is ignored in favor of `{new_key_display}`. \
Please remove `{old_key}` from your configuration"
);
} else {
current.insert(serde_yaml::Value::String((*leaf).into()), old_value);
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
use std::time::Duration;
use rstest::rstest;
use super::*;
#[cfg(any(feature = "sprites", feature = "fonts"))]
use crate::config::file::FileConfigEnum;
use crate::config::file::{CachePolicy, Config, GlobalCacheConfig};
use crate::config::primitives::env::FauxEnv;
use crate::config::test_helpers::{render_failure, render_failure_json};
fn parse_yaml(yaml: &str) -> Config {
parse_config(
yaml,
&HashMap::<String, String>::new(),
Path::new("test.yaml"),
)
.unwrap()
}
fn faux_env(pairs: &[(&'static str, &str)]) -> FauxEnv {
FauxEnv(
pairs
.iter()
.map(|(k, v)| (*k, OsString::from(*v)))
.collect(),
)
}
fn parse_with_env(yaml: &str, env: &FauxEnv) -> Config {
parse_config(yaml, env, Path::new("test.yaml")).unwrap()
}
#[test]
fn syntax_error_unbalanced_quote() {
insta::assert_snapshot!(
render_failure(indoc::indoc! {r#"
srv:
listen_addresses: "0.0.0.0:3000
worker_processes: 4
"#}),
@r#"
× invalid indentation in multiline quoted scalar
╭─[config.yaml:3:3]
2 │ listen_addresses: "0.0.0.0:3000
3 │ worker_processes: 4
· ┬
· ╰── invalid indentation in multiline quoted scalar
╰────
"#
);
}
#[test]
fn unknown_enum_variant_in_on_invalid() {
insta::assert_snapshot!(render_failure("on_invalid: maybe\n"), @"
× unknown variant `maybe`, expected one of continue, ignore, warn, warning,
│ warnings, abort
╭─[config.yaml:1:13]
1 │ on_invalid: maybe
· ──┬──
· ╰── unknown variant `maybe`, expected one of continue, ignore, warn, warning, warnings, abort
╰────
");
}
#[test]
fn substitution_undefined_variable() {
insta::assert_snapshot!(render_failure("cache_size_mb: ${UNDEFINED_VAR}\n"), @"
martin::config::substitution (https://maplibre.org/martin/config-file/)
× Unable to substitute environment variables in config file config.yaml: No
│ such variable: $UNDEFINED_VAR
╭─[config.yaml:1:18]
1 │ cache_size_mb: ${UNDEFINED_VAR}
· ──────┬──────
· ╰── No such variable: $UNDEFINED_VAR
╰────
help: Make sure every ${VAR} reference resolves to an environment variable,
or supply a default with `${VAR:-fallback}`.
");
}
#[test]
fn cors_unsupported_scalar_renders_as_json() {
let json = render_failure_json("cors: 42\n");
let parsed: serde_json::Value =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("not JSON: {e}\n{json}"));
let message = parsed.get("message").and_then(|m| m.as_str()).unwrap_or("");
assert!(
message.contains("invalid type: integer `42`"),
"unexpected message in JSON output: {message}"
);
assert_eq!(
parsed.get("severity").and_then(|s| s.as_str()),
Some("error")
);
assert_eq!(
parsed.get("filename").and_then(|f| f.as_str()),
Some("config.yaml")
);
let labels = parsed.get("labels").and_then(|l| l.as_array()).unwrap();
assert_eq!(labels.len(), 1, "expected one label, got {labels:?}");
let span = labels[0].get("span").unwrap();
assert!(span.get("offset").is_some(), "label missing offset");
assert!(span.get("length").is_some(), "label missing length");
}
#[test]
fn substitution_renders_as_json_with_code_help_url() {
let json = render_failure_json("cache_size_mb: ${UNDEFINED_VAR}\n");
let parsed: serde_json::Value =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("not JSON: {e}\n{json}"));
assert_eq!(
parsed.get("code").and_then(|c| c.as_str()),
Some("martin::config::substitution")
);
let help = parsed.get("help").and_then(|h| h.as_str()).unwrap_or("");
assert!(
help.contains("${VAR}"),
"expected help text mentioning ${{VAR}}, got: {help}"
);
assert_eq!(
parsed.get("url").and_then(|u| u.as_str()),
Some("https://maplibre.org/martin/config-file/")
);
}
#[test]
fn substitution_unclosed_brace() {
insta::assert_snapshot!(render_failure("cache_size_mb: ${BROKEN\n"), @r"
martin::config::substitution (https://maplibre.org/martin/config-file/)
× Unable to substitute environment variables in config file config.yaml:
│ Unexpected character: '\n', expected a closing brace ('}') or colon (':')
╭─[config.yaml:1:24]
1 │ cache_size_mb: ${BROKEN
· ┬
· ╰── Unexpected character: '\n', expected a closing brace ('}') or colon (':')
╰────
help: Make sure every ${VAR} reference resolves to an environment variable,
or supply a default with `${VAR:-fallback}`.
");
}
#[test]
fn cache_migrates_old_to_new_cache_config_key() {
let config = parse_yaml("cache_size_mb: 512");
assert_eq!(config.cache.size_mb, Some(512));
}
#[test]
fn migrate_tile_cache_size_mb_to_cache_tile_size_mb() {
let config = parse_yaml("tile_cache_size_mb: 256");
assert_eq!(config.cache.tile_size_mb, Some(256));
}
#[test]
fn migrate_both_old_cache_keys() {
let config = parse_yaml("cache_size_mb: 512\ntile_cache_size_mb: 256");
assert_eq!(config.cache.size_mb, Some(512));
assert_eq!(config.cache.tile_size_mb, Some(256));
}
#[test]
fn new_cache_key_overrides_old() {
let config = parse_yaml("cache_size_mb: 100\ncache:\n size_mb: 200");
assert_eq!(config.cache.size_mb, Some(200));
}
#[test]
fn new_cache_format_works_directly() {
let config =
parse_yaml("cache:\n size_mb: 512\n tile_size_mb: 256\n minzoom: 2\n maxzoom: 10");
assert_eq!(config.cache.size_mb, Some(512));
assert_eq!(config.cache.tile_size_mb, Some(256));
}
#[cfg(feature = "sprites")]
#[test]
fn migrate_sprites_cache_size_mb() {
let config = parse_yaml("sprites:\n cache_size_mb: 64\n paths: /tmp");
let FileConfigEnum::Config(cfg) = &config.sprites else {
panic!("expected sprites config");
};
assert_eq!(cfg.custom.cache.size_mb, Some(64));
}
#[cfg(feature = "fonts")]
#[test]
fn migrate_fonts_cache_size_mb() {
let config = parse_yaml("fonts:\n cache_size_mb: 32\n paths: /tmp");
let FileConfigEnum::Config(cfg) = &config.fonts else {
panic!("expected fonts config");
};
assert_eq!(cfg.custom.cache.size_mb, Some(32));
}
#[test]
fn migrate_skips_non_mapping_intermediate() {
let result = parse_config(
"cache: true\ncache_size_mb: 100",
&HashMap::<String, String>::new(),
Path::new("test.yaml"),
);
let _ = result;
}
#[test]
fn cache_disable_global() {
let config = parse_yaml("cache: disable");
assert_eq!(config.cache, GlobalCacheConfig::disabled());
assert_eq!(config.cache.size_mb, Some(0));
assert_eq!(config.cache.tile_size_mb, Some(0));
}
#[test]
fn cache_disable_global_propagates_to_unconfigured_source() {
let config = parse_yaml("cache: disable");
let global_policy = config.cache.policy();
let unconfigured_source = CachePolicy::default();
let merged = unconfigured_source.or(global_policy);
for zoom in 0..=u8::MAX {
assert!(!merged.zoom().contains(zoom));
}
}
#[cfg(feature = "sprites")]
#[test]
fn cache_disable_sprites() {
let config = parse_yaml("sprites:\n cache: disable\n paths: /tmp");
let FileConfigEnum::Config(cfg) = &config.sprites else {
panic!("expected sprites config");
};
assert_eq!(cfg.custom.cache.size_mb, Some(0));
}
#[test]
fn cache_expiry_global_config() {
let config = parse_yaml("cache:\n size_mb: 512\n expiry: 1h\n idle_timeout: 15m");
assert_eq!(config.cache.size_mb, Some(512));
assert_eq!(config.cache.expiry, Some(Duration::from_hours(1)));
assert_eq!(config.cache.idle_timeout, Some(Duration::from_mins(15)));
}
#[test]
fn cache_expiry_tile_specific() {
let config = parse_yaml(
"cache:\n expiry: 1h\n idle_timeout: 15m\n tile_expiry: 30m\n tile_idle_timeout: 5m",
);
assert_eq!(config.cache.expiry, Some(Duration::from_hours(1)));
assert_eq!(config.cache.tile_expiry, Some(Duration::from_mins(30)));
assert_eq!(config.cache.tile_idle_timeout, Some(Duration::from_mins(5)));
}
#[test]
fn cache_expiry_none_when_unset() {
let config = parse_yaml("cache:\n size_mb: 512");
assert_eq!(config.cache.expiry, None);
assert_eq!(config.cache.idle_timeout, None);
assert_eq!(config.cache.tile_expiry, None);
assert_eq!(config.cache.tile_idle_timeout, None);
}
#[cfg(feature = "sprites")]
#[test]
fn cache_expiry_sprites() {
let config = parse_yaml(
"sprites:\n cache:\n size_mb: 64\n expiry: 2h\n idle_timeout: 30m\n paths: /tmp",
);
let FileConfigEnum::Config(cfg) = &config.sprites else {
panic!("expected sprites config");
};
assert_eq!(cfg.custom.cache.size_mb, Some(64));
assert_eq!(cfg.custom.cache.expiry, Some(Duration::from_hours(2)));
assert_eq!(cfg.custom.cache.idle_timeout, Some(Duration::from_mins(30)));
}
#[rstest]
#[case::braced("${BASE}", "/my/path")]
#[case::bare("$BASE", "/my/path")]
#[case::braced_with_default_var_present("${BASE:fallback}", "/my/path")]
#[case::default_used_when_var_unset("${UNSET:/fallback}", "/fallback")]
#[case::prefix_and_suffix("prefix-${BASE}-suffix", "prefix-/my/path-suffix")]
#[case::escape_dollar(r"\$BASE", "$BASE")]
#[case::escape_brace(r"\${BASE}", "${BASE}")]
fn substitution_subst_accepted_forms(#[case] input: &str, #[case] expected: &str) {
let env = faux_env(&[("BASE", "/my/path")]);
let yaml = format!("base_path: {input}\n");
let config = parse_with_env(&yaml, &env);
assert_eq!(config.srv.base_path.as_deref(), Some(expected));
}
#[rstest]
#[case::dash_default("${UNSET:-fallback}", "-fallback")]
#[case::plus_alternate("${UNSET:+set}", "+set")]
#[case::question_required("${UNSET:?required}", "?required")]
fn substitution_subst_treats_shell_operators_as_literal(
#[case] input: &str,
#[case] expected: &str,
) {
let env = FauxEnv::default();
let yaml = format!("base_path: {input}\n");
let config = parse_with_env(&yaml, &env);
assert_eq!(config.srv.base_path.as_deref(), Some(expected));
}
#[rstest]
#[case::unquoted("base_path: ${BASE}\n")]
#[case::single_quoted("base_path: '${BASE}'\n")]
#[case::double_quoted("base_path: \"${BASE}\"\n")]
fn substitution_subst_interpolates_regardless_of_yaml_quotes(#[case] yaml: &str) {
let env = faux_env(&[("BASE", "/my/path")]);
let config = parse_with_env(yaml, &env);
assert_eq!(config.srv.base_path.as_deref(), Some("/my/path"));
}
#[test]
fn substitution_subst_errors_on_unset_var_inside_comment() {
insta::assert_snapshot!(
render_failure("# ${UNSET_IN_COMMENT}\nbase_path: /static\n"),
@"
martin::config::substitution (https://maplibre.org/martin/config-file/)
× Unable to substitute environment variables in config file config.yaml: No
│ such variable: $UNSET_IN_COMMENT
╭─[config.yaml:1:5]
1 │ # ${UNSET_IN_COMMENT}
· ────────┬───────
· ╰── No such variable: $UNSET_IN_COMMENT
2 │ base_path: /static
╰────
help: Make sure every ${VAR} reference resolves to an environment variable,
or supply a default with `${VAR:-fallback}`.
"
);
}
#[test]
fn substitution_subst_silently_substitutes_inside_comment() {
let env = faux_env(&[("DEFINED_IN_COMMENT", "anything")]);
let config = parse_with_env("# ${DEFINED_IN_COMMENT}\nbase_path: /x\n", &env);
assert_eq!(config.srv.base_path.as_deref(), Some("/x"));
}
#[test]
fn substitution_empty_default_with_unset_var_becomes_yaml_null() {
let env = FauxEnv::default();
let config = parse_with_env("base_path: ${UNSET:}\n", &env);
assert_eq!(config.srv.base_path, None);
}
#[rstest]
#[case::braced_in_migrated_key(
&[("SIZE", "512")],
"cache_size_mb: ${SIZE}\n",
Some(512), None, None,
)]
#[case::sibling_to_migrated_key(
&[("SIZE", "256"), ("BASE", "/served")],
"tile_cache_size_mb: ${SIZE}\nbase_path: ${BASE}\n",
None, Some(256), Some("/served"),
)]
#[case::bare_in_migrated_key(
&[("SIZE", "1024"), ("BASE", "/p")],
"cache_size_mb: $SIZE\nbase_path: $BASE\n",
Some(1024), None, Some("/p"),
)]
fn substitution_survives_deprecated_migration(
#[case] env_pairs: &[(&'static str, &str)],
#[case] yaml: &str,
#[case] expected_size_mb: Option<u64>,
#[case] expected_tile_size_mb: Option<u64>,
#[case] expected_base_path: Option<&str>,
) {
let env = faux_env(env_pairs);
let config = parse_with_env(yaml, &env);
assert_eq!(config.cache.size_mb, expected_size_mb);
assert_eq!(config.cache.tile_size_mb, expected_tile_size_mb);
assert_eq!(config.srv.base_path.as_deref(), expected_base_path);
}
#[test]
fn substitution_rejects_hyphen_in_variable_name() {
insta::assert_snapshot!(
render_failure("base_path: ${ab-cd}\n"),
@"
martin::config::substitution (https://maplibre.org/martin/config-file/)
× Unable to substitute environment variables in config file config.yaml:
│ Unexpected character: '-', expected a closing brace ('}') or colon (':')
╭─[config.yaml:1:16]
1 │ base_path: ${ab-cd}
· ┬
· ╰── Unexpected character: '-', expected a closing brace ('}') or colon (':')
╰────
help: Make sure every ${VAR} reference resolves to an environment variable,
or supply a default with `${VAR:-fallback}`.
"
);
}
#[test]
fn substitution_double_dollar_is_not_an_escape() {
insta::assert_snapshot!(
render_failure("base_path: $$BASE\n"),
@"
martin::config::substitution (https://maplibre.org/martin/config-file/)
× Unable to substitute environment variables in config file config.yaml:
│ Missing variable name
╭─[config.yaml:1:12]
1 │ base_path: $$BASE
· ┬
· ╰── Missing variable name
╰────
help: Make sure every ${VAR} reference resolves to an environment variable,
or supply a default with `${VAR:-fallback}`.
"
);
}
#[test]
fn substitution_failure_in_comment_renders_as_json() {
let json = render_failure_json("# ${UNSET_IN_COMMENT}\nbase_path: /x\n");
let parsed: serde_json::Value =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("not JSON: {e}\n{json}"));
assert_eq!(
parsed.get("code").and_then(|c| c.as_str()),
Some("martin::config::substitution"),
);
}
}