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