From d840412bfb6831edc7f3d887e5262a28a76aa2ed Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Wed, 4 Mar 2026 18:31:47 -0700 Subject: [PATCH 1/3] feat: multi-profile auth via ~/.config/lineark/config.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single ~/.linear_api_token file only supports one workspace at a time. This adds TOML-based profile support (similar to AWS CLI profiles) so users can store multiple tokens and switch between them. Config file format: [profiles.default] api_token = "lin_api_..." [profiles.work] api_token = "lin_api_..." New auth precedence: 1. --api-token flag 2. $LINEAR_API_TOKEN env var 3. --profile flag / $LINEAR_PROFILE env var → named profile 4. [profiles.default] in config file 5. ~/.linear_api_token legacy fallback SDK: adds Client::from_profile(), updates Client::auto() to accept optional profile name, adds toml dependency. CLI: adds --profile global flag, updates usage output with config file documentation. --- Cargo.lock | 1 + crates/lineark-sdk/Cargo.toml | 1 + crates/lineark-sdk/src/auth.rs | 176 ++++++++++++++++++++-- crates/lineark-sdk/src/blocking_client.rs | 19 ++- crates/lineark-sdk/src/client.rs | 16 +- crates/lineark-sdk/src/helpers.rs | 4 +- crates/lineark/src/commands/usage.rs | 29 +++- crates/lineark/src/main.rs | 8 +- 8 files changed, 229 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d21247f..59cd70b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -913,6 +913,7 @@ dependencies = [ "tempfile", "test-with", "tokio", + "toml", "url", "uuid", "wiremock", diff --git a/crates/lineark-sdk/Cargo.toml b/crates/lineark-sdk/Cargo.toml index b3afeb4..5e130a3 100644 --- a/crates/lineark-sdk/Cargo.toml +++ b/crates/lineark-sdk/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } home = "0.5" +toml = "0.8" url = "2" lineark-derive = { path = "../lineark-derive", version = "0.0.0" } diff --git a/crates/lineark-sdk/src/auth.rs b/crates/lineark-sdk/src/auth.rs index 65783ee..bf20ee4 100644 --- a/crates/lineark-sdk/src/auth.rs +++ b/crates/lineark-sdk/src/auth.rs @@ -1,15 +1,32 @@ //! API token resolution. //! -//! Supports three sources (in precedence order): explicit token, the -//! `LINEAR_API_TOKEN` environment variable, and `~/.linear_api_token` file. +//! Supports multiple sources (in precedence order): +//! 1. Explicit token (CLI flag) +//! 2. `LINEAR_API_TOKEN` environment variable +//! 3. Named profile from `~/.config/lineark/config.toml` +//! 4. `default` profile from `~/.config/lineark/config.toml` +//! 5. Legacy `~/.linear_api_token` file use crate::error::LinearError; +use std::collections::HashMap; use std::path::PathBuf; -/// Resolve a Linear API token from the filesystem. -/// Reads `~/.linear_api_token`. +/// A named profile from the config file. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct Profile { + pub api_token: String, +} + +/// Top-level config file structure. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct Config { + #[serde(default)] + pub profiles: HashMap, +} + +/// Resolve a Linear API token from the legacy `~/.linear_api_token` file. pub fn token_from_file() -> Result { - let path = token_file_path()?; + let path = legacy_token_path()?; std::fs::read_to_string(&path) .map(|s| s.trim().to_string()) .map_err(|e| { @@ -31,13 +48,74 @@ pub fn token_from_env() -> Result { } } -/// Resolve a Linear API token with precedence: env var -> file. -/// (CLI flag takes highest precedence but is handled at the CLI layer.) -pub fn auto_token() -> Result { - token_from_env().or_else(|_| token_from_file()) +/// Resolve a Linear API token from a named profile in `~/.config/lineark/config.toml`. +/// +/// If `profile` is `None`, looks up the `default` profile. +pub fn token_from_config(profile: Option<&str>) -> Result { + let path = config_file_path()?; + let contents = std::fs::read_to_string(&path).map_err(|e| { + LinearError::AuthConfig(format!( + "Could not read config file {}: {}", + path.display(), + e + )) + })?; + + let config: Config = toml::from_str(&contents).map_err(|e| { + LinearError::AuthConfig(format!("Invalid config file {}: {}", path.display(), e)) + })?; + + let name = profile.unwrap_or("default"); + let p = config.profiles.get(name).ok_or_else(|| { + LinearError::AuthConfig(format!( + "Profile '{}' not found in {}", + name, + path.display() + )) + })?; + + let token = p.api_token.trim().to_string(); + if token.is_empty() { + return Err(LinearError::AuthConfig(format!( + "Profile '{}' has an empty api_token", + name + ))); + } + Ok(token) +} + +/// Resolve a Linear API token with precedence: +/// env var -> profile config -> legacy file. +/// +/// If `profile` is `Some`, the config file lookup uses that profile name. +/// If `profile` is `None`, falls through: env -> config `default` -> legacy file. +pub fn auto_token(profile: Option<&str>) -> Result { + // If an explicit profile was requested, skip env and legacy — go straight to config. + if profile.is_some() { + return token_from_config(profile); + } + + // Check $LINEAR_PROFILE env var for profile selection. + if let Ok(env_profile) = std::env::var("LINEAR_PROFILE") { + let env_profile = env_profile.trim().to_string(); + if !env_profile.is_empty() { + return token_from_config(Some(&env_profile)); + } + } + + token_from_env() + .or_else(|_| token_from_config(None)) + .or_else(|_| token_from_file()) +} + +/// Path to the config file: `~/.config/lineark/config.toml`. +pub fn config_file_path() -> Result { + let home = home::home_dir() + .ok_or_else(|| LinearError::AuthConfig("Could not determine home directory".to_string()))?; + Ok(home.join(".config").join("lineark").join("config.toml")) } -fn token_file_path() -> Result { +fn legacy_token_path() -> Result { let home = home::home_dir() .ok_or_else(|| LinearError::AuthConfig("Could not determine home directory".to_string()))?; Ok(home.join(".linear_api_token")) @@ -91,7 +169,7 @@ mod tests { #[test] fn auto_token_prefers_env() { with_env_token(Some("env-token-auto"), || { - assert_eq!(auto_token().unwrap(), "env-token-auto"); + assert_eq!(auto_token(None).unwrap(), "env-token-auto"); }); } @@ -117,9 +195,81 @@ mod tests { } #[test] - fn token_file_path_is_home_based() { - let path = token_file_path().unwrap(); + fn legacy_token_path_is_home_based() { + let path = legacy_token_path().unwrap(); assert!(path.to_str().unwrap().contains(".linear_api_token")); assert!(path.to_str().unwrap().starts_with("/")); } + + #[test] + fn config_file_path_is_xdg_based() { + let path = config_file_path().unwrap(); + let s = path.to_str().unwrap(); + assert!(s.contains(".config/lineark/config.toml")); + assert!(s.starts_with("/")); + } + + #[test] + fn token_from_config_parses_valid_toml() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config").join("lineark"); + std::fs::create_dir_all(&config_dir).unwrap(); + let config_path = config_dir.join("config.toml"); + std::fs::write( + &config_path, + r#" +[profiles.default] +api_token = "lin_default_123" + +[profiles.work] +api_token = "lin_work_456" +"#, + ) + .unwrap(); + + // Parse directly to test the TOML structure + let contents = std::fs::read_to_string(&config_path).unwrap(); + let config: Config = toml::from_str(&contents).unwrap(); + assert_eq!(config.profiles["default"].api_token, "lin_default_123"); + assert_eq!(config.profiles["work"].api_token, "lin_work_456"); + assert_eq!(config.profiles.len(), 2); + } + + #[test] + fn config_missing_profile_errors() { + let contents = r#" +[profiles.default] +api_token = "lin_abc" +"#; + let config: Config = toml::from_str(contents).unwrap(); + assert!(config.profiles.get("nonexistent").is_none()); + } + + #[test] + fn config_empty_token_detected() { + let contents = r#" +[profiles.default] +api_token = " " +"#; + let config: Config = toml::from_str(contents).unwrap(); + assert!(config.profiles["default"].api_token.trim().is_empty()); + } + + #[test] + fn explicit_profile_skips_env() { + // When --profile is specified, env var should be ignored. + // We can't test the full auto_token flow without a real config file, + // but we verify the logic: explicit profile goes straight to config lookup. + with_env_token(Some("env-token"), || { + let result = auto_token(Some("nonexistent")); + // Should fail looking for "nonexistent" in config, NOT succeed with env token + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("nonexistent") || err.contains("config"), + "Expected config-related error, got: {}", + err + ); + }); + } } diff --git a/crates/lineark-sdk/src/blocking_client.rs b/crates/lineark-sdk/src/blocking_client.rs index b81262e..50e27f4 100644 --- a/crates/lineark-sdk/src/blocking_client.rs +++ b/crates/lineark-sdk/src/blocking_client.rs @@ -18,7 +18,7 @@ //! ```no_run //! use lineark_sdk::blocking_client::Client; //! -//! let client = Client::auto().unwrap(); +//! let client = Client::auto(None).unwrap(); //! let me = client.whoami().unwrap(); //! println!("Logged in as: {:?}", me.name); //! @@ -79,10 +79,21 @@ impl Client { }) } - /// Create a blocking client by auto-detecting the token (env -> file). - pub fn auto() -> Result { + /// Create a blocking client from a named profile in `~/.config/lineark/config.toml`. + pub fn from_profile(profile: Option<&str>) -> Result { Ok(Self { - inner: crate::Client::auto()?, + inner: crate::Client::from_profile(profile)?, + rt: build_runtime()?, + }) + } + + /// Create a blocking client by auto-detecting the token. + /// + /// Precedence: env var -> config profile -> legacy file. + /// If `profile` is `Some`, skips env var and goes straight to the config file. + pub fn auto(profile: Option<&str>) -> Result { + Ok(Self { + inner: crate::Client::auto(profile)?, rt: build_runtime()?, }) } diff --git a/crates/lineark-sdk/src/client.rs b/crates/lineark-sdk/src/client.rs index 626b01d..1e79b07 100644 --- a/crates/lineark-sdk/src/client.rs +++ b/crates/lineark-sdk/src/client.rs @@ -51,9 +51,19 @@ impl Client { Self::from_token(auth::token_from_file()?) } - /// Create a client by auto-detecting the token (env -> file). - pub fn auto() -> Result { - Self::from_token(auth::auto_token()?) + /// Create a client from a named profile in `~/.config/lineark/config.toml`. + /// + /// If `profile` is `None`, uses the `default` profile. + pub fn from_profile(profile: Option<&str>) -> Result { + Self::from_token(auth::token_from_config(profile)?) + } + + /// Create a client by auto-detecting the token. + /// + /// Precedence: env var -> config profile -> legacy file. + /// If `profile` is `Some`, skips env var and goes straight to the config file. + pub fn auto(profile: Option<&str>) -> Result { + Self::from_token(auth::auto_token(profile)?) } /// Execute a GraphQL query and extract a single object from the response. diff --git a/crates/lineark-sdk/src/helpers.rs b/crates/lineark-sdk/src/helpers.rs index 1efbd5d..90c3fcc 100644 --- a/crates/lineark-sdk/src/helpers.rs +++ b/crates/lineark-sdk/src/helpers.rs @@ -40,7 +40,7 @@ impl Client { /// /// ```no_run /// # async fn example() -> Result<(), lineark_sdk::LinearError> { - /// let client = lineark_sdk::Client::auto()?; + /// let client = lineark_sdk::Client::auto(None)?; /// let result = client.download_url("https://uploads.linear.app/...").await?; /// std::fs::write("output.png", &result.bytes).unwrap(); /// # Ok(()) @@ -108,7 +108,7 @@ impl Client { /// /// ```no_run /// # async fn example() -> Result<(), lineark_sdk::LinearError> { - /// let client = lineark_sdk::Client::auto()?; + /// let client = lineark_sdk::Client::auto(None)?; /// let bytes = std::fs::read("screenshot.png").unwrap(); /// let result = client /// .upload_file("screenshot.png", "image/png", bytes, false) diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..5359bdc 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -15,6 +15,23 @@ pub async fn run() { } else { "" }; + let config_hint = if std::env::var("HOME") + .map(|h| { + std::path::Path::new(&h) + .join(".config/lineark/config.toml") + .exists() + }) + .unwrap_or(false) + { + " (found)" + } else { + "" + }; + let profile_hint = if std::env::var("LINEAR_PROFILE").is_ok() { + " (set)" + } else { + "" + }; print!( r#"lineark — Linear CLI for humans and LLMs @@ -115,12 +132,22 @@ COMMANDS: GLOBAL OPTIONS: --api-token Override API token + --profile Use a named profile from config file --format human|json Force output format (auto-detected by default) AUTH (in precedence order): 1. --api-token flag 2. $LINEAR_API_TOKEN env var{env_hint} - 3. ~/.linear_api_token file{file_hint} + 3. --profile flag / $LINEAR_PROFILE env var{profile_hint} + 4. [profiles.default] in ~/.config/lineark/config.toml{config_hint} + 5. ~/.linear_api_token file{file_hint} + +CONFIG FILE (~/.config/lineark/config.toml): + [profiles.default] + api_token = "lin_api_..." + + [profiles.work] + api_token = "lin_api_..." "# ); diff --git a/crates/lineark/src/main.rs b/crates/lineark/src/main.rs index e8b2c39..a4f635a 100644 --- a/crates/lineark/src/main.rs +++ b/crates/lineark/src/main.rs @@ -9,10 +9,14 @@ use lineark_sdk::Client; #[derive(Debug, Parser)] #[command(name = "lineark", version, about, after_help = update_hint_blocking())] struct Cli { - /// API token (overrides $LINEAR_API_TOKEN and ~/.linear_api_token). + /// API token (overrides all other auth methods). #[arg(long, global = true)] api_token: Option, + /// Config profile to use from ~/.config/lineark/config.toml. + #[arg(long, global = true)] + profile: Option, + /// Output format. Auto-detected if not specified (human for terminal, json for pipe). #[arg(long, global = true)] format: Option, @@ -100,7 +104,7 @@ async fn main() { // Resolve client. let client = match &cli.api_token { Some(token) => Client::from_token(token), - None => Client::auto(), + None => Client::auto(cli.profile.as_deref()), }; let client = match client { Ok(c) => c, From 257d1581133bf3087de0d2738ce07708c30e5135 Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Wed, 4 Mar 2026 19:17:20 -0700 Subject: [PATCH 2/3] test: comprehensive tests for multi-profile auth SDK unit tests (10 new): - token_from_config_at: default profile, named profile, missing profile, empty token, whitespace trimming, missing file, malformed TOML, no profiles section, empty file, multiple profiles - auto_token precedence: env beats config, explicit profile skips env, LINEAR_PROFILE env var, empty/whitespace LINEAR_PROFILE ignored CLI offline tests (8 new): - --profile flag appears in --help and usage output - usage shows config file example with [profiles.default] - --profile with missing config file gives clear error - --profile with valid temp config resolves token correctly - default profile used when no flags set - LINEAR_PROFILE env var selects named profile - --profile with nonexistent profile name shows "not found" error - --api-token overrides --profile Refactored auth.rs to extract token_from_config_at(path, profile) so config parsing is testable against temp files without relying on the real ~/.config/lineark/config.toml. --- crates/lineark-sdk/src/auth.rs | 333 +++++++++++++++++++++++++------- crates/lineark/tests/offline.rs | 176 +++++++++++++++++ 2 files changed, 437 insertions(+), 72 deletions(-) diff --git a/crates/lineark-sdk/src/auth.rs b/crates/lineark-sdk/src/auth.rs index bf20ee4..4360148 100644 --- a/crates/lineark-sdk/src/auth.rs +++ b/crates/lineark-sdk/src/auth.rs @@ -9,19 +9,19 @@ use crate::error::LinearError; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// A named profile from the config file. #[derive(Debug, serde::Deserialize)] -pub(crate) struct Profile { - pub api_token: String, +struct Profile { + api_token: String, } /// Top-level config file structure. #[derive(Debug, serde::Deserialize)] -pub(crate) struct Config { +struct Config { #[serde(default)] - pub profiles: HashMap, + profiles: HashMap, } /// Resolve a Linear API token from the legacy `~/.linear_api_token` file. @@ -53,7 +53,15 @@ pub fn token_from_env() -> Result { /// If `profile` is `None`, looks up the `default` profile. pub fn token_from_config(profile: Option<&str>) -> Result { let path = config_file_path()?; - let contents = std::fs::read_to_string(&path).map_err(|e| { + token_from_config_at(&path, profile) +} + +/// Resolve a Linear API token from a named profile at a specific config file path. +/// +/// This is the testable core — `token_from_config` is a thin wrapper that +/// provides the default path. +fn token_from_config_at(path: &Path, profile: Option<&str>) -> Result { + let contents = std::fs::read_to_string(path).map_err(|e| { LinearError::AuthConfig(format!( "Could not read config file {}: {}", path.display(), @@ -126,74 +134,85 @@ mod tests { use super::*; use std::sync::Mutex; - /// Guards all tests that manipulate the `LINEAR_API_TOKEN` env var. - /// Tests run in parallel by default — without this, one test's `remove_var` - /// races with another test's `set_var`, causing spurious failures. + /// Guards all tests that manipulate env vars (`LINEAR_API_TOKEN`, `LINEAR_PROFILE`). static ENV_LOCK: Mutex<()> = Mutex::new(()); - /// Run a closure with `LINEAR_API_TOKEN` set to `value`, restoring the - /// original value (or removing it) when done — even on panic. - fn with_env_token(value: Option<&str>, f: F) { + /// Run a closure with specific env vars set, restoring originals when done. + fn with_env(vars: &[(&str, Option<&str>)], f: F) { let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let original = std::env::var("LINEAR_API_TOKEN").ok(); - match value { - Some(v) => std::env::set_var("LINEAR_API_TOKEN", v), - None => std::env::remove_var("LINEAR_API_TOKEN"), + let originals: Vec<(&str, Option)> = vars + .iter() + .map(|(key, _)| (*key, std::env::var(key).ok())) + .collect(); + for (key, value) in vars { + match value { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } } let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); - match &original { - Some(v) => std::env::set_var("LINEAR_API_TOKEN", v), - None => std::env::remove_var("LINEAR_API_TOKEN"), + for (key, original) in &originals { + match original { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } } if let Err(e) = result { std::panic::resume_unwind(e); } } + /// Helper to create a temp config file with the given TOML content. + fn write_temp_config(content: &str) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config").join("lineark"); + std::fs::create_dir_all(&config_dir).unwrap(); + let config_path = config_dir.join("config.toml"); + std::fs::write(&config_path, content).unwrap(); + (dir, config_path) + } + + // ── token_from_env tests ───────────────────────────────────────────── + #[test] fn token_from_env_success() { - with_env_token(Some("test-token-12345"), || { + with_env(&[("LINEAR_API_TOKEN", Some("test-token-12345"))], || { assert_eq!(token_from_env().unwrap(), "test-token-12345"); }); } #[test] fn token_from_env_missing() { - with_env_token(None, || { + with_env(&[("LINEAR_API_TOKEN", None)], || { let result = token_from_env(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("LINEAR_API_TOKEN")); }); } - #[test] - fn auto_token_prefers_env() { - with_env_token(Some("env-token-auto"), || { - assert_eq!(auto_token(None).unwrap(), "env-token-auto"); - }); - } - #[test] fn token_from_env_empty_string_is_treated_as_absent() { - with_env_token(Some(""), || { + with_env(&[("LINEAR_API_TOKEN", Some(""))], || { assert!(token_from_env().is_err()); }); } #[test] fn token_from_env_whitespace_only_is_treated_as_absent() { - with_env_token(Some(" "), || { + with_env(&[("LINEAR_API_TOKEN", Some(" "))], || { assert!(token_from_env().is_err()); }); } #[test] fn token_from_env_trims_whitespace() { - with_env_token(Some(" my-token "), || { + with_env(&[("LINEAR_API_TOKEN", Some(" my-token "))], || { assert_eq!(token_from_env().unwrap(), "my-token"); }); } + // ── path tests ─────────────────────────────────────────────────────── + #[test] fn legacy_token_path_is_home_based() { let path = legacy_token_path().unwrap(); @@ -209,14 +228,11 @@ mod tests { assert!(s.starts_with("/")); } + // ── token_from_config_at tests ─────────────────────────────────────── + #[test] - fn token_from_config_parses_valid_toml() { - let dir = tempfile::tempdir().unwrap(); - let config_dir = dir.path().join(".config").join("lineark"); - std::fs::create_dir_all(&config_dir).unwrap(); - let config_path = config_dir.join("config.toml"); - std::fs::write( - &config_path, + fn config_reads_default_profile() { + let (_dir, path) = write_temp_config( r#" [profiles.default] api_token = "lin_default_123" @@ -224,52 +240,225 @@ api_token = "lin_default_123" [profiles.work] api_token = "lin_work_456" "#, - ) - .unwrap(); - - // Parse directly to test the TOML structure - let contents = std::fs::read_to_string(&config_path).unwrap(); - let config: Config = toml::from_str(&contents).unwrap(); - assert_eq!(config.profiles["default"].api_token, "lin_default_123"); - assert_eq!(config.profiles["work"].api_token, "lin_work_456"); - assert_eq!(config.profiles.len(), 2); + ); + assert_eq!(token_from_config_at(&path, None).unwrap(), "lin_default_123"); } #[test] - fn config_missing_profile_errors() { - let contents = r#" + fn config_reads_named_profile() { + let (_dir, path) = write_temp_config( + r#" +[profiles.default] +api_token = "lin_default_123" + +[profiles.work] +api_token = "lin_work_456" +"#, + ); + assert_eq!( + token_from_config_at(&path, Some("work")).unwrap(), + "lin_work_456" + ); + } + + #[test] + fn config_missing_profile_returns_error() { + let (_dir, path) = write_temp_config( + r#" [profiles.default] api_token = "lin_abc" -"#; - let config: Config = toml::from_str(contents).unwrap(); - assert!(config.profiles.get("nonexistent").is_none()); +"#, + ); + let err = token_from_config_at(&path, Some("nonexistent")).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("nonexistent"), "Error should name the profile: {msg}"); + assert!(msg.contains("not found"), "Error should say not found: {msg}"); } #[test] - fn config_empty_token_detected() { - let contents = r#" + fn config_empty_token_returns_error() { + let (_dir, path) = write_temp_config( + r#" [profiles.default] api_token = " " -"#; - let config: Config = toml::from_str(contents).unwrap(); - assert!(config.profiles["default"].api_token.trim().is_empty()); +"#, + ); + let err = token_from_config_at(&path, None).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("empty"), "Error should mention empty token: {msg}"); } #[test] - fn explicit_profile_skips_env() { - // When --profile is specified, env var should be ignored. - // We can't test the full auto_token flow without a real config file, - // but we verify the logic: explicit profile goes straight to config lookup. - with_env_token(Some("env-token"), || { - let result = auto_token(Some("nonexistent")); - // Should fail looking for "nonexistent" in config, NOT succeed with env token - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("nonexistent") || err.contains("config"), - "Expected config-related error, got: {}", - err - ); - }); + fn config_trims_token_whitespace() { + let (_dir, path) = write_temp_config( + r#" +[profiles.default] +api_token = " lin_trimmed " +"#, + ); + assert_eq!(token_from_config_at(&path, None).unwrap(), "lin_trimmed"); + } + + #[test] + fn config_missing_file_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nonexistent.toml"); + let err = token_from_config_at(&path, None).unwrap_err(); + assert!( + err.to_string().contains("Could not read"), + "Error should mention reading: {}", + err + ); + } + + #[test] + fn config_malformed_toml_returns_error() { + let (_dir, path) = write_temp_config("this is not [valid toml }{"); + let err = token_from_config_at(&path, None).unwrap_err(); + assert!( + err.to_string().contains("Invalid config"), + "Error should mention invalid config: {}", + err + ); + } + + #[test] + fn config_no_profiles_section_returns_error() { + let (_dir, path) = write_temp_config( + r#" +[something_else] +key = "value" +"#, + ); + let err = token_from_config_at(&path, None).unwrap_err(); + assert!( + err.to_string().contains("not found"), + "Error should say profile not found: {}", + err + ); + } + + #[test] + fn config_empty_profiles_section_returns_error() { + let (_dir, path) = write_temp_config(""); + let err = token_from_config_at(&path, None).unwrap_err(); + assert!( + err.to_string().contains("not found"), + "Error should say profile not found: {}", + err + ); + } + + #[test] + fn config_multiple_profiles_are_independent() { + let (_dir, path) = write_temp_config( + r#" +[profiles.default] +api_token = "tok_default" + +[profiles.staging] +api_token = "tok_staging" + +[profiles.production] +api_token = "tok_production" +"#, + ); + assert_eq!(token_from_config_at(&path, None).unwrap(), "tok_default"); + assert_eq!( + token_from_config_at(&path, Some("staging")).unwrap(), + "tok_staging" + ); + assert_eq!( + token_from_config_at(&path, Some("production")).unwrap(), + "tok_production" + ); + } + + // ── auto_token precedence tests ────────────────────────────────────── + + #[test] + fn auto_token_prefers_env_over_config() { + with_env( + &[ + ("LINEAR_API_TOKEN", Some("env-token")), + ("LINEAR_PROFILE", None), + ], + || { + // Even though config file doesn't exist, env token should win + assert_eq!(auto_token(None).unwrap(), "env-token"); + }, + ); + } + + #[test] + fn auto_token_explicit_profile_skips_env() { + with_env( + &[ + ("LINEAR_API_TOKEN", Some("env-token")), + ("LINEAR_PROFILE", None), + ], + || { + // Explicit profile should NOT fall back to env + let result = auto_token(Some("nonexistent")); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("nonexistent") || err.contains("config"), + "Expected config-related error, got: {err}" + ); + }, + ); + } + + #[test] + fn auto_token_linear_profile_env_var_skips_default() { + // LINEAR_PROFILE should direct to the named profile, not "default". + // Since we can't inject the config path into auto_token, we verify + // that it attempts to read the config file (and fails) rather than + // falling through to env token. + with_env( + &[ + ("LINEAR_API_TOKEN", None), + ("LINEAR_PROFILE", Some("staging")), + ], + || { + let result = auto_token(None); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + // Should fail trying to read config, not complain about env var + assert!( + err.contains("config") || err.contains("staging"), + "Expected config-related error for LINEAR_PROFILE, got: {err}" + ); + }, + ); + } + + #[test] + fn auto_token_linear_profile_empty_is_ignored() { + with_env( + &[ + ("LINEAR_API_TOKEN", Some("env-token")), + ("LINEAR_PROFILE", Some("")), + ], + || { + // Empty LINEAR_PROFILE should be ignored, fall through to env + assert_eq!(auto_token(None).unwrap(), "env-token"); + }, + ); + } + + #[test] + fn auto_token_linear_profile_whitespace_is_ignored() { + with_env( + &[ + ("LINEAR_API_TOKEN", Some("env-token")), + ("LINEAR_PROFILE", Some(" ")), + ], + || { + // Whitespace-only LINEAR_PROFILE should be ignored + assert_eq!(auto_token(None).unwrap(), "env-token"); + }, + ); } } diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..b300c59 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -887,3 +887,179 @@ fn usage_includes_comments_delete() { .success() .stdout(predicate::str::contains("comments delete")); } + +// ── Multi-profile auth ────────────────────────────────────────────────────── + +#[test] +fn help_shows_profile_flag() { + lineark() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("--profile")); +} + +#[test] +fn usage_includes_profile_flag() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("--profile")) + .stdout(predicate::str::contains("config.toml")) + .stdout(predicate::str::contains("LINEAR_PROFILE")); +} + +#[test] +fn usage_includes_config_file_example() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("[profiles.default]")) + .stdout(predicate::str::contains("api_token")); +} + +#[test] +fn profile_flag_with_missing_config_shows_error() { + // --profile with no config file should give a clear error, not panic + lineark() + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "work", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("config")); +} + +#[test] +fn profile_flag_with_valid_config() { + // Create a temp config file and point HOME at the temp dir + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config").join("lineark"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + r#" +[profiles.default] +api_token = "lin_test_default" + +[profiles.work] +api_token = "lin_test_work" +"#, + ) + .unwrap(); + + // With HOME overridden, --profile work should try to use lin_test_work + // It will fail on the API call (invalid token), but the auth resolution succeeds + lineark() + .env("HOME", dir.path().to_str().unwrap()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "work", "whoami"]) + .assert() + .failure() + .stderr( + predicate::str::contains("Authentication") + .or(predicate::str::contains("401")) + .or(predicate::str::contains("Unauthorized")), + ); +} + +#[test] +fn default_profile_used_when_no_flags() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config").join("lineark"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + r#" +[profiles.default] +api_token = "lin_test_default_token" +"#, + ) + .unwrap(); + + // With no --profile and no env var, should pick up "default" from config + lineark() + .env("HOME", dir.path().to_str().unwrap()) + .env_remove("LINEAR_API_TOKEN") + .env_remove("LINEAR_PROFILE") + .args(["whoami"]) + .assert() + .failure() + .stderr( + predicate::str::contains("Authentication") + .or(predicate::str::contains("401")) + .or(predicate::str::contains("Unauthorized")), + ); +} + +#[test] +fn linear_profile_env_var_selects_profile() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config").join("lineark"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + r#" +[profiles.default] +api_token = "lin_default" + +[profiles.staging] +api_token = "lin_staging_token" +"#, + ) + .unwrap(); + + // LINEAR_PROFILE=staging should use the staging token + lineark() + .env("HOME", dir.path().to_str().unwrap()) + .env("LINEAR_PROFILE", "staging") + .env_remove("LINEAR_API_TOKEN") + .args(["whoami"]) + .assert() + .failure() + .stderr( + predicate::str::contains("Authentication") + .or(predicate::str::contains("401")) + .or(predicate::str::contains("Unauthorized")), + ); +} + +#[test] +fn profile_flag_nonexistent_profile_shows_error() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join(".config").join("lineark"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + r#" +[profiles.default] +api_token = "lin_default" +"#, + ) + .unwrap(); + + lineark() + .env("HOME", dir.path().to_str().unwrap()) + .env_remove("LINEAR_API_TOKEN") + .args(["--profile", "nonexistent", "whoami"]) + .assert() + .failure() + .stderr(predicate::str::contains("nonexistent")) + .stderr(predicate::str::contains("not found")); +} + +#[test] +fn api_token_flag_overrides_profile() { + // --api-token should take precedence over --profile + lineark() + .env_remove("LINEAR_API_TOKEN") + .args(["--api-token", "lin_explicit", "--profile", "work", "whoami"]) + .assert() + .failure() + .stderr( + predicate::str::contains("Authentication") + .or(predicate::str::contains("401")) + .or(predicate::str::contains("Unauthorized")), + ); +} From 2c7eab7cbafa1f7e8ed63278b0d00cb7bdf05201 Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Wed, 4 Mar 2026 19:21:35 -0700 Subject: [PATCH 3/3] style: apply rustfmt to auth tests --- crates/lineark-sdk/src/auth.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/lineark-sdk/src/auth.rs b/crates/lineark-sdk/src/auth.rs index 4360148..7d0bb2b 100644 --- a/crates/lineark-sdk/src/auth.rs +++ b/crates/lineark-sdk/src/auth.rs @@ -241,7 +241,10 @@ api_token = "lin_default_123" api_token = "lin_work_456" "#, ); - assert_eq!(token_from_config_at(&path, None).unwrap(), "lin_default_123"); + assert_eq!( + token_from_config_at(&path, None).unwrap(), + "lin_default_123" + ); } #[test] @@ -271,8 +274,14 @@ api_token = "lin_abc" ); let err = token_from_config_at(&path, Some("nonexistent")).unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("nonexistent"), "Error should name the profile: {msg}"); - assert!(msg.contains("not found"), "Error should say not found: {msg}"); + assert!( + msg.contains("nonexistent"), + "Error should name the profile: {msg}" + ); + assert!( + msg.contains("not found"), + "Error should say not found: {msg}" + ); } #[test] @@ -285,7 +294,10 @@ api_token = " " ); let err = token_from_config_at(&path, None).unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("empty"), "Error should mention empty token: {msg}"); + assert!( + msg.contains("empty"), + "Error should mention empty token: {msg}" + ); } #[test]