1use crate::models::{DayData, TimePoint, WorkRecord};
2use crate::timer::{TimerState, TimerStatus};
3use anyhow::{Context, Result, anyhow};
4use rusqlite::{Connection, OptionalExtension, Transaction, params};
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{Duration as StdDuration, SystemTime};
9use time::format_description::well_known::Rfc3339;
10use time::macros::format_description;
11use time::{Date, OffsetDateTime};
12
13const DATABASE_FILE_NAME: &str = "work-tuimer.db";
14const LEGACY_RUNNING_TIMER_FILE_NAME: &str = "running_timer.json";
15const LEGACY_ACTIVE_TIMER_FILE_NAME: &str = "active_timer.json";
16const JSON_MIGRATION_META_KEY: &str = "migration.json_to_sqlite.v1";
17
18#[derive(Clone)]
19pub struct Storage {
20 repository: SqliteRepository,
21}
22
23#[derive(Clone)]
24struct SqliteRepository {
25 db_path: PathBuf,
26}
27
28pub struct StorageManager {
31 storage: Storage,
32 file_modified_times: HashMap<Date, Option<SystemTime>>,
33}
34
35pub struct StorageDiagnostics {
36 pub database_path: PathBuf,
37 pub migration_marker: Option<String>,
38 pub days_count: u64,
39 pub work_records_count: u64,
40 pub active_timer_present: bool,
41 pub legacy_day_json_files: u64,
42 pub legacy_timer_json_files: u64,
43}
44
45impl StorageManager {
46 pub fn new() -> Result<Self> {
48 Ok(StorageManager {
49 storage: Storage::new()?,
50 file_modified_times: HashMap::new(),
51 })
52 }
53
54 #[doc(hidden)]
56 #[allow(dead_code)]
57 pub fn new_with_dir(data_dir: PathBuf) -> Result<Self> {
58 Ok(StorageManager {
59 storage: Storage::new_with_dir(data_dir)?,
60 file_modified_times: HashMap::new(),
61 })
62 }
63
64 pub fn load_with_tracking(&mut self, date: Date) -> Result<DayData> {
66 let data = self.storage.load(&date)?;
67 let modified_time = self.storage.try_get_file_modified_time(&date)?;
68 self.file_modified_times.insert(date, modified_time);
69 Ok(data)
70 }
71
72 pub fn check_and_reload(&mut self, date: Date) -> Result<Option<DayData>> {
75 let current_modified = self.storage.try_get_file_modified_time(&date)?;
76
77 let is_tracked = self.file_modified_times.contains_key(&date);
78 if !is_tracked {
79 let data = self.storage.load(&date)?;
80 self.file_modified_times.insert(date, current_modified);
81 return Ok(Some(data));
82 }
83
84 let last_known = self.file_modified_times.get(&date).copied().flatten();
85 if current_modified != last_known {
88 let data = self.storage.load(&date)?;
89 self.file_modified_times.insert(date, current_modified);
90 Ok(Some(data))
91 } else {
92 Ok(None)
93 }
94 }
95
96 #[allow(dead_code)]
98 pub fn add_record(&mut self, date: Date, record: WorkRecord) -> Result<()> {
99 let mut day_data = self.storage.load(&date)?;
100 day_data.add_record(record);
101 self.storage.save(&day_data)?;
102
103 let modified_time = self.storage.try_get_file_modified_time(&date)?;
104 self.file_modified_times.insert(date, modified_time);
105
106 Ok(())
107 }
108
109 #[allow(dead_code)]
111 pub fn update_record(&mut self, date: Date, record: WorkRecord) -> Result<()> {
112 let mut day_data = self.storage.load(&date)?;
113 day_data.add_record(record);
114 self.storage.save(&day_data)?;
115
116 let modified_time = self.storage.try_get_file_modified_time(&date)?;
117 self.file_modified_times.insert(date, modified_time);
118
119 Ok(())
120 }
121
122 #[allow(dead_code)]
125 pub fn remove_record(&mut self, date: Date, id: u32) -> Result<WorkRecord> {
126 let mut day_data = self.storage.load(&date)?;
127
128 let record = day_data
129 .work_records
130 .remove(&id)
131 .context(format!("Record with ID {} not found", id))?;
132
133 self.storage.save(&day_data)?;
134
135 let modified_time = self.storage.try_get_file_modified_time(&date)?;
136 self.file_modified_times.insert(date, modified_time);
137
138 Ok(record)
139 }
140
141 pub fn save(&mut self, day_data: &DayData) -> Result<()> {
143 self.storage.save(day_data)?;
144
145 let modified_time = self.storage.try_get_file_modified_time(&day_data.date)?;
146 self.file_modified_times
147 .insert(day_data.date, modified_time);
148
149 Ok(())
150 }
151
152 pub fn get_last_modified(&self, date: &Date) -> Option<SystemTime> {
154 self.file_modified_times.get(date).copied().flatten()
155 }
156
157 #[allow(dead_code)]
158 pub fn save_active_timer(&self, timer: &TimerState) -> Result<()> {
159 self.storage.save_active_timer(timer)
160 }
161
162 pub fn load_active_timer(&self) -> Result<Option<TimerState>> {
163 self.storage.load_active_timer()
164 }
165
166 #[allow(dead_code)]
167 pub fn clear_active_timer(&self) -> Result<()> {
168 self.storage.clear_active_timer()
169 }
170
171 fn create_timer_manager(&self) -> crate::timer::TimerManager {
172 crate::timer::TimerManager::new(self.storage.clone())
173 }
174
175 pub fn start_timer(
176 &self,
177 task_name: String,
178 description: Option<String>,
179 project: Option<String>,
180 customer: Option<String>,
181 source_record_id: Option<u32>,
182 source_record_date: Option<time::Date>,
183 ) -> Result<TimerState> {
184 let timer_manager = self.create_timer_manager();
185 timer_manager.start(
186 task_name,
187 description,
188 project,
189 customer,
190 source_record_id,
191 source_record_date,
192 )
193 }
194
195 pub fn stop_timer(&self) -> Result<crate::models::WorkRecord> {
196 let timer_manager = self.create_timer_manager();
197 timer_manager.stop()
198 }
199
200 pub fn pause_timer(&self) -> Result<TimerState> {
201 let timer_manager = self.create_timer_manager();
202 timer_manager.pause()
203 }
204
205 pub fn resume_timer(&self) -> Result<TimerState> {
206 let timer_manager = self.create_timer_manager();
207 timer_manager.resume()
208 }
209
210 #[allow(dead_code)]
211 pub fn get_timer_elapsed(&self, timer: &TimerState) -> std::time::Duration {
212 let timer_manager = self.create_timer_manager();
213 timer_manager.get_elapsed_duration(timer)
214 }
215}
216
217impl Storage {
218 pub fn new() -> Result<Self> {
219 let data_dir = Self::get_data_directory()?;
220 Self::new_with_dir(data_dir)
221 }
222
223 #[doc(hidden)]
225 #[allow(dead_code)]
226 pub fn new_with_dir(data_dir: PathBuf) -> Result<Self> {
227 fs::create_dir_all(&data_dir).context("Failed to create data directory")?;
228
229 let db_path = data_dir.join(DATABASE_FILE_NAME);
230 let repository = SqliteRepository::new(db_path);
231 repository.initialize(&data_dir)?;
232
233 Ok(Storage { repository })
234 }
235
236 fn get_data_directory() -> Result<PathBuf> {
237 if let Some(data_dir) = dirs::data_local_dir() {
238 let app_dir = data_dir.join("work-tuimer");
239 if fs::create_dir_all(&app_dir).is_ok() {
240 return Ok(app_dir);
241 }
242 }
243
244 let local_data = PathBuf::from("./data");
245 if fs::create_dir_all(&local_data).is_ok() {
246 return Ok(local_data);
247 }
248
249 anyhow::bail!("Failed to create data directory in system location or ./data")
250 }
251
252 pub fn load(&self, date: &Date) -> Result<DayData> {
253 self.repository.load_day(date)
254 }
255
256 pub fn save(&self, day_data: &DayData) -> Result<()> {
257 self.repository.save_day(day_data)
258 }
259
260 #[allow(dead_code)]
266 pub fn get_file_modified_time(&self, date: &Date) -> Option<SystemTime> {
267 match self.try_get_file_modified_time(date) {
268 Ok(token) => token,
269 Err(err) => {
270 eprintln!(
271 "Failed to read day revision token for {} from storage: {err:#}",
272 date
273 );
274 None
275 }
276 }
277 }
278
279 pub fn try_get_file_modified_time(&self, date: &Date) -> Result<Option<SystemTime>> {
280 self.repository.day_revision_token(date)
281 }
282
283 pub fn save_active_timer(&self, timer: &TimerState) -> Result<()> {
284 self.repository.save_active_timer(timer)
285 }
286
287 pub fn load_active_timer(&self) -> Result<Option<TimerState>> {
288 self.repository.load_active_timer()
289 }
290
291 pub fn clear_active_timer(&self) -> Result<()> {
292 self.repository.clear_active_timer()
293 }
294
295 pub fn diagnostics(&self) -> Result<StorageDiagnostics> {
296 self.repository.diagnostics()
297 }
298
299 pub fn list_dates_with_records(&self) -> Result<Vec<Date>> {
300 self.repository.list_dates_with_records()
301 }
302
303 #[cfg(test)]
304 fn get_db_path(&self) -> PathBuf {
305 self.repository.db_path.clone()
306 }
307}
308
309impl SqliteRepository {
310 fn new(db_path: PathBuf) -> Self {
311 Self { db_path }
312 }
313
314 fn initialize(&self, data_dir: &Path) -> Result<()> {
315 let conn = self.open_connection()?;
316 Self::apply_database_pragmas(&conn)?;
317 Self::initialize_schema(&conn)?;
318 Self::apply_schema_migrations(&conn)?;
319 drop(conn);
320
321 self
322 .migrate_from_legacy_json_if_needed(data_dir)
323 .context("Failed to migrate legacy JSON into SQLite. Fix or remove malformed legacy JSON files and restart")
324 }
325
326 fn open_connection(&self) -> Result<Connection> {
327 let conn = Connection::open(&self.db_path).context(format!(
328 "Failed to open SQLite database at {:?}",
329 self.db_path
330 ))?;
331 conn.execute_batch("PRAGMA foreign_keys = ON;")
332 .context("Failed to enable SQLite foreign key support")?;
333 Ok(conn)
334 }
335
336 fn apply_database_pragmas(conn: &Connection) -> Result<()> {
337 conn.execute_batch(
338 "
339 PRAGMA journal_mode = WAL;
340 PRAGMA synchronous = NORMAL;
341 ",
342 )
343 .context("Failed to configure SQLite pragmas")?;
344 Ok(())
345 }
346
347 fn initialize_schema(conn: &Connection) -> Result<()> {
348 conn.execute_batch(
349 "
350 CREATE TABLE IF NOT EXISTS meta (
351 key TEXT PRIMARY KEY,
352 value TEXT NOT NULL
353 );
354
355 CREATE TABLE IF NOT EXISTS day_meta (
356 date TEXT PRIMARY KEY,
357 last_id INTEGER NOT NULL DEFAULT 0 CHECK (last_id >= 0),
358 revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0)
359 );
360
361 CREATE TABLE IF NOT EXISTS work_records (
362 date TEXT NOT NULL,
363 id INTEGER NOT NULL,
364 name TEXT NOT NULL,
365 start_minutes INTEGER NOT NULL,
366 end_minutes INTEGER NOT NULL,
367 total_minutes INTEGER NOT NULL,
368 project TEXT NOT NULL DEFAULT '',
369 customer TEXT NOT NULL DEFAULT '',
370 description TEXT NOT NULL DEFAULT '',
371 PRIMARY KEY (date, id),
372 FOREIGN KEY (date) REFERENCES day_meta(date) ON DELETE CASCADE
373 );
374
375 CREATE INDEX IF NOT EXISTS idx_work_records_date_start
376 ON work_records(date, start_minutes);
377
378 CREATE TABLE IF NOT EXISTS active_timer (
379 singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
380 id INTEGER,
381 task_name TEXT NOT NULL,
382 description TEXT,
383 project TEXT,
384 customer TEXT,
385 start_time TEXT NOT NULL,
386 end_time TEXT,
387 date TEXT NOT NULL,
388 status TEXT NOT NULL CHECK (status IN ('running', 'paused', 'stopped')),
389 paused_duration_secs INTEGER NOT NULL CHECK (paused_duration_secs >= 0),
390 paused_at TEXT,
391 created_at TEXT NOT NULL,
392 updated_at TEXT NOT NULL,
393 source_record_id INTEGER,
394 source_record_date TEXT
395 );
396 ",
397 )
398 .context("Failed to initialize SQLite schema")?;
399
400 Ok(())
401 }
402
403 fn apply_schema_migrations(conn: &Connection) -> Result<()> {
404 Self::ensure_column_exists(conn, "work_records", "project", "TEXT NOT NULL DEFAULT ''")?;
405 Self::ensure_column_exists(conn, "work_records", "customer", "TEXT NOT NULL DEFAULT ''")?;
406 Self::ensure_column_exists(conn, "active_timer", "project", "TEXT")?;
407 Self::ensure_column_exists(conn, "active_timer", "customer", "TEXT")?;
408 Ok(())
409 }
410
411 fn ensure_column_exists(
412 conn: &Connection,
413 table: &str,
414 column: &str,
415 definition: &str,
416 ) -> Result<()> {
417 if Self::has_column(conn, table, column)? {
418 return Ok(());
419 }
420
421 let alter_sql = format!("ALTER TABLE {table} ADD COLUMN {column} {definition}");
422 conn.execute(&alter_sql, [])
423 .context(format!("Failed to add column {column} to {table}"))?;
424 Ok(())
425 }
426
427 fn has_column(conn: &Connection, table: &str, column: &str) -> Result<bool> {
428 let pragma_sql = format!("PRAGMA table_info({table})");
429 let mut stmt = conn
430 .prepare(&pragma_sql)
431 .context(format!("Failed to inspect table schema for {table}"))?;
432 let mut rows = stmt.query([]).context("Failed to query table_info")?;
433
434 while let Some(row) = rows.next().context("Failed to read table_info row")? {
435 let column_name = row.get::<_, String>(1)?;
436 if column_name == column {
437 return Ok(true);
438 }
439 }
440
441 Ok(false)
442 }
443
444 fn load_day(&self, date: &Date) -> Result<DayData> {
445 let conn = self.open_connection()?;
446 let date_key = format_date(*date);
447
448 let last_id_opt = conn
449 .query_row(
450 "SELECT last_id FROM day_meta WHERE date = ?1",
451 params![date_key.clone()],
452 |row| row.get::<_, i64>(0),
453 )
454 .optional()
455 .context("Failed to query day metadata")?;
456
457 let Some(last_id_raw) = last_id_opt else {
458 return Ok(DayData::new(*date));
459 };
460
461 let last_id = i64_to_u32(last_id_raw, "day_meta.last_id")?;
462
463 let mut day_data = DayData {
464 date: *date,
465 last_id,
466 work_records: HashMap::new(),
467 };
468
469 let mut stmt = conn
470 .prepare(
471 "
472 SELECT id, name, start_minutes, end_minutes, total_minutes, project, customer, description
473 FROM work_records
474 WHERE date = ?1
475 ORDER BY id
476 ",
477 )
478 .context("Failed to prepare work records query")?;
479
480 let mut rows = stmt
481 .query(params![date_key])
482 .context("Failed to query work records")?;
483
484 while let Some(row) = rows.next().context("Failed to read work record row")? {
485 let id = i64_to_u32(row.get::<_, i64>(0)?, "work_records.id")?;
486 let name = row.get::<_, String>(1)?;
487 let start_minutes = i64_to_u32(row.get::<_, i64>(2)?, "work_records.start_minutes")?;
488 let end_minutes = i64_to_u32(row.get::<_, i64>(3)?, "work_records.end_minutes")?;
489 let total_minutes = i64_to_u32(row.get::<_, i64>(4)?, "work_records.total_minutes")?;
490 let project = row.get::<_, Option<String>>(5)?.unwrap_or_default();
491 let customer = row.get::<_, Option<String>>(6)?.unwrap_or_default();
492 let description = row.get::<_, Option<String>>(7)?.unwrap_or_default();
493
494 let start = TimePoint::from_minutes_since_midnight(start_minutes)
495 .map_err(|e| anyhow!(e))
496 .context("Invalid start_minutes value in database")?;
497 let end = TimePoint::from_minutes_since_midnight(end_minutes)
498 .map_err(|e| anyhow!(e))
499 .context("Invalid end_minutes value in database")?;
500
501 let record = WorkRecord {
502 id,
503 name,
504 start,
505 end,
506 total_minutes,
507 project,
508 customer,
509 description,
510 };
511
512 day_data.work_records.insert(id, record);
513 }
514
515 Ok(day_data)
516 }
517
518 fn save_day(&self, day_data: &DayData) -> Result<()> {
519 let mut conn = self.open_connection()?;
520 let tx = conn
521 .transaction()
522 .context("Failed to begin day save transaction")?;
523
524 Self::write_day_data_tx(&tx, day_data, true)?;
525
526 tx.commit()
527 .context("Failed to commit day save transaction")?;
528 Ok(())
529 }
530
531 fn day_revision_token(&self, date: &Date) -> Result<Option<SystemTime>> {
532 let conn = self.open_connection()?;
533 let date_key = format_date(*date);
534
535 let revision_opt = conn
536 .query_row(
537 "SELECT revision FROM day_meta WHERE date = ?1",
538 params![date_key],
539 |row| row.get::<_, i64>(0),
540 )
541 .optional()
542 .context("Failed to query day revision")?;
543
544 match revision_opt {
545 None => Ok(None),
546 Some(revision) => {
547 if revision < 0 {
548 anyhow::bail!("Invalid negative revision in database: {}", revision);
549 }
550 let token = SystemTime::UNIX_EPOCH
553 .checked_add(StdDuration::from_secs(revision as u64))
554 .context("Failed to convert revision to SystemTime")?;
555 Ok(Some(token))
556 }
557 }
558 }
559
560 fn save_active_timer(&self, timer: &TimerState) -> Result<()> {
561 let mut conn = self.open_connection()?;
562 let tx = conn
563 .transaction()
564 .context("Failed to begin active timer transaction")?;
565
566 Self::save_active_timer_tx(&tx, timer)?;
567 tx.commit()
568 .context("Failed to commit active timer transaction")?;
569
570 Ok(())
571 }
572
573 fn load_active_timer(&self) -> Result<Option<TimerState>> {
574 let conn = self.open_connection()?;
575
576 let timer_row = conn
577 .query_row(
578 "
579 SELECT
580 id,
581 task_name,
582 description,
583 project,
584 customer,
585 start_time,
586 end_time,
587 date,
588 status,
589 paused_duration_secs,
590 paused_at,
591 created_at,
592 updated_at,
593 source_record_id,
594 source_record_date
595 FROM active_timer
596 WHERE singleton_id = 1
597 ",
598 [],
599 |row| {
600 Ok((
601 row.get::<_, Option<i64>>(0)?,
602 row.get::<_, String>(1)?,
603 row.get::<_, Option<String>>(2)?,
604 row.get::<_, Option<String>>(3)?,
605 row.get::<_, Option<String>>(4)?,
606 row.get::<_, String>(5)?,
607 row.get::<_, Option<String>>(6)?,
608 row.get::<_, String>(7)?,
609 row.get::<_, String>(8)?,
610 row.get::<_, i64>(9)?,
611 row.get::<_, Option<String>>(10)?,
612 row.get::<_, String>(11)?,
613 row.get::<_, String>(12)?,
614 row.get::<_, Option<i64>>(13)?,
615 row.get::<_, Option<String>>(14)?,
616 ))
617 },
618 )
619 .optional()
620 .context("Failed to query active timer")?;
621
622 let Some(row) = timer_row else {
623 return Ok(None);
624 };
625
626 let id = row
627 .0
628 .map(|v| i64_to_u32(v, "active_timer.id"))
629 .transpose()?;
630 let task_name = row.1;
631 let description = row.2;
632 let project = row.3;
633 let customer = row.4;
634 let start_time = parse_datetime(&row.5, "active_timer.start_time")?;
635 let end_time = row
636 .6
637 .as_deref()
638 .map(|v| parse_datetime(v, "active_timer.end_time"))
639 .transpose()?;
640 let date = parse_date(&row.7).context("Invalid active_timer.date")?;
641 let status = parse_timer_status(&row.8)?;
642 let paused_duration_secs = row.9;
643 let paused_at = row
644 .10
645 .as_deref()
646 .map(|v| parse_datetime(v, "active_timer.paused_at"))
647 .transpose()?;
648 let created_at = parse_datetime(&row.11, "active_timer.created_at")?;
649 let updated_at = parse_datetime(&row.12, "active_timer.updated_at")?;
650 let source_record_id = row
651 .13
652 .map(|v| i64_to_u32(v, "active_timer.source_record_id"))
653 .transpose()?;
654 let source_record_date = row
655 .14
656 .as_deref()
657 .map(parse_date)
658 .transpose()
659 .context("Invalid active_timer.source_record_date")?;
660
661 Ok(Some(TimerState {
662 id,
663 task_name,
664 description,
665 project,
666 customer,
667 start_time,
668 end_time,
669 date,
670 status,
671 paused_duration_secs,
672 paused_at,
673 created_at,
674 updated_at,
675 source_record_id,
676 source_record_date,
677 }))
678 }
679
680 fn clear_active_timer(&self) -> Result<()> {
681 let conn = self.open_connection()?;
682 conn.execute("DELETE FROM active_timer WHERE singleton_id = 1", [])
683 .context("Failed to clear active timer")?;
684 Ok(())
685 }
686
687 fn diagnostics(&self) -> Result<StorageDiagnostics> {
688 let conn = self.open_connection()?;
689
690 let migration_marker = conn
691 .query_row(
692 "SELECT value FROM meta WHERE key = ?1",
693 params![JSON_MIGRATION_META_KEY],
694 |row| row.get::<_, String>(0),
695 )
696 .optional()
697 .context("Failed to query migration marker")?;
698
699 let days_count = i64_to_u64(
700 conn.query_row("SELECT COUNT(*) FROM day_meta", [], |row| {
701 row.get::<_, i64>(0)
702 })
703 .context("Failed to count day rows")?,
704 "day_meta count",
705 )?;
706
707 let work_records_count = i64_to_u64(
708 conn.query_row("SELECT COUNT(*) FROM work_records", [], |row| {
709 row.get::<_, i64>(0)
710 })
711 .context("Failed to count work record rows")?,
712 "work_records count",
713 )?;
714
715 let active_timer_present = conn
716 .query_row(
717 "SELECT 1 FROM active_timer WHERE singleton_id = 1 LIMIT 1",
718 [],
719 |_row| Ok(()),
720 )
721 .optional()
722 .context("Failed to check active timer existence")?
723 .is_some();
724
725 let database_path = self.db_path.clone();
726 let data_dir = database_path
727 .parent()
728 .context("Database path has no parent directory")?;
729
730 let (legacy_day_json_files, legacy_timer_json_files) =
731 Self::count_legacy_json_files(data_dir)?;
732
733 Ok(StorageDiagnostics {
734 database_path,
735 migration_marker,
736 days_count,
737 work_records_count,
738 active_timer_present,
739 legacy_day_json_files,
740 legacy_timer_json_files,
741 })
742 }
743
744 fn list_dates_with_records(&self) -> Result<Vec<Date>> {
745 let conn = self.open_connection()?;
746 let mut stmt = conn
747 .prepare(
748 "
749 SELECT DISTINCT date
750 FROM work_records
751 ORDER BY date
752 ",
753 )
754 .context("Failed to prepare query for exportable dates")?;
755
756 let mut rows = stmt
757 .query([])
758 .context("Failed to query dates with work records")?;
759 let mut dates = Vec::new();
760
761 while let Some(row) = rows.next().context("Failed to read date row")? {
762 let raw_date = row.get::<_, String>(0)?;
763 let date = parse_date(&raw_date)
764 .context(format!("Failed to parse date '{}' from storage", raw_date))?;
765 dates.push(date);
766 }
767
768 Ok(dates)
769 }
770
771 fn count_legacy_json_files(data_dir: &Path) -> Result<(u64, u64)> {
772 let entries = fs::read_dir(data_dir).context("Failed to read data directory")?;
773
774 let mut day_files_count = 0_u64;
775 let mut timer_files_count = 0_u64;
776
777 for entry in entries {
778 let entry = entry.context("Failed to read directory entry")?;
779 let path = entry.path();
780
781 if !path.is_file() {
782 continue;
783 }
784
785 let Some(file_name_os) = path.file_name() else {
786 continue;
787 };
788 let Some(file_name) = file_name_os.to_str() else {
789 continue;
790 };
791
792 if !file_name.ends_with(".json") {
793 continue;
794 }
795
796 if matches!(
797 file_name,
798 LEGACY_RUNNING_TIMER_FILE_NAME | LEGACY_ACTIVE_TIMER_FILE_NAME
799 ) {
800 timer_files_count += 1;
801 continue;
802 }
803
804 let Some(date_part) = file_name.strip_suffix(".json") else {
805 continue;
806 };
807
808 if parse_date(date_part).is_ok() {
809 day_files_count += 1;
810 }
811 }
812
813 Ok((day_files_count, timer_files_count))
814 }
815
816 fn migrate_from_legacy_json_if_needed(&self, data_dir: &Path) -> Result<()> {
817 let mut conn = self.open_connection()?;
818 let tx = conn
819 .transaction()
820 .context("Failed to begin JSON migration transaction")?;
821
822 if Self::get_meta_value_tx(&tx, JSON_MIGRATION_META_KEY)?.is_some() {
823 tx.commit()
824 .context("Failed to finalize already-completed migration transaction")?;
825 return Ok(());
826 }
827
828 Self::import_legacy_day_files_tx(&tx, data_dir)?;
829 Self::import_legacy_timer_tx(&tx, data_dir)?;
830 Self::set_meta_value_tx(&tx, JSON_MIGRATION_META_KEY, "done")?;
831
832 tx.commit().context("Failed to commit JSON migration")?;
833 Ok(())
834 }
835
836 fn write_day_data_tx(
837 tx: &Transaction<'_>,
838 day_data: &DayData,
839 increment_revision: bool,
840 ) -> Result<()> {
841 let date_key = format_date(day_data.date);
842
843 tx.execute(
844 "
845 INSERT INTO day_meta(date, last_id, revision)
846 VALUES (?1, 0, 0)
847 ON CONFLICT(date) DO NOTHING
848 ",
849 params![date_key.clone()],
850 )
851 .context("Failed to upsert day_meta row")?;
852
853 tx.execute(
854 "DELETE FROM work_records WHERE date = ?1",
855 params![date_key.clone()],
856 )
857 .context("Failed to clear existing day work records")?;
858
859 {
860 let mut stmt = tx
861 .prepare(
862 "
863 INSERT INTO work_records (
864 date,
865 id,
866 name,
867 start_minutes,
868 end_minutes,
869 total_minutes,
870 project,
871 customer,
872 description
873 )
874 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
875 ",
876 )
877 .context("Failed to prepare work record insert statement")?;
878
879 for record in day_data.work_records.values() {
880 stmt.execute(params![
881 date_key.clone(),
882 i64::from(record.id),
883 &record.name,
884 i64::from(record.start.to_minutes_since_midnight()),
885 i64::from(record.end.to_minutes_since_midnight()),
886 i64::from(record.total_minutes),
887 &record.project,
888 &record.customer,
889 &record.description,
890 ])
891 .context("Failed to insert work record")?;
892 }
893 }
894
895 if increment_revision {
896 tx.execute(
899 "
900 UPDATE day_meta
901 SET last_id = ?1,
902 revision = revision + 1
903 WHERE date = ?2
904 ",
905 params![i64::from(day_data.last_id), date_key],
906 )
907 .context("Failed to update day_meta with incremented revision")?;
908 } else {
909 tx.execute(
912 "
913 UPDATE day_meta
914 SET last_id = ?1,
915 revision = CASE WHEN revision = 0 THEN 1 ELSE revision END
916 WHERE date = ?2
917 ",
918 params![i64::from(day_data.last_id), date_key],
919 )
920 .context("Failed to update day_meta during migration")?;
921 }
922
923 Ok(())
924 }
925
926 fn save_active_timer_tx(tx: &Transaction<'_>, timer: &TimerState) -> Result<()> {
927 let id = timer.id.map(i64::from);
928 let start_time = format_datetime(timer.start_time)?;
929 let end_time = timer.end_time.map(format_datetime).transpose()?;
930 let date = format_date(timer.date);
931 let status = timer_status_to_str(timer.status);
932 let project = timer.project.as_deref();
933 let customer = timer.customer.as_deref();
934 let paused_at = timer.paused_at.map(format_datetime).transpose()?;
935 let created_at = format_datetime(timer.created_at)?;
936 let updated_at = format_datetime(timer.updated_at)?;
937 let source_record_id = timer.source_record_id.map(i64::from);
938 let source_record_date = timer.source_record_date.map(format_date);
939
940 tx.execute(
941 "
942 INSERT INTO active_timer (
943 singleton_id,
944 id,
945 task_name,
946 description,
947 project,
948 customer,
949 start_time,
950 end_time,
951 date,
952 status,
953 paused_duration_secs,
954 paused_at,
955 created_at,
956 updated_at,
957 source_record_id,
958 source_record_date
959 )
960 VALUES (
961 1,
962 ?1,
963 ?2,
964 ?3,
965 ?4,
966 ?5,
967 ?6,
968 ?7,
969 ?8,
970 ?9,
971 ?10,
972 ?11,
973 ?12,
974 ?13,
975 ?14,
976 ?15
977 )
978 ON CONFLICT(singleton_id) DO UPDATE SET
979 id = excluded.id,
980 task_name = excluded.task_name,
981 description = excluded.description,
982 project = excluded.project,
983 customer = excluded.customer,
984 start_time = excluded.start_time,
985 end_time = excluded.end_time,
986 date = excluded.date,
987 status = excluded.status,
988 paused_duration_secs = excluded.paused_duration_secs,
989 paused_at = excluded.paused_at,
990 created_at = excluded.created_at,
991 updated_at = excluded.updated_at,
992 source_record_id = excluded.source_record_id,
993 source_record_date = excluded.source_record_date
994 ",
995 params![
996 id,
997 &timer.task_name,
998 timer.description.as_deref(),
999 project,
1000 customer,
1001 start_time,
1002 end_time,
1003 date,
1004 status,
1005 timer.paused_duration_secs,
1006 paused_at,
1007 created_at,
1008 updated_at,
1009 source_record_id,
1010 source_record_date,
1011 ],
1012 )
1013 .context("Failed to upsert active timer")?;
1014
1015 Ok(())
1016 }
1017
1018 fn get_meta_value_tx(tx: &Transaction<'_>, key: &str) -> Result<Option<String>> {
1019 tx.query_row(
1020 "SELECT value FROM meta WHERE key = ?1",
1021 params![key],
1022 |row| row.get::<_, String>(0),
1023 )
1024 .optional()
1025 .context(format!("Failed to query meta key '{}'", key))
1026 }
1027
1028 fn set_meta_value_tx(tx: &Transaction<'_>, key: &str, value: &str) -> Result<()> {
1029 tx.execute(
1030 "
1031 INSERT INTO meta(key, value)
1032 VALUES (?1, ?2)
1033 ON CONFLICT(key) DO UPDATE SET value = excluded.value
1034 ",
1035 params![key, value],
1036 )
1037 .context(format!("Failed to upsert meta key '{}'", key))?;
1038 Ok(())
1039 }
1040
1041 fn day_exists_tx(tx: &Transaction<'_>, date_key: &str) -> Result<bool> {
1042 let exists = tx
1043 .query_row(
1044 "SELECT 1 FROM day_meta WHERE date = ?1 LIMIT 1",
1045 params![date_key],
1046 |_row| Ok(()),
1047 )
1048 .optional()
1049 .context("Failed to check day existence")?
1050 .is_some();
1051 Ok(exists)
1052 }
1053
1054 fn import_legacy_day_files_tx(tx: &Transaction<'_>, data_dir: &Path) -> Result<()> {
1055 let entries = fs::read_dir(data_dir).context("Failed to read data directory")?;
1056 let mut paths = Vec::new();
1057
1058 for entry in entries {
1059 let entry = entry.context("Failed to read directory entry")?;
1060 paths.push(entry.path());
1061 }
1062
1063 paths.sort();
1064
1065 for path in paths {
1066 if !path.is_file() {
1067 continue;
1068 }
1069
1070 let Some(file_name_os) = path.file_name() else {
1071 continue;
1072 };
1073 let Some(file_name) = file_name_os.to_str() else {
1074 continue;
1075 };
1076
1077 if !file_name.ends_with(".json") {
1078 continue;
1079 }
1080
1081 if matches!(
1082 file_name,
1083 LEGACY_RUNNING_TIMER_FILE_NAME | LEGACY_ACTIVE_TIMER_FILE_NAME
1084 ) {
1085 continue;
1086 }
1087
1088 let Some(date_part) = file_name.strip_suffix(".json") else {
1089 continue;
1090 };
1091
1092 let parsed_file_date = match parse_date(date_part) {
1093 Ok(date) => date,
1094 Err(_) => continue,
1095 };
1096
1097 let date_key = format_date(parsed_file_date);
1098 if Self::day_exists_tx(tx, &date_key)? {
1099 eprintln!(
1100 "Skipping legacy JSON import for {} from {:?}: data already exists in SQLite",
1101 date_key, path
1102 );
1103 continue;
1104 }
1105
1106 let contents = fs::read_to_string(&path)
1107 .context(format!("Failed to read legacy JSON file: {:?}", path))?;
1108 let day_data: DayData = serde_json::from_str(&contents)
1109 .context(format!("Failed to parse legacy day JSON: {:?}", path))?;
1110
1111 Self::write_day_data_tx(tx, &day_data, false)?;
1112 }
1113
1114 Ok(())
1115 }
1116
1117 fn import_legacy_timer_tx(tx: &Transaction<'_>, data_dir: &Path) -> Result<()> {
1118 let timer_exists = tx
1119 .query_row(
1120 "SELECT 1 FROM active_timer WHERE singleton_id = 1 LIMIT 1",
1121 [],
1122 |_row| Ok(()),
1123 )
1124 .optional()
1125 .context("Failed to check existing active timer")?
1126 .is_some();
1127
1128 if timer_exists {
1129 return Ok(());
1130 }
1131
1132 let running_timer_path = data_dir.join(LEGACY_RUNNING_TIMER_FILE_NAME);
1133 let active_timer_path = data_dir.join(LEGACY_ACTIVE_TIMER_FILE_NAME);
1134
1135 if running_timer_path.exists() && active_timer_path.exists() {
1136 eprintln!(
1137 "Found both legacy timer files ({:?}, {:?}); preferring {:?}",
1138 running_timer_path, active_timer_path, running_timer_path
1139 );
1140 }
1141
1142 let timer_paths = [running_timer_path, active_timer_path];
1143
1144 for path in timer_paths {
1145 if !path.exists() {
1146 continue;
1147 }
1148
1149 let contents = fs::read_to_string(&path)
1150 .context(format!("Failed to read legacy timer JSON: {:?}", path))?;
1151 let timer: TimerState = serde_json::from_str(&contents)
1152 .context(format!("Failed to parse legacy timer JSON: {:?}", path))?;
1153
1154 Self::save_active_timer_tx(tx, &timer)?;
1155 break;
1156 }
1157
1158 Ok(())
1159 }
1160}
1161
1162fn format_date(date: Date) -> String {
1163 format!(
1164 "{:04}-{:02}-{:02}",
1165 date.year(),
1166 date.month() as u8,
1167 date.day()
1168 )
1169}
1170
1171fn parse_date(value: &str) -> Result<Date> {
1172 Date::parse(value, format_description!("[year]-[month]-[day]"))
1173 .context(format!("Invalid date: '{}'. Expected YYYY-MM-DD", value))
1174}
1175
1176fn format_datetime(value: OffsetDateTime) -> Result<String> {
1177 value
1178 .format(&Rfc3339)
1179 .context("Failed to serialize datetime as RFC3339")
1180}
1181
1182fn parse_datetime(value: &str, field: &str) -> Result<OffsetDateTime> {
1183 OffsetDateTime::parse(value, &Rfc3339).context(format!(
1184 "Invalid RFC3339 datetime in {}: '{}'",
1185 field, value
1186 ))
1187}
1188
1189fn timer_status_to_str(status: TimerStatus) -> &'static str {
1190 match status {
1191 TimerStatus::Running => "running",
1192 TimerStatus::Paused => "paused",
1193 TimerStatus::Stopped => "stopped",
1194 }
1195}
1196
1197fn parse_timer_status(value: &str) -> Result<TimerStatus> {
1198 match value {
1199 "running" => Ok(TimerStatus::Running),
1200 "paused" => Ok(TimerStatus::Paused),
1201 "stopped" => Ok(TimerStatus::Stopped),
1202 _ => anyhow::bail!("Invalid timer status in database: {}", value),
1203 }
1204}
1205
1206fn i64_to_u32(value: i64, field_name: &str) -> Result<u32> {
1207 u32::try_from(value).context(format!(
1208 "Invalid value in {}: {} (must fit in u32)",
1209 field_name, value
1210 ))
1211}
1212
1213fn i64_to_u64(value: i64, field_name: &str) -> Result<u64> {
1214 u64::try_from(value).context(format!(
1215 "Invalid value in {}: {} (must fit in u64)",
1216 field_name, value
1217 ))
1218}
1219
1220#[cfg(test)]
1221mod tests {
1222 use super::*;
1223 use tempfile::TempDir;
1224
1225 fn create_test_date() -> Date {
1226 Date::from_calendar_date(2025, time::Month::November, 6).unwrap()
1227 }
1228
1229 fn create_test_record(id: u32, name: &str) -> WorkRecord {
1230 let start = TimePoint::new(9, 0).unwrap();
1231 let end = TimePoint::new(17, 0).unwrap();
1232 WorkRecord::new(id, name.to_string(), start, end)
1233 }
1234
1235 fn create_test_timer() -> TimerState {
1236 let now = OffsetDateTime::now_utc();
1237 TimerState {
1238 id: None,
1239 task_name: "Test Timer".to_string(),
1240 description: Some("Timer description".to_string()),
1241 project: Some("Platform".to_string()),
1242 customer: Some("ACME".to_string()),
1243 start_time: now,
1244 end_time: None,
1245 date: now.date(),
1246 status: TimerStatus::Running,
1247 paused_duration_secs: 0,
1248 paused_at: None,
1249 created_at: now,
1250 updated_at: now,
1251 source_record_id: None,
1252 source_record_date: None,
1253 }
1254 }
1255
1256 #[test]
1257 fn test_new_storage_with_temp_dir_initializes_database() {
1258 let temp_dir = TempDir::new().unwrap();
1259 let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1260
1261 assert!(temp_dir.path().exists());
1262 assert!(storage.get_db_path().exists());
1263 }
1264
1265 #[test]
1266 fn test_save_and_load_day_data_round_trip() {
1267 let temp_dir = TempDir::new().unwrap();
1268 let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1269 let date = create_test_date();
1270
1271 let mut day_data = DayData::new(date);
1272 day_data.add_record(create_test_record(1, "Coding"));
1273 day_data.add_record(create_test_record(2, "Meeting"));
1274
1275 storage.save(&day_data).unwrap();
1276 let loaded = storage.load(&date).unwrap();
1277
1278 assert_eq!(loaded.date, date);
1279 assert_eq!(loaded.last_id, 2);
1280 assert_eq!(loaded.work_records.len(), 2);
1281 assert_eq!(loaded.work_records.get(&1).unwrap().name, "Coding");
1282 assert_eq!(loaded.work_records.get(&2).unwrap().name, "Meeting");
1283 }
1284
1285 #[test]
1286 fn test_list_dates_with_records_only_returns_non_empty_days() {
1287 let temp_dir = TempDir::new().unwrap();
1288 let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1289
1290 let date1 = Date::from_calendar_date(2025, time::Month::November, 6).unwrap();
1291 let date2 = Date::from_calendar_date(2025, time::Month::November, 7).unwrap();
1292 let date3 = Date::from_calendar_date(2025, time::Month::November, 8).unwrap();
1293
1294 let mut day1 = DayData::new(date1);
1295 day1.add_record(create_test_record(1, "Coding"));
1296 storage.save(&day1).unwrap();
1297
1298 storage.save(&DayData::new(date2)).unwrap();
1300
1301 let mut day3 = DayData::new(date3);
1302 day3.add_record(create_test_record(1, "Meeting"));
1303 storage.save(&day3).unwrap();
1304
1305 let dates = storage.list_dates_with_records().unwrap();
1306 assert_eq!(dates, vec![date1, date3]);
1307 }
1308
1309 #[test]
1310 fn test_get_file_modified_time_uses_revision_token() {
1311 let temp_dir = TempDir::new().unwrap();
1312 let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1313 let date = create_test_date();
1314
1315 assert!(storage.get_file_modified_time(&date).is_none());
1316
1317 let mut day_data = DayData::new(date);
1318 day_data.add_record(create_test_record(1, "Task 1"));
1319 storage.save(&day_data).unwrap();
1320 let first = storage.get_file_modified_time(&date);
1321
1322 day_data.add_record(create_test_record(2, "Task 2"));
1323 storage.save(&day_data).unwrap();
1324 let second = storage.get_file_modified_time(&date);
1325
1326 assert!(first.is_some());
1327 assert!(second.is_some());
1328 assert_ne!(first, second);
1329 }
1330
1331 #[test]
1332 fn test_save_load_and_clear_active_timer() {
1333 let temp_dir = TempDir::new().unwrap();
1334 let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1335 let timer = create_test_timer();
1336
1337 storage.save_active_timer(&timer).unwrap();
1338 let loaded = storage.load_active_timer().unwrap();
1339 assert!(loaded.is_some());
1340 let loaded_timer = loaded.unwrap();
1341 assert_eq!(loaded_timer.task_name, "Test Timer");
1342 assert_eq!(loaded_timer.project.as_deref(), Some("Platform"));
1343 assert_eq!(loaded_timer.customer.as_deref(), Some("ACME"));
1344
1345 storage.clear_active_timer().unwrap();
1346 assert!(storage.load_active_timer().unwrap().is_none());
1347 }
1348
1349 #[test]
1350 fn test_storage_manager_check_and_reload_detects_external_changes() {
1351 let temp_dir = TempDir::new().unwrap();
1352 let mut manager1 = StorageManager::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1353 let mut manager2 = StorageManager::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1354 let date = create_test_date();
1355
1356 manager1.load_with_tracking(date).unwrap();
1357 manager2
1358 .add_record(date, create_test_record(1, "External Change"))
1359 .unwrap();
1360
1361 let reloaded = manager1.check_and_reload(date).unwrap();
1362 assert!(reloaded.is_some());
1363 assert_eq!(reloaded.unwrap().work_records.len(), 1);
1364 }
1365
1366 #[test]
1367 fn test_json_migration_imports_legacy_day_data_and_timer() {
1368 let temp_dir = TempDir::new().unwrap();
1369 let data_dir = temp_dir.path();
1370
1371 let date = create_test_date();
1372 let mut day_data = DayData::new(date);
1373 day_data.add_record(create_test_record(1, "Legacy Task"));
1374
1375 let day_json_path = data_dir.join("2025-11-06.json");
1376 fs::write(
1377 &day_json_path,
1378 serde_json::to_string_pretty(&day_data).unwrap(),
1379 )
1380 .unwrap();
1381
1382 let timer = create_test_timer();
1383 let timer_json_path = data_dir.join(LEGACY_RUNNING_TIMER_FILE_NAME);
1384 fs::write(
1385 &timer_json_path,
1386 serde_json::to_string_pretty(&timer).unwrap(),
1387 )
1388 .unwrap();
1389
1390 let storage = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1391
1392 let loaded_day = storage.load(&date).unwrap();
1393 assert_eq!(loaded_day.work_records.len(), 1);
1394 assert_eq!(loaded_day.work_records.get(&1).unwrap().name, "Legacy Task");
1395
1396 let loaded_timer = storage.load_active_timer().unwrap();
1397 assert!(loaded_timer.is_some());
1398 assert_eq!(loaded_timer.unwrap().task_name, "Test Timer");
1399
1400 assert!(day_json_path.exists());
1402 assert!(timer_json_path.exists());
1403 }
1404
1405 #[test]
1406 fn test_json_migration_is_idempotent() {
1407 let temp_dir = TempDir::new().unwrap();
1408 let data_dir = temp_dir.path();
1409
1410 let date = create_test_date();
1411 let mut day_data = DayData::new(date);
1412 day_data.add_record(create_test_record(1, "Legacy Task"));
1413
1414 let day_json_path = data_dir.join("2025-11-06.json");
1415 fs::write(
1416 &day_json_path,
1417 serde_json::to_string_pretty(&day_data).unwrap(),
1418 )
1419 .unwrap();
1420
1421 let storage1 = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1422 let loaded1 = storage1.load(&date).unwrap();
1423 assert_eq!(loaded1.work_records.len(), 1);
1424
1425 let storage2 = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1426 let loaded2 = storage2.load(&date).unwrap();
1427 assert_eq!(loaded2.work_records.len(), 1);
1428 assert_eq!(loaded2.work_records.get(&1).unwrap().name, "Legacy Task");
1429 }
1430
1431 #[test]
1432 fn test_json_migration_fails_on_malformed_legacy_day_file() {
1433 let temp_dir = TempDir::new().unwrap();
1434 let data_dir = temp_dir.path();
1435
1436 let malformed_day_path = data_dir.join("2025-11-08.json");
1437 fs::write(&malformed_day_path, "{ invalid json").unwrap();
1438
1439 let result = Storage::new_with_dir(data_dir.to_path_buf());
1440 assert!(result.is_err());
1441 let err = result.err().expect("expected malformed migration to fail");
1442 let err_msg = format!("{err:#}");
1443 let malformed_path_str = malformed_day_path.display().to_string();
1444 assert!(
1445 err_msg.contains(&malformed_path_str),
1446 "error message '{}' did not include malformed file path '{}'",
1447 err_msg,
1448 malformed_path_str
1449 );
1450
1451 let db_path = data_dir.join(DATABASE_FILE_NAME);
1452 let conn = Connection::open(db_path).unwrap();
1453 let day_rows: i64 = conn
1454 .query_row("SELECT COUNT(*) FROM day_meta", [], |row| row.get(0))
1455 .unwrap();
1456 assert_eq!(day_rows, 0);
1457 }
1458
1459 #[test]
1460 fn test_json_migration_prefers_running_timer_when_both_timer_files_exist() {
1461 let temp_dir = TempDir::new().unwrap();
1462 let data_dir = temp_dir.path();
1463
1464 let mut running_timer = create_test_timer();
1465 running_timer.task_name = "running timer file".to_string();
1466 fs::write(
1467 data_dir.join(LEGACY_RUNNING_TIMER_FILE_NAME),
1468 serde_json::to_string_pretty(&running_timer).unwrap(),
1469 )
1470 .unwrap();
1471
1472 let mut active_timer = create_test_timer();
1473 active_timer.task_name = "active timer file".to_string();
1474 fs::write(
1475 data_dir.join(LEGACY_ACTIVE_TIMER_FILE_NAME),
1476 serde_json::to_string_pretty(&active_timer).unwrap(),
1477 )
1478 .unwrap();
1479
1480 let storage = Storage::new_with_dir(data_dir.to_path_buf()).unwrap();
1481 let loaded = storage.load_active_timer().unwrap().unwrap();
1482
1483 assert_eq!(loaded.task_name, "running timer file");
1484 }
1485
1486 #[test]
1487 fn test_storage_diagnostics_reports_expected_values() {
1488 let temp_dir = TempDir::new().unwrap();
1489 let storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1490
1491 let date = create_test_date();
1492 let mut day_data = DayData::new(date);
1493 day_data.add_record(create_test_record(1, "Task One"));
1494 day_data.add_record(create_test_record(2, "Task Two"));
1495 storage.save(&day_data).unwrap();
1496
1497 fs::write(
1498 temp_dir.path().join("2025-11-09.json"),
1499 serde_json::to_string_pretty(&day_data).unwrap(),
1500 )
1501 .unwrap();
1502
1503 let diagnostics = storage.diagnostics().unwrap();
1504
1505 assert_eq!(diagnostics.migration_marker.as_deref(), Some("done"));
1506 assert_eq!(diagnostics.days_count, 1);
1507 assert_eq!(diagnostics.work_records_count, 2);
1508 assert!(!diagnostics.active_timer_present);
1509 assert_eq!(diagnostics.legacy_day_json_files, 1);
1510 assert_eq!(diagnostics.legacy_timer_json_files, 0);
1511 }
1512
1513 #[test]
1514 fn test_schema_migration_adds_project_and_customer_columns() {
1515 let temp_dir = TempDir::new().unwrap();
1516 let db_path = temp_dir.path().join(DATABASE_FILE_NAME);
1517
1518 {
1519 let conn = Connection::open(&db_path).unwrap();
1520 conn.execute_batch(
1521 "
1522 CREATE TABLE meta (
1523 key TEXT PRIMARY KEY,
1524 value TEXT NOT NULL
1525 );
1526
1527 CREATE TABLE day_meta (
1528 date TEXT PRIMARY KEY,
1529 last_id INTEGER NOT NULL DEFAULT 0 CHECK (last_id >= 0),
1530 revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0)
1531 );
1532
1533 CREATE TABLE work_records (
1534 date TEXT NOT NULL,
1535 id INTEGER NOT NULL,
1536 name TEXT NOT NULL,
1537 start_minutes INTEGER NOT NULL,
1538 end_minutes INTEGER NOT NULL,
1539 total_minutes INTEGER NOT NULL,
1540 description TEXT NOT NULL DEFAULT '',
1541 PRIMARY KEY (date, id),
1542 FOREIGN KEY (date) REFERENCES day_meta(date) ON DELETE CASCADE
1543 );
1544
1545 CREATE TABLE active_timer (
1546 singleton_id INTEGER PRIMARY KEY CHECK (singleton_id = 1),
1547 id INTEGER,
1548 task_name TEXT NOT NULL,
1549 description TEXT,
1550 start_time TEXT NOT NULL,
1551 end_time TEXT,
1552 date TEXT NOT NULL,
1553 status TEXT NOT NULL CHECK (status IN ('running', 'paused', 'stopped')),
1554 paused_duration_secs INTEGER NOT NULL CHECK (paused_duration_secs >= 0),
1555 paused_at TEXT,
1556 created_at TEXT NOT NULL,
1557 updated_at TEXT NOT NULL,
1558 source_record_id INTEGER,
1559 source_record_date TEXT
1560 );
1561 ",
1562 )
1563 .unwrap();
1564 }
1565
1566 let _storage = Storage::new_with_dir(temp_dir.path().to_path_buf()).unwrap();
1567 let conn = Connection::open(db_path).unwrap();
1568
1569 let work_records_has_project: i64 = conn
1570 .query_row(
1571 "SELECT COUNT(*) FROM pragma_table_info('work_records') WHERE name = 'project'",
1572 [],
1573 |row| row.get(0),
1574 )
1575 .unwrap();
1576 let work_records_has_customer: i64 = conn
1577 .query_row(
1578 "SELECT COUNT(*) FROM pragma_table_info('work_records') WHERE name = 'customer'",
1579 [],
1580 |row| row.get(0),
1581 )
1582 .unwrap();
1583 let active_timer_has_project: i64 = conn
1584 .query_row(
1585 "SELECT COUNT(*) FROM pragma_table_info('active_timer') WHERE name = 'project'",
1586 [],
1587 |row| row.get(0),
1588 )
1589 .unwrap();
1590 let active_timer_has_customer: i64 = conn
1591 .query_row(
1592 "SELECT COUNT(*) FROM pragma_table_info('active_timer') WHERE name = 'customer'",
1593 [],
1594 |row| row.get(0),
1595 )
1596 .unwrap();
1597
1598 assert_eq!(work_records_has_project, 1);
1599 assert_eq!(work_records_has_customer, 1);
1600 assert_eq!(active_timer_has_project, 1);
1601 assert_eq!(active_timer_has_customer, 1);
1602 }
1603}