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 (path, dot_path) = rest.rsplit_once('#').unwrap_or((rest, ""));
306 if path.is_empty() || (rest.contains('#') && dot_path.is_empty()) {
307 return Err(SourceError::invalid(
308 "`file` source requires both PATH and DOT_PATH",
309 ));
310 }
311 if format.is_some() && dot_path.is_empty() {
312 return Err(SourceError::invalid(
313 "`file+FORMAT:` needs a `#DOT_PATH`; use `file:PATH` to read the whole file",
314 ));
315 }
316 return Ok(ValueSource::File {
317 path: PathBuf::from(path),
318 dot_path: dot_path.to_string(),
319 format,
320 });
321 }
322 Ok(ValueSource::Literal(raw.to_string()))
325 }
326
327 fn require(&self, scheme: SourceScheme) -> Result<()> {
328 if self.accepts(scheme) {
329 return Ok(());
330 }
331 Err(SourceError::invalid(format!(
332 "`{}` is not a source this argument accepts; {}",
333 scheme.name(),
334 self.syntax_summary()
335 )))
336 }
337}
338
339fn strip_file_scheme(raw: &str) -> Option<(&str, Option<String>)> {
344 let rest = raw.strip_prefix("file")?;
345 if let Some(rest) = rest.strip_prefix(':') {
346 return Some((rest, None));
347 }
348 let rest = rest.strip_prefix('+')?;
349 let (format, rest) = rest.split_once(':')?;
350 Some((rest, Some(format.to_string())))
351}
352
353fn strip_scheme<'a>(raw: &'a str, scheme: &str) -> Option<&'a str> {
356 raw.strip_prefix(scheme)?.strip_prefix(':')
357}
358
359#[derive(Clone, Debug, PartialEq, Eq)]
363pub enum ValueSource {
364 Literal(String),
365 Env(String),
366 File {
367 path: PathBuf,
368 dot_path: String,
369 format: Option<String>,
375 },
376 Stdin,
377 Fd(i32),
378 Prompt,
379 Host {
381 scheme: String,
382 value: String,
383 },
384}
385
386impl ValueSource {
387 #[must_use]
390 pub fn describe(&self) -> String {
391 match self {
392 Self::Literal(_) => "direct".to_string(),
393 Self::Env(name) => format!("env:{name}"),
394 Self::File {
395 path,
396 dot_path,
397 format,
398 } => {
399 let suffix = if dot_path.is_empty() {
400 String::new()
401 } else {
402 format!("#{dot_path}")
403 };
404 match format {
405 Some(format) => format!("file+{format}:{}{suffix}", path.display()),
406 None => format!("file:{}{suffix}", path.display()),
407 }
408 }
409 Self::Stdin => "stdin".to_string(),
410 Self::Fd(number) => format!("fd:{number}"),
411 Self::Prompt => "prompt".to_string(),
412 Self::Host { scheme, value } => format!("{scheme}:{value}"),
413 }
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn a_bare_value_is_the_value_and_a_prefix_names_a_source() {
423 let set = SourceSet::stream();
424 assert_eq!(
425 set.parse("plain").expect("bare"),
426 ValueSource::Literal("plain".to_string())
427 );
428 assert_eq!(
429 set.parse("literal:env:NAME").expect("escape hatch"),
430 ValueSource::Literal("env:NAME".to_string())
431 );
432 assert_eq!(
433 set.parse("env:NAME").expect("env"),
434 ValueSource::Env("NAME".to_string())
435 );
436 assert_eq!(set.parse("stdin").expect("stdin"), ValueSource::Stdin);
437 assert_eq!(set.parse("fd:3").expect("fd"), ValueSource::Fd(3));
438 assert_eq!(set.parse("prompt").expect("prompt"), ValueSource::Prompt);
439 assert_eq!(
440 set.parse("file:/etc/app.json#a.b").expect("file"),
441 ValueSource::File {
442 path: PathBuf::from("/etc/app.json"),
443 dot_path: "a.b".to_string(),
444 format: None,
445 }
446 );
447 assert_eq!(
449 set.parse("postgres://u:p@h/db").expect("url"),
450 ValueSource::Literal("postgres://u:p@h/db".to_string())
451 );
452 }
453
454 #[test]
455 fn a_file_without_an_address_is_a_whole_document_source() {
456 let source = SourceSet::config()
457 .parse("file:/tmp/request.json")
458 .expect("whole file");
459 assert_eq!(
460 source,
461 ValueSource::File {
462 path: PathBuf::from("/tmp/request.json"),
463 dot_path: String::new(),
464 format: None
465 }
466 );
467 assert_eq!(source.describe(), "file:/tmp/request.json");
468 assert!(
469 SourceSet::config()
470 .parse("file+json:/tmp/request.json")
471 .is_err()
472 );
473 }
474
475 #[test]
476 fn a_file_source_may_name_its_format() {
477 let set = SourceSet::config();
478 assert_eq!(
479 set.parse("file+ini:/etc/phoenix.conf#http-password")
480 .expect("named format"),
481 ValueSource::File {
482 path: PathBuf::from("/etc/phoenix.conf"),
483 dot_path: "http-password".to_string(),
484 format: Some("ini".to_string()),
485 }
486 );
487 assert_eq!(
490 set.parse(r"file:C:\creds\app.json#a.b")
491 .expect("drive letter"),
492 ValueSource::File {
493 path: PathBuf::from(r"C:\creds\app.json"),
494 dot_path: "a.b".to_string(),
495 format: None,
496 }
497 );
498 assert!(set.parse("file+:/etc/x#a").is_err());
499 assert!(set.parse("file+nonsense:/etc/x#a").is_ok());
502 }
503
504 #[test]
507 fn a_scheme_outside_the_set_is_refused() {
508 let set = SourceSet::config();
509 let error = set.parse("prompt").expect_err("prompt is not in config()");
510 assert_eq!(error.code(), "value_source_invalid");
511 assert!(error.message().contains("env:NAME"), "{error}");
512 assert!(set.parse("stdin").is_err());
513 assert!(set.parse("fd:3").is_err());
514 assert!(set.parse("env:NAME").is_ok());
515 }
516
517 #[test]
518 fn a_malformed_source_is_refused_before_anything_is_read() {
519 let set = SourceSet::stream();
520 for raw in [
521 "env:",
522 "fd:x",
523 "fd:2",
524 "file:",
525 "file:#a.b",
526 "file:/etc/app.json#",
527 ] {
528 let error = set.parse(raw).expect_err(raw);
529 assert_eq!(error.code(), "value_source_invalid", "{raw}");
530 }
531 }
532
533 #[test]
534 fn a_host_scheme_parses_here_and_is_read_elsewhere() {
535 let set = SourceSet::config().host_scheme("container", "container:NAME");
536 assert_eq!(
537 set.parse("container:app-host").expect("host scheme"),
538 ValueSource::Host {
539 scheme: "container".to_string(),
540 value: "app-host".to_string(),
541 }
542 );
543 assert!(set.parse("container:").is_err());
544 assert_eq!(
546 set.parse("literal:container:x").expect("escape hatch"),
547 ValueSource::Literal("container:x".to_string())
548 );
549 }
550
551 #[test]
552 fn a_source_describes_itself_without_its_value() {
553 assert_eq!(ValueSource::Literal("v".into()).describe(), "direct");
554 assert_eq!(ValueSource::Env("NAME".into()).describe(), "env:NAME");
555 assert_eq!(ValueSource::Fd(3).describe(), "fd:3");
556 assert_eq!(
557 ValueSource::File {
558 path: PathBuf::from("/etc/app.json"),
559 dot_path: "a.b".into(),
560 format: None,
561 }
562 .describe(),
563 "file:/etc/app.json#a.b"
564 );
565 }
566
567 #[test]
568 fn the_syntax_summary_is_what_help_renders() {
569 let summary = SourceSet::config()
570 .host_scheme("container", "container:NAME")
571 .syntax_summary();
572 assert_eq!(
573 summary,
574 "the value, or where to read it: env:NAME, file[+FORMAT]:PATH[#DOT_PATH], container:NAME, \
575 literal:VALUE"
576 );
577 }
578}