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!(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
105/// Compute the capability tier from config state and posting ability.
106///
107/// This is a pure function with no side effects — call it freely.
108pub fn compute_tier(config: &Config, can_post: bool) -> CapabilityTier {
109    // Tier 1: business profile
110    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    // Tier 2: X credentials
119    let backend = config.x_api.provider_backend.as_str();
120    let has_x = if backend == "scraper" {
121        true // scraper mode doesn't need client_id
122    } else {
123        // x_api mode (default)
124        !config.x_api.client_id.trim().is_empty()
125    };
126
127    if !has_x {
128        return CapabilityTier::ProfileReady;
129    }
130
131    // Tier 3: LLM config
132    let has_llm = if config.llm.provider.is_empty() {
133        false
134    } else if config.llm.provider == "ollama" {
135        true // ollama doesn't need an API key
136    } 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    // Tier 4: can post
145    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        // No API key
259        assert_eq!(
260            compute_tier(&config, false),
261            CapabilityTier::ExplorationReady
262        );
263    }
264
265    // -----------------------------------------------------------------------
266    // Additional capability coverage tests
267    // -----------------------------------------------------------------------
268
269    #[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        // No API key
340        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(); // whitespace only
365        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        // No product_keywords, but has competitor_keywords
375        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        // Missing description and keywords
384        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        // No client_id needed for scraper
394        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        // No API key needed for ollama
416        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}