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 std::sync::Arc;

use tera::Function;
use tera::TeraResult;
use tera::Value;

use crate::language::{LanguageEngine, UnicodeLanguageIdentifier, variable_description::Variable};

/// Callback type that returns the [TextFunctionContext] to use for the `text()` template function.
/// This is intended to provide an implicit default language and settings for the `safe_suffix` feature. This function may use the template context or a global context (make sure your invariants hold in that case!).
pub type TextFunctionContextGetter =
	fn(args: &tera::Kwargs, state: &tera::State<'_>) -> TeraResult<TextFunctionContext>;

/// Localization context
#[derive(Debug, Clone)]
pub struct TextFunctionContext {
	/// The default language to use.
	///
	/// If `None` the `text()` function will try to read the language from its context or expect it being passed explicitly using the `lang` parameter.
	pub language: Option<UnicodeLanguageIdentifier>,

	/// The escape function to use for unsafe inputs
	pub escape_function: tera::EscapeFn,

	/// The suffix for safe message ids and arguments
	pub safe_suffix: Option<String>,
}

impl TextFunctionContext {
	/// Weather the given text matches the `safe_suffix` in this context.
	pub fn matches_safe_suffix(&self, text: &str) -> bool {
		if let Some(safe_suffix) = &self.safe_suffix {
			text.ends_with(safe_suffix)
		} else {
			false
		}
	}
}

/// Tera template function that can localize a message.
/// It contains an `Arc` shared reference of fluent bundle.
///
/// Usage assuming it was registered as `text`:
/// ```text
/// {{ text(id="hello-wold", lang="en", examplearg=foo, n=3, bla=true) }}
/// ```
///
/// How types that will be handled:
/// * `null`: will be ignored
/// * `string`: will be passed to the fluent template as is
/// * `number`: will be passed as is with default settings (for now), errors if the number isn't representable as `f64`.
/// * `bool`: will be passed as the strings `true` and `false`
/// * `object`|`array`: will throw a type error
pub struct TextFunction {
	pub(crate) language_engine: Arc<LanguageEngine>,
	pub(crate) context_getter: TextFunctionContextGetter,
}

impl TextFunction {
	/// Create a new TextFunction using a [LanguageEngine] that can be shared between multiple data structures.
	///
	/// The `language_getter` is a callback that returns the language to use if the `lang` argument of the text function is not set. See [TextFunctionContextGetter] for a more detailed explanation.
	pub fn new(
		language_engine: Arc<LanguageEngine>,
		context_getter: TextFunctionContextGetter,
	) -> Self {
		Self {
			language_engine,
			context_getter,
		}
	}
}

impl Function<tera::TeraResult<Value>> for TextFunction {
	fn call(&self, args: tera::Kwargs, state: &tera::State<'_>) -> TeraResult<Value> {
		// Parse id parameter
		let message_id: &str = args.must_get("id")?;

		let context = (self.context_getter)(&args, state)?;

		let is_safe_suffix = context.matches_safe_suffix(&message_id.replace("-", "_"));

		// Parse lang parameter
		let lang: UnicodeLanguageIdentifier = if let Some(lang) = args.get::<String>("lang")? {
			lang.parse()
				.map_err(|e| tera::Error::message(format!("Unable to parse lang argument: {e}")))?
		} else if let Some(lang) = &context.language {
			lang.clone()
		} else {
			return Err(tera::Error::message(
				"Could neither source the language from outside the templating engine, nor was the lang argument set. If you are using the default HumusEngine this is most likely a bug. Otherwise check your `language_getter` implementation.",
			));
		};

		// Parse other parameters
		let mut variables: Vec<(String, Variable)> = vec![];
		for (key, value) in args.iter() {
			let Some(key) = key.as_str() else {
				return Err(tera::Error::message(format!(
					"Key for text() must be a string, got {key:?} instead."
				)));
			};
			if matches!(key, "id" | "lang") {
				continue;
			}
			if let Some(value) = value.as_bool() {
				variables.push((key.to_owned(), Variable::Bool(value)))
			} else if let Some(value) = value.as_number() {
				variables.push((key.to_owned(), Variable::Number(value.as_float())))
			} else if let Some(value) = value.as_str() {
				if is_safe_suffix && !context.matches_safe_suffix(key) {
					let mut escaped_value = Vec::<u8>::default();
					(context.escape_function)(value, &mut escaped_value).map_err(|e| {
						tera::Error::chain(
							format!("While escaping argument {key:?} for text(id={message_id:?})."),
							e,
						)
					})?;
					variables.push((
						key.to_owned(),
						Variable::Text(String::from_utf8_lossy(&escaped_value).to_string()),
					));
				} else {
					variables.push((key.to_owned(), Variable::Text(value.to_owned())));
				}
			} else if value.is_undefined() || value.is_none() {
				continue; // Ignore those
			} else {
				return Err(tera::Error::message(format!(
					"Value arguments for text() must be a bool, number, string or null got {key:?}={value:?} instead."
				)));
			}
		}

		// Actually render the text
		match self
			.language_engine
			.get_text_with_args(Some(lang), message_id, variables)
		{
			Ok(text) => {
				// Remove FSI/PDI isolation marks when they are sourrounded by double quotes to make inserting HTML attributes work.
				let text = text.replace("\"\u{2068}", "\"").replace("\u{2069}\"", "\"");
				if is_safe_suffix {
					Ok(Value::from(text).mark_safe())
				} else {
					Ok(Value::from(text))
				}
			}
			Err(e) => Err(tera::Error::message(format!(
				"Problem rendering message in template: {e}"
			))),
		}
	}

	fn is_safe(&self) -> bool {
		false
	}
}