1use serde::{Deserialize, Serialize};
33use std::fmt;
34use std::path::PathBuf;
35
36#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct SourceError {
41 code: &'static str,
42 message: String,
43}
44
45impl SourceError {
46 #[must_use]
50 pub const fn code(&self) -> &'static str {
51 self.code
52 }
53
54 #[must_use]
55 pub fn message(&self) -> &str {
56 &self.message
57 }
58
59 #[must_use]
61 pub fn invalid(message: impl Into<String>) -> Self {
62 Self {
63 code: "value_source_invalid",
64 message: message.into(),
65 }
66 }
67
68 #[must_use]
72 pub fn unreadable(message: impl Into<String>) -> Self {
73 Self {
74 code: "value_source_unreadable",
75 message: message.into(),
76 }
77 }
78}
79
80impl fmt::Display for SourceError {
81 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82 formatter.write_str(&self.message)
83 }
84}
85
86impl std::error::Error for SourceError {}
87
88type Result<T> = std::result::Result<T, SourceError>;
89
90#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum SourceScheme {
100 Env,
101 File,
102 Stdin,
103 Fd,
104 Prompt,
105}
106
107impl SourceScheme {
108 #[must_use]
109 pub const fn name(self) -> &'static str {
110 match self {
111 Self::Env => "env",
112 Self::File => "file",
113 Self::Stdin => "stdin",
114 Self::Fd => "fd",
115 Self::Prompt => "prompt",
116 }
117 }
118
119 #[must_use]
121 pub const fn syntax(self) -> &'static str {
122 match self {
123 Self::Env => "env:NAME",
124 Self::File => "file[+FORMAT]:PATH#DOT_PATH",
125 Self::Stdin => "stdin",
126 Self::Fd => "fd:N",
127 Self::Prompt => "prompt",
128 }
129 }
130}
131
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145pub struct HostScheme {
146 pub name: String,
147 pub syntax: String,
148}
149
150#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
152pub struct SourceSet {
153 schemes: Vec<SourceScheme>,
154 #[serde(
155 default,
156 rename = "host_schemes",
157 skip_serializing_if = "Vec::is_empty"
158 )]
159 host: Vec<HostScheme>,
160}
161
162impl SourceSet {
163 pub fn new<I: IntoIterator<Item = SourceScheme>>(schemes: I) -> Self {
165 let mut set = Self::default();
166 for scheme in schemes {
167 if !set.schemes.contains(&scheme) {
168 set.schemes.push(scheme);
169 }
170 }
171 set
172 }
173
174 #[must_use]
178 pub fn config() -> Self {
179 Self::new([SourceScheme::Env, SourceScheme::File])
180 }
181
182 #[must_use]
185 pub fn stream() -> Self {
186 Self::new([
187 SourceScheme::Env,
188 SourceScheme::File,
189 SourceScheme::Stdin,
190 SourceScheme::Fd,
191 SourceScheme::Prompt,
192 ])
193 }
194
195 #[must_use]
198 pub fn host_scheme(mut self, name: impl Into<String>, syntax: impl Into<String>) -> Self {
199 self.host.push(HostScheme {
200 name: name.into(),
201 syntax: syntax.into(),
202 });
203 self
204 }
205
206 #[must_use]
207 pub fn schemes(&self) -> &[SourceScheme] {
208 &self.schemes
209 }
210
211 #[must_use]
212 pub fn host_schemes(&self) -> &[HostScheme] {
213 &self.host
214 }
215
216 #[must_use]
217 pub fn accepts(&self, scheme: SourceScheme) -> bool {
218 self.schemes.contains(&scheme)
219 }
220
221 #[must_use]
222 pub fn is_empty(&self) -> bool {
223 self.schemes.is_empty() && self.host.is_empty()
224 }
225
226 #[must_use]
229 pub fn syntax_summary(&self) -> String {
230 let mut parts: Vec<&str> = self.schemes.iter().map(|s| s.syntax()).collect();
231 parts.extend(self.host.iter().map(|scheme| scheme.syntax.as_str()));
232 format!(
233 "the value, or where to read it: {}, literal:VALUE",
234 parts.join(", ")
235 )
236 }
237
238 pub fn parse(&self, raw: &str) -> Result<ValueSource> {
242 if let Some(value) = raw.strip_prefix("literal:") {
245 return Ok(ValueSource::Literal(value.to_string()));
246 }
247 for scheme in &self.host {
248 if let Some(rest) = strip_scheme(raw, &scheme.name) {
249 if rest.is_empty() {
250 return Err(SourceError::invalid(format!(
251 "`{}` source requires a value: {}",
252 scheme.name, scheme.syntax
253 )));
254 }
255 return Ok(ValueSource::Host {
256 scheme: scheme.name.clone(),
257 value: rest.to_string(),
258 });
259 }
260 }
261 if raw == "stdin" {
262 return self
263 .require(SourceScheme::Stdin)
264 .map(|()| ValueSource::Stdin);
265 }
266 if raw == "prompt" {
267 return self
268 .require(SourceScheme::Prompt)
269 .map(|()| ValueSource::Prompt);
270 }
271 if let Some(name) = strip_scheme(raw, "env") {
272 self.require(SourceScheme::Env)?;
273 if name.is_empty() {
274 return Err(SourceError::invalid(
275 "`env` source requires a variable name",
276 ));
277 }
278 return Ok(ValueSource::Env(name.to_string()));
279 }
280 if let Some(number) = strip_scheme(raw, "fd") {
281 self.require(SourceScheme::Fd)?;
282 let number: i32 = number.parse().map_err(|_| {
283 SourceError::invalid("`fd` source requires a numeric descriptor: fd:N")
284 })?;
285 if number < 3 {
288 return Err(SourceError::invalid(
289 "`fd` source requires a descriptor >= 3",
290 ));
291 }
292 return Ok(ValueSource::Fd(number));
293 }
294 if let Some((rest, format)) = strip_file_scheme(raw) {
295 self.require(SourceScheme::File)?;
296 if format.as_deref().is_some_and(str::is_empty) {
297 return Err(SourceError::invalid(
298 "`file` source: `file+` must name a format, as in file+ini:PATH#DOT_PATH",
299 ));
300 }
301 let Some((path, dot_path)) = rest.rsplit_once('#') else {
306 return Err(SourceError::invalid(
307 "`file` source must be file:PATH#DOT_PATH",
308 ));
309 };
310 if path.is_empty() || dot_path.is_empty() {
311 return Err(SourceError::invalid(
312 "`file` source requires both PATH and DOT_PATH",
313 ));
314 }
315 return Ok(ValueSource::File {
316 path: PathBuf::from(path),
317 dot_path: dot_path.to_string(),
318 format,
319 });
320 }
321 Ok(ValueSource::Literal(raw.to_string()))
324 }
325
326 fn require(&self, scheme: SourceScheme) -> Result<()> {
327 if self.accepts(scheme) {
328 return Ok(());
329 }
330 Err(SourceError::invalid(format!(
331 "`{}` is not a source this argument accepts; {}",
332 scheme.name(),
333 self.syntax_summary()
334 )))
335 }
336}
337
338fn strip_file_scheme(raw: &str) -> Option<(&str, Option<String>)> {
343 let rest = raw.strip_prefix("file")?;
344 if let Some(rest) = rest.strip_prefix(':') {
345 return Some((rest, None));
346 }
347 let rest = rest.strip_prefix('+')?;
348 let (format, rest) = rest.split_once(':')?;
349 Some((rest, Some(format.to_string())))
350}
351
352fn strip_scheme<'a>(raw: &'a str, scheme: &str) -> Option<&'a str> {
355 raw.strip_prefix(scheme)?.strip_prefix(':')
356}
357
358#[derive(Clone, Debug, PartialEq, Eq)]
362pub enum ValueSource {
363 Literal(String),
364 Env(String),
365 File {
366 path: PathBuf,
367 dot_path: String,
368 format: Option<String>,
374 },
375 Stdin,
376 Fd(i32),
377 Prompt,
378 Host {
380 scheme: String,
381 value: String,
382 },
383}
384
385impl ValueSource {
386 #[must_use]
389 pub fn describe(&self) -> String {
390 match self {
391 Self::Literal(_) => "direct".to_string(),
392 Self::Env(name) => format!("env:{name}"),
393 Self::File {
394 path,
395 dot_path,
396 format,
397 } => match format {
398 Some(format) => format!("file+{format}:{}#{dot_path}", path.display()),
399 None => format!("file:{}#{dot_path}", path.display()),
400 },
401 Self::Stdin => "stdin".to_string(),
402 Self::Fd(number) => format!("fd:{number}"),
403 Self::Prompt => "prompt".to_string(),
404 Self::Host { scheme, value } => format!("{scheme}:{value}"),
405 }
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn a_bare_value_is_the_value_and_a_prefix_names_a_source() {
415 let set = SourceSet::stream();
416 assert_eq!(
417 set.parse("plain").expect("bare"),
418 ValueSource::Literal("plain".to_string())
419 );
420 assert_eq!(
421 set.parse("literal:env:NAME").expect("escape hatch"),
422 ValueSource::Literal("env:NAME".to_string())
423 );
424 assert_eq!(
425 set.parse("env:NAME").expect("env"),
426 ValueSource::Env("NAME".to_string())
427 );
428 assert_eq!(set.parse("stdin").expect("stdin"), ValueSource::Stdin);
429 assert_eq!(set.parse("fd:3").expect("fd"), ValueSource::Fd(3));
430 assert_eq!(set.parse("prompt").expect("prompt"), ValueSource::Prompt);
431 assert_eq!(
432 set.parse("file:/etc/app.json#a.b").expect("file"),
433 ValueSource::File {
434 path: PathBuf::from("/etc/app.json"),
435 dot_path: "a.b".to_string(),
436 format: None,
437 }
438 );
439 assert_eq!(
441 set.parse("postgres://u:p@h/db").expect("url"),
442 ValueSource::Literal("postgres://u:p@h/db".to_string())
443 );
444 }
445
446 #[test]
447 fn a_file_source_may_name_its_format() {
448 let set = SourceSet::config();
449 assert_eq!(
450 set.parse("file+ini:/etc/phoenix.conf#http-password")
451 .expect("named format"),
452 ValueSource::File {
453 path: PathBuf::from("/etc/phoenix.conf"),
454 dot_path: "http-password".to_string(),
455 format: Some("ini".to_string()),
456 }
457 );
458 assert_eq!(
461 set.parse(r"file:C:\creds\app.json#a.b")
462 .expect("drive letter"),
463 ValueSource::File {
464 path: PathBuf::from(r"C:\creds\app.json"),
465 dot_path: "a.b".to_string(),
466 format: None,
467 }
468 );
469 assert!(set.parse("file+:/etc/x#a").is_err());
470 assert!(set.parse("file+nonsense:/etc/x#a").is_ok());
473 }
474
475 #[test]
478 fn a_scheme_outside_the_set_is_refused() {
479 let set = SourceSet::config();
480 let error = set.parse("prompt").expect_err("prompt is not in config()");
481 assert_eq!(error.code(), "value_source_invalid");
482 assert!(error.message().contains("env:NAME"), "{error}");
483 assert!(set.parse("stdin").is_err());
484 assert!(set.parse("fd:3").is_err());
485 assert!(set.parse("env:NAME").is_ok());
486 }
487
488 #[test]
489 fn a_malformed_source_is_refused_before_anything_is_read() {
490 let set = SourceSet::stream();
491 for raw in [
492 "env:",
493 "fd:x",
494 "fd:2",
495 "file:",
496 "file:/etc/app.json",
497 "file:#a.b",
498 "file:/etc/app.json#",
499 ] {
500 let error = set.parse(raw).expect_err(raw);
501 assert_eq!(error.code(), "value_source_invalid", "{raw}");
502 }
503 }
504
505 #[test]
506 fn a_host_scheme_parses_here_and_is_read_elsewhere() {
507 let set = SourceSet::config().host_scheme("container", "container:NAME");
508 assert_eq!(
509 set.parse("container:afhttp-host").expect("host scheme"),
510 ValueSource::Host {
511 scheme: "container".to_string(),
512 value: "afhttp-host".to_string(),
513 }
514 );
515 assert!(set.parse("container:").is_err());
516 assert_eq!(
518 set.parse("literal:container:x").expect("escape hatch"),
519 ValueSource::Literal("container:x".to_string())
520 );
521 }
522
523 #[test]
524 fn a_source_describes_itself_without_its_value() {
525 assert_eq!(ValueSource::Literal("v".into()).describe(), "direct");
526 assert_eq!(ValueSource::Env("NAME".into()).describe(), "env:NAME");
527 assert_eq!(ValueSource::Fd(3).describe(), "fd:3");
528 assert_eq!(
529 ValueSource::File {
530 path: PathBuf::from("/etc/app.json"),
531 dot_path: "a.b".into(),
532 format: None,
533 }
534 .describe(),
535 "file:/etc/app.json#a.b"
536 );
537 }
538
539 #[test]
540 fn the_syntax_summary_is_what_help_renders() {
541 let summary = SourceSet::config()
542 .host_scheme("container", "container:NAME")
543 .syntax_summary();
544 assert_eq!(
545 summary,
546 "the value, or where to read it: env:NAME, file[+FORMAT]:PATH#DOT_PATH, container:NAME, \
547 literal:VALUE"
548 );
549 }
550}