Skip to main content

tuitbot_core/config/
validation.rs

1//! Configuration validation logic.
2
3use super::Config;
4use crate::error::ConfigError;
5
6impl Config {
7    /// Validate the configuration, returning all errors found (not just the first).
8    pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
9        let mut errors = Vec::new();
10
11        // Validate business profile
12        if self.business.product_name.is_empty() {
13            errors.push(ConfigError::MissingField {
14                field: "business.product_name".to_string(),
15            });
16        }
17
18        if self.business.product_keywords.is_empty() && self.business.competitor_keywords.is_empty()
19        {
20            errors.push(ConfigError::MissingField {
21                field: "business.product_keywords or business.competitor_keywords".to_string(),
22            });
23        }
24
25        // Validate LLM provider
26        if !self.llm.provider.is_empty() {
27            match self.llm.provider.as_str() {
28                "openai" | "anthropic" | "ollama" => {}
29                _ => {
30                    errors.push(ConfigError::InvalidValue {
31                        field: "llm.provider".to_string(),
32                        message: "must be openai, anthropic, or ollama".to_string(),
33                    });
34                }
35            }
36
37            if matches!(self.llm.provider.as_str(), "openai" | "anthropic") {
38                match &self.llm.api_key {
39                    Some(key) if !key.is_empty() => {}
40                    _ => {
41                        errors.push(ConfigError::MissingField {
42                            field: format!(
43                                "llm.api_key (required for {} provider)",
44                                self.llm.provider
45                            ),
46                        });
47                    }
48                }
49            }
50        }
51
52        // Validate auth mode
53        if !self.auth.mode.is_empty() {
54            match self.auth.mode.as_str() {
55                "manual" | "local_callback" => {}
56                _ => {
57                    errors.push(ConfigError::InvalidValue {
58                        field: "auth.mode".to_string(),
59                        message: "must be manual or local_callback".to_string(),
60                    });
61                }
62            }
63        }
64
65        // Validate scoring threshold
66        if self.scoring.threshold > 100 {
67            errors.push(ConfigError::InvalidValue {
68                field: "scoring.threshold".to_string(),
69                message: "must be between 0 and 100".to_string(),
70            });
71        }
72
73        // Validate limits
74        if self.limits.max_replies_per_day == 0 {
75            errors.push(ConfigError::InvalidValue {
76                field: "limits.max_replies_per_day".to_string(),
77                message: "must be greater than 0".to_string(),
78            });
79        }
80
81        if self.limits.max_tweets_per_day == 0 {
82            errors.push(ConfigError::InvalidValue {
83                field: "limits.max_tweets_per_day".to_string(),
84                message: "must be greater than 0".to_string(),
85            });
86        }
87
88        if self.limits.max_threads_per_week == 0 {
89            errors.push(ConfigError::InvalidValue {
90                field: "limits.max_threads_per_week".to_string(),
91                message: "must be greater than 0".to_string(),
92            });
93        }
94
95        if self.limits.min_action_delay_seconds > self.limits.max_action_delay_seconds {
96            errors.push(ConfigError::InvalidValue {
97                field: "limits.min_action_delay_seconds".to_string(),
98                message: "must be less than or equal to max_action_delay_seconds".to_string(),
99            });
100        }
101
102        // Validate schedule
103        if self.schedule.active_hours_start > 23 {
104            errors.push(ConfigError::InvalidValue {
105                field: "schedule.active_hours_start".to_string(),
106                message: "must be between 0 and 23".to_string(),
107            });
108        }
109        if self.schedule.active_hours_end > 23 {
110            errors.push(ConfigError::InvalidValue {
111                field: "schedule.active_hours_end".to_string(),
112                message: "must be between 0 and 23".to_string(),
113            });
114        }
115        if !self.schedule.timezone.is_empty()
116            && self.schedule.timezone.parse::<chrono_tz::Tz>().is_err()
117        {
118            errors.push(ConfigError::InvalidValue {
119                field: "schedule.timezone".to_string(),
120                message: format!(
121                    "'{}' is not a valid IANA timezone name",
122                    self.schedule.timezone
123                ),
124            });
125        }
126        let valid_days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
127        for day in &self.schedule.active_days {
128            if !valid_days.contains(&day.as_str()) {
129                errors.push(ConfigError::InvalidValue {
130                    field: "schedule.active_days".to_string(),
131                    message: format!(
132                        "'{}' is not a valid day abbreviation (use Mon, Tue, Wed, Thu, Fri, Sat, Sun)",
133                        day
134                    ),
135                });
136                break;
137            }
138        }
139
140        // Validate preferred_times
141        for time_str in &self.schedule.preferred_times {
142            if time_str != "auto" && !is_valid_hhmm(time_str) {
143                errors.push(ConfigError::InvalidValue {
144                    field: "schedule.preferred_times".to_string(),
145                    message: format!(
146                        "'{}' is not a valid time (use HH:MM 24h format or \"auto\")",
147                        time_str
148                    ),
149                });
150                break;
151            }
152        }
153
154        // Validate preferred_times_override keys and values
155        for (day, times) in &self.schedule.preferred_times_override {
156            if !valid_days.contains(&day.as_str()) {
157                errors.push(ConfigError::InvalidValue {
158                    field: "schedule.preferred_times_override".to_string(),
159                    message: format!(
160                        "'{}' is not a valid day abbreviation (use Mon, Tue, Wed, Thu, Fri, Sat, Sun)",
161                        day
162                    ),
163                });
164                break;
165            }
166            for time_str in times {
167                if !is_valid_hhmm(time_str) {
168                    errors.push(ConfigError::InvalidValue {
169                        field: "schedule.preferred_times_override".to_string(),
170                        message: format!(
171                            "'{}' is not a valid time for {} (use HH:MM 24h format)",
172                            time_str, day
173                        ),
174                    });
175                    break;
176                }
177            }
178        }
179
180        // Validate MCP policy: tools can't be in both blocked_tools and require_approval_for
181        for tool in &self.mcp_policy.blocked_tools {
182            if self.mcp_policy.require_approval_for.contains(tool) {
183                errors.push(ConfigError::InvalidValue {
184                    field: "mcp_policy.blocked_tools".to_string(),
185                    message: format!(
186                        "tool '{tool}' cannot be in both blocked_tools and require_approval_for"
187                    ),
188                });
189                break;
190            }
191        }
192
193        // Count effective slots per day vs max_tweets_per_day
194        let effective_slots = if self.schedule.preferred_times.is_empty() {
195            0
196        } else {
197            // "auto" expands to 3 slots
198            let base_count: usize = self
199                .schedule
200                .preferred_times
201                .iter()
202                .map(|t| if t == "auto" { 3 } else { 1 })
203                .sum();
204            // Check max across all override days too
205            let max_override = self
206                .schedule
207                .preferred_times_override
208                .values()
209                .map(|v| v.len())
210                .max()
211                .unwrap_or(0);
212            base_count.max(max_override)
213        };
214        if effective_slots > self.limits.max_tweets_per_day as usize {
215            errors.push(ConfigError::InvalidValue {
216                field: "schedule.preferred_times".to_string(),
217                message: format!(
218                    "preferred_times has {} slots but limits.max_tweets_per_day is {} — \
219                     increase the limit or reduce the number of time slots",
220                    effective_slots, self.limits.max_tweets_per_day
221                ),
222            });
223        }
224
225        // Validate thread_preferred_day
226        if let Some(day) = &self.schedule.thread_preferred_day {
227            if !valid_days.contains(&day.as_str()) {
228                errors.push(ConfigError::InvalidValue {
229                    field: "schedule.thread_preferred_day".to_string(),
230                    message: format!(
231                        "'{}' is not a valid day abbreviation (use Mon, Tue, Wed, Thu, Fri, Sat, Sun)",
232                        day
233                    ),
234                });
235            }
236        }
237
238        // Validate thread_preferred_time
239        if !is_valid_hhmm(&self.schedule.thread_preferred_time) {
240            errors.push(ConfigError::InvalidValue {
241                field: "schedule.thread_preferred_time".to_string(),
242                message: format!(
243                    "'{}' is not a valid time (use HH:MM 24h format)",
244                    self.schedule.thread_preferred_time
245                ),
246            });
247        }
248
249        // Validate provider_backend value
250        let backend = self.x_api.provider_backend.as_str();
251        if !backend.is_empty() && backend != "x_api" && backend != "scraper" {
252            errors.push(ConfigError::InvalidValue {
253                field: "x_api.provider_backend".to_string(),
254                message: format!(
255                    "must be 'x_api' or 'scraper', got '{}'",
256                    self.x_api.provider_backend
257                ),
258            });
259        }
260
261        // Reject scraper mode in cloud deployment
262        if self.deployment_mode == super::DeploymentMode::Cloud
263            && self.x_api.provider_backend == "scraper"
264        {
265            errors.push(ConfigError::InvalidValue {
266                field: "x_api.provider_backend".to_string(),
267                message: "Local No-Key Mode is not available in cloud deployment. \
268                          Use the Official X API (provider_backend = \"x_api\")."
269                    .to_string(),
270            });
271        }
272
273        // Require client_id when using official X API backend
274        let is_x_api_backend = backend.is_empty() || backend == "x_api";
275        if is_x_api_backend && self.x_api.client_id.trim().is_empty() {
276            errors.push(ConfigError::MissingField {
277                field: "x_api.client_id".to_string(),
278            });
279        }
280
281        // Validate content sources against deployment capabilities
282        for (i, source) in self.content_sources.sources.iter().enumerate() {
283            if !self.deployment_mode.allows_source_type(&source.source_type) {
284                errors.push(ConfigError::InvalidValue {
285                    field: format!("content_sources.sources[{}].source_type", i),
286                    message: format!(
287                        "source type '{}' is not available in {} deployment mode",
288                        source.source_type, self.deployment_mode
289                    ),
290                });
291            }
292
293            // Warn if both connection_id and service_account_key are set.
294            // Not a blocking error -- session 04 handles precedence.
295            if source.source_type == "google_drive"
296                && source.connection_id.is_some()
297                && source.service_account_key.is_some()
298            {
299                tracing::warn!(
300                    source_index = i,
301                    "content_sources.sources[{}] has both connection_id and \
302                     service_account_key; connection_id takes precedence",
303                    i
304                );
305            }
306
307            // Warn if a google_drive source has neither auth method configured.
308            // The Watchtower will skip this source at runtime, but surface it
309            // during validation so the user knows to connect via the dashboard.
310            if source.source_type == "google_drive"
311                && source.connection_id.is_none()
312                && source.service_account_key.is_none()
313            {
314                tracing::warn!(
315                    source_index = i,
316                    "content_sources.sources[{}] has no authentication configured \
317                     (neither connection_id nor service_account_key); this source \
318                     will be skipped at runtime -- connect via Settings > Content Sources",
319                    i
320                );
321            }
322        }
323
324        if errors.is_empty() {
325            Ok(())
326        } else {
327            Err(errors)
328        }
329    }
330}
331
332/// Check if a string is a valid HH:MM time (24h format).
333fn is_valid_hhmm(s: &str) -> bool {
334    let parts: Vec<&str> = s.split(':').collect();
335    if parts.len() != 2 {
336        return false;
337    }
338    let Ok(hour) = parts[0].parse::<u8>() else {
339        return false;
340    };
341    let Ok(minute) = parts[1].parse::<u8>() else {
342        return false;
343    };
344    hour <= 23 && minute <= 59
345}