gitlab_time_report/export/
csv.rs1use 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#[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#[cfg_attr(test, automock)]
33pub trait CsvWriter {
34 fn write_csv(&self, data: &str, path: &Path) -> Result<(), CsvError>;
35}
36
37pub 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
46pub fn create_csv(time_logs: &Vec<TimeLog>, path: PathBuf) -> Result<(), CsvError> {
50 create_csv_with_writer(time_logs, path, &FileCsvWriter)
51}
52
53fn 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 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 writer.write_csv(&csv_string, &path)?;
124
125 Ok(())
126}
127
128#[derive(Debug, Error)]
130pub enum CsvError {
131 #[error("I/O error while writing CSV file: {0}")]
133 Io(#[from] std::io::Error),
134
135 #[error("CSV serialization error: {0}")]
137 Csv(#[from] csv::Error),
138
139 #[error("Failed to finalize CSV writer: {0}")]
141 CsvFinalize(#[from] Box<csv::IntoInnerError<Writer<Vec<u8>>>>),
142
143 #[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}