Skip to main content

running_process/
runpm_config.rs

1//! TOML config file parsing for the `runpm` PM2-style supervisor CLI.
2//!
3//! Phase 5 of #222 (issue #428). A single `runpm.toml` may carry any
4//! number of `[[app]]` tables — `runpm start --config <path>` iterates
5//! them and registers each one with the daemon.
6//!
7//! Example:
8//!
9//! ```toml
10//! [[app]]
11//! name = "web"
12//! cmd  = ["node", "server.js"]
13//! cwd  = "/srv/web"
14//! env  = { NODE_ENV = "production" }
15//! autorestart      = true
16//! max_restarts     = 10
17//! restart_delay_ms = 1000
18//! min_uptime_ms    = 2000
19//! ```
20//!
21//! Relative `cwd` values are resolved against the config file's parent
22//! directory; absolute paths pass through unchanged. Empty `cmd` arrays
23//! and duplicate `name` entries are rejected with a clear error message
24//! so a typo in one entry doesn't strand the rest of the batch.
25
26use std::collections::HashMap;
27use std::collections::HashSet;
28use std::path::{Path, PathBuf};
29
30use serde::Deserialize;
31use thiserror::Error;
32
33/// Top-level shape of a `runpm.toml` config file.
34#[derive(Deserialize, Debug, Default, Clone)]
35pub struct RunpmConfig {
36    /// Every `[[app]]` table in the file. Missing entirely is fine —
37    /// an empty config is a valid (no-op) batch.
38    #[serde(default)]
39    pub app: Vec<AppConfig>,
40}
41
42/// One `[[app]]` table inside a `runpm.toml` config file.
43///
44/// Field-for-field shape of [`crate::proto::daemon::ServiceConfig`]
45/// minus the daemon-side defaults; the `cwd` field is resolved against
46/// the config file's parent dir by [`RunpmConfig::resolve_cwd`].
47#[derive(Deserialize, Debug, Clone)]
48pub struct AppConfig {
49    /// Service name — must be unique within the file.
50    pub name: String,
51    /// Executable plus arguments. Empty `cmd` is rejected.
52    pub cmd: Vec<String>,
53    /// Working directory. Relative paths are resolved against the
54    /// config file's parent directory by [`RunpmConfig::resolve_cwd`].
55    #[serde(default)]
56    pub cwd: Option<String>,
57    /// Environment variables overlaid on the daemon's environment.
58    #[serde(default)]
59    pub env: HashMap<String, String>,
60    /// Auto-restart on exit. Defaults to `true` to match PM2 ergonomics.
61    #[serde(default = "default_true")]
62    pub autorestart: bool,
63    /// Maximum restart attempts. `None` => use daemon default (unlimited).
64    #[serde(default)]
65    pub max_restarts: Option<u32>,
66    /// Backoff between restarts (milliseconds). `None` => use daemon
67    /// default. Values are capped at `u32::MAX` ms (~49 days) by the
68    /// daemon wire format.
69    #[serde(default)]
70    pub restart_delay_ms: Option<u32>,
71    /// Minimum uptime (milliseconds) before the restart counter resets.
72    /// Capped at `u32::MAX` ms by the daemon wire format.
73    #[serde(default)]
74    pub min_uptime_ms: Option<u32>,
75    /// Grace period (milliseconds) during `runpm stop` before
76    /// SIGKILL/TerminateProcess. Capped at `u32::MAX` ms.
77    #[serde(default)]
78    pub kill_timeout_ms: Option<u32>,
79}
80
81fn default_true() -> bool {
82    true
83}
84
85/// Errors raised by the runpm TOML config loader.
86#[derive(Debug, Error)]
87pub enum RunpmConfigError {
88    /// The config file could not be read from disk.
89    #[error("failed to read runpm config {path}: {source}")]
90    Read {
91        /// Path the loader tried to read.
92        path: PathBuf,
93        /// Underlying I/O error.
94        #[source]
95        source: std::io::Error,
96    },
97    /// The file was not valid TOML / did not match the expected schema.
98    ///
99    /// The underlying error is boxed because `toml::de::Error` is large
100    /// (~128 bytes); inlining it tripped clippy's `result_large_err`.
101    #[error("failed to parse runpm config {path}: {source}")]
102    Parse {
103        /// Path the loader tried to parse.
104        path: PathBuf,
105        /// Underlying TOML deserialize error.
106        #[source]
107        source: Box<toml::de::Error>,
108    },
109    /// An `[[app]]` table had an empty `cmd` array.
110    #[error("app `{name}` has empty cmd in {path}")]
111    EmptyCmd {
112        /// Path to the offending config file.
113        path: PathBuf,
114        /// Name of the offending app entry.
115        name: String,
116    },
117    /// Two or more `[[app]]` tables shared the same `name`.
118    #[error("duplicate app name `{name}` in {path}")]
119    DuplicateName {
120        /// Path to the offending config file.
121        path: PathBuf,
122        /// Name that appeared more than once.
123        name: String,
124    },
125}
126
127impl RunpmConfig {
128    /// Load and validate a `runpm.toml` config from disk.
129    ///
130    /// Returns the parsed config plus the resolved parent directory
131    /// (used downstream to resolve relative `cwd` values via
132    /// [`RunpmConfig::resolve_cwd`]).
133    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    /// Parse + validate a config from an in-memory string. The `path`
142    /// is used only for error messages and (downstream) for resolving
143    /// relative `cwd` entries.
144    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    /// Resolve an app's `cwd` against the config file's parent directory.
174    ///
175    /// - `None` => `None`
176    /// - absolute path => unchanged
177    /// - relative path => joined onto `config_path.parent()`
178    ///
179    /// If `config_path` has no parent (e.g. a bare filename in the CWD),
180    /// relative paths are returned as-is.
181    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        // An empty parent ("" for bare filenames) is treated as CWD —
194        // leaving the relative path unchanged is the right call.
195        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        // PathBuf joins canonically for the host — just check both halves.
319        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        // No parent directory => leave the relative path unchanged so
340        // the daemon resolves it against its own CWD.
341        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}