epics_libcom_rs/runtime/env.rs
1//! EPICS environment parameters — C's `envSubr.c`.
2//!
3//! The **only** declaration of an EPICS environment default in this workspace is
4//! the generated [`super::env_table`], which `env-codegen` derives from the
5//! vendored `configure/CONFIG_ENV` + `CONFIG_SITE_ENV` the same way C's
6//! `bldEnvData.pl` derives `envData.c`. Everything else resolves *through* it.
7//!
8//! That is enforced by shape, not by convention: [`EnvParam`] has no public
9//! constructor (only the generated table, inside this crate, can mint one), and
10//! none of its accessors takes a `default` argument. A caller that could supply
11//! a default would be a second declaration source, so no caller can.
12//!
13//! [`get`] remains for the handful of variables that are NOT EPICS Base
14//! `ENV_PARAM`s — C reads those with a plain `getenv` and its caller owns the
15//! fallback, so this port does too.
16
17use super::stdlib::epics_parse_double;
18
19/// One row of C's `ENV_PARAM` table (`envDefs.h:41-45`): a parameter name and
20/// the default compiled in from `configure/CONFIG_ENV`.
21///
22/// Values of this type exist only in the generated [`super::env_table`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct EnvParam {
25 name: &'static str,
26 default: &'static str,
27}
28
29impl EnvParam {
30 /// Crate-private: the generated table is the only declaration source.
31 pub(crate) const fn new(name: &'static str, default: &'static str) -> Self {
32 Self { name, default }
33 }
34
35 pub const fn name(&self) -> &'static str {
36 self.name
37 }
38
39 /// The compiled-in default string, exactly as it appears in C's
40 /// `envData.c`.
41 pub const fn default_str(&self) -> &'static str {
42 self.default
43 }
44
45 /// C `envGetConfigParamPtr` (`envSubr.c:81-100`): the environment string,
46 /// falling back to the compiled default — with an **empty** result mapped to
47 /// `None`.
48 ///
49 /// The `None`/`Some` answer is a load-bearing *presence* test, not just an
50 /// absence check: `caservertask.c:491-508` uses it to pick *which* of two
51 /// parameters to resolve, so `FOO=` must read as "not configured".
52 pub fn get(&self) -> Option<String> {
53 let v = std::env::var(self.name).unwrap_or_else(|_| self.default.to_string());
54 if v.is_empty() { None } else { Some(v) }
55 }
56
57 /// C `envGetLongConfigParam` (`envSubr.c:303-320`) — `sscanf("%ld")` over the
58 /// resolved value, out of the first 127 bytes (all C's `char text[128]`
59 /// keeps).
60 ///
61 /// `None` is C's non-zero status. A set-but-unparseable value also prints
62 /// C's stderr line first; an *unresolvable* one (empty value and empty
63 /// default) prints nothing, because `envGetConfigParam` returned NULL before
64 /// `sscanf` ever ran.
65 pub fn long(&self) -> Option<i64> {
66 let text = self.get()?;
67 let text = truncate_to_c_buffer(&text);
68 match sscanf_long(text) {
69 Some(v) => Some(v),
70 None => {
71 eprintln!("Unable to find an integer in {}={text}", self.name);
72 None
73 }
74 }
75 }
76
77 /// The C caller idiom for a long-valued parameter:
78 ///
79 /// ```c
80 /// long i = 50; /* == the table default */
81 /// envGetLongConfigParam(&IOCSH_HISTSIZE, &i);
82 /// ```
83 ///
84 /// i.e. a rejected value leaves the compiled default standing. The `50` in
85 /// C's source is a hand-copy of the table; here it comes from the table.
86 pub fn long_or_default(&self) -> i64 {
87 self.long()
88 .or_else(|| sscanf_long(self.default))
89 .unwrap_or(0)
90 }
91
92 /// C `envGetDoubleConfigParam` (`envSubr.c:191-211`) — `epicsScanDouble` over
93 /// the resolved value.
94 ///
95 /// An unset parameter resolves to its compiled default and parses, so
96 /// `Ok(30.0)` for `EPICS_CA_CONN_TMO` on a clean shell. `Err` is C's non-zero
97 /// status: the value (or the default) is empty, or it did not scan — and in
98 /// the latter case C's stderr line has already been printed. C's callers then
99 /// print their own named diagnostic on top of it.
100 pub fn double(&self) -> Result<f64, EnvDoubleError> {
101 let raw = self.get().ok_or(EnvDoubleError::Unresolvable)?;
102 epics_parse_double(&raw).map_err(|_| {
103 eprintln!("Unable to find a real number in {}={raw}", self.name);
104 EnvDoubleError::Invalid
105 })
106 }
107
108 /// C `envGetBoolConfigParam` (`envSubr.c:324-333`):
109 /// `*pBool = epicsStrCaseCmp(text, "yes") == 0`, i.e. true **iff** the
110 /// resolved value is the word `yes`, any case. `"1"`, `"true"`, `"on"`,
111 /// `"yes "` are all false.
112 ///
113 /// `None` is C's -1 status — nothing to compare, so C leaves the caller's
114 /// variable untouched. The caller's `unwrap_or(..)` is then C's own
115 /// initializer, not a second copy of the table default.
116 pub fn bool(&self) -> Option<bool> {
117 Some(self.get()?.eq_ignore_ascii_case("yes"))
118 }
119
120 /// C `envGetInetPortConfigParam` (`envSubr.c:397-424`).
121 ///
122 /// `fallback` is the *compiled* port the C caller passes as `defaultPort`
123 /// — for the `EPICS_CAS_*` overrides that is a **different** parameter's
124 /// default (`caservertask.c:491-508` passes `CA_SERVER_PORT`), which is why
125 /// it is an argument rather than `self.default`. Derive it with
126 /// [`EnvParam::default_port`] so it still comes from the table.
127 ///
128 /// - Not resolvable, or no integer found: the "integer fetch failed"
129 /// diagnostics, then `fallback`.
130 /// - `<= IPPORT_USERRESERVED` (5000) or `> USHRT_MAX`: the "out of range"
131 /// diagnostics, then `fallback`.
132 ///
133 /// The diagnostics are byte-identical to compiled C, once per resolution —
134 /// there is no dedup in C:
135 ///
136 /// ```text
137 /// Unable to find an integer in EPICS_CA_SERVER_PORT=abc
138 /// EPICS Environment "EPICS_CA_SERVER_PORT" integer fetch failed
139 /// setting "EPICS_CA_SERVER_PORT" = 5064
140 /// ```
141 /// ```text
142 /// EPICS Environment "EPICS_CA_SERVER_PORT" out of range
143 /// Setting "EPICS_CA_SERVER_PORT" = 5064
144 /// ```
145 pub fn inet_port(&self, fallback: u16) -> u16 {
146 let mut port = match self.long() {
147 Some(v) => v,
148 None => {
149 eprintln!("EPICS Environment \"{}\" integer fetch failed", self.name);
150 eprintln!("setting \"{}\" = {fallback}", self.name);
151 i64::from(fallback)
152 }
153 };
154
155 if port <= i64::from(IPPORT_USERRESERVED) || port > i64::from(u16::MAX) {
156 eprintln!("EPICS Environment \"{}\" out of range", self.name);
157 port = i64::from(fallback);
158 eprintln!("Setting \"{}\" = {fallback}", self.name);
159 }
160
161 // Range-checked above, so the cast cannot truncate.
162 port as u16
163 }
164
165 /// The compiled default read as a port, at compile time.
166 ///
167 /// This is how a `pub const CA_SERVER_PORT: u16` is derived from the table
168 /// instead of hand-written next to it: a default that is not a valid port
169 /// fails const-evaluation, so the constant cannot drift from `CONFIG_ENV`.
170 pub const fn default_port(&self) -> u16 {
171 let bytes = self.default.as_bytes();
172 assert!(!bytes.is_empty(), "parameter has no compiled default port");
173 let mut i = 0;
174 let mut value: u32 = 0;
175 while i < bytes.len() {
176 let d = bytes[i];
177 assert!(d.is_ascii_digit(), "compiled default is not a port number");
178 value = value * 10 + (d - b'0') as u32;
179 assert!(
180 value <= u16::MAX as u32,
181 "compiled default exceeds USHRT_MAX"
182 );
183 i += 1;
184 }
185 value as u16
186 }
187
188 /// C `envPrtConfigParam` (`envSubr.c:353-364`) — one line of
189 /// `epicsPrtEnvParams`.
190 pub fn describe(&self) -> String {
191 match self.get() {
192 Some(v) => format!("{}: {v}", self.name),
193 None => format!("{} is undefined", self.name),
194 }
195 }
196}
197
198/// Why [`EnvParam::double`] did not yield a value — C's non-zero status.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum EnvDoubleError {
201 /// The value and the compiled default are both empty, so
202 /// `envGetConfigParam` returned NULL before `epicsScanDouble` ran. No
203 /// diagnostic.
204 Unresolvable,
205 /// `epicsScanDouble` rejected the value. C's stderr line is already printed.
206 Invalid,
207}
208
209/// A plain `getenv`, no default and no `ENV_PARAM` behind it.
210///
211/// Only for variables that are NOT in C's table (`EPICS_CA_USE_SHELL_VARS`, the
212/// pvxs `EPICS_PVA*` set, the port's own `EPICS_RS_*`); C reads those with
213/// `getenv` too and its caller owns the fallback. Every EPICS Base `ENV_PARAM`
214/// must go through [`super::env_table`].
215pub fn get(key: &str) -> Option<String> {
216 std::env::var(key).ok()
217}
218
219/// Lowest port number a configurable EPICS port may take.
220///
221/// C parity: `IPPORT_USERRESERVED` is `#define`d to `5000` on every
222/// supported platform (`osi/os/*/osdSock.h`). `envGetInetPortConfigParam`
223/// rejects any port `<= IPPORT_USERRESERVED` or `> USHRT_MAX`.
224pub const IPPORT_USERRESERVED: u32 = 5000;
225
226/// C `envGetConfigParam` copies into `char text[128]` with `strncpy`, so only
227/// the first 127 bytes ever reach `sscanf`.
228fn truncate_to_c_buffer(raw: &str) -> &str {
229 let mut end = raw.len().min(127);
230 while !raw.is_char_boundary(end) {
231 end -= 1;
232 }
233 &raw[..end]
234}
235
236/// Parse the leading integer out of a string the way C `sscanf("%ld")`
237/// does: skip leading whitespace, accept an optional `+`/`-` sign, read
238/// the run of decimal digits, and ignore any trailing garbage suffix.
239///
240/// Returns `None` when no digit is found (C `sscanf` returns 0 matches).
241fn sscanf_long(text: &str) -> Option<i64> {
242 let bytes = text.as_bytes();
243 let mut i = 0;
244 // Skip leading whitespace (C `isspace`: space, \t, \n, \r, \v, \f).
245 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
246 i += 1;
247 }
248 let mut neg = false;
249 if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
250 neg = bytes[i] == b'-';
251 i += 1;
252 }
253 let start = i;
254 let mut value: i64 = 0;
255 while i < bytes.len() && bytes[i].is_ascii_digit() {
256 value = value
257 .saturating_mul(10)
258 .saturating_add((bytes[i] - b'0') as i64);
259 i += 1;
260 }
261 if i == start {
262 // No digits — sscanf matched nothing.
263 return None;
264 }
265 Some(if neg { -value } else { value })
266}
267
268/// C `epicsPrtEnvParams` (`envSubr.c:383-392`) — every parameter EPICS Base
269/// knows, with its effective value, in `env_param_list[]` order.
270pub fn prt_env_params() -> impl Iterator<Item = String> {
271 super::env_table::ENV_PARAM_LIST
272 .iter()
273 .map(EnvParam::describe)
274}
275
276/// C `iocshRegisterCommon` (`iocshRegisterCommon.c:54-67`) — the environment
277/// variables an IOC shell publishes about *itself*, so a `.db`, a `.substitutions`
278/// or an `st.cmd` can expand `$(EPICS_VERSION_FULL)` or `$(ARCH)`.
279///
280/// These are outputs, not inputs: they are not `ENV_PARAM`s, they have no
281/// compiled default to fall back to, and C sets them with `epicsEnvSet`, which
282/// overwrites whatever the shell had. So does this — but only once per process,
283/// because a re-entrant `set_var` is what makes it unsound.
284///
285/// `ARCH` is C's `envGetConfigParam(&EPICS_BUILD_TARGET_ARCH)`, which is the
286/// build that produced the binary; here that is [`super::build_info`], reached
287/// through the generated table like every other parameter.
288pub fn register_iocsh_env_vars() {
289 use super::version as v;
290 static ONCE: std::sync::Once = std::sync::Once::new();
291 ONCE.call_once(|| {
292 // C: `if (targetArch) epicsEnvSet("ARCH", targetArch)` — an unresolvable
293 // parameter is a NULL, and NULL is not published as an empty `ARCH`.
294 let mut vars: Vec<(&str, String)> = super::env_table::EPICS_BUILD_TARGET_ARCH
295 .get()
296 .map(|arch| ("ARCH", arch))
297 .into_iter()
298 .collect();
299 vars.extend([
300 ("EPICS_VERSION_MAJOR", v::EPICS_VERSION.to_string()),
301 ("EPICS_VERSION_MIDDLE", v::EPICS_REVISION.to_string()),
302 ("EPICS_VERSION_MINOR", v::EPICS_MODIFICATION.to_string()),
303 ("EPICS_VERSION_PATCH", v::EPICS_PATCH_LEVEL.to_string()),
304 ("EPICS_VERSION_SNAPSHOT", v::EPICS_DEV_SNAPSHOT.to_string()),
305 ("EPICS_VERSION_SITE", v::EPICS_SITE_VERSION.to_string()),
306 ("EPICS_VERSION_SHORT", v::EPICS_VERSION_SHORT.to_string()),
307 ("EPICS_VERSION_FULL", v::EPICS_VERSION_FULL.to_string()),
308 ]);
309 for (name, value) in vars {
310 // SAFETY: `Once`, and called from `IocShell::new` — C registers these
311 // at the same point, before any script or record can read them.
312 unsafe { std::env::set_var(name, value) };
313 }
314 });
315}
316
317/// Set an environment variable only if it is not already set.
318///
319/// # Safety
320/// Uses `std::env::set_var` which is unsafe in multi-threaded programs.
321/// Call this early in main(), before spawning threads.
322pub fn set_default(name: &str, value: &str) {
323 if std::env::var_os(name).is_none() {
324 // SAFETY: called during IOC startup, before multi-threaded operation.
325 unsafe { std::env::set_var(name, value) };
326 }
327}
328
329/// Set an environment variable to a path relative to a crate's `CARGO_MANIFEST_DIR`.
330///
331/// `manifest_dir` must be the manifest dir of the crate that *owns* the assets,
332/// and `relative` a path beneath it. Reaching out of the crate (`../other-crate`)
333/// produces a path that only exists in a sibling-checkout layout: under a
334/// registry checkout the sibling is version-suffixed (`ad-core-rs-0.22.1`) and
335/// the path never resolves. To point at another crate's assets, use the dir that
336/// crate exports (e.g. `ad_core_rs::AD_CORE_DIR`, `motor_rs::MOTOR_IOC_DIR`).
337///
338/// Usage:
339/// ```ignore
340/// // In a binary crate, expose that crate's own db/ directory:
341/// epics_base_rs::runtime::env::set_crate_path("MYIOC", env!("CARGO_MANIFEST_DIR"), "db");
342/// ```
343pub fn set_crate_path(name: &str, manifest_dir: &str, relative: &str) {
344 set_default(name, &format!("{manifest_dir}/{relative}"));
345}
346
347pub fn hostname() -> String {
348 hostname::get()
349 .ok()
350 .and_then(|s| s.into_string().ok())
351 .unwrap_or_else(|| "localhost".to_string())
352}
353
354#[cfg(test)]
355mod tests {
356 use super::super::env_table::*;
357 use super::*;
358 use serial_test::serial;
359
360 /// A scratch parameter: the accessors are the unit under test, and the
361 /// generated table is not the place to add fixtures.
362 const SCRATCH: EnvParam = EnvParam::new("_EPICS_RT_TEST_VAR", "");
363 const SCRATCH_PORT: EnvParam = EnvParam::new("_EPICS_RT_TEST_PORT", "5064");
364
365 fn clear(p: EnvParam) {
366 // SAFETY: every test that mutates env is `#[serial(epics_env)]`.
367 unsafe { std::env::remove_var(p.name()) };
368 }
369 fn set(p: EnvParam, v: &str) {
370 // SAFETY: see `clear`.
371 unsafe { std::env::set_var(p.name(), v) };
372 }
373
374 #[test]
375 fn test_get_missing() {
376 assert_eq!(get("_EPICS_RT_NONEXISTENT_VAR_12345"), None);
377 }
378
379 #[test]
380 fn test_sscanf_long_lenient_parsing() {
381 // C `sscanf("%ld")` semantics: leading whitespace skipped,
382 // sign accepted, trailing garbage tolerated.
383 assert_eq!(sscanf_long("5064"), Some(5064));
384 assert_eq!(sscanf_long(" 6064"), Some(6064));
385 assert_eq!(sscanf_long("\t6064\n"), Some(6064));
386 assert_eq!(sscanf_long("5064abc"), Some(5064));
387 assert_eq!(sscanf_long("+6064"), Some(6064));
388 assert_eq!(sscanf_long("-1"), Some(-1));
389 assert_eq!(sscanf_long("not_a_number"), None);
390 assert_eq!(sscanf_long(""), None);
391 assert_eq!(sscanf_long(" "), None);
392 }
393
394 /// The compiled default resolves when the environment is silent — the whole
395 /// point of the table.
396 #[test]
397 #[serial(epics_env)]
398 fn unset_resolves_to_the_compiled_default() {
399 clear(EPICS_CA_CONN_TMO);
400 clear(EPICS_CA_SERVER_PORT);
401 clear(EPICS_CA_ADDR_LIST);
402 assert_eq!(EPICS_CA_CONN_TMO.double(), Ok(30.0));
403 assert_eq!(EPICS_CA_SERVER_PORT.long(), Some(5064));
404 assert_eq!(EPICS_CA_AUTO_ADDR_LIST.bool(), Some(true));
405 // Empty compiled default: `envGetConfigParamPtr` answers NULL.
406 assert_eq!(EPICS_CA_ADDR_LIST.get(), None);
407 assert_eq!(EPICS_CAS_SERVER_PORT.get(), None);
408 }
409
410 /// `default_port()` is const-evaluated, so this is really a compile-time
411 /// assertion that the table still holds C's ports.
412 #[test]
413 fn default_port_is_const_derived_from_the_table() {
414 const SERVER: u16 = EPICS_CA_SERVER_PORT.default_port();
415 const REPEATER: u16 = EPICS_CA_REPEATER_PORT.default_port();
416 const LOG: u16 = EPICS_IOC_LOG_PORT.default_port();
417 assert_eq!((SERVER, REPEATER, LOG), (5064, 5065, 7004));
418 }
419
420 #[test]
421 #[serial(epics_env)]
422 fn test_env_inet_port_valid() {
423 set(SCRATCH_PORT, "8080");
424 assert_eq!(SCRATCH_PORT.inet_port(5064), 8080);
425 clear(SCRATCH_PORT);
426 }
427
428 #[test]
429 #[serial(epics_env)]
430 fn test_env_inet_port_invalid_and_missing() {
431 set(SCRATCH_PORT, "not_a_number");
432 assert_eq!(SCRATCH_PORT.inet_port(5064), 5064);
433 clear(SCRATCH_PORT);
434 // Unset: the compiled "5064" default parses, silently.
435 assert_eq!(SCRATCH_PORT.inet_port(5064), 5064);
436 }
437
438 /// An empty value is "not configured" for `envGetConfigParamPtr`, so
439 /// `envGetLongConfigParam` fails and the port falls back to `fallback`.
440 #[test]
441 #[serial(epics_env)]
442 fn test_env_inet_port_empty_value_is_a_failed_fetch() {
443 set(SCRATCH, "");
444 assert_eq!(SCRATCH.get(), None);
445 assert_eq!(SCRATCH.inet_port(5064), 5064);
446 clear(SCRATCH);
447 }
448
449 #[test]
450 #[serial(epics_env)]
451 fn test_env_inet_port_lenient_whitespace_and_suffix() {
452 // C `sscanf("%ld")` accepts leading whitespace + trailing junk;
453 // `u16::parse` would reject both.
454 set(SCRATCH_PORT, " 6064");
455 assert_eq!(SCRATCH_PORT.inet_port(5064), 6064);
456 set(SCRATCH_PORT, "6064abc");
457 assert_eq!(SCRATCH_PORT.inet_port(5064), 6064);
458 clear(SCRATCH_PORT);
459 }
460
461 /// C copies the value into `char text[128]`, so a digit run that only
462 /// starts past byte 127 is invisible to `sscanf`.
463 #[test]
464 #[serial(epics_env)]
465 fn test_env_inet_port_truncates_at_the_c_buffer_length() {
466 set(SCRATCH_PORT, &format!("{}6064", " ".repeat(130)));
467 assert_eq!(SCRATCH_PORT.inet_port(5064), 5064);
468 // The same digits inside the first 127 bytes are seen.
469 set(SCRATCH_PORT, &format!("{}6064", " ".repeat(100)));
470 assert_eq!(SCRATCH_PORT.inet_port(5064), 6064);
471 clear(SCRATCH_PORT);
472 }
473
474 #[test]
475 #[serial(epics_env)]
476 fn test_env_inet_port_rejects_reserved_and_out_of_range_ports() {
477 // C `envGetInetPortConfigParam` rejects port <= IPPORT_USERRESERVED
478 // (5000) or > USHRT_MAX, falling back to the compiled default.
479 for bad in [
480 "0", "1", "80", "443", "3000", "5000", "-1", "65536", "70000", "99999",
481 ] {
482 set(SCRATCH_PORT, bad);
483 assert_eq!(
484 SCRATCH_PORT.inet_port(5064),
485 5064,
486 "out-of-range port {bad:?} must fall back to default"
487 );
488 }
489 // 5001 is the first acceptable port (> IPPORT_USERRESERVED).
490 set(SCRATCH_PORT, "5001");
491 assert_eq!(SCRATCH_PORT.inet_port(5064), 5001);
492 set(SCRATCH_PORT, "65535");
493 assert_eq!(SCRATCH_PORT.inet_port(5064), 65535);
494 clear(SCRATCH_PORT);
495 }
496
497 #[test]
498 #[serial(epics_env)]
499 fn test_get_bool_only_yes_case_insensitive() {
500 // C `envGetBoolConfigParam`: true iff epicsStrCaseCmp(text,"yes")==0.
501 for truthy in &["yes", "YES", "Yes", "yEs", "yeS"] {
502 set(SCRATCH, truthy);
503 assert_eq!(SCRATCH.bool(), Some(true), "{truthy:?} must be true");
504 }
505 for falsy in &[
506 "1", "true", "TRUE", "on", "yes ", " yes", "yes\n", "no", "0",
507 ] {
508 set(SCRATCH, falsy);
509 assert_eq!(SCRATCH.bool(), Some(false), "{falsy:?} must be false");
510 }
511 // Empty value AND empty compiled default: C's -1 status, caller's
512 // initializer survives.
513 set(SCRATCH, "");
514 assert_eq!(SCRATCH.bool(), None);
515 clear(SCRATCH);
516 assert_eq!(SCRATCH.bool(), None);
517 }
518
519 /// `long_or_default` is C's `long i = <default>; envGetLongConfigParam(...)`
520 /// — a rejected value leaves the compiled default standing.
521 #[test]
522 #[serial(epics_env)]
523 fn test_long_or_default_keeps_the_table_default_on_garbage() {
524 clear(IOCSH_HISTSIZE);
525 assert_eq!(IOCSH_HISTSIZE.long_or_default(), 50);
526 set(IOCSH_HISTSIZE, "garbage");
527 assert_eq!(IOCSH_HISTSIZE.long_or_default(), 50);
528 set(IOCSH_HISTSIZE, "1000");
529 assert_eq!(IOCSH_HISTSIZE.long_or_default(), 1000);
530 clear(IOCSH_HISTSIZE);
531 }
532
533 /// `envPrtConfigParam`: `NAME: value`, or `NAME is undefined` when the
534 /// resolved value is empty. Both forms verified against compiled C
535 /// (`env -i softIoc <<< epicsPrtEnvParams`).
536 #[test]
537 #[serial(epics_env)]
538 fn describe_matches_env_prt_config_param() {
539 clear(EPICS_CA_ADDR_LIST);
540 clear(EPICS_CA_SERVER_PORT);
541 assert_eq!(
542 EPICS_CA_ADDR_LIST.describe(),
543 "EPICS_CA_ADDR_LIST is undefined"
544 );
545 assert_eq!(
546 EPICS_CA_SERVER_PORT.describe(),
547 "EPICS_CA_SERVER_PORT: 5064"
548 );
549 set(EPICS_CA_ADDR_LIST, "10.0.0.1");
550 assert_eq!(
551 EPICS_CA_ADDR_LIST.describe(),
552 "EPICS_CA_ADDR_LIST: 10.0.0.1"
553 );
554 clear(EPICS_CA_ADDR_LIST);
555 }
556
557 #[test]
558 fn test_hostname() {
559 let h = hostname();
560 assert!(!h.is_empty());
561 }
562
563 /// The row-by-row diff against the BUILT C.
564 ///
565 /// The fixture is the verbatim stdout of
566 ///
567 /// ```text
568 /// printf 'epicsPrtEnvParams\nexit\n' | env -i epics-base/bin/linux-x86_64/softIoc
569 /// ```
570 ///
571 /// i.e. C walking its generated `env_param_list[]` with nothing in the
572 /// environment. Every name, every position, and every compiled default is
573 /// pinned by it — a dropped parameter, a reordered one, or a default that
574 /// drifts from `CONFIG_ENV` all fail here.
575 ///
576 /// This reads the TABLE, not the process environment, so it is independent
577 /// of whatever the developer's shell exports (and needs no `#[serial]`).
578 /// `describe()`'s env-override path is covered by
579 /// `describe_matches_env_prt_config_param`.
580 #[test]
581 #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))]
582 fn env_param_list_matches_compiled_c() {
583 let expected = include_str!("testdata/epicsPrtEnvParams.txt");
584 let ours: Vec<String> = ENV_PARAM_LIST
585 .iter()
586 .map(|p| {
587 if p.default_str().is_empty() {
588 format!("{} is undefined", p.name())
589 } else {
590 format!("{}: {}", p.name(), p.default_str())
591 }
592 })
593 .collect();
594 let theirs: Vec<&str> = expected.lines().collect();
595 assert_eq!(
596 ours.len(),
597 theirs.len(),
598 "C's env_param_list[] has {} rows, ours has {}",
599 theirs.len(),
600 ours.len()
601 );
602 for (i, (a, b)) in ours.iter().zip(&theirs).enumerate() {
603 assert_eq!(a, b, "row {i} differs from compiled C");
604 }
605 }
606
607 /// The IOC shell's own environment, diffed against compiled C.
608 ///
609 /// Fixture: `printf 'epicsEnvShow\nexit\n' | env -i softIoc` (7.0.10.1-DEV,
610 /// linux-x86_64), keeping the variables `iocshRegisterCommon` sets and
611 /// dropping the two the OS supplies (`PWD`) — nine names, nine values.
612 ///
613 /// A `.db` or an `st.cmd` that expands `$(EPICS_VERSION_FULL)` or `$(ARCH)`
614 /// resolves under C; before this it silently did not resolve here. Note
615 /// `EPICS_VERSION_SITE` is *set and empty*, not absent — an empty
616 /// `epicsEnvSet` value is still a variable, and `$(EPICS_VERSION_SITE)`
617 /// expands to nothing rather than failing.
618 #[test]
619 #[serial(epics_env)]
620 #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))]
621 fn iocsh_registers_the_version_env_vars_c_does() {
622 let expected = include_str!("testdata/iocshRegisterCommon.txt");
623 for name in expected.lines().filter_map(|l| l.split_once('=')) {
624 // SAFETY: `#[serial(epics_env)]`.
625 unsafe { std::env::remove_var(name.0) };
626 }
627
628 register_iocsh_env_vars();
629
630 for (i, line) in expected.lines().enumerate() {
631 let (name, value) = line.split_once('=').expect("NAME=value");
632 assert_eq!(
633 std::env::var(name).ok().as_deref(),
634 Some(value),
635 "line {i}: C's iocshRegisterCommon publishes `{line}`"
636 );
637 }
638 }
639}