use serde::{Deserialize, Serialize};
use super::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapabilityTier {
Unconfigured = 0,
ProfileReady = 1,
ExplorationReady = 2,
GenerationReady = 3,
PostingReady = 4,
}
impl CapabilityTier {
pub fn label(self) -> &'static str {
match self {
Self::Unconfigured => "Unconfigured",
Self::ProfileReady => "Profile Ready",
Self::ExplorationReady => "Exploration Ready",
Self::GenerationReady => "Generation Ready",
Self::PostingReady => "Posting Ready",
}
}
pub fn description(self) -> &'static str {
match self {
Self::Unconfigured => "Complete onboarding to get started",
Self::ProfileReady => "Dashboard access and settings",
Self::ExplorationReady => "Content discovery and scoring",
Self::GenerationReady => "AI draft generation and composition",
Self::PostingReady => "Scheduled posting and autopilot",
}
}
pub fn missing_for_next(self, config: &Config, can_post: bool) -> Vec<String> {
match self {
Self::Unconfigured => {
let mut missing = Vec::new();
if config.business.product_name.is_empty() {
missing.push("Product/profile name".to_string());
}
if config.business.product_description.trim().is_empty() {
missing.push("Product/profile description".to_string());
}
if config.business.product_keywords.is_empty() {
missing.push("Product keywords".to_string());
}
if config.business.industry_topics.is_empty()
&& config.business.product_keywords.is_empty()
{
missing.push("Industry topics".to_string());
}
missing
}
Self::ProfileReady => {
let mut missing = Vec::new();
let backend = config.x_api.provider_backend.as_str();
let is_x_api = backend.is_empty() || backend == "x_api";
if is_x_api && config.x_api.client_id.trim().is_empty() {
missing.push("X API client ID".to_string());
}
missing
}
Self::ExplorationReady => {
let mut missing = Vec::new();
if config.llm.provider.is_empty() {
missing.push("LLM provider".to_string());
} else if matches!(
config.llm.provider.as_str(),
"openai" | "anthropic" | "groq"
) && config.llm.api_key.as_ref().map_or(true, |k| k.is_empty())
{
missing.push("LLM API key".to_string());
}
missing
}
Self::GenerationReady => {
if !can_post {
vec!["Valid posting credentials (OAuth tokens or scraper session)".to_string()]
} else {
vec![]
}
}
Self::PostingReady => vec![],
}
}
}
pub fn compute_tier(config: &Config, can_post: bool) -> CapabilityTier {
if config.business.product_name.is_empty()
|| config.business.product_description.trim().is_empty()
|| (config.business.product_keywords.is_empty()
&& config.business.competitor_keywords.is_empty())
{
return CapabilityTier::Unconfigured;
}
let backend = config.x_api.provider_backend.as_str();
let has_x = if backend == "scraper" {
true } else {
!config.x_api.client_id.trim().is_empty()
};
if !has_x {
return CapabilityTier::ProfileReady;
}
let has_llm = if config.llm.provider.is_empty() {
false
} else if config.llm.provider == "ollama" {
true } else {
config.llm.api_key.as_ref().is_some_and(|k| !k.is_empty())
};
if !has_llm {
return CapabilityTier::ExplorationReady;
}
if !can_post {
return CapabilityTier::GenerationReady;
}
CapabilityTier::PostingReady
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn minimal_profile_config() -> Config {
let mut config = Config::default();
config.business.product_name = "TestProduct".to_string();
config.business.product_description = "A test product".to_string();
config.business.product_keywords = vec!["test".to_string()];
config.business.industry_topics = vec!["testing".to_string()];
config
}
#[test]
fn test_unconfigured_tier() {
let config = Config::default();
assert_eq!(compute_tier(&config, false), CapabilityTier::Unconfigured);
}
#[test]
fn test_profile_ready_tier() {
let config = minimal_profile_config();
assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
}
#[test]
fn test_exploration_ready_x_api() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc123".to_string();
assert_eq!(
compute_tier(&config, false),
CapabilityTier::ExplorationReady
);
}
#[test]
fn test_exploration_ready_scraper() {
let mut config = minimal_profile_config();
config.x_api.provider_backend = "scraper".to_string();
assert_eq!(
compute_tier(&config, false),
CapabilityTier::ExplorationReady
);
}
#[test]
fn test_generation_ready_cloud_provider() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc123".to_string();
config.llm.provider = "openai".to_string();
config.llm.api_key = Some("sk-test".to_string());
assert_eq!(
compute_tier(&config, false),
CapabilityTier::GenerationReady
);
}
#[test]
fn test_generation_ready_ollama() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc123".to_string();
config.llm.provider = "ollama".to_string();
assert_eq!(
compute_tier(&config, false),
CapabilityTier::GenerationReady
);
}
#[test]
fn test_posting_ready() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc123".to_string();
config.llm.provider = "anthropic".to_string();
config.llm.api_key = Some("sk-ant-test".to_string());
assert_eq!(compute_tier(&config, true), CapabilityTier::PostingReady);
}
#[test]
fn test_tier_ordering() {
assert!(CapabilityTier::Unconfigured < CapabilityTier::ProfileReady);
assert!(CapabilityTier::ProfileReady < CapabilityTier::ExplorationReady);
assert!(CapabilityTier::ExplorationReady < CapabilityTier::GenerationReady);
assert!(CapabilityTier::GenerationReady < CapabilityTier::PostingReady);
}
#[test]
fn test_missing_for_next_unconfigured() {
let config = Config::default();
let missing = CapabilityTier::Unconfigured.missing_for_next(&config, false);
assert!(!missing.is_empty());
assert!(missing.iter().any(|m| m.contains("name")));
}
#[test]
fn test_missing_for_next_posting_ready() {
let config = Config::default();
let missing = CapabilityTier::PostingReady.missing_for_next(&config, true);
assert!(missing.is_empty());
}
#[test]
fn test_cloud_provider_without_key_stays_exploration() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc123".to_string();
config.llm.provider = "openai".to_string();
assert_eq!(
compute_tier(&config, false),
CapabilityTier::ExplorationReady
);
}
#[test]
fn test_tier_labels_non_empty() {
let tiers = [
CapabilityTier::Unconfigured,
CapabilityTier::ProfileReady,
CapabilityTier::ExplorationReady,
CapabilityTier::GenerationReady,
CapabilityTier::PostingReady,
];
for tier in tiers {
assert!(!tier.label().is_empty());
assert!(!tier.description().is_empty());
}
}
#[test]
fn test_tier_debug_and_clone() {
let tier = CapabilityTier::GenerationReady;
let cloned = tier;
assert_eq!(tier, cloned);
let debug = format!("{:?}", tier);
assert!(debug.contains("GenerationReady"));
}
#[test]
fn test_tier_serde_roundtrip() {
let tier = CapabilityTier::PostingReady;
let json = serde_json::to_string(&tier).expect("serialize");
assert_eq!(json, "\"posting_ready\"");
let deserialized: CapabilityTier = serde_json::from_str(&json).expect("deserialize");
assert_eq!(deserialized, tier);
}
#[test]
fn test_tier_serde_all_variants() {
let expected = [
(CapabilityTier::Unconfigured, "\"unconfigured\""),
(CapabilityTier::ProfileReady, "\"profile_ready\""),
(CapabilityTier::ExplorationReady, "\"exploration_ready\""),
(CapabilityTier::GenerationReady, "\"generation_ready\""),
(CapabilityTier::PostingReady, "\"posting_ready\""),
];
for (tier, expected_json) in expected {
let json = serde_json::to_string(&tier).expect("serialize");
assert_eq!(json, expected_json, "mismatch for {:?}", tier);
}
}
#[test]
fn test_missing_for_next_profile_ready() {
let config = minimal_profile_config();
let missing = CapabilityTier::ProfileReady.missing_for_next(&config, false);
assert!(!missing.is_empty());
assert!(missing.iter().any(|m| m.contains("X API")));
}
#[test]
fn test_missing_for_next_exploration_ready() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc".to_string();
let missing = CapabilityTier::ExplorationReady.missing_for_next(&config, false);
assert!(!missing.is_empty());
assert!(missing.iter().any(|m| m.contains("LLM")));
}
#[test]
fn test_missing_for_next_exploration_ready_with_provider_no_key() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc".to_string();
config.llm.provider = "anthropic".to_string();
let missing = CapabilityTier::ExplorationReady.missing_for_next(&config, false);
assert!(!missing.is_empty());
assert!(missing.iter().any(|m| m.contains("API key")));
}
#[test]
fn test_missing_for_next_generation_ready_no_post() {
let config = minimal_profile_config();
let missing = CapabilityTier::GenerationReady.missing_for_next(&config, false);
assert!(!missing.is_empty());
assert!(missing.iter().any(|m| m.contains("posting")));
}
#[test]
fn test_missing_for_next_generation_ready_can_post() {
let config = minimal_profile_config();
let missing = CapabilityTier::GenerationReady.missing_for_next(&config, true);
assert!(missing.is_empty());
}
#[test]
fn test_unconfigured_empty_description() {
let mut config = Config::default();
config.business.product_name = "Test".to_string();
config.business.product_description = " ".to_string(); config.business.product_keywords = vec!["kw".to_string()];
assert_eq!(compute_tier(&config, false), CapabilityTier::Unconfigured);
}
#[test]
fn test_competitor_keywords_count_for_profile() {
let mut config = Config::default();
config.business.product_name = "Test".to_string();
config.business.product_description = "A product".to_string();
config.business.competitor_keywords = vec!["rival".to_string()];
assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
}
#[test]
fn test_unconfigured_missing_with_some_fields() {
let mut config = Config::default();
config.business.product_name = "Test".to_string();
let missing = CapabilityTier::Unconfigured.missing_for_next(&config, false);
assert!(missing.iter().any(|m| m.contains("description")));
assert!(missing.iter().any(|m| m.contains("keywords")));
}
#[test]
fn test_profile_ready_scraper_backend_no_client_id() {
let mut config = minimal_profile_config();
config.x_api.provider_backend = "scraper".to_string();
let missing = CapabilityTier::ProfileReady.missing_for_next(&config, false);
assert!(missing.is_empty());
}
#[test]
fn test_cloud_provider_with_empty_key_stays_exploration() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc123".to_string();
config.llm.provider = "anthropic".to_string();
config.llm.api_key = Some("".to_string());
assert_eq!(
compute_tier(&config, false),
CapabilityTier::ExplorationReady
);
}
#[test]
fn test_ollama_no_key_reaches_generation() {
let mut config = minimal_profile_config();
config.x_api.client_id = "abc123".to_string();
config.llm.provider = "ollama".to_string();
assert_eq!(
compute_tier(&config, false),
CapabilityTier::GenerationReady
);
}
#[test]
fn test_whitespace_client_id_stays_profile() {
let mut config = minimal_profile_config();
config.x_api.client_id = " ".to_string();
assert_eq!(compute_tier(&config, false), CapabilityTier::ProfileReady);
}
}