diff --git a/.gitignore b/.gitignore index 784d68be..3d4fde79 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ __pycache__ # Claude Code local runtime state (per-machine, not for commit) **/.claude/scheduled_tasks.lock **/.claude/scheduled_tasks.json +.cursor/ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cba5bb02..51f7172b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -644,6 +644,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -903,6 +915,18 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -1011,6 +1035,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "endi" version = "1.1.1" @@ -1348,12 +1378,15 @@ dependencies = [ "chrono", "clap", "cli-engine", + "console", + "dialoguer", "dirs", "domains-client", "fancy-regex", "flate2", "globset", "httpmock", + "indicatif", "iso_currency", "open", "oxc_allocator", @@ -1783,6 +1816,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "inout" version = "0.1.4" @@ -2704,6 +2750,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "postcard" version = "1.1.3" @@ -3639,6 +3691,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -4342,6 +4400,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 4978212a..b1dba94c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -51,6 +51,9 @@ url = "2" uuid = { version = "1", features = ["v4"] } zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2"] } iso_currency = "0.5.3" +dialoguer = "0.12.0" +console = "0.16.4" +indicatif = "0.18.6" [dev-dependencies] httpmock = "0.8" diff --git a/rust/src/config/settings_form.rs b/rust/src/config/settings_form.rs index a8f027e9..879238ac 100644 --- a/rust/src/config/settings_form.rs +++ b/rust/src/config/settings_form.rs @@ -366,10 +366,10 @@ fn validate_field(field: &SettingsFormV1Field, errors: &mut Vec, path: & } match field { SettingsFormV1Field::Select { options, .. } - | SettingsFormV1Field::MultiSelect { options, .. } => { - if options.is_empty() { - errors.push(format!("{path}.options must contain at least one option")); - } + | SettingsFormV1Field::MultiSelect { options, .. } + if options.is_empty() => + { + errors.push(format!("{path}.options must contain at least one option")); } SettingsFormV1Field::ListGroup { item, .. } => { if !is_field_name(&item.id_field) { diff --git a/rust/src/domain/agreements.rs b/rust/src/domain/agreements.rs index a3097580..d162a559 100644 --- a/rust/src/domain/agreements.rs +++ b/rust/src/domain/agreements.rs @@ -1,16 +1,25 @@ //! `gddy domain agreements` — the legal agreements a TLD requires (v1). +// Interactive recovery prompts write user-facing feedback to stderr. +#![allow(clippy::print_stderr)] + use cli_engine::{ - CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, Tier, + CliCoreError, CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, TableColumn, + Tier, }; use serde_json::json; use domains_client::types; -use super::common::{api_error, comma_joined, make_client}; +use super::common::{ + comma_joined, format_api_error, make_client, prompt_validated_tld, recoverable_tld_api_error, + resolve_tlds, +}; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; +const TLD_PROMPT: &str = "TLD whose agreements to retrieve, e.g. com (without a leading dot)"; + #[derive(Debug, Clone, clap::Args)] struct AgreementsArgs { /// TLD whose agreements to retrieve, e.g. com (repeatable). @@ -44,46 +53,82 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_json_schema::() .with_scopes(&[DOMAINS_READ]), |ctx, args: AgreementsArgs| async move { - let tlds = args.tld; let privacy = args.privacy; let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; - let resp = match client - .agreements() - .tlds(comma_joined(tlds)) - .v1_privacy(privacy) - .send() - .await - { - Ok(r) => r, - Err(e) => return Err(api_error("retrieving legal agreements", debug, e).await), - }; - let agreements: Vec = resp - .into_inner() - .into_iter() - .map(|a| { - json!({ - "agreementKey": a.agreement_key, - "title": a.title, - "url": a.url, - "content": a.content, + let mut tlds = resolve_tlds(&ctx, args.tld, TLD_PROMPT)?; + + loop { + let resp = match client + .agreements() + .tlds(comma_joined(tlds.clone())) + .v1_privacy(privacy) + .send() + .await + { + Ok(r) => r, + Err(domains_client::Error::UnexpectedResponse(resp)) + if ctx.is_interactive() => + { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_tld_api_error(status, &body) { + eprintln!(" {hint}"); + tlds = vec![prompt_validated_tld(TLD_PROMPT)?]; + continue; + } + return Err(CliCoreError::message(format_api_error( + "retrieving legal agreements", + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => { + return Err(super::common::api_error( + "retrieving legal agreements", + debug, + e, + ) + .await); + } + }; + + let agreements: Vec = resp + .into_inner() + .into_iter() + .map(|a| { + json!({ + "agreementKey": a.agreement_key, + "title": a.title, + "url": a.url, + "content": a.content, + }) }) - }) - .collect(); - Ok( - CommandResult::new(json!(agreements)).with_next_actions(vec![ - next_action( - "domain quote ", - "Price a registration and see the agreements for a specific domain", - ) - .with_param("domain", NextActionParam::required()), - next_action( - "domain purchase --quote-token --agree --confirm", - "Register once you have a quote", - ) - .with_param("quote-token", NextActionParam::required()), - ]), - ) + .collect(); + return Ok( + CommandResult::new(json!(agreements)).with_next_actions(vec![ + next_action( + "domain quote ", + "Price a registration and see the agreements for a specific domain", + ) + .with_param("domain", NextActionParam::required()), + next_action( + "domain purchase --quote-token --agree --confirm", + "Register once you have a quote", + ) + .with_param("quote-token", NextActionParam::required()), + ]), + ); + } }, ) } diff --git a/rust/src/domain/available.rs b/rust/src/domain/available.rs index 630ce45e..e2d8c55e 100644 --- a/rust/src/domain/available.rs +++ b/rust/src/domain/available.rs @@ -7,7 +7,10 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, format_money, make_client, period_label, validate_domain_name}; +use super::common::{ + api_error, format_money, make_client, period_label, period_price_map, periods_from_prices, + resolve_domain_name, term_for_period, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -115,7 +118,11 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_view(view_columns()) .with_scopes(&[DOMAINS_READ]), |ctx, args: AvailableArgs| async move { - let domain = validate_domain_name(&args.domain)?; + let domain = resolve_domain_name( + &ctx, + &args.domain, + "Domain name to check (e.g. example.com)", + )?; // --check-type fast|full → v3 optimizeFor SPEED|ACCURACY. let optimize_for = match args.check_type.as_deref() { Some("fast") => Some(types::OptimizationTarget::Speed), @@ -159,14 +166,33 @@ pub(super) fn command() -> RuntimeCommandSpec { let cmd = CommandResult::new(result); if body.available.unwrap_or(false) { + // If interactive, offer to continue directly into registration. + let price_1yr = term_for_period(&prices, 1) + .and_then(|t| t.price.as_ref()) + .and_then(format_money); + let currency_str = shared_currency(&prices); + match super::register::bridge::offer_registration_from_available( + &ctx, + &resolved_domain, + price_1yr, + currency_str, + periods_from_prices(&prices), + period_price_map(&prices), + ) + .await? + { + super::register::BridgeHandoff::Replace(wizard_result) => { + return Ok(wizard_result); + } + super::register::BridgeHandoff::ShowHostOutput => {} + } + Ok(cmd.with_next_actions(vec![ next_action("domain quote ", "Price a registration") .with_param("domain", NextActionParam::value(resolved_domain)), ])) } else { Ok(cmd.with_next_actions(vec![ - // `domain suggest` accepts a seed domain, so the domain just - // checked as taken is a valid query to copy/paste directly. next_action("domain suggest ", "Find alternatives") .with_param("query", NextActionParam::value(resolved_domain)), ])) diff --git a/rust/src/domain/common.rs b/rust/src/domain/common.rs deleted file mode 100644 index 6b2db862..00000000 --- a/rust/src/domain/common.rs +++ /dev/null @@ -1,786 +0,0 @@ -//! Shared helpers for the `domain` command group: the authenticated Domains API -//! client, money formatting, argument helpers, and API-error rendering. Each -//! `gddy domain` subcommand lives in its own sibling module and draws from here. - -use cli_engine::{CliCoreError, CommandContext, Credential, Result}; - -use crate::environments; - -use domains_client::types; - -const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); - -/// Bridges domains-client's request/response observations into cli-engine's -/// `--debug transport` logger. domains-client defines the `TransportObserver` -/// extension point itself and has no compile-time dependency on cli-engine — -/// this crate *pushes* the logging behavior in, rather than domains-client -/// *pulling* it from the engine. -struct CliEngineTransportObserver; - -impl domains_client::TransportObserver for CliEngineTransportObserver { - fn on_request(&self, request: &reqwest::Request) { - cli_engine::transport::debug_log_reqwest_request(request); - } - - fn on_response(&self, status: reqwest::StatusCode, headers: &reqwest::header::HeaderMap) { - cli_engine::transport::debug_log_reqwest_response(status, headers, &[]); - } -} - -static TRANSPORT_OBSERVER_INIT: std::sync::Once = std::sync::Once::new(); - -fn ensure_transport_observer_registered() { - TRANSPORT_OBSERVER_INIT.call_once(|| { - domains_client::set_transport_observer(Some(std::sync::Arc::new( - CliEngineTransportObserver, - ))); - }); -} - -/// The ISO-4217 minor-unit exponent for a currency — how many implied decimal -/// places a [`types::SimpleMoney`] `value` carries (the v3 spec defers money -/// formatting to ISO 4217). Sourced from the `iso_currency` crate's maintained -/// ISO 4217 dataset rather than a hand table, so it stays complete and correct -/// (JPY → 0, USD → 2, KWD → 3, CLF → 4, …). -/// -/// Falls back to 2 for an unrecognized code and for the codes ISO marks with no -/// minor unit (precious metals `XAU`/`XAG`, the IMF SDR `XDR`, `XXX`, test codes) -/// — none of which are spendable currencies that could be a domain price. -fn currency_decimals(code: &str) -> u32 { - iso_currency::Currency::from_code(&code.to_ascii_uppercase()) - .and_then(|c| c.exponent()) - .map_or(2, u32::from) -} - -/// Render a [`types::SimpleMoney`] as a decimal string. v3 money `value`s are in -/// ISO-4217 minor units for the currency (e.g. USD `1199` → `"11.99"`, JPY `1500` -/// → `"1500"`, BHD `1234` → `"1.234"`), NOT the micro-units v1 used. Truncates -/// toward zero (registry prices are whole minor units in practice); the sign is -/// explicit and `unsigned_abs` avoids `i64::MIN` overflow. `None` when the amount -/// is absent. Missing currency defaults to 2 decimals. -pub(super) fn format_money(money: &types::SimpleMoney) -> Option { - let value = money.value?; - let code = money - .currency_code - .as_ref() - .map(|c| c.as_str()) - .unwrap_or(""); - let decimals = currency_decimals(code); - let sign = if value < 0 { "-" } else { "" }; - let abs = value.unsigned_abs(); - if decimals == 0 { - return Some(format!("{sign}{abs}")); - } - let scale = 10u64.pow(decimals); - Some(format!( - "{sign}{}.{:0width$}", - abs / scale, - abs % scale, - width = decimals as usize - )) -} - -/// The entry for a specific year-term period (1, 2, …), or `None` when that term -/// isn't in the list. Used by `suggest` to flatten multiple terms into scalar -/// per-period fields (e.g. `price1Year`, `price2Year`). -pub(super) fn term_for_period( - prices: &[types::TermPrice], - period: u64, -) -> Option<&types::TermPrice> { - let period = std::num::NonZeroU64::new(period)?; - prices.iter().find(|p| p.period == Some(period)) -} - -/// A registration length with its unit spelled out ("1 year", "2 years") — a -/// bare number reads ambiguously in a table, so `quote`/`available` show this -/// alongside the numeric `period` field (which stays a plain number for -/// scripting against `--output json`). -pub(super) fn period_label(period: u64) -> String { - if period == 1 { - "1 year".to_owned() - } else { - format!("{period} years") - } -} - -/// Validate that `raw` (trimmed) is syntactically a valid domain name. Rejects -/// the shapes from DEVEX-885's bug report — embedded whitespace, null bytes, a -/// "protocol://" prefix, a "/path" suffix — via `url::Host::parse`'s WHATWG -/// forbidden-host-code-point + IDNA checks, then layers RFC 1035/1123 shape -/// rules a real domain always satisfies: not a bare IP literal, >=2 -/// dot-separated LDH labels, each 1-63 bytes, total <=253 bytes, and a TLD -/// that isn't all-numeric (ICANN disallows those). Returns the original -/// trimmed input — not `Host::parse`'s ASCII/punycode form — so an -/// already-working Unicode domain's wire format is unchanged. -pub(super) fn validate_domain_name(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(CliCoreError::message("domain name is required")); - } - // `url::Host::parse`'s error `Display` is a single generic string - // ("invalid international domain name") regardless of what's actually - // wrong (a space, a "://" prefix, a "/path" suffix, ...), so it isn't - // worth including — it would just read as a confusing non-sequitur. - let host = url::Host::parse(trimmed) - .map_err(|_| CliCoreError::message(format!("{trimmed:?} is not a valid domain name")))?; - let url::Host::Domain(ascii) = host else { - return Err(CliCoreError::message(format!( - "{trimmed:?} is an IP address, not a domain name" - ))); - }; - let labels: Vec<&str> = ascii.split('.').collect(); - let shape_ok = labels.len() >= 2 - && ascii.len() <= 253 - && labels.iter().all(|l| is_ldh_label(l)) - && !labels - .last() - .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())); - if !shape_ok { - return Err(CliCoreError::message(format!( - "{trimmed:?} doesn't look like a valid domain name (expected something like example.com)" - ))); - } - Ok(trimmed.to_owned()) -} - -/// Validate every value of a repeatable `--nameserver`-style flag as a -/// domain-shaped hostname, wrapping [`validate_domain_name`]'s generic error -/// with the flag's own name — used by both `quote` (the registration -/// profile's nameservers) and `nameservers set` (the target domain's -/// nameservers) so a bad host isn't reported as if it were some other -/// (already-valid) domain argument. -pub(super) fn validate_nameserver_hosts(raw: Vec) -> Result> { - raw.into_iter() - .map(|h| { - validate_domain_name(&h).map_err(|e| CliCoreError::message(format!("--nameserver {e}"))) - }) - .collect() -} - -/// A single RFC 1035/1123 "LDH label": 1-63 bytes, alphanumeric, interior -/// hyphens only (not leading/trailing). -fn is_ldh_label(label: &str) -> bool { - let bytes = label.as_bytes(); - !bytes.is_empty() - && bytes.len() <= 63 - && bytes[0] != b'-' - && bytes[bytes.len() - 1] != b'-' - && bytes - .iter() - .all(|b| b.is_ascii_alphanumeric() || *b == b'-') -} - -/// Whether an async domain-operation status is terminal (no further polling). -/// Only `COMPLETED`/`FAILED` are terminal; every other status (e.g. `SUBMITTED`, -/// `PENDING`, `CONFIRMED`, `EXECUTING`) is treated as still in progress. -pub(super) fn is_terminal_status(status: &str) -> bool { - matches!(status, "COMPLETED" | "FAILED") -} - -/// Renders a `FAILED` operation's `error` payload (name/message) for the -/// user-facing error, e.g. `" (DOMAIN_UNAVAILABLE: the domain is already -/// registered)"`. Empty when the operation carried no error detail (e.g. it -/// failed before an operation with a populated `error` was ever polled). -pub(super) fn format_operation_error(error: Option<&types::Error>) -> String { - let Some(error) = error else { - return String::new(); - }; - match (error.name.as_deref(), error.message.as_deref()) { - (Some(name), Some(message)) => format!(" ({name}: {message})"), - (Some(name), None) => format!(" ({name})"), - (None, Some(message)) => format!(" ({message})"), - (None, None) => String::new(), - } -} - -/// Build a Domains API client for the active environment, authenticating with -/// the resolved OAuth bearer token. -pub(crate) async fn make_client(ctx: &CommandContext) -> Result { - let cred = ctx.credential().await?; - make_client_with_cred(&ctx.middleware.env, &cred) -} - -/// Build the Domains API client from an already-resolved credential, so callers -/// that need the credential themselves (e.g. `purchase`, for the consent -/// principal) resolve it once and reuse the same token for the requests. -pub(crate) fn make_client_with_cred( - env: &str, - cred: &Credential, -) -> Result { - ensure_transport_observer_registered(); - let config = environments::resolve(env)?; - let authorization = format!("Bearer {}", cred.token); - let request_id = uuid::Uuid::new_v4().to_string(); - domains_client::client_with_auth( - &config.domains_api_url, - &authorization, - USER_AGENT, - &request_id, - ) - .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) -} - -/// Collapse a repeatable CLI flag's values into the single query-string value -/// some domains-client endpoints require (e.g. `tlds`, whose OpenAPI param is -/// `style: form, explode: false` — one comma-joined value). progenitor's -/// generated setters always seq-serialize a `Vec` argument as repeated -/// `key=value` pairs regardless of the spec's `explode` setting, so passing -/// multiple `--tlds` occurrences straight through sends `tlds=com&tlds=net` -/// and the API rejects it (`400 MISMATCH_FORMAT`, DEVEX-882). Joining into a -/// single element before calling the setter produces the one pair the API -/// expects. `[]` stays `[]` so callers can still gate on "no filter given". -pub(crate) fn comma_joined(values: Vec) -> Vec { - if values.len() <= 1 { - values - } else { - vec![values.join(",")] - } -} - -/// Turn a domains-client error into a `CliCoreError`, reading the response body -/// for unexpected (non-2xx) responses so the API's actual message isn't lost -/// (progenitor's `Display` prints only the status). Async because reading the -/// body is async. -pub(crate) async fn api_error( - action: &str, - debug: bool, - err: domains_client::Error<()>, -) -> CliCoreError { - match err { - domains_client::Error::UnexpectedResponse(resp) => { - let status = resp.status(); - let request_id = resp - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - let body = resp.text().await.unwrap_or_default(); - CliCoreError::message(format_api_error( - action, - status.as_u16(), - &status.to_string(), - &body, - request_id.as_deref(), - debug, - )) - } - other => CliCoreError::message(format!("{action} failed: {other}")), - } -} - -/// Build the user-facing message for an unexpected API response. Pure so the -/// HTTP 402 → payment-method guidance and the `--debug` request-id line are -/// unit-testable. -pub(crate) fn format_api_error( - action: &str, - status: u16, - status_display: &str, - body: &str, - request_id: Option<&str>, - debug: bool, -) -> String { - let body = body.trim(); - let friendly = friendly_field_errors(body); - let mut msg = match &friendly { - Some(detail) => format!("{action} failed (HTTP {status_display}):\n{detail}"), - None if body.is_empty() => format!("{action} failed (HTTP {status_display})"), - None => format!("{action} failed (HTTP {status_display}): {body}"), - }; - if status == 402 { - msg.push_str( - "\n\nThis usually means your account has no usable payment method. Add one with \ - `gddy payment-methods add` (a credit card or Good-as-Gold balance is required for domain \ - purchases), then try again.", - ); - } - if debug { - if friendly.is_some() && !body.is_empty() { - msg.push_str(&format!("\n\nResponse body: {body}")); - } - if let Some(id) = request_id.map(str::trim).filter(|s| !s.is_empty()) { - msg.push_str(&format!("\n\nRequest ID: {id}")); - } - } - msg -} - -/// A Domains API validation error body. Tolerates the v1 field-level shape -/// (`{"fields":[...]}` or `{"error":{"fields":[...]}}`) and the v3 top-level -/// shape (`{"name","message","correlationId","details":[{"issue","description"}]}`). -#[derive(serde::Deserialize)] -struct ApiErrorBody { - #[serde(default)] - fields: Vec, - #[serde(default)] - error: Option, - #[serde(default)] - details: Vec, -} - -#[derive(serde::Deserialize)] -struct ApiErrorEnvelope { - #[serde(default)] - fields: Vec, -} - -#[derive(serde::Deserialize)] -struct ApiFieldError { - #[serde(default)] - code: String, - #[serde(default)] - path: String, -} - -#[derive(serde::Deserialize)] -struct ApiDetailError { - #[serde(default)] - description: String, - #[serde(default)] - issue: String, -} - -impl ApiDetailError { - /// The human-readable `description` when present; the v3 spec documents it - /// as unstable ("MAY change... MUST NOT depend on this value") and often - /// absent, so fall back to the stable `issue` code rather than dropping the - /// detail entirely. - fn render(&self) -> Option<&str> { - if !self.description.is_empty() { - Some(self.description.as_str()) - } else if !self.issue.is_empty() { - Some(self.issue.as_str()) - } else { - None - } - } -} - -/// Render a structured validation error as a plain-English bullet list, or `None` -/// when `body` isn't a field-level validation error. -fn friendly_field_errors(body: &str) -> Option { - let parsed = serde_json::from_str::(body).ok()?; - let fields = if !parsed.fields.is_empty() { - parsed.fields - } else { - parsed.error.map(|e| e.fields).unwrap_or_default() - }; - if !fields.is_empty() { - let lines: Vec = fields - .iter() - .map(|f| format!(" • {}", describe_field_error(f))) - .collect(); - return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); - } - if !parsed.details.is_empty() { - let lines: Vec = parsed - .details - .iter() - .filter_map(ApiDetailError::render) - .map(|text| format!(" • {text}")) - .collect(); - if !lines.is_empty() { - return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); - } - } - None -} - -fn describe_field_error(f: &ApiFieldError) -> String { - let name = friendly_field_name(&f.path); - let problem = match f.code.as_str() { - "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT" => "is too short", - "LENGTH_OVER" | "MAX_LENGTH" | "TOO_LONG" => "is too long", - "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN" => "is not in the required format", - "REQUIRED" | "MISSING" | "MISSING_REQUIRED_FIELD" => "is required", - _ => "is invalid", - }; - match field_hint(&f.path, &f.code) { - Some(hint) => format!("{name} {problem} ({hint})"), - None => format!("{name} {problem}"), - } -} - -fn field_hint(path: &str, code: &str) -> Option<&'static str> { - let leaf = path.rsplit('.').next().unwrap_or(path); - match (leaf, code) { - ("phone", _) | ("nationalNumber", _) => Some("expected a format like +1.4805551212"), - ("postalCode", "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN") => { - Some("some countries require a specific format, e.g. UK SW1A 2AA") - } - ("line2", "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT") => Some("leave it blank to omit it"), - _ => None, - } -} - -/// Turn an API field path into a human phrase. Falls back to a space-joined, -/// camelCase-split rendering for unrecognized paths. -fn friendly_field_name(path: &str) -> String { - let trimmed = path - .strip_prefix("body.") - .or_else(|| path.strip_prefix("consent.")) - .unwrap_or(path); - let segments: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect(); - if let ["contacts", role, rest @ ..] = segments.as_slice() { - let mut parts = vec![humanize_segment(role)]; - parts.extend(rest.iter().map(|s| humanize_segment(s))); - return parts.join(" "); - } - if segments.is_empty() { - return path.to_string(); - } - segments - .iter() - .map(|s| humanize_segment(s)) - .collect::>() - .join(" ") -} - -fn humanize_segment(seg: &str) -> String { - match seg { - "line1" => "line 1".to_string(), - "line2" => "line 2".to_string(), - "postalCode" => "postal code".to_string(), - "countryCode" => "country".to_string(), - "firstName" => "first name".to_string(), - "lastName" => "last name".to_string(), - "nationalNumber" => "phone".to_string(), - "agreedAt" => "agreed-at timestamp".to_string(), - other => split_camel_case(other), - } -} - -fn split_camel_case(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 4); - for (i, ch) in s.char_indices() { - if ch.is_ascii_uppercase() { - if i != 0 { - out.push(' '); - } - out.push(ch.to_ascii_lowercase()); - } else { - out.push(ch); - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn comma_joined_collapses_multiple_values_into_one_element() { - // Regression for DEVEX-882: repeated `--tlds`/`--tld` values must become - // one comma-joined query element, not stay as separate elements (which - // progenitor would send as repeated `tlds=` pairs). - assert_eq!( - comma_joined(vec!["com".to_string(), "net".to_string(), "io".to_string()]), - vec!["com,net,io".to_string()] - ); - } - - #[test] - fn comma_joined_single_value_is_unchanged() { - assert_eq!( - comma_joined(vec!["com".to_string()]), - vec!["com".to_string()] - ); - } - - #[test] - fn comma_joined_empty_stays_empty() { - assert_eq!(comma_joined(Vec::::new()), Vec::::new()); - } - - fn money(value: Option, currency: &str) -> types::SimpleMoney { - types::SimpleMoney { - value, - currency_code: (!currency.is_empty()) - .then(|| types::CurrencyCode(currency.to_string())), - } - } - - #[test] - fn format_money_uses_iso4217_minor_units_per_currency() { - // v3 `value` is in the currency's ISO-4217 minor units — USD `1199` is - // $11.99, NOT micro-units (the regression that rendered real prices as 0.00). - assert_eq!( - format_money(&money(Some(1199), "USD")).as_deref(), - Some("11.99") - ); - assert_eq!( - format_money(&money(Some(100), "USD")).as_deref(), - Some("1.00") - ); - assert_eq!( - format_money(&money(Some(2050), "USD")).as_deref(), - Some("20.50") - ); - assert_eq!( - format_money(&money(Some(-500), "USD")).as_deref(), - Some("-5.00") - ); - // Zero-decimal (JPY) and three-decimal (BHD) currencies. - assert_eq!( - format_money(&money(Some(1500), "JPY")).as_deref(), - Some("1500") - ); - assert_eq!( - format_money(&money(Some(1234), "BHD")).as_deref(), - Some("1.234") - ); - // Missing amount → None; missing currency defaults to 2 decimals. - assert_eq!(format_money(&money(None, "USD")), None); - assert_eq!( - format_money(&money(Some(1199), "")).as_deref(), - Some("11.99") - ); - } - - #[test] - fn currency_decimals_covers_iso_exceptions() { - assert_eq!(currency_decimals("USD"), 2); - assert_eq!(currency_decimals("EUR"), 2); - assert_eq!(currency_decimals("JPY"), 0); - assert_eq!(currency_decimals("UYI"), 0); - assert_eq!(currency_decimals("KWD"), 3); - assert_eq!(currency_decimals("CLF"), 4); - assert_eq!(currency_decimals("UYW"), 4); - assert_eq!(currency_decimals("XAU"), 2); // no ISO minor unit → default 2 (never a price) - assert_eq!(currency_decimals("ZZZ"), 2); // unknown → default 2 - } - - #[test] - fn payment_required_error_points_to_payment_methods_add() { - let msg = format_api_error( - "domain purchase", - 402, - "402 Payment Required", - r#"{"code":"INVALID_PAYMENT_INFO","message":"Unable to authorize credit"}"#, - None, - false, - ); - assert!(msg.contains("402 Payment Required"), "{msg}"); - assert!(msg.contains("gddy payment-methods add"), "{msg}"); - } - - #[test] - fn v3_error_envelope_fields_render_as_plain_english() { - // v3 wraps validation detail under an `error` object; the friendly - // renderer must reach into it. - let body = r#"{"error":{"code":"INVALID_BODY","fields":[{"code":"LENGTH_UNDER","path":"contacts.registrant.address.line2"}]}}"#; - let msg = format_api_error( - "domain purchase", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!(msg.contains("registrant address line 2"), "{msg}"); - assert!(msg.contains("is too short"), "{msg}"); - assert!(msg.contains("leave it blank to omit"), "{msg}"); - } - - #[test] - fn validate_domain_name_rejects_devex_885_repro_cases() { - // Every case from the DEVEX-885 bug report except the trailing-space - // one (which is accepted-after-trim; see the test below). - for bad in [ - "not a domain", - "https://test.com", - "test.com/page", - "test\u{0}evil.com", - ] { - assert!( - validate_domain_name(bad).is_err(), - "{bad:?} should be rejected" - ); - } - } - - #[test] - fn validate_domain_name_trims_whitespace_instead_of_rejecting() { - assert_eq!( - validate_domain_name("test.com ").expect("trimmed to valid"), - "test.com" - ); - assert_eq!( - validate_domain_name(" test.com").expect("trimmed to valid"), - "test.com" - ); - } - - #[test] - fn validate_domain_name_rejects_empty_or_whitespace_only() { - assert!(validate_domain_name("").is_err()); - assert!(validate_domain_name(" ").is_err()); - } - - #[test] - fn validate_domain_name_rejects_ip_literals() { - assert!(validate_domain_name("1.2.3.4").is_err()); - assert!(validate_domain_name("::1").is_err()); - } - - #[test] - fn validate_domain_name_rejects_missing_or_numeric_tld() { - assert!(validate_domain_name("example").is_err()); - assert!(validate_domain_name("example.123").is_err()); - } - - #[test] - fn validate_domain_name_rejects_leading_or_trailing_hyphen_labels() { - assert!(validate_domain_name("-example.com").is_err()); - assert!(validate_domain_name("example-.com").is_err()); - } - - #[test] - fn validate_domain_name_accepts_well_formed_domains_unchanged() { - for good in ["example.com", "xn--fsq.com", "ns1.example.co.uk"] { - assert_eq!(validate_domain_name(good).expect("valid domain"), good); - } - } - - #[test] - fn validate_domain_name_accepts_unicode_and_returns_original_not_punycode() { - // A real Unicode domain must pass (IDNA-processed for the shape check) - // but the returned string is the user's original input, not - // `Host::parse`'s ASCII/punycode form — no wire-format change for - // domains that already work today. - let input = "café.com"; - assert_eq!( - validate_domain_name(input).expect("valid unicode domain"), - input - ); - } - - #[test] - fn validate_nameserver_hosts_rejects_bad_shape_with_flag_context() { - // Regression: `quote`'s and `nameservers set`'s `--nameserver` values - // must go through the same shape check as a domain arg, but the error - // must say `--nameserver`, not claim the bad value is "the domain". - let err = validate_nameserver_hosts(vec!["bad ns".to_string()]) - .expect_err("malformed host should be rejected"); - let msg = err.to_string(); - assert!(msg.starts_with("--nameserver "), "{msg}"); - assert!(msg.contains("bad ns"), "{msg}"); - } - - #[test] - fn validate_nameserver_hosts_passes_through_valid_hosts_unchanged() { - let hosts = vec!["ns1.example.com".to_string(), "ns2.example.com".to_string()]; - assert_eq!( - validate_nameserver_hosts(hosts.clone()).expect("valid hosts"), - hosts - ); - } - - #[test] - fn validate_nameserver_hosts_empty_list_stays_empty() { - assert_eq!( - validate_nameserver_hosts(Vec::new()).expect("empty is valid"), - Vec::::new() - ); - } - - #[test] - fn v3_top_level_details_render_as_plain_english() { - // The real body a DNS write returns on a name-exclusivity conflict: no - // `fields`/`error` envelope at all, just a top-level `details[]`. - let body = r#"{"correlationId":"abc-123","details":[{"description":"Duplicate data provided for record name, www.","issue":"DUPLICATE_RECORD"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; - let msg = format_api_error( - "dns set", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!( - msg.contains("Duplicate data provided for record name, www."), - "{msg}" - ); - } - - #[test] - fn v3_details_with_no_description_fall_back_to_the_issue_code() { - // `description` is documented as unstable and often omitted; `issue` is - // the stable code. A detail with only `issue` must still render - // something useful, not fall through to the generic opaque message. - let body = r#"{"correlationId":"abc-123","details":[{"issue":"INVALID_NAMESERVER"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; - let msg = format_api_error( - "dns set", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!(msg.contains("INVALID_NAMESERVER"), "{msg}"); - - // Both empty → no friendly rendering, falls through to the generic message. - let body = r#"{"correlationId":"abc-123","details":[{}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; - let msg = format_api_error( - "dns set", - 422, - "422 Unprocessable Entity", - body, - None, - false, - ); - assert!(!msg.contains("some fields are invalid"), "{msg}"); - } - - #[test] - fn period_label_pluralizes_correctly() { - assert_eq!(period_label(1), "1 year"); - assert_eq!(period_label(2), "2 years"); - assert_eq!(period_label(10), "10 years"); - } - - #[test] - fn terminal_status_detection() { - assert!(is_terminal_status("COMPLETED")); - assert!(is_terminal_status("FAILED")); - assert!(!is_terminal_status("CONFIRMED")); - assert!(!is_terminal_status("EXECUTING")); - assert!(!is_terminal_status("SUBMITTED")); - } - - #[test] - fn format_operation_error_includes_name_and_message() { - assert_eq!(format_operation_error(None), ""); - - let name_only = types::Error { - name: Some("DOMAIN_UNAVAILABLE".to_string()), - ..Default::default() - }; - assert_eq!( - format_operation_error(Some(&name_only)), - " (DOMAIN_UNAVAILABLE)" - ); - - let message_only = types::Error { - message: Some("the domain is already registered".to_string()), - ..Default::default() - }; - assert_eq!( - format_operation_error(Some(&message_only)), - " (the domain is already registered)" - ); - - let both = types::Error { - name: Some("DOMAIN_UNAVAILABLE".to_string()), - message: Some("the domain is already registered".to_string()), - ..Default::default() - }; - assert_eq!( - format_operation_error(Some(&both)), - " (DOMAIN_UNAVAILABLE: the domain is already registered)" - ); - - let neither = types::Error::default(); - assert_eq!(format_operation_error(Some(&neither)), ""); - } -} diff --git a/rust/src/domain/common/client.rs b/rust/src/domain/common/client.rs new file mode 100644 index 00000000..8ef3255d --- /dev/null +++ b/rust/src/domain/common/client.rs @@ -0,0 +1,58 @@ +use cli_engine::{CliCoreError, CommandContext, Credential, Result}; + +use crate::environments; + +const USER_AGENT: &str = concat!("godaddy-cli/", env!("CARGO_PKG_VERSION")); + +/// Bridges domains-client's request/response observations into cli-engine's +/// `--debug transport` logger. domains-client defines the `TransportObserver` +/// extension point itself and has no compile-time dependency on cli-engine — +/// this crate *pushes* the logging behavior in, rather than domains-client +/// *pulling* it from the engine. +struct CliEngineTransportObserver; + +impl domains_client::TransportObserver for CliEngineTransportObserver { + fn on_request(&self, request: &reqwest::Request) { + cli_engine::transport::debug_log_reqwest_request(request); + } + + fn on_response(&self, status: reqwest::StatusCode, headers: &reqwest::header::HeaderMap) { + cli_engine::transport::debug_log_reqwest_response(status, headers, &[]); + } +} + +static TRANSPORT_OBSERVER_INIT: std::sync::Once = std::sync::Once::new(); + +fn ensure_transport_observer_registered() { + TRANSPORT_OBSERVER_INIT.call_once(|| { + domains_client::set_transport_observer(Some(std::sync::Arc::new( + CliEngineTransportObserver, + ))); + }); +} +/// Build a Domains API client for the active environment, authenticating with +/// the resolved OAuth bearer token. +pub(crate) async fn make_client(ctx: &CommandContext) -> Result { + let cred = ctx.credential().await?; + make_client_with_cred(&ctx.middleware.env, &cred) +} + +/// Build the Domains API client from an already-resolved credential, so callers +/// that need the credential themselves (e.g. `purchase`, for the consent +/// principal) resolve it once and reuse the same token for the requests. +pub(crate) fn make_client_with_cred( + env: &str, + cred: &Credential, +) -> Result { + ensure_transport_observer_registered(); + let config = environments::resolve(env)?; + let authorization = format!("Bearer {}", cred.token); + let request_id = uuid::Uuid::new_v4().to_string(); + domains_client::client_with_auth( + &config.domains_api_url, + &authorization, + USER_AGENT, + &request_id, + ) + .map_err(|e| CliCoreError::message(format!("failed to build domains client: {e}"))) +} diff --git a/rust/src/domain/common/errors.rs b/rust/src/domain/common/errors.rs new file mode 100644 index 00000000..c07365b4 --- /dev/null +++ b/rust/src/domain/common/errors.rs @@ -0,0 +1,312 @@ +use cli_engine::CliCoreError; + +/// Turn a domains-client error into a `CliCoreError`, reading the response body +/// for unexpected (non-2xx) responses so the API's actual message isn't lost +/// (progenitor's `Display` prints only the status). Async because reading the +/// body is async. +pub(crate) async fn api_error( + action: &str, + debug: bool, + err: domains_client::Error<()>, +) -> CliCoreError { + match err { + domains_client::Error::UnexpectedResponse(resp) => { + let status = resp.status(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + CliCoreError::message(format_api_error( + action, + status.as_u16(), + &status.to_string(), + &body, + request_id.as_deref(), + debug, + )) + } + other => CliCoreError::message(format!("{action} failed: {other}")), + } +} + +/// Build the user-facing message for an unexpected API response. Pure so the +/// HTTP 402 → payment-method guidance and the `--debug` request-id line are +/// unit-testable. +pub(crate) fn format_api_error( + action: &str, + status: u16, + status_display: &str, + body: &str, + request_id: Option<&str>, + debug: bool, +) -> String { + let body = body.trim(); + let friendly = friendly_field_errors(body); + let mut msg = match &friendly { + Some(detail) => format!("{action} failed (HTTP {status_display}):\n{detail}"), + None if body.is_empty() => format!("{action} failed (HTTP {status_display})"), + None => format!("{action} failed (HTTP {status_display}): {body}"), + }; + if status == 402 { + msg.push_str( + "\n\nThis usually means your account has no usable payment method. Add one with \ + `gddy payment-methods add` (a credit card or Good-as-Gold balance is required for domain \ + purchases), then try again.", + ); + } + if debug { + if friendly.is_some() && !body.is_empty() { + msg.push_str(&format!("\n\nResponse body: {body}")); + } + if let Some(id) = request_id.map(str::trim).filter(|s| !s.is_empty()) { + msg.push_str(&format!("\n\nRequest ID: {id}")); + } + } + msg +} + +/// A Domains API validation error body. Tolerates the v1 field-level shape +/// (`{"fields":[...]}` or `{"error":{"fields":[...]}}`) and the v3 top-level +/// shape (`{"name","message","correlationId","details":[{"issue","description"}]}`). +#[derive(serde::Deserialize)] +struct ApiErrorBody { + #[serde(default)] + fields: Vec, + #[serde(default)] + error: Option, + #[serde(default)] + details: Vec, +} + +#[derive(serde::Deserialize)] +struct ApiErrorEnvelope { + #[serde(default)] + fields: Vec, +} + +#[derive(serde::Deserialize)] +struct ApiFieldError { + #[serde(default)] + code: String, + #[serde(default)] + path: String, +} + +#[derive(serde::Deserialize)] +struct ApiDetailError { + #[serde(default)] + description: String, + #[serde(default)] + issue: String, +} + +impl ApiDetailError { + /// The human-readable `description` when present; the v3 spec documents it + /// as unstable ("MAY change... MUST NOT depend on this value") and often + /// absent, so fall back to the stable `issue` code rather than dropping the + /// detail entirely. + fn render(&self) -> Option<&str> { + if !self.description.is_empty() { + Some(self.description.as_str()) + } else if !self.issue.is_empty() { + Some(self.issue.as_str()) + } else { + None + } + } +} + +/// Render a structured validation error as a plain-English bullet list, or `None` +/// when `body` isn't a field-level validation error. +fn friendly_field_errors(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok()?; + let fields = if !parsed.fields.is_empty() { + parsed.fields + } else { + parsed.error.map(|e| e.fields).unwrap_or_default() + }; + if !fields.is_empty() { + let lines: Vec = fields + .iter() + .map(|f| format!(" • {}", describe_field_error(f))) + .collect(); + return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); + } + if !parsed.details.is_empty() { + let lines: Vec = parsed + .details + .iter() + .filter_map(ApiDetailError::render) + .map(|text| format!(" • {text}")) + .collect(); + if !lines.is_empty() { + return Some(format!("some fields are invalid:\n{}", lines.join("\n"))); + } + } + None +} + +fn describe_field_error(f: &ApiFieldError) -> String { + let name = friendly_field_name(&f.path); + let problem = match f.code.as_str() { + "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT" => "is too short", + "LENGTH_OVER" | "MAX_LENGTH" | "TOO_LONG" => "is too long", + "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN" => "is not in the required format", + "REQUIRED" | "MISSING" | "MISSING_REQUIRED_FIELD" => "is required", + _ => "is invalid", + }; + match field_hint(&f.path, &f.code) { + Some(hint) => format!("{name} {problem} ({hint})"), + None => format!("{name} {problem}"), + } +} + +fn field_hint(path: &str, code: &str) -> Option<&'static str> { + let leaf = path.rsplit('.').next().unwrap_or(path); + match (leaf, code) { + ("phone", _) | ("nationalNumber", _) => Some("expected a format like +1.4805551212"), + ("postalCode", "PATTERN" | "INVALID_FORMAT" | "INVALID_PATTERN") => { + Some("some countries require a specific format, e.g. UK SW1A 2AA") + } + ("line2", "LENGTH_UNDER" | "MIN_LENGTH" | "TOO_SHORT") => Some("leave it blank to omit it"), + _ => None, + } +} + +/// Turn an API field path into a human phrase. Falls back to a space-joined, +/// camelCase-split rendering for unrecognized paths. +fn friendly_field_name(path: &str) -> String { + let trimmed = path + .strip_prefix("body.") + .or_else(|| path.strip_prefix("consent.")) + .unwrap_or(path); + let segments: Vec<&str> = trimmed.split('.').filter(|s| !s.is_empty()).collect(); + if let ["contacts", role, rest @ ..] = segments.as_slice() { + let mut parts = vec![humanize_segment(role)]; + parts.extend(rest.iter().map(|s| humanize_segment(s))); + return parts.join(" "); + } + if segments.is_empty() { + return path.to_string(); + } + segments + .iter() + .map(|s| humanize_segment(s)) + .collect::>() + .join(" ") +} + +fn humanize_segment(seg: &str) -> String { + match seg { + "line1" => "line 1".to_string(), + "line2" => "line 2".to_string(), + "postalCode" => "postal code".to_string(), + "countryCode" => "country".to_string(), + "firstName" => "first name".to_string(), + "lastName" => "last name".to_string(), + "nationalNumber" => "phone".to_string(), + "agreedAt" => "agreed-at timestamp".to_string(), + other => split_camel_case(other), + } +} + +fn split_camel_case(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + for (i, ch) in s.char_indices() { + if ch.is_ascii_uppercase() { + if i != 0 { + out.push(' '); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payment_required_error_points_to_payment_methods_add() { + let msg = format_api_error( + "domain purchase", + 402, + "402 Payment Required", + r#"{"code":"INVALID_PAYMENT_INFO","message":"Unable to authorize credit"}"#, + None, + false, + ); + assert!(msg.contains("402 Payment Required"), "{msg}"); + assert!(msg.contains("gddy payment-methods add"), "{msg}"); + } + + #[test] + fn v3_error_envelope_fields_render_as_plain_english() { + // v3 wraps validation detail under an `error` object; the friendly + // renderer must reach into it. + let body = r#"{"error":{"code":"INVALID_BODY","fields":[{"code":"LENGTH_UNDER","path":"contacts.registrant.address.line2"}]}}"#; + let msg = format_api_error( + "domain purchase", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!(msg.contains("registrant address line 2"), "{msg}"); + assert!(msg.contains("is too short"), "{msg}"); + assert!(msg.contains("leave it blank to omit"), "{msg}"); + } + #[test] + fn v3_top_level_details_render_as_plain_english() { + // The real body a DNS write returns on a name-exclusivity conflict: no + // `fields`/`error` envelope at all, just a top-level `details[]`. + let body = r#"{"correlationId":"abc-123","details":[{"description":"Duplicate data provided for record name, www.","issue":"DUPLICATE_RECORD"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; + let msg = format_api_error( + "dns set", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!( + msg.contains("Duplicate data provided for record name, www."), + "{msg}" + ); + } + + #[test] + fn v3_details_with_no_description_fall_back_to_the_issue_code() { + // `description` is documented as unstable and often omitted; `issue` is + // the stable code. A detail with only `issue` must still render + // something useful, not fall through to the generic opaque message. + let body = r#"{"correlationId":"abc-123","details":[{"issue":"INVALID_NAMESERVER"}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; + let msg = format_api_error( + "dns set", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!(msg.contains("INVALID_NAMESERVER"), "{msg}"); + + // Both empty → no friendly rendering, falls through to the generic message. + let body = r#"{"correlationId":"abc-123","details":[{}],"message":"Request failed validation","name":"VALIDATION_ERROR"}"#; + let msg = format_api_error( + "dns set", + 422, + "422 Unprocessable Entity", + body, + None, + false, + ); + assert!(!msg.contains("some fields are invalid"), "{msg}"); + } +} diff --git a/rust/src/domain/common/interactive.rs b/rust/src/domain/common/interactive.rs new file mode 100644 index 00000000..6e4447d4 --- /dev/null +++ b/rust/src/domain/common/interactive.rs @@ -0,0 +1,384 @@ +use std::future::Future; + +use cli_engine::{CliCoreError, CommandContext, Result}; + +use super::client::make_client; +use super::errors::{api_error, format_api_error}; +use super::validation::{ + validate_domain_name, validate_nameserver_hosts, validate_operation_id, validate_quote_token, + validate_tld, +}; + +/// Prompt until the user enters a valid TLD or cancels. +pub(crate) fn prompt_validated_tld(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_tld(input).map(|_| ()).map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_tld(&input) +} + +/// Validate `--tld` values, re-prompting interactively when input is invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_tlds( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + if ctx.is_interactive() { + return Ok(vec![prompt_validated_tld(prompt)?]); + } + return Err(CliCoreError::message("at least one --tld is required")); + } + match raw + .iter() + .map(|t| validate_tld(t)) + .collect::>>() + { + Ok(tlds) => Ok(tlds), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_tld(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// User-facing hint when the agreements API rejects a TLD the user can retry. +pub(crate) fn recoverable_tld_api_error(status: u16, body: &str) -> Option { + if status != 422 { + return None; + } + #[derive(serde::Deserialize)] + struct CodeMessage { + #[serde(default)] + code: String, + #[serde(default)] + message: String, + } + let parsed = serde_json::from_str::(body).ok()?; + if parsed.code != "UNSUPPORTED_TLD" { + return None; + } + if parsed.message.is_empty() { + Some("that TLD is not supported; try another (e.g. com)".to_owned()) + } else { + Some(format!( + "that TLD is not supported: {} — try another (e.g. com)", + parsed.message + )) + } +} + +/// User-facing hint when an operation lookup fails and the user can retry. +pub(crate) fn recoverable_operation_api_error(status: u16, _body: &str) -> Option { + if status == 404 { + Some("no operation found with that ID — check the operation ID and try again".to_owned()) + } else { + None + } +} + +/// User-facing hint when a domain lookup fails and the user can retry. +pub(crate) fn recoverable_domain_lookup_api_error(status: u16, _body: &str) -> Option { + match status { + 404 => Some("that domain was not found — check the name and try again".to_owned()), + 422 => Some("that domain could not be looked up — check the name and try again".to_owned()), + _ => None, + } +} + +/// Prompt until the user enters a valid operation ID or cancels. +pub(crate) fn prompt_validated_operation_id(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_operation_id(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_operation_id(&input) +} + +/// Validate an operation ID, re-prompting interactively when input is invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_operation_id( + ctx: &CommandContext, + raw: &str, + prompt: &str, +) -> Result { + match validate_operation_id(raw) { + Ok(id) => Ok(id), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_operation_id(prompt) + } + Err(e) => Err(e), + } +} + +/// Prompt until the user enters a non-empty quote token or cancels. +pub(crate) fn prompt_validated_quote_token(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_quote_token(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_quote_token(&input) +} + +/// Resolve a quote token, re-prompting interactively when missing or invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_quote_token( + ctx: &CommandContext, + raw: Option, + prompt: &str, +) -> Result { + match raw { + Some(token) => match validate_quote_token(&token) { + Ok(token) => Ok(token), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_quote_token(prompt) + } + Err(e) => Err(e), + }, + None if ctx.is_interactive() => prompt_validated_quote_token(prompt), + None => Err(CliCoreError::message( + "--quote-token is required.\n\ + \n Get one with `gddy domain quote `, or use the interactive \ + wizard:\n\ + \n gddy domain register\n\ + \n Then purchase with:\n\ + \n gddy domain purchase --quote-token --agree --confirm", + )), + } +} + +/// Validate optional `--tlds` filters, re-prompting interactively when invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_optional_tlds( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + return Ok(vec![]); + } + match raw + .iter() + .map(|t| validate_tld(t)) + .collect::>>() + { + Ok(tlds) => Ok(tlds), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_tld(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// Prompt until the user enters a valid domain name or cancels. +pub(crate) fn prompt_validated_domain_name(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_domain_name(input) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_domain_name(&input) +} + +/// Validate a domain name, re-prompting interactively when input is invalid. +/// +/// Used by leaf commands whose positional `DOMAIN` arg may be supplied via +/// cli-engine's missing-arg recovery (which does not validate format). +#[allow(clippy::print_stderr)] // interactive user-facing feedback, not diagnostic logging +pub(crate) fn resolve_domain_name(ctx: &CommandContext, raw: &str, prompt: &str) -> Result { + match validate_domain_name(raw) { + Ok(domain) => Ok(domain), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + prompt_validated_domain_name(prompt) + } + Err(e) => Err(e), + } +} + +/// Prompt until the user enters a valid nameserver host or cancels. +pub(crate) fn prompt_validated_nameserver(prompt: &str) -> Result { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt(prompt) + .validate_with(|input: &String| -> std::result::Result<(), String> { + validate_domain_name(input) + .map(|_| ()) + .map_err(|e| format!("--nameserver {e}")) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + validate_domain_name(&input) +} + +/// Resolve `--nameserver` hosts, re-prompting interactively when missing or invalid. +#[allow(clippy::print_stderr)] +pub(crate) fn resolve_nameserver_hosts( + ctx: &CommandContext, + raw: Vec, + prompt: &str, +) -> Result> { + if raw.is_empty() { + if ctx.is_interactive() { + return Ok(vec![prompt_validated_nameserver(prompt)?]); + } + return Err(CliCoreError::message( + "at least one --nameserver is required", + )); + } + match validate_nameserver_hosts(raw) { + Ok(hosts) => Ok(hosts), + Err(e) if ctx.is_interactive() => { + eprintln!(" {e}"); + Ok(vec![prompt_validated_nameserver(prompt)?]) + } + Err(e) => Err(e), + } +} + +/// Re-run a domain-scoped API call, re-prompting for the domain on recoverable errors. +#[allow(clippy::print_stderr)] +pub(crate) async fn fetch_with_domain_retry( + ctx: &CommandContext, + initial_domain: String, + prompt: &str, + action: &str, + debug: bool, + fetch: F, +) -> Result +where + F: Fn(domains_client::Client, String) -> Fut, + Fut: Future>>, +{ + let client = make_client(ctx).await?; + let mut domain = initial_domain; + loop { + match fetch(client.clone(), domain.clone()).await { + Ok(value) => return Ok(value), + Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_domain_lookup_api_error(status, &body) { + eprintln!(" {hint}"); + domain = prompt_validated_domain_name(prompt)?; + continue; + } + return Err(CliCoreError::message(format_api_error( + action, + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => return Err(api_error(action, debug, e).await), + } + } +} + +/// Re-run an operation lookup, re-prompting for the operation ID on recoverable errors. +#[allow(clippy::print_stderr)] +pub(crate) async fn fetch_with_operation_retry( + ctx: &CommandContext, + initial_id: String, + prompt: &str, + action: &str, + debug: bool, + fetch: F, +) -> Result +where + F: Fn(domains_client::Client, String) -> Fut, + Fut: Future>>, +{ + let client = make_client(ctx).await?; + let mut operation_id = initial_id; + loop { + match fetch(client.clone(), operation_id.clone()).await { + Ok(value) => return Ok(value), + Err(domains_client::Error::UnexpectedResponse(resp)) if ctx.is_interactive() => { + let status = resp.status().as_u16(); + let status_display = resp.status().to_string(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if let Some(hint) = recoverable_operation_api_error(status, &body) { + eprintln!(" {hint}"); + operation_id = prompt_validated_operation_id(prompt)?; + continue; + } + return Err(CliCoreError::message(format_api_error( + action, + status, + &status_display, + &body, + request_id.as_deref(), + debug, + ))); + } + Err(e) => return Err(api_error(action, debug, e).await), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recoverable_tld_api_error_matches_unsupported_tld() { + let hint = recoverable_tld_api_error( + 422, + r#"{"code":"UNSUPPORTED_TLD","message":"The specified TLD is currently unsupported"}"#, + ) + .expect("recoverable"); + assert!(hint.contains("not supported")); + } + + #[test] + fn recoverable_tld_api_error_ignores_other_codes() { + assert!(recoverable_tld_api_error(422, r#"{"code":"OTHER","message":"nope"}"#).is_none()); + assert!(recoverable_tld_api_error(404, r#"{"code":"UNSUPPORTED_TLD"}"#).is_none()); + } +} diff --git a/rust/src/domain/common/mod.rs b/rust/src/domain/common/mod.rs new file mode 100644 index 00000000..a2f96d62 --- /dev/null +++ b/rust/src/domain/common/mod.rs @@ -0,0 +1,71 @@ +//! Shared helpers for the `domain` command group: the authenticated Domains API +//! client, money formatting, argument helpers, and API-error rendering. Each +//! `gddy domain` subcommand lives in its own sibling module and draws from here. + +mod client; +mod errors; +mod interactive; +mod operation; +mod pricing; +mod validation; + +pub(crate) use client::{make_client, make_client_with_cred}; +pub(crate) use errors::{api_error, format_api_error}; +pub(super) use interactive::{ + fetch_with_domain_retry, fetch_with_operation_retry, prompt_validated_domain_name, + prompt_validated_quote_token, prompt_validated_tld, recoverable_tld_api_error, + resolve_domain_name, resolve_nameserver_hosts, resolve_operation_id, resolve_optional_tlds, + resolve_quote_token, resolve_tlds, +}; +pub(super) use operation::{format_operation_error, is_terminal_status}; +pub(super) use pricing::{ + clamp_registration_periods, format_money, is_period_limit_error, parse_max_registration_period, + period_label, period_price_map, periods_from_prices, term_for_period, +}; +pub(super) use validation::{validate_domain_name, validate_nameserver_hosts}; + +/// Collapse a repeatable CLI flag's values into the single query-string value +/// some domains-client endpoints require (e.g. `tlds`, whose OpenAPI param is +/// `style: form, explode: false` — one comma-joined value). progenitor's +/// generated setters always seq-serialize a `Vec` argument as repeated +/// `key=value` pairs regardless of the spec's `explode` setting, so passing +/// multiple `--tlds` occurrences straight through sends `tlds=com&tlds=net` +/// and the API rejects it (`400 MISMATCH_FORMAT`, DEVEX-882). Joining into a +/// single element before calling the setter produces the one pair the API +/// expects. `[]` stays `[]` so callers can still gate on "no filter given". +pub(crate) fn comma_joined(values: Vec) -> Vec { + if values.len() <= 1 { + values + } else { + vec![values.join(",")] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comma_joined_collapses_multiple_values_into_one_element() { + // Regression for DEVEX-882: repeated `--tlds`/`--tld` values must become + // one comma-joined query element, not stay as separate elements (which + // progenitor would send as repeated `tlds=` pairs). + assert_eq!( + comma_joined(vec!["com".to_string(), "net".to_string(), "io".to_string()]), + vec!["com,net,io".to_string()] + ); + } + + #[test] + fn comma_joined_single_value_is_unchanged() { + assert_eq!( + comma_joined(vec!["com".to_string()]), + vec!["com".to_string()] + ); + } + + #[test] + fn comma_joined_empty_stays_empty() { + assert_eq!(comma_joined(Vec::::new()), Vec::::new()); + } +} diff --git a/rust/src/domain/common/operation.rs b/rust/src/domain/common/operation.rs new file mode 100644 index 00000000..5dd77e9f --- /dev/null +++ b/rust/src/domain/common/operation.rs @@ -0,0 +1,74 @@ +use domains_client::types; + +/// Whether an async domain-operation status is terminal (no further polling). +/// Only `COMPLETED`/`FAILED` are terminal; every other status (e.g. `SUBMITTED`, +/// `PENDING`, `CONFIRMED`, `EXECUTING`) is treated as still in progress. +pub(crate) fn is_terminal_status(status: &str) -> bool { + matches!(status, "COMPLETED" | "FAILED") +} + +/// Renders a `FAILED` operation's `error` payload (name/message) for the +/// user-facing error, e.g. `" (DOMAIN_UNAVAILABLE: the domain is already +/// registered)"`. Empty when the operation carried no error detail (e.g. it +/// failed before an operation with a populated `error` was ever polled). +pub(crate) fn format_operation_error(error: Option<&types::Error>) -> String { + let Some(error) = error else { + return String::new(); + }; + match (error.name.as_deref(), error.message.as_deref()) { + (Some(name), Some(message)) => format!(" ({name}: {message})"), + (Some(name), None) => format!(" ({name})"), + (None, Some(message)) => format!(" ({message})"), + (None, None) => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_status_detection() { + assert!(is_terminal_status("COMPLETED")); + assert!(is_terminal_status("FAILED")); + assert!(!is_terminal_status("CONFIRMED")); + assert!(!is_terminal_status("EXECUTING")); + assert!(!is_terminal_status("SUBMITTED")); + } + + #[test] + fn format_operation_error_includes_name_and_message() { + assert_eq!(format_operation_error(None), ""); + + let name_only = types::Error { + name: Some("DOMAIN_UNAVAILABLE".to_string()), + ..Default::default() + }; + assert_eq!( + format_operation_error(Some(&name_only)), + " (DOMAIN_UNAVAILABLE)" + ); + + let message_only = types::Error { + message: Some("the domain is already registered".to_string()), + ..Default::default() + }; + assert_eq!( + format_operation_error(Some(&message_only)), + " (the domain is already registered)" + ); + + let both = types::Error { + name: Some("DOMAIN_UNAVAILABLE".to_string()), + message: Some("the domain is already registered".to_string()), + ..Default::default() + }; + assert_eq!( + format_operation_error(Some(&both)), + " (DOMAIN_UNAVAILABLE: the domain is already registered)" + ); + + let neither = types::Error::default(); + assert_eq!(format_operation_error(Some(&neither)), ""); + } +} diff --git a/rust/src/domain/common/pricing.rs b/rust/src/domain/common/pricing.rs new file mode 100644 index 00000000..0c4fd0b9 --- /dev/null +++ b/rust/src/domain/common/pricing.rs @@ -0,0 +1,192 @@ +use domains_client::types; + +/// The ISO-4217 minor-unit exponent for a currency — how many implied decimal +/// places a [`types::SimpleMoney`] `value` carries (the v3 spec defers money +/// formatting to ISO 4217). Sourced from the `iso_currency` crate's maintained +/// ISO 4217 dataset rather than a hand table, so it stays complete and correct +/// (JPY → 0, USD → 2, KWD → 3, CLF → 4, …). +/// +/// Falls back to 2 for an unrecognized code and for the codes ISO marks with no +/// minor unit (precious metals `XAU`/`XAG`, the IMF SDR `XDR`, `XXX`, test codes) +/// — none of which are spendable currencies that could be a domain price. +fn currency_decimals(code: &str) -> u32 { + iso_currency::Currency::from_code(&code.to_ascii_uppercase()) + .and_then(|c| c.exponent()) + .map_or(2, u32::from) +} + +/// Render a [`types::SimpleMoney`] as a decimal string. v3 money `value`s are in +/// ISO-4217 minor units for the currency (e.g. USD `1199` → `"11.99"`, JPY `1500` +/// → `"1500"`, BHD `1234` → `"1.234"`), NOT the micro-units v1 used. Truncates +/// toward zero (registry prices are whole minor units in practice); the sign is +/// explicit and `unsigned_abs` avoids `i64::MIN` overflow. `None` when the amount +/// is absent. Missing currency defaults to 2 decimals. +pub(crate) fn format_money(money: &types::SimpleMoney) -> Option { + let value = money.value?; + let code = money + .currency_code + .as_ref() + .map(|c| c.as_str()) + .unwrap_or(""); + let decimals = currency_decimals(code); + let sign = if value < 0 { "-" } else { "" }; + let abs = value.unsigned_abs(); + if decimals == 0 { + return Some(format!("{sign}{abs}")); + } + let scale = 10u64.pow(decimals); + Some(format!( + "{sign}{}.{:0width$}", + abs / scale, + abs % scale, + width = decimals as usize + )) +} + +/// The entry for a specific year-term period (1, 2, …), or `None` when that term +/// isn't in the list. Used by `suggest` to flatten multiple terms into scalar +/// per-period fields (e.g. `price1Year`, `price2Year`). +pub(crate) fn term_for_period( + prices: &[types::TermPrice], + period: u64, +) -> Option<&types::TermPrice> { + let period = std::num::NonZeroU64::new(period)?; + prices.iter().find(|p| p.period == Some(period)) +} + +/// Sorted, unique registration periods (years) priced in an availability response. +pub(crate) fn periods_from_prices(prices: &[types::TermPrice]) -> Vec { + let mut periods: Vec = prices + .iter() + .filter_map(|t| t.period.map(|p| p.get())) + .collect(); + periods.sort_unstable(); + periods.dedup(); + periods +} + +/// Indicative total registration price keyed by period years. +pub(crate) fn period_price_map( + prices: &[types::TermPrice], +) -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + for term in prices { + let Some(period) = term.period.map(|p| p.get()) else { + continue; + }; + if let Some(price) = term.price.as_ref().and_then(format_money) { + map.insert(period, price); + } + } + map +} + +/// Whether an API error body indicates the requested registration period exceeds +/// the TLD limit. Availability pricing is indicative; quote is authoritative. +pub(crate) fn is_period_limit_error(body: &str) -> bool { + let lower = body.to_ascii_lowercase(); + lower.contains("not currently supported") || lower.contains("maximum is") +} + +/// Parse the maximum registration period from a period-limit error body. +pub(crate) fn parse_max_registration_period(body: &str) -> Option { + let lower = body.to_ascii_lowercase(); + let needle = "maximum is "; + let rest = lower.split(needle).nth(1)?; + rest.split_whitespace().next()?.parse().ok() +} + +/// Drop unsupported periods and clamp the selected period to the TLD maximum. +pub(crate) fn clamp_registration_periods( + available_periods: &mut Vec, + selected_period: &mut u64, + max_years: u64, +) { + available_periods.retain(|p| *p <= max_years); + if available_periods.is_empty() { + available_periods.push(1); + } + if *selected_period > max_years { + *selected_period = available_periods.iter().copied().max().unwrap_or(1); + } +} + +/// A registration length with its unit spelled out ("1 year", "2 years") — a +/// bare number reads ambiguously in a table, so `quote`/`available` show this +/// alongside the numeric `period` field (which stays a plain number for +/// scripting against `--output json`). +pub(crate) fn period_label(period: u64) -> String { + if period == 1 { + "1 year".to_owned() + } else { + format!("{period} years") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn periods_from_prices_returns_sorted_unique_periods() { + use domains_client::types; + + let prices = vec![ + types::TermPrice { + period: std::num::NonZeroU64::new(3), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(1), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(2), + ..Default::default() + }, + types::TermPrice { + period: std::num::NonZeroU64::new(2), + ..Default::default() + }, + ]; + assert_eq!(periods_from_prices(&prices), vec![1, 2, 3]); + } + + #[test] + fn period_price_map_formats_indicative_totals() { + use domains_client::types; + + let prices = vec![types::TermPrice { + period: std::num::NonZeroU64::new(2), + price: Some(types::SimpleMoney { + value: Some(6098), + currency_code: Some(types::CurrencyCode("USD".to_string())), + }), + ..Default::default() + }]; + let map = period_price_map(&prices); + assert_eq!(map.get(&2).map(String::as_str), Some("60.98")); + } + + #[test] + fn parse_max_registration_period_reads_api_error_text() { + let body = r#"{"details":[{"description":"period 5 is not currently supported; maximum is 3 years"}]}"#; + assert!(is_period_limit_error(body)); + assert_eq!(parse_max_registration_period(body), Some(3)); + } + + #[test] + fn clamp_registration_periods_drops_and_clamps_selection() { + let mut periods = vec![1_u64, 2, 3, 5]; + let mut selected = 5_u64; + clamp_registration_periods(&mut periods, &mut selected, 3); + assert_eq!(periods, vec![1, 2, 3]); + assert_eq!(selected, 3); + } + #[test] + fn period_label_pluralizes_correctly() { + assert_eq!(period_label(1), "1 year"); + assert_eq!(period_label(2), "2 years"); + assert_eq!(period_label(10), "10 years"); + } +} diff --git a/rust/src/domain/common/validation.rs b/rust/src/domain/common/validation.rs new file mode 100644 index 00000000..3f4601d2 --- /dev/null +++ b/rust/src/domain/common/validation.rs @@ -0,0 +1,252 @@ +use cli_engine::{CliCoreError, Result}; + +/// Validate that `raw` (trimmed) is syntactically a valid domain name. Rejects +/// the shapes from DEVEX-885's bug report — embedded whitespace, null bytes, a +/// "protocol://" prefix, a "/path" suffix — via `url::Host::parse`'s WHATWG +/// forbidden-host-code-point + IDNA checks, then layers RFC 1035/1123 shape +/// rules a real domain always satisfies: not a bare IP literal, >=2 +/// dot-separated LDH labels, each 1-63 bytes, total <=253 bytes, and a TLD +/// that isn't all-numeric (ICANN disallows those). Returns the original +/// trimmed input — not `Host::parse`'s ASCII/punycode form — so an +/// already-working Unicode domain's wire format is unchanged. +pub(crate) fn validate_domain_name(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("domain name is required")); + } + // `url::Host::parse`'s error `Display` is a single generic string + // ("invalid international domain name") regardless of what's actually + // wrong (a space, a "://" prefix, a "/path" suffix, ...), so it isn't + // worth including — it would just read as a confusing non-sequitur. + let host = url::Host::parse(trimmed) + .map_err(|_| CliCoreError::message(format!("{trimmed:?} is not a valid domain name")))?; + let url::Host::Domain(ascii) = host else { + return Err(CliCoreError::message(format!( + "{trimmed:?} is an IP address, not a domain name" + ))); + }; + let labels: Vec<&str> = ascii.split('.').collect(); + let shape_ok = labels.len() >= 2 + && ascii.len() <= 253 + && labels.iter().all(|l| is_ldh_label(l)) + && !labels + .last() + .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())); + if !shape_ok { + return Err(CliCoreError::message(format!( + "{trimmed:?} doesn't look like a valid domain name (expected something like example.com)" + ))); + } + Ok(trimmed.to_owned()) +} + +/// Normalize and validate a TLD label for `--tld` / `--tlds` flags. +/// +/// Accepts `com` or `.com` (leading dot stripped), lowercases, and validates +/// each dot-separated label as LDH. +pub(crate) fn validate_tld(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("TLD is required")); + } + let stripped = trimmed.trim_start_matches('.'); + if stripped.is_empty() { + return Err(CliCoreError::message( + "TLD is required (expected something like com, not just a leading dot)", + )); + } + let lower = stripped.to_ascii_lowercase(); + let labels: Vec<&str> = lower.split('.').collect(); + if labels.iter().any(|label| !is_ldh_label(label)) { + return Err(CliCoreError::message(format!( + "{raw:?} doesn't look like a valid TLD (expected something like com or co.uk, without a leading dot)" + ))); + } + if labels + .last() + .is_some_and(|tld| tld.bytes().all(|b| b.is_ascii_digit())) + { + return Err(CliCoreError::message(format!("{raw:?} is not a valid TLD"))); + } + Ok(lower) +} +/// A single RFC 1035/1123 "LDH label": 1-63 bytes, alphanumeric, interior +/// hyphens only (not leading/trailing). +fn is_ldh_label(label: &str) -> bool { + let bytes = label.as_bytes(); + !bytes.is_empty() + && bytes.len() <= 63 + && bytes[0] != b'-' + && bytes[bytes.len() - 1] != b'-' + && bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'-') +} +/// Validate a quote token string (non-empty). +pub(crate) fn validate_quote_token(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("quote token is required")); + } + Ok(trimmed.to_owned()) +} +/// Normalize and validate an async operation ID (UUID). +pub(crate) fn validate_operation_id(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CliCoreError::message("operation ID is required")); + } + uuid::Uuid::parse_str(trimmed).map_err(|_| { + CliCoreError::message(format!( + "{trimmed:?} doesn't look like a valid operation ID (expected a UUID)" + )) + })?; + Ok(trimmed.to_owned()) +} +/// Validate every value of a repeatable `--nameserver`-style flag as a +/// domain-shaped hostname, wrapping [`validate_domain_name`]'s generic error +/// with the flag's own name — used by both `quote` (the registration +/// profile's nameservers) and `nameservers set` (the target domain's +/// nameservers) so a bad host isn't reported as if it were some other +/// (already-valid) domain argument. +pub(crate) fn validate_nameserver_hosts(raw: Vec) -> Result> { + raw.into_iter() + .map(|h| { + validate_domain_name(&h).map_err(|e| CliCoreError::message(format!("--nameserver {e}"))) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_domain_name_rejects_devex_885_repro_cases() { + // Every case from the DEVEX-885 bug report except the trailing-space + // one (which is accepted-after-trim; see the test below). + for bad in [ + "not a domain", + "https://test.com", + "test.com/page", + "test\u{0}evil.com", + ] { + assert!( + validate_domain_name(bad).is_err(), + "{bad:?} should be rejected" + ); + } + } + + #[test] + fn validate_domain_name_trims_whitespace_instead_of_rejecting() { + assert_eq!( + validate_domain_name("test.com ").expect("trimmed to valid"), + "test.com" + ); + assert_eq!( + validate_domain_name(" test.com").expect("trimmed to valid"), + "test.com" + ); + } + + #[test] + fn validate_domain_name_rejects_empty_or_whitespace_only() { + assert!(validate_domain_name("").is_err()); + assert!(validate_domain_name(" ").is_err()); + } + + #[test] + fn validate_domain_name_rejects_ip_literals() { + assert!(validate_domain_name("1.2.3.4").is_err()); + assert!(validate_domain_name("::1").is_err()); + } + + #[test] + fn validate_domain_name_rejects_missing_or_numeric_tld() { + assert!(validate_domain_name("example").is_err()); + assert!(validate_domain_name("example.123").is_err()); + } + + #[test] + fn validate_tld_strips_leading_dot_and_lowercases() { + assert_eq!(validate_tld(".org").expect("valid"), "org"); + assert_eq!(validate_tld("COM").expect("valid"), "com"); + assert_eq!(validate_tld("co.uk").expect("valid"), "co.uk"); + } + + #[test] + fn validate_tld_rejects_empty_or_dot_only() { + assert!(validate_tld("").is_err()); + assert!(validate_tld(".").is_err()); + assert!(validate_tld(" ").is_err()); + } + #[test] + fn validate_operation_id_accepts_uuid() { + assert_eq!( + validate_operation_id("550e8400-e29b-41d4-a716-446655440000").expect("valid"), + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn validate_operation_id_rejects_garbage() { + assert!(validate_operation_id("not-a-uuid").is_err()); + assert!(validate_operation_id("").is_err()); + } + + #[test] + fn validate_domain_name_rejects_leading_or_trailing_hyphen_labels() { + assert!(validate_domain_name("-example.com").is_err()); + assert!(validate_domain_name("example-.com").is_err()); + } + + #[test] + fn validate_domain_name_accepts_well_formed_domains_unchanged() { + for good in ["example.com", "xn--fsq.com", "ns1.example.co.uk"] { + assert_eq!(validate_domain_name(good).expect("valid domain"), good); + } + } + + #[test] + fn validate_domain_name_accepts_unicode_and_returns_original_not_punycode() { + // A real Unicode domain must pass (IDNA-processed for the shape check) + // but the returned string is the user's original input, not + // `Host::parse`'s ASCII/punycode form — no wire-format change for + // domains that already work today. + let input = "café.com"; + assert_eq!( + validate_domain_name(input).expect("valid unicode domain"), + input + ); + } + + #[test] + fn validate_nameserver_hosts_rejects_bad_shape_with_flag_context() { + // Regression: `quote`'s and `nameservers set`'s `--nameserver` values + // must go through the same shape check as a domain arg, but the error + // must say `--nameserver`, not claim the bad value is "the domain". + let err = validate_nameserver_hosts(vec!["bad ns".to_string()]) + .expect_err("malformed host should be rejected"); + let msg = err.to_string(); + assert!(msg.starts_with("--nameserver "), "{msg}"); + assert!(msg.contains("bad ns"), "{msg}"); + } + + #[test] + fn validate_nameserver_hosts_passes_through_valid_hosts_unchanged() { + let hosts = vec!["ns1.example.com".to_string(), "ns2.example.com".to_string()]; + assert_eq!( + validate_nameserver_hosts(hosts.clone()).expect("valid hosts"), + hosts + ); + } + + #[test] + fn validate_nameserver_hosts_empty_list_stays_empty() { + assert_eq!( + validate_nameserver_hosts(Vec::new()).expect("empty is valid"), + Vec::::new() + ); + } +} diff --git a/rust/src/domain/get.rs b/rust/src/domain/get.rs index 7c5dd623..ef7938ac 100644 --- a/rust/src/domain/get.rs +++ b/rust/src/domain/get.rs @@ -6,10 +6,12 @@ use cli_engine::{ use domains_client::types; -use super::common::{api_error, make_client, validate_domain_name}; +use super::common::{fetch_with_domain_retry, resolve_domain_name}; use crate::next_action::next_action; use crate::scopes::DOMAINS_READ; +const DOMAIN_PROMPT: &str = "Domain to look up (e.g. example.com)"; + #[derive(Debug, Clone, clap::Args)] struct GetArgs { /// Domain to look up (must be in your account), e.g. example.com. @@ -30,18 +32,24 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_json_schema::() .with_scopes(&[DOMAINS_READ]), |ctx, args: GetArgs| async move { - let domain = validate_domain_name(&args.domain)?; let debug = !ctx.middleware.debug.is_empty(); - let client = make_client(&ctx).await?; - let detail = match client - .get_domain() - .domain_name(domain.as_str()) - .send() - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("retrieving domain details", debug, e).await), - }; + let domain = resolve_domain_name(&ctx, &args.domain, DOMAIN_PROMPT)?; + let detail = fetch_with_domain_retry( + &ctx, + domain, + DOMAIN_PROMPT, + "retrieving domain details", + debug, + |client, domain| async move { + client + .get_domain() + .domain_name(domain.as_str()) + .send() + .await + .map(|r| r.into_inner()) + }, + ) + .await?; let value = serde_json::to_value(&detail).map_err(|e| { CliCoreError::message(format!("failed to serialize domain details: {e}")) })?; diff --git a/rust/src/domain/guides/domain-register.md b/rust/src/domain/guides/domain-register.md new file mode 100644 index 00000000..415bf9d7 --- /dev/null +++ b/rust/src/domain/guides/domain-register.md @@ -0,0 +1,94 @@ +--- +summary: Interactive domain registration wizard — guided step-by-step domain purchase +--- + +# Interactive domain registration with `gddy domain register` + +The `register` command is an interactive wizard that walks you through +the entire domain registration process in a single session: + +``` +gddy domain register +``` + +## How it works + +The wizard guides you through 5 steps: + +1. **Discovery** — Search for a domain or enter one directly. If taken, view + suggestions and pick an alternative. +2. **Options** — Choose registration period (1–10 years), WHOIS privacy, and + auto-renewal. Optionally set custom nameservers. +3. **Contacts** — Use your account default contacts, load saved contacts from + `contacts.toml`, or enter new ones interactively. +4. **Review & Confirm** — See the full order summary (price, renewal, agreements) + and explicitly consent before any charge is made. +5. **Register** — Submit the registration and wait for the registry to confirm. + +You can go back to a previous step at any point. Pressing Ctrl+C at any time +cancels the wizard — **no charges are made until you explicitly confirm in +Step 4**. + +## Non-interactive mode + +For scripts and CI, pass all options as flags: + +``` +gddy domain register example.com \ + --period 2 \ + --privacy true \ + --auto-renew true \ + --agree \ + --confirm +``` + +Required flags in non-interactive mode: +- Domain name (positional argument) +- `--agree` — consent to legal agreements +- `--confirm` — authorize the purchase + +## Contacts + +The wizard checks for saved contacts at `~/.config/gddy/contacts.toml`. +If found, you can reuse them without re-entering details each time. + +To create a starter contacts file: +``` +gddy domain contacts init +``` + +When you enter contacts manually during the wizard, you'll be offered to save +them for future registrations. + +## Payment + +A valid payment method (credit card or Good-as-Gold balance) must be on file. +If the quote fails with a payment error, the wizard will offer to open the +GoDaddy payment methods page in your browser. + +## Entry from other commands + +When running interactively, related commands offer to continue into registration: + +- `gddy domain available example.com` — if available, asks "Would you like to register?" +- `gddy domain suggest "keywords"` — after results, asks "Would you like to register one?" +- `gddy domain quote example.com` — after pricing, asks "Would you like to purchase now?" + +## Examples + +``` +# Full interactive wizard +gddy domain register + +# Start with a specific domain (skips discovery search) +gddy domain register example.com + +# Non-interactive for scripts +gddy domain register example.com --period 1 --agree --confirm + +# With custom nameservers +gddy domain register example.com \ + --nameserver ns1.example.net \ + --nameserver ns2.example.net \ + --agree --confirm +``` diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index 61ccf620..a12ea382 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -1,5 +1,8 @@ //! `gddy domain list` — list the domains in the account (v3). +// Interactive status recovery prompts write user-facing feedback to stderr. +#![allow(clippy::print_stderr)] + use cli_engine::{ CliCoreError, CommandResult, CommandSpec, NextActionParam, PaginationConfig, Result, RuntimeCommandSpec, Tier, @@ -83,6 +86,34 @@ fn wants_visible_only(statuses: &[String], show_hidden: bool) -> bool { statuses.is_empty() && !show_hidden } +/// Prompt until the user enters a valid `--status` value or cancels. +fn prompt_validated_status() -> Result> { + use dialoguer::Input; + + let input: String = Input::new() + .with_prompt("Domain status filter (e.g. ACTIVE)") + .validate_with(|input: &String| -> std::result::Result<(), String> { + parse_statuses(std::slice::from_ref(input)) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + parse_statuses(&[input]) +} + +/// Validate `--status` values, re-prompting interactively when invalid. +fn resolve_statuses(ctx: &cli_engine::CommandContext, raw: Vec) -> Result> { + match parse_statuses(&raw) { + Ok(statuses) => Ok(statuses), + Err(e) if ctx.is_interactive() && !raw.is_empty() => { + eprintln!(" {e}"); + prompt_validated_status() + } + Err(e) => Err(e), + } +} + #[derive(Debug, Clone, clap::Args)] struct ListArgs { /// Only domains with this status, e.g. ACTIVE (repeatable). @@ -254,7 +285,7 @@ pub(super) fn command() -> RuntimeCommandSpec { }), |ctx, args: ListArgs| async move { let debug = !ctx.middleware.debug.is_empty(); - let statuses = parse_statuses(&args.status)?; + let statuses = resolve_statuses(&ctx, args.status)?; let show_hidden = args.show_hidden; let visible_only = wants_visible_only(&statuses, show_hidden); let client = make_client(&ctx).await?; diff --git a/rust/src/domain/mod.rs b/rust/src/domain/mod.rs index fdf21621..a8385d12 100644 --- a/rust/src/domain/mod.rs +++ b/rust/src/domain/mod.rs @@ -28,6 +28,7 @@ mod nameservers; mod operation; mod purchase; mod quote; +mod register; mod suggest; // Shared with the `dns` module, which builds the same Domains API client and @@ -46,13 +47,13 @@ pub fn module() -> Module { \n\ • list / get — your existing domains and their details\n\ • available / suggest — find a name to register\n\ - • quote — price a registration and see required agreements\n\ - • purchase — register a new domain (charges your account)\n\ + • register — interactive wizard: discover, configure, and buy\n\ + • quote / purchase — scripted two-step registration (quote then buy)\n\ • nameservers set — point a domain at custom nameservers\n\ - • operation status — check on an async operation (e.g. a pending purchase)\n\ + • operation — check on an async operation (e.g. a pending purchase)\n\ \n\ - Reads need the `domains.domain:read` scope; purchase also needs\n\ - `domains.domain:create`, and `nameservers set` needs\n\ + Reads need the `domains.domain:read` scope; purchase/register also\n\ + need `domains.domain:create`, and `nameservers set` needs\n\ `domains.nameserver:update`. Manage a domain's DNS with `gddy dns`.", ), ) @@ -63,14 +64,21 @@ pub fn module() -> Module { .with_command(agreements::command()) .with_command(quote::command()) .with_command(purchase::command()) + .with_command(register::command()) .with_group(nameservers::group()) .with_group(contacts::group()) - .with_group(operation::group()) + .with_command(operation::command()) }) - .with_guides_from_markdown([( - "domain-purchase.md", - include_bytes!("guides/domain-purchase.md").as_slice(), - )]) + .with_guides_from_markdown([ + ( + "domain-purchase.md", + include_bytes!("guides/domain-purchase.md").as_slice(), + ), + ( + "domain-register.md", + include_bytes!("guides/domain-register.md").as_slice(), + ), + ]) } #[cfg(test)] @@ -86,7 +94,7 @@ mod tests { #[tokio::test] async fn domain_commands_require_auth() { const AUTH_FAILURE_EXIT: i32 = 2; - let cases: [&[&str]; 9] = [ + let cases: [&[&str]; 10] = [ &["gddy", "domain", "list", "--output", "json"], &["gddy", "domain", "get", "example.com", "--output", "json"], &[ @@ -121,6 +129,7 @@ mod tests { "--output", "json", ], + &["gddy", "domain", "register", "--output", "json"], &[ "gddy", "domain", @@ -136,7 +145,6 @@ mod tests { "gddy", "domain", "operation", - "status", "dummy-op-id", "--output", "json", diff --git a/rust/src/domain/nameservers.rs b/rust/src/domain/nameservers.rs index 227fe5ca..3edd6f99 100644 --- a/rust/src/domain/nameservers.rs +++ b/rust/src/domain/nameservers.rs @@ -8,7 +8,7 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, make_client, validate_domain_name, validate_nameserver_hosts}; +use super::common::{api_error, make_client, resolve_domain_name, resolve_nameserver_hosts}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_NAMESERVER_UPDATE; @@ -55,8 +55,16 @@ pub(super) fn group() -> RuntimeGroupSpec { .with_output_schema::() .with_scopes(&[DOMAINS_NAMESERVER_UPDATE]), |ctx, args: NameserversSetArgs| async move { - let domain = validate_domain_name(&args.domain)?; - let hosts = validate_nameserver_hosts(args.nameserver)?; + let domain = resolve_domain_name( + &ctx, + &args.domain, + "Domain whose nameservers to replace (e.g. example.com)", + )?; + let hosts = resolve_nameserver_hosts( + &ctx, + args.nameserver, + "Nameserver host (e.g. ns1.example.com)", + )?; let debug = !ctx.middleware.debug.is_empty(); let client = make_client(&ctx).await?; diff --git a/rust/src/domain/operation.rs b/rust/src/domain/operation.rs index 3b15ea91..c44179ff 100644 --- a/rust/src/domain/operation.rs +++ b/rust/src/domain/operation.rs @@ -1,23 +1,24 @@ -//! `gddy domain operation status` — poll an async domain operation (v3). +//! `gddy domain operation` — poll an async domain operation (v3). //! //! Every async domain mutation (register, nameserver updates, and eventually //! renew/transfer) returns an `operationId` that can be re-checked here via the //! same `GET /v3/domains/operations/{operationId}` endpoint `purchase`'s //! bounded poll loop already uses internally. -use cli_engine::{ - CommandResult, CommandSpec, GroupSpec, NextActionParam, RuntimeCommandSpec, RuntimeGroupSpec, - Tier, -}; +use cli_engine::{CommandResult, CommandSpec, NextActionParam, RuntimeCommandSpec, Tier}; use serde_json::json; use domains_client::types; -use super::common::{api_error, format_operation_error, is_terminal_status, make_client}; +use super::common::{ + fetch_with_operation_retry, format_operation_error, is_terminal_status, resolve_operation_id, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; +const OPERATION_ID_PROMPT: &str = "Operation ID returned by an async domain mutation (UUID)"; + output_schema!(DomainOperationStatusResult { "operationId": "string"; "type": "string"; @@ -30,16 +31,16 @@ output_schema!(DomainOperationStatusResult { }); #[derive(Debug, Clone, clap::Args)] -struct OperationStatusArgs { +struct OperationArgs { /// The operationId returned by an async domain mutation. #[arg(value_name = "OPERATION_ID")] operation_id: String, } -fn status_command() -> RuntimeCommandSpec { - RuntimeCommandSpec::new_typed_with_context::( - CommandSpec::from_args::( - "status", +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "operation", "Check the status of an async domain operation", ) .with_long( @@ -53,20 +54,26 @@ fn status_command() -> RuntimeCommandSpec { .with_default_fields("operationId,type,domain,status,orderId,expiresAt,error") .with_output_schema::() .with_scopes(&[DOMAINS_READ]), - |ctx, args: OperationStatusArgs| async move { - let operation_id = args.operation_id; + |ctx, args: OperationArgs| async move { let debug = !ctx.middleware.debug.is_empty(); + let operation_id = resolve_operation_id(&ctx, &args.operation_id, OPERATION_ID_PROMPT)?; - let client = make_client(&ctx).await?; - let op = match client - .get_operation() - .operation_id(types::Uuid(operation_id.clone())) - .send() - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("checking domain operation status", debug, e).await), - }; + let op = fetch_with_operation_retry( + &ctx, + operation_id, + OPERATION_ID_PROMPT, + "checking domain operation status", + debug, + |client, id| async move { + client + .get_operation() + .operation_id(types::Uuid(id)) + .send() + .await + .map(|r| r.into_inner()) + }, + ) + .await?; let status = op .status @@ -78,7 +85,7 @@ fn status_command() -> RuntimeCommandSpec { .operation_id .as_ref() .map(|id| id.to_string()) - .unwrap_or(operation_id); + .unwrap_or_default(); let op_type = op.type_.as_ref().map(|t| t.to_string()).unwrap_or_default(); let mut result = json!({ @@ -120,7 +127,7 @@ fn status_command() -> RuntimeCommandSpec { } else { actions.push( next_action( - "domain operation status ", + "domain operation ", "Re-check whether the operation has finished", ) .with_param("operation-id", NextActionParam::value(op_id)), @@ -131,11 +138,3 @@ fn status_command() -> RuntimeCommandSpec { }, ) } - -pub(super) fn group() -> RuntimeGroupSpec { - RuntimeGroupSpec::new(GroupSpec::new( - "operation", - "Check the status of async domain operations", - )) - .with_command(status_command()) -} diff --git a/rust/src/domain/purchase.rs b/rust/src/domain/purchase.rs index 7b399b0d..8e02004f 100644 --- a/rust/src/domain/purchase.rs +++ b/rust/src/domain/purchase.rs @@ -1,5 +1,7 @@ //! `gddy domain purchase` — register a domain by accepting a cached quote (v3). +#![allow(clippy::print_stderr)] // interactive quote-token recovery writes to stderr + use cli_engine::{ CliCoreError, CommandResult, CommandSpec, Credential, NextActionParam, Result, RuntimeCommandSpec, Tier, @@ -8,7 +10,10 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, format_operation_error, is_terminal_status, make_client_with_cred}; +use super::common::{ + api_error, format_operation_error, is_terminal_status, make_client_with_cred, + prompt_validated_quote_token, resolve_quote_token, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::quote_cache; @@ -136,8 +141,10 @@ fn purchase_consent_types( #[derive(Debug, Clone, clap::Args)] struct PurchaseArgs { /// The quote token from `gddy domain quote` (locks the price + settings). + /// Optional at the clap layer so interactive mode can give a clear recovery + /// path instead of prompting for an opaque UUID. #[arg(long = "quote-token", value_name = "TOKEN")] - quote_token: String, + quote_token: Option, /// Consent to the quote's legal agreements (run without it to list them; /// review with `gddy guide domain-purchase`). @@ -180,7 +187,9 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_output_schema::() .with_scopes(&[DOMAINS_READ, DOMAINS_CREATE]), |ctx, args: PurchaseArgs| async move { - let quote_token = args.quote_token; + const QUOTE_TOKEN_PROMPT: &str = "Quote token from `gddy domain quote`"; + + let mut quote_token = resolve_quote_token(&ctx, args.quote_token, QUOTE_TOKEN_PROMPT)?; let agree = args.agree; let confirm = args.confirm; let debug = !ctx.middleware.debug.is_empty(); @@ -197,28 +206,42 @@ pub(super) fn command() -> RuntimeCommandSpec { // Load the quote the user reviewed. Read-only: the entry is only // removed once the registration succeeds, so an un-`--agree`d run or // a failed charge leaves the quote reusable. - let cached = match quote_cache::get("e_token) { - quote_cache::Lookup::Found(q) => *q, - quote_cache::Lookup::Expired => { - return Err(CliCoreError::message( - "that quote has expired (quotes last ~10 minutes). Re-run \ + let cached = loop { + match quote_cache::get("e_token) { + quote_cache::Lookup::Found(q) => break *q, + quote_cache::Lookup::Expired if ctx.is_interactive() => { + eprintln!( + " that quote has expired (quotes last ~10 minutes); enter a fresh token from `gddy domain quote`" + ); + quote_token = prompt_validated_quote_token(QUOTE_TOKEN_PROMPT)?; + } + quote_cache::Lookup::Missing if ctx.is_interactive() => { + eprintln!( + " no cached quote for that token; run `gddy domain quote ` on this machine first" + ); + quote_token = prompt_validated_quote_token(QUOTE_TOKEN_PROMPT)?; + } + quote_cache::Lookup::Expired => { + return Err(CliCoreError::message( + "that quote has expired (quotes last ~10 minutes). Re-run \ `gddy domain quote ` for a fresh quote and token.", - )); - } - quote_cache::Lookup::Missing => { - return Err(CliCoreError::message( - "no cached quote for that token. Run `gddy domain quote ` \ + )); + } + quote_cache::Lookup::Missing => { + return Err(CliCoreError::message( + "no cached quote for that token. Run `gddy domain quote ` \ first — quotes are cached locally, so quote and purchase must run \ on the same machine within the token's ~10-minute lifetime.", - )); - } - quote_cache::Lookup::NoConfigDir => { - return Err(CliCoreError::message( - "could not locate a config directory to read the quote cache from. \ + )); + } + quote_cache::Lookup::NoConfigDir => { + return Err(CliCoreError::message( + "could not locate a config directory to read the quote cache from. \ `domain purchase` needs the local quote written by `domain quote`; \ ensure a home/config directory is available (e.g. set HOME or \ XDG_CONFIG_HOME) and re-run `gddy domain quote `.", - )); + )); + } } }; let domain = cached.domain.clone(); @@ -381,7 +404,7 @@ pub(super) fn command() -> RuntimeCommandSpec { %status, operation_id = %op, "registration still in progress after polling; check later with \ - `gddy domain operation status {op}`" + `gddy domain operation {op}`" ); } @@ -409,7 +432,7 @@ pub(super) fn command() -> RuntimeCommandSpec { if let Some(op) = &operation_id { actions.push( next_action( - "domain operation status ", + "domain operation ", "Check whether registration has finished since polling gave up", ) .with_param("operation-id", NextActionParam::value(op.to_string())), diff --git a/rust/src/domain/quote.rs b/rust/src/domain/quote.rs index 6d8a007e..a3f270bd 100644 --- a/rust/src/domain/quote.rs +++ b/rust/src/domain/quote.rs @@ -9,8 +9,8 @@ use serde_json::json; use domains_client::types; use super::common::{ - api_error, format_money, make_client, period_label, validate_domain_name, - validate_nameserver_hosts, + fetch_with_domain_retry, format_money, period_label, resolve_domain_name, + resolve_nameserver_hosts, }; use crate::next_action::next_action; use crate::output_schema::output_schema; @@ -305,11 +305,19 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_view(view_columns()) .with_scopes(&[DOMAINS_READ]), |ctx, args: QuoteArgs| async move { - let domain = validate_domain_name(&args.domain)?; + let domain = resolve_domain_name( + &ctx, + &args.domain, + "Domain name to quote (e.g. example.com)", + )?; let period = args.period; let privacy = args.privacy; let renew_auto = !args.no_renew; - let name_servers = validate_nameserver_hosts(args.nameserver)?; + let name_servers = resolve_nameserver_hosts( + &ctx, + args.nameserver, + "Custom nameserver host (e.g. ns1.example.com)", + )?; let debug = !ctx.middleware.debug.is_empty(); let period_nz = std::num::NonZeroU64::new(period).expect("clap value_parser enforces period >= 1"); @@ -317,7 +325,6 @@ pub(super) fn command() -> RuntimeCommandSpec { // Resolve auth first (fail closed before reading local config), then // build the profile. The token is bound to a hash of this quoted // request, so what's quoted here is what `purchase` later registers. - let client = make_client(&ctx).await?; let profile = build_profile(privacy, renew_auto, &name_servers)?; // Cache the exact profile we quote with: the token binds a hash of the // domain/price/profile, so `purchase` must re-send this verbatim or the @@ -330,20 +337,30 @@ pub(super) fn command() -> RuntimeCommandSpec { )) })?; - let quote = match client - .quote_domain_registration() - .body(types::QuoteDomainRegistrationBody { - domain: domain.clone(), - period: period_nz, - profile: Some(profile), - profile_id: None, - }) - .send() - .await - { - Ok(r) => r.into_inner(), - Err(e) => return Err(api_error("quoting registration", debug, e).await), - }; + let quote = fetch_with_domain_retry( + &ctx, + domain.clone(), + "Domain name to quote (e.g. example.com)", + "quoting registration", + debug, + |client, domain| { + let profile = profile.clone(); + async move { + client + .quote_domain_registration() + .body(types::QuoteDomainRegistrationBody { + domain, + period: period_nz, + profile: Some(profile), + profile_id: None, + }) + .send() + .await + .map(|r| r.into_inner()) + } + }, + ) + .await?; let view = quote_to_json("e, &domain); @@ -397,6 +414,31 @@ pub(super) fn command() -> RuntimeCommandSpec { // find it. Warn so the user knows to re-quote on this host. tracing::warn!(error = %e, "could not cache the quote for purchase"); } + // If interactive, offer to purchase directly. + let quote_price = view + .get("price") + .and_then(|v| v.as_str()) + .map(str::to_owned); + let quote_currency = view + .get("currency") + .and_then(|v| v.as_str()) + .map(str::to_owned); + match super::register::bridge::offer_registration_from_quote( + &ctx, + &domain, + &token, + quote_price, + quote_currency, + period, + ) + .await? + { + super::register::BridgeHandoff::Replace(wizard_result) => { + return Ok(wizard_result); + } + super::register::BridgeHandoff::ShowHostOutput => {} + } + next_actions.push( next_action( "domain purchase --quote-token --agree --confirm", diff --git a/rust/src/domain/register/bridge.rs b/rust/src/domain/register/bridge.rs new file mode 100644 index 00000000..55c62c56 --- /dev/null +++ b/rust/src/domain/register/bridge.rs @@ -0,0 +1,168 @@ +//! Bridge functions allowing other domain commands (suggest, available, quote) +//! to hand off to the registration wizard when running interactively. + +use cli_engine::{CliCoreError, Result}; +use dialoguer::Confirm; + +use super::wizard::WizardState; +use super::{BridgeHandoff, WizardExit, cancelled_host_result}; + +/// After `domain available` finds a domain is available, offer to continue +/// with registration. Returns [`BridgeHandoff::ShowHostOutput`] if the user +/// declines; re-asks if the user navigates back from the wizard. +pub(crate) async fn offer_registration_from_available( + ctx: &cli_engine::CommandContext, + domain: &str, + price: Option, + currency: Option, + available_periods: Vec, + period_prices: std::collections::BTreeMap, +) -> Result { + if !ctx.is_interactive() { + return Ok(BridgeHandoff::ShowHostOutput); + } + + loop { + let proceed = Confirm::new() + .with_prompt(format!("Would you like to register {domain}?")) + .default(false) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if !proceed { + return Ok(BridgeHandoff::ShowHostOutput); + } + + let mut state = WizardState::new().with_domain(Some(domain.to_owned())); + state.available = true; + state.price = price.clone(); + state.currency = currency.clone(); + state.available_periods = available_periods.clone(); + state.period_prices = period_prices.clone(); + + match super::launch_wizard(ctx, state, 1).await? { + WizardExit::Completed(result) => { + return Ok(BridgeHandoff::Replace(super::present_for_host_command( + ctx, result, + ))); + } + WizardExit::BackedOut => continue, + WizardExit::Cancelled => { + return Ok(BridgeHandoff::Replace(cancelled_host_result(ctx))); + } + } + } +} + +/// After `domain suggest` displays results, offer to pick one and register. +/// Returns [`BridgeHandoff::ShowHostOutput`] if the user chooses to skip. +/// Re-shows the selection if the user navigates back from the wizard. +pub(crate) async fn offer_registration_from_suggest( + ctx: &cli_engine::CommandContext, + suggestions: &[String], +) -> Result { + if !ctx.is_interactive() || suggestions.is_empty() { + return Ok(BridgeHandoff::ShowHostOutput); + } + + // Show suggestions inline so the user sees what's available before choosing. + eprintln!("\n Here are some available domains based on your input:\n"); + for (i, name) in suggestions.iter().enumerate() { + eprintln!(" {}. {}", i + 1, name); + } + eprintln!(); + + let mut items: Vec = suggestions.to_vec(); + items.push("(enter a different domain)".to_owned()); + items.push("(skip — just show results)".to_owned()); + + loop { + let selection = dialoguer::Select::new() + .with_prompt("Would you like to register one of these domains? Select one to proceed") + .items(&items) + .default(items.len() - 1) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + // Last option = skip, return None to let normal output render. + if selection == items.len() - 1 { + return Ok(BridgeHandoff::ShowHostOutput); + } + + // Second-to-last = enter a different domain. + let domain = if selection == items.len() - 2 { + None + } else { + Some(items[selection].clone()) + }; + + let mut state = WizardState::new().with_domain(domain.clone()); + let exit = if domain.is_some() { + state.available = true; + super::launch_wizard(ctx, state, 1).await? + } else { + super::launch_wizard(ctx, state, 0).await? + }; + + match exit { + WizardExit::Completed(result) => { + return Ok(BridgeHandoff::Replace(super::present_for_host_command( + ctx, result, + ))); + } + WizardExit::BackedOut => continue, + WizardExit::Cancelled => { + return Ok(BridgeHandoff::Replace(cancelled_host_result(ctx))); + } + } + } +} + +/// After `domain quote` prices a domain, offer to purchase it directly. +/// Returns [`BridgeHandoff::ShowHostOutput`] if the user declines. Re-asks if +/// the user navigates back from the wizard. +pub(crate) async fn offer_registration_from_quote( + ctx: &cli_engine::CommandContext, + domain: &str, + quote_token: &str, + price: Option, + currency: Option, + period: u64, +) -> Result { + if !ctx.is_interactive() { + return Ok(BridgeHandoff::ShowHostOutput); + } + + loop { + let proceed = Confirm::new() + .with_prompt(format!("Would you like to purchase {domain} now?")) + .default(false) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if !proceed { + return Ok(BridgeHandoff::ShowHostOutput); + } + + let mut state = WizardState::new() + .with_domain(Some(domain.to_owned())) + .with_period(period); + state.available = true; + state.quote_token = Some(quote_token.to_owned()); + state.price = price.clone(); + state.currency = currency.clone(); + + // Start at step 3 (Review & Confirm) since quote is already done. + match super::launch_wizard(ctx, state, 3).await? { + WizardExit::Completed(result) => { + return Ok(BridgeHandoff::Replace(super::present_for_host_command( + ctx, result, + ))); + } + WizardExit::BackedOut => continue, + WizardExit::Cancelled => { + return Ok(BridgeHandoff::Replace(cancelled_host_result(ctx))); + } + } + } +} diff --git a/rust/src/domain/register/mod.rs b/rust/src/domain/register/mod.rs new file mode 100644 index 00000000..263eef28 --- /dev/null +++ b/rust/src/domain/register/mod.rs @@ -0,0 +1,544 @@ +//! `gddy domain register` — interactive guided domain registration wizard. +//! +//! Walks the user through discovery → configure → confirm → buy in a single +//! session. In non-interactive mode, all options must be passed as flags; +//! the command validates them and executes directly without prompts. + +use cli_engine::{ + CliCoreError, CommandContext, CommandResult, CommandSpec, Envelope, NextActionParam, Result, + RuntimeCommandSpec, TableColumn, Tier, render_human_with_view, +}; +use serde_json::json; + +use crate::domain::common::{is_terminal_status, resolve_domain_name, validate_domain_name}; +use crate::next_action::{next_action, required_value}; +use crate::output_schema::output_schema; +use crate::scopes::{DOMAINS_CREATE, DOMAINS_READ}; + +// Wizard steps write interactive UI to stderr via eprintln and dialoguer/console. +// This is intentional user-facing output, not diagnostic logging. +#[allow(clippy::print_stderr)] +pub(crate) mod bridge; +#[allow(clippy::print_stderr)] +pub(crate) mod steps; +#[allow(clippy::print_stderr)] +pub(crate) mod wizard; + +use wizard::{StepContext, WizardState}; + +output_schema!(DomainRegisterResult { + "domain": "string"; + "status": "string"; + "operationId": "string", optional; + "price": "string", optional; + "currency": "string", optional; +}); + +fn view_columns() -> Vec { + vec![ + TableColumn::new("domain", "Domain"), + TableColumn::new("status", "Status"), + TableColumn::new("operationId", "Operation ID").no_truncate(true), + TableColumn::new("price", "Price"), + TableColumn::new("currency", "Currency"), + ] +} + +#[derive(Debug, Clone, clap::Args)] +struct RegisterArgs { + /// Domain name to register (omit for interactive discovery). + #[arg(value_name = "DOMAIN")] + domain: Option, + + /// Registration period in years (default: 1). + #[arg(long, default_value = "1", value_name = "YEARS")] + period: u64, + + /// Enable WHOIS privacy protection. + #[arg(long, default_value = "true", action = clap::ArgAction::Set)] + privacy: bool, + + /// Enable automatic renewal. + #[arg(long = "auto-renew", default_value = "true", action = clap::ArgAction::Set)] + auto_renew: bool, + + /// Custom nameserver (repeatable; omit for GoDaddy defaults). + #[arg(long = "nameserver", value_name = "HOST")] + nameservers: Vec, + + /// Consent to legal agreements (required in non-interactive mode). + #[arg(long)] + agree: bool, + + /// Confirm the purchase (required in non-interactive mode). + #[arg(long)] + confirm: bool, +} + +pub(super) fn command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_typed_with_context::( + CommandSpec::from_args::( + "register", + "Register a new domain (interactive wizard or direct)", + ) + .with_long( + "Register a new domain interactively or with flags.\n\ + \n\ + In interactive mode (default when running in a terminal), the wizard\n\ + walks you through: domain discovery → registration options → quote\n\ + review → purchase execution.\n\ + \n\ + In non-interactive mode (piped input, CI, or --non-interactive), pass\n\ + the domain name and all options as flags:\n\ + \n \ + gddy domain register example.com --period 1 --agree --confirm\n\ + \n\ + Registration charges your GoDaddy account and cannot be undone.\n\ + A usable payment method must be on file.", + ) + .with_system("domain") + .with_tier(Tier::Destructive) + .with_default_fields("domain,status,operationId,price,currency") + .with_output_schema::() + .with_view(view_columns()) + .with_scopes(&[DOMAINS_READ, DOMAINS_CREATE]), + |ctx, args: RegisterArgs| async move { + let is_interactive = ctx.is_interactive(); + + if is_interactive { + run_interactive(ctx, args).await + } else { + run_non_interactive(ctx, args).await + } + }, + ) +} + +async fn run_interactive(ctx: CommandContext, args: RegisterArgs) -> Result { + let cred = ctx.credential().await?; + let env = ctx.middleware.env.clone(); + let debug = !ctx.middleware.debug.is_empty(); + + // Pre-populate state from any flags the user already provided. + let domain = match args.domain { + Some(d) => Some(resolve_domain_name( + &ctx, + &d, + "Enter domain name to register (e.g. example.com)", + )?), + None => None, + }; + + let state = WizardState::new() + .with_domain(domain) + .with_period(args.period) + .with_privacy(args.privacy) + .with_auto_renew(args.auto_renew) + .with_nameservers(args.nameservers); + + let step_ctx = StepContext { + credential: cred, + env, + debug, + wizard_start_at: 0, + }; + + let final_state = wizard::run_wizard(state, step_ctx, 0).await?; + + if final_state.cancelled { + return Ok(CommandResult::new(json!({"status": "cancelled"}))); + } + build_result(&final_state) +} + +async fn run_non_interactive(ctx: CommandContext, args: RegisterArgs) -> Result { + let domain = args.domain.ok_or_else(|| { + CliCoreError::message( + "domain name is required in non-interactive mode; pass it as a positional argument\n\ + \n Example: gddy domain register example.com --period 1 --agree --confirm", + ) + })?; + let domain = validate_domain_name(&domain)?; + + if !args.agree { + return Err(CliCoreError::message( + "--agree is required in non-interactive mode to consent to legal agreements", + )); + } + if !args.confirm { + return Err(CliCoreError::message( + "--confirm is required in non-interactive mode to authorize the purchase charge", + )); + } + + let cred = ctx.credential().await?; + let env = ctx.middleware.env.clone(); + let debug = !ctx.middleware.debug.is_empty(); + + let mut state = WizardState::new() + .with_domain(Some(domain)) + .with_period(args.period) + .with_privacy(args.privacy) + .with_auto_renew(args.auto_renew) + .with_nameservers(args.nameservers); + state.available = true; + + let step_ctx = StepContext { + credential: cred, + env, + debug, + wizard_start_at: 0, + }; + + // Non-interactive: skip all interactive prompts. --agree and --confirm + // were validated above, so we go straight to quoting and executing. + steps::review::run_non_interactive(&mut state, &step_ctx).await?; + steps::execute::run(&mut state, &step_ctx).await?; + + build_result(&state) +} + +/// Result of an interactive bridge from another domain command into the wizard. +pub(crate) enum BridgeHandoff { + /// Replace the host command's normal stdout output (wizard completed or cancelled). + Replace(CommandResult), + /// Run the host command's normal output (user declined the bridge prompt). + ShowHostOutput, +} + +/// Host-command output after the user cancels the wizard. The wizard already +/// wrote a cancellation message to stderr; stdout should stay quiet in human mode. +pub(crate) fn cancelled_host_result(ctx: &CommandContext) -> CommandResult { + if ctx.middleware.output_format == "human" { + CommandResult::new(json!("")) + } else { + CommandResult::new(json!({"status": "cancelled"})) + } +} + +/// Wizard exit disposition, distinguishing user-initiated back-navigation from +/// explicit cancellation. +pub(crate) enum WizardExit { + /// Wizard completed — here's the result. + Completed(CommandResult), + /// User navigated back past the entry step (caller should re-show its UI). + BackedOut, + /// User explicitly cancelled (Cancel option or Ctrl+C). The wizard already + /// printed a user-facing message; callers should exit without rendering + /// additional output. + Cancelled, +} + +/// Launch the wizard from an external command (e.g. `domain available --interactive`). +/// `start_at` determines which step to begin from (0=discovery, 1=options, etc.). +pub(crate) async fn launch_wizard( + ctx: &CommandContext, + state: WizardState, + start_at: usize, +) -> Result { + let cred = ctx.credential().await?; + let env = ctx.middleware.env.clone(); + let debug = !ctx.middleware.debug.is_empty(); + + let step_ctx = StepContext { + credential: cred, + env, + debug, + wizard_start_at: start_at, + }; + + let final_state = wizard::run_wizard(state, step_ctx, start_at).await?; + if final_state.backed_out { + return Ok(WizardExit::BackedOut); + } + if final_state.cancelled { + return Ok(WizardExit::Cancelled); + } + build_result(&final_state).map(WizardExit::Completed) +} + +fn build_result(state: &WizardState) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain in final state"))?; + let status = state.status.as_deref().unwrap_or("UNKNOWN"); + + let mut result = json!({ + "domain": domain, + "status": status, + }); + if let Some(op) = &state.operation_id { + result["operationId"] = json!(op); + } + if let Some(p) = &state.price { + result["price"] = json!(p); + } + if let Some(c) = &state.currency { + result["currency"] = json!(c); + } + + let mut actions = vec![ + next_action("domain get ", next_action_description(status)) + .with_param("domain", required_value(domain.clone())), + next_action( + "dns set --type A --name @ --data ", + "Point the apex at an IPv4 address", + ) + .with_param("domain", required_value(domain.clone())) + .with_param("ip", NextActionParam::required()), + ]; + // Mirror `domain purchase`: when polling gave up before a terminal state, + // surface the operation-status follow-up with the concrete id prefilled. + if !is_terminal_status(status) + && let Some(op) = &state.operation_id + { + actions.push( + next_action( + "domain operation ", + "Check whether registration has finished since polling gave up", + ) + .with_param("operation-id", required_value(op.clone())), + ); + } + + Ok(CommandResult::new(result).with_next_actions(actions)) +} + +fn next_action_description(status: &str) -> &'static str { + if status == "COMPLETED" { + "See the registered domain's details" + } else { + "Check whether the domain has finished registering" + } +} + +/// Adapt a wizard `CommandResult` for return through a different leaf command +/// (`domain suggest` / `available` / `quote`). +/// +/// Those hosts register their own human views (e.g. suggest's `1yr Price` +/// columns). Rendering a register-shaped payload through them produces empty +/// mismatched fields. For human output we render with the register view +/// ourselves, then return an empty string so the host view path is bypassed +/// (scalar data skips column rendering). JSON/TOON keep the structured result. +pub(crate) fn present_for_host_command( + ctx: &CommandContext, + result: CommandResult, +) -> CommandResult { + if ctx.middleware.output_format != "human" { + return result; + } + + let envelope = + Envelope::success(result.data, "domain").with_next_actions(result.metadata.next_actions); + let rendered = render_human_with_view(&envelope, Some(&view_columns()), ""); + // Write the correctly shaped register summary now; middleware will then + // render the empty-string placeholder below (a blank line), not the host + // command's mismatched table. Use Write rather than print! so the + // print_stdout lint (denied as warnings) does not fire on intentional + // human-output writes. + let _ = std::io::Write::write_all(&mut std::io::stdout(), rendered.as_bytes()); + CommandResult::new(json!("")) +} + +#[cfg(test)] +mod tests { + use super::*; + use wizard::WizardState; + + #[test] + fn build_result_requires_domain_in_state() { + let state = WizardState::new(); + let err = build_result(&state).expect_err("should fail without domain"); + assert!( + err.to_string().contains("no domain"), + "expected domain error, got: {err}" + ); + } + + #[test] + fn build_result_produces_valid_json_with_minimal_state() { + let mut state = WizardState::new(); + state.domain = Some("example.com".to_string()); + state.status = Some("COMPLETED".to_string()); + + let result = build_result(&state).expect("should succeed"); + assert_eq!(result.data["domain"], "example.com"); + assert_eq!(result.data["status"], "COMPLETED"); + assert!(result.data.get("operationId").is_none()); + } + + #[test] + fn build_result_includes_optional_fields_when_present() { + let mut state = WizardState::new(); + state.domain = Some("test.io".to_string()); + state.status = Some("COMPLETED".to_string()); + state.operation_id = Some("op-123".to_string()); + state.price = Some("12.99".to_string()); + state.currency = Some("USD".to_string()); + + let result = build_result(&state).expect("should succeed"); + assert_eq!(result.data["operationId"], "op-123"); + assert_eq!(result.data["price"], "12.99"); + assert_eq!(result.data["currency"], "USD"); + } + + #[test] + fn build_result_prefills_domain_in_next_actions() { + let mut state = WizardState::new(); + state.domain = Some("example.com".to_string()); + state.status = Some("COMPLETED".to_string()); + + let result = build_result(&state).expect("should succeed"); + assert!( + !result.metadata.next_actions.is_empty(), + "expected next actions" + ); + let get = &result.metadata.next_actions[0]; + let domain = get + .params + .get("domain") + .and_then(|p| p.value.as_deref()) + .expect("domain param"); + assert_eq!(domain, "example.com"); + // Substitution of `` → `example.com` happens at render time + // via the envelope's next_actions footer; the stored template keeps + // the placeholder, with the concrete value in params. + assert!(get.command.contains(""), "{}", get.command); + assert!(get.params.get("domain").is_some_and(|p| p.required)); + } + + #[test] + fn build_result_adds_operation_status_when_still_pending() { + let mut state = WizardState::new(); + state.domain = Some("example.com".to_string()); + state.status = Some("EXECUTING".to_string()); + state.operation_id = Some("op-abc".to_string()); + + let result = build_result(&state).expect("should succeed"); + let status_action = result + .metadata + .next_actions + .iter() + .find(|a| a.command.contains("domain operation")) + .expect("pending registration should suggest operation status"); + assert_eq!( + status_action + .params + .get("operation-id") + .and_then(|p| p.value.as_deref()), + Some("op-abc") + ); + } + + #[test] + fn register_view_renders_register_shaped_payload() { + let payload = json!({ + "domain": "example.com", + "status": "COMPLETED", + "operationId": "op-1", + "price": "12.99", + "currency": "USD", + }); + let envelope = Envelope::success(payload, "domain"); + let rendered = render_human_with_view(&envelope, Some(&view_columns()), ""); + assert!(rendered.contains("Domain:"), "{rendered}"); + assert!(rendered.contains("example.com"), "{rendered}"); + assert!(rendered.contains("Status:"), "{rendered}"); + assert!(rendered.contains("COMPLETED"), "{rendered}"); + assert!(rendered.contains("12.99"), "{rendered}"); + assert!( + !rendered.contains("1yr Price"), + "must not use suggest's view labels: {rendered}" + ); + } + + #[test] + fn register_args_defaults_are_user_friendly() { + // Verify clap defaults match WizardState defaults. + let cmd = clap::Command::new("test"); + let cmd = ::augment_args(cmd); + + // period default is "1" + let period_arg = cmd.get_arguments().find(|a| a.get_id() == "period"); + assert!(period_arg.is_some()); + + // privacy default is "true" + let privacy_arg = cmd.get_arguments().find(|a| a.get_id() == "privacy"); + assert!(privacy_arg.is_some()); + } + + #[test] + fn non_interactive_requires_domain_arg() { + let args = RegisterArgs { + domain: None, + period: 1, + privacy: true, + auto_renew: true, + nameservers: vec![], + agree: true, + confirm: true, + }; + // Simulate the check from run_non_interactive. + let err = args.domain.ok_or_else(|| { + CliCoreError::message("domain name is required in non-interactive mode") + }); + assert!(err.is_err()); + assert!( + err.expect_err("should be missing domain") + .to_string() + .contains("domain name is required") + ); + } + + #[test] + fn non_interactive_requires_agree_flag() { + let args = RegisterArgs { + domain: Some("example.com".to_string()), + period: 1, + privacy: true, + auto_renew: true, + nameservers: vec![], + agree: false, + confirm: true, + }; + assert!(!args.agree, "--agree should be false"); + } + + #[test] + fn non_interactive_requires_confirm_flag() { + let args = RegisterArgs { + domain: Some("example.com".to_string()), + period: 1, + privacy: true, + auto_renew: true, + nameservers: vec![], + agree: true, + confirm: false, + }; + assert!(!args.confirm, "--confirm should be false"); + } + + #[test] + fn wizard_state_from_args_maps_correctly() { + let args = RegisterArgs { + domain: Some("test.io".to_string()), + period: 3, + privacy: false, + auto_renew: false, + nameservers: vec!["ns1.test.io".to_string(), "ns2.test.io".to_string()], + agree: true, + confirm: true, + }; + let state = WizardState::new() + .with_period(args.period) + .with_privacy(args.privacy) + .with_auto_renew(args.auto_renew) + .with_nameservers(args.nameservers.clone()); + + assert_eq!(state.period, 3); + assert!(!state.privacy); + assert!(!state.auto_renew); + assert_eq!(state.nameservers.len(), 2); + } +} diff --git a/rust/src/domain/register/steps/contacts.rs b/rust/src/domain/register/steps/contacts.rs new file mode 100644 index 00000000..00ed7cc9 --- /dev/null +++ b/rust/src/domain/register/steps/contacts.rs @@ -0,0 +1,422 @@ +//! Step 3: Contacts — offer to reuse saved contacts, enter new ones, or use +//! account defaults. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use dialoguer::{Confirm, Input, Select}; + +use crate::contacts::{self, Contact, ContactsFile, Role}; + +use super::super::wizard::{ContactsChoice, StepContext, StepResult, WizardState}; + +pub(crate) async fn run(state: &mut WizardState, _ctx: &StepContext) -> Result { + eprintln!( + "\n {} Setting up contacts for registration", + style("👤").bold() + ); + + // Try to load existing contacts.toml. + let saved = contacts::load().ok(); + let has_saved = saved + .as_ref() + .map(|f| f.get(Role::Registrant).is_some()) + .unwrap_or(false); + + let choice = if has_saved { + let options = vec![ + "Use saved contacts from contacts.toml", + "Use account default contacts (no file needed)", + "Enter contacts manually", + "↩ Go back", + ]; + let selection = Select::new() + .with_prompt("How would you like to supply contacts?") + .items(&options) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 0 => { + let file = saved.expect("checked above"); + display_saved_contacts(&file); + ContactsChoice::FromFile(file) + } + 1 => ContactsChoice::AccountDefault, + 2 => collect_contacts_interactively()?, + 3 => return Ok(StepResult::Back), + _ => unreachable!(), + } + } else { + let options = vec![ + "Use account default contacts", + "Enter contacts manually", + "↩ Go back", + ]; + let selection = Select::new() + .with_prompt("How would you like to supply contacts?") + .items(&options) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 0 => ContactsChoice::AccountDefault, + 1 => collect_contacts_interactively()?, + 2 => return Ok(StepResult::Back), + _ => unreachable!(), + } + }; + + match &choice { + ContactsChoice::AccountDefault => { + eprintln!( + " {} Using account default contacts", + style("✓").green().bold() + ); + } + ContactsChoice::FromFile(_) => { + eprintln!( + " {} Using saved contacts from contacts.toml", + style("✓").green().bold() + ); + } + ContactsChoice::Manual(_) => { + eprintln!( + " {} Contacts entered successfully", + style("✓").green().bold() + ); + } + } + + state.contacts = choice; + Ok(StepResult::Continue) +} + +fn display_saved_contacts(file: &ContactsFile) { + eprintln!(" Saved contacts from contacts.toml:"); + for role in [Role::Registrant, Role::Admin, Role::Billing, Role::Tech] { + if file.get(role).is_some() { + eprintln!(" {} {}", style("•").dim(), style(role.label()).bold(),); + } + } +} + +fn collect_contacts_interactively() -> Result { + eprintln!("\n Enter registrant contact details (other roles will use account defaults):"); + + let name_first = prompt_required("First name")?; + let name_last = prompt_required("Last name")?; + let email = prompt_validated("Email", validate_email)?; + let phone = prompt_validated("Phone (e.g. +1.4805551212)", validate_phone)?; + let organization = prompt_optional("Organization (optional, press Enter to skip)")?; + let address1 = prompt_required("Address line 1")?; + let address2 = prompt_optional("Address line 2 (optional, press Enter to skip)")?; + let city = prompt_required("City")?; + let state_prov = prompt_required("State/Province")?; + let postal_code = prompt_required("Postal code")?; + let country = prompt_validated("Country code (2-letter ISO, e.g. US)", validate_country)?; + + let contact = Contact { + name_first, + name_last, + email, + phone, + organization, + address1, + address2, + city, + state: state_prov, + postal_code, + country, + }; + + // Offer to save for future use. + let save = Confirm::new() + .with_prompt("Save these contacts to contacts.toml for future registrations?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if save { + if let Err(e) = save_contact_to_file(&contact) { + eprintln!( + " {} Could not save contacts: {e}", + style("⚠").yellow().bold() + ); + } else if let Some(path) = contacts::contacts_path() { + eprintln!( + " {} Saved to {}", + style("✓").green().bold(), + style(path.display()).dim() + ); + } + } + + let file = ContactsFile { + registrant: Some(contact), + admin: None, + billing: None, + tech: None, + }; + + Ok(ContactsChoice::Manual(file)) +} + +fn prompt_required(label: &str) -> Result { + let value: String = Input::new() + .with_prompt(format!(" Enter {label}")) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + return Err(CliCoreError::message(format!("{label} cannot be empty"))); + } + Ok(trimmed) +} + +fn prompt_optional(label: &str) -> Result> { + let value: String = Input::new() + .with_prompt(format!(" Enter {label} (optional)")) + .allow_empty(true) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed)) + } +} + +fn prompt_validated( + label: &str, + validate: fn(&str) -> std::result::Result<(), String>, +) -> Result { + loop { + let value: String = Input::new() + .with_prompt(format!(" Enter {label}")) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + eprintln!(" This field is required."); + continue; + } + match validate(&trimmed) { + Ok(()) => return Ok(trimmed), + Err(msg) => { + eprintln!(" {}", style(&msg).red()); + continue; + } + } + } +} + +fn validate_email(email: &str) -> std::result::Result<(), String> { + if email.contains('@') && email.contains('.') && email.len() >= 5 { + Ok(()) + } else { + Err("Invalid email format (expected user@domain.tld)".to_owned()) + } +} + +fn validate_phone(phone: &str) -> std::result::Result<(), String> { + // Accept anything the phonenumber crate can parse (validated fully at + // to_api() time); here we just do a basic format check. + if phone.len() >= 7 + && phone.chars().all(|c| { + c.is_ascii_digit() + || c == '+' + || c == '.' + || c == '-' + || c == ' ' + || c == '(' + || c == ')' + }) + { + Ok(()) + } else { + Err( + "Invalid phone format (expected something like +1.4805551212 or (480) 555-1212)" + .to_owned(), + ) + } +} + +fn validate_country(code: &str) -> std::result::Result<(), String> { + let upper = code.to_ascii_uppercase(); + if upper == "C2" || (upper.len() == 2 && upper.bytes().all(|b| b.is_ascii_uppercase())) { + Ok(()) + } else { + Err("Expected a two-letter ISO country code (e.g. US, GB, CA)".to_owned()) + } +} + +/// Save a contact as the registrant in contacts.toml. +pub(crate) fn save_contact_to_file(contact: &Contact) -> std::result::Result<(), String> { + let path = contacts::contacts_path() + .ok_or_else(|| "could not determine config directory".to_owned())?; + + // Ensure parent directory exists. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("could not create config directory: {e}"))?; + } + + let toml_content = format!( + r#"# gddy domain registration contacts +# Saved by `gddy domain register` interactive wizard. + +[registrant] +name_first = "{first}" +name_last = "{last}" +email = "{email}" +phone = "{phone}" +{org}address1 = "{addr1}" +{addr2}city = "{city}" +state = "{state}" +postal_code = "{postal}" +country = "{country}" +"#, + first = escape_toml(&contact.name_first), + last = escape_toml(&contact.name_last), + email = escape_toml(&contact.email), + phone = escape_toml(&contact.phone), + org = contact + .organization + .as_ref() + .map(|o| format!("organization = \"{}\"\n", escape_toml(o))) + .unwrap_or_default(), + addr1 = escape_toml(&contact.address1), + addr2 = contact + .address2 + .as_ref() + .map(|a| format!("address2 = \"{}\"\n", escape_toml(a))) + .unwrap_or_default(), + city = escape_toml(&contact.city), + state = escape_toml(&contact.state), + postal = escape_toml(&contact.postal_code), + country = escape_toml(&contact.country), + ); + + std::fs::write(&path, toml_content) + .map_err(|e| format!("could not write {}: {e}", path.display())) +} + +fn escape_toml(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::NamedTempFile; + + #[test] + fn validate_email_accepts_valid() { + assert!(validate_email("user@example.com").is_ok()); + assert!(validate_email("a@b.c").is_ok()); + } + + #[test] + fn validate_email_rejects_invalid() { + assert!(validate_email("notanemail").is_err()); + assert!(validate_email("@.").is_err()); + assert!(validate_email("").is_err()); + } + + #[test] + fn validate_phone_accepts_common_formats() { + assert!(validate_phone("+1.4805551212").is_ok()); + assert!(validate_phone("(480) 555-1212").is_ok()); + assert!(validate_phone("+44 7793 601890").is_ok()); + } + + #[test] + fn validate_phone_rejects_garbage() { + assert!(validate_phone("abc").is_err()); + assert!(validate_phone("").is_err()); + } + + #[test] + fn validate_country_accepts_iso_codes() { + assert!(validate_country("US").is_ok()); + assert!(validate_country("us").is_ok()); + assert!(validate_country("GB").is_ok()); + assert!(validate_country("C2").is_ok()); + } + + #[test] + fn validate_country_rejects_invalid() { + assert!(validate_country("USA").is_err()); + assert!(validate_country("1").is_err()); + assert!(validate_country("").is_err()); + } + + #[test] + fn escape_toml_handles_special_chars() { + assert_eq!(escape_toml(r#"hello "world""#), r#"hello \"world\""#); + assert_eq!(escape_toml(r"path\to"), r"path\\to"); + } + + #[test] + fn save_contact_roundtrip() { + let contact = Contact { + name_first: "Ada".to_owned(), + name_last: "Lovelace".to_owned(), + email: "ada@example.com".to_owned(), + phone: "+1.4805551212".to_owned(), + organization: Some("Engines Inc".to_owned()), + address1: "1 Bletchley Park".to_owned(), + address2: Some("Suite 100".to_owned()), + city: "Tempe".to_owned(), + state: "AZ".to_owned(), + postal_code: "85281".to_owned(), + country: "US".to_owned(), + }; + + let tmp = NamedTempFile::new().expect("tmpfile"); + let path = tmp.path().to_path_buf(); + + // Write to a temp file to verify the generated TOML is valid. + let toml_content = format!( + r#"[registrant] +name_first = "{first}" +name_last = "{last}" +email = "{email}" +phone = "{phone}" +organization = "{org}" +address1 = "{addr1}" +address2 = "{addr2}" +city = "{city}" +state = "{state}" +postal_code = "{postal}" +country = "{country}" +"#, + first = escape_toml(&contact.name_first), + last = escape_toml(&contact.name_last), + email = escape_toml(&contact.email), + phone = escape_toml(&contact.phone), + org = escape_toml(contact.organization.as_deref().unwrap_or("")), + addr1 = escape_toml(&contact.address1), + addr2 = escape_toml(contact.address2.as_deref().unwrap_or("")), + city = escape_toml(&contact.city), + state = escape_toml(&contact.state), + postal = escape_toml(&contact.postal_code), + country = escape_toml(&contact.country), + ); + + std::fs::write(&path, &toml_content).expect("write"); + + // Parse back and verify. + let raw = std::fs::read_to_string(&path).expect("read"); + let parsed: ContactsFile = toml::from_str(&raw).expect("parse"); + let registrant = parsed.get(Role::Registrant).expect("registrant present"); + assert_eq!(registrant.name_first, "Ada"); + assert_eq!(registrant.name_last, "Lovelace"); + assert_eq!(registrant.email, "ada@example.com"); + assert_eq!(registrant.city, "Tempe"); + assert_eq!(registrant.country, "US"); + } +} diff --git a/rust/src/domain/register/steps/discovery.rs b/rust/src/domain/register/steps/discovery.rs new file mode 100644 index 00000000..70bd5429 --- /dev/null +++ b/rust/src/domain/register/steps/discovery.rs @@ -0,0 +1,290 @@ +//! Step 1: Domain discovery — prompt for a domain name, check availability, +//! and offer alternatives when the requested name is taken. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use dialoguer::Select; + +use crate::domain::common::{ + api_error, make_client_with_cred, period_price_map, periods_from_prices, + prompt_validated_domain_name, term_for_period, +}; + +use crate::retry::with_retry; + +use super::super::wizard::{StepContext, StepResult, WizardState}; + +/// Maximum suggestions to show when a domain is taken. +const MAX_SUGGESTIONS: usize = 10; + +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { + // If we already have a domain from a previous step or CLI arg, skip prompting. + if state.domain.is_some() && state.available { + return Ok(StepResult::Continue); + } + + let domain = match &state.domain { + Some(d) => d.clone(), + None => prompt_validated_domain_name("Enter domain name to register")?, + }; + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + // Check availability with retry for transient failures. + let availability = match with_retry("availability check", 3, || { + let c = &client; + let d = domain.as_str(); + async move { c.get_domain_availability().domain(d).send().await } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain availability check", debug, e).await), + }; + + let available = availability.available.unwrap_or(false); + state.domain = Some(domain.clone()); + + if available { + state.available = true; + let prices = availability.prices.unwrap_or_default(); + state.available_periods = periods_from_prices(&prices); + state.period_prices = period_price_map(&prices); + if let Some(tp) = term_for_period(&prices, 1) { + state.price = tp + .price + .as_ref() + .and_then(crate::domain::common::format_money); + state.currency = tp + .price + .as_ref() + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()); + } + eprintln!( + " {} {} is available!", + style("✓").green().bold(), + style(&domain).cyan().bold() + ); + return Ok(StepResult::Continue); + } + + // Domain is taken — offer suggestions. + eprintln!( + " {} {} is not available.", + style("✗").red().bold(), + style(&domain).cyan() + ); + + let suggestions = fetch_suggestions(&client, &domain, debug).await?; + if suggestions.is_empty() { + eprintln!(" No alternative suggestions found."); + return prompt_retry_or_cancel(state); + } + + select_from_suggestions(state, &suggestions) +} + +async fn fetch_suggestions( + client: &domains_client::Client, + domain: &str, + debug: bool, +) -> Result> { + let page_size = + std::num::NonZeroI64::new(MAX_SUGGESTIONS as i64).expect("MAX_SUGGESTIONS is non-zero"); + let resp = match with_retry("suggestions", 3, || { + let c = client; + let d = domain; + async move { + c.suggest_domains() + .query(d) + .page_size(page_size) + .send() + .await + } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain suggestion", debug, e).await), + }; + + Ok(collect_suggestions(&resp.items, MAX_SUGGESTIONS)) +} + +fn select_from_suggestions( + state: &mut WizardState, + suggestions: &[SuggestionEntry], +) -> Result { + let items: Vec = suggestions + .iter() + .map(|s| match (&s.price, &s.currency) { + (Some(p), Some(c)) => format!("{} ({} {})", s.domain, p, c), + (Some(p), None) => format!("{} ({})", s.domain, p), + _ => s.domain.clone(), + }) + .chain(std::iter::once("↩ Try a different domain".to_string())) + .chain(std::iter::once("✗ Cancel".to_string())) + .collect(); + + let selection = Select::new() + .with_prompt("Choose a domain") + .items(&items) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if selection == items.len() - 1 { + return Ok(StepResult::Cancel); + } + if selection == items.len() - 2 { + state.domain = None; + state.available = false; + state.available_periods.clear(); + state.period_prices.clear(); + return Ok(StepResult::Back); + } + + let chosen = &suggestions[selection]; + state.domain = Some(chosen.domain.clone()); + state.available = true; + state.available_periods = chosen.available_periods.clone(); + state.period_prices = chosen.period_prices.clone(); + state.price = chosen.price.clone(); + state.currency = chosen.currency.clone(); + eprintln!( + " {} Selected {}", + style("✓").green().bold(), + style(&chosen.domain).cyan().bold() + ); + Ok(StepResult::Continue) +} + +fn prompt_retry_or_cancel(state: &mut WizardState) -> Result { + let items = vec!["Try a different domain", "Cancel"]; + let selection = Select::new() + .with_prompt("What would you like to do?") + .items(&items) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if selection == 0 { + state.domain = None; + state.available = false; + state.available_periods.clear(); + state.period_prices.clear(); + Ok(StepResult::Back) + } else { + Ok(StepResult::Cancel) + } +} + +struct SuggestionEntry { + domain: String, + price: Option, + currency: Option, + available_periods: Vec, + period_prices: std::collections::BTreeMap, +} + +/// Extract unique suggestions from raw API items, capped at `max`. +/// Factored out for testability. +fn collect_suggestions( + items: &[domains_client::types::Suggestion], + max: usize, +) -> Vec { + let mut all: Vec = Vec::new(); + for item in items { + if all.len() >= max { + break; + } + let Some(name) = item.domain.as_deref() else { + continue; + }; + if all.iter().any(|s| s.domain == name) { + continue; + } + let prices = item.prices.as_deref().unwrap_or_default(); + let price = term_for_period(prices, 1) + .and_then(|tp| tp.price.as_ref()) + .and_then(crate::domain::common::format_money); + let currency = term_for_period(prices, 1) + .and_then(|tp| tp.price.as_ref()) + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()); + all.push(SuggestionEntry { + domain: name.to_owned(), + price, + currency, + available_periods: periods_from_prices(prices), + period_prices: period_price_map(prices), + }); + } + all +} + +#[cfg(test)] +mod tests { + use super::*; + use domains_client::types::Suggestion; + + fn make_suggestion(domain: &str) -> Suggestion { + Suggestion { + domain: Some(domain.to_string()), + inventory: None, + prices: None, + } + } + + #[test] + fn collect_suggestions_deduplicates() { + let items = vec![ + make_suggestion("a.com"), + make_suggestion("b.com"), + make_suggestion("a.com"), // duplicate + make_suggestion("c.com"), + ]; + let results = collect_suggestions(&items, 10); + assert_eq!(results.len(), 3); + assert_eq!(results[0].domain, "a.com"); + assert_eq!(results[1].domain, "b.com"); + assert_eq!(results[2].domain, "c.com"); + } + + #[test] + fn collect_suggestions_caps_at_max() { + let items: Vec = (0..20) + .map(|i| make_suggestion(&format!("domain{i}.com"))) + .collect(); + let results = collect_suggestions(&items, MAX_SUGGESTIONS); + assert_eq!(results.len(), MAX_SUGGESTIONS); + } + + #[test] + fn collect_suggestions_skips_items_without_domain() { + let items = vec![ + Suggestion { + domain: None, + inventory: None, + prices: None, + }, + make_suggestion("valid.com"), + Suggestion { + domain: None, + inventory: None, + prices: None, + }, + ]; + let results = collect_suggestions(&items, 10); + assert_eq!(results.len(), 1); + assert_eq!(results[0].domain, "valid.com"); + } + + #[test] + fn collect_suggestions_empty_input() { + let results = collect_suggestions(&[], 10); + assert!(results.is_empty()); + } +} diff --git a/rust/src/domain/register/steps/execute.rs b/rust/src/domain/register/steps/execute.rs new file mode 100644 index 00000000..f4167e14 --- /dev/null +++ b/rust/src/domain/register/steps/execute.rs @@ -0,0 +1,249 @@ +//! Step 5: Execute — submit the registration using the cached quote, poll the +//! async operation, and display the result. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use indicatif::{ProgressBar, ProgressStyle}; + +use domains_client::types; + +use crate::domain::common::{ + api_error, format_operation_error, is_terminal_status, make_client_with_cred, +}; +use crate::quote_cache; + +use crate::retry::with_retry; + +use super::super::wizard::{StepContext, StepResult, WizardState}; + +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + let quote_token = state + .quote_token + .as_ref() + .ok_or_else(|| CliCoreError::message("no quote token available"))? + .clone(); + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + // Validate credential is a customer identity. + let _customer_id = ctx + .credential + .sub + .strip_prefix("customer:") + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + CliCoreError::message(format!( + "the OAuth token's subject ({:?}) is not a customer identity; \ + domain registration needs a customer-scoped token", + ctx.credential.sub + )) + })?; + + // Build consent. + let agreement_types: Vec = state + .agreement_types + .iter() + .map(|t| { + t.parse::().map_err(|_| { + CliCoreError::message(format!( + "unrecognized agreement type ({t:?}); re-run the wizard for a fresh quote" + )) + }) + }) + .collect::>>()?; + + let period_nz = std::num::NonZeroU64::new(state.period) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + let (profile, acknowledged_fees) = match quote_cache::get("e_token) { + quote_cache::Lookup::Found(cached) => { + let prof = cached + .profile + .as_ref() + .map(|v| serde_json::from_value::(v.clone())) + .transpose() + .map_err(|e| { + CliCoreError::message(format!("corrupt cached profile: {e}; re-run the wizard")) + })?; + let fees = match cached.fees.as_ref() { + Some(v) => serde_json::from_value::>(v.clone()).map_err(|e| { + CliCoreError::message(format!( + "the cached quote is corrupt or from an older CLI version \ + (could not read its fees: {e}); re-run the wizard for a fresh quote." + )) + })?, + None => vec![], + }; + (prof, fees) + } + _ => (None, vec![]), + }; + + let consent = types::Consent { + agreed_at: types::DateTime( + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + ), + agreed_by: None, + agreement_types, + acknowledged_fees, + }; + + let registration = types::Registration { + consent, + created_at: None, + domain: domain.clone(), + expires_at: None, + fees: vec![], + links: vec![], + operation_id: None, + order_id: None, + period: period_nz, + price: None, + profile, + profile_id: None, + quote_token: Some(types::Uuid(quote_token.clone())), + registration_id: None, + status: None, + updated_at: None, + }; + + // Show spinner during registration. + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template(" {spinner} {msg}") + .expect("valid template"), + ); + spinner.set_message(format!("Registering {}...", domain)); + spinner.enable_steady_tick(std::time::Duration::from_millis(100)); + + let idempotency_key = match quote_cache::get("e_token) { + quote_cache::Lookup::Found(cached) => cached + .idempotency_key + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + _ => uuid::Uuid::new_v4().to_string(), + }; + let accepted = match with_retry("registration", 3, || { + let c = &client; + let key = &idempotency_key; + let reg = registration.clone(); + async move { + c.register_domain() + .idempotency_key(key) + .body(reg) + .send() + .await + } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => { + spinner.finish_and_clear(); + return Err(api_error("domain register", debug, e).await); + } + }; + + // Consume the quote token. + quote_cache::remove("e_token); + + // Poll operation to terminal state. + let mut status = accepted + .status + .as_ref() + .map(|s| s.to_string()) + .unwrap_or_else(|| "SUBMITTED".to_string()); + let operation_id = accepted.operation_id.clone(); + let mut operation_error: Option = None; + + if let Some(op_id) = operation_id.as_ref() { + spinner.set_message(format!("Waiting for registry ({})", domain)); + let mut timed_out = false; + for _ in 0..20 { + if is_terminal_status(&status) { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + match client + .get_operation() + .operation_id(op_id.clone()) + .send() + .await + { + Ok(r) => { + let op = r.into_inner(); + if let Some(s) = op.status { + status = s.to_string(); + } + operation_error = op.error; + } + Err(_) => break, + } + } + if !is_terminal_status(&status) { + timed_out = true; + } + if timed_out { + spinner.finish_and_clear(); + eprintln!( + "\n {} The registration was submitted successfully but the registry hasn't \ + confirmed yet.", + style("⏳").bold() + ); + eprintln!(" Your domain will be registered — this is normal for some TLDs."); + eprintln!(" Check progress with: gddy domain operation {}", op_id); + } + } + + spinner.finish_and_clear(); + + // Display result. + if status == "FAILED" { + let detail = format_operation_error(operation_error.as_ref()); + return Err(CliCoreError::message(format!( + "registration for {domain} failed{detail}; no domain was registered. \ + Please try again." + ))); + } + + if status == "COMPLETED" { + eprintln!( + "\n {} {} has been registered!", + style("🎉").bold(), + style(&domain).green().bold() + ); + } else if !is_terminal_status(&status) { + // Already printed timeout message above; skip duplicate output. + } else { + eprintln!( + "\n {} Registration submitted for {} (status: {})", + style("⏳").bold(), + style(&domain).cyan(), + status + ); + if let Some(op) = &operation_id { + eprintln!(" Check progress with: gddy domain operation {}", op); + } + } + + if let Some(price) = &state.price { + let currency = state.currency.as_deref().unwrap_or(""); + eprintln!(" Charged: {} {}", price, currency); + } + + // Next-step commands are owned by the CommandResult envelope (see + // `build_result`) so human/JSON output has a single Next steps footer — + // printing them here again would duplicate that footer when the wizard + // returns through `domain suggest`/`available`/`quote`. + + state.status = Some(status); + state.operation_id = operation_id.map(|o| o.to_string()); + + Ok(StepResult::Continue) +} diff --git a/rust/src/domain/register/steps/mod.rs b/rust/src/domain/register/steps/mod.rs new file mode 100644 index 00000000..87469fe4 --- /dev/null +++ b/rust/src/domain/register/steps/mod.rs @@ -0,0 +1,7 @@ +//! Wizard step implementations for the domain registration flow. + +pub(super) mod contacts; +pub(super) mod discovery; +pub(super) mod execute; +pub(super) mod options; +pub(super) mod review; diff --git a/rust/src/domain/register/steps/options.rs b/rust/src/domain/register/steps/options.rs new file mode 100644 index 00000000..26f3753e --- /dev/null +++ b/rust/src/domain/register/steps/options.rs @@ -0,0 +1,290 @@ +//! Step 2: Registration options — period, privacy, auto-renew, custom +//! nameservers. + +use cli_engine::{CliCoreError, Result}; +use console::style; +use dialoguer::{Confirm, Input, Select}; +use domains_client::types; + +use crate::domain::common::{ + api_error, clamp_registration_periods, is_period_limit_error, make_client_with_cred, + parse_max_registration_period, period_label, period_price_map, periods_from_prices, + validate_domain_name, +}; +use crate::retry::with_retry; + +use super::super::wizard::{StepContext, StepResult, WizardState}; + +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { + eprintln!( + "\n {} Configuring registration for {}", + style("⚙").bold(), + style(state.domain.as_deref().unwrap_or("unknown")).cyan() + ); + + ensure_available_periods(state, ctx).await?; + refine_periods_with_quote_limit(state, ctx).await?; + + let back_label = options_back_label(ctx); + + let period_labels: Vec = state + .available_periods + .iter() + .map(|p| period_option_label(*p, state)) + .collect(); + + let default_idx = state + .available_periods + .iter() + .position(|&p| p == state.period) + .unwrap_or(0); + + let mut period_items = period_labels; + period_items.push(back_label.clone()); + + let period_idx = Select::new() + .with_prompt("Registration period") + .items(&period_items) + .default(default_idx.min(period_items.len().saturating_sub(2))) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if period_idx == period_items.len() - 1 { + return Ok(StepResult::Back); + } + state.period = state.available_periods[period_idx]; + + // Privacy protection. + state.privacy = Confirm::new() + .with_prompt("Enable WHOIS privacy protection?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + // Auto-renew. + state.auto_renew = Confirm::new() + .with_prompt("Enable auto-renewal?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + // Custom nameservers (optional). + let custom_ns = Confirm::new() + .with_prompt("Use custom nameservers? (No = GoDaddy defaults)") + .default(false) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if custom_ns { + state.nameservers = prompt_nameservers()?; + } else { + state.nameservers = Vec::new(); + } + + eprintln!( + " {} Options configured: {} year(s), privacy={}, auto-renew={}", + style("✓").green().bold(), + state.period, + if state.privacy { "on" } else { "off" }, + if state.auto_renew { "on" } else { "off" }, + ); + + let choices = vec!["Continue", back_label.as_str()]; + let selection = Select::new() + .items(&choices) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if selection == 1 { + return Ok(StepResult::Back); + } + + Ok(StepResult::Continue) +} + +/// Load priced registration periods for the selected domain when discovery or +/// a bridge entry did not already populate them. +async fn ensure_available_periods(state: &mut WizardState, ctx: &StepContext) -> Result<()> { + if !state.available_periods.is_empty() { + return Ok(()); + } + + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))?; + let domain = validate_domain_name(domain)?; + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + let availability = match with_retry("availability check", 3, || { + let c = &client; + let d = domain.as_str(); + async move { c.get_domain_availability().domain(d).send().await } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => return Err(api_error("domain availability check", debug, e).await), + }; + + if !availability.available.unwrap_or(false) { + return Err(CliCoreError::message(format!( + "{domain} is no longer available for registration" + ))); + } + + let prices = availability.prices.unwrap_or_default(); + state.available_periods = periods_from_prices(&prices); + state.period_prices = period_price_map(&prices); + if state.available_periods.is_empty() { + state.available_periods = vec![1]; + } + + Ok(()) +} + +/// Back-navigation label for the options step, depending on wizard entry point. +fn options_back_label(ctx: &StepContext) -> String { + if ctx.wizard_start_at > 0 { + "↩ Go back to change domain".to_string() + } else { + "↩ Go back to discovery".to_string() + } +} + +/// Label for a period option, including indicative total price when known. +fn period_option_label(period: u64, state: &WizardState) -> String { + let base = period_label(period); + match (state.period_prices.get(&period), state.currency.as_deref()) { + (Some(price), Some(currency)) => format!("{base} — {price} {currency}"), + (Some(price), None) => format!("{base} — {price}"), + _ => base, + } +} + +/// Availability pricing is indicative; probe the quote endpoint with the longest +/// offered period to learn the TLD's authoritative maximum. +async fn refine_periods_with_quote_limit(state: &mut WizardState, ctx: &StepContext) -> Result<()> { + let Some(max_offered) = state.available_periods.last().copied() else { + return Ok(()); + }; + if max_offered <= 1 { + return Ok(()); + } + + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + let domain = validate_domain_name(&domain)?; + + let period_nz = std::num::NonZeroU64::new(max_offered) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let profile = types::InlineRegistrationProfile { + auto_renew: Some(state.auto_renew), + contacts: None, + name_servers: None, + privacy: Some(state.privacy), + }; + + match client + .quote_domain_registration() + .body(types::QuoteDomainRegistrationBody { + domain, + period: period_nz, + profile: Some(profile), + profile_id: None, + }) + .send() + .await + { + Ok(_) => Ok(()), + Err(domains_client::Error::UnexpectedResponse(resp)) => { + let body = resp.text().await.unwrap_or_default(); + if is_period_limit_error(&body) + && let Some(max_years) = parse_max_registration_period(&body) + { + clamp_registration_periods( + &mut state.available_periods, + &mut state.period, + max_years, + ); + state.period_prices.retain(|years, _| *years <= max_years); + } + Ok(()) + } + Err(e) => Err(api_error("domain quote", ctx.debug, e).await), + } +} + +fn prompt_nameservers() -> Result> { + let mut nameservers = Vec::new(); + eprintln!(" Enter nameservers (empty line to finish, min 2):"); + loop { + let ns: String = Input::new() + .with_prompt(format!(" Enter NS {}", nameservers.len() + 1)) + .allow_empty(true) + .interact_text() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if ns.is_empty() { + if nameservers.len() < 2 && !nameservers.is_empty() { + eprintln!(" At least 2 nameservers are required."); + continue; + } + break; + } + + match validate_domain_name(&ns) { + Ok(valid) => nameservers.push(valid), + Err(e) => { + eprintln!(" Invalid nameserver: {e}"); + continue; + } + } + } + Ok(nameservers) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::register::wizard::WizardState; + + #[test] + fn default_period_index_falls_back_when_current_period_unavailable() { + let periods = [1_u64, 2, 3]; + let current = 5_u64; + let default_idx = periods.iter().position(|&p| p == current).unwrap_or(0); + assert_eq!(default_idx, 0); + assert_eq!(periods[default_idx], 1); + } + + #[test] + fn options_back_label_reflects_entry_point() { + let mut ctx = StepContext { + credential: cli_engine::Credential::default(), + env: "test".to_string(), + debug: false, + wizard_start_at: 0, + }; + assert_eq!(options_back_label(&ctx), "↩ Go back to discovery"); + ctx.wizard_start_at = 1; + assert_eq!(options_back_label(&ctx), "↩ Go back to change domain"); + } + + #[test] + fn period_option_label_includes_price_when_known() { + let mut state = WizardState::new(); + state.currency = Some("USD".to_string()); + state.period_prices.insert(3, "120.97".to_string()); + assert_eq!(period_option_label(3, &state), "3 years — 120.97 USD"); + assert_eq!(period_option_label(1, &state), "1 year"); + } +} diff --git a/rust/src/domain/register/steps/review.rs b/rust/src/domain/register/steps/review.rs new file mode 100644 index 00000000..e5546aa1 --- /dev/null +++ b/rust/src/domain/register/steps/review.rs @@ -0,0 +1,547 @@ +//! Step 4: Review — fetch a quote, display agreements, show order summary, and +//! request confirmation before executing. Also handles 402 Payment Required +//! by offering to open the browser for payment method setup. + +use cli_engine::{CliCoreError, Result}; +use console::{Alignment, pad_str, style}; +use dialoguer::{Confirm, Select}; + +use domains_client::types; + +use crate::contacts::Role; +use crate::domain::common::{ + api_error, clamp_registration_periods, format_api_error, format_money, is_period_limit_error, + make_client_with_cred, parse_max_registration_period, period_label, validate_nameserver_hosts, +}; +use crate::environments; +use crate::quote_cache; + +use crate::retry::with_retry; + +use super::super::wizard::{ContactsChoice, StepContext, StepResult, WizardState}; + +/// Inner content width for the order-summary box (between the `│ ` and ` │`). +const SUMMARY_INNER_WIDTH: usize = 38; + +/// One padded line of the order-summary box. Uses `console::pad_str` so ANSI +/// color codes and wide glyphs (emoji) don't shift the right border. +fn summary_line(content: &str) -> String { + format!( + " │ {}│", + pad_str(content, SUMMARY_INNER_WIDTH, Alignment::Left, None) + ) +} + +pub(crate) async fn run(state: &mut WizardState, ctx: &StepContext) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + + eprintln!( + "\n {} Fetching quote for {}...", + style("$").bold(), + style(&domain).cyan() + ); + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + let period_nz = std::num::NonZeroU64::new(state.period) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + // Build the registration profile for the quote. + let name_servers = if state.nameservers.is_empty() { + None + } else { + let validated = validate_nameserver_hosts(state.nameservers.clone())?; + Some(types::NameServers( + validated + .iter() + .map(|h| types::NameserverHostname(h.clone())) + .collect(), + )) + }; + + // Resolve contacts from the wizard state. + let contacts = build_contacts_for_profile(&state.contacts)?; + + let profile = types::InlineRegistrationProfile { + auto_renew: Some(state.auto_renew), + contacts, + name_servers, + privacy: Some(state.privacy), + }; + let profile_json = serde_json::to_value(&profile).map_err(|e| { + CliCoreError::message(format!("could not serialize registration profile: {e}")) + })?; + + let quote_body = types::QuoteDomainRegistrationBody { + domain: domain.clone(), + period: period_nz, + profile: Some(profile), + profile_id: None, + }; + let quote = match with_retry("quote", 3, || { + let c = &client; + let b = quote_body.clone(); + async move { c.quote_domain_registration().body(b).send().await } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => match e { + domains_client::Error::UnexpectedResponse(resp) => { + let status = resp.status(); + let request_id = resp + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let body = resp.text().await.unwrap_or_default(); + if is_period_limit_error(&body) + && let Some(max_years) = parse_max_registration_period(&body) + { + let rejected = state.period; + clamp_registration_periods( + &mut state.available_periods, + &mut state.period, + max_years, + ); + state.period_prices.retain(|years, _| *years <= max_years); + eprintln!( + "\n {} Period {} is not supported for this domain (maximum is {}). \ + Go back to Options to choose a different length.", + style("⚠").yellow().bold(), + rejected, + max_years + ); + return Ok(StepResult::Back); + } + let err = CliCoreError::message(format_api_error( + "domain quote", + status.as_u16(), + &status.to_string(), + &body, + request_id.as_deref(), + debug, + )); + if is_payment_error(&err) { + return handle_payment_required(ctx).await; + } + return Err(err); + } + other => { + let err = api_error("domain quote", debug, other).await; + if is_payment_error(&err) { + return handle_payment_required(ctx).await; + } + return Err(err); + } + }, + }; + + // Extract pricing and agreement info. + let price_str = quote.price.as_ref().and_then(format_money); + let renewal_str = quote.renewal_price.as_ref().and_then(format_money); + let currency = quote + .price + .as_ref() + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()) + .unwrap_or_default(); + + let agreements = quote.required_agreements.clone().unwrap_or_default(); + let agreement_titles: Vec = agreements + .iter() + .map(|a| { + let title = a.title.as_deref().unwrap_or("(untitled)"); + match a.url.as_deref() { + Some(url) => format!("{title} ({url})"), + None => title.to_owned(), + } + }) + .collect(); + let agreement_types: Vec = agreements + .iter() + .filter_map(|a| a.agreement_type.as_ref().map(|t| t.to_string())) + .collect(); + + // Display order summary. Pad by visible width (not byte length) so + // styled/colored fields keep the right border aligned. + let w = SUMMARY_INNER_WIDTH + 1; // border fill between ┌ and ┐ + eprintln!("\n ┌{}┐", "─".repeat(w)); + eprintln!( + "{}", + summary_line(&format!("{}", style("Order Summary").bold())) + ); + eprintln!(" ├{}┤", "─".repeat(w)); + eprintln!( + "{}", + summary_line(&format!("Domain: {}", style(&domain).cyan().bold())) + ); + eprintln!( + "{}", + summary_line(&format!("Period: {}", period_label(state.period))) + ); + if let Some(p) = &price_str { + eprintln!( + "{}", + summary_line(&format!( + "Total due: {} {}", + style(p).green().bold(), + currency + )) + ); + } + if let Some(r) = &renewal_str { + eprintln!( + "{}", + summary_line(&format!( + "Renewal price ({}): {r} {currency}", + period_label(state.period) + )) + ); + } + eprintln!( + "{}", + summary_line(&format!( + "Privacy: {}", + if state.privacy { "Yes" } else { "No" } + )) + ); + eprintln!( + "{}", + summary_line(&format!( + "Auto-renew: {}", + if state.auto_renew { "Yes" } else { "No" } + )) + ); + if !state.nameservers.is_empty() { + eprintln!( + "{}", + summary_line(&format!("Nameservers: {}", state.nameservers.join(", "))) + ); + } + eprintln!(" └{}┘", "─".repeat(w)); + + // Show agreements. + if !agreement_titles.is_empty() { + eprintln!("\n Legal agreements:"); + for title in &agreement_titles { + eprintln!(" • {title}"); + } + } + + // Confirm. + let choices = vec![ + format!( + "✓ I agree to the terms above and authorize a charge of {} {}", + price_str.as_deref().unwrap_or("the quoted price"), + currency + ), + "↩ Go back and change options".to_string(), + "✗ Cancel — do not purchase".to_string(), + ]; + let selection = Select::new() + .with_prompt("By proceeding you accept the legal agreements listed above") + .items(&choices) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 1 => return Ok(StepResult::Back), + 2 => return Ok(StepResult::Cancel), + _ => {} // 0 = proceed + } + + // Cache the quote for the execute step. + let quote_token = quote + .quote_token + .as_ref() + .ok_or_else(|| CliCoreError::message("the quote returned no token (domain unavailable?)"))? + .to_string(); + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let fees_json = match quote.fees.as_ref().filter(|f| !f.is_empty()) { + Some(fees) => Some(serde_json::to_value(fees).map_err(|e| { + CliCoreError::message(format!( + "could not serialize the quote's fees for the quote cache: {e}" + )) + })?), + None => None, + }; + quote_cache::save( + "e_token, + quote_cache::CachedQuote { + domain: quote.domain.clone().unwrap_or_else(|| domain.clone()), + period: quote.period.map_or(state.period, |p| p.get()), + price: price_str.clone(), + currency: Some(currency.clone()), + agreement_titles: agreement_titles.clone(), + agreement_types: agreement_types.clone(), + profile: Some(profile_json), + idempotency_key: Some(idempotency_key), + expires_at: quote.expires_at.as_ref().map(|e| e.to_string()), + fees: fees_json, + }, + )?; + + state.quote_token = Some(quote_token); + state.price = price_str; + state.currency = Some(currency); + state.agreement_titles = agreement_titles; + state.agreement_types = agreement_types; + + Ok(StepResult::Continue) +} + +/// Non-interactive variant: fetches a quote and caches it without prompting +/// for confirmation. Callers must have already validated `--agree` and +/// `--confirm` flags before reaching this point. +pub(crate) async fn run_non_interactive( + state: &mut WizardState, + ctx: &StepContext, +) -> Result { + let domain = state + .domain + .as_ref() + .ok_or_else(|| CliCoreError::message("no domain selected"))? + .clone(); + + let client = make_client_with_cred(&ctx.env, &ctx.credential)?; + let debug = ctx.debug; + + let period_nz = std::num::NonZeroU64::new(state.period) + .ok_or_else(|| CliCoreError::message("invalid registration period"))?; + + let name_servers = if state.nameservers.is_empty() { + None + } else { + let validated = validate_nameserver_hosts(state.nameservers.clone())?; + Some(types::NameServers( + validated + .iter() + .map(|h| types::NameserverHostname(h.clone())) + .collect(), + )) + }; + + let contacts = build_contacts_for_profile(&state.contacts)?; + + let profile = types::InlineRegistrationProfile { + auto_renew: Some(state.auto_renew), + contacts, + name_servers, + privacy: Some(state.privacy), + }; + let profile_json = serde_json::to_value(&profile).map_err(|e| { + CliCoreError::message(format!("could not serialize registration profile: {e}")) + })?; + + let quote_body = types::QuoteDomainRegistrationBody { + domain: domain.clone(), + period: period_nz, + profile: Some(profile), + profile_id: None, + }; + let quote = match with_retry("quote", 3, || { + let c = &client; + let b = quote_body.clone(); + async move { c.quote_domain_registration().body(b).send().await } + }) + .await + { + Ok(r) => r.into_inner(), + Err(e) => { + let err = api_error("domain quote", debug, e).await; + if is_payment_error(&err) { + return Err(CliCoreError::message( + "no usable payment method on file; add one at \ + https://account.godaddy.com/payment-methods before retrying", + )); + } + return Err(err); + } + }; + + let price_str = quote.price.as_ref().and_then(format_money); + let currency = quote + .price + .as_ref() + .and_then(|p| p.currency_code.as_ref()) + .map(|c| c.0.clone()) + .unwrap_or_default(); + + let agreements = quote.required_agreements.clone().unwrap_or_default(); + let agreement_titles: Vec = agreements + .iter() + .map(|a| { + let title = a.title.as_deref().unwrap_or("(untitled)"); + match a.url.as_deref() { + Some(url) => format!("{title} ({url})"), + None => title.to_owned(), + } + }) + .collect(); + let agreement_types: Vec = agreements + .iter() + .filter_map(|a| a.agreement_type.as_ref().map(|t| t.to_string())) + .collect(); + + let quote_token = quote + .quote_token + .as_ref() + .ok_or_else(|| CliCoreError::message("the quote returned no token (domain unavailable?)"))? + .to_string(); + let idempotency_key = uuid::Uuid::new_v4().to_string(); + let fees_json = match quote.fees.as_ref().filter(|f| !f.is_empty()) { + Some(fees) => Some(serde_json::to_value(fees).map_err(|e| { + CliCoreError::message(format!( + "could not serialize the quote's fees for the quote cache: {e}" + )) + })?), + None => None, + }; + quote_cache::save( + "e_token, + quote_cache::CachedQuote { + domain: quote.domain.clone().unwrap_or_else(|| domain.clone()), + period: quote.period.map_or(state.period, |p| p.get()), + price: price_str.clone(), + currency: Some(currency.clone()), + agreement_titles: agreement_titles.clone(), + agreement_types: agreement_types.clone(), + profile: Some(profile_json), + idempotency_key: Some(idempotency_key), + expires_at: quote.expires_at.as_ref().map(|e| e.to_string()), + fees: fees_json, + }, + )?; + + state.quote_token = Some(quote_token); + state.price = price_str; + state.currency = Some(currency); + state.agreement_titles = agreement_titles; + state.agreement_types = agreement_types; + + Ok(StepResult::Continue) +} + +/// Convert the wizard's contacts choice into the API's `Contacts` struct. +fn build_contacts_for_profile(choice: &ContactsChoice) -> Result> { + let file = match choice { + ContactsChoice::AccountDefault => return Ok(None), + ContactsChoice::FromFile(f) | ContactsChoice::Manual(f) => f, + }; + + let to_api = |role| file.to_api(role).map_err(CliCoreError::message); + let registrant = to_api(Role::Registrant)?; + let admin = to_api(Role::Admin)?; + let billing = to_api(Role::Billing)?; + let tech = to_api(Role::Tech)?; + + let any_non_registrant = admin.is_some() || billing.is_some() || tech.is_some(); + match registrant { + Some(registrant) => Ok(Some(types::Contacts { + registrant, + admin, + billing, + tech, + })), + None if any_non_registrant => Err(CliCoreError::message( + "contacts define a non-registrant contact but no registrant; the API requires a \ + registrant when any contact is supplied", + )), + None => Ok(None), + } +} + +/// Check if a CLI error is a 402 Payment Required error. +fn is_payment_error(err: &CliCoreError) -> bool { + let msg = err.to_string(); + msg.contains("402") || msg.contains("INVALID_PAYMENT_INFO") || msg.contains("payment") +} + +/// Handle 402: inform the user and offer to open the payment methods page. +async fn handle_payment_required(ctx: &StepContext) -> Result { + eprintln!( + "\n {} No usable payment method found on your account.", + style("⚠").yellow().bold() + ); + eprintln!(" A credit card or Good-as-Gold balance is required for domain purchases."); + + let open_browser = Confirm::new() + .with_prompt("Open the GoDaddy payment methods page in your browser?") + .default(true) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + if open_browser { + let env = environments::resolve(&ctx.env)?; + let url = format!("{}/payment-methods/add-payment?plid=1", env.account_url); + if open::that(&url).is_err() { + eprintln!( + " Could not open browser. Visit: {}", + style(&url).underlined() + ); + } else { + eprintln!(" {} Browser opened.", style("✓").green().bold()); + } + } + + eprintln!("\n After adding a payment method, select '↩ Go back' to retry."); + + let retry_choices = vec!["↩ Go back and retry the quote", "✗ Cancel registration"]; + let selection = Select::new() + .with_prompt("What would you like to do?") + .items(&retry_choices) + .default(0) + .interact() + .map_err(|e| CliCoreError::message(format!("prompt cancelled: {e}")))?; + + match selection { + 0 => Ok(StepResult::Back), + _ => Ok(StepResult::Cancel), + } +} + +#[cfg(test)] +mod tests { + use super::{SUMMARY_INNER_WIDTH, summary_line}; + use console::{measure_text_width, style}; + + #[test] + fn summary_line_keeps_right_border_aligned_with_ansi_styles() { + let plain = summary_line("Period: 2 years"); + let styled = summary_line(&format!( + "Domain: {}", + style("iguanahats.shop").cyan().bold() + )); + let priced = summary_line(&format!( + "Total due: {} USD", + style("60.98").green().bold() + )); + let renewal = summary_line("Renewal price (3 years): 179.97 USD"); + + // Visible width (ANSI stripped) must match across plain and styled + // rows so the box's right `│` lines up in a real terminal. + let widths = [ + measure_text_width(&plain), + measure_text_width(&styled), + measure_text_width(&priced), + measure_text_width(&renewal), + ]; + assert!( + widths.iter().all(|&w| w == widths[0]), + "visible widths drifted: {widths:?}\nplain={plain:?}\nstyled={styled:?}\npriced={priced:?}\nrenewal={renewal:?}" + ); + // " │ " (4) + inner + "│" (1) + assert_eq!(widths[0], 4 + SUMMARY_INNER_WIDTH + 1); + assert!(plain.ends_with('│')); + assert!(styled.ends_with('│')); + assert!(priced.ends_with('│')); + assert!(renewal.ends_with('│')); + } +} diff --git a/rust/src/domain/register/wizard.rs b/rust/src/domain/register/wizard.rs new file mode 100644 index 00000000..65022628 --- /dev/null +++ b/rust/src/domain/register/wizard.rs @@ -0,0 +1,386 @@ +//! Wizard step-runner: manages forward/back navigation, state, and the step +//! header display for the domain registration wizard. + +use cli_engine::{Credential, Result}; +use console::style; + +use super::steps; + +/// The result of running a single wizard step. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StepResult { + /// Advance to the next step. + Continue, + /// Go back to the previous step (or restart the current step if at step 0). + Back, + /// The user cancelled the wizard. + Cancel, +} + +/// Shared context passed to each wizard step. +pub(crate) struct StepContext { + pub credential: Credential, + pub env: String, + pub debug: bool, + /// Wizard entry step (0 = full flow from `domain register`). + pub wizard_start_at: usize, +} + +/// Result of handling a Back action in the wizard loop. +enum BackTransition { + GoTo { step: usize, clear_domain: bool }, + ExitToBridge, +} + +/// Compute the next step (or bridge exit) when the user chooses Back. +/// +/// When the wizard was entered mid-flow from another command (suggest, +/// available, quote), backing out at the entry step returns to that command's +/// bridge UI — not an earlier wizard step the user never saw. +fn back_transition(current: usize, start_at: usize) -> BackTransition { + if current == 0 && start_at > 0 { + return BackTransition::ExitToBridge; + } + if current == start_at { + if start_at > 0 { + return BackTransition::ExitToBridge; + } + // Full `domain register` flow at Discovery — re-run discovery (e.g. "try + // a different domain") instead of exiting the wizard. + return BackTransition::GoTo { + step: 0, + clear_domain: true, + }; + } + let step = current.saturating_sub(1); + BackTransition::GoTo { + step, + clear_domain: step == 0, + } +} + +/// Accumulated state across all wizard steps. +#[derive(Debug, Clone, Default)] +pub(crate) struct WizardState { + // Step 1: Discovery + pub domain: Option, + pub available: bool, + + // Step 2: Options + pub period: u64, + pub privacy: bool, + pub auto_renew: bool, + pub nameservers: Vec, + /// Registration periods (years) priced for the selected domain. + pub available_periods: Vec, + /// Indicative total price per period (years → formatted amount). + pub period_prices: std::collections::BTreeMap, + + // Step 3: Contacts + pub contacts: ContactsChoice, + + // Step 4: Review (populated after quote) + pub quote_token: Option, + pub price: Option, + pub currency: Option, + pub agreement_titles: Vec, + pub agreement_types: Vec, + + // Step 5: Execute (populated after registration) + pub status: Option, + pub operation_id: Option, + + // Set when the wizard is cancelled by the user (not an error). + pub cancelled: bool, + // Set when the user navigated back past the entry step (not a cancellation). + pub backed_out: bool, +} + +/// How contacts are supplied for the registration. +#[derive(Debug, Clone, Default)] +pub(crate) enum ContactsChoice { + /// Use the account's default contacts (omit from request). + #[default] + AccountDefault, + /// Use contacts loaded from contacts.toml. + FromFile(crate::contacts::ContactsFile), + /// Contacts entered interactively during the wizard. + Manual(crate::contacts::ContactsFile), +} + +impl WizardState { + pub fn new() -> Self { + Self { + period: 1, + privacy: true, + auto_renew: true, + ..Default::default() + } + } + + /// Pre-populate from CLI flags for non-interactive fallback or partial entry. + pub fn with_domain(mut self, domain: Option) -> Self { + self.domain = domain; + self + } + + pub fn with_period(mut self, period: u64) -> Self { + self.period = period; + self + } + + pub fn with_privacy(mut self, privacy: bool) -> Self { + self.privacy = privacy; + self + } + + pub fn with_auto_renew(mut self, auto_renew: bool) -> Self { + self.auto_renew = auto_renew; + self + } + + pub fn with_nameservers(mut self, nameservers: Vec) -> Self { + self.nameservers = nameservers; + self + } +} + +/// Step metadata for the header display. +struct StepInfo { + name: &'static str, +} + +const STEPS: &[StepInfo] = &[ + StepInfo { name: "Discovery" }, + StepInfo { name: "Options" }, + StepInfo { name: "Contacts" }, + StepInfo { + name: "Review & Confirm", + }, + StepInfo { name: "Register" }, +]; + +/// Run the wizard starting at `start_at` step (0-indexed). +/// +/// Returns the final `WizardState` on success, or an error if the wizard is +/// cancelled or a step fails. +/// +/// When entered mid-flow (e.g. from `domain suggest` at Options), the header +/// counts only remaining steps — `Step 1/4: Options` rather than `Step 2/5` — +/// so the counter matches the work the user still has to do. +pub(crate) async fn run_wizard( + mut state: WizardState, + mut ctx: StepContext, + start_at: usize, +) -> Result { + ctx.wizard_start_at = start_at; + let total_steps = STEPS.len(); + let mut current = start_at; + // Relative denominator: how many steps this entry point will show. + let display_total = total_steps.saturating_sub(start_at); + + loop { + if current >= total_steps { + break; + } + + let step = &STEPS[current]; + let display_num = current.saturating_sub(start_at) + 1; + eprintln!( + "\n {} Step {}/{}: {}", + style("─").dim(), + display_num, + display_total, + style(step.name).bold() + ); + + let step_result = match current { + 0 => steps::discovery::run(&mut state, &ctx).await, + 1 => steps::options::run(&mut state, &ctx).await, + 2 => steps::contacts::run(&mut state, &ctx).await, + 3 => steps::review::run(&mut state, &ctx).await, + 4 => steps::execute::run(&mut state, &ctx).await, + _ => unreachable!(), + }; + + let result = match step_result { + Ok(r) => r, + Err(e) if is_prompt_cancelled(&e) => { + eprintln!( + "\n {} Interrupted. No charges were made.", + style("✗").red().bold() + ); + state.cancelled = true; + return Ok(state); + } + Err(e) => return Err(e), + }; + + match result { + StepResult::Continue => { + current += 1; + } + StepResult::Back => match back_transition(current, start_at) { + BackTransition::ExitToBridge => { + state.backed_out = true; + return Ok(state); + } + BackTransition::GoTo { step, clear_domain } => { + current = step; + if clear_domain { + state.domain = None; + state.available = false; + state.available_periods.clear(); + state.period_prices.clear(); + } + } + }, + StepResult::Cancel => { + eprintln!( + "\n {} Wizard cancelled. No charges were made.", + style("✗").red().bold() + ); + state.cancelled = true; + return Ok(state); + } + } + } + + Ok(state) +} + +/// Detect if an error came from a cancelled prompt (Ctrl+C or EOF in dialoguer). +fn is_prompt_cancelled(err: &cli_engine::CliCoreError) -> bool { + let msg = err.to_string(); + msg.contains("prompt cancelled") || msg.contains("interrupted") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wizard_state_defaults_are_sensible() { + let state = WizardState::new(); + assert_eq!(state.period, 1); + assert!(state.privacy); + assert!(state.auto_renew); + assert!(state.domain.is_none()); + assert!(state.nameservers.is_empty()); + } + + #[test] + fn wizard_state_builder_methods_work() { + let state = WizardState::new() + .with_domain(Some("example.com".to_string())) + .with_period(2) + .with_privacy(false) + .with_auto_renew(false) + .with_nameservers(vec!["ns1.example.com".to_string()]); + assert_eq!(state.domain.as_deref(), Some("example.com")); + assert_eq!(state.period, 2); + assert!(!state.privacy); + assert!(!state.auto_renew); + assert_eq!(state.nameservers, vec!["ns1.example.com"]); + } + + #[test] + fn step_result_equality() { + assert_eq!(StepResult::Continue, StepResult::Continue); + assert_eq!(StepResult::Back, StepResult::Back); + assert_eq!(StepResult::Cancel, StepResult::Cancel); + assert_ne!(StepResult::Continue, StepResult::Cancel); + } + + #[test] + fn steps_metadata_has_expected_count() { + assert_eq!(STEPS.len(), 5); + assert_eq!(STEPS[0].name, "Discovery"); + assert_eq!(STEPS[1].name, "Options"); + assert_eq!(STEPS[2].name, "Contacts"); + assert_eq!(STEPS[3].name, "Review & Confirm"); + assert_eq!(STEPS[4].name, "Register"); + } + + #[test] + fn back_transition_from_bridge_entry_steps() { + // suggest/available enter at Options — back returns to the host bridge UI. + assert!(matches!( + back_transition(1, 1), + BackTransition::ExitToBridge + )); + // Discovery after bridge entry — return to bridge UI. + assert!(matches!( + back_transition(0, 1), + BackTransition::ExitToBridge + )); + + // quote enters at Review — back returns to the host bridge UI. + assert!(matches!( + back_transition(3, 3), + BackTransition::ExitToBridge + )); + // Mid-flow back within full wizard. + assert!(matches!( + back_transition(2, 0), + BackTransition::GoTo { + step: 1, + clear_domain: false + } + )); + // Full register at Discovery — restart discovery, don't exit. + assert!(matches!( + back_transition(0, 0), + BackTransition::GoTo { + step: 0, + clear_domain: true + } + )); + } + + #[test] + fn mid_flow_step_counter_is_relative_to_entry_point() { + // From suggest/available the wizard starts at Options (index 1): + // remaining steps are Options→Contacts→Review→Register → 4 total, + // and Options itself is display step 1. + let start_at = 1; + let display_total = STEPS.len().saturating_sub(start_at); + assert_eq!(display_total, 4); + assert_eq!(1usize.saturating_sub(start_at) + 1, 1); // Options → 1/4 + assert_eq!(4usize.saturating_sub(start_at) + 1, 4); // Register → 4/4 + + // From quote the wizard starts at Review (index 3): 2 remaining. + let start_at = 3; + let display_total = STEPS.len().saturating_sub(start_at); + assert_eq!(display_total, 2); + assert_eq!(3usize.saturating_sub(start_at) + 1, 1); // Review → 1/2 + assert_eq!(4usize.saturating_sub(start_at) + 1, 2); // Register → 2/2 + } + + #[test] + fn wizard_state_carries_all_fields_through_lifecycle() { + let mut state = WizardState::new() + .with_domain(Some("test.io".to_string())) + .with_period(3); + + state.available = true; + state.quote_token = Some("qt-123".to_string()); + state.price = Some("29.99".to_string()); + state.currency = Some("USD".to_string()); + state.agreement_titles = vec!["ICANN Registrant".to_string()]; + state.agreement_types = vec!["DNRA".to_string()]; + state.status = Some("COMPLETED".to_string()); + state.operation_id = Some("op-456".to_string()); + + assert_eq!(state.domain.as_deref(), Some("test.io")); + assert_eq!(state.period, 3); + assert!(state.available); + assert_eq!(state.quote_token.as_deref(), Some("qt-123")); + assert_eq!(state.price.as_deref(), Some("29.99")); + assert_eq!(state.currency.as_deref(), Some("USD")); + assert_eq!(state.agreement_titles.len(), 1); + assert_eq!(state.agreement_types.len(), 1); + assert_eq!(state.status.as_deref(), Some("COMPLETED")); + assert_eq!(state.operation_id.as_deref(), Some("op-456")); + } +} diff --git a/rust/src/domain/suggest.rs b/rust/src/domain/suggest.rs index b25465b2..625baf4e 100644 --- a/rust/src/domain/suggest.rs +++ b/rust/src/domain/suggest.rs @@ -7,7 +7,9 @@ use serde_json::json; use domains_client::types; -use super::common::{api_error, comma_joined, format_money, make_client, term_for_period}; +use super::common::{ + api_error, comma_joined, format_money, make_client, resolve_optional_tlds, term_for_period, +}; use crate::next_action::next_action; use crate::output_schema::output_schema; use crate::scopes::DOMAINS_READ; @@ -148,7 +150,11 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_scopes(&[DOMAINS_READ]), |ctx, args: SuggestArgs| async move { let query = args.query; - let tlds = args.tlds; + let tlds = resolve_optional_tlds( + &ctx, + args.tlds, + "TLD filter (e.g. com, without a leading dot)", + )?; let limit = args.limit.and_then(nonzero); let length_min = args.length_min; let length_max = args.length_max; @@ -174,6 +180,19 @@ pub(super) fn command() -> RuntimeCommandSpec { }; let suggestions: Vec = resp.items.iter().filter_map(suggestion_to_json).collect(); + + // If interactive, offer to register one of the suggestions. + let domain_names: Vec = suggestions + .iter() + .filter_map(|s| s["domain"].as_str().map(str::to_owned)) + .collect(); + match super::register::bridge::offer_registration_from_suggest(&ctx, &domain_names) + .await? + { + super::register::BridgeHandoff::Replace(result) => return Ok(result), + super::register::BridgeHandoff::ShowHostOutput => {} + } + Ok( CommandResult::new(json!(suggestions)).with_next_actions(vec![ next_action("domain available ", "Check a suggested domain") diff --git a/rust/src/main.rs b/rust/src/main.rs index aef05f4f..12397aaa 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -19,6 +19,7 @@ mod pat; mod payment_methods; mod platform; mod quote_cache; +mod retry; mod scopes; mod scopes_cmd; mod summary; @@ -84,6 +85,8 @@ async fn main() -> ExitCode { .with_auth_provider(auth_provider) .with_auth_extra_commands([scopes_cmd::auth_scopes_command()]) .with_min_stage(cli_engine::Stage::Ga) + // TODO: enable once all commands have been tested under interactive prompting + // .with_auto_interactive(true) .with_environments(Arc::clone(environments::instance())) .with_root_next_actions(Arc::new(|| { vec![ diff --git a/rust/src/retry.rs b/rust/src/retry.rs new file mode 100644 index 00000000..2d087de8 --- /dev/null +++ b/rust/src/retry.rs @@ -0,0 +1,120 @@ +//! Retry helper for transient network errors during API calls. + +use std::future::Future; +use std::time::Duration; + +use console::style; + +/// Retry an async operation up to `max_attempts` times with exponential backoff. +/// Only retries on errors that look transient (timeouts, 5xx, connection errors). +/// Prints a retry notice to stderr on each retry. +#[allow(clippy::print_stderr)] +pub(crate) async fn with_retry( + label: &str, + max_attempts: u32, + mut operation: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, + E: std::fmt::Display, +{ + let mut attempt = 0; + loop { + attempt += 1; + match operation().await { + Ok(v) => return Ok(v), + Err(e) if attempt < max_attempts && is_retryable(&e) => { + let delay = Duration::from_millis(1000 * 2u64.pow(attempt - 1)); + eprintln!( + " {} {} failed (attempt {}/{}), retrying in {}s...", + style("⟳").yellow(), + label, + attempt, + max_attempts, + delay.as_secs() + ); + tokio::time::sleep(delay).await; + } + Err(e) => return Err(e), + } + } +} + +/// Heuristic: is this error likely transient? +fn is_retryable(err: &E) -> bool { + let msg = err.to_string().to_lowercase(); + msg.contains("timeout") + || msg.contains("timed out") + || msg.contains("connection") + || msg.contains("503") + || msg.contains("502") + || msg.contains("504") + || msg.contains("service unavailable") + || msg.contains("temporarily") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + #[tokio::test] + async fn succeeds_on_first_attempt() { + let result: Result<&str, String> = with_retry("test", 3, || async { Ok("ok") }).await; + assert_eq!(result.expect("should succeed"), "ok"); + } + + #[tokio::test] + async fn retries_on_transient_error() { + let attempts = AtomicU32::new(0); + let result: Result<&str, String> = with_retry("test", 3, || { + let n = attempts.fetch_add(1, Ordering::SeqCst); + async move { + if n < 2 { + Err("connection timeout".to_owned()) + } else { + Ok("recovered") + } + } + }) + .await; + assert_eq!(result.expect("should recover"), "recovered"); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn does_not_retry_non_transient() { + let attempts = AtomicU32::new(0); + let result: Result<&str, String> = with_retry("test", 3, || { + attempts.fetch_add(1, Ordering::SeqCst); + async { Err("404 not found".to_owned()) } + }) + .await; + assert!(result.is_err()); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn exhausts_retries() { + let attempts = AtomicU32::new(0); + let result: Result<&str, String> = with_retry("test", 3, || { + attempts.fetch_add(1, Ordering::SeqCst); + async { Err("503 service unavailable".to_owned()) } + }) + .await; + assert!(result.is_err()); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + } + + #[test] + fn is_retryable_detects_transient_errors() { + assert!(is_retryable(&"connection timeout")); + assert!(is_retryable(&"503 Service Unavailable")); + assert!(is_retryable(&"502 Bad Gateway")); + assert!(is_retryable(&"request timed out")); + assert!(!is_retryable(&"404 not found")); + assert!(!is_retryable(&"401 unauthorized")); + assert!(!is_retryable(&"invalid domain")); + } +}