rust_commit_tracker/core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::error::Error;
3use std::fs;
4use std::io::{self, Write};
5use std::path::Path;
6
7const CONFIG_FILE: &str = "config.toml";
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Config {
11    pub discord: DiscordConfig,
12    pub monitoring: MonitoringConfig,
13    pub appearance: AppearanceConfig,
14    pub database: DatabaseConfig,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DiscordConfig {
19    pub webhook_url: String,
20    pub bot_name: String,
21    pub bot_avatar_url: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct MonitoringConfig {
26    pub commits_url: String,
27    pub check_interval_secs: u64,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct AppearanceConfig {
32    pub embed_color: String,
33    pub footer_icon_url: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct DatabaseConfig {
38    pub url: String,
39    pub cleanup_keep_last: i64,
40}
41
42impl Config {
43    pub fn load_or_create() -> Result<Self, Box<dyn Error>> {
44        if Path::new(CONFIG_FILE).exists() {
45            let config = Self::load_from_file()?;
46            config.validate()?;
47            Ok(config)
48        } else {
49            Self::create_default_and_prompt()
50        }
51    }
52
53    fn load_from_file() -> Result<Self, Box<dyn Error>> {
54        let content = fs::read_to_string(CONFIG_FILE)?;
55
56        // Try to parse the config, but handle missing fields gracefully
57        match toml::from_str::<Config>(&content) {
58            Ok(config) => Ok(config),
59            Err(e) => {
60                // If parsing fails due to missing fields, merge with defaults
61                if e.to_string().contains("missing field") {
62                    println!("⚠️  Config file is missing new fields, updating...");
63
64                    // Parse as a generic value first
65                    let mut existing: toml::Value = toml::from_str(&content)?;
66                    let default_config = Self::default();
67                    let default_value = toml::Value::try_from(&default_config)?;
68
69                    // Merge missing fields from defaults
70                    if let (toml::Value::Table(existing_table), toml::Value::Table(default_table)) =
71                        (&mut existing, default_value)
72                    {
73                        for (key, value) in default_table {
74                            if !existing_table.contains_key(&key) {
75                                existing_table.insert(key, value);
76                            }
77                        }
78                    }
79
80                    // Convert back to Config and save the updated version
81                    let updated_config: Config = existing.try_into()?;
82                    let updated_content = toml::to_string_pretty(&updated_config)?;
83                    fs::write(CONFIG_FILE, &updated_content)?;
84
85                    println!("✅ Config file updated with new fields");
86                    Ok(updated_config)
87                } else {
88                    Err(e.into())
89                }
90            }
91        }
92    }
93
94    fn create_default_and_prompt() -> Result<Self, Box<dyn Error>> {
95        println!("🔧 First time setup - Creating configuration file...");
96
97        let default_config = Self::default();
98        let toml_content = toml::to_string_pretty(&default_config)?;
99
100        fs::write(CONFIG_FILE, &toml_content)?;
101
102        println!("✅ Created '{}'", CONFIG_FILE);
103        println!();
104        println!("📝 Please edit the configuration file with your settings:");
105        println!("   - Discord webhook URL (REQUIRED)");
106        println!("   - Bot name and avatar (optional)");
107        println!("   - Monitoring settings (optional)");
108        println!("   - Database path (optional)");
109        println!();
110        print!("Press Enter when you've finished editing the config file...");
111        io::stdout().flush()?;
112
113        let mut input = String::new();
114        io::stdin().read_line(&mut input)?;
115
116        // Reload and validate the config after user edits
117        let config = Self::load_from_file()?;
118        config.validate()?;
119        Ok(config)
120    }
121
122    pub fn validate(&self) -> Result<(), Box<dyn Error>> {
123        // Check if webhook URL is still the placeholder
124        if self.discord.webhook_url == "REPLACE_WITH_YOUR_DISCORD_WEBHOOK_URL" 
125            || self.discord.webhook_url.trim().is_empty() {
126            return Err(format!(
127                "❌ Discord webhook URL not configured!\n\
128                Please edit '{}' and set a valid Discord webhook URL.\n\
129                You can get one from your Discord server settings → Integrations → Webhooks",
130                CONFIG_FILE
131            ).into());
132        }
133
134        // Basic webhook URL validation
135        if !self.discord.webhook_url.starts_with("https://discord.com/api/webhooks/") 
136            && !self.discord.webhook_url.starts_with("https://discordapp.com/api/webhooks/") {
137            return Err(format!(
138                "❌ Invalid Discord webhook URL format!\n\
139                Expected: https://discord.com/api/webhooks/...\n\
140                Got: {}",
141                self.discord.webhook_url
142            ).into());
143        }
144
145        Ok(())
146    }
147
148    pub fn rust_color(&self) -> u32 {
149        // Parse hex color string to u32
150        if self.appearance.embed_color.starts_with('#') {
151            u32::from_str_radix(&self.appearance.embed_color[1..], 16).unwrap_or(0xCD412B)
152        } else {
153            u32::from_str_radix(&self.appearance.embed_color, 16).unwrap_or(0xCD412B)
154        }
155    }
156}
157
158impl Default for Config {
159    fn default() -> Self {
160        Self {
161            discord: DiscordConfig {
162                webhook_url: "REPLACE_WITH_YOUR_DISCORD_WEBHOOK_URL".to_string(),
163                bot_name: "Rust Commit Tracker".to_string(),
164                bot_avatar_url: "https://i.imgur.com/on47Qk9.png".to_string(),
165            },
166            monitoring: MonitoringConfig {
167                commits_url: "https://commits.facepunch.com/?format=json".to_string(),
168                check_interval_secs: 50,
169            },
170            appearance: AppearanceConfig {
171                embed_color: "#CD412B".to_string(), // Rust orange
172                footer_icon_url: "https://i.imgur.com/on47Qk9.png".to_string(),
173            },
174            database: DatabaseConfig {
175                url: "sqlite:commits.db".to_string(),
176                cleanup_keep_last: 1000,
177            },
178        }
179    }
180}