Skip to main content

prosaic_core/
builder.rs

1#[cfg(not(feature = "std"))]
2use alloc::format;
3#[cfg(not(feature = "std"))]
4use alloc::string::{String, ToString};
5#[cfg(not(feature = "std"))]
6use alloc::vec::Vec;
7
8use crate::engine::Engine;
9use crate::error::ProsaicError;
10use crate::language::{Conjunction, Person, Tense, VerbForm, Voice};
11
12/// A subject in a sentence, optionally with an entity type prefix.
13#[derive(Debug, Clone)]
14pub struct Subject {
15    entity_type: Option<String>,
16    name: String,
17}
18
19/// Create a subject with an entity type prefix.
20/// e.g., `subject("class", "Foo")` renders as "the class Foo".
21pub fn subject(entity_type: &str, name: &str) -> Subject {
22    Subject {
23        entity_type: Some(entity_type.to_string()),
24        name: name.to_string(),
25    }
26}
27
28/// Create a plain subject without an entity type.
29pub fn named(name: &str) -> Subject {
30    Subject {
31        entity_type: None,
32        name: name.to_string(),
33    }
34}
35
36/// A subordinate clause attached to a sentence.
37#[derive(Debug, Clone)]
38pub struct Clause {
39    intro: String,
40    amount: Option<usize>,
41    noun: Option<String>,
42    items: Vec<String>,
43    truncate_at: Option<usize>,
44    conjunction: Conjunction,
45}
46
47impl Clause {
48    /// Create a clause introduced by "which {verb}" (e.g., "which impacts").
49    pub fn which(verb: &str) -> Self {
50        Self {
51            intro: format!("which {verb}"),
52            amount: None,
53            noun: None,
54            items: Vec::new(),
55            truncate_at: None,
56            conjunction: Conjunction::And,
57        }
58    }
59
60    /// Create a clause with a custom introduction.
61    pub fn with_intro(intro: &str) -> Self {
62        Self {
63            intro: intro.to_string(),
64            amount: None,
65            noun: None,
66            items: Vec::new(),
67            truncate_at: None,
68            conjunction: Conjunction::And,
69        }
70    }
71
72    /// Set the count for this clause (e.g., "6 direct consumers").
73    pub fn amount(mut self, n: usize) -> Self {
74        self.amount = Some(n);
75        self
76    }
77
78    /// Set the noun that gets pluralized based on the amount.
79    pub fn noun(mut self, noun: &str) -> Self {
80        self.noun = Some(noun.to_string());
81        self
82    }
83
84    /// Set the list of items to enumerate.
85    pub fn list(mut self, items: &[&str]) -> Self {
86        self.items = items.iter().map(|s| s.to_string()).collect();
87        self
88    }
89
90    /// Truncate the list to show at most `n` items, with "and N more" suffix.
91    pub fn truncate(mut self, n: usize) -> Self {
92        self.truncate_at = Some(n);
93        self
94    }
95
96    /// Set the conjunction for joining list items (default: And).
97    pub fn conjunction(mut self, conjunction: Conjunction) -> Self {
98        self.conjunction = conjunction;
99        self
100    }
101
102    fn render(&self, engine: &Engine) -> Result<String, ProsaicError> {
103        let lang = engine.language();
104        let mut parts: Vec<String> = Vec::new();
105
106        if !self.intro.is_empty() {
107            parts.push(self.intro.clone());
108        }
109
110        if let Some(amount) = self.amount {
111            parts.push(amount.to_string());
112
113            if let Some(ref noun) = self.noun {
114                parts.push(lang.pluralize(noun, amount));
115            }
116        } else if let Some(ref noun) = self.noun {
117            parts.push(noun.clone());
118        }
119
120        if !self.items.is_empty() {
121            let display_items = self.truncated_items();
122            let refs: Vec<&str> = display_items.iter().map(|s| s.as_str()).collect();
123            let joined = lang.join_list(&refs, self.conjunction);
124            parts.push(format!("[{joined}]"));
125        }
126
127        Ok(parts.join(" "))
128    }
129
130    fn truncated_items(&self) -> Vec<String> {
131        match self.truncate_at {
132            Some(max) if self.items.len() > max => {
133                let remaining = self.items.len() - max;
134                let mut result: Vec<String> = self.items[..max].to_vec();
135                result.push(format!("{remaining} more"));
136                result
137            }
138            _ => self.items.clone(),
139        }
140    }
141}
142
143/// Builder for constructing sentences programmatically.
144///
145/// The verb is specified either by a simple tense (via `.verb(word, Tense)`)
146/// or a full verb form (via `.form(VerbForm)`). The latter unlocks richer
147/// constructions like "has been renamed" (present perfect passive) or
148/// "is being renamed" (present progressive passive).
149#[derive(Debug, Clone)]
150pub struct Sentence {
151    subject: Option<Subject>,
152    verb: Option<String>,
153    form: VerbForm,
154    voice: Voice,
155    person: Person,
156    preposition: Option<String>,
157    object: Option<String>,
158    clauses: Vec<Clause>,
159}
160
161impl Sentence {
162    pub fn new() -> Self {
163        Self {
164            subject: None,
165            verb: None,
166            form: VerbForm::SimplePast,
167            voice: Voice::Passive,
168            person: Person::Third,
169            preposition: None,
170            object: None,
171            clauses: Vec::new(),
172        }
173    }
174
175    /// Set the subject of the sentence.
176    pub fn subject(mut self, subject: Subject) -> Self {
177        self.subject = Some(subject);
178        self
179    }
180
181    /// Set the verb and its simple tense (Past / Present / Future).
182    /// Implies `Aspect::Simple` and `Mood::Indicative`. For richer forms
183    /// use [`Sentence::form`].
184    pub fn verb(mut self, verb: &str, tense: Tense) -> Self {
185        self.verb = Some(verb.to_string());
186        self.form = VerbForm::from(tense);
187        self
188    }
189
190    /// Set the verb form (tense × aspect × mood) directly. Pair with
191    /// a verb set via [`Sentence::verb_word`] or a prior [`Sentence::verb`].
192    pub fn form(mut self, form: VerbForm) -> Self {
193        self.form = form;
194        self
195    }
196
197    /// Set just the verb word, leaving the existing form in place. Useful
198    /// when `form(…)` was used and a base verb is set separately.
199    pub fn verb_word(mut self, verb: &str) -> Self {
200        self.verb = Some(verb.to_string());
201        self
202    }
203
204    /// Set the voice (default: Passive).
205    ///
206    /// - `Voice::Passive`: "The class Foo was renamed to Foobar"
207    /// - `Voice::Active`: "The class Foo renamed Foobar"
208    pub fn voice(mut self, voice: Voice) -> Self {
209        self.voice = voice;
210        self
211    }
212
213    /// Set the grammatical person used to conjugate auxiliaries
214    /// (default: Third).
215    pub fn person(mut self, person: Person) -> Self {
216        self.person = person;
217        self
218    }
219
220    /// Set the preposition connecting the verb to the object (default: "to" for passive).
221    ///
222    /// e.g., `.preposition("into")` → "was converted into Bar"
223    pub fn preposition(mut self, prep: &str) -> Self {
224        self.preposition = Some(prep.to_string());
225        self
226    }
227
228    /// Set the direct object.
229    pub fn object(mut self, object: &str) -> Self {
230        self.object = Some(object.to_string());
231        self
232    }
233
234    /// Append a subordinate clause.
235    pub fn clause(mut self, clause: Clause) -> Self {
236        self.clauses.push(clause);
237        self
238    }
239
240    /// Render the sentence using the given engine's language.
241    pub fn render(&self, engine: &Engine) -> Result<String, ProsaicError> {
242        let lang = engine.language();
243        let mut parts: Vec<String> = Vec::new();
244
245        // Subject
246        if let Some(ref subject) = self.subject {
247            match &subject.entity_type {
248                Some(et) => parts.push(format!("The {} {}", et, subject.name)),
249                None => parts.push(subject.name.clone()),
250            }
251        }
252
253        // Verb phrase — fully composed through the language's verb_phrase hook.
254        if let Some(ref verb) = self.verb {
255            let phrase = lang.verb_phrase(verb, self.form, self.voice, self.person);
256            parts.push(phrase);
257        }
258
259        // Object (with preposition)
260        if let Some(ref object) = self.object {
261            match &self.preposition {
262                Some(prep) => parts.push(format!("{prep} {object}")),
263                None => {
264                    // Default preposition: "to" for passive voice, none for active
265                    if self.voice == Voice::Passive {
266                        parts.push(format!("to {object}"));
267                    } else {
268                        parts.push(object.clone());
269                    }
270                }
271            }
272        }
273
274        let mut sentence = parts.join(" ");
275
276        // Clauses
277        for clause in &self.clauses {
278            let rendered = clause.render(engine)?;
279            if !rendered.is_empty() {
280                sentence.push(' ');
281                sentence.push_str(&rendered);
282            }
283        }
284
285        Ok(sentence)
286    }
287}
288
289impl Default for Sentence {
290    fn default() -> Self {
291        Self::new()
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::language::{Conjunction, Language, Person, Tense};
299
300    struct TestLang;
301
302    impl Language for TestLang {
303        fn pluralize(&self, word: &str, count: usize) -> String {
304            if count == 1 {
305                word.to_string()
306            } else {
307                format!("{word}s")
308            }
309        }
310        fn singularize(&self, word: &str) -> String {
311            word.strip_suffix('s').unwrap_or(word).to_string()
312        }
313        fn article(&self, word: &str) -> &str {
314            if word.starts_with(|c: char| "aeiou".contains(c.to_ascii_lowercase())) {
315                "an"
316            } else {
317                "a"
318            }
319        }
320        fn conjugate(&self, verb: &str, tense: Tense, _person: Person) -> String {
321            match (verb, tense) {
322                ("be", Tense::Past) => "was".to_string(),
323                ("be", Tense::Present) => "is".to_string(),
324                ("have", Tense::Present) => "has".to_string(),
325                (_, Tense::Past) => format!("{verb}ed"),
326                (_, Tense::Present) => verb.to_string(),
327                (_, Tense::Future) => format!("will {verb}"),
328            }
329        }
330        fn past_participle(&self, verb: &str) -> String {
331            format!("{verb}ed")
332        }
333        fn present_participle(&self, verb: &str) -> String {
334            format!("{verb}ing")
335        }
336        fn join_list(&self, items: &[&str], conjunction: Conjunction) -> String {
337            let conj = match conjunction {
338                Conjunction::And => "and",
339                Conjunction::Or => "or",
340            };
341            match items.len() {
342                0 => String::new(),
343                1 => items[0].to_string(),
344                2 => format!("{} {conj} {}", items[0], items[1]),
345                _ => {
346                    let head = items[..items.len() - 1].join(", ");
347                    format!("{head}, {conj} {}", items[items.len() - 1])
348                }
349            }
350        }
351        fn ordinal(&self, n: usize) -> String {
352            format!("{n}th")
353        }
354        fn number_to_words(&self, n: usize) -> String {
355            format!("<{n}>")
356        }
357    }
358
359    fn test_engine() -> Engine {
360        Engine::new(TestLang)
361    }
362
363    #[test]
364    fn passive_voice_past_tense() {
365        let engine = test_engine();
366        let s = Sentence::new()
367            .subject(subject("class", "Foo"))
368            .verb("rename", Tense::Past)
369            .object("Foobar")
370            .render(&engine)
371            .unwrap();
372
373        assert_eq!(s, "The class Foo was renameed to Foobar");
374    }
375
376    #[test]
377    fn active_voice_past_tense() {
378        let engine = test_engine();
379        let s = Sentence::new()
380            .subject(subject("class", "Foo"))
381            .verb("rename", Tense::Past)
382            .object("Foobar")
383            .voice(Voice::Active)
384            .render(&engine)
385            .unwrap();
386
387        assert_eq!(s, "The class Foo renameed Foobar");
388    }
389
390    #[test]
391    fn passive_voice_with_clause() {
392        let engine = test_engine();
393        let s = Sentence::new()
394            .subject(subject("class", "Foo"))
395            .verb("rename", Tense::Past)
396            .object("Foobar")
397            .clause(Clause::which("impacts").amount(6).noun("direct consumer"))
398            .render(&engine)
399            .unwrap();
400
401        assert_eq!(
402            s,
403            "The class Foo was renameed to Foobar which impacts 6 direct consumers"
404        );
405    }
406
407    #[test]
408    fn passive_voice_with_clause_and_list() {
409        let engine = test_engine();
410        let s = Sentence::new()
411            .subject(subject("class", "Foo"))
412            .verb("rename", Tense::Past)
413            .object("Foobar")
414            .clause(
415                Clause::which("impacts")
416                    .amount(6)
417                    .noun("direct consumer")
418                    .list(&["Baz", "Qux", "Quux", "Corge", "Grault", "Garply"])
419                    .truncate(3),
420            )
421            .render(&engine)
422            .unwrap();
423
424        assert_eq!(
425            s,
426            "The class Foo was renameed to Foobar which impacts 6 direct consumers \
427             [Baz, Qux, Quux, and 3 more]"
428        );
429    }
430
431    #[test]
432    fn passive_voice_no_object() {
433        let engine = test_engine();
434        let s = Sentence::new()
435            .subject(named("UserService"))
436            .verb("modify", Tense::Past)
437            .render(&engine)
438            .unwrap();
439
440        assert_eq!(s, "UserService was modifyed");
441    }
442
443    #[test]
444    fn active_voice_no_object() {
445        let engine = test_engine();
446        let s = Sentence::new()
447            .subject(named("UserService"))
448            .verb("modify", Tense::Past)
449            .voice(Voice::Active)
450            .render(&engine)
451            .unwrap();
452
453        assert_eq!(s, "UserService modifyed");
454    }
455
456    #[test]
457    fn custom_preposition() {
458        let engine = test_engine();
459        let s = Sentence::new()
460            .subject(subject("class", "Foo"))
461            .verb("convert", Tense::Past)
462            .preposition("into")
463            .object("Bar")
464            .render(&engine)
465            .unwrap();
466
467        assert_eq!(s, "The class Foo was converted into Bar");
468    }
469
470    #[test]
471    fn passive_present_tense() {
472        let engine = test_engine();
473        let s = Sentence::new()
474            .subject(subject("module", "Core"))
475            .verb("export", Tense::Present)
476            .clause(Clause::with_intro("").amount(5).noun("component"))
477            .render(&engine)
478            .unwrap();
479
480        assert_eq!(s, "The module Core is exported 5 components");
481    }
482
483    #[test]
484    fn passive_future_tense() {
485        let engine = test_engine();
486        let s = Sentence::new()
487            .subject(subject("interface", "Foo"))
488            .verb("deprecate", Tense::Future)
489            .render(&engine)
490            .unwrap();
491
492        assert_eq!(s, "The interface Foo will be deprecateed");
493    }
494
495    #[test]
496    fn clause_no_truncation_needed() {
497        let engine = test_engine();
498        let s = Sentence::new()
499            .subject(subject("method", "getData"))
500            .verb("delete", Tense::Past)
501            .clause(
502                Clause::which("impacts")
503                    .amount(2)
504                    .noun("caller")
505                    .list(&["ComponentA", "ComponentB"]),
506            )
507            .render(&engine)
508            .unwrap();
509
510        assert_eq!(
511            s,
512            "The method getData was deleteed which impacts 2 callers [ComponentA and ComponentB]"
513        );
514    }
515}