Skip to main content

cubecl_environment/config/
mod.rs

1/// Reusable logger configuration and sink management.
2pub mod logger;
3
4#[cfg(target_has_atomic = "ptr")]
5use alloc::sync::Arc;
6
7#[cfg(not(target_has_atomic = "ptr"))]
8use portable_atomic_util::Arc;
9
10use serde::Serialize;
11use serde::de::DeserializeOwned;
12
13/// Reports a configuration file that exists but could not be read into the
14/// configuration type.
15///
16/// Goes to stderr as well as `log`. A malformed file is silently replaced by
17/// [`Default`], and configuration is resolved on first use — typically before
18/// the application has installed a `log` sink, so a `log`-only warning is
19/// usually written to nothing at all. That combination is how a single stale
20/// field turns every setting in a file into its default with nothing to notice.
21///
22/// Only reached when a file is present *and* malformed, which is always a
23/// mistake worth interrupting for. A missing file, or one without the section
24/// being looked for, is ordinary and stays quiet.
25#[cfg(std_io)]
26fn report_malformed(message: &str) {
27    log::warn!("{message}");
28    std::eprintln!("cubecl config: {message}");
29}
30
31/// Trait for runtime configurations potentially loaded from a TOML file.
32///
33/// Implementors provide a global storage slot and the set of file names to search for;
34/// the trait supplies the lookup, lazy-initialization, and serialization logic.
35///
36/// The singleton stored in [`Config::storage`] is initialized on the first call to
37/// [`Config::get`] by walking up the current working directory looking for any of the
38/// names returned by [`Config::file_names`]. If none is found, [`Default`] is used.
39pub trait RuntimeConfig:
40    Default + Clone + Serialize + DeserializeOwned + Send + Sync + 'static
41{
42    /// Global storage for the configuration singleton.
43    ///
44    /// Each implementor must declare its own `static` slot, because Rust traits
45    /// cannot own statics directly.
46    fn storage() -> &'static crate::sync::Mutex<Option<Arc<Self>>>;
47
48    /// File names searched in each directory during [`Config::from_current_dir`].
49    ///
50    /// The first existing file wins.
51    fn file_names() -> &'static [&'static str];
52
53    /// File names searched in each directory, where only a specific TOML section is loaded
54    /// instead of the whole file.
55    ///
56    /// Each entry is `(file_name, section_name)` and the section must deserialize to `Self`.
57    /// Checked after [`Config::file_names`] at each directory level.
58    fn section_file_names() -> &'static [(&'static str, &'static str)] {
59        &[]
60    }
61
62    /// Hook to override fields from environment variables after loading from disk.
63    ///
64    /// The default implementation returns `self` unchanged.
65    #[cfg(std_io)]
66    fn override_from_env(self) -> Self {
67        self
68    }
69
70    /// Hook invoked exactly once, when the configuration singleton is first
71    /// initialized — whether loaded from disk in [`RuntimeConfig::get`] or
72    /// installed with [`RuntimeConfig::set`] / [`RuntimeConfig::try_set`].
73    ///
74    /// Use it to apply configuration to global state (stream policy, bundle
75    /// installation, ...). Runs while the storage lock is held so that no
76    /// concurrent [`RuntimeConfig::get`] can observe the configuration before
77    /// the hook completed. Consequently the hook must not call
78    /// [`RuntimeConfig::get`], [`RuntimeConfig::set`] or
79    /// [`RuntimeConfig::try_set`] — that would deadlock.
80    fn on_loaded(&self) {}
81
82    /// Retrieves the current configuration, loading it from the current directory if not set.
83    ///
84    /// If no configuration is set, it attempts to load one from any of [`Config::file_names`] in
85    /// the current directory or its parents. If no file is found, a default configuration is used.
86    ///
87    /// # Notes
88    ///
89    /// Calling this function is somewhat expensive, because of a global static lock. The config
90    /// format is optimized for parsing, not for consumption. A good practice is to use a local
91    /// static atomic value that you can populate with the appropriate value from the config
92    /// during initialization.
93    fn get() -> Arc<Self> {
94        let mut state = Self::storage().lock();
95        if state.as_ref().is_none() {
96            cfg_if::cfg_if! {
97                if #[cfg(std_io)] {
98                    let config = Self::from_current_dir();
99                    let config = config.override_from_env();
100                } else {
101                    let config = Self::default();
102                }
103            }
104
105            let config = Arc::new(config);
106            *state = Some(config.clone());
107            // Still under the lock: a concurrent `get` must not observe the
108            // configuration before the hook has run.
109            config.on_loaded();
110
111            return config;
112        }
113
114        state.as_ref().cloned().unwrap()
115    }
116
117    /// Sets the configuration to the provided value.
118    ///
119    /// # Panics
120    /// Panics if the configuration has already been set or read, as it cannot be overridden.
121    ///
122    /// # Warning
123    /// This method must be called at the start of the program, before any calls to
124    /// [`Config::get`]. Attempting to set the configuration after it has been initialized will
125    /// cause a panic.
126    fn set(config: Self) {
127        if !Self::try_set(config) {
128            panic!("Cannot set the configuration multiple times.");
129        }
130    }
131
132    /// Sets the configuration to the provided value, unless it has already been
133    /// set or read — in which case the existing configuration is kept and
134    /// `false` is returned.
135    ///
136    /// Use this from libraries that want to provide a computed default without
137    /// overriding a configuration the application set first.
138    fn try_set(config: Self) -> bool {
139        let mut state = Self::storage().lock();
140        if state.is_some() {
141            return false;
142        }
143        let config = Arc::new(config);
144        *state = Some(config.clone());
145        // Still under the lock: see `get`.
146        config.on_loaded();
147        true
148    }
149
150    /// Save the default configuration to the provided file path.
151    #[cfg(std_io)]
152    fn save_default<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<()> {
153        use std::io::Write;
154
155        let config = Self::get();
156        let content =
157            toml::to_string_pretty(config.as_ref()).expect("Default config should be serializable");
158        let mut file = std::fs::File::create(path)?;
159        file.write_all(content.as_bytes())?;
160
161        Ok(())
162    }
163
164    /// Loads configuration from any of [`Config::file_names`] in the current directory or its
165    /// parents.
166    ///
167    /// Traverses up the directory tree until a valid configuration file is found or the root
168    /// is reached. Returns a default configuration if no file is found.
169    #[cfg(std_io)]
170    fn from_current_dir() -> Self {
171        // A deleted or unreadable cwd is not a reason to abort: there is simply
172        // no configuration file to find from here.
173        let Ok(mut dir) = std::env::current_dir() else {
174            return Self::default();
175        };
176
177        loop {
178            for name in Self::file_names() {
179                if let Ok(content) = Self::from_file_path(dir.join(name)) {
180                    return content;
181                }
182            }
183
184            for (name, section) in Self::section_file_names() {
185                if let Ok(content) = Self::from_section_file_path(dir.join(name), section) {
186                    return content;
187                }
188            }
189
190            if !dir.pop() {
191                break;
192            }
193        }
194
195        Self::default()
196    }
197
198    /// Loads configuration from a specified file path.
199    ///
200    /// A file that does not parse is reported and skipped rather than fatal:
201    /// configuration keys change between releases, and a stale `cubecl.toml`
202    /// left in a checkout must not abort the application that reads it.
203    #[cfg(std_io)]
204    fn from_file_path<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<Self> {
205        let path = path.as_ref();
206        let content = std::fs::read_to_string(path)?;
207
208        match toml::from_str(&content) {
209            Ok(config) => Ok(config),
210            Err(err) => {
211                report_malformed(&alloc::format!(
212                    "Ignoring {path:?}, which doesn't have the right format => {err}"
213                ));
214                Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err))
215            }
216        }
217    }
218
219    /// Loads configuration from a specific TOML section of the file at the given path.
220    #[cfg(std_io)]
221    fn from_section_file_path<P: AsRef<std::path::Path>>(
222        path: P,
223        section: &str,
224    ) -> std::io::Result<Self> {
225        let path = path.as_ref();
226        let content = std::fs::read_to_string(path)?;
227
228        let mut table: toml::Table = match toml::from_str(&content) {
229            Ok(val) => val,
230            Err(err) => {
231                report_malformed(&alloc::format!(
232                    "Ignoring {path:?}, which doesn't have the right format => {err}"
233                ));
234                return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err));
235            }
236        };
237
238        let value = match table.remove(section) {
239            Some(val) => val,
240            None => {
241                return Err(std::io::Error::new(
242                    std::io::ErrorKind::NotFound,
243                    alloc::format!("Section '{section}' not found"),
244                ));
245            }
246        };
247
248        match value.try_into() {
249            Ok(config) => Ok(config),
250            Err(err) => {
251                report_malformed(&alloc::format!(
252                    "Ignoring section '{section}' of {path:?}, which doesn't have the right \
253                     format => {err}"
254                ));
255                Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err))
256            }
257        }
258    }
259}
260
261#[cfg(all(test, std_io))]
262mod tests {
263    use super::*;
264
265    #[derive(Default, Clone, Debug, serde::Serialize, serde::Deserialize)]
266    struct Probe {
267        #[serde(default)]
268        cache: bool,
269    }
270
271    static PROBE: crate::sync::Mutex<Option<Arc<Probe>>> = crate::sync::Mutex::new(None);
272
273    impl RuntimeConfig for Probe {
274        fn storage() -> &'static crate::sync::Mutex<Option<Arc<Self>>> {
275            &PROBE
276        }
277        fn file_names() -> &'static [&'static str] {
278            &["probe.toml"]
279        }
280        fn section_file_names() -> &'static [(&'static str, &'static str)] {
281            &[("host.toml", "probe")]
282        }
283    }
284
285    /// A directory holding one config file, removed when the returned handle
286    /// drops.
287    fn scratch(file: &str, content: &str) -> tempfile::TempDir {
288        let dir = tempfile::tempdir().unwrap();
289        std::fs::write(dir.path().join(file), content).unwrap();
290        dir
291    }
292
293    /// A field whose *type* changed is the failure mode that silently reverts a
294    /// whole file to defaults: the file is found, so nothing looks wrong, but
295    /// every setting in it is dropped. It has to surface as an error rather
296    /// than a `None`-shaped miss.
297    #[test]
298    #[cfg_attr(miri, ignore)]
299    fn a_wrongly_typed_field_fails_the_whole_file() {
300        let dir = scratch("probe.toml", "cache = \"target\"\n");
301
302        let err = Probe::from_file_path(dir.path().join("probe.toml")).unwrap_err();
303        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
304    }
305
306    /// Same, one level in: the section exists but does not deserialize.
307    #[test]
308    #[cfg_attr(miri, ignore)]
309    fn a_wrongly_typed_field_fails_the_whole_section() {
310        let dir = scratch("host.toml", "[probe]\ncache = \"target\"\n");
311
312        let err = Probe::from_section_file_path(dir.path().join("host.toml"), "probe").unwrap_err();
313        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
314    }
315
316    /// A host file that simply has no section for us is ordinary, not an error
317    /// worth reporting — that is how a shared `burn.toml` looks to a crate it
318    /// says nothing about.
319    #[test]
320    #[cfg_attr(miri, ignore)]
321    fn a_missing_section_is_quiet() {
322        let dir = scratch("host.toml", "[other]\nvalue = 1\n");
323
324        let err = Probe::from_section_file_path(dir.path().join("host.toml"), "probe").unwrap_err();
325        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
326    }
327
328    /// The good path still reads the value through.
329    #[test]
330    #[cfg_attr(miri, ignore)]
331    fn a_well_formed_section_parses() {
332        let dir = scratch("host.toml", "[probe]\ncache = true\n");
333
334        let config = Probe::from_section_file_path(dir.path().join("host.toml"), "probe").unwrap();
335        assert!(config.cache);
336    }
337}