Skip to main content

mc_minder/config/
mod.rs

1use serde::Deserialize;
2use std::path::PathBuf;
3use anyhow::{Result, Context};
4// use log::warn;
5
6// Pre-declared types for Config struct
7#[derive(Debug, Deserialize, Clone)]
8pub struct ScheduleEntry {
9    pub interval_mins: u64,
10    pub action: String,
11    #[serde(default)]
12    pub message: String,
13}
14
15#[derive(Debug, Deserialize, Clone)]
16pub struct WatchdogConfig {
17    #[serde(default = "default_watchdog_enabled")]
18    pub enabled: bool,
19    #[serde(default = "default_watchdog_max_restarts")]
20    pub max_restarts: u32,
21    #[serde(default = "default_watchdog_cooldown")]
22    pub cooldown_secs: u64,
23    #[serde(default = "default_watchdog_check_interval")]
24    pub check_interval_secs: u64,
25}
26fn default_watchdog_enabled() -> bool { false }
27fn default_watchdog_max_restarts() -> u32 { 5 }
28fn default_watchdog_cooldown() -> u64 { 30 }
29fn default_watchdog_check_interval() -> u64 { 60 }
30impl Default for WatchdogConfig {
31    fn default() -> Self {
32        Self { enabled: false, max_restarts: 5, cooldown_secs: 30, check_interval_secs: 60 }
33    }
34}
35
36#[derive(Debug, Deserialize, Clone)]
37pub struct LazyStartConfig {
38    #[serde(default = "default_lazy_enabled")]
39    pub enabled: bool,
40    #[serde(default = "default_lazy_port")]
41    pub listen_port: u16,
42    #[serde(default = "default_lazy_idle")]
43    pub idle_timeout_mins: u64,
44}
45fn default_lazy_enabled() -> bool { false }
46fn default_lazy_port() -> u16 { 25565 }
47fn default_lazy_idle() -> u64 { 10 }
48impl Default for LazyStartConfig {
49    fn default() -> Self {
50        Self { enabled: false, listen_port: 25565, idle_timeout_mins: 10 }
51    }
52}
53
54#[derive(Debug, Deserialize, Clone)]
55pub struct Config {
56    #[serde(default)]
57    pub servers: Vec<ServerInstance>,
58    #[serde(default)]
59    pub rcon: RconConfig,
60    #[serde(default)]
61    pub server: ServerConfig,
62    #[serde(default)]
63    pub backup: BackupConfig,
64    #[serde(default)]
65    pub notification: NotificationConfig,
66    #[serde(default)]
67    #[allow(dead_code)]
68    pub jvm: JvmConfig,
69    #[serde(default)]
70    pub mc_status: McStatusConfig,
71    #[serde(default)]
72    pub schedules: Vec<ScheduleEntry>,
73    #[serde(default)]
74    pub watchdog: WatchdogConfig,
75    #[serde(default)]
76    pub lazy_start: LazyStartConfig,
77}
78
79#[derive(Debug, Deserialize, Clone)]
80pub struct RconConfig {
81    #[serde(default = "default_rcon_host")]
82    pub host: String,
83    #[serde(default = "default_rcon_port")]
84    pub port: u16,
85    #[serde(default)]
86    pub password: String,
87}
88
89impl Default for RconConfig {
90    fn default() -> Self {
91        Self {
92            host: default_rcon_host(),
93            port: default_rcon_port(),
94            password: String::new(),
95        }
96    }
97}
98
99fn default_rcon_host() -> String { "127.0.0.1".to_string() }
100fn default_rcon_port() -> u16 { 25575 }
101
102#[derive(Debug, Deserialize, Clone)]
103pub struct ServerConfig {
104    #[serde(default = "default_jar")]
105    pub jar: String,
106    #[serde(default = "default_min_mem")]
107    pub min_mem: String,
108    #[serde(default = "default_max_mem")]
109    pub max_mem: String,
110    #[serde(default = "default_session_name")]
111    pub session_name: String,
112    #[serde(default = "default_log_file")]
113    pub log_file: String,
114    /// Server type: fabric, paper, vanilla, forge, etc. (reserved for future multi-type support)
115    #[serde(default = "default_server_type")]
116    #[allow(dead_code)]
117    pub server_type: String,
118}
119
120fn default_jar() -> String { "fabric-server.jar".to_string() }
121fn default_min_mem() -> String { "512M".to_string() }
122fn default_max_mem() -> String { "1G".to_string() }
123fn default_session_name() -> String { "mc_server".to_string() }
124fn default_log_file() -> String { "logs/latest.log".to_string() }
125fn default_server_type() -> String { "fabric".to_string() }
126
127impl Default for ServerConfig {
128    fn default() -> Self {
129        Self {
130            jar: default_jar(),
131            min_mem: default_min_mem(),
132            max_mem: default_max_mem(),
133            session_name: default_session_name(),
134            log_file: default_log_file(),
135            server_type: default_server_type(),
136        }
137    }
138}
139
140#[derive(Debug, Deserialize, Clone)]
141pub struct BackupConfig {
142    #[serde(default = "default_world_dir")]
143    pub world_dir: String,
144    #[serde(default = "default_backup_dest")]
145    pub backup_dest: String,
146    #[serde(default = "default_retain_days")]
147    pub retain_days: u32,
148    /// Max number of backups to keep (P5-2)
149    #[serde(default = "default_max_backups")]
150    pub max_backups: usize,
151    /// Max age of backups in days (P5-2)
152    #[serde(default = "default_max_backup_days")]
153    pub max_backup_days: u64,
154}
155
156fn default_world_dir() -> String { "world".to_string() }
157fn default_backup_dest() -> String { "../backups".to_string() }
158fn default_retain_days() -> u32 { 7 }
159fn default_max_backups() -> usize { 10 }
160fn default_max_backup_days() -> u64 { 30 }
161
162impl Default for BackupConfig {
163    fn default() -> Self {
164        Self {
165            world_dir: default_world_dir(),
166            backup_dest: default_backup_dest(),
167            retain_days: default_retain_days(),
168            max_backups: default_max_backups(),
169            max_backup_days: default_max_backup_days(),
170        }
171    }
172}
173
174#[derive(Debug, Deserialize, Clone)]
175#[allow(dead_code)]
176pub struct NotificationConfig {
177    #[serde(default)]
178    pub telegram_bot_token: String,
179    #[serde(default)]
180    pub telegram_chat_id: String,
181    #[serde(default = "default_termux_notify")]
182    pub termux_notify: bool,
183}
184
185fn default_termux_notify() -> bool { true }
186
187impl Default for NotificationConfig {
188    fn default() -> Self {
189        Self {
190            telegram_bot_token: String::new(),
191            telegram_chat_id: String::new(),
192            termux_notify: default_termux_notify(),
193        }
194    }
195}
196
197#[derive(Debug, Deserialize, Clone)]
198#[allow(dead_code)]
199pub struct JvmConfig {
200    #[serde(default = "default_gc")]
201    pub gc: String,
202    #[serde(default)]
203    pub extra_flags: String,
204    #[serde(default)]
205    pub xmx: Option<String>,
206    #[serde(default)]
207    pub xms: Option<String>,
208    #[serde(default)]
209    pub jdk_path: Option<String>,
210}
211
212fn default_gc() -> String { "G1GC".to_string() }
213
214#[derive(Debug, Deserialize, Clone)]
215pub struct McStatusConfig {
216    #[serde(default = "default_ping_interval")]
217    pub ping_interval_secs: u64,
218    #[serde(default = "default_ping_timeout")]
219    pub ping_timeout_secs: u64,
220}
221
222fn default_ping_interval() -> u64 { 60 }
223fn default_ping_timeout() -> u64 { 3 }
224
225impl Default for McStatusConfig {
226    fn default() -> Self {
227        Self {
228            ping_interval_secs: default_ping_interval(),
229            ping_timeout_secs: default_ping_timeout(),
230        }
231    }
232}
233
234/// Discover the Minecraft server port from server.properties.
235/// Falls back to 25565 if the file is missing or unreadable.
236pub fn discover_minecraft_port(server_dir: &std::path::Path) -> (u16, Option<String>) {
237    let props_path = server_dir.join("server.properties");
238    match std::fs::read_to_string(&props_path) {
239        Ok(content) => {
240            for line in content.lines() {
241                let line = line.trim();
242                if line.starts_with('#') || line.is_empty() {
243                    continue;
244                }
245                if let Some((key, value)) = line.split_once('=') {
246                    if key.trim() == "server-port" || key.trim() == "query.port" {
247                        if let Ok(port) = value.trim().parse::<u16>() {
248                            return (port, None);
249                        }
250                    }
251                }
252            }
253            (25565, Some("server.properties found but no server-port defined, using default 25565".to_string()))
254        }
255        Err(_) => (25565, Some("server.properties not found, using default port 25565".to_string())),
256    }
257}
258
259impl Default for JvmConfig {
260    fn default() -> Self {
261        Self {
262            gc: default_gc(),
263            extra_flags: String::new(),
264            xmx: None,
265            xms: None,
266            jdk_path: None,
267        }
268    }
269}
270
271impl Config {
272    /// Returns the list of active server instances.
273    /// If [[servers]] is configured, returns those.
274    /// Otherwise, falls back to the legacy single-server config.
275    #[allow(dead_code)]
276    pub fn get_servers(&self) -> Vec<ServerInstance> {
277        if !self.servers.is_empty() {
278            return self.servers.clone();
279        }
280        // Legacy mode: create a single instance from top-level config
281        vec![ServerInstance {
282            name: "default".to_string(),
283            dir: ".".to_string(),
284            server: self.server.clone(),
285            rcon: Some(self.rcon.clone()),
286            jvm: self.jvm.clone(),
287        }]
288    }
289}
290
291impl Config {
292    pub fn load(path: &PathBuf) -> Result<Self> {
293        let content = std::fs::read_to_string(path)
294            .with_context(|| format!("Failed to read config file: {:?}", path))?;
295        
296        Self::load_from_str(&content)
297    }
298
299    pub fn load_from_str(content: &str) -> Result<Self> {
300        let mut config: Config = toml::from_str(content)
301            .with_context(|| "Failed to parse config file")?;
302
303        // Backward compat: if no servers configured and no rcon password,
304        // fill in defaults from top-level config
305        if config.servers.is_empty() && config.rcon.password.is_empty() {
306            config.rcon = RconConfig::default();
307        }
308        
309        Ok(config)
310    }
311
312    pub fn generate_template() -> String {
313        let s = r#"# MC-Minder Configuration File
314# 单服务器配置(传统模式)
315[server]
316jar = "fabric-server.jar"
317min_mem = "512M"
318max_mem = "1G"
319session_name = "mc_server"
320log_file = "logs/latest.log"
321
322[rcon]
323host = "127.0.0.1"
324port = 25575
325password = ""
326
327[backup]
328world_dir = "world"
329backup_dest = "../backups"
330retain_days = 7
331
332[notification]
333telegram_bot_token = ""
334telegram_chat_id = ""
335termux_notify = true
336
337[jvm]
338gc = "G1GC"
339extra_flags = ""
340# jdk_path = "/usr/lib/jvm/java-17-openjdk/bin/java"
341
342# 多服务器配置(取消注释启用)
343# [[servers]]
344# name = "survival"
345# dir = "./survival"
346# [servers.server]
347# jar = "fabric-server.jar"
348# min_mem = "1G"
349# max_mem = "2G"
350# [servers.rcon]
351# password = "secret1"
352"#;
353        s.to_string()
354    }
355}
356
357/// A managed Minecraft server instance.
358#[derive(Debug, Deserialize, Clone)]
359#[allow(dead_code)]
360pub struct ServerInstance {
361    /// Display name for this server
362    #[serde(default = "default_instance_name")]
363    pub name: String,
364    /// Directory containing this server's files (relative to config.toml)
365    #[serde(default = "default_instance_dir")]
366    pub dir: String,
367    /// Server configuration (jar, memory, etc.)
368    #[serde(default)]
369    pub server: ServerConfig,
370    /// RCON configuration (optional override)
371    #[serde(default)]
372    pub rcon: Option<RconConfig>,
373    /// JVM configuration
374    #[serde(default)]
375    pub jvm: JvmConfig,
376}
377
378fn default_instance_name() -> String { "server".to_string() }
379fn default_instance_dir() -> String { ".".to_string() }
380
381/// Auto-discover server instances in subdirectories.
382/// Returns a list of discovered servers with their names and directories.
383pub fn discover_servers(base_dir: &std::path::Path) -> Vec<DiscoveredServer> {
384    let mut servers = Vec::new();
385    if let Ok(entries) = std::fs::read_dir(base_dir) {
386        for entry in entries.flatten() {
387            let path = entry.path();
388            if !path.is_dir() {
389                continue;
390            }
391            // Check if this directory looks like a Minecraft server
392            let has_jar = path.read_dir().map(|d| {
393                d.flatten().any(|e| {
394                    e.file_name().to_string_lossy().contains("server")
395                        || e.file_name().to_string_lossy().ends_with(".jar")
396                })
397            }).unwrap_or(false);
398            let has_props = path.join("server.properties").exists();
399            if has_jar || has_props {
400                let name = path.file_name()
401                    .map(|n| n.to_string_lossy().to_string())
402                    .unwrap_or_else(|| "unknown".to_string());
403                servers.push(DiscoveredServer {
404                    name,
405                    dir: path.to_string_lossy().to_string(),
406                });
407            }
408        }
409    }
410    servers
411}
412
413/// Result of auto-discovering a server instance.
414#[derive(Debug, Clone)]
415pub struct DiscoveredServer {
416    pub name: String,
417    pub dir: String,
418}