use std::collections::HashMap; use std::fs; use std::path::Path; use chrono::{Datelike, NaiveDate, Weekday}; use crate::error::StreamdError; use crate::models::{SpecialDayType, Timesheet}; use super::config::{RepositoryConfig, TimesheetConfig}; use super::report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport}; use super::validation::find_overlapping_timecards; /// Load repository configuration from .streamd.toml file. pub fn load_repository_config(base_folder: &Path) -> Result { let config_path = base_folder.join(".streamd.toml"); if !config_path.exists() { return Ok(RepositoryConfig::default()); } let content = fs::read_to_string(&config_path)?; let config: RepositoryConfig = toml::from_str(&content)?; // Validate timesheet config if present if let Some(ref timesheet) = config.timesheet { timesheet.validate()?; } Ok(config) } /// Calculate total hours worked from timecards. fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 { timesheet .timecards .iter() .map(|tc| { let duration = tc.to_time - tc.from_time; duration.num_minutes() as f64 / 60.0 }) .sum() } /// Determine the day type based on timesheet data. fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period: bool) -> DayType { let weekday = date.weekday(); // Check weekend first if weekday == Weekday::Sat || weekday == Weekday::Sun { return DayType::Weekend; } // Check if outside any period if !has_period { return DayType::OutsidePeriod; } // Check special day types from timesheet if let Some(ts) = timesheet { if let Some(special) = ts.special_day_type { match special { SpecialDayType::Vacation => return DayType::Vacation, SpecialDayType::Holiday => return DayType::Holiday, SpecialDayType::Undertime => return DayType::FlexDay, SpecialDayType::Weekend => return DayType::Weekend, } } if ts.is_sick_leave { return DayType::SickLeave; } // Has timecards or special type return DayType::Regular; } // No timesheet entry for a weekday = Missing DayType::Missing } /// Calculate expected hours for a day based on period config and day type. fn calculate_expected_hours(day_type: DayType, hours_per_day: f64, _date: NaiveDate) -> f64 { match day_type { DayType::Regular => hours_per_day, DayType::SickLeave => hours_per_day, DayType::Vacation => hours_per_day, DayType::Holiday => 0.0, DayType::FlexDay => hours_per_day, DayType::Weekend => 0.0, DayType::Missing => hours_per_day, DayType::OutsidePeriod => 0.0, } } /// Calculate actual hours for a day based on day type rules. fn calculate_actual_hours(day_type: DayType, timecard_hours: f64, expected_hours: f64) -> f64 { match day_type { DayType::Regular => timecard_hours, DayType::SickLeave => expected_hours.max(timecard_hours), DayType::Vacation => expected_hours + timecard_hours, DayType::Holiday => timecard_hours, DayType::FlexDay => 0.0, DayType::Weekend => timecard_hours, DayType::Missing => 0.0, DayType::OutsidePeriod => timecard_hours, } } /// Generate a timesheet report from timesheets and configuration. pub fn generate_report( timesheets: &[Timesheet], config: &TimesheetConfig, ) -> Result { if config.periods.is_empty() { return Ok(TimesheetReport::new()); } // Index timesheets by date for quick lookup let timesheets_by_date: HashMap = timesheets.iter().map(|ts| (ts.date, ts)).collect(); // Find the date range to report on let earliest_period_start = config.periods.iter().map(|p| p.start).min().unwrap(); let latest_period_end = config.periods.iter().map(|p| p.end).max().unwrap(); // Limit to today let today = chrono::Local::now().date_naive(); let end_date = latest_period_end.min(today); // Group by month and generate reports let mut month_reports: Vec = Vec::new(); let mut all_warnings: Vec = Vec::new(); let mut cumulative_balance: f64 = 0.0; // Iterate through all dates in the range let mut current_date = earliest_period_start; let mut current_month: Option<(i32, u32)> = None; let mut current_month_days: Vec = Vec::new(); while current_date <= end_date { let year = current_date.year(); let month = current_date.month(); // Check if we've moved to a new month if current_month != Some((year, month)) { // Save the previous month if it had days if let Some((prev_year, prev_month)) = current_month { if !current_month_days.is_empty() { let month_report = MonthReport::new(prev_year, prev_month).with_days(current_month_days); cumulative_balance += month_report.diff(); month_reports.push(month_report); } } current_month = Some((year, month)); current_month_days = Vec::new(); } // Find if this date falls within a period let period = config.find_period(current_date); let has_period = period.is_some(); let hours_per_day = period.map(|p| p.hours_per_day()).unwrap_or(0.0); // Get timesheet for this date let timesheet = timesheets_by_date.get(¤t_date).copied(); let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.0); // Determine day type let day_type = determine_day_type(current_date, timesheet, has_period); // Skip weekends with no work and days outside periods with no work let should_include = match day_type { DayType::Weekend => timecard_hours > 0.0, DayType::OutsidePeriod => timecard_hours > 0.0, _ => has_period, // Only include days within periods }; if should_include { // Calculate expected and actual hours let expected_hours = calculate_expected_hours(day_type, hours_per_day, current_date); let actual_hours = calculate_actual_hours(day_type, timecard_hours, expected_hours); let mut day_report = DayReport::new(current_date, expected_hours, actual_hours, day_type); // Collect warnings let mut day_warnings: Vec = Vec::new(); // Warning: Missing without explanation if day_type == DayType::Missing { day_warnings.push(DayWarning::MissingWithoutExplanation); all_warnings.push(ReportWarning::new( current_date, DayWarning::MissingWithoutExplanation, )); } // Warning: Overlapping timecards if let Some(ts) = timesheet { let overlaps = find_overlapping_timecards(&ts.timecards); for (first, second) in overlaps { let warning = DayWarning::OverlappingTimecards { first, second }; day_warnings.push(warning.clone()); all_warnings.push(ReportWarning::new(current_date, warning)); } } // Warning: Work outside period if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 { let warning = DayWarning::OutsidePeriod { hours_worked: timecard_hours, }; day_warnings.push(warning.clone()); all_warnings.push(ReportWarning::new(current_date, warning)); } day_report = day_report.with_warnings(day_warnings); current_month_days.push(day_report); } // Move to next day current_date = current_date.succ_opt().unwrap_or(current_date); if current_date == end_date && current_date <= latest_period_end { // We've hit end_date, process this day then stop } } // Don't forget the last month if let Some((year, month)) = current_month { if !current_month_days.is_empty() { let month_report = MonthReport::new(year, month).with_days(current_month_days); cumulative_balance += month_report.diff(); month_reports.push(month_report); } } // Sort months in ascending order (oldest first, newest at bottom) month_reports.sort_by(|a, b| { let a_date = (a.year, a.month); let b_date = (b.year, b.month); a_date.cmp(&b_date) }); Ok(TimesheetReport::new() .with_months(month_reports) .with_cumulative_balance(cumulative_balance) .with_warnings(all_warnings)) } #[cfg(test)] mod tests { use super::*; use crate::models::Timecard; use crate::timesheet::Period; use chrono::NaiveTime; fn date(year: i32, month: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(year, month, day).unwrap() } fn time(hour: u32, min: u32) -> NaiveTime { NaiveTime::from_hms_opt(hour, min, 0).unwrap() } fn make_timesheet(date: NaiveDate, cards: Vec<(u32, u32, u32, u32)>) -> Timesheet { Timesheet { date, is_sick_leave: false, special_day_type: None, timecards: cards .into_iter() .map(|(fh, fm, th, tm)| Timecard::new(time(fh, fm), time(th, tm))) .collect(), } } fn make_config(start: NaiveDate, end: NaiveDate, hours_per_week: f64) -> TimesheetConfig { TimesheetConfig { periods: vec![Period { start, end, hours_per_week, }], } } #[test] fn test_calculate_timecard_hours() { let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 0), (13, 0, 17, 0)]); let hours = calculate_timecard_hours(&ts); assert!((hours - 7.0).abs() < 0.0001); } #[test] fn test_calculate_timecard_hours_with_minutes() { let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 30), (13, 0, 17, 15)]); let hours = calculate_timecard_hours(&ts); assert!((hours - 7.75).abs() < 0.0001); } #[test] fn test_day_type_regular() { // Monday with timesheet let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]); let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); assert_eq!(day_type, DayType::Regular); } #[test] fn test_day_type_weekend() { // Saturday let day_type = determine_day_type(date(2026, 3, 7), None, true); assert_eq!(day_type, DayType::Weekend); } #[test] fn test_day_type_missing() { // Monday with no timesheet let day_type = determine_day_type(date(2026, 3, 2), None, true); assert_eq!(day_type, DayType::Missing); } #[test] fn test_day_type_vacation() { let ts = Timesheet { date: date(2026, 3, 2), is_sick_leave: false, special_day_type: Some(SpecialDayType::Vacation), timecards: vec![], }; let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); assert_eq!(day_type, DayType::Vacation); } #[test] fn test_day_type_sick_leave() { let ts = Timesheet { date: date(2026, 3, 2), is_sick_leave: true, special_day_type: None, timecards: vec![], }; let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); assert_eq!(day_type, DayType::SickLeave); } #[test] fn test_day_type_holiday() { let ts = Timesheet { date: date(2026, 3, 2), is_sick_leave: false, special_day_type: Some(SpecialDayType::Holiday), timecards: vec![], }; let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); assert_eq!(day_type, DayType::Holiday); } #[test] fn test_day_type_flex_day() { let ts = Timesheet { date: date(2026, 3, 2), is_sick_leave: false, special_day_type: Some(SpecialDayType::Undertime), timecards: vec![], }; let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true); assert_eq!(day_type, DayType::FlexDay); } #[test] fn test_day_type_outside_period() { let day_type = determine_day_type(date(2026, 3, 2), None, false); assert_eq!(day_type, DayType::OutsidePeriod); } #[test] fn test_expected_hours_regular() { let hours = calculate_expected_hours(DayType::Regular, 7.6, date(2026, 3, 2)); assert!((hours - 7.6).abs() < 0.0001); } #[test] fn test_expected_hours_holiday() { let hours = calculate_expected_hours(DayType::Holiday, 7.6, date(2026, 3, 2)); assert!((hours - 0.0).abs() < 0.0001); } #[test] fn test_expected_hours_weekend() { let hours = calculate_expected_hours(DayType::Weekend, 7.6, date(2026, 3, 7)); assert!((hours - 0.0).abs() < 0.0001); } #[test] fn test_actual_hours_regular() { let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6); assert!((hours - 8.0).abs() < 0.0001); } #[test] fn test_actual_hours_sick_leave_max() { // Sick leave: max(expected, worked) let hours = calculate_actual_hours(DayType::SickLeave, 3.0, 7.6); assert!((hours - 7.6).abs() < 0.0001); } #[test] fn test_actual_hours_sick_leave_worked_more() { // Sick leave where worked > expected let hours = calculate_actual_hours(DayType::SickLeave, 9.0, 7.6); assert!((hours - 9.0).abs() < 0.0001); } #[test] fn test_actual_hours_vacation() { // Vacation: expected + worked let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6); assert!((hours - 9.6).abs() < 0.0001); } #[test] fn test_actual_hours_flex_day() { // Flex day: always 0 let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6); assert!((hours - 0.0).abs() < 0.0001); } #[test] fn test_generate_report_empty_config() { let config = TimesheetConfig { periods: vec![] }; let report = generate_report(&[], &config).unwrap(); assert!(report.months.is_empty()); } #[test] fn test_generate_report_single_day() { // Monday 2026-03-02 let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); let report = generate_report(×heets, &config).unwrap(); assert_eq!(report.months.len(), 1); assert_eq!(report.months[0].days.len(), 1); let day = &report.months[0].days[0]; assert_eq!(day.date, date(2026, 3, 2)); assert!((day.expected_hours - 8.0).abs() < 0.0001); assert!((day.actual_hours - 8.0).abs() < 0.0001); assert_eq!(day.day_type, DayType::Regular); } #[test] fn test_generate_report_detects_missing_day() { // Period covers Mon-Tue, but only Mon has timesheet let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; // March 2 is Monday, March 3 is Tuesday let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); let report = generate_report(×heets, &config).unwrap(); assert_eq!(report.months[0].days.len(), 2); // First day (Mon) should be regular assert_eq!(report.months[0].days[0].day_type, DayType::Regular); // Second day (Tue) should be missing assert_eq!(report.months[0].days[1].day_type, DayType::Missing); assert!(report.months[0].days[1].has_warnings()); } #[test] fn test_generate_report_weekend_excluded_if_no_work() { // Period covers Mon-Sun, but only Mon has timesheet let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])]; let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); let report = generate_report(×heets, &config).unwrap(); // Should only include Mon-Fri (5 days), not Sat-Sun let days = &report.months[0].days; for day in days { let weekday = day.date.weekday(); assert!(weekday != Weekday::Sat && weekday != Weekday::Sun); } } #[test] fn test_generate_report_weekend_included_if_work() { // Work on Saturday let timesheets = vec![ make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), make_timesheet(date(2026, 3, 7), vec![(10, 0, 14, 0)]), // Saturday ]; let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0); let report = generate_report(×heets, &config).unwrap(); // Should include Saturday let has_saturday = report.months[0] .days .iter() .any(|d| d.date == date(2026, 3, 7)); assert!(has_saturday); } #[test] fn test_generate_report_overlapping_timecards_warning() { let ts = Timesheet { date: date(2026, 3, 2), is_sick_leave: false, special_day_type: None, timecards: vec![ Timecard::new(time(9, 0), time(12, 30)), Timecard::new(time(12, 0), time(13, 0)), ], }; let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0); let report = generate_report(&[ts], &config).unwrap(); assert!(report.has_warnings()); assert!(report.months[0].days[0].has_warnings()); } #[test] fn test_generate_report_cumulative_balance() { // Two days: one with 8h (expected 8h), one with 10h (expected 8h) let timesheets = vec![ make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), // 8h make_timesheet(date(2026, 3, 3), vec![(8, 0, 18, 0)]), // 10h ]; let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0); let report = generate_report(×heets, &config).unwrap(); // Balance should be +2h (18h actual - 16h expected) assert!((report.cumulative_balance - 2.0).abs() < 0.0001); } }