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}