1#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Route {
13 Index,
15 OwnerIndex {
17 owner: String,
19 },
20 GitSmart {
24 rel_path: String,
26 },
27 RepoOverview {
29 owner: String,
31 repo: String,
33 },
34 Tree {
36 owner: String,
38 repo: String,
40 rev: String,
42 path: String,
44 },
45 Blob {
47 owner: String,
49 repo: String,
51 rev: String,
53 path: String,
55 },
56 Raw {
58 owner: String,
60 repo: String,
62 rev: String,
64 path: String,
66 },
67 Commits {
69 owner: String,
71 repo: String,
73 rev: String,
75 },
76 Commit {
78 owner: String,
80 repo: String,
82 sha: String,
84 },
85 Branches {
87 owner: String,
89 repo: String,
91 },
92 Tags {
94 owner: String,
96 repo: String,
98 },
99 Issues {
101 owner: String,
103 repo: String,
105 },
106 IssueNew {
108 owner: String,
110 repo: String,
112 },
113 IssueDetail {
115 owner: String,
117 repo: String,
119 num: u64,
121 },
122 ApiToken,
124 ApiHosted {
126 hex: String,
128 id: String,
130 },
131 ApiReposList,
133 ApiRepo {
135 owner: String,
137 repo: String,
139 },
140 NotFound,
142}
143
144const GIT_SUFFIXES: &[&str] = &["/info/refs", "/git-upload-pack", "/git-receive-pack"];
146
147#[must_use]
151pub fn strip_prefix<'a>(prefix: &str, path: &'a str) -> Option<std::borrow::Cow<'a, str>> {
152 if prefix.is_empty() {
153 return Some(std::borrow::Cow::Borrowed(path));
154 }
155 if let Some(rest) = path.strip_prefix(prefix) {
156 if rest.is_empty() {
157 Some(std::borrow::Cow::Borrowed("/"))
158 } else if rest.starts_with('/') {
159 Some(std::borrow::Cow::Borrowed(rest))
160 } else {
161 None
163 }
164 } else {
165 None
166 }
167}
168
169#[must_use]
172pub fn parse_route(rel: &str) -> Route {
173 let rel = rel.split('?').next().unwrap_or(rel);
175
176 if rel.is_empty() || rel == "/" {
177 return Route::Index;
178 }
179
180 for suffix in GIT_SUFFIXES {
182 if rel.ends_with(suffix) {
183 return Route::GitSmart {
184 rel_path: rel.to_string(),
185 };
186 }
187 }
188
189 let segs: Vec<&str> = rel
190 .trim_matches('/')
191 .split('/')
192 .filter(|s| !s.is_empty())
193 .collect();
194 if segs.is_empty() {
195 return Route::Index;
196 }
197
198 if segs[0] == "api" {
200 return parse_api(&segs[1..]);
201 }
202
203 match segs.as_slice() {
204 [owner] => Route::OwnerIndex {
205 owner: (*owner).to_string(),
206 },
207 [owner, repo] => Route::RepoOverview {
208 owner: (*owner).to_string(),
209 repo: strip_git_suffix(repo),
210 },
211 [owner, repo, verb, rest @ ..] => parse_repo_verb(owner, repo, verb, rest),
212 _ => Route::NotFound,
213 }
214}
215
216fn parse_repo_verb(owner: &str, repo: &str, verb: &str, rest: &[&str]) -> Route {
217 let owner = owner.to_string();
218 let repo = strip_git_suffix(repo);
219 match verb {
220 "tree" | "blob" | "raw" => {
221 if rest.is_empty() {
222 return Route::NotFound;
223 }
224 let rev = rest[0].to_string();
225 let path = rest[1..].join("/");
226 match verb {
227 "tree" => Route::Tree {
228 owner,
229 repo,
230 rev,
231 path,
232 },
233 "blob" => Route::Blob {
234 owner,
235 repo,
236 rev,
237 path,
238 },
239 _ => Route::Raw {
240 owner,
241 repo,
242 rev,
243 path,
244 },
245 }
246 }
247 "commits" => {
248 let rev = rest.first().map(|s| (*s).to_string()).unwrap_or_default();
249 if rev.is_empty() {
250 return Route::NotFound;
251 }
252 Route::Commits { owner, repo, rev }
253 }
254 "commit" => {
255 let sha = rest.first().map(|s| (*s).to_string()).unwrap_or_default();
256 if sha.is_empty() {
257 return Route::NotFound;
258 }
259 Route::Commit { owner, repo, sha }
260 }
261 "branches" if rest.is_empty() => Route::Branches { owner, repo },
262 "tags" if rest.is_empty() => Route::Tags { owner, repo },
263 "issues" => match rest {
264 [] => Route::Issues { owner, repo },
265 ["new"] => Route::IssueNew { owner, repo },
266 [num] => match num.parse::<u64>() {
267 Ok(n) => Route::IssueDetail {
268 owner,
269 repo,
270 num: n,
271 },
272 Err(_) => Route::NotFound,
273 },
274 _ => Route::NotFound,
275 },
276 _ => Route::NotFound,
277 }
278}
279
280fn parse_api(segs: &[&str]) -> Route {
281 match segs {
282 ["token"] => Route::ApiToken,
283 ["hosted", hex, id] => Route::ApiHosted {
284 hex: (*hex).to_string(),
285 id: (*id).to_string(),
286 },
287 ["repos"] => Route::ApiReposList,
288 ["repos", owner, repo] => Route::ApiRepo {
289 owner: (*owner).to_string(),
290 repo: strip_git_suffix(repo),
291 },
292 _ => Route::NotFound,
293 }
294}
295
296#[must_use]
299pub fn strip_git_suffix(repo: &str) -> String {
300 repo.strip_suffix(".git").unwrap_or(repo).to_string()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn strip_prefix_variants() {
309 assert_eq!(strip_prefix("/forge", "/forge").as_deref(), Some("/"));
310 assert_eq!(
311 strip_prefix("/forge", "/forge/alice/repo").as_deref(),
312 Some("/alice/repo")
313 );
314 assert_eq!(strip_prefix("/forge", "/forgery").as_deref(), None);
315 assert_eq!(strip_prefix("/forge", "/other").as_deref(), None);
316 assert_eq!(
318 strip_prefix("", "/alice/repo").as_deref(),
319 Some("/alice/repo")
320 );
321 }
322
323 #[test]
324 fn index_route() {
325 assert_eq!(parse_route("/"), Route::Index);
326 assert_eq!(parse_route(""), Route::Index);
327 }
328
329 #[test]
330 fn git_smart_routes_take_priority() {
331 assert_eq!(
332 parse_route("/alice/repo.git/info/refs"),
333 Route::GitSmart {
334 rel_path: "/alice/repo.git/info/refs".into()
335 }
336 );
337 assert_eq!(
338 parse_route("/alice/repo.git/git-receive-pack"),
339 Route::GitSmart {
340 rel_path: "/alice/repo.git/git-receive-pack".into()
341 }
342 );
343 }
344
345 #[test]
346 fn owner_and_repo_overview() {
347 assert_eq!(
348 parse_route("/alice"),
349 Route::OwnerIndex {
350 owner: "alice".into()
351 }
352 );
353 assert_eq!(
354 parse_route("/alice/myrepo"),
355 Route::RepoOverview {
356 owner: "alice".into(),
357 repo: "myrepo".into()
358 }
359 );
360 assert_eq!(
362 parse_route("/alice/myrepo.git"),
363 Route::RepoOverview {
364 owner: "alice".into(),
365 repo: "myrepo".into()
366 }
367 );
368 }
369
370 #[test]
371 fn tree_blob_raw_split_rev_and_path() {
372 assert_eq!(
373 parse_route("/alice/repo/tree/main/src/lib.rs"),
374 Route::Tree {
375 owner: "alice".into(),
376 repo: "repo".into(),
377 rev: "main".into(),
378 path: "src/lib.rs".into()
379 }
380 );
381 assert_eq!(
382 parse_route("/alice/repo/tree/main"),
383 Route::Tree {
384 owner: "alice".into(),
385 repo: "repo".into(),
386 rev: "main".into(),
387 path: String::new()
388 }
389 );
390 assert_eq!(
391 parse_route("/alice/repo/raw/main/bin/data"),
392 Route::Raw {
393 owner: "alice".into(),
394 repo: "repo".into(),
395 rev: "main".into(),
396 path: "bin/data".into()
397 }
398 );
399 }
400
401 #[test]
402 fn commit_and_commits() {
403 assert_eq!(
404 parse_route("/alice/repo/commits/main"),
405 Route::Commits {
406 owner: "alice".into(),
407 repo: "repo".into(),
408 rev: "main".into()
409 }
410 );
411 assert_eq!(
412 parse_route("/alice/repo/commit/deadbeef"),
413 Route::Commit {
414 owner: "alice".into(),
415 repo: "repo".into(),
416 sha: "deadbeef".into()
417 }
418 );
419 }
420
421 #[test]
422 fn issues_routes() {
423 assert_eq!(
424 parse_route("/alice/repo/issues"),
425 Route::Issues {
426 owner: "alice".into(),
427 repo: "repo".into()
428 }
429 );
430 assert_eq!(
431 parse_route("/alice/repo/issues/new"),
432 Route::IssueNew {
433 owner: "alice".into(),
434 repo: "repo".into()
435 }
436 );
437 assert_eq!(
438 parse_route("/alice/repo/issues/42"),
439 Route::IssueDetail {
440 owner: "alice".into(),
441 repo: "repo".into(),
442 num: 42
443 }
444 );
445 assert_eq!(parse_route("/alice/repo/issues/notanum"), Route::NotFound);
446 }
447
448 #[test]
449 fn api_routes() {
450 assert_eq!(parse_route("/api/token"), Route::ApiToken);
451 assert_eq!(parse_route("/api/repos"), Route::ApiReposList);
452 assert_eq!(
453 parse_route("/api/repos/alice/repo"),
454 Route::ApiRepo {
455 owner: "alice".into(),
456 repo: "repo".into()
457 }
458 );
459 assert_eq!(
460 parse_route("/api/hosted/abc/xyz"),
461 Route::ApiHosted {
462 hex: "abc".into(),
463 id: "xyz".into()
464 }
465 );
466 }
467
468 #[test]
469 fn unknown_is_not_found() {
470 assert_eq!(parse_route("/alice/repo/bogusverb/x"), Route::NotFound);
471 assert_eq!(parse_route("/api/unknown"), Route::NotFound);
472 }
473}