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