1use serde::Deserialize;
2
3use crate::daemon::DaemonSpec;
4use crate::error::{Result, SyspulseError};
5
6#[derive(Debug, Deserialize)]
7#[serde(untagged)]
8enum ConfigFile {
9 Single { daemon: DaemonSpec },
10 Multi { daemon: Vec<DaemonSpec> },
11}
12
13pub fn parse_config(content: &str) -> Result<Vec<DaemonSpec>> {
14 let config: ConfigFile =
15 toml::from_str(content).map_err(|e| SyspulseError::Config(e.to_string()))?;
16 match config {
17 ConfigFile::Single { daemon } => Ok(vec![daemon]),
18 ConfigFile::Multi { daemon } => Ok(daemon),
19 }
20}
21
22pub fn parse_config_file(path: &std::path::Path) -> Result<Vec<DaemonSpec>> {
23 let content = std::fs::read_to_string(path)?;
24 parse_config(&content)
25}
26
27#[cfg(test)]
28mod tests {
29 use super::*;
30
31 #[test]
32 fn parse_single_daemon_config() {
33 let toml = r#"
34[daemon]
35name = "my-api"
36command = ["python", "app.py"]
37"#;
38 let specs = parse_config(toml).unwrap();
39 assert_eq!(specs.len(), 1);
40 assert_eq!(specs[0].name, "my-api");
41 assert_eq!(specs[0].command, vec!["python", "app.py"]);
42 }
43
44 #[test]
45 fn parse_multi_daemon_config() {
46 let toml = r#"
47[[daemon]]
48name = "api"
49command = ["python", "api.py"]
50
51[[daemon]]
52name = "worker"
53command = ["python", "worker.py"]
54"#;
55 let specs = parse_config(toml).unwrap();
56 assert_eq!(specs.len(), 2);
57 assert_eq!(specs[0].name, "api");
58 assert_eq!(specs[1].name, "worker");
59 }
60
61 #[test]
62 fn parse_config_with_all_fields() {
63 let toml = r#"
64[daemon]
65name = "full-daemon"
66command = ["node", "server.js"]
67working_dir = "/opt/app"
68description = "A full daemon example"
69stop_timeout_secs = 60
70tags = ["web", "production"]
71schedule = "0 0 * * *"
72user = "www-data"
73
74[daemon.env]
75NODE_ENV = "production"
76PORT = "3000"
77
78[daemon.health_check]
79type = "http"
80target = "http://localhost:3000/health"
81interval_secs = 15
82timeout_secs = 3
83retries = 5
84start_period_secs = 10
85
86[daemon.restart_policy]
87policy = "on_failure"
88max_retries = 5
89backoff_base_secs = 2.0
90backoff_max_secs = 120.0
91
92[daemon.resource_limits]
93max_memory_bytes = 536870912
94max_cpu_percent = 80.0
95
96[daemon.log_config]
97max_size_bytes = 104857600
98retain_count = 10
99compress_rotated = true
100"#;
101 let specs = parse_config(toml).unwrap();
102 assert_eq!(specs.len(), 1);
103 let spec = &specs[0];
104 assert_eq!(spec.name, "full-daemon");
105 assert_eq!(spec.command, vec!["node", "server.js"]);
106 assert_eq!(spec.stop_timeout_secs, 60);
107 assert_eq!(spec.tags, vec!["web", "production"]);
108 assert_eq!(spec.description.as_deref(), Some("A full daemon example"));
109 assert_eq!(spec.user.as_deref(), Some("www-data"));
110 assert_eq!(
111 spec.env.get("NODE_ENV").map(String::as_str),
112 Some("production")
113 );
114
115 let hc = spec.health_check.as_ref().unwrap();
116 assert_eq!(hc.target, "http://localhost:3000/health");
117 assert_eq!(hc.interval_secs, 15);
118 assert_eq!(hc.retries, 5);
119
120 let rl = spec.resource_limits.as_ref().unwrap();
121 assert_eq!(rl.max_memory_bytes, Some(536870912));
122 assert_eq!(rl.max_cpu_percent, Some(80.0));
123
124 let lc = spec.log_config.as_ref().unwrap();
125 assert_eq!(lc.max_size_bytes, 104857600);
126 assert_eq!(lc.retain_count, 10);
127 assert!(lc.compress_rotated);
128 }
129
130 #[test]
131 fn parse_config_defaults() {
132 let toml = r#"
133[daemon]
134name = "minimal"
135command = ["echo", "hello"]
136"#;
137 let specs = parse_config(toml).unwrap();
138 let spec = &specs[0];
139 assert_eq!(spec.stop_timeout_secs, 30);
140 assert!(spec.env.is_empty());
141 assert!(spec.tags.is_empty());
142 assert!(spec.health_check.is_none());
143 assert!(spec.resource_limits.is_none());
144 assert!(spec.log_config.is_none());
145 assert!(spec.schedule.is_none());
146 assert!(spec.working_dir.is_none());
147 assert!(spec.description.is_none());
148 assert!(spec.user.is_none());
149 }
150
151 #[test]
152 fn parse_invalid_config_returns_error() {
153 let toml = "this is not valid toml [[[";
154 assert!(parse_config(toml).is_err());
155 }
156
157 #[test]
158 fn parse_missing_required_fields_returns_error() {
159 let toml = r#"
160[daemon]
161name = "no-command"
162"#;
163 assert!(parse_config(toml).is_err());
164 }
165}