Skip to main content

cu29_runtime/
config.rs

1//! This module defines the configuration of the copper runtime.
2//! The configuration is a directed graph where nodes are tasks and edges are connections between tasks.
3//! The configuration is serialized in the RON format.
4//! The configuration is used to generate the runtime code at compile time.
5#[cfg(not(feature = "std"))]
6extern crate alloc;
7
8use ConfigGraphs::{Missions, Simple};
9use core::any::type_name;
10use core::fmt;
11use core::fmt::Display;
12use cu29_traits::{CuError, CuResult};
13use cu29_value::Value as CuValue;
14use hashbrown::HashMap;
15pub use petgraph::Direction::Incoming;
16pub use petgraph::Direction::Outgoing;
17use petgraph::stable_graph::{EdgeIndex, NodeIndex, StableDiGraph};
18#[cfg(feature = "std")]
19use petgraph::visit::IntoEdgeReferences;
20use petgraph::visit::{Bfs, EdgeRef};
21use ron::extensions::Extensions;
22use ron::value::Value as RonValue;
23use ron::{Number, Options};
24use serde::de::DeserializeOwned;
25use serde::{Deserialize, Deserializer, Serialize, Serializer};
26
27#[cfg(not(feature = "std"))]
28use alloc::boxed::Box;
29#[cfg(not(feature = "std"))]
30use alloc::collections::BTreeMap;
31#[cfg(not(feature = "std"))]
32use alloc::vec;
33#[cfg(feature = "std")]
34use std::collections::BTreeMap;
35
36#[cfg(not(feature = "std"))]
37mod imp {
38    pub use alloc::borrow::ToOwned;
39    pub use alloc::format;
40    pub use alloc::string::String;
41    pub use alloc::string::ToString;
42    pub use alloc::vec::Vec;
43}
44
45#[cfg(feature = "std")]
46mod imp {
47    pub use html_escape::encode_text;
48    pub use std::fs::read_to_string;
49}
50
51use imp::*;
52
53/// NodeId is the unique identifier of a node in the configuration graph for petgraph
54/// and the code generation.
55pub type NodeId = u32;
56pub const DEFAULT_MISSION_ID: &str = "default";
57
58/// This is the configuration of a component (like a task config or a monitoring config):w
59/// It is a map of key-value pairs.
60/// It is given to the new method of the task implementation.
61#[derive(Serialize, Deserialize, Debug, Clone, Default)]
62pub struct ComponentConfig(pub HashMap<String, Value>);
63
64/// Mapping between resource binding names and bundle-scoped resource ids.
65#[allow(dead_code)]
66impl Display for ComponentConfig {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        let mut first = true;
69        let ComponentConfig(config) = self;
70        write!(f, "{{")?;
71        for (key, value) in config.iter() {
72            if !first {
73                write!(f, ", ")?;
74            }
75            write!(f, "{key}: {value}")?;
76            first = false;
77        }
78        write!(f, "}}")
79    }
80}
81
82// forward map interface
83impl ComponentConfig {
84    #[allow(dead_code)]
85    pub fn new() -> Self {
86        ComponentConfig(HashMap::new())
87    }
88
89    #[allow(dead_code)]
90    pub fn get<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
91    where
92        T: for<'a> TryFrom<&'a Value, Error = ConfigError>,
93    {
94        let ComponentConfig(config) = self;
95        match config.get(key) {
96            Some(value) => T::try_from(value).map(Some),
97            None => Ok(None),
98        }
99    }
100
101    #[allow(dead_code)]
102    /// Retrieve a structured config value by deserializing it with cu29-value.
103    ///
104    /// Example RON:
105    /// `{ "calibration": { "matrix": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], "enabled": true } }`
106    ///
107    /// ```rust,ignore
108    /// #[derive(serde::Deserialize)]
109    /// struct CalibrationCfg {
110    ///     matrix: [[f32; 3]; 3],
111    ///     enabled: bool,
112    /// }
113    /// let cfg: CalibrationCfg = config.get_value("calibration")?.unwrap();
114    /// ```
115    pub fn get_value<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
116    where
117        T: DeserializeOwned,
118    {
119        let ComponentConfig(config) = self;
120        let Some(value) = config.get(key) else {
121            return Ok(None);
122        };
123        let cu_value = ron_value_to_cu_value(&value.0).map_err(|err| err.with_key(key))?;
124        cu_value
125            .deserialize_into::<T>()
126            .map(Some)
127            .map_err(|err| ConfigError {
128                message: format!(
129                    "Config key '{key}' failed to deserialize as {}: {err}",
130                    type_name::<T>()
131                ),
132            })
133    }
134
135    #[allow(dead_code)]
136    pub fn deserialize_into<T>(&self) -> Result<T, ConfigError>
137    where
138        T: DeserializeOwned,
139    {
140        let mut map = BTreeMap::new();
141        for (key, value) in &self.0 {
142            let mapped_value = ron_value_to_cu_value(&value.0).map_err(|err| err.with_key(key))?;
143            map.insert(CuValue::String(key.clone()), mapped_value);
144        }
145
146        CuValue::Map(map)
147            .deserialize_into::<T>()
148            .map_err(|err| ConfigError {
149                message: format!(
150                    "Config failed to deserialize as {}: {err}",
151                    type_name::<T>()
152                ),
153            })
154    }
155
156    #[allow(dead_code)]
157    pub fn set<T: Into<Value>>(&mut self, key: &str, value: T) {
158        let ComponentConfig(config) = self;
159        config.insert(key.to_string(), value.into());
160    }
161
162    #[allow(dead_code)]
163    pub fn merge_from(&mut self, other: &ComponentConfig) {
164        let ComponentConfig(config) = self;
165        for (key, value) in &other.0 {
166            config.insert(key.clone(), value.clone());
167        }
168    }
169}
170
171fn ron_value_to_cu_value(value: &RonValue) -> Result<CuValue, ConfigError> {
172    match value {
173        RonValue::Bool(v) => Ok(CuValue::Bool(*v)),
174        RonValue::Char(v) => Ok(CuValue::Char(*v)),
175        RonValue::String(v) => Ok(CuValue::String(v.clone())),
176        RonValue::Bytes(v) => Ok(CuValue::Bytes(v.clone())),
177        RonValue::Unit => Ok(CuValue::Unit),
178        RonValue::Option(v) => {
179            let mapped = match v {
180                Some(inner) => Some(Box::new(ron_value_to_cu_value(inner)?)),
181                None => None,
182            };
183            Ok(CuValue::Option(mapped))
184        }
185        RonValue::Seq(seq) => {
186            let mut mapped = Vec::with_capacity(seq.len());
187            for item in seq {
188                mapped.push(ron_value_to_cu_value(item)?);
189            }
190            Ok(CuValue::Seq(mapped))
191        }
192        RonValue::Map(map) => {
193            let mut mapped = BTreeMap::new();
194            for (key, value) in map.iter() {
195                let mapped_key = ron_value_to_cu_value(key)?;
196                let mapped_value = ron_value_to_cu_value(value)?;
197                mapped.insert(mapped_key, mapped_value);
198            }
199            Ok(CuValue::Map(mapped))
200        }
201        RonValue::Number(num) => match num {
202            Number::I8(v) => Ok(CuValue::I8(*v)),
203            Number::I16(v) => Ok(CuValue::I16(*v)),
204            Number::I32(v) => Ok(CuValue::I32(*v)),
205            Number::I64(v) => Ok(CuValue::I64(*v)),
206            Number::U8(v) => Ok(CuValue::U8(*v)),
207            Number::U16(v) => Ok(CuValue::U16(*v)),
208            Number::U32(v) => Ok(CuValue::U32(*v)),
209            Number::U64(v) => Ok(CuValue::U64(*v)),
210            Number::F32(v) => Ok(CuValue::F32(v.0)),
211            Number::F64(v) => Ok(CuValue::F64(v.0)),
212            _ => Err(ConfigError {
213                message: "Unsupported RON number variant".to_string(),
214            }),
215        },
216    }
217}
218
219// The configuration Serialization format is as follows:
220// (
221//   tasks : [ (id: "toto", type: "zorglub::MyType", config: {...}),
222//             (id: "titi", type: "zorglub::MyType2", config: {...})]
223//   cnx : [ (src: "toto", dst: "titi", msg: "zorglub::MyMsgType"),...]
224// )
225
226/// Wrapper around the ron::Value to allow for custom serialization.
227#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
228pub struct Value(RonValue);
229
230/// Scalar representation used by compile-time constants after RON parsing.
231#[doc(hidden)]
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub enum ConstantNumber {
234    Signed(i64),
235    Unsigned(u64),
236    Float(f64),
237}
238
239impl ConstantNumber {
240    pub fn as_f64(self) -> f64 {
241        match self {
242            Self::Signed(value) => value as f64,
243            Self::Unsigned(value) => value as f64,
244            Self::Float(value) => value,
245        }
246    }
247}
248
249/// Rust scalar storage selected for a compile-time constant.
250#[doc(hidden)]
251#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
252#[serde(rename_all = "lowercase")]
253pub enum ConstantStorage {
254    I8,
255    I16,
256    I32,
257    I64,
258    Isize,
259    U8,
260    U16,
261    U32,
262    U64,
263    Usize,
264    #[default]
265    F32,
266    F64,
267}
268
269impl ConstantStorage {
270    pub const fn rust_type(self) -> &'static str {
271        match self {
272            Self::I8 => "i8",
273            Self::I16 => "i16",
274            Self::I32 => "i32",
275            Self::I64 => "i64",
276            Self::Isize => "isize",
277            Self::U8 => "u8",
278            Self::U16 => "u16",
279            Self::U32 => "u32",
280            Self::U64 => "u64",
281            Self::Usize => "usize",
282            Self::F32 => "f32",
283            Self::F64 => "f64",
284        }
285    }
286
287    pub const fn supports_quantity(self) -> bool {
288        matches!(self, Self::F32 | Self::F64)
289    }
290}
291
292/// One top-level `constants:` declaration.
293#[doc(hidden)]
294#[derive(Serialize, Deserialize, Debug, Clone)]
295pub struct ConstantConfig {
296    id: String,
297    #[serde(default, deserialize_with = "deserialize_constant_module")]
298    module: Option<String>,
299    #[serde(default)]
300    storage: Option<ConstantStorage>,
301    quantity: Option<cu29_units::constant::Quantity>,
302    unit: Option<cu29_units::constant::Unit>,
303    value: Option<Value>,
304    #[serde(rename = "type")]
305    rust_type: Option<String>,
306    expression: Option<String>,
307}
308
309fn deserialize_constant_module<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
310where
311    D: Deserializer<'de>,
312{
313    Option::<String>::deserialize(deserializer).map(|module| {
314        module.map(|module| {
315            module
316                .chars()
317                .filter(|character| !character.is_whitespace())
318                .collect()
319        })
320    })
321}
322
323impl ConstantConfig {
324    pub const DEFAULT_MODULE: &'static str = "constants";
325
326    pub fn id(&self) -> &str {
327        &self.id
328    }
329
330    pub fn module_path(&self) -> &str {
331        self.module.as_deref().unwrap_or(Self::DEFAULT_MODULE)
332    }
333
334    pub fn qualified_id(&self) -> String {
335        format!("{}::{}", self.module_path(), self.id)
336    }
337
338    pub const fn storage(&self) -> ConstantStorage {
339        match self.storage {
340            Some(storage) => storage,
341            None => ConstantStorage::F32,
342        }
343    }
344
345    pub const fn quantity(&self) -> Option<cu29_units::constant::Quantity> {
346        self.quantity
347    }
348
349    pub const fn explicit_unit(&self) -> Option<cu29_units::constant::Unit> {
350        self.unit
351    }
352
353    pub fn expression_definition(&self) -> Option<(&str, &str)> {
354        self.rust_type.as_deref().zip(self.expression.as_deref())
355    }
356
357    pub fn resolved_unit(&self) -> Result<Option<cu29_units::constant::Unit>, String> {
358        let Some(quantity) = self.quantity else {
359            return Ok(None);
360        };
361        if let Some(unit) = self.unit {
362            return Ok(Some(unit));
363        }
364        let definition = cu29_units::constant::definition(quantity).ok_or_else(|| {
365            format!(
366                "Constant '{}' uses quantity '{}' which is missing from the unit catalogue",
367                self.id,
368                quantity.name()
369            )
370        })?;
371        cu29_units::constant::Unit::from_name(definition.coherent_unit)
372            .map(Some)
373            .ok_or_else(|| {
374                format!(
375                    "Constant '{}' quantity '{}' has invalid coherent unit metadata '{}'",
376                    self.id,
377                    quantity.name(),
378                    definition.coherent_unit
379                )
380            })
381    }
382
383    pub fn numbers(&self) -> Result<(bool, Vec<ConstantNumber>), String> {
384        fn number(value: &RonValue) -> Result<ConstantNumber, String> {
385            match value {
386                RonValue::Number(number) => match number {
387                    Number::I8(value) => Ok(ConstantNumber::Signed(i64::from(*value))),
388                    Number::I16(value) => Ok(ConstantNumber::Signed(i64::from(*value))),
389                    Number::I32(value) => Ok(ConstantNumber::Signed(i64::from(*value))),
390                    Number::I64(value) => Ok(ConstantNumber::Signed(*value)),
391                    Number::U8(value) => Ok(ConstantNumber::Unsigned(u64::from(*value))),
392                    Number::U16(value) => Ok(ConstantNumber::Unsigned(u64::from(*value))),
393                    Number::U32(value) => Ok(ConstantNumber::Unsigned(u64::from(*value))),
394                    Number::U64(value) => Ok(ConstantNumber::Unsigned(*value)),
395                    Number::F32(value) => Ok(ConstantNumber::Float(f64::from(value.0))),
396                    Number::F64(value) => Ok(ConstantNumber::Float(value.0)),
397                    _ => Err("unsupported numeric representation".to_string()),
398                },
399                _ => Err("expected a number".to_string()),
400            }
401        }
402
403        let value = self
404            .value
405            .as_ref()
406            .ok_or_else(|| format!("Constant '{}' does not declare a numeric value", self.id))?;
407        match &value.0 {
408            RonValue::Seq(values) => values
409                .iter()
410                .map(number)
411                .collect::<Result<Vec<_>, _>>()
412                .map(|values| (true, values)),
413            value => number(value).map(|value| (false, vec![value])),
414        }
415        .map_err(|error| format!("Constant '{}': {error}", self.id))
416    }
417
418    pub fn normalized_f32(&self) -> Result<(bool, Vec<f32>), String> {
419        let quantity = self.quantity.ok_or_else(|| {
420            format!(
421                "Constant '{}' does not declare a physical quantity",
422                self.id
423            )
424        })?;
425        let unit = self
426            .resolved_unit()?
427            .ok_or_else(|| format!("Constant '{}' has no resolved unit", self.id))?;
428        let (is_array, numbers) = self.numbers()?;
429        numbers
430            .into_iter()
431            .map(|number| {
432                let value = number.as_f64() as f32;
433                if !value.is_finite() {
434                    return Err(format!("Constant '{}' values must be finite", self.id));
435                }
436                cu29_units::constant::normalize_f32(quantity, unit, value).ok_or_else(|| {
437                    format!(
438                        "Constant '{}' unit '{}' is not compatible with quantity '{}'",
439                        self.id,
440                        unit.name(),
441                        quantity.name()
442                    )
443                })
444            })
445            .collect::<Result<Vec<_>, _>>()
446            .map(|values| (is_array, values))
447    }
448
449    pub fn normalized_f64(&self) -> Result<(bool, Vec<f64>), String> {
450        let quantity = self.quantity.ok_or_else(|| {
451            format!(
452                "Constant '{}' does not declare a physical quantity",
453                self.id
454            )
455        })?;
456        let unit = self
457            .resolved_unit()?
458            .ok_or_else(|| format!("Constant '{}' has no resolved unit", self.id))?;
459        let (is_array, numbers) = self.numbers()?;
460        numbers
461            .into_iter()
462            .map(|number| {
463                let value = number.as_f64();
464                if !value.is_finite() {
465                    return Err(format!("Constant '{}' values must be finite", self.id));
466                }
467                cu29_units::constant::normalize_f64(quantity, unit, value).ok_or_else(|| {
468                    format!(
469                        "Constant '{}' unit '{}' is not compatible with quantity '{}'",
470                        self.id,
471                        unit.name(),
472                        quantity.name()
473                    )
474                })
475            })
476            .collect::<Result<Vec<_>, _>>()
477            .map(|values| (is_array, values))
478    }
479
480    /// Stable comparison key for detecting runtime attempts to change a baked constant.
481    #[allow(dead_code)]
482    pub fn semantic_fingerprint(&self) -> Result<u64, String> {
483        const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
484        const PRIME: u64 = 0x0000_0100_0000_01b3;
485
486        fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
487            for byte in bytes {
488                *hash ^= u64::from(*byte);
489                *hash = hash.wrapping_mul(PRIME);
490            }
491        }
492
493        let mut hash = OFFSET;
494        if let Some((rust_type, expression)) = self.expression_definition() {
495            hash_bytes(&mut hash, b"expression");
496            hash_bytes(&mut hash, rust_type.as_bytes());
497            hash_bytes(&mut hash, &[0]);
498            hash_bytes(&mut hash, expression.as_bytes());
499            return Ok(hash);
500        }
501
502        let storage = self.storage();
503        hash_bytes(&mut hash, b"numeric");
504        hash_bytes(&mut hash, storage.rust_type().as_bytes());
505        hash_bytes(
506            &mut hash,
507            self.quantity
508                .map_or("primitive", |quantity| quantity.name())
509                .as_bytes(),
510        );
511
512        if self.quantity.is_some() {
513            match storage {
514                ConstantStorage::F32 => {
515                    let (is_array, values) = self.normalized_f32()?;
516                    hash_bytes(&mut hash, &[u8::from(is_array)]);
517                    for value in values {
518                        hash_bytes(&mut hash, &value.to_bits().to_le_bytes());
519                    }
520                }
521                ConstantStorage::F64 => {
522                    let (is_array, values) = self.normalized_f64()?;
523                    hash_bytes(&mut hash, &[u8::from(is_array)]);
524                    for value in values {
525                        hash_bytes(&mut hash, &value.to_bits().to_le_bytes());
526                    }
527                }
528                _ => {
529                    return Err(format!(
530                        "Constant '{}' quantity storage must be f32 or f64",
531                        self.id
532                    ));
533                }
534            }
535            return Ok(hash);
536        }
537
538        let (is_array, numbers) = self.numbers()?;
539        hash_bytes(&mut hash, &[u8::from(is_array)]);
540        for number in numbers {
541            match (storage, number) {
542                (ConstantStorage::F32, number) => {
543                    hash_bytes(&mut hash, &(number.as_f64() as f32).to_bits().to_le_bytes())
544                }
545                (ConstantStorage::F64, number) => {
546                    hash_bytes(&mut hash, &number.as_f64().to_bits().to_le_bytes())
547                }
548                (_, ConstantNumber::Signed(value)) => hash_bytes(&mut hash, &value.to_le_bytes()),
549                (_, ConstantNumber::Unsigned(value)) => hash_bytes(&mut hash, &value.to_le_bytes()),
550                (_, ConstantNumber::Float(value)) => {
551                    hash_bytes(&mut hash, &value.to_bits().to_le_bytes())
552                }
553            }
554        }
555        Ok(hash)
556    }
557}
558
559#[derive(Debug, Clone, PartialEq)]
560pub struct ConfigError {
561    message: String,
562}
563
564impl ConfigError {
565    fn type_mismatch(expected: &'static str, value: &Value) -> Self {
566        ConfigError {
567            message: format!("Expected {expected} but got {value:?}"),
568        }
569    }
570
571    fn with_key(self, key: &str) -> Self {
572        ConfigError {
573            message: format!("Config key '{key}': {}", self.message),
574        }
575    }
576}
577
578impl Display for ConfigError {
579    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
580        write!(f, "{}", self.message)
581    }
582}
583
584#[cfg(feature = "std")]
585impl std::error::Error for ConfigError {}
586
587#[cfg(not(feature = "std"))]
588impl core::error::Error for ConfigError {}
589
590impl From<ConfigError> for CuError {
591    fn from(err: ConfigError) -> Self {
592        CuError::from(err.to_string())
593    }
594}
595
596// Macro for implementing From<T> for Value where T is a numeric type
597macro_rules! impl_from_numeric_for_value {
598    ($($source:ty),* $(,)?) => {
599        $(impl From<$source> for Value {
600            fn from(value: $source) -> Self {
601                Value(RonValue::Number(value.into()))
602            }
603        })*
604    };
605}
606
607// Implement From for common numeric types
608impl_from_numeric_for_value!(i8, i16, i32, i64, u8, u16, u32, u64, f32, f64);
609
610impl TryFrom<&Value> for bool {
611    type Error = ConfigError;
612
613    fn try_from(value: &Value) -> Result<Self, Self::Error> {
614        if let Value(RonValue::Bool(v)) = value {
615            Ok(*v)
616        } else {
617            Err(ConfigError::type_mismatch("bool", value))
618        }
619    }
620}
621
622impl From<Value> for bool {
623    fn from(value: Value) -> Self {
624        if let Value(RonValue::Bool(v)) = value {
625            v
626        } else {
627            panic!("Expected a Boolean variant but got {value:?}")
628        }
629    }
630}
631macro_rules! impl_from_value_for_int {
632    ($($target:ty),* $(,)?) => {
633        $(
634            impl From<Value> for $target {
635                fn from(value: Value) -> Self {
636                    if let Value(RonValue::Number(num)) = value {
637                        match num {
638                            Number::I8(n) => n as $target,
639                            Number::I16(n) => n as $target,
640                            Number::I32(n) => n as $target,
641                            Number::I64(n) => n as $target,
642                            Number::U8(n) => n as $target,
643                            Number::U16(n) => n as $target,
644                            Number::U32(n) => n as $target,
645                            Number::U64(n) => n as $target,
646                            Number::F32(_) | Number::F64(_) => {
647                                panic!("Expected an integer Number variant but got {num:?}")
648                            }
649                            _ => {
650                                panic!("Expected an integer Number variant but got {num:?}")
651                            }
652                        }
653                    } else {
654                        panic!("Expected a Number variant but got {value:?}")
655                    }
656                }
657            }
658        )*
659    };
660}
661
662impl_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
663
664macro_rules! impl_try_from_value_for_int {
665    ($($target:ty),* $(,)?) => {
666        $(
667            impl TryFrom<&Value> for $target {
668                type Error = ConfigError;
669
670                fn try_from(value: &Value) -> Result<Self, Self::Error> {
671                    if let Value(RonValue::Number(num)) = value {
672                        match num {
673                            Number::I8(n) => Ok(*n as $target),
674                            Number::I16(n) => Ok(*n as $target),
675                            Number::I32(n) => Ok(*n as $target),
676                            Number::I64(n) => Ok(*n as $target),
677                            Number::U8(n) => Ok(*n as $target),
678                            Number::U16(n) => Ok(*n as $target),
679                            Number::U32(n) => Ok(*n as $target),
680                            Number::U64(n) => Ok(*n as $target),
681                            Number::F32(_) | Number::F64(_) => {
682                                Err(ConfigError::type_mismatch("integer", value))
683                            }
684                            _ => {
685                                Err(ConfigError::type_mismatch("integer", value))
686                            }
687                        }
688                    } else {
689                        Err(ConfigError::type_mismatch("integer", value))
690                    }
691                }
692            }
693        )*
694    };
695}
696
697impl_try_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
698
699impl TryFrom<&Value> for f64 {
700    type Error = ConfigError;
701
702    fn try_from(value: &Value) -> Result<Self, Self::Error> {
703        if let Value(RonValue::Number(num)) = value {
704            let number = match num {
705                Number::I8(n) => *n as f64,
706                Number::I16(n) => *n as f64,
707                Number::I32(n) => *n as f64,
708                Number::I64(n) => *n as f64,
709                Number::U8(n) => *n as f64,
710                Number::U16(n) => *n as f64,
711                Number::U32(n) => *n as f64,
712                Number::U64(n) => *n as f64,
713                Number::F32(n) => n.0 as f64,
714                Number::F64(n) => n.0,
715                _ => {
716                    return Err(ConfigError::type_mismatch("number", value));
717                }
718            };
719            Ok(number)
720        } else {
721            Err(ConfigError::type_mismatch("number", value))
722        }
723    }
724}
725
726impl From<Value> for f64 {
727    fn from(value: Value) -> Self {
728        if let Value(RonValue::Number(num)) = value {
729            num.into_f64()
730        } else {
731            panic!("Expected a Number variant but got {value:?}")
732        }
733    }
734}
735
736//Basically just a copy of the From<Value> for f64.
737impl TryFrom<&Value> for f32 {
738    type Error = ConfigError;
739
740    fn try_from(value: &Value) -> Result<Self, Self::Error> {
741        if let Value(RonValue::Number(num)) = value {
742            let number = match num {
743                Number::I8(n) => *n as f32,
744                Number::I16(n) => *n as f32,
745                Number::I32(n) => *n as f32,
746                Number::I64(n) => *n as f32,
747                Number::U8(n) => *n as f32,
748                Number::U16(n) => *n as f32,
749                Number::U32(n) => *n as f32,
750                Number::U64(n) => *n as f32,
751                Number::F32(n) => n.0,
752                Number::F64(n) => n.0 as f32,
753                _ => {
754                    return Err(ConfigError::type_mismatch("number", value));
755                }
756            };
757            Ok(number)
758        } else {
759            Err(ConfigError::type_mismatch("number", value))
760        }
761    }
762}
763
764impl From<Value> for f32 {
765    fn from(value: Value) -> Self {
766        if let Value(RonValue::Number(num)) = value {
767            num.into_f64() as f32
768        } else {
769            panic!("Expected a Number variant but got {value:?}")
770        }
771    }
772}
773
774impl From<String> for Value {
775    fn from(value: String) -> Self {
776        Value(RonValue::String(value))
777    }
778}
779
780impl TryFrom<&Value> for String {
781    type Error = ConfigError;
782
783    fn try_from(value: &Value) -> Result<Self, Self::Error> {
784        if let Value(RonValue::String(s)) = value {
785            Ok(s.clone())
786        } else {
787            Err(ConfigError::type_mismatch("string", value))
788        }
789    }
790}
791
792impl From<Value> for String {
793    fn from(value: Value) -> Self {
794        if let Value(RonValue::String(s)) = value {
795            s
796        } else {
797            panic!("Expected a String variant")
798        }
799    }
800}
801
802impl Display for Value {
803    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
804        let Value(value) = self;
805        match value {
806            RonValue::Number(n) => {
807                let s = match n {
808                    Number::I8(n) => n.to_string(),
809                    Number::I16(n) => n.to_string(),
810                    Number::I32(n) => n.to_string(),
811                    Number::I64(n) => n.to_string(),
812                    Number::U8(n) => n.to_string(),
813                    Number::U16(n) => n.to_string(),
814                    Number::U32(n) => n.to_string(),
815                    Number::U64(n) => n.to_string(),
816                    Number::F32(n) => n.0.to_string(),
817                    Number::F64(n) => n.0.to_string(),
818                    _ => panic!("Expected a Number variant but got {value:?}"),
819                };
820                write!(f, "{s}")
821            }
822            RonValue::String(s) => write!(f, "{s}"),
823            RonValue::Bool(b) => write!(f, "{b}"),
824            RonValue::Map(m) => write!(f, "{m:?}"),
825            RonValue::Char(c) => write!(f, "{c:?}"),
826            RonValue::Unit => write!(f, "unit"),
827            RonValue::Option(o) => write!(f, "{o:?}"),
828            RonValue::Seq(s) => write!(f, "{s:?}"),
829            RonValue::Bytes(bytes) => write!(f, "{bytes:?}"),
830        }
831    }
832}
833
834/// Logging policy for a `CuHandle`'s payload content.
835///
836/// Set by the source that produces the handle (typically via this enum's slot under
837/// `NodeLogging`) and propagated through clones. The unified-log encoder reads this to
838/// decide whether to write the payload bytes or just a metadata-only record for the
839/// frame. See `cu29_runtime::pool::CuHandle` for the runtime side.
840///
841/// Defined here (instead of in `pool.rs`) so the type is reachable from both the
842/// library and the `cu29-rendercfg` binary, which compiles `config.rs` standalone.
843#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
844#[repr(u8)]
845pub enum HandleContent {
846    /// Always log the full payload (current default).
847    #[serde(rename = "all", alias = "All")]
848    #[default]
849    All = 0,
850    /// Log the payload only if a downstream consumer called `CuHandle::mark_touched`.
851    #[serde(rename = "touched_only", alias = "TouchedOnly")]
852    TouchedOnly = 1,
853    /// Never log the payload; keep only the surrounding metadata (timestamps, status).
854    #[serde(rename = "none", alias = "None")]
855    None = 2,
856}
857
858impl HandleContent {
859    /// Reconstruct a [`HandleContent`] from its `AtomicU8` representation. Unknown
860    /// values fall back to `All` so corrupt state never silently drops payload bytes.
861    #[allow(dead_code)] // Only the lib's pool module calls this; the rendercfg bin doesn't.
862    pub fn from_u8(v: u8) -> Self {
863        match v {
864            1 => HandleContent::TouchedOnly,
865            2 => HandleContent::None,
866            _ => HandleContent::All,
867        }
868    }
869}
870
871/// Configuration for logging in the node.
872#[derive(Serialize, Deserialize, Debug, Clone)]
873pub struct NodeLogging {
874    #[serde(default = "default_as_true")]
875    enabled: bool,
876    #[serde(skip_serializing_if = "Option::is_none")]
877    codec: Option<String>,
878    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
879    codecs: HashMap<String, String>,
880    /// Logging policy applied to the source's pool-acquired `CuHandle`s. Surfaced
881    /// in user RON config as e.g. `logging: ( handle_content: "touched_only" )`.
882    #[serde(default, skip_serializing_if = "is_default_handle_content")]
883    handle_content: HandleContent,
884}
885
886fn is_default_handle_content(c: &HandleContent) -> bool {
887    *c == HandleContent::default()
888}
889
890impl NodeLogging {
891    #[allow(dead_code)]
892    pub fn enabled(&self) -> bool {
893        self.enabled
894    }
895
896    #[allow(dead_code)]
897    pub fn codec(&self) -> Option<&str> {
898        self.codec.as_deref()
899    }
900
901    #[allow(dead_code)]
902    pub fn codecs(&self) -> &HashMap<String, String> {
903        &self.codecs
904    }
905
906    #[allow(dead_code)]
907    pub fn codec_for_msg_type(&self, msg_type: &str) -> Option<&str> {
908        self.codecs
909            .get(msg_type)
910            .map(String::as_str)
911            .or(self.codec.as_deref())
912    }
913
914    /// Logging policy applied to handles minted by this node's pool. Defaults to
915    /// `HandleContent::All` — i.e. existing behavior.
916    pub fn handle_content(&self) -> HandleContent {
917        self.handle_content
918    }
919}
920
921impl Default for NodeLogging {
922    fn default() -> Self {
923        Self {
924            enabled: true,
925            codec: None,
926            codecs: HashMap::new(),
927            handle_content: HandleContent::default(),
928        }
929    }
930}
931
932/// Distinguishes regular tasks from bridge nodes so downstream stages can apply
933/// bridge-specific instantiation rules.
934#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
935pub enum Flavor {
936    #[default]
937    Task,
938    Bridge,
939}
940
941/// Declares which Copper task trait a task node implements.
942///
943/// This lets config express the runtime role explicitly instead of forcing the
944/// proc-macro to guess from graph shape alone.
945#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)]
946pub enum TaskKind {
947    #[serde(rename = "source", alias = "src")]
948    Source,
949    #[serde(rename = "task", alias = "regular", alias = "cutask")]
950    Regular,
951    #[serde(rename = "sink", alias = "snk")]
952    Sink,
953}
954
955impl TaskKind {
956    #[allow(dead_code)]
957    pub fn as_str(&self) -> &'static str {
958        match self {
959            TaskKind::Source => "source",
960            TaskKind::Regular => "task",
961            TaskKind::Sink => "sink",
962        }
963    }
964}
965
966/// Default thread pool name used by `background: true` tasks.
967pub const DEFAULT_BACKGROUND_POOL: &str = "background";
968
969/// Reserved thread pool name driving the `parallel-rt` execution engine. Applied
970/// to each stage worker at startup; never task-bound nor built as a rayon pool.
971#[allow(dead_code)] // consumed by cu29_derive; unused in some binary targets
972pub const RT_POOL: &str = "rt";
973
974/// How a task is backgrounded.
975///
976/// Either a simple on/off flag (`background: true`), which runs the task on the
977/// default [`DEFAULT_BACKGROUND_POOL`] pool, or an explicit pool selection
978/// (`background: (pool: "vision")`).
979#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
980#[serde(untagged)]
981pub enum BackgroundConfig {
982    /// `background: true` / `background: false`.
983    Flag(bool),
984    /// `background: (pool: "vision")`.
985    Pool { pool: String },
986}
987
988/// Refinement policy for an anytime node (`anytime:` on a task).
989///
990/// Every field is optional, but validation requires at least one - `time_budget_ms`,
991/// `max_age_ms` and `max_refines`; see [`CuConfig::validate_anytime_configs`].
992///
993/// Two orthogonal axes organize the fields:
994///
995/// - **Budget** — how much to *spend* per result: `time_budget_ms` and
996///   `max_refines` are hard bounds, `quality_target` and `max_stall` stop
997///   spending early when more is provably not worth it.
998/// - **Utility** — whether the result is *worth having* at all: `max_age_ms`
999///   (worthless because too old) and `quality_floor` (worthless because too
1000///   crude).
1001#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
1002pub struct AnytimeConfig {
1003    /// Wall-clock window for one job in milliseconds, measured from the start of
1004    /// the base computation and checked *between* refinement quanta.
1005    /// In background placement it is measured on the worker thread and may exceed the
1006    /// copperlist period.
1007    #[serde(skip_serializing_if = "Option::is_none")]
1008    pub time_budget_ms: Option<f64>,
1009
1010    /// Validity deadline in milliseconds, measured from the input's earliest time
1011    /// of validity (Tov): past this data age a result is no longer worth starting
1012    /// or waiting for.
1013    #[serde(skip_serializing_if = "Option::is_none")]
1014    pub max_age_ms: Option<f64>,
1015
1016    /// Stop refining early once the reported quality reaches this target, in
1017    /// `(0.0, 1.0]` on the normalized quality scale. Only valid for tasks that
1018    /// report a comparable quality.
1019    #[serde(skip_serializing_if = "Option::is_none")]
1020    pub quality_target: Option<f32>,
1021
1022    /// Publish only if the final reported quality is at least this floor, in
1023    /// `(0.0, 1.0)` on the normalized quality scale; below it the payload is
1024    /// cleared. Only valid for tasks that report a comparable quality.
1025    #[serde(skip_serializing_if = "Option::is_none")]
1026    pub quality_floor: Option<f32>,
1027
1028    /// Hard bound on refinement quanta per job.
1029    #[serde(skip_serializing_if = "Option::is_none")]
1030    pub max_refines: Option<u32>,
1031
1032    /// Stop after this many quanta without the published quality improving.
1033    /// Only valid for tasks that report a comparable quality.
1034    #[serde(skip_serializing_if = "Option::is_none")]
1035    pub max_stall: Option<u32>,
1036}
1037
1038impl AnytimeConfig {
1039    /// Validates the node-local invariants of this policy.
1040    ///
1041    /// Ranges are written as positive containment checks so a NaN coming from
1042    /// the RON fails the check and is rejected, and at least one hard bound is
1043    /// mandatory: `quality_target`, `max_stall` and `quality_floor` alone leave
1044    /// refinement unbounded.
1045    fn validate(&self, task_id: &str) -> CuResult<()> {
1046        if let Some(budget) = self.time_budget_ms {
1047            let valid = budget.is_finite() && budget > 0.0;
1048            if !valid {
1049                return Err(CuError::from(format!(
1050                    "Task '{task_id}': anytime.time_budget_ms must be a positive number of milliseconds (got {budget})."
1051                )));
1052            }
1053        }
1054        if let Some(age) = self.max_age_ms {
1055            let valid = age.is_finite() && age > 0.0;
1056            if !valid {
1057                return Err(CuError::from(format!(
1058                    "Task '{task_id}': anytime.max_age_ms must be a positive number of milliseconds (got {age})."
1059                )));
1060            }
1061        }
1062        if let Some(target) = self.quality_target {
1063            let valid = target > 0.0 && target <= 1.0;
1064            if !valid {
1065                return Err(CuError::from(format!(
1066                    "Task '{task_id}': anytime.quality_target must be within (0.0, 1.0] (got {target})."
1067                )));
1068            }
1069        }
1070        if let Some(floor) = self.quality_floor {
1071            let valid = floor > 0.0 && floor < 1.0;
1072            if !valid {
1073                return Err(CuError::from(format!(
1074                    "Task '{task_id}': anytime.quality_floor must be within (0.0, 1.0) (got {floor})."
1075                )));
1076            }
1077        }
1078        if let Some(refines) = self.max_refines
1079            && refines == 0
1080        {
1081            return Err(CuError::from(format!(
1082                "Task '{task_id}': anytime.max_refines must be at least 1."
1083            )));
1084        }
1085        if let Some(stall) = self.max_stall
1086            && stall == 0
1087        {
1088            return Err(CuError::from(format!(
1089                "Task '{task_id}': anytime.max_stall must be at least 1."
1090            )));
1091        }
1092        if let (Some(floor), Some(target)) = (self.quality_floor, self.quality_target)
1093            && floor > target
1094        {
1095            return Err(CuError::from(format!(
1096                "Task '{task_id}': anytime.quality_floor ({floor}) must not exceed anytime.quality_target ({target}): refinement could stop at the target and then always discard the result."
1097            )));
1098        }
1099        if self.time_budget_ms.is_none() && self.max_age_ms.is_none() && self.max_refines.is_none()
1100        {
1101            return Err(CuError::from(format!(
1102                "Task '{task_id}': anytime needs at least one hard bound: set time_budget_ms, max_age_ms or max_refines. quality_target, max_stall and quality_floor alone leave refinement unbounded."
1103            )));
1104        }
1105        Ok(())
1106    }
1107}
1108
1109/// A node in the configuration graph.
1110/// A node represents a Task in the system Graph.
1111#[derive(Serialize, Deserialize, Debug, Clone)]
1112pub struct Node {
1113    /// Unique node identifier.
1114    id: String,
1115
1116    /// Task rust struct underlying type, e.g. "mymodule::Sensor", etc.
1117    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1118    type_: Option<String>,
1119
1120    /// Declared Copper task role. When omitted, legacy configs still infer it
1121    /// from graph shape when that is unambiguous.
1122    #[serde(skip_serializing_if = "Option::is_none")]
1123    kind: Option<TaskKind>,
1124
1125    /// Config passed to the task.
1126    #[serde(skip_serializing_if = "Option::is_none")]
1127    config: Option<ComponentConfig>,
1128
1129    /// Resources requested by the task.
1130    #[serde(skip_serializing_if = "Option::is_none")]
1131    resources: Option<HashMap<String, String>>,
1132
1133    /// Missions for which this task is run.
1134    missions: Option<Vec<String>>,
1135
1136    /// Run this task in the background:
1137    /// ie. Will be set to run on a background thread and until it is finished `CuTask::process` will return None.
1138    ///
1139    /// Accepts either a simple flag (`background: true`, which uses the default
1140    /// [`DEFAULT_BACKGROUND_POOL`] pool) or an explicit pool selection
1141    /// (`background: (pool: "vision")`).
1142    #[serde(skip_serializing_if = "Option::is_none")]
1143    background: Option<BackgroundConfig>,
1144
1145    /// Anytime refinement policy for this task (base + bounded refinements).
1146    ///
1147    /// Only supported on regular tasks. Orthogonal to `background:`, which adds
1148    /// the async placement layer on top of the refinement loop.
1149    #[serde(skip_serializing_if = "Option::is_none")]
1150    anytime: Option<AnytimeConfig>,
1151
1152    /// Option to include/exclude stubbing for simulation.
1153    /// By default, sources and sinks are replaces (stubbed) by the runtime to avoid trying to compile hardware specific code for sensing or actuation.
1154    /// In some cases, for example a sink or source used as a middleware bridge, you might want to run the real code even in simulation.
1155    /// This option allows to control this behavior.
1156    /// Note: Normal tasks will be run in sim and this parameter ignored.
1157    #[serde(skip_serializing_if = "Option::is_none")]
1158    run_in_sim: Option<bool>,
1159
1160    /// Config passed to the task.
1161    #[serde(skip_serializing_if = "Option::is_none")]
1162    logging: Option<NodeLogging>,
1163
1164    /// Node role in the runtime graph (normal task or bridge endpoint).
1165    #[serde(skip, default)]
1166    flavor: Flavor,
1167    /// Message types that are intentionally not connected (NC) in configuration.
1168    #[serde(skip, default)]
1169    nc_outputs: Vec<String>,
1170    /// Original config connection order for each NC output message type.
1171    #[serde(skip, default)]
1172    nc_output_orders: Vec<usize>,
1173}
1174
1175impl Node {
1176    #[allow(dead_code)]
1177    pub fn new(id: &str, ptype: &str) -> Self {
1178        Node {
1179            id: id.to_string(),
1180            type_: Some(ptype.to_string()),
1181            kind: None,
1182            config: None,
1183            resources: None,
1184            missions: None,
1185            background: None,
1186            anytime: None,
1187            run_in_sim: None,
1188            logging: None,
1189            flavor: Flavor::Task,
1190            nc_outputs: Vec::new(),
1191            nc_output_orders: Vec::new(),
1192        }
1193    }
1194
1195    #[allow(dead_code)]
1196    pub fn new_with_flavor(id: &str, ptype: &str, flavor: Flavor) -> Self {
1197        let mut node = Self::new(id, ptype);
1198        node.flavor = flavor;
1199        node
1200    }
1201
1202    #[allow(dead_code)]
1203    pub fn get_id(&self) -> String {
1204        self.id.clone()
1205    }
1206
1207    #[allow(dead_code)]
1208    pub fn get_type(&self) -> &str {
1209        self.type_.as_ref().unwrap()
1210    }
1211
1212    #[allow(dead_code)]
1213    pub fn set_type(mut self, name: Option<String>) -> Self {
1214        self.type_ = name;
1215        self
1216    }
1217
1218    #[allow(dead_code)]
1219    pub fn get_declared_task_kind(&self) -> Option<TaskKind> {
1220        self.kind
1221    }
1222
1223    #[allow(dead_code)]
1224    pub fn set_task_kind(&mut self, kind: Option<TaskKind>) {
1225        self.kind = kind;
1226    }
1227
1228    #[allow(dead_code)]
1229    pub fn set_resources<I>(&mut self, resources: Option<I>)
1230    where
1231        I: IntoIterator<Item = (String, String)>,
1232    {
1233        self.resources = resources.map(|iter| iter.into_iter().collect());
1234    }
1235
1236    #[allow(dead_code)]
1237    pub fn is_background(&self) -> bool {
1238        match &self.background {
1239            Some(BackgroundConfig::Flag(flag)) => *flag,
1240            Some(BackgroundConfig::Pool { .. }) => true,
1241            None => false,
1242        }
1243    }
1244
1245    /// Name of the thread pool this task should run on when backgrounded.
1246    /// Defaults to [`DEFAULT_BACKGROUND_POOL`] when no explicit pool is set.
1247    #[allow(dead_code)]
1248    pub fn background_pool(&self) -> &str {
1249        match &self.background {
1250            Some(BackgroundConfig::Pool { pool }) => pool.as_str(),
1251            _ => DEFAULT_BACKGROUND_POOL,
1252        }
1253    }
1254
1255    #[allow(dead_code)]
1256    pub fn is_anytime(&self) -> bool {
1257        self.anytime.is_some()
1258    }
1259
1260    /// Anytime refinement policy configured on this node, if any.
1261    #[allow(dead_code)]
1262    pub fn anytime(&self) -> Option<&AnytimeConfig> {
1263        self.anytime.as_ref()
1264    }
1265
1266    /// Sets the anytime refinement policy for this node.
1267    #[allow(dead_code)]
1268    pub fn set_anytime(&mut self, anytime: Option<AnytimeConfig>) {
1269        self.anytime = anytime;
1270    }
1271
1272    #[allow(dead_code)]
1273    pub fn get_instance_config(&self) -> Option<&ComponentConfig> {
1274        self.config.as_ref()
1275    }
1276
1277    #[allow(dead_code)]
1278    pub fn get_resources(&self) -> Option<&HashMap<String, String>> {
1279        self.resources.as_ref()
1280    }
1281
1282    /// By default, assume a source or a sink is not run in sim.
1283    /// Normal tasks will be run in sim and this parameter ignored.
1284    #[allow(dead_code)]
1285    pub fn is_run_in_sim(&self) -> bool {
1286        self.run_in_sim.unwrap_or(false)
1287    }
1288
1289    #[allow(dead_code)]
1290    pub fn is_logging_enabled(&self) -> bool {
1291        if let Some(logging) = &self.logging {
1292            logging.enabled()
1293        } else {
1294            true
1295        }
1296    }
1297
1298    /// Convenience wrapper around [`NodeLogging::handle_content`]: returns the per-handle
1299    /// logging policy for this node, defaulting to [`HandleContent::All`] when no
1300    /// `logging` block is configured.
1301    #[allow(dead_code)]
1302    pub fn handle_content_policy(&self) -> HandleContent {
1303        self.logging
1304            .as_ref()
1305            .map(NodeLogging::handle_content)
1306            .unwrap_or_default()
1307    }
1308
1309    #[allow(dead_code)]
1310    pub fn get_logging(&self) -> Option<&NodeLogging> {
1311        self.logging.as_ref()
1312    }
1313
1314    #[allow(dead_code)]
1315    pub fn get_param<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
1316    where
1317        T: for<'a> TryFrom<&'a Value, Error = ConfigError>,
1318    {
1319        let pc = match self.config.as_ref() {
1320            Some(pc) => pc,
1321            None => return Ok(None),
1322        };
1323        let ComponentConfig(pc) = pc;
1324        match pc.get(key) {
1325            Some(v) => T::try_from(v).map(Some),
1326            None => Ok(None),
1327        }
1328    }
1329
1330    #[allow(dead_code)]
1331    pub fn set_param<T: Into<Value>>(&mut self, key: &str, value: T) {
1332        if self.config.is_none() {
1333            self.config = Some(ComponentConfig(HashMap::new()));
1334        }
1335        let ComponentConfig(config) = self.config.as_mut().unwrap();
1336        config.insert(key.to_string(), value.into());
1337    }
1338
1339    /// Returns whether this node is treated as a normal task or as a bridge.
1340    #[allow(dead_code)]
1341    pub fn get_flavor(&self) -> Flavor {
1342        self.flavor
1343    }
1344
1345    /// Overrides the node flavor; primarily used when injecting bridge nodes.
1346    #[allow(dead_code)]
1347    pub fn set_flavor(&mut self, flavor: Flavor) {
1348        self.flavor = flavor;
1349    }
1350
1351    /// Registers an intentionally unconnected output message type for this node.
1352    #[allow(dead_code)]
1353    pub fn add_nc_output(&mut self, msg_type: &str, order: usize) {
1354        if let Some(pos) = self
1355            .nc_outputs
1356            .iter()
1357            .position(|existing| existing == msg_type)
1358        {
1359            if order < self.nc_output_orders[pos] {
1360                self.nc_output_orders[pos] = order;
1361            }
1362            return;
1363        }
1364        self.nc_outputs.push(msg_type.to_string());
1365        self.nc_output_orders.push(order);
1366    }
1367
1368    /// Returns message types intentionally marked as not connected.
1369    #[allow(dead_code)]
1370    pub fn nc_outputs(&self) -> &[String] {
1371        &self.nc_outputs
1372    }
1373
1374    /// Returns NC outputs paired with original config order.
1375    #[allow(dead_code)]
1376    pub fn nc_outputs_with_order(&self) -> impl Iterator<Item = (&String, usize)> {
1377        self.nc_outputs
1378            .iter()
1379            .zip(self.nc_output_orders.iter().copied())
1380    }
1381}
1382
1383/// Directional mapping for bridge channels.
1384#[derive(Serialize, Deserialize, Debug, Clone)]
1385pub enum BridgeChannelConfigRepresentation {
1386    /// Channel that receives data from the bridge into the graph.
1387    Rx {
1388        id: String,
1389        /// Optional transport/topic identifier specific to the bridge backend.
1390        #[serde(skip_serializing_if = "Option::is_none")]
1391        route: Option<String>,
1392        /// Optional per-channel configuration forwarded to the bridge implementation.
1393        #[serde(skip_serializing_if = "Option::is_none")]
1394        config: Option<ComponentConfig>,
1395    },
1396    /// Channel that transmits data from the graph into the bridge.
1397    Tx {
1398        id: String,
1399        /// Optional transport/topic identifier specific to the bridge backend.
1400        #[serde(skip_serializing_if = "Option::is_none")]
1401        route: Option<String>,
1402        /// Optional per-channel configuration forwarded to the bridge implementation.
1403        #[serde(skip_serializing_if = "Option::is_none")]
1404        config: Option<ComponentConfig>,
1405    },
1406}
1407
1408impl BridgeChannelConfigRepresentation {
1409    /// Stable logical identifier to reference this channel in connections.
1410    #[allow(dead_code)]
1411    pub fn id(&self) -> &str {
1412        match self {
1413            BridgeChannelConfigRepresentation::Rx { id, .. }
1414            | BridgeChannelConfigRepresentation::Tx { id, .. } => id,
1415        }
1416    }
1417
1418    /// Bridge-specific transport path (topic, route, path...) describing this channel.
1419    #[allow(dead_code)]
1420    pub fn route(&self) -> Option<&str> {
1421        match self {
1422            BridgeChannelConfigRepresentation::Rx { route, .. }
1423            | BridgeChannelConfigRepresentation::Tx { route, .. } => route.as_deref(),
1424        }
1425    }
1426}
1427
1428enum EndpointRole {
1429    Source,
1430    Destination,
1431}
1432
1433fn validate_bridge_channel(
1434    bridge: &BridgeConfig,
1435    channel_id: &str,
1436    role: EndpointRole,
1437) -> Result<(), String> {
1438    let channel = bridge
1439        .channels
1440        .iter()
1441        .find(|ch| ch.id() == channel_id)
1442        .ok_or_else(|| {
1443            format!(
1444                "Bridge '{}' does not declare a channel named '{}'",
1445                bridge.id, channel_id
1446            )
1447        })?;
1448
1449    match (role, channel) {
1450        (EndpointRole::Source, BridgeChannelConfigRepresentation::Rx { .. }) => Ok(()),
1451        (EndpointRole::Destination, BridgeChannelConfigRepresentation::Tx { .. }) => Ok(()),
1452        (EndpointRole::Source, BridgeChannelConfigRepresentation::Tx { .. }) => Err(format!(
1453            "Bridge '{}' channel '{}' is Tx and cannot act as a source",
1454            bridge.id, channel_id
1455        )),
1456        (EndpointRole::Destination, BridgeChannelConfigRepresentation::Rx { .. }) => Err(format!(
1457            "Bridge '{}' channel '{}' is Rx and cannot act as a destination",
1458            bridge.id, channel_id
1459        )),
1460    }
1461}
1462
1463/// Declarative definition of a resource bundle.
1464#[derive(Serialize, Deserialize, Debug, Clone)]
1465pub struct ResourceBundleConfig {
1466    pub id: String,
1467    #[serde(rename = "provider")]
1468    pub provider: String,
1469    #[serde(skip_serializing_if = "Option::is_none")]
1470    pub config: Option<ComponentConfig>,
1471    #[serde(skip_serializing_if = "Option::is_none")]
1472    pub missions: Option<Vec<String>>,
1473}
1474
1475/// Declarative definition of a bridge component with a list of channels.
1476#[derive(Serialize, Deserialize, Debug, Clone)]
1477pub struct BridgeConfig {
1478    pub id: String,
1479    #[serde(rename = "type")]
1480    pub type_: String,
1481    #[serde(skip_serializing_if = "Option::is_none")]
1482    pub config: Option<ComponentConfig>,
1483    #[serde(skip_serializing_if = "Option::is_none")]
1484    pub resources: Option<HashMap<String, String>>,
1485    #[serde(skip_serializing_if = "Option::is_none")]
1486    pub missions: Option<Vec<String>>,
1487    /// Whether this bridge should run as the real implementation in simulation mode.
1488    ///
1489    /// Default is `true` to preserve historical behavior where bridges were always
1490    /// instantiated in sim mode.
1491    #[serde(skip_serializing_if = "Option::is_none")]
1492    pub run_in_sim: Option<bool>,
1493    /// List of logical endpoints exposed by this bridge.
1494    pub channels: Vec<BridgeChannelConfigRepresentation>,
1495}
1496
1497impl BridgeConfig {
1498    /// By default, bridges run as real implementations in sim mode for backward compatibility.
1499    #[allow(dead_code)]
1500    pub fn is_run_in_sim(&self) -> bool {
1501        self.run_in_sim.unwrap_or(true)
1502    }
1503
1504    fn to_node(&self) -> Node {
1505        let mut node = Node::new_with_flavor(&self.id, &self.type_, Flavor::Bridge);
1506        node.config = self.config.clone();
1507        node.resources = self.resources.clone();
1508        node.missions = self.missions.clone();
1509        node
1510    }
1511}
1512
1513fn insert_bridge_node(graph: &mut CuGraph, bridge: &BridgeConfig) -> Result<(), String> {
1514    if graph.get_node_id_by_name(bridge.id.as_str()).is_some() {
1515        return Err(format!(
1516            "Bridge '{}' reuses an existing node id. Bridge ids must be unique.",
1517            bridge.id
1518        ));
1519    }
1520    graph
1521        .add_node(bridge.to_node())
1522        .map(|_| ())
1523        .map_err(|e| e.to_string())
1524}
1525
1526/// Serialized representation of a connection used for the RON config.
1527#[derive(Serialize, Deserialize, Debug, Clone)]
1528struct SerializedCnx {
1529    src: String,
1530    dst: String,
1531    msg: String,
1532    missions: Option<Vec<String>>,
1533}
1534
1535/// Special destination endpoint used to mark an output as intentionally not connected.
1536pub const NC_ENDPOINT: &str = "__nc__";
1537
1538/// This represents a connection between 2 tasks (nodes) in the configuration graph.
1539#[derive(Debug, Clone)]
1540pub struct Cnx {
1541    /// Source node id.
1542    pub src: String,
1543    /// Destination node id.
1544    pub dst: String,
1545    /// Message type exchanged between src and dst.
1546    pub msg: String,
1547    /// Restrict this connection for this list of missions.
1548    pub missions: Option<Vec<String>>,
1549    /// Optional channel id when the source endpoint is a bridge.
1550    pub src_channel: Option<String>,
1551    /// Optional channel id when the destination endpoint is a bridge.
1552    pub dst_channel: Option<String>,
1553    /// Original serialized connection index used to preserve output ordering.
1554    pub order: usize,
1555}
1556
1557impl From<&Cnx> for SerializedCnx {
1558    fn from(cnx: &Cnx) -> Self {
1559        SerializedCnx {
1560            src: format_endpoint(&cnx.src, cnx.src_channel.as_deref()),
1561            dst: format_endpoint(&cnx.dst, cnx.dst_channel.as_deref()),
1562            msg: cnx.msg.clone(),
1563            missions: cnx.missions.clone(),
1564        }
1565    }
1566}
1567
1568fn format_endpoint(node: &str, channel: Option<&str>) -> String {
1569    match channel {
1570        Some(ch) => format!("{node}/{ch}"),
1571        None => node.to_string(),
1572    }
1573}
1574
1575fn parse_endpoint(
1576    endpoint: &str,
1577    role: EndpointRole,
1578    bridges: &HashMap<&str, &BridgeConfig>,
1579) -> Result<(String, Option<String>), String> {
1580    if let Some((node, channel)) = endpoint.split_once('/') {
1581        if let Some(bridge) = bridges.get(node) {
1582            validate_bridge_channel(bridge, channel, role)?;
1583            return Ok((node.to_string(), Some(channel.to_string())));
1584        } else {
1585            return Err(format!(
1586                "Endpoint '{endpoint}' references an unknown bridge '{node}'"
1587            ));
1588        }
1589    }
1590
1591    if let Some(bridge) = bridges.get(endpoint) {
1592        return Err(format!(
1593            "Bridge '{}' connections must reference a channel using '{}/<channel>'",
1594            bridge.id, bridge.id
1595        ));
1596    }
1597
1598    Ok((endpoint.to_string(), None))
1599}
1600
1601fn build_bridge_lookup(bridges: Option<&Vec<BridgeConfig>>) -> HashMap<&str, &BridgeConfig> {
1602    let mut map = HashMap::new();
1603    if let Some(bridges) = bridges {
1604        for bridge in bridges {
1605            map.insert(bridge.id.as_str(), bridge);
1606        }
1607    }
1608    map
1609}
1610
1611fn mission_applies(missions: &Option<Vec<String>>, mission_id: &str) -> bool {
1612    missions
1613        .as_ref()
1614        .map(|mission_list| mission_list.iter().any(|m| m == mission_id))
1615        .unwrap_or(true)
1616}
1617
1618fn merge_connection_missions(existing: &mut Option<Vec<String>>, incoming: &Option<Vec<String>>) {
1619    if incoming.is_none() {
1620        *existing = None;
1621        return;
1622    }
1623    if existing.is_none() {
1624        return;
1625    }
1626
1627    if let (Some(existing_missions), Some(incoming_missions)) =
1628        (existing.as_mut(), incoming.as_ref())
1629    {
1630        for mission in incoming_missions {
1631            if !existing_missions
1632                .iter()
1633                .any(|existing_mission| existing_mission == mission)
1634            {
1635                existing_missions.push(mission.clone());
1636            }
1637        }
1638        existing_missions.sort();
1639        existing_missions.dedup();
1640    }
1641}
1642
1643fn register_nc_output<E>(
1644    graph: &mut CuGraph,
1645    src_endpoint: &str,
1646    msg_type: &str,
1647    order: usize,
1648    bridge_lookup: &HashMap<&str, &BridgeConfig>,
1649) -> Result<(), E>
1650where
1651    E: From<String>,
1652{
1653    let (src_name, src_channel) =
1654        parse_endpoint(src_endpoint, EndpointRole::Source, bridge_lookup).map_err(E::from)?;
1655    if src_channel.is_some() {
1656        return Err(E::from(format!(
1657            "NC destination '{}' does not support bridge channels in source endpoint '{}'",
1658            NC_ENDPOINT, src_endpoint
1659        )));
1660    }
1661
1662    let src = graph
1663        .get_node_id_by_name(src_name.as_str())
1664        .ok_or_else(|| E::from(format!("Source node not found: {src_endpoint}")))?;
1665    let src_node = graph
1666        .get_node_mut(src)
1667        .ok_or_else(|| E::from(format!("Source node id {src} not found for NC output")))?;
1668    if src_node.get_flavor() != Flavor::Task {
1669        return Err(E::from(format!(
1670            "NC destination '{}' is only supported for task outputs (source '{}')",
1671            NC_ENDPOINT, src_endpoint
1672        )));
1673    }
1674    src_node.add_nc_output(msg_type, order);
1675    Ok(())
1676}
1677
1678/// A simple wrapper enum for `petgraph::Direction`,
1679/// designed to be converted *into* it via the `From` trait.
1680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1681pub enum CuDirection {
1682    Outgoing,
1683    Incoming,
1684}
1685
1686impl From<CuDirection> for petgraph::Direction {
1687    fn from(dir: CuDirection) -> Self {
1688        match dir {
1689            CuDirection::Outgoing => petgraph::Direction::Outgoing,
1690            CuDirection::Incoming => petgraph::Direction::Incoming,
1691        }
1692    }
1693}
1694
1695#[derive(Default, Debug, Clone)]
1696pub struct CuGraph(pub StableDiGraph<Node, Cnx, NodeId>);
1697
1698impl CuGraph {
1699    #[allow(dead_code)]
1700    pub fn get_all_nodes(&self) -> Vec<(NodeId, &Node)> {
1701        self.0
1702            .node_indices()
1703            .map(|index| (index.index() as u32, &self.0[index]))
1704            .collect()
1705    }
1706
1707    #[allow(dead_code)]
1708    pub fn get_neighbor_ids(&self, node_id: NodeId, dir: CuDirection) -> Vec<NodeId> {
1709        self.0
1710            .neighbors_directed(node_id.into(), dir.into())
1711            .map(|petgraph_index| petgraph_index.index() as NodeId)
1712            .collect()
1713    }
1714
1715    #[allow(dead_code)]
1716    pub fn node_ids(&self) -> Vec<NodeId> {
1717        self.0
1718            .node_indices()
1719            .map(|index| index.index() as NodeId)
1720            .collect()
1721    }
1722
1723    #[allow(dead_code)]
1724    pub fn edge_id_between(&self, source: NodeId, target: NodeId) -> Option<usize> {
1725        self.0
1726            .find_edge(source.into(), target.into())
1727            .map(|edge| edge.index())
1728    }
1729
1730    #[allow(dead_code)]
1731    pub fn edge(&self, edge_id: usize) -> Option<&Cnx> {
1732        self.0.edge_weight(EdgeIndex::new(edge_id))
1733    }
1734
1735    #[allow(dead_code)]
1736    pub fn edges(&self) -> impl Iterator<Item = &Cnx> {
1737        self.0
1738            .edge_indices()
1739            .filter_map(|edge| self.0.edge_weight(edge))
1740    }
1741
1742    #[allow(dead_code)]
1743    pub fn bfs_nodes(&self, start: NodeId) -> Vec<NodeId> {
1744        let mut visitor = Bfs::new(&self.0, start.into());
1745        let mut nodes = Vec::new();
1746        while let Some(node) = visitor.next(&self.0) {
1747            nodes.push(node.index() as NodeId);
1748        }
1749        nodes
1750    }
1751
1752    #[allow(dead_code)]
1753    pub fn incoming_neighbor_count(&self, node_id: NodeId) -> usize {
1754        self.0.neighbors_directed(node_id.into(), Incoming).count()
1755    }
1756
1757    #[allow(dead_code)]
1758    pub fn outgoing_neighbor_count(&self, node_id: NodeId) -> usize {
1759        self.0.neighbors_directed(node_id.into(), Outgoing).count()
1760    }
1761
1762    pub fn node_indices(&self) -> Vec<petgraph::stable_graph::NodeIndex> {
1763        self.0.node_indices().collect()
1764    }
1765
1766    pub fn add_node(&mut self, node: Node) -> CuResult<NodeId> {
1767        Ok(self.0.add_node(node).index() as NodeId)
1768    }
1769
1770    #[allow(dead_code)]
1771    pub fn connection_exists(&self, source: NodeId, target: NodeId) -> bool {
1772        self.0.find_edge(source.into(), target.into()).is_some()
1773    }
1774
1775    pub fn connect_ext(
1776        &mut self,
1777        source: NodeId,
1778        target: NodeId,
1779        msg_type: &str,
1780        missions: Option<Vec<String>>,
1781        src_channel: Option<String>,
1782        dst_channel: Option<String>,
1783    ) -> CuResult<()> {
1784        self.connect_ext_with_order(
1785            source,
1786            target,
1787            msg_type,
1788            missions,
1789            src_channel,
1790            dst_channel,
1791            usize::MAX,
1792        )
1793    }
1794
1795    #[allow(clippy::too_many_arguments)]
1796    pub fn connect_ext_with_order(
1797        &mut self,
1798        source: NodeId,
1799        target: NodeId,
1800        msg_type: &str,
1801        missions: Option<Vec<String>>,
1802        src_channel: Option<String>,
1803        dst_channel: Option<String>,
1804        order: usize,
1805    ) -> CuResult<()> {
1806        let (src_id, dst_id) = (
1807            self.0
1808                .node_weight(source.into())
1809                .ok_or("Source node not found")?
1810                .id
1811                .clone(),
1812            self.0
1813                .node_weight(target.into())
1814                .ok_or("Target node not found")?
1815                .id
1816                .clone(),
1817        );
1818
1819        let _ = self.0.add_edge(
1820            petgraph::stable_graph::NodeIndex::from(source),
1821            petgraph::stable_graph::NodeIndex::from(target),
1822            Cnx {
1823                src: src_id,
1824                dst: dst_id,
1825                msg: msg_type.to_string(),
1826                missions,
1827                src_channel,
1828                dst_channel,
1829                order,
1830            },
1831        );
1832        Ok(())
1833    }
1834    /// Get the node with the given id.
1835    /// If mission_id is provided, get the node from that mission's graph.
1836    /// Otherwise get the node from the simple graph.
1837    #[allow(dead_code)]
1838    pub fn get_node(&self, node_id: NodeId) -> Option<&Node> {
1839        self.0.node_weight(node_id.into())
1840    }
1841
1842    #[allow(dead_code)]
1843    pub fn get_node_weight(&self, index: NodeId) -> Option<&Node> {
1844        self.0.node_weight(index.into())
1845    }
1846
1847    #[allow(dead_code)]
1848    pub fn get_node_mut(&mut self, node_id: NodeId) -> Option<&mut Node> {
1849        self.0.node_weight_mut(node_id.into())
1850    }
1851
1852    pub fn get_node_id_by_name(&self, name: &str) -> Option<NodeId> {
1853        self.0
1854            .node_indices()
1855            .into_iter()
1856            .find(|idx| self.0[*idx].get_id() == name)
1857            .map(|i| i.index() as NodeId)
1858    }
1859
1860    #[allow(dead_code)]
1861    pub fn get_edge_weight(&self, index: usize) -> Option<Cnx> {
1862        self.0.edge_weight(EdgeIndex::new(index)).cloned()
1863    }
1864
1865    #[allow(dead_code)]
1866    pub fn get_node_output_msg_type(&self, node_id: &str) -> Option<String> {
1867        self.get_node_output_msg_types(node_id)
1868            .and_then(|mut msgs| msgs.drain(..1).next())
1869    }
1870
1871    #[allow(dead_code)]
1872    pub fn get_node_output_msg_types(&self, node_id: &str) -> Option<Vec<String>> {
1873        let node_id = self.get_node_id_by_name(node_id)?;
1874        let msgs = self.get_node_output_msg_types_by_id(node_id).ok()?;
1875        (!msgs.is_empty()).then_some(msgs)
1876    }
1877
1878    #[allow(dead_code)]
1879    pub fn get_node_output_msg_types_by_id(&self, node_id: NodeId) -> CuResult<Vec<String>> {
1880        let mut edge_ids = self.get_src_edges(node_id)?;
1881        edge_ids.sort();
1882
1883        let node = self
1884            .get_node(node_id)
1885            .ok_or_else(|| CuError::from(format!("Node id {node_id} not found")))?;
1886
1887        let mut msg_order: Vec<(usize, String)> = Vec::new();
1888        let mut record_msg = |msg: String, order: usize| {
1889            if let Some((existing_order, _)) = msg_order
1890                .iter_mut()
1891                .find(|(_, existing_msg)| *existing_msg == msg)
1892            {
1893                if order < *existing_order {
1894                    *existing_order = order;
1895                }
1896                return;
1897            }
1898            msg_order.push((order, msg));
1899        };
1900
1901        for edge_id in edge_ids {
1902            let Some(edge) = self.edge(edge_id) else {
1903                continue;
1904            };
1905            let order = if edge.order == usize::MAX {
1906                edge_id
1907            } else {
1908                edge.order
1909            };
1910            record_msg(edge.msg.clone(), order);
1911        }
1912
1913        for (msg, order) in node.nc_outputs_with_order() {
1914            record_msg(msg.clone(), order);
1915        }
1916
1917        msg_order.sort_by(|(order_a, msg_a), (order_b, msg_b)| {
1918            order_a.cmp(order_b).then_with(|| msg_a.cmp(msg_b))
1919        });
1920        Ok(msg_order.into_iter().map(|(_, msg)| msg).collect())
1921    }
1922
1923    #[allow(dead_code)]
1924    pub fn get_node_input_msg_type(&self, node_id: &str) -> Option<String> {
1925        self.get_node_input_msg_types(node_id)
1926            .and_then(|mut v| v.pop())
1927    }
1928
1929    pub fn get_node_input_msg_types(&self, node_id: &str) -> Option<Vec<String>> {
1930        self.0.node_indices().find_map(|node_index| {
1931            if let Some(node) = self.0.node_weight(node_index) {
1932                if node.id != node_id {
1933                    return None;
1934                }
1935                let edges: Vec<_> = self
1936                    .0
1937                    .edges_directed(node_index, Incoming)
1938                    .map(|edge| edge.id().index())
1939                    .collect();
1940                if edges.is_empty() {
1941                    return None;
1942                }
1943                let mut edges = edges;
1944                edges.sort();
1945                let msgs = edges
1946                    .into_iter()
1947                    .map(|edge_id| {
1948                        let cnx = self
1949                            .0
1950                            .edge_weight(EdgeIndex::new(edge_id))
1951                            .expect("Found an cnx id but could not retrieve it back");
1952                        cnx.msg.clone()
1953                    })
1954                    .collect();
1955                return Some(msgs);
1956            }
1957            None
1958        })
1959    }
1960
1961    #[allow(dead_code)]
1962    pub fn get_connection_msg_type(&self, source: NodeId, target: NodeId) -> Option<&str> {
1963        self.0
1964            .find_edge(source.into(), target.into())
1965            .map(|edge_index| self.0[edge_index].msg.as_str())
1966    }
1967
1968    /// Get the list of edges that are connected to the given node as a source.
1969    fn get_edges_by_direction(
1970        &self,
1971        node_id: NodeId,
1972        direction: petgraph::Direction,
1973    ) -> CuResult<Vec<usize>> {
1974        Ok(self
1975            .0
1976            .edges_directed(node_id.into(), direction)
1977            .map(|edge| edge.id().index())
1978            .collect())
1979    }
1980
1981    pub fn get_src_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1982        self.get_edges_by_direction(node_id, Outgoing)
1983    }
1984
1985    /// Get the list of edges that are connected to the given node as a destination.
1986    pub fn get_dst_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1987        self.get_edges_by_direction(node_id, Incoming)
1988    }
1989
1990    #[allow(dead_code)]
1991    pub fn node_count(&self) -> usize {
1992        self.0.node_count()
1993    }
1994
1995    #[allow(dead_code)]
1996    pub fn edge_count(&self) -> usize {
1997        self.0.edge_count()
1998    }
1999
2000    /// Adds an edge between two nodes/tasks in the configuration graph.
2001    /// msg_type is the type of message exchanged between the two nodes/tasks.
2002    #[allow(dead_code)]
2003    pub fn connect(&mut self, source: NodeId, target: NodeId, msg_type: &str) -> CuResult<()> {
2004        self.connect_ext(source, target, msg_type, None, None, None)
2005    }
2006}
2007
2008fn validate_task_kind(
2009    node_id: &str,
2010    kind: TaskKind,
2011    has_inputs: bool,
2012    has_outputs: bool,
2013) -> CuResult<()> {
2014    match kind {
2015        TaskKind::Source if has_inputs => Err(CuError::from(format!(
2016            "Task '{node_id}' is declared as kind 'source' but has incoming connections. Sources map to CuSrcTask and cannot consume inputs. Use kind: task instead."
2017        ))),
2018        TaskKind::Regular if !has_inputs => Err(CuError::from(format!(
2019            "Task '{node_id}' is declared as kind 'task' but has no incoming connections. Regular tasks map to CuTask and need at least one input connection. Use kind: source if it is input-free."
2020        ))),
2021        TaskKind::Sink if has_outputs => Err(CuError::from(format!(
2022            "Task '{node_id}' is declared as kind 'sink' but has outgoing or NC outputs. Sinks map to CuSinkTask and cannot produce outputs. Use kind: task instead."
2023        ))),
2024        TaskKind::Sink if !has_inputs => Err(CuError::from(format!(
2025            "Task '{node_id}' is declared as kind 'sink' but has no incoming connections. Sinks need at least one input connection so Copper can determine their input message type."
2026        ))),
2027        _ => Ok(()),
2028    }
2029}
2030
2031#[allow(dead_code)]
2032pub fn infer_task_kind_for_id(graph: &CuGraph, node_id: NodeId) -> Option<TaskKind> {
2033    let node = graph.get_node(node_id)?;
2034    if node.get_flavor() != Flavor::Task {
2035        return None;
2036    }
2037
2038    let has_inputs = !graph.get_dst_edges(node_id).ok()?.is_empty();
2039    let has_outputs = !graph
2040        .get_node_output_msg_types_by_id(node_id)
2041        .ok()?
2042        .is_empty();
2043
2044    match (has_inputs, has_outputs) {
2045        (false, true) => Some(TaskKind::Source),
2046        (true, true) => Some(TaskKind::Regular),
2047        (true, false) => Some(TaskKind::Sink),
2048        (false, false) => None,
2049    }
2050}
2051
2052#[allow(dead_code)]
2053pub fn resolve_task_kind_for_id(graph: &CuGraph, node_id: NodeId) -> CuResult<TaskKind> {
2054    let node = graph
2055        .get_node(node_id)
2056        .ok_or_else(|| CuError::from(format!("Task node id {node_id} not found")))?;
2057    if node.get_flavor() != Flavor::Task {
2058        return Err(CuError::from(format!(
2059            "Node '{}' is not a task and does not have a task kind.",
2060            node.id
2061        )));
2062    }
2063
2064    let has_inputs = !graph.get_dst_edges(node_id)?.is_empty();
2065    let has_outputs = !graph.get_node_output_msg_types_by_id(node_id)?.is_empty();
2066
2067    if let Some(kind) = node.get_declared_task_kind() {
2068        validate_task_kind(node.id.as_str(), kind, has_inputs, has_outputs)?;
2069        return Ok(kind);
2070    }
2071
2072    let inferred = match (has_inputs, has_outputs) {
2073        (false, true) => TaskKind::Source,
2074        (true, true) => TaskKind::Regular,
2075        (true, false) => TaskKind::Sink,
2076        (false, false) => {
2077            return Err(CuError::from(format!(
2078                "Task '{}' has no declared inputs or outputs, so Copper cannot infer whether it is a source, task, or sink. Add `kind: source|task|sink`; source/task nodes also need an output declaration via a connection or `dst: \"{NC_ENDPOINT}\"`.",
2079                node.id
2080            )));
2081        }
2082    };
2083
2084    validate_task_kind(node.id.as_str(), inferred, has_inputs, has_outputs)?;
2085    Ok(inferred)
2086}
2087
2088impl core::ops::Index<NodeIndex> for CuGraph {
2089    type Output = Node;
2090
2091    fn index(&self, index: NodeIndex) -> &Self::Output {
2092        &self.0[index]
2093    }
2094}
2095
2096#[derive(Debug, Clone)]
2097pub enum ConfigGraphs {
2098    Simple(CuGraph),
2099    Missions(HashMap<String, CuGraph>),
2100}
2101
2102impl ConfigGraphs {
2103    /// Returns a consistent hashmap of mission names to Graphs whatever the shape of the config is.
2104    /// Note: if there is only one anonymous mission it will be called "default"
2105    #[allow(dead_code)]
2106    pub fn get_all_missions_graphs(&self) -> HashMap<String, CuGraph> {
2107        match self {
2108            Simple(graph) => HashMap::from([(DEFAULT_MISSION_ID.to_string(), graph.clone())]),
2109            Missions(graphs) => graphs.clone(),
2110        }
2111    }
2112
2113    #[allow(dead_code)]
2114    pub fn get_default_mission_graph(&self) -> CuResult<&CuGraph> {
2115        match self {
2116            Simple(graph) => Ok(graph),
2117            Missions(graphs) => {
2118                if graphs.len() == 1 {
2119                    Ok(graphs.values().next().unwrap())
2120                } else {
2121                    Err("Cannot get default mission graph from mission config".into())
2122                }
2123            }
2124        }
2125    }
2126
2127    #[allow(dead_code)]
2128    pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
2129        match self {
2130            Simple(graph) => match mission_id {
2131                None | Some(DEFAULT_MISSION_ID) => Ok(graph),
2132                Some(_) => Err("Cannot get mission graph from simple config".into()),
2133            },
2134            Missions(graphs) => {
2135                let id = mission_id
2136                    .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
2137                graphs
2138                    .get(id)
2139                    .ok_or_else(|| format!("Mission {id} not found").into())
2140            }
2141        }
2142    }
2143
2144    #[allow(dead_code)]
2145    pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
2146        match self {
2147            Simple(graph) => match mission_id {
2148                None => Ok(graph),
2149                Some(_) => Err("Cannot get mission graph from simple config".into()),
2150            },
2151            Missions(graphs) => {
2152                let id = mission_id
2153                    .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
2154                graphs
2155                    .get_mut(id)
2156                    .ok_or_else(|| format!("Mission {id} not found").into())
2157            }
2158        }
2159    }
2160
2161    pub fn add_mission(&mut self, mission_id: &str) -> CuResult<&mut CuGraph> {
2162        match self {
2163            Simple(_) => Err("Cannot add mission to simple config".into()),
2164            Missions(graphs) => match graphs.entry(mission_id.to_string()) {
2165                hashbrown::hash_map::Entry::Occupied(_) => {
2166                    Err(format!("Mission {mission_id} already exists").into())
2167                }
2168                hashbrown::hash_map::Entry::Vacant(entry) => Ok(entry.insert(CuGraph::default())),
2169            },
2170        }
2171    }
2172}
2173
2174/// CuConfig is the programmatic representation of the configuration graph.
2175/// It is a directed graph where nodes are tasks and edges are connections between tasks.
2176///
2177/// The core of CuConfig is its `graphs` field which can be either a simple graph
2178/// or a collection of mission-specific graphs. The graph structure is based on petgraph.
2179#[derive(Debug, Clone)]
2180pub struct CuConfig {
2181    /// Values baked into the application by `#[copper_runtime]`.
2182    #[doc(hidden)]
2183    pub constants: Vec<ConstantConfig>,
2184    /// Monitoring configuration list.
2185    pub monitors: Vec<MonitorConfig>,
2186    /// Optional logging configuration
2187    pub logging: Option<LoggingConfig>,
2188    /// Optional runtime configuration
2189    pub runtime: Option<RuntimeConfig>,
2190    /// Declarative resource bundle definitions
2191    pub resources: Vec<ResourceBundleConfig>,
2192    /// Declarative bridge definitions that are yet to be expanded into the graph
2193    pub bridges: Vec<BridgeConfig>,
2194    /// Graph structure - either a single graph or multiple mission-specific graphs
2195    pub graphs: ConfigGraphs,
2196}
2197
2198impl CuConfig {
2199    /// Guarantees that a default `"background"` thread pool entry exists in
2200    /// `runtime.thread_pools` whenever the graph has any `background: true`
2201    /// task that didn't explicitly select a pool. Thread pools are otherwise
2202    /// constructed straight from `runtime.thread_pools` by the runtime — they
2203    /// are not stored in `ResourceManager`.
2204    #[cfg(feature = "std")]
2205    fn ensure_default_background_pool(&mut self) {
2206        if !self.has_background_tasks() {
2207            return;
2208        }
2209
2210        const DEFAULT_BACKGROUND_THREADS: usize = 2;
2211
2212        let runtime = self.runtime.get_or_insert_with(RuntimeConfig::default);
2213        if !runtime
2214            .thread_pools
2215            .iter()
2216            .any(|pool| pool.id == DEFAULT_BACKGROUND_POOL)
2217        {
2218            runtime.thread_pools.push(ThreadPoolConfig {
2219                id: DEFAULT_BACKGROUND_POOL.to_string(),
2220                threads: DEFAULT_BACKGROUND_THREADS,
2221                affinity: None,
2222                policy: SchedulingPolicy::Fair,
2223                on_error: OnError::Warn,
2224            });
2225        }
2226    }
2227
2228    #[cfg(feature = "std")]
2229    fn has_background_tasks(&self) -> bool {
2230        match &self.graphs {
2231            ConfigGraphs::Simple(graph) => graph
2232                .get_all_nodes()
2233                .iter()
2234                .any(|(_, node)| node.is_background()),
2235            ConfigGraphs::Missions(graphs) => graphs.values().any(|graph| {
2236                graph
2237                    .get_all_nodes()
2238                    .iter()
2239                    .any(|(_, node)| node.is_background())
2240            }),
2241        }
2242    }
2243}
2244
2245#[derive(Serialize, Deserialize, Default, Debug, Clone)]
2246pub struct MonitorConfig {
2247    #[serde(rename = "type")]
2248    type_: String,
2249    #[serde(skip_serializing_if = "Option::is_none")]
2250    config: Option<ComponentConfig>,
2251}
2252
2253impl MonitorConfig {
2254    #[allow(dead_code)]
2255    pub fn get_type(&self) -> &str {
2256        &self.type_
2257    }
2258
2259    #[allow(dead_code)]
2260    pub fn get_config(&self) -> Option<&ComponentConfig> {
2261        self.config.as_ref()
2262    }
2263}
2264
2265fn default_as_true() -> bool {
2266    true
2267}
2268
2269pub const DEFAULT_KEYFRAME_INTERVAL: u32 = 100;
2270
2271fn default_keyframe_interval() -> Option<u32> {
2272    Some(DEFAULT_KEYFRAME_INTERVAL)
2273}
2274
2275#[derive(Serialize, Deserialize, Debug, Clone)]
2276pub struct LoggingConfig {
2277    /// Enable task logging to the log file.
2278    #[serde(default = "default_as_true", skip_serializing_if = "Clone::clone")]
2279    pub enable_task_logging: bool,
2280
2281    /// Number of preallocated CopperLists available to the runtime.
2282    ///
2283    /// This is consumed by proc-macro codegen and must match the value compiled into the
2284    /// application binary.
2285    #[serde(skip_serializing_if = "Option::is_none")]
2286    pub copperlist_count: Option<usize>,
2287
2288    /// Size of each slab in the log file. (it is the size of the memory mapped file at a time)
2289    #[serde(skip_serializing_if = "Option::is_none")]
2290    pub slab_size_mib: Option<u64>,
2291
2292    /// Pre-allocated size for each section in the log file.
2293    #[serde(skip_serializing_if = "Option::is_none")]
2294    pub section_size_mib: Option<u64>,
2295
2296    /// Interval in copperlists between two "keyframes" in the log file i.e. freezing tasks.
2297    #[serde(
2298        default = "default_keyframe_interval",
2299        skip_serializing_if = "Option::is_none"
2300    )]
2301    pub keyframe_interval: Option<u32>,
2302
2303    /// Named log codec specs reusable across task output bindings.
2304    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2305    pub codecs: Vec<LoggingCodecSpec>,
2306}
2307
2308impl Default for LoggingConfig {
2309    fn default() -> Self {
2310        Self {
2311            enable_task_logging: true,
2312            copperlist_count: None,
2313            slab_size_mib: None,
2314            section_size_mib: None,
2315            keyframe_interval: default_keyframe_interval(),
2316            codecs: Vec::new(),
2317        }
2318    }
2319}
2320
2321#[derive(Serialize, Deserialize, Debug, Clone)]
2322pub struct LoggingCodecSpec {
2323    pub id: String,
2324    #[serde(rename = "type")]
2325    pub type_: String,
2326    #[serde(skip_serializing_if = "Option::is_none")]
2327    pub config: Option<ComponentConfig>,
2328}
2329
2330#[derive(Serialize, Deserialize, Default, Debug, Clone)]
2331pub struct RuntimeConfig {
2332    /// Set a CopperList execution rate target in Hz
2333    /// It will act as a rate limiter: if the execution is slower than this rate,
2334    /// it will continue to execute at "best effort".
2335    ///
2336    /// The main usecase is to not waste cycles when the system doesn't need an unbounded execution rate.
2337    #[serde(skip_serializing_if = "Option::is_none")]
2338    pub rate_target_hz: Option<u64>,
2339
2340    /// Declarative thread pool definitions used by the background-task pools and
2341    /// the `parallel-rt` execution engine. Each pool carries an optional CPU
2342    /// affinity and a scheduling policy/priority.
2343    ///
2344    /// This is a `std`-only concept; on `no_std`/embedded targets there are no
2345    /// threads and this section is ignored.
2346    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2347    pub thread_pools: Vec<ThreadPoolConfig>,
2348}
2349
2350/// Smallest valid real-time priority for [`SchedulingPolicy::Fifo`]/[`SchedulingPolicy::RoundRobin`].
2351pub const MIN_RT_PRIORITY: u8 = 1;
2352/// Largest valid real-time priority for [`SchedulingPolicy::Fifo`]/[`SchedulingPolicy::RoundRobin`].
2353pub const MAX_RT_PRIORITY: u8 = 99;
2354/// Lowest valid niceness for [`SchedulingPolicy::Nice`] (most favorable).
2355pub const MIN_NICE: i8 = -20;
2356/// Highest valid niceness for [`SchedulingPolicy::Nice`] (least favorable).
2357pub const MAX_NICE: i8 = 19;
2358
2359/// Scheduling policy applied to every worker thread of a [`ThreadPoolConfig`].
2360///
2361/// On Linux these map directly onto the POSIX scheduling policies. On other
2362/// platforms they are applied best-effort (see the per-pool
2363/// [`ThreadPoolConfig::on_error`] behavior).
2364#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
2365pub enum SchedulingPolicy {
2366    /// Normal fair time-sharing scheduler (`SCHED_OTHER`/CFS on Linux) with default
2367    /// niceness. The OS shares the CPU fairly across threads and no thread starves.
2368    ///
2369    /// Use for everything that isn't latency-critical. This is the default.
2370    #[default]
2371    Fair,
2372    /// Fair scheduler with an explicit niceness (`-20..=19`, lower is more favorable).
2373    ///
2374    /// A soft priority hint, not a guarantee: a higher (nicer) value yields the CPU
2375    /// more readily. Use to bias a pool below or above normal work without leaving
2376    /// the fair scheduler — e.g. `Nice(10)` for heavy background work that should
2377    /// step aside for the control loop.
2378    Nice(i8),
2379    /// `SCHED_FIFO` real-time policy, priority `1..=99` (higher wins).
2380    ///
2381    /// Hard real-time: a FIFO thread runs ahead of every fair thread and is not
2382    /// time-sliced — it runs until it blocks or a higher-priority RT thread preempts
2383    /// it. Use for the latency-critical pipeline, and pin it with `affinity` so a
2384    /// busy worker cannot starve other work on the same core. Linux-only; typically
2385    /// needs `CAP_SYS_NICE`.
2386    Fifo { priority: u8 },
2387    /// `SCHED_RR` real-time policy, priority `1..=99` (higher wins).
2388    ///
2389    /// Same real-time semantics as [`Fifo`](Self::Fifo), except threads at the same
2390    /// priority are round-robin time-sliced rather than run-to-block. Use when
2391    /// several RT workers share a priority and should interleave fairly. Linux-only;
2392    /// typically needs `CAP_SYS_NICE`.
2393    RoundRobin { priority: u8 },
2394}
2395
2396/// What to do when a pool's affinity or scheduling request cannot be applied
2397/// (for example, setting a real-time priority without `CAP_SYS_NICE`).
2398#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
2399pub enum OnError {
2400    /// Log a warning and fall back to default scheduling. This keeps unprivileged
2401    /// dev/laptop runs working out of the box.
2402    #[default]
2403    Warn,
2404    /// Hard-fail at startup if the requested affinity/scheduler cannot be applied.
2405    /// Use this for deployed real-time robots that must fail loudly.
2406    Strict,
2407}
2408
2409/// Declarative definition of a single thread pool.
2410#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2411pub struct ThreadPoolConfig {
2412    /// Unique pool id. Reserved ids: [`RT_POOL`] (the `parallel-rt` execution
2413    /// engine) and [`DEFAULT_BACKGROUND_POOL`] (the default background pool).
2414    pub id: String,
2415    /// Number of worker threads in the pool.
2416    pub threads: usize,
2417    /// Optional set of logical CPU cores the pool may use. When set, worker `i`
2418    /// is pinned to `affinity[i % affinity.len()]` (Spread): `threads ==
2419    /// affinity.len()` yields one worker pinned per dedicated core.
2420    #[serde(default, skip_serializing_if = "Option::is_none")]
2421    pub affinity: Option<Vec<usize>>,
2422    /// Scheduling policy/priority applied to each worker thread.
2423    #[serde(default)]
2424    pub policy: SchedulingPolicy,
2425    /// What to do if affinity/scheduling cannot be applied.
2426    #[serde(default)]
2427    pub on_error: OnError,
2428}
2429
2430/// Validates the declarative thread pool definitions of a runtime config.
2431///
2432/// Checks ids are non-empty and unique, thread counts are non-zero, real-time
2433/// priorities and niceness values are in range, and affinity lists are non-empty
2434/// when present. This is purely a config-level check; pools are built later.
2435fn validate_thread_pools<E>(runtime: &Option<RuntimeConfig>) -> Result<(), E>
2436where
2437    E: From<String>,
2438{
2439    let Some(runtime) = runtime else {
2440        return Ok(());
2441    };
2442
2443    let mut seen: Vec<&str> = Vec::new();
2444    for pool in &runtime.thread_pools {
2445        if pool.id.is_empty() {
2446            return Err(E::from("Thread pool id cannot be empty".to_string()));
2447        }
2448        if seen.contains(&pool.id.as_str()) {
2449            return Err(E::from(format!("Duplicate thread pool id '{}'", pool.id)));
2450        }
2451        seen.push(pool.id.as_str());
2452
2453        if pool.threads == 0 {
2454            return Err(E::from(format!(
2455                "Thread pool '{}' must have at least 1 thread",
2456                pool.id
2457            )));
2458        }
2459
2460        match pool.policy {
2461            SchedulingPolicy::Fifo { priority } | SchedulingPolicy::RoundRobin { priority } => {
2462                if !(MIN_RT_PRIORITY..=MAX_RT_PRIORITY).contains(&priority) {
2463                    return Err(E::from(format!(
2464                        "Thread pool '{}' real-time priority {priority} is out of range ({MIN_RT_PRIORITY}..={MAX_RT_PRIORITY})",
2465                        pool.id
2466                    )));
2467                }
2468            }
2469            SchedulingPolicy::Nice(nice) => {
2470                if !(MIN_NICE..=MAX_NICE).contains(&nice) {
2471                    return Err(E::from(format!(
2472                        "Thread pool '{}' niceness {nice} is out of range ({MIN_NICE}..={MAX_NICE})",
2473                        pool.id
2474                    )));
2475                }
2476            }
2477            SchedulingPolicy::Fair => {}
2478        }
2479
2480        if let Some(affinity) = &pool.affinity
2481            && affinity.is_empty()
2482        {
2483            return Err(E::from(format!(
2484                "Thread pool '{}' has an empty affinity list; omit `affinity` for no pinning",
2485                pool.id
2486            )));
2487        }
2488    }
2489
2490    Ok(())
2491}
2492
2493/// Maximum representable Copper runtime rate target in whole Hertz.
2494///
2495/// Copper stores runtime periods in integer nanoseconds, so anything above 1 GHz
2496/// would round down to a zero-duration period.
2497pub const MAX_RATE_TARGET_HZ: u64 = 1_000_000_000;
2498
2499/// Missions are used to generate alternative DAGs within the same configuration.
2500#[derive(Serialize, Deserialize, Debug, Clone)]
2501pub struct MissionsConfig {
2502    pub id: String,
2503}
2504
2505/// A compile-time predicate controlling whether a configuration fragment is included.
2506#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2507pub enum ConfigPredicate {
2508    Feature(String),
2509    Not(Box<ConfigPredicate>),
2510    All(Vec<ConfigPredicate>),
2511    Any(Vec<ConfigPredicate>),
2512}
2513
2514#[cfg(feature = "std")]
2515impl ConfigPredicate {
2516    fn evaluate(&self, active_features: &[&str]) -> bool {
2517        match self {
2518            Self::Feature(feature) => active_features.contains(&feature.as_str()),
2519            Self::Not(predicate) => !predicate.evaluate(active_features),
2520            Self::All(predicates) => predicates
2521                .iter()
2522                .all(|predicate| predicate.evaluate(active_features)),
2523            Self::Any(predicates) => predicates
2524                .iter()
2525                .any(|predicate| predicate.evaluate(active_features)),
2526        }
2527    }
2528}
2529
2530/// Includes are used to include other configuration files.
2531#[derive(Serialize, Deserialize, Debug, Clone)]
2532pub struct IncludesConfig {
2533    pub path: String,
2534    #[serde(default)]
2535    pub params: HashMap<String, Value>,
2536    #[serde(default)]
2537    pub missions: Option<Vec<String>>,
2538    #[serde(default)]
2539    pub when: Option<ConfigPredicate>,
2540}
2541
2542/// One subsystem participating in a multi-Copper deployment.
2543#[cfg(feature = "std")]
2544#[allow(dead_code)]
2545#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2546pub struct MultiCopperSubsystemConfig {
2547    pub id: String,
2548    pub config: String,
2549}
2550
2551/// One explicit interconnect between two subsystem bridge channels.
2552#[cfg(feature = "std")]
2553#[allow(dead_code)]
2554#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2555pub struct MultiCopperInterconnectConfig {
2556    pub from: String,
2557    pub to: String,
2558    pub msg: String,
2559    #[serde(default)]
2560    pub when: Option<ConfigPredicate>,
2561}
2562
2563/// One path-based config overlay applied to a parsed local Copper config.
2564#[cfg(feature = "std")]
2565#[allow(dead_code)]
2566#[derive(Serialize, Deserialize, Debug, Clone)]
2567pub struct InstanceConfigSetOperation {
2568    pub path: String,
2569    pub value: ComponentConfig,
2570}
2571
2572/// Typed endpoint reference used by validated multi-Copper interconnects.
2573#[cfg(feature = "std")]
2574#[allow(dead_code)]
2575#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2576pub struct MultiCopperEndpoint {
2577    pub subsystem_id: String,
2578    pub bridge_id: String,
2579    pub channel_id: String,
2580}
2581
2582#[cfg(feature = "std")]
2583impl Display for MultiCopperEndpoint {
2584    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2585        write!(
2586            f,
2587            "{}/{}/{}",
2588            self.subsystem_id, self.bridge_id, self.channel_id
2589        )
2590    }
2591}
2592
2593/// Validated subsystem entry with its compiler-assigned numeric subsystem code and parsed local Copper config.
2594#[cfg(feature = "std")]
2595#[allow(dead_code)]
2596#[derive(Debug, Clone)]
2597pub struct MultiCopperSubsystem {
2598    pub id: String,
2599    pub subsystem_code: u16,
2600    pub config_path: String,
2601    pub config: CuConfig,
2602}
2603
2604/// Validated explicit interconnect between two subsystem endpoints.
2605#[cfg(feature = "std")]
2606#[allow(dead_code)]
2607#[derive(Debug, Clone, PartialEq, Eq)]
2608pub struct MultiCopperInterconnect {
2609    pub from: MultiCopperEndpoint,
2610    pub to: MultiCopperEndpoint,
2611    pub msg: String,
2612    pub bridge_type: String,
2613}
2614
2615/// Strict umbrella configuration describing multiple Copper subsystems and their explicit links.
2616#[cfg(feature = "std")]
2617#[allow(dead_code)]
2618#[derive(Debug, Clone)]
2619pub struct MultiCopperConfig {
2620    pub subsystems: Vec<MultiCopperSubsystem>,
2621    pub interconnects: Vec<MultiCopperInterconnect>,
2622    pub instance_overrides_root: Option<String>,
2623}
2624
2625#[cfg(feature = "std")]
2626impl MultiCopperConfig {
2627    #[allow(dead_code)]
2628    pub fn subsystem(&self, id: &str) -> Option<&MultiCopperSubsystem> {
2629        self.subsystems.iter().find(|subsystem| subsystem.id == id)
2630    }
2631
2632    #[allow(dead_code)]
2633    pub fn resolve_subsystem_config_for_instance(
2634        &self,
2635        subsystem_id: &str,
2636        instance_id: u32,
2637    ) -> CuResult<CuConfig> {
2638        let subsystem = self.subsystem(subsystem_id).ok_or_else(|| {
2639            CuError::from(format!(
2640                "Multi-Copper config does not define subsystem '{}'.",
2641                subsystem_id
2642            ))
2643        })?;
2644        let mut config = subsystem.config.clone();
2645
2646        let Some(root) = &self.instance_overrides_root else {
2647            return Ok(config);
2648        };
2649
2650        let override_path = std::path::Path::new(root)
2651            .join(instance_id.to_string())
2652            .join(format!("{subsystem_id}.ron"));
2653        if !override_path.exists() {
2654            return Ok(config);
2655        }
2656
2657        apply_instance_overrides_from_file(&mut config, &override_path)?;
2658        Ok(config)
2659    }
2660}
2661
2662#[cfg(feature = "std")]
2663#[allow(dead_code)]
2664#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2665struct MultiCopperConfigRepresentation {
2666    subsystems: Vec<MultiCopperSubsystemConfig>,
2667    interconnects: Vec<MultiCopperInterconnectConfig>,
2668    instance_overrides_root: Option<String>,
2669}
2670
2671#[cfg(feature = "std")]
2672#[derive(Serialize, Deserialize, Debug, Clone, Default)]
2673struct InstanceConfigOverridesRepresentation {
2674    #[serde(default)]
2675    set: Vec<InstanceConfigSetOperation>,
2676}
2677
2678#[cfg(feature = "std")]
2679#[allow(dead_code)]
2680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2681enum MultiCopperChannelDirection {
2682    Rx,
2683    Tx,
2684}
2685
2686#[cfg(feature = "std")]
2687#[allow(dead_code)]
2688#[derive(Debug, Clone)]
2689struct MultiCopperChannelContract {
2690    bridge_type: String,
2691    direction: MultiCopperChannelDirection,
2692    msg: Option<String>,
2693}
2694
2695#[cfg(feature = "std")]
2696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2697enum InstanceConfigTargetKind {
2698    Task,
2699    Resource,
2700    Bridge,
2701}
2702
2703/// This is the main Copper configuration representation.
2704#[derive(Serialize, Deserialize, Default)]
2705struct CuConfigRepresentation {
2706    constants: Option<Vec<ConstantConfig>>,
2707    tasks: Option<Vec<Node>>,
2708    resources: Option<Vec<ResourceBundleConfig>>,
2709    bridges: Option<Vec<BridgeConfig>>,
2710    cnx: Option<Vec<SerializedCnx>>,
2711    #[serde(
2712        default,
2713        alias = "monitor",
2714        deserialize_with = "deserialize_monitor_configs"
2715    )]
2716    monitors: Option<Vec<MonitorConfig>>,
2717    logging: Option<LoggingConfig>,
2718    runtime: Option<RuntimeConfig>,
2719    missions: Option<Vec<MissionsConfig>>,
2720    includes: Option<Vec<IncludesConfig>>,
2721}
2722
2723#[derive(Deserialize)]
2724#[serde(untagged)]
2725enum OneOrManyMonitorConfig {
2726    One(MonitorConfig),
2727    Many(Vec<MonitorConfig>),
2728}
2729
2730fn deserialize_monitor_configs<'de, D>(
2731    deserializer: D,
2732) -> Result<Option<Vec<MonitorConfig>>, D::Error>
2733where
2734    D: Deserializer<'de>,
2735{
2736    let parsed = Option::<OneOrManyMonitorConfig>::deserialize(deserializer)?;
2737    Ok(parsed.map(|value| match value {
2738        OneOrManyMonitorConfig::One(single) => vec![single],
2739        OneOrManyMonitorConfig::Many(many) => many,
2740    }))
2741}
2742
2743/// Shared implementation for deserializing a CuConfigRepresentation into a CuConfig
2744fn deserialize_config_representation<E>(
2745    representation: &CuConfigRepresentation,
2746) -> Result<CuConfig, E>
2747where
2748    E: From<String>,
2749{
2750    let mut cuconfig = CuConfig::default();
2751    let bridge_lookup = build_bridge_lookup(representation.bridges.as_ref());
2752
2753    if let Some(mission_configs) = &representation.missions {
2754        // This is the multi-mission case
2755        let mut missions = Missions(HashMap::new());
2756
2757        for mission_config in mission_configs {
2758            let mission_id = mission_config.id.as_str();
2759            let graph = missions
2760                .add_mission(mission_id)
2761                .map_err(|e| E::from(e.to_string()))?;
2762
2763            if let Some(tasks) = &representation.tasks {
2764                for task in tasks {
2765                    if let Some(task_missions) = &task.missions {
2766                        // if there is a filter by mission on the task, only add the task to the mission if it matches the filter.
2767                        if task_missions.contains(&mission_id.to_owned()) {
2768                            graph
2769                                .add_node(task.clone())
2770                                .map_err(|e| E::from(e.to_string()))?;
2771                        }
2772                    } else {
2773                        // if there is no filter by mission on the task, add the task to the mission.
2774                        graph
2775                            .add_node(task.clone())
2776                            .map_err(|e| E::from(e.to_string()))?;
2777                    }
2778                }
2779            }
2780
2781            if let Some(bridges) = &representation.bridges {
2782                for bridge in bridges {
2783                    if mission_applies(&bridge.missions, mission_id) {
2784                        insert_bridge_node(graph, bridge).map_err(E::from)?;
2785                    }
2786                }
2787            }
2788
2789            if let Some(cnx) = &representation.cnx {
2790                for (connection_order, c) in cnx.iter().enumerate() {
2791                    if let Some(cnx_missions) = &c.missions {
2792                        // if there is a filter by mission on the connection, only add the connection to the mission if it matches the filter.
2793                        if cnx_missions.contains(&mission_id.to_owned()) {
2794                            if c.dst == NC_ENDPOINT {
2795                                register_nc_output::<E>(
2796                                    graph,
2797                                    &c.src,
2798                                    &c.msg,
2799                                    connection_order,
2800                                    &bridge_lookup,
2801                                )?;
2802                                continue;
2803                            }
2804                            let (src_name, src_channel) =
2805                                parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
2806                                    .map_err(E::from)?;
2807                            let (dst_name, dst_channel) =
2808                                parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
2809                                    .map_err(E::from)?;
2810                            let src =
2811                                graph
2812                                    .get_node_id_by_name(src_name.as_str())
2813                                    .ok_or_else(|| {
2814                                        E::from(format!("Source node not found: {}", c.src))
2815                                    })?;
2816                            let dst =
2817                                graph
2818                                    .get_node_id_by_name(dst_name.as_str())
2819                                    .ok_or_else(|| {
2820                                        E::from(format!("Destination node not found: {}", c.dst))
2821                                    })?;
2822                            graph
2823                                .connect_ext_with_order(
2824                                    src,
2825                                    dst,
2826                                    &c.msg,
2827                                    Some(cnx_missions.clone()),
2828                                    src_channel,
2829                                    dst_channel,
2830                                    connection_order,
2831                                )
2832                                .map_err(|e| E::from(e.to_string()))?;
2833                        }
2834                    } else {
2835                        // if there is no filter by mission on the connection, add the connection to the mission.
2836                        if c.dst == NC_ENDPOINT {
2837                            register_nc_output::<E>(
2838                                graph,
2839                                &c.src,
2840                                &c.msg,
2841                                connection_order,
2842                                &bridge_lookup,
2843                            )?;
2844                            continue;
2845                        }
2846                        let (src_name, src_channel) =
2847                            parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
2848                                .map_err(E::from)?;
2849                        let (dst_name, dst_channel) =
2850                            parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
2851                                .map_err(E::from)?;
2852                        let src = graph
2853                            .get_node_id_by_name(src_name.as_str())
2854                            .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
2855                        let dst =
2856                            graph
2857                                .get_node_id_by_name(dst_name.as_str())
2858                                .ok_or_else(|| {
2859                                    E::from(format!("Destination node not found: {}", c.dst))
2860                                })?;
2861                        graph
2862                            .connect_ext_with_order(
2863                                src,
2864                                dst,
2865                                &c.msg,
2866                                None,
2867                                src_channel,
2868                                dst_channel,
2869                                connection_order,
2870                            )
2871                            .map_err(|e| E::from(e.to_string()))?;
2872                    }
2873                }
2874            }
2875        }
2876        cuconfig.graphs = missions;
2877    } else {
2878        // this is the simple case
2879        let mut graph = CuGraph::default();
2880
2881        if let Some(tasks) = &representation.tasks {
2882            for task in tasks {
2883                graph
2884                    .add_node(task.clone())
2885                    .map_err(|e| E::from(e.to_string()))?;
2886            }
2887        }
2888
2889        if let Some(bridges) = &representation.bridges {
2890            for bridge in bridges {
2891                insert_bridge_node(&mut graph, bridge).map_err(E::from)?;
2892            }
2893        }
2894
2895        if let Some(cnx) = &representation.cnx {
2896            for (connection_order, c) in cnx.iter().enumerate() {
2897                if c.dst == NC_ENDPOINT {
2898                    register_nc_output::<E>(
2899                        &mut graph,
2900                        &c.src,
2901                        &c.msg,
2902                        connection_order,
2903                        &bridge_lookup,
2904                    )?;
2905                    continue;
2906                }
2907                let (src_name, src_channel) =
2908                    parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
2909                        .map_err(E::from)?;
2910                let (dst_name, dst_channel) =
2911                    parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
2912                        .map_err(E::from)?;
2913                let src = graph
2914                    .get_node_id_by_name(src_name.as_str())
2915                    .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
2916                let dst = graph
2917                    .get_node_id_by_name(dst_name.as_str())
2918                    .ok_or_else(|| E::from(format!("Destination node not found: {}", c.dst)))?;
2919                graph
2920                    .connect_ext_with_order(
2921                        src,
2922                        dst,
2923                        &c.msg,
2924                        None,
2925                        src_channel,
2926                        dst_channel,
2927                        connection_order,
2928                    )
2929                    .map_err(|e| E::from(e.to_string()))?;
2930            }
2931        }
2932        cuconfig.graphs = Simple(graph);
2933    }
2934
2935    cuconfig.monitors = representation.monitors.clone().unwrap_or_default();
2936    cuconfig.constants = representation.constants.clone().unwrap_or_default();
2937    cuconfig.logging = representation.logging.clone();
2938    cuconfig.runtime = representation.runtime.clone();
2939    cuconfig.resources = representation.resources.clone().unwrap_or_default();
2940    cuconfig.bridges = representation.bridges.clone().unwrap_or_default();
2941
2942    validate_thread_pools::<E>(&cuconfig.runtime)?;
2943
2944    Ok(cuconfig)
2945}
2946
2947impl<'de> Deserialize<'de> for CuConfig {
2948    /// This is a custom serialization to make this implementation independent of petgraph.
2949    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2950    where
2951        D: Deserializer<'de>,
2952    {
2953        let representation =
2954            CuConfigRepresentation::deserialize(deserializer).map_err(serde::de::Error::custom)?;
2955
2956        // Convert String errors to D::Error using serde::de::Error::custom
2957        match deserialize_config_representation::<String>(&representation) {
2958            Ok(config) => Ok(config),
2959            Err(e) => Err(serde::de::Error::custom(e)),
2960        }
2961    }
2962}
2963
2964impl Serialize for CuConfig {
2965    /// This is a custom serialization to make this implementation independent of petgraph.
2966    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2967    where
2968        S: Serializer,
2969    {
2970        let bridges = if self.bridges.is_empty() {
2971            None
2972        } else {
2973            Some(self.bridges.clone())
2974        };
2975        let resources = if self.resources.is_empty() {
2976            None
2977        } else {
2978            Some(self.resources.clone())
2979        };
2980        let monitors = (!self.monitors.is_empty()).then_some(self.monitors.clone());
2981        match &self.graphs {
2982            Simple(graph) => {
2983                let tasks: Vec<Node> = graph
2984                    .0
2985                    .node_indices()
2986                    .map(|idx| graph.0[idx].clone())
2987                    .filter(|node| node.get_flavor() == Flavor::Task)
2988                    .collect();
2989
2990                let mut ordered_cnx: Vec<(usize, SerializedCnx)> = graph
2991                    .0
2992                    .edge_indices()
2993                    .map(|edge_idx| {
2994                        let edge = &graph.0[edge_idx];
2995                        let order = if edge.order == usize::MAX {
2996                            edge_idx.index()
2997                        } else {
2998                            edge.order
2999                        };
3000                        (order, SerializedCnx::from(edge))
3001                    })
3002                    .collect();
3003                for node_idx in graph.0.node_indices() {
3004                    let node = &graph.0[node_idx];
3005                    if node.get_flavor() != Flavor::Task {
3006                        continue;
3007                    }
3008                    for (msg, order) in node.nc_outputs_with_order() {
3009                        ordered_cnx.push((
3010                            order,
3011                            SerializedCnx {
3012                                src: node.get_id(),
3013                                dst: NC_ENDPOINT.to_string(),
3014                                msg: msg.clone(),
3015                                missions: None,
3016                            },
3017                        ));
3018                    }
3019                }
3020                ordered_cnx.sort_by(|(order_a, cnx_a), (order_b, cnx_b)| {
3021                    order_a
3022                        .cmp(order_b)
3023                        .then_with(|| cnx_a.src.cmp(&cnx_b.src))
3024                        .then_with(|| cnx_a.dst.cmp(&cnx_b.dst))
3025                        .then_with(|| cnx_a.msg.cmp(&cnx_b.msg))
3026                });
3027                let cnx: Vec<SerializedCnx> = ordered_cnx
3028                    .into_iter()
3029                    .map(|(_, serialized)| serialized)
3030                    .collect();
3031
3032                CuConfigRepresentation {
3033                    constants: (!self.constants.is_empty()).then_some(self.constants.clone()),
3034                    tasks: Some(tasks),
3035                    bridges: bridges.clone(),
3036                    cnx: Some(cnx),
3037                    monitors: monitors.clone(),
3038                    logging: self.logging.clone(),
3039                    runtime: self.runtime.clone(),
3040                    resources: resources.clone(),
3041                    missions: None,
3042                    includes: None,
3043                }
3044                .serialize(serializer)
3045            }
3046            Missions(graphs) => {
3047                let missions = graphs
3048                    .keys()
3049                    .map(|id| MissionsConfig { id: id.clone() })
3050                    .collect();
3051
3052                // Collect all unique tasks across missions
3053                let mut tasks = Vec::new();
3054                let mut ordered_cnx: Vec<(usize, SerializedCnx)> = Vec::new();
3055
3056                for (mission_id, graph) in graphs {
3057                    // Add all nodes from this mission
3058                    for node_idx in graph.node_indices() {
3059                        let node = &graph[node_idx];
3060                        if node.get_flavor() == Flavor::Task
3061                            && !tasks.iter().any(|n: &Node| n.id == node.id)
3062                        {
3063                            tasks.push(node.clone());
3064                        }
3065                    }
3066
3067                    // Add all edges from this mission
3068                    for edge_idx in graph.0.edge_indices() {
3069                        let edge = &graph.0[edge_idx];
3070                        let order = if edge.order == usize::MAX {
3071                            edge_idx.index()
3072                        } else {
3073                            edge.order
3074                        };
3075                        let serialized = SerializedCnx::from(edge);
3076                        if let Some((existing_order, existing_serialized)) =
3077                            ordered_cnx.iter_mut().find(|(_, c)| {
3078                                c.src == serialized.src
3079                                    && c.dst == serialized.dst
3080                                    && c.msg == serialized.msg
3081                            })
3082                        {
3083                            if order < *existing_order {
3084                                *existing_order = order;
3085                            }
3086                            merge_connection_missions(
3087                                &mut existing_serialized.missions,
3088                                &serialized.missions,
3089                            );
3090                        } else {
3091                            ordered_cnx.push((order, serialized));
3092                        }
3093                    }
3094                    for node_idx in graph.0.node_indices() {
3095                        let node = &graph.0[node_idx];
3096                        if node.get_flavor() != Flavor::Task {
3097                            continue;
3098                        }
3099                        for (msg, order) in node.nc_outputs_with_order() {
3100                            let serialized = SerializedCnx {
3101                                src: node.get_id(),
3102                                dst: NC_ENDPOINT.to_string(),
3103                                msg: msg.clone(),
3104                                missions: Some(vec![mission_id.clone()]),
3105                            };
3106                            if let Some((existing_order, existing_serialized)) =
3107                                ordered_cnx.iter_mut().find(|(_, c)| {
3108                                    c.src == serialized.src
3109                                        && c.dst == serialized.dst
3110                                        && c.msg == serialized.msg
3111                                })
3112                            {
3113                                if order < *existing_order {
3114                                    *existing_order = order;
3115                                }
3116                                merge_connection_missions(
3117                                    &mut existing_serialized.missions,
3118                                    &serialized.missions,
3119                                );
3120                            } else {
3121                                ordered_cnx.push((order, serialized));
3122                            }
3123                        }
3124                    }
3125                }
3126                ordered_cnx.sort_by(|(order_a, cnx_a), (order_b, cnx_b)| {
3127                    order_a
3128                        .cmp(order_b)
3129                        .then_with(|| cnx_a.src.cmp(&cnx_b.src))
3130                        .then_with(|| cnx_a.dst.cmp(&cnx_b.dst))
3131                        .then_with(|| cnx_a.msg.cmp(&cnx_b.msg))
3132                });
3133                let cnx: Vec<SerializedCnx> = ordered_cnx
3134                    .into_iter()
3135                    .map(|(_, serialized)| serialized)
3136                    .collect();
3137
3138                CuConfigRepresentation {
3139                    constants: (!self.constants.is_empty()).then_some(self.constants.clone()),
3140                    tasks: Some(tasks),
3141                    resources: resources.clone(),
3142                    bridges,
3143                    cnx: Some(cnx),
3144                    monitors,
3145                    logging: self.logging.clone(),
3146                    runtime: self.runtime.clone(),
3147                    missions: Some(missions),
3148                    includes: None,
3149                }
3150                .serialize(serializer)
3151            }
3152        }
3153    }
3154}
3155
3156impl Default for CuConfig {
3157    fn default() -> Self {
3158        CuConfig {
3159            constants: Vec::new(),
3160            graphs: Simple(CuGraph(StableDiGraph::new())),
3161            monitors: Vec::new(),
3162            logging: None,
3163            runtime: None,
3164            resources: Vec::new(),
3165            bridges: Vec::new(),
3166        }
3167    }
3168}
3169
3170/// The implementation has a lot of convenience methods to manipulate
3171/// the configuration to give some flexibility into programmatically creating the configuration.
3172impl CuConfig {
3173    #[allow(dead_code)]
3174    pub fn new_simple_type() -> Self {
3175        Self::default()
3176    }
3177
3178    #[allow(dead_code)]
3179    pub fn new_mission_type() -> Self {
3180        CuConfig {
3181            constants: Vec::new(),
3182            graphs: Missions(HashMap::new()),
3183            monitors: Vec::new(),
3184            logging: None,
3185            runtime: None,
3186            resources: Vec::new(),
3187            bridges: Vec::new(),
3188        }
3189    }
3190
3191    fn get_options() -> Options {
3192        Options::default()
3193            .with_default_extension(Extensions::IMPLICIT_SOME)
3194            .with_default_extension(Extensions::UNWRAP_NEWTYPES)
3195            .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
3196    }
3197
3198    #[allow(dead_code)]
3199    pub fn serialize_ron(&self) -> CuResult<String> {
3200        let ron = Self::get_options();
3201        let pretty = ron::ser::PrettyConfig::default();
3202        ron.to_string_pretty(&self, pretty)
3203            .map_err(|e| CuError::from(format!("Error serializing configuration: {e}")))
3204    }
3205
3206    #[allow(dead_code)]
3207    pub fn deserialize_ron(ron: &str) -> CuResult<Self> {
3208        let representation = Self::get_options().from_str(ron).map_err(|e| {
3209            CuError::from(format!(
3210                "Syntax Error in config: {} at position {}",
3211                e.code, e.span
3212            ))
3213        })?;
3214        Self::deserialize_impl(representation)
3215            .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))
3216    }
3217
3218    fn deserialize_impl(representation: CuConfigRepresentation) -> Result<Self, String> {
3219        deserialize_config_representation(&representation)
3220    }
3221
3222    /// Render the configuration graph in the dot format.
3223    #[cfg(feature = "std")]
3224    #[allow(dead_code)]
3225    pub fn render(
3226        &self,
3227        output: &mut dyn std::io::Write,
3228        mission_id: Option<&str>,
3229    ) -> CuResult<()> {
3230        writeln!(output, "digraph G {{")
3231            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3232        writeln!(output, "    graph [rankdir=LR, nodesep=0.8, ranksep=1.2];")
3233            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3234        writeln!(output, "    node [shape=plain, fontname=\"Noto Sans\"];")
3235            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3236        writeln!(output, "    edge [fontname=\"Noto Sans\"];")
3237            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3238
3239        let sections = match (&self.graphs, mission_id) {
3240            (Simple(graph), _) => vec![RenderSection { label: None, graph }],
3241            (Missions(graphs), Some(id)) => {
3242                let graph = graphs
3243                    .get(id)
3244                    .ok_or_else(|| CuError::from(format!("Mission {id} not found")))?;
3245                vec![RenderSection {
3246                    label: Some(id.to_string()),
3247                    graph,
3248                }]
3249            }
3250            (Missions(graphs), None) => {
3251                let mut missions: Vec<_> = graphs.iter().collect();
3252                missions.sort_by(|a, b| a.0.cmp(b.0));
3253                missions
3254                    .into_iter()
3255                    .map(|(label, graph)| RenderSection {
3256                        label: Some(label.clone()),
3257                        graph,
3258                    })
3259                    .collect()
3260            }
3261        };
3262
3263        for section in sections {
3264            self.render_section(output, section.graph, section.label.as_deref())?;
3265        }
3266
3267        writeln!(output, "}}")
3268            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3269        Ok(())
3270    }
3271
3272    #[allow(dead_code)]
3273    pub fn get_all_instances_configs(
3274        &self,
3275        mission_id: Option<&str>,
3276    ) -> Vec<Option<&ComponentConfig>> {
3277        let graph = self.graphs.get_graph(mission_id).unwrap();
3278        graph
3279            .get_all_nodes()
3280            .iter()
3281            .map(|(_, node)| node.get_instance_config())
3282            .collect()
3283    }
3284
3285    #[allow(dead_code)]
3286    pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
3287        self.graphs.get_graph(mission_id)
3288    }
3289
3290    #[allow(dead_code)]
3291    pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
3292        self.graphs.get_graph_mut(mission_id)
3293    }
3294
3295    #[allow(dead_code)]
3296    pub fn get_monitor_config(&self) -> Option<&MonitorConfig> {
3297        self.monitors.first()
3298    }
3299
3300    #[allow(dead_code)]
3301    pub fn get_monitor_configs(&self) -> &[MonitorConfig] {
3302        &self.monitors
3303    }
3304
3305    #[allow(dead_code)]
3306    pub fn get_runtime_config(&self) -> Option<&RuntimeConfig> {
3307        self.runtime.as_ref()
3308    }
3309
3310    #[allow(dead_code)]
3311    pub fn find_task_node(&self, mission_id: Option<&str>, task_id: &str) -> Option<&Node> {
3312        self.get_graph(mission_id)
3313            .ok()?
3314            .get_all_nodes()
3315            .into_iter()
3316            .find_map(|(_, node)| {
3317                (node.get_flavor() == Flavor::Task && node.id == task_id).then_some(node)
3318            })
3319    }
3320
3321    #[allow(dead_code)]
3322    pub fn find_logging_codec_spec(&self, codec_id: &str) -> Option<&LoggingCodecSpec> {
3323        self.logging
3324            .as_ref()?
3325            .codecs
3326            .iter()
3327            .find(|spec| spec.id == codec_id)
3328    }
3329
3330    /// Validate compile-time constant names, shapes, scalar ranges, and unit compatibility.
3331    pub fn validate_constants(&self) -> CuResult<()> {
3332        fn validate_integer(
3333            id: &str,
3334            storage: ConstantStorage,
3335            number: ConstantNumber,
3336        ) -> CuResult<()> {
3337            let valid = match (storage, number) {
3338                (ConstantStorage::I8, ConstantNumber::Signed(value)) => i8::try_from(value).is_ok(),
3339                (ConstantStorage::I16, ConstantNumber::Signed(value)) => {
3340                    i16::try_from(value).is_ok()
3341                }
3342                (ConstantStorage::I32, ConstantNumber::Signed(value)) => {
3343                    i32::try_from(value).is_ok()
3344                }
3345                (ConstantStorage::I64, ConstantNumber::Signed(_)) => true,
3346                (ConstantStorage::Isize, ConstantNumber::Signed(value)) => {
3347                    isize::try_from(value).is_ok()
3348                }
3349                (ConstantStorage::U8, ConstantNumber::Unsigned(value)) => {
3350                    u8::try_from(value).is_ok()
3351                }
3352                (ConstantStorage::U16, ConstantNumber::Unsigned(value)) => {
3353                    u16::try_from(value).is_ok()
3354                }
3355                (ConstantStorage::U32, ConstantNumber::Unsigned(value)) => {
3356                    u32::try_from(value).is_ok()
3357                }
3358                (ConstantStorage::U64, ConstantNumber::Unsigned(_)) => true,
3359                (ConstantStorage::Usize, ConstantNumber::Unsigned(value)) => {
3360                    usize::try_from(value).is_ok()
3361                }
3362                _ => false,
3363            };
3364            if valid {
3365                Ok(())
3366            } else {
3367                Err(CuError::from(format!(
3368                    "Constant '{id}' value {number:?} cannot be represented as {}",
3369                    storage.rust_type()
3370                )))
3371            }
3372        }
3373
3374        let mut ids = HashMap::new();
3375        for constant in &self.constants {
3376            if constant.id().is_empty() {
3377                return Err(CuError::from("Constant ids cannot be empty"));
3378            }
3379            if ids
3380                .insert((constant.module_path(), constant.id()), ())
3381                .is_some()
3382            {
3383                return Err(CuError::from(format!(
3384                    "Duplicate constant '{}'. Constant ids must be unique within a module.",
3385                    constant.qualified_id()
3386                )));
3387            }
3388
3389            match (
3390                constant.value.is_some(),
3391                constant.rust_type.as_deref(),
3392                constant.expression.as_deref(),
3393            ) {
3394                (true, None, None) => {}
3395                (true, _, _) => {
3396                    return Err(CuError::from(format!(
3397                        "Constant '{}' cannot combine numeric 'value' with 'type' or 'expression'",
3398                        constant.id()
3399                    )));
3400                }
3401                (false, Some(rust_type), Some(expression)) => {
3402                    if constant.storage.is_some()
3403                        || constant.quantity.is_some()
3404                        || constant.unit.is_some()
3405                    {
3406                        return Err(CuError::from(format!(
3407                            "Constant '{}' cannot combine 'type' and 'expression' with numeric 'storage', 'quantity', or 'unit'",
3408                            constant.id()
3409                        )));
3410                    }
3411                    if rust_type.trim().is_empty() {
3412                        return Err(CuError::from(format!(
3413                            "Constant '{}' type cannot be empty",
3414                            constant.id()
3415                        )));
3416                    }
3417                    if expression.trim().is_empty() {
3418                        return Err(CuError::from(format!(
3419                            "Constant '{}' expression cannot be empty",
3420                            constant.id()
3421                        )));
3422                    }
3423                    continue;
3424                }
3425                (false, Some(_), None) => {
3426                    return Err(CuError::from(format!(
3427                        "Constant '{}' declares 'type' without 'expression'",
3428                        constant.id()
3429                    )));
3430                }
3431                (false, None, Some(_)) => {
3432                    return Err(CuError::from(format!(
3433                        "Constant '{}' declares 'expression' without 'type'",
3434                        constant.id()
3435                    )));
3436                }
3437                (false, None, None) => {
3438                    return Err(CuError::from(format!(
3439                        "Constant '{}' must declare either numeric 'value' or both 'type' and 'expression'",
3440                        constant.id()
3441                    )));
3442                }
3443            }
3444
3445            if constant.quantity().is_none() && constant.explicit_unit().is_some() {
3446                return Err(CuError::from(format!(
3447                    "Constant '{}' declares a unit without a quantity",
3448                    constant.id()
3449                )));
3450            }
3451
3452            if constant.quantity().is_some() {
3453                if !constant.storage().supports_quantity() {
3454                    return Err(CuError::from(format!(
3455                        "Constant '{}' quantity '{}' requires storage f32 or f64, not {}",
3456                        constant.id(),
3457                        constant.quantity().map_or("", |quantity| quantity.name()),
3458                        constant.storage().rust_type()
3459                    )));
3460                }
3461                let normalized = match constant.storage() {
3462                    ConstantStorage::F32 => constant.normalized_f32().map(|_| ()),
3463                    ConstantStorage::F64 => constant.normalized_f64().map(|_| ()),
3464                    _ => unreachable!("quantity storage was checked above"),
3465                };
3466                normalized.map_err(CuError::from)?;
3467                continue;
3468            }
3469
3470            let (_, numbers) = constant.numbers().map_err(CuError::from)?;
3471            for number in numbers {
3472                match constant.storage() {
3473                    ConstantStorage::F32 => {
3474                        if !(number.as_f64() as f32).is_finite() {
3475                            return Err(CuError::from(format!(
3476                                "Constant '{}' values must be finite",
3477                                constant.id()
3478                            )));
3479                        }
3480                    }
3481                    ConstantStorage::F64 => {
3482                        if !number.as_f64().is_finite() {
3483                            return Err(CuError::from(format!(
3484                                "Constant '{}' values must be finite",
3485                                constant.id()
3486                            )));
3487                        }
3488                    }
3489                    storage => validate_integer(constant.id(), storage, number)?,
3490                }
3491            }
3492        }
3493        Ok(())
3494    }
3495
3496    /// Validate the logging configuration to ensure section pre-allocation sizes do not exceed slab sizes.
3497    /// This method is wrapper around [LoggingConfig::validate]
3498    pub fn validate_logging_config(&self) -> CuResult<()> {
3499        if let Some(logging) = &self.logging {
3500            return logging.validate();
3501        }
3502        Ok(())
3503    }
3504
3505    /// Validate the runtime configuration.
3506    pub fn validate_runtime_config(&self) -> CuResult<()> {
3507        if let Some(runtime) = &self.runtime {
3508            return runtime.validate();
3509        }
3510        Ok(())
3511    }
3512
3513    /// Validates every `anytime:` policy in the resolved graphs.
3514    ///
3515    /// Runs at configuration-resolution time, the first point where both the
3516    /// resolved graphs and `runtime.rate_target_hz` are known:
3517    ///
3518    /// 1. node-local bounds and ranges (see [`AnytimeConfig`]);
3519    /// 2. `anytime:` is only supported on regular tasks — refinement needs both
3520    ///    an input and an output;
3521    /// 3. an anytime task has exactly one input connection (the runner anchors
3522    ///    the job on the input's Tov) and at most one output message type
3523    ///    (`base()` and every `refine()` write the same output slot);
3524    /// 4. a *foreground* anytime task needs `max_refines`: the execution plan
3525    ///    is static (the node compiles to a base step plus `max_refines` refine
3526    ///    steps, see `curuntime::expand_anytime_steps`), so the refine count
3527    ///    must be known at compile time;
3528    /// 5. fit the period: a *foreground* anytime task in a rate-limited config
3529    ///    must set a time bound (`time_budget_ms` or `max_age_ms`), and the
3530    ///    worst-case window — `min` of the ones set — must be smaller than the
3531    ///    loop period. Background nodes and configs without a rate target skip
3532    ///    this check.
3533    pub fn validate_anytime_configs(&self) -> CuResult<()> {
3534        let rate_target_hz = self.runtime.as_ref().and_then(|r| r.rate_target_hz);
3535        match &self.graphs {
3536            Simple(graph) => validate_anytime_graph(graph, rate_target_hz),
3537            Missions(graphs) => {
3538                for graph in graphs.values() {
3539                    validate_anytime_graph(graph, rate_target_hz)?;
3540                }
3541                Ok(())
3542            }
3543        }
3544    }
3545}
3546
3547/// Checks every `anytime:` node of one graph: local bounds, regular-task kind,
3548/// single-input/single-output arity, and the foreground fit-the-period rule
3549/// (see [`CuConfig::validate_anytime_configs`]).
3550fn validate_anytime_graph(graph: &CuGraph, rate_target_hz: Option<u64>) -> CuResult<()> {
3551    for (node_id, node) in graph.get_all_nodes() {
3552        let Some(anytime) = node.anytime() else {
3553            continue;
3554        };
3555        anytime.validate(&node.id)?;
3556
3557        let kind = resolve_task_kind_for_id(graph, node_id)?;
3558        if kind != TaskKind::Regular {
3559            return Err(CuError::from(format!(
3560                "Task '{}' is declared with an anytime: policy but resolves to kind '{}'. Anytime refinement needs both an input and an output, so it is only supported on regular tasks.",
3561                node.id,
3562                kind.as_str()
3563            )));
3564        }
3565
3566        // Foreground and background alike: the runner reads the job anchor
3567        // from the single input's Tov, and base()/refine() write one stable
3568        // output slot. Zero declared outputs is fine when the kind is
3569        // declared — the macro synthesizes exactly one nc output.
3570        let input_count = graph.get_dst_edges(node_id)?.len();
3571        if input_count != 1 {
3572            return Err(CuError::from(format!(
3573                "Task '{}' is an anytime task and must have exactly one input connection (found {input_count}): the runner anchors the job on the input's Tov.",
3574                node.id
3575            )));
3576        }
3577        let output_count = graph.get_node_output_msg_types_by_id(node_id)?.len();
3578        if output_count > 1 {
3579            return Err(CuError::from(format!(
3580                "Task '{}' is an anytime task and must have exactly one output message type (found {output_count}): base() and every refine() write the same output slot.",
3581                node.id
3582            )));
3583        }
3584
3585        // Background placement: the refinement window runs on a worker thread
3586        // and may exceed the copperlist period — that is the point of it.
3587        if node.is_background() {
3588            continue;
3589        }
3590
3591        // Foreground placement compiles to a static plan: the node's step is
3592        // followed by exactly max_refines refine steps, so the count must be
3593        // known here — a time-only hard bound cannot produce a static plan.
3594        if anytime.max_refines.is_none() {
3595            return Err(CuError::from(format!(
3596                "Task '{}' is a foreground anytime task and needs anytime.max_refines: the execution plan is static, so the refine step count must be known at compile time. time_budget_ms/max_age_ms remain early-stop conditions within those quanta.",
3597                node.id
3598            )));
3599        }
3600
3601        let Some(rate_target_hz) = rate_target_hz else {
3602            continue;
3603        };
3604        let window_ms = match (anytime.time_budget_ms, anytime.max_age_ms) {
3605            (Some(budget), Some(age)) => budget.min(age),
3606            (Some(budget), None) => budget,
3607            (None, Some(age)) => age,
3608            (None, None) => {
3609                return Err(CuError::from(format!(
3610                    "Task '{}' is a foreground anytime task in a rate-limited config and needs a time bound: set anytime.time_budget_ms or anytime.max_age_ms. max_refines alone gives the runtime no time quantity to check against the {rate_target_hz} Hz loop period, and one slow quantum would silently overrun it.",
3611                    node.id
3612                )));
3613            }
3614        };
3615        let period_ms = 1_000.0 / rate_target_hz as f64;
3616        if window_ms >= period_ms {
3617            return Err(CuError::from(format!(
3618                "Task '{}': the worst-case anytime window ({window_ms} ms) does not fit within the {rate_target_hz} Hz loop period ({period_ms} ms) with headroom for the rest of the copperlist. Tighten the time bound, lower runtime.rate_target_hz, or run the task with background: true.",
3619                node.id
3620            )));
3621        }
3622    }
3623    Ok(())
3624}
3625
3626#[cfg(feature = "std")]
3627#[derive(Default)]
3628pub(crate) struct PortLookup {
3629    pub inputs: HashMap<String, String>,
3630    pub outputs: HashMap<String, String>,
3631    pub default_input: Option<String>,
3632    pub default_output: Option<String>,
3633}
3634
3635#[cfg(feature = "std")]
3636#[derive(Clone)]
3637pub(crate) struct RenderNode {
3638    pub id: String,
3639    pub type_name: String,
3640    pub flavor: Flavor,
3641    pub inputs: Vec<String>,
3642    pub outputs: Vec<String>,
3643}
3644
3645#[cfg(feature = "std")]
3646#[derive(Clone)]
3647pub(crate) struct RenderConnection {
3648    pub src: String,
3649    pub src_port: Option<String>,
3650    #[allow(dead_code)]
3651    pub src_channel: Option<String>,
3652    pub dst: String,
3653    pub dst_port: Option<String>,
3654    #[allow(dead_code)]
3655    pub dst_channel: Option<String>,
3656    pub msg: String,
3657}
3658
3659#[cfg(feature = "std")]
3660pub(crate) struct RenderTopology {
3661    pub nodes: Vec<RenderNode>,
3662    pub connections: Vec<RenderConnection>,
3663}
3664
3665#[cfg(feature = "std")]
3666impl RenderTopology {
3667    pub fn sort_connections(&mut self) {
3668        self.connections.sort_by(|a, b| {
3669            a.src
3670                .cmp(&b.src)
3671                .then(a.dst.cmp(&b.dst))
3672                .then(a.msg.cmp(&b.msg))
3673        });
3674    }
3675}
3676
3677#[cfg(feature = "std")]
3678#[allow(dead_code)]
3679struct RenderSection<'a> {
3680    label: Option<String>,
3681    graph: &'a CuGraph,
3682}
3683
3684#[cfg(feature = "std")]
3685impl CuConfig {
3686    #[allow(dead_code)]
3687    fn render_section(
3688        &self,
3689        output: &mut dyn std::io::Write,
3690        graph: &CuGraph,
3691        label: Option<&str>,
3692    ) -> CuResult<()> {
3693        use std::fmt::Write as FmtWrite;
3694
3695        let mut topology = build_render_topology(graph, &self.bridges);
3696        topology.nodes.sort_by(|a, b| a.id.cmp(&b.id));
3697        topology.sort_connections();
3698
3699        let cluster_id = label.map(|lbl| format!("cluster_{}", sanitize_identifier(lbl)));
3700        if let Some(ref cluster_id) = cluster_id {
3701            writeln!(output, "    subgraph \"{cluster_id}\" {{")
3702                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3703            writeln!(
3704                output,
3705                "        label=<<B>Mission: {}</B>>;",
3706                encode_text(label.unwrap())
3707            )
3708            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3709            writeln!(
3710                output,
3711                "        labelloc=t; labeljust=l; color=\"#bbbbbb\"; style=\"rounded\"; margin=20;"
3712            )
3713            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3714        }
3715        let indent = if cluster_id.is_some() {
3716            "        "
3717        } else {
3718            "    "
3719        };
3720        let node_prefix = label
3721            .map(|lbl| format!("{}__", sanitize_identifier(lbl)))
3722            .unwrap_or_default();
3723
3724        let mut port_lookup: HashMap<String, PortLookup> = HashMap::new();
3725        let mut id_lookup: HashMap<String, String> = HashMap::new();
3726
3727        for node in &topology.nodes {
3728            let node_idx = graph
3729                .get_node_id_by_name(node.id.as_str())
3730                .ok_or_else(|| CuError::from(format!("Node '{}' missing from graph", node.id)))?;
3731            let node_weight = graph
3732                .get_node(node_idx)
3733                .ok_or_else(|| CuError::from(format!("Node '{}' missing weight", node.id)))?;
3734
3735            let fillcolor = match node.flavor {
3736                Flavor::Bridge => "#faedcd",
3737                Flavor::Task => match resolve_task_kind_for_id(graph, node_idx)? {
3738                    TaskKind::Source => "#ddefc7",
3739                    TaskKind::Sink => "#cce0ff",
3740                    TaskKind::Regular => "#f2f2f2",
3741                },
3742            };
3743
3744            let port_base = format!("{}{}", node_prefix, sanitize_identifier(&node.id));
3745            let (inputs_table, input_map, default_input) =
3746                build_port_table("Inputs", &node.inputs, &port_base, "in");
3747            let (outputs_table, output_map, default_output) =
3748                build_port_table("Outputs", &node.outputs, &port_base, "out");
3749            let config_html = node_weight.config.as_ref().and_then(build_config_table);
3750
3751            let mut label_html = String::new();
3752            write!(
3753                label_html,
3754                "<TABLE BORDER=\"0\" CELLBORDER=\"1\" CELLSPACING=\"0\" CELLPADDING=\"6\" COLOR=\"gray\" BGCOLOR=\"white\">"
3755            )
3756            .unwrap();
3757            write!(
3758                label_html,
3759                "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\" BGCOLOR=\"{fillcolor}\"><FONT POINT-SIZE=\"12\"><B>{}</B></FONT><BR/><FONT COLOR=\"dimgray\">[{}]</FONT></TD></TR>",
3760                encode_text(&node.id),
3761                encode_text(&node.type_name)
3762            )
3763            .unwrap();
3764            write!(
3765                label_html,
3766                "<TR><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{inputs_table}</TD><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{outputs_table}</TD></TR>"
3767            )
3768            .unwrap();
3769
3770            if let Some(config_html) = config_html {
3771                write!(
3772                    label_html,
3773                    "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\">{config_html}</TD></TR>"
3774                )
3775                .unwrap();
3776            }
3777
3778            label_html.push_str("</TABLE>");
3779
3780            let identifier_raw = if node_prefix.is_empty() {
3781                node.id.clone()
3782            } else {
3783                format!("{node_prefix}{}", node.id)
3784            };
3785            let identifier = escape_dot_id(&identifier_raw);
3786            writeln!(output, "{indent}\"{identifier}\" [label=<{label_html}>];")
3787                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3788
3789            id_lookup.insert(node.id.clone(), identifier);
3790            port_lookup.insert(
3791                node.id.clone(),
3792                PortLookup {
3793                    inputs: input_map,
3794                    outputs: output_map,
3795                    default_input,
3796                    default_output,
3797                },
3798            );
3799        }
3800
3801        for cnx in &topology.connections {
3802            let src_id = id_lookup
3803                .get(&cnx.src)
3804                .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.src)))?;
3805            let dst_id = id_lookup
3806                .get(&cnx.dst)
3807                .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.dst)))?;
3808            let src_suffix = port_lookup
3809                .get(&cnx.src)
3810                .and_then(|lookup| lookup.resolve_output(cnx.src_port.as_deref()))
3811                .map(|port| format!(":\"{port}\":e"))
3812                .unwrap_or_default();
3813            let dst_suffix = port_lookup
3814                .get(&cnx.dst)
3815                .and_then(|lookup| lookup.resolve_input(cnx.dst_port.as_deref()))
3816                .map(|port| format!(":\"{port}\":w"))
3817                .unwrap_or_default();
3818            let msg = encode_text(&cnx.msg);
3819            writeln!(
3820                output,
3821                "{indent}\"{src_id}\"{src_suffix} -> \"{dst_id}\"{dst_suffix} [label=< <B><FONT COLOR=\"gray\">{msg}</FONT></B> >];"
3822            )
3823            .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3824        }
3825
3826        if cluster_id.is_some() {
3827            writeln!(output, "    }}")
3828                .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3829        }
3830
3831        Ok(())
3832    }
3833}
3834
3835#[cfg(feature = "std")]
3836pub(crate) fn build_render_topology(graph: &CuGraph, bridges: &[BridgeConfig]) -> RenderTopology {
3837    let mut bridge_lookup = HashMap::new();
3838    for bridge in bridges {
3839        bridge_lookup.insert(bridge.id.as_str(), bridge);
3840    }
3841
3842    let mut nodes: Vec<RenderNode> = Vec::new();
3843    let mut node_lookup: HashMap<String, usize> = HashMap::new();
3844    for (node_idx, node) in graph.get_all_nodes() {
3845        let node_id = node.get_id();
3846        let mut inputs = Vec::new();
3847        let mut outputs = Vec::new();
3848        if node.get_flavor() == Flavor::Bridge
3849            && let Some(bridge) = bridge_lookup.get(node_id.as_str())
3850        {
3851            for channel in &bridge.channels {
3852                match channel {
3853                    // Rx brings data from the bridge into the graph, so treat it as an output.
3854                    BridgeChannelConfigRepresentation::Rx { id, .. } => outputs.push(id.clone()),
3855                    // Tx consumes data from the graph heading into the bridge, so show it on the input side.
3856                    BridgeChannelConfigRepresentation::Tx { id, .. } => inputs.push(id.clone()),
3857                }
3858            }
3859        } else if node.get_flavor() == Flavor::Task {
3860            for (idx, msg) in graph
3861                .get_node_output_msg_types_by_id(node_idx)
3862                .unwrap_or_default()
3863                .into_iter()
3864                .enumerate()
3865            {
3866                outputs.push(format!("out{idx}: {msg}"));
3867            }
3868        }
3869
3870        node_lookup.insert(node_id.clone(), nodes.len());
3871        nodes.push(RenderNode {
3872            id: node_id,
3873            type_name: node.get_type().to_string(),
3874            flavor: node.get_flavor(),
3875            inputs,
3876            outputs,
3877        });
3878    }
3879
3880    let mut output_port_lookup: Vec<HashMap<String, String>> = vec![HashMap::new(); nodes.len()];
3881    for (node_idx, node) in graph.get_all_nodes() {
3882        let Some(&idx) = node_lookup.get(&node.get_id()) else {
3883            continue;
3884        };
3885        if node.get_flavor() != Flavor::Task {
3886            continue;
3887        }
3888        for (port_idx, msg) in graph
3889            .get_node_output_msg_types_by_id(node_idx)
3890            .unwrap_or_default()
3891            .into_iter()
3892            .enumerate()
3893        {
3894            output_port_lookup[idx].insert(msg.clone(), format!("out{port_idx}: {msg}"));
3895        }
3896    }
3897
3898    let mut auto_input_counts = vec![0usize; nodes.len()];
3899    for edge in graph.0.edge_references() {
3900        let cnx = edge.weight();
3901        if let Some(&idx) = node_lookup.get(&cnx.dst)
3902            && nodes[idx].flavor == Flavor::Task
3903            && cnx.dst_channel.is_none()
3904        {
3905            auto_input_counts[idx] += 1;
3906        }
3907    }
3908
3909    let mut next_auto_input = vec![0usize; nodes.len()];
3910    let mut connections = Vec::new();
3911    for edge in graph.0.edge_references() {
3912        let cnx = edge.weight();
3913        let mut src_port = cnx.src_channel.clone();
3914        let mut dst_port = cnx.dst_channel.clone();
3915
3916        if let Some(&idx) = node_lookup.get(&cnx.src) {
3917            let node = &mut nodes[idx];
3918            if node.flavor == Flavor::Task && src_port.is_none() {
3919                src_port = output_port_lookup[idx].get(&cnx.msg).cloned();
3920            }
3921        }
3922        if let Some(&idx) = node_lookup.get(&cnx.dst) {
3923            let node = &mut nodes[idx];
3924            if node.flavor == Flavor::Task && dst_port.is_none() {
3925                let count = auto_input_counts[idx];
3926                let next = if count <= 1 {
3927                    "in".to_string()
3928                } else {
3929                    let next = format!("in.{}", next_auto_input[idx]);
3930                    next_auto_input[idx] += 1;
3931                    next
3932                };
3933                node.inputs.push(next.clone());
3934                dst_port = Some(next);
3935            }
3936        }
3937
3938        connections.push(RenderConnection {
3939            src: cnx.src.clone(),
3940            src_port,
3941            src_channel: cnx.src_channel.clone(),
3942            dst: cnx.dst.clone(),
3943            dst_port,
3944            dst_channel: cnx.dst_channel.clone(),
3945            msg: cnx.msg.clone(),
3946        });
3947    }
3948
3949    RenderTopology { nodes, connections }
3950}
3951
3952#[cfg(feature = "std")]
3953impl PortLookup {
3954    pub fn resolve_input(&self, name: Option<&str>) -> Option<&str> {
3955        if let Some(name) = name
3956            && let Some(port) = self.inputs.get(name)
3957        {
3958            return Some(port.as_str());
3959        }
3960        self.default_input.as_deref()
3961    }
3962
3963    pub fn resolve_output(&self, name: Option<&str>) -> Option<&str> {
3964        if let Some(name) = name
3965            && let Some(port) = self.outputs.get(name)
3966        {
3967            return Some(port.as_str());
3968        }
3969        self.default_output.as_deref()
3970    }
3971}
3972
3973#[cfg(feature = "std")]
3974#[allow(dead_code)]
3975fn build_port_table(
3976    title: &str,
3977    names: &[String],
3978    base_id: &str,
3979    prefix: &str,
3980) -> (String, HashMap<String, String>, Option<String>) {
3981    use std::fmt::Write as FmtWrite;
3982
3983    let mut html = String::new();
3984    write!(
3985        html,
3986        "<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">"
3987    )
3988    .unwrap();
3989    write!(
3990        html,
3991        "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT></TD></TR>",
3992        encode_text(title)
3993    )
3994    .unwrap();
3995
3996    let mut lookup = HashMap::new();
3997    let mut default_port = None;
3998
3999    if names.is_empty() {
4000        html.push_str("<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"lightgray\">&mdash;</FONT></TD></TR>");
4001    } else {
4002        for (idx, name) in names.iter().enumerate() {
4003            let port_id = format!("{base_id}_{prefix}_{idx}");
4004            write!(
4005                html,
4006                "<TR><TD PORT=\"{port_id}\" ALIGN=\"LEFT\">{}</TD></TR>",
4007                encode_text(name)
4008            )
4009            .unwrap();
4010            lookup.insert(name.clone(), port_id.clone());
4011            if idx == 0 {
4012                default_port = Some(port_id);
4013            }
4014        }
4015    }
4016
4017    html.push_str("</TABLE>");
4018    (html, lookup, default_port)
4019}
4020
4021#[cfg(feature = "std")]
4022#[allow(dead_code)]
4023fn build_config_table(config: &ComponentConfig) -> Option<String> {
4024    use std::fmt::Write as FmtWrite;
4025
4026    if config.0.is_empty() {
4027        return None;
4028    }
4029
4030    let mut entries: Vec<_> = config.0.iter().collect();
4031    entries.sort_by(|a, b| a.0.cmp(b.0));
4032
4033    let mut html = String::new();
4034    html.push_str("<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">");
4035    for (key, value) in entries {
4036        let value_txt = format!("{value}");
4037        write!(
4038            html,
4039            "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT> = {}</TD></TR>",
4040            encode_text(key),
4041            encode_text(&value_txt)
4042        )
4043        .unwrap();
4044    }
4045    html.push_str("</TABLE>");
4046    Some(html)
4047}
4048
4049#[cfg(feature = "std")]
4050#[allow(dead_code)]
4051fn sanitize_identifier(value: &str) -> String {
4052    value
4053        .chars()
4054        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
4055        .collect()
4056}
4057
4058#[cfg(feature = "std")]
4059#[allow(dead_code)]
4060fn escape_dot_id(value: &str) -> String {
4061    let mut escaped = String::with_capacity(value.len());
4062    for ch in value.chars() {
4063        match ch {
4064            '"' => escaped.push_str("\\\""),
4065            '\\' => escaped.push_str("\\\\"),
4066            _ => escaped.push(ch),
4067        }
4068    }
4069    escaped
4070}
4071
4072impl LoggingConfig {
4073    /// Validate the logging configuration to ensure section pre-allocation sizes do not exceed slab sizes.
4074    pub fn validate(&self) -> CuResult<()> {
4075        if let Some(copperlist_count) = self.copperlist_count
4076            && copperlist_count == 0
4077        {
4078            return Err(CuError::from(
4079                "CopperList count cannot be zero. Set logging.copperlist_count to at least 1.",
4080            ));
4081        }
4082
4083        if let Some(section_size_mib) = self.section_size_mib
4084            && let Some(slab_size_mib) = self.slab_size_mib
4085            && section_size_mib > slab_size_mib
4086        {
4087            return Err(CuError::from(format!(
4088                "Section size ({section_size_mib} MiB) cannot be larger than slab size ({slab_size_mib} MiB). Adjust the parameters accordingly."
4089            )));
4090        }
4091
4092        let mut codec_ids = HashMap::new();
4093        for codec in &self.codecs {
4094            if codec_ids.insert(codec.id.as_str(), ()).is_some() {
4095                return Err(CuError::from(format!(
4096                    "Duplicate logging codec id '{}'. Codec ids must be unique.",
4097                    codec.id
4098                )));
4099            }
4100        }
4101
4102        Ok(())
4103    }
4104}
4105
4106impl RuntimeConfig {
4107    /// Validate runtime loop-rate settings.
4108    pub fn validate(&self) -> CuResult<()> {
4109        if let Some(rate_target_hz) = self.rate_target_hz {
4110            if rate_target_hz == 0 {
4111                return Err(CuError::from(
4112                    "Runtime rate target cannot be zero. Set runtime.rate_target_hz to at least 1.",
4113                ));
4114            }
4115
4116            if rate_target_hz > MAX_RATE_TARGET_HZ {
4117                return Err(CuError::from(format!(
4118                    "Runtime rate target ({rate_target_hz} Hz) exceeds the supported maximum of {MAX_RATE_TARGET_HZ} Hz."
4119                )));
4120            }
4121        }
4122
4123        Ok(())
4124    }
4125}
4126
4127#[allow(dead_code)] // dead in no-std
4128fn substitute_parameters(content: &str, params: &HashMap<String, Value>) -> String {
4129    let mut result = content.to_string();
4130
4131    for (key, value) in params {
4132        let pattern = format!("{{{{{key}}}}}");
4133        result = result.replace(&pattern, &value.to_string());
4134    }
4135
4136    result
4137}
4138
4139/// Returns a merged CuConfigRepresentation.
4140#[cfg(feature = "std")]
4141fn process_includes(
4142    file_path: &str,
4143    base_representation: CuConfigRepresentation,
4144    processed_files: &mut Vec<String>,
4145    active_features: &[&str],
4146) -> CuResult<CuConfigRepresentation> {
4147    // Note: Circular dependency detection removed
4148    processed_files.push(file_path.to_string());
4149
4150    let mut result = base_representation;
4151
4152    if let Some(includes) = result.includes.take() {
4153        for include in includes {
4154            if include
4155                .when
4156                .as_ref()
4157                .is_some_and(|predicate| !predicate.evaluate(active_features))
4158            {
4159                continue;
4160            }
4161
4162            let include_path = if include.path.starts_with('/') {
4163                include.path.clone()
4164            } else {
4165                let current_dir = std::path::Path::new(file_path).parent();
4166
4167                match current_dir.map(|path| path.to_string_lossy().to_string()) {
4168                    Some(current_dir) if !current_dir.is_empty() => {
4169                        format!("{}/{}", current_dir, include.path)
4170                    }
4171                    _ => include.path,
4172                }
4173            };
4174
4175            let include_content = read_to_string(&include_path).map_err(|e| {
4176                CuError::from(format!("Failed to read include file: {include_path}"))
4177                    .add_cause(e.to_string().as_str())
4178            })?;
4179
4180            let processed_content = substitute_parameters(&include_content, &include.params);
4181
4182            let mut included_representation: CuConfigRepresentation = match Options::default()
4183                .with_default_extension(Extensions::IMPLICIT_SOME)
4184                .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4185                .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4186                .from_str(&processed_content)
4187            {
4188                Ok(rep) => rep,
4189                Err(e) => {
4190                    return Err(CuError::from(format!(
4191                        "Failed to parse include file: {} - Error: {} at position {}",
4192                        include_path, e.code, e.span
4193                    )));
4194                }
4195            };
4196
4197            included_representation = process_includes(
4198                &include_path,
4199                included_representation,
4200                processed_files,
4201                active_features,
4202            )?;
4203
4204            if let Some(included_constants) = included_representation.constants {
4205                if result.constants.is_none() {
4206                    result.constants = Some(included_constants);
4207                } else {
4208                    let mut constants = result.constants.take().unwrap();
4209                    for included_constant in included_constants {
4210                        if !constants.iter().any(|constant| {
4211                            constant.id == included_constant.id
4212                                && constant.module_path() == included_constant.module_path()
4213                        }) {
4214                            constants.push(included_constant);
4215                        }
4216                    }
4217                    result.constants = Some(constants);
4218                }
4219            }
4220
4221            if let Some(included_tasks) = included_representation.tasks {
4222                if result.tasks.is_none() {
4223                    result.tasks = Some(included_tasks);
4224                } else {
4225                    let mut tasks = result.tasks.take().unwrap();
4226                    for included_task in included_tasks {
4227                        if !tasks.iter().any(|t| t.id == included_task.id) {
4228                            tasks.push(included_task);
4229                        }
4230                    }
4231                    result.tasks = Some(tasks);
4232                }
4233            }
4234
4235            if let Some(included_bridges) = included_representation.bridges {
4236                if result.bridges.is_none() {
4237                    result.bridges = Some(included_bridges);
4238                } else {
4239                    let mut bridges = result.bridges.take().unwrap();
4240                    for included_bridge in included_bridges {
4241                        if !bridges.iter().any(|b| b.id == included_bridge.id) {
4242                            bridges.push(included_bridge);
4243                        }
4244                    }
4245                    result.bridges = Some(bridges);
4246                }
4247            }
4248
4249            if let Some(included_resources) = included_representation.resources {
4250                if result.resources.is_none() {
4251                    result.resources = Some(included_resources);
4252                } else {
4253                    let mut resources = result.resources.take().unwrap();
4254                    for included_resource in included_resources {
4255                        if !resources.iter().any(|r| r.id == included_resource.id) {
4256                            resources.push(included_resource);
4257                        }
4258                    }
4259                    result.resources = Some(resources);
4260                }
4261            }
4262
4263            if let Some(included_cnx) = included_representation.cnx {
4264                if result.cnx.is_none() {
4265                    result.cnx = Some(included_cnx);
4266                } else {
4267                    let mut cnx = result.cnx.take().unwrap();
4268                    for included_c in included_cnx {
4269                        if let Some(existing_cnx) = cnx.iter_mut().find(|c| {
4270                            c.src == included_c.src
4271                                && c.dst == included_c.dst
4272                                && c.msg == included_c.msg
4273                        }) {
4274                            merge_connection_missions(
4275                                &mut existing_cnx.missions,
4276                                &included_c.missions,
4277                            );
4278                        } else {
4279                            cnx.push(included_c);
4280                        }
4281                    }
4282                    result.cnx = Some(cnx);
4283                }
4284            }
4285
4286            if let Some(included_monitors) = included_representation.monitors {
4287                if result.monitors.is_none() {
4288                    result.monitors = Some(included_monitors);
4289                } else {
4290                    let mut monitors = result.monitors.take().unwrap();
4291                    for included_monitor in included_monitors {
4292                        if !monitors.iter().any(|m| m.type_ == included_monitor.type_) {
4293                            monitors.push(included_monitor);
4294                        }
4295                    }
4296                    result.monitors = Some(monitors);
4297                }
4298            }
4299
4300            if result.logging.is_none() {
4301                result.logging = included_representation.logging;
4302            }
4303
4304            if result.runtime.is_none() {
4305                result.runtime = included_representation.runtime;
4306            }
4307
4308            if let Some(included_missions) = included_representation.missions {
4309                if result.missions.is_none() {
4310                    result.missions = Some(included_missions);
4311                } else {
4312                    let mut missions = result.missions.take().unwrap();
4313                    for included_mission in included_missions {
4314                        if !missions.iter().any(|m| m.id == included_mission.id) {
4315                            missions.push(included_mission);
4316                        }
4317                    }
4318                    result.missions = Some(missions);
4319                }
4320            }
4321        }
4322    }
4323
4324    Ok(result)
4325}
4326
4327#[cfg(feature = "std")]
4328fn parse_instance_config_overrides_string(
4329    content: &str,
4330) -> CuResult<InstanceConfigOverridesRepresentation> {
4331    Options::default()
4332        .with_default_extension(Extensions::IMPLICIT_SOME)
4333        .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4334        .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4335        .from_str(content)
4336        .map_err(|e| {
4337            CuError::from(format!(
4338                "Failed to parse instance override file: Error: {} at position {}",
4339                e.code, e.span
4340            ))
4341        })
4342}
4343
4344#[cfg(feature = "std")]
4345fn merge_component_config(target: &mut Option<ComponentConfig>, value: &ComponentConfig) {
4346    if let Some(existing) = target {
4347        existing.merge_from(value);
4348    } else {
4349        *target = Some(value.clone());
4350    }
4351}
4352
4353#[cfg(feature = "std")]
4354fn apply_task_config_override_to_graph(
4355    graph: &mut CuGraph,
4356    task_id: &str,
4357    value: &ComponentConfig,
4358) -> usize {
4359    let mut matches = 0usize;
4360    let node_indices: Vec<_> = graph.0.node_indices().collect();
4361    for node_index in node_indices {
4362        let node = &mut graph.0[node_index];
4363        if node.get_flavor() == Flavor::Task && node.id == task_id {
4364            merge_component_config(&mut node.config, value);
4365            matches += 1;
4366        }
4367    }
4368    matches
4369}
4370
4371#[cfg(feature = "std")]
4372fn apply_bridge_node_config_override_to_graph(
4373    graph: &mut CuGraph,
4374    bridge_id: &str,
4375    value: &ComponentConfig,
4376) {
4377    let node_indices: Vec<_> = graph.0.node_indices().collect();
4378    for node_index in node_indices {
4379        let node = &mut graph.0[node_index];
4380        if node.get_flavor() == Flavor::Bridge && node.id == bridge_id {
4381            merge_component_config(&mut node.config, value);
4382        }
4383    }
4384}
4385
4386#[cfg(feature = "std")]
4387fn parse_instance_override_target(path: &str) -> CuResult<(InstanceConfigTargetKind, String)> {
4388    let mut parts = path.split('/');
4389    let scope = parts.next().unwrap_or_default();
4390    let id = parts.next().unwrap_or_default();
4391    let leaf = parts.next().unwrap_or_default();
4392
4393    if scope.is_empty() || id.is_empty() || leaf.is_empty() || parts.next().is_some() {
4394        return Err(CuError::from(format!(
4395            "Invalid instance override path '{}'. Expected 'tasks/<id>/config', 'resources/<id>/config', or 'bridges/<id>/config'.",
4396            path
4397        )));
4398    }
4399
4400    if leaf != "config" {
4401        return Err(CuError::from(format!(
4402            "Invalid instance override path '{}'. Only the '/config' leaf is supported.",
4403            path
4404        )));
4405    }
4406
4407    let kind = match scope {
4408        "tasks" => InstanceConfigTargetKind::Task,
4409        "resources" => InstanceConfigTargetKind::Resource,
4410        "bridges" => InstanceConfigTargetKind::Bridge,
4411        _ => {
4412            return Err(CuError::from(format!(
4413                "Invalid instance override path '{}'. Supported roots are 'tasks', 'resources', and 'bridges'.",
4414                path
4415            )));
4416        }
4417    };
4418
4419    Ok((kind, id.to_string()))
4420}
4421
4422#[cfg(feature = "std")]
4423fn apply_instance_config_set_operation(
4424    config: &mut CuConfig,
4425    operation: &InstanceConfigSetOperation,
4426) -> CuResult<()> {
4427    let (target_kind, target_id) = parse_instance_override_target(&operation.path)?;
4428
4429    match target_kind {
4430        InstanceConfigTargetKind::Task => {
4431            let matches = match &mut config.graphs {
4432                ConfigGraphs::Simple(graph) => {
4433                    apply_task_config_override_to_graph(graph, &target_id, &operation.value)
4434                }
4435                ConfigGraphs::Missions(graphs) => graphs
4436                    .values_mut()
4437                    .map(|graph| {
4438                        apply_task_config_override_to_graph(graph, &target_id, &operation.value)
4439                    })
4440                    .sum(),
4441            };
4442
4443            if matches == 0 {
4444                return Err(CuError::from(format!(
4445                    "Instance override path '{}' targets unknown task '{}'.",
4446                    operation.path, target_id
4447                )));
4448            }
4449        }
4450        InstanceConfigTargetKind::Resource => {
4451            let mut matches = 0usize;
4452            for resource in &mut config.resources {
4453                if resource.id == target_id {
4454                    merge_component_config(&mut resource.config, &operation.value);
4455                    matches += 1;
4456                }
4457            }
4458            if matches == 0 {
4459                return Err(CuError::from(format!(
4460                    "Instance override path '{}' targets unknown resource '{}'.",
4461                    operation.path, target_id
4462                )));
4463            }
4464        }
4465        InstanceConfigTargetKind::Bridge => {
4466            let mut matches = 0usize;
4467            for bridge in &mut config.bridges {
4468                if bridge.id == target_id {
4469                    merge_component_config(&mut bridge.config, &operation.value);
4470                    matches += 1;
4471                }
4472            }
4473            if matches == 0 {
4474                return Err(CuError::from(format!(
4475                    "Instance override path '{}' targets unknown bridge '{}'.",
4476                    operation.path, target_id
4477                )));
4478            }
4479
4480            match &mut config.graphs {
4481                ConfigGraphs::Simple(graph) => {
4482                    apply_bridge_node_config_override_to_graph(graph, &target_id, &operation.value);
4483                }
4484                ConfigGraphs::Missions(graphs) => {
4485                    for graph in graphs.values_mut() {
4486                        apply_bridge_node_config_override_to_graph(
4487                            graph,
4488                            &target_id,
4489                            &operation.value,
4490                        );
4491                    }
4492                }
4493            }
4494        }
4495    }
4496
4497    Ok(())
4498}
4499
4500#[cfg(feature = "std")]
4501fn apply_instance_overrides(
4502    config: &mut CuConfig,
4503    overrides: &InstanceConfigOverridesRepresentation,
4504) -> CuResult<()> {
4505    for operation in &overrides.set {
4506        apply_instance_config_set_operation(config, operation)?;
4507    }
4508    Ok(())
4509}
4510
4511#[cfg(feature = "std")]
4512fn apply_instance_overrides_from_file(
4513    config: &mut CuConfig,
4514    override_path: &std::path::Path,
4515) -> CuResult<()> {
4516    let override_content = read_to_string(override_path).map_err(|e| {
4517        CuError::from(format!(
4518            "Failed to read instance override file '{}'",
4519            override_path.display()
4520        ))
4521        .add_cause(e.to_string().as_str())
4522    })?;
4523    let overrides = parse_instance_config_overrides_string(&override_content).map_err(|e| {
4524        CuError::from(format!(
4525            "Failed to parse instance override file '{}': {e}",
4526            override_path.display()
4527        ))
4528    })?;
4529    apply_instance_overrides(config, &overrides)
4530}
4531
4532#[cfg(feature = "std")]
4533#[allow(dead_code)]
4534fn parse_multi_config_string(content: &str) -> CuResult<MultiCopperConfigRepresentation> {
4535    Options::default()
4536        .with_default_extension(Extensions::IMPLICIT_SOME)
4537        .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4538        .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4539        .from_str(content)
4540        .map_err(|e| {
4541            CuError::from(format!(
4542                "Failed to parse multi-Copper configuration: Error: {} at position {}",
4543                e.code, e.span
4544            ))
4545        })
4546}
4547
4548#[cfg(feature = "std")]
4549#[allow(dead_code)]
4550fn resolve_relative_config_path(base_path: Option<&str>, referenced_path: &str) -> String {
4551    if referenced_path.starts_with('/') || base_path.is_none() {
4552        return referenced_path.to_string();
4553    }
4554
4555    let current_dir = std::path::Path::new(base_path.expect("checked above"))
4556        .parent()
4557        .unwrap_or_else(|| std::path::Path::new(""))
4558        .to_path_buf();
4559    current_dir
4560        .join(referenced_path)
4561        .to_string_lossy()
4562        .to_string()
4563}
4564
4565#[cfg(feature = "std")]
4566#[allow(dead_code)]
4567fn parse_multi_endpoint(endpoint: &str) -> CuResult<MultiCopperEndpoint> {
4568    let mut parts = endpoint.split('/');
4569    let subsystem_id = parts.next().unwrap_or_default();
4570    let bridge_id = parts.next().unwrap_or_default();
4571    let channel_id = parts.next().unwrap_or_default();
4572
4573    if subsystem_id.is_empty()
4574        || bridge_id.is_empty()
4575        || channel_id.is_empty()
4576        || parts.next().is_some()
4577    {
4578        return Err(CuError::from(format!(
4579            "Invalid multi-Copper endpoint '{endpoint}'. Expected 'subsystem/bridge/channel'."
4580        )));
4581    }
4582
4583    Ok(MultiCopperEndpoint {
4584        subsystem_id: subsystem_id.to_string(),
4585        bridge_id: bridge_id.to_string(),
4586        channel_id: channel_id.to_string(),
4587    })
4588}
4589
4590#[cfg(feature = "std")]
4591#[allow(dead_code)]
4592fn multi_channel_key(bridge_id: &str, channel_id: &str) -> String {
4593    format!("{bridge_id}/{channel_id}")
4594}
4595
4596#[cfg(feature = "std")]
4597#[allow(dead_code)]
4598fn register_multi_channel_msg(
4599    contracts: &mut HashMap<String, MultiCopperChannelContract>,
4600    bridge_id: &str,
4601    channel_id: &str,
4602    expected_direction: MultiCopperChannelDirection,
4603    msg: &str,
4604) -> CuResult<()> {
4605    let key = multi_channel_key(bridge_id, channel_id);
4606    let contract = contracts.get_mut(&key).ok_or_else(|| {
4607        CuError::from(format!(
4608            "Bridge channel '{bridge_id}/{channel_id}' is referenced by the graph but not declared in the bridge config."
4609        ))
4610    })?;
4611
4612    if contract.direction != expected_direction {
4613        let expected = match expected_direction {
4614            MultiCopperChannelDirection::Rx => "Rx",
4615            MultiCopperChannelDirection::Tx => "Tx",
4616        };
4617        return Err(CuError::from(format!(
4618            "Bridge channel '{bridge_id}/{channel_id}' is used as {expected} in the graph but declared with the opposite direction."
4619        )));
4620    }
4621
4622    match &contract.msg {
4623        Some(existing) if existing != msg => Err(CuError::from(format!(
4624            "Bridge channel '{bridge_id}/{channel_id}' carries inconsistent message types '{existing}' and '{msg}'."
4625        ))),
4626        Some(_) => Ok(()),
4627        None => {
4628            contract.msg = Some(msg.to_string());
4629            Ok(())
4630        }
4631    }
4632}
4633
4634#[cfg(feature = "std")]
4635#[allow(dead_code)]
4636fn build_multi_bridge_channel_contracts(
4637    config: &CuConfig,
4638) -> CuResult<HashMap<String, MultiCopperChannelContract>> {
4639    let graph = config
4640        .graphs
4641        .get_graph(Some(DEFAULT_MISSION_ID))
4642        .map_err(|e| {
4643            CuError::from(format!(
4644                "Multi-Copper subsystem configs with missions must define a '{DEFAULT_MISSION_ID}' mission: {e}"
4645            ))
4646        })?;
4647
4648    let mut contracts = HashMap::new();
4649    for bridge in &config.bridges {
4650        for channel in &bridge.channels {
4651            let (channel_id, direction) = match channel {
4652                BridgeChannelConfigRepresentation::Rx { id, .. } => {
4653                    (id.as_str(), MultiCopperChannelDirection::Rx)
4654                }
4655                BridgeChannelConfigRepresentation::Tx { id, .. } => {
4656                    (id.as_str(), MultiCopperChannelDirection::Tx)
4657                }
4658            };
4659
4660            let key = multi_channel_key(&bridge.id, channel_id);
4661            if contracts.contains_key(&key) {
4662                return Err(CuError::from(format!(
4663                    "Duplicate bridge channel declaration for '{key}'."
4664                )));
4665            }
4666
4667            contracts.insert(
4668                key,
4669                MultiCopperChannelContract {
4670                    bridge_type: bridge.type_.clone(),
4671                    direction,
4672                    msg: None,
4673                },
4674            );
4675        }
4676    }
4677
4678    for edge in graph.edges() {
4679        if let Some(channel_id) = &edge.src_channel {
4680            register_multi_channel_msg(
4681                &mut contracts,
4682                &edge.src,
4683                channel_id,
4684                MultiCopperChannelDirection::Rx,
4685                &edge.msg,
4686            )?;
4687        }
4688        if let Some(channel_id) = &edge.dst_channel {
4689            register_multi_channel_msg(
4690                &mut contracts,
4691                &edge.dst,
4692                channel_id,
4693                MultiCopperChannelDirection::Tx,
4694                &edge.msg,
4695            )?;
4696        }
4697    }
4698
4699    Ok(contracts)
4700}
4701
4702#[cfg(feature = "std")]
4703#[allow(dead_code)]
4704fn validate_multi_config_representation(
4705    representation: MultiCopperConfigRepresentation,
4706    file_path: Option<&str>,
4707    active_features: &[&str],
4708) -> CuResult<MultiCopperConfig> {
4709    if representation
4710        .instance_overrides_root
4711        .as_ref()
4712        .is_some_and(|root| root.trim().is_empty())
4713    {
4714        return Err(CuError::from(
4715            "Multi-Copper instance_overrides_root must not be empty.",
4716        ));
4717    }
4718
4719    if representation.subsystems.is_empty() {
4720        return Err(CuError::from(
4721            "Multi-Copper config must declare at least one subsystem.",
4722        ));
4723    }
4724    if representation.subsystems.len() > usize::from(u16::MAX) + 1 {
4725        return Err(CuError::from(
4726            "Multi-Copper config supports at most 65536 distinct subsystem ids.",
4727        ));
4728    }
4729
4730    let mut seen_subsystems = std::collections::HashSet::new();
4731    for subsystem in &representation.subsystems {
4732        if subsystem.id.trim().is_empty() {
4733            return Err(CuError::from(
4734                "Multi-Copper subsystem ids must not be empty.",
4735            ));
4736        }
4737        if !seen_subsystems.insert(subsystem.id.clone()) {
4738            return Err(CuError::from(format!(
4739                "Duplicate multi-Copper subsystem id '{}'.",
4740                subsystem.id
4741            )));
4742        }
4743    }
4744
4745    let mut sorted_ids: Vec<_> = representation
4746        .subsystems
4747        .iter()
4748        .map(|subsystem| subsystem.id.clone())
4749        .collect();
4750    sorted_ids.sort();
4751    let subsystem_code_map: HashMap<_, _> = sorted_ids
4752        .into_iter()
4753        .enumerate()
4754        .map(|(idx, id)| {
4755            (
4756                id,
4757                u16::try_from(idx).expect("subsystem count was validated against u16 range"),
4758            )
4759        })
4760        .collect();
4761
4762    let mut subsystem_contracts: HashMap<String, HashMap<String, MultiCopperChannelContract>> =
4763        HashMap::new();
4764    let mut subsystems = Vec::with_capacity(representation.subsystems.len());
4765
4766    for subsystem in representation.subsystems {
4767        let resolved_config_path = resolve_relative_config_path(file_path, &subsystem.config);
4768        let config = read_configuration_with_features(&resolved_config_path, active_features)
4769            .map_err(|e| {
4770                CuError::from(format!(
4771                    "Failed to read subsystem '{}' from '{}': {e}",
4772                    subsystem.id, resolved_config_path
4773                ))
4774            })?;
4775        let contracts = build_multi_bridge_channel_contracts(&config).map_err(|e| {
4776            CuError::from(format!(
4777                "Invalid subsystem '{}' for multi-Copper validation: {e}",
4778                subsystem.id
4779            ))
4780        })?;
4781        subsystem_contracts.insert(subsystem.id.clone(), contracts);
4782        subsystems.push(MultiCopperSubsystem {
4783            subsystem_code: *subsystem_code_map
4784                .get(&subsystem.id)
4785                .expect("subsystem code map must contain every subsystem"),
4786            id: subsystem.id,
4787            config_path: resolved_config_path,
4788            config,
4789        });
4790    }
4791
4792    let mut interconnects = Vec::with_capacity(representation.interconnects.len());
4793    for interconnect in representation.interconnects {
4794        if interconnect
4795            .when
4796            .as_ref()
4797            .is_some_and(|predicate| !predicate.evaluate(active_features))
4798        {
4799            continue;
4800        }
4801
4802        let from = parse_multi_endpoint(&interconnect.from).map_err(|e| {
4803            CuError::from(format!(
4804                "Invalid multi-Copper interconnect source '{}': {e}",
4805                interconnect.from
4806            ))
4807        })?;
4808        let to = parse_multi_endpoint(&interconnect.to).map_err(|e| {
4809            CuError::from(format!(
4810                "Invalid multi-Copper interconnect destination '{}': {e}",
4811                interconnect.to
4812            ))
4813        })?;
4814
4815        let from_contracts = subsystem_contracts.get(&from.subsystem_id).ok_or_else(|| {
4816            CuError::from(format!(
4817                "Interconnect source '{}' references unknown subsystem '{}'.",
4818                from, from.subsystem_id
4819            ))
4820        })?;
4821        let to_contracts = subsystem_contracts.get(&to.subsystem_id).ok_or_else(|| {
4822            CuError::from(format!(
4823                "Interconnect destination '{}' references unknown subsystem '{}'.",
4824                to, to.subsystem_id
4825            ))
4826        })?;
4827
4828        let from_contract = from_contracts
4829            .get(&multi_channel_key(&from.bridge_id, &from.channel_id))
4830            .ok_or_else(|| {
4831                CuError::from(format!(
4832                    "Interconnect source '{}' references unknown bridge channel.",
4833                    from
4834                ))
4835            })?;
4836        let to_contract = to_contracts
4837            .get(&multi_channel_key(&to.bridge_id, &to.channel_id))
4838            .ok_or_else(|| {
4839                CuError::from(format!(
4840                    "Interconnect destination '{}' references unknown bridge channel.",
4841                    to
4842                ))
4843            })?;
4844
4845        if from_contract.direction != MultiCopperChannelDirection::Tx {
4846            return Err(CuError::from(format!(
4847                "Interconnect source '{}' must reference a Tx bridge channel.",
4848                from
4849            )));
4850        }
4851        if to_contract.direction != MultiCopperChannelDirection::Rx {
4852            return Err(CuError::from(format!(
4853                "Interconnect destination '{}' must reference an Rx bridge channel.",
4854                to
4855            )));
4856        }
4857
4858        if from_contract.bridge_type != to_contract.bridge_type {
4859            return Err(CuError::from(format!(
4860                "Interconnect '{}' -> '{}' mixes incompatible bridge types '{}' and '{}'.",
4861                from, to, from_contract.bridge_type, to_contract.bridge_type
4862            )));
4863        }
4864
4865        let from_msg = from_contract.msg.as_ref().ok_or_else(|| {
4866            CuError::from(format!(
4867                "Interconnect source '{}' is not wired inside subsystem '{}', so its message type cannot be inferred.",
4868                from, from.subsystem_id
4869            ))
4870        })?;
4871        let to_msg = to_contract.msg.as_ref().ok_or_else(|| {
4872            CuError::from(format!(
4873                "Interconnect destination '{}' is not wired inside subsystem '{}', so its message type cannot be inferred.",
4874                to, to.subsystem_id
4875            ))
4876        })?;
4877
4878        if from_msg != to_msg {
4879            return Err(CuError::from(format!(
4880                "Interconnect '{}' -> '{}' connects incompatible message types '{}' and '{}'.",
4881                from, to, from_msg, to_msg
4882            )));
4883        }
4884        if interconnect.msg != *from_msg {
4885            return Err(CuError::from(format!(
4886                "Interconnect '{}' -> '{}' declares message type '{}' but subsystem graphs require '{}'.",
4887                from, to, interconnect.msg, from_msg
4888            )));
4889        }
4890
4891        interconnects.push(MultiCopperInterconnect {
4892            from,
4893            to,
4894            msg: interconnect.msg,
4895            bridge_type: from_contract.bridge_type.clone(),
4896        });
4897    }
4898
4899    let instance_overrides_root = representation
4900        .instance_overrides_root
4901        .as_ref()
4902        .map(|root| resolve_relative_config_path(file_path, root));
4903
4904    Ok(MultiCopperConfig {
4905        subsystems,
4906        interconnects,
4907        instance_overrides_root,
4908    })
4909}
4910
4911/// Read a copper configuration from a file.
4912#[cfg(feature = "std")]
4913pub fn read_configuration(config_filename: &str) -> CuResult<CuConfig> {
4914    read_configuration_with_features(config_filename, &[])
4915}
4916
4917/// Read a Copper configuration using the supplied compile-time Cargo features.
4918#[cfg(feature = "std")]
4919pub fn read_configuration_with_features(
4920    config_filename: &str,
4921    active_features: &[&str],
4922) -> CuResult<CuConfig> {
4923    let config_content = read_configuration_content(config_filename)?;
4924    read_configuration_str_with_features(config_content, Some(config_filename), active_features)
4925}
4926
4927#[cfg(feature = "std")]
4928fn read_configuration_content(config_filename: &str) -> CuResult<String> {
4929    read_to_string(config_filename).map_err(|e| {
4930        CuError::from(format!(
4931            "Failed to read configuration file: {:?}",
4932            config_filename
4933        ))
4934        .add_cause(e.to_string().as_str())
4935    })
4936}
4937
4938/// Read a copper configuration from a String.
4939/// Parse a RON string into a CuConfigRepresentation, using the standard options.
4940/// Returns an error if the parsing fails.
4941fn parse_config_string(content: &str) -> CuResult<CuConfigRepresentation> {
4942    Options::default()
4943        .with_default_extension(Extensions::IMPLICIT_SOME)
4944        .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4945        .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4946        .from_str(content)
4947        .map_err(|e| {
4948            CuError::from(format!(
4949                "Failed to parse configuration: Error: {} at position {}",
4950                e.code, e.span
4951            ))
4952        })
4953}
4954
4955/// Convert a CuConfigRepresentation to a CuConfig.
4956/// Uses the deserialize_impl method and validates the logging configuration.
4957fn config_representation_to_config(representation: CuConfigRepresentation) -> CuResult<CuConfig> {
4958    #[allow(unused_mut)]
4959    let mut cuconfig = CuConfig::deserialize_impl(representation)
4960        .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))?;
4961
4962    #[cfg(feature = "std")]
4963    cuconfig.ensure_default_background_pool();
4964
4965    cuconfig.validate_logging_config()?;
4966    cuconfig.validate_runtime_config()?;
4967    cuconfig.validate_anytime_configs()?;
4968    cuconfig.validate_constants()?;
4969
4970    Ok(cuconfig)
4971}
4972
4973#[allow(unused_variables)]
4974fn resolve_configuration_representation(
4975    config_content: &str,
4976    file_path: Option<&str>,
4977    active_features: &[&str],
4978) -> CuResult<CuConfigRepresentation> {
4979    // Parse the configuration string
4980    let representation = parse_config_string(config_content)?;
4981
4982    // Process includes and generate a merged configuration if a file path is provided
4983    // includes are only available with std.
4984    #[cfg(feature = "std")]
4985    let representation = if let Some(path) = file_path {
4986        process_includes(path, representation, &mut Vec::new(), active_features)?
4987    } else {
4988        representation
4989    };
4990
4991    Ok(representation)
4992}
4993
4994/// Read a Copper configuration and return the include-expanded RON used by proc-macro bundling.
4995///
4996/// The RON is serialized from the ordered source representation before it is lowered into
4997/// mission graph hash maps. This keeps task ordering aligned with generated runtime code.
4998#[cfg(feature = "std")]
4999#[doc(hidden)]
5000#[allow(dead_code)]
5001pub fn read_configuration_with_resolved_ron(config_filename: &str) -> CuResult<(CuConfig, String)> {
5002    read_configuration_with_resolved_ron_and_features(config_filename, &[])
5003}
5004
5005/// Read and expand a Copper configuration using the supplied compile-time Cargo features.
5006#[cfg(feature = "std")]
5007#[doc(hidden)]
5008pub fn read_configuration_with_resolved_ron_and_features(
5009    config_filename: &str,
5010    active_features: &[&str],
5011) -> CuResult<(CuConfig, String)> {
5012    let config_content = read_configuration_content(config_filename)?;
5013    let representation = resolve_configuration_representation(
5014        &config_content,
5015        Some(config_filename),
5016        active_features,
5017    )?;
5018    let resolved_ron = CuConfig::get_options()
5019        .to_string_pretty(&representation, ron::ser::PrettyConfig::default())
5020        .map_err(|e| CuError::from(format!("Error serializing configuration: {e}")))?;
5021    let config = config_representation_to_config(representation)?;
5022    Ok((config, resolved_ron))
5023}
5024
5025#[allow(dead_code)]
5026pub fn read_configuration_str(
5027    config_content: String,
5028    file_path: Option<&str>,
5029) -> CuResult<CuConfig> {
5030    read_configuration_str_with_features(config_content, file_path, &[])
5031}
5032
5033/// Read a Copper configuration string using the supplied compile-time Cargo features.
5034pub fn read_configuration_str_with_features(
5035    config_content: String,
5036    file_path: Option<&str>,
5037    active_features: &[&str],
5038) -> CuResult<CuConfig> {
5039    let representation =
5040        resolve_configuration_representation(&config_content, file_path, active_features)?;
5041
5042    // Convert the representation to a CuConfig and validate
5043    config_representation_to_config(representation)
5044}
5045
5046/// Read a strict multi-Copper umbrella configuration from a file.
5047#[cfg(feature = "std")]
5048#[allow(dead_code)]
5049pub fn read_multi_configuration(config_filename: &str) -> CuResult<MultiCopperConfig> {
5050    read_multi_configuration_with_features(config_filename, &[])
5051}
5052
5053/// Read a multi-Copper configuration using the supplied compile-time Cargo features.
5054#[cfg(feature = "std")]
5055#[allow(dead_code)]
5056pub fn read_multi_configuration_with_features(
5057    config_filename: &str,
5058    active_features: &[&str],
5059) -> CuResult<MultiCopperConfig> {
5060    let config_content = read_to_string(config_filename).map_err(|e| {
5061        CuError::from(format!(
5062            "Failed to read multi-Copper configuration file: {:?}",
5063            config_filename
5064        ))
5065        .add_cause(e.to_string().as_str())
5066    })?;
5067    read_multi_configuration_str_with_features(
5068        config_content,
5069        Some(config_filename),
5070        active_features,
5071    )
5072}
5073
5074/// Read a strict multi-Copper umbrella configuration from a string.
5075#[cfg(feature = "std")]
5076#[allow(dead_code)]
5077pub fn read_multi_configuration_str(
5078    config_content: String,
5079    file_path: Option<&str>,
5080) -> CuResult<MultiCopperConfig> {
5081    read_multi_configuration_str_with_features(config_content, file_path, &[])
5082}
5083
5084/// Read a multi-Copper configuration string using the supplied compile-time Cargo features.
5085#[cfg(feature = "std")]
5086#[allow(dead_code)]
5087pub fn read_multi_configuration_str_with_features(
5088    config_content: String,
5089    file_path: Option<&str>,
5090    active_features: &[&str],
5091) -> CuResult<MultiCopperConfig> {
5092    let representation = parse_multi_config_string(&config_content)?;
5093    validate_multi_config_representation(representation, file_path, active_features)
5094}
5095
5096// tests
5097#[cfg(test)]
5098mod tests {
5099    use super::*;
5100    #[cfg(not(feature = "std"))]
5101    use alloc::vec;
5102    use serde::Deserialize;
5103    #[cfg(feature = "std")]
5104    use std::path::{Path, PathBuf};
5105
5106    #[test]
5107    fn test_plain_serialize() {
5108        let mut config = CuConfig::default();
5109        let graph = config.get_graph_mut(None).unwrap();
5110        let n1 = graph
5111            .add_node(Node::new("test1", "package::Plugin1"))
5112            .unwrap();
5113        let n2 = graph
5114            .add_node(Node::new("test2", "package::Plugin2"))
5115            .unwrap();
5116        graph.connect(n1, n2, "msgpkg::MsgType").unwrap();
5117        let serialized = config.serialize_ron().unwrap();
5118        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5119        let graph = config.graphs.get_graph(None).unwrap();
5120        let deserialized_graph = deserialized.graphs.get_graph(None).unwrap();
5121        assert_eq!(graph.node_count(), deserialized_graph.node_count());
5122        assert_eq!(graph.edge_count(), deserialized_graph.edge_count());
5123    }
5124
5125    #[test]
5126    fn test_serialize_with_params() {
5127        let mut config = CuConfig::default();
5128        let graph = config.get_graph_mut(None).unwrap();
5129        let mut camera = Node::new("copper-camera", "camerapkg::Camera");
5130        camera.set_param::<Value>("resolution-height", 1080.into());
5131        graph.add_node(camera).unwrap();
5132        let serialized = config.serialize_ron().unwrap();
5133        let config = CuConfig::deserialize_ron(&serialized).unwrap();
5134        let deserialized = config.get_graph(None).unwrap();
5135        let resolution = deserialized
5136            .get_node(0)
5137            .unwrap()
5138            .get_param::<i32>("resolution-height")
5139            .expect("resolution-height lookup failed");
5140        assert_eq!(resolution, Some(1080));
5141    }
5142
5143    #[derive(Debug, Deserialize, PartialEq)]
5144    struct InnerSettings {
5145        threshold: u32,
5146        flags: Option<bool>,
5147    }
5148
5149    #[derive(Debug, Deserialize, PartialEq)]
5150    struct SettingsConfig {
5151        gain: f32,
5152        matrix: [[f32; 3]; 3],
5153        inner: InnerSettings,
5154        tags: Vec<String>,
5155    }
5156
5157    #[test]
5158    fn test_component_config_get_value_structured() {
5159        let txt = r#"
5160            (
5161                tasks: [
5162                    (
5163                        id: "task",
5164                        type: "pkg::Task",
5165                        config: {
5166                            "settings": {
5167                                "gain": 1.5,
5168                                "matrix": [
5169                                    [1.0, 0.0, 0.0],
5170                                    [0.0, 1.0, 0.0],
5171                                    [0.0, 0.0, 1.0],
5172                                ],
5173                                "inner": { "threshold": 42, "flags": Some(true) },
5174                                "tags": ["alpha", "beta"],
5175                            },
5176                        },
5177                    ),
5178                ],
5179                cnx: [],
5180            )
5181        "#;
5182        let config = CuConfig::deserialize_ron(txt).unwrap();
5183        let graph = config.graphs.get_graph(None).unwrap();
5184        let node = graph.get_node(0).unwrap();
5185        let component = node.get_instance_config().expect("missing config");
5186        let settings = component
5187            .get_value::<SettingsConfig>("settings")
5188            .expect("settings lookup failed")
5189            .expect("missing settings");
5190        let expected = SettingsConfig {
5191            gain: 1.5,
5192            matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
5193            inner: InnerSettings {
5194                threshold: 42,
5195                flags: Some(true),
5196            },
5197            tags: vec!["alpha".to_string(), "beta".to_string()],
5198        };
5199        assert_eq!(settings, expected);
5200    }
5201
5202    #[test]
5203    fn test_component_config_get_value_scalar_compatibility() {
5204        let txt = r#"
5205            (
5206                tasks: [
5207                    (id: "task", type: "pkg::Task", config: { "scalar": 7 }),
5208                ],
5209                cnx: [],
5210            )
5211        "#;
5212        let config = CuConfig::deserialize_ron(txt).unwrap();
5213        let graph = config.graphs.get_graph(None).unwrap();
5214        let node = graph.get_node(0).unwrap();
5215        let component = node.get_instance_config().expect("missing config");
5216        let scalar = component
5217            .get::<u32>("scalar")
5218            .expect("scalar lookup failed");
5219        assert_eq!(scalar, Some(7));
5220    }
5221
5222    #[test]
5223    fn test_component_config_get_value_mixed_usage() {
5224        let txt = r#"
5225            (
5226                tasks: [
5227                    (
5228                        id: "task",
5229                        type: "pkg::Task",
5230                        config: {
5231                            "scalar": 12,
5232                            "settings": {
5233                                "gain": 2.5,
5234                                "matrix": [
5235                                    [1.0, 2.0, 3.0],
5236                                    [4.0, 5.0, 6.0],
5237                                    [7.0, 8.0, 9.0],
5238                                ],
5239                                "inner": { "threshold": 7, "flags": None },
5240                                "tags": ["gamma"],
5241                            },
5242                        },
5243                    ),
5244                ],
5245                cnx: [],
5246            )
5247        "#;
5248        let config = CuConfig::deserialize_ron(txt).unwrap();
5249        let graph = config.graphs.get_graph(None).unwrap();
5250        let node = graph.get_node(0).unwrap();
5251        let component = node.get_instance_config().expect("missing config");
5252        let scalar = component
5253            .get::<u32>("scalar")
5254            .expect("scalar lookup failed");
5255        let settings = component
5256            .get_value::<SettingsConfig>("settings")
5257            .expect("settings lookup failed");
5258        assert_eq!(scalar, Some(12));
5259        assert!(settings.is_some());
5260    }
5261
5262    #[test]
5263    fn test_component_config_get_value_error_includes_key() {
5264        let txt = r#"
5265            (
5266                tasks: [
5267                    (
5268                        id: "task",
5269                        type: "pkg::Task",
5270                        config: { "settings": { "gain": 1.0 } },
5271                    ),
5272                ],
5273                cnx: [],
5274            )
5275        "#;
5276        let config = CuConfig::deserialize_ron(txt).unwrap();
5277        let graph = config.graphs.get_graph(None).unwrap();
5278        let node = graph.get_node(0).unwrap();
5279        let component = node.get_instance_config().expect("missing config");
5280        let err = component
5281            .get_value::<u32>("settings")
5282            .expect_err("expected type mismatch");
5283        assert!(err.to_string().contains("settings"));
5284    }
5285
5286    #[test]
5287    fn test_deserialization_error() {
5288        // Task needs to be an array, but provided tuple wrongfully
5289        let txt = r#"( tasks: (), cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
5290        let err = CuConfig::deserialize_ron(txt).expect_err("expected deserialization error");
5291        assert!(
5292            err.to_string()
5293                .contains("Syntax Error in config: Expected opening `[` at position 1:9-1:10")
5294        );
5295    }
5296
5297    #[test]
5298    fn test_compile_time_constant_defaults_and_normalization() {
5299        let config = read_configuration_str(
5300            r#"(
5301                constants: [
5302                    (id: "COUNT", storage: usize, value: 12),
5303                    (id: "COUNT", module: "diagnostics", storage: usize, value: 24),
5304                    (id: "LENGTH_DEFAULT", quantity: length, value: [0.18, 0.0, 0.31]),
5305                    (id: "LENGTH_EXPLICIT", quantity: length, unit: meter, storage: f32,
5306                        value: [0.18, 0.0, 0.31]),
5307                    (id: "LENGTH_MM", quantity: length, unit: millimeter,
5308                        value: [180.0, 0.0, 310.0]),
5309                    (id: "ANGLE_DEG", quantity: angle, unit: degree, value: 180.0),
5310                    (id: "MASS_DEFAULT", quantity: mass, value: 1.0),
5311                    (id: "TEMPERATURE_C", quantity: thermodynamic_temperature,
5312                        unit: degree_celsius, storage: f64, value: 20.0),
5313                    (id: "CONSTRUCTED", module: "geometry", type: "crate::ConstPair",
5314                        expression: "crate::ConstPair::new(crate::constants::COUNT)"),
5315                    (id: "CONSTRUCTED_COPY", module: "geometry", type: "crate::ConstPair",
5316                        expression: "crate::ConstPair::new(crate::constants::COUNT)"),
5317                    (id: "CONSTRUCTED_REWRITTEN", module: "geometry", type: "crate::ConstPair",
5318                        expression: "crate::ConstPair::new( crate::constants::COUNT )"),
5319                ],
5320                tasks: [],
5321                cnx: [],
5322            )"#
5323            .to_string(),
5324            None,
5325        )
5326        .unwrap();
5327
5328        assert_eq!(config.constants[0].module_path(), "constants");
5329        assert_eq!(config.constants[0].qualified_id(), "constants::COUNT");
5330        assert_eq!(config.constants[0].storage(), ConstantStorage::Usize);
5331        assert_eq!(config.constants[1].module_path(), "diagnostics");
5332        assert_eq!(config.constants[1].qualified_id(), "diagnostics::COUNT");
5333        let (_, default_length) = config.constants[2].normalized_f32().unwrap();
5334        let (_, explicit_length) = config.constants[3].normalized_f32().unwrap();
5335        let (_, millimeters) = config.constants[4].normalized_f32().unwrap();
5336        assert_eq!(default_length[0].to_bits(), explicit_length[0].to_bits());
5337        assert_eq!(default_length[2].to_bits(), explicit_length[2].to_bits());
5338        assert_eq!(default_length, millimeters);
5339        assert_eq!(
5340            config.constants[2].semantic_fingerprint().unwrap(),
5341            config.constants[3].semantic_fingerprint().unwrap()
5342        );
5343        assert_eq!(
5344            config.constants[2].semantic_fingerprint().unwrap(),
5345            config.constants[4].semantic_fingerprint().unwrap()
5346        );
5347
5348        let (_, angle) = config.constants[5].normalized_f32().unwrap();
5349        assert_eq!(angle[0].to_bits(), core::f32::consts::PI.to_bits());
5350
5351        let mass = &config.constants[6];
5352        assert_eq!(mass.resolved_unit().unwrap().unwrap().name(), "kilogram");
5353        assert_eq!(mass.normalized_f32().unwrap().1, vec![1.0]);
5354
5355        let temperature = config.constants[7].normalized_f64().unwrap().1[0];
5356        assert!((temperature - 293.15).abs() < f64::EPSILON * 4.0);
5357
5358        assert_eq!(
5359            config.constants[8].expression_definition(),
5360            Some((
5361                "crate::ConstPair",
5362                "crate::ConstPair::new(crate::constants::COUNT)"
5363            ))
5364        );
5365        assert_eq!(
5366            config.constants[8].semantic_fingerprint().unwrap(),
5367            config.constants[9].semantic_fingerprint().unwrap()
5368        );
5369        assert_ne!(
5370            config.constants[8].semantic_fingerprint().unwrap(),
5371            config.constants[10].semantic_fingerprint().unwrap()
5372        );
5373
5374        let serialized = config.serialize_ron().unwrap();
5375        let reparsed = CuConfig::deserialize_ron(&serialized).unwrap();
5376        assert_eq!(
5377            reparsed.constants[8].expression_definition(),
5378            config.constants[8].expression_definition()
5379        );
5380        assert_eq!(
5381            reparsed.constants[8].semantic_fingerprint().unwrap(),
5382            config.constants[8].semantic_fingerprint().unwrap()
5383        );
5384    }
5385
5386    #[test]
5387    fn test_compile_time_constant_rejects_invalid_definition_shapes() {
5388        let cases = [
5389            (
5390                r#"(id: "BAD", type: "crate::Pair")"#,
5391                "declares 'type' without 'expression'",
5392            ),
5393            (
5394                r#"(id: "BAD", expression: "crate::Pair::new()")"#,
5395                "declares 'expression' without 'type'",
5396            ),
5397            (
5398                r#"(id: "BAD", value: 1, type: "u32", expression: "1")"#,
5399                "cannot combine numeric 'value' with 'type' or 'expression'",
5400            ),
5401            (
5402                r#"(id: "BAD", storage: f32, type: "u32", expression: "1")"#,
5403                "cannot combine 'type' and 'expression' with numeric 'storage', 'quantity', or 'unit'",
5404            ),
5405            (
5406                r#"(id: "BAD")"#,
5407                "must declare either numeric 'value' or both 'type' and 'expression'",
5408            ),
5409        ];
5410
5411        for (constant, expected) in cases {
5412            let source = format!("(constants: [{constant}], tasks: [], cnx: [])");
5413            let error = read_configuration_str(source, None)
5414                .expect_err("invalid constant definition shape must fail");
5415            assert!(
5416                error.to_string().contains(expected),
5417                "unexpected error: {error}"
5418            );
5419        }
5420    }
5421
5422    #[test]
5423    fn test_compile_time_constant_rejects_duplicate_qualified_id() {
5424        let error = read_configuration_str(
5425            r#"(
5426                constants: [
5427                    (id: "COUNT", module: "diagnostics", value: 1),
5428                    (id: "COUNT", module: "diagnostics", value: 2),
5429                ],
5430                tasks: [],
5431                cnx: [],
5432            )"#
5433            .to_string(),
5434            None,
5435        )
5436        .expect_err("duplicate qualified constant id must fail");
5437        assert!(
5438            error
5439                .to_string()
5440                .contains("Duplicate constant 'diagnostics::COUNT'")
5441        );
5442    }
5443
5444    #[test]
5445    fn test_compile_time_constant_rejects_incompatible_unit() {
5446        let error = read_configuration_str(
5447            r#"(
5448                constants: [(id: "BAD", quantity: length, unit: degree, value: 1.0)],
5449                tasks: [],
5450                cnx: [],
5451            )"#
5452            .to_string(),
5453            None,
5454        )
5455        .expect_err("length in degrees must fail");
5456        assert!(
5457            error
5458                .to_string()
5459                .contains("unit 'degree' is not compatible with quantity 'length'")
5460        );
5461    }
5462
5463    #[test]
5464    fn test_missions() {
5465        let txt = r#"( missions: [ (id: "data_collection"), (id: "autonomous")])"#;
5466        let config = CuConfig::deserialize_ron(txt).unwrap();
5467        let graph = config.graphs.get_graph(Some("data_collection")).unwrap();
5468        assert!(graph.node_count() == 0);
5469        let graph = config.graphs.get_graph(Some("autonomous")).unwrap();
5470        assert!(graph.node_count() == 0);
5471    }
5472
5473    #[test]
5474    fn test_monitor_plural_syntax() {
5475        let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
5476        let config = CuConfig::deserialize_ron(txt).unwrap();
5477        assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
5478
5479        let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", config: { "toto": 4, } )] ) "#;
5480        let config = CuConfig::deserialize_ron(txt).unwrap();
5481        assert_eq!(
5482            config
5483                .get_monitor_config()
5484                .unwrap()
5485                .config
5486                .as_ref()
5487                .unwrap()
5488                .0["toto"]
5489                .0,
5490            4u8.into()
5491        );
5492    }
5493
5494    #[test]
5495    fn test_monitor_singular_syntax() {
5496        let txt = r#"( tasks: [], cnx: [], monitor: (type: "ExampleMonitor", config: { "toto": 4, } ) ) "#;
5497        let config = CuConfig::deserialize_ron(txt).unwrap();
5498        assert_eq!(config.get_monitor_configs().len(), 1);
5499        assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
5500        assert_eq!(
5501            config
5502                .get_monitor_config()
5503                .unwrap()
5504                .config
5505                .as_ref()
5506                .unwrap()
5507                .0["toto"]
5508                .0,
5509            4u8.into()
5510        );
5511    }
5512
5513    #[test]
5514    #[cfg(feature = "std")]
5515    fn test_render_topology_multi_input_ports() {
5516        let mut config = CuConfig::default();
5517        let graph = config.get_graph_mut(None).unwrap();
5518        let src1 = graph.add_node(Node::new("src1", "tasks::Source1")).unwrap();
5519        let src2 = graph.add_node(Node::new("src2", "tasks::Source2")).unwrap();
5520        let dst = graph.add_node(Node::new("dst", "tasks::Dst")).unwrap();
5521        graph.connect(src1, dst, "msg::A").unwrap();
5522        graph.connect(src2, dst, "msg::B").unwrap();
5523
5524        let topology = build_render_topology(graph, &[]);
5525        let dst_node = topology
5526            .nodes
5527            .iter()
5528            .find(|node| node.id == "dst")
5529            .expect("missing dst node");
5530        assert_eq!(dst_node.inputs.len(), 2);
5531
5532        let mut dst_ports: Vec<_> = topology
5533            .connections
5534            .iter()
5535            .filter(|cnx| cnx.dst == "dst")
5536            .map(|cnx| cnx.dst_port.as_deref().expect("missing dst port"))
5537            .collect();
5538        dst_ports.sort();
5539        assert_eq!(dst_ports, vec!["in.0", "in.1"]);
5540    }
5541
5542    #[test]
5543    fn test_logging_parameters() {
5544        // Test with `enable_task_logging: false`
5545        let txt = r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, enable_task_logging: false ),) "#;
5546
5547        let config = CuConfig::deserialize_ron(txt).unwrap();
5548        assert!(config.logging.is_some());
5549        let logging_config = config.logging.unwrap();
5550        assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
5551        assert_eq!(logging_config.section_size_mib.unwrap(), 100);
5552        assert!(!logging_config.enable_task_logging);
5553
5554        // Test with `enable_task_logging` not provided
5555        let txt =
5556            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, ),) "#;
5557        let config = CuConfig::deserialize_ron(txt).unwrap();
5558        assert!(config.logging.is_some());
5559        let logging_config = config.logging.unwrap();
5560        assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
5561        assert_eq!(logging_config.section_size_mib.unwrap(), 100);
5562        assert!(logging_config.enable_task_logging);
5563    }
5564
5565    #[test]
5566    fn test_node_logging_handle_content_round_trips() {
5567        // RON enum variants use bare identifiers — same convention as `kind: source`.
5568        let txt = r#"(
5569            tasks: [
5570                (id: "cam", type: "pkg::Cam", kind: source, logging: (handle_content: touched_only)),
5571                (id: "noop", type: "pkg::Noop", kind: sink),
5572            ],
5573            cnx: [
5574                (src: "cam", dst: "noop", msg: "pkg::Frame"),
5575            ],
5576        )"#;
5577
5578        let config = CuConfig::deserialize_ron(txt).unwrap();
5579        let cam = config.find_task_node(None, "cam").unwrap();
5580        assert_eq!(cam.handle_content_policy(), HandleContent::TouchedOnly);
5581
5582        // A node without an explicit `logging` block falls back to `All`.
5583        let noop = config.find_task_node(None, "noop").unwrap();
5584        assert_eq!(noop.handle_content_policy(), HandleContent::All);
5585
5586        // Round-trip preserves the policy.
5587        let reserialized = config.serialize_ron().unwrap();
5588        let reparsed = CuConfig::deserialize_ron(&reserialized).unwrap();
5589        let cam2 = reparsed.find_task_node(None, "cam").unwrap();
5590        assert_eq!(cam2.handle_content_policy(), HandleContent::TouchedOnly);
5591    }
5592
5593    #[test]
5594    fn test_node_logging_handle_content_all_variants_parse() {
5595        for (value, expected) in [
5596            ("all", HandleContent::All),
5597            ("touched_only", HandleContent::TouchedOnly),
5598            ("none", HandleContent::None),
5599        ] {
5600            let txt = format!(
5601                r#"(
5602                    tasks: [(id: "s", type: "pkg::T", kind: source, logging: (handle_content: {value}))],
5603                    cnx: [(src: "s", dst: "__nc__", msg: "pkg::M")],
5604                )"#
5605            );
5606            let config = CuConfig::deserialize_ron(&txt).unwrap();
5607            assert_eq!(
5608                config
5609                    .find_task_node(None, "s")
5610                    .unwrap()
5611                    .handle_content_policy(),
5612                expected,
5613                "policy mismatch for `{value}`"
5614            );
5615        }
5616    }
5617
5618    #[test]
5619    fn test_bridge_parsing() {
5620        let txt = r#"
5621        (
5622            tasks: [
5623                (id: "dst", type: "tasks::Destination"),
5624                (id: "src", type: "tasks::Source"),
5625            ],
5626            bridges: [
5627                (
5628                    id: "radio",
5629                    type: "tasks::SerialBridge",
5630                    config: { "path": "/dev/ttyACM0", "baud": 921600 },
5631                    channels: [
5632                        Rx ( id: "status", route: "sys/status" ),
5633                        Tx ( id: "motor", route: "motor/cmd" ),
5634                    ],
5635                ),
5636            ],
5637            cnx: [
5638                (src: "radio/status", dst: "dst", msg: "mymsgs::Status"),
5639                (src: "src", dst: "radio/motor", msg: "mymsgs::MotorCmd"),
5640            ],
5641        )
5642        "#;
5643
5644        let config = CuConfig::deserialize_ron(txt).unwrap();
5645        assert_eq!(config.bridges.len(), 1);
5646        let bridge = &config.bridges[0];
5647        assert_eq!(bridge.id, "radio");
5648        assert_eq!(bridge.channels.len(), 2);
5649        match &bridge.channels[0] {
5650            BridgeChannelConfigRepresentation::Rx { id, route, .. } => {
5651                assert_eq!(id, "status");
5652                assert_eq!(route.as_deref(), Some("sys/status"));
5653            }
5654            _ => panic!("expected Rx channel"),
5655        }
5656        match &bridge.channels[1] {
5657            BridgeChannelConfigRepresentation::Tx { id, route, .. } => {
5658                assert_eq!(id, "motor");
5659                assert_eq!(route.as_deref(), Some("motor/cmd"));
5660            }
5661            _ => panic!("expected Tx channel"),
5662        }
5663        let graph = config.graphs.get_graph(None).unwrap();
5664        let bridge_id = graph
5665            .get_node_id_by_name("radio")
5666            .expect("bridge node missing");
5667        let bridge_node = graph.get_node(bridge_id).unwrap();
5668        assert_eq!(bridge_node.get_flavor(), Flavor::Bridge);
5669
5670        // Edges should retain channel metadata.
5671        let mut edges = Vec::new();
5672        for edge_idx in graph.0.edge_indices() {
5673            edges.push(graph.0[edge_idx].clone());
5674        }
5675        assert_eq!(edges.len(), 2);
5676        let status_edge = edges
5677            .iter()
5678            .find(|e| e.dst == "dst")
5679            .expect("status edge missing");
5680        assert_eq!(status_edge.src_channel.as_deref(), Some("status"));
5681        assert!(status_edge.dst_channel.is_none());
5682        let motor_edge = edges
5683            .iter()
5684            .find(|e| e.dst_channel.is_some())
5685            .expect("motor edge missing");
5686        assert_eq!(motor_edge.dst_channel.as_deref(), Some("motor"));
5687    }
5688
5689    #[test]
5690    fn test_bridge_roundtrip() {
5691        let mut config = CuConfig::default();
5692        let mut bridge_config = ComponentConfig::default();
5693        bridge_config.set("port", "/dev/ttyACM0".to_string());
5694        config.bridges.push(BridgeConfig {
5695            id: "radio".to_string(),
5696            type_: "tasks::SerialBridge".to_string(),
5697            config: Some(bridge_config),
5698            resources: None,
5699            missions: None,
5700            run_in_sim: None,
5701            channels: vec![
5702                BridgeChannelConfigRepresentation::Rx {
5703                    id: "status".to_string(),
5704                    route: Some("sys/status".to_string()),
5705                    config: None,
5706                },
5707                BridgeChannelConfigRepresentation::Tx {
5708                    id: "motor".to_string(),
5709                    route: Some("motor/cmd".to_string()),
5710                    config: None,
5711                },
5712            ],
5713        });
5714
5715        let serialized = config.serialize_ron().unwrap();
5716        assert!(
5717            serialized.contains("bridges"),
5718            "bridges section missing from serialized config"
5719        );
5720        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5721        assert_eq!(deserialized.bridges.len(), 1);
5722        let bridge = &deserialized.bridges[0];
5723        assert!(bridge.is_run_in_sim());
5724        assert_eq!(bridge.channels.len(), 2);
5725        assert!(matches!(
5726            bridge.channels[0],
5727            BridgeChannelConfigRepresentation::Rx { .. }
5728        ));
5729        assert!(matches!(
5730            bridge.channels[1],
5731            BridgeChannelConfigRepresentation::Tx { .. }
5732        ));
5733    }
5734
5735    #[test]
5736    fn test_resource_parsing() {
5737        let txt = r#"
5738        (
5739            resources: [
5740                (
5741                    id: "fc",
5742                    provider: "copper_board_px4::Px4Bundle",
5743                    config: { "baud": 921600 },
5744                    missions: ["m1"],
5745                ),
5746                (
5747                    id: "misc",
5748                    provider: "cu29_runtime::StdClockBundle",
5749                ),
5750            ],
5751        )
5752        "#;
5753
5754        let config = CuConfig::deserialize_ron(txt).unwrap();
5755        assert_eq!(config.resources.len(), 2);
5756        let fc = &config.resources[0];
5757        assert_eq!(fc.id, "fc");
5758        assert_eq!(fc.provider, "copper_board_px4::Px4Bundle");
5759        assert_eq!(fc.missions.as_deref(), Some(&["m1".to_string()][..]));
5760        let baud: u32 = fc
5761            .config
5762            .as_ref()
5763            .expect("missing config")
5764            .get::<u32>("baud")
5765            .expect("baud lookup failed")
5766            .expect("missing baud");
5767        assert_eq!(baud, 921_600);
5768        let misc = &config.resources[1];
5769        assert_eq!(misc.id, "misc");
5770        assert_eq!(misc.provider, "cu29_runtime::StdClockBundle");
5771        assert!(misc.config.is_none());
5772    }
5773
5774    #[test]
5775    fn test_resource_roundtrip() {
5776        let mut config = CuConfig::default();
5777        let mut bundle_cfg = ComponentConfig::default();
5778        bundle_cfg.set("path", "/dev/ttyACM0".to_string());
5779        config.resources.push(ResourceBundleConfig {
5780            id: "fc".to_string(),
5781            provider: "copper_board_px4::Px4Bundle".to_string(),
5782            config: Some(bundle_cfg),
5783            missions: Some(vec!["m1".to_string()]),
5784        });
5785
5786        let serialized = config.serialize_ron().unwrap();
5787        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5788        assert_eq!(deserialized.resources.len(), 1);
5789        let res = &deserialized.resources[0];
5790        assert_eq!(res.id, "fc");
5791        assert_eq!(res.provider, "copper_board_px4::Px4Bundle");
5792        assert_eq!(res.missions.as_deref(), Some(&["m1".to_string()][..]));
5793        let path: String = res
5794            .config
5795            .as_ref()
5796            .expect("missing config")
5797            .get::<String>("path")
5798            .expect("path lookup failed")
5799            .expect("missing path");
5800        assert_eq!(path, "/dev/ttyACM0");
5801    }
5802
5803    #[test]
5804    fn test_bridge_channel_config() {
5805        let txt = r#"
5806        (
5807            tasks: [],
5808            bridges: [
5809                (
5810                    id: "radio",
5811                    type: "tasks::SerialBridge",
5812                    channels: [
5813                        Rx ( id: "status", route: "sys/status", config: { "filter": "fast" } ),
5814                        Tx ( id: "imu", route: "telemetry/imu", config: { "rate": 100 } ),
5815                    ],
5816                ),
5817            ],
5818            cnx: [],
5819        )
5820        "#;
5821
5822        let config = CuConfig::deserialize_ron(txt).unwrap();
5823        let bridge = &config.bridges[0];
5824        match &bridge.channels[0] {
5825            BridgeChannelConfigRepresentation::Rx {
5826                config: Some(cfg), ..
5827            } => {
5828                let val = cfg
5829                    .get::<String>("filter")
5830                    .expect("filter lookup failed")
5831                    .expect("filter missing");
5832                assert_eq!(val, "fast");
5833            }
5834            _ => panic!("expected Rx channel with config"),
5835        }
5836        match &bridge.channels[1] {
5837            BridgeChannelConfigRepresentation::Tx {
5838                config: Some(cfg), ..
5839            } => {
5840                let rate = cfg
5841                    .get::<i32>("rate")
5842                    .expect("rate lookup failed")
5843                    .expect("rate missing");
5844                assert_eq!(rate, 100);
5845            }
5846            _ => panic!("expected Tx channel with config"),
5847        }
5848    }
5849
5850    #[test]
5851    fn test_task_resources_roundtrip() {
5852        let txt = r#"
5853        (
5854            tasks: [
5855                (
5856                    id: "imu",
5857                    type: "tasks::ImuDriver",
5858                    resources: { "bus": "fc.spi_1", "irq": "fc.gpio_imu" },
5859                ),
5860            ],
5861            cnx: [],
5862        )
5863        "#;
5864
5865        let config = CuConfig::deserialize_ron(txt).unwrap();
5866        let graph = config.graphs.get_graph(None).unwrap();
5867        let node = graph.get_node(0).expect("missing task node");
5868        let resources = node.get_resources().expect("missing resources map");
5869        assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
5870        assert_eq!(
5871            resources.get("irq").map(String::as_str),
5872            Some("fc.gpio_imu")
5873        );
5874
5875        let serialized = config.serialize_ron().unwrap();
5876        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5877        let graph = deserialized.graphs.get_graph(None).unwrap();
5878        let node = graph.get_node(0).expect("missing task node");
5879        let resources = node
5880            .get_resources()
5881            .expect("missing resources map after roundtrip");
5882        assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
5883        assert_eq!(
5884            resources.get("irq").map(String::as_str),
5885            Some("fc.gpio_imu")
5886        );
5887    }
5888
5889    #[test]
5890    fn test_bridge_resources_preserved() {
5891        let mut config = CuConfig::default();
5892        config.resources.push(ResourceBundleConfig {
5893            id: "fc".to_string(),
5894            provider: "board::Bundle".to_string(),
5895            config: None,
5896            missions: None,
5897        });
5898        let bridge_resources = HashMap::from([("serial".to_string(), "fc.serial0".to_string())]);
5899        config.bridges.push(BridgeConfig {
5900            id: "radio".to_string(),
5901            type_: "tasks::SerialBridge".to_string(),
5902            config: None,
5903            resources: Some(bridge_resources),
5904            missions: None,
5905            run_in_sim: None,
5906            channels: vec![BridgeChannelConfigRepresentation::Tx {
5907                id: "uplink".to_string(),
5908                route: None,
5909                config: None,
5910            }],
5911        });
5912
5913        let serialized = config.serialize_ron().unwrap();
5914        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5915        let graph = deserialized.graphs.get_graph(None).expect("missing graph");
5916        let bridge_id = graph
5917            .get_node_id_by_name("radio")
5918            .expect("bridge node missing");
5919        let node = graph.get_node(bridge_id).expect("missing bridge node");
5920        let resources = node
5921            .get_resources()
5922            .expect("bridge resources were not preserved");
5923        assert_eq!(
5924            resources.get("serial").map(String::as_str),
5925            Some("fc.serial0")
5926        );
5927    }
5928
5929    #[test]
5930    fn test_demo_config_parses() {
5931        let txt = r#"(
5932    resources: [
5933        (
5934            id: "fc",
5935            provider: "crate::resources::RadioBundle",
5936        ),
5937    ],
5938    tasks: [
5939        (id: "thr", type: "tasks::ThrottleControl"),
5940        (id: "tele0", type: "tasks::TelemetrySink0"),
5941        (id: "tele1", type: "tasks::TelemetrySink1"),
5942        (id: "tele2", type: "tasks::TelemetrySink2"),
5943        (id: "tele3", type: "tasks::TelemetrySink3"),
5944    ],
5945    bridges: [
5946        (  id: "crsf",
5947           type: "cu_crsf::CrsfBridge<SerialResource, SerialPortError>",
5948           resources: { "serial": "fc.serial" },
5949           channels: [
5950                Rx ( id: "rc_rx" ),  // receiving RC Channels
5951                Tx ( id: "lq_tx" ),  // Sending LineQuality back
5952            ],
5953        ),
5954        (
5955            id: "bdshot",
5956            type: "cu_bdshot::RpBdshotBridge",
5957            channels: [
5958                Tx ( id: "esc0_tx" ),
5959                Tx ( id: "esc1_tx" ),
5960                Tx ( id: "esc2_tx" ),
5961                Tx ( id: "esc3_tx" ),
5962                Rx ( id: "esc0_rx" ),
5963                Rx ( id: "esc1_rx" ),
5964                Rx ( id: "esc2_rx" ),
5965                Rx ( id: "esc3_rx" ),
5966            ],
5967        ),
5968    ],
5969    cnx: [
5970        (src: "crsf/rc_rx", dst: "thr", msg: "cu_crsf::messages::RcChannelsPayload"),
5971        (src: "thr", dst: "bdshot/esc0_tx", msg: "cu_bdshot::EscCommand"),
5972        (src: "thr", dst: "bdshot/esc1_tx", msg: "cu_bdshot::EscCommand"),
5973        (src: "thr", dst: "bdshot/esc2_tx", msg: "cu_bdshot::EscCommand"),
5974        (src: "thr", dst: "bdshot/esc3_tx", msg: "cu_bdshot::EscCommand"),
5975        (src: "bdshot/esc0_rx", dst: "tele0", msg: "cu_bdshot::EscTelemetry"),
5976        (src: "bdshot/esc1_rx", dst: "tele1", msg: "cu_bdshot::EscTelemetry"),
5977        (src: "bdshot/esc2_rx", dst: "tele2", msg: "cu_bdshot::EscTelemetry"),
5978        (src: "bdshot/esc3_rx", dst: "tele3", msg: "cu_bdshot::EscTelemetry"),
5979    ],
5980)"#;
5981        let config = CuConfig::deserialize_ron(txt).unwrap();
5982        assert_eq!(config.resources.len(), 1);
5983        assert_eq!(config.bridges.len(), 2);
5984    }
5985
5986    #[test]
5987    fn test_bridge_tx_cannot_be_source() {
5988        let txt = r#"
5989        (
5990            tasks: [
5991                (id: "dst", type: "tasks::Destination"),
5992            ],
5993            bridges: [
5994                (
5995                    id: "radio",
5996                    type: "tasks::SerialBridge",
5997                    channels: [
5998                        Tx ( id: "motor", route: "motor/cmd" ),
5999                    ],
6000                ),
6001            ],
6002            cnx: [
6003                (src: "radio/motor", dst: "dst", msg: "mymsgs::MotorCmd"),
6004            ],
6005        )
6006        "#;
6007
6008        let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge source error");
6009        assert!(
6010            err.to_string()
6011                .contains("channel 'motor' is Tx and cannot act as a source")
6012        );
6013    }
6014
6015    #[test]
6016    fn test_bridge_rx_cannot_be_destination() {
6017        let txt = r#"
6018        (
6019            tasks: [
6020                (id: "src", type: "tasks::Source"),
6021            ],
6022            bridges: [
6023                (
6024                    id: "radio",
6025                    type: "tasks::SerialBridge",
6026                    channels: [
6027                        Rx ( id: "status", route: "sys/status" ),
6028                    ],
6029                ),
6030            ],
6031            cnx: [
6032                (src: "src", dst: "radio/status", msg: "mymsgs::Status"),
6033            ],
6034        )
6035        "#;
6036
6037        let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge destination error");
6038        assert!(
6039            err.to_string()
6040                .contains("channel 'status' is Rx and cannot act as a destination")
6041        );
6042    }
6043
6044    #[test]
6045    fn test_validate_logging_config() {
6046        // Test with valid logging configuration
6047        let txt =
6048            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100 ) )"#;
6049        let config = CuConfig::deserialize_ron(txt).unwrap();
6050        assert!(config.validate_logging_config().is_ok());
6051
6052        // Test with invalid logging configuration
6053        let txt =
6054            r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 100, section_size_mib: 1024 ) )"#;
6055        let config = CuConfig::deserialize_ron(txt).unwrap();
6056        assert!(config.validate_logging_config().is_err());
6057    }
6058
6059    // this test makes sure the edge id is suitable to be used to sort the inputs of a task
6060    #[test]
6061    fn test_deserialization_edge_id_assignment() {
6062        // note here that the src1 task is added before src2 in the tasks array,
6063        // however, src1 connection is added AFTER src2 in the cnx array
6064        let txt = r#"(
6065            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
6066            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")]
6067        )"#;
6068        let config = CuConfig::deserialize_ron(txt).unwrap();
6069        let graph = config.graphs.get_graph(None).unwrap();
6070        assert!(config.validate_logging_config().is_ok());
6071
6072        // the node id depends on the order in which the tasks are added
6073        let src1_id = 0;
6074        assert_eq!(graph.get_node(src1_id).unwrap().id, "src1");
6075        let src2_id = 1;
6076        assert_eq!(graph.get_node(src2_id).unwrap().id, "src2");
6077
6078        // the edge id depends on the order the connection is created
6079        // the src2 was added second in the tasks, but the connection was added first
6080        let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
6081        assert_eq!(src1_edge_id, 1);
6082        let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
6083        assert_eq!(src2_edge_id, 0);
6084    }
6085
6086    #[test]
6087    fn test_simple_missions() {
6088        // A simple config that selection a source depending on the mission it is in.
6089        let txt = r#"(
6090                    missions: [ (id: "m1"),
6091                                (id: "m2"),
6092                                ],
6093                    tasks: [(id: "src1", type: "a", missions: ["m1"]),
6094                            (id: "src2", type: "b", missions: ["m2"]),
6095                            (id: "sink", type: "c")],
6096
6097                    cnx: [
6098                            (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
6099                            (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
6100                         ],
6101              )
6102              "#;
6103
6104        let config = CuConfig::deserialize_ron(txt).unwrap();
6105        let m1_graph = config.graphs.get_graph(Some("m1")).unwrap();
6106        assert_eq!(m1_graph.edge_count(), 1);
6107        assert_eq!(m1_graph.node_count(), 2);
6108        let index = 0;
6109        let cnx = m1_graph.get_edge_weight(index).unwrap();
6110
6111        assert_eq!(cnx.src, "src1");
6112        assert_eq!(cnx.dst, "sink");
6113        assert_eq!(cnx.msg, "u32");
6114        assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
6115
6116        let m2_graph = config.graphs.get_graph(Some("m2")).unwrap();
6117        assert_eq!(m2_graph.edge_count(), 1);
6118        assert_eq!(m2_graph.node_count(), 2);
6119        let index = 0;
6120        let cnx = m2_graph.get_edge_weight(index).unwrap();
6121        assert_eq!(cnx.src, "src2");
6122        assert_eq!(cnx.dst, "sink");
6123        assert_eq!(cnx.msg, "u32");
6124        assert_eq!(cnx.missions, Some(vec!["m2".to_string()]));
6125    }
6126    #[test]
6127    fn test_mission_serde() {
6128        // A simple config that selection a source depending on the mission it is in.
6129        let txt = r#"(
6130                    missions: [ (id: "m1"),
6131                                (id: "m2"),
6132                                ],
6133                    tasks: [(id: "src1", type: "a", missions: ["m1"]),
6134                            (id: "src2", type: "b", missions: ["m2"]),
6135                            (id: "sink", type: "c")],
6136
6137                    cnx: [
6138                            (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
6139                            (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
6140                         ],
6141              )
6142              "#;
6143
6144        let config = CuConfig::deserialize_ron(txt).unwrap();
6145        let serialized = config.serialize_ron().unwrap();
6146        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6147        let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
6148        assert_eq!(m1_graph.edge_count(), 1);
6149        assert_eq!(m1_graph.node_count(), 2);
6150        let index = 0;
6151        let cnx = m1_graph.get_edge_weight(index).unwrap();
6152        assert_eq!(cnx.src, "src1");
6153        assert_eq!(cnx.dst, "sink");
6154        assert_eq!(cnx.msg, "u32");
6155        assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
6156    }
6157
6158    #[test]
6159    fn test_mission_scoped_nc_connection_survives_serialize_roundtrip() {
6160        let txt = r#"(
6161            missions: [(id: "m1"), (id: "m2")],
6162            tasks: [
6163                (id: "src_m1", type: "a", missions: ["m1"]),
6164                (id: "src_m2", type: "b", missions: ["m2"]),
6165            ],
6166            cnx: [
6167                (src: "src_m1", dst: "__nc__", msg: "msg::A", missions: ["m1"]),
6168                (src: "src_m2", dst: "__nc__", msg: "msg::B", missions: ["m2"]),
6169            ]
6170        )"#;
6171
6172        let config = CuConfig::deserialize_ron(txt).unwrap();
6173        let serialized = config.serialize_ron().unwrap();
6174        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6175
6176        let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
6177        let src_m1_id = m1_graph.get_node_id_by_name("src_m1").unwrap();
6178        let src_m1 = m1_graph.get_node(src_m1_id).unwrap();
6179        assert_eq!(src_m1.nc_outputs(), &["msg::A".to_string()]);
6180
6181        let m2_graph = deserialized.graphs.get_graph(Some("m2")).unwrap();
6182        let src_m2_id = m2_graph.get_node_id_by_name("src_m2").unwrap();
6183        let src_m2 = m2_graph.get_node(src_m2_id).unwrap();
6184        assert_eq!(src_m2.nc_outputs(), &["msg::B".to_string()]);
6185    }
6186
6187    #[test]
6188    fn test_keyframe_interval() {
6189        // note here that the src1 task is added before src2 in the tasks array,
6190        // however, src1 connection is added AFTER src2 in the cnx array
6191        let txt = r#"(
6192            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
6193            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
6194            logging: ( keyframe_interval: 314 )
6195        )"#;
6196        let config = CuConfig::deserialize_ron(txt).unwrap();
6197        let logging_config = config.logging.unwrap();
6198        assert_eq!(logging_config.keyframe_interval.unwrap(), 314);
6199    }
6200
6201    #[test]
6202    fn test_default_keyframe_interval() {
6203        // note here that the src1 task is added before src2 in the tasks array,
6204        // however, src1 connection is added AFTER src2 in the cnx array
6205        let txt = r#"(
6206            tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
6207            cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
6208            logging: ( slab_size_mib: 200, section_size_mib: 1024, )
6209        )"#;
6210        let config = CuConfig::deserialize_ron(txt).unwrap();
6211        let logging_config = config.logging.unwrap();
6212        assert_eq!(logging_config.keyframe_interval.unwrap(), 100);
6213    }
6214
6215    #[test]
6216    fn test_task_kind_roundtrip_and_alias() {
6217        let txt = r#"(
6218            tasks: [
6219                (id: "src", type: "a", kind: source),
6220                (id: "regular", type: "b", kind: regular),
6221                (id: "sink", type: "c", kind: sink),
6222            ],
6223            cnx: [
6224                (src: "src", dst: "regular", msg: "msg::A"),
6225                (src: "regular", dst: "sink", msg: "msg::B"),
6226            ]
6227        )"#;
6228
6229        let config = CuConfig::deserialize_ron(txt).unwrap();
6230        let graph = config.get_graph(None).unwrap();
6231
6232        assert_eq!(
6233            graph
6234                .get_node(graph.get_node_id_by_name("src").unwrap())
6235                .unwrap()
6236                .get_declared_task_kind(),
6237            Some(TaskKind::Source)
6238        );
6239        assert_eq!(
6240            graph
6241                .get_node(graph.get_node_id_by_name("regular").unwrap())
6242                .unwrap()
6243                .get_declared_task_kind(),
6244            Some(TaskKind::Regular)
6245        );
6246        assert_eq!(
6247            graph
6248                .get_node(graph.get_node_id_by_name("sink").unwrap())
6249                .unwrap()
6250                .get_declared_task_kind(),
6251            Some(TaskKind::Sink)
6252        );
6253
6254        let serialized = config.serialize_ron().unwrap();
6255        assert!(serialized.contains("kind: source"));
6256        assert!(serialized.contains("kind: task"));
6257        assert!(serialized.contains("kind: sink"));
6258    }
6259
6260    #[test]
6261    fn test_resolve_task_kind_uses_nc_outputs_for_regular_tasks() {
6262        let txt = r#"(
6263            tasks: [
6264                (id: "src", type: "a"),
6265                (id: "regular", type: "b"),
6266            ],
6267            cnx: [
6268                (src: "src", dst: "regular", msg: "msg::A"),
6269                (src: "regular", dst: "__nc__", msg: "msg::B"),
6270            ]
6271        )"#;
6272
6273        let config = CuConfig::deserialize_ron(txt).unwrap();
6274        let graph = config.get_graph(None).unwrap();
6275        let regular_id = graph.get_node_id_by_name("regular").unwrap();
6276
6277        assert_eq!(
6278            resolve_task_kind_for_id(graph, regular_id).unwrap(),
6279            TaskKind::Regular
6280        );
6281    }
6282
6283    #[test]
6284    fn test_resolve_task_kind_rejects_isolated_task_without_kind() {
6285        let txt = r#"(
6286            tasks: [
6287                (id: "lonely", type: "a"),
6288            ],
6289            cnx: []
6290        )"#;
6291
6292        let config = CuConfig::deserialize_ron(txt).unwrap();
6293        let graph = config.get_graph(None).unwrap();
6294        let lonely_id = graph.get_node_id_by_name("lonely").unwrap();
6295
6296        let err = resolve_task_kind_for_id(graph, lonely_id).expect_err("expected task kind error");
6297        assert!(
6298            err.to_string()
6299                .contains("cannot infer whether it is a source, task, or sink"),
6300            "unexpected error: {err}"
6301        );
6302    }
6303
6304    #[test]
6305    fn test_resolve_explicit_source_kind_allows_missing_declared_outputs() {
6306        let txt = r#"(
6307            tasks: [
6308                (id: "src", type: "a", kind: source),
6309            ],
6310            cnx: []
6311        )"#;
6312
6313        let config = CuConfig::deserialize_ron(txt).unwrap();
6314        let graph = config.get_graph(None).unwrap();
6315        let src_id = graph.get_node_id_by_name("src").unwrap();
6316
6317        assert_eq!(
6318            resolve_task_kind_for_id(graph, src_id).unwrap(),
6319            TaskKind::Source
6320        );
6321    }
6322
6323    #[test]
6324    fn test_resolve_explicit_regular_kind_allows_missing_declared_outputs() {
6325        let txt = r#"(
6326            tasks: [
6327                (id: "src", type: "a"),
6328                (id: "regular", type: "b", kind: task),
6329            ],
6330            cnx: [
6331                (src: "src", dst: "regular", msg: "msg::A"),
6332            ]
6333        )"#;
6334
6335        let config = CuConfig::deserialize_ron(txt).unwrap();
6336        let graph = config.get_graph(None).unwrap();
6337        let regular_id = graph.get_node_id_by_name("regular").unwrap();
6338
6339        assert_eq!(
6340            resolve_task_kind_for_id(graph, regular_id).unwrap(),
6341            TaskKind::Regular
6342        );
6343    }
6344
6345    #[test]
6346    fn test_runtime_rate_target_rejects_zero() {
6347        let txt = r#"(
6348            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6349            cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6350            runtime: (rate_target_hz: 0)
6351        )"#;
6352
6353        let err =
6354            read_configuration_str(txt.to_string(), None).expect_err("runtime config should fail");
6355        assert!(
6356            err.to_string()
6357                .contains("Runtime rate target cannot be zero"),
6358            "unexpected error: {err}"
6359        );
6360    }
6361
6362    #[test]
6363    fn test_runtime_rate_target_rejects_above_nanosecond_resolution() {
6364        let txt = format!(
6365            r#"(
6366                tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6367                cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6368                runtime: (rate_target_hz: {})
6369            )"#,
6370            MAX_RATE_TARGET_HZ + 1
6371        );
6372
6373        let err = read_configuration_str(txt, None).expect_err("runtime config should fail");
6374        assert!(
6375            err.to_string().contains("exceeds the supported maximum"),
6376            "unexpected error: {err}"
6377        );
6378    }
6379
6380    /// Builds a src -> any -> sink config with the given `anytime:` policy body,
6381    /// extra node attributes (e.g. `, background: true`) and top-level extras
6382    /// (e.g. `runtime: (rate_target_hz: 100),`).
6383    fn anytime_config_txt(policy: &str, node_attrs: &str, top_level: &str) -> String {
6384        format!(
6385            r#"(
6386            tasks: [
6387                (id: "src", type: "a"),
6388                (id: "any", type: "b", anytime: ({policy}){node_attrs}),
6389                (id: "sink", type: "c"),
6390            ],
6391            cnx: [
6392                (src: "src", dst: "any", msg: "msg::A"),
6393                (src: "any", dst: "sink", msg: "msg::B"),
6394            ],
6395            {top_level}
6396        )"#
6397        )
6398    }
6399
6400    fn expect_anytime_error(txt: String, expected: &str) {
6401        let err = read_configuration_str(txt, None).expect_err("anytime config should fail");
6402        assert!(
6403            err.to_string().contains(expected),
6404            "unexpected error: {err}"
6405        );
6406    }
6407
6408    #[test]
6409    fn test_anytime_node_parses_and_exposes_policy() {
6410        let txt = anytime_config_txt(
6411            r#"
6412                time_budget_ms: 8.0,
6413                max_age_ms: 100.0,
6414                quality_target: 0.95,
6415                quality_floor: 0.30,
6416                max_refines: 64,
6417                max_stall: 4,
6418            "#,
6419            ", background: true",
6420            "",
6421        );
6422        let config = read_configuration_str(txt, None).unwrap();
6423        let graph = config.get_graph(None).unwrap();
6424        let node = graph
6425            .get_node(graph.get_node_id_by_name("any").unwrap())
6426            .unwrap();
6427        assert!(node.is_anytime());
6428        assert!(node.is_background());
6429        assert_eq!(
6430            node.anytime().unwrap(),
6431            &AnytimeConfig {
6432                time_budget_ms: Some(8.0),
6433                max_age_ms: Some(100.0),
6434                quality_target: Some(0.95),
6435                quality_floor: Some(0.30),
6436                max_refines: Some(64),
6437                max_stall: Some(4),
6438            }
6439        );
6440        let src = graph
6441            .get_node(graph.get_node_id_by_name("src").unwrap())
6442            .unwrap();
6443        assert!(!src.is_anytime());
6444        assert!(src.anytime().is_none());
6445    }
6446
6447    #[test]
6448    fn test_anytime_typical_perception_config_is_accepted() {
6449        // The doc's typical perception config; foreground placement compiles to
6450        // a static plan, so max_refines is part of the minimum foreground set.
6451        let txt = anytime_config_txt(
6452            "max_age_ms: 100.0, quality_target: 0.9, max_refines: 22",
6453            "",
6454            "",
6455        );
6456        let config = read_configuration_str(txt, None).unwrap();
6457        let graph = config.get_graph(None).unwrap();
6458        let node = graph
6459            .get_node(graph.get_node_id_by_name("any").unwrap())
6460            .unwrap();
6461        let anytime = node.anytime().unwrap();
6462        assert_eq!(anytime.max_age_ms, Some(100.0));
6463        assert_eq!(anytime.quality_target, Some(0.9));
6464        assert_eq!(anytime.max_refines, Some(22));
6465        assert_eq!(anytime.time_budget_ms, None);
6466    }
6467
6468    #[test]
6469    fn test_anytime_arity_is_one_input_one_output() {
6470        // Two inputs: the runner cannot pick a Tov anchor.
6471        let two_inputs = r#"(
6472            tasks: [
6473                (id: "src_a", type: "a"),
6474                (id: "src_b", type: "a"),
6475                (id: "any", type: "b", anytime: (max_refines: 2)),
6476                (id: "sink", type: "c"),
6477            ],
6478            cnx: [
6479                (src: "src_a", dst: "any", msg: "msg::A"),
6480                (src: "src_b", dst: "any", msg: "msg::A"),
6481                (src: "any", dst: "sink", msg: "msg::B"),
6482            ],
6483        )"#;
6484        expect_anytime_error(
6485            two_inputs.to_string(),
6486            "exactly one input connection (found 2)",
6487        );
6488
6489        // Two output message types: refine() has no single slot to rewrite.
6490        let two_outputs = r#"(
6491            tasks: [
6492                (id: "src", type: "a"),
6493                (id: "any", type: "b", anytime: (max_refines: 2)),
6494                (id: "sink_a", type: "c"),
6495                (id: "sink_b", type: "c"),
6496            ],
6497            cnx: [
6498                (src: "src", dst: "any", msg: "msg::A"),
6499                (src: "any", dst: "sink_a", msg: "msg::B"),
6500                (src: "any", dst: "sink_b", msg: "msg::C"),
6501            ],
6502        )"#;
6503        expect_anytime_error(
6504            two_outputs.to_string(),
6505            "exactly one output message type (found 2)",
6506        );
6507
6508        // Fan-out of ONE output type to two consumers stays legal.
6509        let fan_out = r#"(
6510            tasks: [
6511                (id: "src", type: "a"),
6512                (id: "any", type: "b", anytime: (max_refines: 2)),
6513                (id: "sink_a", type: "c"),
6514                (id: "sink_b", type: "c"),
6515            ],
6516            cnx: [
6517                (src: "src", dst: "any", msg: "msg::A"),
6518                (src: "any", dst: "sink_a", msg: "msg::B"),
6519                (src: "any", dst: "sink_b", msg: "msg::B"),
6520            ],
6521        )"#;
6522        read_configuration_str(fan_out.to_string(), None).unwrap();
6523    }
6524
6525    #[test]
6526    fn test_anytime_foreground_needs_max_refines() {
6527        // A time-only hard bound cannot produce a static plan in the foreground.
6528        expect_anytime_error(
6529            anytime_config_txt("max_age_ms: 100.0, quality_target: 0.9", "", ""),
6530            "needs anytime.max_refines",
6531        );
6532        // Background placement has no static refine schedule to emit.
6533        let background = anytime_config_txt("max_age_ms: 100.0", ", background: true", "");
6534        read_configuration_str(background, None).unwrap();
6535    }
6536
6537    #[test]
6538    fn test_anytime_survives_serialize_roundtrip() {
6539        let txt = anytime_config_txt("time_budget_ms: 8.0, max_refines: 64", "", "");
6540        let config = CuConfig::deserialize_ron(&txt).unwrap();
6541        let serialized = config.serialize_ron().unwrap();
6542        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6543        let graph = deserialized.get_graph(None).unwrap();
6544        let node = graph
6545            .get_node(graph.get_node_id_by_name("any").unwrap())
6546            .unwrap();
6547        assert_eq!(
6548            node.anytime().unwrap(),
6549            &AnytimeConfig {
6550                time_budget_ms: Some(8.0),
6551                max_age_ms: None,
6552                quality_target: None,
6553                quality_floor: None,
6554                max_refines: Some(64),
6555                max_stall: None,
6556            }
6557        );
6558    }
6559
6560    #[test]
6561    fn test_anytime_rejects_missing_hard_bound() {
6562        expect_anytime_error(
6563            anytime_config_txt("quality_target: 0.9, max_stall: 4", "", ""),
6564            "needs at least one hard bound",
6565        );
6566    }
6567
6568    #[test]
6569    fn test_anytime_rejects_nan_quality_target() {
6570        expect_anytime_error(
6571            anytime_config_txt("time_budget_ms: 8.0, quality_target: NaN", "", ""),
6572            "anytime.quality_target must be within (0.0, 1.0]",
6573        );
6574    }
6575
6576    #[test]
6577    fn test_anytime_rejects_non_positive_times() {
6578        expect_anytime_error(
6579            anytime_config_txt("time_budget_ms: 0.0", "", ""),
6580            "anytime.time_budget_ms must be a positive",
6581        );
6582        expect_anytime_error(
6583            anytime_config_txt("max_age_ms: -5.0", "", ""),
6584            "anytime.max_age_ms must be a positive",
6585        );
6586        expect_anytime_error(
6587            anytime_config_txt("time_budget_ms: inf", "", ""),
6588            "anytime.time_budget_ms must be a positive",
6589        );
6590    }
6591
6592    #[test]
6593    fn test_anytime_rejects_zero_counts() {
6594        expect_anytime_error(
6595            anytime_config_txt("max_refines: 0", "", ""),
6596            "anytime.max_refines must be at least 1",
6597        );
6598        expect_anytime_error(
6599            anytime_config_txt("max_refines: 4, max_stall: 0", "", ""),
6600            "anytime.max_stall must be at least 1",
6601        );
6602    }
6603
6604    #[test]
6605    fn test_anytime_quality_ranges() {
6606        // target is (0.0, 1.0]: exactly 1.0 is fine, 0.0 is not.
6607        let ok = anytime_config_txt(
6608            "time_budget_ms: 8.0, quality_target: 1.0, max_refines: 4",
6609            "",
6610            "",
6611        );
6612        read_configuration_str(ok, None).unwrap();
6613        expect_anytime_error(
6614            anytime_config_txt("time_budget_ms: 8.0, quality_target: 0.0", "", ""),
6615            "anytime.quality_target must be within (0.0, 1.0]",
6616        );
6617        // floor is (0.0, 1.0): exactly 1.0 is rejected.
6618        expect_anytime_error(
6619            anytime_config_txt("time_budget_ms: 8.0, quality_floor: 1.0", "", ""),
6620            "anytime.quality_floor must be within (0.0, 1.0)",
6621        );
6622    }
6623
6624    #[test]
6625    fn test_anytime_rejects_floor_above_target() {
6626        expect_anytime_error(
6627            anytime_config_txt(
6628                "time_budget_ms: 8.0, quality_target: 0.5, quality_floor: 0.8",
6629                "",
6630                "",
6631            ),
6632            "must not exceed anytime.quality_target",
6633        );
6634    }
6635
6636    #[test]
6637    fn test_anytime_rejects_sources_and_sinks() {
6638        let on_source = r#"(
6639            tasks: [
6640                (id: "src", type: "a", anytime: (max_refines: 4)),
6641                (id: "sink", type: "b"),
6642            ],
6643            cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6644        )"#;
6645        expect_anytime_error(on_source.to_string(), "only supported on regular tasks");
6646
6647        let on_sink = r#"(
6648            tasks: [
6649                (id: "src", type: "a"),
6650                (id: "sink", type: "b", anytime: (max_refines: 4)),
6651            ],
6652            cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6653        )"#;
6654        expect_anytime_error(on_sink.to_string(), "only supported on regular tasks");
6655    }
6656
6657    #[test]
6658    fn test_anytime_foreground_rate_limited_needs_time_bound() {
6659        expect_anytime_error(
6660            anytime_config_txt("max_refines: 64", "", "runtime: (rate_target_hz: 100),"),
6661            "needs a time bound",
6662        );
6663    }
6664
6665    #[test]
6666    fn test_anytime_foreground_window_must_fit_period() {
6667        expect_anytime_error(
6668            anytime_config_txt(
6669                "time_budget_ms: 12.0, max_refines: 8",
6670                "",
6671                "runtime: (rate_target_hz: 100),",
6672            ),
6673            "does not fit within",
6674        );
6675        // The worst-case window is min(time_budget_ms, max_age_ms).
6676        let ok = anytime_config_txt(
6677            "time_budget_ms: 20.0, max_age_ms: 5.0, max_refines: 8",
6678            "",
6679            "runtime: (rate_target_hz: 100),",
6680        );
6681        read_configuration_str(ok, None).unwrap();
6682    }
6683
6684    #[test]
6685    fn test_anytime_background_exempt_from_fit_check() {
6686        let txt = anytime_config_txt(
6687            "max_refines: 64",
6688            ", background: true",
6689            "runtime: (rate_target_hz: 100),",
6690        );
6691        read_configuration_str(txt, None).unwrap();
6692    }
6693
6694    #[test]
6695    fn test_anytime_no_rate_target_accepts_refines_only_foreground() {
6696        let txt = anytime_config_txt("max_refines: 64", "", "");
6697        read_configuration_str(txt, None).unwrap();
6698    }
6699
6700    #[test]
6701    fn test_anytime_validated_per_mission_graph() {
6702        let txt = r#"(
6703            missions: [(id: "A"), (id: "B")],
6704            tasks: [
6705                (id: "src", type: "a"),
6706                (id: "any", type: "b", missions: ["B"], anytime: (quality_target: 0.9)),
6707                (id: "sink", type: "c"),
6708            ],
6709            cnx: [
6710                (src: "src", dst: "any", msg: "msg::A", missions: ["B"]),
6711                (src: "any", dst: "sink", msg: "msg::B", missions: ["B"]),
6712                (src: "src", dst: "sink", msg: "msg::A", missions: ["A"]),
6713            ],
6714        )"#;
6715        expect_anytime_error(txt.to_string(), "needs at least one hard bound");
6716    }
6717
6718    #[test]
6719    fn test_nc_connection_marks_source_output_without_creating_edge() {
6720        let txt = r#"(
6721            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6722            cnx: [
6723                (src: "src", dst: "sink", msg: "msg::A"),
6724                (src: "src", dst: "__nc__", msg: "msg::B"),
6725            ]
6726        )"#;
6727        let config = CuConfig::deserialize_ron(txt).unwrap();
6728        let graph = config.get_graph(None).unwrap();
6729        let src_id = graph.get_node_id_by_name("src").unwrap();
6730        let src_node = graph.get_node(src_id).unwrap();
6731
6732        assert_eq!(graph.edge_count(), 1);
6733        assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
6734    }
6735
6736    #[test]
6737    fn test_nc_connection_survives_serialize_roundtrip() {
6738        let txt = r#"(
6739            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6740            cnx: [
6741                (src: "src", dst: "sink", msg: "msg::A"),
6742                (src: "src", dst: "__nc__", msg: "msg::B"),
6743            ]
6744        )"#;
6745        let config = CuConfig::deserialize_ron(txt).unwrap();
6746        let serialized = config.serialize_ron().unwrap();
6747        let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6748        let graph = deserialized.get_graph(None).unwrap();
6749        let src_id = graph.get_node_id_by_name("src").unwrap();
6750        let src_node = graph.get_node(src_id).unwrap();
6751
6752        assert_eq!(graph.edge_count(), 1);
6753        assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
6754    }
6755
6756    #[test]
6757    fn test_nc_connection_preserves_original_connection_order() {
6758        let txt = r#"(
6759            tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6760            cnx: [
6761                (src: "src", dst: "__nc__", msg: "msg::A"),
6762                (src: "src", dst: "sink", msg: "msg::B"),
6763            ]
6764        )"#;
6765        let config = CuConfig::deserialize_ron(txt).unwrap();
6766        let graph = config.get_graph(None).unwrap();
6767        let src_id = graph.get_node_id_by_name("src").unwrap();
6768        let src_node = graph.get_node(src_id).unwrap();
6769        let edge_id = graph.get_src_edges(src_id).unwrap()[0];
6770        let edge = graph.edge(edge_id).unwrap();
6771
6772        assert_eq!(edge.msg, "msg::B");
6773        assert_eq!(edge.order, 1);
6774        assert_eq!(
6775            src_node
6776                .nc_outputs_with_order()
6777                .map(|(msg, order)| (msg.as_str(), order))
6778                .collect::<Vec<_>>(),
6779            vec![("msg::A", 0)]
6780        );
6781    }
6782
6783    #[cfg(feature = "std")]
6784    fn multi_config_test_dir(name: &str) -> PathBuf {
6785        let unique = std::time::SystemTime::now()
6786            .duration_since(std::time::UNIX_EPOCH)
6787            .expect("system time before unix epoch")
6788            .as_nanos();
6789        let dir = std::env::temp_dir().join(format!("cu29_multi_config_{name}_{unique}"));
6790        std::fs::create_dir_all(&dir).expect("create temp test dir");
6791        dir
6792    }
6793
6794    #[cfg(feature = "std")]
6795    fn write_multi_config_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
6796        let path = dir.join(name);
6797        std::fs::write(&path, contents).expect("write temp config file");
6798        path
6799    }
6800
6801    #[cfg(feature = "std")]
6802    fn alpha_subsystem_config() -> &'static str {
6803        r#"(
6804            tasks: [
6805                (id: "src", type: "demo::Src"),
6806                (id: "sink", type: "demo::Sink"),
6807            ],
6808            bridges: [
6809                (
6810                    id: "zenoh",
6811                    type: "demo::ZenohBridge",
6812                    channels: [
6813                        Tx(id: "ping"),
6814                        Rx(id: "pong"),
6815                    ],
6816                ),
6817            ],
6818            cnx: [
6819                (src: "src", dst: "zenoh/ping", msg: "demo::Ping"),
6820                (src: "zenoh/pong", dst: "sink", msg: "demo::Pong"),
6821            ],
6822        )"#
6823    }
6824
6825    #[cfg(feature = "std")]
6826    fn beta_subsystem_config() -> &'static str {
6827        r#"(
6828            tasks: [
6829                (id: "responder", type: "demo::Responder"),
6830            ],
6831            bridges: [
6832                (
6833                    id: "zenoh",
6834                    type: "demo::ZenohBridge",
6835                    channels: [
6836                        Rx(id: "ping"),
6837                        Tx(id: "pong"),
6838                    ],
6839                ),
6840            ],
6841            cnx: [
6842                (src: "zenoh/ping", dst: "responder", msg: "demo::Ping"),
6843                (src: "responder", dst: "zenoh/pong", msg: "demo::Pong"),
6844            ],
6845        )"#
6846    }
6847
6848    #[cfg(feature = "std")]
6849    fn instance_override_subsystem_config() -> &'static str {
6850        r#"(
6851            tasks: [
6852                (
6853                    id: "imu",
6854                    type: "demo::ImuTask",
6855                    config: {
6856                        "sample_hz": 200,
6857                    },
6858                ),
6859            ],
6860            resources: [
6861                (
6862                    id: "board",
6863                    provider: "demo::BoardBundle",
6864                    config: {
6865                        "bus": "i2c-1",
6866                    },
6867                ),
6868            ],
6869            bridges: [
6870                (
6871                    id: "radio",
6872                    type: "demo::RadioBridge",
6873                    config: {
6874                        "mtu": 32,
6875                    },
6876                    channels: [
6877                        Tx(id: "tx"),
6878                        Rx(id: "rx"),
6879                    ],
6880                ),
6881            ],
6882            cnx: [
6883                (src: "imu", dst: "radio/tx", msg: "demo::Packet"),
6884                (src: "radio/rx", dst: "imu", msg: "demo::Packet"),
6885            ],
6886        )"#
6887    }
6888
6889    #[cfg(feature = "std")]
6890    #[test]
6891    fn test_read_multi_configuration_assigns_stable_subsystem_codes() {
6892        let dir = multi_config_test_dir("stable_ids");
6893        write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
6894        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
6895        let network_path = write_multi_config_file(
6896            &dir,
6897            "network.ron",
6898            r#"(
6899                subsystems: [
6900                    (id: "beta", config: "beta.ron"),
6901                    (id: "alpha", config: "alpha.ron"),
6902                ],
6903                interconnects: [
6904                    (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Ping"),
6905                    (from: "beta/zenoh/pong", to: "alpha/zenoh/pong", msg: "demo::Pong"),
6906                ],
6907            )"#,
6908        );
6909
6910        let config =
6911            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
6912
6913        let alpha = config.subsystem("alpha").expect("alpha subsystem missing");
6914        let beta = config.subsystem("beta").expect("beta subsystem missing");
6915        assert_eq!(alpha.subsystem_code, 0);
6916        assert_eq!(beta.subsystem_code, 1);
6917        assert_eq!(config.interconnects.len(), 2);
6918        assert_eq!(config.interconnects[0].bridge_type, "demo::ZenohBridge");
6919    }
6920
6921    #[cfg(feature = "std")]
6922    #[test]
6923    fn test_multi_configuration_filters_interconnects_by_feature() {
6924        let dir = multi_config_test_dir("feature_interconnects");
6925        write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
6926        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
6927        let network_path = write_multi_config_file(
6928            &dir,
6929            "network.ron",
6930            r#"(
6931                subsystems: [
6932                    (id: "alpha", config: "alpha.ron"),
6933                    (id: "beta", config: "beta.ron"),
6934                ],
6935                interconnects: [
6936                    (
6937                        from: "alpha/zenoh/ping",
6938                        to: "beta/zenoh/ping",
6939                        msg: "demo::Ping",
6940                        when: Feature("networked"),
6941                    ),
6942                    (
6943                        from: "beta/zenoh/pong",
6944                        to: "alpha/zenoh/pong",
6945                        msg: "demo::Pong",
6946                        when: Feature("networked"),
6947                    ),
6948                ],
6949            )"#,
6950        );
6951
6952        let disconnected = read_multi_configuration_with_features(
6953            network_path.to_str().expect("network path utf8"),
6954            &[],
6955        )
6956        .unwrap();
6957        assert!(disconnected.interconnects.is_empty());
6958
6959        let networked = read_multi_configuration_with_features(
6960            network_path.to_str().expect("network path utf8"),
6961            &["networked"],
6962        )
6963        .unwrap();
6964        assert_eq!(networked.interconnects.len(), 2);
6965    }
6966
6967    #[cfg(feature = "std")]
6968    #[test]
6969    fn test_multi_configuration_uses_default_mission_contracts() {
6970        let dir = multi_config_test_dir("default_mission");
6971        write_multi_config_file(
6972            &dir,
6973            "alpha.ron",
6974            r#"(
6975                missions: [(id: "default"), (id: "diagnostics")],
6976                tasks: [
6977                    (id: "src", type: "demo::Src"),
6978                    (
6979                        id: "diagnostic",
6980                        type: "demo::Diagnostic",
6981                        missions: ["diagnostics"],
6982                    ),
6983                ],
6984                bridges: [
6985                    (
6986                        id: "zenoh",
6987                        type: "demo::ZenohBridge",
6988                        channels: [Tx(id: "ping")],
6989                    ),
6990                ],
6991                cnx: [
6992                    (src: "src", dst: "zenoh/ping", msg: "demo::Ping"),
6993                    (
6994                        src: "diagnostic",
6995                        dst: "__nc__",
6996                        msg: "demo::DiagnosticMessage",
6997                        missions: ["diagnostics"],
6998                    ),
6999                ],
7000            )"#,
7001        );
7002        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
7003        let network_path = write_multi_config_file(
7004            &dir,
7005            "network.ron",
7006            r#"(
7007                subsystems: [
7008                    (id: "alpha", config: "alpha.ron"),
7009                    (id: "beta", config: "beta.ron"),
7010                ],
7011                interconnects: [
7012                    (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Ping"),
7013                ],
7014            )"#,
7015        );
7016
7017        let config =
7018            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7019        assert_eq!(config.interconnects.len(), 1);
7020    }
7021
7022    #[cfg(feature = "std")]
7023    #[test]
7024    fn test_read_multi_configuration_rejects_wrong_direction() {
7025        let dir = multi_config_test_dir("wrong_direction");
7026        write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
7027        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
7028        let network_path = write_multi_config_file(
7029            &dir,
7030            "network.ron",
7031            r#"(
7032                subsystems: [
7033                    (id: "alpha", config: "alpha.ron"),
7034                    (id: "beta", config: "beta.ron"),
7035                ],
7036                interconnects: [
7037                    (from: "alpha/zenoh/pong", to: "beta/zenoh/ping", msg: "demo::Pong"),
7038                ],
7039            )"#,
7040        );
7041
7042        let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
7043            .expect_err("direction mismatch should fail");
7044
7045        assert!(
7046            err.to_string()
7047                .contains("must reference a Tx bridge channel"),
7048            "unexpected error: {err}"
7049        );
7050    }
7051
7052    #[cfg(feature = "std")]
7053    #[test]
7054    fn test_read_multi_configuration_rejects_declared_message_mismatch() {
7055        let dir = multi_config_test_dir("msg_mismatch");
7056        write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
7057        write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
7058        let network_path = write_multi_config_file(
7059            &dir,
7060            "network.ron",
7061            r#"(
7062                subsystems: [
7063                    (id: "alpha", config: "alpha.ron"),
7064                    (id: "beta", config: "beta.ron"),
7065                ],
7066                interconnects: [
7067                    (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Wrong"),
7068                ],
7069            )"#,
7070        );
7071
7072        let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
7073            .expect_err("message mismatch should fail");
7074
7075        assert!(
7076            err.to_string()
7077                .contains("declares message type 'demo::Wrong'"),
7078            "unexpected error: {err}"
7079        );
7080    }
7081
7082    #[cfg(feature = "std")]
7083    #[test]
7084    fn test_read_multi_configuration_resolves_instance_override_root() {
7085        let dir = multi_config_test_dir("instance_root");
7086        write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
7087        let network_path = write_multi_config_file(
7088            &dir,
7089            "multi_copper.ron",
7090            r#"(
7091                subsystems: [
7092                    (id: "robot", config: "robot.ron"),
7093                ],
7094                interconnects: [],
7095                instance_overrides_root: "instances",
7096            )"#,
7097        );
7098
7099        let config =
7100            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7101
7102        assert_eq!(
7103            config.instance_overrides_root.as_deref().map(Path::new),
7104            Some(dir.join("instances").as_path())
7105        );
7106    }
7107
7108    #[cfg(feature = "std")]
7109    #[test]
7110    fn test_resolve_subsystem_config_for_instance_applies_overrides() {
7111        let dir = multi_config_test_dir("instance_apply");
7112        write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
7113        let instances_dir = dir.join("instances").join("17");
7114        std::fs::create_dir_all(&instances_dir).expect("create instance dir");
7115        write_multi_config_file(
7116            &instances_dir,
7117            "robot.ron",
7118            r#"(
7119                set: [
7120                    (
7121                        path: "tasks/imu/config",
7122                        value: {
7123                            "gyro_bias": [0.1, -0.2, 0.3],
7124                        },
7125                    ),
7126                    (
7127                        path: "resources/board/config",
7128                        value: {
7129                            "bus": "robot17-imu",
7130                        },
7131                    ),
7132                    (
7133                        path: "bridges/radio/config",
7134                        value: {
7135                            "mtu": 64,
7136                        },
7137                    ),
7138                ],
7139            )"#,
7140        );
7141        let network_path = write_multi_config_file(
7142            &dir,
7143            "multi_copper.ron",
7144            r#"(
7145                subsystems: [
7146                    (id: "robot", config: "robot.ron"),
7147                ],
7148                interconnects: [],
7149                instance_overrides_root: "instances",
7150            )"#,
7151        );
7152
7153        let multi =
7154            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7155        let effective = multi
7156            .resolve_subsystem_config_for_instance("robot", 17)
7157            .expect("effective config");
7158
7159        let graph = effective.get_graph(None).expect("graph");
7160        let imu_id = graph.get_node_id_by_name("imu").expect("imu node");
7161        let imu = graph.get_node(imu_id).expect("imu weight");
7162        let imu_cfg = imu.get_instance_config().expect("imu config");
7163        assert_eq!(imu_cfg.get::<u64>("sample_hz").unwrap(), Some(200));
7164        let gyro_bias: Vec<f64> = imu_cfg
7165            .get_value("gyro_bias")
7166            .expect("gyro_bias deserialize")
7167            .expect("gyro_bias value");
7168        assert_eq!(gyro_bias, vec![0.1, -0.2, 0.3]);
7169
7170        let board = effective
7171            .resources
7172            .iter()
7173            .find(|resource| resource.id == "board")
7174            .expect("board resource");
7175        assert_eq!(
7176            board.config.as_ref().unwrap().get::<String>("bus").unwrap(),
7177            Some("robot17-imu".to_string())
7178        );
7179
7180        let radio = effective
7181            .bridges
7182            .iter()
7183            .find(|bridge| bridge.id == "radio")
7184            .expect("radio bridge");
7185        assert_eq!(
7186            radio.config.as_ref().unwrap().get::<u64>("mtu").unwrap(),
7187            Some(64)
7188        );
7189
7190        let radio_id = graph.get_node_id_by_name("radio").expect("radio node");
7191        let radio_node = graph.get_node(radio_id).expect("radio weight");
7192        assert_eq!(
7193            radio_node
7194                .get_instance_config()
7195                .unwrap()
7196                .get::<u64>("mtu")
7197                .unwrap(),
7198            Some(64)
7199        );
7200    }
7201
7202    #[cfg(feature = "std")]
7203    #[test]
7204    fn test_resolve_subsystem_config_for_instance_rejects_unknown_path() {
7205        let dir = multi_config_test_dir("instance_unknown");
7206        write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
7207        let instances_dir = dir.join("instances").join("17");
7208        std::fs::create_dir_all(&instances_dir).expect("create instance dir");
7209        write_multi_config_file(
7210            &instances_dir,
7211            "robot.ron",
7212            r#"(
7213                set: [
7214                    (
7215                        path: "tasks/missing/config",
7216                        value: {
7217                            "gyro_bias": [1.0, 2.0, 3.0],
7218                        },
7219                    ),
7220                ],
7221            )"#,
7222        );
7223        let network_path = write_multi_config_file(
7224            &dir,
7225            "multi_copper.ron",
7226            r#"(
7227                subsystems: [
7228                    (id: "robot", config: "robot.ron"),
7229                ],
7230                interconnects: [],
7231                instance_overrides_root: "instances",
7232            )"#,
7233        );
7234
7235        let multi =
7236            read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7237        let err = multi
7238            .resolve_subsystem_config_for_instance("robot", 17)
7239            .expect_err("unknown task override should fail");
7240
7241        assert!(
7242            err.to_string().contains("targets unknown task 'missing'"),
7243            "unexpected error: {err}"
7244        );
7245    }
7246
7247    #[test]
7248    fn test_thread_pools_parse_and_round_trip() {
7249        let txt = r#"(
7250            runtime: (
7251                rate_target_hz: 1000,
7252                thread_pools: [
7253                    ( id: "rt",         threads: 4, affinity: [2, 3, 4, 5], policy: Fifo(priority: 80) ),
7254                    ( id: "background", threads: 2, affinity: [0, 1] ),
7255                    ( id: "vision",     threads: 2, policy: Nice(10), on_error: Strict ),
7256                ],
7257            ),
7258            tasks: [ ( id: "t", type: "tasks::Foo" ) ],
7259        )"#;
7260        let config = CuConfig::deserialize_ron(txt).unwrap();
7261        let runtime = config.runtime.as_ref().expect("runtime config");
7262        assert_eq!(runtime.thread_pools.len(), 3);
7263
7264        let rt = &runtime.thread_pools[0];
7265        assert_eq!(rt.id, "rt");
7266        assert_eq!(rt.threads, 4);
7267        assert_eq!(rt.affinity.as_deref(), Some([2, 3, 4, 5].as_slice()));
7268        assert_eq!(rt.policy, SchedulingPolicy::Fifo { priority: 80 });
7269        assert_eq!(rt.on_error, OnError::Warn);
7270
7271        let bg = &runtime.thread_pools[1];
7272        assert_eq!(bg.id, "background");
7273        assert_eq!(bg.policy, SchedulingPolicy::Fair);
7274
7275        let vision = &runtime.thread_pools[2];
7276        assert_eq!(vision.policy, SchedulingPolicy::Nice(10));
7277        assert_eq!(vision.affinity, None);
7278        assert_eq!(vision.on_error, OnError::Strict);
7279
7280        // Round-trips through serialization.
7281        let serialized = config.serialize_ron().unwrap();
7282        let reparsed = CuConfig::deserialize_ron(&serialized).unwrap();
7283        assert_eq!(
7284            reparsed.runtime.as_ref().unwrap().thread_pools,
7285            runtime.thread_pools
7286        );
7287    }
7288
7289    #[test]
7290    fn test_background_flag_and_pool_forms() {
7291        let txt = r#"(
7292            tasks: [
7293                ( id: "a", type: "tasks::Foo", background: true ),
7294                ( id: "b", type: "tasks::Foo", background: (pool: "vision") ),
7295                ( id: "c", type: "tasks::Foo" ),
7296            ],
7297            cnx: [],
7298        )"#;
7299        let config = CuConfig::deserialize_ron(txt).unwrap();
7300        let graph = config.get_graph(None).unwrap();
7301
7302        let a = graph.get_node(0).unwrap();
7303        assert!(a.is_background());
7304        assert_eq!(a.background_pool(), DEFAULT_BACKGROUND_POOL);
7305
7306        let b = graph.get_node(1).unwrap();
7307        assert!(b.is_background());
7308        assert_eq!(b.background_pool(), "vision");
7309
7310        let c = graph.get_node(2).unwrap();
7311        assert!(!c.is_background());
7312        assert_eq!(c.background_pool(), DEFAULT_BACKGROUND_POOL);
7313    }
7314
7315    #[test]
7316    fn test_thread_pool_validation_rejects_bad_configs() {
7317        let cases = [
7318            (
7319                r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 0 ) ] ), tasks: [] )"#,
7320                "at least 1 thread",
7321            ),
7322            (
7323                r#"( runtime: ( thread_pools: [ ( id: "a", threads: 1 ), ( id: "a", threads: 1 ) ] ), tasks: [] )"#,
7324                "Duplicate thread pool id",
7325            ),
7326            (
7327                r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 1, policy: Fifo(priority: 200) ) ] ), tasks: [] )"#,
7328                "out of range",
7329            ),
7330            (
7331                r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 1, affinity: [] ) ] ), tasks: [] )"#,
7332                "empty affinity",
7333            ),
7334        ];
7335
7336        for (txt, expected) in cases {
7337            let err = CuConfig::deserialize_ron(txt)
7338                .expect_err("expected thread pool validation to fail");
7339            assert!(
7340                err.to_string().contains(expected),
7341                "error '{err}' did not contain '{expected}'"
7342            );
7343        }
7344    }
7345
7346    #[cfg(feature = "std")]
7347    #[test]
7348    fn test_default_background_pool_injected_for_background_tasks() {
7349        let txt = r#"(
7350            tasks: [
7351                ( id: "src", type: "tasks::Src" ),
7352                ( id: "bg",  type: "tasks::Task", background: true ),
7353            ],
7354            cnx: [
7355                ( src: "src", dst: "bg", msg: "i32" ),
7356                ( src: "bg", dst: "__nc__", msg: "i32" ),
7357            ],
7358        )"#;
7359        let config = read_configuration_str(txt.to_string(), None).unwrap();
7360        let pools = &config.runtime.as_ref().unwrap().thread_pools;
7361        let background: Vec<_> = pools
7362            .iter()
7363            .filter(|p| p.id == DEFAULT_BACKGROUND_POOL)
7364            .collect();
7365        assert_eq!(background.len(), 1);
7366        assert_eq!(background[0].threads, 2);
7367        // Thread pools are owned by the runtime, not the resource manager — no
7368        // synthetic "threadpool" bundle should be injected.
7369        assert!(!config.resources.iter().any(|b| b.id == "threadpool"));
7370    }
7371}