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, EmbeddingConfig, GoogleDriveConnectorConfig,
33    IntervalsConfig, LimitsConfig, LlmConfig, LoggingConfig, ScoringConfig, ServerConfig,
34    StorageConfig, 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    /// Embedding provider configuration for semantic search indexing.
161    /// When `None`, semantic indexing is disabled.
162    #[serde(default)]
163    pub embedding: Option<EmbeddingConfig>,
164}
165
166impl Config {
167    /// Load configuration from a TOML file with environment variable overrides.
168    ///
169    /// The loading sequence:
170    /// 1. Determine config file path (argument > `TUITBOT_CONFIG` env var > default)
171    /// 2. Parse TOML file (or use defaults if default path doesn't exist)
172    /// 3. Apply environment variable overrides
173    pub fn load(config_path: Option<&str>) -> Result<Config, ConfigError> {
174        let (path, explicit) = Self::resolve_config_path(config_path);
175
176        let mut config = match std::fs::read_to_string(&path) {
177            Ok(contents) => toml::from_str::<Config>(&contents)
178                .map_err(|e| ConfigError::ParseError { source: e })?,
179            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
180                if explicit {
181                    return Err(ConfigError::FileNotFound {
182                        path: path.display().to_string(),
183                    });
184                }
185                Config::default()
186            }
187            Err(_) => {
188                return Err(ConfigError::FileNotFound {
189                    path: path.display().to_string(),
190                });
191            }
192        };
193
194        config.apply_env_overrides()?;
195
196        Ok(config)
197    }
198
199    /// Load configuration and validate it, returning all validation errors at once.
200    pub fn load_and_validate(config_path: Option<&str>) -> Result<Config, Vec<ConfigError>> {
201        let config = Config::load(config_path).map_err(|e| vec![e])?;
202        config.validate()?;
203        Ok(config)
204    }
205
206    /// Returns `true` if approval mode is effectively enabled.
207    ///
208    /// In composer mode, approval mode is implicitly enabled for
209    /// **autonomous** loops so the user controls all automated posting.
210    /// Manual compose actions from the dashboard respect the explicit
211    /// `approval_mode` setting — use [`Config::approval_mode`] directly
212    /// for user-initiated flows.
213    pub fn effective_approval_mode(&self) -> bool {
214        self.approval_mode || self.mode == OperatingMode::Composer
215    }
216
217    /// Returns `true` if the agent is in composer mode.
218    pub fn is_composer_mode(&self) -> bool {
219        self.mode == OperatingMode::Composer
220    }
221
222    /// Resolve the config file path from arguments, env vars, or default.
223    ///
224    /// Returns `(path, explicit)` where `explicit` is true if the path was
225    /// explicitly provided (via argument or env var) rather than using the default.
226    fn resolve_config_path(config_path: Option<&str>) -> (PathBuf, bool) {
227        if let Some(path) = config_path {
228            return (expand_tilde(path), true);
229        }
230
231        if let Ok(env_path) = env::var("TUITBOT_CONFIG") {
232            return (expand_tilde(&env_path), true);
233        }
234
235        (expand_tilde("~/.tuitbot/config.toml"), false)
236    }
237}
238
239/// Expand `~` at the start of a path to the user's home directory.
240fn expand_tilde(path: &str) -> PathBuf {
241    if let Some(rest) = path.strip_prefix("~/") {
242        if let Some(home) = dirs::home_dir() {
243            return home.join(rest);
244        }
245    } else if path == "~" {
246        if let Some(home) = dirs::home_dir() {
247            return home;
248        }
249    }
250    PathBuf::from(path)
251}