From b48c43ee41aa6512f2c63d7ee6d882269fdf3fbb Mon Sep 17 00:00:00 2001 From: cod3ddy Date: Tue, 29 Sep 2026 19:42:48 +0200 Subject: [PATCH 1/3] fix(country): match alpha-2 codes regardless of case --- country.go | 2 +- normalizer_test.go | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/country.go b/country.go index d188fbb..81144a3 100644 --- a/country.go +++ b/country.go @@ -19,7 +19,7 @@ type Country struct { // GetCountryByA2 returns a country by its ISO 3166-1 alpha-2 code, or nil if not found. func (n *Normalizer) GetCountryByA2(a2 string) *Country { for i := range n.countries { - if n.countries[i].A2 == a2 { + if strings.EqualFold(n.countries[i].A2, a2) { return &n.countries[i] } } diff --git a/normalizer_test.go b/normalizer_test.go index b95719e..a8323d2 100644 --- a/normalizer_test.go +++ b/normalizer_test.go @@ -25,6 +25,12 @@ func TestNewNormalizer_ValidCountry(t *testing.T) { assert.NotNil(t, n) } +func TestNewNormalizer_LowerCaseCountry(t *testing.T) { + n, err := NewNormalizer("mw") + require.NoError(t, err) + assert.Equal(t, "MW", n.defaultCountry.A2) +} + func TestNormalize(t *testing.T) { norm := mustNormalizer(t, "MW") @@ -190,6 +196,12 @@ func TestGetCountryByA2(t *testing.T) { assert.Equal(t, "1", c.DialingCode) }) + t.Run("lower case", func(t *testing.T) { + c := norm.GetCountryByA2("us") + require.NotNil(t, c) + assert.Equal(t, "US", c.A2) + }) + t.Run("not found returns nil", func(t *testing.T) { c := norm.GetCountryByA2("XX") assert.Nil(t, c) From 376645ffb812c6ac59c7c17fc4199789d0a02e87 Mon Sep 17 00:00:00 2001 From: cod3ddy Date: Tue, 29 Sep 2026 19:42:58 +0200 Subject: [PATCH 2/3] fix(country): map shared dialling codes to their main country --- Readme.md | 15 +++++++++------ countries.json | 11 +++++++++++ country.go | 15 ++++++++------- normalizer.go | 4 +++- normalizer_test.go | 29 +++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Readme.md b/Readme.md index 18a4b07..c41e8bc 100644 --- a/Readme.md +++ b/Readme.md @@ -111,7 +111,7 @@ Sanja includes comprehensive country data for **250+ countries and territories** - **International dialing codes** (e.g., `1`, `44`, `265`) ### Data Source -The country data used in this package was sourced from [Kaggle - Country 2ISO3UN Digit Code and Dialing Code](https://www.kaggle.com/datasets/migeruj/country-2iso3un-digit-code-and-dialing-code). +The country data used in this package was sourced from [Kaggle - Country 2ISO3UN Digit Code and Dialing Code](https://www.kaggle.com/datasets/migeruj/country-2iso3un-digit-code-and-dialing-code). The main country for each shared dialling code follows `mainCountryForCode` in Google’s [libphonenumber metadata](https://github.com/google/libphonenumber/blob/master/resources/PhoneNumberMetadata.xml). ## API Reference @@ -119,11 +119,14 @@ The country data used in this package was sourced from [Kaggle - Country 2ISO3UN ```go type Country struct { - Name string - A2 string // ISO Alpha-2 code (e.g., "US") - A3 string // ISO Alpha-3 code (e.g., "USA") - NumCode int // ISO Numeric code (e.g., 840) - DialingCode string // International dialing code (e.g., "1") + Name string + A2 string // ISO Alpha-2 code (e.g., "US") + A3 string // ISO Alpha-3 code (e.g., "USA") + NumCode int // ISO Numeric code (e.g., 840) + DialingCode string // International dialing code (e.g., "1"), or several separated by commas + MainCountryForCode bool // True for the country returned when several share a dialing code + MinLocalDigits int // Fewest digits after the dialing code + MaxLocalDigits int // Most digits after the dialing code } type Normalizer struct { diff --git a/countries.json b/countries.json index fae8560..d50231b 100644 --- a/countries.json +++ b/countries.json @@ -113,6 +113,7 @@ "a3": "AUS", "numCode": 36, "dialingCode": "61", + "mainCountryForCode": true, "minLocalDigits": 9, "maxLocalDigits": 9 }, @@ -500,6 +501,7 @@ "a3": "CUW", "numCode": 531, "dialingCode": "599", + "mainCountryForCode": true, "minLocalDigits": 7, "maxLocalDigits": 8 }, @@ -635,6 +637,7 @@ "a3": "FLK", "numCode": 238, "dialingCode": "500", + "mainCountryForCode": true, "minLocalDigits": 5, "maxLocalDigits": 5 }, @@ -788,6 +791,7 @@ "a3": "GLP", "numCode": 312, "dialingCode": "590", + "mainCountryForCode": true, "minLocalDigits": 9, "maxLocalDigits": 9 }, @@ -1346,6 +1350,7 @@ "a3": "MAR", "numCode": 504, "dialingCode": "212", + "mainCountryForCode": true, "minLocalDigits": 9, "maxLocalDigits": 9 }, @@ -1463,6 +1468,7 @@ "a3": "NFK", "numCode": 574, "dialingCode": "672", + "mainCountryForCode": true, "minLocalDigits": 6, "maxLocalDigits": 12 }, @@ -1481,6 +1487,7 @@ "a3": "NOR", "numCode": 578, "dialingCode": "47", + "mainCountryForCode": true, "minLocalDigits": 8, "maxLocalDigits": 8 }, @@ -1625,6 +1632,7 @@ "a3": "RUS", "numCode": 643, "dialingCode": "7", + "mainCountryForCode": true, "minLocalDigits": 10, "maxLocalDigits": 10 }, @@ -1643,6 +1651,7 @@ "a3": "REU", "numCode": 638, "dialingCode": "262", + "mainCountryForCode": true, "minLocalDigits": 9, "maxLocalDigits": 9 }, @@ -2102,6 +2111,7 @@ "a3": "GBR", "numCode": 826, "dialingCode": "44", + "mainCountryForCode": true, "minLocalDigits": 9, "maxLocalDigits": 10 }, @@ -2111,6 +2121,7 @@ "a3": "USA", "numCode": 840, "dialingCode": "1", + "mainCountryForCode": true, "minLocalDigits": 10, "maxLocalDigits": 10 }, diff --git a/country.go b/country.go index 81144a3..798020f 100644 --- a/country.go +++ b/country.go @@ -7,13 +7,14 @@ import ( // Country represents a country with its dialing codes and E.164 digit constraints. type Country struct { - Name string `json:"name"` - A2 string `json:"a2"` - A3 string `json:"a3"` - NumCode int `json:"numCode"` - DialingCode string `json:"dialingCode"` - MinLocalDigits int `json:"minLocalDigits"` - MaxLocalDigits int `json:"maxLocalDigits"` + Name string `json:"name"` + A2 string `json:"a2"` + A3 string `json:"a3"` + NumCode int `json:"numCode"` + DialingCode string `json:"dialingCode"` + MainCountryForCode bool `json:"mainCountryForCode"` + MinLocalDigits int `json:"minLocalDigits"` + MaxLocalDigits int `json:"maxLocalDigits"` } // GetCountryByA2 returns a country by its ISO 3166-1 alpha-2 code, or nil if not found. diff --git a/normalizer.go b/normalizer.go index f55f876..3e4bbc1 100644 --- a/normalizer.go +++ b/normalizer.go @@ -32,7 +32,9 @@ func NewNormalizer(defaultCountryA2 string) (*Normalizer, error) { for i := range n.countries { country := &n.countries[i] for _, code := range splitDialingCodes(country.DialingCode) { - n.codeMap[code] = country + if _, taken := n.codeMap[code]; !taken || country.MainCountryForCode { + n.codeMap[code] = country + } } } diff --git a/normalizer_test.go b/normalizer_test.go index a8323d2..86169a7 100644 --- a/normalizer_test.go +++ b/normalizer_test.go @@ -229,6 +229,35 @@ func TestGetCountryByCode(t *testing.T) { }) } +func TestGetCountryByCode_SharedCodes(t *testing.T) { + norm := mustNormalizer(t, "MW") + + testCases := []struct { + code string + expected string + }{ + {code: "1", expected: "US"}, + {code: "7", expected: "RU"}, + {code: "44", expected: "GB"}, + {code: "47", expected: "NO"}, + {code: "61", expected: "AU"}, + {code: "212", expected: "MA"}, + {code: "262", expected: "RE"}, + {code: "500", expected: "FK"}, + {code: "590", expected: "GP"}, + {code: "599", expected: "CW"}, + {code: "672", expected: "NF"}, + } + + for _, tc := range testCases { + t.Run(tc.code, func(t *testing.T) { + c := norm.GetCountryByCode(tc.code) + require.NotNil(t, c) + assert.Equal(t, tc.expected, c.A2) + }) + } +} + func TestValidatePhoneNumber(t *testing.T) { norm := mustNormalizer(t, "MW") From d9ff18e9a15d623ad5e0d371546a7100f9826002 Mon Sep 17 00:00:00 2001 From: cod3ddy Date: Tue, 29 Sep 2026 19:43:21 +0200 Subject: [PATCH 3/3] feat(country): add CountryForNumber --- Readme.md | 7 +++++++ country.go | 17 +++++++++++++++++ normalizer_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/Readme.md b/Readme.md index c41e8bc..2637065 100644 --- a/Readme.md +++ b/Readme.md @@ -99,8 +99,15 @@ fmt.Printf("Malawi dialing code: %s\n", country.DialingCode) country = norm.GetCountryByCode("44") fmt.Printf("Country with code 44: %s\n", country.Name) // Output: Country with code 44: United Kingdom + +// Get the country an international number belongs to +country, err := norm.CountryForNumber("+16845551234") +fmt.Printf("Country for +1 684 555 1234: %s\n", country.Name) +// Output: Country for +1 684 555 1234: American Samoa ``` +Some dialling codes are shared by several countries, such as `1` (the United States, Canada and much of the Caribbean) and `7` (Russia and Kazakhstan). For those codes, `GetCountryByCode` and `CountryForNumber` return the main country, so `+1 416 555 1234` (Toronto) comes back as the United States. + ## Supported Countries Sanja includes comprehensive country data for **250+ countries and territories** with: diff --git a/country.go b/country.go index 798020f..62b89aa 100644 --- a/country.go +++ b/country.go @@ -17,6 +17,8 @@ type Country struct { MaxLocalDigits int `json:"maxLocalDigits"` } +const longestDialingCodeDigits = 4 + // GetCountryByA2 returns a country by its ISO 3166-1 alpha-2 code, or nil if not found. func (n *Normalizer) GetCountryByA2(a2 string) *Country { for i := range n.countries { @@ -33,6 +35,21 @@ func (n *Normalizer) GetCountryByCode(code string) *Country { return n.codeMap[code] } +func (n *Normalizer) CountryForNumber(e164 string) (*Country, error) { + digits, hasPlus := strings.CutPrefix(cleanPhone(e164), "+") + if !hasPlus || digits == "" { + return nil, ErrInvalidPhoneNumber + } + + for length := min(len(digits), longestDialingCodeDigits); length > 0; length-- { + if country := n.GetCountryByCode(digits[:length]); country != nil { + return country, nil + } + } + + return nil, fmt.Errorf("%w: %s", ErrUnknownCountry, e164) +} + // ValidatePhoneNumber validates that phone is a valid E.164 number for the given country. // The number must include the country code prefix (with or without leading +). // It checks E.164 total length (<= 15 digits) and the country's known subscriber digit range. diff --git a/normalizer_test.go b/normalizer_test.go index 86169a7..6d7b041 100644 --- a/normalizer_test.go +++ b/normalizer_test.go @@ -258,6 +258,50 @@ func TestGetCountryByCode_SharedCodes(t *testing.T) { } } +func TestDialingCodesFitLongestDialingCodeDigits(t *testing.T) { + for _, country := range getCountries() { + for _, code := range splitDialingCodes(country.DialingCode) { + assert.LessOrEqual(t, len(code), longestDialingCodeDigits, country.A2) + } + } +} + +func TestCountryForNumber(t *testing.T) { + norm := mustNormalizer(t, "MW") + + testCases := []struct { + name string + phone string + expected string + expectedErr error + }{ + {name: "three-digit code", phone: "+265991234567", expected: "MW"}, + {name: "formatted number", phone: "+265 99 123 4567", expected: "MW"}, + {name: "longer code wins over shared code", phone: "+16845551234", expected: "AS"}, + {name: "second code of a multi-code country", phone: "+18295551234", expected: "DO"}, + {name: "shared code gives main country", phone: "+12025551234", expected: "US"}, + {name: "shared code 44", phone: "+447911123456", expected: "GB"}, + {name: "no plus", phone: "265991234567", expectedErr: ErrInvalidPhoneNumber}, + {name: "empty", phone: "", expectedErr: ErrInvalidPhoneNumber}, + {name: "plus only", phone: "+", expectedErr: ErrInvalidPhoneNumber}, + {name: "unassigned code", phone: "+8081234567", expectedErr: ErrUnknownCountry}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + c, err := norm.CountryForNumber(tc.phone) + if tc.expectedErr != nil { + assert.ErrorIs(t, err, tc.expectedErr) + assert.Nil(t, c) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expected, c.A2) + }) + } +} + func TestValidatePhoneNumber(t *testing.T) { norm := mustNormalizer(t, "MW")