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