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}