use std::num::NonZeroU32;
use mlua::{MultiValue, Table, Value};
use crate::model::{ModelNeedOpts, Temperature};
use crate::{Error, Result};
pub(crate) fn parse_single_alias(args: &MultiValue, label: &str) -> mlua::Result<String> {
match args.iter().next() {
Some(Value::String(value)) => value
.to_str()
.map_err(|_| mlua::Error::external(format!("{label} alias must be a UTF-8 string")))
.map(|s| s.to_owned()),
_ => Err(mlua::Error::external(format!(
"{label} expects a string alias as first argument"
))),
}
}
pub(crate) fn parse_need_args(args: MultiValue) -> mlua::Result<(String, String, ModelNeedOpts)> {
let mut values = args.into_iter();
let alias = match values.next() {
Some(Value::String(value)) => value
.to_str()
.map_err(|_| mlua::Error::external("models.need alias must be a UTF-8 string"))?
.to_owned(),
_ => {
return Err(mlua::Error::external(
"models.need expects alias, description, and optional opts table",
));
}
};
let description = match values.next() {
Some(Value::String(value)) => value
.to_str()
.map_err(|_| mlua::Error::external("models.need description must be a UTF-8 string"))?
.to_owned(),
_ => {
return Err(mlua::Error::external(
"models.need expects alias, description, and optional opts table",
));
}
};
let opts = match values.next() {
None | Some(Value::Nil) => ModelNeedOpts::default(),
Some(Value::Table(table)) => parse_opts_table(&table)?,
Some(_) => {
return Err(mlua::Error::external(
"models.need opts must be a table when provided",
));
}
};
if values.next().is_some() {
return Err(mlua::Error::external(
"models.need expects at most three arguments",
));
}
Ok((alias, description, opts))
}
pub(crate) fn parse_opts_table(table: &Table) -> mlua::Result<ModelNeedOpts> {
let mut opts = ModelNeedOpts::default();
for pair in table.pairs::<Value, Value>() {
let (key, value) = pair?;
let key = match key {
Value::String(key) => key
.to_str()
.map_err(|_| mlua::Error::external("models.need opts key must be a UTF-8 string"))?
.to_owned(),
_ => {
return Err(mlua::Error::external(
"models.need opts keys must be strings",
));
}
};
match key.as_str() {
"thinking" => {
opts.thinking = Some(value_as_bool(&value, "thinking")?);
}
"context" => {
opts.context = Some(value_as_nonzero_u32(&value, "context")?);
}
"temperature" => {
opts.temperature = Some(value_as_temperature(&value)?);
}
"max_tokens" => {
opts.max_tokens = Some(value_as_nonzero_u32(&value, "max_tokens")?);
}
other => {
return Err(mlua::Error::external(format!(
"unknown models.need opts key {other:?}"
)));
}
}
}
Ok(opts)
}
pub(crate) fn value_as_temperature(value: &Value) -> mlua::Result<Temperature> {
let temperature = decode_lua_number(value, "temperature")?;
Temperature::new(temperature).map_err(|error| {
mlua::Error::external(format!("models.need opts.temperature is invalid: {error}"))
})
}
pub(crate) fn value_as_bool(value: &Value, field: &str) -> mlua::Result<bool> {
match value {
Value::Boolean(flag) => Ok(*flag),
_ => Err(mlua::Error::external(format!(
"models.need opts.{field} must be a boolean"
))),
}
}
pub(crate) fn value_as_u32(value: &Value, field: &str) -> mlua::Result<u32> {
match value {
Value::Integer(number) => u32::try_from(*number).map_err(|_| {
mlua::Error::external(format!(
"models.need opts.{field} must be a non-negative integer"
))
}),
Value::Number(number) if number.fract() == 0.0 => {
let truncated = number.trunc();
if (0.0..=f64::from(u32::MAX)).contains(&truncated) {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "range checked against u32::MAX and non-negative"
)]
Ok(truncated as u32)
} else {
Err(mlua::Error::external(format!(
"models.need opts.{field} must be a non-negative integer"
)))
}
}
_ => Err(mlua::Error::external(format!(
"models.need opts.{field} must be a non-negative integer"
))),
}
}
pub(crate) fn value_as_nonzero_u32(value: &Value, field: &str) -> mlua::Result<NonZeroU32> {
let raw = value_as_u32(value, field)?;
NonZeroU32::new(raw).ok_or_else(|| {
mlua::Error::external(format!(
"models.need opts.{field} must be greater than zero"
))
})
}
pub(crate) fn decode_lua_number(value: &Value, field: &str) -> mlua::Result<f64> {
match value {
Value::Number(number) => Ok(*number),
Value::Integer(number) => {
#[expect(
clippy::cast_precision_loss,
reason = "domain is bounded by the caller; large magnitudes are rejected there"
)]
Ok(*number as f64)
}
_ => Err(mlua::Error::external(format!(
"models.need opts.{field} must be a number"
))),
}
}
pub(crate) fn validate_alias(alias: &str) -> Result<()> {
let bytes = alias.as_bytes();
let valid = (1..=64).contains(&bytes.len())
&& bytes[0].is_ascii_alphabetic()
&& bytes[1..]
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));
if valid {
Ok(())
} else {
Err(Error::Lua(format!(
"invalid model alias {alias:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}"
)))
}
}