use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use serde::Deserialize;
use serde::de::{self, Deserializer, MapAccess, Visitor};
use crate::route::{Route, RouteError};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TierError {
#[error(".tiers.json is malformed: {0}")]
Malformed(String),
#[error("{0}")]
Route(#[from] RouteError),
}
pub const AGENTBOX_ACTIVITIES: [&str; 4] = ["diagram-polish", "diagram-narrative", "diagram-synthesis", "seal-review"];
pub const ACTIVITIES: [&str; 4] = AGENTBOX_ACTIVITIES;
#[derive(Debug)]
struct Unique<V>(BTreeMap<String, V>);
impl<'de, V: Deserialize<'de>> Deserialize<'de> for Unique<V> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Walk<V>(std::marker::PhantomData<V>);
impl<'de, V: Deserialize<'de>> Visitor<'de> for Walk<V> {
type Value = Unique<V>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an object")
}
fn visit_map<A: MapAccess<'de>>(self, mut m: A) -> Result<Self::Value, A::Error> {
let mut out = BTreeMap::new();
while let Some((k, v)) = m.next_entry::<String, V>()? {
if out.contains_key(&k) {
return Err(de::Error::custom(format!("duplicate key `{k}`")));
}
out.insert(k, v);
}
Ok(Unique(out))
}
}
d.deserialize_map(Walk(std::marker::PhantomData))
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
version: u32,
corpora: Unique<Unique<String>>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum RoutePolicy {
#[default]
Portable,
Agentbox,
}
impl RoutePolicy {
fn parse(self, text: &str) -> Result<Route, RouteError> {
match self {
Self::Portable => Route::parse(text),
Self::Agentbox => Route::parse_agentbox(text),
}
}
}
fn activity_name(name: &str) -> Result<(), TierError> {
if !name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
|| !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
{
return Err(TierError::Malformed(format!("invalid activity `{name}` (expected an ASCII identifier)")));
}
Ok(())
}
fn corpus_name(name: &str) -> Result<(), TierError> {
if name.trim().is_empty() || name.chars().any(char::is_control) {
return Err(TierError::Malformed("corpus name must be non-empty and contain no control characters".into()));
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Tiers {
corpora: BTreeMap<(String, String), String>,
allowed_activities: Option<BTreeSet<String>>,
route_policy: RoutePolicy,
}
impl Tiers {
pub fn parse(text: &str) -> Result<Self, TierError> {
Self::read(Some(text), None, RoutePolicy::Portable)
}
pub fn parse_with_activities(text: &str, activities: &[&str]) -> Result<Self, TierError> {
Self::read(Some(text), Some(activities), RoutePolicy::Portable)
}
pub fn parse_agentbox(text: &str) -> Result<Self, TierError> {
Self::read(Some(text), Some(&AGENTBOX_ACTIVITIES), RoutePolicy::Agentbox)
}
pub fn from_file_text(text: Option<&str>) -> Result<Self, TierError> {
Self::read(text, None, RoutePolicy::Portable)
}
pub fn from_file_text_with_activities(text: Option<&str>, activities: &[&str]) -> Result<Self, TierError> {
Self::read(text, Some(activities), RoutePolicy::Portable)
}
pub fn from_file_text_agentbox(text: Option<&str>) -> Result<Self, TierError> {
Self::read(text, Some(&AGENTBOX_ACTIVITIES), RoutePolicy::Agentbox)
}
fn read(text: Option<&str>, activities: Option<&[&str]>, route_policy: RoutePolicy) -> Result<Self, TierError> {
let allowed_activities = activities
.map(|names| {
names
.iter()
.map(|name| {
activity_name(name)?;
Ok((*name).to_owned())
})
.collect::<Result<BTreeSet<_>, TierError>>()
})
.transpose()?;
let mut tiers = Self { corpora: BTreeMap::new(), allowed_activities, route_policy };
let Some(text) = text else { return Ok(tiers) };
let file: File = serde_json::from_str(text).map_err(|e| TierError::Malformed(e.to_string()))?;
if file.version != 1 {
return Err(TierError::Malformed(format!("version {} is not supported (expected 1)", file.version)));
}
for (name, activities) in file.corpora.0 {
corpus_name(&name)?;
for (activity, route) in activities.0 {
tiers.check_activity(&activity)?;
route_policy
.parse(&route)
.map_err(|e| TierError::Malformed(format!("corpus `{name}`, activity `{activity}`: {e}")))?;
tiers.corpora.insert((name.clone(), activity), route.trim().to_owned());
}
}
Ok(tiers)
}
fn check_activity(&self, activity: &str) -> Result<(), TierError> {
activity_name(activity)?;
if let Some(allowed) = &self.allowed_activities {
if !allowed.contains(activity) {
return Err(TierError::Malformed(format!("unknown activity `{activity}` under selected policy")));
}
}
Ok(())
}
pub fn start_route(&self, corpus: &str, activity: &str, default: &str) -> Result<String, TierError> {
corpus_name(corpus)?;
self.check_activity(activity)?;
self.route_policy.parse(default)?;
let key = (corpus.to_owned(), activity.to_owned());
Ok(self.corpora.get(&key).cloned().unwrap_or_else(|| default.trim().to_owned()))
}
}