1use std::fmt;
2
3use url::Url;
4
5use crate::error::{Result, YtError};
6
7#[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 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 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 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 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 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
183fn 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}