Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ __pycache__
# Claude Code local runtime state (per-machine, not for commit)
**/.claude/scheduled_tasks.lock
**/.claude/scheduled_tasks.json
.cursor/
69 changes: 67 additions & 2 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ async-trait = "0.1"
bytes = "1"
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["std", "string"] }
cli-engine = { features = ["pkce-auth"], version = "0.9.0" }
cli-engine = { features = ["pkce-auth"], version = "0.9.2" }
dirs = "6"
domains-client = { path = "domains-client" }
fancy-regex = "0.14"
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 4 additions & 4 deletions rust/src/config/settings_form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,10 @@ fn validate_field(field: &SettingsFormV1Field, errors: &mut Vec<String>, 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) {
Expand Down
27 changes: 24 additions & 3 deletions rust/src/domain/available.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, periods_from_prices, term_for_period,
validate_domain_name,
};
use crate::next_action::next_action;
use crate::output_schema::output_schema;
use crate::scopes::DOMAINS_READ;
Expand Down Expand Up @@ -159,14 +162,32 @@ 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),
)
.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 <domain>", "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 <query>", "Find alternatives")
.with_param("query", NextActionParam::value(resolved_domain)),
]))
Expand Down
82 changes: 82 additions & 0 deletions rust/src/domain/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,47 @@ pub(super) fn term_for_period(
prices.iter().find(|p| p.period == Some(period))
}

/// Sorted, unique registration periods (years) priced in an availability response.
pub(super) fn periods_from_prices(prices: &[types::TermPrice]) -> Vec<u64> {
let mut periods: Vec<u64> = prices
.iter()
.filter_map(|t| t.period.map(|p| p.get()))
.collect();
periods.sort_unstable();
periods.dedup();
periods
}

/// Whether an API error body indicates the requested registration period exceeds
/// the TLD limit. Availability pricing is indicative; quote is authoritative.
pub(super) 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(super) fn parse_max_registration_period(body: &str) -> Option<u64> {
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(super) fn clamp_registration_periods(
available_periods: &mut Vec<u64>,
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
Expand Down Expand Up @@ -492,6 +533,47 @@ mod tests {
assert_eq!(comma_joined(Vec::<String>::new()), Vec::<String>::new());
}

#[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 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);
}

fn money(value: Option<i64>, currency: &str) -> types::SimpleMoney {
types::SimpleMoney {
value,
Expand Down
Loading
Loading