pub(crate) mod commands;
pub(crate) mod defaults;
mod resolve;
mod schema;
use std::fmt;
use std::path::{Path, PathBuf};
pub(crate) use schema::{Merge, RsigmaConfigPartial};
pub(crate) fn load_and_merge(explicit: Option<&Path>) -> RsigmaConfigPartial {
match load_layered(explicit) {
Ok(loaded) => {
for (path, key) in &loaded.unknown_keys {
eprintln!("warning: unknown config key '{key}' in {}", path.display());
}
for section in inactive_sections(&loaded.config) {
eprintln!(
"warning: config section '{section}' is set but inert in this build (feature disabled)"
);
}
defaults::defaults_partial()
.merge(loaded.config)
.merge(resolve::env_partial())
}
Err(e) => {
eprintln!("error: {e}");
std::process::exit(crate::exit_code::CONFIG_ERROR);
}
}
}
pub(crate) fn discovered_log_format(explicit: Option<&Path>) -> Option<String> {
let loaded = load_layered(explicit).ok()?;
let merged = loaded.config.merge(resolve::env_partial());
merged.global.and_then(|g| g.log_format)
}
pub(crate) fn discovered_global_output(
explicit: Option<&Path>,
) -> (Option<String>, Option<String>) {
let Some(loaded) = load_layered(explicit).ok() else {
return (None, None);
};
let merged = loaded.config.merge(resolve::env_partial());
let Some(global) = merged.global else {
return (None, None);
};
(global.output_format, global.color)
}
pub(crate) fn print_dry_run(section: &str, base: &RsigmaConfigPartial) {
let value = resolve::to_value(base);
let filtered = value
.get(section)
.cloned()
.unwrap_or(serde_json::Value::Null);
eprintln!(
"# effective {section} config (defaults < file < env); CLI flags override these at runtime"
);
println!("{}", yaml_serde::to_string(&filtered).unwrap_or_default());
}
#[derive(Debug)]
pub(crate) enum ConfigError {
Read {
path: PathBuf,
source: std::io::Error,
},
Parse { path: PathBuf, message: String },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Read { path, source } => {
write!(f, "could not read config {}: {source}", path.display())
}
ConfigError::Parse { path, message } => {
write!(f, "could not parse config {}: {message}", path.display())
}
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Default)]
pub(crate) struct LoadedConfig {
pub config: RsigmaConfigPartial,
pub sources: Vec<PathBuf>,
pub unknown_keys: Vec<(PathBuf, String)>,
}
fn user_config_dir() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if !p.as_os_str().is_empty() {
return Some(p.join("rsigma"));
}
}
dirs::home_dir().map(|h| h.join(".config").join("rsigma"))
}
fn first_existing(dir: &Path, stem: &str) -> Option<PathBuf> {
for ext in ["yaml", "yml"] {
let candidate = dir.join(format!("{stem}.{ext}"));
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn find_rsigmarc() -> Option<PathBuf> {
let mut current = std::env::current_dir().ok()?;
loop {
let candidate = current.join(".rsigmarc");
if candidate.is_file() {
return Some(candidate);
}
if !current.pop() {
return None;
}
}
}
pub(crate) fn discover(explicit: Option<&Path>) -> Vec<PathBuf> {
if let Some(path) = explicit {
return vec![path.to_path_buf()];
}
let mut paths = Vec::new();
if let Some(p) = first_existing(Path::new("/etc/rsigma"), "config") {
paths.push(p);
}
if let Some(dir) = user_config_dir()
&& let Some(p) = first_existing(&dir, "config")
{
paths.push(p);
}
if let Some(p) = find_rsigmarc() {
paths.push(p);
}
if let Some(p) = first_existing(Path::new("."), "rsigma") {
paths.push(p);
}
paths
}
fn load_file(path: &Path) -> Result<(RsigmaConfigPartial, Vec<String>), ConfigError> {
let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
let mut unknown = Vec::new();
let mut docs = yaml_serde::Deserializer::from_str(&content);
let Some(doc) = docs.next() else {
return Ok((RsigmaConfigPartial::default(), unknown));
};
let partial: RsigmaConfigPartial =
serde_ignored::deserialize(doc, |path| unknown.push(path.to_string())).map_err(|e| {
let raw = e.to_string();
let message = if raw.contains("expected struct RsigmaConfigPartial") {
format!(
"top-level config must be a YAML mapping of sections \
(global, daemon, eval); got {raw}"
)
} else {
raw
};
ConfigError::Parse {
path: path.to_path_buf(),
message,
}
})?;
Ok((partial, unknown))
}
pub(crate) fn load_layered(explicit: Option<&Path>) -> Result<LoadedConfig, ConfigError> {
let mut loaded = LoadedConfig::default();
for path in discover(explicit) {
let (partial, unknown) = load_file(&path)?;
for key in unknown {
loaded.unknown_keys.push((path.clone(), key));
}
loaded.config = std::mem::take(&mut loaded.config).merge(partial);
loaded.sources.push(path);
}
Ok(loaded)
}
#[allow(unused_mut, unused_variables)]
pub(crate) fn inactive_sections(config: &RsigmaConfigPartial) -> Vec<&'static str> {
let mut inactive: Vec<&'static str> = Vec::new();
#[cfg(not(feature = "daemon"))]
if config.daemon.is_some() {
inactive.push("daemon");
}
#[cfg(not(feature = "daemon-nats"))]
if config
.daemon
.as_ref()
.and_then(|d| d.nats.as_ref())
.is_some()
{
inactive.push("daemon.nats");
}
#[cfg(not(feature = "daemon-tls"))]
if config
.daemon
.as_ref()
.and_then(|d| d.api.as_ref())
.and_then(|a| a.tls.as_ref())
.is_some()
{
inactive.push("daemon.api.tls");
}
inactive
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn explicit_path_replaces_discovery() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("custom.yaml");
std::fs::write(&path, "version: 1\n").unwrap();
let discovered = discover(Some(&path));
assert_eq!(discovered, vec![path]);
}
#[test]
fn load_collects_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rsigma.yaml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
"version: 1\nbogus_key: true\ndaemon:\n rules: /x\n nope: 1"
)
.unwrap();
let (partial, unknown) = load_file(&path).unwrap();
assert_eq!(partial.version, Some(1));
assert!(unknown.iter().any(|k| k == "bogus_key"));
assert!(unknown.iter().any(|k| k.contains("nope")));
}
#[test]
fn empty_file_yields_default() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("rsigma.yaml");
std::fs::write(&path, "").unwrap();
let (partial, unknown) = load_file(&path).unwrap();
assert!(partial.version.is_none());
assert!(unknown.is_empty());
}
}