Skip to main content

hyperactor_config/
lib.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Core configuration and attribute infrastructure for Hyperactor.
10//!
11//! This crate provides the core infrastructure for type-safe configuration
12//! management including:
13//! - `ConfigAttr`: Metadata for configuration keys
14//! - Helper functions to load/save `Attrs` (from env via `from_env`,
15//!   from YAML via `from_yaml`, and `to_yaml`)
16//! - Global layered configuration store under [`crate::global`]
17//!
18//! Individual crates should declare their own config keys using `declare_attrs!`
19//! and import `ConfigAttr`, `CONFIG`, and other infrastructure from this crate.
20
21use std::env;
22use std::fs::File;
23use std::io::Read;
24use std::path::Path;
25
26use derive_more::Display;
27use derive_more::From;
28use derive_more::Into;
29use serde::Deserialize;
30use serde::Serialize;
31use shell_quote::QuoteRefExt;
32use thiserror::Error;
33use typeuri::Named;
34
35pub mod attrs;
36pub mod flattrs;
37pub mod global;
38
39// Re-export commonly used items
40pub use attrs::AttrKeyInfo;
41pub use attrs::AttrValue;
42pub use attrs::Attrs;
43pub use attrs::Key;
44pub use attrs::SerializableValue;
45// Re-export bincode for macro usage (deserialize_bincode in AttrKeyInfo)
46#[doc(hidden)]
47pub use bincode;
48pub use flattrs::Flattrs;
49pub use flattrs::FlattrsValidationError;
50// Re-export AttrValue derive macro
51pub use hyperactor_config_macros::AttrValue;
52// Re-export macros needed by declare_attrs!
53pub use inventory::submit;
54pub use paste::paste;
55// Re-export typeuri for macro usage
56#[doc(hidden)]
57pub use typeuri;
58
59// declare_attrs is already exported via #[macro_export] in attrs.rs
60
61/// Metadata describing how a configuration key is exposed across
62/// environments.
63///
64/// Each `ConfigAttr` entry defines how a Rust configuration key maps
65/// to external representations:
66///  - `env_name`: the environment variable consulted by
67///    [`global::init_from_env()`] when loading configuration.
68///  - `py_name`: the Python keyword argument accepted by
69///    `monarch.configure(...)` and returned by `get_configuration()`.
70///  - `propagate`: whether this config should be inherited by child
71///    processes spawned via `BootstrapProcManager`. Set to `false`
72///    for process-local configs (e.g., TLS cert paths).
73///
74/// All configuration keys should carry this meta-attribute via
75/// `@meta(CONFIG = ConfigAttr { ... })`.
76#[derive(Clone, Debug, Serialize, Deserialize)]
77pub struct ConfigAttr {
78    /// Environment variable consulted by `global::init_from_env()`.
79    pub env_name: Option<String>,
80
81    /// Python kwarg name used by `monarch.configure(...)` and
82    /// `get_configuration()`.
83    pub py_name: Option<String>,
84
85    /// Whether this config should be inherited by child processes.
86    /// Set to `false` for process-local configs like TLS cert paths.
87    pub propagate: bool,
88}
89
90impl ConfigAttr {
91    /// Create a new `ConfigAttr` with the given env/py names.
92    ///
93    /// Defaults to `propagate: true` (inherited by child processes).
94    /// Use [`process_local`](Self::process_local) for configs that
95    /// should not propagate.
96    pub fn new(env_name: Option<String>, py_name: Option<String>) -> Self {
97        Self {
98            env_name,
99            py_name,
100            propagate: true,
101        }
102    }
103
104    /// Mark this config as process-local (not propagated to children).
105    ///
106    /// Use for configs like TLS cert paths that are specific to the
107    /// current process.
108    pub fn process_local(mut self) -> Self {
109        self.propagate = false;
110        self
111    }
112}
113
114impl Named for ConfigAttr {
115    fn typename() -> &'static str {
116        "hyperactor_config::ConfigAttr"
117    }
118}
119
120impl AttrValue for ConfigAttr {
121    fn display(&self) -> String {
122        serde_json::to_string(self).unwrap_or_else(|_| "<invalid ConfigAttr>".into())
123    }
124    fn parse(s: &str) -> Result<Self, anyhow::Error> {
125        Ok(serde_json::from_str(s)?)
126    }
127}
128
129#[derive(
130    Clone,
131    Copy,
132    Debug,
133    Display,
134    From,
135    Into,
136    PartialEq,
137    Eq,
138    Serialize,
139    Deserialize
140)]
141#[display("{}", _0)]
142pub struct NonZeroUsize(std::num::NonZeroUsize);
143
144#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
145#[error("expected non-zero usize")]
146pub struct NonZeroUsizeError;
147
148impl NonZeroUsize {
149    pub const MIN: Self = Self(std::num::NonZeroUsize::MIN);
150
151    pub const fn new(value: usize) -> Option<Self> {
152        match std::num::NonZeroUsize::new(value) {
153            Some(value) => Some(Self(value)),
154            None => None,
155        }
156    }
157
158    pub const fn get(self) -> usize {
159        self.0.get()
160    }
161
162    pub const fn into_std(self) -> std::num::NonZeroUsize {
163        self.0
164    }
165}
166
167impl Named for NonZeroUsize {
168    fn typename() -> &'static str {
169        "hyperactor_config::NonZeroUsize"
170    }
171}
172
173impl TryFrom<usize> for NonZeroUsize {
174    type Error = NonZeroUsizeError;
175
176    fn try_from(value: usize) -> Result<Self, Self::Error> {
177        Self::new(value).ok_or(NonZeroUsizeError)
178    }
179}
180
181impl From<NonZeroUsize> for usize {
182    fn from(value: NonZeroUsize) -> Self {
183        value.get()
184    }
185}
186
187impl AttrValue for NonZeroUsize {
188    fn display(&self) -> String {
189        self.to_string()
190    }
191
192    fn parse(s: &str) -> Result<Self, anyhow::Error> {
193        Ok(s.parse::<usize>()?.try_into()?)
194    }
195}
196
197/// Metadata describing how an attribute key is exposed through the
198/// HTTP introspection schema.
199///
200/// Each `IntrospectAttr` value defines the public schema entry for a
201/// Rust attribute key as exposed by endpoints such as `GET
202/// /v1/_schema`:
203/// - `name`: short public key name used in HTTP JSON and schema
204///   output (for example, `"node_type"` instead of a fully qualified
205///   Rust path)
206/// - `desc`: human-readable description shown in the schema output
207#[derive(Clone, Debug, Serialize, Deserialize)]
208pub struct IntrospectAttr {
209    /// Short public name for the key in HTTP JSON and schema.
210    pub name: String,
211
212    /// Human-readable description for the schema endpoint.
213    pub desc: String,
214}
215
216impl Named for IntrospectAttr {
217    fn typename() -> &'static str {
218        "hyperactor_config::IntrospectAttr"
219    }
220}
221
222impl AttrValue for IntrospectAttr {
223    fn display(&self) -> String {
224        serde_json::to_string(self).unwrap_or_else(|_| "<invalid IntrospectAttr>".into())
225    }
226    fn parse(s: &str) -> Result<Self, anyhow::Error> {
227        Ok(serde_json::from_str(s)?)
228    }
229}
230
231declare_attrs! {
232    /// This is a meta-attribute marking a configuration key.
233    ///
234    /// It carries metadata used to bridge Rust, environment
235    /// variables, and Python:
236    ///  - `env_name`: environment variable name consulted by
237    ///    `global::init_from_env()`.
238    ///  - `py_name`: keyword argument name recognized by
239    ///    `monarch.configure(...)`.
240    ///
241    /// All configuration keys should be annotated with this
242    /// attribute.
243    pub attr CONFIG: ConfigAttr;
244
245    /// This is a meta-attribute marking a key as part of the HTTP
246    /// introspection schema.
247    ///
248    /// It carries the public schema metadata used to expose actor and
249    /// mesh topology attributes through the introspection API.
250    ///
251    /// Keys that should be visible through the introspection HTTP
252    /// surface should be annotated with this attribute, for example:
253    ///
254    /// `@meta(INTROSPECT = IntrospectAttr { name: "...".into(), desc:
255    /// "...".into() })`
256    pub attr INTROSPECT: IntrospectAttr;
257}
258
259/// Load configuration from environment variables
260pub fn from_env() -> Attrs {
261    let mut config = Attrs::new();
262    let mut output = String::new();
263
264    fn export(env_var: &str, value: Option<&dyn SerializableValue>) -> String {
265        let env_var: String = env_var.quoted(shell_quote::Bash);
266        let value: String = value
267            .map_or("".to_string(), SerializableValue::display)
268            .quoted(shell_quote::Bash);
269        format!("export {}={}\n", env_var, value)
270    }
271
272    for key in inventory::iter::<AttrKeyInfo>() {
273        // Skip keys that are not marked as CONFIG or that do not
274        // declare an environment variable mapping. Only CONFIG-marked
275        // keys with an `env_name` participate in environment
276        // initialization.
277        let Some(cfg_meta) = key.meta.get(CONFIG) else {
278            continue;
279        };
280        let Some(env_var) = cfg_meta.env_name.as_deref() else {
281            continue;
282        };
283
284        let Ok(val) = env::var(env_var) else {
285            // Default value
286            output.push_str("# ");
287            output.push_str(&export(env_var, key.default));
288            continue;
289        };
290
291        match (key.parse)(&val) {
292            Err(e) => {
293                tracing::error!(
294                    "failed to override config key {} from value \"{}\" in ${}: {})",
295                    key.name,
296                    val,
297                    env_var,
298                    e
299                );
300                output.push_str("# ");
301                output.push_str(&export(env_var, key.default));
302            }
303            Ok(parsed) => {
304                output.push_str("# ");
305                output.push_str(&export(env_var, key.default));
306                output.push_str(&export(env_var, Some(parsed.as_ref())));
307                config.insert_value_by_name_unchecked(key.name, parsed);
308            }
309        }
310    }
311
312    tracing::info!(
313        "loaded configuration from environment:\n{}",
314        output.trim_end()
315    );
316
317    config
318}
319
320/// Load configuration from a YAML file
321pub fn from_yaml<P: AsRef<Path>>(path: P) -> Result<Attrs, anyhow::Error> {
322    let mut file = File::open(path)?;
323    let mut contents = String::new();
324    file.read_to_string(&mut contents)?;
325    Ok(serde_yaml::from_str(&contents)?)
326}
327
328/// Save configuration to a YAML file
329pub fn to_yaml<P: AsRef<Path>>(attrs: &Attrs, path: P) -> Result<(), anyhow::Error> {
330    let yaml = serde_yaml::to_string(attrs)?;
331    std::fs::write(path, yaml)?;
332    Ok(())
333}
334
335#[cfg(test)]
336mod tests {
337    use std::collections::HashSet;
338    use std::net::Ipv4Addr;
339
340    use indoc::indoc;
341
342    use crate::AttrValue;
343    use crate::CONFIG;
344    use crate::ConfigAttr;
345    use crate::NonZeroUsize;
346    use crate::attrs::declare_attrs;
347    use crate::from_env;
348    use crate::from_yaml;
349    use crate::to_yaml;
350
351    /// Like the `logs_assert` injected by `#[traced_test]`, but without scope
352    /// filtering. Use when asserting on events emitted outside the test's span
353    /// (e.g. from spawned tasks or panic hooks).
354    fn logs_assert_unscoped(f: impl Fn(&[&str]) -> Result<(), String>) {
355        let buf = tracing_test::internal::global_buf().lock().unwrap();
356        let logs_str = std::str::from_utf8(&buf).expect("Logs contain invalid UTF8");
357        let lines: Vec<&str> = logs_str.lines().collect();
358        match f(&lines) {
359            Ok(()) => {}
360            Err(msg) => panic!("{}", msg),
361        }
362    }
363
364    #[derive(
365        Debug,
366        Clone,
367        Copy,
368        PartialEq,
369        Eq,
370        serde::Serialize,
371        serde::Deserialize
372    )]
373    pub(crate) enum TestMode {
374        Development,
375        Staging,
376        Production,
377    }
378
379    impl std::fmt::Display for TestMode {
380        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381            match self {
382                TestMode::Development => write!(f, "dev"),
383                TestMode::Staging => write!(f, "staging"),
384                TestMode::Production => write!(f, "prod"),
385            }
386        }
387    }
388
389    impl std::str::FromStr for TestMode {
390        type Err = anyhow::Error;
391
392        fn from_str(s: &str) -> Result<Self, Self::Err> {
393            match s {
394                "dev" => Ok(TestMode::Development),
395                "staging" => Ok(TestMode::Staging),
396                "prod" => Ok(TestMode::Production),
397                _ => Err(anyhow::anyhow!("unknown mode: {}", s)),
398            }
399        }
400    }
401
402    impl typeuri::Named for TestMode {
403        fn typename() -> &'static str {
404            "hyperactor_config::tests::TestMode"
405        }
406    }
407
408    impl crate::attrs::AttrValue for TestMode {
409        fn display(&self) -> String {
410            self.to_string()
411        }
412
413        fn parse(s: &str) -> Result<Self, anyhow::Error> {
414            s.parse()
415        }
416    }
417
418    declare_attrs! {
419        @meta(CONFIG = ConfigAttr::new(
420            Some("TEST_USIZE_KEY".to_string()),
421            None,
422        ))
423        pub attr USIZE_KEY: usize = 10;
424
425        @meta(CONFIG = ConfigAttr::new(
426            Some("TEST_STRING_KEY".to_string()),
427            None,
428        ))
429        pub attr STRING_KEY: String = String::new();
430
431        @meta(CONFIG = ConfigAttr::new(
432            Some("TEST_BOOL_KEY".to_string()),
433            None,
434        ))
435        pub attr BOOL_KEY: bool = false;
436
437        @meta(CONFIG = ConfigAttr::new(
438            Some("TEST_I64_KEY".to_string()),
439            None,
440        ))
441        pub attr I64_KEY: i64 = -42;
442
443        @meta(CONFIG = ConfigAttr::new(
444            Some("TEST_F64_KEY".to_string()),
445            None,
446        ))
447        pub attr F64_KEY: f64 = 3.14;
448
449        @meta(CONFIG = ConfigAttr::new(
450            Some("TEST_U32_KEY".to_string()),
451            Some("test_u32_key".to_string()),
452        ))
453        pub attr U32_KEY: u32 = 100;
454
455        @meta(CONFIG = ConfigAttr::new(
456            Some("TEST_DURATION_KEY".to_string()),
457            None,
458        ))
459        pub attr DURATION_KEY: std::time::Duration = std::time::Duration::from_mins(1);
460
461        @meta(CONFIG = ConfigAttr::new(
462            Some("TEST_MODE_KEY".to_string()),
463            None,
464        ))
465        pub attr MODE_KEY: TestMode = TestMode::Development;
466
467        @meta(CONFIG = ConfigAttr::new(
468            Some("TEST_IP_KEY".to_string()),
469            None,
470        ))
471        pub attr IP_KEY: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1);
472
473        @meta(CONFIG = ConfigAttr::new(
474            Some("TEST_SYSTEMTIME_KEY".to_string()),
475            None,
476        ))
477        pub attr SYSTEMTIME_KEY: std::time::SystemTime = std::time::UNIX_EPOCH;
478
479        @meta(CONFIG = ConfigAttr::new(
480            None,
481            Some("test_no_env_key".to_string()),
482        ))
483        pub attr NO_ENV_KEY: usize = 999;
484    }
485
486    #[tracing_test::traced_test]
487    #[test]
488    // TODO: OSS: The logs_assert function returned an error: missing log lines: {"# export HYPERACTOR_DEFAULT_ENCODING=serde_multipart", ...}
489    #[cfg_attr(not(fbcode_build), ignore)]
490    fn test_from_env() {
491        // Set environment variables
492        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
493        unsafe { std::env::set_var("TEST_USIZE_KEY", "1024") };
494        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
495        unsafe { std::env::set_var("TEST_STRING_KEY", "world") };
496        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
497        unsafe { std::env::set_var("TEST_BOOL_KEY", "true") };
498        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
499        unsafe { std::env::set_var("TEST_I64_KEY", "-999") };
500        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
501        unsafe { std::env::set_var("TEST_F64_KEY", "2.718") };
502        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
503        unsafe { std::env::set_var("TEST_U32_KEY", "500") };
504        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
505        unsafe { std::env::set_var("TEST_DURATION_KEY", "5s") };
506        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
507        unsafe { std::env::set_var("TEST_MODE_KEY", "prod") };
508        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
509        unsafe { std::env::set_var("TEST_IP_KEY", "192.168.1.1") };
510        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
511        unsafe { std::env::set_var("TEST_SYSTEMTIME_KEY", "2024-01-15T10:30:00Z") };
512
513        let config = from_env();
514
515        // Verify values loaded from environment
516        assert_eq!(config[USIZE_KEY], 1024);
517        assert_eq!(config[STRING_KEY], "world");
518        assert!(config[BOOL_KEY]);
519        assert_eq!(config[I64_KEY], -999);
520        assert_eq!(config[F64_KEY], 2.718);
521        assert_eq!(config[U32_KEY], 500);
522        assert_eq!(config[DURATION_KEY], std::time::Duration::from_secs(5));
523        assert_eq!(config[MODE_KEY], TestMode::Production);
524        assert_eq!(config[IP_KEY], Ipv4Addr::new(192, 168, 1, 1));
525        assert_eq!(
526            config[SYSTEMTIME_KEY],
527            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1705314600) // 2024-01-15T10:30:00Z
528        );
529
530        // Verify key without env_name uses default
531        assert_eq!(config[NO_ENV_KEY], 999);
532
533        let expected_lines: HashSet<&str> = indoc! {"
534            # export TEST_USIZE_KEY=10
535            export TEST_USIZE_KEY=1024
536            # export TEST_STRING_KEY=''
537            export TEST_STRING_KEY=world
538            # export TEST_BOOL_KEY=0
539            export TEST_BOOL_KEY=1
540            # export TEST_I64_KEY=-42
541            export TEST_I64_KEY=-999
542            # export TEST_F64_KEY=3.14
543            export TEST_F64_KEY=2.718
544            # export TEST_U32_KEY=100
545            export TEST_U32_KEY=500
546            # export TEST_DURATION_KEY=1m
547            export TEST_DURATION_KEY=5s
548            # export TEST_MODE_KEY=dev
549            export TEST_MODE_KEY=prod
550            # export TEST_IP_KEY=127.0.0.1
551            export TEST_IP_KEY=192.168.1.1
552        "}
553        .trim_end()
554        .lines()
555        .collect();
556
557        // For some reason, logs_contain fails to find these lines individually
558        // (possibly to do with the fact that we have newlines in our log entries);
559        // instead, we test it manually.
560        logs_assert_unscoped(|logged_lines: &[&str]| {
561            let mut expected_lines = expected_lines.clone(); // this is an `Fn` closure
562            for logged in logged_lines {
563                expected_lines.remove(logged);
564            }
565
566            if expected_lines.is_empty() {
567                Ok(())
568            } else {
569                Err(format!("missing log lines: {:?}", expected_lines))
570            }
571        });
572
573        // Clean up
574        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
575        unsafe { std::env::remove_var("TEST_USIZE_KEY") };
576        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
577        unsafe { std::env::remove_var("TEST_STRING_KEY") };
578        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
579        unsafe { std::env::remove_var("TEST_BOOL_KEY") };
580        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
581        unsafe { std::env::remove_var("TEST_I64_KEY") };
582        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
583        unsafe { std::env::remove_var("TEST_F64_KEY") };
584        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
585        unsafe { std::env::remove_var("TEST_U32_KEY") };
586        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
587        unsafe { std::env::remove_var("TEST_DURATION_KEY") };
588        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
589        unsafe { std::env::remove_var("TEST_MODE_KEY") };
590        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
591        unsafe { std::env::remove_var("TEST_IP_KEY") };
592        // SAFETY: TODO: Audit that the environment access only happens in single-threaded code.
593        unsafe { std::env::remove_var("TEST_SYSTEMTIME_KEY") };
594    }
595
596    #[test]
597    fn test_yaml_round_trip() {
598        let temp_path = std::env::temp_dir().join("test_config.yaml");
599
600        let mut config = crate::Attrs::new();
601        config.set(USIZE_KEY, 2048);
602        config.set(STRING_KEY, "hello_yaml".to_string());
603        config.set(BOOL_KEY, true);
604        config.set(I64_KEY, -123);
605        config.set(F64_KEY, 1.414);
606        config.set(U32_KEY, 777);
607        config.set(DURATION_KEY, std::time::Duration::from_mins(2));
608        config.set(MODE_KEY, TestMode::Staging);
609        config.set(IP_KEY, Ipv4Addr::new(10, 0, 0, 1));
610        config.set(
611            SYSTEMTIME_KEY,
612            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1609459200),
613        );
614
615        to_yaml(&config, &temp_path).unwrap();
616
617        let yaml_content = std::fs::read_to_string(&temp_path).unwrap();
618
619        eprintln!("YAML content:\n{}", yaml_content);
620
621        assert!(yaml_content.contains("2048"));
622        assert!(yaml_content.contains("hello_yaml"));
623        assert!(yaml_content.contains("Staging"));
624
625        let loaded_config = from_yaml(&temp_path).unwrap();
626
627        assert_eq!(loaded_config[USIZE_KEY], 2048);
628        assert_eq!(loaded_config[STRING_KEY], "hello_yaml");
629        assert!(loaded_config[BOOL_KEY]);
630        assert_eq!(loaded_config[I64_KEY], -123);
631        assert_eq!(loaded_config[F64_KEY], 1.414);
632        assert_eq!(loaded_config[U32_KEY], 777);
633        assert_eq!(
634            loaded_config[DURATION_KEY],
635            std::time::Duration::from_mins(2)
636        );
637        assert_eq!(loaded_config[MODE_KEY], TestMode::Staging);
638        assert_eq!(loaded_config[IP_KEY], Ipv4Addr::new(10, 0, 0, 1));
639        assert_eq!(
640            loaded_config[SYSTEMTIME_KEY],
641            std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1609459200)
642        );
643
644        let _ = std::fs::remove_file(&temp_path);
645    }
646
647    #[test]
648    fn test_nonzero_usize_attr_value() {
649        let value = NonZeroUsize::parse("16").unwrap();
650        assert_eq!(value.get(), 16);
651        assert_eq!(value.display(), "16");
652        assert_eq!(usize::from(value), 16);
653        assert_eq!(
654            std::num::NonZeroUsize::from(value),
655            std::num::NonZeroUsize::new(16).unwrap()
656        );
657        assert_eq!(Option::<NonZeroUsize>::parse("").unwrap(), None);
658        assert_eq!(
659            Option::<NonZeroUsize>::parse("16")
660                .unwrap()
661                .map(NonZeroUsize::get),
662            Some(16)
663        );
664        assert!(NonZeroUsize::try_from(0).is_err());
665        assert!(NonZeroUsize::parse("0").is_err());
666        assert!(Option::<NonZeroUsize>::parse("0").is_err());
667    }
668
669    // Verify that the INTROSPECT meta-attribute attaches structured
670    // introspection metadata to an attribute key and that the
671    // metadata can be retrieved through the attrs API.
672    //
673    // The declare_attrs! block defines a key annotated with
674    // `@meta(INTROSPECT = IntrospectAttr { ... })`. Calling
675    // `.attrs()` on the key should expose that metadata, and
676    // `meta.get(INTROSPECT)` should return the stored
677    // `IntrospectAttr` with the declared `name` and `desc` values.
678    #[test]
679    fn test_introspect_meta_key() {
680        use crate::INTROSPECT;
681        use crate::IntrospectAttr;
682
683        declare_attrs! {
684            @meta(INTROSPECT = IntrospectAttr {
685                name: "test_key".into(),
686                desc: "A test introspection key".into(),
687            })
688            attr TEST_INTROSPECT_KEY: String;
689        }
690        let meta = TEST_INTROSPECT_KEY.attrs();
691        let introspect = meta
692            .get(INTROSPECT)
693            .expect("INTROSPECT meta-attr should be set");
694        assert_eq!(introspect.name, "test_key");
695        assert_eq!(introspect.desc, "A test introspection key");
696    }
697}