Skip to main content

yt/config/
profile.rs

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    /// `YOUTRACK_URL` is set: a temporary profile authenticated with `YOUTRACK_TOKEN`.
12    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
38/// Picks the profile for this invocation: environment, flag, repo URL, default, the only one.
39pub 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
132/// Other profiles signed in as the same account as `name` (same instance and login); they share its keyring entry.
133pub 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}