Skip to main content

stint_core/
import.rs

1//! One-time data import from CSV files.
2//!
3//! Supports generic CSV import and Toggl/Clockify export formats.
4
5use 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
17/// ISO datetime format for CSV parsing: YYYY-MM-DD HH:MM:SS.
18const DATETIME_FMT: &[FormatItem<'static>] =
19    format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
20
21/// Result of an import operation.
22#[derive(Debug)]
23pub struct ImportResult {
24    /// Number of entries imported.
25    pub entries_imported: usize,
26    /// Number of projects created.
27    pub projects_created: usize,
28    /// Number of rows skipped (errors or duplicates).
29    pub rows_skipped: usize,
30}
31
32/// Imports time entries from a generic CSV file.
33///
34/// Expected columns: project, start, end, duration_secs, notes
35/// (header row required). Missing optional fields are treated as empty.
36pub 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); // Keep as Option for consistent field access
55    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        // Validate row data BEFORE creating any project
82
83        // Parse start time (skip row if missing/unparseable)
84        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        // Parse end time
96        let end = end_col
97            .and_then(|i| fields.get(i))
98            .and_then(|s| parse_datetime(s));
99
100        // Parse duration (reject negative values)
101        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        // Ensure end is set (imported entries should always be completed)
109        let end = end.or_else(|| duration_secs.map(|d| start + time::Duration::seconds(d)));
110
111        // Skip rows that can't produce a completed entry or have inverted ranges
112        match end {
113            Some(e) if e >= start => {}
114            _ => {
115                result.rows_skipped += 1;
116                continue;
117            }
118        }
119
120        // Parse notes
121        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        // Row is valid — now find or create the project
127        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                // Duplicate entry (e.g., unique running-per-project constraint) — skip
167                result.rows_skipped += 1;
168            }
169            Err(e) => return Err(e.into()), // Real storage failure — abort
170        }
171    }
172
173    Ok(result)
174}
175
176/// Splits a CSV line respecting quoted fields (RFC 4180).
177fn 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
207/// Parses a datetime string in common formats.
208fn parse_datetime(s: &str) -> Option<OffsetDateTime> {
209    // Try ISO 8601 / RFC 3339
210    if let Ok(dt) = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) {
211        return Some(dt);
212    }
213    // Try YYYY-MM-DD HH:MM:SS
214    if let Ok(pdt) = time::PrimitiveDateTime::parse(s, DATETIME_FMT) {
215        return Some(pdt.assume_utc());
216    }
217    // Try date only: YYYY-MM-DD
218    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}