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    /// The keyring account for `login` on this instance, e.g. `jane@acme.youtrack.cloud`.
49    pub fn account(&self, login: &str) -> String {
50        let address = self.to_string();
51        let without_scheme = address
52            .split_once("://")
53            .map_or(address.as_str(), |(_, rest)| rest);
54        format!("{login}@{without_scheme}")
55    }
56
57    pub fn api(&self) -> Url {
58        self.join("api/")
59    }
60
61    pub fn origin(&self) -> Url {
62        self.join("/")
63    }
64
65    pub fn join(&self, relative: &str) -> Url {
66        self.base
67            .join(relative)
68            .unwrap_or_else(|_| self.base.clone())
69    }
70
71    pub fn issue_url(&self, id: &str) -> Url {
72        self.join(&format!("issue/{id}"))
73    }
74
75    pub fn project_url(&self, short_name: &str) -> Url {
76        self.join(&format!("projects/{short_name}"))
77    }
78
79    pub fn profile_name_hint(&self) -> String {
80        self.host()
81            .split('.')
82            .next()
83            .unwrap_or("default")
84            .to_string()
85    }
86
87    pub fn article_url(&self, id: &str) -> Url {
88        self.join(&format!("articles/{id}"))
89    }
90
91    pub fn board_url(&self, id: &str) -> Url {
92        self.join(&format!("agiles/{id}/current"))
93    }
94
95    /// Accepts `WEB-42` or this instance's issue URL, e.g. `https://acme.youtrack.cloud/issue/WEB-42/slug`.
96    pub fn issue_id(&self, input: &str) -> Result<String> {
97        self.readable_id(
98            input,
99            "issue",
100            "an issue ID like WEB-42 or an issue URL",
101            is_issue_id,
102        )
103    }
104
105    /// Accepts `KB-A-12` or this instance's article URL, e.g. `https://acme.youtrack.cloud/articles/KB-A-12/slug`.
106    pub fn article_id(&self, input: &str) -> Result<String> {
107        let id = self.readable_id(
108            input,
109            "articles",
110            "an article ID like KB-A-12 or an article URL",
111            is_article_id,
112        )?;
113        // YouTrack matches the project part in any case but finds the article only with an uppercase `A`.
114        let (prefix, number) = id.rsplit_once('-').unwrap_or((&id, ""));
115        let project = &prefix[..prefix.len() - 2];
116        Ok(format!("{project}-A-{number}"))
117    }
118
119    fn readable_id(
120        &self,
121        input: &str,
122        segment: &str,
123        expected: &str,
124        valid: fn(&str) -> bool,
125    ) -> Result<String> {
126        let input = input.trim();
127        let not_an_id = || -> miette::Report {
128            YtError::Usage {
129                message: format!("'{input}' is not {expected}"),
130            }
131            .into()
132        };
133        let id = if input.contains("://") {
134            let url = Url::parse(input).map_err(|_| not_an_id())?;
135            if url.host_str() != self.base.host_str() {
136                return Err(YtError::Usage {
137                    message: format!("{input} belongs to a different instance than {self}"),
138                }
139                .into());
140            }
141            let rest = url
142                .path()
143                .strip_prefix(self.base.path())
144                .unwrap_or(url.path());
145            let mut segments = rest.split('/').filter(|part| !part.is_empty());
146            match (segments.next(), segments.next()) {
147                (Some(found), Some(id)) if found == segment => id.to_string(),
148                _ => return Err(not_an_id()),
149            }
150        } else {
151            input.to_string()
152        };
153        if valid(&id) { Ok(id) } else { Err(not_an_id()) }
154    }
155}
156
157fn is_issue_id(id: &str) -> bool {
158    let Some((project, number)) = id.rsplit_once('-') else {
159        return false;
160    };
161    project
162        .chars()
163        .next()
164        .is_some_and(|c| c.is_ascii_alphabetic())
165        && project
166            .chars()
167            .all(|c| c.is_ascii_alphanumeric() || c == '_')
168        && !number.is_empty()
169        && number.chars().all(|c| c.is_ascii_digit())
170}
171
172/// `KB-A-12`: a project, `A` for article, a number.
173fn is_article_id(id: &str) -> bool {
174    id.rsplit_once('-')
175        .and_then(|(prefix, number)| {
176            let project = prefix
177                .strip_suffix("-A")
178                .or_else(|| prefix.strip_suffix("-a"))?;
179            Some(is_issue_id(&format!("{project}-{number}")))
180        })
181        .unwrap_or(false)
182}
183
184impl fmt::Display for InstanceUrl {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        f.write_str(self.base.as_str().trim_end_matches('/'))
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn normalises_what_users_type() {
196        for input in [
197            "acme.youtrack.cloud",
198            "https://acme.youtrack.cloud",
199            "https://acme.youtrack.cloud/",
200            "https://acme.youtrack.cloud/api",
201            "https://acme.youtrack.cloud/api/",
202            " https://acme.youtrack.cloud?x=1#y ",
203        ] {
204            assert_eq!(
205                InstanceUrl::parse(input).unwrap().to_string(),
206                "https://acme.youtrack.cloud",
207                "{input}"
208            );
209        }
210    }
211
212    #[test]
213    fn keeps_a_context_path() {
214        let url = InstanceUrl::parse("https://youtrack.example.com/youtrack/api").unwrap();
215        assert_eq!(url.to_string(), "https://youtrack.example.com/youtrack");
216        assert_eq!(
217            url.api().as_str(),
218            "https://youtrack.example.com/youtrack/api/"
219        );
220        assert_eq!(url.origin().as_str(), "https://youtrack.example.com/");
221        assert_eq!(
222            url.issue_url("WEB-1").as_str(),
223            "https://youtrack.example.com/youtrack/issue/WEB-1"
224        );
225        assert_eq!(
226            url.join("users/me?tab=account-security").as_str(),
227            "https://youtrack.example.com/youtrack/users/me?tab=account-security"
228        );
229        assert_eq!(url.account("jane"), "jane@youtrack.example.com/youtrack");
230    }
231
232    #[test]
233    fn account_and_name_hint_for_a_cloud_instance() {
234        let url = InstanceUrl::parse("acme.youtrack.cloud").unwrap();
235        assert_eq!(url.account("jane"), "jane@acme.youtrack.cloud");
236        assert_eq!(url.profile_name_hint(), "acme");
237    }
238
239    #[test]
240    fn rejects_other_schemes() {
241        assert!(InstanceUrl::parse("ftp://acme.youtrack.cloud").is_err());
242    }
243
244    #[test]
245    fn issue_ids_and_issue_urls() {
246        let url = InstanceUrl::parse("https://host.example/youtrack").unwrap();
247        assert_eq!(url.issue_id("WEB-42").unwrap(), "WEB-42");
248        assert_eq!(
249            url.issue_id("https://host.example/youtrack/issue/WEB-42/login-fails")
250                .unwrap(),
251            "WEB-42"
252        );
253        assert!(
254            url.issue_id("https://other.example/youtrack/issue/WEB-42")
255                .is_err()
256        );
257        assert!(url.issue_id("WEB").is_err());
258        assert!(url.issue_id("42").is_err());
259    }
260
261    #[test]
262    fn article_ids_and_article_urls() {
263        let url = InstanceUrl::parse("https://host.example/youtrack").unwrap();
264        assert_eq!(url.article_id("KB-A-12").unwrap(), "KB-A-12");
265        assert_eq!(
266            url.article_id("https://host.example/youtrack/articles/KB-A-12/getting-started")
267                .unwrap(),
268            "KB-A-12"
269        );
270        assert_eq!(url.article_id("kb-a-12").unwrap(), "kb-A-12");
271        assert!(url.article_id("KB-12").is_err());
272        assert!(
273            url.article_id("https://host.example/youtrack/issue/KB-A-12")
274                .is_err()
275        );
276        assert!(
277            url.article_id("https://other.example/youtrack/articles/KB-A-12")
278                .is_err()
279        );
280        assert_eq!(
281            url.article_url("KB-A-12").as_str(),
282            "https://host.example/youtrack/articles/KB-A-12"
283        );
284    }
285}