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 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 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 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 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
172fn 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}