From efa29a376b57da43c95e27567c8aa65888ed20ef Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Fri, 23 Oct 2026 14:49:41 -0700 Subject: [PATCH 1/2] rust(feat): interactive profile setup for sift-cli config `config create` now prompts for profile values and creates new profiles with `--profile `. `config update` without flags opens an editor that shows current values, hides the API key, and confirms a masked change list. Outside a terminal it exits 2 instead of a silent no-op. `--profile default` now selects the top-level profile in every command. --- rust/crates/sift_cli/CHANGELOG.md | 24 + .../docs/src/getting-started/configuration.md | 61 +- .../docs/src/getting-started/profiles.md | 51 +- .../docs/src/getting-started/verifying.md | 2 +- .../assets/docs/src/reference/commands.md | 4 +- rust/crates/sift_cli/src/cli/mod.rs | 15 +- rust/crates/sift_cli/src/cmd/config/mod.rs | 581 +++++++++++++----- rust/crates/sift_cli/src/cmd/config/tests.rs | 169 +++-- rust/crates/sift_cli/src/cmd/mod.rs | 16 +- rust/crates/sift_cli/src/main.rs | 2 +- rust/crates/sift_cli/src/util/tty.rs | 148 +++-- 11 files changed, 777 insertions(+), 296 deletions(-) diff --git a/rust/crates/sift_cli/CHANGELOG.md b/rust/crates/sift_cli/CHANGELOG.md index aa8360bf25..60ff94b86b 100644 --- a/rust/crates/sift_cli/CHANGELOG.md +++ b/rust/crates/sift_cli/CHANGELOG.md @@ -7,6 +7,30 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### What's New +- `config update` without field flags no longer prints `Nothing to update.` + and exits 0. In a terminal, it now opens an interactive editor. Outside a + terminal, it exits with status 2. Scripts that call it with no flags now + fail. +- `config update` stops with a pointer to `config create` when the config file + is missing. When `--profile` names a profile that does not exist, the + interactive editor stops the same way. Field flags still create a new named + profile. +- `config create` now prompts for the profile values in a terminal. It also + creates new profiles: `sift-cli --profile staging config create`, or, without + `--profile`, it asks for a name when the default profile is already set up. + Outside a terminal, it still creates only an empty file. +- The interactive editor lists named profiles to pick from and shows each + current value in brackets. Press Enter to keep a value. The API key is not + shown while you type it. Before it saves, the editor lists only the changed + fields, with the key masked to its last four characters. It writes nothing + when no field changed. The confirmation now defaults to yes. +- `--profile default` now selects the top-level profile in every command. Before, + it looked for a `[default]` table, so `sift-cli --profile default ping` + failed on a file with only top-level keys. A file that has a `[default]` + table still uses that table. +- `config update` with field flags prints the fields that changed. It reports + `No changes` and does not write the file when the values are already set. + ## [v0.7.5] - October 19, 2026 ### What's New diff --git a/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md b/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md index 1aca6f9fab..0c10befa74 100644 --- a/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md +++ b/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md @@ -7,7 +7,7 @@ Each CLI profile needs four values: | `grpc_uri` | Base gRPC endpoint for Sift | `https://grpc-api.siftstack.com` | | `rest_uri` | Base REST endpoint for Sift | `https://api.siftstack.com` | | `app_uri` | Required web app origin from your browser | `https://app.siftstack.com` | -| `apikey` | Your Sift API key | `sift_...` | +| `api_key` | Your Sift API key | `sift_...` | For Sift Cloud, use the gRPC and REST values in the table. For other environments, use the endpoints from your administrator. See @@ -43,24 +43,59 @@ sift-cli config where ## Bootstrapping your credentials -The fastest way to get set up is the interactive flow. First create the file, -then fill it in: +Run `config create` in a terminal. It creates the config file and prompts for +the values of the `default` profile: ```sh sift-cli config create -sift-cli config update --interactive ``` -The interactive prompt asks for the profile, API URLs, API key, and app origin. -The prompt shows the result before it writes the file. A blank profile selects -the `default` profile. +```text +Creating a config file at '~/Library/Application Support/sift.toml'. + +Editing profile 'default'. Press Enter to keep the value in brackets. + + gRPC URL: https://grpc-api.siftstack.com + REST URL: https://api.siftstack.com + API key: + App URL [https://app.siftstack.com]: + +Changes to 'default': + grpc_uri (unset) -> https://grpc-api.siftstack.com + rest_uri (unset) -> https://api.siftstack.com + api_key (unset) -> ****a91f + app_uri (unset) -> https://app.siftstack.com +Apply? [Y/n]: +Saved profile 'default'. +Run 'sift-cli ping' to check the connection. +``` + +The terminal does not show the API key while you type it. The change list shows +only its last four characters. The command writes nothing until you confirm. + +### Editing a profile + +Run `config update` without field flags to edit a profile interactively: + +```sh +sift-cli config update +``` + +If the file has named profiles, the command first asks which profile to edit. +Use `--profile ` to skip that question. Each prompt shows the current value +in brackets. Press Enter to keep it. The command lists only the fields you +changed. If nothing changed, it writes nothing. + +`config update` edits existing profiles only. If the config file or the named +profile does not exist, the command stops and tells you to run `config create`. ### Non-interactive setup -To configure everything in one command (useful for scripts and CI), pass the -values as flags: +Outside a terminal, `config create` only creates an empty config file. To set +the values in one command (for scripts and CI), pass them as flags: ```sh +sift-cli config create sift-cli config update \ --grpc-uri https://grpc-api.siftstack.com \ --rest-uri https://api.siftstack.com \ @@ -69,12 +104,16 @@ sift-cli config update \ ``` Short forms are `-g` for gRPC, `-r` for REST, and `-k` for the API key. -Omitted fields stay unchanged. You can update one value at a time: +Omitted fields stay unchanged. The command prints the fields that changed. You +can update one value at a time: ```sh sift-cli config update --api-key "$SIFT_API_KEY" ``` +Without field flags outside a terminal, `config update` exits with status 2, +because it cannot prompt. + ## Inspecting the config ```sh @@ -88,7 +127,7 @@ A configured `default` profile looks like this: grpc_uri = "https://grpc-api.siftstack.com" rest_uri = "https://api.siftstack.com" app_uri = "https://app.siftstack.com" -apikey = "sift_..." +api_key = "sift_..." ``` ## Disabling TLS diff --git a/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md b/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md index 6eed55652e..71ca84b4b8 100644 --- a/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md +++ b/rust/crates/sift_cli/assets/docs/src/getting-started/profiles.md @@ -7,23 +7,35 @@ re-editing your config. ## The default profile When you do not name a profile, settings are read from and written to the top -level of the config file. This is the `default` profile: +level of the config file. This is the `default` profile. `--profile default` +also selects it: ```toml grpc_uri = "https://grpc-api.siftstack.com" rest_uri = "https://api.siftstack.com" app_uri = "https://app.siftstack.com" -apikey = "sift_prod_..." +api_key = "sift_prod_..." ``` ## Adding a named profile -Pass `--profile ` to `config update`. The profile is created as its own -TOML table: +In a terminal, pass the new name to `config create`. It prompts for each value +of the new profile: ```sh -sift-cli config update --profile mission \ - --grpc-uri https://api.staging.example.com \ +sift-cli --profile staging config create +``` + +Without `--profile`, `config create` asks for a name when the `default` profile +is already set up. If the profile already exists, the command tells you to use +`config update` instead. + +For scripts, pass `--profile ` and field flags to `config update`. A name +that does not exist yet is created: + +```sh +sift-cli --profile staging config update \ + --grpc-uri https://grpc-api.staging.example.com \ --rest-uri https://api.staging.example.com \ --app-uri https://sift.staging.example.com \ --api-key "$SIFT_STAGING_API_KEY" @@ -35,20 +47,33 @@ The resulting file: grpc_uri = "https://grpc-api.siftstack.com" rest_uri = "https://api.siftstack.com" app_uri = "https://app.siftstack.com" -apikey = "sift_prod_..." +api_key = "sift_prod_..." [staging] grpc_uri = "https://grpc-api.staging.example.com" rest_uri = "https://api.staging.example.com" app_uri = "https://sift.staging.example.com" -apikey = "sift_staging_..." +api_key = "sift_staging_..." +``` + +Each profile is stored as its own TOML table. + +## Editing a named profile + +Run `config update` without field flags. The command lists the profiles and +asks which one to edit: + +```text +Select a profile to edit: + 1) default + 2) staging +Profile [1]: 2 ``` -The interactive flow also supports profiles. When prompted for a profile name, -enter `staging` instead of leaving it blank: +Enter a number or a profile name. To skip the list, name the profile: ```sh -sift-cli config update --interactive +sift-cli --profile staging config update ``` ## Using a profile @@ -56,8 +81,8 @@ sift-cli config update --interactive `--profile` is a global flag, so it goes on any command that talks to Sift: ```sh -sift-cli --profile mission ping -sift-cli --profile mission import csv ./telemetry.csv --asset rover-1 +sift-cli --profile staging ping +sift-cli --profile staging import csv ./telemetry.csv --asset rover-1 ``` Omit `--profile` to use the default profile. diff --git a/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md b/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md index 4456d64be5..409dcc489d 100644 --- a/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md +++ b/rust/crates/sift_cli/assets/docs/src/getting-started/verifying.md @@ -26,7 +26,7 @@ sift-cli --disable-tls ping | Symptom | Likely cause and fix | | ----------------------------------------- | ------------------------------------------------------------------------------------ | -| `expected to find 'sift.toml'` | No config file yet. Run `sift-cli config create`, then `config update`. | +| `expected to find 'sift.toml'` | No config file yet. Run `sift-cli config create`. | | `Expected value of 'apikey' to be present`| The profile is missing a field. Re-run `sift-cli config update` with the missing value. | | `Profile '' not found` | The named profile does not exist. Check `sift-cli config show`. | | Authentication or permission errors | The API key is wrong or lacks access. Generate a new key in the Sift web app. | diff --git a/rust/crates/sift_cli/assets/docs/src/reference/commands.md b/rust/crates/sift_cli/assets/docs/src/reference/commands.md index 47729465a6..41f36595e7 100644 --- a/rust/crates/sift_cli/assets/docs/src/reference/commands.md +++ b/rust/crates/sift_cli/assets/docs/src/reference/commands.md @@ -18,10 +18,10 @@ Manage CLI configuration. See [Configuration](../getting-started/configuration.m | Command | Description | | ----------------------- | ------------------------------------------------- | -| `config create` | Create a new (empty) config file. | +| `config create` | Create the config file or a new profile, then prompt for its values. | | `config show` | Print the current config file. | | `config where` | Print the path to the config file. | -| `config update` | Set connection, app, or key fields. Use `--interactive` for prompts. | +| `config update` | Edit an existing profile. Prompts when run without field flags. | ## `import` diff --git a/rust/crates/sift_cli/src/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index dbe3431eee..3862eea6e1 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -370,16 +370,25 @@ pub enum ConfigCmd { /// Show the path to the current config file Where, - /// Create a new config file (fails if one already exists) + /// Create the config file or a new profile, then prompt for its values. + /// + /// Without --profile, sets up the default profile, or asks for a new + /// profile name when the default is already set up. Outside a terminal, + /// only creates an empty config file. Create, - /// Update fields in the existing config file + /// Update fields of an existing profile. + /// + /// With field flags, sets those fields without prompting. A new --profile + /// name is created. Without field flags in a terminal, prompts for each + /// value and shows the current one. Without field flags outside a + /// terminal, exits with status 2. Update(ConfigUpdateArgs), } #[derive(clap::Args)] pub struct ConfigUpdateArgs { - /// Edit or create a profile interactively (ignores other flags) + /// Prompt for each value even when field flags are given (ignores them) #[arg(short, long)] pub interactive: bool, diff --git a/rust/crates/sift_cli/src/cmd/config/mod.rs b/rust/crates/sift_cli/src/cmd/config/mod.rs index 378fb917cf..d771478f6e 100644 --- a/rust/crates/sift_cli/src/cmd/config/mod.rs +++ b/rust/crates/sift_cli/src/cmd/config/mod.rs @@ -5,24 +5,26 @@ use crate::BIN_NAME; use anyhow::{Context, Result, anyhow}; use crossterm::style::Stylize; use std::{ - fs::{File, OpenOptions, create_dir_all, metadata, read_to_string}, - io::Write, - path::PathBuf, + fs::{create_dir_all, metadata, read_to_string, write}, + path::{Path, PathBuf}, process::ExitCode, }; use toml::{Table, Value}; use crate::{ cli::ConfigUpdateArgs, - cmd::{API_KEY_KEY, API_KEY_KEY_LEGACY}, + cmd::{API_KEY_KEY, API_KEY_KEY_LEGACY, profile_api_key}, util::{ app_uri::{infer_app_uri, normalize_app_uri}, - tty::{Output, PromptUser}, + tty::{Output, is_interactive, read_hidden, read_line}, }, }; pub const CONFIG_FILE_NAME: &str = "sift.toml"; +/// The name shown for the top-level profile. +const DEFAULT_PROFILE: &str = "default"; + #[derive(Debug, Clone, Eq, PartialEq)] pub(super) enum AppUriState { Configured(String), @@ -38,118 +40,424 @@ pub fn show() -> Result { Ok(ExitCode::SUCCESS) } -pub fn create() -> Result { +/// The connection fields of one profile, as stored in the config file. +#[derive(Debug, Clone, Default, Eq, PartialEq)] +pub(super) struct ProfileFields { + grpc_uri: Option, + rest_uri: Option, + api_key: Option, + app_uri: Option, +} + +impl ProfileFields { + /// The fields of `profile`. All unset when the profile does not exist. + fn of(config: &Table, profile: Option<&str>) -> Self { + let Ok(table) = profile_table(config, profile) else { + return Self::default(); + }; + let field = |key: &str| table.get(key).and_then(Value::as_str).map(str::to_string); + Self { + grpc_uri: field("grpc_uri"), + rest_uri: field("rest_uri"), + api_key: profile_api_key(table).and_then(|v| v.as_str().map(str::to_string)), + app_uri: field("app_uri"), + } + } + + fn is_empty(&self) -> bool { + *self == Self::default() + } +} + +pub fn create(profile: Option) -> Result { let path = get_config_file_path()?; let p = path.display().to_string(); - - if metadata(&path).is_ok() { + let exists = metadata(&path).is_ok(); + + if !is_interactive() { + if exists { + Output::new() + .line(format!("A config file already exists at '{}'.", p.yellow())) + .tip(format!( + "Use '{}' to set profile values.", + format!("{BIN_NAME} config update").green() + )) + .print(); + return Ok(ExitCode::SUCCESS); + } + write_config(&path, &Table::new())?; Output::new() - .line(format!("A config file already exists at '{}'.", p.yellow())) + .line(format!( + "An empty config file has been created at '{}'.", + p.yellow() + )) .tip(format!( - "Use '{}' to view the contents.", - format!("{BIN_NAME} config show").green() + "Use '{}' with --grpc-uri, --rest-uri, --api-key, and --app-uri to configure it.", + format!("{BIN_NAME} config update").green() )) .print(); return Ok(ExitCode::SUCCESS); } - create_config_file()?; - - Output::new() - .line(format!( - "An empty config file has been created at '{}'.", - p.yellow() - )) - .tip(format!( - "Use '{}' to configure it.", - format!("{BIN_NAME} config update").green() - )) - .print(); + let config = if exists { + read_config(&path)? + } else { + Table::new() + }; + let explicit_default = profile.as_deref() == Some(DEFAULT_PROFILE); + + let profile = match resolve_profile(&config, profile) { + None if ProfileFields::of(&config, None).is_empty() => None, + None if explicit_default => { + Output::new() + .line(format!( + "Profile '{}' already exists.", + DEFAULT_PROFILE.yellow() + )) + .tip(format!( + "Use '{}' to edit it.", + format!("{BIN_NAME} config update").green() + )) + .print(); + return Ok(ExitCode::SUCCESS); + } + Some(name) => match config.get(&name) { + Some(Value::Table(_)) => { + Output::new() + .line(format!( + "Profile '{}' already exists.", + name.as_str().yellow() + )) + .tip(format!( + "Use '{}' to edit it.", + format!("{BIN_NAME} --profile {name} config update").green() + )) + .print(); + return Ok(ExitCode::SUCCESS); + } + Some(_) => { + Output::new() + .line(format!( + "'{name}' is a config field, not a profile. Choose another name." + )) + .eprint(); + return Ok(ExitCode::FAILURE); + } + None => Some(name), + }, + None => match prompt_new_profile_name(&config)? { + Some(name) => Some(name), + None => return aborted(), + }, + }; - Ok(ExitCode::SUCCESS) + if !exists { + println!("Creating a config file at '{}'.\n", p.yellow()); + } + edit_profile(&path, config, profile) } pub fn update(profile: Option, args: ConfigUpdateArgs) -> Result { - let mut configured_profile = profile.clone(); + let path = get_config_file_path()?; + if metadata(&path).is_err() { + Output::new() + .line(format!( + "'{}' not found.", + path.display().to_string().yellow() + )) + .tip(format!( + "Run '{}' to set up your first profile.", + format!("{BIN_NAME} config create").green() + )) + .eprint(); + return Ok(ExitCode::FAILURE); + } + let config = read_config(&path)?; + let profile = resolve_profile(&config, profile); + + let non_empty = |value: Option| value.filter(|v| !v.is_empty()); + let flags = ProfileFields { + grpc_uri: non_empty(args.grpc_uri), + rest_uri: non_empty(args.rest_uri), + api_key: non_empty(args.api_key), + app_uri: non_empty(args.app_uri), + }; + if !args.interactive && !flags.is_empty() { + let Some(updated) = updated_config(&config, profile.as_deref(), flags)? else { + return Ok(ExitCode::SUCCESS); + }; + save(&path, &updated, profile.as_deref())?; + return Ok(ExitCode::SUCCESS); + } - let updated_config = { - if !args.interactive { - if is_update_empty(&args) { - Output::new().line("Nothing to update.").print(); - return Ok(ExitCode::SUCCESS); - } - get_updated_config( - profile.clone(), - args.grpc_uri, - args.rest_uri, - args.api_key, - args.app_uri, - )? - } else { - let [prof, grpc, rest, key]: [Option; 4] = PromptUser::new() - .header("Any blank values will be ignored preserving the original.") - .prompt(" Specify the profile to configure (leave blank for default profile): ") - .prompt(" Specify the gRPC API base URL: ") - .prompt(" Specify the REST API base URL: ") - .prompt(" Provide your Sift API key: ") - .run()? - .try_into() - .unwrap(); - - configured_profile = prof.clone(); - let suggested_app_uri = rest.as_deref().and_then(infer_app_uri); - let app_prompt = suggested_app_uri.map_or_else( - || { - " Open your Sift web app and copy its URL origin. Keep the scheme and host. \ - Specify that origin here (for example, https://sift.example.net): " - .to_string() - }, - |uri| format!(" Specify the Sift web app URL [{uri}]: "), - ); - let [app]: [Option; 1] = PromptUser::new() - .prompt(app_prompt) - .run()? - .try_into() - .unwrap(); - let app = app.or_else(|| suggested_app_uri.map(str::to_string)); - - let updated = get_updated_config(prof, grpc, rest, key, app)?; - let divider = "-".repeat(40); - - let [confirmation]: [Option; 1] = PromptUser::new() - .prompt(format!( - "\n{divider}\n{updated}\n{divider}\nDoes this look correct? [y/n]: " + if !is_interactive() { + Output::new() + .line("No fields to update, and no terminal to prompt in.") + .tip(format!( + "Pass one or more of --grpc-uri, --rest-uri, --api-key, or --app-uri. \ + Or run '{}' in a terminal to edit a profile interactively.", + format!("{BIN_NAME} config update").green() + )) + .eprint(); + return Ok(ExitCode::from(2)); + } + + let profile = match profile { + Some(name) if profile_table(&config, Some(&name)).is_err() => { + Output::new() + .line(format!("Profile '{}' not found.", name.as_str().yellow())) + .tip(format!( + "Run '{}' to create it.", + format!("{BIN_NAME} --profile {name} config create").green() )) - .run()? - .try_into() - .unwrap(); + .eprint(); + return Ok(ExitCode::FAILURE); + } + Some(name) => Some(name), + None => select_profile(&config)?, + }; + edit_profile(&path, config, profile) +} - if confirmation.is_none_or(|c| c != "y") { - Output::new().line("Operation aborted.").print(); - return Ok(ExitCode::SUCCESS); - } - updated +/// Prompt for each field of `profile`, show the changes, and save them after +/// the user confirms. A blank answer keeps the current value. +fn edit_profile(path: &Path, config: Table, profile: Option) -> Result { + let name = profile_name(profile.as_deref()); + let current = ProfileFields::of(&config, profile.as_deref()); + + println!( + "Editing profile '{}'. Press Enter to keep the value in brackets.\n", + name.yellow() + ); + let grpc_uri = read_line(&field_prompt("gRPC URL", current.grpc_uri.as_deref()))?; + let rest_uri = read_line(&field_prompt("REST URL", current.rest_uri.as_deref()))?; + let api_key = read_hidden(&field_prompt( + "API key", + current.api_key.as_deref().map(mask_key).as_deref(), + ))?; + + let current_app_uri = current.app_uri.as_deref().and_then(normalize_app_uri); + let suggested_app_uri = current_app_uri.or_else(|| { + rest_uri + .as_deref() + .or(current.rest_uri.as_deref()) + .and_then(infer_app_uri) + }); + if suggested_app_uri.is_none() { + println!( + " Open your Sift web app and copy its URL origin. Keep the scheme and host \ + (for example, https://sift.example.net)." + ); + } + let app_uri = read_line(&field_prompt("App URL", suggested_app_uri))?.or_else(|| { + suggested_app_uri + .filter(|_| current_app_uri.is_none()) + .map(str::to_string) + }); + + println!(); + let edits = ProfileFields { + grpc_uri, + rest_uri, + api_key, + app_uri, + }; + let Some(updated) = updated_config(&config, profile.as_deref(), edits)? else { + return Ok(ExitCode::SUCCESS); + }; + + let confirmation = read_line("Apply? [Y/n]: ")?; + if confirmation.is_some_and(|c| !c.eq_ignore_ascii_case("y") && !c.eq_ignore_ascii_case("yes")) + { + return aborted(); + } + + save(path, &updated, profile.as_deref())?; + println!( + "Run '{}' to check the connection.", + format!("{BIN_NAME} {}ping", profile_flag(profile.as_deref())).green() + ); + Ok(ExitCode::SUCCESS) +} + +/// Ask which existing profile to edit. None selects the default profile. +fn select_profile(config: &Table) -> Result> { + let named = named_profiles(config); + if named.is_empty() { + return Ok(None); + } + + println!("Select a profile to edit:"); + println!(" 1) {DEFAULT_PROFILE}"); + for (i, name) in named.iter().enumerate() { + println!(" {}) {name}", i + 2); + } + loop { + let Some(answer) = read_line("Profile [1]: ")? else { + return Ok(None); + }; + if answer == "1" || answer == DEFAULT_PROFILE { + return Ok(None); } + let by_number = answer + .parse::() + .ok() + .and_then(|n| n.checked_sub(2)) + .and_then(|i| named.get(i)); + if let Some(name) = by_number.or_else(|| named.iter().find(|n| **n == answer)) { + println!(); + return Ok(Some(name.clone())); + } + println!(" '{answer}' is not in the list. Enter a number or a profile name."); + } +} + +/// Ask for the name of a profile that does not exist yet. None if the user +/// leaves it blank. +fn prompt_new_profile_name(config: &Table) -> Result> { + let existing = std::iter::once(DEFAULT_PROFILE.to_string()) + .chain(named_profiles(config)) + .collect::>(); + println!("Existing profiles: {}.", existing.join(", ")); + loop { + let Some(name) = read_line("Name for the new profile: ")? else { + return Ok(None); + }; + if name == DEFAULT_PROFILE || config.contains_key(&name) { + println!( + " '{name}' is already in use. Enter another name, or run '{}' to edit it.", + format!("{BIN_NAME} --profile {name} config update").green() + ); + continue; + } + println!(); + return Ok(Some(name)); + } +} + +fn field_prompt(label: &str, current: Option<&str>) -> String { + match current { + Some(value) => format!(" {label} [{value}]: "), + None => format!(" {label}: "), + } +} + +/// Show only the last four characters of an API key. +pub(super) fn mask_key(key: &str) -> String { + let len = key.chars().count(); + if len <= 8 { + return "****".to_string(); + } + let tail = key.chars().skip(len - 4).collect::(); + format!("****{tail}") +} + +/// The names of the named profiles, sorted. +pub(super) fn named_profiles(config: &Table) -> Vec { + config + .iter() + .filter(|(_, value)| value.is_table()) + .map(|(name, _)| name.clone()) + .collect() +} + +/// One changed field, as display strings. An API key is masked. +#[derive(Debug, Eq, PartialEq)] +pub(super) struct Change { + field: &'static str, + old: String, + new: String, +} + +/// Apply `edits` to a copy of `config` and print the changed fields of +/// `profile`. None, after a "No changes" notice, when no field changed. +fn updated_config( + config: &Table, + profile: Option<&str>, + edits: ProfileFields, +) -> Result> { + let mut updated = config.clone(); + apply_profile_updates( + &mut updated, + profile.map(str::to_string), + edits.grpc_uri, + edits.rest_uri, + edits.api_key, + edits.app_uri, + )?; + let changes = profile_changes(config, &updated, profile); + if changes.is_empty() { + println!( + "No changes to '{}'. Nothing was written.", + profile_name(profile).yellow() + ); + return Ok(None); + } + + println!("Changes to '{}':", profile_name(profile).yellow()); + let old_width = changes.iter().map(|c| c.old.len()).max().unwrap_or(0); + for Change { field, old, new } in &changes { + println!( + " {field:<8} {old: {}", + new.as_str().green() + ); + } + Ok(Some(updated)) +} + +/// The fields of `profile` that differ between `before` and `after`. +pub(super) fn profile_changes(before: &Table, after: &Table, profile: Option<&str>) -> Vec { + let (before, after) = ( + ProfileFields::of(before, profile), + ProfileFields::of(after, profile), + ); + let show = |value: &Option, secret: bool| match value { + None => "(unset)".to_string(), + Some(v) if secret => mask_key(v), + Some(v) => v.clone(), }; + [ + ("grpc_uri", &before.grpc_uri, &after.grpc_uri, false), + ("rest_uri", &before.rest_uri, &after.rest_uri, false), + (API_KEY_KEY, &before.api_key, &after.api_key, true), + ("app_uri", &before.app_uri, &after.app_uri, false), + ] + .into_iter() + .filter(|(_, old, new, _)| old != new) + .map(|(field, old, new, secret)| Change { + field, + old: show(old, secret), + new: show(new, secret), + }) + .collect() +} - let config_toml = updated_config - .parse::() - .context("updated config is invalid TOML")?; - let app_uri_state = app_uri_state(&config_toml, configured_profile.as_deref())?; - update_config_file(updated_config)?; - - let target = configured_profile - .as_deref() - .unwrap_or("default") - .to_string(); - Output::new() - .line(format!( - "Successfully configured the '{}' profile.", - target.yellow() - )) - .print(); - print_app_uri_guidance(configured_profile.as_deref(), &app_uri_state); +fn save(path: &Path, config: &Table, profile: Option<&str>) -> Result<()> { + let app_uri_state = app_uri_state(config, profile)?; + write_config(path, config)?; + println!("Saved profile '{}'.", profile_name(profile).yellow()); + print_app_uri_guidance(profile, &app_uri_state); + Ok(()) +} + +/// Resolve `--profile default` to the top-level profile, unless the file has a +/// `[default]` table. +pub(super) fn resolve_profile(config: &Table, profile: Option) -> Option { + profile.filter(|name| name != DEFAULT_PROFILE || config.contains_key(name)) +} + +fn profile_name(profile: Option<&str>) -> &str { + profile.unwrap_or(DEFAULT_PROFILE) +} +/// The `--profile ` argument for a suggested command. Empty for the +/// default profile. +pub(super) fn profile_flag(profile: Option<&str>) -> String { + profile.map_or_else(String::new, |profile| format!("--profile {profile} ")) +} + +fn aborted() -> Result { + Output::new().line("Operation aborted.").print(); Ok(ExitCode::SUCCESS) } @@ -177,44 +485,11 @@ pub(super) fn get_config_file_path() -> Result { .ok_or(anyhow!("user config directory not found")) } -fn create_config_file() -> Result<(File, PathBuf)> { - let path = get_config_file_path()?; - - // Create the parent directories if they don't exist. - if let Some(parent_dir) = path.parent() { - create_dir_all(parent_dir).context("failed to create parent directories")?; - } - - let config_file = File::create_new(&path).context("failed to create config file")?; - - Ok((config_file, path)) -} - -fn get_updated_config( - profile: Option, - grpc_uri: Option, - rest_uri: Option, - api_key: Option, - app_uri: Option, -) -> Result { - let path = get_config_file_path()?; - - let contents = read_to_string(path).context("failed to read config file")?; - - let mut config_toml = contents +fn read_config(path: &Path) -> Result
{ + read_to_string(path) + .context("failed to read config file")? .parse::
() - .context("config file is invalid TOML")?; - - apply_profile_updates( - &mut config_toml, - profile, - grpc_uri, - rest_uri, - api_key, - app_uri, - )?; - - Ok(config_toml.to_string()) + .context("config file is invalid TOML") } fn apply_profile_updates( @@ -271,12 +546,7 @@ fn apply_profile_updates( } pub(super) fn inspect_app_uri(profile: Option<&str>) -> Result { - let path = get_config_file_path()?; - let contents = read_to_string(path).context("failed to read config file")?; - let config_toml = contents - .parse::
() - .context("config file is invalid TOML")?; - app_uri_state(&config_toml, profile) + app_uri_state(&read_config(&get_config_file_path()?)?, profile) } fn app_uri_state(config_toml: &Table, profile: Option<&str>) -> Result { @@ -310,7 +580,7 @@ fn profile_table<'a>(config_toml: &'a Table, profile: Option<&str>) -> Result<&' } fn print_app_uri_guidance(profile: Option<&str>, state: &AppUriState) { - let profile_flag = profile.map_or_else(String::new, |profile| format!("--profile {profile} ")); + let profile_flag = profile_flag(profile); match state { AppUriState::Configured(_) => {} AppUriState::MissingKnown(app_uri) => println!( @@ -333,28 +603,9 @@ fn print_app_uri_guidance(profile: Option<&str>, state: &AppUriState) { } } -fn update_config_file(updated: String) -> Result<()> { - let path = get_config_file_path()?; - - let mut config = OpenOptions::new() - .write(true) - .truncate(true) - .open(path) - .context("failed to open config file")?; - - write!(config, "{updated}").context("failed to update config file") -} - -fn is_update_empty(args: &ConfigUpdateArgs) -> bool { - let ConfigUpdateArgs { - grpc_uri, - rest_uri, - api_key, - app_uri, - .. - } = args; - grpc_uri.as_ref().is_none_or(|s| s.is_empty()) - && rest_uri.as_ref().is_none_or(|s| s.is_empty()) - && api_key.as_ref().is_none_or(|s| s.is_empty()) - && app_uri.as_ref().is_none_or(|s| s.is_empty()) +fn write_config(path: &Path, config: &Table) -> Result<()> { + if let Some(parent_dir) = path.parent() { + create_dir_all(parent_dir).context("failed to create parent directories")?; + } + write(path, config.to_string()).context("failed to write config file") } diff --git a/rust/crates/sift_cli/src/cmd/config/tests.rs b/rust/crates/sift_cli/src/cmd/config/tests.rs index 797885e1d2..7076fd4c2d 100644 --- a/rust/crates/sift_cli/src/cmd/config/tests.rs +++ b/rust/crates/sift_cli/src/cmd/config/tests.rs @@ -1,56 +1,3 @@ -mod test_is_update_empty { - use super::super::is_update_empty; - use crate::cli::ConfigUpdateArgs; - - fn args( - grpc_uri: Option<&str>, - rest_uri: Option<&str>, - api_key: Option<&str>, - app_uri: Option<&str>, - ) -> ConfigUpdateArgs { - ConfigUpdateArgs { - interactive: false, - grpc_uri: grpc_uri.map(String::from), - rest_uri: rest_uri.map(String::from), - api_key: api_key.map(String::from), - app_uri: app_uri.map(String::from), - } - } - - #[test] - fn no_flags_is_empty() { - assert!(is_update_empty(&args(None, None, None, None))); - } - - #[test] - fn all_empty_strings_is_empty() { - assert!(is_update_empty(&args( - Some(""), - Some(""), - Some(""), - Some("") - ))); - } - - #[test] - fn any_single_flag_is_not_empty() { - assert!(!is_update_empty(&args(Some("g"), None, None, None))); - assert!(!is_update_empty(&args(None, Some("r"), None, None))); - assert!(!is_update_empty(&args(None, None, Some("k"), None))); - assert!(!is_update_empty(&args(None, None, None, Some("a")))); - } - - #[test] - fn all_flags_set_is_not_empty() { - assert!(!is_update_empty(&args( - Some("g"), - Some("r"), - Some("k"), - Some("a") - ))); - } -} - mod app_uri { use super::super::{AppUriState, app_uri_state, apply_profile_updates}; use toml::{Table, Value}; @@ -249,3 +196,119 @@ mod api_key_key { assert_eq!(config[API_KEY_KEY_LEGACY].as_str(), Some("top")); } } + +mod profile_edits { + use super::super::{ + Change, apply_profile_updates, mask_key, named_profiles, profile_changes, resolve_profile, + }; + use toml::Table; + + fn config(input: &str) -> Table { + input.parse().unwrap() + } + + fn change(field: &'static str, old: &str, new: &str) -> Change { + Change { + field, + old: old.to_string(), + new: new.to_string(), + } + } + + #[test] + fn masks_all_but_the_last_four_characters() { + assert_eq!(mask_key("sift_abcdefgh1234"), "****1234"); + assert_eq!(mask_key("short"), "****"); + assert_eq!(mask_key("12345678"), "****"); + } + + #[test] + fn lists_named_profiles_sorted() { + let config = config( + r#" +rest_uri = "https://api.siftstack.com" + +[staging] +rest_uri = "https://api.staging.example.net" + +[prod] +rest_uri = "https://api.example.net" +"#, + ); + assert_eq!(named_profiles(&config), ["prod", "staging"]); + assert!(named_profiles(&Table::new()).is_empty()); + } + + #[test] + fn reports_only_changed_fields_with_the_key_masked() { + let before = config( + r#" +rest_uri = "https://api.example.net" +apikey = "sift_old_key_a91f" +app_uri = "https://sift.example.net" +"#, + ); + let mut after = before.clone(); + apply_profile_updates( + &mut after, + None, + Some("grpc.example.net:443".to_string()), + Some("https://api.example.net".to_string()), + Some("sift_new_key_7c02".to_string()), + None, + ) + .unwrap(); + + assert_eq!( + profile_changes(&before, &after, None), + [ + change("grpc_uri", "(unset)", "grpc.example.net:443"), + change("api_key", "****a91f", "****7c02"), + ] + ); + } + + #[test] + fn a_new_named_profile_starts_unset() { + let before = config("rest_uri = \"https://api.siftstack.com\"\n"); + let mut after = before.clone(); + apply_profile_updates( + &mut after, + Some("staging".to_string()), + None, + Some("https://api.staging.example.net".to_string()), + None, + None, + ) + .unwrap(); + + assert_eq!( + profile_changes(&before, &after, Some("staging")), + [change( + "rest_uri", + "(unset)", + "https://api.staging.example.net" + )] + ); + assert!(profile_changes(&before, &after, None).is_empty()); + } + + #[test] + fn default_profile_name_means_the_top_level_profile() { + let top_level = config("rest_uri = \"https://api.siftstack.com\"\n"); + assert_eq!( + resolve_profile(&top_level, Some("default".to_string())), + None + ); + assert_eq!( + resolve_profile(&top_level, Some("staging".to_string())).as_deref(), + Some("staging") + ); + + let table = config("[default]\nrest_uri = \"https://api.siftstack.com\"\n"); + assert_eq!( + resolve_profile(&table, Some("default".to_string())).as_deref(), + Some("default") + ); + } +} diff --git a/rust/crates/sift_cli/src/cmd/mod.rs b/rust/crates/sift_cli/src/cmd/mod.rs index c11e8b055f..48428fe89c 100644 --- a/rust/crates/sift_cli/src/cmd/mod.rs +++ b/rust/crates/sift_cli/src/cmd/mod.rs @@ -71,6 +71,7 @@ impl Context { .parse::
() .context("failed to parse config file")?; + let profile = config::resolve_profile(&config_toml, profile); let target_profile = match profile.as_deref() { Some(prof) => { let Some(Value::Table(target)) = config_toml.get(prof) else { @@ -147,7 +148,7 @@ impl Context { fn app_uri_guidance(profile: Option<&str>, rest_uri: &str) -> String { let profile_name = profile.unwrap_or("default"); - let profile_flag = profile.map_or_else(String::new, |profile| format!("--profile {profile} ")); + let profile_flag = config::profile_flag(profile); match infer_app_uri(rest_uri) { Some(app_uri) => format!( "The Sift MCP server cannot start because profile '{profile_name}' has no usable \ @@ -207,6 +208,19 @@ apikey = "mission-key" assert_eq!(mission.api_key, "mission-key"); } + #[test] + fn default_profile_name_selects_the_top_level_profile() { + let default = context(COMPLETE_CONFIG, Some("default")).unwrap(); + assert_eq!(default.rest_uri, "https://api.siftstack.com"); + + let config = format!( + "{COMPLETE_CONFIG}\n[default]\ngrpc_uri = \"https://grpc.table.example.net\"\n\ + rest_uri = \"https://api.table.example.net\"\napi_key = \"table-key\"\n" + ); + let table = context(&config, Some("default")).unwrap(); + assert_eq!(table.rest_uri, "https://api.table.example.net"); + } + #[test] fn rejects_each_missing_required_connection_field() { for (field, config) in [ diff --git a/rust/crates/sift_cli/src/main.rs b/rust/crates/sift_cli/src/main.rs index e091d686a2..289f52504c 100644 --- a/rust/crates/sift_cli/src/main.rs +++ b/rust/crates/sift_cli/src/main.rs @@ -68,7 +68,7 @@ fn run(clargs: cli::Args) -> Result { match cmd { Cmd::Config(cmd) => match cmd { ConfigCmd::Show => return cmd::config::show(), - ConfigCmd::Create => return cmd::config::create(), + ConfigCmd::Create => return cmd::config::create(clargs.profile), ConfigCmd::Where => return cmd::config::config_where(), ConfigCmd::Update(args) => return cmd::config::update(clargs.profile, args), }, diff --git a/rust/crates/sift_cli/src/util/tty.rs b/rust/crates/sift_cli/src/util/tty.rs index d877918500..f54e792169 100644 --- a/rust/crates/sift_cli/src/util/tty.rs +++ b/rust/crates/sift_cli/src/util/tty.rs @@ -1,62 +1,93 @@ use std::{ fmt::Display, - io::{self, IsTerminal, Write}, + io::{self, IsTerminal, Read, Write}, }; -use anyhow::Result; -use crossterm::style::Stylize; +use anyhow::{Result, bail}; +use crossterm::{style::Stylize, terminal}; -#[derive(Default)] -pub struct PromptUser { - header: Option, - prompts: Vec, +/// Print `prompt` and read one line. None for a blank line or end of input. +pub fn read_line(prompt: &str) -> Result> { + write_prompt(prompt)?; + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + Ok(non_blank(&buf)) } -impl PromptUser { - pub fn new() -> Self { - Self::default() - } - - pub fn header>(&mut self, header: S) -> &mut Self { - self.header.replace(header.into()); - self +/// Like [`read_line`], but the terminal does not echo the input. +/// +/// Errors when the user presses Ctrl-C, since raw mode stops the terminal from +/// sending SIGINT. +pub fn read_hidden(prompt: &str) -> Result> { + struct RawMode; + impl Drop for RawMode { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + } } - pub fn prompt>(&mut self, prompt: S) -> &mut Self { - self.prompts.push(prompt.into()); - self - } + write_prompt(prompt)?; + let raw_mode = { + terminal::enable_raw_mode()?; + RawMode + }; + // Read through the same buffered stdin as `read_line`, so input typed or + // pasted ahead of this prompt is not skipped. + let secret = read_secret(&mut io::stdin().lock())?; + drop(raw_mode); + println!(); + + let Some(secret) = secret else { + bail!("Operation aborted."); + }; + Ok(non_blank(&secret)) +} - pub fn run(&self) -> Result>> { - if self.prompts.is_empty() { - return Ok(Vec::new()); +/// Read raw-mode input up to Enter or end of input. None on Ctrl-C. +/// +/// Handles Backspace and Ctrl-U. Drops other control keys and escape +/// sequences, such as arrow keys. +fn read_secret(input: &mut impl Read) -> io::Result> { + let mut next = || -> io::Result> { + let mut byte = [0]; + Ok((input.read(&mut byte)? == 1).then_some(byte[0])) + }; + let mut secret = Vec::new(); + while let Some(byte) = next()? { + match byte { + b'\r' | b'\n' => break, + 0x03 => return Ok(None), + 0x15 => secret.clear(), + 0x08 | 0x7f => { + // Remove one UTF-8 character: its continuation bytes, then its lead byte. + while secret.pop().is_some_and(|b| b & 0xc0 == 0x80) {} + } + 0x1b => { + if next()? == Some(b'[') { + while next()?.is_some_and(|b| !(0x40..=0x7e).contains(&b)) {} + } + } + b if b < 0x20 => {} + b => secret.push(b), } - let mut user_inputs = Vec::with_capacity(self.prompts.len()); + } + Ok(Some(String::from_utf8_lossy(&secret).into_owned())) +} - let stdin = io::stdin(); - let mut stdout = io::stdout(); +fn write_prompt(prompt: &str) -> Result<()> { + let mut stdout = io::stdout(); + write!(stdout, "{prompt}")?; + Ok(stdout.flush()?) +} - if let Some(header) = self.header.as_ref() { - writeln!(stdout, "{header}")?; - } +fn non_blank(input: &str) -> Option { + let input = input.trim(); + (!input.is_empty()).then(|| input.to_string()) +} - for prompt in &self.prompts { - write!(stdout, "{prompt}")?; - stdout.flush()?; - - let user_input = { - let mut buf = String::new(); - stdin.read_line(&mut buf)?; - buf.trim().to_string() - }; - if user_input.is_empty() { - user_inputs.push(None); - } else { - user_inputs.push(Some(user_input)); - } - } - Ok(user_inputs) - } +/// Whether both stdin and stdout are a terminal, so the CLI can prompt. +pub fn is_interactive() -> bool { + io::stdin().is_terminal() && io::stdout().is_terminal() } pub fn stdout_is_tty() -> bool { @@ -119,7 +150,32 @@ impl Output { #[cfg(test)] mod tests { - use super::hyperlink; + use super::{hyperlink, read_secret}; + + fn secret(input: &[u8]) -> Option { + read_secret(&mut &input[..]).unwrap() + } + + #[test] + fn read_secret_stops_at_enter_and_keeps_the_rest_unread() { + let mut input = &b"key1\rnext line\n"[..]; + assert_eq!(read_secret(&mut input).unwrap().as_deref(), Some("key1")); + assert_eq!(input, b"next line\n"); + } + + #[test] + fn read_secret_handles_editing_keys() { + assert_eq!(secret(b"abcd\x7f\x7fx\r").as_deref(), Some("abx")); + assert_eq!(secret(b"wrong\x15right\r").as_deref(), Some("right")); + assert_eq!(secret(b"ke\x17y\r").as_deref(), Some("key")); + assert_eq!(secret(b"k\x1b[Dey\r").as_deref(), Some("key")); + assert_eq!(secret("k\u{e9}\x7fey\r".as_bytes()).as_deref(), Some("key")); + } + + #[test] + fn read_secret_aborts_on_ctrl_c() { + assert_eq!(secret(b"key\x03"), None); + } #[test] fn hyperlink_wraps_text_in_an_osc_8_sequence() { From 77e76dc6906bf82b5669cab8b881bbb67cef94b1 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Fri, 23 Oct 2026 15:11:58 -0700 Subject: [PATCH 2/2] rust(feat): show current and new values on separate lines in config editor Each field now shows its current value on its own line above a `new:` prompt, and the header says to type a new value or leave it blank to keep the current one. Suggested app URLs say they are used when left blank. --- rust/crates/sift_cli/CHANGELOG.md | 5 +- .../docs/src/getting-started/configuration.md | 24 ++++- rust/crates/sift_cli/src/cmd/config/mod.rs | 95 +++++++++++++------ 3 files changed, 87 insertions(+), 37 deletions(-) diff --git a/rust/crates/sift_cli/CHANGELOG.md b/rust/crates/sift_cli/CHANGELOG.md index 60ff94b86b..86ba807386 100644 --- a/rust/crates/sift_cli/CHANGELOG.md +++ b/rust/crates/sift_cli/CHANGELOG.md @@ -19,8 +19,9 @@ This project adheres to [Semantic Versioning](http://semver.org/). creates new profiles: `sift-cli --profile staging config create`, or, without `--profile`, it asks for a name when the default profile is already set up. Outside a terminal, it still creates only an empty file. -- The interactive editor lists named profiles to pick from and shows each - current value in brackets. Press Enter to keep a value. The API key is not +- The interactive editor lists named profiles to pick from and shows the + current value of each field. Type a new value to change a field, or leave it + blank to keep the current value. The API key is not shown while you type it. Before it saves, the editor lists only the changed fields, with the key masked to its last four characters. It writes nothing when no field changed. The confirmation now defaults to yes. diff --git a/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md b/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md index 0c10befa74..da91f697d9 100644 --- a/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md +++ b/rust/crates/sift_cli/assets/docs/src/getting-started/configuration.md @@ -53,12 +53,15 @@ sift-cli config create ```text Creating a config file at '~/Library/Application Support/sift.toml'. -Editing profile 'default'. Press Enter to keep the value in brackets. +Editing profile 'default'. Enter a value for each field. gRPC URL: https://grpc-api.siftstack.com REST URL: https://api.siftstack.com API key: - App URL [https://app.siftstack.com]: + + App URL + suggested: https://app.siftstack.com (leave blank to use it) + new: Changes to 'default': grpc_uri (unset) -> https://grpc-api.siftstack.com @@ -82,9 +85,20 @@ sift-cli config update ``` If the file has named profiles, the command first asks which profile to edit. -Use `--profile ` to skip that question. Each prompt shows the current value -in brackets. Press Enter to keep it. The command lists only the fields you -changed. If nothing changed, it writes nothing. +Use `--profile ` to skip that question. Each field shows its current +value: + +```text +Editing profile 'staging'. +To change a field, type a new value and press Enter. To keep the current value, leave it blank and press Enter. + + gRPC URL + current: https://grpc-api.staging.example.net + new: +``` + +The command lists only the fields you changed. If nothing changed, it writes +nothing. `config update` edits existing profiles only. If the config file or the named profile does not exist, the command stops and tells you to run `config create`. diff --git a/rust/crates/sift_cli/src/cmd/config/mod.rs b/rust/crates/sift_cli/src/cmd/config/mod.rs index d771478f6e..2e2cf48f0d 100644 --- a/rust/crates/sift_cli/src/cmd/config/mod.rs +++ b/rust/crates/sift_cli/src/cmd/config/mod.rs @@ -225,35 +225,48 @@ fn edit_profile(path: &Path, config: Table, profile: Option) -> Result prompt_field("App URL", Shown::Current(current), false)?, + (None, Some(inferred)) => prompt_field("App URL", Shown::Suggested(inferred), false)? + .or_else(|| Some(inferred.to_string())), + (None, None) => { + println!( + " Open your Sift web app and copy its URL origin. Keep the scheme and host \ + (for example, https://sift.example.net)." + ); + prompt_field("App URL", Shown::Nothing, false)? + } + }; println!(); let edits = ProfileFields { @@ -335,11 +348,33 @@ fn prompt_new_profile_name(config: &Table) -> Result> { } } -fn field_prompt(label: &str, current: Option<&str>) -> String { - match current { - Some(value) => format!(" {label} [{value}]: "), - None => format!(" {label}: "), +/// The value a field prompt shows above the input. +enum Shown<'a> { + Nothing, + /// Enter keeps it. + Current(&'a str), + /// Enter uses it. + Suggested(&'a str), +} + +impl<'a> Shown<'a> { + fn current(value: Option<&'a str>) -> Self { + value.map_or(Self::Nothing, Self::Current) + } +} + +/// Prompt for one field. None when the user presses Enter without typing. +fn prompt_field(label: &str, shown: Shown, hidden: bool) -> Result> { + let read = if hidden { read_hidden } else { read_line }; + match shown { + Shown::Nothing => return read(&format!(" {label}: ")), + Shown::Current(value) => println!("\n {}\n current: {value}", label.bold()), + Shown::Suggested(value) => println!( + "\n {}\n suggested: {value} (leave blank to use it)", + label.bold() + ), } + read(" new: ") } /// Show only the last four characters of an API key.