use std::fs; use std::path::Path; use chrono::Datelike; use walkdir::WalkDir; use crate::config::Settings; const SEPARATOR_WIDTH: usize = 71; const COLUMN_SEPARATOR_WIDTH: usize = 65; use crate::error::StreamdError; use crate::extract::parse_markdown_file; use crate::localize::localize_stream_file; use crate::models::LocalizedShard; use crate::timesheet::{ extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration, DayType, DayWarning, MonthReport, TimesheetReport, }; fn load_all_shards(base_folder: &Path) -> Result, StreamdError> { let mut shards = Vec::new(); for entry in WalkDir::new(base_folder) .max_depth(1) .into_iter() .filter_map(|e| e.ok()) { let path = entry.path(); if path.extension().map(|e| e == "md").unwrap_or(false) { let file_name = path.to_string_lossy().to_string(); let content = fs::read_to_string(path)?; let stream_file = parse_markdown_file(&file_name, &content); if let Ok(shard) = localize_stream_file(&stream_file, &BasicTimesheetConfiguration) { shards.push(shard); } } } Ok(shards) } /// Format hours with sign for display. fn format_diff(hours: f64) -> String { if hours >= 0.0 { format!("+{:.1}h", hours.abs()) } else { format!("{:.1}h", hours) } } /// Format hours for display without sign. fn format_hours(hours: f64) -> String { format!("{:.1}h", hours.abs()) } /// Get the weekday abbreviation. fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str { match date.weekday() { chrono::Weekday::Mon => "Mon", chrono::Weekday::Tue => "Tue", chrono::Weekday::Wed => "Wed", chrono::Weekday::Thu => "Thu", chrono::Weekday::Fri => "Fri", chrono::Weekday::Sat => "Sat", chrono::Weekday::Sun => "Sun", } } /// Print the timesheet report header. fn print_header() { let double_line = "\u{2550}".repeat(SEPARATOR_WIDTH); println!("{}", double_line); println!(" TIMESHEET REPORT"); println!("{}", double_line); println!(); } /// Print a month report. fn print_month(month: &MonthReport) { let diff_str = format_diff(month.diff()); let month_title = format!("{} {}", month.month_name(), month.year); // Month header with diff print!("\u{2550}\u{2550}\u{2550} {} ", month_title); let padding = 52 - month_title.len() - diff_str.len(); for _ in 0..padding { print!("\u{2550}"); } println!(" Diff: {} \u{2550}\u{2550}\u{2550}", diff_str); println!(); // Column headers let light_line = "\u{2500}".repeat(COLUMN_SEPARATOR_WIDTH); println!(" Date Day Expected Actual Diff Type"); println!(" {}", light_line); // Day rows for day in &month.days { let date_str = day.date.format("%Y-%m-%d").to_string(); let weekday = weekday_abbrev(day.date); let expected = format_hours(day.expected_hours); let actual = format_hours(day.actual_hours); let diff = format_diff(day.diff()); let type_str = match day.day_type { DayType::Regular => String::new(), DayType::Missing if day.has_warnings() => "\u{26a0} Missing".to_string(), _ => day.day_type.to_string(), }; // Add overlap warning indicator let type_str = if day .warnings .iter() .any(|w| matches!(w, DayWarning::OverlappingTimecards { .. })) { if type_str.is_empty() { "\u{26a0} Overlap".to_string() } else { format!("{} \u{26a0}", type_str) } } else { type_str }; println!( " {} {} {:>7} {:>7} {:>6} {}", date_str, weekday, expected, actual, diff, type_str ); } // Monthly totals println!(" {}", light_line); println!( " Monthly: {:>7} {:>7} {:>6}", format_hours(month.total_expected()), format_hours(month.total_actual()), format_diff(month.diff()) ); println!(); } /// Print the cumulative balance. fn print_cumulative_balance(balance: f64) { let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH); println!("{}", light_line); println!( " CUMULATIVE BALANCE: {}", format_diff(balance) ); println!("{}", light_line); } /// Print warnings section. fn print_warnings(report: &TimesheetReport) { if !report.has_warnings() { return; } println!(); println!("\u{26a0} Warnings:"); println!(); // Group warnings by type let missing_warnings: Vec<_> = report .warnings .iter() .filter(|w| matches!(w.warning, DayWarning::MissingWithoutExplanation)) .collect(); let overlap_warnings: Vec<_> = report .warnings .iter() .filter(|w| matches!(w.warning, DayWarning::OverlappingTimecards { .. })) .collect(); let outside_period_warnings: Vec<_> = report .warnings .iter() .filter(|w| matches!(w.warning, DayWarning::OutsidePeriod { .. })) .collect(); if !missing_warnings.is_empty() { println!(" Missing days without explanation:"); for w in &missing_warnings { let weekday = weekday_abbrev(w.date); println!( " - {} ({}): No entries and no leave/holiday marker", w.date.format("%Y-%m-%d"), weekday ); } println!(); } if !overlap_warnings.is_empty() { println!(" Overlapping timecards:"); for w in &overlap_warnings { if let DayWarning::OverlappingTimecards { first, second } = &w.warning { println!( " - {}: {}-{} overlaps with {}-{}", w.date.format("%Y-%m-%d"), first.0.format("%H:%M"), first.1.format("%H:%M"), second.0.format("%H:%M"), second.1.format("%H:%M") ); } } println!(); } if !outside_period_warnings.is_empty() { println!(" Work logged outside configured periods:"); for w in &outside_period_warnings { if let DayWarning::OutsidePeriod { hours_worked } = &w.warning { println!( " - {}: {:.1}h worked (no period configured)", w.date.format("%Y-%m-%d"), hours_worked ); } } println!(); } } pub fn run() -> Result<(), StreamdError> { let settings = Settings::load()?; let base_folder = Path::new(&settings.base_folder); // Load repository configuration let repo_config = load_repository_config(base_folder)?; // Check if timesheet is configured let timesheet_config = match repo_config.timesheet { Some(config) => config, None => { println!("No timesheet configuration found in .streamd.toml"); println!(); println!("Add a [timesheet] section with periods to enable timesheet reporting:"); println!(); println!(" [timesheet]"); println!(" [[timesheet.periods]]"); println!(" start = \"2026-01-01\""); println!(" end = \"2026-12-31\""); println!(" hours_per_week = 40.0"); return Ok(()); } }; // Load all markdown files and extract timesheets let all_shards = load_all_shards(base_folder)?; let timesheets = extract_timesheets(&all_shards)?; // Generate the report let report = generate_report(×heets, ×heet_config)?; if report.months.is_empty() { println!("No timesheet data found for the configured periods."); return Ok(()); } // Print the report print_header(); for month in &report.months { print_month(month); } print_cumulative_balance(report.cumulative_balance); print_warnings(&report); Ok(()) }