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