diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 3a41134..e05b79b 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -6,5 +6,5 @@ # will have schema files for capabilities auto-completion /gen/schemas - -schedule.* \ No newline at end of file +# Ignore exported txt/doc files and the log file +rota.* \ No newline at end of file diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6d132e7..5566dae 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3222,6 +3222,7 @@ dependencies = [ name = "rota" version = "0.1.0" dependencies = [ + "anyhow", "chrono", "docx-rs", "itertools", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3bf81fb..8719cb3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,3 +29,4 @@ tauri-plugin-log = "2" log = "0.4.29" rand = "0.9.2" docx-rs = "0.4.18" +anyhow = "1.0.100" diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 4a4cf65..471f747 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,3 +1,4 @@ +use anyhow::Context; use chrono::Month; use serde::{Deserialize, Serialize}; @@ -6,6 +7,7 @@ use crate::{ slot::Day, }; +const MONTH: u8 = 2; const YEAR: i32 = 2026; #[derive(Debug, Clone)] @@ -53,30 +55,6 @@ pub struct UserConfig { } impl UserConfig { - pub fn new(month: u8) -> Self { - let month = Month::try_from(month).unwrap(); - let total_days = month.num_days(YEAR).unwrap(); - let total_slots = (1..=total_days) - .map(|d| if Day(d).is_open_shift() { 2 } else { 1 }) - .sum(); - - let total_holiday_slots = (1..=total_days) - .filter(|&d| Day(d).is_weekend(month.number_from_month(), YEAR)) - .map(|d| if Day(d).is_open_shift() { 2 } else { 1 }) - .sum(); - - Self { - month, - year: YEAR, - holidays: vec![], - residents: vec![], - toxic_pairs: vec![], - total_days, - total_slots, - total_holiday_slots, - } - } - pub fn with_holidays(mut self, holidays: Vec) -> Self { self.holidays = holidays; self.total_holiday_slots = self.total_holiday_slots(); @@ -113,7 +91,7 @@ impl UserConfig { impl Default for UserConfig { fn default() -> Self { - let month = Month::try_from(2).unwrap(); + let month = Month::try_from(MONTH).unwrap(); let total_days = month.num_days(YEAR).unwrap(); @@ -139,11 +117,13 @@ impl Default for UserConfig { } } -impl From for UserConfig { - fn from(value: UserConfigDTO) -> Self { - let month = Month::try_from(value.month).unwrap(); +impl TryFrom for UserConfig { + type Error = anyhow::Error; - let total_days = month.num_days(YEAR).unwrap(); + fn try_from(value: UserConfigDTO) -> Result { + let month = Month::try_from(value.month)?; + + let total_days = month.num_days(value.year).context("Failed to parse")?; let total_slots = (1..=total_days) .map(|d| if Day(d).is_open_shift() { 2 } else { 1 }) @@ -157,7 +137,7 @@ impl From for UserConfig { .map(|d| if Day(d).is_open_shift() { 2 } else { 1 }) .sum(); - Self { + Ok(Self { month, year: value.year, holidays: value.holidays, @@ -170,6 +150,6 @@ impl From for UserConfig { total_days, total_slots, total_holiday_slots, - } + }) } } diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 6fb7546..e56502d 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -1,5 +1,6 @@ use std::{fs::File, io::Write}; +use anyhow::Context; use docx_rs::{Docx, Paragraph, Run, RunFonts, Table, TableCell, TableRow}; use crate::{ @@ -16,45 +17,51 @@ pub enum FileType { } pub trait Export { - fn export(&self, file_type: FileType, config: &UserConfig, tracker: &WorkloadTracker); + fn export( + &self, + file_type: FileType, + config: &UserConfig, + tracker: &WorkloadTracker, + ) -> anyhow::Result<()>; } impl Export for MonthlySchedule { - fn export(&self, file_type: FileType, config: &UserConfig, tracker: &WorkloadTracker) { + fn export( + &self, + file_type: FileType, + config: &UserConfig, + tracker: &WorkloadTracker, + ) -> anyhow::Result<()> { match file_type { - FileType::Txt => self.export_as_txt(config, tracker), - FileType::Docx => self.export_as_doc(config), + FileType::Txt => self.export_as_txt(config, tracker)?, + FileType::Docx => self.export_as_doc(config)?, }; - // TODO: make this env var from a config file? Option to change this in-app - let env_path = "rota/me/"; - println!( - "exported type {:?}. Saved at folder path {}", - file_type, env_path - ); + Ok(()) } } impl MonthlySchedule { - pub fn export_as_txt(&self, config: &UserConfig, tracker: &WorkloadTracker) -> String { - let file = File::create("schedule.txt").unwrap(); + pub fn export_as_txt( + &self, + config: &UserConfig, + tracker: &WorkloadTracker, + ) -> anyhow::Result<()> { + let file = File::create("rota.txt")?; let mut writer = std::io::BufWriter::new(file); - writer - .write_all(self.pretty_print(config).as_bytes()) - .expect("Failed to write schedule"); + writer.write_all(self.pretty_print(config).as_bytes())?; - writer - .write_all(self.report(config, tracker).as_bytes()) - .expect("Failed to write report"); + writer.write_all(self.report(config, tracker).as_bytes())?; - writer.flush().expect("Failed to flush buffer"); - "ok".to_string() + writer.flush()?; + + Ok(()) } - pub fn export_as_doc(&self, config: &UserConfig) -> String { - let path = std::path::Path::new("./schedule.docx"); - let file = std::fs::File::create(path).unwrap(); + pub fn export_as_doc(&self, config: &UserConfig) -> anyhow::Result<()> { + let path = std::path::Path::new("rota.docx"); + let file = std::fs::File::create(path)?; let header = Table::new(vec![ TableRow::new(vec![TableCell::new().add_paragraph( @@ -101,7 +108,7 @@ impl MonthlySchedule { .iter() .find(|r| Some(&r.id) == slot_first_res_id) .map(|r| r.name.as_str()) - .unwrap(); + .unwrap_or("-"); let res_name_2 = if day.is_open_shift() { let slot_second = Slot::new(Day(d), ShiftPosition::Second); @@ -153,18 +160,23 @@ impl MonthlySchedule { doc = doc.add_table(residents_table); - doc.build().pack(file).unwrap(); + doc.build().pack(file)?; - "just a string".to_string() + tauri_plugin_opener::open_path(path, None::<&str>) + .context("Created file but failed to open it")?; + + Ok(()) } } #[cfg(test)] mod tests { + use anyhow::Ok; use rstest::{fixture, rstest}; use crate::{ config::UserConfig, + export::{Export, FileType}, resident::Resident, schedule::MonthlySchedule, scheduler::Scheduler, @@ -203,13 +215,29 @@ mod tests { WorkloadTracker::default() } + #[rstest] + pub fn test_export_as_txt( + mut schedule: MonthlySchedule, + mut tracker: WorkloadTracker, + scheduler: Scheduler, + ) -> anyhow::Result<()> { + assert!(scheduler.run(&mut schedule, &mut tracker)?); + + schedule.export(FileType::Txt, &scheduler.config, &tracker)?; + + Ok(()) + } + #[rstest] pub fn test_export_as_doc( mut schedule: MonthlySchedule, mut tracker: WorkloadTracker, scheduler: Scheduler, - ) { - scheduler.run(&mut schedule, &mut tracker); - schedule.export_as_doc(&scheduler.config); + ) -> anyhow::Result<()> { + assert!(scheduler.run(&mut schedule, &mut tracker)?); + + schedule.export(FileType::Docx, &scheduler.config, &tracker)?; + + Ok(()) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a5e2097..61d1441 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,13 @@ -use std::{env::home_dir, sync::Mutex}; +use std::sync::Mutex; + +use log::{error, info}; use crate::{ config::{UserConfig, UserConfigDTO}, export::{Export, FileType}, schedule::MonthlySchedule, scheduler::Scheduler, - workload::{WorkloadBounds, WorkloadTracker}, + workload::WorkloadTracker, }; pub mod config; @@ -14,6 +16,7 @@ pub mod resident; pub mod schedule; pub mod scheduler; pub mod slot; +pub mod timer; pub mod workload; struct AppState { @@ -25,40 +28,61 @@ struct AppState { /// information for the residents, the forbidden pairs the holidays /// and the period of the schedule #[tauri::command] -fn generate(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> MonthlySchedule { - let config = UserConfig::from(config); +fn generate( + config: UserConfigDTO, + state: tauri::State<'_, AppState>, +) -> Result { let mut schedule = MonthlySchedule::new(); let mut tracker = WorkloadTracker::default(); - let bounds = WorkloadBounds::new_with_config(&config); - let scheduler = Scheduler::new(config, bounds); + let scheduler = + Scheduler::new_with_config(UserConfig::try_from(config).map_err(|e| e.to_string())?); - scheduler.run(&mut schedule, &mut tracker); + scheduler + .run(&mut schedule, &mut tracker) + .inspect_err(|e| error!("{e}")) + .map_err(|e| e.to_string())?; + + info!( + "Scheduler finished successfully in {}ms", + scheduler.timer.elapsed_in_ms() + ); let mut internal_schedule = state.schedule.lock().unwrap(); - *internal_schedule = schedule.clone(); - let mut internal_tracker = state.tracker.lock().unwrap(); + + *internal_schedule = schedule.clone(); *internal_tracker = tracker.clone(); - schedule + Ok(schedule) } #[tauri::command] -fn export(config: UserConfigDTO, state: tauri::State<'_, AppState>) { - let config = UserConfig::from(config); +fn export(config: UserConfigDTO, state: tauri::State<'_, AppState>) -> Result<(), String> { + let config = UserConfig::try_from(config) + .inspect_err(|e| error!("{e}")) + .map_err(|e| e.to_string())?; + let schedule = state.schedule.lock().unwrap(); let tracker = state.tracker.lock().unwrap(); - schedule.export(FileType::Docx, &config, &tracker); - schedule.export(FileType::Txt, &config, &tracker); + + schedule + .export(FileType::Docx, &config, &tracker) + .inspect_err(|e| error!("{e}")) + .map_err(|e| e.to_string())?; + schedule + .export(FileType::Txt, &config, &tracker) + .inspect_err(|e| error!("{e}")) + .map_err(|e| e.to_string())?; + + let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from(".")); + info!("Files exported at {}", log_dir.display()); + + Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let log_dir = home_dir().unwrap().join(".rota_logs"); - - if let Err(e) = std::fs::create_dir_all(&log_dir) { - eprintln!("Cannot create log folder: {}", e); - } + let log_dir = std::env::current_dir().unwrap_or(std::path::PathBuf::from(".")); tauri::Builder::default() .manage(AppState { @@ -70,7 +94,7 @@ pub fn run() { .targets([tauri_plugin_log::Target::new( tauri_plugin_log::TargetKind::Folder { path: log_dir, - file_name: Some("rota".to_string()), // Note: Plugin adds .log automatically + file_name: Some("rota".to_string()), }, )]) .level(log::LevelFilter::Info) @@ -79,7 +103,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .invoke_handler(tauri::generate_handler![generate, export]) .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .expect("Error while running tauri application"); } #[cfg(test)] diff --git a/src-tauri/src/schedule.rs b/src-tauri/src/schedule.rs index bd5d775..b750ab9 100644 --- a/src-tauri/src/schedule.rs +++ b/src-tauri/src/schedule.rs @@ -119,7 +119,7 @@ impl MonthlySchedule { .iter() .find(|r| &r.id == res_id) .map(|r| r.name.as_str()) - .unwrap(); + .unwrap_or(""); output.push_str(&format!( "Ημέρα {:2} - {:9} - {:11}: {},\n", diff --git a/src-tauri/src/scheduler.rs b/src-tauri/src/scheduler.rs index 92d0c2b..984cf66 100644 --- a/src-tauri/src/scheduler.rs +++ b/src-tauri/src/scheduler.rs @@ -3,6 +3,7 @@ use crate::{ resident::ResidentId, schedule::MonthlySchedule, slot::Slot, + timer::Timer, workload::{WorkloadBounds, WorkloadTracker}, }; @@ -11,25 +12,40 @@ use rand::Rng; pub struct Scheduler { pub config: UserConfig, pub bounds: WorkloadBounds, + pub timer: Timer, } impl Scheduler { pub fn new(config: UserConfig, bounds: WorkloadBounds) -> Self { - Self { config, bounds } + Self { + config, + bounds, + timer: Timer::default(), + } } pub fn new_with_config(config: UserConfig) -> Self { let bounds = WorkloadBounds::new_with_config(&config); - Self { config, bounds } + Self { + config, + bounds, + timer: Timer::default(), + } } - pub fn run(&self, schedule: &mut MonthlySchedule, tracker: &mut WorkloadTracker) -> bool { + pub fn run( + &self, + schedule: &mut MonthlySchedule, + tracker: &mut WorkloadTracker, + ) -> anyhow::Result { schedule.prefill(&self.config); for (slot, res_id) in schedule.0.iter() { tracker.insert(*res_id, &self.config, *slot); } + //TODO: add validation + self.search(schedule, tracker, Slot::default()) } @@ -41,15 +57,19 @@ impl Scheduler { schedule: &mut MonthlySchedule, tracker: &mut WorkloadTracker, slot: Slot, - ) -> bool { + ) -> anyhow::Result { + if self.timer.limit_exceeded() { + anyhow::bail!("Time exceeded. Restrictions too tight"); + } + if !slot.is_first() && schedule.restrictions_violated(&slot.previous(), &self.config, &self.bounds, tracker) { - return false; + return Ok(false); } if slot.greater_than(self.config.total_days) { - return tracker.are_all_thresholds_met(&self.config, &self.bounds); + return Ok(tracker.are_all_thresholds_met(&self.config, &self.bounds)); } if schedule.is_slot_manually_assigned(&slot) { @@ -68,15 +88,15 @@ impl Scheduler { schedule.insert(slot, id); tracker.insert(id, &self.config, slot); - if self.search(schedule, tracker, slot.next()) { - return true; + if self.search(schedule, tracker, slot.next())? { + return Ok(true); } schedule.remove(slot); tracker.remove(id, &self.config, slot); } - false + Ok(false) } /// Return all valid residents for the current slot @@ -150,7 +170,7 @@ mod tests { mut tracker: WorkloadTracker, scheduler: Scheduler, ) { - assert!(scheduler.run(&mut schedule, &mut tracker)); + assert!(scheduler.run(&mut schedule, &mut tracker).is_ok()); for d in 1..=scheduler.config.total_days { let day = Day(d); diff --git a/src-tauri/src/slot.rs b/src-tauri/src/slot.rs index 03adda4..96cffe9 100644 --- a/src-tauri/src/slot.rs +++ b/src-tauri/src/slot.rs @@ -139,9 +139,15 @@ impl Day { } pub fn is_weekend(&self, month: u32, year: i32) -> bool { - let date = NaiveDate::from_ymd_opt(year, month, self.0 as u32).unwrap(); - let weekday = date.weekday(); - weekday == Weekday::Sat || weekday == Weekday::Sun + let date = NaiveDate::from_ymd_opt(year, month, self.0 as u32); + + match date { + Some(date) => { + let weekday = date.weekday(); + weekday == Weekday::Sat || weekday == Weekday::Sun + } + None => false, + } } pub fn weekday(&self, month: u32, year: i32) -> Weekday { diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs new file mode 100644 index 0000000..3d3df45 --- /dev/null +++ b/src-tauri/src/timer.rs @@ -0,0 +1,27 @@ +use std::time::Instant; + +pub const TIME_LIMIT_IN_MS: u128 = 100000; + +pub struct Timer { + instant: Instant, + limit: u128, +} + +impl Timer { + pub fn limit_exceeded(&self) -> bool { + self.instant.elapsed().as_millis() > self.limit + } + + pub fn elapsed_in_ms(&self) -> u128 { + self.instant.elapsed().as_millis() + } +} + +impl Default for Timer { + fn default() -> Self { + Self { + instant: std::time::Instant::now(), + limit: TIME_LIMIT_IN_MS, + } + } +} diff --git a/src-tauri/tests/integration.rs b/src-tauri/tests/integration.rs index edb687c..a40ccfb 100644 --- a/src-tauri/tests/integration.rs +++ b/src-tauri/tests/integration.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod integration_tests { + use anyhow::Ok; use rota_lib::{ config::{ToxicPair, UserConfig}, resident::Resident, @@ -12,7 +13,7 @@ mod integration_tests { #[fixture] fn minimal_config() -> UserConfig { - UserConfig::new(2).with_residents(vec![ + UserConfig::default().with_residents(vec![ Resident::new(1, "R1"), Resident::new(2, "R2"), Resident::new(3, "R3"), @@ -22,7 +23,7 @@ mod integration_tests { #[fixture] fn maximal_config() -> UserConfig { - UserConfig::new(2) + UserConfig::default() .with_holidays(vec![2, 3, 10, 11, 12, 25]) .with_residents(vec![ Resident::new(1, "R1").with_max_shifts(3), @@ -46,7 +47,7 @@ mod integration_tests { #[fixture] fn manual_shifts_heavy_config() -> UserConfig { - UserConfig::new(2).with_residents(vec![ + UserConfig::default().with_residents(vec![ Resident::new(1, "R1").with_manual_shifts(vec![ Slot::new(Day(1), ShiftPosition::First), Slot::new(Day(3), ShiftPosition::First), @@ -65,7 +66,7 @@ mod integration_tests { #[fixture] fn complex_config() -> UserConfig { - UserConfig::new(2) + UserConfig::default() .with_holidays(vec![5, 12, 19, 26]) .with_residents(vec![ Resident::new(1, "R1") @@ -93,43 +94,57 @@ mod integration_tests { } #[rstest] - fn test_minimal_config(minimal_config: UserConfig) { + fn test_minimal_config(minimal_config: UserConfig) -> anyhow::Result<()> { let mut schedule = MonthlySchedule::new(); let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(minimal_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)); + assert!(scheduler.run(&mut schedule, &mut tracker)?); + validate_all_constraints(&schedule, &tracker, &minimal_config); + + Ok(()) } #[rstest] - fn test_maximal_config(maximal_config: UserConfig) { + fn test_maximal_config(maximal_config: UserConfig) -> anyhow::Result<()> { let mut schedule = MonthlySchedule::new(); let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(maximal_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)); + assert!(scheduler.run(&mut schedule, &mut tracker)?); + validate_all_constraints(&schedule, &tracker, &maximal_config); + + Ok(()) } #[rstest] - fn test_manual_shifts_heavy_config(manual_shifts_heavy_config: UserConfig) { + fn test_manual_shifts_heavy_config( + manual_shifts_heavy_config: UserConfig, + ) -> anyhow::Result<()> { let mut schedule = MonthlySchedule::new(); let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(manual_shifts_heavy_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)); + assert!(scheduler.run(&mut schedule, &mut tracker)?); + validate_all_constraints(&schedule, &tracker, &manual_shifts_heavy_config); + + Ok(()) } #[rstest] - fn test_complex_config(complex_config: UserConfig) { + fn test_complex_config(complex_config: UserConfig) -> anyhow::Result<()> { let mut schedule = MonthlySchedule::new(); let mut tracker = WorkloadTracker::default(); let scheduler = Scheduler::new_with_config(complex_config.clone()); - assert!(scheduler.run(&mut schedule, &mut tracker)); + assert!(scheduler.run(&mut schedule, &mut tracker)?); + validate_all_constraints(&schedule, &tracker, &complex_config); + + Ok(()) } fn validate_all_constraints(