Skip to main content

cu29_rendercfg/
config.rs

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