use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
pub const DEFAULT_RECOVERY_DEBOUNCE_MS: u64 = 1000;
pub const DEFAULT_SOCKET_MODE: u32 = 0o600;
#[derive(Clone, Debug)]
pub struct Config {
pub socket: PathBuf,
pub threshold: Duration,
pub recovery_cmd: Option<String>,
pub recovery_debounce: Duration,
pub file_export: Option<PathBuf>,
pub prom_addr: Option<SocketAddr>,
pub shutdown_after: Option<Duration>,
pub recovery_timeout: Option<Duration>,
pub socket_mode: u32,
}
#[derive(Debug)]
pub enum ConfigError {
MissingValue(&'static str),
MissingRequired(&'static str),
UnknownFlag(String),
BadInteger {
flag: &'static str,
raw: String,
},
BadSocketMode(String),
BadAddr(String),
HelpRequested,
}
impl core::fmt::Display for ConfigError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
ConfigError::MissingValue(flag) => write!(f, "{flag} requires a value"),
ConfigError::MissingRequired(flag) => write!(f, "missing required flag {flag}"),
ConfigError::UnknownFlag(s) => write!(f, "unknown flag {s}"),
ConfigError::BadInteger { flag, raw } => {
write!(f, "{flag}: not a valid unsigned integer: {raw:?}")
}
ConfigError::BadSocketMode(raw) => {
write!(
f,
"--socket-mode: expected octal digits (e.g. 600, 0600, or 0o600), got: {raw:?}"
)
}
ConfigError::BadAddr(raw) => {
write!(f, "--prom-addr: not a valid socket address: {raw:?}")
}
ConfigError::HelpRequested => f.write_str("--help"),
}
}
}
impl std::error::Error for ConfigError {}
impl Config {
pub const HELP: &'static str = "\
varta-watch — observe Varta Lifeline Protocol agents over a Unix socket.
USAGE:
varta-watch --socket <PATH> --threshold-ms <MS> [OPTIONS]
REQUIRED:
--socket <PATH> Path to bind the observer's UDS.
--threshold-ms <MS> Per-pid silence window before a stall is
surfaced (milliseconds).
OPTIONAL:
--recovery-cmd <TEMPLATE> Shell fragment run on each unique stall.
The literal {pid} is replaced with the
stalled pid before /bin/sh -c executes.
--recovery-debounce-ms <MS> Per-pid debounce window for recovery
invocations (default 1000).
--socket-mode <OCTAL> File mode for the observer socket
(default 0600 — owner-only r/w).
--export-file <PATH> Append one tab-separated event line per
observer event to this file.
--prom-addr <IP:PORT> Bind a Prometheus text-format endpoint at
GET /metrics on this address.
--recovery-timeout-ms <MS> Kill-after deadline for recovery children;
if a child runs longer than this it is
killed via kill(2). Without this flag the
child is allowed to run until completion.
--shutdown-after-secs <SECS> Exit cleanly after the given uptime
(used by integration tests).
-h, --help Print this message and exit.
";
pub fn from_args(args: impl IntoIterator<Item = String>) -> Result<Config, ConfigError> {
let mut socket: Option<PathBuf> = None;
let mut threshold_ms: Option<u64> = None;
let mut recovery_cmd: Option<String> = None;
let mut recovery_debounce_ms: Option<u64> = None;
let mut file_export: Option<PathBuf> = None;
let mut prom_addr: Option<SocketAddr> = None;
let mut shutdown_after_secs: Option<u64> = None;
let mut recovery_timeout_ms: Option<u64> = None;
let mut socket_mode: Option<u32> = None;
let mut iter = args.into_iter();
while let Some(tok) = iter.next() {
match tok.as_str() {
"--help" | "-h" => return Err(ConfigError::HelpRequested),
"--socket" => {
let v = iter.next().ok_or(ConfigError::MissingValue("--socket"))?;
socket = Some(PathBuf::from(v));
}
"--threshold-ms" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--threshold-ms"))?;
threshold_ms = Some(parse_u64("--threshold-ms", &v)?);
}
"--recovery-cmd" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--recovery-cmd"))?;
recovery_cmd = Some(v);
}
"--recovery-debounce-ms" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--recovery-debounce-ms"))?;
recovery_debounce_ms = Some(parse_u64("--recovery-debounce-ms", &v)?);
}
"--socket-mode" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--socket-mode"))?;
socket_mode = Some(parse_octal(&v)?);
}
"--export-file" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--export-file"))?;
file_export = Some(PathBuf::from(v));
}
"--prom-addr" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--prom-addr"))?;
prom_addr = Some(
v.parse::<SocketAddr>()
.map_err(|_| ConfigError::BadAddr(v))?,
);
}
"--recovery-timeout-ms" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--recovery-timeout-ms"))?;
recovery_timeout_ms = Some(parse_u64("--recovery-timeout-ms", &v)?);
}
"--shutdown-after-secs" => {
let v = iter
.next()
.ok_or(ConfigError::MissingValue("--shutdown-after-secs"))?;
shutdown_after_secs = Some(parse_u64("--shutdown-after-secs", &v)?);
}
other => return Err(ConfigError::UnknownFlag(other.to_string())),
}
}
let socket = socket.ok_or(ConfigError::MissingRequired("--socket"))?;
let threshold_ms = threshold_ms.ok_or(ConfigError::MissingRequired("--threshold-ms"))?;
let recovery_debounce =
Duration::from_millis(recovery_debounce_ms.unwrap_or(DEFAULT_RECOVERY_DEBOUNCE_MS));
Ok(Config {
socket,
threshold: Duration::from_millis(threshold_ms),
recovery_cmd,
recovery_debounce,
file_export,
prom_addr,
shutdown_after: shutdown_after_secs.map(Duration::from_secs),
recovery_timeout: recovery_timeout_ms.map(Duration::from_millis),
socket_mode: socket_mode.unwrap_or(DEFAULT_SOCKET_MODE),
})
}
}
fn parse_u64(flag: &'static str, raw: &str) -> Result<u64, ConfigError> {
raw.parse::<u64>().map_err(|_| ConfigError::BadInteger {
flag,
raw: raw.to_string(),
})
}
fn parse_octal(raw: &str) -> Result<u32, ConfigError> {
let digits = raw
.strip_prefix("0o")
.or_else(|| raw.strip_prefix("0O"))
.unwrap_or(raw);
if digits.is_empty() {
return Err(ConfigError::BadSocketMode(raw.to_string()));
}
u32::from_str_radix(digits, 8).map_err(|_| ConfigError::BadSocketMode(raw.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
fn args(toks: &[&str]) -> Vec<String> {
toks.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parses_minimal_required_flags() {
let cfg = Config::from_args(args(&["--socket", "/tmp/x.sock", "--threshold-ms", "250"]))
.expect("parse");
assert_eq!(cfg.socket, PathBuf::from("/tmp/x.sock"));
assert_eq!(cfg.threshold, Duration::from_millis(250));
assert_eq!(cfg.recovery_debounce, Duration::from_millis(1000));
assert_eq!(cfg.socket_mode, 0o600);
assert!(cfg.recovery_cmd.is_none());
assert!(cfg.prom_addr.is_none());
}
#[test]
fn parses_full_flag_surface() {
let cfg = Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--recovery-cmd",
"echo {pid}",
"--recovery-debounce-ms",
"750",
"--export-file",
"/tmp/e.log",
"--prom-addr",
"127.0.0.1:9090",
"--shutdown-after-secs",
"3",
]))
.expect("parse");
assert_eq!(cfg.recovery_cmd.as_deref(), Some("echo {pid}"));
assert_eq!(cfg.recovery_debounce, Duration::from_millis(750));
assert_eq!(cfg.file_export, Some(PathBuf::from("/tmp/e.log")));
assert_eq!(
cfg.prom_addr,
Some("127.0.0.1:9090".parse::<SocketAddr>().unwrap())
);
assert_eq!(cfg.shutdown_after, Some(Duration::from_secs(3)));
}
#[test]
fn help_returns_help_requested() {
match Config::from_args(args(&["--help"])) {
Err(ConfigError::HelpRequested) => {}
other => panic!("expected HelpRequested, got {other:?}"),
}
}
#[test]
fn unknown_flag_is_rejected() {
match Config::from_args(args(&["--nope"])) {
Err(ConfigError::UnknownFlag(s)) => assert_eq!(s, "--nope"),
other => panic!("expected UnknownFlag, got {other:?}"),
}
}
#[test]
fn missing_required_socket_is_rejected() {
match Config::from_args(args(&["--threshold-ms", "100"])) {
Err(ConfigError::MissingRequired("--socket")) => {}
other => panic!("expected MissingRequired(--socket), got {other:?}"),
}
}
#[test]
fn help_text_lists_every_known_flag() {
for flag in [
"--socket",
"--threshold-ms",
"--recovery-cmd",
"--recovery-debounce-ms",
"--recovery-timeout-ms",
"--export-file",
"--prom-addr",
"--socket-mode",
"--shutdown-after-secs",
"--help",
] {
assert!(
Config::HELP.contains(flag),
"Config::HELP missing flag {flag}"
);
}
}
#[test]
fn parses_recovery_timeout_ms() {
let cfg = Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--recovery-timeout-ms",
"2500",
]))
.expect("parse");
assert_eq!(cfg.recovery_timeout, Some(Duration::from_millis(2500)));
}
#[test]
fn recovery_timeout_omitted_is_none() {
let cfg =
Config::from_args(args(&["--socket", "/s", "--threshold-ms", "100"])).expect("parse");
assert!(cfg.recovery_timeout.is_none());
}
#[test]
fn parses_socket_mode_octal() {
let cfg = Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--socket-mode",
"660",
]))
.expect("parse");
assert_eq!(cfg.socket_mode, 0o660);
}
#[test]
fn socket_mode_rejects_non_octal() {
match Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--socket-mode",
"999",
])) {
Err(ConfigError::BadSocketMode(_)) => {}
other => panic!("expected BadSocketMode, got {other:?}"),
}
}
#[test]
fn socket_mode_accepts_0o_prefix() {
let cfg = Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--socket-mode",
"0o640",
]))
.expect("parse");
assert_eq!(cfg.socket_mode, 0o640);
}
#[test]
fn socket_mode_accepts_uppercase_0o_prefix() {
let cfg = Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--socket-mode",
"0O640",
]))
.expect("parse");
assert_eq!(cfg.socket_mode, 0o640);
}
#[test]
fn socket_mode_accepts_leading_zero() {
let cfg = Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--socket-mode",
"0644",
]))
.expect("parse");
assert_eq!(cfg.socket_mode, 0o644);
}
#[test]
fn socket_mode_rejects_empty_after_prefix() {
match Config::from_args(args(&[
"--socket",
"/s",
"--threshold-ms",
"100",
"--socket-mode",
"0o",
])) {
Err(ConfigError::BadSocketMode(raw)) => assert_eq!(raw, "0o"),
other => panic!("expected BadSocketMode, got {other:?}"),
}
}
}