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 pub(super) repository_name: String,
26}
27
28impl<'a> RenderOptions<'a> {
29 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#[derive(Debug, PartialEq)]
58pub enum BurndownType {
59 Total,
61 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#[derive(Debug)]
77pub struct BurndownOptions {
78 pub(super) weeks_per_sprint: u16,
80 pub(super) sprints: u16,
82 pub(super) hours_per_person: f32,
84 pub(super) start_date: NaiveDate,
86}
87
88impl BurndownOptions {
89 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 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 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#[derive(Debug, Error)]
161pub enum ChartSettingError {
162 #[error("The theme JSON file was not found.")]
164 FileNotFound,
165 #[error("IO Error: {0}")]
167 IoError(#[from] std::io::Error),
168 #[error("Could not create chart: {0}")]
170 CharmingError(#[from] charming::EchartsError),
171 #[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}