feat(timesheet): add configuration model with period validation
Add TimesheetConfig and Period structs for configuring timesheet periods with expected working hours per week. Include RepositoryConfig for loading .streamd.toml with timezone and timesheet configuration. Validation ensures: - No overlapping periods - Valid date ranges (start <= end) Also adds chrono-tz dependency for timezone support.
This commit is contained in:
parent
38597e9fbb
commit
92c8e9712a
4 changed files with 352 additions and 0 deletions
258
src/timesheet/config.rs
Normal file
258
src/timesheet/config.rs
Normal file
|
|
@ -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<Period>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub timesheet: Option<TimesheetConfig>,
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue