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 pub fn secret(&self) -> Option<&str> {
31 match self {
32 TokenResolution::Env(s) | TokenResolution::Config(s) => Some(s),
33 TokenResolution::Native => None,
34 }
35 }
36
37 pub fn env_pair(&self, tool: Tool) -> Option<(String, String)> {
40 self.secret()
41 .map(|s| (tool.token_var().to_string(), s.to_string()))
42 }
43}
44
45impl std::fmt::Display for TokenResolution {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 TokenResolution::Env(_) => write!(f, "from GITLAB_TOKEN/LINEAR_TOKEN env var"),
49 TokenResolution::Config(_) => write!(f, "from tix.toml"),
50 TokenResolution::Native => write!(f, "native CLI auth"),
51 }
52 }
53}
54
55pub trait EnvReader {
57 fn get(&self, key: &str) -> Option<String>;
58}
59
60pub struct RealEnv;
62
63impl EnvReader for RealEnv {
64 fn get(&self, key: &str) -> Option<String> {
65 env::var(key).ok()
66 }
67}
68
69pub fn resolve(
74 tool: Tool,
75 config: &Config,
76 env: &dyn EnvReader,
77 warn: &mut dyn FnMut(&str),
78) -> TokenResolution {
79 if let Some(value) = env.get(tool.token_var()).filter(|v| !v.trim().is_empty()) {
80 return TokenResolution::Env(value);
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 use super::*;
110 use crate::config::RemoteConfig;
111
112 struct TestEnv<'a>(&'a [(&'a str, &'a str)]);
113
114 impl EnvReader for TestEnv<'_> {
115 fn get(&self, key: &str) -> Option<String> {
116 self.0
117 .iter()
118 .find(|(k, _)| *k == key)
119 .map(|(_, v)| v.to_string())
120 }
121 }
122
123 fn config_with_file_token(tool: Tool) -> Config {
124 let mut config = Config::default();
125 match tool {
126 Tool::GitLab => {
127 config.export.gitlab = Some(RemoteConfig {
128 token: Some("gl-secret".into()),
129 ..RemoteConfig::default()
130 });
131 }
132 Tool::Linear => {
133 config.export.linear = Some(RemoteConfig {
134 token: Some("lin-secret".into()),
135 ..RemoteConfig::default()
136 });
137 }
138 }
139 config
140 }
141
142 #[test]
143 fn env_var_wins_over_file_token_without_warning() {
144 let config = config_with_file_token(Tool::GitLab);
145 let env = TestEnv(&[("GITLAB_TOKEN", "env-secret")]);
146 let mut warned = false;
147 let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| warned = true);
148 assert_eq!(resolved, TokenResolution::Env("env-secret".into()));
149 assert!(!warned, "env token must not warn");
150 }
151
152 #[test]
153 fn empty_env_var_falls_back_to_file_token_and_warns() {
154 let config = config_with_file_token(Tool::Linear);
155 let env = TestEnv(&[("LINEAR_TOKEN", "")]);
156 let mut warnings: Vec<String> = Vec::new();
157 let resolved = resolve(Tool::Linear, &config, &env, &mut |w| {
158 warnings.push(w.to_string())
159 });
160 assert_eq!(resolved, TokenResolution::Config("lin-secret".into()));
161 assert_eq!(warnings.len(), 1);
162 assert_eq!(
163 warnings[0],
164 "warning: reading LINEAR token from tix.toml — prefer the LINEAR_TOKEN environment variable to avoid committing a secret"
165 );
166 assert!(
167 !warnings[0].contains("lin-secret"),
168 "warning must not echo the secret: {}",
169 warnings[0]
170 );
171 }
172
173 #[test]
174 fn file_token_warns_once_and_not_for_other_tool() {
175 let config = config_with_file_token(Tool::GitLab);
176 let env = TestEnv(&[]);
177 let mut warnings: Vec<String> = Vec::new();
178 let resolved = resolve(Tool::GitLab, &config, &env, &mut |w| {
179 warnings.push(w.to_string())
180 });
181 assert_eq!(resolved, TokenResolution::Config("gl-secret".into()));
182 assert_eq!(warnings.len(), 1);
183 assert_eq!(
184 warnings[0],
185 "warning: reading GITLAB token from tix.toml — prefer the GITLAB_TOKEN environment variable to avoid committing a secret"
186 );
187 }
188
189 #[test]
190 fn native_auth_when_neither_source_configured() {
191 let config = Config::default();
192 let env = TestEnv(&[]);
193 let resolved = resolve(Tool::GitLab, &config, &env, &mut |_| {});
194 assert_eq!(resolved, TokenResolution::Native);
195 assert_eq!(resolved.secret(), None);
196 assert_eq!(resolved.env_pair(Tool::GitLab), None);
197 }
198
199 #[test]
200 fn env_pair_uses_tool_token_var() {
201 let resolved = TokenResolution::Env("s".into());
202 assert_eq!(
203 resolved.env_pair(Tool::GitLab),
204 Some(("GITLAB_TOKEN".to_string(), "s".to_string()))
205 );
206 assert_eq!(
207 resolved.env_pair(Tool::Linear),
208 Some(("LINEAR_TOKEN".to_string(), "s".to_string()))
209 );
210 }
211
212 #[test]
213 fn display_never_echoes_the_secret() {
214 let resolved = TokenResolution::Env("sekrit".into());
215 assert!(!resolved.to_string().contains("sekrit"));
216 let config_resolved = TokenResolution::Config("sekrit".into());
217 assert!(!config_resolved.to_string().contains("sekrit"));
218 }
219}