Skip to main content

tuitbot_core/config/
mod.rs

1//! Configuration management for Tuitbot.
2//!
3//! Supports three-layer configuration loading:
4//! 1. Built-in defaults
5//! 2. TOML config file (`~/.tuitbot/config.toml`)
6//! 3. Environment variable overrides (`TUITBOT_` prefix)
7//!
8//! CLI flag overrides are applied by the binary crate after loading.
9
10pub mod capability;
11mod defaults;
12mod enrichment;
13mod env_overrides;
14pub mod merge;
15mod types;
16mod types_policy;
17mod validation;
18
19#[cfg(test)]
20mod tests;
21#[cfg(test)]
22mod tests_backend;
23
24pub use capability::{compute_tier, CapabilityTier};
25pub use enrichment::{EnrichmentStage, ProfileCompleteness};
26pub use merge::{
27    effective_config, merge_overrides, split_patch_by_scope, validate_override_keys,
28    EffectiveConfigResult, ACCOUNT_SCOPED_KEYS,
29};
30pub use types::{
31    AuthConfig, BusinessProfile, ConnectorConfig, ContentSourceEntry, ContentSourcesConfig,
32    DeploymentCapabilities, DeploymentMode, GoogleDriveConnectorConfig, IntervalsConfig,
33    LimitsConfig, LlmConfig, LoggingConfig, ScoringConfig, ServerConfig, StorageConfig,
34    TargetsConfig, XApiConfig,
35};
36pub use types_policy::{CircuitBreakerConfig, McpPolicyConfig, ScheduleConfig};
37
38use crate::error::ConfigError;
39use serde::{Deserialize, Serialize};
40use std::env;
41use std::path::PathBuf;
42
43fn default_approval_mode() -> bool {
44    true
45}
46
47fn default_max_batch_approve() -> usize {
48    25
49}
50
51/// Operating mode controlling how autonomous Tuitbot is.
52///
53/// - **Autopilot**: Full autonomous operation — discovers, generates, and posts content.
54/// - **Composer**: User-controlled posting with on-demand AI intelligence.
55///   In composer mode, `approval_mode` is implicitly `true` and autonomous
56///   posting loops (content, threads, discovery replies) are disabled.
57#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
58#[serde(rename_all = "lowercase")]
59pub enum OperatingMode {
60    /// Full autonomous operation.
61    #[default]
62    Autopilot,
63    /// User-controlled posting with on-demand AI assist.
64    Composer,
65}
66
67impl std::fmt::Display for OperatingMode {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            OperatingMode::Autopilot => write!(f, "autopilot"),
71            OperatingMode::Composer => write!(f, "composer"),
72        }
73    }
74}
75
76/// Top-level configuration for the Tuitbot agent.
77#[derive(Debug, Clone, Default, Deserialize, Serialize)]
78pub struct Config {
79    /// Operating mode: "autopilot" (default) or "composer".
80    #[serde(default)]
81    pub mode: OperatingMode,
82
83    /// X API credentials.
84    #[serde(default)]
85    pub x_api: XApiConfig,
86
87    /// Authentication settings.
88    #[serde(default)]
89    pub auth: AuthConfig,
90
91    /// Business profile for content targeting.
92    #[serde(default)]
93    pub business: BusinessProfile,
94
95    /// Scoring engine weights and threshold.
96    #[serde(default)]
97    pub scoring: ScoringConfig,
98
99    /// Safety limits for API actions.
100    #[serde(default)]
101    pub limits: LimitsConfig,
102
103    /// Automation interval settings.
104    #[serde(default)]
105    pub intervals: IntervalsConfig,
106
107    /// LLM provider configuration.
108    #[serde(default)]
109    pub llm: LlmConfig,
110
111    /// Target account monitoring configuration.
112    #[serde(default)]
113    pub targets: TargetsConfig,
114
115    /// Enable approval mode: queue posts for human review instead of posting.
116    #[serde(default = "default_approval_mode")]
117    pub approval_mode: bool,
118
119    /// Maximum items that can be batch-approved at once.
120    #[serde(default = "default_max_batch_approve")]
121    pub max_batch_approve: usize,
122
123    /// Server binding configuration for LAN access.
124    #[serde(default)]
125    pub server: ServerConfig,
126
127    /// Data storage configuration.
128    #[serde(default)]
129    pub storage: StorageConfig,
130
131    /// Logging and observability settings.
132    #[serde(default)]
133    pub logging: LoggingConfig,
134
135    /// Active hours schedule for posting.
136    #[serde(default)]
137    pub schedule: ScheduleConfig,
138
139    /// MCP mutation policy enforcement.
140    #[serde(default)]
141    pub mcp_policy: McpPolicyConfig,
142
143    /// Circuit breaker for X API rate-limit protection.
144    #[serde(default)]
145    pub circuit_breaker: CircuitBreakerConfig,
146
147    /// Content source configuration for the Watchtower.
148    #[serde(default)]
149    pub content_sources: ContentSourcesConfig,
150
151    /// Deployment mode: desktop (default), self_host, or cloud.
152    /// Controls which source types and features are available.
153    #[serde(default)]
154    pub deployment_mode: DeploymentMode,
155
156    /// Connector configuration for remote source OAuth flows.
157    #[serde(default)]
158    pub connectors: ConnectorConfig,
159}
160
161impl Config {
162    /// Load configuration from a TOML file with environment variable overrides.
163    ///
164    /// The loading sequence:
165    /// 1. Determine config file path (argument > `TUITBOT_CONFIG` env var > default)
166    /// 2. Parse TOML file (or use defaults if default path doesn't exist)
167    /// 3. Apply environment variable overrides
168    pub fn load(config_path: Option<&str>) -> Result<Config, ConfigError> {
169        let (path, explicit) = Self::resolve_config_path(config_path);
170
171        let mut config = match std::fs::read_to_string(&path) {
172            Ok(contents) => toml::from_str::<Config>(&contents)
173                .map_err(|e| ConfigError::ParseError { source: e })?,
174            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
175                if explicit {
176                    return Err(ConfigError::FileNotFound {
177                        path: path.display().to_string(),
178                    });
179                }
180                Config::default()
181            }
182            Err(_) => {
183                return Err(ConfigError::FileNotFound {
184                    path: path.display().to_string(),
185                });
186            }
187        };
188
189        config.apply_env_overrides()?;
190
191        Ok(config)
192    }
193
194    /// Load configuration and validate it, returning all validation errors at once.
195    pub fn load_and_validate(config_path: Option<&str>) -> Result<Config, Vec<ConfigError>> {
196        let config = Config::load(config_path).map_err(|e| vec![e])?;
197        config.validate()?;
198        Ok(config)
199    }
200
201    /// Returns `true` if approval mode is effectively enabled.
202    ///
203    /// In composer mode, approval mode is implicitly enabled for
204    /// **autonomous** loops so the user controls all automated posting.
205    /// Manual compose actions from the dashboard respect the explicit
206    /// `approval_mode` setting — use [`Config::approval_mode`] directly
207    /// for user-initiated flows.
208    pub fn effective_approval_mode(&self) -> bool {
209        self.approval_mode || self.mode == OperatingMode::Composer
210    }
211
212    /// Returns `true` if the agent is in composer mode.
213    pub fn is_composer_mode(&self) -> bool {
214        self.mode == OperatingMode::Composer
215    }
216
217    /// Resolve the config file path from arguments, env vars, or default.
218    ///
219    /// Returns `(path, explicit)` where `explicit` is true if the path was
220    /// explicitly provided (via argument or env var) rather than using the default.
221    fn resolve_config_path(config_path: Option<&str>) -> (PathBuf, bool) {
222        if let Some(path) = config_path {
223            return (expand_tilde(path), true);
224        }
225
226        if let Ok(env_path) = env::var("TUITBOT_CONFIG") {
227            return (expand_tilde(&env_path), true);
228        }
229
230        (expand_tilde("~/.tuitbot/config.toml"), false)
231    }
232}
233
234/// Expand `~` at the start of a path to the user's home directory.
235fn expand_tilde(path: &str) -> PathBuf {
236    if let Some(rest) = path.strip_prefix("~/") {
237        if let Some(home) = dirs::home_dir() {
238            return home.join(rest);
239        }
240    } else if path == "~" {
241        if let Some(home) = dirs::home_dir() {
242            return home;
243        }
244    }
245    PathBuf::from(path)
246}