gitlab_time_report/charts/
chart_options.rs

1//! Data structures for creating charts
2
3use crate::model::TimeLog;
4use chrono::{Local, NaiveDate};
5use std::path::Path;
6use std::process;
7use thiserror::Error;
8
9/// Contains all information needed for rendering a chart.
10/// Create a new instance with [`RenderOptions::new()`].
11pub struct RenderOptions<'a> {
12    /// The width of the rendered chart.
13    pub(super) width: u16,
14    /// The height of the rendered chart.
15    pub(super) height: u16,
16    /// The path to the chart theme JSON file.
17    pub(super) theme_file_path: Option<&'a Path>,
18    /// The path the charts will be written to.
19    pub(super) output_path: &'a Path,
20    /// Counter that will be added to the file name.
21    /// Used to determine the order in which the charts will be added to the dashboard.
22    pub(super) file_name_prefix: u8,
23}
24
25impl<'a> RenderOptions<'a> {
26    /// Creates a new `RenderOptions` instance.
27    /// # Errors
28    /// Returns a [`ChartSettingError::FileNotFound`] if the theme file does not exist.
29    pub fn new(
30        width: u16,
31        height: u16,
32        theme_file_path: Option<&'a Path>,
33        output_path: &'a Path,
34    ) -> Result<Self, ChartSettingError> {
35        if let Some(path) = &theme_file_path
36            && !path.exists()
37        {
38            return Err(ChartSettingError::FileNotFound);
39        }
40
41        Ok(Self {
42            width,
43            height,
44            theme_file_path,
45            output_path,
46            file_name_prefix: 1,
47        })
48    }
49}
50
51/// The type of burndown chart to create.
52#[derive(Debug, PartialEq)]
53pub enum BurndownType {
54    /// The burndown chart shows the total amount of work done per week/sprint.
55    Total,
56    /// The burndown chart shows the amount of work done per week/sprint per person.
57    PerPerson,
58}
59
60impl std::fmt::Display for BurndownType {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            BurndownType::Total => write!(f, "total"),
64            BurndownType::PerPerson => write!(f, "per-person"),
65        }
66    }
67}
68
69/// Contains all information needed for creating a burndown chart.
70/// Create a new instance with [`BurndownOptions::new()`].
71#[derive(Debug)]
72pub struct BurndownOptions {
73    /// The number of weeks a sprint has.
74    pub(super) weeks_per_sprint: u16,
75    /// The number of sprints the project has.
76    pub(super) sprints: u16,
77    /// How many hours a single person should work on the project in total.
78    pub(super) hours_per_person: f32,
79    /// The start date of the project.
80    pub(super) start_date: NaiveDate,
81}
82
83impl BurndownOptions {
84    /// Creates a new `BurndownOptions` instance. For the meaning of the parameters, see [`BurndownOptions`].
85    /// `TimeLogs` need to be passed in as a fallback for the start date if it is `None`.
86    /// # Parameters
87    /// - `time_logs`: Entries from the GitLab API
88    /// - `weeks_per_sprint`: Duration of a sprint in weeks.
89    /// - `sprints`: How many sprints the project has. If sprints aren't used, set it
90    ///   to the number of weeks your project has.
91    /// - `hours_per_person`: How many hours *a single user/team* should work on the project in total.
92    /// - `start_date`: Starting date of the project (usually first log date)
93    /// # Errors
94    /// Returns [`ChartSettingError::InvalidInputData`] if the input data is not valid.
95    pub fn new(
96        time_logs: &[TimeLog],
97        weeks_per_sprint: u16,
98        sprints: u16,
99        hours_per_person: f32,
100        start_date: Option<NaiveDate>,
101    ) -> Result<Self, ChartSettingError> {
102        if time_logs.is_empty() {
103            return Err(ChartSettingError::InvalidInputData(
104                "No time logs found".to_string(),
105            ));
106        }
107
108        // Set the start date to the earliest time log date if not set
109        let start_date = start_date.unwrap_or_else(|| {
110            time_logs
111                .iter()
112                .map(|t| t.spent_at.date_naive())
113                .min()
114                .unwrap_or_else(|| {
115                    eprintln!("No time logs found.");
116                    process::exit(6);
117                })
118        });
119
120        // Some validation checks
121        if weeks_per_sprint == 0 {
122            return Err(ChartSettingError::InvalidInputData(
123                "Weeks per Sprint cannot be 0".to_string(),
124            ));
125        }
126
127        if hours_per_person == 0.0 {
128            return Err(ChartSettingError::InvalidInputData(
129                "Hours per Person cannot be 0".to_string(),
130            ));
131        }
132
133        if sprints == 0 {
134            return Err(ChartSettingError::InvalidInputData(
135                "Sprints cannot be 0".to_string(),
136            ));
137        }
138
139        if start_date > Local::now().date_naive() {
140            return Err(ChartSettingError::InvalidInputData(
141                "Start date cannot be in the future".to_string(),
142            ));
143        }
144
145        Ok(Self {
146            weeks_per_sprint,
147            sprints,
148            hours_per_person,
149            start_date,
150        })
151    }
152}
153
154/// Possible errors when creating a chart.
155#[derive(Debug, Error)]
156pub enum ChartSettingError {
157    /// The JSON file containing the chart theme settings was not found.
158    #[error("The theme JSON file was not found.")]
159    FileNotFound,
160    /// IO error while reading or writing the file.
161    #[error("IO Error: {0}")]
162    IoError(#[from] std::io::Error),
163    /// Error during chart creation.
164    #[error("Could not create chart: {0}")]
165    CharmingError(#[from] charming::EchartsError),
166    /// The chart input values failed validation.
167    #[error("Invalid input data: {0}")]
168    InvalidInputData(String),
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::charts::tests::*;
175
176    const WIDTH: u16 = 600;
177    const HEIGHT: u16 = 600;
178
179    #[test]
180    fn renderoptions_new_returns_ok_with_theme_path_set() {
181        let tmp = tempfile::NamedTempFile::new().unwrap();
182        let theme_path = tmp.path();
183        let output_path = Path::new("docs/charts/");
184        let chart_options = RenderOptions::new(WIDTH, HEIGHT, Some(theme_path), output_path);
185        let result = chart_options;
186        assert!(result.is_ok());
187        let render_options = result.unwrap();
188        assert_eq!(render_options.theme_file_path, Some(theme_path));
189        assert_eq!(render_options.output_path, output_path);
190    }
191
192    #[test]
193    fn renderoptions_new_returns_ok_with_no_theme_path_set() {
194        let theme_path = None;
195        let output_path = Path::new("docs/charts/");
196        let chart_options = RenderOptions::new(WIDTH, HEIGHT, theme_path, output_path);
197        let result = chart_options;
198        assert!(result.is_ok());
199        let render_options = result.unwrap();
200        assert_eq!(render_options.theme_file_path, None);
201        assert_eq!(render_options.output_path, output_path);
202    }
203
204    #[test]
205    fn renderoptions_new_returns_err_with_invalid_path() {
206        let theme_path = Path::new("invalidfile");
207        let output_path = Path::new("charts");
208        let chart_options = RenderOptions::new(WIDTH, HEIGHT, Some(theme_path), output_path);
209        let result = chart_options;
210        assert!(result.is_err());
211        assert!(matches!(result, Err(ChartSettingError::FileNotFound)));
212    }
213
214    #[test]
215    fn burndownoptions_new_returns_ok_with_valid_input_data() {
216        let time_logs = get_time_logs();
217        let chart_options = BurndownOptions::new(
218            &time_logs,
219            WEEKS_PER_SPRINT_DEFAULT,
220            SPRINTS,
221            TOTAL_HOURS_PER_PERSON,
222            PROJECT_START,
223        );
224        let result = chart_options;
225        assert!(result.is_ok());
226        let burndown_options = result.unwrap();
227        assert_eq!(burndown_options.weeks_per_sprint, WEEKS_PER_SPRINT_DEFAULT);
228        assert_eq!(burndown_options.sprints, SPRINTS);
229        #[expect(clippy::float_cmp)]
230        {
231            assert_eq!(burndown_options.hours_per_person, TOTAL_HOURS_PER_PERSON);
232        }
233        assert_eq!(burndown_options.start_date, PROJECT_START.unwrap());
234    }
235
236    #[test]
237    fn burndownoptions_new_returns_ok_with_implicit_start_date() {
238        let time_logs = get_time_logs();
239        let chart_options = BurndownOptions::new(
240            &time_logs,
241            WEEKS_PER_SPRINT_DEFAULT,
242            SPRINTS,
243            TOTAL_HOURS_PER_PERSON,
244            None,
245        );
246        let result = chart_options;
247        assert!(result.is_ok());
248        let burndown_options = result.unwrap();
249        assert_eq!(burndown_options.weeks_per_sprint, WEEKS_PER_SPRINT_DEFAULT);
250        assert_eq!(burndown_options.sprints, SPRINTS);
251        #[expect(clippy::float_cmp)]
252        {
253            assert_eq!(burndown_options.hours_per_person, TOTAL_HOURS_PER_PERSON);
254        }
255        let first_date = time_logs.iter().map(|l| l.spent_at).min().unwrap();
256        assert_eq!(burndown_options.start_date, first_date.date_naive());
257    }
258
259    #[test]
260    fn burndownoptions_new_returns_err_without_timelogs() {
261        let time_logs = Vec::<TimeLog>::new();
262        let chart_options = BurndownOptions::new(
263            &time_logs,
264            WEEKS_PER_SPRINT_DEFAULT,
265            SPRINTS,
266            TOTAL_HOURS_PER_PERSON,
267            PROJECT_START,
268        );
269        let result = chart_options;
270        assert!(result.is_err());
271        assert!(
272            matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
273            "Should not allow empty time logs"
274        );
275    }
276
277    #[test]
278    fn burndownoptions_new_returns_err_with_zero_weeks_per_sprint() {
279        let time_logs = get_time_logs();
280        let chart_options = BurndownOptions::new(
281            &time_logs,
282            0,
283            SPRINTS,
284            TOTAL_HOURS_PER_PERSON,
285            PROJECT_START,
286        );
287        let result = chart_options;
288        assert!(result.is_err());
289        assert!(
290            matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
291            "Should not allow zero weeks per sprint"
292        );
293    }
294
295    #[test]
296    fn burndownoptions_new_returns_err_with_invalid_hours_per_person() {
297        let time_logs = get_time_logs();
298        let chart_options = BurndownOptions::new(
299            &time_logs,
300            WEEKS_PER_SPRINT_DEFAULT,
301            SPRINTS,
302            0.0,
303            PROJECT_START,
304        );
305        let result = chart_options;
306        assert!(result.is_err());
307        assert!(
308            matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
309            "Should not allow zero hours per person"
310        );
311    }
312
313    #[test]
314    fn burndownoptions_new_returns_err_with_zero_sprints() {
315        let time_logs = get_time_logs();
316        let chart_options = BurndownOptions::new(
317            &time_logs,
318            WEEKS_PER_SPRINT_DEFAULT,
319            0,
320            TOTAL_HOURS_PER_PERSON,
321            PROJECT_START,
322        );
323        let result = chart_options;
324        assert!(result.is_err());
325        assert!(
326            matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
327            "Should not allow zero sprints"
328        );
329    }
330
331    #[test]
332    fn burndownoptions_new_returns_err_with_start_date_in_future() {
333        let time_logs = get_time_logs();
334        let chart_options = BurndownOptions::new(
335            &time_logs,
336            WEEKS_PER_SPRINT_DEFAULT,
337            SPRINTS,
338            TOTAL_HOURS_PER_PERSON,
339            Some(Local::now().date_naive() + chrono::Duration::days(1)),
340        );
341        let result = chart_options;
342        assert!(result.is_err());
343        assert!(
344            matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
345            "Should not allow start date in the future"
346        );
347    }
348}