extern crate alloc;
use alloc::collections::BTreeMap;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use std::path::PathBuf;
use crate::{Config, ConfigError, Node, Origin};
pub const ENV_SOURCE: &str = "environment";
pub const CLI_SOURCE: &str = "command line";
pub fn path_node(path: &str, value: &str, origin: Origin) -> Result<Node, ConfigError> {
at_path(path, Node::Scalar(value.to_string(), origin))
}
fn at_path(path: &str, leaf: Node) -> Result<Node, ConfigError> {
let segments: Vec<&str> = path.split('.').collect();
if segments.iter().any(|s| s.is_empty() || *s == "+") {
return Err(ConfigError::Usage(alloc::format!(
"`{path}` is not a dotted setting path"
)));
}
if segments[..segments.len() - 1]
.iter()
.any(|s| s.starts_with('+'))
{
return Err(ConfigError::Usage(alloc::format!(
"`{path}`: only the last segment may be written `+name`"
)));
}
let mut node = leaf;
for segment in segments.iter().rev() {
let mut map = BTreeMap::new();
map.insert((*segment).to_string(), node);
node = Node::Map(map);
}
Ok(node)
}
fn tree_path(path: &str) -> String {
let path = path.trim();
match path.strip_prefix('+') {
Some(rest) => match rest.rsplit_once('.') {
Some((head, last)) => alloc::format!("{head}.+{last}"),
None => alloc::format!("+{rest}"),
},
None => path.to_string(),
}
}
pub fn env_layer<F, L>(table: &[(&str, &str)], lookup: F, is_list: L) -> Result<Node, ConfigError>
where
F: Fn(&str) -> Option<String>,
L: Fn(&str) -> bool,
{
let mut root = Node::Map(BTreeMap::new());
for &(name, path) in table {
let Some(value) = lookup(name) else {
continue;
};
let origin = Origin::Env(name.to_string());
let mut node = path_node(path, &value, origin.clone())?;
if is_list(path) {
let items = value
.split(',')
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| Node::Scalar(v.to_string(), origin.clone()))
.collect();
set_leaf(&mut node, Node::Seq(items));
}
root.merge(node)?;
}
Ok(root)
}
fn set_leaf(node: &mut Node, leaf: Node) {
let mut here = node;
loop {
match here {
Node::Map(map) => match map.values_mut().next() {
Some(next) => here = next,
None => return,
},
slot => {
*slot = leaf;
return;
}
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CommandLine {
config: Option<PathBuf>,
sets: Vec<(String, String)>,
entries: Vec<Entry>,
check: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Entry {
path: String,
fields: Vec<(String, String)>,
flag: String,
}
impl CommandLine {
pub fn extract<I>(args: I) -> Result<(Self, Vec<String>), ConfigError>
where
I: IntoIterator<Item = String>,
{
let mut out = Self::default();
let mut rest = Vec::new();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
if arg == "--" {
rest.push(arg);
rest.extend(args);
break;
}
let (flag, inline) = match arg.split_once('=') {
Some((flag, value)) if flag == "--config" || flag == "--set" => {
(flag.to_string(), Some(value.to_string()))
}
_ => (arg.clone(), None),
};
match flag.as_str() {
"--check-config" => out.check = true,
"--config" => {
let value = match inline {
Some(v) => v,
None => args.next().ok_or_else(|| {
ConfigError::Usage("--config expects a path".to_string())
})?,
};
if value.is_empty() {
return Err(ConfigError::Usage("--config expects a path".to_string()));
}
if out.config.is_some() {
return Err(ConfigError::Usage(
"--config given twice; name one base file".to_string(),
));
}
out.config = Some(PathBuf::from(value));
}
"--set" => {
let value = match inline {
Some(v) => v,
None => args.next().ok_or_else(|| {
ConfigError::Usage("--set expects PATH=VALUE".to_string())
})?,
};
out.push_set(&value)?;
}
_ => match arg.strip_prefix("--").and_then(|a| a.split_once('=')) {
Some((name, value)) if !name.is_empty() => {
rest.push(alloc::format!("--{name}"));
rest.push(value.to_string());
}
_ => rest.push(arg),
},
}
}
Ok((out, rest))
}
pub fn push_set(&mut self, assignment: &str) -> Result<(), ConfigError> {
let Some((path, value)) = assignment.split_once('=') else {
return Err(ConfigError::Usage(alloc::format!(
"--set {assignment}: expected PATH=VALUE"
)));
};
let path = tree_path(path);
path_node(&path, value, Origin::Default)?;
self.sets.push((path, value.to_string()));
Ok(())
}
pub fn push_entry(
&mut self,
path: &str,
fields: &[(&str, &str)],
flag: &str,
) -> Result<(), ConfigError> {
let path = tree_path(path);
if !path
.rsplit('.')
.next()
.is_some_and(|last| last.starts_with('+'))
{
return Err(ConfigError::Usage(alloc::format!(
"{flag}: `{path}` appends only when written `+name`"
)));
}
at_path(&path, Node::Map(BTreeMap::new()))?;
if let Some((name, _)) = fields
.iter()
.find(|(name, _)| name.is_empty() || name.contains('.') || name.starts_with('+'))
{
return Err(ConfigError::Usage(alloc::format!(
"{flag}: `{name}` is not a setting name"
)));
}
self.entries.push(Entry {
path,
fields: fields
.iter()
.map(|(name, value)| ((*name).to_string(), (*value).to_string()))
.collect(),
flag: flag.to_string(),
});
Ok(())
}
#[must_use]
pub fn check(&self) -> bool {
self.check
}
#[must_use]
pub fn with_config(mut self, path: impl Into<PathBuf>) -> Self {
self.config = Some(path.into());
self
}
#[must_use]
pub fn config_path(&self) -> Option<&std::path::Path> {
self.config.as_deref()
}
#[must_use]
pub fn sets(&self) -> &[(String, String)] {
&self.sets
}
pub fn layer(&self) -> Result<Vec<Node>, ConfigError> {
let sets = self.sets.iter().map(|(path, value)| {
let shown = if is_secret_path(path) {
"***"
} else {
value.as_str()
};
path_node(
path,
value,
Origin::Cli(alloc::format!("--set {path}={shown}")),
)
});
let entries = self.entries.iter().map(|entry| {
let origin = Origin::Cli(entry.flag.clone());
let map = entry
.fields
.iter()
.map(|(name, value)| (name.clone(), Node::Scalar(value.clone(), origin.clone())))
.collect();
at_path(&entry.path, Node::Map(map))
});
sets.chain(entries).collect()
}
pub fn apply(&self, config: &mut Config) -> Result<(), ConfigError> {
if self.sets.is_empty() && self.entries.is_empty() {
return Ok(());
}
config.push_each(self.layer()?, CLI_SOURCE.to_string())
}
}
pub fn env_config<F, L>(
table: &[(&str, &str)],
lookup: F,
is_list: L,
) -> Result<Config, ConfigError>
where
F: Fn(&str) -> Option<String>,
L: Fn(&str) -> bool,
{
let mut config = Config::default();
let node = env_layer(table, lookup, is_list)?;
if matches!(&node, Node::Map(m) if !m.is_empty()) {
config.push(node, ENV_SOURCE.to_string())?;
}
Ok(config)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemLayout {
pub base: PathBuf,
pub drop_ins: Vec<PathBuf>,
}
impl SystemLayout {
#[must_use]
pub fn dir() -> PathBuf {
#[cfg(windows)]
{
let root = std::env::var_os("PROGRAMDATA")
.map_or_else(|| PathBuf::from(r"C:\ProgramData"), PathBuf::from);
root.join("ZeroDDS")
}
#[cfg(target_os = "macos")]
{
PathBuf::from("/usr/local/etc/zerodds")
}
#[cfg(not(any(windows, target_os = "macos")))]
{
PathBuf::from("/etc/zerodds")
}
}
#[must_use]
pub fn for_stem(stem: &str) -> Self {
let dir = Self::dir();
#[allow(unused_mut)]
let mut drop_ins = alloc::vec![dir.join(alloc::format!("{stem}.d"))];
#[cfg(target_os = "linux")]
drop_ins.push(PathBuf::from("/run/zerodds").join(alloc::format!("{stem}.d")));
Self {
base: dir.join(alloc::format!("{stem}.yaml")),
drop_ins,
}
}
#[must_use]
pub fn loader(&self) -> crate::Loader {
self.drop_ins
.iter()
.fold(crate::Loader::new().base(&self.base), |l, d| {
l.drop_in_dir(d)
})
}
}
#[must_use]
pub fn loader_for(command_line: &CommandLine, stem: &str) -> crate::Loader {
match command_line.config_path() {
Some(path) => crate::Loader::new()
.base(path)
.require_base()
.drop_in_dir(path.with_extension("d")),
None => SystemLayout::for_stem(stem).loader(),
}
}
#[must_use]
pub fn unknown_keys<K>(config: &Config, known: K) -> Vec<(String, Origin)>
where
K: Fn(&str) -> bool,
{
let mut out: Vec<(String, Origin)> = Vec::new();
for (path, origin) in crate::origins(config) {
let path = path.split('[').next().unwrap_or_default().to_string();
if !known(&path) && !out.iter().any(|(p, _)| *p == path) {
out.push((path, origin));
}
}
out
}
#[must_use]
pub fn is_secret_path(path: &str) -> bool {
path.split('.').any(|segment| {
let segment = segment
.split('[')
.next()
.unwrap_or_default()
.trim_start_matches('+')
.to_ascii_lowercase();
[
"token",
"password",
"secret",
"passphrase",
"psk",
"sasl_users",
"credentials",
]
.iter()
.any(|word| segment.contains(word))
})
}