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