1use std::env;
13
14use crate::config::Config;
15use crate::sync::Tool;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum TokenResolution {
20 Env(String),
23 Config(String),
25 Native,
28}
29
30impl TokenResolution {
31 #[must_use]
33 pub fn secret(&self) -> Option<&str> {
34 match self {
35 TokenResolution::Env(s) | TokenResolution::Config(s) => Some(s),
36 TokenResolution::Native => None,
37 }
38 }
39}
40
41impl std::fmt::Display for TokenResolution {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 match self {
44 TokenResolution::Env(_) => {
45 write!(f, "from GITLAB_TOKEN/LINEAR_TOKEN/LINEAR_API_KEY env var")
46 }
47 TokenResolution::Config(_) => write!(f, "from tix.toml"),
48 TokenResolution::Native => write!(f, "native CLI auth"),
49 }
50 }
51}
52
53pub trait EnvReader {
55 fn get(&self, key: &str) -> Option<String>;
56}
57
58pub struct RealEnv;
60
61impl EnvReader for RealEnv {
62 fn get(&self, key: &str) -> Option<String> {
63 env::var(key).ok()
64 }
65}
66
67pub fn resolve(
72 tool: Tool,
73 config: &Config,
74 env: &dyn EnvReader,
75 warn: &mut dyn FnMut(&str),
76) -> TokenResolution {
77 for var in tool.token_vars() {
78 if let Some(value) = env.get(var).filter(|v| !v.trim().is_empty()) {
79 return TokenResolution::Env(value);
80 }
81 }
82 let file_token = match tool {
83 Tool::GitLab => config
84 .export
85 .gitlab
86 .as_ref()
87 .and_then(|r| r.token.as_deref()),
88 Tool::Linear => config
89 .export
90 .linear
91 .as_ref()
92 .and_then(|r| r.token.as_deref()),
93 };
94 match file_token {
95 Some(value) if !value.trim().is_empty() => {
96 warn(&format!(
97 "warning: reading {} token from tix.toml — prefer the {} environment variable to avoid committing a secret",
98 tool.token_var().trim_end_matches("_TOKEN"),
99 tool.token_var()
100 ));
101 TokenResolution::Config(value.to_string())
102 }
103 _ => TokenResolution::Native,
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 #[allow(clippy::wildcard_imports)]
110 use super::*;
111 use crate::config::RemoteConfig;
112
113 struct TestEnv<'a>(&'a [(&'a str, &'a str)]);
114
115 impl EnvReader for TestEnv<'_> {
116 fn get(&self, key: &str) -> Option<String> {
117 self.0
118 .iter()
119 .find(|(k, _)| *k == key)
120 .map(|(_, v)| v.to_string())
121 }
122 }
123
124 fn config_with_file_token(tool: Tool) -> Config {
125 let mut config = Config::default();
126 match tool {
127 Tool::GitLab => {
128 config.export.gitlab = Some(RemoteConfig {
129 token: Some("gl-secret".into()),
130 ..RemoteConfig::default()
131 });
132 }
133 Tool::Linear => {
134 config.export.linear = Some(RemoteConfig {
135 token: Some("lin-secret".into()),
136 ..RemoteConfig::default()
137 });
138 }
139 }
140 config
141 }
142
143 #[test]
144 fn env_var_wins_over_file_token_without_warning() {
145 let config = config_with_file_token(Tool::GitLab);
146 let env = TestEnv(&[("GITLAB_TOKEN", "env-secret")]);
147 let mut warned = false;
148 let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| warned = true);
149 assert_eq!(resolved, TokenResolution::Env("env-secret".into()));
150 assert!(!warned, "env token must not warn");
151 }
152
153 #[test]
154 fn linear_token_wins_over_linear_api_key() {
155 let config = Config::default();
156 let env = TestEnv(&[
157 ("LINEAR_TOKEN", "tix-name"),
158 ("LINEAR_API_KEY", "tool-name"),
159 ]);
160 let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
161 assert_eq!(resolved, TokenResolution::Env("tix-name".into()));
162 }
163
164 #[test]
165 fn linear_api_key_alone_is_read() {
166 let config = Config::default();
167 let env = TestEnv(&[("LINEAR_API_KEY", "tool-name")]);
168 let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
169 assert_eq!(resolved, TokenResolution::Env("tool-name".into()));
170 }
171
172 #[test]
173 fn a_blank_linear_token_falls_through_to_the_api_key() {
174 let config = Config::default();
175 let env = TestEnv(&[("LINEAR_TOKEN", " "), ("LINEAR_API_KEY", "tool-name")]);
176 let resolved = resolve(Tool::Linear, &config, &env, &mut |_| {});
177 assert_eq!(resolved, TokenResolution::Env("tool-name".into()));
178 }
179
180 #[test]
181 fn a_blank_linear_api_key_still_falls_through_to_the_file_token() {
182 let config = config_with_file_token(Tool::Linear);
183 let env = TestEnv(&[("LINEAR_API_KEY", "")]);
184 let mut warned = false;
185 let resolved = resolve(Tool::Linear, &config, &env, &mut |_| warned = true);
186 assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
187 assert!(warned, "the file token still warns");
188 }
189
190 #[test]
191 fn gitlab_reads_only_its_own_token_var() {
192 let config = Config::default();
193 let env = TestEnv(&[("LINEAR_API_KEY", "tool-name")]);
195 let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
196 assert_eq!(resolved, TokenResolution::Native);
197 }
198
199 #[test]
200 fn empty_env_var_falls_back_to_file_token_and_warns() {
201 let config = config_with_file_token(Tool::Linear);
202 let env = TestEnv(&[("LINEAR_TOKEN", "")]);
203 let mut warnings: Vec<String> = Vec::new();
204 let resolved = resolve(Tool::Linear, &config, &env, &mut |w| {
205 warnings.push(w.to_string())
206 });
207 assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
208 assert_eq!(warnings.len(), 1);
209 assert_eq!(
210 warnings[0],
211 "warning: reading LINEAR token from tix.toml — prefer the LINEAR_TOKEN environment variable to avoid committing a secret"
212 );
213 assert!(
214 !warnings[0].contains("lin-secret"),
215 "warning must not echo the secret: {}",
216 warnings[0]
217 );
218 }
219
220 #[test]
221 fn file_token_warns_once_and_not_for_other_tool() {
222 let config = config_with_file_token(Tool::GitLab);
223 let env = TestEnv(&[]);
224 let mut warnings: Vec<String> = Vec::new();
225 let resolved = resolve(Tool::GitLab, &config, &env, &mut |w| {
226 warnings.push(w.to_string())
227 });
228 assert_eq!(resolved, TokenResolution::Config("gl-secret".into()));
229 assert_eq!(warnings.len(), 1);
230 assert_eq!(
231 warnings[0],
232 "warning: reading GITLAB token from tix.toml — prefer the GITLAB_TOKEN environment variable to avoid committing a secret"
233 );
234 }
235
236 #[test]
237 fn native_auth_when_neither_source_configured() {
238 let config = Config::default();
239 let env = TestEnv(&[]);
240 let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
241 assert_eq!(resolved, TokenResolution::Native);
242 assert_eq!(resolved.secret(), None);
243 }
244
245 #[test]
246 fn display_never_echoes_the_secret() {
247 let resolved = TokenResolution::Env("sekrit".into());
248 assert!(!resolved.to_string().contains("sekrit"));
249 let config_resolved = TokenResolution::Config("sekrit".into());
250 assert!(!config_resolved.to_string().contains("sekrit"));
251 }
252}