1use itertools::Itertools;
80use serde::Serialize;
81use thiserror::Error;
82use tinytemplate::TinyTemplate;
83use url::{ParseError, Url};
84
85#[derive(Debug, Serialize)]
86struct ExpandEnvironment {
87 path: String,
88}
89
90fn expand(input: &str, environment: ExpandEnvironment) -> Result<String, GolinkError> {
91 let mut tt = TinyTemplate::new();
92 tt.add_template("url_input", input)?;
93 let rendered = tt.render("url_input", &environment)?;
94
95 if input == rendered {
99 if let Some(mut url) = Url::parse(input).ok() {
100 if !environment.path.is_empty() {
101 url.set_path(&vec![url.path().trim_end_matches('/'), &environment.path].join("/"));
102 }
103
104 return Ok(url.to_string());
105 } else {
106 return Ok(format!("{rendered}/{}", environment.path));
107 }
108 }
109 return Ok(rendered);
110}
111
112#[derive(Error, Debug, Clone, PartialEq, Eq)]
113pub enum GolinkError {
114 #[error("String could not be parsed as URL")]
115 UrlParseError(#[from] ParseError),
116
117 #[error("Could not pull path segments from the input value")]
118 InvalidInputUrl,
119
120 #[error("No first path segment")]
121 NoFirstPathSegment,
122
123 #[error("Could not parse template correctly")]
124 ImproperTemplate(String),
125
126 #[error("Key {0} not found in lookup function")]
127 NotFound(String),
128}
129
130impl From<tinytemplate::error::Error> for GolinkError {
131 fn from(tt_error: tinytemplate::error::Error) -> Self {
132 GolinkError::ImproperTemplate(tt_error.to_string())
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum GolinkResolution {
138 MetadataRequest(String),
139 RedirectRequest(String, String),
140}
141
142pub fn resolve(
143 input: &str,
144 lookup: &dyn Fn(&str) -> Option<String>,
145) -> Result<GolinkResolution, GolinkError> {
146 let url = Url::parse(input).or_else(|_| Url::parse("https://go/")?.join(input))?;
147 let mut segments = url.path_segments().ok_or(GolinkError::InvalidInputUrl)?;
148 let short = segments
149 .next()
150 .ok_or(GolinkError::NoFirstPathSegment)?
151 .to_ascii_lowercase()
152 .replace('-', "")
153 .replace("%20", "");
154
155 if short.is_empty() {
156 return Err(GolinkError::NoFirstPathSegment);
157 }
158
159 if {
160 let this = &url.path().chars().last();
161 let f = |char| char == &'+';
162 matches!(this, Some(x) if f(x))
163 } {
164 return Ok(GolinkResolution::MetadataRequest(
165 short.trim_end_matches('+').to_owned(),
166 ));
167 }
168
169 let remainder = segments.join("/");
170
171 let lookup_value = lookup(&short).ok_or_else(|| GolinkError::NotFound(short.clone()))?;
172
173 let expansion = expand(&lookup_value, ExpandEnvironment { path: remainder })?;
174
175 Ok(GolinkResolution::RedirectRequest(expansion, short))
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use pretty_assertions::assert_eq;
182
183 fn lookup(input: &str) -> Option<String> {
184 if input == "test" {
185 return Some("http://example.com/".to_string());
186 }
187 if input == "test2" {
188 return Some("http://example.com/test.html?a=b&c[]=d".to_string());
189 }
190 if input == "prs" {
191 return Some("https://github.com/pulls?q=is:open+is:pr+review-requested:{{ if path }}{ path }{{ else }}@me{{ endif }}+archived:false".to_string());
192 }
193 if input == "abcd" {
194 return Some("efgh".to_string());
195 }
196 None
197 }
198
199 #[test]
200 fn it_works() {
201 let computed = resolve("/test", &lookup);
202 assert_eq!(
203 computed,
204 Ok(GolinkResolution::RedirectRequest(
205 "http://example.com/".to_string(),
206 "test".to_string()
207 ))
208 )
209 }
210
211 #[test]
212 fn it_works_with_url() {
213 let computed = resolve("https://jil.im/test", &lookup);
214 assert_eq!(
215 computed,
216 Ok(GolinkResolution::RedirectRequest(
217 "http://example.com/".to_string(),
218 "test".to_string()
219 ))
220 )
221 }
222
223 #[test]
224 fn it_works_with_no_leading_slash() {
225 let computed = resolve("test", &lookup);
226 assert_eq!(
227 computed,
228 Ok(GolinkResolution::RedirectRequest(
229 "http://example.com/".to_string(),
230 "test".to_string()
231 ))
232 )
233 }
234
235 #[test]
236 fn it_works_for_complex_url() {
237 let computed = resolve("/test2", &lookup);
238 assert_eq!(
239 computed,
240 Ok(GolinkResolution::RedirectRequest(
241 "http://example.com/test.html?a=b&c[]=d".to_string(),
242 "test2".to_string()
243 ))
244 )
245 }
246
247 #[test]
248 fn it_ignores_case() {
249 let computed = resolve("/TEST", &lookup);
250 assert_eq!(
251 computed,
252 Ok(GolinkResolution::RedirectRequest(
253 "http://example.com/".to_string(),
254 "test".to_string()
255 ))
256 )
257 }
258
259 #[test]
260 fn it_ignores_hyphens() {
261 let computed = resolve("/t-est", &lookup);
262 assert_eq!(
263 computed,
264 Ok(GolinkResolution::RedirectRequest(
265 "http://example.com/".to_string(),
266 "test".to_string()
267 ))
268 )
269 }
270
271 #[test]
272 fn it_ignores_whitespace() {
273 let computed = resolve("/t est", &lookup);
274 assert_eq!(
275 computed,
276 Ok(GolinkResolution::RedirectRequest(
277 "http://example.com/".to_string(),
278 "test".to_string()
279 ))
280 )
281 }
282
283 #[test]
284 fn it_returns_metadata_request() {
285 let computed = resolve("/test+", &lookup);
286 assert_eq!(
287 computed,
288 Ok(GolinkResolution::MetadataRequest("test".to_string()))
289 )
290 }
291
292 #[test]
293 fn it_returns_correct_metadata_request_with_hyphens() {
294 let computed = resolve("/tEs-t+", &lookup);
295 assert_eq!(
296 computed,
297 Ok(GolinkResolution::MetadataRequest("test".to_string()))
298 )
299 }
300
301 #[test]
302 fn it_does_not_append_remaining_path_segments_with_invalid_resolved_url() {
303 let computed = resolve("/abcd/a/b/c", &lookup);
304 assert_eq!(
305 computed,
306 Ok(GolinkResolution::RedirectRequest(
307 "efgh/a/b/c".to_string(),
308 "abcd".to_string()
309 ))
310 )
311 }
312
313 #[test]
314 fn it_appends_remaining_path_segments() {
315 let computed = resolve("/test/a/b/c", &lookup);
316 assert_eq!(
317 computed,
318 Ok(GolinkResolution::RedirectRequest(
319 "http://example.com/a/b/c".to_string(),
320 "test".to_string()
321 ))
322 )
323 }
324
325 #[test]
326 fn it_appends_remaining_path_segments_for_maps_url() {
327 let computed = resolve("/test2/a/b/c", &lookup);
328 assert_eq!(
329 computed,
330 Ok(GolinkResolution::RedirectRequest(
331 "http://example.com/test.html/a/b/c?a=b&c[]=d".to_string(),
332 "test2".to_string()
333 ))
334 )
335 }
336
337 #[test]
338 fn it_uses_path_in_template() {
339 let computed = resolve("/prs/jameslittle230", &lookup);
340 assert_eq!(
341 computed,
342 Ok(GolinkResolution::RedirectRequest(
343 "https://github.com/pulls?q=is:open+is:pr+review-requested:jameslittle230+archived:false".to_string(),
344 "prs".to_string()
345 ))
346 )
347 }
348
349 #[test]
350 fn it_uses_fallback_in_template() {
351 let computed = resolve("/prs", &lookup);
352 assert_eq!(
353 computed,
354 Ok(GolinkResolution::RedirectRequest(
355 "https://github.com/pulls?q=is:open+is:pr+review-requested:@me+archived:false"
356 .to_string(),
357 "prs".to_string()
358 ))
359 )
360 }
361
362 #[test]
363 fn it_uses_fallback_in_template_with_trailing_slash() {
364 let computed = resolve("/prs/", &lookup);
365 assert_eq!(
366 computed,
367 Ok(GolinkResolution::RedirectRequest(
368 "https://github.com/pulls?q=is:open+is:pr+review-requested:@me+archived:false"
369 .to_string(),
370 "prs".to_string()
371 ))
372 )
373 }
374
375 #[test]
376 fn it_allows_the_long_url_to_not_be_a_valid_url() {
377 let computed = resolve("/abcd", &lookup);
378 assert_eq!(
379 computed,
380 Ok(GolinkResolution::RedirectRequest(
381 "efgh".to_string(),
382 "abcd".to_string()
383 ))
384 )
385 }
386
387 #[test]
388 fn it_fails_with_invalid_input_url() {
389 let computed = resolve("a:3gb", &lookup);
390 assert_eq!(computed, Err(GolinkError::InvalidInputUrl))
391 }
392
393 #[test]
394 fn it_fails_with_empty_string() {
395 let computed = resolve("", &lookup);
396 assert_eq!(computed, Err(GolinkError::NoFirstPathSegment))
397 }
398
399 #[test]
400 fn it_fails_with_whitespace_only_string() {
401 let computed = resolve(" \n", &lookup);
402 assert_eq!(computed, Err(GolinkError::NoFirstPathSegment))
403 }
404}