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