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}