caltemps 0.1.0

A tool to query and report on your iCalendar data from vDirs.
use chrono::{DateTime, Datelike, Local, TimeZone};
use clap::Parser;
use config::{Config, ConfigError, Environment, File};
use icalendar::CalendarComponent::Event;
use icalendar::{Calendar, Component, DatePerhapsTime};
use std::ffi::OsString;
use std::fs::{self};
use std::io;
use std::path::Path;

#[derive(Debug)]
struct IcsReadError {
    path: OsString,
    //error: Box<dyn std::error::Error>,
}

#[derive(Debug, serde::Deserialize, Clone)]
#[allow(unused)]
pub struct Settings {
    pub vdir_path: String,
    pub default_filter: String,
    pub default_date_range: String,
}

impl Settings {
    pub fn new(config_file: String) -> Result<Self, ConfigError> {
        let mut builder = Config::builder()
            .set_default("vdir_path", "calendars")?
            .set_default("default_filter", "")?
            .set_default("default_date_range", "2025-01-01T00:00:00+00:00")?;
        if Path::new(&config_file).is_file() {
            builder = builder.add_source(File::with_name(&config_file))
        };
        let cfg = builder
            // Support CALTEMPS_VARIABLE env vars
            .add_source(Environment::with_prefix("caltemps"))
            .build()?;
        cfg.try_deserialize()
    }
}

/// Query and report on your iCalendar data from vDirs.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
    #[arg(short, long, env="CALTEMPS_CONFIG",
        default_value_t=xdg::BaseDirectories::with_prefix("caltemps").unwrap()
            .get_config_file("config.toml").into_os_string().into_string().expect("Strange path"))]
    config: String,
    #[arg(short, long, env = "CALTEMPS_FILTER")]
    filter: Option<String>,
    #[arg(short, long, env = "CALTEMPS_DATE_RANGE")]
    date_range: Option<String>,
}

fn list_ics_from_dir(
    path: &Path,
) -> Result<impl Iterator<Item = OsString>, Box<dyn std::error::Error>> {
    match fs::read_dir(path) {
        Ok(dirs) => Ok(dirs.filter_map(|res| match res {
            Ok(t) => {
                if let Some(x) = t.path().extension() {
                    if x.eq_ignore_ascii_case("ics") {
                        Some(t.path().into_os_string())
                    } else {
                        None
                    }
                } else {
                    None
                }
            }
            Err(_) => None,
        })),
        Err(error) => Err(Box::new(error)),
    }
}

fn read_ics_from_dir(
    path: &Path,
) -> Result<impl Iterator<Item = Result<Calendar, IcsReadError>>, Box<dyn std::error::Error>> {
    match list_ics_from_dir(path) {
        Ok(ics_it) => Ok(ics_it.map(|ipath| {
            // Read file to output
            let ics_path = ipath.to_os_string();
            match &mut fs::File::open(ipath) {
                Ok(f) => {
                    let readable: &mut dyn io::Read = f;
                    let mut output = String::new();
                    match readable.read_to_string(&mut output) {
                        Ok(_) => {
                            //icalendar::parser::read_calendar(&output)
                            match output.parse::<Calendar>() {
                                Ok(read) => Ok(read),
                                Err(_error) => Err(IcsReadError {
                                    path: ics_path,
                                    //error: Box::new(error.into()),
                                }),
                            }
                        }
                        Err(_error) => Err(IcsReadError {
                            path: ics_path,
                            //error: Box::new(error.to_string()),
                        }),
                    }
                }
                Err(_error) => Err(IcsReadError {
                    path: ics_path,
                    //error: Box::new(error.to_string()),
                }),
            }
        })),
        Err(error) => Err(error),
    }
}

fn read_vdir_cal(path: &Path) -> Result<Calendar, Box<dyn std::error::Error>> {
    let mut cal = Calendar::new();
    // TODO: we can read some metadata like displayname and color
    /*if path.join("displayname").is_file() {
        cal.
    }*/
    match read_ics_from_dir(Path::new(path)) {
        Ok(entries_it) => {
            for entry in entries_it {
                match entry {
                    Ok(mut partial_cal) => cal.append(&mut partial_cal),
                    Err(error) => eprintln!("Issue reading {:#?}\n\t{:#?}", error.path, error),
                };
            }
            //let entries = entries_it.collect::<Vec<_>>();
            //println!("{:#?}", entries);
            Ok(cal)
        }
        Err(error) => Err(error),
    }
}

fn dpt_to_dt(dpt: Option<DatePerhapsTime>) -> Option<DateTime<Local>> {
    match dpt {
        Some(icalendar::DatePerhapsTime::DateTime(cdt)) => cdt.try_into_utc().map(|d| d.into()),
        Some(icalendar::DatePerhapsTime::Date(nd)) => Some(
            Local
                .with_ymd_and_hms(nd.year(), nd.month(), nd.day(), 0, 0, 0)
                .unwrap(),
        ),
        _ => None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // BEGIN: args
    let cli = Cli::parse();
    let cfg = Settings::new(cli.config).unwrap();
    // TODO: actually make this a date range :-)
    let start =
        DateTime::parse_from_rfc3339(&cli.date_range.unwrap_or(cfg.default_date_range)).unwrap();
    // END: args

    match read_vdir_cal(Path::new(&cfg.vdir_path)) {
        Ok(cal) => {
            let mut x = 0;
            for c in cal
                .components
                .into_iter()
                .filter_map(|c| match c {
                    Event(e) => Some(e),
                    _ => None,
                })
                .filter(|c| {
                    if let Some(summary) = c.get_summary() {
                        if summary
                            .contains(&(cli.filter.clone().unwrap_or(cfg.default_filter.clone())))
                        {
                            return match dpt_to_dt(c.get_start()) {
                                Some(dtl) => start <= dtl && dpt_to_dt(c.get_end()).is_some(),
                                _ => false,
                            };
                        }
                    }
                    false
                })
            {
                if let Some(ds) = dpt_to_dt(c.get_start()) {
                    if let Some(de) = dpt_to_dt(c.get_end()) {
                        x += (de - ds).num_minutes();
                    }
                }
            }
            println!("Accounted {:.2}h", x as f64 / 60.0);
            Ok(())
        }
        Err(error) => {
            eprintln!("Error working on:\t'{}'", cfg.vdir_path);
            eprintln!("{}", error);
            Err(error)
        }
    }
}