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
8 changes: 8 additions & 0 deletions src/broker/beenverified.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ impl BrokerConnector for BeenVerifiedBroker {
}
}

fn home_country(&self) -> Option<&str> {
Some("US")
}

fn data_countries(&self) -> &[&str] {
&["US"]
}

async fn scan(&self, query: &PersonQuery) -> anyhow::Result<Vec<FoundRecord>> {
let state = query
.state
Expand Down
73 changes: 73 additions & 0 deletions src/broker/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@ use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};

/// ISO 3166-1 alpha-2 country code (e.g. "US", "FR", "DE").
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct CountryCode(String);

impl CountryCode {
/// Create a new `CountryCode` from a string, validating that it is
/// exactly 2 uppercase ASCII letters.
pub fn new(code: &str) -> anyhow::Result<Self> {
let code = code.trim().to_uppercase();
if code.len() != 2 || !code.chars().all(|c| c.is_ascii_uppercase()) {
anyhow::bail!(
"Invalid country code '{}': must be exactly 2 uppercase ASCII letters (ISO 3166-1 alpha-2)",
code
);
}
Ok(Self(code))
}

pub fn as_str(&self) -> &str {
&self.0
}
}

impl std::fmt::Display for CountryCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonQuery {
pub first_name: String,
Expand All @@ -16,6 +45,7 @@ pub struct PersonQuery {
pub phone: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub country: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
Expand Down Expand Up @@ -51,6 +81,14 @@ pub trait BrokerConnector: Send + Sync {
fn id(&self) -> &str;
fn name(&self) -> &str;
fn capabilities(&self) -> ConnectorCapabilities;
/// The country where this broker is headquartered (ISO 3166-1 alpha-2).
fn home_country(&self) -> Option<&str> {
None
}
/// Countries whose residents' data this broker processes.
fn data_countries(&self) -> &[&str] {
&[]
}
async fn scan(&self, query: &PersonQuery) -> anyhow::Result<Vec<FoundRecord>>;
async fn request_deletion(
&self,
Expand Down Expand Up @@ -109,6 +147,7 @@ mod tests {
phone: None,
city: None,
state: None,
country: None,
};
let results = dummy.scan(&query).await.unwrap();
assert!(!results.is_empty());
Expand All @@ -135,6 +174,7 @@ mod tests {
phone: None,
city: None,
state: None,
country: None,
};
let result = bv.scan(&query).await;
assert!(result.is_err());
Expand All @@ -154,6 +194,7 @@ mod tests {
phone: None,
city: None,
state: None,
country: None,
};
assert!(bv.request_deletion(&query, &[]).await.is_err());
assert!(bv.check_deletion_status("ref-123").await.is_err());
Expand All @@ -169,6 +210,7 @@ mod tests {
phone: None,
city: None,
state: None,
country: None,
};
let records = dummy.scan(&query).await.unwrap();
let submission = dummy.request_deletion(&query, &records).await.unwrap();
Expand All @@ -180,4 +222,35 @@ mod tests {
.unwrap();
assert_eq!(status.status, "in_progress");
}

#[test]
fn test_country_code_valid() {
assert!(CountryCode::new("US").is_ok());
assert!(CountryCode::new("us").is_ok()); // auto-uppercased
assert!(CountryCode::new("De").is_ok());
assert_eq!(CountryCode::new("us").unwrap().as_str(), "US");
}

#[test]
fn test_country_code_invalid() {
assert!(CountryCode::new("").is_err());
assert!(CountryCode::new("A").is_err());
assert!(CountryCode::new("USA").is_err());
assert!(CountryCode::new("12").is_err());
assert!(CountryCode::new("U ").is_err());
}

#[test]
fn test_beenverified_country_methods() {
let bv = beenverified::BeenVerifiedBroker::new().unwrap();
assert_eq!(bv.home_country(), Some("US"));
assert!(bv.data_countries().contains(&"US"));
}

#[test]
fn test_dummy_country_defaults() {
let dummy = dummy::DummyBroker;
assert_eq!(dummy.home_country(), None);
assert!(dummy.data_countries().is_empty());
}
}
4 changes: 4 additions & 0 deletions src/broker/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct RegistryBroker {
description: Option<String>,
category: Option<String>,
connector: Option<String>,
country: Option<String>,
data_countries: Option<String>,
}

/// Fetch the broker registry from the remote URL and return Broker models.
Expand Down Expand Up @@ -38,6 +40,8 @@ pub async fn fetch_registry() -> anyhow::Result<Vec<Broker>> {
description: rb.description,
category: rb.category,
connector: rb.connector,
country: rb.country,
data_countries: rb.data_countries,
registry_updated_at: Some(now.clone()),
created_at: now.clone(),
updated_at: now.clone(),
Expand Down
31 changes: 21 additions & 10 deletions src/cli/broker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ use comfy_table::{Cell, Table};

use crate::db::Database;

pub fn list_brokers(db: &Database, category: Option<&str>) -> anyhow::Result<()> {
let brokers = db.list_brokers(category)?;
pub fn list_brokers(
db: &Database,
category: Option<&str>,
country: Option<&str>,
) -> anyhow::Result<()> {
let brokers = db.list_brokers(category, country, None)?;

if brokers.is_empty() {
println!(
Expand All @@ -13,13 +17,14 @@ pub fn list_brokers(db: &Database, category: Option<&str>) -> anyhow::Result<()>
}

let mut table = Table::new();
table.set_header(vec!["ID", "Name", "Category", "Website"]);
table.set_header(vec!["ID", "Name", "Category", "Country", "Website"]);

for b in &brokers {
table.add_row(vec![
Cell::new(&b.id),
Cell::new(&b.name),
Cell::new(b.category.as_deref().unwrap_or("-")),
Cell::new(b.country.as_deref().unwrap_or("-")),
Cell::new(b.website.as_deref().unwrap_or("-")),
]);
}
Expand All @@ -32,21 +37,27 @@ pub fn broker_info(db: &Database, id: &str) -> anyhow::Result<()> {
let broker = db.get_broker(id)?;
match broker {
Some(b) => {
println!("ID: {}", b.id);
println!("Name: {}", b.name);
println!("ID: {}", b.id);
println!("Name: {}", b.name);
if let Some(w) = &b.website {
println!("Website: {w}");
println!("Website: {w}");
}
if let Some(d) = &b.description {
println!("Description: {d}");
println!("Description: {d}");
}
if let Some(c) = &b.category {
println!("Category: {c}");
println!("Category: {c}");
}
if let Some(conn) = &b.connector {
println!("Connector: {conn}");
println!("Connector: {conn}");
}
println!("Updated: {}", b.updated_at);
if let Some(c) = &b.country {
println!("Country: {c}");
}
if let Some(dc) = &b.data_countries {
println!("Data countries: {dc}");
}
println!("Updated: {}", b.updated_at);
}
None => {
anyhow::bail!("Broker '{}' not found", id);
Expand Down
6 changes: 6 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ pub enum Command {
/// State for location-based searches
#[arg(long)]
state: Option<String>,
/// Your country (ISO 3166-1 alpha-2, e.g. US, GB, DE) — filters to relevant brokers
#[arg(long)]
country: Option<String>,
/// Only scan specific brokers (comma-separated IDs)
#[arg(long, value_delimiter = ',')]
brokers: Vec<String>,
Expand Down Expand Up @@ -105,6 +108,9 @@ pub enum BrokerCommand {
/// Filter by category
#[arg(long)]
category: Option<String>,
/// Filter by broker home country (ISO 3166-1 alpha-2)
#[arg(long)]
country: Option<String>,
},
/// Show details about a specific broker
Info {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub async fn update_registry(db: &Database) -> anyhow::Result<()> {

pub fn registry_info(db: &Database) -> anyhow::Result<()> {
let last_fetched = db.get_registry_meta("last_fetched_at")?;
let broker_count = db.list_brokers(None)?.len();
let broker_count = db.list_brokers(None, None, None)?.len();

match last_fetched {
Some(ts) => println!("Last updated: {ts}"),
Expand Down
34 changes: 26 additions & 8 deletions src/cli/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,23 @@ pub async fn scan(
query: &PersonQuery,
broker_filter: &[String],
) -> anyhow::Result<()> {
let active_connectors: Vec<_> = if broker_filter.is_empty() {
connectors.iter().collect()
} else {
connectors
.iter()
.filter(|(id, _)| broker_filter.contains(id))
.collect()
};
let user_country = query.country.as_deref().map(|c| c.to_uppercase());

let active_connectors: Vec<_> = connectors
.iter()
.filter(|(id, _)| broker_filter.is_empty() || broker_filter.contains(id))
.filter(|(_, conn)| {
// If the user specified a country, only include connectors that
// process data for that country (or have no country restriction).
match &user_country {
Some(uc) => {
let dc = conn.data_countries();
dc.is_empty() || dc.iter().any(|c| c.eq_ignore_ascii_case(uc))
}
None => true,
}
})
.collect();

if active_connectors.is_empty() {
println!("No matching connectors found. Available connectors:");
Expand Down Expand Up @@ -48,6 +57,15 @@ pub async fn scan(
description: None,
category: None,
connector: Some(id.to_string()),
country: connector.home_country().map(String::from),
data_countries: {
let dc = connector.data_countries();
if dc.is_empty() {
None
} else {
Some(dc.join(","))
}
},
registry_updated_at: None,
created_at: now.clone(),
updated_at: now,
Expand Down
3 changes: 3 additions & 0 deletions src/db/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ const MIGRATIONS: &[&str] = &[
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
// Migration 2: Add country fields to brokers
"ALTER TABLE brokers ADD COLUMN country TEXT;
ALTER TABLE brokers ADD COLUMN data_countries TEXT;",
];

pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
Expand Down
28 changes: 25 additions & 3 deletions src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ mod tests {
description: Some("A test broker".into()),
category: Some("people-search".into()),
connector: None,
country: Some("US".into()),
data_countries: Some("US,GB".into()),
registry_updated_at: None,
created_at: now.clone(),
updated_at: now,
Expand All @@ -72,15 +74,31 @@ mod tests {

let fetched = db.get_broker("test-broker").unwrap().unwrap();
assert_eq!(fetched.name, "Test Broker");
assert_eq!(fetched.country.as_deref(), Some("US"));
assert_eq!(fetched.data_countries.as_deref(), Some("US,GB"));

let all = db.list_brokers(None).unwrap();
let all = db.list_brokers(None, None, None).unwrap();
assert_eq!(all.len(), 1);

let filtered = db.list_brokers(Some("people-search")).unwrap();
let filtered = db.list_brokers(Some("people-search"), None, None).unwrap();
assert_eq!(filtered.len(), 1);

let empty = db.list_brokers(Some("nonexistent")).unwrap();
let empty = db.list_brokers(Some("nonexistent"), None, None).unwrap();
assert!(empty.is_empty());

// Filter by country
let by_country = db.list_brokers(None, Some("US"), None).unwrap();
assert_eq!(by_country.len(), 1);

let no_country = db.list_brokers(None, Some("DE"), None).unwrap();
assert!(no_country.is_empty());

// Filter by data_country
let by_data = db.list_brokers(None, None, Some("GB")).unwrap();
assert_eq!(by_data.len(), 1);

let no_data = db.list_brokers(None, None, Some("FR")).unwrap();
assert!(no_data.is_empty());
}

#[test]
Expand All @@ -96,6 +114,8 @@ mod tests {
description: None,
category: None,
connector: None,
country: None,
data_countries: None,
registry_updated_at: None,
created_at: now.clone(),
updated_at: now.clone(),
Expand Down Expand Up @@ -135,6 +155,8 @@ mod tests {
description: None,
category: None,
connector: None,
country: None,
data_countries: None,
registry_updated_at: None,
created_at: now.clone(),
updated_at: now.clone(),
Expand Down
2 changes: 2 additions & 0 deletions src/db/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ pub struct Broker {
pub description: Option<String>,
pub category: Option<String>,
pub connector: Option<String>,
pub country: Option<String>,
pub data_countries: Option<String>,
pub registry_updated_at: Option<String>,
pub created_at: String,
pub updated_at: String,
Expand Down
Loading
Loading