Skip to main content

usage_config/
cli.rs

1//! The command line as a layer.
2//!
3//! The highest layer there is, and the one the fleet gets least right. hk declares eighteen
4//! `sources.cli` bindings and reads five — the flags live in a second, hand-maintained struct and
5//! are consumed ad hoc. mise hand-copies thirteen flags into its settings in a forty-nine-line
6//! function, and `--jobs` bypasses even that by going through the environment. pitchfork's `--help`
7//! documents a CLI layer it does not have.
8//!
9//! What they are all writing is this: for each setting a flag was given for, one entry at the top of
10//! the merge. The part that is easy to get wrong is *given*, which is why this layer takes values
11//! rather than a struct — a `bool` field is `false` whether the flag was absent or explicitly
12//! negated, and a layer that cannot tell those apart makes `--no-colour` indistinguishable from
13//! saying nothing, which silently outranks every file on the machine.
14//!
15//! ```
16//! use usage_config::{resolve, CliLayer, Layers, PropMeta, Registry, Ty, Value};
17//!
18//! static PROPS: &[PropMeta] = &[PropMeta {
19//!     cli: &["--jobs", "-j"],
20//!     ..PropMeta::new("jobs", Ty::Uint)
21//! }];
22//! const REGISTRY: Registry = Registry::new(PROPS);
23//!
24//! // What a parser produces: the settings a flag was actually given for.
25//! let cli = CliLayer::new([("jobs", "8")]);
26//! let resolved = resolve(REGISTRY, Layers::new().then(&cli))?;
27//!
28//! assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
29//! // Named as the flag rather than as "the command line", so an explanation is actionable.
30//! assert_eq!(
31//!     resolved.origin_key("jobs").unwrap().describe(),
32//!     "--jobs",
33//! );
34//! # Ok::<(), usage_config::LayerError>(())
35//! ```
36
37use crate::layer::{Layer, LayerCtx, LayerError, LayerOutput, Warning, WarningKind};
38use crate::registry::Registry;
39use crate::source::{Origin, SourceKind};
40use crate::value::Value;
41
42/// Settings given on the command line.
43pub struct CliLayer {
44    given: Vec<(String, Given)>,
45}
46
47/// One value a flag was given, as text or with a shape of its own.
48enum Given {
49    /// What a flag's argument is before anything types it, which is what a parser hands over.
50    Text(String),
51    /// A value a caller has already made: a `bool` from a switch, a count, a list it collected.
52    Shaped(Value),
53    /// A value that is not text at all: bytes an argument can hold and a setting cannot.
54    Unrepresentable,
55}
56
57impl CliLayer {
58    /// The settings a flag was given for, as text.
59    ///
60    /// Only what was *given*: a flag left off the command line is not an entry here, because the
61    /// command line outranks every other layer and an entry it did not earn would silently beat a
62    /// file the user did write.
63    pub fn new(given: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>) -> Self {
64        Self {
65            given: given
66                .into_iter()
67                .map(|(key, value)| (key.into(), Given::Text(value.into())))
68                .collect(),
69        }
70    }
71
72    /// A setting given as text, added to what this layer already has.
73    ///
74    /// Chained rather than collected, so a CLI can build the layer with one call per flag it has and
75    /// leave out the ones it has not — which is the shape a generated `to_settings_layer` wants.
76    pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
77        self.given.push((key.into(), Given::Text(value.into())));
78        self
79    }
80
81    /// A setting given as a value that already has a shape.
82    ///
83    /// A switch is a `bool`, a `--verbose` count is a number, a repeated flag is a list: a caller
84    /// holding those has no reason to render them to text for this to read them back.
85    pub fn with_value(mut self, key: impl Into<String>, value: Value) -> Self {
86        self.given.push((key.into(), Given::Shaped(value)));
87        self
88    }
89
90    /// A setting whose flag was given a value that cannot be one.
91    ///
92    /// An argument is bytes and a setting is text — every layer below this one reads a file or a
93    /// variable that had to be UTF-8 to exist. On Unix a path need not be, so `--exclude` can be
94    /// handed something the resolution has nowhere to put. Rendering it lossily would set the
95    /// setting to a value nobody typed, and one that no longer names the file it came from, while
96    /// the CLI's own field still holds the real bytes: one flag, two answers. This says so instead,
97    /// and the setting keeps whatever the layers below it gave.
98    pub fn with_unrepresentable(mut self, key: impl Into<String>) -> Self {
99        self.given.push((key.into(), Given::Unrepresentable));
100        self
101    }
102
103    /// Whether a flag was given for anything at all.
104    ///
105    /// A CLI with no settings on its command line can leave this layer out of the plan rather than
106    /// adding an empty one, though adding one changes nothing.
107    pub fn is_empty(&self) -> bool {
108        self.given.is_empty()
109    }
110
111    /// What a report should call the flag that set `key`.
112    ///
113    /// The first spelling the setting declares, because that is the long one a spec lists first and
114    /// the one worth printing: "set by `--jobs`" is actionable in a way that "set by the command
115    /// line" is not. A setting whose registry declares no flag is named by its key, which is the
116    /// most that can be said about a CLI that bound something it never documented.
117    ///
118    /// Asked of the declaration the CLI bound to rather than of the setting that declaration ends
119    /// up meaning, which is the same distinction a deprecation warning makes. A flag bound to a
120    /// renamed key reads the replacement's flags through `lookup`, so `--old` was reported as
121    /// `--new`: a flag nobody typed, named as the thing to stop passing.
122    fn origin(&self, registry: Registry, key: &str) -> Origin {
123        let named = registry
124            .lookup_exact(key)
125            .and_then(|id| registry.get(id).cli.first().copied());
126        Origin::new(SourceKind::CLI, named.unwrap_or(key))
127    }
128}
129
130impl Layer for CliLayer {
131    fn source(&self) -> SourceKind {
132        SourceKind::CLI
133    }
134
135    fn load(&self, ctx: &LayerCtx) -> Result<LayerOutput, LayerError> {
136        let mut out = LayerOutput::new();
137        for (key, given) in &self.given {
138            let origin = self.origin(ctx.registry(), key);
139            let entry = match given {
140                Given::Text(raw) => ctx.entry_for_key(key, raw, origin),
141                Given::Shaped(value) => ctx.entry_from_value(key, value.clone(), origin),
142                Given::Unrepresentable => {
143                    let named = origin.describe();
144                    out.warn(
145                        Warning::at(
146                            format!(
147                                "{named} was given a value that is not text, so {key} keeps the \
148                                 value it had"
149                            ),
150                            origin,
151                        )
152                        .of(WarningKind::WrongType),
153                    );
154                    continue;
155                }
156            };
157            match entry {
158                Ok(entry) => out.push(entry),
159                // A flag the CLI bound to a setting nothing declares, or a value the declared type
160                // cannot read: reported like any other layer's, rather than a panic in the one layer
161                // whose contents are the CLI author's own doing. They will see it on their first run.
162                Err(warning) => out.warn(warning),
163            }
164        }
165        Ok(out)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::registry::{PropMeta, Scope};
173    use crate::resolve::{resolve, Layers};
174    use crate::ty::{Parser, Ty};
175    use crate::value::Const;
176
177    static PROPS: &[PropMeta] = &[
178        PropMeta {
179            default: Some(Const::Int(4)),
180            envs: &["HK_JOBS"],
181            cli: &["--jobs", "-j"],
182            ..PropMeta::new("jobs", Ty::Uint)
183        },
184        PropMeta {
185            cli: &["--colour", "--no-colour"],
186            ..PropMeta::new("colour", Ty::Bool)
187        },
188        PropMeta {
189            parse: Some(Parser::ListByComma),
190            cli: &["--exclude"],
191            ..PropMeta::new("exclude", Ty::List(&Ty::String))
192        },
193        // Settable from a checkout's own file is exactly what this is not, and the command line is
194        // the user's own — so a flag may set it.
195        PropMeta {
196            scope: Scope::Global,
197            cli: &["--trusted"],
198            ..PropMeta::new("trusted", Ty::Bool)
199        },
200        // No flag at all, which is most settings.
201        PropMeta::new("stash", Ty::String),
202        // An old name with a flag of its own, still bound by a CLI that has not dropped it yet.
203        PropMeta {
204            cli: &["--concurrency"],
205            renamed_to: Some("jobs"),
206            ..PropMeta::new("concurrency", Ty::Uint)
207        },
208    ];
209    const REGISTRY: Registry = Registry::new(PROPS);
210
211    #[test]
212    fn a_flag_that_was_given_sets_its_setting() {
213        let cli = CliLayer::new([("jobs", "8")]);
214        let resolved = resolve(REGISTRY, Layers::new().then(&cli)).expect("resolves");
215        assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
216        // Named as the flag, not as "the command line": a user who does not like the answer needs to
217        // know what to stop passing.
218        assert_eq!(
219            resolved.origin_key("jobs").map(|o| o.describe()),
220            Some("--jobs")
221        );
222    }
223
224    #[test]
225    fn the_command_line_outranks_everything() {
226        let cli = CliLayer::new([("jobs", "8")]);
227        let env = crate::env::EnvLayer::new([("HK_JOBS".to_string(), "6".to_string())]);
228        let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env)).expect("resolves");
229        assert_eq!(resolved.get_key("jobs"), Some(&Value::Int(8)));
230    }
231
232    #[test]
233    fn a_flag_that_was_not_given_is_not_an_entry() {
234        // The whole point of taking given values rather than a struct. A `bool` field is `false`
235        // whether the flag was absent or explicitly negated, so a layer built from the struct sets
236        // every switch on the command line — silently outranking every file on the machine.
237        let cli = CliLayer::new(Vec::<(String, String)>::new());
238        assert!(cli.is_empty());
239        let env = crate::env::EnvLayer::new([("HK_JOBS".to_string(), "6".to_string())]);
240        let resolved = resolve(REGISTRY, Layers::new().then(&cli).then(&env)).expect("resolves");
241        assert_eq!(
242            resolved.get_key("jobs"),
243            Some(&Value::Int(6)),
244            "the environment should still be what set it"
245        );
246        assert_eq!(resolved.get_key("colour"), None, "no flag, no value");
247    }
248
249    #[test]
250    fn a_value_that_already_has_a_shape_is_taken_as_it_is() {
251        // A switch is a `bool` and a repeated flag is a list. A caller holding those has no reason to
252        // render them to text for this to read them back.
253        let cli = CliLayer::new(Vec::<(String, String)>::new())
254            .with_value("colour", Value::Bool(false))
255            .with_value(
256                "exclude",
257                Value::List(vec![Value::from("target"), Value::from("dist")]),
258            );
259        let resolved = resolve(REGISTRY, Layers::new().then(&cli)).expect("resolves");
260        assert_eq!(resolved.get_key("colour"), Some(&Value::Bool(false)));
261        assert_eq!(
262            resolved.get_key("exclude"),
263            Some(&Value::List(vec![
264                Value::from("target"),
265                Value::from("dist")
266            ]))
267        );
268    }
269
270    #[test]
271    fn a_flag_may_set_a_setting_no_file_can() {
272        // `scope="global"` is about what a *checkout* can carry. The command line is the user's own.
273        let cli = CliLayer::new([("trusted", "true")]);
274        let resolved = resolve(REGISTRY, Layers::new().then(&cli)).expect("resolves");
275        assert_eq!(resolved.get_key("trusted"), Some(&Value::Bool(true)));
276        assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
277    }
278
279    #[test]
280    fn a_setting_with_no_declared_flag_is_named_by_its_key() {
281        // A CLI can bind a flag to a setting whose spec never mentioned one. That is worth reporting
282        // as best as it can be — the key — rather than refusing to resolve it.
283        let cli = CliLayer::new([("stash", "none")]);
284        let resolved = resolve(REGISTRY, Layers::new().then(&cli)).expect("resolves");
285        assert_eq!(resolved.get_key("stash"), Some(&Value::from("none")));
286        assert_eq!(
287            resolved.origin_key("stash").map(|o| o.describe()),
288            Some("stash")
289        );
290    }
291
292    #[test]
293    fn a_flag_bound_to_an_old_name_is_named_by_the_old_name() {
294        // `lookup` answers "which setting is this", which is what a *value* needs and what the merge
295        // does with it. A flag's name is a question about the declaration the CLI bound to: reading
296        // the replacement's flags through a rename reported `--old` as `--new`, naming a flag the
297        // user never typed as the thing to stop passing.
298        let cli = CliLayer::new([("concurrency", "8")]);
299        let resolved = resolve(REGISTRY, Layers::new().then(&cli)).expect("resolves");
300        assert_eq!(
301            resolved.get_key("jobs"),
302            Some(&Value::Int(8)),
303            "still folds"
304        );
305        assert_eq!(
306            resolved.origin_key("jobs").map(|o| o.describe()),
307            Some("--concurrency")
308        );
309    }
310
311    #[test]
312    fn a_value_that_is_not_text_is_said_rather_than_rendered() {
313        // An argument is bytes and a setting is text. Rendering `--exclude $'\xff'` lossily would
314        // set `exclude` to a string nobody typed, naming a file that does not exist, while the CLI's
315        // own field still held the real bytes — one flag, two answers, and the command line's answer
316        // is the one that outranks every file on the machine.
317        let cli = CliLayer::new([("jobs", "8")]).with_unrepresentable("exclude");
318        let resolved = resolve(REGISTRY, Layers::new().then(&cli)).expect("resolves");
319        assert_eq!(
320            resolved.get_key("jobs"),
321            Some(&Value::Int(8)),
322            "the rest is unaffected"
323        );
324        assert_eq!(
325            resolved.get_key("exclude"),
326            None,
327            "and this keeps what it had"
328        );
329        let kinds: Vec<_> = resolved.warnings.iter().map(|w| w.kind).collect();
330        assert_eq!(kinds, vec![crate::layer::WarningKind::WrongType]);
331        // Named by the flag, since that is what the user would retype.
332        assert!(
333            resolved.warnings[0]
334                .message
335                .starts_with("--exclude was given a value that is not text"),
336            "{:?}",
337            resolved.warnings[0].message
338        );
339    }
340
341    #[test]
342    fn a_flag_bound_to_nothing_is_a_warning_rather_than_a_crash() {
343        // The CLI author's own mistake, found on their first run: a flag bound to a setting that does
344        // not exist, or a value the declared type cannot read. Reported like any other layer's,
345        // because a panic in the one layer whose contents are the author's doing is no more useful
346        // and much harder to see past.
347        let cli = CliLayer::new([("nonesuch", "1"), ("jobs", "lots")]);
348        let resolved = resolve(REGISTRY, Layers::new().then(&cli)).expect("resolves");
349        assert_eq!(
350            resolved.get_key("jobs"),
351            Some(&Value::Int(4)),
352            "the default"
353        );
354        let kinds: Vec<_> = resolved.warnings.iter().map(|w| w.kind).collect();
355        assert_eq!(
356            kinds,
357            vec![
358                crate::layer::WarningKind::UnknownSetting,
359                crate::layer::WarningKind::WrongType
360            ]
361        );
362    }
363}