gitlab_time_report_cli/
main.rs

1//! This crate provides a Command Line Interface to fetch time logs from a GitLab project and
2//! generate statistics and charts from them.
3
4#![cfg(not(tarpaulin_include))]
5mod arguments;
6mod fetch_projects;
7mod print_table;
8
9use arguments::Command;
10use chrono::NaiveDate;
11use clap::Parser;
12use gitlab_time_report::charts::{BurndownType, ChartSettingError};
13use gitlab_time_report::dashboard::create_html;
14use gitlab_time_report::model::{Label, Milestone, TimeLog, TrackableItemKind};
15use gitlab_time_report::validation::{TimeLogValidator, ValidationProblem};
16use gitlab_time_report::{TimeDeltaExt, charts, create_csv, filters};
17use std::collections::{BTreeMap, HashSet};
18
19fn main() -> Result<(), String> {
20    let cli = arguments::Arguments::parse();
21
22    let project =
23        fetch_projects::fetch_projects(cli.url, cli.token.as_ref()).map_err(|e| e.to_string())?;
24
25    let start_date_for_validation: Option<NaiveDate> = match &cli.command {
26        Some(Command::Charts { chart_options } | Command::Dashboard { chart_options, .. }) => {
27            chart_options.start_date
28        }
29        _ => None,
30    };
31
32    validate_time_logs(
33        &project.time_logs,
34        cli.validation_details,
35        start_date_for_validation,
36        cli.validation_max_hours,
37    );
38
39    match &cli.command {
40        Some(Command::Export { output }) => {
41            create_csv(&project.time_logs, output.clone()).map_err(|e| e.to_string())?;
42            println!("CSV created at {}", output.display());
43        }
44        Some(Command::Charts { chart_options }) => {
45            let (selected_labels, other_label) = create_label_options(cli.labels);
46            create_charts(
47                &project.time_logs,
48                chart_options,
49                selected_labels.as_ref(),
50                other_label.as_ref(),
51                &project.name,
52            )
53            .map_err(|e| e.to_string())?;
54        }
55        Some(Command::Dashboard { chart_options }) => {
56            let (selected_labels, other_label) = create_label_options(cli.labels);
57            create_charts(
58                &project.time_logs,
59                chart_options,
60                selected_labels.as_ref(),
61                other_label.as_ref(),
62                &project.name,
63            )
64            .map_err(|e| e.to_string())?;
65
66            let path = create_html(
67                &project.time_logs,
68                &chart_options.output,
69                selected_labels.as_ref(),
70                other_label.as_ref(),
71                &project.name,
72            )
73            .map_err(|e| e.to_string())?;
74            println!("Dashboard '{}' successfully created.", path.display());
75        }
76        None => {
77            print_table::print_timelogs_in_timeframes_by_user(&project.time_logs);
78
79            let (selected_labels, other_label) = create_label_options(cli.labels);
80            print_table::print_total_time_by_label(
81                &project.time_logs,
82                selected_labels.as_ref(),
83                other_label.as_ref(),
84            );
85
86            print_table::print_total_time_by_milestone(&project.time_logs);
87            print_table::print_todays_timelogs(&project.time_logs);
88        }
89    }
90    Ok(())
91}
92
93/// Runs validation on the entered time logs with all validators and outputs the result to the console.
94pub(crate) fn validate_time_logs(
95    time_logs: &[TimeLog],
96    show_validation_details: bool,
97    start_date: Option<chrono::NaiveDate>,
98    max_hours: u16,
99) {
100    let mut validator = TimeLogValidator::new()
101        .with_validator(gitlab_time_report::validation::ExcessiveHoursValidator::new(max_hours))
102        .with_validator(gitlab_time_report::validation::HasSummaryValidator)
103        .with_validator(gitlab_time_report::validation::NoFutureDateValidator)
104        .with_validator(gitlab_time_report::validation::DuplicatesValidator::new());
105
106    if let Some(start_date) = start_date {
107        validator = validator.with_validator(
108            gitlab_time_report::validation::BeforeStartDateValidator::new(start_date),
109        );
110    }
111
112    let results = validator.validate(time_logs);
113    let number_of_problems = results.iter().filter(|r| !r.is_valid()).count();
114
115    // Print summary and return when detailed listing is not desired
116    if !show_validation_details {
117        match number_of_problems {
118            0 => println!("\nNo problems found in the time logs of the project."),
119            _ => println!(
120                "\n{number_of_problems} problems found in the time logs of the project. To see the problems, run with --validation-details",
121            ),
122        }
123        return;
124    }
125
126    println!(
127        "\
128=============================================
129      Time Logs with validation problems
130============================================="
131    );
132
133    for result in results {
134        if result.is_valid() {
135            continue;
136        }
137
138        let time_log = result.time_log;
139        let trackable_item_text = match &time_log.trackable_item.kind {
140            TrackableItemKind::Issue(_) => "Issue #",
141            TrackableItemKind::MergeRequest(_) => "Merge Request !",
142        };
143
144        println!(
145            "{trackable_item_text}{}: {}",
146            time_log.trackable_item.common.id, time_log.trackable_item.common.title,
147        );
148        println!(
149            "{}, ({}), {}: {}",
150            time_log.spent_at.date_naive(),
151            time_log.time_spent.to_hm_string(),
152            time_log.user.name,
153            time_log.summary.as_deref().unwrap_or_default()
154        );
155
156        for problem in &result.problems {
157            match problem {
158                ValidationProblem::ExcessiveHours { max_hours } => {
159                    println!("Time spent exceeds maximum of {max_hours} hours");
160                }
161                ValidationProblem::MissingSummary => println!("No summary was entered"),
162                ValidationProblem::FutureDate => println!("Date is in the future"),
163                ValidationProblem::DuplicateEntry => println!("Duplicate entry"),
164                ValidationProblem::BeforeStartDate { start_date } => println!(
165                    "Date is before project start date: {}",
166                    start_date.format("%Y-%m-%d")
167                ),
168            }
169            println!();
170        }
171    }
172    println!(
173        "\
174=============================================
175Total Problems found: {number_of_problems}
176=============================================\n",
177    );
178}
179
180/// Creates the graphs based on the time logs and the chart options.
181pub(crate) fn create_charts(
182    time_logs: &[TimeLog],
183    options: &arguments::ChartOptionsArgs,
184    selected_labels: Option<&HashSet<String>>,
185    other_label: Option<&Label>,
186    repository_name: &str,
187) -> Result<(), ChartSettingError> {
188    let burndown_options = charts::BurndownOptions::new(
189        time_logs,
190        options.weeks_per_sprint,
191        options.sprints,
192        options.hours_per_person,
193        options.start_date,
194    )?;
195    let mut render_options = charts::RenderOptions::new(
196        options.width,
197        options.height,
198        options.theme_json.as_deref(),
199        &options.output,
200        repository_name,
201    )?;
202
203    println!("Creating Bar Chart for Hours spent by Users...");
204    let by_user: BTreeMap<_, _> = filters::group_by_user(time_logs).collect();
205    charts::create_bar_chart(
206        by_user.clone(),
207        "Hours spent by Users",
208        "Users",
209        &mut render_options,
210    )?;
211
212    println!("Creating Burndown Charts...");
213    charts::create_burndown_chart(
214        time_logs,
215        &BurndownType::PerPerson,
216        &burndown_options,
217        &mut render_options,
218    )?;
219    charts::create_burndown_chart(
220        time_logs,
221        &BurndownType::Total,
222        &burndown_options,
223        &mut render_options,
224    )?;
225
226    println!("Creating Bar Chart for Hours spent by Milestones...");
227    let by_milestone = filters::group_by_milestone(time_logs).collect();
228    charts::create_bar_chart::<Milestone>(
229        by_milestone,
230        "Hours spent by Milestones",
231        "Milestones",
232        &mut render_options,
233    )?;
234
235    println!("Creating Pie Chart for Hours spent by Labels...");
236    let by_label: BTreeMap<_, _> =
237        filters::group_by_label(time_logs, selected_labels, other_label).collect();
238    charts::create_pie_chart(
239        by_label.clone(),
240        "Hours spent by Labels",
241        &mut render_options,
242    )?;
243
244    println!("Creating Bar Chart for Hours spent by Label and User...");
245    let by_label_and_user = by_label
246        .clone()
247        .into_iter()
248        .map(|(label, logs)| {
249            let time_by_label = filters::group_by_user(logs).collect();
250            (label, time_by_label)
251        })
252        .collect::<BTreeMap<Option<_>, BTreeMap<_, Vec<_>>>>();
253    charts::create_grouped_bar_chart(
254        by_label_and_user,
255        "Hours spent by Label and User",
256        50.0,
257        &mut render_options,
258    )?;
259
260    println!("Creating Bar Chart of Estimates and actual time by Labels...");
261    charts::create_estimate_chart::<Label>(
262        by_label,
263        "Estimates and actual time per Label",
264        &mut render_options,
265    )?;
266
267    println!("Creating Pie Chart for Hours spent by Issue or Merge Request...");
268    let by_type: BTreeMap<_, _> = filters::group_by_type(time_logs).collect();
269    let by_type_refs: BTreeMap<_, _> = by_type.iter().map(|(k, v)| (k, v.clone())).collect();
270    charts::create_pie_chart(
271        by_type_refs.clone(),
272        "Hours spent by Issue or Merge Request",
273        &mut render_options,
274    )?;
275
276    println!("Creating Bar Chart for Hours spent by Issue or Merge Request and User...");
277    let by_type_and_user = by_type_refs
278        .iter()
279        .map(|(kind, logs)| {
280            let time_by_label = filters::group_by_user(logs.clone()).collect();
281            (kind, time_by_label)
282        })
283        .collect::<BTreeMap<_, BTreeMap<_, Vec<_>>>>();
284    charts::create_grouped_bar_chart(
285        by_type_and_user,
286        "Hours spent by Issue or Merge Request and User",
287        0.0,
288        &mut render_options,
289    )?;
290
291    println!(
292        "Charts successfully created in directory '{}'.",
293        options.output.display()
294    );
295
296    Ok(())
297}
298
299/// Checks if a list of labels has been given.
300/// If yes, create a [`HashSet`] from the labels and return it, including an "Others" label for the
301/// rest of the labels which are not included in the set.
302pub(crate) fn create_label_options(
303    labels: Vec<String>,
304) -> (Option<HashSet<String>>, Option<Label>) {
305    let selected_labels = match labels.is_empty() {
306        true => None,
307        false => Some(HashSet::from_iter(labels)),
308    };
309
310    // Only use the "Others" label if there are labels selected.
311    let other_label = selected_labels.as_ref().map(|_| Label {
312        title: "Others".to_string(),
313    });
314
315    (selected_labels, other_label)
316}