1use super::Context;
12use crate::error::{Mecha10Error, Result};
13use crate::node_config_base::NodeConfigBase;
14use anyhow::Context as _;
15use schemars::JsonSchema;
16use serde::de::DeserializeOwned;
17use serde_json::Value;
18
19impl Context {
20 pub async fn load_node_config<T>(&self, node_name: &str) -> Result<T>
82 where
83 T: DeserializeOwned + Default,
84 {
85 self.load_node_config_for_instance(node_name, self.instance()).await
86 }
87
88 pub async fn load_node_config_for_instance<T>(&self, node_name: &str, instance_id: Option<&str>) -> Result<T>
119 where
120 T: DeserializeOwned + Default,
121 {
122 Self::resolve_node_config(node_name, instance_id).await
123 }
124
125 pub async fn load_and_validate_node_config<T>(&self, node_name: &str) -> Result<T>
150 where
151 T: DeserializeOwned + Default + JsonSchema,
152 {
153 Self::resolve_node_config_validated(node_name).await
154 }
155
156 fn find_node_config_path(node_name: &str) -> Result<Option<std::path::PathBuf>> {
161 use std::env;
162
163 let current_dir = env::current_dir()
164 .map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
165
166 let Some(project_root) = crate::fs_utils::find_project_root(¤t_dir) else {
167 return Ok(None);
168 };
169
170 let flat_config_path = project_root
171 .join("configs")
172 .join("nodes")
173 .join("@mecha10")
174 .join(node_name)
175 .join("config.json");
176 if flat_config_path.exists() {
177 return Ok(Some(flat_config_path));
178 }
179
180 let project_config_path = project_root
181 .join("configs")
182 .join("nodes")
183 .join(node_name)
184 .join("config.json");
185 if project_config_path.exists() {
186 return Ok(Some(project_config_path));
187 }
188
189 Ok(None)
190 }
191
192 async fn resolve_node_config_validated<T>(node_name: &str) -> Result<T>
197 where
198 T: DeserializeOwned + Default + JsonSchema,
199 {
200 use std::env;
201
202 let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
203
204 let Some(config_path) = Self::find_node_config_path(node_name)? else {
205 tracing::debug!(
206 "No config file found for node '{}', using Default implementation",
207 node_name
208 );
209 return Ok(T::default());
210 };
211
212 tracing::debug!(
213 "Loading node config for '{}' from: {}",
214 node_name,
215 config_path.display()
216 );
217 Self::load_environment_aware_config_validated(&config_path, &environment)
218 .with_context(|| {
219 format!(
220 "Failed to load node config for '{}' from '{}'",
221 node_name,
222 config_path.display()
223 )
224 })
225 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))
226 }
227
228 fn load_environment_aware_config_validated<T>(path: &std::path::Path, environment: &str) -> Result<T>
234 where
235 T: DeserializeOwned + Default + JsonSchema,
236 {
237 let content =
238 std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
239
240 let json_value: Value =
241 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
242
243 let obj = json_value.as_object().ok_or_else(|| {
244 Mecha10Error::Configuration(format!(
245 "Config must be a JSON object with 'dev'/'production' keys: {}",
246 path.display()
247 ))
248 })?;
249
250 let env_key = match environment {
251 "production" | "prod" => "production",
252 _ => "dev",
253 };
254
255 let env_section = obj.get(env_key).ok_or_else(|| {
256 Mecha10Error::Configuration(format!(
257 "Config is missing its '{}' environment section (MECHA10_ENV='{}'): {}",
258 env_key,
259 environment,
260 path.display()
261 ))
262 })?;
263
264 let config_value = split_base_and_custom(env_section)?;
265 validate_against_declared_schema::<T>(&config_value, path, env_key)?;
266
267 tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
268 let config: T = serde_json::from_value(config_value)
269 .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
270 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
271
272 Ok(config)
273 }
274
275 async fn resolve_node_config<T>(node_name: &str, instance_id: Option<&str>) -> Result<T>
279 where
280 T: DeserializeOwned + Default,
281 {
282 use std::env;
283
284 let environment = env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
285
286 let current_dir = env::current_dir()
287 .map_err(|e| Mecha10Error::Configuration(format!("Failed to get current directory: {}", e)))?;
288
289 if let Some(project_root) = crate::fs_utils::find_project_root(¤t_dir) {
290 let flat_config_path = project_root
291 .join("configs")
292 .join("nodes")
293 .join("@mecha10")
294 .join(node_name)
295 .join("config.json");
296
297 if flat_config_path.exists() {
298 tracing::debug!(
299 "Loading node config for '{}' from flat path: {}",
300 node_name,
301 flat_config_path.display()
302 );
303 return Self::load_environment_aware_config(&flat_config_path, &environment, instance_id)
304 .with_context(|| {
305 format!(
306 "Failed to load node config for '{}' from '{}'",
307 node_name,
308 flat_config_path.display()
309 )
310 })
311 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
312 }
313
314 let project_config_path = project_root
315 .join("configs")
316 .join("nodes")
317 .join(node_name)
318 .join("config.json");
319
320 if project_config_path.exists() {
321 tracing::debug!(
322 "Loading node config for '{}' from project path: {}",
323 node_name,
324 project_config_path.display()
325 );
326 return Self::load_environment_aware_config(&project_config_path, &environment, instance_id)
327 .with_context(|| {
328 format!(
329 "Failed to load node config for '{}' from '{}'",
330 node_name,
331 project_config_path.display()
332 )
333 })
334 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)));
335 }
336 }
337
338 tracing::debug!(
339 "No config file found for node '{}', using Default implementation",
340 node_name
341 );
342 let config = T::default();
343 Ok(config)
344 }
345
346 fn load_environment_aware_config<T>(
356 path: &std::path::Path,
357 environment: &str,
358 instance_id: Option<&str>,
359 ) -> Result<T>
360 where
361 T: DeserializeOwned + Default,
362 {
363 let content =
364 std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?;
365
366 let json_value: serde_json::Value =
367 serde_json::from_str(&content).with_context(|| format!("Failed to parse JSON from: {}", path.display()))?;
368
369 let obj = json_value.as_object().ok_or_else(|| {
370 Mecha10Error::Configuration(format!(
371 "Config must be a JSON object with 'dev'/'production' keys: {}",
372 path.display()
373 ))
374 })?;
375
376 let env_key = match environment {
377 "production" | "prod" => "production",
378 _ => "dev",
379 };
380
381 let env_section = obj.get(env_key).ok_or_else(|| {
382 Mecha10Error::Configuration(format!(
383 "Config is missing its '{}' environment section (MECHA10_ENV='{}'): {}",
384 env_key,
385 environment,
386 path.display()
387 ))
388 })?;
389
390 tracing::debug!("Loading '{}' environment section from: {}", env_key, path.display());
391
392 let config_value = match instance_id {
398 Some(id) => env_section
399 .get(id)
400 .ok_or_else(|| {
401 Mecha10Error::Configuration(format!(
402 "Config for instance '{}' not found - expected a top-level '{}' key inside the '{}' \
403 environment section of: {}",
404 id,
405 id,
406 env_key,
407 path.display()
408 ))
409 })?
410 .clone(),
411 None => env_section.clone(),
412 };
413
414 let config_value = split_base_and_custom(&config_value)?;
415 let config: T = serde_json::from_value(config_value)
416 .with_context(|| format!("Failed to deserialize '{}' section from: {}", env_key, path.display()))
417 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
418
419 Ok(config)
420 }
421}
422
423pub fn split_base_and_custom(value: &Value) -> Result<Value> {
450 let base: NodeConfigBase = serde_json::from_value(value.clone())
451 .with_context(|| "Failed to deserialize reserved/base config fields")
452 .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))?;
453 let base_fields = serde_json::to_value(&base)
454 .expect("NodeConfigBase always serializes to a JSON object")
455 .as_object()
456 .expect("NodeConfigBase always serializes to a JSON object")
457 .clone();
458
459 let mut merged = match value.as_object() {
468 Some(input_obj) => base_fields
469 .into_iter()
470 .filter(|(key, _)| input_obj.contains_key(key))
471 .collect::<serde_json::Map<String, Value>>(),
472 None => base_fields,
473 };
474
475 let custom = value
476 .get("custom")
477 .cloned()
478 .unwrap_or_else(|| Value::Object(Default::default()));
479 let custom_obj = custom
480 .as_object()
481 .ok_or_else(|| Mecha10Error::Configuration("Node config's 'custom' field must be a JSON object".to_string()))?;
482 merged.extend(custom_obj.clone());
483
484 Ok(Value::Object(merged))
485}
486
487fn validate_against_declared_schema<T: JsonSchema>(
493 value: &Value,
494 path: &std::path::Path,
495 env_label: &str,
496) -> Result<()> {
497 let schema = schemars::SchemaGenerator::default().into_root_schema_for::<T>();
498 let schema_value = serde_json::to_value(schema).expect("a schemars-derived root schema always serializes to JSON");
499
500 let validator = jsonschema::options()
501 .with_draft(jsonschema::Draft::Draft7)
502 .build(&schema_value)
503 .map_err(|e| Mecha10Error::Configuration(format!("Invalid schema derived for node config type: {}", e)))?;
504
505 if validator.is_valid(value) {
506 return Ok(());
507 }
508
509 let messages: Vec<String> = validator
510 .iter_errors(value)
511 .map(|e| format!(" - {}: {} ({})", e.instance_path(), e, e.instance()))
512 .collect();
513
514 Err(Mecha10Error::Configuration(format!(
515 "Node config validation failed for '{}' [{}]:\n{}",
516 path.display(),
517 env_label,
518 messages.join("\n")
519 )))
520}
521
522#[cfg(test)]
523mod tests {
524 use super::Context;
525 use schemars::JsonSchema;
526 use serde::{Deserialize, Serialize};
527 use serial_test::serial;
528 use std::fs;
529
530 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
531 struct TestNodeConfig {
532 #[serde(default)]
533 value: String,
534 }
535
536 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
537 #[serde(deny_unknown_fields)]
538 struct StrictTestNodeConfig {
539 #[serde(default)]
540 value: String,
541 #[serde(flatten)]
542 base: crate::node_config_base::NodeConfigBase,
543 }
544
545 #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)]
547 struct StrictPositiveConfig {
548 #[serde(default)]
549 #[schemars(range(min = 1))]
550 rate_hz: i32,
551 }
552
553 fn envelope(dev_value: &str) -> String {
557 format!(
558 r#"{{"dev": {{"custom": {{"value": "{dev_value}"}}}}, "production": {{"custom": {{"value": "{dev_value}-prod"}}}}}}"#
559 )
560 }
561
562 #[tokio::test]
563 #[serial]
564 async fn resolve_node_config_loads_from_project_tier_when_only_project_config_exists() {
565 let original_dir = std::env::current_dir().unwrap();
566
567 let project = tempfile::tempdir().unwrap();
568 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
569 let node_dir = project.path().join("configs").join("nodes").join("my-node");
570 fs::create_dir_all(&node_dir).unwrap();
571 fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
572
573 std::env::set_current_dir(project.path()).unwrap();
574 let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
575 std::env::set_current_dir(original_dir).unwrap();
576
577 assert_eq!(result.unwrap().value, "project");
578 }
579
580 #[tokio::test]
581 #[serial]
582 async fn resolve_node_config_prefers_framework_tier_over_project_tier() {
583 let original_dir = std::env::current_dir().unwrap();
584
585 let project = tempfile::tempdir().unwrap();
586 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
587
588 let framework_dir = project
589 .path()
590 .join("configs")
591 .join("nodes")
592 .join("@mecha10")
593 .join("my-node");
594 fs::create_dir_all(&framework_dir).unwrap();
595 fs::write(framework_dir.join("config.json"), envelope("framework")).unwrap();
596
597 let project_dir = project.path().join("configs").join("nodes").join("my-node");
598 fs::create_dir_all(&project_dir).unwrap();
599 fs::write(project_dir.join("config.json"), envelope("project")).unwrap();
600
601 std::env::set_current_dir(project.path()).unwrap();
602 let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
603 std::env::set_current_dir(original_dir).unwrap();
604
605 assert_eq!(result.unwrap().value, "framework");
606 }
607
608 #[tokio::test]
611 #[serial]
612 async fn resolve_node_config_with_instance_id_loads_the_keyed_sub_object() {
613 let original_dir = std::env::current_dir().unwrap();
614
615 let project = tempfile::tempdir().unwrap();
616 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
617 let node_dir = project.path().join("configs").join("nodes").join("camera");
618 fs::create_dir_all(&node_dir).unwrap();
619 fs::write(
620 node_dir.join("config.json"),
621 r#"{
622 "dev": {
623 "real": {"custom": {"value": "real"}},
624 "sim": {"custom": {"value": "sim"}}
625 },
626 "production": {
627 "real": {"custom": {"value": "real-prod"}},
628 "sim": {"custom": {"value": "sim-prod"}}
629 }
630 }"#,
631 )
632 .unwrap();
633
634 std::env::set_current_dir(project.path()).unwrap();
635 let real = Context::resolve_node_config::<TestNodeConfig>("camera", Some("real")).await;
636 let sim = Context::resolve_node_config::<TestNodeConfig>("camera", Some("sim")).await;
637 std::env::set_current_dir(original_dir).unwrap();
638
639 assert_eq!(real.unwrap().value, "real");
640 assert_eq!(sim.unwrap().value, "sim");
641 }
642
643 #[tokio::test]
646 #[serial]
647 async fn resolve_node_config_errors_when_instance_id_key_is_missing() {
648 let original_dir = std::env::current_dir().unwrap();
649
650 let project = tempfile::tempdir().unwrap();
651 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
652 let node_dir = project.path().join("configs").join("nodes").join("camera");
653 fs::create_dir_all(&node_dir).unwrap();
654 fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
655
656 std::env::set_current_dir(project.path()).unwrap();
657 let result = Context::resolve_node_config::<TestNodeConfig>("camera", Some("missing")).await;
658 std::env::set_current_dir(original_dir).unwrap();
659
660 let err = result.unwrap_err();
661 assert!(err.to_string().contains("missing"));
662 }
663
664 #[tokio::test]
667 #[serial]
668 async fn resolve_node_config_without_instance_id_is_unaffected_by_keyed_config_support() {
669 let original_dir = std::env::current_dir().unwrap();
670
671 let project = tempfile::tempdir().unwrap();
672 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
673 let node_dir = project.path().join("configs").join("nodes").join("my-node");
674 fs::create_dir_all(&node_dir).unwrap();
675 fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
676
677 std::env::set_current_dir(project.path()).unwrap();
678 let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
679 std::env::set_current_dir(original_dir).unwrap();
680
681 assert_eq!(result.unwrap().value, "project");
682 }
683
684 #[tokio::test]
693 #[serial]
694 async fn resolve_node_config_populates_flattened_relay_topics_field_on_a_strict_node_config() {
695 let original_dir = std::env::current_dir().unwrap();
696
697 let project = tempfile::tempdir().unwrap();
698 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
699 let node_dir = project.path().join("configs").join("nodes").join("my-node");
700 fs::create_dir_all(&node_dir).unwrap();
701 fs::write(
702 node_dir.join("config.json"),
703 r#"{
704 "dev": { "custom": { "value": "project" }, "relay_topics": ["/my-node/status"] },
705 "production": { "custom": { "value": "project-prod" }, "relay_topics": ["/my-node/status"] }
706 }"#,
707 )
708 .unwrap();
709
710 std::env::set_current_dir(project.path()).unwrap();
711 let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node", None).await;
712 std::env::set_current_dir(original_dir).unwrap();
713
714 let config = result.unwrap();
715 assert_eq!(config.value, "project");
716 assert_eq!(
717 config.base.relay.relay_topics,
718 Some(vec!["/my-node/status".to_string()])
719 );
720 }
721
722 #[tokio::test]
723 #[serial]
724 async fn resolve_node_config_ignores_dead_v1_and_common_tiers_and_falls_back_to_default() {
725 let original_dir = std::env::current_dir().unwrap();
726
727 let project = tempfile::tempdir().unwrap();
728 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
729
730 let env_tier_dir = project.path().join("configs").join("dev").join("nodes").join("my-node");
731 fs::create_dir_all(&env_tier_dir).unwrap();
732 fs::write(env_tier_dir.join("config.json"), r#"{"value": "dead-env-tier"}"#).unwrap();
733
734 let common_tier_dir = project
735 .path()
736 .join("configs")
737 .join("common")
738 .join("nodes")
739 .join("my-node");
740 fs::create_dir_all(&common_tier_dir).unwrap();
741 fs::write(common_tier_dir.join("config.json"), r#"{"value": "dead-common-tier"}"#).unwrap();
742
743 std::env::set_current_dir(project.path()).unwrap();
744 let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
745 std::env::set_current_dir(original_dir).unwrap();
746
747 assert_eq!(result.unwrap(), TestNodeConfig::default());
748 }
749
750 #[tokio::test]
753 #[serial]
754 async fn resolve_node_config_errors_when_active_environment_section_is_missing() {
755 let original_dir = std::env::current_dir().unwrap();
756 let original_env = std::env::var("MECHA10_ENV").ok();
757
758 let project = tempfile::tempdir().unwrap();
759 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
760 let node_dir = project.path().join("configs").join("nodes").join("my-node");
761 fs::create_dir_all(&node_dir).unwrap();
762 fs::write(node_dir.join("config.json"), r#"{"dev": {"value": "project"}}"#).unwrap();
763
764 std::env::set_var("MECHA10_ENV", "production");
765 std::env::set_current_dir(project.path()).unwrap();
766 let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
767 std::env::set_current_dir(original_dir).unwrap();
768 match original_env {
769 Some(value) => std::env::set_var("MECHA10_ENV", value),
770 None => std::env::remove_var("MECHA10_ENV"),
771 }
772
773 let err = result.unwrap_err();
774 assert!(err.to_string().contains("production"));
775 }
776
777 #[tokio::test]
778 #[serial]
779 async fn resolve_node_config_falls_back_to_default_when_no_project_root_is_found() {
780 let original_dir = std::env::current_dir().unwrap();
781
782 let dir = tempfile::tempdir().unwrap();
783
784 std::env::set_current_dir(dir.path()).unwrap();
785 let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
786 std::env::set_current_dir(original_dir).unwrap();
787
788 assert_eq!(result.unwrap(), TestNodeConfig::default());
789 }
790
791 #[tokio::test]
792 #[serial]
793 async fn resolve_node_config_validated_passes_for_a_schema_satisfying_config() {
794 let original_dir = std::env::current_dir().unwrap();
795
796 let project = tempfile::tempdir().unwrap();
797 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
798 let node_dir = project.path().join("configs").join("nodes").join("my-node");
799 fs::create_dir_all(&node_dir).unwrap();
800 fs::write(node_dir.join("config.json"), envelope("project")).unwrap();
801
802 std::env::set_current_dir(project.path()).unwrap();
803 let result = Context::resolve_node_config_validated::<TestNodeConfig>("my-node").await;
804 std::env::set_current_dir(original_dir).unwrap();
805
806 assert_eq!(result.unwrap().value, "project");
807 }
808
809 #[tokio::test]
815 #[serial]
816 async fn resolve_node_config_validated_rejects_a_value_violating_a_schemars_constraint() {
817 let original_dir = std::env::current_dir().unwrap();
818
819 let project = tempfile::tempdir().unwrap();
820 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
821 let node_dir = project.path().join("configs").join("nodes").join("my-node");
822 fs::create_dir_all(&node_dir).unwrap();
823 fs::write(
824 node_dir.join("config.json"),
825 r#"{"dev": {"custom": {"rate_hz": 0}}, "production": {"custom": {"rate_hz": 1}}}"#,
826 )
827 .unwrap();
828
829 std::env::set_current_dir(project.path()).unwrap();
830 let result = Context::resolve_node_config_validated::<StrictPositiveConfig>("my-node").await;
831 std::env::set_current_dir(original_dir).unwrap();
832
833 let err = result.unwrap_err().to_string();
834 assert!(err.contains("rate_hz"), "error should name the failing field: {}", err);
835 }
836
837 #[tokio::test]
838 #[serial]
839 async fn resolve_node_config_validated_falls_back_to_default_when_no_config_file_exists() {
840 let original_dir = std::env::current_dir().unwrap();
841
842 let dir = tempfile::tempdir().unwrap();
843
844 std::env::set_current_dir(dir.path()).unwrap();
845 let result = Context::resolve_node_config_validated::<TestNodeConfig>("my-node").await;
846 std::env::set_current_dir(original_dir).unwrap();
847
848 assert_eq!(result.unwrap(), TestNodeConfig::default());
849 }
850
851 #[tokio::test]
855 #[serial]
856 async fn resolve_node_config_validated_populates_flattened_relay_topics_field() {
857 let original_dir = std::env::current_dir().unwrap();
858
859 let project = tempfile::tempdir().unwrap();
860 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
861 let node_dir = project.path().join("configs").join("nodes").join("my-node");
862 fs::create_dir_all(&node_dir).unwrap();
863 fs::write(
864 node_dir.join("config.json"),
865 r#"{
866 "dev": { "custom": { "value": "project" }, "relay_topics": ["/my-node/status"] },
867 "production": { "custom": { "value": "project-prod" }, "relay_topics": ["/my-node/status"] }
868 }"#,
869 )
870 .unwrap();
871
872 std::env::set_current_dir(project.path()).unwrap();
873 let result = Context::resolve_node_config_validated::<StrictTestNodeConfig>("my-node").await;
874 std::env::set_current_dir(original_dir).unwrap();
875
876 let config = result.unwrap();
877 assert_eq!(config.value, "project");
878 assert_eq!(
879 config.base.relay.relay_topics,
880 Some(vec!["/my-node/status".to_string()])
881 );
882 }
883
884 #[tokio::test]
888 #[serial]
889 async fn resolve_node_config_ignores_a_node_specific_field_left_flat_at_the_top_level() {
890 let original_dir = std::env::current_dir().unwrap();
891
892 let project = tempfile::tempdir().unwrap();
893 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
894 let node_dir = project.path().join("configs").join("nodes").join("my-node");
895 fs::create_dir_all(&node_dir).unwrap();
896 fs::write(
897 node_dir.join("config.json"),
898 r#"{
899 "dev": { "value": "flat-and-ignored" },
900 "production": { "value": "flat-and-ignored-prod" }
901 }"#,
902 )
903 .unwrap();
904
905 std::env::set_current_dir(project.path()).unwrap();
906 let result = Context::resolve_node_config::<TestNodeConfig>("my-node", None).await;
907 std::env::set_current_dir(original_dir).unwrap();
908
909 assert_eq!(result.unwrap().value, "");
910 }
911
912 #[tokio::test]
915 #[serial]
916 async fn resolve_node_config_treats_missing_custom_as_no_custom_fields() {
917 let original_dir = std::env::current_dir().unwrap();
918
919 let project = tempfile::tempdir().unwrap();
920 fs::write(project.path().join("mecha10.json"), "{}").unwrap();
921 let node_dir = project.path().join("configs").join("nodes").join("my-node");
922 fs::create_dir_all(&node_dir).unwrap();
923 fs::write(
924 node_dir.join("config.json"),
925 r#"{
926 "dev": { "relay_topics": ["/my-node/status"] },
927 "production": { "relay_topics": ["/my-node/status"] }
928 }"#,
929 )
930 .unwrap();
931
932 std::env::set_current_dir(project.path()).unwrap();
933 let result = Context::resolve_node_config::<StrictTestNodeConfig>("my-node", None).await;
934 std::env::set_current_dir(original_dir).unwrap();
935
936 let config = result.unwrap();
937 assert_eq!(config.value, "");
938 assert_eq!(
939 config.base.relay.relay_topics,
940 Some(vec!["/my-node/status".to_string()])
941 );
942 }
943
944 #[test]
953 fn split_base_and_custom_does_not_inject_an_unmentioned_base_field() {
954 let section = serde_json::json!({
955 "relay_topics": ["/my-node/status"],
956 "custom": { "value": "project" }
957 });
958
959 let merged = super::split_base_and_custom(§ion).unwrap();
960 let merged = merged.as_object().unwrap();
961
962 assert!(merged.contains_key("relay_topics"), "mentioned field must survive");
963 assert!(
964 merged.contains_key("value"),
965 "custom field must be promoted to the top level"
966 );
967 assert!(
968 !merged.contains_key("topics"),
969 "a base field never mentioned by the section must not be injected: {:?}",
970 merged
971 );
972 assert!(
973 !merged.contains_key("model") && !merged.contains_key("provider"),
974 "unmentioned Option base fields must not be injected either: {:?}",
975 merged
976 );
977 }
978
979 #[test]
983 fn split_base_and_custom_still_carries_through_a_mentioned_base_field() {
984 let section = serde_json::json!({
985 "topics": { "publishes": [{"output": "/a/b"}], "subscribes": [] },
986 "custom": {}
987 });
988
989 let merged = super::split_base_and_custom(§ion).unwrap();
990 assert_eq!(
991 merged["topics"],
992 serde_json::json!({ "publishes": [{"output": "/a/b"}], "subscribes": [] })
993 );
994 }
995}