use crate::{Mecha10Error, Result};
use anyhow::Context as _;
use serde::de::DeserializeOwned;
use std::env;
use std::fs;
use std::path::Path;
pub mod defaults {
pub fn default_odom_rate_hz() -> f32 {
50.0
}
pub fn default_max_linear_vel() -> f32 {
0.5
}
pub fn default_max_angular_vel() -> f32 {
1.0
}
}
pub fn env_or_default(key: &str, default: &str) -> String {
env::var(key).unwrap_or_else(|_| default.to_string())
}
pub fn to_websocket_url(url: &str) -> String {
if let Some(rest) = url.strip_prefix("https://") {
format!("wss://{}", rest)
} else if let Some(rest) = url.strip_prefix("http://") {
format!("ws://{}", rest)
} else {
format!("wss://{}", url)
}
}
pub fn load_config<T, P>(path: P) -> Result<T>
where
T: DeserializeOwned,
P: AsRef<Path>,
{
let path = path.as_ref();
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read config file at '{}'", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
let content = substitute_env_vars(&content)
.with_context(|| format!("Failed to substitute environment variables in '{}'", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
let value: serde_json::Value = match path.extension().and_then(|s| s.to_str()) {
Some("json") => serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON config from '{}'", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?,
Some("yaml") | Some("yml") => serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML config from '{}'", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?,
Some("toml") => {
let toml_value: toml::Value = toml::from_str(&content)
.with_context(|| format!("Failed to parse TOML config from '{}'", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
serde_json::to_value(toml_value)
.map_err(|e| Mecha10Error::Configuration(format!("Failed to convert TOML: {}", e)))?
}
Some(ext) => {
return Err(Mecha10Error::Configuration(format!(
"Unsupported config file extension: {}",
ext
)));
}
None => {
return Err(Mecha10Error::Configuration("Config file has no extension".to_string()));
}
};
let final_value = extract_env_config(value);
let config: T = serde_json::from_value(final_value)
.with_context(|| format!("Failed to parse config from '{}'", path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
maybe_validate(&config)?;
Ok(config)
}
fn extract_env_config(value: serde_json::Value) -> serde_json::Value {
let env_keys = ["dev", "development", "prod", "production", "staging", "test"];
if let serde_json::Value::Object(ref map) = value {
let current_env = env::var("MECHA10_ENV").unwrap_or_else(|_| "development".to_string());
let env_key = match current_env.as_str() {
"development" | "dev" => {
if map.contains_key("dev") {
"dev"
} else {
"development"
}
}
"production" | "prod" => {
if map.contains_key("production") {
"production"
} else {
"prod"
}
}
other => other,
};
if let Some(env_config) = map.get(env_key) {
return env_config.clone();
}
let has_any_env_keys = map.keys().any(|k| env_keys.contains(&k.as_str()));
if has_any_env_keys {
return serde_json::Value::Object(serde_json::Map::new());
}
return serde_json::Value::Object(serde_json::Map::new());
}
serde_json::Value::Object(serde_json::Map::new())
}
pub fn load_config_from_env<T>(prefix: &str) -> Result<T>
where
T: DeserializeOwned,
{
let mut config_map = serde_json::Map::new();
for (key, value) in env::vars() {
if let Some(suffix) = key.strip_prefix(&format!("{}_", prefix)) {
let json_key = suffix.to_lowercase();
let json_value = serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value));
config_map.insert(json_key, json_value);
}
}
let config: T = serde_json::from_value(serde_json::Value::Object(config_map))
.map_err(|e| Mecha10Error::Configuration(format!("Failed to parse config from environment: {}", e)))?;
maybe_validate(&config)?;
Ok(config)
}
pub fn substitute_env_vars(input: &str) -> Result<String> {
let mut result = input.to_string();
let re = regex::Regex::new(r"\$\{([A-Z_][A-Z0-9_]*)\}").unwrap();
for cap in re.captures_iter(input) {
let var_name = &cap[1];
let var_value = env::var(var_name).map_err(|_| {
Mecha10Error::Configuration(format!(
"Environment variable {} not found (required by config)",
var_name
))
})?;
result = result.replace(&format!("${{{}}}", var_name), &var_value);
}
let re = regex::Regex::new(r"\$([A-Z_][A-Z0-9_]*)").unwrap();
for cap in re.captures_iter(input) {
let var_name = &cap[1];
if let Ok(var_value) = env::var(var_name) {
result = result.replace(&format!("${}", var_name), &var_value);
}
}
Ok(result)
}
pub trait Validate {
fn validate(&self) -> Result<()>;
}
pub(crate) fn maybe_validate<T>(config: &T) -> Result<()> {
#[allow(dead_code)]
struct Wrap<'a, T>(&'a T);
trait NoopValidate {
fn maybe_validate(&self) -> Result<()> {
Ok(())
}
}
impl<T> NoopValidate for Wrap<'_, T> {}
impl<T: Validate> Wrap<'_, T> {
#[allow(dead_code)]
fn maybe_validate(&self) -> Result<()> {
self.0.validate()
}
}
Wrap(config).maybe_validate()
}
pub fn load_and_validate<T, P>(path: P) -> Result<T>
where
T: DeserializeOwned + Validate,
P: AsRef<Path>,
{
load_config(path)
}
#[cfg(feature = "schema-validation")]
pub fn load_project_config<P: AsRef<Path>>(path: P) -> Result<serde_json::Value> {
use crate::schema_validation::load_and_validate_project;
load_and_validate_project(path)
}
pub fn config_key_to_path(key: &str) -> String {
if key == "mecha10.json" {
"mecha10.json".to_string()
} else if key.contains('/') {
format!("nodes/{}/config.json", key)
} else {
format!("{}/config.json", key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_key_to_path_project_config() {
assert_eq!(config_key_to_path("mecha10.json"), "mecha10.json");
}
#[test]
fn test_config_key_to_path_scoped_node() {
assert_eq!(config_key_to_path("@mecha10/motor"), "nodes/@mecha10/motor/config.json");
}
#[test]
fn test_config_key_to_path_simple_key() {
assert_eq!(config_key_to_path("simulation"), "simulation/config.json");
}
#[test]
fn test_to_websocket_url_https() {
assert_eq!(to_websocket_url("https://mecha.industries"), "wss://mecha.industries");
}
#[test]
fn test_to_websocket_url_http() {
assert_eq!(to_websocket_url("http://localhost:8000"), "ws://localhost:8000");
}
#[test]
fn test_to_websocket_url_no_scheme() {
assert_eq!(to_websocket_url("mecha.industries"), "wss://mecha.industries");
}
struct AlwaysValid;
impl Validate for AlwaysValid {
fn validate(&self) -> Result<()> {
Ok(())
}
}
struct AlwaysInvalid;
impl Validate for AlwaysInvalid {
fn validate(&self) -> Result<()> {
Err(Mecha10Error::Configuration("always invalid".to_string()))
}
}
struct NotValidated;
#[test]
fn test_maybe_validate_calls_validate_when_implemented() {
assert!(maybe_validate(&AlwaysValid).is_ok());
}
#[test]
fn test_maybe_validate_propagates_validation_errors() {
let err = maybe_validate(&AlwaysInvalid).unwrap_err();
assert!(matches!(err, Mecha10Error::Configuration(_)));
}
#[test]
fn test_maybe_validate_is_noop_for_types_without_validate() {
assert!(maybe_validate(&NotValidated).is_ok());
}
}