Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ You will need a Quicknode API key to get started. Once you have that, you can ru
1. `--api-key <KEY>` flag
2. The config file: the `--config-file <PATH>` 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
Expand Down Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <PATH>"
.to_string(),
)
})?;

let key = match args.api_key.or(global.api_key) {
Expand Down Expand Up @@ -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 <PATH>"
.to_string(),
)
})?;
config::delete_config(&path)?;
if !global.quiet {
Expand Down
42 changes: 35 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
config_dir().map(|d| d.join("qn").join("config.toml"))
}
Expand All @@ -82,21 +85,24 @@ fn config_dir() -> Option<PathBuf> {
resolve_config_dir(
std::env::var_os("XDG_CONFIG_HOME"),
std::env::var_os("HOME"),
std::env::var_os("USERPROFILE"),
)
}

/// Pure version of [`config_dir`] for testing.
fn resolve_config_dir(
xdg_config_home: Option<std::ffi::OsString>,
home: Option<std::ffi::OsString>,
userprofile: Option<std::ffi::OsString>,
) -> Option<PathBuf> {
if let Some(xdg) = xdg_config_home {
let p = PathBuf::from(xdg);
if p.is_absolute() {
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.
Expand Down Expand Up @@ -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"));
Expand All @@ -348,20 +355,41 @@ 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"));
}

#[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]
Expand Down
Loading