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 /// The state every compiled-default assertion needs: nothing the generated
375 /// table knows about is set in the process environment. Restored on drop.
376 ///
377 /// Hand-picked `clear` calls cannot establish it, because the list that is
378 /// cleared and the list that is asserted on are then maintained apart:
379 /// `unset_resolves_to_the_compiled_default` cleared three parameters and
380 /// asserted on five, so a developer shell exporting
381 /// `EPICS_CA_AUTO_ADDR_LIST=NO` — which a shell that sets
382 /// `EPICS_CA_ADDR_LIST` normally does — failed it, on a table the test
383 /// never doubted. Walking `ENV_PARAM_LIST` deletes the second list:
384 /// whatever a test asserts on, the table has already covered.
385 #[must_use]
386 struct SilentEnv(Vec<(&'static str, String)>);
387
388 impl SilentEnv {
389 fn take() -> Self {
390 let saved = ENV_PARAM_LIST
391 .iter()
392 .filter_map(|p| std::env::var(p.name()).ok().map(|v| (p.name(), v)))
393 .collect();
394 for p in ENV_PARAM_LIST {
395 clear(*p);
396 }
397 Self(saved)
398 }
399 }
400
401 impl Drop for SilentEnv {
402 fn drop(&mut self) {
403 for (name, value) in &self.0 {
404 // SAFETY: see `clear`.
405 unsafe { std::env::set_var(name, value) };
406 }
407 }
408 }
409
410 #[test]
411 fn test_get_missing() {
412 assert_eq!(get("_EPICS_RT_NONEXISTENT_VAR_12345"), None);
413 }
414
415 #[test]
416 fn test_sscanf_long_lenient_parsing() {
417 // C `sscanf("%ld")` semantics: leading whitespace skipped,
418 // sign accepted, trailing garbage tolerated.
419 assert_eq!(sscanf_long("5064"), Some(5064));
420 assert_eq!(sscanf_long(" 6064"), Some(6064));
421 assert_eq!(sscanf_long("\t6064\n"), Some(6064));
422 assert_eq!(sscanf_long("5064abc"), Some(5064));
423 assert_eq!(sscanf_long("+6064"), Some(6064));
424 assert_eq!(sscanf_long("-1"), Some(-1));
425 assert_eq!(sscanf_long("not_a_number"), None);
426 assert_eq!(sscanf_long(""), None);
427 assert_eq!(sscanf_long(" "), None);
428 }
429
430 /// The compiled default resolves when the environment is silent — the whole
431 /// point of the table.
432 #[test]
433 #[serial(epics_env)]
434 fn unset_resolves_to_the_compiled_default() {
435 let _silent = SilentEnv::take();
436 assert_eq!(EPICS_CA_CONN_TMO.double(), Ok(30.0));
437 assert_eq!(EPICS_CA_SERVER_PORT.long(), Some(5064));
438 assert_eq!(EPICS_CA_AUTO_ADDR_LIST.bool(), Some(true));
439 // Empty compiled default: `envGetConfigParamPtr` answers NULL.
440 assert_eq!(EPICS_CA_ADDR_LIST.get(), None);
441 assert_eq!(EPICS_CAS_SERVER_PORT.get(), None);
442 }
443
444 /// `default_port()` is const-evaluated, so this is really a compile-time
445 /// assertion that the table still holds C's ports.
446 #[test]
447 fn default_port_is_const_derived_from_the_table() {
448 const SERVER: u16 = EPICS_CA_SERVER_PORT.default_port();
449 const REPEATER: u16 = EPICS_CA_REPEATER_PORT.default_port();
450 const LOG: u16 = EPICS_IOC_LOG_PORT.default_port();
451 assert_eq!((SERVER, REPEATER, LOG), (5064, 5065, 7004));
452 }
453
454 #[test]
455 #[serial(epics_env)]
456 fn test_env_inet_port_valid() {
457 set(SCRATCH_PORT, "8080");
458 assert_eq!(SCRATCH_PORT.inet_port(5064), 8080);
459 clear(SCRATCH_PORT);
460 }
461
462 #[test]
463 #[serial(epics_env)]
464 fn test_env_inet_port_invalid_and_missing() {
465 set(SCRATCH_PORT, "not_a_number");
466 assert_eq!(SCRATCH_PORT.inet_port(5064), 5064);
467 clear(SCRATCH_PORT);
468 // Unset: the compiled "5064" default parses, silently.
469 assert_eq!(SCRATCH_PORT.inet_port(5064), 5064);
470 }
471
472 /// An empty value is "not configured" for `envGetConfigParamPtr`, so
473 /// `envGetLongConfigParam` fails and the port falls back to `fallback`.
474 #[test]
475 #[serial(epics_env)]
476 fn test_env_inet_port_empty_value_is_a_failed_fetch() {
477 set(SCRATCH, "");
478 assert_eq!(SCRATCH.get(), None);
479 assert_eq!(SCRATCH.inet_port(5064), 5064);
480 clear(SCRATCH);
481 }
482
483 #[test]
484 #[serial(epics_env)]
485 fn test_env_inet_port_lenient_whitespace_and_suffix() {
486 // C `sscanf("%ld")` accepts leading whitespace + trailing junk;
487 // `u16::parse` would reject both.
488 set(SCRATCH_PORT, " 6064");
489 assert_eq!(SCRATCH_PORT.inet_port(5064), 6064);
490 set(SCRATCH_PORT, "6064abc");
491 assert_eq!(SCRATCH_PORT.inet_port(5064), 6064);
492 clear(SCRATCH_PORT);
493 }
494
495 /// C copies the value into `char text[128]`, so a digit run that only
496 /// starts past byte 127 is invisible to `sscanf`.
497 #[test]
498 #[serial(epics_env)]
499 fn test_env_inet_port_truncates_at_the_c_buffer_length() {
500 set(SCRATCH_PORT, &format!("{}6064", " ".repeat(130)));
501 assert_eq!(SCRATCH_PORT.inet_port(5064), 5064);
502 // The same digits inside the first 127 bytes are seen.
503 set(SCRATCH_PORT, &format!("{}6064", " ".repeat(100)));
504 assert_eq!(SCRATCH_PORT.inet_port(5064), 6064);
505 clear(SCRATCH_PORT);
506 }
507
508 #[test]
509 #[serial(epics_env)]
510 fn test_env_inet_port_rejects_reserved_and_out_of_range_ports() {
511 // C `envGetInetPortConfigParam` rejects port <= IPPORT_USERRESERVED
512 // (5000) or > USHRT_MAX, falling back to the compiled default.
513 for bad in [
514 "0", "1", "80", "443", "3000", "5000", "-1", "65536", "70000", "99999",
515 ] {
516 set(SCRATCH_PORT, bad);
517 assert_eq!(
518 SCRATCH_PORT.inet_port(5064),
519 5064,
520 "out-of-range port {bad:?} must fall back to default"
521 );
522 }
523 // 5001 is the first acceptable port (> IPPORT_USERRESERVED).
524 set(SCRATCH_PORT, "5001");
525 assert_eq!(SCRATCH_PORT.inet_port(5064), 5001);
526 set(SCRATCH_PORT, "65535");
527 assert_eq!(SCRATCH_PORT.inet_port(5064), 65535);
528 clear(SCRATCH_PORT);
529 }
530
531 #[test]
532 #[serial(epics_env)]
533 fn test_get_bool_only_yes_case_insensitive() {
534 // C `envGetBoolConfigParam`: true iff epicsStrCaseCmp(text,"yes")==0.
535 for truthy in &["yes", "YES", "Yes", "yEs", "yeS"] {
536 set(SCRATCH, truthy);
537 assert_eq!(SCRATCH.bool(), Some(true), "{truthy:?} must be true");
538 }
539 for falsy in &[
540 "1", "true", "TRUE", "on", "yes ", " yes", "yes\n", "no", "0",
541 ] {
542 set(SCRATCH, falsy);
543 assert_eq!(SCRATCH.bool(), Some(false), "{falsy:?} must be false");
544 }
545 // Empty value AND empty compiled default: C's -1 status, caller's
546 // initializer survives.
547 set(SCRATCH, "");
548 assert_eq!(SCRATCH.bool(), None);
549 clear(SCRATCH);
550 assert_eq!(SCRATCH.bool(), None);
551 }
552
553 /// `long_or_default` is C's `long i = <default>; envGetLongConfigParam(...)`
554 /// — a rejected value leaves the compiled default standing.
555 #[test]
556 #[serial(epics_env)]
557 fn test_long_or_default_keeps_the_table_default_on_garbage() {
558 clear(IOCSH_HISTSIZE);
559 assert_eq!(IOCSH_HISTSIZE.long_or_default(), 50);
560 set(IOCSH_HISTSIZE, "garbage");
561 assert_eq!(IOCSH_HISTSIZE.long_or_default(), 50);
562 set(IOCSH_HISTSIZE, "1000");
563 assert_eq!(IOCSH_HISTSIZE.long_or_default(), 1000);
564 clear(IOCSH_HISTSIZE);
565 }
566
567 /// `envPrtConfigParam`: `NAME: value`, or `NAME is undefined` when the
568 /// resolved value is empty. Both forms verified against compiled C
569 /// (`env -i softIoc <<< epicsPrtEnvParams`).
570 #[test]
571 #[serial(epics_env)]
572 fn describe_matches_env_prt_config_param() {
573 let _silent = SilentEnv::take();
574 assert_eq!(
575 EPICS_CA_ADDR_LIST.describe(),
576 "EPICS_CA_ADDR_LIST is undefined"
577 );
578 assert_eq!(
579 EPICS_CA_SERVER_PORT.describe(),
580 "EPICS_CA_SERVER_PORT: 5064"
581 );
582 set(EPICS_CA_ADDR_LIST, "10.0.0.1");
583 assert_eq!(
584 EPICS_CA_ADDR_LIST.describe(),
585 "EPICS_CA_ADDR_LIST: 10.0.0.1"
586 );
587 }
588
589 #[test]
590 fn test_hostname() {
591 let h = hostname();
592 assert!(!h.is_empty());
593 }
594
595 /// The row-by-row diff against the BUILT C.
596 ///
597 /// The fixture is the verbatim stdout of
598 ///
599 /// ```text
600 /// printf 'epicsPrtEnvParams\nexit\n' | env -i epics-base/bin/linux-x86_64/softIoc
601 /// ```
602 ///
603 /// i.e. C walking its generated `env_param_list[]` with nothing in the
604 /// environment. Every name, every position, and every compiled default is
605 /// pinned by it — a dropped parameter, a reordered one, or a default that
606 /// drifts from `CONFIG_ENV` all fail here.
607 ///
608 /// This reads the TABLE, not the process environment, so it is independent
609 /// of whatever the developer's shell exports (and needs no `#[serial]`).
610 /// `describe()`'s env-override path is covered by
611 /// `describe_matches_env_prt_config_param`.
612 #[test]
613 #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))]
614 fn env_param_list_matches_compiled_c() {
615 let expected = include_str!("testdata/epicsPrtEnvParams.txt");
616 let ours: Vec<String> = ENV_PARAM_LIST
617 .iter()
618 .map(|p| {
619 if p.default_str().is_empty() {
620 format!("{} is undefined", p.name())
621 } else {
622 format!("{}: {}", p.name(), p.default_str())
623 }
624 })
625 .collect();
626 let theirs: Vec<&str> = expected.lines().collect();
627 assert_eq!(
628 ours.len(),
629 theirs.len(),
630 "C's env_param_list[] has {} rows, ours has {}",
631 theirs.len(),
632 ours.len()
633 );
634 for (i, (a, b)) in ours.iter().zip(&theirs).enumerate() {
635 assert_eq!(a, b, "row {i} differs from compiled C");
636 }
637 }
638
639 /// The IOC shell's own environment, diffed against compiled C.
640 ///
641 /// Fixture: `printf 'epicsEnvShow\nexit\n' | env -i softIoc` (7.0.10.1-DEV,
642 /// linux-x86_64), keeping the variables `iocshRegisterCommon` sets and
643 /// dropping the two the OS supplies (`PWD`) — nine names, nine values.
644 ///
645 /// A `.db` or an `st.cmd` that expands `$(EPICS_VERSION_FULL)` or `$(ARCH)`
646 /// resolves under C; before this it silently did not resolve here. Note
647 /// `EPICS_VERSION_SITE` is *set and empty*, not absent — an empty
648 /// `epicsEnvSet` value is still a variable, and `$(EPICS_VERSION_SITE)`
649 /// expands to nothing rather than failing.
650 #[test]
651 #[serial(epics_env)]
652 #[cfg(all(target_os = "linux", target_arch = "x86_64", target_env = "gnu"))]
653 fn iocsh_registers_the_version_env_vars_c_does() {
654 let expected = include_str!("testdata/iocshRegisterCommon.txt");
655 for name in expected.lines().filter_map(|l| l.split_once('=')) {
656 // SAFETY: `#[serial(epics_env)]`.
657 unsafe { std::env::remove_var(name.0) };
658 }
659
660 register_iocsh_env_vars();
661
662 for (i, line) in expected.lines().enumerate() {
663 let (name, value) = line.split_once('=').expect("NAME=value");
664 assert_eq!(
665 std::env::var(name).ok().as_deref(),
666 Some(value),
667 "line {i}: C's iocshRegisterCommon publishes `{line}`"
668 );
669 }
670 }
671}