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