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]     a whole file, or one address inside a 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 (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        // An unrecognized prefix is not a source; it is a value that contains a
323        // colon, which is ordinary in a URL or a `user:pass` pair.
324        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
339/// `file:rest` or `file+FORMAT:rest`, and the format the caller named.
340///
341/// The `+FORMAT` sits before the colon so a Windows drive letter in the path
342/// cannot be mistaken for it.
343fn 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
353/// `scheme:rest`, or nothing. Split on the first colon only, so a path or a URL
354/// after the prefix keeps its own colons.
355fn strip_scheme<'a>(raw: &'a str, scheme: &str) -> Option<&'a str> {
356    raw.strip_prefix(scheme)?.strip_prefix(':')
357}
358
359// ── the parsed source ───────────────────────────────────────────────────────
360
361/// Where one value is, decided at parse time and read on demand.
362#[derive(Clone, Debug, PartialEq, Eq)]
363pub enum ValueSource {
364    Literal(String),
365    Env(String),
366    File {
367        path: PathBuf,
368        dot_path: String,
369        /// The format the caller named with `file+FORMAT:`, if any.
370        ///
371        /// Carried as the caller wrote it: this module decides argv grammar and
372        /// deliberately knows nothing about document formats, so the name is
373        /// resolved — and rejected if unknown — where the read happens.
374        format: Option<String>,
375    },
376    Stdin,
377    Fd(i32),
378    Prompt,
379    /// A scheme the host declared and reads itself.
380    Host {
381        scheme: String,
382        value: String,
383    },
384}
385
386impl ValueSource {
387    /// How this source is named where a result, a log, or an error may be read
388    /// by someone else. Never the value it resolves to.
389    #[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        // A colon that is not a scheme is just part of the value.
448        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        // The `+FORMAT` sits before the colon, so a Windows drive letter in the
488        // path is never mistaken for one.
489        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        // The name itself is checked where the read happens, not here: this
500        // module is not allowed to know what a document format is.
501        assert!(set.parse("file+nonsense:/etc/x#a").is_ok());
502    }
503
504    /// A source an argument does not accept is refused by name, and the refusal
505    /// says what it would have accepted.
506    #[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        // The escape hatch still outranks a host scheme.
545        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}