use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process;
use clap::{Args, Subcommand};
use serde_json::{Value, json};
use crate::exit_code;
use super::defaults::{self, defaults_partial};
use super::resolve::{Source, env_partial, resolve_layers, to_value, value_at};
use super::{discover, inactive_sections, load_and_merge, load_layered};
const TEMPLATE: &str = include_str!("template.yaml");
#[derive(Subcommand, Debug)]
pub(crate) enum ConfigCommands {
Init(InitArgs),
Validate(ValidateArgs),
Show(ShowArgs),
Schema,
Path(PathArgs),
Reload(ReloadArgs),
}
#[derive(Args, Debug)]
pub(crate) struct InitArgs {
#[arg(short, long)]
pub output: Option<PathBuf>,
#[arg(long)]
pub force: bool,
}
#[derive(Args, Debug)]
pub(crate) struct ValidateArgs {
#[arg(short, long)]
pub config: Option<PathBuf>,
#[arg(long, default_value = "text", value_parser = ["text", "json"])]
pub format: String,
#[arg(long)]
pub strict: bool,
}
#[derive(Args, Debug)]
pub(crate) struct ShowArgs {
#[arg(short, long)]
pub config: Option<PathBuf>,
#[arg(long = "for", value_parser = ["global", "daemon", "eval"])]
pub section: Option<String>,
#[arg(long, default_value = "text", value_parser = ["text", "json", "yaml"])]
pub format: String,
}
#[derive(Args, Debug)]
pub(crate) struct PathArgs {
#[arg(short, long)]
pub config: Option<PathBuf>,
}
#[derive(Args, Debug)]
pub(crate) struct ReloadArgs {
#[arg(long)]
pub addr: Option<String>,
#[arg(short, long)]
pub config: Option<PathBuf>,
}
pub(crate) fn dispatch(cmd: ConfigCommands) {
match cmd {
ConfigCommands::Init(args) => cmd_init(args),
ConfigCommands::Validate(args) => cmd_validate(args),
ConfigCommands::Show(args) => cmd_show(args),
ConfigCommands::Schema => cmd_schema(),
ConfigCommands::Path(args) => cmd_path(args),
ConfigCommands::Reload(args) => cmd_reload(args),
}
}
fn cmd_init(args: InitArgs) {
let output = args.output.unwrap_or_else(|| PathBuf::from("rsigma.yaml"));
if output.exists() && !args.force {
eprintln!(
"refusing to overwrite existing {} (pass --force to replace it)",
output.display()
);
process::exit(exit_code::CONFIG_ERROR);
}
if let Err(e) = std::fs::write(&output, TEMPLATE) {
eprintln!("could not write {}: {e}", output.display());
process::exit(exit_code::CONFIG_ERROR);
}
eprintln!("Wrote config template to {}", output.display());
}
fn cmd_validate(args: ValidateArgs) {
let json = args.format == "json";
match load_layered(args.config.as_deref()) {
Ok(loaded) => {
let inactive = inactive_sections(&loaded.config);
let unknown_count = loaded.unknown_keys.len();
let failed = args.strict && unknown_count > 0;
if json {
let envelope = serde_json::json!({
"ok": !failed,
"sources": loaded.sources,
"unknown_keys": loaded
.unknown_keys
.iter()
.map(|(path, key)| serde_json::json!({
"file": path,
"key": key,
}))
.collect::<Vec<_>>(),
"inactive_sections": inactive,
});
println!("{}", serde_json::to_string_pretty(&envelope).unwrap());
} else {
if loaded.sources.is_empty() {
eprintln!("No config files found; compiled defaults apply.");
} else {
eprintln!("Loaded (low to high precedence):");
for source in &loaded.sources {
eprintln!(" - {}", source.display());
}
}
for (path, key) in &loaded.unknown_keys {
eprintln!("warning: unknown key '{key}' in {}", path.display());
}
for section in &inactive {
eprintln!(
"warning: section '{section}' is set but inert in this build (feature disabled)"
);
}
if failed {
eprintln!("{unknown_count} unknown key(s) found (--strict)");
} else {
eprintln!("Config is valid.");
}
}
if failed {
process::exit(exit_code::CONFIG_ERROR);
}
}
Err(e) => {
if json {
let envelope = serde_json::json!({
"ok": false,
"error": e.to_string(),
});
println!("{}", serde_json::to_string_pretty(&envelope).unwrap());
} else {
eprintln!("error: {e}");
}
process::exit(exit_code::CONFIG_ERROR);
}
}
}
fn cmd_show(args: ShowArgs) {
let loaded = match load_layered(args.config.as_deref()) {
Ok(loaded) => loaded,
Err(e) => {
eprintln!("error: {e}");
process::exit(exit_code::CONFIG_ERROR);
}
};
let default_v = to_value(&defaults_partial());
let file_v = to_value(&loaded.config);
let env_v = to_value(&env_partial());
let resolved = resolve_layers(default_v, file_v, env_v, Value::Null);
let filter = args.section.as_deref();
let merged = filter_section(&resolved.merged, filter);
match args.format.as_str() {
"json" => {
let sources: BTreeMap<&String, Source> = resolved
.sources
.iter()
.filter(|(path, _)| section_matches(path, filter))
.map(|(path, source)| (path, *source))
.collect();
let envelope = json!({ "config": merged, "sources": sources });
println!("{}", serde_json::to_string_pretty(&envelope).unwrap());
}
"yaml" => {
println!("{}", yaml_serde::to_string(&merged).unwrap_or_default());
}
_ => {
for (path, source) in &resolved.sources {
if !section_matches(path, filter) {
continue;
}
let value = value_at(&resolved.merged, path)
.map(render_scalar)
.unwrap_or_default();
println!("{path} = {value} ({source})");
}
}
}
}
fn filter_section(merged: &Value, section: Option<&str>) -> Value {
match (section, merged) {
(Some(name), Value::Object(map)) => {
let mut out = serde_json::Map::new();
if let Some(v) = map.get(name) {
out.insert(name.to_string(), v.clone());
}
Value::Object(out)
}
_ => merged.clone(),
}
}
fn section_matches(path: &str, section: Option<&str>) -> bool {
match section {
None => true,
Some(name) => path == name || path.starts_with(&format!("{name}.")),
}
}
fn render_scalar(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn cmd_schema() {
let schema = schemars::schema_for!(super::RsigmaConfigPartial);
match serde_json::to_string_pretty(&schema) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("could not serialize schema: {e}");
process::exit(exit_code::CONFIG_ERROR);
}
}
}
fn cmd_path(args: PathArgs) {
let paths = discover(args.config.as_deref());
if paths.is_empty() {
println!("none");
} else {
for path in paths {
println!("{}", path.display());
}
}
}
fn cmd_reload(args: ReloadArgs) {
let addr = args.addr.unwrap_or_else(|| {
let base = load_and_merge(args.config.as_deref());
base.daemon
.and_then(|d| d.api)
.and_then(|a| a.addr)
.unwrap_or_else(|| defaults::API_ADDR.to_string())
});
let url = reload_url(&addr);
match ureq::post(&url).send_empty() {
Ok(resp) if resp.status().is_success() => {
eprintln!("reload requested: {url}");
}
Ok(resp) => {
eprintln!("reload failed: {url} returned HTTP {}", resp.status());
process::exit(exit_code::CONFIG_ERROR);
}
Err(e) => {
eprintln!("reload failed: could not reach {url}: {e}");
eprintln!("(is the daemon running? on unix you can also `kill -HUP <pid>`)");
process::exit(exit_code::CONFIG_ERROR);
}
}
}
fn reload_url(addr: &str) -> String {
if addr.starts_with("http://") || addr.starts_with("https://") {
format!("{}/api/v1/reload", addr.trim_end_matches('/'))
} else {
let host_port = addr
.replace("0.0.0.0", "127.0.0.1")
.replace("[::]", "[::1]");
format!("http://{host_port}/api/v1/reload")
}
}
#[cfg(test)]
mod tests {
use super::reload_url;
#[test]
fn reload_url_maps_wildcard_to_loopback() {
assert_eq!(
reload_url("0.0.0.0:9090"),
"http://127.0.0.1:9090/api/v1/reload"
);
assert_eq!(
reload_url("10.0.0.1:9090"),
"http://10.0.0.1:9090/api/v1/reload"
);
}
#[test]
fn reload_url_accepts_full_url() {
assert_eq!(
reload_url("https://daemon.internal:9443/"),
"https://daemon.internal:9443/api/v1/reload"
);
}
}