tuitbot_core/config/
enrichment.rs1use super::Config;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum EnrichmentStage {
12 Voice,
14 Persona,
16 Targeting,
18}
19
20impl EnrichmentStage {
21 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 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 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
51pub struct ProfileCompleteness {
57 pub stages: Vec<(EnrichmentStage, bool)>,
59}
60
61impl ProfileCompleteness {
62 pub fn completed_count(&self) -> usize {
64 self.stages.iter().filter(|(_, done)| *done).count()
65 }
66
67 pub fn total_count(&self) -> usize {
69 self.stages.len()
70 }
71
72 pub fn is_fully_enriched(&self) -> bool {
74 self.stages.iter().all(|(_, done)| *done)
75 }
76
77 pub fn next_incomplete(&self) -> Option<EnrichmentStage> {
79 self.stages
80 .iter()
81 .find(|(_, done)| !*done)
82 .map(|(stage, _)| *stage)
83 }
84
85 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
98impl Config {
103 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#[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); }
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); }
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); }
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 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 #[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; 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 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}