summaryrefslogtreecommitdiffstats
path: root/src/config/mod.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-24 15:38:32 +0200
committertv <tv@krebsco.de>2026-10-24 15:38:32 +0200
commiteb9cc70985a27fe16788a885b854fb514234c43a (patch)
tree6dfc192abea479f4690766c06b1ffa64d7e95f28 /src/config/mod.rs
parentfeb349ee09aa2151c421840bec521719c6149dcb (diff)
allow unsetting config options
Diffstat (limited to 'src/config/mod.rs')
-rw-r--r--src/config/mod.rs237
1 files changed, 214 insertions, 23 deletions
diff --git a/src/config/mod.rs b/src/config/mod.rs
index e62b2b6..4f6c5e0 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -22,7 +22,7 @@ use clap::parser::ValueSource;
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
-use tracing::{debug, info};
+use tracing::{debug, info, warn};
use import::{Imported, Raw};
@@ -309,6 +309,9 @@ pub(crate) enum Provenance {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Origin {
pub(crate) provenance: Provenance,
+ /// The option was unset here (back to its default): `--unset`, `unset = [...]` in a
+ /// file, or `null` in a JSON file.
+ pub(crate) unset: bool,
/// Importers the value (or some list elements) came from, e.g. `env:HOSTNAME`.
pub(crate) imports: Vec<String>,
}
@@ -325,6 +328,9 @@ impl std::fmt::Display for Origin {
Provenance::File(path) => write!(f, "{}", path.display())?,
Provenance::Argument => write!(f, "the command line")?,
}
+ if self.unset {
+ write!(f, " (unset)")?;
+ }
if self.is_imported() {
write!(f, " ({})", self.imports.join(", "))?;
}
@@ -341,7 +347,9 @@ pub(crate) struct Entry {
impl Entry {
/// The value for logs: imported values are never shown.
pub(crate) fn shown(&self) -> String {
- if self.origin.is_imported() {
+ if self.origin.unset {
+ "<default>".to_owned()
+ } else if self.origin.is_imported() {
"<imported>".to_owned()
} else {
self.value.to_string()
@@ -349,9 +357,118 @@ impl Entry {
}
}
-/// Option name to value.
+/// Option name to value. A `null` value unsets the option (back to its default).
pub(crate) type Layer = BTreeMap<String, Entry>;
+/// An unset of `name` from `provenance`.
+fn unset_entry(provenance: Provenance) -> Entry {
+ Entry {
+ value: JsonValue::Null,
+ origin: Origin {
+ provenance,
+ unset: true,
+ imports: Vec::new(),
+ },
+ }
+}
+
+/// Options that can't be combined. A source that sets one side drops the other side's
+/// values from earlier sources (lower precedence), so e.g. `--mycelium-rest-url` on the
+/// command line overrides `mycelium-key-file` from a file. Both set in one source is an
+/// error (`Settings::check`).
+const CONFLICTS: &[(&[&str], &[&str])] = &[(
+ &["mycelium-rest-url"],
+ &[
+ "mycelium-key-file",
+ "mycelium-network-key-file",
+ "mycelium-network-name",
+ "mycelium-tcp-listen-port",
+ ],
+)];
+
+/// The options `name` can't be combined with.
+fn conflicting(name: &str) -> impl Iterator<Item = &'static str> + '_ {
+ CONFLICTS.iter().flat_map(move |(a, b)| {
+ let other: &'static [&'static str] = if a.contains(&name) {
+ b
+ } else if b.contains(&name) {
+ a
+ } else {
+ &[]
+ };
+ other.iter().copied()
+ })
+}
+
+/// How a later source relates to the earlier ones it overrides (for logging).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Overlay {
+ /// A later include over an earlier include of the same file: usually a mistake.
+ IncludeOverInclude,
+ /// A file's own values over its includes, or a later `--config` over an earlier.
+ FileOverFile,
+ /// The command line over files.
+ ArgumentOverFile,
+}
+
+/// Puts `entry` for `name` into `layer`, which holds earlier sources, logging what it
+/// replaces and dropping earlier values it can't be combined with.
+pub(crate) fn overlay(layer: &mut Layer, name: String, entry: Entry, how: Overlay) {
+ let log = |message: &str, option: &str, previous: &Entry| match how {
+ Overlay::IncludeOverInclude => warn!(
+ option,
+ value = %entry.shown(),
+ from = %entry.origin,
+ overrides = %previous.shown(),
+ overridden_from = %previous.origin,
+ "{message}"
+ ),
+ Overlay::FileOverFile => debug!(
+ option,
+ from = %entry.origin,
+ overrides = %previous.origin,
+ "{message}"
+ ),
+ Overlay::ArgumentOverFile => info!(
+ option,
+ value = %entry.shown(),
+ overrides = %previous.shown(),
+ from = %previous.origin,
+ "{message}"
+ ),
+ };
+
+ if !entry.value.is_null() {
+ for other in conflicting(&name) {
+ let earlier = layer
+ .get(other)
+ .is_some_and(|previous| previous.origin.provenance != entry.origin.provenance);
+ if earlier {
+ if let Some(previous) = layer.remove(other) {
+ log(
+ &format!(
+ "{name} can't be combined with {other}; dropped the earlier value"
+ ),
+ other,
+ &previous,
+ );
+ }
+ }
+ }
+ }
+ if let Some(previous) = layer.get(&name) {
+ let message = match how {
+ Overlay::IncludeOverInclude => {
+ "Two included configuration files set the same option; the later one wins"
+ }
+ Overlay::FileOverFile => "Configuration file overrides an earlier one",
+ Overlay::ArgumentOverFile => "Command-line argument overrides the configuration file",
+ };
+ log(message, &name, previous);
+ }
+ layer.insert(name, entry);
+}
+
/// The value of a text, by the option's kind.
fn from_text(kind: Kind, text: &str) -> Result<JsonValue> {
Ok(match kind {
@@ -426,6 +543,9 @@ fn resolve_file_value(
/// The first option whose value alone doesn't fit (serde doesn't name it).
fn culprit(merged: &Layer) -> Option<(&String, &Entry)> {
merged.iter().find(|(name, entry)| {
+ if entry.value.is_null() {
+ return false;
+ }
let mut alone = serde_json::Map::new();
alone.insert("htpasswd".to_owned(), JsonValue::String(String::new()));
alone.insert((*name).clone(), entry.value.clone());
@@ -437,6 +557,7 @@ fn culprit(merged: &Layer) -> Option<(&String, &Entry)> {
const CONFIG: &str = "config";
const PRINT_CONFIG: &str = "print-config";
+const UNSET: &str = "unset";
fn command() -> Command {
let mut command = Command::new("continuum")
@@ -456,6 +577,13 @@ fn command() -> Command {
.help("Configuration file (.toml or .json); repeatable, later files win."),
)
.arg(
+ Arg::new(UNSET)
+ .long(UNSET)
+ .value_name("OPTION")
+ .action(ArgAction::Append)
+ .help("Unset an option set by a configuration file (back to its default); repeatable."),
+ )
+ .arg(
Arg::new(PRINT_CONFIG)
.long(PRINT_CONFIG)
.action(ArgAction::SetTrue)
@@ -479,9 +607,15 @@ fn command() -> Command {
command
}
-/// The values given as arguments.
+/// The values given as arguments (unsets first, so a value given too wins).
fn arguments(matches: &ArgMatches) -> Result<Layer> {
let mut layer = Layer::new();
+ for name in matches.get_many::<String>(UNSET).into_iter().flatten() {
+ if spec(name).is_none() {
+ bail!("--unset {name}: no such option");
+ }
+ layer.insert(name.clone(), unset_entry(Provenance::Argument));
+ }
for spec in OPTIONS {
if matches.value_source(spec.name) != Some(ValueSource::CommandLine) {
continue;
@@ -522,6 +656,7 @@ fn arguments(matches: &ArgMatches) -> Result<Layer> {
value,
origin: Origin {
provenance: Provenance::Argument,
+ unset: false,
imports,
},
},
@@ -571,6 +706,9 @@ impl Loaded {
if origin.is_imported() {
entry.insert("imported".into(), origin.imports.clone().into());
}
+ if origin.unset {
+ entry.insert("unset".into(), true.into());
+ }
}
}
let imported = self.origins.get(spec.name).is_some_and(Origin::is_imported);
@@ -609,28 +747,11 @@ pub(crate) fn load() -> Result<Loaded> {
let mut merged = Layer::new();
for path in &files {
for (name, entry) in file::load(path)? {
- if let Some(previous) = merged.get(&name) {
- debug!(
- option = %name,
- from = %entry.origin,
- overrides = %previous.origin,
- "Later configuration file overrides an earlier one"
- );
- }
- merged.insert(name, entry);
+ overlay(&mut merged, name, entry, Overlay::FileOverFile);
}
}
for (name, entry) in arguments(&matches)? {
- if let Some(previous) = merged.get(&name) {
- info!(
- option = %name,
- value = %entry.shown(),
- overrides = %previous.shown(),
- from = %previous.origin,
- "Command-line argument overrides the configuration file"
- );
- }
- merged.insert(name, entry);
+ overlay(&mut merged, name, entry, Overlay::ArgumentOverFile);
}
let mut loaded = resolve(merged, files)?;
loaded.print_only = print_only;
@@ -638,8 +759,10 @@ pub(crate) fn load() -> Result<Loaded> {
}
fn resolve(merged: Layer, files: Vec<PathBuf>) -> Result<Loaded> {
+ // Unset options take their defaults.
let object: serde_json::Map<String, JsonValue> = merged
.iter()
+ .filter(|(_, entry)| !entry.value.is_null())
.map(|(name, entry)| (name.clone(), entry.value.clone()))
.collect();
let mut settings: Settings =
@@ -682,6 +805,7 @@ mod tests {
value: value.clone(),
origin: Origin {
provenance: Provenance::Argument,
+ unset: false,
imports: Vec::new(),
},
},
@@ -718,6 +842,73 @@ mod tests {
}
#[test]
+ fn later_sources_override_conflicts_and_unset() {
+ let from = |provenance: Provenance, value: JsonValue| Entry {
+ value,
+ origin: Origin {
+ provenance,
+ unset: false,
+ imports: Vec::new(),
+ },
+ };
+ let file = |value| from(Provenance::File("/etc/continuum.toml".into()), value);
+ let argument = |value| from(Provenance::Argument, value);
+ let url = json!("http://[::1]:8989");
+
+ let mut merged = Layer::new();
+ overlay(
+ &mut merged,
+ "htpasswd".into(),
+ file(json!("u")),
+ Overlay::FileOverFile,
+ );
+ overlay(
+ &mut merged,
+ "mycelium-key-file".into(),
+ file(json!("k")),
+ Overlay::FileOverFile,
+ );
+ overlay(
+ &mut merged,
+ "alias".into(),
+ file(json!("a")),
+ Overlay::FileOverFile,
+ );
+
+ // Both in one source: an error.
+ let mut same = merged.clone();
+ overlay(
+ &mut same,
+ "mycelium-rest-url".into(),
+ file(url.clone()),
+ Overlay::FileOverFile,
+ );
+ assert!(resolve(same, Vec::new()).is_err());
+
+ // The command line wins over the conflicting file value, and unsets.
+ overlay(
+ &mut merged,
+ "mycelium-rest-url".into(),
+ argument(url),
+ Overlay::ArgumentOverFile,
+ );
+ overlay(
+ &mut merged,
+ "alias".into(),
+ unset_entry(Provenance::Argument),
+ Overlay::ArgumentOverFile,
+ );
+ let loaded = resolve(merged, Vec::new()).unwrap();
+ assert!(loaded.settings.mycelium_key_file.is_none());
+ assert!(loaded.settings.mycelium_rest_url.is_some());
+ assert!(loaded.settings.alias.is_none());
+ assert_eq!(
+ loaded.report()["options"]["alias"],
+ json!({ "source": "argument", "unset": true, "value": null })
+ );
+ }
+
+ #[test]
fn report_hides_imported_values() {
let mut values = layer(json!({ "htpasswd": "users", "alias": "secret-ish" }));
values.get_mut("alias").unwrap().origin.imports = vec!["env:HOSTNAME".into()];