tuitbot_core/config/
validation.rs1use super::Config;
4use crate::error::ConfigError;
5
6impl Config {
7 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
9 let mut errors = Vec::new();
10
11 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 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 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 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 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 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 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 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 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 let effective_slots = if self.schedule.preferred_times.is_empty() {
195 0
196 } else {
197 let base_count: usize = self
199 .schedule
200 .preferred_times
201 .iter()
202 .map(|t| if t == "auto" { 3 } else { 1 })
203 .sum();
204 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 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 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 for (i, source) in self.content_sources.sources.iter().enumerate() {
251 if !self.deployment_mode.allows_source_type(&source.source_type) {
252 errors.push(ConfigError::InvalidValue {
253 field: format!("content_sources.sources[{}].source_type", i),
254 message: format!(
255 "source type '{}' is not available in {} deployment mode",
256 source.source_type, self.deployment_mode
257 ),
258 });
259 }
260 }
261
262 if errors.is_empty() {
263 Ok(())
264 } else {
265 Err(errors)
266 }
267 }
268}
269
270fn is_valid_hhmm(s: &str) -> bool {
272 let parts: Vec<&str> = s.split(':').collect();
273 if parts.len() != 2 {
274 return false;
275 }
276 let Ok(hour) = parts[0].parse::<u8>() else {
277 return false;
278 };
279 let Ok(minute) = parts[1].parse::<u8>() else {
280 return false;
281 };
282 hour <= 23 && minute <= 59
283}