Skip to main content

tuitbot_core/config/
capability.rs

1//! Capability tier model for progressive activation.
2//!
3//! Defines four tiers that represent what the user can do based on their
4//! current configuration state. Tiers are computed — never stored — so
5//! they always reflect the live config.
6
7use serde::{Deserialize, Serialize};
8
9use super::Config;
10
11/// Progressive activation tiers, ordered from least to most capable.
12///
13/// Each tier is a strict superset of the previous one.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum CapabilityTier {
17    /// No configuration exists yet.
18    Unconfigured = 0,
19    /// Business profile is filled — dashboard access, view settings.
20    ProfileReady = 1,
21    /// Profile + X credentials — discovery, search, scoring.
22    ExplorationReady = 2,
23    /// Exploration + valid LLM config — draft generation, reply composition.
24    GenerationReady = 3,
25    /// Generation + valid posting tokens — scheduled posting, autopilot.
26    PostingReady = 4,
27}
28
29impl CapabilityTier {
30    /// Human-readable label for this tier.
31    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    /// Short description of what this tier unlocks.
42    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    /// Returns a list of what's missing to reach the next tier.
53    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
107/// Compute the capability tier from config state and posting ability.
108///
109/// This is a pure function with no side effects — call it freely.
110pub fn compute_tier(config: &Config, can_post: bool) -> CapabilityTier {
111    // Tier 1: business profile
112    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    // Tier 2: X credentials
121    let backend = config.x_api.provider_backend.as_str();
122    let has_x = if backend == "scraper" {
123        true // scraper mode doesn't need client_id
124    } else {
125        // x_api mode (default)
126        !config.x_api.client_id.trim().is_empty()
127    };
128
129    if !has_x {
130        return CapabilityTier::ProfileReady;
131    }
132
133    // Tier 3: LLM config
134    let has_llm = if config.llm.provider.is_empty() {
135        false
136    } else if config.llm.provider == "ollama" {
137        true // ollama doesn't need an API key
138    } 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    // Tier 4: can post
147    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        // No API key
261        assert_eq!(
262            compute_tier(&config, false),
263            CapabilityTier::ExplorationReady
264        );
265    }
266
267    // -----------------------------------------------------------------------
268    // Additional capability coverage tests
269    // -----------------------------------------------------------------------
270
271    #[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        // No API key
342        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(); // whitespace only
367        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        // No product_keywords, but has competitor_keywords
377        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        // Missing description and keywords
386        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        // No client_id needed for scraper
396        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        // No API key needed for ollama
418        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}