gitlab_time_report/charts/
chart_options.rs1use crate::model::TimeLog;
4use chrono::{Local, NaiveDate};
5use std::path::Path;
6use std::process;
7use thiserror::Error;
8
9pub struct RenderOptions<'a> {
12 pub(super) width: u16,
14 pub(super) height: u16,
16 pub(super) theme_file_path: Option<&'a Path>,
18 pub(super) output_path: &'a Path,
20 pub(super) file_name_prefix: u8,
23}
24
25impl<'a> RenderOptions<'a> {
26 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#[derive(Debug, PartialEq)]
53pub enum BurndownType {
54 Total,
56 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#[derive(Debug)]
72pub struct BurndownOptions {
73 pub(super) weeks_per_sprint: u16,
75 pub(super) sprints: u16,
77 pub(super) hours_per_person: f32,
79 pub(super) start_date: NaiveDate,
81}
82
83impl BurndownOptions {
84 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 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 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#[derive(Debug, Error)]
156pub enum ChartSettingError {
157 #[error("The theme JSON file was not found.")]
159 FileNotFound,
160 #[error("IO Error: {0}")]
162 IoError(#[from] std::io::Error),
163 #[error("Could not create chart: {0}")]
165 CharmingError(#[from] charming::EchartsError),
166 #[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}