use core::sync::atomic::{AtomicUsize, Ordering};
#[cfg(not(feature = "std"))]
use alloc::boxed::Box;
#[cfg(not(feature = "std"))]
use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::string::{String, ToString};
#[cfg(not(feature = "std"))]
use alloc::vec;
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::collections::{HashMap, HashSet, new_map, new_set};
use crate::faithfulness::score_faithfulness;
use crate::session::Session;
use crate::agreement::AgreementFeatures;
use crate::antonyms::{AntonymRegistry, insert_not};
use crate::context::{Context, IntoContext, Value};
use crate::discourse::{ListStyle, ReferenceForm, Transition};
use crate::error::ProsaicError;
use crate::hedge::{HedgeMode, hedge as hedge_fn, parse_mode as parse_hedge_mode};
use crate::language::{Conjunction, Language, Person, PluralCategory, VerbForm};
#[cfg(feature = "polish")]
use crate::length::split_long_in_place;
#[cfg(feature = "polish")]
use crate::punctuation::smart_quotes_in_place;
use crate::quantify::{QuantifyMode, parse_mode as parse_quantify_mode, quantify as quantify_fn};
#[cfg(feature = "reg")]
use crate::reg::{
EntityDescriptor, EntityRegistry, distinguishing_attributes, distinguishing_subgraph,
};
use crate::salience::{Salience, SalienceThresholds};
use crate::synonyms::SynonymRegistry;
use crate::template::{Pipe, PipeArg, Segment, Template};
#[cfg(feature = "time")]
use crate::time::format_relative;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Strictness {
#[default]
Strict,
Lenient,
Silent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Variation {
#[default]
Fixed,
Seeded(u64),
RoundRobin,
Random,
}
#[cfg(feature = "reg")]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum RegAlgorithm {
#[default]
DaleReiter,
GraphBased,
}
#[derive(Debug, Clone)]
pub struct SalientTemplate {
pub salience: Salience,
pub template: Template,
pub language: Option<String>,
pub style: Option<String>,
}
impl SalientTemplate {
pub fn new(
salience: Salience,
template: Template,
language: Option<String>,
style: Option<String>,
) -> Self {
Self {
salience,
template,
language,
style,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RenderExplanation {
pub output: String,
pub template_key: String,
pub variant_index: usize,
pub variant_source: String,
pub salience: Salience,
pub candidate_scores: Option<Vec<f64>>,
pub reference_form: Option<ReferenceForm>,
pub connective: Option<&'static str>,
pub list_style: Option<ListStyle>,
pub focus_is_plural: bool,
pub length_split_applied: bool,
pub cleanup_stripped_tail: bool,
pub centering_transition: Transition,
}
pub struct RenderIter<'a> {
engine: &'a Engine,
session: &'a mut Session,
events: &'a [(&'a str, Context)],
i: usize,
}
impl<'a> Iterator for RenderIter<'a> {
type Item = Result<String, ProsaicError>;
fn next(&mut self) -> Option<Self::Item> {
if self.i >= self.events.len() {
return None;
}
let fail = |this: &mut RenderIter<'_>, e: ProsaicError| -> Option<Self::Item> {
this.i = this.events.len();
Some(Err(e))
};
let action_end = self.engine.find_same_action_run(self.events, self.i);
if action_end > self.i + 1 {
let key = self.events[self.i].0;
let run = &self.events[self.i..action_end];
let sentence = match self
.engine
.render_aggregated_subjects(self.session, key, run)
{
Ok(s) => s,
Err(e) => return fail(self, e),
};
self.i = action_end;
return Some(Ok(sentence));
}
let gap_end = self.engine.find_gapping_run(self.events, self.i);
if gap_end > self.i + 1 {
let mut rendered: Vec<String> = Vec::with_capacity(gap_end - self.i);
for (key, ctx) in &self.events[self.i..gap_end] {
match self.engine.render(self.session, key, ctx) {
Ok(s) => rendered.push(s),
Err(e) => return fail(self, e),
}
}
self.i = gap_end;
if let Some(gapped) = reduce_gapping(&rendered) {
return Some(Ok(gapped));
}
return Some(Ok(rendered.join(" ")));
}
let entity_end = self.engine.find_same_entity_run(self.events, self.i);
if entity_end > self.i + 1 {
let mut run_rendered: Vec<String> = Vec::with_capacity(entity_end - self.i);
for (key, ctx) in &self.events[self.i..entity_end] {
match self.engine.render(self.session, key, ctx) {
Ok(s) => run_rendered.push(s),
Err(e) => return fail(self, e),
}
}
self.i = entity_end;
if let Some(reduced) = reduce_same_entity_clauses(&run_rendered) {
return Some(Ok(reduced));
}
return Some(Ok(run_rendered.join(" ")));
}
let (key, ctx) = &self.events[self.i];
self.i += 1;
match self.engine.render(self.session, key, ctx) {
Ok(s) => Some(Ok(s)),
Err(e) => {
self.i = self.events.len();
Some(Err(e))
}
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct VariantScore {
pub index: usize,
pub source: String,
pub rendered: String,
pub score: f64,
pub salience: Salience,
pub is_last_selected: bool,
pub selected: bool,
}
pub struct Engine {
language: Box<dyn Language>,
templates: HashMap<String, Vec<SalientTemplate>>,
strictness: Strictness,
variation: Variation,
salience_thresholds: SalienceThresholds,
rr_initial: HashMap<String, usize>,
#[cfg(feature = "reg")]
entity_registry: EntityRegistry,
#[cfg(feature = "reg")]
reg_preference: Vec<String>,
#[cfg(feature = "reg")]
reg_algorithm: RegAlgorithm,
synonyms: SynonymRegistry,
#[cfg(feature = "time")]
reference_time: Option<i64>,
antonyms: AntonymRegistry,
#[cfg(feature = "polish")]
max_sentence_length: Option<usize>,
#[cfg(feature = "polish")]
smart_quotes: bool,
sentence_rhythm_enabled: bool,
partials: HashMap<String, Template>,
language_preference: Option<String>,
style_preference: Option<String>,
faithfulness_threshold: Option<f32>,
style_profile: crate::style::StyleProfile,
refine_config: crate::refine::RefineConfig,
}
#[derive(Debug, Clone, Copy, Default)]
struct RenderOptions {
suppress_auto_connective: bool,
}
struct RenderCtx<'e, 's> {
engine: &'e Engine,
session: &'s mut Session,
}
impl<'e, 's> RenderCtx<'e, 's> {
fn new(engine: &'e Engine, session: &'s mut Session) -> Self {
Self { engine, session }
}
fn render_tx_with_options(
&mut self,
key: &str,
all_alternatives: &[SalientTemplate],
context: &Context,
options: RenderOptions,
) -> Result<String, ProsaicError> {
self.session.discourse.begin_render();
let entity_name = context
.get("name")
.or_else(|| context.get("old_name"))
.map(|v| v.as_display());
let entity_type = context.get("entity_type").map(|v| v.as_display());
let connective = if options.suppress_auto_connective {
None
} else {
let relation = self
.session
.discourse
.detect_relation(key, entity_name.as_deref());
let prefs = &self.engine.style_profile.connectives;
let rst_key = rst_for_discourse(&relation);
let allow_owned: Option<Vec<&str>> = rst_key
.and_then(|rst| prefs.allowed.get(&rst))
.map(|v| v.iter().map(String::as_str).collect());
let prefer_owned: Option<Vec<(&str, f32)>> = rst_key
.and_then(|rst| prefs.preferred.get(&rst))
.map(|v| v.iter().map(|(s, w)| (s.as_str(), *w)).collect());
let forbid_owned: Option<Vec<&str>> =
if self.session.refine_blacklist_connectives.is_empty() {
None
} else {
Some(
self.session
.refine_blacklist_connectives
.iter()
.map(String::as_str)
.collect(),
)
};
self.session.discourse.select_connective_filtered(
&relation,
allow_owned.as_deref(),
prefer_owned.as_deref(),
forbid_owned.as_deref(),
)
};
let target_salience = self.resolve_target_salience(key, context);
let alternatives = filter_alternatives(
all_alternatives,
target_salience,
self.engine.language_preference.as_deref(),
self.engine.style_preference.as_deref(),
);
let (template, variant_index) =
self.select_alternative_scored(key, &alternatives, context)?;
self.session
.discourse
.record_template_choice(key, variant_index);
let mut output = String::with_capacity(128);
self.render_template_into(&mut output, key, template, context)?;
if let Some(conn) = connective {
if conn.starts_with("It ") {
prepend_replacing_subject_in_place(&mut output, conn, entity_name.as_deref());
} else {
lowercase_first_in_place(&mut output);
let mut buf = String::with_capacity(conn.len() + 1 + output.len());
buf.push_str(conn);
buf.push(' ');
buf.push_str(&output);
core::mem::swap(&mut output, &mut buf);
}
}
if starts_with_refer_pipe(template) {
capitalize_first_in_place(&mut output);
}
let cleanup_stripped = cleanup_artifacts_in_place(&mut output, self.engine.strictness);
self.session
.discourse
.set_cleanup_stripped_tail(cleanup_stripped);
terminate_sentence_in_place(&mut output);
#[cfg(feature = "polish")]
if let Some(max_chars) = self.engine.max_sentence_length {
split_long_in_place(&mut output, max_chars);
}
#[cfg(feature = "polish")]
if self.engine.smart_quotes {
smart_quotes_in_place(&mut output);
}
if let Some(threshold) = self.engine.faithfulness_threshold {
let literals = template.literal_tokens();
let score = score_faithfulness(&output, context, &literals, &*self.engine.language);
if !score.passes(threshold) {
return Err(ProsaicError::FaithfulnessRejection {
precision: score.precision,
polarity_match: score.polarity_match,
});
}
}
if let (Some(name), Some(etype)) = (&entity_name, &entity_type) {
self.session.discourse.mention_entity(name, etype);
}
self.session.discourse.record_output_words(&output);
self.session.discourse.record_sentence_rhythm(&output);
self.session.discourse.advance_cb();
Ok(output)
}
fn resolve_target_salience(&self, key: &str, context: &Context) -> Salience {
resolve_target_salience_for(self.engine, self.session, key, context)
}
fn candidate_discourse_score(&self, candidate: &str) -> f64 {
let mut score = self.session.discourse.repetition_score(candidate);
if self.engine.sentence_rhythm_enabled {
score += self.session.discourse.sentence_rhythm_score(candidate);
}
let target_distribution = self
.session
.refine_length_distribution
.as_ref()
.unwrap_or(&self.engine.style_profile.sentence_length);
score += profile_length_bias_score(candidate, &self.session.discourse, target_distribution);
score
}
fn select_alternative_scored<'a>(
&mut self,
key: &str,
alternatives: &[&'a Template],
context: &Context,
) -> Result<(&'a Template, usize), ProsaicError> {
if alternatives.len() == 1 {
return Ok((alternatives[0], 0));
}
let allow_choose_best = matches!(
self.engine.variation,
Variation::Seeded(_) | Variation::Random
);
if !allow_choose_best {
let index = self.select_variant_index(key, alternatives.len());
return Ok((alternatives[index], index));
}
let last_variant = self.session.discourse.last_template_variant(key);
let is_first = self.session.discourse.is_first_render();
if is_first {
let index = self.select_variant_index(key, alternatives.len());
return Ok((alternatives[index], index));
}
let snapshot = self.session.clone();
let mut candidates: Vec<(usize, String)> = Vec::new();
let mut scratch = String::with_capacity(128);
for (i, template) in alternatives.iter().enumerate() {
if Some(i) == last_variant {
continue;
}
scratch.clear();
match self.render_template_into(&mut scratch, key, template, context) {
Ok(()) => {}
Err(e) => {
*self.session = snapshot;
return Err(e);
}
}
candidates.push((i, scratch.clone()));
}
*self.session = snapshot;
if candidates.is_empty() {
let index = last_variant.unwrap_or(0).min(alternatives.len() - 1);
return Ok((alternatives[index], index));
}
let mut best_index = candidates[0].0;
let mut best_score = f64::MAX;
for (i, candidate) in &candidates {
let score = self.candidate_discourse_score(candidate);
if score < best_score {
best_score = score;
best_index = *i;
}
}
Ok((alternatives[best_index], best_index))
}
fn select_variant_index(&mut self, key: &str, count: usize) -> usize {
match self.engine.variation {
Variation::Fixed => 0,
Variation::Seeded(seed) => {
let hash = simple_hash(key, seed);
hash as usize % count
}
Variation::RoundRobin => {
let counter = self
.session
.round_robin_counters
.entry(key.to_string())
.or_insert_with(|| AtomicUsize::new(0))
.fetch_add(1, Ordering::Relaxed);
counter % count
}
Variation::Random => {
#[cfg(feature = "std")]
{
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as usize;
nanos % count
}
#[cfg(not(feature = "std"))]
{
let _ = count;
0
}
}
}
}
fn render_template_into(
&mut self,
out: &mut String,
key: &str,
template: &Template,
context: &Context,
) -> Result<(), ProsaicError> {
self.render_segments_into(out, key, &template.segments, context)
}
fn render_segments_into(
&mut self,
out: &mut String,
key: &str,
segments: &[Segment],
context: &Context,
) -> Result<(), ProsaicError> {
for segment in segments {
match segment {
Segment::Literal(text) => out.push_str(text),
Segment::Slot {
key: slot_key,
pipes,
} => {
self.render_slot_into(out, key, slot_key, pipes, context)?;
}
Segment::Conditional {
condition_key,
inner,
} => {
if is_truthy(context.get(condition_key)) {
self.render_segments_into(out, key, inner, context)?;
}
}
Segment::Partial { name } => {
let partial_segments = self.engine.partials.get(name).ok_or_else(|| {
ProsaicError::TemplateParseError {
template: key.to_string(),
position: 0,
reason: format!(
"unknown partial `{name}` — register it with `engine.register_partial`"
),
}
})?.segments.clone();
self.render_segments_into(out, key, &partial_segments, context)?;
}
}
}
Ok(())
}
fn render_slot_into(
&mut self,
out: &mut String,
template_key: &str,
slot_key: &str,
pipes: &[Pipe],
context: &Context,
) -> Result<(), ProsaicError> {
let value = match context.get(slot_key) {
Some(v) => v.clone(),
None => {
let s = self.handle_missing_slot(template_key, slot_key)?;
out.push_str(&s);
return Ok(());
}
};
if pipes.is_empty() {
out.push_str(&value.as_display());
return Ok(());
}
let mut current = value;
for pipe in pipes {
current = self.apply_pipe(pipe, ¤t, context)?;
}
out.push_str(¤t.as_display());
Ok(())
}
fn handle_missing_slot(
&self,
template_key: &str,
slot_key: &str,
) -> Result<String, ProsaicError> {
match self.engine.strictness {
Strictness::Strict => Err(ProsaicError::MissingSlot {
template: template_key.to_string(),
slot: slot_key.to_string(),
}),
Strictness::Lenient => Ok(format!("[missing: {slot_key}]")),
Strictness::Silent => Ok(String::new()),
}
}
fn apply_pipe(
&mut self,
pipe: &Pipe,
value: &Value,
context: &Context,
) -> Result<Value, ProsaicError> {
match pipe.name.as_str() {
"plural" => self.pipe_plural(pipe, value),
"pluralize" => self.pipe_pluralize(pipe, value, context),
"article" => self.pipe_article(value),
"join" => self.pipe_join(pipe, value),
"ordinal" => self.pipe_ordinal(value),
"words" => self.pipe_words(value),
"truncate" => self.pipe_truncate(pipe, value),
"capitalize" => self.pipe_capitalize(value),
"refer" => self.pipe_refer(pipe, value, context),
"possessive" => self.pipe_possessive(pipe, value, context),
"verb" => self.pipe_verb(pipe, value),
"syn" => self.pipe_syn(value),
#[cfg(feature = "time")]
"relative" => self.pipe_relative(value),
#[cfg(feature = "time")]
"since_last" => self.pipe_since_last(value),
"quantify" => self.pipe_quantify(pipe, value),
"proportion" => self.pipe_proportion(pipe, value, context),
"demonstrative" => self.pipe_demonstrative(value),
"hedge" => self.pipe_hedge(pipe, value),
"negated" => self.pipe_negated(value),
"choose" => self.pipe_choose(pipe, value),
_ => Err(ProsaicError::InvalidPipe {
pipe: pipe.name.clone(),
reason: "unknown pipe".to_string(),
}),
}
}
fn pipe_choose(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
let arg_str = match &pipe.arg {
Some(PipeArg::String(s)) => s.as_str(),
Some(PipeArg::Number(_)) | None => {
return Err(ProsaicError::InvalidPipe {
pipe: "choose".to_string(),
reason: "choose requires an argument of the form \
'key=value,key=value,default=value'"
.to_string(),
});
}
};
let pairs = parse_choose_pairs(arg_str)?;
if pairs.is_empty() {
return Err(ProsaicError::InvalidPipe {
pipe: "choose".to_string(),
reason: "choose argument is empty".to_string(),
});
}
let display = value.as_display();
let normalized = display.trim().to_lowercase();
for (k, v) in &pairs {
if k.to_lowercase() == normalized {
return Ok(Value::String(v.clone()));
}
}
for (k, v) in &pairs {
if k.eq_ignore_ascii_case("default") {
return Ok(Value::String(v.clone()));
}
}
match self.engine.strictness {
Strictness::Strict => Err(ProsaicError::InvalidPipe {
pipe: "choose".to_string(),
reason: format!("no matching key for value `{display}` and no default"),
}),
Strictness::Lenient => Ok(Value::String(format!("[choose: no match for {display}]"))),
Strictness::Silent => Ok(Value::String(String::new())),
}
}
fn pipe_refer(
&mut self,
pipe: &Pipe,
value: &Value,
context: &Context,
) -> Result<Value, ProsaicError> {
if let Value::List(names) = value {
return self.pipe_refer_plural(pipe, names, context);
}
self.pipe_refer_single(pipe, value, context)
}
fn pipe_possessive(
&self,
pipe: &Pipe,
value: &Value,
context: &Context,
) -> Result<Value, ProsaicError> {
if let Value::List(names) = value {
return self.pipe_possessive_plural(pipe, names, context);
}
self.pipe_possessive_single(value)
}
fn pipe_refer_single(
&self,
pipe: &Pipe,
value: &Value,
context: &Context,
) -> Result<Value, ProsaicError> {
let name = value.as_display();
let entity_type = match &pipe.arg {
Some(PipeArg::String(t)) => t.clone(),
_ => context
.get("entity_type")
.map(|v| v.as_display())
.unwrap_or_default(),
};
let form = self.session.discourse.reference_form_with_density(
&name,
matches!(
self.engine.style_profile.pronoun_density,
crate::style::PronounDensity::Low
),
matches!(
self.engine.style_profile.pronoun_density,
crate::style::PronounDensity::High
),
);
let rendered = match form {
ReferenceForm::Full => self.engine.render_full_reference(&name, &entity_type),
ReferenceForm::ShortName => name,
ReferenceForm::Pronoun
| ReferenceForm::Possessive
| ReferenceForm::Demonstrative
| ReferenceForm::Zero => {
let features = reference_features(value, self.session.discourse.focus_is_plural());
self.engine
.language
.realize_reference(form, &features)
.unwrap_or_default()
}
};
Ok(Value::String(rendered))
}
fn pipe_possessive_single(&self, value: &Value) -> Result<Value, ProsaicError> {
let name = value.as_display();
let form = self.session.discourse.reference_form_with_density(
&name,
matches!(
self.engine.style_profile.pronoun_density,
crate::style::PronounDensity::Low
),
matches!(
self.engine.style_profile.pronoun_density,
crate::style::PronounDensity::High
),
);
let rendered = match form {
ReferenceForm::Pronoun | ReferenceForm::Demonstrative | ReferenceForm::Zero => {
let features = reference_features(value, self.session.discourse.focus_is_plural());
self.engine
.language
.realize_reference(ReferenceForm::Possessive, &features)
.unwrap_or_else(|| self.engine.language.possessive_name(&name))
}
ReferenceForm::Full | ReferenceForm::ShortName | ReferenceForm::Possessive => {
self.engine.language.possessive_name(&name)
}
};
Ok(Value::String(rendered))
}
fn pipe_possessive_plural(
&self,
pipe: &Pipe,
names: &[String],
context: &Context,
) -> Result<Value, ProsaicError> {
match names.len() {
0 => Ok(Value::String(String::new())),
1 => {
let v = Value::String(names[0].clone());
self.pipe_possessive_single(&v)
}
n => {
let entity_type = match &pipe.arg {
Some(PipeArg::String(t)) => t.clone(),
_ => context
.get("entity_type")
.map(|v| v.as_display())
.unwrap_or_default(),
};
let owner = if entity_type.is_empty() {
let items: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
self.engine
.language
.join_list(&items, crate::language::Conjunction::And)
} else {
self.engine.language.plural_description(
&entity_type,
n,
&crate::agreement::AgreementFeatures::default(),
)
};
Ok(Value::String(self.engine.language.possessive_name(&owner)))
}
}
}
fn pipe_refer_plural(
&mut self,
pipe: &Pipe,
names: &[String],
context: &Context,
) -> Result<Value, ProsaicError> {
match names.len() {
0 => Ok(Value::String(String::new())),
1 => {
let v = Value::String(names[0].clone());
self.pipe_refer_single(pipe, &v, context)
}
n => {
let entity_type = match &pipe.arg {
Some(PipeArg::String(t)) => t.clone(),
_ => context
.get("entity_type")
.map(|v| v.as_display())
.unwrap_or_default(),
};
if !entity_type.is_empty() {
for name in names {
self.session.discourse.mention_entity(name, &entity_type);
}
}
self.session.discourse.set_focus_plural(true);
let features = crate::agreement::AgreementFeatures::default();
let output = self
.engine
.language
.plural_description(&entity_type, n, &features);
Ok(Value::String(output))
}
}
}
fn pipe_demonstrative(&self, value: &Value) -> Result<Value, ProsaicError> {
let noun = value.as_display();
if noun.is_empty() {
return Ok(Value::String(noun));
}
let determiner = if self.session.discourse.has_prior_render() {
"this"
} else {
"the"
};
Ok(Value::String(format!("{determiner} {noun}")))
}
fn pipe_syn(&self, value: &Value) -> Result<Value, ProsaicError> {
let word = value.as_display();
let synonyms = match self.engine.synonyms.synonyms_for(&word) {
Some(s) => s,
None => return Ok(Value::String(word)),
};
if synonyms.is_empty() {
return Ok(Value::String(word));
}
let mut best = &synonyms[0];
let mut best_score = self.session.discourse.word_frequency(&synonyms[0]);
for syn in &synonyms[1..] {
let score = self.session.discourse.word_frequency(syn);
if score < best_score {
best_score = score;
best = syn;
}
}
let result = if word
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false)
{
let mut s = best.clone();
capitalize_first_in_place(&mut s);
s
} else {
best.clone()
};
Ok(Value::String(result))
}
fn pipe_join(&mut self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
let items = value.as_list().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "join".to_string(),
reason: "value must be a list".to_string(),
})?;
let forced_style = match &pipe.arg {
Some(PipeArg::String(s)) if s == "bracketed" => Some(ListStyle::Bracketed),
Some(PipeArg::String(s)) if s == "including" => Some(ListStyle::Including),
Some(PipeArg::String(s)) if s == "such_as" => Some(ListStyle::SuchAs),
Some(PipeArg::String(s)) if s == "dash" => Some(ListStyle::Dash),
Some(PipeArg::String(s)) if s == "among_others" => Some(ListStyle::AmongOthers),
Some(PipeArg::String(s)) if s == "to_name_a_few" => Some(ListStyle::ToNameAFew),
Some(PipeArg::String(s)) if s == "plus_more" => Some(ListStyle::PlusMore),
_ => None,
};
let conjunction = match &pipe.arg {
Some(PipeArg::String(s)) if s == "or" => Conjunction::Or,
_ => Conjunction::And,
};
let style = match forced_style {
Some(s) => {
self.session.discourse.record_list_style_used(s);
s
}
None => {
let mut bias_target =
list_style_bias_target(self.engine.style_profile.list_style_bias);
if let Some(target) = bias_target
&& self.session.refine_blacklist_list_styles.contains(&target)
{
bias_target = None;
}
let chosen = self
.session
.discourse
.next_list_style_with_bias(bias_target);
if self.session.refine_blacklist_list_styles.contains(&chosen) {
let mut next = chosen;
for _ in 0..crate::discourse::list_styles_count() {
next = self.session.discourse.next_list_style_with_bias(None);
if !self.session.refine_blacklist_list_styles.contains(&next) {
break;
}
}
next
} else {
chosen
}
}
};
let refs: Vec<&str> = items.iter().map(|s| s.as_str()).collect();
let has_truncation = items.last().is_some_and(|last| {
last.ends_with(" more")
&& last
.split_whitespace()
.next()
.is_some_and(|w| w.parse::<usize>().is_ok())
});
if has_truncation && items.len() >= 2 {
let shown = &refs[..refs.len() - 1];
let remainder = &items[items.len() - 1];
Ok(Value::String(format_truncated_list(
shown,
remainder,
style,
conjunction,
&*self.engine.language,
)))
} else {
let joined = self.engine.language.join_list(&refs, conjunction);
Ok(Value::String(joined))
}
}
fn pipe_pluralize(
&self,
pipe: &Pipe,
value: &Value,
_context: &Context,
) -> Result<Value, ProsaicError> {
let word = match &pipe.arg {
Some(PipeArg::String(w)) => w.as_str(),
_ => {
return Err(ProsaicError::InvalidPipe {
pipe: "pluralize".to_string(),
reason: "requires a word argument, e.g., {count|pluralize:item}".to_string(),
});
}
};
let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "pluralize".to_string(),
reason: "value must be a number".to_string(),
})? as usize;
Ok(Value::String(self.engine.language.pluralize(word, count)))
}
fn pipe_plural(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
let noun = match &pipe.arg {
Some(PipeArg::String(s)) => s.as_str(),
_ => {
return Err(ProsaicError::InvalidPipe {
pipe: "plural".to_string(),
reason: "requires a singular noun argument, e.g., {count|plural:service}"
.to_string(),
});
}
};
let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "plural".to_string(),
reason: "requires a numeric slot value".to_string(),
})?;
let category: PluralCategory = self.engine.language.plural_category(count);
Ok(Value::String(
self.engine.language.pluralize_with_category(noun, category),
))
}
fn pipe_article(&self, value: &Value) -> Result<Value, ProsaicError> {
let word = value.as_display();
let article = self.engine.language.article(&word);
Ok(Value::String(format!("{article} {word}")))
}
fn pipe_ordinal(&self, value: &Value) -> Result<Value, ProsaicError> {
let n = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "ordinal".to_string(),
reason: "value must be a number".to_string(),
})? as usize;
Ok(Value::String(self.engine.language.ordinal(n)))
}
fn pipe_words(&self, value: &Value) -> Result<Value, ProsaicError> {
let n = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "words".to_string(),
reason: "value must be a number".to_string(),
})? as usize;
Ok(Value::String(self.engine.language.number_to_words(n)))
}
fn pipe_truncate(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
let max = match &pipe.arg {
Some(PipeArg::Number(n)) => *n,
_ => {
return Err(ProsaicError::InvalidPipe {
pipe: "truncate".to_string(),
reason: "requires a numeric argument, e.g., {items|truncate:3}".to_string(),
});
}
};
let items = value.as_list().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "truncate".to_string(),
reason: "value must be a list".to_string(),
})?;
if items.len() <= max {
return Ok(value.clone());
}
let remaining = items.len() - max;
let mut truncated: Vec<String> = items[..max].to_vec();
let suffix = format!("{remaining} more");
truncated.push(suffix);
Ok(Value::List(truncated))
}
fn pipe_capitalize(&self, value: &Value) -> Result<Value, ProsaicError> {
let mut s = value.as_display();
capitalize_first_in_place(&mut s);
Ok(Value::String(s))
}
fn pipe_negated(&self, value: &Value) -> Result<Value, ProsaicError> {
let phrase = value.as_display();
if let Some(positive) = self.engine.antonyms.lookup(&phrase) {
return Ok(Value::String(positive.to_string()));
}
Ok(Value::String(insert_not(&phrase)))
}
fn pipe_hedge(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
let score = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "hedge".to_string(),
reason: "value must be a 0..=100 integer confidence score".to_string(),
})?;
let mode = match &pipe.arg {
None => HedgeMode::Adverb,
Some(PipeArg::String(s)) => {
parse_hedge_mode(s).ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "hedge".to_string(),
reason: format!(
"unknown hedge mode `{s}` — expected one of adverb, modal, prefix"
),
})?
}
Some(PipeArg::Number(_)) => {
return Err(ProsaicError::InvalidPipe {
pipe: "hedge".to_string(),
reason: "hedge argument must be a mode name, not a number".to_string(),
});
}
};
Ok(Value::String(
hedge_with_calibration(score, mode, &self.engine.style_profile.hedging).to_string(),
))
}
fn pipe_proportion(
&self,
pipe: &Pipe,
value: &Value,
context: &Context,
) -> Result<Value, ProsaicError> {
let arg_str = match &pipe.arg {
Some(PipeArg::String(s)) => s.as_str(),
Some(PipeArg::Number(_)) | None => {
return Err(ProsaicError::InvalidPipe {
pipe: "proportion".to_string(),
reason: "requires an argument of the form \
`proportion:total_key[:singular_noun]`"
.to_string(),
});
}
};
let (total_key, noun) = match arg_str.split_once(':') {
Some((k, n)) => {
let n = n.trim();
(k.trim(), if n.is_empty() { None } else { Some(n) })
}
None => (arg_str.trim(), None),
};
if total_key.is_empty() {
return Err(ProsaicError::InvalidPipe {
pipe: "proportion".to_string(),
reason: "missing total context key — use `proportion:total_key[:noun]`".to_string(),
});
}
let matching = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "proportion".to_string(),
reason: "value must be a number".to_string(),
})?;
let total_value = context
.get(total_key)
.ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "proportion".to_string(),
reason: format!("total context key `{total_key}` not found"),
})?;
let total = total_value
.as_number()
.ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "proportion".to_string(),
reason: format!("total context key `{total_key}` is not a number"),
})?;
let features = AgreementFeatures::default();
let phrase = self
.engine
.language
.proportion_phrase(matching, total, noun, &features);
Ok(Value::String(phrase))
}
fn pipe_quantify(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
let count = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "quantify".to_string(),
reason: "value must be a number".to_string(),
})?;
let mode = match &pipe.arg {
None => QuantifyMode::Natural,
Some(PipeArg::String(s)) => {
parse_quantify_mode(s).ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "quantify".to_string(),
reason: format!(
"unknown quantify mode `{s}` — expected one of natural, exact, hedged"
),
})?
}
Some(PipeArg::Number(_)) => {
return Err(ProsaicError::InvalidPipe {
pipe: "quantify".to_string(),
reason: "quantify argument must be a mode name, not a number".to_string(),
});
}
};
Ok(Value::String(quantify_fn(
count,
mode,
&*self.engine.language,
)))
}
fn pipe_verb(&self, pipe: &Pipe, value: &Value) -> Result<Value, ProsaicError> {
let spec = match &pipe.arg {
Some(PipeArg::String(s)) => s.as_str(),
_ => {
return Err(ProsaicError::InvalidPipe {
pipe: "verb".to_string(),
reason: "requires a form spec argument, e.g., \
{rename|verb:present_perfect}"
.to_string(),
});
}
};
let (form, voice) =
VerbForm::parse_spec(spec).ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "verb".to_string(),
reason: format!(
"unknown verb form spec `{spec}` — expected one of past, present, future, \
present_perfect, past_perfect, future_perfect, present_progressive, \
past_progressive, conditional, conditional_perfect \
(optionally prefixed with `active_` or `passive_`)"
),
})?;
let verb = value.as_display();
let phrase = self
.engine
.language
.verb_phrase(&verb, form, voice, Person::Third);
Ok(Value::String(phrase))
}
#[cfg(feature = "time")]
fn pipe_relative(&self, value: &Value) -> Result<Value, ProsaicError> {
let ts = value.as_number().ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "relative".to_string(),
reason: "value must be a Unix-epoch integer (seconds)".to_string(),
})?;
let now = match self.engine.reference_time {
Some(n) => n,
None => {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
};
let diff = now - ts;
Ok(Value::String(format_relative(diff)))
}
#[cfg(feature = "time")]
fn pipe_since_last(&mut self, value: &Value) -> Result<Value, ProsaicError> {
let Some(ts) = value.as_number() else {
return Err(ProsaicError::InvalidPipe {
pipe: "since_last".to_string(),
reason: "expected numeric Unix-seconds timestamp".to_string(),
});
};
let marker = match self.session.last_temporal_anchor {
Some(anchor) => self.engine.language.since_last_marker(ts - anchor),
None => {
let now = match self.engine.reference_time {
Some(n) => n,
None => {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
};
format_relative(now - ts)
}
};
Ok(Value::String(marker))
}
fn score_all_variants(
&mut self,
key: &str,
all: &[SalientTemplate],
ctx: &Context,
) -> Result<Vec<VariantScore>, ProsaicError> {
let target_salience = self.resolve_target_salience(key, ctx);
let alternatives = filter_alternatives(
all,
target_salience,
self.engine.language_preference.as_deref(),
self.engine.style_preference.as_deref(),
);
let snapshot = self.session.clone();
let last_variant = self.session.discourse.last_template_variant(key);
let mut scores: Vec<VariantScore> = Vec::with_capacity(alternatives.len());
let mut scratch = String::with_capacity(128);
for (i, template) in alternatives.iter().enumerate() {
scratch.clear();
match self.render_template_into(&mut scratch, key, template, ctx) {
Ok(()) => {}
Err(e) => {
*self.session = snapshot;
return Err(e);
}
}
scores.push(VariantScore {
index: i,
source: template.source.clone(),
rendered: scratch.clone(),
score: 0.0,
salience: target_salience,
is_last_selected: Some(i) == last_variant,
selected: false,
});
}
for s in scores.iter_mut() {
s.score = self.candidate_discourse_score(&s.rendered);
}
let selected_idx = self.pick_variant_index(key, &alternatives, last_variant, &scores);
if let Some(idx) = selected_idx
&& let Some(s) = scores.get_mut(idx)
{
s.selected = true;
}
*self.session = snapshot;
Ok(scores)
}
fn pick_variant_index(
&self,
key: &str,
alternatives: &[&Template],
last_variant: Option<usize>,
scores: &[VariantScore],
) -> Option<usize> {
if alternatives.is_empty() {
return None;
}
if alternatives.len() == 1 {
return Some(0);
}
let allow_choose_best = matches!(
self.engine.variation,
Variation::Seeded(_) | Variation::Random
);
let is_first = self.session.discourse.is_first_render();
if !allow_choose_best || is_first {
return Some(
self.engine
.pick_variant_index_static(key, alternatives.len()),
);
}
let mut best_idx: Option<usize> = None;
let mut best_score = f64::MAX;
for (i, s) in scores.iter().enumerate() {
if Some(i) == last_variant && scores.len() > 1 {
continue;
}
if s.score < best_score {
best_score = s.score;
best_idx = Some(i);
}
}
best_idx.or(Some(0))
}
}
impl Engine {
pub fn new(language: impl Language + 'static) -> Self {
Self {
language: Box::new(language),
templates: new_map(),
strictness: Strictness::default(),
variation: Variation::default(),
salience_thresholds: SalienceThresholds::default(),
rr_initial: new_map(),
#[cfg(feature = "reg")]
entity_registry: EntityRegistry::new(),
#[cfg(feature = "reg")]
reg_preference: Vec::new(),
#[cfg(feature = "reg")]
reg_algorithm: RegAlgorithm::default(),
synonyms: SynonymRegistry::new(),
#[cfg(feature = "time")]
reference_time: None,
antonyms: AntonymRegistry::new(),
#[cfg(feature = "polish")]
max_sentence_length: None,
#[cfg(feature = "polish")]
smart_quotes: false,
sentence_rhythm_enabled: true,
partials: new_map(),
language_preference: None,
style_preference: None,
faithfulness_threshold: None,
style_profile: crate::style::StyleProfile::neutral(),
refine_config: crate::refine::RefineConfig::off(),
}
}
pub fn language_preference(mut self, lang: impl Into<String>) -> Self {
self.language_preference = Some(lang.into());
self
}
pub fn set_language_preference(&mut self, lang: impl Into<String>) {
self.language_preference = Some(lang.into());
}
pub fn style_preference(mut self, style: impl Into<String>) -> Self {
self.style_preference = Some(style.into());
self
}
pub fn set_style_preference(&mut self, style: impl Into<String>) {
self.style_preference = Some(style.into());
}
pub fn strictness(mut self, strictness: Strictness) -> Self {
self.strictness = strictness;
self
}
pub fn variation(mut self, variation: Variation) -> Self {
self.variation = variation;
self
}
pub fn salience_thresholds(mut self, thresholds: SalienceThresholds) -> Self {
self.salience_thresholds = thresholds;
self
}
pub fn style_profile(mut self, profile: crate::style::StyleProfile) -> Self {
self.style_profile = profile;
self
}
pub fn current_style_profile(&self) -> &crate::style::StyleProfile {
&self.style_profile
}
pub fn refine(mut self, config: crate::refine::RefineConfig) -> Self {
self.refine_config = config;
self
}
pub fn current_refine_config(&self) -> &crate::refine::RefineConfig {
&self.refine_config
}
#[cfg(feature = "reg")]
pub fn register_entity(&mut self, descriptor: EntityDescriptor) {
self.entity_registry.insert(descriptor);
}
#[cfg(feature = "reg")]
pub fn attribute_preference(mut self, order: Vec<String>) -> Self {
self.reg_preference = order;
self
}
#[cfg(feature = "reg")]
pub fn reg_algorithm(mut self, algo: RegAlgorithm) -> Self {
self.reg_algorithm = algo;
self
}
#[cfg(feature = "time")]
pub fn reference_time(mut self, unix_secs: i64) -> Self {
self.reference_time = Some(unix_secs);
self
}
pub fn register_partial(&mut self, name: &str, source: &str) -> Result<(), ProsaicError> {
let template = Template::parse(source)?;
let previous = self.partials.insert(name.to_string(), template);
if let Err(cycle) = detect_partial_cycle(&self.partials, name) {
match previous {
Some(prior) => {
self.partials.insert(name.to_string(), prior);
}
None => {
self.partials.remove(name);
}
}
return Err(ProsaicError::RecursivePartial { cycle });
}
Ok(())
}
#[cfg(feature = "polish")]
pub fn smart_quotes(mut self, enabled: bool) -> Self {
self.smart_quotes = enabled;
self
}
#[cfg(feature = "polish")]
pub fn max_sentence_length(mut self, max_chars: usize) -> Self {
self.max_sentence_length = Some(max_chars);
self
}
pub fn sentence_rhythm(mut self, enabled: bool) -> Self {
self.sentence_rhythm_enabled = enabled;
self
}
pub fn register_antonym(&mut self, negative: &str, positive: &str) {
self.antonyms.register(negative, positive);
}
pub fn register_synonyms(&mut self, group: &[&str]) {
self.synonyms.register_group(group);
}
pub fn with_faithfulness_gate(mut self, threshold: f32) -> Self {
self.faithfulness_threshold = Some(threshold);
self
}
pub fn language(&self) -> &dyn Language {
&*self.language
}
pub fn register_template(&mut self, key: &str, source: &str) -> Result<(), ProsaicError> {
self.register_template_at(key, source, Salience::Medium)
}
pub fn register_template_at(
&mut self,
key: &str,
source: &str,
salience: Salience,
) -> Result<(), ProsaicError> {
self.register_template_with_language_and_style_at(key, source, salience, None, None)
}
pub fn register_template_with_language(
&mut self,
key: &str,
source: &str,
language: Option<&str>,
) -> Result<(), ProsaicError> {
self.register_template_with_language_and_style_at(
key,
source,
Salience::Medium,
language,
None,
)
}
pub fn register_template_with_style(
&mut self,
key: &str,
source: &str,
style: Option<&str>,
) -> Result<(), ProsaicError> {
self.register_template_with_language_and_style_at(
key,
source,
Salience::Medium,
None,
style,
)
}
pub fn register_template_with_language_and_style(
&mut self,
key: &str,
source: &str,
language: Option<&str>,
style: Option<&str>,
) -> Result<(), ProsaicError> {
self.register_template_with_language_and_style_at(
key,
source,
Salience::Medium,
language,
style,
)
}
#[cfg(feature = "serde")]
pub fn load_manifest(&mut self, json: &str) -> Result<(), ProsaicError> {
let bundle: manifest_loader::ManifestBundle =
serde_json::from_str(json).map_err(|e| ProsaicError::TemplateParseError {
template: "(manifest)".to_string(),
position: 0,
reason: format!("manifest JSON parse error: {e}"),
})?;
if bundle.schema_version != 1 {
return Err(ProsaicError::TemplateParseError {
template: "(manifest)".to_string(),
position: 0,
reason: format!(
"unsupported manifest schema version {}",
bundle.schema_version
),
});
}
apply_manifest_engine_settings(self, &bundle.engine)?;
self.language_preference = Some(bundle.language);
for partial in bundle.partials {
self.register_partial(&partial.name, &partial.body)?;
}
for template in bundle.templates {
for variant in template.variants {
let salience = match variant.salience.as_str() {
"low" => Salience::Low,
"medium" => Salience::Medium,
"high" => Salience::High,
other => {
return Err(ProsaicError::TemplateParseError {
template: "(manifest)".to_string(),
position: 0,
reason: format!(
"unknown salience `{other}` for template `{}`",
template.key
),
});
}
};
self.register_template_with_language_and_style_at(
&template.key,
&variant.body,
salience,
variant.language.as_deref(),
variant.style.as_deref(),
)?;
}
}
Ok(())
}
pub fn register_template_with_language_at(
&mut self,
key: &str,
source: &str,
salience: Salience,
language: Option<&str>,
) -> Result<(), ProsaicError> {
self.register_template_with_language_and_style_at(key, source, salience, language, None)
}
pub fn register_template_with_style_at(
&mut self,
key: &str,
source: &str,
salience: Salience,
style: Option<&str>,
) -> Result<(), ProsaicError> {
self.register_template_with_language_and_style_at(key, source, salience, None, style)
}
pub fn register_template_with_language_and_style_at(
&mut self,
key: &str,
source: &str,
salience: Salience,
language: Option<&str>,
style: Option<&str>,
) -> Result<(), ProsaicError> {
let template = Template::parse(source)?;
template
.infer_types()
.map_err(|reason| ProsaicError::TemplateParseError {
template: source.to_string(),
position: 0,
reason,
})?;
self.templates
.entry(key.to_string())
.or_default()
.push(SalientTemplate::new(
salience,
template,
language.map(|s| s.to_string()),
style.map(|s| s.to_string()),
));
self.rr_initial.entry(key.to_string()).or_insert(0);
Ok(())
}
pub fn register_template_with_schema<T>(
&mut self,
key: &str,
source: &str,
) -> Result<(), ProsaicError>
where
T: crate::HasProsaicSchema + crate::IntoContext,
{
let template = Template::parse(source)?;
let inferred =
template
.infer_types()
.map_err(|reason| ProsaicError::TemplateParseError {
template: source.to_string(),
position: 0,
reason,
})?;
let ty = core::any::type_name::<T>();
for (slot, expected) in &inferred {
let actual = crate::schema_lookup(T::PROSAIC_SCHEMA, slot).ok_or_else(|| {
ProsaicError::TemplateParseError {
template: source.to_string(),
position: 0,
reason: format!(
"slot `{slot}` required by template is not declared in context `{ty}`"
),
}
})?;
if !crate::types_compatible(actual, *expected) {
return Err(ProsaicError::TemplateParseError {
template: source.to_string(),
position: 0,
reason: format!(
"slot `{slot}` in context `{ty}` has type {actual:?} but template pipe chain expects {expected:?}"
),
});
}
}
self.templates
.entry(key.to_string())
.or_default()
.push(SalientTemplate::new(Salience::Medium, template, None, None));
self.rr_initial.entry(key.to_string()).or_insert(0);
Ok(())
}
pub fn has_template(&self, key: &str) -> bool {
self.templates.contains_key(key)
}
pub fn context_salience(&self, ctx: &Context) -> Salience {
let thresholds = apply_salience_bias(self.salience_thresholds, self.style_profile.salience);
Salience::from_context(ctx, thresholds)
}
pub fn render(
&self,
session: &mut Session,
key: &str,
context: impl IntoContext,
) -> Result<String, ProsaicError> {
self.render_with_options(session, key, context, RenderOptions::default())
}
fn render_with_options(
&self,
session: &mut Session,
key: &str,
context: impl IntoContext,
options: RenderOptions,
) -> Result<String, ProsaicError> {
let all_alternatives = self
.templates
.get(key)
.ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
let context = context.into_context();
let snapshot = session.clone();
match RenderCtx::new(self, session).render_tx_with_options(
key,
all_alternatives,
&context,
options,
) {
Ok(output) => {
#[cfg(feature = "time")]
if let Some(Value::Number(ts)) = context.get("timestamp") {
session.last_temporal_anchor = Some(*ts);
}
Ok(output)
}
Err(e) => {
*session = snapshot;
Err(e)
}
}
}
pub fn score_variants(
&self,
session: &mut Session,
key: &str,
context: impl IntoContext,
) -> Result<Vec<VariantScore>, ProsaicError> {
let all = self
.templates
.get(key)
.ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
let ctx = context.into_context();
RenderCtx::new(self, session).score_all_variants(key, all, &ctx)
}
pub fn render_inline(
&self,
session: &mut Session,
source: &str,
context: impl IntoContext,
) -> Result<String, ProsaicError> {
let template = Template::parse(source)?;
let context = context.into_context();
let mut scratch = session.clone();
let mut output = String::with_capacity(128);
RenderCtx::new(self, &mut scratch).render_template_into(
&mut output,
"<inline>",
&template,
&context,
)?;
session.discourse.record_output_words(&output);
Ok(output)
}
pub fn render_batch(
&self,
session: &mut Session,
events: &[(&str, Context)],
) -> Result<String, ProsaicError> {
if events.is_empty() {
return Ok(String::new());
}
let mut sentences: Vec<String> = Vec::new();
let mut i = 0;
while i < events.len() {
let action_end = self.find_same_action_run(events, i);
if action_end > i + 1 {
let sentence =
self.render_aggregated_subjects(session, events[i].0, &events[i..action_end])?;
sentences.push(sentence);
i = action_end;
continue;
}
let gap_end = self.find_gapping_run(events, i);
if gap_end > i + 1 {
let mut rendered: Vec<String> = Vec::with_capacity(gap_end - i);
for (key, ctx) in &events[i..gap_end] {
rendered.push(self.render(session, key, ctx)?);
}
if let Some(gapped) = reduce_gapping(&rendered) {
sentences.push(gapped);
} else {
sentences.extend(rendered);
}
i = gap_end;
continue;
}
let entity_end = self.find_same_entity_run(events, i);
if entity_end > i + 1 {
let mut run_rendered: Vec<String> = Vec::with_capacity(entity_end - i);
for (key, ctx) in &events[i..entity_end] {
run_rendered.push(self.render(session, key, ctx)?);
}
if let Some(reduced) = reduce_same_entity_clauses(&run_rendered) {
sentences.push(reduced);
} else {
sentences.extend(run_rendered);
}
i = entity_end;
continue;
}
let (key, ref ctx) = events[i];
sentences.push(self.render(session, key, ctx)?);
i += 1;
}
Ok(sentences.join(" "))
}
pub fn render_batch_with_relations(
&self,
session: &mut Session,
events: &[(&str, Context, Option<crate::rst::RstRelation>)],
) -> Result<String, ProsaicError> {
if events.is_empty() {
return Ok(String::new());
}
if events.iter().all(|(_, _, r)| r.is_none()) {
let pairs: Vec<(&str, Context)> =
events.iter().map(|(k, c, _)| (*k, c.clone())).collect();
return self.render_batch(session, &pairs);
}
let mut output = String::new();
for (i, (key, ctx, relation)) in events.iter().enumerate() {
if i > 0 {
if let Some(rel) = relation {
if let Some(marker) = self.language.discourse_marker(*rel) {
output.push(' ');
output.push_str(marker);
} else {
output.push(' ');
}
} else {
output.push(' ');
}
}
let options = if i > 0 && relation.is_some() {
RenderOptions {
suppress_auto_connective: true,
}
} else {
RenderOptions::default()
};
let sentence = self.render_with_options(session, key, ctx, options)?;
if i > 0 && relation.is_some() {
output.push_str(&lowercase_first_if_determiner(&sentence));
} else {
output.push_str(&sentence);
}
}
Ok(output)
}
fn find_same_entity_run(&self, events: &[(&str, Context)], start: usize) -> usize {
if start >= events.len() {
return start;
}
let first_ctx = &events[start].1;
let first_name = match entity_name_from_context(first_ctx) {
Some(n) => n,
None => return start + 1,
};
let first_type = first_ctx.get("entity_type").map(|v| v.as_display());
let mut end = start + 1;
while end < events.len() {
let ctx = &events[end].1;
let name = match entity_name_from_context(ctx) {
Some(n) => n,
None => break,
};
if name != first_name {
break;
}
let ty = ctx.get("entity_type").map(|v| v.as_display());
if ty != first_type {
break;
}
end += 1;
}
end
}
pub fn render_explained(
&self,
session: &mut Session,
key: &str,
context: impl IntoContext,
) -> Result<RenderExplanation, ProsaicError> {
let all_alternatives = self
.templates
.get(key)
.ok_or_else(|| ProsaicError::UnknownTemplate(key.to_string()))?;
let context = context.into_context();
let target_salience = resolve_target_salience_for(self, session, key, &context);
let alternatives = filter_alternatives(
all_alternatives,
target_salience,
self.language_preference.as_deref(),
self.style_preference.as_deref(),
);
let candidate_scores = {
let allow_choose_best =
matches!(self.variation, Variation::Seeded(_) | Variation::Random);
let is_first = session.discourse.is_first_render();
if !allow_choose_best || is_first || alternatives.len() < 2 {
None
} else {
let mut scoring_session = session.clone();
let snapshot = scoring_session.clone();
let mut scored: Vec<f64> = Vec::with_capacity(alternatives.len());
let mut scoring_failed = false;
let mut scratch = String::with_capacity(128);
for template in &alternatives {
scratch.clear();
match RenderCtx::new(self, &mut scoring_session).render_template_into(
&mut scratch,
key,
template,
&context,
) {
Ok(()) => {
let mut score = scoring_session.discourse.repetition_score(&scratch);
if self.sentence_rhythm_enabled {
score += scoring_session.discourse.sentence_rhythm_score(&scratch);
}
scored.push(score);
}
Err(_) => {
scoring_failed = true;
break;
}
}
scoring_session = snapshot.clone();
}
if scoring_failed { None } else { Some(scored) }
}
};
let entity_name = context
.get("name")
.or_else(|| context.get("old_name"))
.map(|v| v.as_display());
let reference_form = entity_name.as_ref().map(|n| {
session.discourse.reference_form_with_density(
n,
matches!(
self.style_profile.pronoun_density,
crate::style::PronounDensity::Low
),
matches!(
self.style_profile.pronoun_density,
crate::style::PronounDensity::High
),
)
});
let output = self.render(session, key, &context)?;
let variant_index = session
.discourse
.last_template_variant(key)
.unwrap_or(0)
.min(alternatives.len().saturating_sub(1));
let variant_source = alternatives
.get(variant_index)
.map(|t| t.source.clone())
.unwrap_or_default();
let focus_is_plural = session.discourse.focus_is_plural();
let centering_transition = session.discourse.last_transition();
#[cfg(feature = "polish")]
let length_split_applied = self
.max_sentence_length
.is_some_and(|max| output.chars().count() > max && output.contains(". "));
#[cfg(not(feature = "polish"))]
let length_split_applied = false;
let connective = detect_leading_connective(&output);
let list_style = session.discourse.last_list_style_used();
let cleanup_stripped_tail = session.discourse.last_cleanup_stripped_tail();
Ok(RenderExplanation {
output,
template_key: key.to_string(),
variant_index,
variant_source,
salience: target_salience,
candidate_scores,
reference_form,
connective,
list_style,
focus_is_plural,
length_split_applied,
cleanup_stripped_tail,
centering_transition,
})
}
pub fn render_iter<'a>(
&'a self,
session: &'a mut Session,
events: &'a [(&'a str, Context)],
) -> RenderIter<'a> {
RenderIter {
engine: self,
session,
events,
i: 0,
}
}
fn find_same_action_run(&self, events: &[(&str, Context)], start: usize) -> usize {
if start >= events.len() {
return start;
}
let (first_key, ref first_ctx) = events[start];
let first_name = entity_name_from_context(first_ctx);
if first_name.is_none() {
return start + 1;
}
let mut end = start + 1;
let mut seen_names: crate::collections::HashSet<String> = crate::collections::new_set();
seen_names.insert(first_name.unwrap());
while end < events.len() {
let (key, ref ctx) = events[end];
if key != first_key {
break;
}
let name = match entity_name_from_context(ctx) {
Some(n) => n,
None => break,
};
if seen_names.contains(&name) {
break;
}
if !contexts_compatible_for_aggregation(first_ctx, ctx) {
break;
}
seen_names.insert(name);
end += 1;
}
end
}
fn find_gapping_run(&self, events: &[(&str, Context)], start: usize) -> usize {
if start >= events.len() {
return start;
}
let (first_key, first_ctx) = (events[start].0, &events[start].1);
let Some(first_name) = entity_name_from_context(first_ctx) else {
return start + 1;
};
let mut end = start + 1;
let mut seen: crate::collections::HashSet<String> = core::iter::once(first_name).collect();
while end < events.len() {
let (k, ctx) = (events[end].0, &events[end].1);
if k != first_key {
break;
}
let Some(name) = entity_name_from_context(ctx) else {
break;
};
if seen.contains(&name) {
break;
}
if contexts_compatible_for_aggregation(first_ctx, ctx) {
break;
}
seen.insert(name);
end += 1;
}
end
}
fn render_aggregated_subjects(
&self,
session: &mut Session,
key: &str,
events: &[(&str, Context)],
) -> Result<String, ProsaicError> {
let names: Vec<String> = events
.iter()
.filter_map(|(_, ctx)| entity_name_from_context(ctx))
.collect();
if names.is_empty() {
let mut sentences = Vec::new();
for (k, ctx) in events {
sentences.push(self.render(session, k, ctx)?);
}
return Ok(sentences.join(" "));
}
let refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect();
let combined_name = self
.language
.join_list(&refs, crate::language::Conjunction::And);
let mut combined_ctx = events[0].1.clone();
if combined_ctx.get("old_name").is_some() {
combined_ctx.insert("old_name", Value::String(combined_name.clone()));
}
if combined_ctx.get("name").is_some() {
combined_ctx.insert("name", Value::String(combined_name.clone()));
}
let rendered = self.render(session, key, combined_ctx)?;
session.discourse.set_focus_plural(true);
Ok(pluralize_agreement(&rendered, &*self.language))
}
fn pick_variant_index_static(&self, key: &str, count: usize) -> usize {
match self.variation {
Variation::Fixed => 0,
Variation::Seeded(seed) => {
let hash = simple_hash(key, seed);
hash as usize % count
}
Variation::Random => {
#[cfg(feature = "std")]
{
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos() as usize;
nanos % count
}
#[cfg(not(feature = "std"))]
{
let _ = count;
0
}
}
Variation::RoundRobin => 0,
}
}
fn render_full_reference(&self, name: &str, fallback_type: &str) -> String {
#[cfg(feature = "reg")]
let (attrs, relation): (Vec<String>, Option<(String, String)>) = {
let registered = if fallback_type.is_empty() {
None
} else {
self.entity_registry.get(fallback_type, name)
};
let target = match registered {
Some(d) => d.clone(),
None => {
if fallback_type.is_empty() {
return name.to_string();
}
EntityDescriptor::new(name, fallback_type)
}
};
match self.reg_algorithm {
RegAlgorithm::DaleReiter => (
distinguishing_attributes(&target, &self.entity_registry, &self.reg_preference),
None,
),
RegAlgorithm::GraphBased => {
let desc = distinguishing_subgraph(
&target,
&self.entity_registry,
&self.reg_preference,
);
(desc.attributes, desc.relation)
}
}
};
#[cfg(not(feature = "reg"))]
let (attrs, relation): (Vec<String>, Option<(String, String)>) = {
if fallback_type.is_empty() {
return name.to_string();
}
(Vec::new(), None)
};
#[cfg(feature = "reg")]
let entity_type = if fallback_type.is_empty() {
self.entity_registry
.get("", name)
.map(|d| d.entity_type.clone())
.unwrap_or_default()
} else {
fallback_type.to_string()
};
#[cfg(not(feature = "reg"))]
let entity_type = fallback_type.to_string();
if entity_type.is_empty() {
if attrs.is_empty() {
return name.to_string();
}
return format!("the {} {}", attrs.join(" "), name);
}
let lower_type = entity_type.to_lowercase();
let base = if attrs.is_empty() {
format!("the {lower_type} {name}")
} else {
format!("the {} {lower_type} {name}", attrs.join(" "))
};
if let Some((label, target_name)) = relation {
format!("{base} {label} {target_name}")
} else {
base
}
}
pub fn new_session(&self) -> Session {
Session::new()
}
}
fn format_truncated_list(
shown: &[&str],
remainder: &str,
style: ListStyle,
conjunction: Conjunction,
language: &dyn Language,
) -> String {
let joined = language.join_list(shown, conjunction);
match style {
ListStyle::Including => {
format!("including {joined} among others")
}
ListStyle::SuchAs => {
format!("such as {joined}")
}
ListStyle::Dash => {
format!("\u{2014} notably {joined}, plus {remainder}")
}
ListStyle::Bracketed => {
let refs: Vec<&str> = shown
.iter()
.copied()
.chain(core::iter::once(remainder.trim()))
.collect();
let all_joined = language.join_list(&refs, conjunction);
format!("[{all_joined}]")
}
ListStyle::AmongOthers => {
format!("{joined}, among others")
}
ListStyle::ToNameAFew => {
format!("{joined}, to name a few")
}
ListStyle::PlusMore => {
format!("{joined}, plus {remainder}")
}
}
}
const AUX_PREFIXES: &[&str] = &[
"would have been",
"will have been",
"would have",
"will have",
"has been",
"had been",
"have been",
"is being",
"was being",
"are being",
"were being",
"will be",
"would be",
"is",
"are",
"was",
"were",
"has",
"have",
"had",
"will",
];
fn detect_partial_cycle(
partials: &HashMap<String, Template>,
entry_name: &str,
) -> Result<(), Vec<String>> {
let mut path: Vec<String> = Vec::new();
let mut on_stack: HashSet<String> = new_set();
let mut fully_explored: HashSet<String> = new_set();
visit(
partials,
entry_name,
&mut path,
&mut on_stack,
&mut fully_explored,
)
}
fn visit(
partials: &HashMap<String, Template>,
name: &str,
path: &mut Vec<String>,
on_stack: &mut HashSet<String>,
fully_explored: &mut HashSet<String>,
) -> Result<(), Vec<String>> {
if fully_explored.contains(name) {
return Ok(());
}
if on_stack.contains(name) {
let start = path.iter().position(|n| n == name).unwrap_or(0);
let mut cycle: Vec<String> = path[start..].to_vec();
cycle.push(name.to_string());
return Err(cycle);
}
let template = match partials.get(name) {
Some(t) => t,
None => return Ok(()),
};
path.push(name.to_string());
on_stack.insert(name.to_string());
for child in template.partial_names() {
visit(partials, &child, path, on_stack, fully_explored)?;
}
on_stack.remove(name);
path.pop();
fully_explored.insert(name.to_string());
Ok(())
}
fn reduce_same_entity_clauses(sentences: &[String]) -> Option<String> {
if sentences.len() < 2 {
return None;
}
let head = sentences[0].trim_end();
let head_body = head.trim_end_matches(['.', '!', '?']);
let (head_subject_aux, head_aux, head_predicate) = split_subject_aux(head_body)?;
if predicate_has_embedded_clause(head_predicate) {
return None;
}
let mut predicates: Vec<String> = vec![head_predicate.to_string()];
for s in &sentences[1..] {
let trimmed = s.trim_end();
let without_conn = strip_leading_connective(trimmed);
let without_conn_str: &str = &without_conn;
let body = without_conn_str.trim_end_matches(['.', '!', '?']);
let (aux, predicate) = match strip_it_aux_prefix(body) {
Some(parsed) => parsed,
None => {
let remainder = strip_head_subject_prefix(body, head_subject_aux)?;
if remainder.is_empty() {
return None;
}
(head_aux, remainder)
}
};
if aux != head_aux {
return None;
}
if predicate_has_embedded_clause(predicate) {
return None;
}
predicates.push(predicate.to_string());
}
let joined = match predicates.len() {
0 => return None,
1 => predicates.into_iter().next().unwrap(),
2 => format!("{} and {}", predicates[0], predicates[1]),
_ => {
let last = predicates.pop().unwrap();
let head = predicates.join(", ");
format!("{head}, and {last}")
}
};
Some(format!("{head_subject_aux} {joined}."))
}
fn split_subject_and_rest(s: &str) -> Option<(&str, Vec<&str>)> {
for aux in AUX_PREFIXES {
let marker = format!(" {aux} ");
if let Some(pos) = s.find(&marker) {
let subject = &s[..pos];
let rest_str = &s[pos + 1..];
let rest_tokens: Vec<&str> = rest_str.split_whitespace().collect();
return Some((subject, rest_tokens));
}
}
None
}
fn longest_common_prefix_len(parsed: &[(&str, Vec<&str>)]) -> usize {
if parsed.is_empty() {
return 0;
}
let min_len = parsed.iter().map(|(_, t)| t.len()).min().unwrap_or(0);
for i in 0..min_len {
let candidate = parsed[0].1[i];
if !parsed.iter().all(|(_, t)| t[i] == candidate) {
return i;
}
}
min_len
}
fn reduce_gapping(sentences: &[String]) -> Option<String> {
if sentences.len() < 2 {
return None;
}
if sentences.iter().any(|s| predicate_has_embedded_clause(s)) {
return None;
}
let parsed: Vec<(&str, Vec<&str>)> = sentences
.iter()
.map(|s| {
let trimmed = s.trim_end();
let stripped = strip_leading_connective(trimmed.trim_end_matches(['.', '!', '?']));
let _ = stripped; let body = trimmed.trim_end_matches(['.', '!', '?']);
let body_stripped: &str = {
const CONNECTIVES: &[&str] = &[
"Additionally,",
"Furthermore,",
"Similarly,",
"Likewise,",
"Meanwhile,",
"However,",
"On the other hand,",
];
let mut result = body;
for conn in CONNECTIVES {
if let Some(rest) = body.strip_prefix(conn) {
result = rest.trim_start();
break;
}
}
result
};
split_subject_and_rest(body_stripped)
})
.collect::<Option<Vec<_>>>()?;
{
let mut seen: crate::collections::HashSet<&str> = crate::collections::new_set();
for (subj, _) in &parsed {
if !seen.insert(*subj) {
return None;
}
}
}
let raw_anchor_len = longest_common_prefix_len(&parsed);
const PREPOSITIONS: &[&str] = &[
"to", "from", "at", "in", "on", "by", "for", "with", "into", "onto", "out", "off", "over",
"under", "above", "below", "through", "across", "against", "along", "around", "behind",
"beside", "between", "during", "inside", "outside", "toward", "towards", "upon", "within",
"without",
];
let anchor_len = {
let mut len = raw_anchor_len;
while len > 0 && PREPOSITIONS.contains(&parsed[0].1[len - 1]) {
len -= 1;
}
len
};
if anchor_len < 2 {
return None;
}
if parsed.iter().any(|(_, toks)| toks.len() <= anchor_len) {
return None;
}
let anchor = parsed[0].1[..anchor_len].join(" ");
let suffixes: Vec<String> = parsed
.iter()
.map(|(_, toks)| toks[anchor_len..].join(" "))
.collect();
let capitalize = |s: &str| -> String {
let mut cs = s.chars();
match cs.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + cs.as_str(),
}
};
let first = format!("{} {} {}", capitalize(parsed[0].0), anchor, suffixes[0]);
let tail: Vec<String> = parsed
.iter()
.skip(1)
.zip(suffixes.iter().skip(1))
.map(|((subj, _), suf)| format!("{} {suf}", capitalize(subj)))
.collect();
let joined = match tail.len() {
1 => format!("{first}, and {}", tail[0]),
_ => {
let (last, rest) = tail.split_last().unwrap();
format!("{first}, {}, and {last}", rest.join(", "))
}
};
Some(format!("{joined}."))
}
fn predicate_has_embedded_clause(predicate: &str) -> bool {
let lower = predicate.to_lowercase();
const MARKERS: &[&str] = &[
", which",
", affecting",
", impacting",
", requiring",
", including",
];
MARKERS.iter().any(|m| lower.contains(m))
}
fn detect_leading_connective(s: &str) -> Option<&'static str> {
const CONNECTIVES: &[&str] = &[
"Additionally,",
"Furthermore,",
"Similarly,",
"Likewise,",
"Meanwhile,",
"However,",
"On the other hand,",
"It also",
];
CONNECTIVES.iter().copied().find(|c| s.starts_with(c))
}
fn strip_leading_connective(s: &str) -> alloc::borrow::Cow<'_, str> {
const CONNECTIVES: &[&str] = &[
"Additionally,",
"Furthermore,",
"Similarly,",
"Likewise,",
"Meanwhile,",
"However,",
"On the other hand,",
];
for conn in CONNECTIVES {
if let Some(rest) = s.strip_prefix(conn) {
return alloc::borrow::Cow::Borrowed(rest.trim_start());
}
}
if let Some(rest) = s.strip_prefix("It also ") {
return alloc::borrow::Cow::Owned(format!("It {}", rest.trim_start()));
}
alloc::borrow::Cow::Borrowed(s)
}
fn split_subject_aux(body: &str) -> Option<(&str, &str, &str)> {
for aux in AUX_PREFIXES {
let marker = format!(" {aux} ");
if let Some(pos) = body.find(&marker) {
let subject_aux_end = pos + 1 + aux.len(); let subject_aux = &body[..subject_aux_end];
let predicate = body[subject_aux_end..].trim_start();
return Some((subject_aux, aux, predicate));
}
}
None
}
fn strip_it_aux_prefix(body: &str) -> Option<(&str, &str)> {
let rest = body
.strip_prefix("It ")
.or_else(|| body.strip_prefix("it "))?;
for aux in AUX_PREFIXES {
let marker_with_space = format!("{aux} ");
if let Some(tail) = rest.strip_prefix(&marker_with_space) {
return Some((aux, tail.trim_start()));
}
if rest == *aux {
return None;
}
}
None
}
fn strip_head_subject_prefix<'a>(body: &'a str, subject_aux: &str) -> Option<&'a str> {
let with_space = format!("{subject_aux} ");
if let Some(rest) = body.strip_prefix(with_space.as_str()) {
return Some(rest.trim_start());
}
let mut lowercased = subject_aux.to_string();
if let Some(first) = lowercased.chars().next()
&& first.is_uppercase()
{
let first_len = first.len_utf8();
let lower: String = first.to_lowercase().collect();
lowercased.replace_range(0..first_len, &lower);
let with_space_lower = format!("{lowercased} ");
if let Some(rest) = body.strip_prefix(with_space_lower.as_str()) {
return Some(rest.trim_start());
}
}
None
}
fn cleanup_artifacts_in_place(output: &mut String, strictness: Strictness) -> bool {
collapse_and_tidy_in_place(output);
if strictness == Strictness::Silent {
strip_dangling_tail_words_in_place(output)
} else {
false
}
}
fn collapse_and_tidy_in_place(output: &mut String) {
let mut scratch = String::with_capacity(output.len());
let mut last_was_space = false;
let mut started = false;
let chars: Vec<char> = output.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
let c = chars[i];
if c.is_whitespace() {
if started {
last_was_space = true;
}
} else {
if last_was_space && !matches!(c, ',' | '.' | '!' | '?' | ':' | ';' | ')' | ']') {
scratch.push(' ');
}
scratch.push(c);
last_was_space = false;
started = true;
}
i += 1;
}
core::mem::swap(output, &mut scratch);
}
const ORPHAN_TAIL_WORDS: &[&str] = &[
"by", "to", "from", "in", "on", "at", "of", "with", "for", "into", "onto", "upon", "about",
"between", "among", "through", "across",
"and", "or", "but", "nor", "yet", "because", "since", "while", "when", "where", "whether", "unless", "until", "than",
];
fn strip_dangling_tail_words_in_place(output: &mut String) -> bool {
let mut stripped_any = false;
loop {
let (body, _) = split_trailing_punct(output);
let body_len = body.len();
let trimmed_body = body.trim_end();
let last_word_start = match trimmed_body.rfind(char::is_whitespace) {
Some(idx) => idx + 1,
None => {
return stripped_any;
}
};
let last_word = &trimmed_body[last_word_start..];
let last_word_lower = last_word.to_lowercase();
if ORPHAN_TAIL_WORDS.contains(&last_word_lower.as_str()) {
let new_body_end = trimmed_body[..last_word_start].trim_end().len();
if new_body_end == 0 {
return stripped_any;
}
let tail_punct_owned = output[body_len..].to_string();
output.truncate(new_body_end);
output.push_str(&tail_punct_owned);
stripped_any = true;
continue;
}
return stripped_any;
}
}
fn split_trailing_punct(s: &str) -> (&str, &str) {
let punct_start = s
.char_indices()
.rev()
.take_while(|(_, c)| matches!(c, '.' | '!' | '?' | ','))
.last()
.map(|(i, _)| i)
.unwrap_or(s.len());
(&s[..punct_start], &s[punct_start..])
}
fn terminate_sentence_in_place(output: &mut String) {
let trimmed_end = output.trim_end();
if trimmed_end.is_empty() {
return;
}
let last = trimmed_end.chars().last().unwrap();
if matches!(last, '.' | '!' | '?') {
return;
}
let first = trimmed_end.chars().next().unwrap();
if !first.is_uppercase() {
return;
}
let word_count = trimmed_end.split_whitespace().count();
if word_count < 3 {
return;
}
let trimmed_len = output.trim_end().len();
output.truncate(trimmed_len);
output.push('.');
}
fn reference_features(value: &Value, focus_is_plural: bool) -> crate::agreement::AgreementFeatures {
let mut features = match value {
Value::Entity { features, .. } => *features,
_ => crate::agreement::AgreementFeatures::default(),
};
if focus_is_plural {
features.number = crate::agreement::Number::Plural;
} else if matches!(features.number, crate::agreement::Number::Unknown) {
features.number = crate::agreement::Number::Singular;
}
features
}
fn starts_with_refer_pipe(template: &Template) -> bool {
match template.segments.first() {
Some(Segment::Slot { pipes, .. }) => pipes
.iter()
.any(|p| p.name == "refer" || p.name == "possessive"),
_ => false,
}
}
fn capitalize_first_in_place(output: &mut String) {
let first = match output.chars().next() {
Some(c) if c.is_lowercase() => c,
_ => return,
};
let first_len = first.len_utf8();
let upper: String = first.to_uppercase().collect();
output.replace_range(0..first_len, &upper);
}
fn lowercase_first_in_place(output: &mut String) {
let first = match output.chars().next() {
Some(c) if c.is_uppercase() => c,
_ => return,
};
let first_len = first.len_utf8();
let lower: String = first.to_lowercase().collect();
output.replace_range(0..first_len, &lower);
}
fn lowercase_first_if_determiner(s: &str) -> String {
let first_word_end = s.find(char::is_whitespace).unwrap_or(s.len());
let first = &s[..first_word_end];
const DETERMINERS: &[&str] = &[
"The", "A", "An", "El", "La", "Los", "Las", "Un", "Una", "Der", "Die", "Das", ];
if DETERMINERS.contains(&first) {
let mut result = String::with_capacity(s.len());
let mut chars = first.chars();
if let Some(c) = chars.next() {
result.extend(c.to_lowercase());
}
result.push_str(chars.as_str());
result.push_str(&s[first_word_end..]);
result
} else {
s.to_string()
}
}
fn prepend_replacing_subject_in_place(
output: &mut String,
connective: &str,
entity_name: Option<&str>,
) {
let name_is_single_token = entity_name
.map(|n| !n.trim().is_empty() && !n.contains(char::is_whitespace))
.unwrap_or(false);
if name_is_single_token && let Some(rest) = output.strip_prefix("The ") {
let words: Vec<&str> = rest.splitn(3, ' ').collect();
if words.len() >= 3 {
let tail = words[2..].join(" ");
let mut buf = String::with_capacity(connective.len() + 1 + tail.len());
buf.push_str(connective);
buf.push(' ');
buf.push_str(&tail);
core::mem::swap(output, &mut buf);
return;
}
}
if let Some(rest) = output.strip_prefix("it ") {
let mut buf = String::with_capacity(connective.len() + 1 + rest.len());
buf.push_str(connective);
buf.push(' ');
buf.push_str(rest);
core::mem::swap(output, &mut buf);
return;
}
lowercase_first_in_place(output);
let mut buf = String::with_capacity(connective.len() + 1 + output.len());
buf.push_str(connective);
buf.push(' ');
buf.push_str(output);
core::mem::swap(output, &mut buf);
}
fn hedge_with_calibration(
score: i64,
mode: HedgeMode,
calibration: &crate::style::HedgingCalibration,
) -> &'static str {
let calibrated = (score + calibration.offset as i64).clamp(0, 100);
let initial = hedge_fn(calibrated, mode);
if !is_forbidden(initial, &calibration.forbid) {
return initial;
}
const BUCKET_CENTERS: [i64; 5] = [10, 40, 60, 80, 95];
let start_idx = BUCKET_CENTERS
.iter()
.position(|&c| c >= calibrated)
.unwrap_or(0);
for &c in BUCKET_CENTERS.iter().skip(start_idx + 1) {
let candidate = hedge_fn(c, mode);
if !is_forbidden(candidate, &calibration.forbid) {
return candidate;
}
}
initial
}
fn is_forbidden(candidate: &str, forbid: &[String]) -> bool {
forbid.iter().any(|f| f.eq_ignore_ascii_case(candidate))
}
fn list_style_bias_target(bias: crate::style::ListStyleBias) -> Option<ListStyle> {
match bias {
crate::style::ListStyleBias::Auto => None,
crate::style::ListStyleBias::Including => Some(ListStyle::Including),
crate::style::ListStyleBias::SuchAs => Some(ListStyle::SuchAs),
crate::style::ListStyleBias::Dash => Some(ListStyle::Dash),
crate::style::ListStyleBias::Bracketed => Some(ListStyle::Bracketed),
}
}
fn rst_for_discourse(
relation: &crate::discourse::DiscourseRelation,
) -> Option<crate::rst::RstRelation> {
match relation {
crate::discourse::DiscourseRelation::SameEntityDifferentAction => {
Some(crate::rst::RstRelation::Elaboration)
}
crate::discourse::DiscourseRelation::DifferentEntitySameAction => {
Some(crate::rst::RstRelation::Sequence)
}
crate::discourse::DiscourseRelation::Contrast => Some(crate::rst::RstRelation::Contrast),
crate::discourse::DiscourseRelation::None => None,
}
}
fn profile_length_bias_score(
candidate: &str,
discourse: &crate::discourse::DiscourseState,
target: &crate::style::LengthDistribution,
) -> f64 {
if target.is_neutral() {
return 0.0;
}
let candidate_lengths = crate::discourse::sentence_word_counts(candidate);
let mut counts = [0usize; 3]; let bucket_for = |len: usize| -> usize {
if len <= target.short_max_words as usize {
0
} else if len <= target.medium_max_words as usize {
1
} else {
2
}
};
for len in discourse.sentence_length_iter() {
counts[bucket_for(len)] += 1;
}
for &len in &candidate_lengths {
counts[bucket_for(len)] += 1;
}
let total: usize = counts.iter().sum();
if total == 0 {
return 0.0;
}
let observed = [
counts[0] as f32 / total as f32,
counts[1] as f32 / total as f32,
counts[2] as f32 / total as f32,
];
let target_sum = target.short + target.medium + target.long;
if target_sum <= 0.0 || !target_sum.is_finite() {
return 0.0;
}
let target_norm = [
target.short / target_sum,
target.medium / target_sum,
target.long / target_sum,
];
let distance = (observed[0] - target_norm[0]).abs()
+ (observed[1] - target_norm[1]).abs()
+ (observed[2] - target_norm[2]).abs();
const PROFILE_LENGTH_WEIGHT: f64 = 3.0;
PROFILE_LENGTH_WEIGHT * distance as f64
}
fn apply_salience_bias(
thresholds: SalienceThresholds,
bias: crate::style::SalienceBias,
) -> SalienceThresholds {
match bias {
crate::style::SalienceBias::Auto => thresholds,
crate::style::SalienceBias::Lower => {
let low_max = (thresholds.low_max - 1).max(0);
let high_min = (thresholds.high_min - 5).max(low_max + 1);
SalienceThresholds { low_max, high_min }
}
crate::style::SalienceBias::Higher => {
let low_max = thresholds.low_max + 2;
let high_min = thresholds.high_min + 10;
SalienceThresholds { low_max, high_min }
}
}
}
fn resolve_target_salience_for(
engine: &Engine,
session: &Session,
key: &str,
context: &Context,
) -> Salience {
if let Some(forced) = session.refine_forced_tier_for(key) {
return forced;
}
let salience_bias = session
.refine_salience_bias
.unwrap_or(engine.style_profile.salience);
let thresholds = apply_salience_bias(engine.salience_thresholds, salience_bias);
apply_verbosity_bias(
Salience::from_context(context, thresholds),
engine.style_profile.verbosity,
)
}
fn apply_verbosity_bias(target: Salience, verbosity: crate::style::Verbosity) -> Salience {
match (verbosity, target) {
(crate::style::Verbosity::Neutral, t) => t,
(crate::style::Verbosity::Terse, Salience::High) => Salience::Medium,
(crate::style::Verbosity::Terse, Salience::Medium) => Salience::Low,
(crate::style::Verbosity::Terse, Salience::Low) => Salience::Low,
(crate::style::Verbosity::Verbose, Salience::Low) => Salience::Medium,
(crate::style::Verbosity::Verbose, Salience::Medium) => Salience::High,
(crate::style::Verbosity::Verbose, Salience::High) => Salience::High,
}
}
fn filter_alternatives<'a>(
alternatives: &'a [SalientTemplate],
target: Salience,
language_preference: Option<&str>,
style_preference: Option<&str>,
) -> Vec<&'a Template> {
let all: Vec<&'a SalientTemplate> = alternatives.iter().collect();
let lang_filtered = prefer_tag(all, language_preference, |s| s.language.as_deref());
let style_filtered = prefer_tag(lang_filtered, style_preference, |s| s.style.as_deref());
let exact: Vec<&'a Template> = style_filtered
.iter()
.filter(|s| s.salience == target)
.map(|s| &s.template)
.collect();
if !exact.is_empty() {
return exact;
}
let medium: Vec<&'a Template> = style_filtered
.iter()
.filter(|s| s.salience == Salience::Medium)
.map(|s| &s.template)
.collect();
if !medium.is_empty() {
return medium;
}
style_filtered.iter().map(|s| &s.template).collect()
}
fn prefer_tag<'a>(
alternatives: Vec<&'a SalientTemplate>,
preference: Option<&str>,
tag: impl Fn(&SalientTemplate) -> Option<&str>,
) -> Vec<&'a SalientTemplate> {
if let Some(pref) = preference {
let matching: Vec<&'a SalientTemplate> = alternatives
.iter()
.copied()
.filter(|s| tag(s) == Some(pref))
.collect();
if !matching.is_empty() {
return matching;
}
}
let untagged: Vec<&'a SalientTemplate> = alternatives
.iter()
.copied()
.filter(|s| tag(s).is_none())
.collect();
if !untagged.is_empty() {
untagged
} else {
alternatives
}
}
fn is_truthy(value: Option<&Value>) -> bool {
match value {
None => false,
Some(Value::Number(n)) => *n != 0,
Some(Value::String(s)) => !s.is_empty(),
Some(Value::List(items)) => !items.is_empty(),
Some(Value::Entity { name, .. }) => !name.is_empty(),
}
}
fn entity_name_from_context(context: &Context) -> Option<String> {
context
.get("name")
.or_else(|| context.get("old_name"))
.map(|v| v.as_display())
}
fn contexts_compatible_for_aggregation(a: &Context, b: &Context) -> bool {
let entity_keys = ["name", "old_name"];
let a_keys: Vec<&String> = a
.keys()
.filter(|k| !entity_keys.contains(&k.as_str()))
.collect();
let b_keys: Vec<&String> = b
.keys()
.filter(|k| !entity_keys.contains(&k.as_str()))
.collect();
if a_keys.len() != b_keys.len() {
return false;
}
for key in &a_keys {
if !b_keys.contains(key) {
return false;
}
if a.get(key) != b.get(key) {
return false;
}
}
true
}
fn pluralize_agreement(output: &str, lang: &dyn Language) -> String {
let mut result = output.to_string();
let verb_replacements = &[(" was ", " were "), (" has ", " have "), (" is ", " are ")];
for (singular, plural) in verb_replacements {
result = result.replace(singular, plural);
}
if let Some(rest) = result.strip_prefix("The ")
&& let Some(space_idx) = rest.find(' ')
{
let type_word = &rest[..space_idx];
if type_word.chars().all(|c| c.is_lowercase()) && type_word.len() < 15 {
let plural = lang.pluralize(type_word, 2);
if plural != type_word {
result = format!("The {} {}", plural, &rest[space_idx + 1..]);
}
}
}
result
}
fn simple_hash(key: &str, seed: u64) -> u64 {
let mut hash = seed;
for byte in key.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as u64);
}
hash
}
fn parse_choose_pairs(arg: &str) -> Result<Vec<(String, String)>, ProsaicError> {
let mut out = Vec::new();
for raw_pair in arg.split(',') {
let pair = raw_pair.trim();
if pair.is_empty() {
continue;
}
let eq = pair.find('=').ok_or_else(|| ProsaicError::InvalidPipe {
pipe: "choose".to_string(),
reason: format!("pair `{pair}` is missing `=` separator"),
})?;
let key = pair[..eq].trim().to_string();
let value = pair[eq + 1..].trim().to_string();
if key.is_empty() {
return Err(ProsaicError::InvalidPipe {
pipe: "choose".to_string(),
reason: format!("pair `{pair}` has empty key"),
});
}
out.push((key, value));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::language::{Conjunction, Language, Person, Tense};
struct TestLang;
impl Language for TestLang {
fn pluralize(&self, word: &str, count: usize) -> String {
if count == 1 {
word.to_string()
} else {
format!("{word}s")
}
}
fn singularize(&self, word: &str) -> String {
word.strip_suffix('s').unwrap_or(word).to_string()
}
fn article(&self, word: &str) -> &str {
if word.starts_with(|c: char| "aeiou".contains(c.to_ascii_lowercase())) {
"an"
} else {
"a"
}
}
fn conjugate(&self, verb: &str, tense: Tense, _person: Person) -> String {
match (verb, tense) {
("be", Tense::Past) => "was".to_string(),
("be", Tense::Present) => "is".to_string(),
("have", Tense::Present) => "has".to_string(),
(_, Tense::Past) => format!("{verb}ed"),
(_, Tense::Present) => verb.to_string(),
(_, Tense::Future) => format!("will {verb}"),
}
}
fn past_participle(&self, verb: &str) -> String {
format!("{verb}ed")
}
fn present_participle(&self, verb: &str) -> String {
format!("{verb}ing")
}
fn join_list(&self, items: &[&str], conjunction: Conjunction) -> String {
let conj = match conjunction {
Conjunction::And => "and",
Conjunction::Or => "or",
};
match items.len() {
0 => String::new(),
1 => items[0].to_string(),
2 => format!("{} {conj} {}", items[0], items[1]),
_ => {
let head = items[..items.len() - 1].join(", ");
format!("{head}, {conj} {}", items[items.len() - 1])
}
}
}
fn ordinal(&self, n: usize) -> String {
let suffix = match n % 10 {
1 if n % 100 != 11 => "st",
2 if n % 100 != 12 => "nd",
3 if n % 100 != 13 => "rd",
_ => "th",
};
format!("{n}{suffix}")
}
fn number_to_words(&self, n: usize) -> String {
format!("<{n}>") }
}
fn test_engine() -> Engine {
Engine::new(TestLang)
}
fn test_session() -> Session {
Session::new()
}
#[test]
fn style_preference_selects_matching_style() {
let mut engine = test_engine().style_preference("executive");
engine.register_template("t", "technical {name}").unwrap();
engine
.register_template_with_style("t", "executive {name}", Some("executive"))
.unwrap();
let mut ctx = Context::new();
ctx.insert("name", Value::String("summary".into()));
let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
assert_eq!(out, "executive summary");
}
#[test]
fn style_preference_falls_back_to_unstyled_before_any_style() {
let mut engine = test_engine().style_preference("customer");
engine
.register_template_with_style("t", "executive {name}", Some("executive"))
.unwrap();
engine.register_template("t", "plain {name}").unwrap();
let mut ctx = Context::new();
ctx.insert("name", Value::String("summary".into()));
let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
assert_eq!(out, "plain summary");
}
#[test]
fn style_filter_runs_inside_language_filter() {
let mut engine = test_engine()
.language_preference("en")
.style_preference("executive");
engine
.register_template_with_language_and_style(
"t",
"english executive {name}",
Some("en"),
Some("executive"),
)
.unwrap();
engine
.register_template_with_language_and_style(
"t",
"spanish executive {name}",
Some("es"),
Some("executive"),
)
.unwrap();
let mut ctx = Context::new();
ctx.insert("name", Value::String("summary".into()));
let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
assert_eq!(out, "english executive summary");
}
#[test]
fn no_style_preference_prefers_unstyled_variants() {
let mut engine = test_engine();
engine
.register_template_with_style("t", "styled {name}", Some("executive"))
.unwrap();
engine.register_template("t", "unstyled {name}").unwrap();
let mut ctx = Context::new();
ctx.insert("name", Value::String("summary".into()));
let out = engine.render(&mut test_session(), "t", &ctx).unwrap();
assert_eq!(out, "unstyled summary");
}
#[test]
fn has_template_returns_true_for_registered() {
let mut engine = test_engine();
engine.register_template("t", "hello").unwrap();
assert!(engine.has_template("t"));
assert!(!engine.has_template("nope"));
}
#[test]
fn render_simple_substitution() {
let mut engine = test_engine();
engine.register_template("greet", "Hello {name}!").unwrap();
let mut ctx = Context::new();
ctx.insert("name", Value::String("world".into()));
let mut session = test_session();
assert_eq!(
engine.render(&mut session, "greet", &ctx).unwrap(),
"Hello world!"
);
}
#[test]
fn render_missing_slot_strict() {
let mut engine = test_engine();
engine.register_template("greet", "Hello {name}!").unwrap();
let ctx = Context::new();
let mut session = test_session();
let result = engine.render(&mut session, "greet", &ctx);
assert!(matches!(result, Err(ProsaicError::MissingSlot { .. })));
}
#[test]
fn render_missing_slot_lenient() {
let mut engine = test_engine().strictness(Strictness::Lenient);
engine.register_template("greet", "Hello {name}!").unwrap();
let ctx = Context::new();
let mut session = test_session();
assert_eq!(
engine.render(&mut session, "greet", &ctx).unwrap(),
"Hello [missing: name]!"
);
}
#[test]
fn render_missing_slot_silent() {
let mut engine = test_engine().strictness(Strictness::Silent);
engine.register_template("greet", "Hello {name}!").unwrap();
let ctx = Context::new();
let mut session = test_session();
assert_eq!(
engine.render(&mut session, "greet", &ctx).unwrap(),
"Hello!"
);
}
#[test]
fn render_unknown_template() {
let engine = test_engine();
let ctx = Context::new();
let mut session = test_session();
let result = engine.render(&mut session, "nonexistent", &ctx);
assert!(matches!(result, Err(ProsaicError::UnknownTemplate(_))));
}
#[test]
fn render_pluralize_pipe() {
let mut engine = test_engine();
engine
.register_template("count", "{n} {n|pluralize:item}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("n", Value::Number(1));
assert_eq!(
engine.render(&mut session, "count", &ctx).unwrap(),
"1 item"
);
session.reset();
ctx.insert("n", Value::Number(5));
assert_eq!(
engine.render(&mut session, "count", &ctx).unwrap(),
"5 items"
);
}
#[test]
fn render_article_pipe() {
let mut engine = test_engine();
engine.register_template("a", "{thing|article}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("thing", Value::String("apple".into()));
assert_eq!(engine.render(&mut session, "a", &ctx).unwrap(), "an apple");
session.reset();
ctx.insert("thing", Value::String("banana".into()));
assert_eq!(engine.render(&mut session, "a", &ctx).unwrap(), "a banana");
}
#[test]
fn render_join_pipe() {
let mut engine = test_engine();
engine.register_template("list", "{items|join}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec!["a".into(), "b".into(), "c".into()]),
);
assert_eq!(
engine.render(&mut session, "list", &ctx).unwrap(),
"a, b, and c"
);
}
#[test]
fn render_join_or_pipe() {
let mut engine = test_engine();
engine.register_template("list", "{items|join:or}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec!["a".into(), "b".into(), "c".into()]),
);
assert_eq!(
engine.render(&mut session, "list", &ctx).unwrap(),
"a, b, or c"
);
}
#[test]
fn render_truncate_then_join_bracketed() {
let mut engine = test_engine();
engine
.register_template("t", "{items|truncate:2|join:bracketed}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec![
"a".into(),
"b".into(),
"c".into(),
"d".into(),
"e".into(),
]),
);
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"[a, b, and 3 more]"
);
}
#[test]
fn render_capitalize_pipe() {
let mut engine = test_engine();
engine
.register_template("cap", "{word|capitalize}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("word", Value::String("hello".into()));
assert_eq!(engine.render(&mut session, "cap", &ctx).unwrap(), "Hello");
}
#[test]
fn render_ordinal_pipe() {
let mut engine = test_engine();
engine.register_template("o", "{n|ordinal}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("n", Value::Number(3));
assert_eq!(engine.render(&mut session, "o", &ctx).unwrap(), "3rd");
}
#[test]
fn render_inline_template() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("name", Value::String("world".into()));
assert_eq!(
engine
.render_inline(&mut session, "Hello {name}!", &ctx)
.unwrap(),
"Hello world!"
);
}
#[test]
fn render_inline_does_not_advance_list_style_cycle() {
let mut engine = test_engine();
engine.register_template("t", "{items|join}").unwrap();
let mut s_ref = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec!["a".into(), "b".into(), "c".into()]),
);
let ref_out = engine.render(&mut s_ref, "t", &ctx).unwrap();
let mut s_test = test_session();
engine
.render_inline(&mut s_test, "{items|join}", &ctx)
.unwrap();
let after_inline = engine.render(&mut s_test, "t", &ctx).unwrap();
assert_eq!(
ref_out, after_inline,
"inline render leaked list-style cycle into a later registered render"
);
}
#[test]
fn render_inline_failure_leaves_session_unchanged() {
let engine = test_engine();
let mut session = test_session();
let snapshot = session.clone();
let result = engine.render_inline(&mut session, "Hello {nope}!", Context::new());
assert!(result.is_err(), "expected missing-slot error");
assert_eq!(
session.discourse.focus_is_plural(),
snapshot.discourse.focus_is_plural()
);
}
#[test]
fn render_inline_does_not_mention_entities_via_plural_refer() {
let mut engine = test_engine();
engine.register_template("t", "{name|refer}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Alpha".into()));
let _ = engine
.render_inline(&mut session, "{name|refer}", &ctx)
.unwrap();
let out = engine.render(&mut session, "t", &ctx).unwrap();
assert!(
out.contains("The class Alpha"),
"expected Full form (no leaked entity mention); got: {out}"
);
}
#[test]
fn variation_fixed_always_picks_first() {
let mut engine = test_engine().variation(Variation::Fixed);
engine.register_template("t", "first").unwrap();
engine.register_template("t", "second").unwrap();
let mut session = test_session();
let ctx = Context::new();
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "first");
}
#[test]
fn variation_seeded_is_deterministic() {
let mut engine = test_engine().variation(Variation::Seeded(42));
engine.register_template("t", "first").unwrap();
engine.register_template("t", "second").unwrap();
let ctx = Context::new();
let mut session1 = test_session();
let result1 = engine.render(&mut session1, "t", &ctx).unwrap();
let mut session2 = test_session();
let result2 = engine.render(&mut session2, "t", &ctx).unwrap();
assert_eq!(result1, result2);
}
#[test]
fn unknown_pipe_is_error() {
let mut engine = test_engine();
let err = engine
.register_template("t", "{name|nonexistent}")
.unwrap_err();
assert!(
matches!(err, ProsaicError::TemplateParseError { .. }),
"expected TemplateParseError for unknown pipe, got {err:?}"
);
}
#[test]
fn complex_template_end_to_end() {
let mut engine = test_engine();
engine
.register_template(
"entity.renamed",
"The {entity_type} {old_name} was renamed to {new_name} \
which impacts {count} direct {count|pluralize:consumer}",
)
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("old_name", Value::String("Foo".into()));
ctx.insert("new_name", Value::String("Foobar".into()));
ctx.insert("count", Value::Number(6));
assert_eq!(
engine.render(&mut session, "entity.renamed", &ctx).unwrap(),
"The class Foo was renamed to Foobar which impacts 6 direct consumers."
);
}
#[test]
fn reset_clears_discourse_state() {
let mut engine = test_engine();
engine
.register_template("t", "The {entity_type} {name} was modified")
.unwrap();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let mut session = test_session();
engine.render(&mut session, "t", &ctx).unwrap();
session.reset();
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert!(result.starts_with("The class Foo"));
}
#[test]
fn template_anti_repeat_with_multiple_variants() {
let mut engine = test_engine().variation(Variation::Seeded(1));
engine
.register_template("t", "alpha distinct tokens")
.unwrap();
engine
.register_template("t", "beta different tokens")
.unwrap();
engine
.register_template("t", "gamma unique tokens")
.unwrap();
let mut session = test_session();
let ctx = Context::new();
let r1 = engine.render(&mut session, "t", &ctx).unwrap();
let r2 = engine.render(&mut session, "t", &ctx).unwrap();
assert_ne!(r1, r2);
}
#[test]
fn list_style_cycles_across_renders() {
let mut engine = test_engine();
engine
.register_template("t", "{items|truncate:1|join}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec!["alpha".into(), "beta".into(), "gamma".into()]),
);
let r1 = engine.render(&mut session, "t", &ctx).unwrap();
let r2 = engine.render(&mut session, "t", &ctx).unwrap();
let r3 = engine.render(&mut session, "t", &ctx).unwrap();
let r4 = engine.render(&mut session, "t", &ctx).unwrap();
let results = vec![r1, r2, r3, r4];
let unique: std::collections::HashSet<&String> = results.iter().collect();
assert!(
unique.len() >= 3,
"Expected at least 3 unique list styles, got {}: {:?}",
unique.len(),
results
);
}
#[test]
fn bracketed_style_forced() {
let mut engine = test_engine();
engine
.register_template("t", "{items|truncate:1|join:bracketed}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec!["alpha".into(), "beta".into(), "gamma".into()]),
);
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert!(
result.starts_with('[') && result.ends_with(']'),
"Expected bracketed format, got: {result}"
);
}
#[test]
fn refer_first_mention_uses_full_form() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} was updated")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("UserService".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The class UserService was updated.");
}
#[test]
fn refer_second_mention_uses_pronoun() {
let mut engine = test_engine();
engine
.register_template("first", "{name|refer} was modified")
.unwrap();
engine
.register_template("second", "{name|refer} now has new behavior")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let r1 = engine.render(&mut session, "first", &ctx).unwrap();
let r2 = engine.render(&mut session, "second", &ctx).unwrap();
assert_eq!(r1, "The class Foo was modified.");
assert!(
r2.contains("it now has new behavior") || r2.contains("It now has new behavior"),
"Expected pronoun reference, got: {r2}"
);
}
#[test]
fn refer_ambiguity_prevents_pronoun() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} changed")
.unwrap();
let mut session = test_session();
let mut ctx_a = Context::new();
ctx_a.insert("entity_type", Value::String("class".into()));
ctx_a.insert("name", Value::String("ServiceA".into()));
engine.render(&mut session, "t", &ctx_a).unwrap();
let mut ctx_b = Context::new();
ctx_b.insert("entity_type", Value::String("class".into()));
ctx_b.insert("name", Value::String("ServiceB".into()));
engine.render(&mut session, "t", &ctx_b).unwrap();
let result = engine.render(&mut session, "t", &ctx_a).unwrap();
assert!(
result.contains("ServiceA changed") || result.contains("serviceA changed"),
"Expected short name (not pronoun), got: {result}"
);
assert!(
!result.contains("It changed") && !result.contains("it changed"),
"Should not use pronoun with ambiguity, got: {result}"
);
}
#[test]
fn refer_explicit_entity_type() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer:method} was called")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("name", Value::String("processOrder".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The method processOrder was called.");
}
#[test]
fn refer_reset_reintroduces_full_form() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} updated")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
engine.render(&mut session, "t", &ctx).unwrap();
session.reset();
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The class Foo updated.");
}
#[test]
fn refer_distant_mention_reintroduces_full() {
let mut engine = test_engine();
engine
.register_template("track", "{name|refer} was tracked")
.unwrap();
engine
.register_template("other", "Something else happened")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let mut other_ctx = Context::new();
other_ctx.insert("entity_type", Value::String("method".into()));
other_ctx.insert("name", Value::String("bar".into()));
engine.render(&mut session, "track", &ctx).unwrap();
engine.render(&mut session, "other", &other_ctx).unwrap();
engine.render(&mut session, "other", &other_ctx).unwrap();
engine.render(&mut session, "other", &other_ctx).unwrap();
let result = engine.render(&mut session, "track", &ctx).unwrap();
assert_eq!(result, "The class Foo was tracked.");
}
#[test]
fn explain_reports_variant_index_and_source() {
let mut engine = test_engine();
engine.register_template("t", "alpha").unwrap();
engine.register_template("t", "beta").unwrap();
let mut session = test_session();
let exp = engine
.render_explained(&mut session, "t", Context::new())
.unwrap();
assert_eq!(exp.template_key, "t");
assert_eq!(exp.variant_index, 0);
assert_eq!(exp.variant_source, "alpha");
assert_eq!(exp.salience, Salience::Medium);
}
#[test]
fn explain_reports_reference_form_when_refer_pipe_fires() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} was modified")
.unwrap();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let mut session = test_session();
let exp = engine.render_explained(&mut session, "t", &ctx).unwrap();
assert_eq!(exp.reference_form, Some(ReferenceForm::Full));
}
#[test]
fn explain_reports_centering_transition() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} was modified")
.unwrap();
let mut s = test_session();
let mut c = Context::new();
c.insert("entity_type", Value::String("class".into()));
c.insert("name", Value::String("Foo".into()));
let e1 = engine.render_explained(&mut s, "t", &c).unwrap();
assert_eq!(e1.centering_transition, Transition::NoCb);
let e2 = engine.render_explained(&mut s, "t", &c).unwrap();
assert_eq!(e2.centering_transition, Transition::Continue);
let mut c2 = Context::new();
c2.insert("entity_type", Value::String("class".into()));
c2.insert("name", Value::String("Bar".into()));
let e3 = engine.render_explained(&mut s, "t", &c2).unwrap();
assert_eq!(e3.centering_transition, Transition::Retain);
}
#[test]
fn explain_captures_connective_on_continuation() {
let mut engine = test_engine();
engine
.register_template("t", "The {entity_type} {name} was renamed")
.unwrap();
engine
.register_template("u", "The {entity_type} {name} was modified")
.unwrap();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let mut session = test_session();
engine.render(&mut session, "t", &ctx).unwrap();
let exp = engine.render_explained(&mut session, "u", &ctx).unwrap();
assert_eq!(exp.connective, Some("Additionally,"));
}
#[test]
fn render_explained_reports_list_style_when_join_fires() {
let mut engine = test_engine();
engine
.register_template("list", "{items|join:bracketed}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec!["a".into(), "b".into(), "c".into()]),
);
let exp = engine.render_explained(&mut session, "list", &ctx).unwrap();
assert_eq!(
exp.list_style,
Some(ListStyle::Bracketed),
"render_explained should report the forced list style; got: {:?}",
exp.list_style
);
}
#[test]
fn render_explained_list_style_none_when_no_join_fired() {
let mut engine = test_engine();
engine
.register_template("plain", "The {entity_type} {name} was renamed")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let exp = engine
.render_explained(&mut session, "plain", &ctx)
.unwrap();
assert_eq!(exp.list_style, None);
}
#[test]
fn render_explained_reports_cleanup_stripped_tail_in_silent_mode() {
let mut engine = test_engine().strictness(Strictness::Silent);
engine
.register_template("add", "A new {entity_type} was added in {location}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
let exp = engine.render_explained(&mut session, "add", &ctx).unwrap();
assert!(
exp.cleanup_stripped_tail,
"Silent-mode render with dangling tail should report cleanup_stripped_tail=true; got output: {:?}",
exp.output
);
}
#[test]
fn render_explained_cleanup_stripped_tail_false_for_clean_render() {
let mut engine = test_engine();
engine
.register_template("plain", "The {entity_type} {name} was renamed")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let exp = engine
.render_explained(&mut session, "plain", &ctx)
.unwrap();
assert!(!exp.cleanup_stripped_tail);
}
#[test]
fn render_iter_yields_one_sentence_per_event_when_no_aggregation() {
let mut engine = test_engine();
engine.register_template("a", "Alpha was seen").unwrap();
engine.register_template("b", "Beta was found").unwrap();
let mut session = test_session();
let events: Vec<(&str, Context)> = vec![("a", Context::new()), ("b", Context::new())];
let results: Vec<_> = engine
.render_iter(&mut session, &events)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(results.len(), 2);
assert!(results[0].contains("Alpha"));
assert!(results[1].contains("Beta"));
}
#[test]
fn render_iter_error_is_terminal_for_single_events() {
let mut engine = test_engine();
engine
.register_template("bad", "{missing_slot} was lost")
.unwrap();
let mut session = test_session();
let events: Vec<(&str, Context)> = vec![("bad", Context::new())];
let mut iter = engine.render_iter(&mut session, &events);
let first = iter.next();
assert!(matches!(first, Some(Err(_))));
let second = iter.next();
assert!(
second.is_none(),
"iterator must return None after a terminal error"
);
}
#[test]
fn render_iter_error_is_terminal_inside_aggregated_run() {
let mut engine = test_engine();
engine
.register_template("saw", "{name} saw {target}")
.unwrap();
let mut good = Context::new();
good.insert("entity_type", Value::String("class".into()));
good.insert("name", Value::String("Alpha".into()));
good.insert("target", Value::String("X".into()));
let mut bad = Context::new();
bad.insert("entity_type", Value::String("class".into()));
bad.insert("name", Value::String("Beta".into()));
let mut session = test_session();
let events: Vec<(&str, Context)> = vec![("saw", good), ("saw", bad)];
let mut iter = engine.render_iter(&mut session, &events);
let first = iter.next();
assert!(
matches!(first, Some(Err(_))),
"expected the aggregated render to fail; got: {first:?}"
);
assert!(
iter.next().is_none(),
"iterator must be terminal after an aggregated-run error"
);
}
#[test]
fn render_iter_collapses_same_entity_run_into_one_sentence() {
let mut engine = test_engine();
engine
.register_template("renamed", "{name|refer} was renamed")
.unwrap();
engine
.register_template("modified", "{name|refer} was modified")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let events: Vec<(&str, Context)> =
vec![("renamed", ctx.clone()), ("modified", ctx.clone())];
let iter_results: Vec<_> = engine
.render_iter(&mut session, &events)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(iter_results.len(), 1);
assert!(
iter_results[0].contains("renamed and modified"),
"got: {}",
iter_results[0]
);
}
#[test]
fn score_variants_returns_one_entry_per_alternative() {
let mut engine = test_engine();
engine.register_template("t", "alpha").unwrap();
engine.register_template("t", "beta").unwrap();
engine.register_template("t", "gamma").unwrap();
let mut session = test_session();
let scores = engine
.score_variants(&mut session, "t", Context::new())
.unwrap();
assert_eq!(scores.len(), 3);
let sources: Vec<_> = scores.iter().map(|s| s.source.as_str()).collect();
assert_eq!(sources, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn score_variants_marks_one_as_selected() {
let mut engine = test_engine();
engine.register_template("t", "alpha").unwrap();
engine.register_template("t", "beta").unwrap();
let mut session = test_session();
let scores = engine
.score_variants(&mut session, "t", Context::new())
.unwrap();
assert_eq!(scores.iter().filter(|s| s.selected).count(), 1);
}
#[test]
fn score_variants_does_not_mutate_discourse() {
let mut engine = test_engine();
engine.register_template("t", "alpha").unwrap();
engine.register_template("t", "beta").unwrap();
let mut session = test_session();
let _ = engine
.score_variants(&mut session, "t", Context::new())
.unwrap();
let r1 = engine.render(&mut session, "t", Context::new()).unwrap();
assert_eq!(r1, "alpha");
}
#[test]
fn score_variants_unknown_key_errors() {
let engine = test_engine();
let mut session = test_session();
let result = engine.score_variants(&mut session, "never_registered", Context::new());
assert!(matches!(result, Err(ProsaicError::UnknownTemplate(_))));
}
#[test]
fn partial_expands_inline() {
let mut engine = test_engine();
engine
.register_partial("tail", ", affecting {count} {count|pluralize:consumer}")
.unwrap();
engine
.register_template("t", "The class Foo was modified{>tail}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("count", Value::Number(3));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The class Foo was modified, affecting 3 consumers."
);
}
#[test]
fn partial_shared_across_templates() {
let mut engine = test_engine();
engine
.register_partial("tail", ", affecting {count} {count|pluralize:consumer}")
.unwrap();
engine
.register_template("modified", "The class {name} was modified{>tail}")
.unwrap();
engine
.register_template("renamed", "The class {name} was renamed{>tail}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("name", Value::String("Foo".into()));
ctx.insert("count", Value::Number(2));
assert_eq!(
engine.render(&mut session, "modified", &ctx).unwrap(),
"The class Foo was modified, affecting 2 consumers."
);
assert_eq!(
engine.render(&mut session, "renamed", &ctx).unwrap(),
"The class Foo was renamed, affecting 2 consumers."
);
}
#[test]
fn unknown_partial_errors() {
let mut engine = test_engine();
engine
.register_template("t", "Hello{>missing_partial}")
.unwrap();
let mut session = test_session();
let result = engine.render(&mut session, "t", Context::new());
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn direct_recursive_partial_is_rejected() {
let mut engine = test_engine();
let result = engine.register_partial("a", "{>a}");
match result {
Err(ProsaicError::RecursivePartial { cycle }) => {
assert_eq!(cycle, vec!["a".to_string(), "a".to_string()]);
}
other => panic!("expected RecursivePartial, got {other:?}"),
}
assert!(!engine.partials.contains_key("a"));
}
#[test]
fn indirect_recursive_partial_is_rejected() {
let mut engine = test_engine();
engine.register_partial("a", "{>b}").unwrap();
let result = engine.register_partial("b", "{>a}");
match result {
Err(ProsaicError::RecursivePartial { cycle }) => {
assert!(
cycle.contains(&"a".to_string()) && cycle.contains(&"b".to_string()),
"cycle should include both partials; got {cycle:?}"
);
assert_eq!(cycle.first(), cycle.last());
}
other => panic!("expected RecursivePartial, got {other:?}"),
}
assert!(!engine.partials.contains_key("b"));
assert!(engine.partials.contains_key("a"));
}
#[test]
fn non_cyclic_partial_chain_is_accepted() {
let mut engine = test_engine();
engine.register_partial("inner", "-inner-").unwrap();
engine.register_partial("middle", "[{>inner}]").unwrap();
engine.register_partial("outer", "<{>middle}>").unwrap();
engine
.register_template("t", "prefix {>outer} suffix")
.unwrap();
let mut session = test_session();
let out = engine.render(&mut session, "t", Context::new()).unwrap();
assert!(out.contains("<[-inner-]>"), "got: {out}");
}
#[test]
fn updating_partial_to_introduce_cycle_rolls_back() {
let mut engine = test_engine();
engine.register_partial("a", "literal-a").unwrap();
engine.register_partial("b", "{>a}").unwrap();
let result = engine.register_partial("a", "{>b}");
assert!(matches!(result, Err(ProsaicError::RecursivePartial { .. })));
engine.register_template("t", "see {>a} here").unwrap();
let mut session = test_session();
let out = engine.render(&mut session, "t", Context::new()).unwrap();
assert!(
out.contains("literal-a"),
"expected prior partial body to be restored; got: {out}"
);
}
#[test]
fn length_budget_splits_long_sentence_at_which() {
let mut engine = test_engine().max_sentence_length(50);
engine
.register_template(
"t",
"The class UserService was renamed to AccountService, \
which impacts 6 consumers",
)
.unwrap();
let mut session = test_session();
let out = engine.render(&mut session, "t", Context::new()).unwrap();
assert!(out.contains("This impacts 6 consumers"), "got: {out}");
assert!(out.contains(". "), "expected a sentence break, got: {out}");
}
#[test]
fn length_budget_does_nothing_when_sentence_fits() {
let mut engine = test_engine().max_sentence_length(200);
engine
.register_template("t", "The class Foo was modified")
.unwrap();
let mut session = test_session();
let out = engine.render(&mut session, "t", Context::new()).unwrap();
assert_eq!(out, "The class Foo was modified.");
}
#[test]
fn negated_pipe_uses_registered_antonym() {
let mut engine = test_engine();
engine.register_antonym("was modified", "remained unchanged");
engine
.register_template("t", "The class Foo {p|negated}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("p", Value::String("was modified".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The class Foo remained unchanged."
);
}
#[test]
fn negated_pipe_inserts_not_when_no_antonym() {
let mut engine = test_engine();
engine
.register_template("t", "The class Foo {p|negated}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("p", Value::String("was modified".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The class Foo was not modified."
);
}
#[test]
fn negated_pipe_handles_perfect_aux() {
let mut engine = test_engine();
engine
.register_template("t", "The class Foo {p|negated}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("p", Value::String("has been renamed".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The class Foo has not been renamed."
);
}
#[test]
fn hedge_pipe_default_adverb() {
let mut engine = test_engine();
engine
.register_template("t", "The change {conf|hedge} broke the build")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("conf", Value::Number(60));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The change probably broke the build."
);
}
#[test]
fn hedge_pipe_modal_mode() {
let mut engine = test_engine();
engine
.register_template("t", "The change {conf|hedge:modal} break things")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("conf", Value::Number(40));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The change might break things."
);
}
#[test]
fn hedge_pipe_rejects_unknown_mode() {
let mut engine = test_engine();
engine.register_template("t", "{c|hedge:bogus}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("c", Value::Number(60));
assert!(matches!(
engine.render(&mut session, "t", &ctx),
Err(ProsaicError::InvalidPipe { .. })
));
}
#[test]
fn demonstrative_uses_the_on_first_render() {
let mut engine = test_engine();
engine
.register_template("t", "{noun|demonstrative}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("noun", Value::String("change".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"the change"
);
}
#[test]
fn demonstrative_uses_this_on_continuation() {
let mut engine = test_engine();
engine.register_template("prime", "setup").unwrap();
engine
.register_template("t", "{noun|demonstrative}")
.unwrap();
let mut session = test_session();
engine
.render(&mut session, "prime", Context::new())
.unwrap();
let mut ctx = Context::new();
ctx.insert("noun", Value::String("change".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "this change");
}
#[test]
fn demonstrative_resets_to_the_after_reset() {
let mut engine = test_engine();
engine.register_template("prime", "setup").unwrap();
engine
.register_template("t", "{noun|demonstrative}")
.unwrap();
let mut session = test_session();
engine
.render(&mut session, "prime", Context::new())
.unwrap();
session.reset();
let mut ctx = Context::new();
ctx.insert("noun", Value::String("change".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"the change"
);
}
#[test]
fn quantify_pipe_natural_defaults() {
let mut engine = test_engine();
engine
.register_template("t", "{n|quantify} consumer")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("n", Value::Number(0));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"no consumer"
);
session.reset();
ctx.insert("n", Value::Number(1));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"a single consumer"
);
session.reset();
ctx.insert("n", Value::Number(300));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"hundreds of consumer"
);
}
#[test]
fn quantify_pipe_exact_mode() {
let mut engine = test_engine();
engine
.register_template("t", "{n|quantify:exact} callers")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("n", Value::Number(47));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"47 callers"
);
}
#[test]
fn quantify_pipe_hedged_mode() {
let mut engine = test_engine();
engine
.register_template("t", "{n|quantify:hedged} dependents")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("n", Value::Number(4));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"a few dependents"
);
}
#[test]
fn quantify_pipe_rejects_unknown_mode() {
let mut engine = test_engine();
engine.register_template("t", "{n|quantify:bogus}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("n", Value::Number(5));
assert!(matches!(
engine.render(&mut session, "t", &ctx),
Err(ProsaicError::InvalidPipe { .. })
));
}
#[test]
fn relative_pipe_renders_past_phrases() {
let now: i64 = 1_700_000_000;
let mut engine = test_engine().reference_time(now);
engine.register_template("t", "{ts|relative}").unwrap();
let cases = [
(now, "just now"),
(now - 60, "1 minute ago"),
(now - 3600, "an hour ago"),
(now - 86400 - 3600, "yesterday"),
(now - 3 * 86400, "3 days ago"),
(now - 10 * 86400, "last week"),
(now - 3 * 30 * 86400, "3 months ago"),
(now - 2 * 365 * 86400, "2 years ago"),
];
for (ts, expected) in cases {
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("ts", Value::Number(ts));
let rendered = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(rendered, expected, "for ts={ts}");
}
}
#[test]
fn relative_pipe_renders_future_phrases() {
let now: i64 = 1_700_000_000;
let mut engine = test_engine().reference_time(now);
engine.register_template("t", "{ts|relative}").unwrap();
let cases = [
(now + 3600, "in an hour"),
(now + 86400 + 3600, "tomorrow"),
(now + 3 * 86400, "in 3 days"),
(now + 10 * 86400, "next week"),
];
for (ts, expected) in cases {
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("ts", Value::Number(ts));
let rendered = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(rendered, expected, "for ts={ts}");
}
}
#[test]
fn relative_pipe_rejects_non_numeric() {
let mut engine = test_engine().reference_time(1_700_000_000);
engine.register_template("t", "{x|relative}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("x", Value::String("not a number".into()));
let result = engine.render(&mut session, "t", &ctx);
assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
}
#[cfg(feature = "time")]
#[test]
fn since_last_first_event_falls_back_to_relative() {
let now = 1_700_000_000;
let mut engine = test_engine().reference_time(now);
engine.register_template("t", "{ts|since_last}").unwrap();
let mut s = Session::new();
let mut ctx = Context::new();
ctx.insert("ts", Value::Number(now - 3 * 86400)); ctx.insert("timestamp", Value::Number(now - 3 * 86400));
let out = engine.render(&mut s, "t", &ctx).unwrap();
assert!(out.contains("3 days ago"), "got: {out}");
}
#[cfg(feature = "time")]
#[test]
fn since_last_subsequent_event_uses_anchor() {
let now = 1_700_000_000;
let mut engine = test_engine().reference_time(now);
engine.register_template("t", "{ts|since_last}").unwrap();
let mut s = Session::new();
let mut c1 = Context::new();
let t1 = now - 3 * 86400;
c1.insert("ts", Value::Number(t1));
c1.insert("timestamp", Value::Number(t1));
engine.render(&mut s, "t", &c1).unwrap();
let mut c2 = Context::new();
let t2 = t1 + 86400;
c2.insert("ts", Value::Number(t2));
c2.insert("timestamp", Value::Number(t2));
let out = engine.render(&mut s, "t", &c2).unwrap();
assert!(out.contains("the next day"), "got: {out}");
}
#[cfg(feature = "time")]
#[test]
fn since_last_survives_session_reset() {
let now = 1_700_000_000;
let mut engine = test_engine().reference_time(now);
engine.register_template("t", "{ts|since_last}").unwrap();
let mut s = Session::new();
let mut c1 = Context::new();
let t1 = now - 3 * 86400;
c1.insert("ts", Value::Number(t1));
c1.insert("timestamp", Value::Number(t1));
engine.render(&mut s, "t", &c1).unwrap();
s.reset(); assert_eq!(s.last_temporal_anchor, Some(t1));
let mut c2 = Context::new();
let t2 = t1 + 86400;
c2.insert("ts", Value::Number(t2));
c2.insert("timestamp", Value::Number(t2));
let out = engine.render(&mut s, "t", &c2).unwrap();
assert!(out.contains("the next day"), "got: {out}");
}
#[cfg(feature = "time")]
#[test]
fn since_last_reset_temporal_restarts_narrative() {
let now = 1_700_000_000;
let mut engine = test_engine().reference_time(now);
engine.register_template("t", "{ts|since_last}").unwrap();
let mut s = Session::new();
let mut c1 = Context::new();
let t1 = now - 3 * 86400;
c1.insert("ts", Value::Number(t1));
c1.insert("timestamp", Value::Number(t1));
engine.render(&mut s, "t", &c1).unwrap();
s.reset_temporal();
let mut c2 = Context::new();
let t2 = t1 + 86400;
c2.insert("ts", Value::Number(t2));
c2.insert("timestamp", Value::Number(t2));
let out = engine.render(&mut s, "t", &c2).unwrap();
assert!(out.contains("2 days ago"), "got: {out}");
}
#[cfg(feature = "time")]
#[test]
fn since_last_anchor_set_after_successful_render() {
let now = 1_700_000_000;
let mut engine = test_engine().reference_time(now);
engine.register_template("t", "{ts|since_last}").unwrap();
let mut s = Session::new();
assert_eq!(s.last_temporal_anchor, None);
let mut ctx = Context::new();
let ts = now - 86400;
ctx.insert("ts", Value::Number(ts));
ctx.insert("timestamp", Value::Number(ts));
engine.render(&mut s, "t", &ctx).unwrap();
assert_eq!(s.last_temporal_anchor, Some(ts));
}
#[cfg(feature = "time")]
#[test]
fn since_last_rejects_non_numeric() {
let mut engine = test_engine().reference_time(1_700_000_000);
engine.register_template("t", "{x|since_last}").unwrap();
let mut s = Session::new();
let mut ctx = Context::new();
ctx.insert("x", Value::String("not a number".into()));
let result = engine.render(&mut s, "t", &ctx);
assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
}
#[test]
fn syn_pipe_passes_through_unregistered_words() {
let mut engine = test_engine();
engine.register_template("t", "{word|syn}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("word", Value::String("unregistered".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"unregistered"
);
}
#[test]
fn syn_pipe_rotates_across_renders() {
let mut engine = test_engine();
engine.register_synonyms(&["class", "type", "kind"]);
engine
.register_template("t", "the {word|syn} was seen")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("word", Value::String("class".into()));
let r1 = engine.render(&mut session, "t", &ctx).unwrap();
let r2 = engine.render(&mut session, "t", &ctx).unwrap();
let r3 = engine.render(&mut session, "t", &ctx).unwrap();
let combined = format!("{r1} | {r2} | {r3}");
assert!(combined.contains("class"), "got: {combined}");
assert!(combined.contains("type"), "got: {combined}");
assert!(combined.contains("kind"), "got: {combined}");
}
#[test]
fn syn_pipe_preserves_capitalization() {
let mut engine = test_engine();
engine.register_synonyms(&["class", "type"]);
engine.register_template("t", "{word|syn}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("word", Value::String("Class".into()));
let first = engine.render(&mut session, "t", &ctx).unwrap();
assert!(
first.chars().next().unwrap().is_uppercase(),
"expected capitalized output, got: {first}"
);
}
#[test]
fn syn_pipe_deterministic_tie_break_first_registered_wins() {
let mut engine = test_engine();
engine.register_synonyms(&["alpha", "beta", "gamma"]);
engine.register_template("t", "{word|syn}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("word", Value::String("alpha".into()));
assert_eq!(engine.render(&mut session, "t", &ctx).unwrap(), "alpha");
}
#[test]
fn reduce_merges_three_simple_same_entity_passives() {
let reduced = reduce_same_entity_clauses(&[
"The class UserService was renamed to AccountService.".to_string(),
"It was modified.".to_string(),
"It was moved from src/ to lib/.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some(
"The class UserService was renamed to AccountService, \
modified, and moved from src/ to lib/."
)
);
}
#[test]
fn reduce_two_clauses_uses_and_without_oxford_comma() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"It was modified.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo was renamed and modified.")
);
}
#[test]
fn reduce_rejects_mixed_auxiliaries() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"It has been modified.".to_string(),
]);
assert!(reduced.is_none());
}
#[test]
fn reduce_rejects_embedded_which_clauses() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed, which impacts 6 consumers.".to_string(),
"It was modified.".to_string(),
]);
assert!(reduced.is_none());
}
#[test]
fn reduce_strips_connectives_and_merges() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"Additionally, it was modified.".to_string(),
"Furthermore, it was moved.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo was renamed, modified, and moved.")
);
}
#[test]
fn reduce_rejects_single_sentence() {
let reduced = reduce_same_entity_clauses(&["The class Foo was renamed.".to_string()]);
assert!(reduced.is_none());
}
#[test]
fn reduce_accepts_full_np_repetition_same_entity() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"The class Foo was modified.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo was renamed and modified.")
);
}
#[test]
fn reduce_handles_has_been_perfect_passive() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo has been renamed.".to_string(),
"It has been modified.".to_string(),
"It has been moved.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo has been renamed, modified, and moved.")
);
}
#[test]
fn reduce_accepts_it_also_connective() {
let reduced = reduce_same_entity_clauses(&[
"The class UserService was renamed.".to_string(),
"It also was modified.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class UserService was renamed and modified.")
);
}
#[test]
fn prepend_replacing_subject_single_word_name_strips_np() {
let mut out = String::from("The class Foo was modified");
prepend_replacing_subject_in_place(&mut out, "It also", Some("Foo"));
assert_eq!(out, "It also was modified");
}
#[test]
fn prepend_replacing_subject_multiword_name_falls_back() {
let mut out = String::from("The feature Login flow was modified");
prepend_replacing_subject_in_place(&mut out, "It also", Some("Login flow"));
assert_eq!(out, "It also the feature Login flow was modified");
assert!(
!out.contains("flow was modified") || out.starts_with("It also the feature"),
"must not chop 'Login' off the subject; got: {out}"
);
}
#[test]
fn prepend_replacing_subject_unknown_name_falls_back() {
let mut out = String::from("The class Foo was modified");
prepend_replacing_subject_in_place(&mut out, "It also", None);
assert_eq!(out, "It also the class Foo was modified");
}
#[test]
fn render_sequence_with_multiword_name_produces_valid_prose() {
let mut engine = test_engine();
engine
.register_template("renamed", "{name|refer} was renamed")
.unwrap();
engine
.register_template("modified", "{name|refer} was modified")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("feature".into()));
ctx.insert("name", Value::String("Login flow".into()));
let r1 = engine.render(&mut session, "renamed", &ctx).unwrap();
assert!(r1.contains("Login flow"), "got: {r1}");
let r2 = engine.render(&mut session, "modified", &ctx).unwrap();
assert!(
!r2.starts_with("flow ") && !r2.contains("also flow "),
"follow-up render corrupted multi-word name; got: {r2}"
);
}
#[test]
fn reduce_accepts_mixed_discourse_connectives() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"Additionally, it was modified.".to_string(),
"It also was moved.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo was renamed, modified, and moved.")
);
}
#[test]
fn reduce_accepts_full_np_repetition() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"The class Foo was modified.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo was renamed and modified.")
);
}
#[test]
fn reduce_accepts_full_np_repetition_three_clauses() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"The class Foo was modified.".to_string(),
"The class Foo was moved.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo was renamed, modified, and moved.")
);
}
#[test]
fn reduce_mixed_np_and_pronoun_accepted() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"It was modified.".to_string(),
"The class Foo was moved.".to_string(),
]);
assert_eq!(
reduced.as_deref(),
Some("The class Foo was renamed, modified, and moved.")
);
}
#[test]
fn reduce_rejects_different_np_repetition() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"The class Bar was modified.".to_string(),
]);
assert_eq!(reduced, None);
}
#[test]
fn reduce_rejects_full_np_with_embedded_clause() {
let reduced = reduce_same_entity_clauses(&[
"The class Foo was renamed.".to_string(),
"The class Foo was modified, which affects 6 consumers.".to_string(),
]);
assert_eq!(reduced, None);
}
#[test]
fn reduce_gapping_two_events() {
let ss = vec![
"Foo was moved to core".to_string(),
"Bar was moved to util".to_string(),
];
let out = reduce_gapping(&ss).unwrap();
assert_eq!(out, "Foo was moved to core, and Bar to util.");
}
#[test]
fn reduce_gapping_three_events() {
let ss = vec![
"Foo was moved to core".to_string(),
"Bar was moved to util".to_string(),
"Baz was moved to api".to_string(),
];
let out = reduce_gapping(&ss).unwrap();
assert_eq!(out, "Foo was moved to core, Bar to util, and Baz to api.");
}
#[test]
fn reduce_gapping_rejects_single() {
let ss = vec!["Foo was moved to core".to_string()];
assert!(reduce_gapping(&ss).is_none());
}
#[test]
fn reduce_gapping_rejects_short_anchor() {
let ss = vec!["Foo was moved".to_string(), "Bar was modified".to_string()];
assert!(reduce_gapping(&ss).is_none());
}
#[test]
fn reduce_gapping_rejects_embedded_clause() {
let ss = vec![
"Foo was moved, affecting 3 consumers, to core".to_string(),
"Bar was moved to util".to_string(),
];
assert!(reduce_gapping(&ss).is_none());
}
#[test]
fn reduce_gapping_rejects_identical_subjects() {
let ss = vec![
"Foo was moved to core".to_string(),
"Foo was moved to core".to_string(),
];
assert!(reduce_gapping(&ss).is_none());
}
#[test]
fn reduce_gapping_rejects_empty_suffix() {
let ss = vec!["Foo was moved".to_string(), "Bar was moved".to_string()];
assert!(reduce_gapping(&ss).is_none());
}
#[test]
fn render_batch_applies_gapping_when_objects_differ() {
let mut engine = test_engine();
engine
.register_template("code.moved", "{name} was moved to {new_location}")
.unwrap();
let make = |name: &str, loc: &str| {
let mut c = Context::new();
c.insert("entity_type", Value::String("class".into()));
c.insert("name", Value::String(name.into()));
c.insert("new_location", Value::String(loc.into()));
c
};
let events = vec![
("code.moved", make("Foo", "core")),
("code.moved", make("Bar", "util")),
("code.moved", make("Baz", "api")),
];
let mut s = Session::new();
let out = engine.render_batch(&mut s, &events).unwrap();
assert_eq!(out, "Foo was moved to core, Bar to util, and Baz to api.");
}
#[test]
fn render_batch_gapping_does_not_apply_when_objects_match() {
let mut engine = test_engine();
engine
.register_template("code.moved", "{name} was moved to {new_location}")
.unwrap();
let make = |name: &str| {
let mut c = Context::new();
c.insert("entity_type", Value::String("class".into()));
c.insert("name", Value::String(name.into()));
c.insert("new_location", Value::String("core".into()));
c
};
let events = vec![("code.moved", make("Foo")), ("code.moved", make("Bar"))];
let mut s = Session::new();
let out = engine.render_batch(&mut s, &events).unwrap();
assert!(
out.contains("Foo and Bar") && out.contains("core"),
"got: {out}"
);
assert!(!out.contains(", and Bar to "), "got: {out}");
}
#[test]
fn render_iter_applies_gapping() {
let mut engine = test_engine();
engine
.register_template("code.moved", "{name} was moved to {new_location}")
.unwrap();
let make = |name: &str, loc: &str| {
let mut c = Context::new();
c.insert("entity_type", Value::String("class".into()));
c.insert("name", Value::String(name.into()));
c.insert("new_location", Value::String(loc.into()));
c
};
let events = vec![
("code.moved", make("Foo", "core")),
("code.moved", make("Bar", "util")),
("code.moved", make("Baz", "api")),
];
let mut s = Session::new();
let collected: Result<Vec<_>, _> = engine.render_iter(&mut s, &events).collect();
let collected = collected.unwrap();
assert_eq!(collected.len(), 1);
assert_eq!(
collected[0],
"Foo was moved to core, Bar to util, and Baz to api."
);
}
#[test]
fn silent_strips_trailing_dangling_preposition() {
let mut engine = test_engine().strictness(Strictness::Silent);
engine
.register_template("t", "The file was modified by {author}")
.unwrap();
let mut session = test_session();
let ctx = Context::new();
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The file was modified."
);
}
#[test]
fn silent_strips_dangling_preposition_with_punct() {
let mut engine = test_engine().strictness(Strictness::Silent);
engine
.register_template("t", "The class was renamed to {new_name}.")
.unwrap();
let mut session = test_session();
let ctx = Context::new();
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The class was renamed."
);
}
#[test]
fn silent_strips_orphan_conjunction() {
let mut engine = test_engine().strictness(Strictness::Silent);
engine
.register_template("t", "The module exports {a} and {b}")
.unwrap();
let mut session = test_session();
let ctx = Context::new();
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The module exports."
);
}
#[test]
fn silent_strips_chained_orphans() {
let mut engine = test_engine().strictness(Strictness::Silent);
engine
.register_template("t", "The job was scheduled by {a} at {b}")
.unwrap();
let mut session = test_session();
let ctx = Context::new();
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The job was scheduled."
);
}
#[test]
fn silent_preserves_content_when_orphans_would_empty_output() {
let mut engine = test_engine().strictness(Strictness::Silent);
engine.register_template("t", "by {author}").unwrap();
let mut session = test_session();
let ctx = Context::new();
assert_eq!(engine.render(&mut session, "t", &ctx).unwrap(), "by");
}
#[test]
fn whitespace_collapsing_runs_regardless_of_strictness() {
let mut engine = test_engine();
engine
.register_template("t", "The quick brown fox")
.unwrap();
let mut session = test_session();
let ctx = Context::new();
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"The quick brown fox."
);
}
#[test]
fn strict_mode_unaffected_by_cleanup_tail_stripping() {
let mut engine = test_engine();
engine
.register_template("t", "modified by {author}")
.unwrap();
let mut session = test_session();
let ctx = Context::new();
assert!(engine.render(&mut session, "t", &ctx).is_err());
}
#[test]
fn reg_with_no_registry_behaves_as_before() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} was modified")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("UserService".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The class UserService was modified.");
}
#[test]
fn reg_adds_distinguisher_when_same_type_registered() {
let mut engine = test_engine();
engine.register_entity(
crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
);
engine.register_entity(
crate::EntityDescriptor::new("AuthService", "class").with_attribute("layer", "infra"),
);
engine
.register_template("t", "{name|refer} was modified")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("UserService".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The domain class UserService was modified.");
}
#[test]
fn reg_no_distinguisher_needed_when_types_differ() {
let mut engine = test_engine();
engine.register_entity(
crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
);
engine.register_entity(
crate::EntityDescriptor::new("UserModule", "module").with_attribute("layer", "infra"),
);
engine
.register_template("t", "{name|refer} was modified")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("UserService".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The class UserService was modified.");
}
#[test]
fn reg_preference_order_steers_attribute_choice() {
let mut engine = test_engine().attribute_preference(vec!["size".to_string()]);
engine.register_entity(
crate::EntityDescriptor::new("Foo", "widget")
.with_attribute("color", "red")
.with_attribute("size", "small"),
);
engine.register_entity(
crate::EntityDescriptor::new("Bar", "widget")
.with_attribute("color", "blue")
.with_attribute("size", "large"),
);
engine
.register_template("t", "{name|refer} appeared")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("widget".into()));
ctx.insert("name", Value::String("Foo".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The small widget Foo appeared.");
}
#[test]
fn reg_same_name_different_type_does_not_cross_contaminate() {
let mut engine = test_engine();
engine.register_entity(
crate::EntityDescriptor::new("UserService", "class").with_attribute("layer", "domain"),
);
engine.register_entity(
crate::EntityDescriptor::new("UserService", "trait").with_attribute("scope", "public"),
);
engine
.register_template("t", "{name|refer} was modified")
.unwrap();
let mut session = test_session();
let mut ctx_class = Context::new();
ctx_class.insert("entity_type", Value::String("class".into()));
ctx_class.insert("name", Value::String("UserService".into()));
let r = engine.render(&mut session, "t", &ctx_class).unwrap();
assert!(r.contains("class UserService"), "got: {r}");
assert!(!r.contains("trait"), "got: {r}");
assert_eq!(r, "The class UserService was modified.");
let mut session2 = test_session();
let mut ctx_trait = Context::new();
ctx_trait.insert("entity_type", Value::String("trait".into()));
ctx_trait.insert("name", Value::String("UserService".into()));
let r2 = engine.render(&mut session2, "t", &ctx_trait).unwrap();
assert_eq!(r2, "The trait UserService was modified.");
}
#[test]
fn reg_multiple_attributes_needed() {
let mut engine = test_engine();
engine.register_entity(
crate::EntityDescriptor::new("A", "widget")
.with_attribute("color", "red")
.with_attribute("size", "small"),
);
engine.register_entity(
crate::EntityDescriptor::new("B", "widget")
.with_attribute("color", "red")
.with_attribute("size", "large"),
);
engine.register_entity(
crate::EntityDescriptor::new("C", "widget")
.with_attribute("color", "blue")
.with_attribute("size", "small"),
);
engine = engine.attribute_preference(vec!["color".to_string(), "size".to_string()]);
engine
.register_template("t", "{name|refer} appeared")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("widget".into()));
ctx.insert("name", Value::String("A".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "The red small widget A appeared.");
}
#[test]
fn refer_no_entity_type_falls_back_to_name() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} appeared")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("name", Value::String("something".into()));
let result = engine.render(&mut session, "t", &ctx).unwrap();
assert_eq!(result, "Something appeared");
}
#[test]
fn failed_render_does_not_mutate_discourse() {
let mut engine = test_engine();
engine
.register_template("ok", "{name|refer} was updated")
.unwrap();
engine
.register_template("bad", "{missing_slot} fails here")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let r1 = engine.render(&mut session, "ok", &ctx).unwrap();
assert!(r1.contains("class Foo"), "r1 = {r1}");
let bad_ctx = Context::new();
assert!(engine.render(&mut session, "bad", &bad_ctx).is_err());
let r2 = engine.render(&mut session, "ok", &ctx).unwrap();
assert!(
r2.contains("it") || r2.contains("It"),
"Expected pronoun reference after failed render was rolled back, got: {r2}"
);
}
#[test]
fn round_robin_counter_is_transactional_on_failure() {
let mut engine = test_engine().variation(Variation::RoundRobin);
engine.register_template("ok", "alpha {name}").unwrap();
engine.register_template("ok", "beta {name}").unwrap();
engine.register_template("ok", "gamma {name}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("name", Value::String("x".into()));
let empty = Context::new();
assert!(
engine
.render(&mut session, "ok", &ctx)
.unwrap()
.contains("alpha")
);
assert!(engine.render(&mut session, "ok", &empty).is_err());
assert!(
engine
.render(&mut session, "ok", &ctx)
.unwrap()
.contains("beta")
);
}
#[test]
fn round_robin_actually_rotates() {
let mut engine = test_engine().variation(Variation::RoundRobin);
engine.register_template("t", "alpha").unwrap();
engine.register_template("t", "beta").unwrap();
engine.register_template("t", "gamma").unwrap();
let mut session = test_session();
let ctx = Context::new();
let r1 = engine.render(&mut session, "t", &ctx).unwrap();
let r2 = engine.render(&mut session, "t", &ctx).unwrap();
let r3 = engine.render(&mut session, "t", &ctx).unwrap();
let r4 = engine.render(&mut session, "t", &ctx).unwrap();
assert!(r1.starts_with("alpha"), "r1 = {r1}");
assert!(r2.contains("beta"), "r2 = {r2}");
assert!(r3.contains("gamma"), "r3 = {r3}");
assert!(r4.contains("alpha"), "r4 = {r4}");
}
#[test]
fn fixed_variation_stays_fixed_across_renders() {
let mut engine = test_engine().variation(Variation::Fixed);
engine.register_template("t", "alpha body here").unwrap();
engine.register_template("t", "beta body here").unwrap();
let mut session = test_session();
let ctx = Context::new();
for _ in 0..5 {
let rendered = engine.render(&mut session, "t", &ctx).unwrap();
assert!(
rendered.contains("alpha body here"),
"Fixed should always pick the first-registered template, got: {rendered}"
);
assert!(
!rendered.contains("beta"),
"Fixed must never emit a later-registered alternative, got: {rendered}"
);
}
}
#[test]
fn verb_pipe_simple_past_passive() {
let mut engine = test_engine();
engine.register_template("t", "{action|verb:past}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("rename".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"was renameed"
);
}
#[test]
fn verb_pipe_present_perfect_passive() {
let mut engine = test_engine();
engine
.register_template("t", "{action|verb:present_perfect}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("rename".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"has been renameed"
);
}
#[test]
fn verb_pipe_present_progressive_passive() {
let mut engine = test_engine();
engine
.register_template("t", "{action|verb:present_progressive}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("rename".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"is being renameed"
);
}
#[test]
fn verb_pipe_active_voice_prefix() {
let mut engine = test_engine();
engine
.register_template("t", "{action|verb:active_present_perfect}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("rename".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"has renameed"
);
}
#[test]
fn verb_pipe_conditional() {
let mut engine = test_engine();
engine
.register_template("t", "{action|verb:conditional}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("rename".into()));
assert_eq!(
engine.render(&mut session, "t", &ctx).unwrap(),
"would be renameed"
);
}
#[test]
fn verb_pipe_unknown_spec_is_error() {
let mut engine = test_engine();
engine
.register_template("t", "{action|verb:bogus_form}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("rename".into()));
let result = engine.render(&mut session, "t", &ctx);
assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
}
#[test]
fn verb_pipe_missing_spec_is_error() {
let mut engine = test_engine();
engine.register_template("t", "{action|verb}").unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("rename".into()));
let result = engine.render(&mut session, "t", &ctx);
assert!(matches!(result, Err(ProsaicError::InvalidPipe { .. })));
}
#[test]
fn candidate_scoring_does_not_advance_list_style() {
let mut engine = test_engine().variation(Variation::Seeded(1));
engine
.register_template("t", "alpha uses {items|truncate:1|join}")
.unwrap();
engine
.register_template("t", "beta uses {items|truncate:1|join}")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert(
"items",
Value::List(vec!["a".into(), "b".into(), "c".into()]),
);
let r1 = engine.render(&mut session, "t", &ctx).unwrap();
let r2 = engine.render(&mut session, "t", &ctx).unwrap();
let r3 = engine.render(&mut session, "t", &ctx).unwrap();
let styles: std::collections::HashSet<&str> = [r1.as_str(), r2.as_str(), r3.as_str()]
.into_iter()
.collect();
assert_eq!(
styles.len(),
3,
"Expected three distinct list styles across three renders, got: {r1} / {r2} / {r3}"
);
}
#[test]
fn choose_pipe_exact_match() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("critical".into()));
let out = engine
.render_inline(
&mut session,
"{level|choose: critical=URGENT, warn=WARN, default=INFO}",
&ctx,
)
.unwrap();
assert_eq!(out, "URGENT");
}
#[test]
fn choose_pipe_case_insensitive_match() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("CRITICAL".into()));
let out = engine
.render_inline(
&mut session,
"{level|choose: critical=URGENT, default=INFO}",
&ctx,
)
.unwrap();
assert_eq!(out, "URGENT");
}
#[test]
fn choose_pipe_default_fallback() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("info".into()));
let out = engine
.render_inline(
&mut session,
"{level|choose: critical=URGENT, default=INFO}",
&ctx,
)
.unwrap();
assert_eq!(out, "INFO");
}
#[test]
fn choose_pipe_number_slot() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("count", Value::Number(1));
let out = engine
.render_inline(&mut session, "{count|choose: 1=is, default=are}", &ctx)
.unwrap();
assert_eq!(out, "is");
let mut session2 = test_session();
let mut ctx2 = Context::new();
ctx2.insert("count", Value::Number(5));
let out2 = engine
.render_inline(&mut session2, "{count|choose: 1=is, default=are}", &ctx2)
.unwrap();
assert_eq!(out2, "are");
}
#[test]
fn choose_pipe_chains_with_other_pipes() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("action", Value::String("modify".into()));
let out = engine
.render_inline(
&mut session,
"{action|choose: rename=renamed, modify=modified, default=changed|capitalize}",
&ctx,
)
.unwrap();
assert!(out.contains("Modified"), "got: {out}");
}
#[test]
fn choose_pipe_strict_no_match_no_default_errors() {
let engine = test_engine().strictness(Strictness::Strict);
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("info".into()));
let err = engine
.render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
.unwrap_err();
assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
}
#[test]
fn choose_pipe_lenient_no_match_returns_placeholder() {
let engine = test_engine().strictness(Strictness::Lenient);
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("info".into()));
let out = engine
.render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
.unwrap();
assert!(out.contains("[choose: no match for info]"), "got: {out}");
}
#[test]
fn choose_pipe_silent_no_match_returns_empty() {
let engine = test_engine().strictness(Strictness::Silent);
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("info".into()));
let out = engine
.render_inline(&mut session, "{level|choose: critical=URGENT}", &ctx)
.unwrap();
assert_eq!(out, "");
}
#[test]
fn choose_pipe_missing_arg_errors() {
let engine = test_engine().strictness(Strictness::Strict);
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("info".into()));
let err = engine
.render_inline(&mut session, "{level|choose}", &ctx)
.unwrap_err();
assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
}
#[test]
fn choose_pipe_malformed_arg_errors() {
let engine = test_engine().strictness(Strictness::Strict);
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("level", Value::String("info".into()));
let err = engine
.render_inline(&mut session, "{level|choose: no_equals_here}", &ctx)
.unwrap_err();
assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
}
#[test]
fn plural_pipe_singular_for_one() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("count", Value::Number(1));
let out = engine
.render_inline(&mut session, "{count|plural:service}", &ctx)
.unwrap();
assert!(out.contains("service"));
assert!(!out.contains("services"));
}
#[test]
fn plural_pipe_plural_for_many() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("count", Value::Number(5));
let out = engine
.render_inline(&mut session, "{count|plural:service}", &ctx)
.unwrap();
assert!(out.contains("services"));
}
#[test]
fn plural_pipe_plural_for_zero() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("count", Value::Number(0));
let out = engine
.render_inline(&mut session, "{count|plural:service}", &ctx)
.unwrap();
assert!(out.contains("services"));
}
#[test]
fn plural_pipe_requires_noun_arg() {
let engine = test_engine().strictness(Strictness::Strict);
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("count", Value::Number(3));
let err = engine
.render_inline(&mut session, "{count|plural}", &ctx)
.unwrap_err();
assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
}
#[test]
fn plural_pipe_requires_numeric_value() {
let engine = test_engine().strictness(Strictness::Strict);
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("word", Value::String("hello".into()));
let err = engine
.render_inline(&mut session, "{word|plural:service}", &ctx)
.unwrap_err();
assert!(matches!(err, ProsaicError::InvalidPipe { .. }));
}
#[test]
fn pronoun_realization_routes_through_language_trait() {
let mut engine = test_engine();
engine
.register_template("t", "{name|refer} was modified")
.unwrap();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("entity_type", Value::String("class".into()));
ctx.insert("name", Value::String("Foo".into()));
let r1 = engine.render(&mut session, "t", &ctx).unwrap();
assert!(r1.contains("The class Foo"), "got: {r1}");
let r2 = engine.render(&mut session, "t", &ctx).unwrap();
assert!(
r2.to_lowercase().contains("it was") || r2.to_lowercase().contains("it "),
"got: {r2}"
);
}
#[test]
fn plural_pipe_and_pluralize_pipe_coexist() {
let engine = test_engine();
let mut session = test_session();
let mut ctx = Context::new();
ctx.insert("count", Value::Number(2));
let plural_out = engine
.render_inline(&mut session, "{count|plural:item}", &ctx)
.unwrap();
session.reset();
let pluralize_out = engine
.render_inline(&mut session, "{count|pluralize:item}", &ctx)
.unwrap();
assert_eq!(plural_out, pluralize_out);
}
}
#[cfg(test)]
mod render_batch_with_relations_tests {
use super::*;
use crate::language::{Conjunction, Language, Person, Tense};
use crate::rst::RstRelation;
struct SimpleLang;
impl Language for SimpleLang {
fn pluralize(&self, word: &str, count: usize) -> String {
if count == 1 {
word.to_string()
} else {
format!("{word}s")
}
}
fn singularize(&self, word: &str) -> String {
word.strip_suffix('s').unwrap_or(word).to_string()
}
fn article(&self, _word: &str) -> &str {
"the"
}
fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
verb.to_string()
}
fn past_participle(&self, verb: &str) -> String {
format!("{verb}ed")
}
fn present_participle(&self, verb: &str) -> String {
format!("{verb}ing")
}
fn join_list(&self, items: &[&str], _c: Conjunction) -> String {
items.join(", ")
}
fn ordinal(&self, n: usize) -> String {
format!("{n}th")
}
fn number_to_words(&self, n: usize) -> String {
n.to_string()
}
}
fn make_engine() -> Engine {
Engine::new(SimpleLang)
.strictness(Strictness::Strict)
.variation(Variation::Fixed)
}
fn ctx_with_name(name: &str) -> Context {
let mut c = Context::new();
c.insert("name", Value::String(name.into()));
c
}
#[test]
fn render_batch_with_relations_inserts_marker() {
let mut engine = make_engine();
engine
.register_template("t", "The class {name} was modified")
.unwrap();
let mut s = Session::new();
let ctx = ctx_with_name("Foo");
let events = vec![
("t", ctx.clone(), None),
("t", ctx, Some(RstRelation::Elaboration)),
];
let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
assert!(out.contains("Furthermore, "), "got: {out}");
}
#[test]
fn render_batch_with_relations_lowercases_determiner_after_marker() {
let mut engine = make_engine();
engine
.register_template("t", "The class {name} was modified")
.unwrap();
let mut s = Session::new();
let ctx = ctx_with_name("Foo");
let events = vec![
("t", ctx.clone(), None),
("t", ctx, Some(RstRelation::Contrast)),
];
let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
assert!(out.contains("However, the class"), "got: {out}");
}
#[test]
fn render_batch_with_all_none_delegates_to_render_batch() {
let mut engine = make_engine();
engine
.register_template("t", "{name} was modified")
.unwrap();
let mut s = Session::new();
let ctx = ctx_with_name("Foo");
let triples = vec![("t", ctx.clone(), None), ("t", ctx.clone(), None)];
let pairs: Vec<_> = triples.iter().map(|(k, c, _)| (*k, c.clone())).collect();
let mut s2 = Session::new();
let from_triples = engine
.render_batch_with_relations(&mut s, &triples)
.unwrap();
let from_pairs = engine.render_batch(&mut s2, &pairs).unwrap();
assert_eq!(from_triples, from_pairs);
}
#[test]
fn render_batch_with_relations_empty_is_empty_string() {
let engine = make_engine();
let mut s = Session::new();
let events: Vec<(&str, Context, Option<RstRelation>)> = vec![];
let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
assert_eq!(out, "");
}
#[test]
fn rst_marker_strips_auto_connective_to_avoid_double_prepend() {
let mut engine = make_engine();
engine
.register_template("t", "The class {name} was modified")
.unwrap();
let mut s = Session::new();
let events = vec![
("t", ctx_with_name("Foo"), None),
("t", ctx_with_name("Bar"), Some(RstRelation::Elaboration)),
];
let out = engine.render_batch_with_relations(&mut s, &events).unwrap();
assert!(out.contains("Furthermore, "), "got: {out}");
assert!(
!out.contains("Furthermore, Similarly,") && !out.contains("Furthermore, Likewise,"),
"RST marker should suppress / strip auto-connective; got: {out}"
);
}
fn ctx_with_entity(name: &str) -> Context {
let mut c = Context::new();
c.insert("entity_type", Value::String("class".into()));
c.insert("name", Value::String(name.into()));
c
}
#[test]
fn rst_render_leaves_session_free_of_unemitted_connective() {
let mut engine = make_engine();
engine
.register_template("t", "The class {name} was modified")
.unwrap();
let mut s = Session::new();
let events = vec![
("t", ctx_with_entity("Foo"), None),
("t", ctx_with_entity("Bar"), Some(RstRelation::Elaboration)),
];
let _ = engine.render_batch_with_relations(&mut s, &events).unwrap();
let exp = engine
.render_explained(&mut s, "t", ctx_with_entity("Baz"))
.unwrap();
assert_eq!(
exp.connective,
Some("Similarly,"),
"RST render leaked connective history; got connective={:?}, output={}",
exp.connective,
exp.output
);
}
#[test]
fn rst_render_does_not_record_unemitted_connective_words() {
let mut engine = make_engine().variation(Variation::Seeded(42));
engine
.register_template("t", "The class {name} was modified")
.unwrap();
let mut s = Session::new();
let events = vec![
("t", ctx_with_entity("Foo"), None),
("t", ctx_with_entity("Bar"), Some(RstRelation::Elaboration)),
];
let _ = engine.render_batch_with_relations(&mut s, &events).unwrap();
assert_eq!(
s.discourse.word_frequency("similarly"),
0.0,
"RST render leaked 'similarly' into word history"
);
}
}
#[cfg(test)]
mod engine_thread_safety {
use super::Engine;
const _: fn() = || {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Engine>();
};
}
#[cfg(feature = "serde")]
fn apply_manifest_engine_settings(
engine: &mut Engine,
settings: &manifest_loader::ManifestEngineSettings,
) -> Result<(), ProsaicError> {
engine.strictness = match settings.strictness.as_str() {
"" | "strict" => Strictness::Strict,
"lenient" => Strictness::Lenient,
"silent" => Strictness::Silent,
other => {
return Err(ProsaicError::TemplateParseError {
template: "(manifest)".to_string(),
position: 0,
reason: format!("unknown strictness `{other}`"),
});
}
};
engine.variation = match settings.variation.as_str() {
"" | "fixed" => Variation::Fixed,
"round_robin" | "round-robin" => Variation::RoundRobin,
"random" => Variation::Random,
other => {
return Err(ProsaicError::TemplateParseError {
template: "(manifest)".to_string(),
position: 0,
reason: format!("unknown variation `{other}`"),
});
}
};
#[cfg(feature = "polish")]
{
engine.smart_quotes = settings.smart_quotes;
engine.max_sentence_length = if settings.max_sentence_length > 0 {
Some(settings.max_sentence_length)
} else {
None
};
}
#[cfg(not(feature = "polish"))]
if settings.smart_quotes || settings.max_sentence_length > 0 {
return Err(ProsaicError::TemplateParseError {
template: "(manifest)".to_string(),
position: 0,
reason: "manifest uses polish settings, but prosaic-core was built without the `polish` feature".to_string(),
});
}
if settings.faithfulness_min < 0.0 {
return Err(ProsaicError::TemplateParseError {
template: "(manifest)".to_string(),
position: 0,
reason: format!(
"faithfulness_min must be non-negative, got {}",
settings.faithfulness_min
),
});
}
engine.faithfulness_threshold = if settings.faithfulness_min > 0.0 {
Some(settings.faithfulness_min as f32)
} else {
None
};
if let Some(thresholds) = &settings.salience_thresholds {
engine.salience_thresholds = SalienceThresholds {
low_max: thresholds.low_max,
high_min: thresholds.high_min,
};
}
engine.style_preference = settings.style.clone();
Ok(())
}
#[cfg(feature = "serde")]
mod manifest_loader {
#[cfg(not(feature = "std"))]
use alloc::string::{String, ToString};
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use serde::Deserialize;
#[derive(Deserialize)]
pub struct ManifestBundle {
pub schema_version: u32,
#[allow(dead_code)]
pub name: String,
#[allow(dead_code)]
pub version: String,
pub language: String,
#[allow(dead_code)]
#[serde(default)]
pub engine: ManifestEngineSettings,
pub templates: Vec<ManifestTemplate>,
pub partials: Vec<ManifestPartial>,
}
#[allow(dead_code)]
#[derive(Deserialize, Default)]
pub struct ManifestEngineSettings {
#[serde(default)]
pub strictness: String,
#[serde(default)]
pub variation: String,
#[serde(default)]
pub smart_quotes: bool,
#[serde(default)]
pub max_sentence_length: usize,
#[serde(default)]
pub faithfulness_min: f64,
#[serde(default)]
pub salience_thresholds: Option<ManifestSalienceThresholds>,
#[serde(default)]
pub style: Option<String>,
}
#[derive(Deserialize)]
pub struct ManifestSalienceThresholds {
pub low_max: i64,
pub high_min: i64,
}
#[derive(Deserialize)]
pub struct ManifestTemplate {
pub key: String,
#[serde(default)]
#[allow(dead_code)]
pub description: String,
pub variants: Vec<ManifestVariant>,
}
#[derive(Deserialize)]
pub struct ManifestVariant {
#[serde(default = "default_salience")]
pub salience: String,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub style: Option<String>,
pub body: String,
}
fn default_salience() -> String {
"medium".to_string()
}
#[derive(Deserialize)]
pub struct ManifestPartial {
pub name: String,
pub body: String,
}
}
#[cfg(test)]
mod register_template_type_tests {
use super::*;
use crate::language::{Conjunction, Language, Person, Tense};
struct TestLang;
impl Language for TestLang {
fn pluralize(&self, word: &str, count: usize) -> String {
if count == 1 {
word.to_string()
} else {
format!("{word}s")
}
}
fn singularize(&self, word: &str) -> String {
word.strip_suffix('s').unwrap_or(word).to_string()
}
fn article(&self, _word: &str) -> &str {
"a"
}
fn conjugate(&self, verb: &str, _t: Tense, _p: Person) -> String {
verb.to_string()
}
fn past_participle(&self, verb: &str) -> String {
format!("{verb}ed")
}
fn present_participle(&self, verb: &str) -> String {
format!("{verb}ing")
}
fn join_list(&self, items: &[&str], conj: Conjunction) -> String {
let c = match conj {
Conjunction::And => "and",
Conjunction::Or => "or",
};
items.join(&format!(" {c} "))
}
fn ordinal(&self, n: usize) -> String {
format!("{n}th")
}
fn number_to_words(&self, n: usize) -> String {
format!("<{n}>")
}
}
#[test]
fn register_template_rejects_chain_mismatch() {
let mut engine = Engine::new(TestLang);
let err = engine
.register_template("bad", "{x|capitalize|pluralize}")
.unwrap_err();
match err {
ProsaicError::TemplateParseError { reason, .. } => {
assert!(
reason.contains("chain mismatch"),
"unexpected reason: {reason}"
);
}
other => panic!("expected TemplateParseError, got {other:?}"),
}
}
#[test]
fn register_template_rejects_multi_mention_conflict() {
let mut engine = Engine::new(TestLang);
let err = engine
.register_template("bad", "{x|pluralize:item} and {x|join}")
.unwrap_err();
assert!(matches!(err, ProsaicError::TemplateParseError { .. }));
}
#[test]
fn register_template_accepts_valid_template() {
let mut engine = Engine::new(TestLang);
engine
.register_template("good", "The {name} has {count|pluralize:item}")
.unwrap();
}
#[test]
fn register_template_accepts_bare_slots() {
let mut engine = Engine::new(TestLang);
engine.register_template("bare", "Hello {name}").unwrap();
}
}