git-gemini-forge 0.6.0

A simple Gemini server that serves a read-only view of public repositories from a Git forge.
mod config;
pub mod error;

use crate::network::ForgeApiKind;
pub use config::*;
use error::Error;
use secrecy::SecretString;
use std::env;
use std::str::FromStr;
use std::sync::OnceLock;
use url::Url;

const CERTS_DIR_KEY: &'static str = "CERTS_DIR";
const FORGE_TYPE_KEY: &'static str = "FORGE_TYPE";
const FORGE_URL_KEY: &'static str = "FORGE_URL";
const FORGE_AUTH_TOKEN_KEY: &'static str = "FORGE_AUTH_TOKEN";
const FORGE_LIST_USERS_KEY: &'static str = "FORGE_LIST_USERS";

/// 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> {
	let default = Config::default();

	// Get the certificates directory
	let certs_dir: String = environment
		.get(CERTS_DIR_KEY)
		.unwrap_or_else(|_| default.certs_dir);

	// Get the API shape identifier we should use
	let forge_kind: ForgeApiKind = match environment.get(FORGE_TYPE_KEY) {
		Err(_) => default.forge_kind,
		Ok(forge_type) => ForgeApiKind::from_str(&forge_type.to_lowercase())
			.or_else(|err| Err(Error::BadPlatformType(err)))?,
	};

	// Get the URL of the git forge
	let forge_url: Url = match environment.get(FORGE_URL_KEY) {
		Err(_) => default.forge_url,
		Ok(forge_url_string) => match Url::parse(&forge_url_string) {
			Err(parse_error) => return Err(Error::BadUrl(parse_error)),
			Ok(forge_url) => forge_url,
		},
	};

	// Get the auth token if it exists
	let forge_secret_key: Option<SecretString> = match environment.get(FORGE_AUTH_TOKEN_KEY) {
		Err(_) => default.forge_secret_key,
		Ok(val) => Some(SecretString::from(val)),
	};

	// Get whether we're allowed to list users
	let forge_list_users: bool = match environment.get(FORGE_LIST_USERS_KEY) {
		Err(_) => default.forge_list_users,
		Ok(val) => val.parse::<bool>().unwrap_or(default.forge_list_users),
	};

	return Ok(Config {
		forge_kind,
		forge_url,
		forge_secret_key,
		forge_list_users,
		certs_dir,
	});
}

fn config_or_error() -> &'static Result<Config, Error> {
	static CONFIG: OnceLock<Result<Config, Error>> = OnceLock::new();
	return CONFIG.get_or_init(|| {
		println!("Initializing Config");
		return config_with_env(&RealEnv);
	});
}

/// Returns the retrieved configuration values. Panics if the
/// config is invalid.
pub fn config() -> &'static Config {
	config_or_error().as_ref().expect("Config is invalid")
}

// MARK: - Tests

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

	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_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(_)) = result else {
			panic!("Result should be an error");
		};
	}

	#[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.certs_dir, ".certs");
		if let ForgeApiKind::Forgejo = config.forge_kind {
			// yay!
		} else {
			unreachable!("Expected Forgejo API type");
		}
	}
}