rcal 2.1.0

OMS Critical Abstraction Layer (CAL) implementation for Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
#![allow(dead_code)]
use crate::uci::base::UUID;
use crate::uci::{CalError, CalImplementationErrorKind, CalResult};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::fs;

#[derive(Deserialize, Serialize, Default, Debug, Clone)]
#[serde(default)]
pub struct CalConfig {
    pub system: System,
    #[serde(rename = "uuid-factory")]
    pub uuidfactory: UUIDFactory,
    pub transport: Vec<Transport>,
    pub service: Vec<Service>,
    /// Named externalizer configurations.
    ///
    /// Use the short built-in names `"xml"` or (with feature `compression`) `"compression"`
    /// without a section for defaults.  Add a `[externalizer.<name>]` section to override
    /// options or to define a named chain.
    pub externalizer: HashMap<String, ExternalizerConfig>,
}

impl CalConfig {
    pub fn get_service(&self, name: &str) -> Option<&Service> {
        self.service.iter().find(|item| item.id == name)
    }

    pub fn get_transport(&self, name: &str) -> Option<&Transport> {
        self.transport.iter().find(|item| item.id == name)
    }

    pub fn get_transport_for_service(&self, name: &str) -> Option<&Transport> {
        let service_conf = self.get_service(name)?;
        let transport_name = service_conf
            .transport
            .as_ref()
            .or(self.system.default_transport.as_ref())?;
        self.get_transport(transport_name)
    }
}

impl fmt::Display for CalConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", toml::to_string(self).unwrap())
    }
}

/// Log level for a sink or global default.
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
    Trace,
    Debug,
    #[default]
    Info,
    Warn,
    Error,
}

impl From<LogLevel> for slog::Level {
    fn from(l: LogLevel) -> Self {
        match l {
            LogLevel::Trace => slog::Level::Trace,
            LogLevel::Debug => slog::Level::Debug,
            LogLevel::Info => slog::Level::Info,
            LogLevel::Warn => slog::Level::Warning,
            LogLevel::Error => slog::Level::Error,
        }
    }
}

/// Log output format.
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum LogFormat {
    /// Colored terminal output.
    #[default]
    Pretty,
    /// Plaintext, no ANSI colors.
    Basic,
    /// key=value pairs (logfmt).
    Logfmt,
    /// JSON objects.
    Json,
}

/// Log sink destination.
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase", tag = "type")]
pub enum SinkType {
    #[default]
    Stdout,
    Stderr,
    File {
        path: String,
    },
}

/// Configuration for one log sink.
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(default)]
pub struct SinkConfig {
    #[serde(flatten)]
    pub sink_type: SinkType,
    pub level: LogLevel,
    pub format: LogFormat,
    /// Subsystem names to include; empty = accept all.
    pub subsystems: Vec<String>,
}

impl Default for SinkConfig {
    fn default() -> Self {
        Self {
            sink_type: SinkType::Stdout,
            level: LogLevel::Warn,
            format: LogFormat::Pretty,
            subsystems: Vec::new(),
        }
    }
}

/// Logging configuration stored under `[system.logging]`.
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
#[serde(default)]
pub struct LoggingConfig {
    pub default_level: LogLevel,
    pub sink: Vec<SinkConfig>,
}

#[derive(Deserialize, Serialize, Default, Debug, Clone)]
#[serde(default)]
pub struct System {
    pub id: String,
    pub label: Option<String>,
    pub uuid: UUID,
    pub default_transport: Option<String>,
    pub logging: LoggingConfig,
    /// Optional MissionID UUID populated in message headers.
    pub mission_id: Option<UUID>,
    /// Message mode populated in message headers (default: "LIVE").
    pub mode: Option<String>,
    /// Classification populated in message security info (default: "U").
    pub classification: Option<String>,
    /// OwnerProducer values populated in message security info (default: ["USA"]).
    pub owner_producer: Vec<String>,
}

#[derive(Deserialize, Serialize, Default, Debug, Clone)]
pub enum UUIDFactoryType {
    #[default]
    Random,
    TimeBased,
}

#[derive(Deserialize, Serialize, Default, Debug, Clone)]
#[serde(default)]
pub struct UUIDFactory {
    #[serde(rename = "type")]
    /// The factory type
    pub type_: UUIDFactoryType,

    /// Namespace for "namespace' generators."
    pub namespace: Option<UUID>,

    /// MAC address to timebased. If not specified, the
    /// default interface's mac address will be used.
    pub node: Option<mac_address::MacAddress>,
}

/// Serialization format used internally by [`XmlExternalizer`][crate::externalizer::XmlExternalizer].
///
/// This type controls XML whitespace only.  Transport-level externalizer selection
/// is configured via [`Transport::externalizer`] and [`CalConfig::externalizer`].
#[derive(Deserialize, Serialize, Default, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SerializationFormat {
    /// Whitespace-compressed XML (default).
    #[default]
    Xml,
    /// Indented, human-readable XML.
    PrettyXml,
}

/// Configuration for a named externalizer.
///
/// Reference the name in [`Transport::externalizer`].
/// Built-in names (`"xml"`, and with feature `compression`: `"compression"`) work
/// without a section entry; add a section only to override defaults or build a chain.
///
/// # Examples (TOML)
/// ```toml
/// [externalizer.pretty]
/// type = "xml"
/// pretty = true
///
/// [externalizer.gzip_xml]
/// type = "compression"
/// inner = "xml"          # which externalizer to wrap (default: "xml")
/// compression_type = "gzip"   # gzip | deflate | zlib  (default: "gzip")
/// [externalizer.gzip_xml.options]
/// level = 6              # 0–9, default per algorithm
/// ```
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum ExternalizerConfig {
    /// XML serialization.
    Xml {
        /// Use indented, human-readable XML (default: `false`).
        #[serde(default)]
        pretty: bool,
    },
    /// Byte-level compression chain wrapping an inner externalizer.
    ///
    /// Requires the `compression` feature.
    #[cfg(feature = "compression")]
    Compression {
        /// Name of the inner externalizer to wrap (default: `"xml"`).
        #[serde(default = "default_inner_externalizer")]
        inner: String,
        /// Compression algorithm (default: `"gzip"`).
        #[serde(default)]
        compression_type: CompressionType,
        /// Algorithm-specific options (e.g. `level = 6`).
        #[serde(default)]
        options: HashMap<String, toml::Value>,
    },
}

#[cfg(feature = "compression")]
impl CompressionType {
    /// Returns the string identifier for this compression type.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Gzip => "gzip",
            Self::Deflate => "deflate",
            Self::Zlib => "zlib",
        }
    }
}

#[cfg(feature = "compression")]
impl std::str::FromStr for CompressionType {
    type Err = ();
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "gzip" => Ok(Self::Gzip),
            "deflate" => Ok(Self::Deflate),
            "zlib" => Ok(Self::Zlib),
            _ => Err(()),
        }
    }
}

#[cfg(feature = "compression")]
fn default_inner_externalizer() -> String {
    "xml".to_string()
}

/// Compression algorithm for [`ExternalizerConfig::Compression`].
///
/// All variants are enabled by `flate2`'s default features.
#[cfg(feature = "compression")]
#[derive(Deserialize, Serialize, Default, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CompressionType {
    #[default]
    Gzip,
    Deflate,
    Zlib,
}

#[derive(Deserialize, Serialize, Default, Debug, Clone)]
#[serde(default)]
pub struct Transport {
    pub id: String,
    #[serde(rename = "type")]
    pub type_: String,
    pub uri: String,
    /// Name of the externalizer to use for this transport (default: `"xml"`).
    ///
    /// Use a built-in name (`"xml"`, `"compression"`) or reference a
    /// `[externalizer.<name>]` section in `CalConfig`.
    pub externalizer: Option<String>,
}

/// A name-to-UUID mapping used for components and capabilities (CAL-005203).
#[derive(Deserialize, Serialize, Default, Debug, Clone)]
pub struct NamedUuid {
    /// Logical name for the component or capability.
    pub name: String,
    /// UUID assigned to this component or capability.
    pub uuid: UUID,
}

#[derive(Deserialize, Serialize, Default, Debug, Clone)]
#[serde(default)]
pub struct Service {
    pub id: String,
    pub transport: Option<String>,
    pub topic: Vec<Topic>,
    /// Optional service UUID used to populate the ServiceID field in message headers.
    pub uuid: Option<UUID>,
    /// Optional subsystem UUID this service belongs to (CAL-005203, CERT CXX-011170).
    pub subsystem_uuid: Option<UUID>,
    /// Component UUIDs accessible via this service (CAL-005203, CERT CXX-011171).
    pub components: Vec<NamedUuid>,
    /// Capability UUIDs accessible via this service (CAL-005203, CERT CXX-011172).
    pub capabilities: Vec<NamedUuid>,
    /// Duration string for periodic status message interval (e.g. "1s", "500ms").
    pub status_delay: Option<String>,
    /// When true, the service registers a ServiceStatusDataRequest reader and responds automatically.
    pub service_status_data_request_enable: bool,
}

impl Service {
    /// Returns the UUID of the named component, or `None` if not configured.
    pub fn get_component_uuid(&self, name: &str) -> Option<UUID> {
        self.components
            .iter()
            .find(|c| c.name == name)
            .map(|c| c.uuid)
    }

    /// Returns the UUID of the named capability, or `None` if not configured.
    pub fn get_capability_uuid(&self, name: &str) -> Option<UUID> {
        self.capabilities
            .iter()
            .find(|c| c.name == name)
            .map(|c| c.uuid)
    }
}

/// Reliability policy in TOML config — mirrors `cal::Reliability` but serde-friendly.
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ReliabilityConfig {
    #[default]
    BestEffort,
    Reliable,
}

/// Per-topic Quality of Service settings (CERT CAL-005210).
///
/// All fields are optional; absent fields leave the corresponding `TopicQos` field at its
/// default value.
#[derive(Deserialize, Serialize, Default, Debug, Clone)]
#[serde(default)]
pub struct TopicQosConfig {
    /// Reliability policy (`best_effort` or `reliable`).
    pub reliability: Option<ReliabilityConfig>,
    /// Minimum inter-message gap in milliseconds (CAL-005431).
    pub time_based_filter_ms: Option<u64>,
    /// Maximum message age in milliseconds before eviction from the reader buffer (CAL-005437).
    pub expiration_ms: Option<u64>,
    /// Maximum writer-side buffered messages (CAL-005444, CAL-005445).
    pub writer_buffer: Option<usize>,
    /// Maximum reader-side buffered messages (CAL-015746, CAL-016079).
    pub reader_buffer: Option<usize>,
}

#[derive(Deserialize, Serialize, Default, Debug, Clone)]
#[serde(default)]
pub struct Topic {
    pub id: String,
    #[serde(rename = "type")]
    pub type_: Option<String>,
    pub topic: Option<String>,
    /// Optional per-topic QoS defaults (CAL-005210).
    pub qos: Option<TopicQosConfig>,
}

pub fn parse_config_from_file(filename: &str) -> CalResult<CalConfig> {
    let config_str = fs::read_to_string(filename).map_err(|err| {
        CalError::with_impl_source(
            CalImplementationErrorKind::ConfigError,
            format!("Can't read config file: {}", filename),
            err,
        )
    })?;
    parse_config(config_str.as_str())
}

pub fn parse_config(config_str: &str) -> CalResult<CalConfig> {
    let config = toml::from_str(config_str).map_err(|err| {
        CalError::with_impl_source(
            CalImplementationErrorKind::ConfigError,
            "Can't parse configuration",
            err,
        )
    })?;
    Ok(config)
}

#[cfg(test)]
use std::env;
#[cfg(test)]
use std::path::PathBuf;
/// A test utility that returns a full path to a configutation file [`filename`]
/// Only usable in unit tests. This will panic! if there's a problem converting the
/// path into a string.
#[cfg(test)]
pub fn get_test_config_path(filename: &str) -> String {
    let mut file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    file_path.push("tests");
    file_path.push("fixtures");
    file_path.push(filename);
    file_path.to_string_lossy().into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_file() {
        parse_config_from_file(get_test_config_path("calconfig_sample.toml").as_str()).unwrap();
    }

    #[test]
    fn test_uuid_factory() {
        parse_config("[system]\nid=\"foo\"\n[uuid-factory]\ntype=\"Random\"\n").unwrap();
        parse_config("[system]\nid=\"foo\"\n[uuid-factory]\ntype=\"TimeBased\"\n").unwrap();
        parse_config("[system]\nid=\"foo\"\n[uuid-factory]\ntype=\"TimeBased\"\nnode=\"00:11:22:33:44:55\"\n").unwrap();
    }

    #[test]
    fn test_topic_qos_config_parses() {
        let toml = r#"
[system]
id = "test"

[[service]]
id = "Svc"

[[service.topic]]
id = "SystemStatus"

[service.topic.qos]
reliability = "best_effort"
time_based_filter_ms = 100
expiration_ms = 5000
reader_buffer = 10
writer_buffer = 5
"#;
        let cfg = parse_config(toml).unwrap();
        let svc = cfg.get_service("Svc").unwrap();
        let topic = svc.topic.iter().find(|t| t.id == "SystemStatus").unwrap();
        let qos = topic.qos.as_ref().unwrap();
        assert_eq!(qos.reliability, Some(ReliabilityConfig::BestEffort));
        assert_eq!(qos.time_based_filter_ms, Some(100));
        assert_eq!(qos.expiration_ms, Some(5000));
        assert_eq!(qos.reader_buffer, Some(10));
        assert_eq!(qos.writer_buffer, Some(5));
    }

    #[test]
    fn test_topic_without_qos_parses() {
        let toml = "[system]\nid=\"foo\"\n[[service]]\nid=\"Svc\"\n[[service.topic]]\nid=\"T\"\n";
        let cfg = parse_config(toml).unwrap();
        let topic = &cfg.get_service("Svc").unwrap().topic[0];
        assert!(topic.qos.is_none());
    }
}