1use std::path::Path;
6
7use time::format_description::FormatItem;
8use time::macros::format_description;
9use time::OffsetDateTime;
10
11use crate::error::StintError;
12use crate::models::entry::{EntrySource, TimeEntry};
13use crate::models::project::{Project, ProjectSource, ProjectStatus};
14use crate::models::types::{EntryId, ProjectId};
15use crate::storage::Storage;
16
17const DATETIME_FMT: &[FormatItem<'static>] =
19 format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
20
21#[derive(Debug)]
23pub struct ImportResult {
24 pub entries_imported: usize,
26 pub projects_created: usize,
28 pub rows_skipped: usize,
30}
31
32pub fn import_csv(storage: &impl Storage, path: &Path) -> Result<ImportResult, StintError> {
37 let contents = std::fs::read_to_string(path)
38 .map_err(|e| StintError::InvalidInput(format!("failed to read {}: {e}", path.display())))?;
39
40 let mut lines = contents.lines();
41 let header = lines
42 .next()
43 .ok_or_else(|| StintError::InvalidInput("CSV file is empty".to_string()))?;
44
45 let columns: Vec<String> = header.split(',').map(|c| c.trim().to_lowercase()).collect();
46
47 let col_idx = |name: &str| -> Option<usize> { columns.iter().position(|c| c == name) };
48
49 let project_col = col_idx("project")
50 .ok_or_else(|| StintError::InvalidInput("CSV missing 'project' column".to_string()))?;
51
52 let start_col = col_idx("start")
53 .ok_or_else(|| StintError::InvalidInput("CSV missing 'start' column".to_string()))?;
54 let start_col = Some(start_col); let end_col = col_idx("end");
56 let duration_col = col_idx("duration_secs").or_else(|| col_idx("duration"));
57 let notes_col = col_idx("notes").or_else(|| col_idx("description"));
58
59 let now = OffsetDateTime::now_utc();
60 let mut result = ImportResult {
61 entries_imported: 0,
62 projects_created: 0,
63 rows_skipped: 0,
64 };
65
66 for line in lines {
67 if line.trim().is_empty() {
68 continue;
69 }
70
71 let fields = split_csv_line(line);
72
73 let project_name = match fields.get(project_col) {
74 Some(name) if !name.is_empty() => name.as_str(),
75 _ => {
76 result.rows_skipped += 1;
77 continue;
78 }
79 };
80
81 let start = match start_col
85 .and_then(|i| fields.get(i))
86 .and_then(|s| parse_datetime(s))
87 {
88 Some(dt) => dt,
89 None => {
90 result.rows_skipped += 1;
91 continue;
92 }
93 };
94
95 let end = end_col
97 .and_then(|i| fields.get(i))
98 .and_then(|s| parse_datetime(s));
99
100 let duration_secs = duration_col
102 .and_then(|i| fields.get(i))
103 .and_then(|s| s.parse::<i64>().ok())
104 .filter(|&d| d >= 0)
105 .or_else(|| end.map(|e| (e - start).whole_seconds()))
106 .filter(|&d| d >= 0);
107
108 let end = end.or_else(|| duration_secs.map(|d| start + time::Duration::seconds(d)));
110
111 match end {
113 Some(e) if e >= start => {}
114 _ => {
115 result.rows_skipped += 1;
116 continue;
117 }
118 }
119
120 let notes = notes_col
122 .and_then(|i| fields.get(i))
123 .map(|s| s.to_string())
124 .filter(|s| !s.is_empty());
125
126 let project = match storage.get_project_by_name(project_name)? {
128 Some(p) => p,
129 None => {
130 let p = Project {
131 id: ProjectId::new(),
132 name: project_name.to_string(),
133 paths: vec![],
134 tags: vec![],
135 hourly_rate_cents: None,
136 status: ProjectStatus::Active,
137 source: ProjectSource::Manual,
138 created_at: now,
139 updated_at: now,
140 };
141 storage.create_project(&p)?;
142 result.projects_created += 1;
143 p
144 }
145 };
146
147 let entry = TimeEntry {
148 id: EntryId::new(),
149 project_id: project.id.clone(),
150 session_id: None,
151 start,
152 end,
153 duration_secs,
154 source: EntrySource::Added,
155 notes,
156 tags: vec![],
157 created_at: now,
158 updated_at: now,
159 };
160
161 match storage.create_entry(&entry) {
162 Ok(()) => result.entries_imported += 1,
163 Err(crate::storage::error::StorageError::Database(ref e))
164 if e.to_string().contains("UNIQUE constraint") =>
165 {
166 result.rows_skipped += 1;
168 }
169 Err(e) => return Err(e.into()), }
171 }
172
173 Ok(result)
174}
175
176fn split_csv_line(line: &str) -> Vec<String> {
178 let mut fields = Vec::new();
179 let mut current = String::new();
180 let mut in_quotes = false;
181 let mut chars = line.chars().peekable();
182
183 while let Some(ch) = chars.next() {
184 match ch {
185 '"' if in_quotes => {
186 if chars.peek() == Some(&'"') {
187 current.push('"');
188 chars.next();
189 } else {
190 in_quotes = false;
191 }
192 }
193 '"' if !in_quotes && current.is_empty() => {
194 in_quotes = true;
195 }
196 ',' if !in_quotes => {
197 fields.push(current.trim().to_string());
198 current = String::new();
199 }
200 _ => current.push(ch),
201 }
202 }
203 fields.push(current.trim().to_string());
204 fields
205}
206
207fn parse_datetime(s: &str) -> Option<OffsetDateTime> {
209 if let Ok(dt) = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) {
211 return Some(dt);
212 }
213 if let Ok(pdt) = time::PrimitiveDateTime::parse(s, DATETIME_FMT) {
215 return Some(pdt.assume_utc());
216 }
217 let date_fmt: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]");
219 if let Ok(d) = time::Date::parse(s, date_fmt) {
220 return Some(d.midnight().assume_utc());
221 }
222 None
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::storage::sqlite::SqliteStorage;
229 use std::io::Write;
230 use tempfile::NamedTempFile;
231
232 fn setup() -> SqliteStorage {
233 SqliteStorage::open_in_memory().unwrap()
234 }
235
236 #[test]
237 fn import_basic_csv() {
238 let storage = setup();
239 let mut file = NamedTempFile::new().unwrap();
240 writeln!(
241 file,
242 "project,start,end,duration_secs,notes\nmy-app,2026-01-15 09:00:00,2026-01-15 10:30:00,5400,Morning work"
243 )
244 .unwrap();
245
246 let result = import_csv(&storage, file.path()).unwrap();
247 assert_eq!(result.entries_imported, 1);
248 assert_eq!(result.projects_created, 1);
249 assert_eq!(result.rows_skipped, 0);
250
251 let project = storage.get_project_by_name("my-app").unwrap().unwrap();
252 assert_eq!(project.name, "my-app");
253 }
254
255 #[test]
256 fn import_creates_projects_as_needed() {
257 let storage = setup();
258 let mut file = NamedTempFile::new().unwrap();
259 writeln!(
260 file,
261 "project,start,end,duration_secs\napp-1,2026-01-01 09:00:00,2026-01-01 10:00:00,3600\napp-2,2026-01-01 11:00:00,2026-01-01 11:30:00,1800\napp-1,2026-01-01 14:00:00,2026-01-01 14:15:00,900"
262 )
263 .unwrap();
264
265 let result = import_csv(&storage, file.path()).unwrap();
266 assert_eq!(result.entries_imported, 3);
267 assert_eq!(result.projects_created, 2);
268 }
269
270 #[test]
271 fn import_skips_empty_project() {
272 let storage = setup();
273 let mut file = NamedTempFile::new().unwrap();
274 writeln!(file, "project,start,duration_secs\n,2026-01-01 09:00:00,3600\nmy-app,2026-01-01 10:00:00,1800").unwrap();
275
276 let result = import_csv(&storage, file.path()).unwrap();
277 assert_eq!(result.entries_imported, 1);
278 assert_eq!(result.rows_skipped, 1);
279 }
280
281 #[test]
282 fn import_empty_file_errors() {
283 let storage = setup();
284 let file = NamedTempFile::new().unwrap();
285
286 let result = import_csv(&storage, file.path());
287 assert!(result.is_err());
288 }
289
290 #[test]
291 fn parse_datetime_formats() {
292 assert!(parse_datetime("2026-01-15 09:00:00").is_some());
293 assert!(parse_datetime("2026-01-15T09:00:00Z").is_some());
294 assert!(parse_datetime("2026-01-15").is_some());
295 assert!(parse_datetime("garbage").is_none());
296 }
297}