Skip to main content

yt/
instance.rs

1use std::fmt;
2
3use url::Url;
4
5use crate::error::{Result, YtError};
6
7/// The address a user opens YouTrack at, including any context path (`https://host/youtrack`).
8#[derive(Clone, Debug, PartialEq, Eq, Hash)]
9pub struct InstanceUrl {
10    base: Url,
11}
12
13impl InstanceUrl {
14    pub fn parse(input: &str) -> Result<Self> {
15        let input = input.trim();
16        let invalid = |reason: &str| -> miette::Report {
17            YtError::Usage {
18                message: format!("'{input}' is not a valid YouTrack URL: {reason}"),
19            }
20            .into()
21        };
22        let candidate = if input.contains("://") {
23            input.to_string()
24        } else {
25            format!("https://{input}")
26        };
27        let mut url = Url::parse(&candidate).map_err(|error| invalid(&error.to_string()))?;
28        if !matches!(url.scheme(), "https" | "http") {
29            return Err(invalid("use an http or https address"));
30        }
31        if url.host_str().is_none() {
32            return Err(invalid("there is no host"));
33        }
34        url.set_query(None);
35        url.set_fragment(None);
36        let _ = url.set_username("");
37        let _ = url.set_password(None);
38        let trimmed = url.path().trim_end_matches('/');
39        let path = trimmed.strip_suffix("/api").unwrap_or(trimmed).to_string();
40        url.set_path(&format!("{path}/"));
41        Ok(Self { base: url })
42    }
43
44    pub fn host(&self) -> &str {
45        self.base.host_str().unwrap_or_default()
46    }
47
48    /// Plain `http` to another machine: credentials and data cross the network in clear text.
49    pub fn is_unencrypted(&self) -> bool {
50        let loopback = match self.base.host() {
51            Some(url::Host::Domain(domain)) => domain == "localhost",
52            Some(url::Host::Ipv4(address)) => address.is_loopback(),
53            Some(url::Host::Ipv6(address)) => address.is_loopback(),
54            None => false,
55        };
56        self.base.scheme() == "http" && !loopback
57    }
58
59    /// The keyring account for `login` on this instance, e.g. `jane@acme.youtrack.cloud`.
60    pub fn account(&self, login: &str) -> String {
61        let address = self.to_string();
62        let without_scheme = address
63            .split_once("://")
64            .map_or(address.as_str(), |(_, rest)| rest);
65        format!("{login}@{without_scheme}")
66    }
67
68    pub fn api(&self) -> Url {
69        self.join("api/")
70    }
71
72    pub fn origin(&self) -> Url {
73        self.join("/")
74    }
75
76    pub fn join(&self, relative: &str) -> Url {
77        self.base
78            .join(relative)
79            .unwrap_or_else(|_| self.base.clone())
80    }
81
82    pub fn issue_url(&self, id: &str) -> Url {
83        self.join(&format!("issue/{id}"))
84    }
85
86    pub fn project_url(&self, short_name: &str) -> Url {
87        self.join(&format!("projects/{short_name}"))
88    }
89
90    pub fn profile_name_hint(&self) -> String {
91        self.host()
92            .split('.')
93            .next()
94            .unwrap_or("default")
95            .to_string()
96    }
97
98    pub fn article_url(&self, id: &str) -> Url {
99        self.join(&format!("articles/{id}"))
100    }
101
102    pub fn board_url(&self, id: &str) -> Url {
103        self.join(&format!("agiles/{id}/current"))
104    }
105
106    /// Accepts `WEB-42` or this instance's issue URL, e.g. `https://acme.youtrack.cloud/issue/WEB-42/slug`.
107    pub fn issue_id(&self, input: &str) -> Result<String> {
108        self.readable_id(
109            input,
110            "issue",
111            "an issue ID like WEB-42 or an issue URL",
112            is_issue_id,
113        )
114    }
115
116    /// Accepts `KB-A-12` or this instance's article URL, e.g. `https://acme.youtrack.cloud/articles/KB-A-12/slug`.
117    pub fn article_id(&self, input: &str) -> Result<String> {
118        let id = self.readable_id(
119            input,
120            "articles",
121            "an article ID like KB-A-12 or an article URL",
122            is_article_id,
123        )?;
124        // YouTrack matches the project part in any case but finds the article only with an uppercase `A`.
125        let (prefix, number) = id.rsplit_once('-').unwrap_or((&id, ""));
126        let project = &prefix[..prefix.len() - 2];
127        Ok(format!("{project}-A-{number}"))
128    }
129
130    fn readable_id(
131        &self,
132        input: &str,
133        segment: &str,
134        expected: &str,
135        valid: fn(&str) -> bool,
136    ) -> Result<String> {
137        let input = input.trim();
138        let not_an_id = || -> miette::Report {
139            YtError::Usage {
140                message: format!("'{input}' is not {expected}"),
141            }
142            .into()
143        };
144        let id = if input.contains("://") {
145            let url = Url::parse(input).map_err(|_| not_an_id())?;
146            if url.host_str() != self.base.host_str() {
147                return Err(YtError::Usage {
148                    message: format!("{input} belongs to a different instance than {self}"),
149                }
150                .into());
151            }
152            let rest = url
153                .path()
154                .strip_prefix(self.base.path())
155                .unwrap_or(url.path());
156            let mut segments = rest.split('/').filter(|part| !part.is_empty());
157            match (segments.next(), segments.next()) {
158                (Some(found), Some(id)) if found == segment => id.to_string(),
159                _ => return Err(not_an_id()),
160            }
161        } else {
162            input.to_string()
163        };
164        if valid(&id) { Ok(id) } else { Err(not_an_id()) }
165    }
166}
167
168fn is_issue_id(id: &str) -> bool {
169    let Some((project, number)) = id.rsplit_once('-') else {
170        return false;
171    };
172    project
173        .chars()
174        .next()
175        .is_some_and(|c| c.is_ascii_alphabetic())
176        && project
177            .chars()
178            .all(|c| c.is_ascii_alphanumeric() || c == '_')
179        && !number.is_empty()
180        && number.chars().all(|c| c.is_ascii_digit())
181}
182
183/// `KB-A-12`: a project, `A` for article, a number.
184fn is_article_id(id: &str) -> bool {
185    id.rsplit_once('-')
186        .and_then(|(prefix, number)| {
187            let project = prefix
188                .strip_suffix("-A")
189                .or_else(|| prefix.strip_suffix("-a"))?;
190            Some(is_issue_id(&format!("{project}-{number}")))
191        })
192        .unwrap_or(false)
193}
194
195impl fmt::Display for InstanceUrl {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        f.write_str(self.base.as_str().trim_end_matches('/'))
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn normalises_what_users_type() {
207        for input in [
208            "acme.youtrack.cloud",
209            "https://acme.youtrack.cloud",
210            "https://acme.youtrack.cloud/",
211            "https://acme.youtrack.cloud/api",
212            "https://acme.youtrack.cloud/api/",
213            " https://acme.youtrack.cloud?x=1#y ",
214        ] {
215            assert_eq!(
216                InstanceUrl::parse(input).unwrap().to_string(),
217                "https://acme.youtrack.cloud",
218                "{input}"
219            );
220        }
221    }
222
223    #[test]
224    fn keeps_a_context_path() {
225        let url = InstanceUrl::parse("https://youtrack.example.com/youtrack/api").unwrap();
226        assert_eq!(url.to_string(), "https://youtrack.example.com/youtrack");
227        assert_eq!(
228            url.api().as_str(),
229            "https://youtrack.example.com/youtrack/api/"
230        );
231        assert_eq!(url.origin().as_str(), "https://youtrack.example.com/");
232        assert_eq!(
233            url.issue_url("WEB-1").as_str(),
234            "https://youtrack.example.com/youtrack/issue/WEB-1"
235        );
236        assert_eq!(
237            url.join("users/me?tab=account-security").as_str(),
238            "https://youtrack.example.com/youtrack/users/me?tab=account-security"
239        );
240        assert_eq!(url.account("jane"), "jane@youtrack.example.com/youtrack");
241    }
242
243    #[test]
244    fn account_and_name_hint_for_a_cloud_instance() {
245        let url = InstanceUrl::parse("acme.youtrack.cloud").unwrap();
246        assert_eq!(url.account("jane"), "jane@acme.youtrack.cloud");
247        assert_eq!(url.profile_name_hint(), "acme");
248    }
249
250    #[test]
251    fn rejects_other_schemes() {
252        assert!(InstanceUrl::parse("ftp://acme.youtrack.cloud").is_err());
253    }
254
255    #[test]
256    fn plain_http_is_unencrypted_except_on_this_machine() {
257        for input in ["http://youtrack.lan", "http://10.0.0.5:8080/youtrack"] {
258            assert!(
259                InstanceUrl::parse(input).unwrap().is_unencrypted(),
260                "{input}"
261            );
262        }
263        for input in [
264            "acme.youtrack.cloud",
265            "https://youtrack.lan",
266            "http://localhost:8080",
267            "http://127.0.0.1:8080",
268            "http://127.1.2.3",
269            "http://[::1]:8080",
270        ] {
271            assert!(
272                !InstanceUrl::parse(input).unwrap().is_unencrypted(),
273                "{input}"
274            );
275        }
276    }
277
278    #[test]
279    fn issue_ids_and_issue_urls() {
280        let url = InstanceUrl::parse("https://host.example/youtrack").unwrap();
281        assert_eq!(url.issue_id("WEB-42").unwrap(), "WEB-42");
282        assert_eq!(
283            url.issue_id("https://host.example/youtrack/issue/WEB-42/login-fails")
284                .unwrap(),
285            "WEB-42"
286        );
287        assert!(
288            url.issue_id("https://other.example/youtrack/issue/WEB-42")
289                .is_err()
290        );
291        assert!(url.issue_id("WEB").is_err());
292        assert!(url.issue_id("42").is_err());
293    }
294
295    #[test]
296    fn article_ids_and_article_urls() {
297        let url = InstanceUrl::parse("https://host.example/youtrack").unwrap();
298        assert_eq!(url.article_id("KB-A-12").unwrap(), "KB-A-12");
299        assert_eq!(
300            url.article_id("https://host.example/youtrack/articles/KB-A-12/getting-started")
301                .unwrap(),
302            "KB-A-12"
303        );
304        assert_eq!(url.article_id("kb-a-12").unwrap(), "kb-A-12");
305        assert!(url.article_id("KB-12").is_err());
306        assert!(
307            url.article_id("https://host.example/youtrack/issue/KB-A-12")
308                .is_err()
309        );
310        assert!(
311            url.article_id("https://other.example/youtrack/articles/KB-A-12")
312                .is_err()
313        );
314        assert_eq!(
315            url.article_url("KB-A-12").as_str(),
316            "https://host.example/youtrack/articles/KB-A-12"
317        );
318    }
319}