refactor: rewrite in rust
All checks were successful
Continuous Integration / Lint, Check & Test (push) Successful in 1m38s
Continuous Integration / Build Package (push) Successful in 1m54s

This commit is contained in:
Konstantin Fickel 2026-03-29 18:19:15 +02:00
parent 20a3e8b437
commit ed493cff29
Signed by: kfickel
GPG key ID: A793722F9933C1A5
72 changed files with 5684 additions and 3688 deletions

537
src/timesheet/extract.rs Normal file
View file

@ -0,0 +1,537 @@
use chrono::{DateTime, Utc};
use itertools::Itertools;
use crate::error::StreamdError;
use crate::models::{LocalizedShard, SpecialDayType, Timecard, Timesheet};
use crate::query::find_shard_by_set_dimension;
use super::configuration::TIMESHEET_DIMENSION_NAME;
use super::TimesheetPointType;
/// A point in time with an associated timesheet type.
#[derive(Debug, Clone)]
struct TimesheetPoint {
moment: DateTime<Utc>,
point_type: TimesheetPointType,
}
/// Convert a localized shard to a timesheet point.
fn shard_to_timesheet_point(shard: &LocalizedShard) -> Option<TimesheetPoint> {
let type_str = shard.location.get(TIMESHEET_DIMENSION_NAME)?;
let point_type = type_str.parse::<TimesheetPointType>().ok()?;
Some(TimesheetPoint {
moment: shard.moment,
point_type,
})
}
/// Convert localized shards to timesheet points.
fn shards_to_timesheet_points(shards: &[LocalizedShard]) -> Vec<TimesheetPoint> {
find_shard_by_set_dimension(shards, TIMESHEET_DIMENSION_NAME)
.iter()
.filter_map(shard_to_timesheet_point)
.collect()
}
/// Aggregate timesheet points for a single day into a Timesheet.
fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result<Option<Timesheet>, StreamdError> {
if points.is_empty() {
return Ok(None);
}
let sorted_points: Vec<_> = {
let mut pts = points.to_vec();
pts.sort_by_key(|p| p.moment);
pts
};
let card_date = sorted_points[0].moment.date_naive();
let mut is_sick_leave = false;
let mut special_day_type: Option<SpecialDayType> = None;
// State machine: starting in "break" mode (not working)
let mut last_is_break = true;
let mut last_time = sorted_points[0].moment.time();
let mut timecards: Vec<Timecard> = Vec::new();
for point in &sorted_points {
if point.moment.date_naive() != card_date {
return Err(StreamdError::TimesheetError(
"Dates of all given timesheet days should be consistent".to_string(),
));
}
let point_time = point.moment.time();
match point.point_type {
TimesheetPointType::Holiday => {
if special_day_type.is_some() {
return Err(StreamdError::TimesheetError(format!(
"{} is both {:?} and {:?}",
card_date, point.point_type, special_day_type
)));
}
special_day_type = Some(SpecialDayType::Holiday);
}
TimesheetPointType::Vacation => {
if special_day_type.is_some() {
return Err(StreamdError::TimesheetError(format!(
"{} is both {:?} and {:?}",
card_date, point.point_type, special_day_type
)));
}
special_day_type = Some(SpecialDayType::Vacation);
}
TimesheetPointType::Undertime => {
if special_day_type.is_some() {
return Err(StreamdError::TimesheetError(format!(
"{} is both {:?} and {:?}",
card_date, point.point_type, special_day_type
)));
}
special_day_type = Some(SpecialDayType::Undertime);
}
TimesheetPointType::SickLeave => {
is_sick_leave = true;
}
TimesheetPointType::Break => {
if !last_is_break {
timecards.push(Timecard::new(last_time, point_time));
last_is_break = true;
last_time = point_time;
}
}
TimesheetPointType::Card => {
if last_is_break {
last_is_break = false;
last_time = point_time;
}
}
}
}
// Check that we ended in break mode
if !last_is_break {
return Err(StreamdError::TimesheetError(format!(
"Last Timecard of {} is not a break!",
card_date
)));
}
// Only return a timesheet if there's meaningful data
if timecards.is_empty() && !is_sick_leave && special_day_type.is_none() {
return Ok(None);
}
Ok(Some(Timesheet {
date: card_date,
is_sick_leave,
special_day_type,
timecards,
}))
}
/// Aggregate timesheet points into timesheets, grouped by day.
fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> {
let mut timesheets = Vec::new();
// Group by date
for (_date, group) in &points.iter().chunk_by(|p| p.moment.date_naive()) {
let day_points: Vec<_> = group.cloned().collect();
if let Some(timesheet) = aggregate_timecard_day(&day_points)? {
timesheets.push(timesheet);
}
}
Ok(timesheets)
}
/// Extract timesheets from localized shards.
pub fn extract_timesheets(shards: &[LocalizedShard]) -> Result<Vec<Timesheet>, StreamdError> {
let points = shards_to_timesheet_points(shards);
aggregate_timecards(&points)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveTime, TimeZone};
use indexmap::IndexMap;
fn point(at: DateTime<Utc>, point_type: TimesheetPointType) -> LocalizedShard {
let mut location = IndexMap::new();
location.insert(
TIMESHEET_DIMENSION_NAME.to_string(),
point_type.as_str().to_string(),
);
location.insert("file".to_string(), "dummy.md".to_string());
LocalizedShard {
moment: at,
markers: vec!["Timesheet".to_string()],
tags: vec![],
start_line: 1,
end_line: 1,
children: vec![],
location,
}
}
#[test]
fn test_single_work_block() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(17, 30, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].date, day.date_naive());
assert!(!result[0].is_sick_leave);
assert!(result[0].special_day_type.is_none());
assert_eq!(result[0].timecards.len(), 1);
assert_eq!(
result[0].timecards[0].from_time,
NaiveTime::from_hms_opt(9, 0, 0).unwrap()
);
assert_eq!(
result[0].timecards[0].to_time,
NaiveTime::from_hms_opt(17, 30, 0).unwrap()
);
}
#[test]
fn test_three_work_blocks_separated_by_breaks() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(7, 15, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 45, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(16, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].timecards.len(), 3);
assert_eq!(
result[0].timecards[0].from_time,
NaiveTime::from_hms_opt(7, 15, 0).unwrap()
);
assert_eq!(
result[0].timecards[0].to_time,
NaiveTime::from_hms_opt(12, 0, 0).unwrap()
);
}
#[test]
fn test_input_order_is_not_required_within_a_day() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(15, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(7, 15, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 45, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(16, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].timecards.len(), 3);
}
#[test]
fn test_groups_by_day() {
let day1 = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let day2 = Utc.with_ymd_and_hms(2026, 2, 2, 0, 0, 0).unwrap();
let shards = vec![
point(
day1.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day1.with_time(NaiveTime::from_hms_opt(17, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day2.with_time(NaiveTime::from_hms_opt(10, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day2.with_time(NaiveTime::from_hms_opt(18, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result[0].date, day1.date_naive());
assert_eq!(result[1].date, day2.date_naive());
}
#[test]
fn test_day_with_only_special_day_type_vacation() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Vacation,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Vacation));
assert!(result[0].timecards.is_empty());
}
#[test]
fn test_day_with_only_special_day_type_holiday() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Holiday,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Holiday));
}
#[test]
fn test_day_with_only_special_day_type_undertime() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Undertime,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].special_day_type, Some(SpecialDayType::Undertime));
}
#[test]
fn test_day_with_sick_leave_and_timecards() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(7, 30, 0).unwrap())
.unwrap(),
TimesheetPointType::SickLeave,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_sick_leave);
assert_eq!(result[0].timecards.len(), 1);
}
#[test]
fn test_day_with_sick_leave_only() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::SickLeave,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_sick_leave);
assert!(result[0].timecards.is_empty());
}
#[test]
fn test_empty_input() {
let result = extract_timesheets(&[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_day_with_only_cards_and_no_break_is_invalid() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Card,
),
];
let result = extract_timesheets(&shards);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("not a break"));
}
#[test]
fn test_two_special_day_types_same_day_is_invalid() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(8, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Vacation,
),
point(
day.with_time(NaiveTime::from_hms_opt(8, 5, 0).unwrap())
.unwrap(),
TimesheetPointType::Holiday,
),
point(
day.with_time(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("is both"));
}
#[test]
fn test_day_with_only_breaks_is_ignored() {
let day = Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap();
let shards = vec![
point(
day.with_time(NaiveTime::from_hms_opt(12, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
point(
day.with_time(NaiveTime::from_hms_opt(13, 0, 0).unwrap())
.unwrap(),
TimesheetPointType::Break,
),
];
let result = extract_timesheets(&shards).unwrap();
assert!(result.is_empty());
}
}