Skip to main content

tuitbot_core/startup/
config.rs

1//! API tier types, token storage model, and startup error type.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6use crate::x_api::scopes::{self, ScopeAnalysis};
7
8// ============================================================================
9// API Tier
10// ============================================================================
11
12/// Detected X API tier.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ApiTier {
15    /// Free tier -- posting only (no search, no mentions).
16    Free,
17    /// Basic tier -- adds search/discovery.
18    Basic,
19    /// Pro tier -- all features.
20    Pro,
21}
22
23impl fmt::Display for ApiTier {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            ApiTier::Free => write!(f, "Free"),
27            ApiTier::Basic => write!(f, "Basic"),
28            ApiTier::Pro => write!(f, "Pro"),
29        }
30    }
31}
32
33/// Capabilities enabled by the current API tier.
34#[derive(Debug, Clone)]
35pub struct TierCapabilities {
36    /// Whether the mentions loop can run.
37    pub mentions: bool,
38    /// Whether the discovery/search loop can run.
39    pub discovery: bool,
40    /// Whether posting (tweets + threads) is available.
41    pub posting: bool,
42    /// Whether tweet search is available.
43    pub search: bool,
44}
45
46impl TierCapabilities {
47    /// Determine capabilities for a given tier.
48    pub fn for_tier(tier: ApiTier) -> Self {
49        match tier {
50            ApiTier::Free => Self {
51                mentions: false,
52                discovery: false,
53                posting: true,
54                search: false,
55            },
56            ApiTier::Basic | ApiTier::Pro => Self {
57                mentions: true,
58                discovery: true,
59                posting: true,
60                search: true,
61            },
62        }
63    }
64
65    /// List the names of enabled automation loops.
66    pub fn enabled_loop_names(&self) -> Vec<&'static str> {
67        let mut loops = Vec::new();
68        if self.mentions {
69            loops.push("mentions");
70        }
71        if self.discovery {
72            loops.push("discovery");
73        }
74        // Content and threads are always enabled (no special tier required).
75        loops.push("content");
76        loops.push("threads");
77        loops
78    }
79
80    /// Format the tier capabilities as a status line.
81    pub fn format_status(&self) -> String {
82        let status = |enabled: bool| if enabled { "enabled" } else { "DISABLED" };
83        format!(
84            "Mentions: {}, Discovery: {}, Content: enabled, Threads: enabled",
85            status(self.mentions),
86            status(self.discovery),
87        )
88    }
89}
90
91// ============================================================================
92// Stored Tokens
93// ============================================================================
94
95/// OAuth tokens persisted to disk at `~/.tuitbot/tokens.json`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct StoredTokens {
98    /// OAuth 2.0 access token.
99    pub access_token: String,
100
101    /// OAuth 2.0 refresh token (for offline.access scope).
102    #[serde(default)]
103    pub refresh_token: Option<String>,
104
105    /// Token expiration timestamp.
106    #[serde(default)]
107    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
108
109    /// Granted OAuth scopes returned by X during token exchange.
110    #[serde(default)]
111    pub scopes: Vec<String>,
112}
113
114impl StoredTokens {
115    /// Check if the token has expired.
116    pub fn is_expired(&self) -> bool {
117        match self.expires_at {
118            Some(expires) => chrono::Utc::now() >= expires,
119            None => false,
120        }
121    }
122
123    /// Time remaining until token expires.
124    pub fn time_until_expiry(&self) -> Option<chrono::TimeDelta> {
125        self.expires_at.map(|expires| expires - chrono::Utc::now())
126    }
127
128    /// Format time until expiry as a human-readable string.
129    pub fn format_expiry(&self) -> String {
130        match self.time_until_expiry() {
131            Some(duration) if duration.num_seconds() > 0 => {
132                let hours = duration.num_hours();
133                let minutes = duration.num_minutes() % 60;
134                if hours > 0 {
135                    format!("{hours}h {minutes}m")
136                } else {
137                    format!("{minutes}m")
138                }
139            }
140            Some(_) => "expired".to_string(),
141            None => "no expiry set".to_string(),
142        }
143    }
144
145    /// Whether this token file includes scope metadata.
146    pub fn has_scope_info(&self) -> bool {
147        !self.scopes.is_empty()
148    }
149
150    /// Check whether a specific scope is granted.
151    pub fn has_scope(&self, scope: &str) -> bool {
152        self.scopes.iter().any(|granted| granted == scope)
153    }
154
155    /// Analyze granted scopes versus required Tuitbot scopes.
156    pub fn analyze_scopes(&self) -> ScopeAnalysis {
157        scopes::analyze_scopes(&self.scopes)
158    }
159}
160
161// ============================================================================
162// Startup Error
163// ============================================================================
164
165/// Errors that can occur during startup operations.
166#[derive(Debug, thiserror::Error)]
167pub enum StartupError {
168    /// Configuration is invalid or missing.
169    #[error("configuration error: {0}")]
170    Config(String),
171
172    /// No tokens found -- user needs to authenticate first.
173    #[error("authentication required: run `tuitbot auth` first")]
174    AuthRequired,
175
176    /// Tokens are expired and need re-authentication.
177    #[error("authentication expired: run `tuitbot auth` to re-authenticate")]
178    AuthExpired,
179
180    /// Token refresh attempt failed.
181    #[error("token refresh failed: {0}")]
182    TokenRefreshFailed(String),
183
184    /// Database initialization or access error.
185    #[error("database error: {0}")]
186    Database(String),
187
188    /// LLM provider configuration or connectivity error.
189    #[error("LLM provider error: {0}")]
190    LlmError(String),
191
192    /// X API communication error.
193    #[error("X API error: {0}")]
194    XApiError(String),
195
196    /// File I/O error.
197    #[error("I/O error: {0}")]
198    Io(#[from] std::io::Error),
199
200    /// Any other error.
201    #[error("{0}")]
202    Other(String),
203}