use std::collections::HashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
#[derive(Deserialize, Debug, Default, Clone)]
pub struct RunpmConfig {
#[serde(default)]
pub app: Vec<AppConfig>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct AppConfig {
pub name: String,
pub cmd: Vec<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default = "default_true")]
pub autorestart: bool,
#[serde(default)]
pub max_restarts: Option<u32>,
#[serde(default)]
pub restart_delay_ms: Option<u32>,
#[serde(default)]
pub min_uptime_ms: Option<u32>,
#[serde(default)]
pub kill_timeout_ms: Option<u32>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Error)]
pub enum RunpmConfigError {
#[error("failed to read runpm config {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse runpm config {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: Box<toml::de::Error>,
},
#[error("app `{name}` has empty cmd in {path}")]
EmptyCmd {
path: PathBuf,
name: String,
},
#[error("duplicate app name `{name}` in {path}")]
DuplicateName {
path: PathBuf,
name: String,
},
}
impl RunpmConfig {
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)
}
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(())
}
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());
};
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");
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() {
let resolved =
RunpmConfig::resolve_cwd(Path::new("runpm.toml"), &Some("services/web".to_string()));
assert_eq!(resolved.as_deref(), Some("services/web"));
}
}