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..7d0bb2b 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::path::PathBuf; +use std::collections::HashMap; +use std::path::{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)] +struct Profile { + api_token: String, +} + +/// Top-level config file structure. +#[derive(Debug, serde::Deserialize)] +struct Config { + #[serde(default)] + 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,82 @@ 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()?; + 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(), + 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")) @@ -48,78 +134,343 @@ 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().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 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("/")); + } + + // ── token_from_config_at tests ─────────────────────────────────────── + + #[test] + fn config_reads_default_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, None).unwrap(), + "lin_default_123" + ); + } + + #[test] + 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 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_returns_error() { + let (_dir, path) = write_temp_config( + r#" +[profiles.default] +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}" + ); + } + + #[test] + 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-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, 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")), + ); +}