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;
11
12use crate::error::ConfigError;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::env;
16use std::path::PathBuf;
17
18/// Top-level configuration for the Tuitbot agent.
19#[derive(Debug, Clone, Default, Deserialize, Serialize)]
20pub struct Config {
21    /// X API credentials.
22    #[serde(default)]
23    pub x_api: XApiConfig,
24
25    /// Authentication settings.
26    #[serde(default)]
27    pub auth: AuthConfig,
28
29    /// Business profile for content targeting.
30    #[serde(default)]
31    pub business: BusinessProfile,
32
33    /// Scoring engine weights and threshold.
34    #[serde(default)]
35    pub scoring: ScoringConfig,
36
37    /// Safety limits for API actions.
38    #[serde(default)]
39    pub limits: LimitsConfig,
40
41    /// Automation interval settings.
42    #[serde(default)]
43    pub intervals: IntervalsConfig,
44
45    /// LLM provider configuration.
46    #[serde(default)]
47    pub llm: LlmConfig,
48
49    /// Target account monitoring configuration.
50    #[serde(default)]
51    pub targets: TargetsConfig,
52
53    /// Enable approval mode: queue posts for human review instead of posting.
54    #[serde(default)]
55    pub approval_mode: bool,
56
57    /// Data storage configuration.
58    #[serde(default)]
59    pub storage: StorageConfig,
60
61    /// Logging and observability settings.
62    #[serde(default)]
63    pub logging: LoggingConfig,
64
65    /// Active hours schedule for posting.
66    #[serde(default)]
67    pub schedule: ScheduleConfig,
68}
69
70/// X API credentials.
71#[derive(Debug, Clone, Default, Deserialize, Serialize)]
72pub struct XApiConfig {
73    /// OAuth 2.0 client ID.
74    #[serde(default)]
75    pub client_id: String,
76
77    /// OAuth 2.0 client secret (optional for public clients).
78    #[serde(default)]
79    pub client_secret: Option<String>,
80}
81
82/// Authentication mode and callback settings.
83#[derive(Debug, Clone, Deserialize, Serialize)]
84pub struct AuthConfig {
85    /// Auth mode: "manual" or "local_callback".
86    #[serde(default = "default_auth_mode")]
87    pub mode: String,
88
89    /// Host for local callback server.
90    #[serde(default = "default_callback_host")]
91    pub callback_host: String,
92
93    /// Port for local callback server.
94    #[serde(default = "default_callback_port")]
95    pub callback_port: u16,
96}
97
98/// Business profile for content targeting and keyword matching.
99#[derive(Debug, Clone, Default, Deserialize, Serialize)]
100pub struct BusinessProfile {
101    /// Name of the user's product.
102    #[serde(default)]
103    pub product_name: String,
104
105    /// One-line description of the product.
106    #[serde(default)]
107    pub product_description: String,
108
109    /// URL to the product website.
110    #[serde(default)]
111    pub product_url: Option<String>,
112
113    /// Description of the target audience.
114    #[serde(default)]
115    pub target_audience: String,
116
117    /// Keywords for tweet discovery.
118    #[serde(default)]
119    pub product_keywords: Vec<String>,
120
121    /// Competitor-related keywords for discovery.
122    #[serde(default)]
123    pub competitor_keywords: Vec<String>,
124
125    /// Topics for content generation.
126    #[serde(default)]
127    pub industry_topics: Vec<String>,
128
129    /// Brand voice / personality description for all generated content.
130    #[serde(default)]
131    pub brand_voice: Option<String>,
132
133    /// Style guidelines specific to replies.
134    #[serde(default)]
135    pub reply_style: Option<String>,
136
137    /// Style guidelines specific to original tweets and threads.
138    #[serde(default)]
139    pub content_style: Option<String>,
140
141    /// Opinions the persona holds (used to add variety to generated content).
142    #[serde(default)]
143    pub persona_opinions: Vec<String>,
144
145    /// Experiences the persona can reference (keeps content authentic).
146    #[serde(default)]
147    pub persona_experiences: Vec<String>,
148
149    /// Core content pillars (broad themes the account focuses on).
150    #[serde(default)]
151    pub content_pillars: Vec<String>,
152}
153
154/// Scoring engine weights and threshold.
155#[derive(Debug, Clone, Deserialize, Serialize)]
156pub struct ScoringConfig {
157    /// Minimum score (0-100) to trigger a reply.
158    #[serde(default = "default_threshold")]
159    pub threshold: u32,
160
161    /// Maximum points for keyword relevance.
162    #[serde(default = "default_keyword_relevance_max")]
163    pub keyword_relevance_max: f32,
164
165    /// Maximum points for author follower count.
166    #[serde(default = "default_follower_count_max")]
167    pub follower_count_max: f32,
168
169    /// Maximum points for tweet recency.
170    #[serde(default = "default_recency_max")]
171    pub recency_max: f32,
172
173    /// Maximum points for engagement rate.
174    #[serde(default = "default_engagement_rate_max")]
175    pub engagement_rate_max: f32,
176
177    /// Maximum points for reply count signal (fewer replies = higher score).
178    #[serde(default = "default_reply_count_max")]
179    pub reply_count_max: f32,
180
181    /// Maximum points for content type signal (text-only originals score highest).
182    #[serde(default = "default_content_type_max")]
183    pub content_type_max: f32,
184}
185
186/// Safety limits for API actions.
187#[derive(Debug, Clone, Deserialize, Serialize)]
188pub struct LimitsConfig {
189    /// Maximum replies per day.
190    #[serde(default = "default_max_replies_per_day")]
191    pub max_replies_per_day: u32,
192
193    /// Maximum original tweets per day.
194    #[serde(default = "default_max_tweets_per_day")]
195    pub max_tweets_per_day: u32,
196
197    /// Maximum threads per week.
198    #[serde(default = "default_max_threads_per_week")]
199    pub max_threads_per_week: u32,
200
201    /// Minimum delay between actions in seconds.
202    #[serde(default = "default_min_action_delay_seconds")]
203    pub min_action_delay_seconds: u64,
204
205    /// Maximum delay between actions in seconds.
206    #[serde(default = "default_max_action_delay_seconds")]
207    pub max_action_delay_seconds: u64,
208
209    /// Maximum replies to the same author per day.
210    #[serde(default = "default_max_replies_per_author_per_day")]
211    pub max_replies_per_author_per_day: u32,
212
213    /// Phrases that should never appear in generated replies.
214    #[serde(default = "default_banned_phrases")]
215    pub banned_phrases: Vec<String>,
216
217    /// Fraction of replies that may mention the product (0.0 - 1.0).
218    #[serde(default = "default_product_mention_ratio")]
219    pub product_mention_ratio: f32,
220}
221
222/// Automation interval settings.
223#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct IntervalsConfig {
225    /// Seconds between mention checks.
226    #[serde(default = "default_mentions_check_seconds")]
227    pub mentions_check_seconds: u64,
228
229    /// Seconds between discovery searches.
230    #[serde(default = "default_discovery_search_seconds")]
231    pub discovery_search_seconds: u64,
232
233    /// Seconds for content post window.
234    #[serde(default = "default_content_post_window_seconds")]
235    pub content_post_window_seconds: u64,
236
237    /// Seconds between thread posts.
238    #[serde(default = "default_thread_interval_seconds")]
239    pub thread_interval_seconds: u64,
240}
241
242/// Target account monitoring configuration.
243#[derive(Debug, Clone, Default, Deserialize, Serialize)]
244pub struct TargetsConfig {
245    /// Target account usernames to monitor (without @).
246    #[serde(default)]
247    pub accounts: Vec<String>,
248
249    /// Maximum target account replies per day (separate from general limit).
250    #[serde(default = "default_max_target_replies_per_day")]
251    pub max_target_replies_per_day: u32,
252
253    /// Whether to auto-follow target accounts.
254    #[serde(default)]
255    pub auto_follow: bool,
256
257    /// Number of days to wait after following before engaging.
258    #[serde(default = "default_follow_warmup_days")]
259    pub follow_warmup_days: u32,
260}
261
262fn default_max_target_replies_per_day() -> u32 {
263    3
264}
265fn default_follow_warmup_days() -> u32 {
266    3
267}
268
269/// LLM provider configuration.
270#[derive(Debug, Clone, Default, Deserialize, Serialize)]
271pub struct LlmConfig {
272    /// LLM provider name: "openai", "anthropic", or "ollama".
273    #[serde(default)]
274    pub provider: String,
275
276    /// API key for the LLM provider (not needed for ollama).
277    #[serde(default)]
278    pub api_key: Option<String>,
279
280    /// Provider-specific model name.
281    #[serde(default)]
282    pub model: String,
283
284    /// Override URL for custom endpoints.
285    #[serde(default)]
286    pub base_url: Option<String>,
287}
288
289/// Data storage configuration.
290#[derive(Debug, Clone, Deserialize, Serialize)]
291pub struct StorageConfig {
292    /// Path to the SQLite database file.
293    #[serde(default = "default_db_path")]
294    pub db_path: String,
295
296    /// Number of days to retain data.
297    #[serde(default = "default_retention_days")]
298    pub retention_days: u32,
299}
300
301/// Logging and observability settings.
302#[derive(Debug, Clone, Default, Deserialize, Serialize)]
303pub struct LoggingConfig {
304    /// Seconds between periodic status summaries (0 = disabled).
305    #[serde(default)]
306    pub status_interval_seconds: u64,
307}
308
309/// Active hours schedule configuration.
310#[derive(Debug, Clone, Deserialize, Serialize)]
311pub struct ScheduleConfig {
312    /// IANA timezone name (e.g. "America/New_York", "UTC").
313    #[serde(default = "default_timezone")]
314    pub timezone: String,
315
316    /// Hour of day (0-23) when active posting window starts.
317    #[serde(default = "default_active_hours_start")]
318    pub active_hours_start: u8,
319
320    /// Hour of day (0-23) when active posting window ends.
321    #[serde(default = "default_active_hours_end")]
322    pub active_hours_end: u8,
323
324    /// Days of the week when posting is active (e.g. ["Mon", "Tue", ...]).
325    #[serde(default = "default_active_days")]
326    pub active_days: Vec<String>,
327
328    /// Preferred posting times for tweets (HH:MM in 24h format, in configured timezone).
329    /// When set, the content loop posts at these specific times instead of using interval mode.
330    /// Use "auto" for research-backed defaults: 09:15, 12:30, 17:00.
331    #[serde(default)]
332    pub preferred_times: Vec<String>,
333
334    /// Per-day overrides for preferred posting times.
335    /// Keys are day abbreviations (Mon-Sun), values are lists of "HH:MM" times.
336    /// Days not listed use the base `preferred_times`. Empty list = no posts that day.
337    #[serde(default)]
338    pub preferred_times_override: HashMap<String, Vec<String>>,
339
340    /// Preferred day for weekly thread posting (Mon-Sun). None = interval mode.
341    #[serde(default)]
342    pub thread_preferred_day: Option<String>,
343
344    /// Preferred time for weekly thread posting (HH:MM, 24h format).
345    #[serde(default = "default_thread_preferred_time")]
346    pub thread_preferred_time: String,
347}
348
349impl Default for ScheduleConfig {
350    fn default() -> Self {
351        Self {
352            timezone: default_timezone(),
353            active_hours_start: default_active_hours_start(),
354            active_hours_end: default_active_hours_end(),
355            active_days: default_active_days(),
356            preferred_times: Vec::new(),
357            preferred_times_override: HashMap::new(),
358            thread_preferred_day: None,
359            thread_preferred_time: default_thread_preferred_time(),
360        }
361    }
362}
363
364fn default_timezone() -> String {
365    "UTC".to_string()
366}
367fn default_active_hours_start() -> u8 {
368    8
369}
370fn default_active_hours_end() -> u8 {
371    22
372}
373fn default_active_days() -> Vec<String> {
374    vec![
375        "Mon".to_string(),
376        "Tue".to_string(),
377        "Wed".to_string(),
378        "Thu".to_string(),
379        "Fri".to_string(),
380        "Sat".to_string(),
381        "Sun".to_string(),
382    ]
383}
384fn default_thread_preferred_time() -> String {
385    "10:00".to_string()
386}
387
388// --- Default value functions for serde ---
389
390fn default_auth_mode() -> String {
391    "manual".to_string()
392}
393fn default_callback_host() -> String {
394    "127.0.0.1".to_string()
395}
396fn default_callback_port() -> u16 {
397    8080
398}
399fn default_threshold() -> u32 {
400    60
401}
402fn default_keyword_relevance_max() -> f32 {
403    25.0
404}
405fn default_follower_count_max() -> f32 {
406    15.0
407}
408fn default_recency_max() -> f32 {
409    10.0
410}
411fn default_engagement_rate_max() -> f32 {
412    15.0
413}
414fn default_reply_count_max() -> f32 {
415    15.0
416}
417fn default_content_type_max() -> f32 {
418    10.0
419}
420fn default_max_replies_per_day() -> u32 {
421    5
422}
423fn default_max_tweets_per_day() -> u32 {
424    6
425}
426fn default_max_threads_per_week() -> u32 {
427    1
428}
429fn default_min_action_delay_seconds() -> u64 {
430    45
431}
432fn default_max_action_delay_seconds() -> u64 {
433    180
434}
435fn default_mentions_check_seconds() -> u64 {
436    300
437}
438fn default_discovery_search_seconds() -> u64 {
439    900
440}
441fn default_content_post_window_seconds() -> u64 {
442    10800
443}
444fn default_thread_interval_seconds() -> u64 {
445    604800
446}
447fn default_max_replies_per_author_per_day() -> u32 {
448    1
449}
450fn default_banned_phrases() -> Vec<String> {
451    vec![
452        "check out".to_string(),
453        "you should try".to_string(),
454        "I recommend".to_string(),
455        "link in bio".to_string(),
456    ]
457}
458fn default_product_mention_ratio() -> f32 {
459    0.2
460}
461fn default_db_path() -> String {
462    "~/.tuitbot/tuitbot.db".to_string()
463}
464fn default_retention_days() -> u32 {
465    90
466}
467
468impl Config {
469    /// Load configuration from a TOML file with environment variable overrides.
470    ///
471    /// The loading sequence:
472    /// 1. Determine config file path (argument > `TUITBOT_CONFIG` env var > default)
473    /// 2. Parse TOML file (or use defaults if default path doesn't exist)
474    /// 3. Apply environment variable overrides
475    pub fn load(config_path: Option<&str>) -> Result<Config, ConfigError> {
476        let (path, explicit) = Self::resolve_config_path(config_path);
477
478        let mut config = match std::fs::read_to_string(&path) {
479            Ok(contents) => toml::from_str::<Config>(&contents)
480                .map_err(|e| ConfigError::ParseError { source: e })?,
481            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
482                if explicit {
483                    return Err(ConfigError::FileNotFound {
484                        path: path.display().to_string(),
485                    });
486                }
487                Config::default()
488            }
489            Err(_) => {
490                return Err(ConfigError::FileNotFound {
491                    path: path.display().to_string(),
492                });
493            }
494        };
495
496        config.apply_env_overrides()?;
497
498        Ok(config)
499    }
500
501    /// Load configuration and validate it, returning all validation errors at once.
502    pub fn load_and_validate(config_path: Option<&str>) -> Result<Config, Vec<ConfigError>> {
503        let config = Config::load(config_path).map_err(|e| vec![e])?;
504        config.validate()?;
505        Ok(config)
506    }
507
508    /// Validate the configuration, returning all errors found (not just the first).
509    pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
510        let mut errors = Vec::new();
511
512        // Validate business profile
513        if self.business.product_name.is_empty() {
514            errors.push(ConfigError::MissingField {
515                field: "business.product_name".to_string(),
516            });
517        }
518
519        if self.business.product_keywords.is_empty() && self.business.competitor_keywords.is_empty()
520        {
521            errors.push(ConfigError::MissingField {
522                field: "business.product_keywords or business.competitor_keywords".to_string(),
523            });
524        }
525
526        // Validate LLM provider
527        if !self.llm.provider.is_empty() {
528            match self.llm.provider.as_str() {
529                "openai" | "anthropic" | "ollama" => {}
530                _ => {
531                    errors.push(ConfigError::InvalidValue {
532                        field: "llm.provider".to_string(),
533                        message: "must be openai, anthropic, or ollama".to_string(),
534                    });
535                }
536            }
537
538            if matches!(self.llm.provider.as_str(), "openai" | "anthropic") {
539                match &self.llm.api_key {
540                    Some(key) if !key.is_empty() => {}
541                    _ => {
542                        errors.push(ConfigError::MissingField {
543                            field: format!(
544                                "llm.api_key (required for {} provider)",
545                                self.llm.provider
546                            ),
547                        });
548                    }
549                }
550            }
551        }
552
553        // Validate auth mode
554        if !self.auth.mode.is_empty() {
555            match self.auth.mode.as_str() {
556                "manual" | "local_callback" => {}
557                _ => {
558                    errors.push(ConfigError::InvalidValue {
559                        field: "auth.mode".to_string(),
560                        message: "must be manual or local_callback".to_string(),
561                    });
562                }
563            }
564        }
565
566        // Validate scoring threshold
567        if self.scoring.threshold > 100 {
568            errors.push(ConfigError::InvalidValue {
569                field: "scoring.threshold".to_string(),
570                message: "must be between 0 and 100".to_string(),
571            });
572        }
573
574        // Validate limits
575        if self.limits.max_replies_per_day == 0 {
576            errors.push(ConfigError::InvalidValue {
577                field: "limits.max_replies_per_day".to_string(),
578                message: "must be greater than 0".to_string(),
579            });
580        }
581
582        if self.limits.max_tweets_per_day == 0 {
583            errors.push(ConfigError::InvalidValue {
584                field: "limits.max_tweets_per_day".to_string(),
585                message: "must be greater than 0".to_string(),
586            });
587        }
588
589        if self.limits.max_threads_per_week == 0 {
590            errors.push(ConfigError::InvalidValue {
591                field: "limits.max_threads_per_week".to_string(),
592                message: "must be greater than 0".to_string(),
593            });
594        }
595
596        if self.limits.min_action_delay_seconds > self.limits.max_action_delay_seconds {
597            errors.push(ConfigError::InvalidValue {
598                field: "limits.min_action_delay_seconds".to_string(),
599                message: "must be less than or equal to max_action_delay_seconds".to_string(),
600            });
601        }
602
603        // Validate schedule
604        if self.schedule.active_hours_start > 23 {
605            errors.push(ConfigError::InvalidValue {
606                field: "schedule.active_hours_start".to_string(),
607                message: "must be between 0 and 23".to_string(),
608            });
609        }
610        if self.schedule.active_hours_end > 23 {
611            errors.push(ConfigError::InvalidValue {
612                field: "schedule.active_hours_end".to_string(),
613                message: "must be between 0 and 23".to_string(),
614            });
615        }
616        if !self.schedule.timezone.is_empty()
617            && self.schedule.timezone.parse::<chrono_tz::Tz>().is_err()
618        {
619            errors.push(ConfigError::InvalidValue {
620                field: "schedule.timezone".to_string(),
621                message: format!(
622                    "'{}' is not a valid IANA timezone name",
623                    self.schedule.timezone
624                ),
625            });
626        }
627        let valid_days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
628        for day in &self.schedule.active_days {
629            if !valid_days.contains(&day.as_str()) {
630                errors.push(ConfigError::InvalidValue {
631                    field: "schedule.active_days".to_string(),
632                    message: format!(
633                        "'{}' is not a valid day abbreviation (use Mon, Tue, Wed, Thu, Fri, Sat, Sun)",
634                        day
635                    ),
636                });
637                break;
638            }
639        }
640
641        // Validate preferred_times
642        for time_str in &self.schedule.preferred_times {
643            if time_str != "auto" && !is_valid_hhmm(time_str) {
644                errors.push(ConfigError::InvalidValue {
645                    field: "schedule.preferred_times".to_string(),
646                    message: format!(
647                        "'{}' is not a valid time (use HH:MM 24h format or \"auto\")",
648                        time_str
649                    ),
650                });
651                break;
652            }
653        }
654
655        // Validate preferred_times_override keys and values
656        for (day, times) in &self.schedule.preferred_times_override {
657            if !valid_days.contains(&day.as_str()) {
658                errors.push(ConfigError::InvalidValue {
659                    field: "schedule.preferred_times_override".to_string(),
660                    message: format!(
661                        "'{}' is not a valid day abbreviation (use Mon, Tue, Wed, Thu, Fri, Sat, Sun)",
662                        day
663                    ),
664                });
665                break;
666            }
667            for time_str in times {
668                if !is_valid_hhmm(time_str) {
669                    errors.push(ConfigError::InvalidValue {
670                        field: "schedule.preferred_times_override".to_string(),
671                        message: format!(
672                            "'{}' is not a valid time for {} (use HH:MM 24h format)",
673                            time_str, day
674                        ),
675                    });
676                    break;
677                }
678            }
679        }
680
681        // Count effective slots per day vs max_tweets_per_day
682        let effective_slots = if self.schedule.preferred_times.is_empty() {
683            0
684        } else {
685            // "auto" expands to 3 slots
686            let base_count: usize = self
687                .schedule
688                .preferred_times
689                .iter()
690                .map(|t| if t == "auto" { 3 } else { 1 })
691                .sum();
692            // Check max across all override days too
693            let max_override = self
694                .schedule
695                .preferred_times_override
696                .values()
697                .map(|v| v.len())
698                .max()
699                .unwrap_or(0);
700            base_count.max(max_override)
701        };
702        if effective_slots > self.limits.max_tweets_per_day as usize {
703            errors.push(ConfigError::InvalidValue {
704                field: "schedule.preferred_times".to_string(),
705                message: format!(
706                    "preferred_times has {} slots but limits.max_tweets_per_day is {} — \
707                     increase the limit or reduce the number of time slots",
708                    effective_slots, self.limits.max_tweets_per_day
709                ),
710            });
711        }
712
713        // Validate thread_preferred_day
714        if let Some(day) = &self.schedule.thread_preferred_day {
715            if !valid_days.contains(&day.as_str()) {
716                errors.push(ConfigError::InvalidValue {
717                    field: "schedule.thread_preferred_day".to_string(),
718                    message: format!(
719                        "'{}' is not a valid day abbreviation (use Mon, Tue, Wed, Thu, Fri, Sat, Sun)",
720                        day
721                    ),
722                });
723            }
724        }
725
726        // Validate thread_preferred_time
727        if !is_valid_hhmm(&self.schedule.thread_preferred_time) {
728            errors.push(ConfigError::InvalidValue {
729                field: "schedule.thread_preferred_time".to_string(),
730                message: format!(
731                    "'{}' is not a valid time (use HH:MM 24h format)",
732                    self.schedule.thread_preferred_time
733                ),
734            });
735        }
736
737        if errors.is_empty() {
738            Ok(())
739        } else {
740            Err(errors)
741        }
742    }
743
744    /// Resolve the config file path from arguments, env vars, or default.
745    ///
746    /// Returns `(path, explicit)` where `explicit` is true if the path was
747    /// explicitly provided (via argument or env var) rather than using the default.
748    fn resolve_config_path(config_path: Option<&str>) -> (PathBuf, bool) {
749        if let Some(path) = config_path {
750            return (expand_tilde(path), true);
751        }
752
753        if let Ok(env_path) = env::var("TUITBOT_CONFIG") {
754            return (expand_tilde(&env_path), true);
755        }
756
757        (expand_tilde("~/.tuitbot/config.toml"), false)
758    }
759
760    /// Apply environment variable overrides to the configuration.
761    ///
762    /// Environment variables use the `TUITBOT_` prefix with double underscores
763    /// separating nested keys (e.g., `TUITBOT_LLM__API_KEY`).
764    fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
765        // X API
766        if let Ok(val) = env::var("TUITBOT_X_API__CLIENT_ID") {
767            self.x_api.client_id = val;
768        }
769        if let Ok(val) = env::var("TUITBOT_X_API__CLIENT_SECRET") {
770            self.x_api.client_secret = Some(val);
771        }
772
773        // Auth
774        if let Ok(val) = env::var("TUITBOT_AUTH__MODE") {
775            self.auth.mode = val;
776        }
777        if let Ok(val) = env::var("TUITBOT_AUTH__CALLBACK_HOST") {
778            self.auth.callback_host = val;
779        }
780        if let Ok(val) = env::var("TUITBOT_AUTH__CALLBACK_PORT") {
781            self.auth.callback_port = parse_env_u16("TUITBOT_AUTH__CALLBACK_PORT", &val)?;
782        }
783
784        // Business
785        if let Ok(val) = env::var("TUITBOT_BUSINESS__PRODUCT_NAME") {
786            self.business.product_name = val;
787        }
788        if let Ok(val) = env::var("TUITBOT_BUSINESS__PRODUCT_DESCRIPTION") {
789            self.business.product_description = val;
790        }
791        if let Ok(val) = env::var("TUITBOT_BUSINESS__PRODUCT_URL") {
792            self.business.product_url = Some(val);
793        }
794        if let Ok(val) = env::var("TUITBOT_BUSINESS__TARGET_AUDIENCE") {
795            self.business.target_audience = val;
796        }
797        if let Ok(val) = env::var("TUITBOT_BUSINESS__PRODUCT_KEYWORDS") {
798            self.business.product_keywords = split_csv(&val);
799        }
800        if let Ok(val) = env::var("TUITBOT_BUSINESS__COMPETITOR_KEYWORDS") {
801            self.business.competitor_keywords = split_csv(&val);
802        }
803        if let Ok(val) = env::var("TUITBOT_BUSINESS__INDUSTRY_TOPICS") {
804            self.business.industry_topics = split_csv(&val);
805        }
806        if let Ok(val) = env::var("TUITBOT_BUSINESS__BRAND_VOICE") {
807            self.business.brand_voice = Some(val);
808        }
809        if let Ok(val) = env::var("TUITBOT_BUSINESS__REPLY_STYLE") {
810            self.business.reply_style = Some(val);
811        }
812        if let Ok(val) = env::var("TUITBOT_BUSINESS__CONTENT_STYLE") {
813            self.business.content_style = Some(val);
814        }
815
816        // Scoring
817        if let Ok(val) = env::var("TUITBOT_SCORING__THRESHOLD") {
818            self.scoring.threshold = parse_env_u32("TUITBOT_SCORING__THRESHOLD", &val)?;
819        }
820
821        if let Ok(val) = env::var("TUITBOT_SCORING__REPLY_COUNT_MAX") {
822            self.scoring.reply_count_max = parse_env_f32("TUITBOT_SCORING__REPLY_COUNT_MAX", &val)?;
823        }
824        if let Ok(val) = env::var("TUITBOT_SCORING__CONTENT_TYPE_MAX") {
825            self.scoring.content_type_max =
826                parse_env_f32("TUITBOT_SCORING__CONTENT_TYPE_MAX", &val)?;
827        }
828
829        // Limits
830        if let Ok(val) = env::var("TUITBOT_LIMITS__MAX_REPLIES_PER_DAY") {
831            self.limits.max_replies_per_day =
832                parse_env_u32("TUITBOT_LIMITS__MAX_REPLIES_PER_DAY", &val)?;
833        }
834        if let Ok(val) = env::var("TUITBOT_LIMITS__MAX_TWEETS_PER_DAY") {
835            self.limits.max_tweets_per_day =
836                parse_env_u32("TUITBOT_LIMITS__MAX_TWEETS_PER_DAY", &val)?;
837        }
838        if let Ok(val) = env::var("TUITBOT_LIMITS__MAX_THREADS_PER_WEEK") {
839            self.limits.max_threads_per_week =
840                parse_env_u32("TUITBOT_LIMITS__MAX_THREADS_PER_WEEK", &val)?;
841        }
842        if let Ok(val) = env::var("TUITBOT_LIMITS__MIN_ACTION_DELAY_SECONDS") {
843            self.limits.min_action_delay_seconds =
844                parse_env_u64("TUITBOT_LIMITS__MIN_ACTION_DELAY_SECONDS", &val)?;
845        }
846        if let Ok(val) = env::var("TUITBOT_LIMITS__MAX_ACTION_DELAY_SECONDS") {
847            self.limits.max_action_delay_seconds =
848                parse_env_u64("TUITBOT_LIMITS__MAX_ACTION_DELAY_SECONDS", &val)?;
849        }
850        if let Ok(val) = env::var("TUITBOT_LIMITS__MAX_REPLIES_PER_AUTHOR_PER_DAY") {
851            self.limits.max_replies_per_author_per_day =
852                parse_env_u32("TUITBOT_LIMITS__MAX_REPLIES_PER_AUTHOR_PER_DAY", &val)?;
853        }
854        if let Ok(val) = env::var("TUITBOT_LIMITS__BANNED_PHRASES") {
855            self.limits.banned_phrases = split_csv(&val);
856        }
857        if let Ok(val) = env::var("TUITBOT_LIMITS__PRODUCT_MENTION_RATIO") {
858            self.limits.product_mention_ratio =
859                parse_env_f32("TUITBOT_LIMITS__PRODUCT_MENTION_RATIO", &val)?;
860        }
861
862        // Intervals
863        if let Ok(val) = env::var("TUITBOT_INTERVALS__MENTIONS_CHECK_SECONDS") {
864            self.intervals.mentions_check_seconds =
865                parse_env_u64("TUITBOT_INTERVALS__MENTIONS_CHECK_SECONDS", &val)?;
866        }
867        if let Ok(val) = env::var("TUITBOT_INTERVALS__DISCOVERY_SEARCH_SECONDS") {
868            self.intervals.discovery_search_seconds =
869                parse_env_u64("TUITBOT_INTERVALS__DISCOVERY_SEARCH_SECONDS", &val)?;
870        }
871        if let Ok(val) = env::var("TUITBOT_INTERVALS__CONTENT_POST_WINDOW_SECONDS") {
872            self.intervals.content_post_window_seconds =
873                parse_env_u64("TUITBOT_INTERVALS__CONTENT_POST_WINDOW_SECONDS", &val)?;
874        }
875        if let Ok(val) = env::var("TUITBOT_INTERVALS__THREAD_INTERVAL_SECONDS") {
876            self.intervals.thread_interval_seconds =
877                parse_env_u64("TUITBOT_INTERVALS__THREAD_INTERVAL_SECONDS", &val)?;
878        }
879
880        // Targets
881        if let Ok(val) = env::var("TUITBOT_TARGETS__ACCOUNTS") {
882            self.targets.accounts = split_csv(&val);
883        }
884        if let Ok(val) = env::var("TUITBOT_TARGETS__MAX_TARGET_REPLIES_PER_DAY") {
885            self.targets.max_target_replies_per_day =
886                parse_env_u32("TUITBOT_TARGETS__MAX_TARGET_REPLIES_PER_DAY", &val)?;
887        }
888
889        // LLM
890        if let Ok(val) = env::var("TUITBOT_LLM__PROVIDER") {
891            self.llm.provider = val;
892        }
893        if let Ok(val) = env::var("TUITBOT_LLM__API_KEY") {
894            self.llm.api_key = Some(val);
895        }
896        if let Ok(val) = env::var("TUITBOT_LLM__MODEL") {
897            self.llm.model = val;
898        }
899        if let Ok(val) = env::var("TUITBOT_LLM__BASE_URL") {
900            self.llm.base_url = Some(val);
901        }
902
903        // Storage
904        if let Ok(val) = env::var("TUITBOT_STORAGE__DB_PATH") {
905            self.storage.db_path = val;
906        }
907        if let Ok(val) = env::var("TUITBOT_STORAGE__RETENTION_DAYS") {
908            self.storage.retention_days = parse_env_u32("TUITBOT_STORAGE__RETENTION_DAYS", &val)?;
909        }
910
911        // Logging
912        if let Ok(val) = env::var("TUITBOT_LOGGING__STATUS_INTERVAL_SECONDS") {
913            self.logging.status_interval_seconds =
914                parse_env_u64("TUITBOT_LOGGING__STATUS_INTERVAL_SECONDS", &val)?;
915        }
916
917        // Schedule
918        if let Ok(val) = env::var("TUITBOT_SCHEDULE__TIMEZONE") {
919            self.schedule.timezone = val;
920        }
921        if let Ok(val) = env::var("TUITBOT_SCHEDULE__ACTIVE_HOURS_START") {
922            self.schedule.active_hours_start =
923                parse_env_u8("TUITBOT_SCHEDULE__ACTIVE_HOURS_START", &val)?;
924        }
925        if let Ok(val) = env::var("TUITBOT_SCHEDULE__ACTIVE_HOURS_END") {
926            self.schedule.active_hours_end =
927                parse_env_u8("TUITBOT_SCHEDULE__ACTIVE_HOURS_END", &val)?;
928        }
929        if let Ok(val) = env::var("TUITBOT_SCHEDULE__ACTIVE_DAYS") {
930            self.schedule.active_days = split_csv(&val);
931        }
932        if let Ok(val) = env::var("TUITBOT_SCHEDULE__PREFERRED_TIMES") {
933            self.schedule.preferred_times = split_csv(&val);
934        }
935        if let Ok(val) = env::var("TUITBOT_SCHEDULE__THREAD_PREFERRED_DAY") {
936            let val = val.trim().to_string();
937            if val.is_empty() || val == "none" {
938                self.schedule.thread_preferred_day = None;
939            } else {
940                self.schedule.thread_preferred_day = Some(val);
941            }
942        }
943        if let Ok(val) = env::var("TUITBOT_SCHEDULE__THREAD_PREFERRED_TIME") {
944            self.schedule.thread_preferred_time = val;
945        }
946
947        // Approval mode
948        let explicit_approval = if let Ok(val) = env::var("TUITBOT_APPROVAL_MODE") {
949            self.approval_mode = parse_env_bool("TUITBOT_APPROVAL_MODE", &val)?;
950            true
951        } else {
952            false
953        };
954
955        // OpenClaw auto-detection: enable approval mode when running inside
956        // OpenClaw unless the user explicitly set TUITBOT_APPROVAL_MODE.
957        if !explicit_approval && env::vars().any(|(k, _)| k.starts_with("OPENCLAW_")) {
958            self.approval_mode = true;
959        }
960
961        Ok(())
962    }
963}
964
965/// Check if a string is a valid HH:MM time (24h format).
966fn is_valid_hhmm(s: &str) -> bool {
967    let parts: Vec<&str> = s.split(':').collect();
968    if parts.len() != 2 {
969        return false;
970    }
971    let Ok(hour) = parts[0].parse::<u8>() else {
972        return false;
973    };
974    let Ok(minute) = parts[1].parse::<u8>() else {
975        return false;
976    };
977    hour <= 23 && minute <= 59
978}
979
980/// Expand `~` at the start of a path to the user's home directory.
981fn expand_tilde(path: &str) -> PathBuf {
982    if let Some(rest) = path.strip_prefix("~/") {
983        if let Some(home) = dirs::home_dir() {
984            return home.join(rest);
985        }
986    } else if path == "~" {
987        if let Some(home) = dirs::home_dir() {
988            return home;
989        }
990    }
991    PathBuf::from(path)
992}
993
994/// Split a comma-separated string into trimmed, non-empty values.
995fn split_csv(s: &str) -> Vec<String> {
996    s.split(',')
997        .map(|v| v.trim().to_string())
998        .filter(|v| !v.is_empty())
999        .collect()
1000}
1001
1002/// Parse an environment variable value as `u16`.
1003fn parse_env_u16(var_name: &str, val: &str) -> Result<u16, ConfigError> {
1004    val.parse::<u16>().map_err(|_| ConfigError::InvalidValue {
1005        field: var_name.to_string(),
1006        message: format!("'{val}' is not a valid u16"),
1007    })
1008}
1009
1010/// Parse an environment variable value as `u32`.
1011fn parse_env_u32(var_name: &str, val: &str) -> Result<u32, ConfigError> {
1012    val.parse::<u32>().map_err(|_| ConfigError::InvalidValue {
1013        field: var_name.to_string(),
1014        message: format!("'{val}' is not a valid u32"),
1015    })
1016}
1017
1018/// Parse an environment variable value as `f32`.
1019fn parse_env_f32(var_name: &str, val: &str) -> Result<f32, ConfigError> {
1020    val.parse::<f32>().map_err(|_| ConfigError::InvalidValue {
1021        field: var_name.to_string(),
1022        message: format!("'{val}' is not a valid f32"),
1023    })
1024}
1025
1026/// Parse an environment variable value as `u64`.
1027fn parse_env_u64(var_name: &str, val: &str) -> Result<u64, ConfigError> {
1028    val.parse::<u64>().map_err(|_| ConfigError::InvalidValue {
1029        field: var_name.to_string(),
1030        message: format!("'{val}' is not a valid u64"),
1031    })
1032}
1033
1034/// Parse an environment variable value as `u8`.
1035fn parse_env_u8(var_name: &str, val: &str) -> Result<u8, ConfigError> {
1036    val.parse::<u8>().map_err(|_| ConfigError::InvalidValue {
1037        field: var_name.to_string(),
1038        message: format!("'{val}' is not a valid u8"),
1039    })
1040}
1041
1042/// Parse an environment variable value as a boolean.
1043///
1044/// Accepts: `true`, `false`, `1`, `0`, `yes`, `no` (case-insensitive).
1045fn parse_env_bool(var_name: &str, val: &str) -> Result<bool, ConfigError> {
1046    match val.trim().to_lowercase().as_str() {
1047        "true" | "1" | "yes" => Ok(true),
1048        "false" | "0" | "no" => Ok(false),
1049        _ => Err(ConfigError::InvalidValue {
1050            field: var_name.to_string(),
1051            message: format!("'{val}' is not a valid boolean (use true/false/1/0/yes/no)"),
1052        }),
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use std::env;
1060
1061    #[test]
1062    fn load_valid_toml() {
1063        let toml_str = r#"
1064[x_api]
1065client_id = "test-client-id"
1066
1067[business]
1068product_name = "TestProduct"
1069product_description = "A test product"
1070target_audience = "developers"
1071product_keywords = ["rust", "cli"]
1072industry_topics = ["rust", "development"]
1073
1074[llm]
1075provider = "ollama"
1076model = "llama2"
1077
1078[scoring]
1079threshold = 80
1080"#;
1081        let config: Config = toml::from_str(toml_str).expect("valid TOML");
1082        assert_eq!(config.x_api.client_id, "test-client-id");
1083        assert_eq!(config.business.product_name, "TestProduct");
1084        assert_eq!(config.scoring.threshold, 80);
1085        assert_eq!(config.llm.provider, "ollama");
1086    }
1087
1088    #[test]
1089    fn missing_sections_use_defaults() {
1090        let toml_str = r#"
1091[x_api]
1092client_id = "test"
1093"#;
1094        let config: Config = toml::from_str(toml_str).expect("valid TOML");
1095        assert_eq!(config.auth.mode, "manual");
1096        assert_eq!(config.auth.callback_port, 8080);
1097        assert_eq!(config.scoring.threshold, 60);
1098        assert_eq!(config.limits.max_replies_per_day, 5);
1099        assert_eq!(config.intervals.mentions_check_seconds, 300);
1100        assert_eq!(config.storage.db_path, "~/.tuitbot/tuitbot.db");
1101        assert_eq!(config.storage.retention_days, 90);
1102        assert_eq!(config.logging.status_interval_seconds, 0);
1103    }
1104
1105    #[test]
1106    fn env_var_override_string() {
1107        // Use a unique env var prefix to avoid test interference
1108        env::set_var("TUITBOT_LLM__PROVIDER", "anthropic");
1109        let mut config = Config::default();
1110        config.apply_env_overrides().expect("env override");
1111        assert_eq!(config.llm.provider, "anthropic");
1112        env::remove_var("TUITBOT_LLM__PROVIDER");
1113    }
1114
1115    #[test]
1116    fn env_var_override_numeric() {
1117        env::set_var("TUITBOT_SCORING__THRESHOLD", "85");
1118        let mut config = Config::default();
1119        config.apply_env_overrides().expect("env override");
1120        assert_eq!(config.scoring.threshold, 85);
1121        env::remove_var("TUITBOT_SCORING__THRESHOLD");
1122    }
1123
1124    #[test]
1125    fn env_var_override_csv() {
1126        env::set_var("TUITBOT_BUSINESS__PRODUCT_KEYWORDS", "rust, cli, tools");
1127        let mut config = Config::default();
1128        config.apply_env_overrides().expect("env override");
1129        assert_eq!(
1130            config.business.product_keywords,
1131            vec!["rust", "cli", "tools"]
1132        );
1133        env::remove_var("TUITBOT_BUSINESS__PRODUCT_KEYWORDS");
1134    }
1135
1136    #[test]
1137    fn env_var_invalid_numeric_returns_error() {
1138        // Test the parse function directly to avoid env var race conditions
1139        // with other tests that call apply_env_overrides()
1140        let result = parse_env_u32("TUITBOT_SCORING__THRESHOLD", "not_a_number");
1141        assert!(result.is_err());
1142        match result.unwrap_err() {
1143            ConfigError::InvalidValue { field, .. } => {
1144                assert_eq!(field, "TUITBOT_SCORING__THRESHOLD");
1145            }
1146            other => panic!("expected InvalidValue, got: {other}"),
1147        }
1148    }
1149
1150    #[test]
1151    fn validate_missing_product_name() {
1152        let config = Config::default();
1153        let errors = config.validate().unwrap_err();
1154        assert!(errors.iter().any(
1155            |e| matches!(e, ConfigError::MissingField { field } if field == "business.product_name")
1156        ));
1157    }
1158
1159    #[test]
1160    fn validate_invalid_llm_provider() {
1161        let mut config = Config::default();
1162        config.business.product_name = "Test".to_string();
1163        config.business.product_keywords = vec!["test".to_string()];
1164        config.llm.provider = "invalid_provider".to_string();
1165        let errors = config.validate().unwrap_err();
1166        assert!(errors.iter().any(
1167            |e| matches!(e, ConfigError::InvalidValue { field, .. } if field == "llm.provider")
1168        ));
1169    }
1170
1171    #[test]
1172    fn validate_threshold_over_100() {
1173        let mut config = Config::default();
1174        config.business.product_name = "Test".to_string();
1175        config.business.product_keywords = vec!["test".to_string()];
1176        config.llm.provider = "ollama".to_string();
1177        config.scoring.threshold = 101;
1178        let errors = config.validate().unwrap_err();
1179        assert!(errors.iter().any(
1180            |e| matches!(e, ConfigError::InvalidValue { field, .. } if field == "scoring.threshold")
1181        ));
1182    }
1183
1184    #[test]
1185    fn validate_threshold_boundary_values() {
1186        let mut config = Config::default();
1187        config.business.product_name = "Test".to_string();
1188        config.business.product_keywords = vec!["test".to_string()];
1189        config.llm.provider = "ollama".to_string();
1190
1191        config.scoring.threshold = 0;
1192        assert!(config.validate().is_ok());
1193
1194        config.scoring.threshold = 100;
1195        assert!(config.validate().is_ok());
1196    }
1197
1198    #[test]
1199    fn validate_returns_multiple_errors() {
1200        let mut config = Config::default();
1201        // Missing product_name (default is empty)
1202        // Missing keywords (default is empty)
1203        config.llm.provider = "invalid".to_string();
1204        config.scoring.threshold = 101;
1205        config.limits.max_replies_per_day = 0;
1206
1207        let errors = config.validate().unwrap_err();
1208        assert!(
1209            errors.len() >= 4,
1210            "expected at least 4 errors, got {}: {:?}",
1211            errors.len(),
1212            errors
1213        );
1214    }
1215
1216    #[test]
1217    fn validate_valid_config_passes() {
1218        let mut config = Config::default();
1219        config.business.product_name = "TestProduct".to_string();
1220        config.business.product_keywords = vec!["test".to_string()];
1221        config.llm.provider = "ollama".to_string();
1222        config.llm.model = "llama2".to_string();
1223        assert!(config.validate().is_ok());
1224    }
1225
1226    #[test]
1227    fn validate_openai_requires_api_key() {
1228        let mut config = Config::default();
1229        config.business.product_name = "Test".to_string();
1230        config.business.product_keywords = vec!["test".to_string()];
1231        config.llm.provider = "openai".to_string();
1232        config.llm.api_key = None;
1233        let errors = config.validate().unwrap_err();
1234        assert!(errors.iter().any(
1235            |e| matches!(e, ConfigError::MissingField { field } if field.contains("llm.api_key"))
1236        ));
1237    }
1238
1239    #[test]
1240    fn validate_delay_ordering() {
1241        let mut config = Config::default();
1242        config.business.product_name = "Test".to_string();
1243        config.business.product_keywords = vec!["test".to_string()];
1244        config.llm.provider = "ollama".to_string();
1245        config.limits.min_action_delay_seconds = 200;
1246        config.limits.max_action_delay_seconds = 100;
1247        let errors = config.validate().unwrap_err();
1248        assert!(errors.iter().any(|e| matches!(e, ConfigError::InvalidValue { field, .. } if field == "limits.min_action_delay_seconds")));
1249    }
1250
1251    #[test]
1252    fn expand_tilde_works() {
1253        let expanded = expand_tilde("~/.tuitbot/config.toml");
1254        assert!(!expanded.to_string_lossy().starts_with('~'));
1255    }
1256
1257    #[test]
1258    fn split_csv_trims_and_filters() {
1259        let result = split_csv("  rust , cli ,, tools  ");
1260        assert_eq!(result, vec!["rust", "cli", "tools"]);
1261    }
1262
1263    #[test]
1264    fn validate_preferred_times_valid() {
1265        let mut config = Config::default();
1266        config.business.product_name = "Test".to_string();
1267        config.business.product_keywords = vec!["test".to_string()];
1268        config.llm.provider = "ollama".to_string();
1269        config.schedule.preferred_times = vec!["09:15".to_string(), "12:30".to_string()];
1270        assert!(config.validate().is_ok());
1271    }
1272
1273    #[test]
1274    fn validate_preferred_times_auto() {
1275        let mut config = Config::default();
1276        config.business.product_name = "Test".to_string();
1277        config.business.product_keywords = vec!["test".to_string()];
1278        config.llm.provider = "ollama".to_string();
1279        config.schedule.preferred_times = vec!["auto".to_string()];
1280        assert!(config.validate().is_ok());
1281    }
1282
1283    #[test]
1284    fn validate_preferred_times_invalid_format() {
1285        let mut config = Config::default();
1286        config.business.product_name = "Test".to_string();
1287        config.business.product_keywords = vec!["test".to_string()];
1288        config.llm.provider = "ollama".to_string();
1289        config.schedule.preferred_times = vec!["9:15".to_string(), "25:00".to_string()];
1290        let errors = config.validate().unwrap_err();
1291        assert!(errors.iter().any(
1292            |e| matches!(e, ConfigError::InvalidValue { field, .. } if field == "schedule.preferred_times")
1293        ));
1294    }
1295
1296    #[test]
1297    fn validate_preferred_times_exceeds_max_tweets() {
1298        let mut config = Config::default();
1299        config.business.product_name = "Test".to_string();
1300        config.business.product_keywords = vec!["test".to_string()];
1301        config.llm.provider = "ollama".to_string();
1302        config.limits.max_tweets_per_day = 2;
1303        config.schedule.preferred_times = vec![
1304            "09:00".to_string(),
1305            "12:00".to_string(),
1306            "17:00".to_string(),
1307        ];
1308        let errors = config.validate().unwrap_err();
1309        assert!(errors.iter().any(
1310            |e| matches!(e, ConfigError::InvalidValue { field, message } if field == "schedule.preferred_times" && message.contains("3 slots"))
1311        ));
1312    }
1313
1314    #[test]
1315    fn validate_thread_preferred_day_invalid() {
1316        let mut config = Config::default();
1317        config.business.product_name = "Test".to_string();
1318        config.business.product_keywords = vec!["test".to_string()];
1319        config.llm.provider = "ollama".to_string();
1320        config.schedule.thread_preferred_day = Some("Monday".to_string());
1321        let errors = config.validate().unwrap_err();
1322        assert!(errors.iter().any(
1323            |e| matches!(e, ConfigError::InvalidValue { field, .. } if field == "schedule.thread_preferred_day")
1324        ));
1325    }
1326
1327    #[test]
1328    fn validate_thread_preferred_time_invalid() {
1329        let mut config = Config::default();
1330        config.business.product_name = "Test".to_string();
1331        config.business.product_keywords = vec!["test".to_string()];
1332        config.llm.provider = "ollama".to_string();
1333        config.schedule.thread_preferred_time = "25:00".to_string();
1334        let errors = config.validate().unwrap_err();
1335        assert!(errors.iter().any(
1336            |e| matches!(e, ConfigError::InvalidValue { field, .. } if field == "schedule.thread_preferred_time")
1337        ));
1338    }
1339
1340    #[test]
1341    fn preferred_times_override_invalid_day() {
1342        let mut config = Config::default();
1343        config.business.product_name = "Test".to_string();
1344        config.business.product_keywords = vec!["test".to_string()];
1345        config.llm.provider = "ollama".to_string();
1346        config
1347            .schedule
1348            .preferred_times_override
1349            .insert("Monday".to_string(), vec!["09:00".to_string()]);
1350        let errors = config.validate().unwrap_err();
1351        assert!(errors.iter().any(
1352            |e| matches!(e, ConfigError::InvalidValue { field, .. } if field == "schedule.preferred_times_override")
1353        ));
1354    }
1355
1356    #[test]
1357    fn preferred_times_toml_roundtrip() {
1358        let toml_str = r#"
1359[x_api]
1360client_id = "test"
1361
1362[business]
1363product_name = "Test"
1364product_keywords = ["test"]
1365
1366[llm]
1367provider = "ollama"
1368model = "llama2"
1369
1370[schedule]
1371timezone = "America/New_York"
1372preferred_times = ["09:15", "12:30", "17:00"]
1373thread_preferred_day = "Tue"
1374thread_preferred_time = "10:00"
1375"#;
1376        let config: Config = toml::from_str(toml_str).expect("valid TOML");
1377        assert_eq!(
1378            config.schedule.preferred_times,
1379            vec!["09:15", "12:30", "17:00"]
1380        );
1381        assert_eq!(
1382            config.schedule.thread_preferred_day,
1383            Some("Tue".to_string())
1384        );
1385        assert_eq!(config.schedule.thread_preferred_time, "10:00");
1386    }
1387
1388    #[test]
1389    fn config_file_not_found_explicit_path() {
1390        let result = Config::load(Some("/nonexistent/path/config.toml"));
1391        assert!(result.is_err());
1392        match result.unwrap_err() {
1393            ConfigError::FileNotFound { path } => {
1394                assert_eq!(path, "/nonexistent/path/config.toml");
1395            }
1396            other => panic!("expected FileNotFound, got: {other}"),
1397        }
1398    }
1399
1400    #[test]
1401    fn parse_env_bool_values() {
1402        assert!(parse_env_bool("TEST", "true").unwrap());
1403        assert!(parse_env_bool("TEST", "True").unwrap());
1404        assert!(parse_env_bool("TEST", "1").unwrap());
1405        assert!(parse_env_bool("TEST", "yes").unwrap());
1406        assert!(parse_env_bool("TEST", "YES").unwrap());
1407        assert!(!parse_env_bool("TEST", "false").unwrap());
1408        assert!(!parse_env_bool("TEST", "False").unwrap());
1409        assert!(!parse_env_bool("TEST", "0").unwrap());
1410        assert!(!parse_env_bool("TEST", "no").unwrap());
1411        assert!(!parse_env_bool("TEST", "NO").unwrap());
1412        assert!(parse_env_bool("TEST", "maybe").is_err());
1413    }
1414
1415    #[test]
1416    fn env_var_override_approval_mode() {
1417        env::set_var("TUITBOT_APPROVAL_MODE", "true");
1418        let mut config = Config::default();
1419        assert!(!config.approval_mode);
1420        config.apply_env_overrides().expect("env override");
1421        assert!(config.approval_mode);
1422        env::remove_var("TUITBOT_APPROVAL_MODE");
1423    }
1424
1425    #[test]
1426    fn env_var_override_approval_mode_false() {
1427        env::set_var("TUITBOT_APPROVAL_MODE", "false");
1428        let mut config = Config::default();
1429        config.approval_mode = true;
1430        config.apply_env_overrides().expect("env override");
1431        assert!(!config.approval_mode);
1432        env::remove_var("TUITBOT_APPROVAL_MODE");
1433    }
1434
1435    #[test]
1436    fn openclaw_env_enables_approval_mode() {
1437        env::set_var("OPENCLAW_AGENT_ID", "test");
1438        let mut config = Config::default();
1439        assert!(!config.approval_mode);
1440        config.apply_env_overrides().expect("env override");
1441        assert!(config.approval_mode);
1442        env::remove_var("OPENCLAW_AGENT_ID");
1443    }
1444
1445    #[test]
1446    fn openclaw_env_respects_explicit_override() {
1447        env::set_var("OPENCLAW_AGENT_ID", "test");
1448        env::set_var("TUITBOT_APPROVAL_MODE", "false");
1449        let mut config = Config::default();
1450        config.apply_env_overrides().expect("env override");
1451        assert!(!config.approval_mode);
1452        env::remove_var("OPENCLAW_AGENT_ID");
1453        env::remove_var("TUITBOT_APPROVAL_MODE");
1454    }
1455}