use config::{Config, ConfigError, File};
use rand::Rng;
use std::fs;
#[derive(serde_derive::Deserialize, serde_derive::Serialize, Debug, Clone)]
pub struct General {
pub lang: String,
#[serde(default = "default_mpc_auth_token")]
pub mpc_auth_token: String,
}
pub(crate) fn default_mpc_auth_token() -> String {
std::env::var("MPC_AUTH_TOKEN").unwrap_or_default()
}
#[derive(serde_derive::Deserialize, serde_derive::Serialize, Debug, Clone)]
pub struct Observatory {
pub place: String,
pub latitude: f32,
pub longitude: f32,
pub altitude: f32,
pub observatory_name: String,
pub observer_name: String,
pub mpc_code: String,
pub north_altitude: i32,
pub south_altitude: i32,
pub east_altitude: i32,
pub west_altitude: i32,
}
#[derive(serde_derive::Deserialize, serde_derive::Serialize, Debug, Clone)]
pub struct Settings {
pub general: General,
pub observatory: Observatory,
}
fn default_settings() -> Settings {
let test = false; let mut rng = rand::rng();
let default_general: General = General {
lang: "en".to_string(),
mpc_auth_token: default_mpc_auth_token(),
};
let default_observatory: Observatory = match test {
false => Observatory {
place: "default".to_string(),
latitude: rng.random_range(0.1..89.9) as f32,
longitude: rng.random_range(0.1..179.9) as f32,
altitude: rng.random_range(0.1..100.0) as f32,
observatory_name: "default".to_string(),
observer_name: "default".to_string(),
mpc_code: "500".to_string(),
north_altitude: 30,
east_altitude: 45,
south_altitude: 20,
west_altitude: 70,
},
true => Observatory {
place: "default".to_string(),
latitude: 0.0, longitude: 0.0, altitude: 0.0, observatory_name: "default".to_string(),
observer_name: "default".to_string(),
mpc_code: "500".to_string(),
north_altitude: 30,
east_altitude: 45,
south_altitude: 20,
west_altitude: 70,
},
};
Settings {
general: default_general,
observatory: default_observatory,
}
}
fn parse_float64(value: &str) -> Result<f64, Box<dyn std::error::Error>> {
match value.parse::<f64>() {
Ok(value) => Ok(value),
Err(_) => Err("Could not parse value as float".into()),
}
}
fn parse_integer64(value: &str) -> Result<i64, Box<dyn std::error::Error>> {
match value.parse::<i64>() {
Ok(value) => Ok(value),
Err(_) => Err("Could not parse value as float".into()),
}
}
pub fn modify_field_at_path(
config_path: &std::path::Path,
key: String,
value: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let config_path_str = config_path
.to_str()
.ok_or("Failed to convert config path to string")?;
let contents = fs::read_to_string(config_path_str)?;
let mut settings: toml::Value = toml::from_str(&contents)?;
match key.as_str() {
"lang" => settings["general"]["lang"] = toml::Value::String(value.to_string()),
"place" => settings["observatory"]["place"] = toml::Value::String(value.to_string()),
"latitude" => {
settings["observatory"]["latitude"] = toml::Value::Float(
parse_float64(value)
.map_err(|e| format!("Failed to parse latitude: {}", e))?
)
}
"longitude" => {
settings["observatory"]["longitude"] = toml::Value::Float(
parse_float64(value)
.map_err(|e| format!("Failed to parse longitude: {}", e))?
)
}
"altitude" => {
settings["observatory"]["altitude"] = toml::Value::Float(
parse_float64(value)
.map_err(|e| format!("Failed to parse altitude: {}", e))?
)
}
"observatory_name" => {
settings["observatory"]["observatory_name"] = toml::Value::String(value.to_string())
}
"observer_name" => {
settings["observatory"]["observer_name"] = toml::Value::String(value.to_string())
}
"mpc_code" => settings["observatory"]["mpc_code"] = toml::Value::String(value.to_string()),
"north_altitude" => {
settings["observatory"]["north_altitude"] = toml::Value::Integer(
parse_integer64(value)
.map_err(|e| format!("Failed to parse north_altitude: {}", e))?
)
}
"south_altitude" => {
settings["observatory"]["south_altitude"] = toml::Value::Integer(
parse_integer64(value)
.map_err(|e| format!("Failed to parse south_altitude: {}", e))?
)
}
"east_altitude" => {
settings["observatory"]["east_altitude"] = toml::Value::Integer(
parse_integer64(value)
.map_err(|e| format!("Failed to parse east_altitude: {}", e))?
)
}
"west_altitude" => {
settings["observatory"]["west_altitude"] = toml::Value::Integer(
parse_integer64(value)
.map_err(|e| format!("Failed to parse west_altitude: {}", e))?
)
}
_ => {}
}
let updated_contents = toml::to_string(&settings)?;
fs::write(config_path_str, updated_contents)?;
Ok(())
}
pub fn modify_field_in_file(key: String, value: &str) -> Result<(), Box<dyn std::error::Error>> {
let config_dir = dirs::config_local_dir().ok_or("Failed to get config directory")?;
let config_path = config_dir.join("asteroid_tui").join("config.toml");
modify_field_at_path(&config_path, key, value)
}
impl Settings {
pub fn new() -> Result<Self, ConfigError> {
let config_dir = dirs::config_local_dir()
.ok_or_else(|| ConfigError::Message("Failed to get config directory".to_string()))?;
let asteroid_tui_dir = config_dir.join("asteroid_tui");
let config_file = asteroid_tui_dir.join("config.toml");
if fs::metadata(&asteroid_tui_dir).is_err() {
fs::create_dir_all(&asteroid_tui_dir)
.map_err(|e| ConfigError::Message(format!("Failed to create config directory: {}", e)))?;
}
if fs::metadata(&config_file).is_err() {
let default_settings = default_settings();
let default_toml = toml::to_string(&default_settings)
.map_err(|e| ConfigError::Message(format!("Failed to serialize default settings: {}", e)))?;
let config_file_str = config_file
.to_str()
.ok_or_else(|| ConfigError::Message("Failed to convert config file path to string".to_string()))?;
fs::write(config_file_str, default_toml)
.map_err(|e| ConfigError::Message(format!("Failed to write default config file: {}", e)))?;
}
let config_file_str = config_file
.to_str()
.ok_or_else(|| ConfigError::Message("Failed to convert config file path to string".to_string()))?;
let s = Config::builder()
.add_source(File::with_name(config_file_str))
.build()?;
s.try_deserialize()
}
pub fn from_toml_str(toml: &str) -> Result<Self, ConfigError> {
toml::from_str(toml)
.map_err(|e| ConfigError::Message(format!("Failed to parse TOML: {}", e)))
}
pub fn from_config_file(path: &std::path::Path) -> Result<Self, ConfigError> {
let path_str = path.to_str().ok_or_else(|| {
ConfigError::Message("Failed to convert config path to string".to_string())
})?;
let s = Config::builder()
.add_source(File::with_name(path_str))
.build()?;
s.try_deserialize()
}
pub fn get_lang(&self) -> &String {
&self.general.lang
}
pub fn set_lang(&mut self, lang: String) -> Result<(), Box<dyn std::error::Error>> {
modify_field_in_file("lang".to_string(), &lang)?;
Ok(())
}
pub fn get_mpc_auth_token(&self) -> String {
std::env::var("MPC_AUTH_TOKEN")
.unwrap_or_else(|_| self.general.mpc_auth_token.clone())
}
pub fn set_settings(&mut self, settings: Settings) -> Result<(), Box<dyn std::error::Error>> {
self.observatory = settings.observatory;
let config_path = dirs::config_local_dir()
.ok_or("Failed to get config dir")?
.join("asteroid_tui")
.join("config.toml");
let toml = toml::to_string(&self)?;
std::fs::write(config_path, toml)?;
Ok(())
}
pub fn get_place(&self) -> &String {
&self.observatory.place
}
pub fn get_observatory_name(&self) -> &String {
&self.observatory.observatory_name
}
pub fn get_observer_name(&self) -> &String {
&self.observatory.observer_name
}
pub fn get_mpc_code(&self) -> &String {
&self.observatory.mpc_code
}
pub fn get_latitude(&self) -> &f32 {
&self.observatory.latitude
}
pub fn get_longitude(&self) -> &f32 {
&self.observatory.longitude
}
pub fn get_altitude(&self) -> &f32 {
&self.observatory.altitude
}
pub fn get_north_altitude(&self) -> &i32 {
&self.observatory.north_altitude
}
pub fn get_south_altitude(&self) -> &i32 {
&self.observatory.south_altitude
}
pub fn get_east_altitude(&self) -> &i32 {
&self.observatory.east_altitude
}
pub fn get_west_altitude(&self) -> &i32 {
&self.observatory.west_altitude
}
pub fn get_all_settings(&self) -> Settings {
self.clone()
}
pub fn set_latitude(&mut self, latitude: f32) {
self.observatory.latitude = latitude;
}
pub fn set_longitude(&mut self, longitude: f32) {
self.observatory.longitude = longitude;
}
pub fn merge_observatory_form(&self, fields: &[String]) -> Result<Self, Box<dyn std::error::Error>> {
if fields.len() < 11 {
return Err(format!(
"expected 11 observatory fields, got {}",
fields.len()
)
.into());
}
let general = General {
lang: self.general.lang.clone(),
mpc_auth_token: default_mpc_auth_token(),
};
let observatory = Observatory {
place: if fields[0].is_empty() {
self.get_place().to_string()
} else {
fields[0].clone()
},
latitude: if fields[1].is_empty() {
*self.get_latitude()
} else {
fields[1].parse::<f32>()?
},
longitude: if fields[2].is_empty() {
*self.get_longitude()
} else {
fields[2].parse::<f32>()?
},
altitude: if fields[3].is_empty() {
*self.get_altitude()
} else {
fields[3].parse::<f32>()?
},
observatory_name: if fields[4].is_empty() {
self.get_observatory_name().to_string()
} else {
fields[4].clone()
},
observer_name: if fields[5].is_empty() {
self.get_observer_name().to_string()
} else {
fields[5].clone()
},
mpc_code: if fields[6].is_empty() {
self.get_mpc_code().to_string()
} else {
fields[6].clone()
},
north_altitude: if fields[7].is_empty() {
*self.get_north_altitude()
} else {
fields[7].parse::<i32>()?
},
south_altitude: if fields[8].is_empty() {
*self.get_south_altitude()
} else {
fields[8].parse::<i32>()?
},
east_altitude: if fields[9].is_empty() {
*self.get_east_altitude()
} else {
fields[9].parse::<i32>()?
},
west_altitude: if fields[10].is_empty() {
*self.get_west_altitude()
} else {
fields[10].parse::<i32>()?
},
};
Ok(Settings {
general,
observatory,
})
}
}
#[cfg(test)]
mod test {
use super::*;
use std::io::Write;
#[test]
fn test_from_toml_str_example_config() {
let toml = include_str!("../docs/config.example.toml");
let s = Settings::from_toml_str(toml).unwrap();
assert_eq!(s.get_lang(), "en");
assert_eq!(s.get_place(), "La Spezia");
assert!((*s.get_latitude() - 44.1).abs() < f32::EPSILON);
assert!(s.get_north_altitude().is_positive());
}
#[test]
fn test_modify_field_at_path() {
let mut file = tempfile::NamedTempFile::new().unwrap();
let toml = include_str!("../docs/config.example.toml");
file.write_all(toml.as_bytes()).unwrap();
modify_field_at_path(file.path(), "lang".to_string(), "it").unwrap();
let contents = fs::read_to_string(file.path()).unwrap();
let updated = Settings::from_toml_str(&contents).unwrap();
assert_eq!(updated.get_lang(), "it");
}
fn base_settings() -> Settings {
Settings::from_toml_str(include_str!("../docs/config.example.toml")).unwrap()
}
#[test]
fn test_merge_observatory_form_keeps_empty_fields() {
let base = base_settings();
let fields = vec!["".to_string(); 11];
let merged = base.merge_observatory_form(&fields).unwrap();
assert_eq!(merged.get_place(), base.get_place());
assert_eq!(merged.get_mpc_code(), base.get_mpc_code());
}
#[test]
fn test_merge_observatory_form_overrides_place_and_lat() {
let base = base_settings();
let mut fields = vec!["".to_string(); 11];
fields[0] = "New Site".to_string();
fields[1] = "45.5".to_string();
let merged = base.merge_observatory_form(&fields).unwrap();
assert_eq!(merged.get_place(), "New Site");
assert!((*merged.get_latitude() - 45.5).abs() < f32::EPSILON);
assert_eq!(merged.get_longitude(), base.get_longitude());
}
#[test]
fn test_merge_observatory_form_rejects_wrong_field_count() {
let base = base_settings();
assert!(base.merge_observatory_form(&["only".to_string()]).is_err());
}
}