1mod defaults;
11
12use crate::error::ConfigError;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::env;
16use std::path::PathBuf;
17
18#[derive(Debug, Clone, Default, Deserialize, Serialize)]
20pub struct Config {
21 #[serde(default)]
23 pub x_api: XApiConfig,
24
25 #[serde(default)]
27 pub auth: AuthConfig,
28
29 #[serde(default)]
31 pub business: BusinessProfile,
32
33 #[serde(default)]
35 pub scoring: ScoringConfig,
36
37 #[serde(default)]
39 pub limits: LimitsConfig,
40
41 #[serde(default)]
43 pub intervals: IntervalsConfig,
44
45 #[serde(default)]
47 pub llm: LlmConfig,
48
49 #[serde(default)]
51 pub targets: TargetsConfig,
52
53 #[serde(default)]
55 pub approval_mode: bool,
56
57 #[serde(default)]
59 pub storage: StorageConfig,
60
61 #[serde(default)]
63 pub logging: LoggingConfig,
64
65 #[serde(default)]
67 pub schedule: ScheduleConfig,
68}
69
70#[derive(Debug, Clone, Default, Deserialize, Serialize)]
72pub struct XApiConfig {
73 #[serde(default)]
75 pub client_id: String,
76
77 #[serde(default)]
79 pub client_secret: Option<String>,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize)]
84pub struct AuthConfig {
85 #[serde(default = "default_auth_mode")]
87 pub mode: String,
88
89 #[serde(default = "default_callback_host")]
91 pub callback_host: String,
92
93 #[serde(default = "default_callback_port")]
95 pub callback_port: u16,
96}
97
98#[derive(Debug, Clone, Default, Deserialize, Serialize)]
100pub struct BusinessProfile {
101 #[serde(default)]
103 pub product_name: String,
104
105 #[serde(default)]
107 pub product_description: String,
108
109 #[serde(default)]
111 pub product_url: Option<String>,
112
113 #[serde(default)]
115 pub target_audience: String,
116
117 #[serde(default)]
119 pub product_keywords: Vec<String>,
120
121 #[serde(default)]
123 pub competitor_keywords: Vec<String>,
124
125 #[serde(default)]
127 pub industry_topics: Vec<String>,
128
129 #[serde(default)]
131 pub brand_voice: Option<String>,
132
133 #[serde(default)]
135 pub reply_style: Option<String>,
136
137 #[serde(default)]
139 pub content_style: Option<String>,
140
141 #[serde(default)]
143 pub persona_opinions: Vec<String>,
144
145 #[serde(default)]
147 pub persona_experiences: Vec<String>,
148
149 #[serde(default)]
151 pub content_pillars: Vec<String>,
152}
153
154#[derive(Debug, Clone, Deserialize, Serialize)]
156pub struct ScoringConfig {
157 #[serde(default = "default_threshold")]
159 pub threshold: u32,
160
161 #[serde(default = "default_keyword_relevance_max")]
163 pub keyword_relevance_max: f32,
164
165 #[serde(default = "default_follower_count_max")]
167 pub follower_count_max: f32,
168
169 #[serde(default = "default_recency_max")]
171 pub recency_max: f32,
172
173 #[serde(default = "default_engagement_rate_max")]
175 pub engagement_rate_max: f32,
176
177 #[serde(default = "default_reply_count_max")]
179 pub reply_count_max: f32,
180
181 #[serde(default = "default_content_type_max")]
183 pub content_type_max: f32,
184}
185
186#[derive(Debug, Clone, Deserialize, Serialize)]
188pub struct LimitsConfig {
189 #[serde(default = "default_max_replies_per_day")]
191 pub max_replies_per_day: u32,
192
193 #[serde(default = "default_max_tweets_per_day")]
195 pub max_tweets_per_day: u32,
196
197 #[serde(default = "default_max_threads_per_week")]
199 pub max_threads_per_week: u32,
200
201 #[serde(default = "default_min_action_delay_seconds")]
203 pub min_action_delay_seconds: u64,
204
205 #[serde(default = "default_max_action_delay_seconds")]
207 pub max_action_delay_seconds: u64,
208
209 #[serde(default = "default_max_replies_per_author_per_day")]
211 pub max_replies_per_author_per_day: u32,
212
213 #[serde(default = "default_banned_phrases")]
215 pub banned_phrases: Vec<String>,
216
217 #[serde(default = "default_product_mention_ratio")]
219 pub product_mention_ratio: f32,
220}
221
222#[derive(Debug, Clone, Deserialize, Serialize)]
224pub struct IntervalsConfig {
225 #[serde(default = "default_mentions_check_seconds")]
227 pub mentions_check_seconds: u64,
228
229 #[serde(default = "default_discovery_search_seconds")]
231 pub discovery_search_seconds: u64,
232
233 #[serde(default = "default_content_post_window_seconds")]
235 pub content_post_window_seconds: u64,
236
237 #[serde(default = "default_thread_interval_seconds")]
239 pub thread_interval_seconds: u64,
240}
241
242#[derive(Debug, Clone, Default, Deserialize, Serialize)]
244pub struct TargetsConfig {
245 #[serde(default)]
247 pub accounts: Vec<String>,
248
249 #[serde(default = "default_max_target_replies_per_day")]
251 pub max_target_replies_per_day: u32,
252
253 #[serde(default)]
255 pub auto_follow: bool,
256
257 #[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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
271pub struct LlmConfig {
272 #[serde(default)]
274 pub provider: String,
275
276 #[serde(default)]
278 pub api_key: Option<String>,
279
280 #[serde(default)]
282 pub model: String,
283
284 #[serde(default)]
286 pub base_url: Option<String>,
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
291pub struct StorageConfig {
292 #[serde(default = "default_db_path")]
294 pub db_path: String,
295
296 #[serde(default = "default_retention_days")]
298 pub retention_days: u32,
299}
300
301#[derive(Debug, Clone, Default, Deserialize, Serialize)]
303pub struct LoggingConfig {
304 #[serde(default)]
306 pub status_interval_seconds: u64,
307}
308
309#[derive(Debug, Clone, Deserialize, Serialize)]
311pub struct ScheduleConfig {
312 #[serde(default = "default_timezone")]
314 pub timezone: String,
315
316 #[serde(default = "default_active_hours_start")]
318 pub active_hours_start: u8,
319
320 #[serde(default = "default_active_hours_end")]
322 pub active_hours_end: u8,
323
324 #[serde(default = "default_active_days")]
326 pub active_days: Vec<String>,
327
328 #[serde(default)]
332 pub preferred_times: Vec<String>,
333
334 #[serde(default)]
338 pub preferred_times_override: HashMap<String, Vec<String>>,
339
340 #[serde(default)]
342 pub thread_preferred_day: Option<String>,
343
344 #[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
388fn 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 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 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 pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
510 let mut errors = Vec::new();
511
512 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 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 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 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 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 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 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 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 let effective_slots = if self.schedule.preferred_times.is_empty() {
683 0
684 } else {
685 let base_count: usize = self
687 .schedule
688 .preferred_times
689 .iter()
690 .map(|t| if t == "auto" { 3 } else { 1 })
691 .sum();
692 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 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 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 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 fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
765 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 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 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 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 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 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 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 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 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 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 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 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 if !explicit_approval && env::vars().any(|(k, _)| k.starts_with("OPENCLAW_")) {
958 self.approval_mode = true;
959 }
960
961 Ok(())
962 }
963}
964
965fn 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
980fn 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
994fn 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
1002fn 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
1010fn 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
1018fn 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
1026fn 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
1034fn 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
1042fn 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 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 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 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}