Skip to main content

cu29_runtime/
config.rs

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