Skip to main content

agent_first_data/cli_spec/
sources.rs

1//! The grammar of a value that names *where it is* instead of carrying itself.
2//!
3//! An argument value on argv is visible to every process on the machine
4//! (`ps`), lands in shell history, and reaches any log that echoes the command.
5//! For a credential that is a leak; for a large or awkward value it is merely
6//! unpleasant. Both are answered the same way — let the argument name a source:
7//!
8//! ```text
9//! VALUE                    the value itself
10//! literal:VALUE            …when it starts with one of the prefixes below
11//! env:NAME                 an environment variable
12//! file:PATH#DOT_PATH       one address inside a supported document
13//! file+FORMAT:PATH#DOT     …when the filename cannot say which (`.conf`, no extension)
14//! stdin                    the whole of standard input
15//! fd:N                     the whole of an inherited file descriptor
16//! prompt                   asked for on the controlling terminal, without echo
17//! ```
18//!
19//! This file is the *grammar* only: which sources an argument accepts, and how
20//! one value is classified. That is argv's business, so it lives in the core
21//! with the rest of what the registry decides — nothing here opens a file, and
22//! nothing here knows what a secret is. Reading a classified source, and the
23//! policy that separates a printable value from a credential, is
24//! [`crate::value_source`](../value_source/index.html)'s.
25//!
26//! Acceptance is declared per argument, with
27//! [`ArgSpec::sources`](super::ArgSpec::sources), because a source turns an
28//! argument into a reader of files and environment variables: right for a
29//! credential, wrong for most everything else. The declaration also renders the
30//! syntax into help and `--docs`, so no host repeats it in an `about` string.
31
32use serde::{Deserialize, Serialize};
33use std::fmt;
34use std::path::PathBuf;
35
36// ── errors ──────────────────────────────────────────────────────────────────
37
38/// A source that could not be understood, or could not be read.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct SourceError {
41    code: &'static str,
42    message: String,
43}
44
45impl SourceError {
46    /// Stable machine-readable code: `value_source_invalid` for a source this
47    /// argument cannot accept or cannot parse, `value_source_unreadable` for
48    /// one that parsed but could not be read.
49    #[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    /// A source this argument cannot accept, or cannot parse.
60    #[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    /// A source that parsed, but whose value could not be obtained. Raised by
69    /// whoever does the reading — for the schemes this crate implements, that
70    /// is [`crate::value_source`]; for a host scheme, the host.
71    #[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// ── schemes and the set an argument accepts ─────────────────────────────────
91
92/// One way of naming where a value is.
93///
94/// A literal value is always accepted and is therefore not a scheme; what a
95/// [`SourceSet`] lists is the indirection an argument allows *besides* typing
96/// the value.
97#[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    /// How the scheme is spelled in help, value name included.
120    #[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/// A scheme a host defines for itself, declared only so help and validation
133/// know about it.
134///
135/// The reading is the host's own — [`SourceSet::parse`] answers
136/// [`ValueSource::Host`] and leaves the rest to it. A `container:NAME` scheme
137/// that reads a token out of a container the host manages is one:
138/// nothing about it belongs in this crate, but everything about *documenting*
139/// it does.
140///
141/// [`CliSpec::build`](super::CliSpec::build) requires a lowercase
142/// `name`, rejects built-in/reserved names and duplicates, and requires
143/// `syntax` to begin with `name:` plus a value placeholder.
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145pub struct HostScheme {
146    pub name: String,
147    pub syntax: String,
148}
149
150/// The sources one argument accepts.
151#[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    /// Exactly the listed schemes, in the order they will be documented.
164    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    /// The two sources a value can come from without a terminal or a pipe: an
175    /// environment variable, or an address in a document. This is the set
176    /// most arguments want.
177    #[must_use]
178    pub fn config() -> Self {
179        Self::new([SourceScheme::Env, SourceScheme::File])
180    }
181
182    /// [`SourceSet::config`] plus the streams — for a value that may be piped
183    /// in, handed over on an inherited descriptor, or typed by a person.
184    #[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    /// Declare a scheme this crate does not implement. The host parses and
196    /// reads it; this only teaches help and validation that it exists.
197    #[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    /// The syntax line help and `--docs` render, so an `about` string never
227    /// has to repeat it.
228    #[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    /// Classify one argument value. Pure: nothing is opened, read, or asked
239    /// for, so an argv that names an unacceptable source is rejected as a usage
240    /// error before any of it can happen.
241    pub fn parse(&self, raw: &str) -> Result<ValueSource> {
242        // First, so a literal value that happens to start with a scheme prefix
243        // stays expressible.
244        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            // 0, 1, and 2 are this process's own streams; naming one of them is
286            // a mistake that would read the wrong thing rather than fail.
287            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            // The last `#` separates the file path from the document address,
302            // so a filesystem path may itself contain `#`. A DOT_PATH in this
303            // source spelling therefore cannot contain `#`; use another source
304            // for that uncommon external key.
305            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        // An unrecognized prefix is not a source; it is a value that contains a
322        // colon, which is ordinary in a URL or a `user:pass` pair.
323        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
338/// `file:rest` or `file+FORMAT:rest`, and the format the caller named.
339///
340/// The `+FORMAT` sits before the colon so a Windows drive letter in the path
341/// cannot be mistaken for it.
342fn 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
352/// `scheme:rest`, or nothing. Split on the first colon only, so a path or a URL
353/// after the prefix keeps its own colons.
354fn strip_scheme<'a>(raw: &'a str, scheme: &str) -> Option<&'a str> {
355    raw.strip_prefix(scheme)?.strip_prefix(':')
356}
357
358// ── the parsed source ───────────────────────────────────────────────────────
359
360/// Where one value is, decided at parse time and read on demand.
361#[derive(Clone, Debug, PartialEq, Eq)]
362pub enum ValueSource {
363    Literal(String),
364    Env(String),
365    File {
366        path: PathBuf,
367        dot_path: String,
368        /// The format the caller named with `file+FORMAT:`, if any.
369        ///
370        /// Carried as the caller wrote it: this module decides argv grammar and
371        /// deliberately knows nothing about document formats, so the name is
372        /// resolved — and rejected if unknown — where the read happens.
373        format: Option<String>,
374    },
375    Stdin,
376    Fd(i32),
377    Prompt,
378    /// A scheme the host declared and reads itself.
379    Host {
380        scheme: String,
381        value: String,
382    },
383}
384
385impl ValueSource {
386    /// How this source is named where a result, a log, or an error may be read
387    /// by someone else. Never the value it resolves to.
388    #[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        // A colon that is not a scheme is just part of the value.
440        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        // The `+FORMAT` sits before the colon, so a Windows drive letter in the
459        // path is never mistaken for one.
460        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        // The name itself is checked where the read happens, not here: this
471        // module is not allowed to know what a document format is.
472        assert!(set.parse("file+nonsense:/etc/x#a").is_ok());
473    }
474
475    /// A source an argument does not accept is refused by name, and the refusal
476    /// says what it would have accepted.
477    #[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:app-host").expect("host scheme"),
510            ValueSource::Host {
511                scheme: "container".to_string(),
512                value: "app-host".to_string(),
513            }
514        );
515        assert!(set.parse("container:").is_err());
516        // The escape hatch still outranks a host scheme.
517        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}