use super::Context;
use crate::error::{Mecha10Error, Result};
use crate::node_config_base::NodeConfigBase;
use anyhow::Context as _;
use schemars::JsonSchema;
use serde::de::DeserializeOwned;
use serde_json::Value;
impl Context {
pub async fn load_node_config<T>(&self, node_name: &str) -> Result<T>
where
T: DeserializeOwned + Default,
{
self.load_node_config_for_instance(node_name, self.instance()).await
}
pub async fn load_node_config_for_instance<T>(&self, node_name: &str, instance_id: Option<&str>) -> Result<T>
where
T: DeserializeOwned + Default,
{
Self::resolve_node_config(node_name, instance_id).await
}
pub async fn load_and_validate_node_config<T>(&self, node_name: &str) -> Result<T>
where
T: DeserializeOwned + Default + JsonSchema,
{
Self::resolve_node_config_validated(node_name).await
}
fn find_node_config_path(node_name: &str) -> Result<Option<std::path::PathBuf>> {
use std::env;
let current_dir = env::current_dir()
.map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
let Some(project_root) = crate::fs_utils::find_project_root(¤t_dir) else {
return Ok(None);
};
let flat_config_path = project_root
.join("configs")
.join("nodes")
.join("@mecha10")
.join(node_name)
.join("config.json");
if flat_config_path.exists() {
return Ok(Some(flat_config_path));
}
let project_config_path = project_root
.join("configs")
.join("nodes")
.join(node_name)
.join("config.json");
if project_config_path.exists() {
return Ok(Some(project_config_path));
}
Ok(None)
}
async fn resolve_node_config_validated<T>(node_name: &str) -> Result<T>
where
T: DeserializeOwned + Default + JsonSchema,
{
use std::env;
let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
let Some(config_path) = Self::find_node_config_path(node_name)? else {
tracing::debug!(
"No config file found for node '{}', using Default implementation",
node_name
);
return Ok(T::default());
};
tracing::debug!(
"Loading node config for '{}' from: {}",
node_name,
config_path.display()
);
Self::load_environment_aware_config_validated(&config_path, &environment)
.with_context(|| {
format!(
"Failed to load node config for '{}' from '{}'",
node_name,
config_path.display()
)
})
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))
}
fn load_environment_aware_config_validated<T>(path: &std::path::Path, environment: &str) -> Result<T>
where
T: DeserializeOwned + Default + JsonSchema,
{
let content =
std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
let json_value: Value =
serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
let obj = json_value.as_object().ok_or_else(|| {
Mecha10Error::Configuration(format!(
"Config must be a JSON object with 'dev'/'production' keys: {}",
path.display()
))
})?;
let env_key = match environment {
"production" | "prod" => "production",
_ => "dev",
};
let env_section = obj.get(env_key).ok_or_else(|| {
Mecha10Error::Configuration(format!(
"Config is missing its '{}' environment section (MECHA10_ENV='{}'): {}",
env_key,
environment,
path.display()
))
})?;
let config_value = split_base_and_custom(env_section)?;
validate_against_declared_schema::<T>(&config_value, path, env_key)?;
tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
let config: T = serde_json::from_value(config_value)
.with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
Ok(config)
}
async fn resolve_node_config<T>(node_name: &str, instance_id: Option<&str>) -> Result<T>
where
T: DeserializeOwned + Default,
{
use std::env;
let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
let current_dir = env::current_dir()
.map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
if let Some(project_root) = crate::fs_utils::find_project_root(¤t_dir) {
let flat_config_path = project_root
.join("configs")
.join("nodes")
.join("@mecha10")
.join(node_name)
.join("config.json");
if flat_config_path.exists() {
tracing::debug!(
"Loading node config for '{}' from flat path: {}",
node_name,
flat_config_path.display()
);
return Self::load_environment_aware_config(&flat_config_path, &environment, instance_id)
.with_context(|| {
format!(
"Failed to load node config for '{}' from '{}'",
node_name,
flat_config_path.display()
)
})
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
}
let project_config_path = project_root
.join("configs")
.join("nodes")
.join(node_name)
.join("config.json");
if project_config_path.exists() {
tracing::debug!(
"Loading node config for '{}' from project path: {}",
node_name,
project_config_path.display()
);
return Self::load_environment_aware_config(&project_config_path, &environment, instance_id)
.with_context(|| {
format!(
"Failed to load node config for '{}' from '{}'",
node_name,
project_config_path.display()
)
})
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
}
}
tracing::debug!(
"No config file found for node '{}', using Default implementation",
node_name
);
let config = T::default();
Ok(config)
}
fn load_environment_aware_config<T>(
path: &std::path::Path,
environment: &str,
instance_id: Option<&str>,
) -> Result<T>
where
T: DeserializeOwned + Default,
{
let content =
std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
let json_value: serde_json::Value =
serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
let obj = json_value.as_object().ok_or_else(|| {
Mecha10Error::Configuration(format!(
"Config must be a JSON object with 'dev'/'production' keys: {}",
path.display()
))
})?;
let env_key = match environment {
"production" | "prod" => "production",
_ => "dev",
};
let env_section = obj.get(env_key).ok_or_else(|| {
Mecha10Error::Configuration(format!(
"Config is missing its '{}' environment section (MECHA10_ENV='{}'): {}",
env_key,
environment,
path.display()
))
})?;
tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
let config_value = match instance_id {
Some(id) => env_section
.get(id)
.ok_or_else(|| {
Mecha10Error::Configuration(format!(
"Config for instance '{}' not found - expected a top-level '{}' key inside the '{}' \
environment section of: {}",
id,
id,
env_key,
path.display()
))
})?
.clone(),
None => env_section.clone(),
};
let config_value = split_base_and_custom(&config_value)?;
let config: T = serde_json::from_value(config_value)
.with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
Ok(config)
}
}
pub fn split_base_and_custom(value: &Value) -> Result<Value> {
let base: NodeConfigBase = serde_json::from_value(value.clone())
.with_context(|| "Failed to deserialize reserved/base config fields")
.map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
let base_fields = serde_json::to_value(&base)
.expect("NodeConfigBase always serializes to a JSON object")
.as_object()
.expect("NodeConfigBase always serializes to a JSON object")
.clone();
let mut merged = match value.as_object() {
Some(input_obj) => base_fields
.into_iter()
.filter(|(key, _)| input_obj.contains_key(key))
.collect::<serde_json::Map<String, Value>>(),
None => base_fields,
};
let custom = value
.get("custom")
.cloned()
.unwrap_or_else(|| Value::Object(Default::default()));
let custom_obj = custom
.as_object()
.ok_or_else(|| Mecha10Error::Configuration("Node config's 'custom' field must be a JSON object".to_string()))?;
merged.extend(custom_obj.clone());
Ok(Value::Object(merged))
}
fn validate_against_declared_schema<T: JsonSchema>(
value: &Value,
path: &std::path::Path,
env_label: &str,
) -> Result<()> {
let schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
let schema_value = serde_json::to_value(schema).expect("a schemars-derived root schema always serializes to JSON");
let validator = jsonschema::options()
.with_draft(jsonschema::Draft::Draft7)
.build(&schema_value)
.map_err(|e| Mecha10Error::Configuration(format!("Invalid schema derived for node config type: {}", e)))?;
if validator.is_valid(value) {
return Ok(());
}
let messages: Vec<String> = validator
.iter_errors(value)
.map(|e| format!(" - {}: {} ({})", e.instance_path(), e, e.instance()))
.collect();
Err(Mecha10Error::Configuration(format!(
"Node config validation failed for '{}' [{}]:\n{}",
path.display(),
env_label,
messages.join("\n")
)))
}
#[cfg(test)]
mod tests {
use super::Context;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serial_test::serial;
use std::fs;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
struct TestNodeConfig {
#[serde(default)]
value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
#[serde(deny_unknown_fields)]
struct StrictTestNodeConfig {
#[serde(default)]
value: String,
#[serde(flatten)]
base: crate::node_config_base::NodeConfigBase,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
struct StrictPositiveConfig {
#[serde(default)]
#[schemars(range(min = 1))]
rate_hz: i32,
}
fn envelope(dev_value: &str) -> String {
format!(
r#"{{"dev": {{"custom": {{"value": "{dev_value}"}}}}, "production": {{"custom": {{"value": "{dev_value}-prod"}}}}}}"#
)
}
#[tokio::test]
#[serial]
async fn resolve_node_config_loads_from_project_tier_when_only_project_config_exists() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap().value, "project");
}
#[tokio::test]
#[serial]
async fn resolve_node_config_prefers_framework_tier_over_project_tier() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let framework_dir = project
.path()
.join("configs")
.join("nodes")
.join("@mecha10")
.join("my-node");
fs::create_dir_all(&framework_dir).unwrap();
fs::write(framework_dir.join("config.json"), envelope("framework")).unwrap();
let project_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&project_dir).unwrap();
fs::write(project_dir.join("config.json"), envelope("project")).unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap().value, "framework");
}
#[tokio::test]
#[serial]
async fn resolve_node_config_with_instance_id_loads_the_keyed_sub_object() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("camera");
fs::create_dir_all(&node_dir).unwrap();
fs::write(
node_dir.join("config.json"),
r#"{
"dev": {
"real": {"custom": {"value": "real"}},
"sim": {"custom": {"value": "sim"}}
},
"production": {
"real": {"custom": {"value": "real-prod"}},
"sim": {"custom": {"value": "sim-prod"}}
}
}"#,
)
.unwrap();
std::env::set_current_dir(project.path()).unwrap();
let real = Context::resolve_node_config::<TestNodeConfig>("camera", Some("real")).await;
let sim = Context::resolve_node_config::<TestNodeConfig>("camera", Some("sim")).await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(real.unwrap().value, "real");
assert_eq!(sim.unwrap().value, "sim");
}
#[tokio::test]
#[serial]
async fn resolve_node_config_errors_when_instance_id_key_is_missing() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("camera");
fs::create_dir_all(&node_dir).unwrap();
fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("camera", Some("missing")).await;
std::env::set_current_dir(original_dir).unwrap();
let err = result.unwrap_err();
assert!(err.to_string().contains("missing"));
}
#[tokio::test]
#[serial]
async fn resolve_node_config_without_instance_id_is_unaffected_by_keyed_config_support() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap().value, "project");
}
#[tokio::test]
#[serial]
async fn resolve_node_config_populates_flattened_relay_topics_field_on_a_strict_node_config() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(
node_dir.join("config.json"),
r#"{
"dev": { "custom": { "value": "project" }, "relay_topics": ["/my-node/status"] },
"production": { "custom": { "value": "project-prod" }, "relay_topics": ["/my-node/status"] }
}"#,
)
.unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
let config = result.unwrap();
assert_eq!(config.value, "project");
assert_eq!(
config.base.relay.relay_topics,
Some(vec!["/my-node/status".to_string()])
);
}
#[tokio::test]
#[serial]
async fn resolve_node_config_ignores_dead_v1_and_common_tiers_and_falls_back_to_default() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let env_tier_dir = project.path().join("configs").join("dev").join("nodes").join("my-node");
fs::create_dir_all(&env_tier_dir).unwrap();
fs::write(env_tier_dir.join("config.json"), r#"{"value": "dead-env-tier"}"#).unwrap();
let common_tier_dir = project
.path()
.join("configs")
.join("common")
.join("nodes")
.join("my-node");
fs::create_dir_all(&common_tier_dir).unwrap();
fs::write(common_tier_dir.join("config.json"), r#"{"value": "dead-common-tier"}"#).unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap(), TestNodeConfig::default());
}
#[tokio::test]
#[serial]
async fn resolve_node_config_errors_when_active_environment_section_is_missing() {
let original_dir = std::env::current_dir().unwrap();
let original_env = std::env::var("MECHA10_ENV").ok();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(node_dir.join("config.json"), r#"{"dev": {"value": "project"}}"#).unwrap();
std::env::set_var("MECHA10_ENV", "production");
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
match original_env {
Some(value) => std::env::set_var("MECHA10_ENV", value),
None => std::env::remove_var("MECHA10_ENV"),
}
let err = result.unwrap_err();
assert!(err.to_string().contains("production"));
}
#[tokio::test]
#[serial]
async fn resolve_node_config_falls_back_to_default_when_no_project_root_is_found() {
let original_dir = std::env::current_dir().unwrap();
let dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap(), TestNodeConfig::default());
}
#[tokio::test]
#[serial]
async fn resolve_node_config_validated_passes_for_a_schema_satisfying_config() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config_validated::<TestNodeConfig>("my-node").await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap().value, "project");
}
#[tokio::test]
#[serial]
async fn resolve_node_config_validated_rejects_a_value_violating_a_schemars_constraint() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(
node_dir.join("config.json"),
r#"{"dev": {"custom": {"rate_hz": 0}}, "production": {"custom": {"rate_hz": 1}}}"#,
)
.unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config_validated::<StrictPositiveConfig>("my-node").await;
std::env::set_current_dir(original_dir).unwrap();
let err = result.unwrap_err().to_string();
assert!(err.contains("rate_hz"), "error should name the failing field: {}", err);
}
#[tokio::test]
#[serial]
async fn resolve_node_config_validated_falls_back_to_default_when_no_config_file_exists() {
let original_dir = std::env::current_dir().unwrap();
let dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let result = Context::resolve_node_config_validated::<TestNodeConfig>("my-node").await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap(), TestNodeConfig::default());
}
#[tokio::test]
#[serial]
async fn resolve_node_config_validated_populates_flattened_relay_topics_field() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(
node_dir.join("config.json"),
r#"{
"dev": { "custom": { "value": "project" }, "relay_topics": ["/my-node/status"] },
"production": { "custom": { "value": "project-prod" }, "relay_topics": ["/my-node/status"] }
}"#,
)
.unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config_validated::<StrictTestNodeConfig>("my-node").await;
std::env::set_current_dir(original_dir).unwrap();
let config = result.unwrap();
assert_eq!(config.value, "project");
assert_eq!(
config.base.relay.relay_topics,
Some(vec!["/my-node/status".to_string()])
);
}
#[tokio::test]
#[serial]
async fn resolve_node_config_ignores_a_node_specific_field_left_flat_at_the_top_level() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(
node_dir.join("config.json"),
r#"{
"dev": { "value": "flat-and-ignored" },
"production": { "value": "flat-and-ignored-prod" }
}"#,
)
.unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
assert_eq!(result.unwrap().value, "");
}
#[tokio::test]
#[serial]
async fn resolve_node_config_treats_missing_custom_as_no_custom_fields() {
let original_dir = std::env::current_dir().unwrap();
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("mecha10.json"), "{}").unwrap();
let node_dir = project.path().join("configs").join("nodes").join("my-node");
fs::create_dir_all(&node_dir).unwrap();
fs::write(
node_dir.join("config.json"),
r#"{
"dev": { "relay_topics": ["/my-node/status"] },
"production": { "relay_topics": ["/my-node/status"] }
}"#,
)
.unwrap();
std::env::set_current_dir(project.path()).unwrap();
let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node", None).await;
std::env::set_current_dir(original_dir).unwrap();
let config = result.unwrap();
assert_eq!(config.value, "");
assert_eq!(
config.base.relay.relay_topics,
Some(vec!["/my-node/status".to_string()])
);
}
#[test]
fn split_base_and_custom_does_not_inject_an_unmentioned_base_field() {
let section = serde_json::json!({
"relay_topics": ["/my-node/status"],
"custom": { "value": "project" }
});
let merged = super::split_base_and_custom(§ion).unwrap();
let merged = merged.as_object().unwrap();
assert!(merged.contains_key("relay_topics"), "mentioned field must survive");
assert!(
merged.contains_key("value"),
"custom field must be promoted to the top level"
);
assert!(
!merged.contains_key("topics"),
"a base field never mentioned by the section must not be injected: {:?}",
merged
);
assert!(
!merged.contains_key("model") && !merged.contains_key("provider"),
"unmentioned Option base fields must not be injected either: {:?}",
merged
);
}
#[test]
fn split_base_and_custom_still_carries_through_a_mentioned_base_field() {
let section = serde_json::json!({
"topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] },
"custom": {}
});
let merged = super::split_base_and_custom(§ion).unwrap();
assert_eq!(
merged["topics"],
serde_json::json!({ "publishes": [{"output": "/a/b"}], "subscribes": [] })
);
}
}