diff --git a/config/src/files.rs b/config/src/files.rs index 43bb4059e..3eaaf42ab 100644 --- a/config/src/files.rs +++ b/config/src/files.rs @@ -13,6 +13,7 @@ //! whose files are pkl or `.npmrc` writes its own layer against [`Layer`] and takes nothing it //! does not use. +use std::ffi::OsString; use std::path::{Component, Path, PathBuf, Prefix}; use crate::layer::{Layer, LayerCtx, LayerError, LayerOutput}; @@ -36,6 +37,48 @@ pub enum Format { Yaml, } +/// Which XDG base directory a file lives under. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum XdgBase { + /// `XDG_CONFIG_HOME` and `XDG_CONFIG_DIRS`. + Config, + /// `XDG_DATA_HOME` and `XDG_DATA_DIRS`. + Data, + /// `XDG_STATE_HOME`. + State, + /// `XDG_CACHE_HOME`. + Cache, + /// `XDG_RUNTIME_DIR`, which has no fallback. + Runtime, +} + +#[derive(Default)] +struct XdgEnv { + home: Option, + config_home: Option, + config_dirs: Option, + data_home: Option, + data_dirs: Option, + state_home: Option, + cache_home: Option, + runtime_dir: Option, +} + +impl XdgEnv { + fn from_process() -> Self { + Self { + home: std::env::var_os("HOME"), + config_home: std::env::var_os("XDG_CONFIG_HOME"), + config_dirs: std::env::var_os("XDG_CONFIG_DIRS"), + data_home: std::env::var_os("XDG_DATA_HOME"), + data_dirs: std::env::var_os("XDG_DATA_DIRS"), + state_home: std::env::var_os("XDG_STATE_HOME"), + cache_home: std::env::var_os("XDG_CACHE_HOME"), + runtime_dir: std::env::var_os("XDG_RUNTIME_DIR"), + } + } +} + impl Format { /// The format an extension implies, when one does. /// @@ -64,7 +107,7 @@ impl Format { /// accident, and that is the check a `scope="global"` setting exists for. pub struct FileLayer { paths: Vec, - scope: FileScope, + scopes: Vec, format: Option, prefix: Option, preprocess: Option, @@ -75,7 +118,75 @@ impl FileLayer { pub fn at(path: impl Into, scope: FileScope) -> Self { Self { paths: vec![path.into()], - scope, + scopes: vec![scope], + format: None, + prefix: None, + preprocess: None, + } + } + + /// A file under an XDG base directory. + /// + /// `path` is relative to the chosen base, for example `"ex/config.toml"`. Config and + /// data files include their system search directories and are read in ascending + /// precedence, with the user file last. State and cache use their standard + /// `$HOME/.local/state` and `$HOME/.cache` fallbacks; the runtime directory has no + /// fallback. Relative paths in XDG variables are invalid and are ignored. + /// + /// This constructor reads the process environment. Use [`FileLayer::at`] when the + /// application has already resolved or overridden its config directory. + pub fn xdg(base: XdgBase, path: impl AsRef) -> Self { + let path = path.as_ref(); + assert!( + is_xdg_relative(path), + "an XDG path must be relative to its base and cannot traverse a parent" + ); + Self::xdg_from(base, path, XdgEnv::from_process()) + } + + fn xdg_from(base: XdgBase, path: &Path, env: XdgEnv) -> Self { + let mut paths = Vec::new(); + let mut scopes = Vec::new(); + + let system_dirs: Vec = match base { + XdgBase::Config => xdg_dirs(env.config_dirs, &["/etc/xdg"]), + XdgBase::Data => xdg_dirs(env.data_dirs, &["/usr/local/share", "/usr/share"]), + XdgBase::State | XdgBase::Cache | XdgBase::Runtime => Vec::new(), + }; + // XDG *_DIRS are highest-precedence first, while one FileLayer is merged in the order + // stored. Reverse them so the first directory gets the last word. + for dir in system_dirs.into_iter().rev() { + paths.push(dir.join(path)); + scopes.push(FileScope::System); + } + + let (user, fallback) = match base { + XdgBase::Config => (env.config_home, Some(Path::new(".config"))), + XdgBase::Data => (env.data_home, Some(Path::new(".local/share"))), + XdgBase::State => (env.state_home, Some(Path::new(".local/state"))), + XdgBase::Cache => (env.cache_home, Some(Path::new(".cache"))), + XdgBase::Runtime => (env.runtime_dir, None), + }; + let user_dir = match user.filter(|value| !value.is_empty()) { + Some(value) => { + let dir = PathBuf::from(value); + dir.is_absolute().then_some(dir) + } + None => env + .home + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .zip(fallback) + .map(|(dir, suffix)| dir.join(suffix)), + }; + if let Some(dir) = user_dir { + paths.push(dir.join(path)); + scopes.push(FileScope::Global); + } + + Self { + paths, + scopes, format: None, prefix: None, preprocess: None, @@ -124,9 +235,10 @@ impl FileLayer { dir = current.parent(); } found.reverse(); + let found_len = found.len(); Self { paths: found, - scope, + scopes: vec![scope; found_len], format: None, prefix: None, preprocess: None, @@ -167,7 +279,13 @@ impl FileLayer { &self.paths } - fn read(&self, path: &Path, ctx: &LayerCtx, out: &mut LayerOutput) -> Result<(), LayerError> { + fn read( + &self, + path: &Path, + scope: FileScope, + ctx: &LayerCtx, + out: &mut LayerOutput, + ) -> Result<(), LayerError> { // Absent is the normal case, not a failure: a find-up chain is mostly directories with // no config file in them. *Only* absent, though — a file that is there and cannot be // read is the case this module's own doc calls an error, and `read_to_string` also @@ -231,7 +349,7 @@ impl FileLayer { } })?; for (key, found) in flat { - let origin = Origin::file(format!("{}#{key}", path.display()), self.scope); + let origin = Origin::file(format!("{}#{key}", path.display()), scope); match found { // Through `entry_for_key`, which is what makes an unknown key a warning, // follows a rename while remembering the name that was written, and reads the @@ -262,13 +380,47 @@ impl Layer for FileLayer { fn load(&self, ctx: &LayerCtx) -> Result { let mut out = LayerOutput::new(); - for path in &self.paths { - self.read(path, ctx, &mut out)?; + for (path, scope) in self.paths.iter().zip(self.scopes.iter().copied()) { + self.read(path, scope, ctx, &mut out)?; } Ok(out) } } +fn xdg_dirs(value: Option, defaults: &[&str]) -> Vec { + match value.filter(|value| !value.is_empty()) { + Some(value) => std::env::split_paths(&value) + .filter(|dir| dir.is_absolute()) + .collect(), + None => defaults.iter().map(PathBuf::from).collect(), + } +} + +/// Whether a path stays beneath an XDG base on both Unix and Windows. +/// +/// The host's [`Component`]s catch its native absolute and parent forms. The text checks catch +/// Windows forms while cross-compiling or testing on Unix, where a backslash and drive prefix +/// would otherwise be ordinary filename characters. +fn is_xdg_relative(path: &Path) -> bool { + if path.as_os_str().is_empty() + || path.components().any(|component| { + matches!( + component, + Component::Prefix(_) | Component::RootDir | Component::ParentDir + ) + }) + { + return false; + } + let Some(text) = path.to_str() else { + return true; + }; + let bytes = text.as_bytes(); + let windows_prefix = text.starts_with('\\') + || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'); + !windows_prefix && !text.split(['/', '\\']).any(|part| part == "..") +} + /// What one key in a file turned out to hold. enum Read { /// Text, which the spec's own named parser and declared type will make sense of. @@ -920,6 +1072,138 @@ mod tests { } } + #[test] + fn xdg_paths_are_relative_under_unix_and_windows_rules() { + for path in ["ex/config.toml", "ex/./config.toml", "config.toml"] { + assert!(is_xdg_relative(Path::new(path)), "{path}"); + } + for path in [ + "", + "/tmp/config.toml", + "../config.toml", + "ex/../../config.toml", + r"\Windows\config.toml", + r"C:\config.toml", + r"C:config.toml", + r"..\config.toml", + r"ex\..\config.toml", + ] { + assert!(!is_xdg_relative(Path::new(path)), "{path}"); + } + } + + #[test] + #[should_panic(expected = "an XDG path must be relative")] + fn the_public_xdg_constructor_rejects_an_escaping_path() { + let _ = FileLayer::xdg(XdgBase::Config, "../config.toml"); + } + + #[cfg(unix)] + #[test] + fn xdg_paths_put_the_user_above_system_directories() { + let layer = FileLayer::xdg_from( + XdgBase::Config, + Path::new("ex/config.toml"), + XdgEnv { + config_home: Some(OsString::from("/home/user/config")), + config_dirs: Some(OsString::from("/etc/xdg:/opt/xdg")), + ..Default::default() + }, + ); + assert_eq!( + layer.paths(), + &[ + PathBuf::from("/opt/xdg/ex/config.toml"), + PathBuf::from("/etc/xdg/ex/config.toml"), + PathBuf::from("/home/user/config/ex/config.toml"), + ] + ); + assert_eq!( + layer.scopes, + [FileScope::System, FileScope::System, FileScope::Global] + ); + } + + #[cfg(unix)] + #[test] + fn xdg_paths_use_the_spec_defaults() { + let layer = FileLayer::xdg_from( + XdgBase::Config, + Path::new("ex/config.toml"), + XdgEnv { + home: Some(OsString::from("/home/user")), + config_home: Some(OsString::new()), + config_dirs: Some(OsString::new()), + ..Default::default() + }, + ); + assert_eq!( + layer.paths(), + &[ + PathBuf::from("/etc/xdg/ex/config.toml"), + PathBuf::from("/home/user/.config/ex/config.toml"), + ] + ); + } + + #[cfg(unix)] + #[test] + fn xdg_paths_ignore_relative_environment_values() { + let layer = FileLayer::xdg_from( + XdgBase::Config, + Path::new("ex/config.toml"), + XdgEnv { + home: Some(OsString::from("/home/user")), + config_home: Some(OsString::from("relative/user")), + config_dirs: Some(OsString::from("relative:/etc/ex:also-relative")), + ..Default::default() + }, + ); + assert_eq!(layer.paths(), &[PathBuf::from("/etc/ex/ex/config.toml")]); + assert_eq!(layer.scopes, [FileScope::System]); + } + + #[cfg(unix)] + #[test] + fn each_xdg_base_uses_its_own_home_and_fallback() { + let env = || XdgEnv { + home: Some(OsString::from("/home/user")), + ..Default::default() + }; + let path = Path::new("ex/value"); + assert_eq!( + FileLayer::xdg_from(XdgBase::Data, path, env()).paths(), + &[ + PathBuf::from("/usr/share/ex/value"), + PathBuf::from("/usr/local/share/ex/value"), + PathBuf::from("/home/user/.local/share/ex/value"), + ] + ); + assert_eq!( + FileLayer::xdg_from(XdgBase::State, path, env()).paths(), + &[PathBuf::from("/home/user/.local/state/ex/value")] + ); + assert_eq!( + FileLayer::xdg_from(XdgBase::Cache, path, env()).paths(), + &[PathBuf::from("/home/user/.cache/ex/value")] + ); + assert!(FileLayer::xdg_from(XdgBase::Runtime, path, env()) + .paths() + .is_empty()); + assert_eq!( + FileLayer::xdg_from( + XdgBase::Runtime, + path, + XdgEnv { + runtime_dir: Some(OsString::from("/run/user/1000")), + ..Default::default() + } + ) + .paths(), + &[PathBuf::from("/run/user/1000/ex/value")] + ); + } + #[cfg(feature = "toml")] #[test] fn a_file_supplies_values_the_spec_understands() { diff --git a/config/src/lib.rs b/config/src/lib.rs index b32481930..ec30cf08c 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -77,7 +77,7 @@ pub use cli::CliLayer; pub use env::EnvLayer; pub use explain::explain; #[cfg(any(feature = "toml", feature = "json", feature = "yaml"))] -pub use files::{FileLayer, Format}; +pub use files::{FileLayer, Format, XdgBase}; pub use layer::{Entry, Layer, LayerCtx, LayerError, LayerOutput, Warning, WarningKind}; pub use props::{concat_prop_specs, concat_props, Props}; pub use read::{Fold, FromValue, ReadError, ReadErrorKind, ReadErrors}; diff --git a/conformance/tests/derive_config.rs b/conformance/tests/derive_config.rs index 6dac326c0..d748da9e0 100644 --- a/conformance/tests/derive_config.rs +++ b/conformance/tests/derive_config.rs @@ -43,6 +43,7 @@ struct TaskSettings { set_hint = "git config {key} {value}" ))] #[usage(file(path = "/etc/ex.toml", scope = "system", format = "toml"))] +#[usage(file(path = "ex/config.toml", xdg = "config"))] #[usage(file(path = "ex.toml", findup))] struct Settings { /// How many jobs to run at once @@ -289,10 +290,23 @@ fn the_emitted_config_block_is_the_spec_grammar() { .iter() .map(|file| file.path.as_str()) .collect::>(), - vec!["/etc/ex.toml", "ex.toml"], + vec![ + "/etc/ex.toml", + "$XDG_CONFIG_DIRS/ex/config.toml", + "$XDG_CONFIG_HOME/ex/config.toml", + "ex.toml" + ], "file declaration order is precedence" ); - assert!(spec.config.files[1].findup); + assert_eq!( + spec.config.files[1].scope, + usage::spec::config::SpecConfigFileScope::System + ); + assert_eq!( + spec.config.files[2].scope, + usage::spec::config::SpecConfigFileScope::Global + ); + assert!(spec.config.files[3].findup); assert!( !spec .config diff --git a/derive/src/config.rs b/derive/src/config.rs index 25f4c5869..1dd67fc3d 100644 --- a/derive/src/config.rs +++ b/derive/src/config.rs @@ -98,6 +98,7 @@ struct Source { struct File { path: String, findup: bool, + xdg: Option, scope: FileScope, format: Option, } @@ -109,6 +110,15 @@ enum FileScope { System, } +#[derive(Clone, Copy)] +enum XdgBase { + Config, + Data, + State, + Cache, + Runtime, +} + enum Field { Prop(Box), /// Another `Config` struct whose props follow this position in the joined registry. @@ -494,6 +504,7 @@ fn file_decl(meta: &Meta) -> syn::Result { let mut path = None; let mut findup = false; let mut saw_findup = false; + let mut xdg = None; let mut scope = FileScope::Project; let mut saw_scope = false; let mut format = None; @@ -512,6 +523,28 @@ fn file_decl(meta: &Meta) -> syn::Result { findup = flag_value(&item)?; saw_findup = true; } + "xdg" => { + if xdg.is_some() { + return Err(syn::Error::new_spanned(&item, "`xdg` is given twice")); + } + let value = string_value(&item)?; + xdg = Some(match value.as_str() { + "config" => XdgBase::Config, + "data" => XdgBase::Data, + "state" => XdgBase::State, + "cache" => XdgBase::Cache, + "runtime" => XdgBase::Runtime, + _ => { + return Err(syn::Error::new_spanned( + &item, + format!( + "`{value}` is not an XDG base; use `config`, `data`, `state`, \ + `cache`, or `runtime`" + ), + )) + } + }); + } "scope" => { if saw_scope { return Err(syn::Error::new_spanned(&item, "`scope` is given twice")); @@ -544,7 +577,7 @@ fn file_decl(meta: &Meta) -> syn::Result { item.path(), format!( "a config file does not understand `{other}`; use `path`, `findup`, \ - `scope`, or `format`" + `xdg`, `scope`, or `format`" ), )) } @@ -558,9 +591,23 @@ fn file_decl(meta: &Meta) -> syn::Result { "a config file path cannot be empty", )); } + if xdg.is_some() && (saw_findup || saw_scope) { + return Err(syn::Error::new_spanned( + meta, + "an XDG file's base decides its scope; do not combine `xdg` with `findup` or \ + `scope`", + )); + } + if xdg.is_some() && std::path::Path::new(&path).is_absolute() { + return Err(syn::Error::new_spanned( + meta, + "an XDG config file path must be relative to the XDG config directories", + )); + } Ok(File { path, findup, + xdg, scope, format, }) @@ -1416,21 +1463,49 @@ pub fn emit(config: &Config) -> TokenStream { set_hint: #set_hint, }) }); - let files = config.files.iter().map(|file| { + let files = config.files.iter().flat_map(|file| { let path = &file.path; - let findup = file.findup; - let scope = match file.scope { - FileScope::Project => quote!(#cfg::FileScope::Project), - FileScope::Global => quote!(#cfg::FileScope::Global), - FileScope::System => quote!(#cfg::FileScope::System), - }; let format = option_str(&file.format); - quote!(#cfg::SpecFile { - path: #path, - findup: #findup, - scope: #scope, - format: #format, - }) + if let Some(base) = file.xdg { + let (dirs, home) = match base { + XdgBase::Config => (Some("$XDG_CONFIG_DIRS"), "$XDG_CONFIG_HOME"), + XdgBase::Data => (Some("$XDG_DATA_DIRS"), "$XDG_DATA_HOME"), + XdgBase::State => (None, "$XDG_STATE_HOME"), + XdgBase::Cache => (None, "$XDG_CACHE_HOME"), + XdgBase::Runtime => (None, "$XDG_RUNTIME_DIR"), + }; + let global = format!("{home}/{path}"); + let mut expanded = Vec::new(); + if let Some(dirs) = dirs { + let system = format!("{dirs}/{path}"); + expanded.push(quote!(#cfg::SpecFile { + path: #system, + findup: false, + scope: #cfg::FileScope::System, + format: #format, + })); + } + expanded.push(quote!(#cfg::SpecFile { + path: #global, + findup: false, + scope: #cfg::FileScope::Global, + format: #format, + })); + expanded + } else { + let findup = file.findup; + let scope = match file.scope { + FileScope::Project => quote!(#cfg::FileScope::Project), + FileScope::Global => quote!(#cfg::FileScope::Global), + FileScope::System => quote!(#cfg::FileScope::System), + }; + vec![quote!(#cfg::SpecFile { + path: #path, + findup: #findup, + scope: #scope, + format: #format, + })] + } }); // Reads in declaration order, advancing a cursor: one id per own prop, a group's length @@ -2343,6 +2418,7 @@ mod tests { r#" #[usage(source(kind = "git", name = "git config"))] #[usage(file(path = "ex.toml", findup, scope = "project"))] + #[usage(file(path = "ex/config.toml", xdg = "config", format = "toml"))] struct Settings { jobs: u64, } @@ -2393,6 +2469,31 @@ mod tests { ); assert!(err.contains("not a config file scope"), "unhelpful: {err}"); + for declaration in [ + r#"file(path = "ex/config.toml", xdg = "config", findup)"#, + r#"file(path = "ex/config.toml", xdg = "cache", scope = "global")"#, + ] { + let err = rejection(&format!( + r#" + #[usage({declaration})] + struct Settings {{ + jobs: u64, + }} + "# + )); + assert!(err.contains("base decides its scope"), "{err}"); + } + + let err = rejection( + r#" + #[usage(file(path = "ex/value", xdg = "local"))] + struct Settings { + jobs: u64, + } + "#, + ); + assert!(err.contains("not an XDG base"), "{err}"); + let err = rejection( r#" struct Settings { diff --git a/docs/rust/configuration.md b/docs/rust/configuration.md index 27c382054..04b907a9c 100644 --- a/docs/rust/configuration.md +++ b/docs/rust/configuration.md @@ -71,11 +71,12 @@ exactly as they do for flags. Struct-level attributes declare the `config` block around those settings. They are documentation for resolvers the CLI already owns, not extra runtime layers: -| Attribute | Effect | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `prefix = "task"` | Prefix every field's key in this struct | -| `source(kind = "git", name = "git config", doc_hint = "…", set_hint = "…")` | A custom source kind's display metadata. Repeatable; kinds are written in sorted order | -| `file(path = "ex.toml", findup, scope = "project", format = "toml")` | A config file in the documented precedence chain. Repeatable; **declaration order is precedence**, last wins | +| Attribute | Effect | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `prefix = "task"` | Prefix every field's key in this struct | +| `source(kind = "git", name = "git config", doc_hint = "…", set_hint = "…")` | A custom source kind's display metadata. Repeatable; kinds are written in sorted order | +| `file(path = "ex.toml", findup, scope = "project", format = "toml")` | A config file in the documented precedence chain. Repeatable; **declaration order is precedence**, last wins | +| `file(path = "ex/config.toml", xdg = "config", format = "toml")` | XDG config, data, state, cache, or runtime locations; expands into their standard precedence in the emitted spec | `source` and `file` belong to the struct they are written on. Flattening another `Config` type splices its _settings_, not its source/file declarations — a nested group that also @@ -153,15 +154,20 @@ struct. The CLI names the layers it has — that stays its own business — and decides what every value means: ```rust -use usage::config::{resolve, EnvLayer, FileLayer, FileScope, Layers}; +use usage::config::{resolve, EnvLayer, FileLayer, FileScope, Layers, XdgBase}; let (cli, cli_layer) = Ex::parse_from_with_settings(&argv)?; let env = EnvLayer::from_process(); // FileLayer needs a format feature on usage-config (`toml`, `json`, or `yaml`). +let user_and_system = FileLayer::xdg(XdgBase::Config, "ex/config.toml"); let project = FileLayer::find_up("ex.toml", &cwd, None, FileScope::Project); let resolved = resolve( Settings::SETTINGS_REGISTRY, - Layers::new().then(&cli_layer).then(&env).then(&project), + Layers::new() + .then(&cli_layer) + .then(&env) + .then(&project) + .then(&user_and_system), )?; let settings = Settings::read(&resolved)?; for warning in usage::config::explain::warnings(&resolved) { @@ -169,6 +175,15 @@ for warning in usage::config::explain::warnings(&resolved) { } ``` +Pair `#[usage(file(path = "ex/config.toml", xdg = "config"))]` on the derived settings +struct with `FileLayer::xdg(XdgBase::Config, "ex/config.toml")` at runtime. The base may +instead be `data`, `state`, `cache`, or `runtime`. The derive expands the declaration into +the corresponding paths in the emitted spec, while `FileLayer::xdg` reads the process's XDG +locations in their standard precedence. For the config base: +the user file under `XDG_CONFIG_HOME` wins over files under `XDG_CONFIG_DIRS`, and the first +directory in `XDG_CONFIG_DIRS` wins over later ones. Unset variables fall back to +`$HOME/.config` and `/etc/xdg`. + `read` visits every field before returning, so the error is the whole list of what is wrong rather than the first thing found. Provenance is the merge's own output: `explain`, `list`, and per-setting `origin` come free, without a second merge to drift from the first.