1use serde::{Deserialize, Serialize};
8
9use super::Config;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum CapabilityTier {
17 Unconfigured = 0,
19 ProfileReady = 1,
21 ExplorationReady = 2,
23 GenerationReady = 3,
25 PostingReady = 4,
27}
28
29impl CapabilityTier {
30 pub fn label(self) -> &'static str {
32 match self {
33 Self::Unconfigured => "Unconfigured",
34 Self::ProfileReady => "Profile Ready",
35 Self::ExplorationReady => "Exploration Ready",
36 Self::GenerationReady => "Generation Ready",
37 Self::PostingReady => "Posting Ready",
38 }
39 }
40
41 pub fn description(self) -> &'static str {
43 match self {
44 Self::Unconfigured => "Complete onboarding to get started",
45 Self::ProfileReady => "Dashboard access and settings",
46 Self::ExplorationReady => "Content discovery and scoring",
47 Self::GenerationReady => "AI draft generation and composition",
48 Self::PostingReady => "Scheduled posting and autopilot",
49 }
50 }
51
52 pub fn missing_for_next(self, config: &Config, can_post: bool) -> Vec<String> {
54 match self {
55 Self::Unconfigured => {
56 let mut missing = Vec::new();
57 if config.business.product_name.is_empty() {
58 missing.push("Product/profile name".to_string());
59 }
60 if config.business.product_description.trim().is_empty() {
61 missing.push("Product/profile description".to_string());
62 }
63 if config.business.product_keywords.is_empty() {
64 missing.push("Product keywords".to_string());
65 }
66 if config.business.industry_topics.is_empty()
67 && config.business.product_keywords.is_empty()
68 {
69 missing.push("Industry topics".to_string());
70 }
71 missing
72 }
73 Self::ProfileReady => {
74 let mut missing = Vec::new();
75 let backend = config.x_api.provider_backend.as_str();
76 let is_x_api = backend.is_empty() || backend == "x_api";
77 if is_x_api && config.x_api.client_id.trim().is_empty() {
78 missing.push("X API client ID".to_string());
79 }
80 missing
81 }
82 Self::ExplorationReady => {
83 let mut missing = Vec::new();
84 if config.llm.provider.is_empty() {
85 missing.push("LLM provider".to_string());
86 } else if matches!(
87 config.llm.provider.as_str(),
88 "openai" | "anthropic" | "groq"
89 ) && config.llm.api_key.as_ref().map_or(true, |k| k.is_empty())
90 {
91 missing.push("LLM API key".to_string());
92 }
93 missing
94 }
95 Self::GenerationReady => {
96 if !can_post {
97 vec!["Valid posting credentials (OAuth tokens or scraper session)".to_string()]
98 } else {
99 vec![]
100 }
101 }
102 Self::PostingReady => vec![],
103 }
104 }
105}
106
107pub fn compute_tier(config: &Config, can_post: bool) -> CapabilityTier {
111 if config.business.product_name.is_empty()
113 || config.business.product_description.trim().is_empty()
114 || (config.business.product_keywords.is_empty()
115 && config.business.competitor_keywords.is_empty())
116 {
117 return CapabilityTier::Unconfigured;
118 }
119
120 let backend = config.x_api.provider_backend.as_str();
122 let has_x = if backend == "scraper" {
123 true } else {
125 !config.x_api.client_id.trim().is_empty()
127 };
128
129 if !has_x {
130 return CapabilityTier::ProfileReady;
131 }
132
133 let has_llm = if config.llm.provider.is_empty() {
135 false
136 } else if config.llm.provider == "ollama" {
137 true } else {
139 config.llm.api_key.as_ref().is_some_and(|k| !k.is_empty())
140 };
141
142 if !has_llm {
143 return CapabilityTier::ExplorationReady;
144 }
145
146 if !can_post {
148 return CapabilityTier::GenerationReady;
149 }
150
151 CapabilityTier::PostingReady
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use crate::config::Config;
158
159 fn minimal_profile_config() -> Config {
160 let mut config = Config::default();
161 config.business.product_name = "TestProduct".to_string();
162 config.business.product_description = "A test product".to_string();
163 config.business.product_keywords = vec!["test".to_string()];
164 config.business.industry_topics = vec!["testing".to_string()];
165 config
166 }
167
168 #[test]
169 fn test_unconfigured_tier() {
170 let config = Config::default();
171 assert_eq!(compute_tier(&config, false), CapabilityTier::Unconfigured);
172 }
173
174 #[test]
175 fn test_profile_ready_tier() {
176 let config = minimal_profile_config();
177 assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
178 }
179
180 #[test]
181 fn test_exploration_ready_x_api() {
182 let mut config = minimal_profile_config();
183 config.x_api.client_id = "abc123".to_string();
184 assert_eq!(
185 compute_tier(&config, false),
186 CapabilityTier::ExplorationReady
187 );
188 }
189
190 #[test]
191 fn test_exploration_ready_scraper() {
192 let mut config = minimal_profile_config();
193 config.x_api.provider_backend = "scraper".to_string();
194 assert_eq!(
195 compute_tier(&config, false),
196 CapabilityTier::ExplorationReady
197 );
198 }
199
200 #[test]
201 fn test_generation_ready_cloud_provider() {
202 let mut config = minimal_profile_config();
203 config.x_api.client_id = "abc123".to_string();
204 config.llm.provider = "openai".to_string();
205 config.llm.api_key = Some("sk-test".to_string());
206 assert_eq!(
207 compute_tier(&config, false),
208 CapabilityTier::GenerationReady
209 );
210 }
211
212 #[test]
213 fn test_generation_ready_ollama() {
214 let mut config = minimal_profile_config();
215 config.x_api.client_id = "abc123".to_string();
216 config.llm.provider = "ollama".to_string();
217 assert_eq!(
218 compute_tier(&config, false),
219 CapabilityTier::GenerationReady
220 );
221 }
222
223 #[test]
224 fn test_posting_ready() {
225 let mut config = minimal_profile_config();
226 config.x_api.client_id = "abc123".to_string();
227 config.llm.provider = "anthropic".to_string();
228 config.llm.api_key = Some("sk-ant-test".to_string());
229 assert_eq!(compute_tier(&config, true), CapabilityTier::PostingReady);
230 }
231
232 #[test]
233 fn test_tier_ordering() {
234 assert!(CapabilityTier::Unconfigured < CapabilityTier::ProfileReady);
235 assert!(CapabilityTier::ProfileReady < CapabilityTier::ExplorationReady);
236 assert!(CapabilityTier::ExplorationReady < CapabilityTier::GenerationReady);
237 assert!(CapabilityTier::GenerationReady < CapabilityTier::PostingReady);
238 }
239
240 #[test]
241 fn test_missing_for_next_unconfigured() {
242 let config = Config::default();
243 let missing = CapabilityTier::Unconfigured.missing_for_next(&config, false);
244 assert!(!missing.is_empty());
245 assert!(missing.iter().any(|m| m.contains("name")));
246 }
247
248 #[test]
249 fn test_missing_for_next_posting_ready() {
250 let config = Config::default();
251 let missing = CapabilityTier::PostingReady.missing_for_next(&config, true);
252 assert!(missing.is_empty());
253 }
254
255 #[test]
256 fn test_cloud_provider_without_key_stays_exploration() {
257 let mut config = minimal_profile_config();
258 config.x_api.client_id = "abc123".to_string();
259 config.llm.provider = "openai".to_string();
260 assert_eq!(
262 compute_tier(&config, false),
263 CapabilityTier::ExplorationReady
264 );
265 }
266
267 #[test]
272 fn test_tier_labels_non_empty() {
273 let tiers = [
274 CapabilityTier::Unconfigured,
275 CapabilityTier::ProfileReady,
276 CapabilityTier::ExplorationReady,
277 CapabilityTier::GenerationReady,
278 CapabilityTier::PostingReady,
279 ];
280 for tier in tiers {
281 assert!(!tier.label().is_empty());
282 assert!(!tier.description().is_empty());
283 }
284 }
285
286 #[test]
287 fn test_tier_debug_and_clone() {
288 let tier = CapabilityTier::GenerationReady;
289 let cloned = tier;
290 assert_eq!(tier, cloned);
291 let debug = format!("{:?}", tier);
292 assert!(debug.contains("GenerationReady"));
293 }
294
295 #[test]
296 fn test_tier_serde_roundtrip() {
297 let tier = CapabilityTier::PostingReady;
298 let json = serde_json::to_string(&tier).expect("serialize");
299 assert_eq!(json, "\"posting_ready\"");
300 let deserialized: CapabilityTier = serde_json::from_str(&json).expect("deserialize");
301 assert_eq!(deserialized, tier);
302 }
303
304 #[test]
305 fn test_tier_serde_all_variants() {
306 let expected = [
307 (CapabilityTier::Unconfigured, "\"unconfigured\""),
308 (CapabilityTier::ProfileReady, "\"profile_ready\""),
309 (CapabilityTier::ExplorationReady, "\"exploration_ready\""),
310 (CapabilityTier::GenerationReady, "\"generation_ready\""),
311 (CapabilityTier::PostingReady, "\"posting_ready\""),
312 ];
313 for (tier, expected_json) in expected {
314 let json = serde_json::to_string(&tier).expect("serialize");
315 assert_eq!(json, expected_json, "mismatch for {:?}", tier);
316 }
317 }
318
319 #[test]
320 fn test_missing_for_next_profile_ready() {
321 let config = minimal_profile_config();
322 let missing = CapabilityTier::ProfileReady.missing_for_next(&config, false);
323 assert!(!missing.is_empty());
324 assert!(missing.iter().any(|m| m.contains("X API")));
325 }
326
327 #[test]
328 fn test_missing_for_next_exploration_ready() {
329 let mut config = minimal_profile_config();
330 config.x_api.client_id = "abc".to_string();
331 let missing = CapabilityTier::ExplorationReady.missing_for_next(&config, false);
332 assert!(!missing.is_empty());
333 assert!(missing.iter().any(|m| m.contains("LLM")));
334 }
335
336 #[test]
337 fn test_missing_for_next_exploration_ready_with_provider_no_key() {
338 let mut config = minimal_profile_config();
339 config.x_api.client_id = "abc".to_string();
340 config.llm.provider = "anthropic".to_string();
341 let missing = CapabilityTier::ExplorationReady.missing_for_next(&config, false);
343 assert!(!missing.is_empty());
344 assert!(missing.iter().any(|m| m.contains("API key")));
345 }
346
347 #[test]
348 fn test_missing_for_next_generation_ready_no_post() {
349 let config = minimal_profile_config();
350 let missing = CapabilityTier::GenerationReady.missing_for_next(&config, false);
351 assert!(!missing.is_empty());
352 assert!(missing.iter().any(|m| m.contains("posting")));
353 }
354
355 #[test]
356 fn test_missing_for_next_generation_ready_can_post() {
357 let config = minimal_profile_config();
358 let missing = CapabilityTier::GenerationReady.missing_for_next(&config, true);
359 assert!(missing.is_empty());
360 }
361
362 #[test]
363 fn test_unconfigured_empty_description() {
364 let mut config = Config::default();
365 config.business.product_name = "Test".to_string();
366 config.business.product_description = " ".to_string(); config.business.product_keywords = vec!["kw".to_string()];
368 assert_eq!(compute_tier(&config, false), CapabilityTier::Unconfigured);
369 }
370
371 #[test]
372 fn test_competitor_keywords_count_for_profile() {
373 let mut config = Config::default();
374 config.business.product_name = "Test".to_string();
375 config.business.product_description = "A product".to_string();
376 config.business.competitor_keywords = vec!["rival".to_string()];
378 assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
379 }
380
381 #[test]
382 fn test_unconfigured_missing_with_some_fields() {
383 let mut config = Config::default();
384 config.business.product_name = "Test".to_string();
385 let missing = CapabilityTier::Unconfigured.missing_for_next(&config, false);
387 assert!(missing.iter().any(|m| m.contains("description")));
388 assert!(missing.iter().any(|m| m.contains("keywords")));
389 }
390
391 #[test]
392 fn test_profile_ready_scraper_backend_no_client_id() {
393 let mut config = minimal_profile_config();
394 config.x_api.provider_backend = "scraper".to_string();
395 let missing = CapabilityTier::ProfileReady.missing_for_next(&config, false);
397 assert!(missing.is_empty());
398 }
399
400 #[test]
401 fn test_cloud_provider_with_empty_key_stays_exploration() {
402 let mut config = minimal_profile_config();
403 config.x_api.client_id = "abc123".to_string();
404 config.llm.provider = "anthropic".to_string();
405 config.llm.api_key = Some("".to_string());
406 assert_eq!(
407 compute_tier(&config, false),
408 CapabilityTier::ExplorationReady
409 );
410 }
411
412 #[test]
413 fn test_ollama_no_key_reaches_generation() {
414 let mut config = minimal_profile_config();
415 config.x_api.client_id = "abc123".to_string();
416 config.llm.provider = "ollama".to_string();
417 assert_eq!(
419 compute_tier(&config, false),
420 CapabilityTier::GenerationReady
421 );
422 }
423
424 #[test]
425 fn test_whitespace_client_id_stays_profile() {
426 let mut config = minimal_profile_config();
427 config.x_api.client_id = " ".to_string();
428 assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
429 }
430}