use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SourceError {
code: &'static str,
message: String,
}
impl SourceError {
#[must_use]
pub const fn code(&self) -> &'static str {
self.code
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn invalid(message: impl Into<String>) -> Self {
Self {
code: "value_source_invalid",
message: message.into(),
}
}
#[must_use]
pub fn unreadable(message: impl Into<String>) -> Self {
Self {
code: "value_source_unreadable",
message: message.into(),
}
}
}
impl fmt::Display for SourceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for SourceError {}
type Result<T> = std::result::Result<T, SourceError>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceScheme {
Env,
File,
Stdin,
Fd,
Prompt,
}
impl SourceScheme {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Env => "env",
Self::File => "file",
Self::Stdin => "stdin",
Self::Fd => "fd",
Self::Prompt => "prompt",
}
}
#[must_use]
pub const fn syntax(self) -> &'static str {
match self {
Self::Env => "env:NAME",
Self::File => "file[+FORMAT]:PATH#DOT_PATH",
Self::Stdin => "stdin",
Self::Fd => "fd:N",
Self::Prompt => "prompt",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostScheme {
pub name: String,
pub syntax: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceSet {
schemes: Vec<SourceScheme>,
#[serde(
default,
rename = "host_schemes",
skip_serializing_if = "Vec::is_empty"
)]
host: Vec<HostScheme>,
}
impl SourceSet {
pub fn new<I: IntoIterator<Item = SourceScheme>>(schemes: I) -> Self {
let mut set = Self::default();
for scheme in schemes {
if !set.schemes.contains(&scheme) {
set.schemes.push(scheme);
}
}
set
}
#[must_use]
pub fn config() -> Self {
Self::new([SourceScheme::Env, SourceScheme::File])
}
#[must_use]
pub fn stream() -> Self {
Self::new([
SourceScheme::Env,
SourceScheme::File,
SourceScheme::Stdin,
SourceScheme::Fd,
SourceScheme::Prompt,
])
}
#[must_use]
pub fn host_scheme(mut self, name: impl Into<String>, syntax: impl Into<String>) -> Self {
self.host.push(HostScheme {
name: name.into(),
syntax: syntax.into(),
});
self
}
#[must_use]
pub fn schemes(&self) -> &[SourceScheme] {
&self.schemes
}
#[must_use]
pub fn host_schemes(&self) -> &[HostScheme] {
&self.host
}
#[must_use]
pub fn accepts(&self, scheme: SourceScheme) -> bool {
self.schemes.contains(&scheme)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.schemes.is_empty() && self.host.is_empty()
}
#[must_use]
pub fn syntax_summary(&self) -> String {
let mut parts: Vec<&str> = self.schemes.iter().map(|s| s.syntax()).collect();
parts.extend(self.host.iter().map(|scheme| scheme.syntax.as_str()));
format!(
"the value, or where to read it: {}, literal:VALUE",
parts.join(", ")
)
}
pub fn parse(&self, raw: &str) -> Result<ValueSource> {
if let Some(value) = raw.strip_prefix("literal:") {
return Ok(ValueSource::Literal(value.to_string()));
}
for scheme in &self.host {
if let Some(rest) = strip_scheme(raw, &scheme.name) {
if rest.is_empty() {
return Err(SourceError::invalid(format!(
"`{}` source requires a value: {}",
scheme.name, scheme.syntax
)));
}
return Ok(ValueSource::Host {
scheme: scheme.name.clone(),
value: rest.to_string(),
});
}
}
if raw == "stdin" {
return self
.require(SourceScheme::Stdin)
.map(|()| ValueSource::Stdin);
}
if raw == "prompt" {
return self
.require(SourceScheme::Prompt)
.map(|()| ValueSource::Prompt);
}
if let Some(name) = strip_scheme(raw, "env") {
self.require(SourceScheme::Env)?;
if name.is_empty() {
return Err(SourceError::invalid(
"`env` source requires a variable name",
));
}
return Ok(ValueSource::Env(name.to_string()));
}
if let Some(number) = strip_scheme(raw, "fd") {
self.require(SourceScheme::Fd)?;
let number: i32 = number.parse().map_err(|_| {
SourceError::invalid("`fd` source requires a numeric descriptor: fd:N")
})?;
if number < 3 {
return Err(SourceError::invalid(
"`fd` source requires a descriptor >= 3",
));
}
return Ok(ValueSource::Fd(number));
}
if let Some((rest, format)) = strip_file_scheme(raw) {
self.require(SourceScheme::File)?;
if format.as_deref().is_some_and(str::is_empty) {
return Err(SourceError::invalid(
"`file` source: `file+` must name a format, as in file+ini:PATH#DOT_PATH",
));
}
let Some((path, dot_path)) = rest.rsplit_once('#') else {
return Err(SourceError::invalid(
"`file` source must be file:PATH#DOT_PATH",
));
};
if path.is_empty() || dot_path.is_empty() {
return Err(SourceError::invalid(
"`file` source requires both PATH and DOT_PATH",
));
}
return Ok(ValueSource::File {
path: PathBuf::from(path),
dot_path: dot_path.to_string(),
format,
});
}
Ok(ValueSource::Literal(raw.to_string()))
}
fn require(&self, scheme: SourceScheme) -> Result<()> {
if self.accepts(scheme) {
return Ok(());
}
Err(SourceError::invalid(format!(
"`{}` is not a source this argument accepts; {}",
scheme.name(),
self.syntax_summary()
)))
}
}
fn strip_file_scheme(raw: &str) -> Option<(&str, Option<String>)> {
let rest = raw.strip_prefix("file")?;
if let Some(rest) = rest.strip_prefix(':') {
return Some((rest, None));
}
let rest = rest.strip_prefix('+')?;
let (format, rest) = rest.split_once(':')?;
Some((rest, Some(format.to_string())))
}
fn strip_scheme<'a>(raw: &'a str, scheme: &str) -> Option<&'a str> {
raw.strip_prefix(scheme)?.strip_prefix(':')
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ValueSource {
Literal(String),
Env(String),
File {
path: PathBuf,
dot_path: String,
format: Option<String>,
},
Stdin,
Fd(i32),
Prompt,
Host {
scheme: String,
value: String,
},
}
impl ValueSource {
#[must_use]
pub fn describe(&self) -> String {
match self {
Self::Literal(_) => "direct".to_string(),
Self::Env(name) => format!("env:{name}"),
Self::File {
path,
dot_path,
format,
} => match format {
Some(format) => format!("file+{format}:{}#{dot_path}", path.display()),
None => format!("file:{}#{dot_path}", path.display()),
},
Self::Stdin => "stdin".to_string(),
Self::Fd(number) => format!("fd:{number}"),
Self::Prompt => "prompt".to_string(),
Self::Host { scheme, value } => format!("{scheme}:{value}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_value_is_the_value_and_a_prefix_names_a_source() {
let set = SourceSet::stream();
assert_eq!(
set.parse("plain").expect("bare"),
ValueSource::Literal("plain".to_string())
);
assert_eq!(
set.parse("literal:env:NAME").expect("escape hatch"),
ValueSource::Literal("env:NAME".to_string())
);
assert_eq!(
set.parse("env:NAME").expect("env"),
ValueSource::Env("NAME".to_string())
);
assert_eq!(set.parse("stdin").expect("stdin"), ValueSource::Stdin);
assert_eq!(set.parse("fd:3").expect("fd"), ValueSource::Fd(3));
assert_eq!(set.parse("prompt").expect("prompt"), ValueSource::Prompt);
assert_eq!(
set.parse("file:/etc/app.json#a.b").expect("file"),
ValueSource::File {
path: PathBuf::from("/etc/app.json"),
dot_path: "a.b".to_string(),
format: None,
}
);
assert_eq!(
set.parse("postgres://u:p@h/db").expect("url"),
ValueSource::Literal("postgres://u:p@h/db".to_string())
);
}
#[test]
fn a_file_source_may_name_its_format() {
let set = SourceSet::config();
assert_eq!(
set.parse("file+ini:/etc/phoenix.conf#http-password")
.expect("named format"),
ValueSource::File {
path: PathBuf::from("/etc/phoenix.conf"),
dot_path: "http-password".to_string(),
format: Some("ini".to_string()),
}
);
assert_eq!(
set.parse(r"file:C:\creds\app.json#a.b")
.expect("drive letter"),
ValueSource::File {
path: PathBuf::from(r"C:\creds\app.json"),
dot_path: "a.b".to_string(),
format: None,
}
);
assert!(set.parse("file+:/etc/x#a").is_err());
assert!(set.parse("file+nonsense:/etc/x#a").is_ok());
}
#[test]
fn a_scheme_outside_the_set_is_refused() {
let set = SourceSet::config();
let error = set.parse("prompt").expect_err("prompt is not in config()");
assert_eq!(error.code(), "value_source_invalid");
assert!(error.message().contains("env:NAME"), "{error}");
assert!(set.parse("stdin").is_err());
assert!(set.parse("fd:3").is_err());
assert!(set.parse("env:NAME").is_ok());
}
#[test]
fn a_malformed_source_is_refused_before_anything_is_read() {
let set = SourceSet::stream();
for raw in [
"env:",
"fd:x",
"fd:2",
"file:",
"file:/etc/app.json",
"file:#a.b",
"file:/etc/app.json#",
] {
let error = set.parse(raw).expect_err(raw);
assert_eq!(error.code(), "value_source_invalid", "{raw}");
}
}
#[test]
fn a_host_scheme_parses_here_and_is_read_elsewhere() {
let set = SourceSet::config().host_scheme("container", "container:NAME");
assert_eq!(
set.parse("container:afhttp-host").expect("host scheme"),
ValueSource::Host {
scheme: "container".to_string(),
value: "afhttp-host".to_string(),
}
);
assert!(set.parse("container:").is_err());
assert_eq!(
set.parse("literal:container:x").expect("escape hatch"),
ValueSource::Literal("container:x".to_string())
);
}
#[test]
fn a_source_describes_itself_without_its_value() {
assert_eq!(ValueSource::Literal("v".into()).describe(), "direct");
assert_eq!(ValueSource::Env("NAME".into()).describe(), "env:NAME");
assert_eq!(ValueSource::Fd(3).describe(), "fd:3");
assert_eq!(
ValueSource::File {
path: PathBuf::from("/etc/app.json"),
dot_path: "a.b".into(),
format: None,
}
.describe(),
"file:/etc/app.json#a.b"
);
}
#[test]
fn the_syntax_summary_is_what_help_renders() {
let summary = SourceSet::config()
.host_scheme("container", "container:NAME")
.syntax_summary();
assert_eq!(
summary,
"the value, or where to read it: env:NAME, file[+FORMAT]:PATH#DOT_PATH, container:NAME, \
literal:VALUE"
);
}
}