use std::collections::HashMap;
use std::fmt;
use std::path::Path;
use std::sync::Arc;
use serde::Deserialize;
use tracing::{info, warn};
use crate::labels::{Mode, PolicyDefaults};
#[derive(Clone, Deserialize)]
#[serde(transparent)]
pub struct Secret(String);
impl Secret {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret(\"[REDACTED]\")")
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct RegistryCredentials {
#[serde(default)]
pub username: Option<String>,
pub token: Secret,
}
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub registry: HashMap<String, RegistryCredentials>,
#[serde(default)]
pub notifications: HashMap<String, NotificationTarget>,
#[serde(default)]
pub settings: Settings,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Settings {
#[serde(default)]
pub default_mode: Option<String>,
#[serde(default)]
pub cleanup: bool,
#[serde(default)]
pub prune_dangling: bool,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ResolvedSettings {
pub default_mode: Option<Mode>,
pub cleanup: bool,
pub prune_dangling: bool,
}
impl ResolvedSettings {
pub fn policy_defaults(&self) -> PolicyDefaults {
PolicyDefaults {
mode: self.default_mode,
cleanup: self.cleanup,
}
}
}
fn parse_env_bool(var: &str, raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => {
warn!(
var = %var,
value = %raw,
"ignoring invalid boolean (expected true/false/1/0)"
);
None
}
}
}
fn resolve_settings<I>(mut settings: Settings, env_vars: I) -> ResolvedSettings
where
I: Iterator<Item = (String, String)>,
{
for (key, value) in env_vars {
match key.as_str() {
"FRESHDOCK_DEFAULT_MODE" => {
if value.parse::<Mode>().is_ok() {
settings.default_mode = Some(value);
} else {
warn!(
value = %value,
"ignoring invalid FRESHDOCK_DEFAULT_MODE (expected one of \
live, nightly, weekly, monthly, watch, off)"
);
}
}
"FRESHDOCK_CLEANUP" => {
if let Some(flag) = parse_env_bool(&key, &value) {
settings.cleanup = flag;
}
}
"FRESHDOCK_PRUNE_DANGLING" => {
if let Some(flag) = parse_env_bool(&key, &value) {
settings.prune_dangling = flag;
}
}
_ => {}
}
}
let default_mode = match settings.default_mode.as_deref() {
None => None,
Some(raw) => match raw.parse::<Mode>() {
Ok(mode) => Some(mode),
Err(_) => {
warn!(
value = %raw,
"ignoring invalid [settings] default_mode (expected one of \
live, nightly, weekly, monthly, watch, off); falling back to watch"
);
None
}
},
};
ResolvedSettings {
default_mode,
cleanup: settings.cleanup,
prune_dangling: settings.prune_dangling,
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum NotificationTarget {
Webhook {
url: Secret,
#[serde(default)]
triggers: Option<Vec<String>>,
},
Discord {
webhook_url: Secret,
#[serde(default)]
triggers: Option<Vec<String>>,
},
Telegram {
bot_token: Secret,
chat_id: String,
#[serde(default)]
triggers: Option<Vec<String>>,
},
Smtp {
host: String,
#[serde(default = "default_smtp_port")]
port: u16,
#[serde(default)]
username: Option<String>,
#[serde(default)]
password: Option<Secret>,
from: String,
to: Vec<String>,
#[serde(default = "default_true")]
starttls: bool,
#[serde(default)]
triggers: Option<Vec<String>>,
},
}
fn default_smtp_port() -> u16 {
587
}
fn default_true() -> bool {
true
}
#[derive(Debug, Default)]
pub struct NotificationConfig {
pub targets: HashMap<String, NotificationTarget>,
}
pub struct LoadedConfig {
pub credentials: Arc<CredentialStore>,
pub notifications: NotificationConfig,
pub settings: ResolvedSettings,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("reading config file {path}: {source}")]
Read {
path: String,
source: std::io::Error,
},
#[error("parsing config file {path}: {source}")]
Parse {
path: String,
source: toml::de::Error,
},
}
#[derive(Debug, Default)]
pub struct CredentialStore {
by_host: HashMap<String, RegistryCredentials>,
}
impl CredentialStore {
pub fn get(&self, host: &str) -> Option<&RegistryCredentials> {
self.by_host.get(&canonicalize_host(host))
}
pub fn is_empty(&self) -> bool {
self.by_host.is_empty()
}
pub fn len(&self) -> usize {
self.by_host.len()
}
pub fn hosts(&self) -> Vec<&str> {
let mut hosts: Vec<&str> = self.by_host.keys().map(String::as_str).collect();
hosts.sort_unstable();
hosts
}
}
fn log_credentials_loaded(credentials: &CredentialStore) {
if credentials.is_empty() {
info!("no registry credentials loaded; using anonymous access only");
} else {
info!(
count = credentials.len(),
registries = ?credentials.hosts(),
"registry credentials loaded"
);
}
}
impl Config {
pub fn from_toml(input: &str) -> Result<Self, toml::de::Error> {
toml::from_str(input)
}
pub fn load(path: Option<&Path>) -> Result<LoadedConfig, ConfigError> {
let mut config = match path {
Some(p) => Self::read_file(p)?,
None => {
let default = Path::new(DEFAULT_CONFIG_FILE);
match default.try_exists() {
Ok(true) => Self::read_file(default)?,
Ok(false) => Self::default(),
Err(source) => {
return Err(ConfigError::Read {
path: default.display().to_string(),
source,
});
}
}
}
};
let notifications =
build_notifications(std::mem::take(&mut config.notifications), std::env::vars());
let settings = resolve_settings(std::mem::take(&mut config.settings), std::env::vars());
let credentials = Arc::new(build_store(config, std::env::vars()));
log_credentials_loaded(&credentials);
Ok(LoadedConfig {
credentials,
notifications,
settings,
})
}
fn read_file(path: &Path) -> Result<Self, ConfigError> {
let body = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
path: path.display().to_string(),
source,
})?;
Self::from_toml(&body).map_err(|source| ConfigError::Parse {
path: path.display().to_string(),
source,
})
}
}
pub const DEFAULT_CONFIG_FILE: &str = "freshdock.toml";
pub const ENV_VAR_HELP: &str = "Registry credentials may also be supplied via environment, which \
overrides the config file:\n FRESHDOCK_REGISTRY_<NAME>_USERNAME e.g. FRESHDOCK_REGISTRY_GHCR_USERNAME\n \
FRESHDOCK_REGISTRY_<NAME>_TOKEN e.g. FRESHDOCK_REGISTRY_GHCR_TOKEN\n<NAME> is dockerhub, ghcr, quay, \
lscr, or a registry host.\nNotification secrets may be overridden the same way (<NAME> is the \
[notifications.<NAME>] table name, upper-cased with '-' as '_'):\n FRESHDOCK_NOTIFY_<NAME>_BOT_TOKEN (telegram)\n \
FRESHDOCK_NOTIFY_<NAME>_PASSWORD (smtp)\nUse plain alphanumeric target names so two can't map to the \
same variable (e.g. `ops-mail` and `ops_mail` collide).\n[settings] defaults may be supplied or overridden \
the same way:\n FRESHDOCK_DEFAULT_MODE live|nightly|weekly|monthly|watch|off\n \
FRESHDOCK_CLEANUP true/false/1/0\n FRESHDOCK_PRUNE_DANGLING true/false/1/0\n\
Run flags have env forms too, the flag winning: FRESHDOCK_INTERVAL, FRESHDOCK_TICK, FRESHDOCK_STOP_TIMEOUT \
(see `freshdock run --help`).\nNO_COLOR (any non-empty value) disables colored output.\nFRESHDOCK_CONFIG \
sets the config file path.";
pub(crate) fn canonicalize_host(key: &str) -> String {
match key.trim().to_ascii_lowercase().as_str() {
"dockerhub" | "docker" | "docker.io" | "registry-1.docker.io" | "index.docker.io" => {
"docker.io".to_string()
}
"ghcr" | "ghcr.io" => "ghcr.io".to_string(),
"quay" | "quay.io" => "quay.io".to_string(),
"lscr" | "lscr.io" => "lscr.io".to_string(),
other => other.to_string(),
}
}
pub fn build_store<I>(config: Config, env_vars: I) -> CredentialStore
where
I: Iterator<Item = (String, String)>,
{
let mut by_host: HashMap<String, RegistryCredentials> = HashMap::new();
for (key, creds) in config.registry {
by_host.insert(canonicalize_host(&key), creds);
}
let mut env: HashMap<String, (Option<String>, Option<Secret>)> = HashMap::new();
for (key, value) in env_vars {
let Some(rest) = key.strip_prefix("FRESHDOCK_REGISTRY_") else {
continue;
};
if let Some(name) = rest.strip_suffix("_USERNAME") {
env.entry(canonicalize_host(&name.to_ascii_lowercase()))
.or_default()
.0 = Some(value);
} else if let Some(name) = rest.strip_suffix("_TOKEN") {
env.entry(canonicalize_host(&name.to_ascii_lowercase()))
.or_default()
.1 = Some(Secret::new(value));
}
}
for (host, (username, token)) in env {
match by_host.get_mut(&host) {
Some(existing) => {
if username.is_some() {
existing.username = username;
}
if let Some(token) = token {
existing.token = token;
}
}
None => match token {
Some(token) => {
by_host.insert(host, RegistryCredentials { username, token });
}
None => warn!(
registry = %host,
"ignoring FRESHDOCK_REGISTRY_*_USERNAME with no matching token"
),
},
}
}
CredentialStore { by_host }
}
fn notify_env_name(key: &str) -> String {
key.to_ascii_uppercase().replace('-', "_")
}
pub fn build_notifications<I>(
mut targets: HashMap<String, NotificationTarget>,
env_vars: I,
) -> NotificationConfig
where
I: Iterator<Item = (String, String)>,
{
let index: HashMap<String, String> = targets
.keys()
.map(|k| (notify_env_name(k), k.clone()))
.collect();
for (key, value) in env_vars {
let Some(rest) = key.strip_prefix("FRESHDOCK_NOTIFY_") else {
continue;
};
if let Some(name) = rest.strip_suffix("_BOT_TOKEN") {
match index.get(name).and_then(|k| targets.get_mut(k)) {
Some(NotificationTarget::Telegram { bot_token, .. }) => {
*bot_token = Secret::new(value);
}
_ => warn!(
target = %name,
"ignoring FRESHDOCK_NOTIFY_*_BOT_TOKEN: no matching telegram target"
),
}
} else if let Some(name) = rest.strip_suffix("_PASSWORD") {
match index.get(name).and_then(|k| targets.get_mut(k)) {
Some(NotificationTarget::Smtp { password, .. }) => {
*password = Some(Secret::new(value));
}
_ => warn!(
target = %name,
"ignoring FRESHDOCK_NOTIFY_*_PASSWORD: no matching smtp target"
),
}
}
}
NotificationConfig { targets }
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> std::vec::IntoIter<(String, String)> {
pairs
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect::<Vec<_>>()
.into_iter()
}
#[test]
fn parses_registry_table() {
let cfg = Config::from_toml(
r#"
[registry.ghcr]
username = "octocat"
token = "ghp_xxx"
"#,
)
.unwrap();
let store = build_store(cfg, env(&[]));
let c = store
.get("ghcr.io")
.expect("ghcr alias resolves to ghcr.io");
assert_eq!(c.username.as_deref(), Some("octocat"));
assert_eq!(c.token.expose(), "ghp_xxx");
}
#[test]
fn token_without_username_is_allowed() {
let cfg = Config::from_toml("[registry.quay]\ntoken = \"t\"\n").unwrap();
let store = build_store(cfg, env(&[]));
let c = store.get("quay.io").unwrap();
assert!(c.username.is_none());
assert_eq!(c.token.expose(), "t");
}
#[test]
fn env_token_overrides_file_token_keeping_file_username() {
let cfg =
Config::from_toml("[registry.ghcr]\nusername = \"u\"\ntoken = \"file\"\n").unwrap();
let store = build_store(cfg, env(&[("FRESHDOCK_REGISTRY_GHCR_TOKEN", "envtok")]));
let c = store.get("ghcr.io").unwrap();
assert_eq!(c.token.expose(), "envtok");
assert_eq!(c.username.as_deref(), Some("u"));
}
#[test]
fn env_creates_entry_when_file_has_none() {
let store = build_store(
Config::default(),
env(&[
("FRESHDOCK_REGISTRY_DOCKERHUB_USERNAME", "me"),
("FRESHDOCK_REGISTRY_DOCKERHUB_TOKEN", "pat"),
]),
);
let c = store
.get("docker.io")
.expect("dockerhub env maps to docker.io");
assert_eq!(c.username.as_deref(), Some("me"));
assert_eq!(c.token.expose(), "pat");
}
#[test]
fn env_username_without_token_is_dropped() {
let store = build_store(
Config::default(),
env(&[("FRESHDOCK_REGISTRY_GHCR_USERNAME", "u")]),
);
assert!(store.get("ghcr.io").is_none());
assert!(store.is_empty());
}
#[test]
fn unrelated_env_vars_are_ignored() {
let store = build_store(
Config::default(),
env(&[("PATH", "/usr/bin"), ("FRESHDOCK_CONFIG", "x.toml")]),
);
assert!(store.is_empty());
}
#[test]
fn canonicalize_folds_aliases_and_hosts() {
assert_eq!(canonicalize_host("dockerhub"), "docker.io");
assert_eq!(canonicalize_host("registry-1.docker.io"), "docker.io");
assert_eq!(canonicalize_host("GHCR"), "ghcr.io");
assert_eq!(canonicalize_host("quay.io"), "quay.io");
assert_eq!(canonicalize_host("Reg.Example.COM"), "reg.example.com");
}
#[test]
fn unknown_host_key_in_file_is_kept_verbatim() {
let cfg = Config::from_toml("[registry.\"reg.example.com\"]\ntoken = \"t\"\n").unwrap();
let store = build_store(cfg, env(&[]));
assert!(store.get("reg.example.com").is_some());
}
#[test]
fn hosts_returns_sorted_canonical_hosts() {
let cfg = Config::from_toml(
r#"
[registry.ghcr]
token = "g"
[registry.dockerhub]
username = "u"
token = "d"
[registry."reg.example.com"]
token = "r"
"#,
)
.unwrap();
let store = build_store(cfg, env(&[]));
assert_eq!(store.hosts(), ["docker.io", "ghcr.io", "reg.example.com"]);
assert_eq!(store.len(), 3);
assert!(!store.is_empty());
}
#[test]
fn hosts_empty_for_default_store() {
let store = CredentialStore::default();
assert!(store.hosts().is_empty());
assert_eq!(store.len(), 0);
assert!(store.is_empty());
}
#[test]
fn secret_debug_is_redacted() {
let s = Secret::new("hunter2");
assert_eq!(format!("{s:?}"), "Secret(\"[REDACTED]\")");
let c = RegistryCredentials {
username: Some("u".into()),
token: Secret::new("hunter2"),
};
assert!(
!format!("{c:?}").contains("hunter2"),
"token leaked via struct Debug: {c:?}"
);
}
fn capture_logs(f: impl FnOnce()) -> String {
use std::io::Write;
use std::sync::{Arc, Mutex};
use tracing_subscriber::fmt::MakeWriter;
#[derive(Clone)]
struct BufWriter(Arc<Mutex<Vec<u8>>>);
impl Write for BufWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for BufWriter {
type Writer = BufWriter;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
let buf = Arc::new(Mutex::new(Vec::new()));
let subscriber = tracing_subscriber::fmt()
.with_writer(BufWriter(buf.clone()))
.with_ansi(false)
.finish();
tracing::subscriber::with_default(subscriber, f);
String::from_utf8(buf.lock().unwrap().clone()).unwrap()
}
#[test]
fn token_is_redacted_in_tracing_output() {
let creds = RegistryCredentials {
username: Some("user".into()),
token: Secret::new("supersecret-pat"),
};
let out = capture_logs(|| {
tracing::info!(?creds, "loaded credentials");
tracing::info!(token = ?creds.token, "token field");
});
assert!(!out.contains("supersecret-pat"), "secret leaked: {out}");
assert!(
out.contains("[REDACTED]"),
"expected redaction marker: {out}"
);
}
#[test]
fn credential_summary_log_never_leaks_token() {
let cfg = Config::from_toml(
"[registry.dockerhub]\nusername = \"u\"\ntoken = \"supersecret-pat\"\n",
)
.unwrap();
let store = build_store(cfg, env(&[]));
let out = capture_logs(|| log_credentials_loaded(&store));
assert!(!out.contains("supersecret-pat"), "secret leaked: {out}");
assert!(out.contains("docker.io"), "host should be present: {out}");
}
#[test]
fn credential_summary_logs_anonymous_when_empty() {
let out = capture_logs(|| log_credentials_loaded(&CredentialStore::default()));
assert!(
out.contains("anonymous"),
"empty store must announce anonymous-only: {out}"
);
}
fn notifications(toml: &str) -> HashMap<String, NotificationTarget> {
Config::from_toml(toml).unwrap().notifications
}
#[test]
fn parses_each_backend_type() {
let t = notifications(
r#"
[notifications.hook]
type = "webhook"
url = "https://example.com/h"
[notifications.chat]
type = "discord"
webhook_url = "https://discord.com/api/webhooks/1/a"
[notifications.tg]
type = "telegram"
bot_token = "123:abc"
chat_id = "42"
[notifications.mail]
type = "smtp"
host = "smtp.example.com"
from = "a@example.com"
to = ["b@example.com"]
"#,
);
assert!(matches!(t["hook"], NotificationTarget::Webhook { .. }));
assert!(matches!(t["chat"], NotificationTarget::Discord { .. }));
assert!(matches!(t["tg"], NotificationTarget::Telegram { .. }));
assert!(matches!(t["mail"], NotificationTarget::Smtp { .. }));
}
#[test]
fn unknown_backend_type_is_a_parse_error() {
let err = Config::from_toml(
"[notifications.x]\ntype = \"carrier-pigeon\"\nurl = \"https://e.com\"\n",
)
.unwrap_err();
assert!(err.to_string().contains("carrier-pigeon") || err.to_string().contains("unknown"));
}
#[test]
fn omitted_triggers_parse_to_none_and_smtp_defaults_apply() {
let t = notifications(
"[notifications.mail]\ntype = \"smtp\"\nhost = \"h\"\nfrom = \"a@e.com\"\nto = [\"b@e.com\"]\n",
);
match &t["mail"] {
NotificationTarget::Smtp {
port,
starttls,
triggers,
..
} => {
assert_eq!(*port, 587, "default submission port");
assert!(*starttls, "starttls defaults on");
assert!(
triggers.is_none(),
"omitted triggers → None (subscribe all)"
);
}
other => panic!("expected smtp, got {other:?}"),
}
}
#[test]
fn env_overlays_telegram_token_and_smtp_password_onto_declared_targets() {
let targets = notifications(
r#"
[notifications.tg]
type = "telegram"
bot_token = "file-token"
chat_id = "42"
[notifications.mail]
type = "smtp"
host = "h"
from = "a@e.com"
to = ["b@e.com"]
"#,
);
let cfg = build_notifications(
targets,
env(&[
("FRESHDOCK_NOTIFY_TG_BOT_TOKEN", "env-token"),
("FRESHDOCK_NOTIFY_MAIL_PASSWORD", "env-pass"),
]),
);
match &cfg.targets["tg"] {
NotificationTarget::Telegram { bot_token, .. } => {
assert_eq!(bot_token.expose(), "env-token", "env wins over file token");
}
_ => unreachable!(),
}
match &cfg.targets["mail"] {
NotificationTarget::Smtp { password, .. } => {
assert_eq!(password.as_ref().unwrap().expose(), "env-pass");
}
_ => unreachable!(),
}
}
#[test]
fn env_overlay_ignores_a_type_mismatch() {
let targets =
notifications("[notifications.tg]\ntype = \"webhook\"\nurl = \"https://e.com\"\n");
let cfg = build_notifications(
targets,
env(&[("FRESHDOCK_NOTIFY_TG_BOT_TOKEN", "env-token")]),
);
assert!(matches!(
cfg.targets["tg"],
NotificationTarget::Webhook { .. }
));
}
#[test]
fn env_overlay_matches_a_hyphenated_target_name() {
let targets = notifications(
"[notifications.ops-mail]\ntype = \"smtp\"\nhost = \"h\"\nfrom = \"a@e.com\"\nto = [\"b@e.com\"]\n",
);
let cfg = build_notifications(
targets,
env(&[("FRESHDOCK_NOTIFY_OPS_MAIL_PASSWORD", "env-pass")]),
);
match &cfg.targets["ops-mail"] {
NotificationTarget::Smtp { password, .. } => {
assert_eq!(password.as_ref().unwrap().expose(), "env-pass");
}
_ => unreachable!(),
}
}
#[test]
fn settings_table_parses_and_resolves() {
let cfg = Config::from_toml(
r#"
[settings]
default_mode = "nightly"
cleanup = true
prune_dangling = true
"#,
)
.unwrap();
let resolved = resolve_settings(cfg.settings, env(&[]));
assert_eq!(resolved.default_mode, Some(Mode::Nightly));
assert!(resolved.cleanup);
assert!(resolved.prune_dangling);
assert_eq!(
resolved.policy_defaults().mode,
Some(Mode::Nightly),
"policy_defaults forwards the resolved mode"
);
assert!(resolved.policy_defaults().cleanup);
}
#[test]
fn missing_settings_table_yields_all_defaults() {
let cfg = Config::from_toml("[registry.ghcr]\ntoken = \"t\"\n").unwrap();
let resolved = resolve_settings(cfg.settings, env(&[]));
assert_eq!(resolved.default_mode, None);
assert!(!resolved.cleanup);
assert!(!resolved.prune_dangling);
}
#[test]
fn invalid_default_mode_is_ignored_not_an_error() {
let cfg = Config::from_toml("[settings]\ndefault_mode = \"hourly\"\n").unwrap();
let resolved = resolve_settings(cfg.settings, env(&[]));
assert_eq!(resolved.default_mode, None);
}
#[test]
fn env_default_mode_overrides_file_mode() {
let cfg = Config::from_toml("[settings]\ndefault_mode = \"nightly\"\n").unwrap();
let resolved = resolve_settings(cfg.settings, env(&[("FRESHDOCK_DEFAULT_MODE", "weekly")]));
assert_eq!(resolved.default_mode, Some(Mode::Weekly));
}
#[test]
fn env_invalid_default_mode_keeps_file_value() {
let cfg = Config::from_toml("[settings]\ndefault_mode = \"nightly\"\n").unwrap();
let resolved = resolve_settings(cfg.settings, env(&[("FRESHDOCK_DEFAULT_MODE", "hourly")]));
assert_eq!(resolved.default_mode, Some(Mode::Nightly));
}
#[test]
fn env_bools_accept_true_false_1_0_case_insensitive() {
for (raw, expected) in [
("1", true),
("0", false),
("TRUE", true),
(" false ", false),
] {
let resolved = resolve_settings(
Settings::default(),
env(&[
("FRESHDOCK_CLEANUP", raw),
("FRESHDOCK_PRUNE_DANGLING", raw),
]),
);
assert_eq!(resolved.cleanup, expected, "cleanup: {raw:?}");
assert_eq!(resolved.prune_dangling, expected, "prune_dangling: {raw:?}");
}
}
#[test]
fn env_invalid_bool_keeps_file_value() {
let cfg = Config::from_toml("[settings]\ncleanup = true\n").unwrap();
let resolved = resolve_settings(cfg.settings, env(&[("FRESHDOCK_CLEANUP", "maybe")]));
assert!(resolved.cleanup);
}
#[test]
fn env_settings_apply_with_no_file_table() {
let resolved = resolve_settings(
Settings::default(),
env(&[
("FRESHDOCK_DEFAULT_MODE", "live"),
("FRESHDOCK_CLEANUP", "true"),
("FRESHDOCK_PRUNE_DANGLING", "1"),
]),
);
assert_eq!(resolved.default_mode, Some(Mode::Live));
assert!(resolved.cleanup);
assert!(resolved.prune_dangling);
}
}