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!(config.llm.provider.as_str(), "openai" | "anthropic")
87 && config.llm.api_key.as_ref().map_or(true, |k| k.is_empty())
88 {
89 missing.push("LLM API key".to_string());
90 }
91 missing
92 }
93 Self::GenerationReady => {
94 if !can_post {
95 vec!["Valid posting credentials (OAuth tokens or scraper session)".to_string()]
96 } else {
97 vec![]
98 }
99 }
100 Self::PostingReady => vec![],
101 }
102 }
103}
104
105pub fn compute_tier(config: &Config, can_post: bool) -> CapabilityTier {
109 if config.business.product_name.is_empty()
111 || config.business.product_description.trim().is_empty()
112 || (config.business.product_keywords.is_empty()
113 && config.business.competitor_keywords.is_empty())
114 {
115 return CapabilityTier::Unconfigured;
116 }
117
118 let backend = config.x_api.provider_backend.as_str();
120 let has_x = if backend == "scraper" {
121 true } else {
123 !config.x_api.client_id.trim().is_empty()
125 };
126
127 if !has_x {
128 return CapabilityTier::ProfileReady;
129 }
130
131 let has_llm = if config.llm.provider.is_empty() {
133 false
134 } else if config.llm.provider == "ollama" {
135 true } else {
137 config.llm.api_key.as_ref().is_some_and(|k| !k.is_empty())
138 };
139
140 if !has_llm {
141 return CapabilityTier::ExplorationReady;
142 }
143
144 if !can_post {
146 return CapabilityTier::GenerationReady;
147 }
148
149 CapabilityTier::PostingReady
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::config::Config;
156
157 fn minimal_profile_config() -> Config {
158 let mut config = Config::default();
159 config.business.product_name = "TestProduct".to_string();
160 config.business.product_description = "A test product".to_string();
161 config.business.product_keywords = vec!["test".to_string()];
162 config.business.industry_topics = vec!["testing".to_string()];
163 config
164 }
165
166 #[test]
167 fn test_unconfigured_tier() {
168 let config = Config::default();
169 assert_eq!(compute_tier(&config, false), CapabilityTier::Unconfigured);
170 }
171
172 #[test]
173 fn test_profile_ready_tier() {
174 let config = minimal_profile_config();
175 assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
176 }
177
178 #[test]
179 fn test_exploration_ready_x_api() {
180 let mut config = minimal_profile_config();
181 config.x_api.client_id = "abc123".to_string();
182 assert_eq!(
183 compute_tier(&config, false),
184 CapabilityTier::ExplorationReady
185 );
186 }
187
188 #[test]
189 fn test_exploration_ready_scraper() {
190 let mut config = minimal_profile_config();
191 config.x_api.provider_backend = "scraper".to_string();
192 assert_eq!(
193 compute_tier(&config, false),
194 CapabilityTier::ExplorationReady
195 );
196 }
197
198 #[test]
199 fn test_generation_ready_cloud_provider() {
200 let mut config = minimal_profile_config();
201 config.x_api.client_id = "abc123".to_string();
202 config.llm.provider = "openai".to_string();
203 config.llm.api_key = Some("sk-test".to_string());
204 assert_eq!(
205 compute_tier(&config, false),
206 CapabilityTier::GenerationReady
207 );
208 }
209
210 #[test]
211 fn test_generation_ready_ollama() {
212 let mut config = minimal_profile_config();
213 config.x_api.client_id = "abc123".to_string();
214 config.llm.provider = "ollama".to_string();
215 assert_eq!(
216 compute_tier(&config, false),
217 CapabilityTier::GenerationReady
218 );
219 }
220
221 #[test]
222 fn test_posting_ready() {
223 let mut config = minimal_profile_config();
224 config.x_api.client_id = "abc123".to_string();
225 config.llm.provider = "anthropic".to_string();
226 config.llm.api_key = Some("sk-ant-test".to_string());
227 assert_eq!(compute_tier(&config, true), CapabilityTier::PostingReady);
228 }
229
230 #[test]
231 fn test_tier_ordering() {
232 assert!(CapabilityTier::Unconfigured < CapabilityTier::ProfileReady);
233 assert!(CapabilityTier::ProfileReady < CapabilityTier::ExplorationReady);
234 assert!(CapabilityTier::ExplorationReady < CapabilityTier::GenerationReady);
235 assert!(CapabilityTier::GenerationReady < CapabilityTier::PostingReady);
236 }
237
238 #[test]
239 fn test_missing_for_next_unconfigured() {
240 let config = Config::default();
241 let missing = CapabilityTier::Unconfigured.missing_for_next(&config, false);
242 assert!(!missing.is_empty());
243 assert!(missing.iter().any(|m| m.contains("name")));
244 }
245
246 #[test]
247 fn test_missing_for_next_posting_ready() {
248 let config = Config::default();
249 let missing = CapabilityTier::PostingReady.missing_for_next(&config, true);
250 assert!(missing.is_empty());
251 }
252
253 #[test]
254 fn test_cloud_provider_without_key_stays_exploration() {
255 let mut config = minimal_profile_config();
256 config.x_api.client_id = "abc123".to_string();
257 config.llm.provider = "openai".to_string();
258 assert_eq!(
260 compute_tier(&config, false),
261 CapabilityTier::ExplorationReady
262 );
263 }
264
265 #[test]
270 fn test_tier_labels_non_empty() {
271 let tiers = [
272 CapabilityTier::Unconfigured,
273 CapabilityTier::ProfileReady,
274 CapabilityTier::ExplorationReady,
275 CapabilityTier::GenerationReady,
276 CapabilityTier::PostingReady,
277 ];
278 for tier in tiers {
279 assert!(!tier.label().is_empty());
280 assert!(!tier.description().is_empty());
281 }
282 }
283
284 #[test]
285 fn test_tier_debug_and_clone() {
286 let tier = CapabilityTier::GenerationReady;
287 let cloned = tier;
288 assert_eq!(tier, cloned);
289 let debug = format!("{:?}", tier);
290 assert!(debug.contains("GenerationReady"));
291 }
292
293 #[test]
294 fn test_tier_serde_roundtrip() {
295 let tier = CapabilityTier::PostingReady;
296 let json = serde_json::to_string(&tier).expect("serialize");
297 assert_eq!(json, "\"posting_ready\"");
298 let deserialized: CapabilityTier = serde_json::from_str(&json).expect("deserialize");
299 assert_eq!(deserialized, tier);
300 }
301
302 #[test]
303 fn test_tier_serde_all_variants() {
304 let expected = [
305 (CapabilityTier::Unconfigured, "\"unconfigured\""),
306 (CapabilityTier::ProfileReady, "\"profile_ready\""),
307 (CapabilityTier::ExplorationReady, "\"exploration_ready\""),
308 (CapabilityTier::GenerationReady, "\"generation_ready\""),
309 (CapabilityTier::PostingReady, "\"posting_ready\""),
310 ];
311 for (tier, expected_json) in expected {
312 let json = serde_json::to_string(&tier).expect("serialize");
313 assert_eq!(json, expected_json, "mismatch for {:?}", tier);
314 }
315 }
316
317 #[test]
318 fn test_missing_for_next_profile_ready() {
319 let config = minimal_profile_config();
320 let missing = CapabilityTier::ProfileReady.missing_for_next(&config, false);
321 assert!(!missing.is_empty());
322 assert!(missing.iter().any(|m| m.contains("X API")));
323 }
324
325 #[test]
326 fn test_missing_for_next_exploration_ready() {
327 let mut config = minimal_profile_config();
328 config.x_api.client_id = "abc".to_string();
329 let missing = CapabilityTier::ExplorationReady.missing_for_next(&config, false);
330 assert!(!missing.is_empty());
331 assert!(missing.iter().any(|m| m.contains("LLM")));
332 }
333
334 #[test]
335 fn test_missing_for_next_exploration_ready_with_provider_no_key() {
336 let mut config = minimal_profile_config();
337 config.x_api.client_id = "abc".to_string();
338 config.llm.provider = "anthropic".to_string();
339 let missing = CapabilityTier::ExplorationReady.missing_for_next(&config, false);
341 assert!(!missing.is_empty());
342 assert!(missing.iter().any(|m| m.contains("API key")));
343 }
344
345 #[test]
346 fn test_missing_for_next_generation_ready_no_post() {
347 let config = minimal_profile_config();
348 let missing = CapabilityTier::GenerationReady.missing_for_next(&config, false);
349 assert!(!missing.is_empty());
350 assert!(missing.iter().any(|m| m.contains("posting")));
351 }
352
353 #[test]
354 fn test_missing_for_next_generation_ready_can_post() {
355 let config = minimal_profile_config();
356 let missing = CapabilityTier::GenerationReady.missing_for_next(&config, true);
357 assert!(missing.is_empty());
358 }
359
360 #[test]
361 fn test_unconfigured_empty_description() {
362 let mut config = Config::default();
363 config.business.product_name = "Test".to_string();
364 config.business.product_description = " ".to_string(); config.business.product_keywords = vec!["kw".to_string()];
366 assert_eq!(compute_tier(&config, false), CapabilityTier::Unconfigured);
367 }
368
369 #[test]
370 fn test_competitor_keywords_count_for_profile() {
371 let mut config = Config::default();
372 config.business.product_name = "Test".to_string();
373 config.business.product_description = "A product".to_string();
374 config.business.competitor_keywords = vec!["rival".to_string()];
376 assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
377 }
378
379 #[test]
380 fn test_unconfigured_missing_with_some_fields() {
381 let mut config = Config::default();
382 config.business.product_name = "Test".to_string();
383 let missing = CapabilityTier::Unconfigured.missing_for_next(&config, false);
385 assert!(missing.iter().any(|m| m.contains("description")));
386 assert!(missing.iter().any(|m| m.contains("keywords")));
387 }
388
389 #[test]
390 fn test_profile_ready_scraper_backend_no_client_id() {
391 let mut config = minimal_profile_config();
392 config.x_api.provider_backend = "scraper".to_string();
393 let missing = CapabilityTier::ProfileReady.missing_for_next(&config, false);
395 assert!(missing.is_empty());
396 }
397
398 #[test]
399 fn test_cloud_provider_with_empty_key_stays_exploration() {
400 let mut config = minimal_profile_config();
401 config.x_api.client_id = "abc123".to_string();
402 config.llm.provider = "anthropic".to_string();
403 config.llm.api_key = Some("".to_string());
404 assert_eq!(
405 compute_tier(&config, false),
406 CapabilityTier::ExplorationReady
407 );
408 }
409
410 #[test]
411 fn test_ollama_no_key_reaches_generation() {
412 let mut config = minimal_profile_config();
413 config.x_api.client_id = "abc123".to_string();
414 config.llm.provider = "ollama".to_string();
415 assert_eq!(
417 compute_tier(&config, false),
418 CapabilityTier::GenerationReady
419 );
420 }
421
422 #[test]
423 fn test_whitespace_client_id_stays_profile() {
424 let mut config = minimal_profile_config();
425 config.x_api.client_id = " ".to_string();
426 assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
427 }
428}