running_process/
runpm_config.rs1use std::collections::HashMap;
27use std::collections::HashSet;
28use std::path::{Path, PathBuf};
29
30use serde::Deserialize;
31use thiserror::Error;
32
33#[derive(Deserialize, Debug, Default, Clone)]
35pub struct RunpmConfig {
36 #[serde(default)]
39 pub app: Vec<AppConfig>,
40}
41
42#[derive(Deserialize, Debug, Clone)]
48pub struct AppConfig {
49 pub name: String,
51 pub cmd: Vec<String>,
53 #[serde(default)]
56 pub cwd: Option<String>,
57 #[serde(default)]
59 pub env: HashMap<String, String>,
60 #[serde(default = "default_true")]
62 pub autorestart: bool,
63 #[serde(default)]
65 pub max_restarts: Option<u32>,
66 #[serde(default)]
70 pub restart_delay_ms: Option<u32>,
71 #[serde(default)]
74 pub min_uptime_ms: Option<u32>,
75 #[serde(default)]
78 pub kill_timeout_ms: Option<u32>,
79}
80
81fn default_true() -> bool {
82 true
83}
84
85#[derive(Debug, Error)]
87pub enum RunpmConfigError {
88 #[error("failed to read runpm config {path}: {source}")]
90 Read {
91 path: PathBuf,
93 #[source]
95 source: std::io::Error,
96 },
97 #[error("failed to parse runpm config {path}: {source}")]
102 Parse {
103 path: PathBuf,
105 #[source]
107 source: Box<toml::de::Error>,
108 },
109 #[error("app `{name}` has empty cmd in {path}")]
111 EmptyCmd {
112 path: PathBuf,
114 name: String,
116 },
117 #[error("duplicate app name `{name}` in {path}")]
119 DuplicateName {
120 path: PathBuf,
122 name: String,
124 },
125}
126
127impl RunpmConfig {
128 pub fn load(path: &Path) -> Result<Self, RunpmConfigError> {
134 let text = std::fs::read_to_string(path).map_err(|source| RunpmConfigError::Read {
135 path: path.to_path_buf(),
136 source,
137 })?;
138 Self::from_str_validated(&text, path)
139 }
140
141 pub fn from_str_validated(text: &str, path: &Path) -> Result<Self, RunpmConfigError> {
145 let parsed: RunpmConfig =
146 toml::from_str(text).map_err(|source| RunpmConfigError::Parse {
147 path: path.to_path_buf(),
148 source: Box::new(source),
149 })?;
150 parsed.validate(path)?;
151 Ok(parsed)
152 }
153
154 fn validate(&self, path: &Path) -> Result<(), RunpmConfigError> {
155 let mut seen: HashSet<&str> = HashSet::new();
156 for app in &self.app {
157 if app.cmd.is_empty() {
158 return Err(RunpmConfigError::EmptyCmd {
159 path: path.to_path_buf(),
160 name: app.name.clone(),
161 });
162 }
163 if !seen.insert(app.name.as_str()) {
164 return Err(RunpmConfigError::DuplicateName {
165 path: path.to_path_buf(),
166 name: app.name.clone(),
167 });
168 }
169 }
170 Ok(())
171 }
172
173 pub fn resolve_cwd(config_path: &Path, raw: &Option<String>) -> Option<String> {
182 let raw = raw.as_ref()?;
183 if raw.is_empty() {
184 return None;
185 }
186 let candidate = Path::new(raw);
187 if candidate.is_absolute() {
188 return Some(raw.clone());
189 }
190 let Some(parent) = config_path.parent() else {
191 return Some(raw.clone());
192 };
193 if parent.as_os_str().is_empty() {
196 return Some(raw.clone());
197 }
198 Some(parent.join(candidate).to_string_lossy().into_owned())
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn parses_minimal_single_app_config() {
208 let text = r#"
209 [[app]]
210 name = "web"
211 cmd = ["node", "server.js"]
212 "#;
213 let cfg = RunpmConfig::from_str_validated(text, Path::new("runpm.toml")).expect("parse ok");
214 assert_eq!(cfg.app.len(), 1);
215 let app = &cfg.app[0];
216 assert_eq!(app.name, "web");
217 assert_eq!(app.cmd, vec!["node", "server.js"]);
218 assert_eq!(app.cwd, None);
219 assert!(app.env.is_empty());
220 assert!(app.autorestart, "autorestart defaults to true");
221 assert_eq!(app.max_restarts, None);
222 }
223
224 #[test]
225 fn parses_full_config_with_env_and_cwd() {
226 let text = r#"
227 [[app]]
228 name = "web"
229 cmd = ["node", "server.js"]
230 cwd = "/srv/web"
231 env = { NODE_ENV = "production", PORT = "8080" }
232 autorestart = false
233 max_restarts = 10
234 restart_delay_ms = 1000
235 min_uptime_ms = 2000
236 kill_timeout_ms = 7500
237 "#;
238 let cfg = RunpmConfig::from_str_validated(text, Path::new("runpm.toml")).expect("parse ok");
239 assert_eq!(cfg.app.len(), 1);
240 let app = &cfg.app[0];
241 assert_eq!(app.cwd.as_deref(), Some("/srv/web"));
242 assert_eq!(
243 app.env.get("NODE_ENV").map(String::as_str),
244 Some("production")
245 );
246 assert_eq!(app.env.get("PORT").map(String::as_str), Some("8080"));
247 assert!(!app.autorestart);
248 assert_eq!(app.max_restarts, Some(10));
249 assert_eq!(app.restart_delay_ms, Some(1000));
250 assert_eq!(app.min_uptime_ms, Some(2000));
251 assert_eq!(app.kill_timeout_ms, Some(7500));
252 }
253
254 #[test]
255 fn rejects_empty_cmd_with_clear_error() {
256 let text = r#"
257 [[app]]
258 name = "broken"
259 cmd = []
260 "#;
261 let err = RunpmConfig::from_str_validated(text, Path::new("runpm.toml"))
262 .expect_err("empty cmd must be rejected");
263 let msg = err.to_string();
264 assert!(
265 msg.contains("broken"),
266 "error must mention the app name; got: {msg}"
267 );
268 assert!(
269 msg.contains("empty cmd"),
270 "error must mention 'empty cmd'; got: {msg}"
271 );
272 }
273
274 #[test]
275 fn rejects_duplicate_app_names() {
276 let text = r#"
277 [[app]]
278 name = "web"
279 cmd = ["a"]
280
281 [[app]]
282 name = "web"
283 cmd = ["b"]
284 "#;
285 let err = RunpmConfig::from_str_validated(text, Path::new("runpm.toml"))
286 .expect_err("duplicate names must be rejected");
287 let msg = err.to_string();
288 assert!(
289 msg.contains("duplicate") && msg.contains("web"),
290 "error must mention 'duplicate' and the offending name; got: {msg}"
291 );
292 }
293
294 #[test]
295 fn parses_empty_file_as_empty_batch() {
296 let cfg = RunpmConfig::from_str_validated("", Path::new("runpm.toml")).expect("parse ok");
297 assert!(cfg.app.is_empty());
298 }
299
300 #[test]
301 fn resolve_cwd_passes_through_absolute_paths() {
302 #[cfg(unix)]
303 let abs = "/srv/web".to_string();
304 #[cfg(windows)]
305 let abs = "C:\\srv\\web".to_string();
306
307 let resolved = RunpmConfig::resolve_cwd(Path::new("/tmp/runpm.toml"), &Some(abs.clone()));
308 assert_eq!(resolved.as_deref(), Some(abs.as_str()));
309 }
310
311 #[test]
312 fn resolve_cwd_joins_relative_paths_against_config_parent() {
313 let resolved = RunpmConfig::resolve_cwd(
314 Path::new("/etc/runpm/runpm.toml"),
315 &Some("services/web".to_string()),
316 )
317 .expect("relative path must resolve");
318 assert!(
320 resolved.contains("etc") && resolved.contains("runpm") && resolved.ends_with("web"),
321 "resolved path should contain config parent and original relative tail; got {resolved}",
322 );
323 }
324
325 #[test]
326 fn resolve_cwd_none_returns_none() {
327 let resolved = RunpmConfig::resolve_cwd(Path::new("/etc/runpm.toml"), &None);
328 assert_eq!(resolved, None);
329 }
330
331 #[test]
332 fn resolve_cwd_empty_string_returns_none() {
333 let resolved = RunpmConfig::resolve_cwd(Path::new("/etc/runpm.toml"), &Some(String::new()));
334 assert_eq!(resolved, None);
335 }
336
337 #[test]
338 fn resolve_cwd_with_bare_filename_config_path_keeps_relative() {
339 let resolved =
342 RunpmConfig::resolve_cwd(Path::new("runpm.toml"), &Some("services/web".to_string()));
343 assert_eq!(resolved.as_deref(), Some("services/web"));
344 }
345}