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