1use secrecy::SecretString;
2
3use crate::config::file::{GlobalConfig, Profile};
4use crate::config::repo::RepoConfig;
5use crate::env::Env;
6use crate::error::{Result, YtError};
7use crate::instance::InstanceUrl;
8
9#[derive(Debug)]
10pub enum ProfileChoice {
11 Env {
13 url: InstanceUrl,
14 token: SecretString,
15 },
16 Named {
17 name: String,
18 profile: Profile,
19 },
20}
21
22impl ProfileChoice {
23 pub fn label(&self) -> &str {
24 match self {
25 Self::Env { .. } => "YOUTRACK_URL",
26 Self::Named { name, .. } => name,
27 }
28 }
29
30 pub fn url(&self) -> Result<InstanceUrl> {
31 match self {
32 Self::Env { url, .. } => Ok(url.clone()),
33 Self::Named { profile, .. } => InstanceUrl::parse(&profile.url),
34 }
35 }
36}
37
38pub fn resolve(
40 flag: Option<&str>,
41 env: &Env,
42 repo: Option<&RepoConfig>,
43 config: &GlobalConfig,
44) -> Result<ProfileChoice> {
45 if let Some(url) = env.get("YOUTRACK_URL") {
46 let token = env.get("YOUTRACK_TOKEN").ok_or_else(|| YtError::Usage {
47 message: "YOUTRACK_URL is set but YOUTRACK_TOKEN is not".into(),
48 })?;
49 crate::redact::register(token);
50 return Ok(ProfileChoice::Env {
51 url: InstanceUrl::parse(url)?,
52 token: SecretString::from(token.to_string()),
53 });
54 }
55 if let Some(name) = flag {
56 return named(name, config);
57 }
58 if let Some(repo) = repo
59 && let Some(url) = &repo.url
60 {
61 let matches: Vec<(&String, &Profile)> = config
62 .profiles
63 .iter()
64 .filter(|(_, profile)| InstanceUrl::parse(&profile.url).ok().as_ref() == Some(url))
65 .collect();
66 return match matches.as_slice() {
67 [] => Err(YtError::NoProfileForUrl {
68 url: url.to_string(),
69 path: repo.path.display().to_string(),
70 }
71 .into()),
72 [(name, _)] => named(name, config),
73 several => match several.iter().find(|(_, profile)| profile.default_for_url) {
74 Some((name, _)) => named(name, config),
75 None => Err(YtError::AmbiguousProfile {
76 url: url.to_string(),
77 count: several.len(),
78 }
79 .into()),
80 },
81 };
82 }
83 if let Some(name) = &config.default_profile {
84 return named(name, config);
85 }
86 match config.profiles.iter().collect::<Vec<_>>().as_slice() {
87 [(name, _)] => named(name, config),
88 _ => Err(YtError::NoProfile.into()),
89 }
90}
91
92pub fn project(
93 flag: Option<&str>,
94 repo: Option<&RepoConfig>,
95 choice: &ProfileChoice,
96) -> Option<String> {
97 let from_profile = match choice {
98 ProfileChoice::Named { profile, .. } => profile.project.clone(),
99 ProfileChoice::Env { .. } => None,
100 };
101 flag.map(str::to_string)
102 .or_else(|| repo.and_then(|repo| repo.project.clone()))
103 .or(from_profile)
104}
105
106pub fn unknown_profile(name: &str, config: &GlobalConfig) -> miette::Report {
107 let suggestion = config
108 .profiles
109 .keys()
110 .map(|candidate| (strsim::jaro_winkler(name, candidate), candidate))
111 .filter(|(score, _)| *score > 0.8)
112 .max_by(|a, b| a.0.total_cmp(&b.0))
113 .map(|(_, candidate)| format!("did you mean '{candidate}'?"));
114 YtError::UnknownProfile {
115 name: name.to_string(),
116 help: suggestion.or_else(|| Some("run `yt profile list` to see the profiles".into())),
117 }
118 .into()
119}
120
121fn named(name: &str, config: &GlobalConfig) -> Result<ProfileChoice> {
122 let profile = config
123 .profiles
124 .get(name)
125 .ok_or_else(|| unknown_profile(name, config))?;
126 Ok(ProfileChoice::Named {
127 name: name.to_string(),
128 profile: profile.clone(),
129 })
130}
131
132pub fn sharing_account<'a>(config: &'a GlobalConfig, name: &str) -> Vec<&'a str> {
134 let Some(profile) = config.profiles.get(name) else {
135 return Vec::new();
136 };
137 let Some(login) = profile.login.as_deref() else {
138 return Vec::new();
139 };
140 let url = InstanceUrl::parse(&profile.url).ok();
141 config
142 .profiles
143 .iter()
144 .filter(|(other, candidate)| {
145 other.as_str() != name
146 && candidate.login.as_deref() == Some(login)
147 && url.is_some()
148 && InstanceUrl::parse(&candidate.url).ok() == url
149 })
150 .map(|(other, _)| other.as_str())
151 .collect()
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use crate::config::file::AuthKind;
158
159 fn profile(url: &str, default_for_url: bool) -> Profile {
160 Profile {
161 url: url.into(),
162 login: Some("jane".into()),
163 auth: AuthKind::Token,
164 project: Some("PRO".into()),
165 default_for_url,
166 oauth_client_id: None,
167 ca_file: None,
168 revocable: false,
169 }
170 }
171
172 fn config() -> GlobalConfig {
173 GlobalConfig {
174 default_profile: Some("home".into()),
175 profiles: [
176 (
177 "home".to_string(),
178 profile("https://home.youtrack.cloud", false),
179 ),
180 (
181 "work".to_string(),
182 profile("https://acme.youtrack.cloud", true),
183 ),
184 (
185 "bot".to_string(),
186 profile("https://acme.youtrack.cloud", false),
187 ),
188 ]
189 .into(),
190 }
191 }
192
193 fn name(choice: ProfileChoice) -> String {
194 choice.label().to_string()
195 }
196
197 fn repo(url: &str) -> RepoConfig {
198 RepoConfig {
199 path: "/r/.yt.toml".into(),
200 url: Some(InstanceUrl::parse(url).unwrap()),
201 project: Some("WEB".into()),
202 }
203 }
204
205 #[test]
206 fn environment_wins() {
207 let env = Env::from_vars([
208 ("YOUTRACK_URL", "https://ci.youtrack.cloud"),
209 ("YOUTRACK_TOKEN", "perm-x"),
210 ]);
211 let choice = resolve(Some("home"), &env, None, &config()).unwrap();
212 assert!(
213 matches!(choice, ProfileChoice::Env { ref url, .. } if url.to_string() == "https://ci.youtrack.cloud")
214 );
215 }
216
217 #[test]
218 fn a_url_without_a_token_is_a_usage_error() {
219 let env = Env::from_vars([("YOUTRACK_URL", "https://ci.youtrack.cloud")]);
220 let error = resolve(None, &env, None, &config()).unwrap_err();
221 assert_eq!(crate::error::exit_code(&error), 2);
222 }
223
224 #[test]
225 fn the_flag_wins_over_repo_and_default() {
226 let choice = resolve(
227 Some("bot"),
228 &Env::default(),
229 Some(&repo("https://home.youtrack.cloud")),
230 &config(),
231 )
232 .unwrap();
233 assert_eq!(name(choice), "bot");
234 }
235
236 #[test]
237 fn the_repo_url_picks_the_default_for_that_url() {
238 let choice = resolve(
239 None,
240 &Env::default(),
241 Some(&repo("https://acme.youtrack.cloud/")),
242 &config(),
243 )
244 .unwrap();
245 assert_eq!(name(choice), "work");
246 }
247
248 #[test]
249 fn a_repo_url_without_a_profile_is_an_error() {
250 let error = resolve(
251 None,
252 &Env::default(),
253 Some(&repo("https://other.youtrack.cloud")),
254 &config(),
255 )
256 .unwrap_err();
257 assert!(matches!(
258 error.downcast_ref::<YtError>(),
259 Some(YtError::NoProfileForUrl { .. })
260 ));
261 let help = error
262 .help()
263 .map(|help| help.to_string())
264 .unwrap_or_default();
265 assert!(
266 help.starts_with("/r/.yt.toml names this instance")
267 && help.contains("yt auth login https://other.youtrack.cloud"),
268 "{help}"
269 );
270 }
271
272 #[test]
273 fn several_profiles_without_a_default_for_the_url_are_ambiguous() {
274 let mut config = config();
275 config.profiles.get_mut("work").unwrap().default_for_url = false;
276 let error = resolve(
277 None,
278 &Env::default(),
279 Some(&repo("https://acme.youtrack.cloud")),
280 &config,
281 )
282 .unwrap_err();
283 assert!(matches!(
284 error.downcast_ref::<YtError>(),
285 Some(YtError::AmbiguousProfile { count: 2, .. })
286 ));
287 }
288
289 #[test]
290 fn then_the_default_profile_then_the_only_one() {
291 assert_eq!(
292 name(resolve(None, &Env::default(), None, &config()).unwrap()),
293 "home"
294 );
295 let only = GlobalConfig {
296 default_profile: None,
297 profiles: [(
298 "solo".to_string(),
299 profile("https://x.youtrack.cloud", false),
300 )]
301 .into(),
302 };
303 assert_eq!(
304 name(resolve(None, &Env::default(), None, &only).unwrap()),
305 "solo"
306 );
307 let error = resolve(None, &Env::default(), None, &GlobalConfig::default()).unwrap_err();
308 assert!(matches!(
309 error.downcast_ref::<YtError>(),
310 Some(YtError::NoProfile)
311 ));
312 }
313
314 #[test]
315 fn unknown_names_get_a_suggestion() {
316 let error = resolve(Some("wrok"), &Env::default(), None, &config()).unwrap_err();
317 assert!(format!("{error:?}").contains("did you mean 'work'?"));
318 }
319
320 #[test]
321 fn project_order() {
322 let choice = resolve(Some("home"), &Env::default(), None, &config()).unwrap();
323 let repo = repo("https://home.youtrack.cloud");
324 assert_eq!(
325 project(Some("FLAG"), Some(&repo), &choice).as_deref(),
326 Some("FLAG")
327 );
328 assert_eq!(project(None, Some(&repo), &choice).as_deref(), Some("WEB"));
329 assert_eq!(project(None, None, &choice).as_deref(), Some("PRO"));
330 }
331
332 #[test]
333 fn profiles_signed_in_as_the_same_account_share_it() {
334 let mut bob = profile("https://acme.youtrack.cloud", false);
335 bob.login = Some("bob".into());
336 let config = GlobalConfig {
337 default_profile: None,
338 profiles: [
339 (
340 "a".to_string(),
341 profile("https://acme.youtrack.cloud/", true),
342 ),
343 ("b".to_string(), profile("acme.youtrack.cloud", false)),
344 ("c".to_string(), bob),
345 (
346 "d".to_string(),
347 profile("https://other.youtrack.cloud", true),
348 ),
349 ]
350 .into(),
351 };
352 assert_eq!(sharing_account(&config, "b"), vec!["a"]);
353 assert!(sharing_account(&config, "c").is_empty());
354 assert!(sharing_account(&config, "missing").is_empty());
355 }
356}