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        }
88    }
89    Ok(())
90}
91
92/// Runs validation on the entered time logs with all validators and outputs the result to the console.
93pub(crate) fn validate_time_logs(
94    time_logs: &[TimeLog],
95    show_validation_details: bool,
96    start_date: Option<chrono::NaiveDate>,
97    max_hours: u16,
98) {
99    let mut validator = TimeLogValidator::new()
100        .with_validator(gitlab_time_report::validation::ExcessiveHoursValidator::new(max_hours))
101        .with_validator(gitlab_time_report::validation::HasSummaryValidator)
102        .with_validator(gitlab_time_report::validation::NoFutureDateValidator)
103        .with_validator(gitlab_time_report::validation::DuplicatesValidator::new());
104
105    if let Some(start_date) = start_date {
106        validator = validator.with_validator(
107            gitlab_time_report::validation::BeforeStartDateValidator::new(start_date),
108        );
109    }
110
111    let results = validator.validate(time_logs);
112    let number_of_problems = results.iter().filter(|r| !r.is_valid()).count();
113
114    // Print summary and return when detailed listing is not desired
115    if !show_validation_details {
116        match number_of_problems {
117            0 => println!("\nNo problems found in the time logs of the project."),
118            _ => println!(
119                "\n{number_of_problems} problems found in the time logs of the project. To see the problems, run with --validation-details",
120            ),
121        }
122        return;
123    }
124
125    println!(
126        "\
127=============================================
128      Time Logs with validation problems
129============================================="
130    );
131
132    for result in results {
133        if result.is_valid() {
134            continue;
135        }
136
137        let time_log = result.time_log;
138        let trackable_item_text = match &time_log.trackable_item.kind {
139            TrackableItemKind::Issue(_) => "Issue #",
140            TrackableItemKind::MergeRequest(_) => "Merge Request !",
141        };
142
143        println!(
144            "{trackable_item_text}{}: {}",
145            time_log.trackable_item.common.id, time_log.trackable_item.common.title,
146        );
147        println!(
148            "{}, ({}), {}: {}",
149            time_log.spent_at.date_naive(),
150            time_log.time_spent.to_hm_string(),
151            time_log.user.name,
152            time_log.summary.as_deref().unwrap_or_default()
153        );
154
155        for problem in &result.problems {
156            match problem {
157                ValidationProblem::ExcessiveHours { max_hours } => {
158                    println!("Time spent exceeds maximum of {max_hours} hours");
159                }
160                ValidationProblem::MissingSummary => println!("No summary was entered"),
161                ValidationProblem::FutureDate => println!("Date is in the future"),
162                ValidationProblem::DuplicateEntry => println!("Duplicate entry"),
163                ValidationProblem::BeforeStartDate { start_date } => println!(
164                    "Date is before project start date: {}",
165                    start_date.format("%Y-%m-%d")
166                ),
167            }
168            println!();
169        }
170    }
171    println!(
172        "\
173=============================================
174Total Problems found: {number_of_problems}
175=============================================\n",
176    );
177}
178
179/// Creates the graphs based on the time logs and the chart options.
180pub(crate) fn create_charts(
181    time_logs: &[TimeLog],
182    options: &arguments::ChartOptionsArgs,
183    selected_labels: Option<&HashSet<String>>,
184    other_label: Option<&Label>,
185    repository_name: &str,
186) -> Result<(), ChartSettingError> {
187    let burndown_options = charts::BurndownOptions::new(
188        time_logs,
189        options.weeks_per_sprint,
190        options.sprints,
191        options.hours_per_person,
192        options.start_date,
193    )?;
194    let mut render_options = charts::RenderOptions::new(
195        options.width,
196        options.height,
197        options.theme_json.as_deref(),
198        &options.output,
199        repository_name,
200    )?;
201
202    println!("Creating Bar Chart for Hours spent by Users...");
203    let by_user: BTreeMap<_, _> = filters::group_by_user(time_logs).collect();
204    charts::create_bar_chart(
205        by_user.clone(),
206        "Hours spent by Users",
207        "Users",
208        &mut render_options,
209    )?;
210
211    println!("Creating Burndown Charts...");
212    charts::create_burndown_chart(
213        time_logs,
214        &BurndownType::PerPerson,
215        &burndown_options,
216        &mut render_options,
217    )?;
218    charts::create_burndown_chart(
219        time_logs,
220        &BurndownType::Total,
221        &burndown_options,
222        &mut render_options,
223    )?;
224
225    println!("Creating Bar Chart for Hours spent by Milestones...");
226    let by_milestone = filters::group_by_milestone(time_logs).collect();
227    charts::create_bar_chart::<Milestone>(
228        by_milestone,
229        "Hours spent by Milestones",
230        "Milestones",
231        &mut render_options,
232    )?;
233
234    println!("Creating Pie Chart for Hours spent by Labels...");
235    let by_label: BTreeMap<_, _> =
236        filters::group_by_label(time_logs, selected_labels, other_label).collect();
237    charts::create_pie_chart(
238        by_label.clone(),
239        "Hours spent by Labels",
240        &mut render_options,
241    )?;
242
243    println!("Creating Bar Chart for Hours spent by Label and User...");
244    let by_label_and_user = by_label
245        .clone()
246        .into_iter()
247        .map(|(label, logs)| {
248            let time_by_label = filters::group_by_user(logs).collect();
249            (label, time_by_label)
250        })
251        .collect::<BTreeMap<Option<_>, BTreeMap<_, Vec<_>>>>();
252    charts::create_grouped_bar_chart(
253        by_label_and_user,
254        "Hours spent by Label and User",
255        50.0,
256        &mut render_options,
257    )?;
258
259    println!("Creating Bar Chart of Estimates and actual time by Labels...");
260    charts::create_estimate_chart::<Label>(
261        by_label,
262        "Estimates and actual time per Label",
263        &mut render_options,
264    )?;
265
266    println!("Creating Pie Chart for Hours spent by Issue or Merge Request...");
267    let by_type: BTreeMap<_, _> = filters::group_by_type(time_logs).collect();
268    let by_type_refs: BTreeMap<_, _> = by_type.iter().map(|(k, v)| (k, v.clone())).collect();
269    charts::create_pie_chart(
270        by_type_refs.clone(),
271        "Hours spent by Issue or Merge Request",
272        &mut render_options,
273    )?;
274
275    println!("Creating Bar Chart for Hours spent by Issue or Merge Request and User...");
276    let by_type_and_user = by_type_refs
277        .iter()
278        .map(|(kind, logs)| {
279            let time_by_label = filters::group_by_user(logs.clone()).collect();
280            (kind, time_by_label)
281        })
282        .collect::<BTreeMap<_, BTreeMap<_, Vec<_>>>>();
283    charts::create_grouped_bar_chart(
284        by_type_and_user,
285        "Hours spent by Issue or Merge Request and User",
286        0.0,
287        &mut render_options,
288    )?;
289
290    println!(
291        "Charts successfully created in directory '{}'.",
292        options.output.display()
293    );
294
295    Ok(())
296}
297
298/// Checks if a list of labels has been given.
299/// If yes, create a [`HashSet`] from the labels and return it, including an "Others" label for the
300/// rest of the labels which are not included in the set.
301pub(crate) fn create_label_options(
302    labels: Vec<String>,
303) -> (Option<HashSet<String>>, Option<Label>) {
304    let selected_labels = match labels.is_empty() {
305        true => None,
306        false => Some(HashSet::from_iter(labels)),
307    };
308
309    // Only use the "Others" label if there are labels selected.
310    let other_label = selected_labels.as_ref().map(|_| Label {
311        title: "Others".to_string(),
312    });
313
314    (selected_labels, other_label)
315}