use crate::Error;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rubric {
what: String,
examples: Vec<String>,
counterexamples: Vec<String>,
}
impl Rubric {
pub fn new(what: impl Into<String>) -> Self {
Self {
what: what.into(),
examples: Vec::new(),
counterexamples: Vec::new(),
}
}
pub fn example(mut self, example: impl Into<String>) -> Self {
self.examples.push(example.into());
self
}
pub fn counterexample(mut self, counterexample: impl Into<String>) -> Self {
self.counterexamples.push(counterexample.into());
self
}
pub fn render(&self) -> Result<String, Error> {
if self.what.trim().is_empty()
&& !(self.examples.is_empty() && self.counterexamples.is_empty())
{
return Err(Error::Config {
detail: "examples need a non-empty rubric to attach to".into(),
});
}
check_clause(&self.examples, "example")?;
check_clause(&self.counterexamples, "counterexample")?;
for example in &self.examples {
if self.counterexamples.contains(example) {
return Err(Error::Config {
detail: format!(
"{example:?} is both an example and a counterexample; it cannot be in and out of the same option"
),
});
}
}
let mut out = self.what.clone();
if !self.examples.is_empty() {
out.push_str("\nExamples: ");
out.push_str(&self.examples.join("; "));
}
if !self.counterexamples.is_empty() {
out.push_str("\nNot this option: ");
out.push_str(&self.counterexamples.join("; "));
}
Ok(out)
}
}
pub(crate) fn render_pair(yes: &Rubric, no: &Rubric) -> Result<(String, String), Error> {
check_shared(&[yes, no], "option", |i| {
if i == 0 { "yes" } else { "no" }.to_owned()
})?;
Ok((yes.render()?, no.render()?))
}
pub(crate) fn render_options(
options: &[(String, Option<Rubric>)],
) -> Result<Vec<(String, Option<String>)>, Error> {
let described: Vec<(&str, &Rubric)> = options
.iter()
.filter_map(|(key, rubric)| rubric.as_ref().map(|r| (key.as_str(), r)))
.collect();
let rubrics: Vec<&Rubric> = described.iter().map(|(_, r)| *r).collect();
check_shared(&rubrics, "option", |i| format!("{:?}", described[i].0))?;
options
.iter()
.map(|(key, rubric)| {
let rendered = match rubric {
Some(rubric) => Some(rubric.render()?),
None => None,
};
Ok((key.clone(), rendered))
})
.collect()
}
pub(crate) fn render_levels(levels: &[Rubric]) -> Result<Vec<String>, Error> {
for (i, level) in levels.iter().enumerate() {
if !level.counterexamples.is_empty() {
return Err(Error::Config {
detail: format!(
"a counterexample is not allowed on level {i}; a level is a position on a scale, not an option to rule out"
),
});
}
}
let rubrics: Vec<&Rubric> = levels.iter().collect();
check_shared(&rubrics, "level", |i| format!("level {i}"))?;
levels.iter().map(Rubric::render).collect()
}
fn check_shared(
rubrics: &[&Rubric],
noun: &str,
label: impl Fn(usize) -> String,
) -> Result<(), Error> {
for (i, rubric) in rubrics.iter().enumerate() {
for example in &rubric.examples {
for (j, earlier) in rubrics[..i].iter().enumerate() {
if earlier.examples.contains(example) {
return Err(Error::Config {
detail: format!(
"{example:?} is an example of both {} and {}; an input belongs to one {noun}",
label(j),
label(i)
),
});
}
}
}
}
Ok(())
}
fn check_clause(items: &[String], kind: &str) -> Result<(), Error> {
for (i, item) in items.iter().enumerate() {
if item.trim().is_empty() {
return Err(Error::Config {
detail: format!("an {kind} must not be empty"),
});
}
if item.contains(['\n', '\r']) {
return Err(Error::Config {
detail: format!(
"an {kind} may not contain a line break (U+000A or U+000D): {item:?}"
),
});
}
if items[..i].contains(item) {
return Err(Error::Config {
detail: format!("duplicate {kind} {item:?}"),
});
}
}
Ok(())
}
mod sealed {
pub trait Sealed {}
}
pub trait IntoRubric: sealed::Sealed {
fn into_rubric(self) -> Rubric;
}
impl<T: Into<String>> sealed::Sealed for T {}
impl<T: Into<String>> IntoRubric for T {
fn into_rubric(self) -> Rubric {
Rubric::new(self)
}
}
impl sealed::Sealed for Rubric {}
impl IntoRubric for Rubric {
fn into_rubric(self) -> Rubric {
self
}
}