use serde_json::Value;
pub fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
pub fn http_get(url: &str) -> Option<String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(5)))
.build()
.into();
let mut resp = agent.get(url).call().ok()?;
resp.body_mut().read_to_string().ok()
}
pub fn ip_geolocate() -> Option<(f64, f64)> {
let fresh = ipinfo_geolocate().or_else(ipapi_geolocate);
if let Some(coords) = fresh {
save_location_cache(coords);
return Some(coords);
}
load_location_cache()
}
pub fn ipinfo_geolocate() -> Option<(f64, f64)> {
let raw = http_get("https://ipinfo.io/json")?;
let v: Value = serde_json::from_str(&raw).ok()?;
let loc = v.get("loc")?.as_str()?;
let (lat, lon) = loc.split_once(',')?;
Some((lat.parse().ok()?, lon.parse().ok()?))
}
pub fn ipapi_geolocate() -> Option<(f64, f64)> {
let raw = http_get("https://ipapi.co/json")?;
let v: Value = serde_json::from_str(&raw).ok()?;
let lat = v.get("latitude")?.as_f64()?;
let lon = v.get("longitude")?.as_f64()?;
Some((lat, lon))
}
pub fn location_cache_path() -> Option<std::path::PathBuf> {
let home = std::env::var_os("HOME")?;
let mut p = std::path::PathBuf::from(home);
p.push(".powerliners");
std::fs::create_dir_all(&p).ok()?;
p.push("location.json");
Some(p)
}
pub fn save_location_cache(coords: (f64, f64)) {
if let Some(path) = location_cache_path() {
let _ = std::fs::write(
path,
format!(r#"{{"lat":{},"lon":{}}}"#, coords.0, coords.1),
);
}
}
pub fn load_location_cache() -> Option<(f64, f64)> {
let path = location_cache_path()?;
let raw = std::fs::read_to_string(path).ok()?;
let v: Value = serde_json::from_str(&raw).ok()?;
let lat = v.get("lat")?.as_f64()?;
let lon = v.get("lon")?.as_f64()?;
Some((lat, lon))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urlencode_passes_through_safe_chars() {
assert_eq!(urlencode("abc-DEF_123.~"), "abc-DEF_123.~");
}
#[test]
fn urlencode_space_becomes_plus() {
assert_eq!(urlencode("hello world"), "hello+world");
}
#[test]
fn urlencode_percent_escapes_unsafe_bytes() {
assert_eq!(urlencode(","), "%2C");
assert_eq!(urlencode("+"), "%2B");
assert_eq!(urlencode("a/b"), "a%2Fb");
}
#[test]
fn urlencode_combines_paths() {
assert_eq!(urlencode("San Francisco,US"), "San+Francisco%2CUS");
}
#[test]
fn urlencode_empty_is_empty() {
assert_eq!(urlencode(""), "");
}
#[test]
fn urlencode_handles_high_bytes() {
assert_eq!(urlencode("é"), "%C3%A9");
}
#[test]
fn location_cache_path_returns_powerliners_dir_when_home_set() {
if let Some(home) = std::env::var_os("HOME") {
let p = location_cache_path().expect("HOME set → Some");
let home_str = home.to_string_lossy();
assert!(
p.starts_with(home_str.as_ref()),
"expected path under $HOME, got {}",
p.display()
);
assert_eq!(
p.file_name().and_then(|s| s.to_str()),
Some("location.json")
);
assert!(
p.parent()
.and_then(|s| s.file_name())
.and_then(|s| s.to_str())
== Some(".powerliners"),
"expected .powerliners parent, got {}",
p.display()
);
}
}
#[test]
fn save_then_load_location_cache_round_trips() {
if location_cache_path().is_none() {
return; }
let original = load_location_cache();
let sentinel = (12.34_f64, -56.78_f64);
save_location_cache(sentinel);
let read_back = load_location_cache().expect("just wrote → Some");
assert!((read_back.0 - sentinel.0).abs() < 1e-9);
assert!((read_back.1 - sentinel.1).abs() < 1e-9);
match original {
Some(prev) => save_location_cache(prev),
None => {
if let Some(path) = location_cache_path() {
let _ = std::fs::remove_file(path);
}
}
}
}
#[test]
fn load_location_cache_returns_none_on_missing_file() {
if location_cache_path().is_none() {
return;
}
let original = load_location_cache();
if let Some(path) = location_cache_path() {
let _ = std::fs::remove_file(&path);
}
assert!(load_location_cache().is_none());
if let Some(prev) = original {
save_location_cache(prev);
}
}
}