From c5da0a425d3f48c56e64978d96682e9c79205752 Mon Sep 17 00:00:00 2001 From: John Mitsch Date: Thu, 11 Jun 2026 14:10:31 -0400 Subject: [PATCH] fix(config): fall back to USERPROFILE for config dir on Windows (DX-5667) On Windows, shells set USERPROFILE rather than HOME, so config_dir() returned None and 'qn auth login' failed with 'no config directory available on this platform'. The home directory now resolves from HOME first, then USERPROFILE, keeping the same ~/.config/qn/config.toml layout on every platform. Unix behavior is unchanged. Also makes the no-config-directory error actionable (name the env vars to set, or --config-file) and documents the Windows path in the README. Adds 3 unit tests for the resolver (USERPROFILE fallback, HOME precedence, all-unset). --- README.md | 6 ++++-- src/commands/auth.rs | 10 ++++++++-- src/config.rs | 42 +++++++++++++++++++++++++++++++++++------- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5e60eda..3123533 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,8 @@ You will need a Quicknode API key to get started. Once you have that, you can ru 1. `--api-key ` flag 2. The config file: the `--config-file ` flag if given, otherwise `~/.config/qn/config.toml` — or `$XDG_CONFIG_HOME/qn/config.toml` if that - env var is set. Managed by `qn auth login`. + env var is set. The same layout applies on Windows: + `%USERPROFILE%\.config\qn\config.toml`. Managed by `qn auth login`. There is deliberately **no environment-variable key source**: a key left exported in a shell is invisible state that outlives the session it was set @@ -219,7 +220,8 @@ qn completions powershell > qn.ps1 `qn` reads no API credentials from the environment (see [Authentication](#authentication) for why). The conventional variables are honored: `NO_COLOR` and `TERM=dumb` disable color, and -`XDG_CONFIG_HOME`/`HOME` locate the default config file. The CLI hands the +`XDG_CONFIG_HOME`/`HOME` (`USERPROFILE` on Windows) locate the default +config file. The CLI hands the key to the SDK explicitly; it does not read the SDK's `QN_SDK__*` environment namespace. diff --git a/src/commands/auth.rs b/src/commands/auth.rs index ab3786f..3bcf67b 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -56,7 +56,10 @@ pub async fn run(args: Args, global: GlobalArgs) -> Result<(), CliError> { async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> { let path = global.resolve_config_path().ok_or_else(|| { - CliError::Arg("no config directory available on this platform".to_string()) + CliError::Arg( + "no config directory available: set HOME (or USERPROFILE on Windows), or pass --config-file " + .to_string(), + ) })?; let key = match args.api_key.or(global.api_key) { @@ -88,7 +91,10 @@ async fn login(args: LoginArgs, global: GlobalArgs) -> Result<(), CliError> { fn logout(global: GlobalArgs) -> Result<(), CliError> { let path = global.resolve_config_path().ok_or_else(|| { - CliError::Arg("no config directory available on this platform".to_string()) + CliError::Arg( + "no config directory available: set HOME (or USERPROFILE on Windows), or pass --config-file " + .to_string(), + ) })?; config::delete_config(&path)?; if !global.quiet { diff --git a/src/config.rs b/src/config.rs index 64d0a1a..b21db60 100644 --- a/src/config.rs +++ b/src/config.rs @@ -70,10 +70,13 @@ pub struct OutputSection { /// Returns the canonical config path: `$XDG_CONFIG_HOME/qn/config.toml` if the /// env var is set, otherwise `~/.config/qn/config.toml`. We use the same path /// on every platform — easier to document, easier to share across machines — -/// rather than the OS-native `directories`-crate locations. +/// rather than the OS-native `directories`-crate locations. The home directory +/// comes from `$HOME`, falling back to `%USERPROFILE%` (Windows shells set the +/// latter, not the former). /// -/// Returns `None` only if neither `$XDG_CONFIG_HOME` nor `$HOME` is set, which -/// would mean the user's shell environment is broken. +/// Returns `None` only if none of `$XDG_CONFIG_HOME`, `$HOME`, or +/// `%USERPROFILE%` is set, which would mean the user's shell environment is +/// broken. pub fn config_path() -> Option { config_dir().map(|d| d.join("qn").join("config.toml")) } @@ -82,6 +85,7 @@ fn config_dir() -> Option { resolve_config_dir( std::env::var_os("XDG_CONFIG_HOME"), std::env::var_os("HOME"), + std::env::var_os("USERPROFILE"), ) } @@ -89,6 +93,7 @@ fn config_dir() -> Option { fn resolve_config_dir( xdg_config_home: Option, home: Option, + userprofile: Option, ) -> Option { if let Some(xdg) = xdg_config_home { let p = PathBuf::from(xdg); @@ -96,7 +101,8 @@ fn resolve_config_dir( return Some(p); } } - home.map(|h| PathBuf::from(h).join(".config")) + home.or(userprofile) + .map(|h| PathBuf::from(h).join(".config")) } /// Loads the config file at `path`, returning `Ok(None)` if it doesn't exist. @@ -337,6 +343,7 @@ mod tests { let d = resolve_config_dir( Some(std::ffi::OsString::from("/custom/xdg")), Some(std::ffi::OsString::from("/home/u")), + None, ) .unwrap(); assert_eq!(d, PathBuf::from("/custom/xdg")); @@ -348,6 +355,7 @@ mod tests { let d = resolve_config_dir( Some(std::ffi::OsString::from("relative/path")), Some(std::ffi::OsString::from("/home/u")), + None, ) .unwrap(); assert_eq!(d, PathBuf::from("/home/u/.config")); @@ -355,13 +363,33 @@ mod tests { #[test] fn config_dir_falls_back_to_home_dot_config() { - let d = resolve_config_dir(None, Some(std::ffi::OsString::from("/home/u"))).unwrap(); + let d = resolve_config_dir(None, Some(std::ffi::OsString::from("/home/u")), None).unwrap(); assert_eq!(d, PathBuf::from("/home/u/.config")); } #[test] - fn config_dir_returns_none_without_home() { - assert!(resolve_config_dir(None, None).is_none()); + fn config_dir_falls_back_to_userprofile_without_home() { + // Windows shells set USERPROFILE, not HOME. + let userprofile = std::ffi::OsString::from(r"C:\Users\u"); + let d = resolve_config_dir(None, None, Some(userprofile.clone())).unwrap(); + assert_eq!(d, PathBuf::from(userprofile).join(".config")); + } + + #[test] + fn config_dir_prefers_home_over_userprofile() { + // e.g. Git Bash on Windows sets both; HOME wins. + let d = resolve_config_dir( + None, + Some(std::ffi::OsString::from("/home/u")), + Some(std::ffi::OsString::from(r"C:\Users\u")), + ) + .unwrap(); + assert_eq!(d, PathBuf::from("/home/u/.config")); + } + + #[test] + fn config_dir_returns_none_without_home_or_userprofile() { + assert!(resolve_config_dir(None, None, None).is_none()); } #[test]