use crate::model::TimeLog;
use chrono::{Local, NaiveDate};
use std::path::Path;
use std::process;
use thiserror::Error;
pub struct RenderOptions<'a> {
pub(super) width: u16,
pub(super) height: u16,
pub(super) theme_file_path: Option<&'a Path>,
pub(super) output_path: &'a Path,
pub(super) file_name_prefix: u8,
pub(super) repository_name: String,
}
impl<'a> RenderOptions<'a> {
pub fn new(
width: u16,
height: u16,
theme_file_path: Option<&'a Path>,
output_path: &'a Path,
repository_name: &'a str,
) -> Result<Self, ChartSettingError> {
if let Some(path) = &theme_file_path
&& !path.exists()
{
return Err(ChartSettingError::FileNotFound);
}
Ok(Self {
width,
height,
theme_file_path,
output_path,
file_name_prefix: 1,
repository_name: repository_name
.replace(", ", "_")
.replace(' ', "-")
.to_lowercase(),
})
}
}
#[derive(Debug, PartialEq)]
pub enum BurndownType {
Total,
PerPerson,
}
impl std::fmt::Display for BurndownType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BurndownType::Total => write!(f, "total"),
BurndownType::PerPerson => write!(f, "per-person"),
}
}
}
#[derive(Debug)]
pub struct BurndownOptions {
pub(super) weeks_per_sprint: u16,
pub(super) sprints: u16,
pub(super) hours_per_person: f32,
pub(super) start_date: NaiveDate,
}
impl BurndownOptions {
pub fn new(
time_logs: &[TimeLog],
weeks_per_sprint: u16,
sprints: u16,
hours_per_person: f32,
start_date: Option<NaiveDate>,
) -> Result<Self, ChartSettingError> {
if time_logs.is_empty() {
return Err(ChartSettingError::InvalidInputData(
"No time logs found".to_string(),
));
}
let start_date = start_date.unwrap_or_else(|| {
time_logs
.iter()
.map(|t| t.spent_at.date_naive())
.min()
.unwrap_or_else(|| {
eprintln!("No time logs found.");
process::exit(6);
})
});
if weeks_per_sprint == 0 {
return Err(ChartSettingError::InvalidInputData(
"Weeks per Sprint cannot be 0".to_string(),
));
}
if hours_per_person == 0.0 {
return Err(ChartSettingError::InvalidInputData(
"Hours per Person cannot be 0".to_string(),
));
}
if sprints == 0 {
return Err(ChartSettingError::InvalidInputData(
"Sprints cannot be 0".to_string(),
));
}
if start_date > Local::now().date_naive() {
return Err(ChartSettingError::InvalidInputData(
"Start date cannot be in the future".to_string(),
));
}
Ok(Self {
weeks_per_sprint,
sprints,
hours_per_person,
start_date,
})
}
}
#[derive(Debug, Error)]
pub enum ChartSettingError {
#[error("The theme JSON file was not found.")]
FileNotFound,
#[error("IO Error: {0}")]
IoError(#[from] std::io::Error),
#[error("Could not create chart: {0}")]
CharmingError(#[from] charming::EchartsError),
#[error("Invalid input data: {0}")]
InvalidInputData(String),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::charts::tests::*;
const WIDTH: u16 = 600;
const HEIGHT: u16 = 600;
const REPOSITORY_NAME_INPUT: &str = "Sample Repository";
const REPOSITORY_NAME_OUTPUT: &str = "sample-repository";
#[test]
fn renderoptions_new_returns_ok_with_theme_path_set() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let theme_path = tmp.path();
let output_path = Path::new("docs/charts/");
let chart_options = RenderOptions::new(
WIDTH,
HEIGHT,
Some(theme_path),
output_path,
REPOSITORY_NAME_INPUT,
);
let result = chart_options;
assert!(result.is_ok());
let render_options = result.unwrap();
assert_eq!(render_options.width, WIDTH);
assert_eq!(render_options.height, HEIGHT);
assert_eq!(render_options.theme_file_path, Some(theme_path));
assert_eq!(render_options.output_path, output_path);
assert_eq!(render_options.repository_name, REPOSITORY_NAME_OUTPUT);
}
#[test]
fn renderoptions_new_returns_ok_with_no_theme_path_set() {
let theme_path = None;
let output_path = Path::new("docs/charts/");
let chart_options = RenderOptions::new(
WIDTH,
HEIGHT,
theme_path,
output_path,
REPOSITORY_NAME_INPUT,
);
let result = chart_options;
assert!(result.is_ok());
let render_options = result.unwrap();
assert_eq!(render_options.width, WIDTH);
assert_eq!(render_options.height, HEIGHT);
assert_eq!(render_options.theme_file_path, None);
assert_eq!(render_options.output_path, output_path);
assert_eq!(render_options.repository_name, REPOSITORY_NAME_OUTPUT);
}
#[test]
fn renderoptions_new_returns_err_with_invalid_path() {
let theme_path = Path::new("invalidfile");
let output_path = Path::new("charts");
let chart_options = RenderOptions::new(
WIDTH,
HEIGHT,
Some(theme_path),
output_path,
REPOSITORY_NAME_INPUT,
);
let result = chart_options;
assert!(result.is_err());
assert!(matches!(result, Err(ChartSettingError::FileNotFound)));
}
#[test]
fn burndownoptions_new_returns_ok_with_valid_input_data() {
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let result = chart_options;
assert!(result.is_ok());
let burndown_options = result.unwrap();
assert_eq!(burndown_options.weeks_per_sprint, WEEKS_PER_SPRINT_DEFAULT);
assert_eq!(burndown_options.sprints, SPRINTS);
#[expect(clippy::float_cmp)]
{
assert_eq!(burndown_options.hours_per_person, TOTAL_HOURS_PER_PERSON);
}
assert_eq!(burndown_options.start_date, PROJECT_START.unwrap());
}
#[test]
fn burndownoptions_new_returns_ok_with_implicit_start_date() {
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
None,
);
let result = chart_options;
assert!(result.is_ok());
let burndown_options = result.unwrap();
assert_eq!(burndown_options.weeks_per_sprint, WEEKS_PER_SPRINT_DEFAULT);
assert_eq!(burndown_options.sprints, SPRINTS);
#[expect(clippy::float_cmp)]
{
assert_eq!(burndown_options.hours_per_person, TOTAL_HOURS_PER_PERSON);
}
let first_date = time_logs.iter().map(|l| l.spent_at).min().unwrap();
assert_eq!(burndown_options.start_date, first_date.date_naive());
}
#[test]
fn burndownoptions_new_returns_err_without_timelogs() {
let time_logs = Vec::<TimeLog>::new();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let result = chart_options;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
"Should not allow empty time logs"
);
}
#[test]
fn burndownoptions_new_returns_err_with_zero_weeks_per_sprint() {
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
0,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let result = chart_options;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
"Should not allow zero weeks per sprint"
);
}
#[test]
fn burndownoptions_new_returns_err_with_invalid_hours_per_person() {
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
SPRINTS,
0.0,
PROJECT_START,
);
let result = chart_options;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
"Should not allow zero hours per person"
);
}
#[test]
fn burndownoptions_new_returns_err_with_zero_sprints() {
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
0,
TOTAL_HOURS_PER_PERSON,
PROJECT_START,
);
let result = chart_options;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
"Should not allow zero sprints"
);
}
#[test]
fn burndownoptions_new_returns_err_with_start_date_in_future() {
let time_logs = get_time_logs();
let chart_options = BurndownOptions::new(
&time_logs,
WEEKS_PER_SPRINT_DEFAULT,
SPRINTS,
TOTAL_HOURS_PER_PERSON,
Some(Local::now().date_naive() + chrono::Duration::days(1)),
);
let result = chart_options;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ChartSettingError::InvalidInputData(_)),
"Should not allow start date in the future"
);
}
}