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