weathr 1.1.0

A terminal-based ASCII weather application with animated scenes driven by real-time weather data
Documentation
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;

#[derive(Deserialize, Debug, Default, Clone)]
pub struct Config {
    #[serde(default)]
    pub location: Location,
    #[serde(default)]
    pub hide_hud: bool,
}

#[derive(Deserialize, Debug, Clone)]
pub struct Location {
    #[serde(default = "default_latitude")]
    pub latitude: f64,
    #[serde(default = "default_longitude")]
    pub longitude: f64,
    #[serde(default)]
    pub auto: bool,
    #[serde(default)]
    pub hide: bool,
}

fn default_latitude() -> f64 {
    52.52
}

fn default_longitude() -> f64 {
    13.41
}

impl Default for Location {
    fn default() -> Self {
        Self {
            latitude: default_latitude(),
            longitude: default_longitude(),
            auto: true,
            hide: false,
        }
    }
}

impl Config {
    pub fn load() -> Result<Self, String> {
        // try local config.toml
        if let Ok(cwd) = std::env::current_dir() {
            let local_config = cwd.join("config.toml");
            if local_config.exists() {
                let config = Self::load_from_path(&local_config)?;
                config.validate()?;
                return Ok(config);
            }
        }

        // try XDG config
        let config_path = Self::get_config_path()?;

        if !config_path.exists() {
            eprintln!("Config file not found at {:?}", config_path);
            eprintln!("Auto-detecting location via IP...");
            eprintln!("(Set auto = false in config to use Berlin as default)");
            return Ok(Self::default());
        }

        let config = Self::load_from_path(&config_path)?;
        config.validate()?;
        Ok(config)
    }

    fn validate(&self) -> Result<(), String> {
        if self.location.latitude < -90.0 || self.location.latitude > 90.0 {
            return Err(format!(
                "Invalid latitude: {}. Must be between -90.0 and 90.0",
                self.location.latitude
            ));
        }

        if self.location.longitude < -180.0 || self.location.longitude > 180.0 {
            return Err(format!(
                "Invalid longitude: {}. Must be between -180.0 and 180.0",
                self.location.longitude
            ));
        }

        Ok(())
    }

    pub fn load_from_path(path: &PathBuf) -> Result<Self, String> {
        let content = fs::read_to_string(path)
            .map_err(|e| format!("Failed to read config file at {:?}: {}", path, e))?;

        toml::from_str(&content).map_err(|e| format!("Failed to parse config: {}", e))
    }

    fn get_config_path() -> Result<PathBuf, String> {
        let config_dir = if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
            PathBuf::from(xdg_config)
        } else {
            dirs::config_dir().ok_or("Could not determine config directory")?
        };

        Ok(config_dir.join("weathr").join("config.toml"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn test_config_deserialize_valid() {
        let toml_content = r#"
[location]
latitude = 52.52
longitude = 13.41
"#;
        let config: Config = toml::from_str(toml_content).unwrap();
        assert_eq!(config.location.latitude, 52.52);
        assert_eq!(config.location.longitude, 13.41);
    }

    #[test]
    fn test_config_deserialize_negative_coordinates() {
        let toml_content = r#"
[location]
latitude = -33.8688
longitude = 151.2093
"#;
        let config: Config = toml::from_str(toml_content).unwrap();
        assert_eq!(config.location.latitude, -33.8688);
        assert_eq!(config.location.longitude, 151.2093);
    }

    #[test]
    fn test_config_load_from_path_success() {
        let temp_dir = std::env::temp_dir();
        let test_config_path = temp_dir.join("weathr_test_config.toml");

        let mut file = fs::File::create(&test_config_path).unwrap();
        writeln!(file, "[location]").unwrap();
        writeln!(file, "latitude = 40.7128").unwrap();
        writeln!(file, "longitude = -74.0060").unwrap();

        let config = Config::load_from_path(&test_config_path).unwrap();
        assert_eq!(config.location.latitude, 40.7128);
        assert_eq!(config.location.longitude, -74.0060);

        fs::remove_file(test_config_path).ok();
    }

    #[test]
    fn test_config_load_from_path_file_not_found() {
        let nonexistent_path = PathBuf::from("/tmp/nonexistent_weathr_config_12345.toml");
        let result = Config::load_from_path(&nonexistent_path);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to read config file"));
    }

    #[test]
    fn test_config_load_from_path_invalid_toml() {
        let temp_dir = std::env::temp_dir();
        let test_config_path = temp_dir.join("weathr_test_invalid.toml");

        let mut file = fs::File::create(&test_config_path).unwrap();
        writeln!(file, "this is not valid toml {{{{").unwrap();

        let result = Config::load_from_path(&test_config_path);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("Failed to parse config"));

        fs::remove_file(test_config_path).ok();
    }

    #[test]
    fn test_config_missing_latitude() {
        let toml_content = r#"
[location]
longitude = 13.41
"#;
        let config: Config = toml::from_str(toml_content).unwrap();
        assert_eq!(config.location.latitude, 52.52);
        assert_eq!(config.location.longitude, 13.41);
    }

    #[test]
    fn test_config_missing_longitude() {
        let toml_content = r#"
[location]
latitude = 52.52
"#;
        let config: Config = toml::from_str(toml_content).unwrap();
        assert_eq!(config.location.latitude, 52.52);
        assert_eq!(config.location.longitude, 13.41);
    }

    #[test]
    fn test_location_boundary_values() {
        let toml_content = r#"
[location]
latitude = 90.0
longitude = 180.0
"#;
        let config: Config = toml::from_str(toml_content).unwrap();
        assert_eq!(config.location.latitude, 90.0);
        assert_eq!(config.location.longitude, 180.0);
    }

    #[test]
    fn test_location_zero_coordinates() {
        let toml_content = r#"
[location]
latitude = 0.0
longitude = 0.0
"#;
        let config: Config = toml::from_str(toml_content).unwrap();
        assert_eq!(config.location.latitude, 0.0);
        assert_eq!(config.location.longitude, 0.0);
    }

    #[test]
    fn test_validation_invalid_latitude_high() {
        let config = Config {
            location: Location {
                latitude: 91.0,
                longitude: 0.0,
                auto: false,
                hide: false,
            },
            hide_hud: false,
        };
        let result = config.validate();
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("latitude"));
    }

    #[test]
    fn test_validation_invalid_latitude_low() {
        let config = Config {
            location: Location {
                latitude: -91.0,
                longitude: 0.0,
                auto: false,
                hide: false,
            },
            hide_hud: false,
        };
        let result = config.validate();
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("latitude"));
    }

    #[test]
    fn test_validation_invalid_longitude_high() {
        let config = Config {
            location: Location {
                latitude: 0.0,
                longitude: 181.0,
                auto: false,
                hide: false,
            },
            hide_hud: false,
        };
        let result = config.validate();
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("longitude"));
    }

    #[test]
    fn test_validation_invalid_longitude_low() {
        let config = Config {
            location: Location {
                latitude: 0.0,
                longitude: -181.0,
                auto: false,
                hide: false,
            },
            hide_hud: false,
        };
        let result = config.validate();
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("longitude"));
    }

    #[test]
    fn test_validation_valid_config() {
        let config = Config {
            location: Location {
                latitude: 52.52,
                longitude: 13.41,
                auto: false,
                hide: false,
            },
            hide_hud: false,
        };
        let result = config.validate();
        assert!(result.is_ok());
    }
}