use anyhow::{anyhow, Result};
use bgpkit_commons::BgpkitCommons;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
static COUNTRY_DATA: OnceLock<CountryData> = OnceLock::new();
struct CountryData {
entries: Vec<CountryEntry>,
}
impl CountryData {
fn load() -> Result<Self> {
let mut commons = BgpkitCommons::new();
commons
.load_countries()
.map_err(|e| anyhow!("Failed to load countries from bgpkit-commons: {}", e))?;
let countries = commons
.country_all()
.map_err(|e| anyhow!("Failed to get countries: {}", e))?;
let entries: Vec<CountryEntry> = countries
.into_iter()
.map(|c| CountryEntry {
code: c.code,
name: c.name,
})
.collect();
Ok(Self { entries })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, tabled::Tabled)]
pub struct CountryEntry {
pub code: String,
pub name: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum CountryOutputFormat {
#[default]
Table,
Json,
Simple,
Markdown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "cli", derive(clap::Args))]
pub struct CountryLookupArgs {
#[cfg_attr(feature = "cli", clap(value_name = "QUERY"))]
pub query: Option<String>,
#[cfg_attr(feature = "cli", clap(short, long))]
#[serde(default)]
pub all: bool,
#[cfg_attr(feature = "cli", clap(short, long, default_value = "table"))]
#[serde(default)]
pub format: CountryOutputFormat,
}
impl CountryLookupArgs {
pub fn new(query: impl Into<String>) -> Self {
Self {
query: Some(query.into()),
all: false,
format: CountryOutputFormat::default(),
}
}
pub fn all_countries() -> Self {
Self {
query: None,
all: true,
format: CountryOutputFormat::default(),
}
}
pub fn with_format(mut self, format: CountryOutputFormat) -> Self {
self.format = format;
self
}
pub fn validate(&self) -> Result<(), String> {
if !self.all && self.query.is_none() {
return Err("Either a query or --all flag is required".to_string());
}
Ok(())
}
}
pub struct CountryLens {
_marker: std::marker::PhantomData<()>,
}
impl CountryLens {
pub fn new() -> Self {
let _ = COUNTRY_DATA.get_or_init(|| {
CountryData::load().unwrap_or_else(|e| {
tracing::warn!("Failed to load country data: {}. Using empty dataset.", e);
CountryData {
entries: Vec::new(),
}
})
});
Self {
_marker: std::marker::PhantomData,
}
}
fn data(&self) -> &CountryData {
COUNTRY_DATA.get_or_init(|| {
CountryData::load().unwrap_or_else(|_| CountryData {
entries: Vec::new(),
})
})
}
pub fn search(&self, args: &CountryLookupArgs) -> Result<Vec<CountryEntry>> {
if args.all {
return Ok(self.all());
}
match &args.query {
Some(query) => Ok(self.lookup(query)),
None => Err(anyhow!("Either a query or --all flag is required")),
}
}
pub fn lookup_code(&self, code: &str) -> Option<&str> {
let code_upper = code.to_uppercase();
self.data()
.entries
.iter()
.find(|e| e.code == code_upper)
.map(|e| e.name.as_str())
}
pub fn lookup(&self, query: &str) -> Vec<CountryEntry> {
let mut entries = vec![];
let query_lower = query.to_lowercase();
let query_upper = query.to_uppercase();
for entry in &self.data().entries {
if entry.code == query_upper {
return vec![entry.clone()];
} else if entry.name.to_lowercase().contains(&query_lower) {
entries.push(entry.clone());
}
}
entries
}
pub fn all(&self) -> Vec<CountryEntry> {
let mut entries = self.data().entries.clone();
entries.sort_by(|a, b| a.code.cmp(&b.code));
entries
}
pub fn format_results(&self, results: &[CountryEntry], format: &CountryOutputFormat) -> String {
if results.is_empty() {
return match format {
CountryOutputFormat::Json => "[]".to_string(),
_ => "No countries found".to_string(),
};
}
match format {
CountryOutputFormat::Table => {
use tabled::settings::Style;
use tabled::Table;
Table::new(results).with(Style::rounded()).to_string()
}
CountryOutputFormat::Markdown => {
use tabled::settings::Style;
use tabled::Table;
Table::new(results).with(Style::markdown()).to_string()
}
CountryOutputFormat::Json => serde_json::to_string_pretty(results).unwrap_or_default(),
CountryOutputFormat::Simple => results
.iter()
.map(|e| format!("{}: {}", e.code, e.name))
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn format_json(&self, results: &[CountryEntry], pretty: bool) -> String {
if pretty {
serde_json::to_string_pretty(results).unwrap_or_default()
} else {
serde_json::to_string(results).unwrap_or_default()
}
}
}
impl Default for CountryLens {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lookup_code() {
let lens = CountryLens::new();
let result = lens.lookup_code("US");
assert!(result.is_some());
let name = result.unwrap();
assert!(name.contains("United States") || name.contains("America"));
let result_lower = lens.lookup_code("us");
assert!(result_lower.is_some());
assert!(lens.lookup_code("XX").is_none());
}
#[test]
fn test_lookup_by_code() {
let lens = CountryLens::new();
let results = lens.lookup("US");
assert_eq!(results.len(), 1);
assert_eq!(results[0].code, "US");
}
#[test]
fn test_lookup_by_name() {
let lens = CountryLens::new();
let results = lens.lookup("united");
assert!(!results.is_empty());
}
#[test]
fn test_all() {
let lens = CountryLens::new();
let all = lens.all();
assert!(!all.is_empty());
if all.len() > 1 {
assert!(all[0].code < all[1].code);
}
}
#[test]
fn test_search_with_args() {
let lens = CountryLens::new();
let args = CountryLookupArgs::new("US");
let results = lens.search(&args).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].code, "US");
let args = CountryLookupArgs::all_countries();
let results = lens.search(&args).unwrap();
assert!(!results.is_empty());
}
#[test]
fn test_args_validation() {
let args = CountryLookupArgs::default();
assert!(args.validate().is_err());
let args = CountryLookupArgs::new("US");
assert!(args.validate().is_ok());
let args = CountryLookupArgs::all_countries();
assert!(args.validate().is_ok());
}
#[test]
fn test_format_results() {
let lens = CountryLens::new();
let results = vec![
CountryEntry {
code: "US".to_string(),
name: "United States".to_string(),
},
CountryEntry {
code: "CA".to_string(),
name: "Canada".to_string(),
},
];
let output = lens.format_results(&results, &CountryOutputFormat::Json);
assert!(output.contains("US"));
assert!(output.contains("United States"));
let output = lens.format_results(&results, &CountryOutputFormat::Simple);
assert!(output.contains("US: United States"));
assert!(output.contains("CA: Canada"));
let output = lens.format_results(&[], &CountryOutputFormat::Simple);
assert_eq!(output, "No countries found");
let output = lens.format_results(&[], &CountryOutputFormat::Json);
assert_eq!(output, "[]");
}
#[test]
fn test_format_json() {
let lens = CountryLens::new();
let results = vec![CountryEntry {
code: "US".to_string(),
name: "United States".to_string(),
}];
let compact = lens.format_json(&results, false);
assert!(compact.contains("US"));
let pretty = lens.format_json(&results, true);
assert!(pretty.contains('\n'));
}
#[test]
fn test_args_builder() {
let args = CountryLookupArgs::new("US").with_format(CountryOutputFormat::Json);
assert_eq!(args.query, Some("US".to_string()));
assert!(matches!(args.format, CountryOutputFormat::Json));
}
}