Skip to main content

tuitbot_core/config/
enrichment.rs

1//! Progressive enrichment stages and profile completeness tracking.
2
3use super::Config;
4
5// ---------------------------------------------------------------------------
6// Enrichment stages
7// ---------------------------------------------------------------------------
8
9/// An enrichment stage that groups related configuration fields.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum EnrichmentStage {
12    /// Brand voice, reply style, content style — shapes every LLM output.
13    Voice,
14    /// Opinions, experiences, content pillars — makes content authentic.
15    Persona,
16    /// Target accounts, competitor keywords — focuses discovery.
17    Targeting,
18}
19
20impl EnrichmentStage {
21    /// Human-readable label for display.
22    pub fn label(self) -> &'static str {
23        match self {
24            Self::Voice => "Voice",
25            Self::Persona => "Persona",
26            Self::Targeting => "Targeting",
27        }
28    }
29
30    /// Short description of what this stage unlocks.
31    pub fn description(self) -> &'static str {
32        match self {
33            Self::Voice => "shapes every LLM-generated reply and tweet",
34            Self::Persona => "makes content authentic with opinions and experiences",
35            Self::Targeting => "focuses discovery on specific accounts and competitors",
36        }
37    }
38
39    /// All stages in recommended order.
40    pub fn all() -> &'static [EnrichmentStage] {
41        &[Self::Voice, Self::Persona, Self::Targeting]
42    }
43}
44
45impl std::fmt::Display for EnrichmentStage {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "{}", self.label())
48    }
49}
50
51// ---------------------------------------------------------------------------
52// Profile completeness
53// ---------------------------------------------------------------------------
54
55/// Snapshot of profile completeness across all enrichment stages.
56pub struct ProfileCompleteness {
57    /// Each stage paired with its completion status.
58    pub stages: Vec<(EnrichmentStage, bool)>,
59}
60
61impl ProfileCompleteness {
62    /// Number of completed stages.
63    pub fn completed_count(&self) -> usize {
64        self.stages.iter().filter(|(_, done)| *done).count()
65    }
66
67    /// Total number of stages.
68    pub fn total_count(&self) -> usize {
69        self.stages.len()
70    }
71
72    /// Whether all enrichment stages are complete.
73    pub fn is_fully_enriched(&self) -> bool {
74        self.stages.iter().all(|(_, done)| *done)
75    }
76
77    /// The next incomplete stage, if any.
78    pub fn next_incomplete(&self) -> Option<EnrichmentStage> {
79        self.stages
80            .iter()
81            .find(|(_, done)| !*done)
82            .map(|(stage, _)| *stage)
83    }
84
85    /// One-line summary like "Voice OK  Persona --  Targeting OK".
86    pub fn one_line_summary(&self) -> String {
87        self.stages
88            .iter()
89            .map(|(stage, done)| {
90                let status = if *done { "OK" } else { "--" };
91                format!("{} {}", stage.label(), status)
92            })
93            .collect::<Vec<_>>()
94            .join("  ")
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Config impl
100// ---------------------------------------------------------------------------
101
102impl Config {
103    /// Compute profile completeness across all enrichment stages.
104    pub fn profile_completeness(&self) -> ProfileCompleteness {
105        let opt_non_empty =
106            |opt: &Option<String>| opt.as_ref().is_some_and(|v| !v.trim().is_empty());
107
108        let voice = opt_non_empty(&self.business.brand_voice)
109            || opt_non_empty(&self.business.reply_style)
110            || opt_non_empty(&self.business.content_style);
111
112        let persona = !self.business.persona_opinions.is_empty()
113            || !self.business.persona_experiences.is_empty()
114            || !self.business.content_pillars.is_empty();
115
116        let targeting =
117            !self.targets.accounts.is_empty() || !self.business.competitor_keywords.is_empty();
118
119        ProfileCompleteness {
120            stages: vec![
121                (EnrichmentStage::Voice, voice),
122                (EnrichmentStage::Persona, persona),
123                (EnrichmentStage::Targeting, targeting),
124            ],
125        }
126    }
127}
128
129// ---------------------------------------------------------------------------
130// Tests
131// ---------------------------------------------------------------------------
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn empty_config() -> Config {
138        Config::default()
139    }
140
141    #[test]
142    fn all_empty_has_zero_completeness() {
143        let config = empty_config();
144        let pc = config.profile_completeness();
145        assert_eq!(pc.completed_count(), 0);
146        assert_eq!(pc.total_count(), 3);
147        assert!(!pc.is_fully_enriched());
148    }
149
150    #[test]
151    fn voice_stage_complete_when_brand_voice_set() {
152        let mut config = empty_config();
153        config.business.brand_voice = Some("witty and concise".to_string());
154        let pc = config.profile_completeness();
155        assert_eq!(pc.completed_count(), 1);
156        assert!(pc.stages[0].1); // Voice = true
157    }
158
159    #[test]
160    fn voice_stage_complete_when_reply_style_set() {
161        let mut config = empty_config();
162        config.business.reply_style = Some("helpful".to_string());
163        let pc = config.profile_completeness();
164        assert!(pc.stages[0].1);
165    }
166
167    #[test]
168    fn voice_stage_complete_when_content_style_set() {
169        let mut config = empty_config();
170        config.business.content_style = Some("educational".to_string());
171        let pc = config.profile_completeness();
172        assert!(pc.stages[0].1);
173    }
174
175    #[test]
176    fn persona_stage_complete_when_opinions_set() {
177        let mut config = empty_config();
178        config.business.persona_opinions = vec!["types are good".to_string()];
179        let pc = config.profile_completeness();
180        assert!(pc.stages[1].1); // Persona = true
181    }
182
183    #[test]
184    fn targeting_stage_complete_when_accounts_set() {
185        let mut config = empty_config();
186        config.targets.accounts = vec!["elonmusk".to_string()];
187        let pc = config.profile_completeness();
188        assert!(pc.stages[2].1); // Targeting = true
189    }
190
191    #[test]
192    fn targeting_stage_complete_when_competitor_keywords_set() {
193        let mut config = empty_config();
194        config.business.competitor_keywords = vec!["rival_tool".to_string()];
195        let pc = config.profile_completeness();
196        assert!(pc.stages[2].1);
197    }
198
199    #[test]
200    fn empty_string_voice_not_counted() {
201        let mut config = empty_config();
202        config.business.brand_voice = Some("".to_string());
203        let pc = config.profile_completeness();
204        assert!(!pc.stages[0].1);
205    }
206
207    #[test]
208    fn whitespace_only_voice_not_counted() {
209        let mut config = empty_config();
210        config.business.brand_voice = Some("   ".to_string());
211        let pc = config.profile_completeness();
212        assert!(!pc.stages[0].1);
213    }
214
215    #[test]
216    fn fully_enriched_config() {
217        let mut config = empty_config();
218        config.business.brand_voice = Some("witty".to_string());
219        config.business.persona_opinions = vec!["opinion".to_string()];
220        config.targets.accounts = vec!["target".to_string()];
221        let pc = config.profile_completeness();
222        assert_eq!(pc.completed_count(), 3);
223        assert!(pc.is_fully_enriched());
224        assert!(pc.next_incomplete().is_none());
225    }
226
227    #[test]
228    fn next_incomplete_returns_first_missing() {
229        let mut config = empty_config();
230        config.business.brand_voice = Some("witty".to_string());
231        // Persona is missing, targeting is missing
232        let pc = config.profile_completeness();
233        assert_eq!(pc.next_incomplete(), Some(EnrichmentStage::Persona));
234    }
235
236    #[test]
237    fn one_line_summary_format() {
238        let mut config = empty_config();
239        config.business.brand_voice = Some("witty".to_string());
240        config.targets.accounts = vec!["target".to_string()];
241        let pc = config.profile_completeness();
242        assert_eq!(pc.one_line_summary(), "Voice OK  Persona --  Targeting OK");
243    }
244
245    #[test]
246    fn enrichment_stage_all_returns_three() {
247        assert_eq!(EnrichmentStage::all().len(), 3);
248    }
249
250    #[test]
251    fn enrichment_stage_display() {
252        assert_eq!(format!("{}", EnrichmentStage::Voice), "Voice");
253        assert_eq!(format!("{}", EnrichmentStage::Persona), "Persona");
254        assert_eq!(format!("{}", EnrichmentStage::Targeting), "Targeting");
255    }
256
257    // -----------------------------------------------------------------------
258    // Additional enrichment coverage tests
259    // -----------------------------------------------------------------------
260
261    #[test]
262    fn enrichment_stage_labels() {
263        assert_eq!(EnrichmentStage::Voice.label(), "Voice");
264        assert_eq!(EnrichmentStage::Persona.label(), "Persona");
265        assert_eq!(EnrichmentStage::Targeting.label(), "Targeting");
266    }
267
268    #[test]
269    fn enrichment_stage_descriptions_non_empty() {
270        for stage in EnrichmentStage::all() {
271            assert!(!stage.description().is_empty());
272        }
273    }
274
275    #[test]
276    fn enrichment_stage_all_order() {
277        let all = EnrichmentStage::all();
278        assert_eq!(all[0], EnrichmentStage::Voice);
279        assert_eq!(all[1], EnrichmentStage::Persona);
280        assert_eq!(all[2], EnrichmentStage::Targeting);
281    }
282
283    #[test]
284    fn enrichment_stage_debug_and_clone() {
285        let stage = EnrichmentStage::Voice;
286        let cloned = stage;
287        assert_eq!(stage, cloned);
288        let debug = format!("{:?}", stage);
289        assert!(debug.contains("Voice"));
290    }
291
292    #[test]
293    fn enrichment_stage_copy_trait() {
294        let a = EnrichmentStage::Persona;
295        let b = a; // Copy
296        assert_eq!(a, b);
297    }
298
299    #[test]
300    fn profile_completeness_total_count() {
301        let config = empty_config();
302        let pc = config.profile_completeness();
303        assert_eq!(pc.total_count(), 3);
304    }
305
306    #[test]
307    fn persona_stage_complete_when_experiences_set() {
308        let mut config = empty_config();
309        config.business.persona_experiences = vec!["Built CLI tools".to_string()];
310        let pc = config.profile_completeness();
311        assert!(pc.stages[1].1);
312    }
313
314    #[test]
315    fn persona_stage_complete_when_pillars_set() {
316        let mut config = empty_config();
317        config.business.content_pillars = vec!["DevOps".to_string()];
318        let pc = config.profile_completeness();
319        assert!(pc.stages[1].1);
320    }
321
322    #[test]
323    fn next_incomplete_skips_completed() {
324        let mut config = empty_config();
325        config.business.brand_voice = Some("witty".to_string());
326        config.business.persona_opinions = vec!["types are good".to_string()];
327        // Voice and Persona complete, Targeting missing
328        let pc = config.profile_completeness();
329        assert_eq!(pc.next_incomplete(), Some(EnrichmentStage::Targeting));
330    }
331
332    #[test]
333    fn one_line_summary_all_incomplete() {
334        let config = empty_config();
335        let pc = config.profile_completeness();
336        assert_eq!(pc.one_line_summary(), "Voice --  Persona --  Targeting --");
337    }
338
339    #[test]
340    fn one_line_summary_all_complete() {
341        let mut config = empty_config();
342        config.business.brand_voice = Some("witty".to_string());
343        config.business.persona_opinions = vec!["opinion".to_string()];
344        config.targets.accounts = vec!["target".to_string()];
345        let pc = config.profile_completeness();
346        assert_eq!(pc.one_line_summary(), "Voice OK  Persona OK  Targeting OK");
347    }
348
349    #[test]
350    fn completed_count_partial() {
351        let mut config = empty_config();
352        config.business.brand_voice = Some("witty".to_string());
353        let pc = config.profile_completeness();
354        assert_eq!(pc.completed_count(), 1);
355        assert!(!pc.is_fully_enriched());
356    }
357
358    #[test]
359    fn voice_with_only_content_style() {
360        let mut config = empty_config();
361        config.business.content_style = Some("Technical".to_string());
362        let pc = config.profile_completeness();
363        assert!(pc.stages[0].1);
364        assert_eq!(pc.completed_count(), 1);
365    }
366
367    #[test]
368    fn targeting_both_accounts_and_competitors() {
369        let mut config = empty_config();
370        config.targets.accounts = vec!["target".to_string()];
371        config.business.competitor_keywords = vec!["rival".to_string()];
372        let pc = config.profile_completeness();
373        assert!(pc.stages[2].1);
374    }
375
376    #[test]
377    fn enrichment_stage_description_voice() {
378        let desc = EnrichmentStage::Voice.description();
379        assert!(desc.contains("LLM"));
380    }
381
382    #[test]
383    fn enrichment_stage_description_persona() {
384        let desc = EnrichmentStage::Persona.description();
385        assert!(desc.contains("authentic"));
386    }
387
388    #[test]
389    fn enrichment_stage_description_targeting() {
390        let desc = EnrichmentStage::Targeting.description();
391        assert!(desc.contains("discovery"));
392    }
393}