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