use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(transparent)]
pub struct Author(pub String);
impl Author {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for Author {
fn from(word: &str) -> Self {
Self(word.to_owned())
}
}
impl fmt::Display for Author {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
pub trait Operation: Clone + Eq + fmt::Debug {
fn name(&self) -> &str;
}
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
)]
#[serde(transparent)]
pub struct OpWord(pub String);
impl Operation for OpWord {
fn name(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("'{op}' is not an op {author} may issue: {reason}")]
pub struct Refusal {
pub author: Author,
pub op: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{key}: {why}")]
pub struct NarrowingRefused {
pub key: String,
pub why: String,
}
pub const NOT_GRANTED: &str = "nothing grants it to this author";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Allowlist<Op: Operation> {
vocabulary: Vec<Op>,
grants: BTreeMap<Author, Vec<Op>>,
reasons: BTreeMap<Author, BTreeMap<String, String>>,
}
impl<Op: Operation> Allowlist<Op> {
#[must_use]
pub fn new(vocabulary: impl IntoIterator<Item = Op>) -> Self {
Self {
vocabulary: vocabulary.into_iter().collect(),
grants: BTreeMap::new(),
reasons: BTreeMap::new(),
}
}
pub fn declare(&mut self, author: Author) -> &mut Self {
self.grants.entry(author).or_default();
self
}
pub fn grant(&mut self, author: Author, op: Op) -> &mut Self {
let granted = self.grants.entry(author).or_default();
if !granted.contains(&op) {
granted.push(op);
}
self
}
pub fn refuse(&mut self, author: Author, op: &Op, reason: impl Into<String>) -> &mut Self {
self.grants.entry(author.clone()).or_default();
self.reasons
.entry(author)
.or_default()
.insert(op.name().to_owned(), reason.into());
self
}
pub fn allows(&self, author: &Author, op: &Op) -> Result<(), Refusal> {
if self
.grants
.get(author)
.is_some_and(|granted| granted.contains(op))
{
return Ok(());
}
Err(Refusal {
author: author.clone(),
op: op.name().to_owned(),
reason: self
.reasons
.get(author)
.and_then(|reasons| reasons.get(op.name()))
.cloned()
.unwrap_or_else(|| NOT_GRANTED.to_owned()),
})
}
#[must_use]
pub fn granted(&self, author: &Author) -> Vec<Op> {
self.grants.get(author).cloned().unwrap_or_default()
}
#[must_use]
pub fn authors(&self) -> Vec<Author> {
self.grants.keys().cloned().collect()
}
#[must_use]
pub fn vocabulary(&self) -> &[Op] {
&self.vocabulary
}
pub fn narrow(
&mut self,
key: &str,
author: &Author,
capabilities: &[String],
reason: &str,
) -> Result<(), NarrowingRefused> {
let Some(granted) = self.grants.get(author) else {
return Err(NarrowingRefused {
key: key.to_owned(),
why: format!(
"`{author}` is not an author this profile declares, and a configuration may not add one; the authors are: {}",
self.authors()
.iter()
.map(Author::as_str)
.collect::<Vec<_>>()
.join(", ")
),
});
};
let mut kept = Vec::new();
for word in capabilities {
let Some(op) = self.vocabulary.iter().find(|op| op.name() == word) else {
return Err(NarrowingRefused {
key: key.to_owned(),
why: format!(
"`{word}` is not an op; the ops are: {}",
self.vocabulary
.iter()
.map(Operation::name)
.collect::<Vec<_>>()
.join(", ")
),
});
};
if !granted.contains(op) {
return Err(NarrowingRefused {
key: key.to_owned(),
why: format!(
"`{word}` is not granted to {author} by the profile, and a configuration may narrow an author's grants but never widen them"
),
});
}
kept.push(op.clone());
}
let dropped: BTreeSet<String> = granted
.iter()
.filter(|op| !kept.contains(op))
.map(|op| op.name().to_owned())
.collect();
let reasons = self.reasons.entry(author.clone()).or_default();
for word in dropped {
reasons.insert(word, reason.to_owned());
}
self.grants.insert(author.clone(), kept);
Ok(())
}
#[must_use]
pub fn words(&self) -> Allowlist<OpWord> {
let word = |op: &Op| OpWord(op.name().to_owned());
Allowlist {
vocabulary: self.vocabulary.iter().map(word).collect(),
grants: self
.grants
.iter()
.map(|(author, ops)| (author.clone(), ops.iter().map(word).collect()))
.collect(),
reasons: self.reasons.clone(),
}
}
}