1#[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
53pub type NodeId = u32;
56pub const DEFAULT_MISSION_ID: &str = "default";
57
58#[derive(Serialize, Deserialize, Debug, Clone, Default)]
62pub struct ComponentConfig(pub HashMap<String, Value>);
63
64#[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
82impl 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 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
228pub struct Value(RonValue);
229
230#[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#[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#[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 #[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
596macro_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
607impl_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
736impl 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#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
844#[repr(u8)]
845pub enum HandleContent {
846 #[serde(rename = "all", alias = "All")]
848 #[default]
849 All = 0,
850 #[serde(rename = "touched_only", alias = "TouchedOnly")]
852 TouchedOnly = 1,
853 #[serde(rename = "none", alias = "None")]
855 None = 2,
856}
857
858impl HandleContent {
859 #[allow(dead_code)] 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#[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 #[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 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#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
935pub enum Flavor {
936 #[default]
937 Task,
938 Bridge,
939}
940
941#[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
966pub const DEFAULT_BACKGROUND_POOL: &str = "background";
968
969#[allow(dead_code)] pub const RT_POOL: &str = "rt";
973
974#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
980#[serde(untagged)]
981pub enum BackgroundConfig {
982 Flag(bool),
984 Pool { pool: String },
986}
987
988#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
1002pub struct AnytimeConfig {
1003 #[serde(skip_serializing_if = "Option::is_none")]
1008 pub time_budget_ms: Option<f64>,
1009
1010 #[serde(skip_serializing_if = "Option::is_none")]
1014 pub max_age_ms: Option<f64>,
1015
1016 #[serde(skip_serializing_if = "Option::is_none")]
1020 pub quality_target: Option<f32>,
1021
1022 #[serde(skip_serializing_if = "Option::is_none")]
1026 pub quality_floor: Option<f32>,
1027
1028 #[serde(skip_serializing_if = "Option::is_none")]
1030 pub max_refines: Option<u32>,
1031
1032 #[serde(skip_serializing_if = "Option::is_none")]
1035 pub max_stall: Option<u32>,
1036}
1037
1038impl AnytimeConfig {
1039 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#[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#[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#[derive(Serialize, Deserialize, Debug, Clone)]
1129pub struct Node {
1130 id: String,
1132
1133 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1135 type_: Option<String>,
1136
1137 #[serde(skip_serializing_if = "Option::is_none")]
1140 kind: Option<TaskKind>,
1141
1142 #[serde(skip_serializing_if = "Option::is_none")]
1144 config: Option<ComponentConfig>,
1145
1146 #[serde(skip_serializing_if = "Option::is_none")]
1148 resources: Option<HashMap<String, String>>,
1149
1150 missions: Option<Vec<String>>,
1152
1153 #[serde(skip_serializing_if = "Option::is_none")]
1160 background: Option<BackgroundConfig>,
1161
1162 #[serde(skip_serializing_if = "Option::is_none")]
1167 anytime: Option<AnytimeConfig>,
1168
1169 #[serde(skip_serializing_if = "Option::is_none")]
1175 run_in_sim: Option<bool>,
1176
1177 #[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 #[serde(skip, default)]
1186 flavor: Flavor,
1187 #[serde(skip, default)]
1189 nc_outputs: Vec<String>,
1190 #[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 #[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 #[allow(dead_code)]
1283 pub fn anytime(&self) -> Option<&AnytimeConfig> {
1284 self.anytime.as_ref()
1285 }
1286
1287 #[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 #[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 #[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 #[allow(dead_code)]
1367 pub fn get_flavor(&self) -> Flavor {
1368 self.flavor
1369 }
1370
1371 #[allow(dead_code)]
1373 pub fn set_flavor(&mut self, flavor: Flavor) {
1374 self.flavor = flavor;
1375 }
1376
1377 #[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 #[allow(dead_code)]
1396 pub fn nc_outputs(&self) -> &[String] {
1397 &self.nc_outputs
1398 }
1399
1400 #[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#[derive(Serialize, Deserialize, Debug, Clone)]
1411pub enum BridgeChannelConfigRepresentation {
1412 Rx {
1414 id: String,
1415 #[serde(skip_serializing_if = "Option::is_none")]
1417 route: Option<String>,
1418 #[serde(skip_serializing_if = "Option::is_none")]
1420 config: Option<ComponentConfig>,
1421 },
1422 Tx {
1424 id: String,
1425 #[serde(skip_serializing_if = "Option::is_none")]
1427 route: Option<String>,
1428 #[serde(skip_serializing_if = "Option::is_none")]
1430 config: Option<ComponentConfig>,
1431 },
1432}
1433
1434impl BridgeChannelConfigRepresentation {
1435 #[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 #[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#[derive(Serialize, Deserialize, Debug, Clone)]
1491pub struct ResourceBundleConfig {
1492 #[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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
1506#[serde(deny_unknown_fields)]
1507pub struct LogStreamingConfig {
1508 pub destinations: Vec<LogStreamDestinationConfig>,
1509}
1510
1511#[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 pub recovery_interval: u32,
1523 pub max_record_bytes: u64,
1524}
1525
1526#[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#[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#[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#[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#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
1628 pub run_in_sim: Option<bool>,
1629 pub channels: Vec<BridgeChannelConfigRepresentation>,
1631}
1632
1633impl BridgeConfig {
1634 #[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#[derive(Serialize, Deserialize, Debug, Clone)]
1664struct SerializedCnx {
1665 src: String,
1666 dst: String,
1667 msg: String,
1668 missions: Option<Vec<String>>,
1669}
1670
1671pub const NC_ENDPOINT: &str = "__nc__";
1673
1674#[derive(Debug, Clone)]
1676pub struct Cnx {
1677 pub src: String,
1679 pub dst: String,
1681 pub msg: String,
1683 pub missions: Option<Vec<String>>,
1685 pub src_channel: Option<String>,
1687 pub dst_channel: Option<String>,
1689 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#[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 #[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 #[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 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 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 #[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 #[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#[derive(Debug, Clone)]
2377pub struct CuConfig {
2378 #[doc(hidden)]
2380 pub constants: Vec<ConstantConfig>,
2381 pub monitors: Vec<MonitorConfig>,
2383 pub logging: Option<LoggingConfig>,
2385 pub runtime: Option<RuntimeConfig>,
2387 pub resources: Vec<ResourceBundleConfig>,
2389 pub log_streaming: Option<LogStreamingConfig>,
2391 pub bridges: Vec<BridgeConfig>,
2393 pub graphs: ConfigGraphs,
2395}
2396
2397fn 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 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 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 #[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 #[allow(dead_code)]
2628 pub fn planner_config(&self) -> Option<&PlannerConfig> {
2629 self.runtime.as_ref()?.planner.as_ref()
2630 }
2631
2632 #[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 #[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 #[serde(default = "default_as_true", skip_serializing_if = "Clone::clone")]
2714 pub enable_task_logging: bool,
2715
2716 #[serde(default = "default_as_true", skip_serializing_if = "Clone::clone")]
2722 pub enable_keyframe_logging: bool,
2723
2724 #[serde(skip_serializing_if = "Option::is_none")]
2729 pub copperlist_count: Option<usize>,
2730
2731 #[serde(skip_serializing_if = "Option::is_none")]
2733 pub slab_size_mib: Option<u64>,
2734
2735 #[serde(skip_serializing_if = "Option::is_none")]
2737 pub section_size_mib: Option<u64>,
2738
2739 #[serde(
2741 default = "default_keyframe_interval",
2742 skip_serializing_if = "Option::is_none"
2743 )]
2744 pub keyframe_interval: Option<u32>,
2745
2746 #[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 #[serde(skip_serializing_if = "Option::is_none")]
2782 pub rate_target_hz: Option<u64>,
2783
2784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2791 pub thread_pools: Vec<ThreadPoolConfig>,
2792
2793 #[serde(default, skip_serializing_if = "Option::is_none")]
2800 pub planner: Option<PlannerConfig>,
2801}
2802
2803#[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 #[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 #[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
2840pub const MIN_RT_PRIORITY: u8 = 1;
2842pub const MAX_RT_PRIORITY: u8 = 99;
2844pub const MIN_NICE: i8 = -20;
2846pub const MAX_NICE: i8 = 19;
2848
2849#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
2855pub enum SchedulingPolicy {
2856 #[default]
2861 Fair,
2862 Nice(i8),
2869 Fifo { priority: u8 },
2877 RoundRobin { priority: u8 },
2884}
2885
2886#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
2889pub enum OnError {
2890 #[default]
2893 Warn,
2894 Strict,
2897}
2898
2899#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2901pub struct ThreadPoolConfig {
2902 pub id: String,
2905 pub threads: usize,
2907 #[serde(default, skip_serializing_if = "Option::is_none")]
2911 pub affinity: Option<Vec<usize>>,
2912 #[serde(default)]
2914 pub policy: SchedulingPolicy,
2915 #[serde(default)]
2917 pub on_error: OnError,
2918}
2919
2920fn 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
2983pub const MAX_RATE_TARGET_HZ: u64 = 1_000_000_000;
2988
2989#[derive(Serialize, Deserialize, Debug, Clone)]
2991pub struct MissionsConfig {
2992 pub id: String,
2993}
2994
2995#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
3234fn 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 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 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 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 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 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 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 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 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 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 let mut tasks = Vec::new();
3547 let mut ordered_cnx: Vec<(usize, SerializedCnx)> = Vec::new();
3548
3549 for (mission_id, graph) in graphs {
3550 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 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
3665impl 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 #[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 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 pub fn validate_logging_config(&self) -> CuResult<()> {
3995 if let Some(logging) = &self.logging {
3996 return logging.validate();
3997 }
3998 Ok(())
3999 }
4000
4001 pub fn validate_runtime_config(&self) -> CuResult<()> {
4003 if let Some(runtime) = &self.runtime {
4004 return runtime.validate();
4005 }
4006 Ok(())
4007 }
4008
4009 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
4043fn 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 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 if node.is_background() {
4084 continue;
4085 }
4086
4087 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 BridgeChannelConfigRepresentation::Rx { id, .. } => outputs.push(id.clone()),
4351 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\">—</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 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 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)] fn 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#[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 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#[cfg(feature = "std")]
5413pub fn read_configuration(config_filename: &str) -> CuResult<CuConfig> {
5414 read_configuration_with_features(config_filename, &[])
5415}
5416
5417#[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
5438fn 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
5455fn 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 let representation = parse_config_string(config_content)?;
5482
5483 #[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#[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#[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
5534pub 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 config_representation_to_config(representation)
5545}
5546
5547#[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#[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#[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#[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#[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 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 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 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 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 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 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 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 let noop = config.find_task_node(None, "noop").unwrap();
6134 assert_eq!(noop.handle_content_policy(), HandleContent::All);
6135
6136 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 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 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 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 #[test]
6751 fn test_deserialization_edge_id_assignment() {
6752 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 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 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 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 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 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 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 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 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 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 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 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 expect_anytime_error(
7233 anytime_config_txt("max_age_ms: 100.0, quality_target: 0.9", "", ""),
7234 "needs anytime.max_refines",
7235 );
7236 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 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 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 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 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 assert!(!config.resources.iter().any(|b| b.id == "threadpool"));
8074 }
8075}