implement phase 1~3

This commit is contained in:
ms2k 2026-03-20 02:18:13 +09:00
commit c60f3b2ab0
7 changed files with 913 additions and 0 deletions

148
src/app.rs Normal file
View file

@ -0,0 +1,148 @@
use std::sync::mpsc::{self, Sender};
use std::time::Duration;
use eframe::egui;
use crate::config::Config;
use crate::countries::{get_countries, Country};
use crate::nordvpn::{self, CmdResult, Poller};
use crate::ui::country_selector::CountrySelector;
pub struct App {
config: Config,
countries: Vec<Country>,
poller: Poller,
cmd_tx: Sender<CmdResult>,
cmd_rx: mpsc::Receiver<CmdResult>,
selector: CountrySelector,
/// Last connect/disconnect result shown inline below the status bar.
notification: Option<String>,
}
impl App {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let config = Config::load();
let countries = get_countries(&config.external_app);
let poller = Poller::new(config.external_app.clone(), config.refresh_rate);
let (cmd_tx, cmd_rx) = mpsc::channel();
Self {
config,
countries,
poller,
cmd_tx,
cmd_rx,
selector: CountrySelector::default(),
notification: None,
}
}
fn spawn_connect(&self, nordvpn_key: String) {
let app = self.config.external_app.clone();
let tx = self.cmd_tx.clone();
std::thread::spawn(move || {
let _ = tx.send(nordvpn::connect(&app, &nordvpn_key));
});
}
fn spawn_disconnect(&self) {
let app = self.config.external_app.clone();
let tx = self.cmd_tx.clone();
std::thread::spawn(move || {
let _ = tx.send(nordvpn::disconnect(&app));
});
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Drain completed command results.
while let Ok(result) = self.cmd_rx.try_recv() {
self.notification = Some(match result {
CmdResult::Success { output, .. } => output.trim().to_string(),
CmdResult::Failure { cmd, message } => {
format!("Error running '{cmd}': {message}")
}
});
}
// Repaint often enough to stay live without busy-looping.
ctx.request_repaint_after(Duration::from_secs(1));
let status = self.poller.status.lock().unwrap().clone();
// ── Status panel ────────────────────────────────────────────────────
egui::TopBottomPanel::top("status_panel")
.min_height(40.0)
.show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.strong(&status.status);
if let Some(country) = &status.country {
ui.separator();
ui.label(country);
}
if let Some(ip) = &status.ip {
ui.separator();
ui.label(ip);
}
if let Some(uptime) = &status.uptime {
ui.separator();
ui.weak(uptime);
}
});
if let Some(note) = &self.notification {
ui.add_space(2.0);
ui.weak(note);
}
ui.add_space(4.0);
});
// ── Bottom bar ──────────────────────────────────────────────────────
egui::TopBottomPanel::bottom("bottom_bar")
.min_height(36.0)
.show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("Disconnect").clicked() {
self.notification = None;
self.spawn_disconnect();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Configure").clicked() {
// Phase 7
}
});
});
});
// ── Left panel — country selector ───────────────────────────────────
let panel_width = ctx.screen_rect().width() * 0.3;
let connect_key =
egui::SidePanel::left("country_selector")
.exact_width(panel_width)
.resizable(false)
.show(ctx, |ui| {
self.selector.show(
ui,
&self.countries,
&self.config.favorites,
status.country.as_deref(),
)
})
.inner;
if let Some(key) = connect_key {
self.notification = None;
self.spawn_connect(key);
}
// ── Central panel — world map placeholder ───────────────────────────
egui::CentralPanel::default().show(ctx, |ui| {
ui.centered_and_justified(|ui| {
ui.weak("World map — Phase 5");
});
});
}
}

93
src/config.rs Normal file
View file

@ -0,0 +1,93 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const CONFIG_FILE: &str = "config.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub external_app: String,
pub refresh_rate: u64,
pub favorites: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
external_app: "nordvpn".to_string(),
refresh_rate: 5,
favorites: vec![
"United_States".to_string(),
"United_Kingdom".to_string(),
"Japan".to_string(),
"Germany".to_string(),
"Australia".to_string(),
"South_Korea".to_string(),
"Canada".to_string(),
"France".to_string(),
],
}
}
}
impl Config {
pub fn load() -> Self {
let path = config_path();
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text).unwrap_or_else(|e| {
eprintln!("Failed to parse {}: {e}. Using defaults.", path.display());
Self::default()
}),
Err(_) => Self::default(),
}
}
pub fn save(&self) -> Result<(), String> {
let text = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
std::fs::write(config_path(), text).map_err(|e| e.to_string())
}
}
fn config_path() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join(CONFIG_FILE)))
.unwrap_or_else(|| PathBuf::from(CONFIG_FILE))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_values() {
let c = Config::default();
assert_eq!(c.external_app, "nordvpn");
assert_eq!(c.refresh_rate, 5);
assert!(!c.favorites.is_empty());
}
#[test]
fn roundtrip() {
let original = Config {
external_app: "/usr/bin/nordvpn".to_string(),
refresh_rate: 10,
favorites: vec!["US".to_string(), "DE".to_string()],
};
let serialized = toml::to_string_pretty(&original).expect("serialize");
let restored: Config = toml::from_str(&serialized).expect("deserialize");
assert_eq!(restored.external_app, original.external_app);
assert_eq!(restored.refresh_rate, original.refresh_rate);
assert_eq!(restored.favorites, original.favorites);
}
#[test]
fn missing_fields_fall_back_to_defaults() {
// Only external_app is present — other fields must use their Default values.
let partial = r#"external_app = "custom-vpn""#;
let c: Config = toml::from_str(partial).expect("partial config should deserialize");
assert_eq!(c.external_app, "custom-vpn");
assert_eq!(c.refresh_rate, Config::default().refresh_rate);
assert_eq!(c.favorites, Config::default().favorites);
}
}

141
src/countries.rs Normal file
View file

@ -0,0 +1,141 @@
use crate::nordvpn;
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
pub struct Country {
/// Display name, e.g. "United States"
pub name: String,
/// Exact token from `nordvpn countries`; used as both the connect arg
/// and the flag SVG filename base (`flags/{nordvpn_key}.svg`)
pub nordvpn_key: String,
}
impl Country {
fn from_key(key: &str) -> Self {
Self {
name: display_name(key),
nordvpn_key: key.to_string(),
}
}
}
fn display_name(key: &str) -> String {
// The only country whose common name can't be derived from the key alone.
if key == "Lao_Peoples_Democratic_Republic" {
return "Lao People's Democratic Republic".to_string();
}
// Rejoin words, lowercasing connectives that appear mid-name.
key.split('_')
.enumerate()
.map(|(i, word)| {
if i > 0 && matches!(word, "And" | "Of" | "The") {
word.to_lowercase()
} else {
word.to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Fetch the country list from `nordvpn countries`.
/// Returns an empty list if the CLI is unavailable — the app won't be
/// functional in that case regardless.
pub fn get_countries(external_app: &str) -> Vec<Country> {
nordvpn::countries(external_app)
.map(|output| parse_cli_output(&output))
.unwrap_or_default()
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Parse `nordvpn countries` stdout.
///
/// Lines that are not plain country tokens (e.g. the update notice
/// "A new version of NordVPN is available!") are skipped — country tokens
/// contain only ASCII letters and underscores.
fn parse_cli_output(output: &str) -> Vec<Country> {
output
.lines()
.map(str::trim)
.filter(|line| {
!line.is_empty() && line.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
})
.map(Country::from_key)
.collect()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_name_replaces_underscores() {
assert_eq!(display_name("United_States"), "United States");
assert_eq!(display_name("Germany"), "Germany");
}
#[test]
fn display_name_lowercases_connectives() {
assert_eq!(display_name("Bosnia_And_Herzegovina"), "Bosnia and Herzegovina");
assert_eq!(display_name("Trinidad_And_Tobago"), "Trinidad and Tobago");
assert_eq!(display_name("Isle_Of_Man"), "Isle of Man");
}
#[test]
fn display_name_connective_preserved_at_start() {
// "And" or "Of" as the first word should stay capitalised (unlikely
// in practice, but the rule should be consistent).
assert_eq!(display_name("And_Something"), "And Something");
}
#[test]
fn display_name_exception_apostrophe() {
assert_eq!(
display_name("Lao_Peoples_Democratic_Republic"),
"Lao People's Democratic Republic"
);
}
#[test]
fn nordvpn_key_preserved_exactly() {
let c = Country::from_key("United_States");
assert_eq!(c.nordvpn_key, "United_States");
assert_eq!(c.name, "United States");
}
#[test]
fn cli_output_skips_update_notice() {
let output = "\
A new version of NordVPN is available!
Please update the app.
Germany
Japan
United_States";
let countries = parse_cli_output(output);
assert_eq!(countries.len(), 3);
assert_eq!(countries[0].nordvpn_key, "Germany");
assert_eq!(countries[1].nordvpn_key, "Japan");
assert_eq!(countries[2].nordvpn_key, "United_States");
}
#[test]
fn cli_output_skips_blank_lines() {
let countries = parse_cli_output("\n\nGermany\n\nJapan\n");
assert_eq!(countries.len(), 2);
}
}

24
src/main.rs Normal file
View file

@ -0,0 +1,24 @@
// TODO: remove after phase 7 — dead_code is expected while modules are being built up
#![allow(dead_code)]
mod app;
mod config;
mod countries;
mod nordvpn;
mod ui;
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: eframe::egui::ViewportBuilder::default()
.with_title("NordVPN")
.with_inner_size([1024.0, 640.0])
.with_min_inner_size([640.0, 400.0]),
..Default::default()
};
eframe::run_native(
"NordVPN",
options,
Box::new(|cc| Ok(Box::new(app::App::new(cc)))),
)
}

283
src/nordvpn.rs Normal file
View file

@ -0,0 +1,283 @@
use std::collections::HashMap;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Default)]
pub struct StatusInfo {
pub status: String,
pub server: Option<String>,
pub country: Option<String>,
pub ip: Option<String>,
pub city: Option<String>,
pub protocol: Option<String>,
pub transfer: Option<String>,
pub uptime: Option<String>,
}
impl StatusInfo {
pub fn is_connected(&self) -> bool {
self.status.eq_ignore_ascii_case("connected")
}
}
/// Result sent back to the UI thread after a connect/disconnect command.
#[derive(Debug)]
pub enum CmdResult {
Success { cmd: String, output: String },
Failure { cmd: String, message: String },
}
// ---------------------------------------------------------------------------
// CLI wrappers
// ---------------------------------------------------------------------------
pub fn connect(external_app: &str, country_code: &str) -> CmdResult {
run_cmd(external_app, &["connect", country_code])
}
pub fn disconnect(external_app: &str) -> CmdResult {
run_cmd(external_app, &["disconnect"])
}
pub fn countries(external_app: &str) -> Result<String, String> {
let out = Command::new(external_app)
.args(["countries"])
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&out.stderr).into_owned())
}
}
fn run_cmd(external_app: &str, args: &[&str]) -> CmdResult {
let cmd = format!("{} {}", external_app, args.join(" "));
match Command::new(external_app).args(args).output() {
Ok(out) if out.status.success() => CmdResult::Success {
cmd,
output: String::from_utf8_lossy(&out.stdout).into_owned(),
},
Ok(out) => CmdResult::Failure {
cmd,
message: String::from_utf8_lossy(&out.stderr).into_owned(),
},
Err(e) => CmdResult::Failure { cmd, message: e.to_string() },
}
}
// ---------------------------------------------------------------------------
// Status parsing
// ---------------------------------------------------------------------------
/// Parse `nordvpn status` stdout into a `StatusInfo`.
///
/// The output is a series of `Key: Value` lines. Lines without `: ` are ignored.
/// ANSI escape sequences (e.g. colour codes) are stripped before parsing.
pub fn parse_status(raw: &str) -> StatusInfo {
let cleaned = strip_ansi(raw);
let map: HashMap<String, String> = cleaned
.lines()
.filter_map(|line| {
let (k, v) = line.split_once(':')?;
Some((k.trim().to_lowercase(), v.trim().to_string()))
})
.collect();
StatusInfo {
status: map.get("status").cloned().unwrap_or_else(|| "Unknown".to_string()),
server: map.get("current server").cloned(),
country: map.get("country").cloned(),
ip: map.get("your new ip").or_else(|| map.get("ip")).cloned(),
city: map.get("city").cloned(),
protocol: map.get("current protocol").cloned(),
transfer: map.get("transfer").cloned(),
uptime: map.get("uptime").cloned(),
}
}
/// Remove ANSI escape sequences from a string.
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
// consume until a letter (end of escape sequence)
for ch in chars.by_ref() {
if ch.is_ascii_alphabetic() {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn fetch_status(external_app: &str) -> StatusInfo {
match Command::new(external_app).args(["status"]).output() {
Ok(out) if out.status.success() => {
parse_status(&String::from_utf8_lossy(&out.stdout))
}
Ok(out) => StatusInfo {
status: format!("Error: {}", String::from_utf8_lossy(&out.stderr).trim()),
..Default::default()
},
Err(e) => StatusInfo {
status: format!("Error: {e}"),
..Default::default()
},
}
}
// ---------------------------------------------------------------------------
// Status poller
// ---------------------------------------------------------------------------
struct PollConfig {
external_app: String,
refresh_rate: u64,
}
/// Background status poller. Drops cleanly when the owning `App` is closed.
pub struct Poller {
pub status: Arc<Mutex<StatusInfo>>,
poll_config: Arc<Mutex<PollConfig>>,
shutdown: Arc<AtomicBool>,
}
impl Poller {
pub fn new(external_app: String, refresh_rate: u64) -> Self {
let status = Arc::new(Mutex::new(StatusInfo::default()));
let poll_config = Arc::new(Mutex::new(PollConfig { external_app, refresh_rate }));
let shutdown = Arc::new(AtomicBool::new(false));
let status_w = Arc::clone(&status);
let config_r = Arc::clone(&poll_config);
let stop = Arc::clone(&shutdown);
std::thread::Builder::new()
.name("nordvpn-status-poller".into())
.spawn(move || {
loop {
if stop.load(Ordering::Relaxed) {
break;
}
let (app, rate) = {
let cfg = config_r.lock().unwrap();
(cfg.external_app.clone(), cfg.refresh_rate)
};
*status_w.lock().unwrap() = fetch_status(&app);
// Sleep in 100 ms increments so shutdown is responsive.
let ticks = (rate * 10).max(1);
for _ in 0..ticks {
if stop.load(Ordering::Relaxed) {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
}
})
.expect("failed to spawn poller thread");
Self { status, poll_config, shutdown }
}
/// Update the poller's config (effective on next poll cycle).
pub fn update_config(&self, external_app: String, refresh_rate: u64) {
let mut cfg = self.poll_config.lock().unwrap();
cfg.external_app = external_app;
cfg.refresh_rate = refresh_rate;
}
}
impl Drop for Poller {
fn drop(&mut self) {
self.shutdown.store(true, Ordering::Relaxed);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
const CONNECTED_OUTPUT: &str = "\
Status: Connected
Current server: us9999.nordvpn.com
Country: United States
City: New York
Your new IP: 1.2.3.4
Current technology: NORDLYNX
Current protocol: UDP
Transfer: 1.23 GiB received, 234.56 MiB sent
Uptime: 4 hours 23 minutes 12 seconds";
const DISCONNECTED_OUTPUT: &str = "Status: Disconnected";
#[test]
fn parse_connected() {
let s = parse_status(CONNECTED_OUTPUT);
assert!(s.is_connected());
assert_eq!(s.country.as_deref(), Some("United States"));
assert_eq!(s.ip.as_deref(), Some("1.2.3.4"));
assert_eq!(s.city.as_deref(), Some("New York"));
assert_eq!(s.protocol.as_deref(), Some("UDP"));
assert!(s.transfer.is_some());
assert!(s.uptime.is_some());
}
#[test]
fn parse_disconnected() {
let s = parse_status(DISCONNECTED_OUTPUT);
assert!(!s.is_connected());
assert_eq!(s.status, "Disconnected");
assert!(s.ip.is_none());
}
#[test]
fn parse_empty_returns_unknown() {
let s = parse_status("");
assert_eq!(s.status, "Unknown");
}
#[test]
fn parse_partial_does_not_panic() {
let s = parse_status("Status: Connected\ngarbage line\n: empty key");
assert!(s.is_connected());
assert!(s.ip.is_none());
}
#[test]
fn strip_ansi_removes_escape_sequences() {
let raw = "\x1b[1mStatus\x1b[0m: Connected";
assert_eq!(strip_ansi(raw), "Status: Connected");
}
#[test]
fn parse_status_with_ansi() {
let raw = "\x1b[32mStatus\x1b[0m: Connected\n\x1b[1mCity\x1b[0m: Berlin";
let s = parse_status(raw);
assert!(s.is_connected());
assert_eq!(s.city.as_deref(), Some("Berlin"));
}
#[test]
fn is_connected_case_insensitive() {
let s = parse_status("Status: CONNECTED");
assert!(s.is_connected());
}
}

223
src/ui/country_selector.rs Normal file
View file

@ -0,0 +1,223 @@
use std::collections::HashSet;
use eframe::egui;
use crate::countries::Country;
#[derive(Default, PartialEq)]
enum ViewMode {
Grid,
#[default]
List,
}
pub struct CountrySelector {
filter: String,
view_mode: ViewMode,
}
impl Default for CountrySelector {
fn default() -> Self {
Self { filter: String::new(), view_mode: ViewMode::default() }
}
}
impl CountrySelector {
/// Draw the selector panel. Returns the `nordvpn_key` of the country the
/// user clicked, if any.
pub fn show(
&mut self,
ui: &mut egui::Ui,
countries: &[Country],
favorites: &[String],
connected_key: Option<&str>,
) -> Option<String> {
let mut clicked = None;
// ── Toggle + search bar ─────────────────────────────────────────────
ui.horizontal(|ui| {
ui.selectable_value(&mut self.view_mode, ViewMode::List, "List");
ui.selectable_value(&mut self.view_mode, ViewMode::Grid, "Grid");
ui.add(
egui::TextEdit::singleline(&mut self.filter)
.hint_text("🔍 Filter…")
.desired_width(f32::INFINITY),
);
});
ui.add_space(2.0);
// ── Filter + partition ──────────────────────────────────────────────
let filter_lower = self.filter.to_lowercase();
let fav_set: HashSet<&str> = favorites.iter().map(String::as_str).collect();
let matches = |c: &&Country| {
filter_lower.is_empty() || c.name.to_lowercase().contains(&filter_lower)
};
let favs: Vec<&Country> = countries.iter().filter(|c| fav_set.contains(c.nordvpn_key.as_str())).filter(matches).collect();
let rest: Vec<&Country> = countries.iter().filter(|c| !fav_set.contains(c.nordvpn_key.as_str())).filter(matches).collect();
// ── Scrollable content ──────────────────────────────────────────────
egui::ScrollArea::vertical().show(ui, |ui| {
match self.view_mode {
ViewMode::List => {
clicked = show_list(ui, &favs, &rest, connected_key);
}
ViewMode::Grid => {
clicked = show_grid(ui, &favs, &rest, connected_key);
}
}
});
clicked
}
}
// ---------------------------------------------------------------------------
// List view
// ---------------------------------------------------------------------------
fn show_list(
ui: &mut egui::Ui,
favs: &[&Country],
rest: &[&Country],
connected_key: Option<&str>,
) -> Option<String> {
let mut clicked = None;
if !favs.is_empty() {
ui.label(egui::RichText::new("★ Favorites").weak().small());
ui.separator();
for c in favs {
if country_row(ui, c, connected_key) {
clicked = Some(c.nordvpn_key.clone());
}
}
ui.add_space(4.0);
}
if !rest.is_empty() {
if !favs.is_empty() {
ui.label(egui::RichText::new("All").weak().small());
ui.separator();
}
for c in rest {
if country_row(ui, c, connected_key) {
clicked = Some(c.nordvpn_key.clone());
}
}
}
clicked
}
fn country_row(ui: &mut egui::Ui, country: &Country, connected_key: Option<&str>) -> bool {
let is_connected = connected_key.is_some_and(|k| names_match(&country.name, k));
let text = if is_connected {
egui::RichText::new(format!("▶ {}", country.name)).strong()
} else {
egui::RichText::new(&country.name)
};
let resp = ui.add(egui::Button::new(text).frame(false).min_size(egui::vec2(ui.available_width(), 0.0)));
resp.clicked()
}
// ---------------------------------------------------------------------------
// Grid view (text labels — flags added in phase 4)
// ---------------------------------------------------------------------------
fn show_grid(
ui: &mut egui::Ui,
favs: &[&Country],
rest: &[&Country],
connected_key: Option<&str>,
) -> Option<String> {
let mut clicked = None;
if !favs.is_empty() {
ui.label(egui::RichText::new("★ Favorites").weak().small());
ui.separator();
clicked = clicked.or(grid_section(ui, favs, connected_key));
ui.add_space(4.0);
}
if !rest.is_empty() {
if !favs.is_empty() {
ui.label(egui::RichText::new("All").weak().small());
ui.separator();
}
clicked = clicked.or(grid_section(ui, rest, connected_key));
}
clicked
}
fn grid_section(
ui: &mut egui::Ui,
countries: &[&Country],
connected_key: Option<&str>,
) -> Option<String> {
let mut clicked = None;
// Wrap buttons horizontally — egui handles column breaks automatically.
ui.horizontal_wrapped(|ui| {
for c in countries {
let is_connected = connected_key.is_some_and(|k| names_match(&c.name, k));
// Truncate to first word so buttons stay compact without flags.
let label = first_word(&c.name);
let text = if is_connected {
egui::RichText::new(label).strong()
} else {
egui::RichText::new(label)
};
if ui.add(egui::Button::new(text).min_size(egui::vec2(60.0, 24.0))).clicked() {
clicked = Some(c.nordvpn_key.clone());
}
}
});
clicked
}
fn first_word(s: &str) -> &str {
s.split_whitespace().next().unwrap_or(s)
}
/// Compare a country display name against the string nordvpn status outputs
/// for "Country:", normalising away differences like apostrophes.
///
/// e.g. our display name "Lao People's Democratic Republic" and the status
/// output "Lao Peoples Democratic Republic" both normalise to the same string.
fn names_match(display_name: &str, status_country: &str) -> bool {
let norm = |s: &str| s.to_lowercase().replace('\'', "");
norm(display_name) == norm(status_country)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_match_exact() {
assert!(names_match("Germany", "Germany"));
assert!(names_match("United States", "United States"));
}
#[test]
fn names_match_case_insensitive() {
assert!(names_match("Germany", "germany"));
}
#[test]
fn names_match_apostrophe_normalisation() {
// Our display name has an apostrophe; nordvpn status output does not.
assert!(names_match("Lao People's Democratic Republic", "Lao Peoples Democratic Republic"));
}
#[test]
fn names_match_no_false_positives() {
assert!(!names_match("Germany", "France"));
assert!(!names_match("United States", "United Kingdom"));
}
}

1
src/ui/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod country_selector;