Skip to content

Add carrier name lookup from bundled prefix data#101

Open
SylvainM98 wants to merge 3 commits into
whisperfish:mainfrom
SylvainM98:feat/carrier-name-lookup
Open

Add carrier name lookup from bundled prefix data#101
SylvainM98 wants to merge 3 commits into
whisperfish:mainfrom
SylvainM98:feat/carrier-name-lookup

Conversation

@SylvainM98

Copy link
Copy Markdown

Summary

  • Add carrier_mapper module that resolves carrier names from the existing assets/carrier/ prefix data compiled at build time
  • Longest-prefix matching algorithm consistent with libphonenumber's Java implementation
  • Multi-language support (10 languages) with locale fallback (zh_TWzh_Hant, CJK does not fall back to English)

Public API

  • carrier_mapper::name_for_number(number, lang) — carrier name with number-type filtering (mobile, fixed-line-or-mobile, pager)
  • carrier_mapper::name_for_valid_number(number, lang) — carrier name without type check
  • carrier_mapper::safe_display_name(number, lang) — carrier name only for countries without mobile number portability

Changes

  • src/carrier_mapper.rs — new module (3 public functions, locale resolution, 40 unit tests)
  • build.rs — add build_carrier_data() to compile assets/carrier/ txt files into a binary blob via postcard
  • src/lib.rs — expose carrier_mapper module
  • README.md — add carrier lookup example

No data files were modified. This only adds code to use the carrier data that is already shipped with the crate.

Test plan

  • cargo test — 134 existing tests pass, 40 new carrier tests pass, 2 doctests pass
  • cargo clippy — no warnings
  • Cross-validated results against Python phonenumbers on 19 test numbers
  • Verified carrier data is identical to official libphonenumber v9.0.21

Expose carrier_mapper module with longest-prefix matching against the
existing assets/carrier/ data compiled at build time via postcard.

Public API:
- name_for_number: carrier name with number-type filtering
- name_for_valid_number: carrier name without type check
- safe_display_name: carrier name only for non-portable countries

Supports 10 languages with locale fallback (zh_TW → zh_Hant, CJK
does not fall back to English). Includes 38 unit tests covering
French/international carriers, overlapping prefixes, multi-language,
locale normalization, type filtering, and database sanity checks.

@rubdos rubdos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only looked at the build script for now, it needs some work on the failure modes. I'll try to have a pass over your actual library code during the week. Thanks for filing your PR!

Comment thread build.rs Outdated
Comment on lines +43 to +52
if !carrier_dir.is_dir() {
// Write empty data if carrier directory is missing.
let out_path = Path::new(&env::var("OUT_DIR").unwrap()).join("carrier_data.bin");
type CarrierEntries = Vec<(String, Vec<(String, String)>)>;
let empty: (CarrierEntries, usize) = (Vec::new(), 0);
let mut out =
BufWriter::new(File::create(&out_path).expect("could not create carrier data file"));
postcard::to_io(&empty, &mut out).expect("failed to serialize carrier data");
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To deduplicate this: the !carrier_dir case is the case where you do not populate the entries map, i.e., the case where lang_dirs is just a Vec::default(). In practice, I would even try to complete avoid this case, and just assert that the directory exists. This is completely in control of our repo, and the disappearance of the directory is a bug.

Comment thread build.rs Outdated
lang_dirs.sort_by_key(|e| e.file_name());

for lang_entry in lang_dirs {
let lang = lang_entry.file_name().to_string_lossy().to_string();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let lang = lang_entry.file_name().to_string_lossy().to_string();
let lang = lang_entry.file_name().to_str().expect("unicode carrier directory");

Comment thread build.rs Outdated
// Walk each language directory: assets/carrier/en/, assets/carrier/zh/, etc.
let mut lang_dirs: Vec<_> = fs::read_dir(carrier_dir)
.expect("could not read carrier directory")
.filter_map(|e| e.ok())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I'd handle this error instead of ignoring it. Just .expect() it, and make any cases that you actually want to ignore explicit.

Comment thread build.rs Outdated

let mut txt_files: Vec<_> = fs::read_dir(&lang_path)
.unwrap_or_else(|e| panic!("could not read {}: {e}", lang_path.display()))
.filter_map(|e| e.ok())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: handle this error instead of ignoring it. Just .expect() it, and make any cases that you actually want to ignore explicit.

Comment thread build.rs Outdated
e.path()
.extension()
.map(|ext| ext == "txt")
.unwrap_or(false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This basically means: if upstream changes the extension, we have an empty database. So we need to somehow guard against that case. Then again: you have test cases that assert some entries, so this is probably fine.

Comment thread build.rs Outdated
Comment on lines +90 to +100
if let Some((prefix, name)) = line.split_once('|') {
let prefix = prefix.trim();
let name = name.trim();
if !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()) {
max_prefix_len = max_prefix_len.max(prefix.len());
entries
.entry(prefix.to_string())
.or_default()
.insert(lang.clone(), name.to_string());
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if let Some((prefix, name)) = line.split_once('|') {
let prefix = prefix.trim();
let name = name.trim();
if !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()) {
max_prefix_len = max_prefix_len.max(prefix.len());
entries
.entry(prefix.to_string())
.or_default()
.insert(lang.clone(), name.to_string());
}
}
let (prefix, name) = line.split_once('|').expect("line format");
let prefix = prefix.trim();
let name = name.trim();
if !prefix.is_empty() && prefix.bytes().all(|b| b.is_ascii_digit()) {
max_prefix_len = max_prefix_len.max(prefix.len());
entries
.entry(prefix.to_string())
.or_default()
.insert(lang.clone(), name.to_string());
}

Replace silent error swallowing with explicit panics per reviewer request:
assert carrier directory exists, use .expect() over filter_map(|e| e.ok()),
use .to_str().expect() over to_string_lossy(), unwrap split_once result,
simplify extension check with is_some_and.

Also align both OUT_DIR lookups to .expect() for consistency with the rest
of the file.
@SylvainM98

Copy link
Copy Markdown
Author

Thanks for the review! All changes applied:

  • assert instead of empty fallback — the carrier directory is part of the repo, its absence is a bug
  • .to_str().expect() instead of to_string_lossy() — kept .to_string() at the end since lang needs to be a String for the BTreeMap insert (your exact suggestion doesn't compile)
  • Both filter_map(|e| e.ok()) replaced with explicit error handling
  • split_once('|').expect("line format") instead of if let Some
  • .is_some_and() for extension check — minor cleanup, same behavior

I also noticed that build_metadata_database (line 27) had an .unwrap() on env::var("OUT_DIR") without a message — same pattern I had copied for build_carrier_data. Aligned both to .expect("OUT_DIR not set") for consistency with the rest of the file.

@sentry

sentry Bot commented Mar 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.73504% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.02%. Comparing base (416118f) to head (428415b).

Files with missing lines Patch % Lines
src/carrier_mapper.rs 92.73% 17 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #101      +/-   ##
==========================================
+ Coverage   67.30%   70.02%   +2.71%     
==========================================
  Files          18       19       +1     
  Lines        2138     2372     +234     
==========================================
+ Hits         1439     1661     +222     
- Misses        699      711      +12     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants