diff --git a/Cargo.lock b/Cargo.lock index ba51b84..e0a0cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "4.6.0" @@ -550,6 +572,53 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -613,6 +682,21 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "redox_users" version = "0.4.6" @@ -751,11 +835,18 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "streamd" version = "0.1.0" dependencies = [ "chrono", + "chrono-tz", "clap", "clap_complete", "directories", diff --git a/Cargo.toml b/Cargo.toml index d933964..8b8d502 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ pulldown-cmark = "0.12" regex = "1" once_cell = "1" chrono = { version = "0.4", features = ["serde"] } +chrono-tz = "0.9" walkdir = "2" indexmap = { version = "2", features = ["serde"] } itertools = "0.13" diff --git a/src/timesheet/config.rs b/src/timesheet/config.rs new file mode 100644 index 0000000..e743cc2 --- /dev/null +++ b/src/timesheet/config.rs @@ -0,0 +1,258 @@ +use chrono::NaiveDate; +use serde::Deserialize; + +use crate::error::StreamdError; + +/// Configuration for timesheet periods and timezone. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct TimesheetConfig { + #[serde(default)] + pub periods: Vec, +} + +/// A period of time with expected working hours per week. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct Period { + pub start: NaiveDate, + pub end: NaiveDate, + pub hours_per_week: f64, +} + +impl Period { + /// Calculate the expected hours per day (Mon-Fri distribution). + pub fn hours_per_day(&self) -> f64 { + self.hours_per_week / 5.0 + } + + /// Check if a date falls within this period. + pub fn contains(&self, date: NaiveDate) -> bool { + date >= self.start && date <= self.end + } +} + +impl TimesheetConfig { + /// Validate the timesheet configuration. + /// - Ensures no periods overlap + /// - Ensures start <= end for each period + pub fn validate(&self) -> Result<(), StreamdError> { + // Check each period has valid date range + for period in &self.periods { + if period.start > period.end { + return Err(StreamdError::ConfigError(format!( + "Period start date {} is after end date {}", + period.start, period.end + ))); + } + } + + // Check for overlapping periods + for i in 0..self.periods.len() { + for j in (i + 1)..self.periods.len() { + if periods_overlap(&self.periods[i], &self.periods[j]) { + return Err(StreamdError::ConfigError(format!( + "Periods overlap: {}-{} and {}-{}", + self.periods[i].start, + self.periods[i].end, + self.periods[j].start, + self.periods[j].end + ))); + } + } + } + + Ok(()) + } + + /// Find the period that contains a given date. + pub fn find_period(&self, date: NaiveDate) -> Option<&Period> { + self.periods.iter().find(|p| p.contains(date)) + } +} + +/// Check if two periods overlap. +fn periods_overlap(a: &Period, b: &Period) -> bool { + a.start <= b.end && b.start <= a.end +} + +/// Repository-level configuration loaded from .streamd.toml. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct RepositoryConfig { + #[serde(default)] + pub timezone: Option, + #[serde(default)] + pub timesheet: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + #[test] + fn test_period_contains_date() { + let period = Period { + start: date(2026, 1, 1), + end: date(2026, 6, 30), + hours_per_week: 38.0, + }; + + assert!(period.contains(date(2026, 1, 1))); + assert!(period.contains(date(2026, 3, 15))); + assert!(period.contains(date(2026, 6, 30))); + assert!(!period.contains(date(2025, 12, 31))); + assert!(!period.contains(date(2026, 7, 1))); + } + + #[test] + fn test_period_hours_per_day() { + let period = Period { + start: date(2026, 1, 1), + end: date(2026, 6, 30), + hours_per_week: 38.0, + }; + + assert!((period.hours_per_day() - 7.6).abs() < 0.0001); + } + + #[test] + fn test_validate_valid_config() { + let config = TimesheetConfig { + periods: vec![ + Period { + start: date(2026, 1, 1), + end: date(2026, 6, 30), + hours_per_week: 38.0, + }, + Period { + start: date(2026, 7, 1), + end: date(2026, 12, 31), + hours_per_week: 40.0, + }, + ], + }; + + assert!(config.validate().is_ok()); + } + + #[test] + fn test_validate_overlapping_periods() { + let config = TimesheetConfig { + periods: vec![ + Period { + start: date(2026, 1, 1), + end: date(2026, 6, 30), + hours_per_week: 38.0, + }, + Period { + start: date(2026, 6, 15), + end: date(2026, 12, 31), + hours_per_week: 40.0, + }, + ], + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("overlap")); + } + + #[test] + fn test_validate_start_after_end() { + let config = TimesheetConfig { + periods: vec![Period { + start: date(2026, 6, 30), + end: date(2026, 1, 1), + hours_per_week: 38.0, + }], + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("after")); + } + + #[test] + fn test_find_period() { + let config = TimesheetConfig { + periods: vec![ + Period { + start: date(2026, 1, 1), + end: date(2026, 6, 30), + hours_per_week: 38.0, + }, + Period { + start: date(2026, 7, 1), + end: date(2026, 12, 31), + hours_per_week: 40.0, + }, + ], + }; + + let period = config.find_period(date(2026, 3, 15)); + assert!(period.is_some()); + assert!((period.unwrap().hours_per_week - 38.0).abs() < 0.0001); + + let period = config.find_period(date(2026, 9, 15)); + assert!(period.is_some()); + assert!((period.unwrap().hours_per_week - 40.0).abs() < 0.0001); + + let period = config.find_period(date(2025, 12, 15)); + assert!(period.is_none()); + } + + #[test] + fn test_empty_config_is_valid() { + let config = TimesheetConfig { periods: vec![] }; + assert!(config.validate().is_ok()); + } + + #[test] + fn test_adjacent_periods_not_overlapping() { + let config = TimesheetConfig { + periods: vec![ + Period { + start: date(2026, 1, 1), + end: date(2026, 6, 30), + hours_per_week: 38.0, + }, + Period { + start: date(2026, 7, 1), + end: date(2026, 12, 31), + hours_per_week: 40.0, + }, + ], + }; + + assert!(config.validate().is_ok()); + } + + #[test] + fn test_deserialize_repository_config() { + let toml_str = r#" +timezone = "Europe/Berlin" + +[timesheet] +[[timesheet.periods]] +start = "2026-01-01" +end = "2026-06-30" +hours_per_week = 38.0 + +[[timesheet.periods]] +start = "2026-07-01" +end = "2026-12-31" +hours_per_week = 40.0 +"#; + + let config: RepositoryConfig = toml::from_str(toml_str).unwrap(); + + assert_eq!(config.timezone, Some("Europe/Berlin".to_string())); + assert!(config.timesheet.is_some()); + let timesheet = config.timesheet.unwrap(); + assert_eq!(timesheet.periods.len(), 2); + assert!((timesheet.periods[0].hours_per_week - 38.0).abs() < 0.0001); + assert!((timesheet.periods[1].hours_per_week - 40.0).abs() < 0.0001); + } +} diff --git a/src/timesheet/mod.rs b/src/timesheet/mod.rs index 85bbab2..6d1c1d7 100644 --- a/src/timesheet/mod.rs +++ b/src/timesheet/mod.rs @@ -1,7 +1,9 @@ +mod config; mod configuration; mod extract; mod point_types; +pub use config::{Period, RepositoryConfig, TimesheetConfig}; pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG}; pub use extract::extract_timesheets; pub use point_types::TimesheetPointType;