diff --git a/CHANGELOG.md b/CHANGELOG.md index a595db7..9e52b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ that may never merge. They are not releases and are not listed here. set in. One setting today: `update-check`, which `mapbox config set update-check off` turns off for good, mirroring `MAPBOX_NO_UPDATE_CHECK`. +- `mapbox config list`/`unset`, alongside `get`/`set`. `list` reports every + setting in one call rather than one key at a time; `unset` clears a + setting back to "never set" rather than writing its current default value + explicitly — the difference that lets a later default change reach a + cleared key but not one a caller pinned to the old default on purpose. + - The README now documents installing without the install script: the archives are plain HTTP downloads, `manifest.json` lists every target with its checksum, and the commands to verify and extract one are written out. diff --git a/docs/commands.md b/docs/commands.md index d20a63d..972f0c5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -61,7 +61,8 @@ nests, and is typed `mapbox styles draft get`. **[Uninstall](#uninstall)** — [uninstall](#mapbox-uninstall) **[Config](#config)** — [config.get](#mapbox-config-get) · -[config.set](#mapbox-config-set) +[config.set](#mapbox-config-set) · [config.list](#mapbox-config-list) · +[config.unset](#mapbox-config-unset) **[Usage](#usage)** — [usage](#mapbox-usage) @@ -3196,6 +3197,87 @@ update-check set to off. +### `mapbox config list` + +Lists every setting and its current value — `get` answers one key at a +time, this answers all of them in one call, falling back to each one's +default the same way `get` does. + +#### Parameters + +None. + +#### Examples + +```sh +mapbox config list +``` + +#### Outputs + + + + +
textjson
+ +``` +update-check on +``` + + + +```json +[ + { + "key": "update-check", + "value": true + } +] +``` + +
+ +### `mapbox config unset` + +Clears a setting back to its default, rather than setting it to that +default value explicitly. The difference matters the next time this CLI +changes what a setting's default is: a cleared key picks up the new +default, a key explicitly set to the old default value does not. + +#### Parameters + +| Parameter | Effect | +| --- | --- | +| `` | Which setting to clear. Only `update-check` exists today. | + +#### Examples + +```sh +mapbox config unset update-check +``` + +#### Outputs + + + + +
textjson
+ +``` +update-check cleared, now on (default). +``` + + + +```json +{ + "key": "update-check", + "value": true +} +``` + +
+ --- ## Usage diff --git a/src/config.rs b/src/config.rs index 0a4a5f6..af955ea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,9 +6,13 @@ //! file beside the credentials, written through the same //! [`crate::auth::write_private`] so it gets the same `0600` treatment. //! -//! One setting today — `update-check` — with room for more: `get`/`set` take -//! a `key`, restricted by clap to [`KEYS`], so adding a second setting is a -//! new key and a new match arm rather than a new pair of subcommands. +//! One setting today — `update-check` — with room for more: `get`/`set`/ +//! `unset` take a `key`, restricted by clap to [`KEYS`], so adding a second +//! setting is a new key and a new match arm rather than a new subcommand. +//! `list` needs no key at all: it walks [`KEYS`] and reports every setting's +//! current value in one call, which `get` cannot — the whole reason it +//! exists alongside `get`/`set` rather than waiting for a second setting to +//! make the gap visible. use std::path::PathBuf; @@ -16,7 +20,7 @@ use anyhow::{Context, Result}; use clap::builder::PossibleValuesParser; use clap::{Arg, ArgMatches, Command}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json, Value}; use crate::auth; use crate::output::{self, Mode}; @@ -87,6 +91,28 @@ fn on_off(enabled: bool) -> &'static str { } } +/// `key`'s current value in `config`, resolved to its default the same way +/// every getter here does. Shared by [`get`] and [`list`] so the two cannot +/// answer a key differently. +fn resolve(config: &Config, key: &str) -> bool { + match key { + UPDATE_CHECK_KEY => update_check_setting(config), + _ => unreachable!("clap's value_parser restricts `key` to {KEYS:?}"), + } +} + +/// Clears `key` back to "never set" in `config`, in place. The counterpart +/// to `set`'s `Some(enabled)` — distinct from setting a key to its default +/// value, which [`resolve`] would read identically but which +/// `serde(skip_serializing_if)` would not write identically: a later default +/// change reaches only a key that was actually cleared. +fn clear(config: &mut Config, key: &str) { + match key { + UPDATE_CHECK_KEY => config.update_check = None, + _ => unreachable!("clap's value_parser restricts `key` to {KEYS:?}"), + } +} + pub fn command() -> Command { let key_arg = || { Arg::new("key") @@ -113,23 +139,30 @@ pub fn command() -> Command { .arg(key_arg()) .arg(Arg::new("value").required(true).value_parser([ON, OFF])), ) + .subcommand(Command::new("list").about("List every setting and its current value")) + .subcommand( + Command::new("unset") + .about("Clear a setting back to its default") + .long_about( + "Clear a setting back to its default, rather than setting it to that \ + default value explicitly — the difference matters the next time this \ + CLI changes what the default is: a cleared key picks up the new default, \ + a key explicitly set to the old default value does not.", + ) + .arg(key_arg()), + ) } pub fn get(matches: &ArgMatches, mode: Mode) -> Result<()> { let key = matches.get_one::("key").expect("required"); let config = read_config(); + let enabled = resolve(&config, key); - match key.as_str() { - UPDATE_CHECK_KEY => { - let enabled = update_check_setting(&config); - output::emit( - mode, - on_off(enabled), - json!({ "key": key, "value": enabled }), - ) - } - _ => unreachable!("clap's value_parser restricts `key` to {KEYS:?}"), - } + output::emit( + mode, + on_off(enabled), + json!({ "key": key, "value": enabled }), + ) } pub fn set(matches: &ArgMatches, mode: Mode) -> Result<()> { @@ -151,6 +184,37 @@ pub fn set(matches: &ArgMatches, mode: Mode) -> Result<()> { ) } +pub fn list(mode: Mode) -> Result<()> { + let config = read_config(); + let entries: Vec = KEYS + .iter() + .map(|key| json!({ "key": key, "value": resolve(&config, key) })) + .collect(); + + let text = KEYS + .iter() + .map(|key| format!("{key}\t{}", on_off(resolve(&config, key)))) + .collect::>() + .join("\n"); + + output::emit(mode, &text, Value::Array(entries)) +} + +pub fn unset(matches: &ArgMatches, mode: Mode) -> Result<()> { + let key = matches.get_one::("key").expect("required"); + + let mut config = read_config(); + clear(&mut config, key); + write_config(&config)?; + + let enabled = resolve(&config, key); + output::emit( + mode, + &format!("{key} cleared, now {} (default).", on_off(enabled)), + json!({ "key": key, "value": enabled }), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -181,4 +245,35 @@ mod tests { assert_eq!(on_off(true), ON); assert_eq!(on_off(false), OFF); } + + #[test] + fn resolve_matches_update_check_setting_at_every_state() { + for update_check in [None, Some(true), Some(false)] { + let config = Config { update_check }; + assert_eq!( + resolve(&config, UPDATE_CHECK_KEY), + update_check_setting(&config) + ); + } + } + + /// `clear` and `set` leave different bytes on disk even when they leave + /// the same *value*: this is the difference `unset` exists to offer, so + /// it is worth pinning rather than only exercising through `resolve`, + /// which cannot tell the two states apart by design. + #[test] + fn clear_removes_the_key_rather_than_writing_the_default() { + let mut explicit_default = Config { + update_check: Some(true), + }; + clear(&mut explicit_default, UPDATE_CHECK_KEY); + assert_eq!(explicit_default, Config::default()); + assert_eq!(explicit_default.update_check, None); + + let mut explicit_off = Config { + update_check: Some(false), + }; + clear(&mut explicit_off, UPDATE_CHECK_KEY); + assert_eq!(explicit_off.update_check, None); + } } diff --git a/src/main.rs b/src/main.rs index 035dcea..a3a7961 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1052,6 +1052,8 @@ fn run(app: &Command, specs: &[ServiceSpec], matches: &ArgMatches, mode: Mode) - Some((config::COMMAND, config_matches)) => match config_matches.subcommand() { Some(("get", get_matches)) => config::get(get_matches, mode)?, Some(("set", set_matches)) => config::set(set_matches, mode)?, + Some(("list", _)) => config::list(mode)?, + Some(("unset", unset_matches)) => config::unset(unset_matches, mode)?, _ => unreachable!("`config` sets subcommand_required(true)"), }, // Token resolution mirrors the service arm below, minus path diff --git a/tests/config.rs b/tests/config.rs index 36dc19f..c6e2558 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -123,6 +123,70 @@ fn an_unknown_key_or_value_is_a_usage_error_not_a_panic() { ); } +#[test] +fn list_reports_every_setting_including_an_unset_one() { + let home = scratch("list"); + + // Nothing set yet: list still names the one known key, at its default. + let empty = command(&home) + .args(["-o", "json", "config", "list"]) + .output() + .expect("run mapbox config list"); + assert!(empty.status.success()); + assert_eq!(stdout(&empty), r#"[{"key":"update-check","value":true}]"#); + + let set = command(&home) + .args(["config", "set", "update-check", "off"]) + .output() + .expect("run mapbox config set"); + assert!(set.status.success()); + + let after = command(&home) + .args(["-o", "json", "config", "list"]) + .output() + .expect("run mapbox config list"); + assert!(after.status.success()); + assert_eq!(stdout(&after), r#"[{"key":"update-check","value":false}]"#); + + let text = command(&home) + .args(["-o", "text", "config", "list"]) + .output() + .expect("run mapbox config list"); + assert!(text.status.success()); + assert_eq!(stdout(&text), "update-check\toff"); +} + +#[test] +fn unset_clears_the_key_rather_than_writing_the_default() { + let home = scratch("unset"); + let path = config_dir(&home).join("config.json"); + + // Set to `on` explicitly — the default value, but written, not absent. + let set = command(&home) + .args(["config", "set", "update-check", "on"]) + .output() + .expect("run mapbox config set"); + assert!(set.status.success()); + let written = std::fs::read_to_string(&path).expect("config.json exists"); + assert!( + written.contains("update_check"), + "an explicit `on` should still be written: {written:?}" + ); + + let unset = command(&home) + .args(["-o", "json", "config", "unset", "update-check"]) + .output() + .expect("run mapbox config unset"); + assert!(unset.status.success()); + assert_eq!(stdout(&unset), r#"{"key":"update-check","value":true}"#); + + let cleared = std::fs::read_to_string(&path).expect("config.json still exists"); + assert_eq!( + cleared, "{}", + "unset should remove the key, not merely write back its default" + ); +} + #[cfg(unix)] #[test] fn the_config_file_is_written_private() {