use crate::i18n::{self, keys};
use crate::observing_target_list::PossibleTarget;
use crate::{
observing_target_list::parse_whats_up_response, observing_target_list::WhatsUpParams,
sun_moon_times, sun_moon_times::SunMoonTimesResponse, tui, weather, weather::Forecast,
};
use anyhow::{Context, Result};
use chrono::format::StrftimeItems;
use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, Utc};
use promkit::{
crossterm::{
execute,
terminal::{disable_raw_mode, Clear, ClearType},
},
preset::listbox::Listbox,
preset::readline::Readline,
};
use comfy_table::Table;
fn parse_input(input: &str) -> Result<DateTime<Utc>, chrono::ParseError> {
let naive_dt = NaiveDateTime::parse_from_str(input, "%Y%m%d%H%M")?;
Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc))
}
fn add_hours(dt: DateTime<Utc>, hours: u32) -> DateTime<Utc> {
dt + Duration::hours(hours.into())
}
fn format_output(dt: DateTime<Utc>) -> String {
let items = StrftimeItems::new("%a %H");
dt.format_with_items(items).to_string()
}
fn create_weather_table() -> Result<()> {
let _ = disable_raw_mode();
println!("\nFetching weather forecast... / Recupero previsioni meteo...");
let mut table = Table::new();
let data = weather::prepare_data()
.map_err(|e| {
eprintln!("Error fetching weather data / Errore nel recupero dati meteo: {}", e);
e
})?;
let timezero = format!("{}00", data.init);
let forecast = data.dataseries;
let converters: Vec<Box<dyn Fn(&Forecast) -> String>> = vec![
Box::new(move |item: &Forecast| {
let timezero_clone = timezero.clone();
match parse_input(timezero_clone.as_str()) {
Ok(result) => {
let new_dt = add_hours(result, item.timepoint as u32);
format_output(new_dt)
}
Err(e) => format!("Errore durante il parsing: {}", e),
}
}),
Box::new(|item: &Forecast| item.cloud_cover.to_str().to_string()),
Box::new(|item: &Forecast| item.seeing.to_str().to_string()),
Box::new(|item: &Forecast| item.transparency.to_str().to_string()),
Box::new(|item: &Forecast| item.lifted_index.to_str().to_string()),
Box::new(|item: &Forecast| item.rh2m.to_str().to_string()),
Box::new(|item: &Forecast| {
format!(
"{} at {}",
item.wind10m.direction,
item.wind10m.speed.to_str()
)
}),
Box::new(|item: &Forecast| item.temp2m.to_string()),
Box::new(|item: &Forecast| item.prec_type.clone()),
];
table.set_width(80).set_header(vec![
"Time", "Clouds", "Seeing", "Transp", "Instab", "RH2m", "Wind", "T", "Prec",
]);
for item in forecast {
let row: Vec<String> = converters
.iter()
.map(|converter| converter(&item))
.collect();
table.add_row(row);
}
println!("{table}");
Ok(())
}
fn generate_sun_moon_times_table() -> Result<()> {
let _ = disable_raw_mode();
println!("\nFetching sun/moon times... / Recupero orari sole/luna...");
let data: SunMoonTimesResponse = sun_moon_times::prepare_data()
.map_err(|e| {
eprintln!("Error fetching sun/moon times / Errore nel recupero orari: {}", e);
e
})?;
println!("All times are {}", data.tzid);
println!("Sunrise: {}", data.results.sunrise);
println!("Sunset: {}", data.results.sunset);
println!("Solar noon: {}", data.results.solar_noon);
println!("Day length: {}", data.results.day_length);
println!(
"Civil twilight begin: {}",
data.results.civil_twilight_begin
);
println!("Civil twilight end: {}", data.results.civil_twilight_end);
println!(
"Nautical twilight begin: {}",
data.results.nautical_twilight_begin
);
println!(
"Nautical twilight end: {}",
data.results.nautical_twilight_end
);
println!(
"Astronomical twilight begin: {}",
data.results.astronomical_twilight_begin
);
println!(
"Astronomical twilight end: {}",
data.results.astronomical_twilight_end
);
Ok(())
}
const SCHEDULING: [&str; 5] = ["1", "2", "3", "9", "0"];
fn create_menu_validator<'a>(options: &'a [&'a str]) -> impl Fn(&str) -> bool + 'a {
move |option: &str| options.contains(&option)
}
fn create_menu_error_generator<'a>(options: &'a [&'a str]) -> impl Fn(&str) -> String + 'a {
move |option: &str| {
format!(
"Invalid option: {}. Please choose between {}.",
option,
options.join(", ")
)
}
}
fn validate_scheduling_menu_option(option: &str) -> bool {
create_menu_validator(&SCHEDULING)(option)
}
fn generate_scheduling_menu_error_message(option: &str) -> String {
create_menu_error_generator(&SCHEDULING)(option)
}
pub fn scheduling_menu() -> Result<(), Box<dyn std::error::Error>> {
let _ = disable_raw_mode();
execute!(std::io::stdout(), Clear(ClearType::All))?;
println!(
"\n\n\n{}
1. {}
2. {}
3. {}
9. {}
0. {}",
i18n::t(keys::SCHEDULING_MENU_TITLE),
i18n::t(keys::WEATHER_FORECAST),
i18n::t(keys::SUN_MOON_TIMES),
i18n::t(keys::OBSERVING_TARGET_LIST),
i18n::t(keys::BACK),
i18n::t(keys::QUIT)
);
let mut p = Readline::default()
.title(&i18n::t(keys::SELECT_OPTION))
.validator(
validate_scheduling_menu_option,
generate_scheduling_menu_error_message,
)
.prompt()?;
let result = p.run()?;
match result.as_str() {
"1" => {
if let Err(e) = create_weather_table() {
eprintln!("Error: {}", e);
}
}
"2" => {
if let Err(e) = generate_sun_moon_times_table() {
eprintln!("Error: {}", e);
}
}
"3" => observing_target_list()?,
"9" => tui::main_menu()?,
_ => (),
}
Ok(())
}
const WEATHER_FORECAST: [&str; 2] = ["9", "0"];
fn validate_weather_forecast_option(option: &str) -> bool {
create_menu_validator(&WEATHER_FORECAST)(option)
}
fn generate_weather_forecast_error_message(option: &str) -> String {
create_menu_error_generator(&WEATHER_FORECAST)(option)
}
fn validate_date(year: u32, month: u32, day: u32) -> bool {
NaiveDate::from_ymd_opt(year as i32, month, day).is_some()
}
fn validate_year(year: &str) -> bool {
year.parse::<u32>()
.map(|y| y >= 1900 && y <= 2200)
.unwrap_or(false)
}
fn validate_month(month: &str) -> bool {
month.parse::<u32>()
.map(|m| m >= 1 && m <= 12)
.unwrap_or(false)
}
fn validate_day(day: &str) -> bool {
day.parse::<u32>()
.map(|d| d >= 1 && d <= 31)
.unwrap_or(false)
}
fn validate_hour(hour: &str) -> bool {
hour.parse::<u32>()
.map(|h| h <= 23)
.unwrap_or(false)
}
fn validate_minute(minute: &str) -> bool {
minute.parse::<u32>()
.map(|m| m <= 59)
.unwrap_or(false)
}
fn read_year() -> Result<String> {
let year = Readline::default()
.title("Year (YYYY): ")
.validator(validate_year, |x| format!("{} is not a valid year (1900-2200)", x))
.prompt()
.context("Failed to create year prompt")?
.run()
.context("Failed to read year input")?;
Ok(year)
}
fn read_month() -> Result<String> {
let month = Readline::default()
.title("Month (MM): ")
.validator(validate_month, |x| format!("{} is not a valid month (1-12)", x))
.prompt()
.context("Failed to create month prompt")?
.run()
.context("Failed to read month input")?;
Ok(month)
}
fn read_day(year: &str, month: &str) -> Result<String> {
let year_num = year.parse::<u32>()
.context("Invalid year for date validation")?;
let month_num = month.parse::<u32>()
.context("Invalid month for date validation")?;
loop {
let day = Readline::default()
.title("Day (DD): ")
.validator(validate_day, |x| format!("{} is not a valid day (1-31)", x))
.prompt()
.context("Failed to create day prompt")?
.run()
.context("Failed to read day input")?;
if let Ok(day_num) = day.parse::<u32>() {
if validate_date(year_num, month_num, day_num) {
return Ok(day);
} else {
eprintln!("{} is not a valid day for {}/{}", day, month, year);
continue;
}
}
return Ok(day);
}
}
fn read_hour() -> Result<String> {
let hour = Readline::default()
.title("Hour (HH, 0-23): ")
.validator(validate_hour, |x| format!("{} is not a valid hour (0-23)", x))
.prompt()
.context("Failed to create hour prompt")?
.run()
.context("Failed to read hour input")?;
Ok(hour)
}
fn read_minute() -> Result<String> {
let minute = Readline::default()
.title("Minute (MM, 0-59): ")
.validator(validate_minute, |x| format!("{} is not a valid minute (0-59)", x))
.prompt()
.context("Failed to create minute prompt")?
.run()
.context("Failed to read minute input")?;
Ok(minute)
}
fn read_positive_integer(title: &str, field_name: &str) -> Result<String> {
let value = Readline::default()
.title(title)
.validator(
|x| x.parse::<u32>().is_ok(),
|x| format!("{} is not a valid positive number", x),
)
.prompt()
.with_context(|| format!("Failed to create {} prompt", field_name))?
.run()
.with_context(|| format!("Failed to read {} input", field_name))?;
Ok(value)
}
pub fn weather_forecast() -> Result<(), Box<dyn std::error::Error>> {
let _ = disable_raw_mode();
execute!(std::io::stdout(), Clear(ClearType::All))?;
println!("\n\n\nWeather Forecast\n\n");
create_weather_table()?;
let mut p = Readline::default()
.title("\n9 to go back, 0 to quit:")
.validator(
validate_weather_forecast_option,
generate_weather_forecast_error_message,
)
.prompt()?;
let result = p.run()?;
if result.as_str() == "9" {
tui::settings_menu()?
}
Ok(())
}
fn map_object_type_to_code(object_type: &str) -> &str {
match object_type {
"Asteroid" => "mp",
"NEO" => "neo",
"Comet" => "cmt",
_ => "mp",
}
}
fn read_object_type() -> Result<String> {
let object_type = Listbox::new(vec!["Asteroid", "NEO", "Comet"])
.title("Select object type")
.prompt()
.context("Failed to create object type prompt")?
.run()
.context("Failed to read object type input")?;
Ok(object_type)
}
pub fn observing_target_list() -> Result<(), Box<dyn std::error::Error>> {
let _ = disable_raw_mode();
execute!(std::io::stdout(), Clear(ClearType::All))?;
println!("\n\n\nObserving Target List\n\n");
let year = read_year()?;
let month = read_month()?;
let day = read_day(&year, &month)?;
let hour = read_hour()?;
let minute = read_minute()?;
let duration = read_positive_integer("Duration in hours (H or HH): ", "duration")?;
let max_objects = read_positive_integer("Maximum number of objects: ", "max_objects")?;
let min_alt = read_positive_integer("Minimum Altitude (deg): ", "min_alt")?;
let solar_elong = read_positive_integer("Maximum Solar elongation (deg): ", "solar_elong")?;
let lunar_elong = read_positive_integer("Maximum Lunar elongation (deg): ", "lunar_elong")?;
let object_type = read_object_type()?;
let object_type_code = map_object_type_to_code(&object_type);
let whats_up_params = WhatsUpParams {
year,
month,
day,
hour,
minute,
max_objects,
duration,
min_alt,
solar_elong,
lunar_elong,
object_type: object_type_code.to_string(),
};
println!("\nFetching observing target list... / Recupero lista obiettivi osservazione...");
let data = parse_whats_up_response(&whats_up_params)
.map_err(|e| {
eprintln!("Error fetching observing target list / Errore nel recupero lista: {}", e);
e
})?;
if data.is_empty() {
println!("\nNo visible objects found for the selected criteria.");
println!("Nessun oggetto visibile trovato per i criteri selezionati.");
} else {
println!("\nFound {} object(s) / Trovato/i {} oggetto/i:", data.len(), data.len());
create_whats_up_list_table(data);
}
let mut p = Readline::default()
.title(&format!("\n9 {} / {}, 0 {} / {}:",
i18n::t(keys::BACK), "Indietro",
i18n::t(keys::QUIT), "Esci"))
.validator(
validate_weather_forecast_option,
generate_weather_forecast_error_message,
)
.prompt()?;
let result = p.run()?;
if result.as_str() == "9" {
scheduling_menu()?
}
Ok(())
}
fn create_whats_up_list_table(data: Vec<PossibleTarget>) {
let _ = disable_raw_mode();
let mut table = Table::new();
let converters: Vec<Box<dyn Fn(&PossibleTarget) -> String>> = vec![
Box::new(|item: &PossibleTarget| item.designation.to_string()),
Box::new(|item: &PossibleTarget| item.magnitude.to_string()),
Box::new(|item: &PossibleTarget| item.ra.to_string()),
Box::new(|item: &PossibleTarget| item.dec.to_string()),
Box::new(|item: &PossibleTarget| item.altitude.to_string()),
];
table
.set_width(80)
.set_header(vec!["Designation", "Magnitude", "RA", "DEC", "Altitude"]);
for item in data {
let row: Vec<String> = converters
.iter()
.map(|converter| converter(&item))
.collect();
table.add_row(row);
}
println!("{table}");
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_validate_date() {
assert!(validate_date(2000, 1, 1));
assert!(validate_date(2000, 2, 29)); assert!(validate_date(2024, 12, 31));
assert!(!validate_date(2000, 2, 30)); assert!(!validate_date(2001, 2, 29)); assert!(!validate_date(2000, 13, 1)); assert!(!validate_date(2000, 0, 1)); assert!(!validate_date(2000, 1, 0)); assert!(!validate_date(2000, 1, 32)); }
#[test]
fn test_validate_year() {
assert!(validate_year("2000"));
assert!(validate_year("2024"));
assert!(validate_year("1900"));
assert!(validate_year("2200"));
assert!(!validate_year("1899")); assert!(!validate_year("2201")); assert!(!validate_year("abc")); assert!(!validate_year("")); }
#[test]
fn test_validate_month() {
for i in 1..=12 {
assert!(validate_month(&i.to_string()));
}
assert!(!validate_month("0"));
assert!(!validate_month("13"));
assert!(!validate_month("abc"));
assert!(!validate_month(""));
}
#[test]
fn test_validate_day() {
for i in 1..=31 {
assert!(validate_day(&i.to_string()));
}
assert!(!validate_day("0"));
assert!(!validate_day("32"));
assert!(!validate_day("abc"));
assert!(!validate_day(""));
}
#[test]
fn test_validate_hour() {
for i in 0..=23 {
assert!(validate_hour(&i.to_string()));
}
assert!(!validate_hour("24"));
assert!(!validate_hour("abc"));
assert!(!validate_hour(""));
}
#[test]
fn test_validate_minute() {
for i in 0..=59 {
assert!(validate_minute(&i.to_string()));
}
assert!(!validate_minute("60"));
assert!(!validate_minute("abc"));
assert!(!validate_minute(""));
}
#[test]
fn test_map_object_type_to_code() {
assert_eq!(map_object_type_to_code("Asteroid"), "mp");
assert_eq!(map_object_type_to_code("NEO"), "neo");
assert_eq!(map_object_type_to_code("Comet"), "cmt");
assert_eq!(map_object_type_to_code("Unknown"), "mp"); }
}