git-gemini-forge 0.2.0

A simple Gemini server that serves a read-only view of public repositories from a Git forge.
pub mod error;
use error::Error;
use url::Url;
use std::env;

const CERTS_DIR_KEY: &'static str = "CERTS_DIR";
const FORGE_TYPE_KEY: &'static str = "PLATFORM_TYPE";
const FORGE_URL_KEY: &'static str = "FORGE_URL";

const CRATE_VERSION: &'static str = env!("CARGO_PKG_VERSION", "Package version unknown");

#[derive(Clone, Debug)]
pub struct Config {
	/// The version of this software.
	pub crate_version: &'static str,

	/// The API we should use to communicate with the forge.
	pub forge_type: String,

	/// The URL of the forge.
	pub forge_url: Url,

	/// The directory to look for certificate files.
	pub certs_dir: String,
}

/// A trait that wraps env var getters to make env vars testable.
/// See https://stackoverflow.com/a/35858566.
trait QueryEnvironment {
	/// Wraps `std::env::var` to fetch the environment variable `key` from the current
	/// process. The original documentation is as follows:
	///
	/// # Errors
	///
	/// This function will return an error if the environment variable isn't set.
	///
	/// This function may return an error if the environment variable's name contains
	/// the equal sign character (`=`) or the NUL character.
	///
	/// This function will return an error if the environment variable's value is
	/// not valid Unicode. If this is not desired, consider using [`var_os`].
	///
	/// # Examples
	///
	/// ```
	/// use std::env;
	///
	/// let key = "HOME";
	/// match env::var(key) {
	///     Ok(val) => println!("{key}: {val:?}"),
	///     Err(e) => println!("couldn't interpret {key}: {e}"),
	/// }
	/// ```
	fn get(&self, key: &str) -> Result<String, std::env::VarError>;
}

/// Wraps the real program environment to make environment variables testable.
struct RealEnv;

impl QueryEnvironment for RealEnv {
	fn get(&self, key: &str) -> Result<String, std::env::VarError> {
		return env::var(key);
	}
}

/// Retrieves configuration values from the given environment.
fn config_with_env(environment: &dyn QueryEnvironment) -> Result<Config, Error> {
	// Get the certificates directory
	let default_certs_dir: &str = ".certs";
	let certs_dir: String = environment.get(CERTS_DIR_KEY)
		.unwrap_or(String::from(default_certs_dir));

	// Get the API shape identifier we should use
	let default_type: &str = "forgejo";
	let forge_type: String = environment.get(FORGE_TYPE_KEY)
		.unwrap_or(String::from(default_type))
		.to_lowercase();

	// Make sure it's one we support
	if forge_type != default_type {
		return Err(Error::BadPlatformType(forge_type));
	}

	// Get the URL of the git forge
	let forge_url_string: String = environment.get(FORGE_URL_KEY)
		.unwrap_or(String::from(""));
	if forge_url_string.len() == 0 {
		return Err(Error::MissingUrl);
	}

	match Url::parse(forge_url_string.as_str()) {
		Err(parse_error) => Err(Error::BadUrl(parse_error)),
		Ok(forge_url) => Ok(Config {
			crate_version: CRATE_VERSION,
			forge_type,
			forge_url,
			certs_dir
		}),
	}
}

/// Retrieves configuration values from environment variables.
pub fn config() -> Result<Config, Error> {
	return config_with_env(&RealEnv);
}

#[cfg(test)]
mod tests {
	use std::collections::hash_map::HashMap;
	use super::*;

	impl QueryEnvironment for HashMap<&str, String> {
		fn get(&self, key: &str) -> Result<String, std::env::VarError> {
			// Treat own contents as env var
			return match self.get(key) {
				Some(val) => Ok(val.clone()),
				None => Err(std::env::VarError::NotPresent),
			}
		}
	}

	#[test]
	fn test_error_if_no_forge_url() {
		let result: Result<Config, Error> = config_with_env(&HashMap::new());
		let Err(Error::MissingUrl) = result else {
			panic!("Result should be an error");
		};
	}

	#[test]
	fn test_error_if_malformed_forge_url() {
		let mut mock_env: HashMap<&str, String> = HashMap::new();
		mock_env.insert(FORGE_URL_KEY, String::from("foobar"));

		let result: Result<Config, Error> = config_with_env(&mock_env);
		let Err(Error::BadUrl(_)) = result else {
			panic!("Result should be an error");
		};
	}

	#[test]
	fn test_error_if_unknown_forge_type() {
		let mut mock_env: HashMap<&str, String> = HashMap::new();
		mock_env.insert(FORGE_URL_KEY, String::from("http://localhost"));
		mock_env.insert(FORGE_TYPE_KEY, String::from("foobar"));

		let result: Result<Config, Error> = config_with_env(&mock_env);
		let Err(Error::BadPlatformType(platform_type)) = result else {
			panic!("Result should be an error");
		};
		assert_eq!(platform_type, "foobar");
	}

	#[test]
	fn test_defaul_config_with_minimal_env() {
		let mut mock_env: HashMap<&str, String> = HashMap::new();
		mock_env.insert(FORGE_URL_KEY, String::from("http://localhost"));

		let result: Result<Config, Error> = config_with_env(&mock_env);
		let Ok(config) = result else {
			panic!("Result should be a Config instance");
		};
		assert_eq!(config.crate_version, CRATE_VERSION);
		assert_eq!(config.certs_dir, ".certs");
		assert_eq!(config.forge_type, "forgejo");
		assert_eq!(config.forge_url, Url::parse("http://localhost").unwrap());
	}
}