git-gemini-forge 0.6.2

A simple Gemini server that serves a read-only view of public repositories from a Git forge.
/// Data which may be interpolated into a URI path or decoded for display.
///
/// This struct makes it more difficult to inadvertently percent-encode
/// a value more than once, or to interpolate an unprotected string into
/// a path segment.
pub struct PercentEncoded(String);

impl PercentEncoded {
	/// Encodes the given value for use in a URI component.
	pub fn new(value: &str) -> Self {
		Self(urlencoding::encode(value).into_owned())
	}

	/// Assumes the given value is already percent-encoded. Useful for when the value comes from
	/// a path parameter from a router. Consult your router's documentation to ensure the value
	/// is actually percent-encoded.
	#[inline]
	pub const fn new_unchecked(value: String) -> Self {
		Self(value)
	}

	/// The percent-encoded value, suitable for use as a URI component.
	#[inline]
	pub const fn uri_component(&self) -> &String {
		&self.0
	}

	/// The percent-encoded value, retaining unencoded any URI path separators (`/`).
	/// This is useful for constructing a URI where additional segments don't necessarily
	/// constitute a separate path.
	///
	/// For example:
	/// - `/AverageHelper/git-average-name/src/handlers/templates` is equivalent in our router
	/// to `/AverageHelper/git-average-name/src%2Fhandlers%2Ftemplates`, so we can write links
	/// using this function instead of [`PercentEncoded::uri_component`].
	pub fn uri_component_retaining_sep(&self) -> String {
		self.0.replace("%2F", "/")
	}

	/// The raw decoded value, not suitable for use as a URI component.
	pub fn display(&self) -> String {
		urlencoding::decode(&self.0)
			.expect("The same value can be decoded and encoded")
			.into_owned()
	}
}

// MARK: - Tests

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn test_encoding() {
		let cases = vec![
			("", ""),
			("AAAAAAAAAA", "AAAAAAAAAA"),
			("AAAAA.AAAAA", "AAAAA.AAAAA"),
			("foo$bar", "foo%24bar"),
			("foo%bar", "foo%25bar"),
			("foo&bar", "foo%26bar"),
			("/", "%2F"),
			("foo/bar", "foo%2Fbar"),
			("foo/$bar", "foo%2F%24bar"),
			("foo/%bar", "foo%2F%25bar"),
			("foo/&bar", "foo%2F%26bar"),
			("/foo/bar", "%2Ffoo%2Fbar"),
			("foo/bar/baz", "foo%2Fbar%2Fbaz"),
			("/foo/bar/baz", "%2Ffoo%2Fbar%2Fbaz"),
		];

		for (value, expected) in cases {
			let actual = PercentEncoded::new(value);
			assert_eq!(actual.uri_component(), expected);
		}
	}

	#[test]
	fn test_encoding_retaining_separator() {
		let cases = vec![
			("", ""),
			("AAAAAAAAAA", "AAAAAAAAAA"),
			("AAAAA.AAAAA", "AAAAA.AAAAA"),
			("foo$bar", "foo%24bar"),
			("foo%bar", "foo%25bar"),
			("foo&bar", "foo%26bar"),
			("/", "/"),
			("foo/bar", "foo/bar"),
			("foo/$bar", "foo/%24bar"),
			("foo/%bar", "foo/%25bar"),
			("foo/&bar", "foo/%26bar"),
			("/foo/bar", "/foo/bar"),
			("foo/bar/baz", "foo/bar/baz"),
			("/foo/bar/baz", "/foo/bar/baz"),
		];

		for (value, expected) in cases {
			let actual = PercentEncoded::new(value);
			assert_eq!(actual.uri_component_retaining_sep(), expected);
		}
	}

	#[test]
	fn test_decoding() {
		let cases = vec![
			("", ""),
			("AAAAAAAAAA", "AAAAAAAAAA"),
			("AAAAA.AAAAA", "AAAAA.AAAAA"),
			("foo%24bar", "foo$bar"),
			("foo%25bar", "foo%bar"),
			("foo%26bar", "foo&bar"),
			("%2F", "/"),
			("foo%2Fbar", "foo/bar"),
			("foo%2F%24bar", "foo/$bar"),
			("foo%2F%25bar", "foo/%bar"),
			("foo%2F%26bar", "foo/&bar"),
			("%2Ffoo%2Fbar", "/foo/bar"),
			("foo%2Fbar%2Fbaz", "foo/bar/baz"),
			("%2Ffoo%2Fbar%2Fbaz", "/foo/bar/baz"),
		];

		for (value, expected) in cases {
			let actual = PercentEncoded::new_unchecked(value.to_string());
			assert_eq!(actual.display(), expected);
		}
	}
}