1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
//! TOML config file parsing for the `runpm` PM2-style supervisor CLI.
//!
//! Phase 5 of #222 (issue #428). A single `runpm.toml` may carry any
//! number of `[[app]]` tables — `runpm start --config <path>` iterates
//! them and registers each one with the daemon.
//!
//! Example:
//!
//! ```toml
//! [[app]]
//! name = "web"
//! cmd = ["node", "server.js"]
//! cwd = "/srv/web"
//! env = { NODE_ENV = "production" }
//! autorestart = true
//! max_restarts = 10
//! restart_delay_ms = 1000
//! min_uptime_ms = 2000
//! ```
//!
//! Relative `cwd` values are resolved against the config file's parent
//! directory; absolute paths pass through unchanged. Empty `cmd` arrays
//! and duplicate `name` entries are rejected with a clear error message
//! so a typo in one entry doesn't strand the rest of the batch.
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
/// Top-level shape of a `runpm.toml` config file.
#[derive(Deserialize, Debug, Default, Clone)]
pub struct RunpmConfig {
/// Every `[[app]]` table in the file. Missing entirely is fine —
/// an empty config is a valid (no-op) batch.
#[serde(default)]
pub app: Vec<AppConfig>,
}
/// One `[[app]]` table inside a `runpm.toml` config file.
///
/// Field-for-field shape of [`crate::proto::daemon::ServiceConfig`]
/// minus the daemon-side defaults; the `cwd` field is resolved against
/// the config file's parent dir by [`RunpmConfig::resolve_cwd`].
#[derive(Deserialize, Debug, Clone)]
pub struct AppConfig {
/// Service name — must be unique within the file.
pub name: String,
/// Executable plus arguments. Empty `cmd` is rejected.
pub cmd: Vec<String>,
/// Working directory. Relative paths are resolved against the
/// config file's parent directory by [`RunpmConfig::resolve_cwd`].
#[serde(default)]
pub cwd: Option<String>,
/// Environment variables overlaid on the daemon's environment.
#[serde(default)]
pub env: HashMap<String, String>,
/// Auto-restart on exit. Defaults to `true` to match PM2 ergonomics.
#[serde(default = "default_true")]
pub autorestart: bool,
/// Maximum restart attempts. `None` => use daemon default (unlimited).
#[serde(default)]
pub max_restarts: Option<u32>,
/// Backoff between restarts (milliseconds). `None` => use daemon
/// default. Values are capped at `u32::MAX` ms (~49 days) by the
/// daemon wire format.
#[serde(default)]
pub restart_delay_ms: Option<u32>,
/// Minimum uptime (milliseconds) before the restart counter resets.
/// Capped at `u32::MAX` ms by the daemon wire format.
#[serde(default)]
pub min_uptime_ms: Option<u32>,
/// Grace period (milliseconds) during `runpm stop` before
/// SIGKILL/TerminateProcess. Capped at `u32::MAX` ms.
#[serde(default)]
pub kill_timeout_ms: Option<u32>,
}
fn default_true() -> bool {
true
}
/// Errors raised by the runpm TOML config loader.
#[derive(Debug, Error)]
pub enum RunpmConfigError {
/// The config file could not be read from disk.
#[error("failed to read runpm config {path}: {source}")]
Read {
/// Path the loader tried to read.
path: PathBuf,
/// Underlying I/O error.
#[source]
source: std::io::Error,
},
/// The file was not valid TOML / did not match the expected schema.
///
/// The underlying error is boxed because `toml::de::Error` is large
/// (~128 bytes); inlining it tripped clippy's `result_large_err`.
#[error("failed to parse runpm config {path}: {source}")]
Parse {
/// Path the loader tried to parse.
path: PathBuf,
/// Underlying TOML deserialize error.
#[source]
source: Box<toml::de::Error>,
},
/// An `[[app]]` table had an empty `cmd` array.
#[error("app `{name}` has empty cmd in {path}")]
EmptyCmd {
/// Path to the offending config file.
path: PathBuf,
/// Name of the offending app entry.
name: String,
},
/// Two or more `[[app]]` tables shared the same `name`.
#[error("duplicate app name `{name}` in {path}")]
DuplicateName {
/// Path to the offending config file.
path: PathBuf,
/// Name that appeared more than once.
name: String,
},
}
impl RunpmConfig {
/// Load and validate a `runpm.toml` config from disk.
///
/// Returns the parsed config plus the resolved parent directory
/// (used downstream to resolve relative `cwd` values via
/// [`RunpmConfig::resolve_cwd`]).
pub fn load(path: &Path) -> Result<Self, RunpmConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| RunpmConfigError::Read {
path: path.to_path_buf(),
source,
})?;
Self::from_str_validated(&text, path)
}
/// Parse + validate a config from an in-memory string. The `path`
/// is used only for error messages and (downstream) for resolving
/// relative `cwd` entries.
pub fn from_str_validated(text: &str, path: &Path) -> Result<Self, RunpmConfigError> {
let parsed: RunpmConfig =
toml::from_str(text).map_err(|source| RunpmConfigError::Parse {
path: path.to_path_buf(),
source: Box::new(source),
})?;
parsed.validate(path)?;
Ok(parsed)
}
fn validate(&self, path: &Path) -> Result<(), RunpmConfigError> {
let mut seen: HashSet<&str> = HashSet::new();
for app in &self.app {
if app.cmd.is_empty() {
return Err(RunpmConfigError::EmptyCmd {
path: path.to_path_buf(),
name: app.name.clone(),
});
}
if !seen.insert(app.name.as_str()) {
return Err(RunpmConfigError::DuplicateName {
path: path.to_path_buf(),
name: app.name.clone(),
});
}
}
Ok(())
}
/// Resolve an app's `cwd` against the config file's parent directory.
///
/// - `None` => `None`
/// - absolute path => unchanged
/// - relative path => joined onto `config_path.parent()`
///
/// If `config_path` has no parent (e.g. a bare filename in the CWD),
/// relative paths are returned as-is.
pub fn resolve_cwd(config_path: &Path, raw: &Option<String>) -> Option<String> {
let raw = raw.as_ref()?;
if raw.is_empty() {
return None;
}
let candidate = Path::new(raw);
if candidate.is_absolute() {
return Some(raw.clone());
}
let Some(parent) = config_path.parent() else {
return Some(raw.clone());
};
// An empty parent ("" for bare filenames) is treated as CWD —
// leaving the relative path unchanged is the right call.
if parent.as_os_str().is_empty() {
return Some(raw.clone());
}
Some(parent.join(candidate).to_string_lossy().into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_minimal_single_app_config() {
let text = r#"
[[app]]
name = "web"
cmd = ["node", "server.js"]
"#;
let cfg = RunpmConfig::from_str_validated(text, Path::new("runpm.toml")).expect("parse ok");
assert_eq!(cfg.app.len(), 1);
let app = &cfg.app[0];
assert_eq!(app.name, "web");
assert_eq!(app.cmd, vec!["node", "server.js"]);
assert_eq!(app.cwd, None);
assert!(app.env.is_empty());
assert!(app.autorestart, "autorestart defaults to true");
assert_eq!(app.max_restarts, None);
}
#[test]
fn parses_full_config_with_env_and_cwd() {
let text = r#"
[[app]]
name = "web"
cmd = ["node", "server.js"]
cwd = "/srv/web"
env = { NODE_ENV = "production", PORT = "8080" }
autorestart = false
max_restarts = 10
restart_delay_ms = 1000
min_uptime_ms = 2000
kill_timeout_ms = 7500
"#;
let cfg = RunpmConfig::from_str_validated(text, Path::new("runpm.toml")).expect("parse ok");
assert_eq!(cfg.app.len(), 1);
let app = &cfg.app[0];
assert_eq!(app.cwd.as_deref(), Some("/srv/web"));
assert_eq!(
app.env.get("NODE_ENV").map(String::as_str),
Some("production")
);
assert_eq!(app.env.get("PORT").map(String::as_str), Some("8080"));
assert!(!app.autorestart);
assert_eq!(app.max_restarts, Some(10));
assert_eq!(app.restart_delay_ms, Some(1000));
assert_eq!(app.min_uptime_ms, Some(2000));
assert_eq!(app.kill_timeout_ms, Some(7500));
}
#[test]
fn rejects_empty_cmd_with_clear_error() {
let text = r#"
[[app]]
name = "broken"
cmd = []
"#;
let err = RunpmConfig::from_str_validated(text, Path::new("runpm.toml"))
.expect_err("empty cmd must be rejected");
let msg = err.to_string();
assert!(
msg.contains("broken"),
"error must mention the app name; got: {msg}"
);
assert!(
msg.contains("empty cmd"),
"error must mention 'empty cmd'; got: {msg}"
);
}
#[test]
fn rejects_duplicate_app_names() {
let text = r#"
[[app]]
name = "web"
cmd = ["a"]
[[app]]
name = "web"
cmd = ["b"]
"#;
let err = RunpmConfig::from_str_validated(text, Path::new("runpm.toml"))
.expect_err("duplicate names must be rejected");
let msg = err.to_string();
assert!(
msg.contains("duplicate") && msg.contains("web"),
"error must mention 'duplicate' and the offending name; got: {msg}"
);
}
#[test]
fn parses_empty_file_as_empty_batch() {
let cfg = RunpmConfig::from_str_validated("", Path::new("runpm.toml")).expect("parse ok");
assert!(cfg.app.is_empty());
}
#[test]
fn resolve_cwd_passes_through_absolute_paths() {
#[cfg(unix)]
let abs = "/srv/web".to_string();
#[cfg(windows)]
let abs = "C:\\srv\\web".to_string();
let resolved = RunpmConfig::resolve_cwd(Path::new("/tmp/runpm.toml"), &Some(abs.clone()));
assert_eq!(resolved.as_deref(), Some(abs.as_str()));
}
#[test]
fn resolve_cwd_joins_relative_paths_against_config_parent() {
let resolved = RunpmConfig::resolve_cwd(
Path::new("/etc/runpm/runpm.toml"),
&Some("services/web".to_string()),
)
.expect("relative path must resolve");
// PathBuf joins canonically for the host — just check both halves.
assert!(
resolved.contains("etc") && resolved.contains("runpm") && resolved.ends_with("web"),
"resolved path should contain config parent and original relative tail; got {resolved}",
);
}
#[test]
fn resolve_cwd_none_returns_none() {
let resolved = RunpmConfig::resolve_cwd(Path::new("/etc/runpm.toml"), &None);
assert_eq!(resolved, None);
}
#[test]
fn resolve_cwd_empty_string_returns_none() {
let resolved = RunpmConfig::resolve_cwd(Path::new("/etc/runpm.toml"), &Some(String::new()));
assert_eq!(resolved, None);
}
#[test]
fn resolve_cwd_with_bare_filename_config_path_keeps_relative() {
// No parent directory => leave the relative path unchanged so
// the daemon resolves it against its own CWD.
let resolved =
RunpmConfig::resolve_cwd(Path::new("runpm.toml"), &Some("services/web".to_string()));
assert_eq!(resolved.as_deref(), Some("services/web"));
}
}