steid

@jamesgill /

1//! Building the URLs the repository pages link to each other with.
2//!
3//! Out of `browse` because six other modules imported them from there and none of them
4//! were browsing: an issue links to a tree, a feed links to a log, an archive encodes a
5//! revision. A URL builder that lives in the page it happens to have been written for
6//! makes every other page a dependent of that page.
7//!
8//! Two encoding rules run through all of them and are the reason they are functions
9//! rather than `format!` at each call site. A **revision is encoded whole**, slashes
10//! included, so `feature/login` stays one segment and the `/-/` separator keeps its
11//! meaning; a **path keeps its slashes**, because there they are structure. Getting
12//! that backwards produces a URL that resolves to the wrong thing rather than one that
13//! fails.
14
15use crate::domain::{RefName, RepoPath};
16
17/// Percent-encodes a URL segment.
18///
19/// Hand-rolled rather than pulled in as a dependency: it is the unreserved set from
20/// RFC 3986 and nothing else. `keep_slash` is the difference between a path, whose
21/// slashes are structure, and a revision, whose slashes are part of its name.
22pub(super) fn encode(value: &str, keep_slash: bool) -> String {
23 let mut encoded = String::with_capacity(value.len());
24
25 for byte in value.bytes() {
26 match byte {
27 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
28 encoded.push(byte as char);
29 }
30 b'/' if keep_slash => encoded.push('/'),
31 other => encoded.push_str(&format!("%{other:02X}")),
32 }
33 }
34
35 encoded
36}
37
38/// The URL for a path at a revision.
39///
40/// The revision is encoded whole, slashes included, so `feature/login` stays one
41/// segment and the `/-/` separator keeps its meaning. The path keeps its slashes,
42/// because they *are* segments.
43pub(super) fn tree_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
44 let encoded = encode(rev.as_str(), false);
45
46 if path.is_root() {
47 format!("/{handle}/repos/{name}/tree/{encoded}")
48 } else {
49 format!(
50 "/{handle}/repos/{name}/tree/{encoded}/-/{}",
51 encode(path.as_str(), true)
52 )
53 }
54}
55
56/// The URL a file's bytes are served from.
57///
58/// Always has a path: there are no raw bytes for a directory, so the root has no raw
59/// URL and the `/-/` separator is unconditional.
60pub(super) fn raw_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
61 format!(
62 "/{handle}/repos/{name}/raw/{}/-/{}",
63 encode(rev.as_str(), false),
64 encode(path.as_str(), true)
65 )
66}
67
68/// The commit log's URL, at a revision or at the default branch.
69pub(super) fn log_url(handle: &str, name: &str, rev: &str) -> String {
70 if rev.is_empty() {
71 format!("/{handle}/repos/{name}/log")
72 } else {
73 format!("/{handle}/repos/{name}/log/{}", encode(rev, false))
74 }
75}
76
77/// One path's history, at a revision.
78///
79/// The log's URL with the tree's `/-/` separator on the end, encoded the same way — the
80/// revision whole, so a slash in a branch name stays one segment, and the path with its
81/// slashes intact, because there they are structure.
82pub(super) fn path_log_url(handle: &str, name: &str, rev: &RefName, path: &RepoPath) -> String {
83 format!(
84 "/{handle}/repos/{name}/log/{}/-/{}",
85 encode(rev.as_str(), false),
86 encode(path.as_str(), true)
87 )
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn rev(value: &str) -> RefName {
95 RefName::new(value).expect("valid revision")
96 }
97 #[test]
98 fn a_root_tree_url_has_no_separator() {
99 assert_eq!(
100 tree_url("ada", "steid", &rev("main"), &RepoPath::root()),
101 "/ada/repos/steid/tree/main"
102 );
103 }
104
105 #[test]
106 fn a_path_follows_the_separator_with_its_slashes_intact() {
107 let path = RepoPath::new("src/domain/repo.rs").expect("valid");
108
109 assert_eq!(
110 tree_url("ada", "steid", &rev("main"), &path),
111 "/ada/repos/steid/tree/main/-/src/domain/repo.rs"
112 );
113 }
114
115 #[test]
116 fn a_revisions_slashes_are_encoded_so_it_stays_one_segment() {
117 // Otherwise `feature/login` would look like a revision plus a path, which is
118 // the ambiguity the separator exists to remove.
119 assert_eq!(
120 tree_url("ada", "steid", &rev("feature/login"), &RepoPath::root()),
121 "/ada/repos/steid/tree/feature%2Flogin"
122 );
123 }
124
125 #[test]
126 fn names_needing_escaping_are_encoded() {
127 let path = RepoPath::new("docs/a b#c.md").expect("valid");
128
129 assert_eq!(
130 tree_url("ada", "steid", &rev("main"), &path),
131 "/ada/repos/steid/tree/main/-/docs/a%20b%23c.md"
132 );
133 }
134
135 #[test]
136 fn the_log_url_is_the_default_branch_when_no_revision_is_named() {
137 assert_eq!(log_url("ada", "steid", ""), "/ada/repos/steid/log");
138 assert_eq!(
139 log_url("ada", "steid", "feature/login"),
140 "/ada/repos/steid/log/feature%2Flogin"
141 );
142 }
143
144 #[test]
145 fn a_paths_history_url_is_the_logs_with_the_separator_on_the_end() {
146 let path = RepoPath::new("src/main.rs").expect("valid");
147
148 assert_eq!(
149 path_log_url("ada", "steid", &rev("main"), &path),
150 "/ada/repos/steid/log/main/-/src/main.rs"
151 );
152 // The revision keeps its slashes encoded, so it stays one segment and the
153 // separator keeps its meaning — the tree URL's rule.
154 assert_eq!(
155 path_log_url("ada", "steid", &rev("feature/login"), &path),
156 "/ada/repos/steid/log/feature%2Flogin/-/src/main.rs"
157 );
158 }
159
160 #[test]
161 fn a_raw_url_always_carries_a_path() {
162 let path = RepoPath::new("src/main.rs").expect("valid");
163
164 assert_eq!(
165 raw_url("ada", "steid", &rev("main"), &path),
166 "/ada/repos/steid/raw/main/-/src/main.rs"
167 );
168 assert_eq!(
169 raw_url("ada", "steid", &rev("feature/login"), &path),
170 "/ada/repos/steid/raw/feature%2Flogin/-/src/main.rs"
171 );
172 }
173}