lib-humus 0.6.0

Helps creating configurable frontends for humans and computers using axum, Tera and toml.
Documentation
// SPDX-FileCopyrightText: 2026 Slatian <baschdel@disroot.org>
//
// SPDX-License-Identifier: AGPL-3.0-or-later

use serde::{Deserialize, Serialize};
pub use tinystr::TinyAsciiStr;

use std::collections::HashMap;

use crate::headers::AcceptHeaderItem;
use crate::templating::{HumusApiFormat, MediaType};

/// An identifier string for an output format that either resolves to an API or Template renderer.
///
/// This is limited in length (currently 16 ASCII chars)
pub type HumusFormatIdentifier = TinyAsciiStr<16>;

/// The datastructure that is parsed from the `templates.toml` file in the templates directory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplatesManifest {
	/// The default template format to use in case none is specified
	pub default_format: HumusFormatIdentifier,

	/// A description of all supported template formats
	pub format: HashMap<HumusFormatIdentifier, FormatDescription>,

	/// Aliases for adding support for additional media types and for disambiguating when multiple frmats map to the same media type (wildcards).
	#[serde(default)]
	pub media_type_aliases: HashMap<AcceptHeaderItem, HumusFormatIdentifier>,

	/// Fallback for when a UA doesn't request a known format, applied before the `default_format` after the `media_type_aliases`. Prioritized in order of increasing length.
	#[serde(default)]
	pub format_by_user_agent_prefix: HashMap<String, HumusFormatIdentifier>,
}

/// Describes a single template format that can be rendered to.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatDescription {
	/// The media type that the server should serve the output of this template as.
	pub media_type: MediaType,

	/// The file extension that the template files use without a leading dot.
	///
	/// Example: if your template file is named `index.html` set `html` here.
	pub extension: String,

	/// Full filename of additional templates to load
	#[serde(default)]
	pub additional_templates: Vec<String>,
}

impl TemplatesManifest {
	fn is_valid_format<ApiFormat: HumusApiFormat>(&self, format: &HumusFormatIdentifier) -> bool {
		self.format.contains_key(format) || ApiFormat::from_name(format).is_some()
	}

	/// Searches the template manifest for problems in the context of a given `ApiFormat`
	pub fn find_problems<ApiFormat: HumusApiFormat>(&self) -> Vec<TemplatesManifestProblem> {
		let mut problems = vec![];

		// Test for default format
		if !self.is_valid_format::<ApiFormat>(&self.default_format) {
			problems.push(TemplatesManifestProblem::DefaultReferencesNonSpecifiedFormat);
		}

		let mut media_type_to_formats: HashMap<AcceptHeaderItem, Vec<HumusFormatIdentifier>> =
			HashMap::new();

		for (format, description) in self.format.iter() {
			// Test for overlaps with API format
			if ApiFormat::from_name(format).is_some() {
				problems.push(
					TemplatesManifestProblem::SpecifiedFormatOverlapsWithApiFormat {
						format: *format,
					},
				);
			}

			// Test for extensions that start or end with a dot
			if description.extension.starts_with(".") || description.extension.ends_with(".") {
				problems.push(
					TemplatesManifestProblem::ExtensionShouldNotStartOrEndWithDot {
						format: *format,
					},
				)
			}

			let media_type_string = description.media_type.to_string();

			if media_type_string != description.media_type.essence_str() {
				media_type_to_formats
					.entry(AcceptHeaderItem::MediaType(
						description.media_type.essence_str().to_string(),
					))
					.or_default()
					.push(*format);
			}

			media_type_to_formats
				.entry(AcceptHeaderItem::MediaType(media_type_string))
				.or_default()
				.push(*format);

			media_type_to_formats
				.entry(AcceptHeaderItem::PartialWildcard(
					description.media_type.type_().to_string(),
				))
				.or_default()
				.push(*format);
		}

		for api_format in ApiFormat::get_all() {
			let format = api_format.get_name();
			let media_type_string = api_format.get_media_type().to_string();

			if media_type_string != api_format.get_media_type().essence_str() {
				media_type_to_formats
					.entry(AcceptHeaderItem::MediaType(
						api_format.get_media_type().essence_str().to_owned(),
					))
					.or_default()
					.push(format);
			}

			media_type_to_formats
				.entry(AcceptHeaderItem::MediaType(media_type_string))
				.or_default()
				.push(format);

			media_type_to_formats
				.entry(AcceptHeaderItem::PartialWildcard(
					api_format.get_media_type().type_().to_string(),
				))
				.or_default()
				.push(format);
		}

		for (accept_header_item, formats) in media_type_to_formats {
			if formats.len() <= 1 || self.media_type_aliases.contains_key(&accept_header_item) {
				continue;
			}

			problems.push(TemplatesManifestProblem::AmbigiousMediaType {
				media_type: accept_header_item,
				formats,
			});
		}

		for (accept_header_item, format) in self.media_type_aliases.iter() {
			if matches!(accept_header_item, AcceptHeaderItem::Wildcard) {
				problems.push(TemplatesManifestProblem::BlankWildcardMediaTypeAliasNotSupported);
				continue;
			}
			if !self.is_valid_format::<ApiFormat>(format) {
				problems.push(
					TemplatesManifestProblem::MediaTypeAliasReferencesNonSpecifiedFormat {
						accept_header_item: accept_header_item.clone(),
					},
				)
			}
		}

		for (prefix, format) in self.format_by_user_agent_prefix.iter() {
			if !self.is_valid_format::<ApiFormat>(format) {
				problems.push(
					TemplatesManifestProblem::DefaultByUserAgentReferencesNonSpecifiedFormat {
						prefix: prefix.clone(),
					},
				);
			}
		}

		problems
	}
}

/// Describes a single problem that can occur with a template manifest
#[derive(Debug, thiserror::Error)]
pub enum TemplatesManifestProblem {
	#[error("The File extension at format.{format}.extension may not start or end with a dot.")]
	ExtensionShouldNotStartOrEndWithDot { format: HumusFormatIdentifier },

	#[error(
		"The format specified at format.{format} overlaps with an API format that uses the same id, please change the format id."
	)]
	SpecifiedFormatOverlapsWithApiFormat { format: HumusFormatIdentifier },

	#[error("The default_format references a format that isn't specified as format or API format.")]
	DefaultReferencesNonSpecifiedFormat,

	#[error(
		"The media_type_aliases.{accept_header_item:?} references a format that isn't specified as format or API format."
	)]
	MediaTypeAliasReferencesNonSpecifiedFormat {
		accept_header_item: AcceptHeaderItem,
	},

	#[error(
		"The default_format_by_user_agent_prefix.{prefix:?} references a format that isn't specified as format or API format."
	)]
	DefaultByUserAgentReferencesNonSpecifiedFormat { prefix: String },

	#[error(
		"The media_type {media_type:?} is given for multiple formats {formats:?}, which makes it unclear which format should be the default for this media type. Please specify a default media_type using a media type alias."
	)]
	AmbigiousMediaType {
		media_type: AcceptHeaderItem,
		formats: Vec<HumusFormatIdentifier>,
	},

	#[error(
		"Using '*/*' as a key in media_type_aliases is not supported, use the default_format for this case."
	)]
	BlankWildcardMediaTypeAliasNotSupported,
}