gitlab_time_report/export/
csv.rs

1//! Contains the methods to create a CSV file from a list of time logs.
2
3use crate::model::{TimeLog, TrackableItemKind};
4use chrono::NaiveDate;
5use csv::{Writer, WriterBuilder};
6use serde::Serialize;
7use std::path::{Path, PathBuf};
8use thiserror::Error;
9
10#[cfg(test)]
11use mockall::automock;
12
13/// The columns of the CSV to be exported.
14#[derive(Serialize)]
15struct TimeLogCsvRow {
16    spent_at: NaiveDate,
17    time_spent_seconds: i64,
18    summary: Option<String>,
19    user_name: String,
20    trackable_item_title: String,
21    trackable_item_type: String,
22    trackable_item_id: u32,
23    trackable_item_estimate_seconds: i64,
24    trackable_item_total_time_spent: i64,
25    trackable_item_milestone: Option<String>,
26    trackable_item_labels: String,
27    trackable_item_assignees: String,
28    merge_request_reviewers: String,
29}
30
31/// Contains the method to abstract the writing of CSV files
32#[cfg_attr(test, automock)]
33pub trait CsvWriter {
34    fn write_csv(&self, data: &str, path: &Path) -> Result<(), CsvError>;
35}
36
37/// Default implementation that writes to actual files
38pub struct FileCsvWriter;
39
40impl CsvWriter for FileCsvWriter {
41    fn write_csv(&self, data: &str, path: &Path) -> Result<(), CsvError> {
42        Ok(std::fs::write(path, data)?)
43    }
44}
45
46/// Creates a CSV file at `path` which contains the given time logs.
47/// # Errors
48/// Possible errors can be seen in [`CsvError`].
49pub fn create_csv(time_logs: &Vec<TimeLog>, path: PathBuf) -> Result<(), CsvError> {
50    create_csv_with_writer(time_logs, path, &FileCsvWriter)
51}
52
53/// Implementation of [`create_csv()`] that takes a [`CsvWriter`] to write the file.
54fn create_csv_with_writer(
55    time_logs: &Vec<TimeLog>,
56    mut path: PathBuf,
57    writer: &impl CsvWriter,
58) -> Result<(), CsvError> {
59    if path.extension().is_none() {
60        path.set_extension("csv");
61    }
62
63    // Write to an in-memory buffer first
64    let mut buffer = WriterBuilder::new().from_writer(vec![]);
65
66    for log in time_logs {
67        let trackable_item = &log.trackable_item.common;
68
69        let milestone = trackable_item.milestone.as_ref().map(|m| m.title.clone());
70
71        let labels = trackable_item
72            .labels
73            .labels
74            .iter()
75            .map(|l| l.title.clone())
76            .collect::<Vec<_>>()
77            .join(",");
78
79        let assignees = trackable_item
80            .assignees
81            .users
82            .iter()
83            .map(|u| u.name.clone())
84            .collect::<Vec<_>>()
85            .join(",");
86
87        let mr_reviewers = match &log.trackable_item.kind {
88            TrackableItemKind::MergeRequest(mr) => mr
89                .reviewers
90                .users
91                .iter()
92                .map(|u| u.name.clone())
93                .collect::<Vec<_>>()
94                .join(","),
95            TrackableItemKind::Issue(_) => String::new(),
96        };
97
98        let csv_row = TimeLogCsvRow {
99            spent_at: log.spent_at.date_naive(),
100            time_spent_seconds: log.time_spent.num_seconds(),
101            summary: log.summary.clone(),
102            user_name: log.user.name.clone(),
103            trackable_item_title: trackable_item.title.clone(),
104            trackable_item_type: log.trackable_item.kind.to_string(),
105            trackable_item_id: trackable_item.id,
106            trackable_item_estimate_seconds: trackable_item.time_estimate.num_seconds(),
107            trackable_item_total_time_spent: trackable_item.total_time_spent.num_seconds(),
108            trackable_item_milestone: milestone,
109            trackable_item_labels: labels,
110            trackable_item_assignees: assignees,
111            merge_request_reviewers: mr_reviewers,
112        };
113        buffer.serialize(csv_row)?;
114    }
115
116    let bytes = buffer
117        .into_inner()
118        .map_err(|e| CsvError::CsvFinalize(Box::new(e)))?;
119
120    let csv_string = String::from_utf8(bytes)?;
121
122    // Use the writer to write the data to the file
123    writer.write_csv(&csv_string, &path)?;
124
125    Ok(())
126}
127
128/// Errors that can occur during CSV file creation.
129#[derive(Debug, Error)]
130pub enum CsvError {
131    /// An error has occurred when writing files to disk.
132    #[error("I/O error while writing CSV file: {0}")]
133    Io(#[from] std::io::Error),
134
135    /// An Error happened during serialization/flush from [`csv`].
136    #[error("CSV serialization error: {0}")]
137    Csv(#[from] csv::Error),
138
139    /// Error when finalizing the writer.
140    #[error("Failed to finalize CSV writer: {0}")]
141    CsvFinalize(#[from] Box<csv::IntoInnerError<Writer<Vec<u8>>>>),
142
143    /// The in-memory CSV data could not be converted to UTF-8 text.
144    #[error("CSV data is not valid UTF-8: {0}")]
145    Utf8(#[from] std::string::FromUtf8Error),
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::model::{
152        Issue, MergeRequest, TimeLog, TrackableItem, TrackableItemFields, User, UserNodes,
153    };
154    use chrono::{DateTime, Duration, Local};
155
156    fn get_timelogs() -> Vec<TimeLog> {
157        vec![
158            TimeLog {
159                spent_at: "2025-10-14T10:00:00+01:00"
160                    .parse::<DateTime<Local>>()
161                    .unwrap(),
162                time_spent: Duration::seconds(3600),
163                summary: Some("Timelog 1 Summary".to_string()),
164                user: User {
165                    name: "User 1".to_string(),
166                    username: String::default(),
167                },
168                trackable_item: TrackableItem {
169                    common: TrackableItemFields {
170                        id: 1,
171                        title: "Issue Title".to_string(),
172                        time_estimate: Duration::seconds(4200),
173                        total_time_spent: Duration::seconds(3600),
174                        ..Default::default()
175                    },
176                    kind: TrackableItemKind::Issue(Issue::default()),
177                },
178            },
179            TimeLog {
180                spent_at: "2025-10-13T14:30:00+01:00"
181                    .parse::<DateTime<Local>>()
182                    .unwrap(),
183                time_spent: Duration::seconds(3600),
184                summary: Some("Timelog 2 Summary".to_string()),
185                user: User {
186                    name: "User 2".to_string(),
187                    username: String::default(),
188                },
189                trackable_item: TrackableItem {
190                    common: TrackableItemFields {
191                        id: 2,
192                        title: "MR Title".to_string(),
193                        time_estimate: Duration::seconds(2700),
194                        total_time_spent: Duration::seconds(3600),
195                        ..Default::default()
196                    },
197                    kind: TrackableItemKind::MergeRequest(MergeRequest {
198                        reviewers: UserNodes { users: vec![] },
199                    }),
200                },
201            },
202        ]
203    }
204
205    #[test]
206    fn test_create_csv_mocked() {
207        let path = PathBuf::from("test");
208        let mut mock_writer = MockCsvWriter::new();
209
210        mock_writer
211            .expect_write_csv()
212            .times(1)
213            .returning(|data, path| {
214                assert_eq!(path, Path::new("test.csv"));
215                assert!(data.contains("spent_at,time_spent_seconds"));
216                assert!(data.contains("Timelog 1 Summary"));
217                assert!(data.contains("User 1"));
218                Ok(())
219            });
220
221        let time_logs = get_timelogs();
222
223        let result = create_csv_with_writer(&time_logs, path, &mock_writer);
224        assert!(result.is_ok());
225    }
226}