use serde::{Deserialize, Serialize};
use super::messages::NetworkV1;
use super::{require_schema, validate_id};
use crate::{UtcTimestamp, ValidationError};
pub const ENGINE_OPTIN_SCHEMA: &str = "leanctx.hive.engine-optin/v1";
pub const ENGINE_OPTIN_STOP_SCHEMA: &str = "leanctx.hive.engine-optin-stop/v1";
pub const DEFAULT_DAILY_TASKS: u32 = 5;
pub const MAX_DAILY_TASKS: u32 = 100;
pub const MAX_OPTIN_VAULTS: usize = 200;
pub const MAX_OPTIN_NETWORKS: usize = 200;
pub const MAX_OPTIN_TOPICS: usize = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct DailyBudgetV1 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tasks: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usd_micros: Option<u64>,
}
impl Default for DailyBudgetV1 {
fn default() -> Self {
Self {
tasks: Some(DEFAULT_DAILY_TASKS),
tokens: None,
usd_micros: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DailyUsageV1 {
pub day: String,
pub tasks: u32,
pub tokens: u64,
pub usd_micros: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineOptinV1 {
pub schema: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub optin_id: Option<String>,
pub active: bool,
#[serde(default)]
pub vaults: Vec<String>,
#[serde(default)]
pub networks: Vec<NetworkV1>,
#[serde(default)]
pub topics: Vec<String>,
pub daily_budget: DailyBudgetV1,
pub used_today: DailyUsageV1,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stopped_at: Option<UtcTimestamp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<UtcTimestamp>,
}
fn topic_ok(topic: &str) -> bool {
(2..=32).contains(&topic.len())
&& topic
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
}
fn day_ok(day: &str) -> bool {
let b = day.as_bytes();
b.len() == 10
&& b[4] == b'-'
&& b[7] == b'-'
&& [0..4, 5..7, 8..10]
.iter()
.all(|r| b[r.clone()].iter().all(u8::is_ascii_digit))
}
impl EngineOptinV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, ENGINE_OPTIN_SCHEMA)?;
match &self.optin_id {
Some(id) => validate_id(id, "optin_id")?,
None if self.active
|| !self.vaults.is_empty()
|| !self.networks.is_empty()
|| !self.topics.is_empty() =>
{
return Err(ValidationError::new(
"an engine without opt-in is inactive with empty scope",
));
}
None => {}
}
if self.vaults.len() > MAX_OPTIN_VAULTS || self.networks.len() > MAX_OPTIN_NETWORKS {
return Err(ValidationError::new(format!(
"at most {MAX_OPTIN_VAULTS} vaults and {MAX_OPTIN_NETWORKS} networks"
)));
}
for (i, vault) in self.vaults.iter().enumerate() {
super::validate_vault_id(vault)?;
if i > 0 && self.vaults[i - 1] >= *vault {
return Err(ValidationError::new(
"vaults must be sorted without repeats",
));
}
}
if self.topics.len() > MAX_OPTIN_TOPICS
|| !self.topics.iter().all(|t| topic_ok(t))
|| self.topics.windows(2).any(|pair| pair[0] >= pair[1])
{
return Err(ValidationError::new(
"up to 5 topics of 2-32 [a-z0-9-], sorted without repeats",
));
}
if self
.daily_budget
.tasks
.is_some_and(|t| !(1..=MAX_DAILY_TASKS).contains(&t))
|| self.daily_budget.tokens == Some(0)
{
return Err(ValidationError::new(
"daily_budget is 1-100 tasks or none, and a non-zero token limit",
));
}
if !day_ok(&self.used_today.day) {
return Err(ValidationError::new("used_today.day is YYYY-MM-DD"));
}
if self.active == self.stopped_at.is_some() && self.optin_id.is_some() {
return Err(ValidationError::new(
"stopped_at is set exactly while the opt-in is inactive",
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EngineOptinStopV1 {
pub schema: String,
pub active: bool,
}
impl EngineOptinStopV1 {
pub fn validate(&self) -> Result<(), ValidationError> {
require_schema(&self.schema, ENGINE_OPTIN_STOP_SCHEMA)?;
if self.active {
return Err(ValidationError::new("an engine can only stop its opt-in"));
}
Ok(())
}
}