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
52 .replace(", ", "_")
53 .replace(' ', "-")
54 .to_lowercase(),
55 })
56 }
57}
58
59#[derive(Debug, PartialEq)]
61pub enum BurndownType {
62 Total,
64 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#[derive(Debug)]
80pub struct BurndownOptions {
81 pub(super) weeks_per_sprint: u16,
83 pub(super) sprints: u16,
85 pub(super) hours_per_person: f32,
87 pub(super) start_date: NaiveDate,
89}
90
91impl BurndownOptions {
92 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 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 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#[derive(Debug, Error)]
164pub enum ChartSettingError {
165 #[error("The theme JSON file was not found.")]
167 FileNotFound,
168 #[error("IO Error: {0}")]
170 IoError(#[from] std::io::Error),
171 #[error("Could not create chart: {0}")]
173 CharmingError(#[from] charming::EchartsError),
174 #[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}