use std::net::SocketAddr;
use std::path::PathBuf;
use crate::config::{ConfigError, Result};
pub fn value(variable: &'static str) -> Result<Option<String>> {
crate::config::environment_value(variable)
}
pub fn flag(variable: &'static str) -> Result<Option<bool>> {
value(variable)?
.map(|value| match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(ConfigError::VariableValue {
variable,
message: "must be a boolean value",
}),
})
.transpose()
}
pub fn public_url() -> Result<Option<url::Url>> {
value("PUBLIC_URL")?.map(parse_public_url).transpose()
}
pub fn public_url_or_local(bind_addr: SocketAddr) -> Result<url::Url> {
public_url()?.map_or_else(
|| {
url::Url::parse(&format!("http://127.0.0.1:{}", bind_addr.port())).map_err(|_| {
ConfigError::validation("public_url", "the generated local HTTP URL must be valid")
})
},
Ok,
)
}
fn parse_public_url(value: String) -> Result<url::Url> {
let mut url = crate::config::parse_network_url(
"public_url",
&value,
&["http", "https"],
false,
"must be an HTTP or HTTPS URL with a host and no embedded credentials",
)?;
if url.query().is_some() || url.fragment().is_some() {
return Err(ConfigError::validation(
"public_url",
"must not contain a query or fragment",
));
}
let normalized_path = url.path().trim_end_matches('/').to_owned();
url.set_path(&normalized_path);
Ok(url)
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EnvFiles {
pub base: Option<PathBuf>,
pub override_file: Option<PathBuf>,
}
impl EnvFiles {
pub fn load(&self) -> Result<EnvLoad> {
let mut loaded = EnvLoad::default();
if let Some(path) = &self.base
&& path.is_file()
{
dotenvy::from_path(path)
.map_err(|error| ConfigError::environment(path.clone(), error))?;
loaded.base = true;
}
if let Some(path) = &self.override_file
&& path.is_file()
{
dotenvy::from_path_override(path)
.map_err(|error| ConfigError::environment(path.clone(), error))?;
loaded.override_file = true;
}
Ok(loaded)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EnvLoad {
pub base: bool,
pub override_file: bool,
}
impl EnvLoad {
pub const fn any(self) -> bool {
self.base || self.override_file
}
}
#[cfg(test)]
mod tests {
use std::error::Error as _;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
use super::*;
use crate::EnvironmentFileError;
static FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[test]
fn absent_explicit_files_are_ignored() {
let files = EnvFiles {
base: Some(std::env::temp_dir().join("missing-base.env")),
override_file: Some(std::env::temp_dir().join("missing-override.env")),
};
assert_eq!(
files.load().expect("missing files should be optional"),
EnvLoad::default()
);
}
#[test]
fn load_report_combines_results() {
assert!(!EnvLoad::default().any());
assert!(
EnvLoad {
base: true,
override_file: false,
}
.any()
);
}
#[test]
fn public_urls_are_validated_and_normalized() {
let url = parse_public_url("https://example.invalid/app/".to_owned())
.expect("valid public URL should resolve");
assert_eq!(url.as_str(), "https://example.invalid/app");
for invalid in [
"ftp://example.invalid",
"https://user:secret@example.invalid",
"https://example.invalid?debug=true",
"https://example.invalid#fragment",
] {
assert!(
parse_public_url(invalid.to_owned()).is_err(),
"{invalid} should be rejected"
);
}
}
#[test]
fn malformed_file_reports_a_sanitized_typed_source() {
let path = std::env::temp_dir().join(format!(
"explicit-env-{}-{}.env",
std::process::id(),
FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed)
));
fs::write(&path, "API_TOKEN='do-not-log-this\n")
.expect("invalid dotenv fixture should be writable");
let error = EnvFiles {
base: Some(path.clone()),
override_file: None,
}
.load()
.expect_err("malformed dotenv should fail");
fs::remove_file(path).ok();
let display = error.to_string();
let debug = format!("{error:?}");
assert!(!display.contains("do-not-log-this"));
assert!(!debug.contains("do-not-log-this"));
assert!(matches!(
error
.source()
.and_then(|source| source.downcast_ref::<EnvironmentFileError>()),
Some(EnvironmentFileError::Syntax { .. })
));
}
}