Skip to main content

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}