ratado 0.2.0

A fast, keyboard-driven terminal task manager built with Rust and Ratatui
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
//! Database connection and initialization.
//!
//! This module provides the [`Database`] struct for connecting to and interacting
//! with the Turso/SQLite database. It handles connection management, path resolution,
//! and provides a high-level interface for database operations.
//!
//! # Examples
//!
//! ```rust,no_run
//! use ratado::storage::Database;
//!
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
//! // Open database at default location (platform-specific, see below)
//! let db = Database::open_default().await?;
//!
//! // Or open an in-memory database for testing
//! let db = Database::open_in_memory().await?;
//! # Ok(())
//! # }
//! ```

use std::path::{Path, PathBuf};

use directories::ProjectDirs;
use thiserror::Error;
use turso::{Builder, Connection, Row, Rows, Value};

/// Errors that can occur during storage operations.
///
/// This enum covers all error conditions that can arise when working with
/// the database, including connection failures, query errors, and data
/// conversion issues.
#[derive(Error, Debug)]
pub enum StorageError {
    /// Database operation failed
    #[error("Database error: {0}")]
    Database(#[from] turso::Error),

    /// Failed to create or access the config directory
    #[error("Failed to access config directory: {0}")]
    ConfigDir(#[from] std::io::Error),

    /// Could not determine the user's config directory
    #[error("Could not determine config directory")]
    NoConfigDir,

    /// Data conversion or parsing error
    #[error("Data conversion error: {0}")]
    Conversion(String),

    /// Record not found
    #[error("Record not found: {0}")]
    NotFound(String),

    /// Migration error
    #[error("Migration error: {0}")]
    Migration(String),
}

/// Result type for storage operations.
pub type Result<T> = std::result::Result<T, StorageError>;

/// Database connection wrapper.
///
/// Provides a high-level interface for database operations. The database
/// uses Turso (SQLite-compatible) for local storage.
///
/// # Thread Safety
///
/// The `Database` struct is `Send` and `Sync`, and can be safely shared
/// between threads. Each operation acquires the connection as needed.
///
/// # Examples
///
/// ```rust,no_run
/// use ratado::storage::Database;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let db = Database::open_in_memory().await?;
///
/// // Execute a query
/// db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY)", ()).await?;
/// # Ok(())
/// # }
/// ```
#[derive(Clone)]
pub struct Database {
    conn: Connection,
}

impl Database {
    /// Opens a database at the specified path.
    ///
    /// Creates the database file and parent directories if they don't exist.
    ///
    /// # Arguments
    ///
    /// * `path` - Path to the database file
    ///
    /// # Returns
    ///
    /// A new `Database` instance connected to the specified file.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The parent directory cannot be created
    /// - The database file cannot be opened or created
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ratado::storage::Database;
    /// use std::path::Path;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let db = Database::open(Path::new("/path/to/database.db")).await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn open(path: &Path) -> Result<Self> {
        // Create parent directory if it doesn't exist
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let path_str = path.to_string_lossy();
        let db = Builder::new_local(&path_str).build().await?;
        let conn = db.connect()?;

        // Enable foreign key constraints
        conn.execute("PRAGMA foreign_keys = ON", ()).await?;

        Ok(Self { conn })
    }

    /// Opens an in-memory database.
    ///
    /// Useful for testing. Data is lost when the database is dropped.
    ///
    /// # Returns
    ///
    /// A new `Database` instance with an in-memory database.
    ///
    /// # Errors
    ///
    /// Returns an error if the in-memory database cannot be created.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ratado::storage::Database;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let db = Database::open_in_memory().await?;
    /// // Use for testing...
    /// # Ok(())
    /// # }
    /// ```
    pub async fn open_in_memory() -> Result<Self> {
        let db = Builder::new_local(":memory:").build().await?;
        let conn = db.connect()?;

        // Enable foreign key constraints
        conn.execute("PRAGMA foreign_keys = ON", ()).await?;

        Ok(Self { conn })
    }

    /// Opens the database at the default location.
    ///
    /// The default path is `~/Library/Application Support/ratado/ratado.db` on macOS
    /// or `~/.config/ratado/ratado.db` on Linux.
    /// Creates the directory structure if it doesn't exist.
    ///
    /// # Returns
    ///
    /// A new `Database` instance at the default location.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The config directory cannot be determined
    /// - The directory cannot be created
    /// - The database cannot be opened
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ratado::storage::Database;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let db = Database::open_default().await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn open_default() -> Result<Self> {
        let path = Self::default_path()?;
        Self::open(&path).await
    }

    /// Returns the default database path.
    ///
    /// The default path is `~/Library/Application Support/ratado/ratado.db` on macOS
    /// or `~/.config/ratado/ratado.db` on Linux.
    /// Creates the config directory if it doesn't exist.
    ///
    /// # Returns
    ///
    /// The path to the default database file.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - The config directory cannot be determined (e.g., no HOME set)
    /// - The directory cannot be created
    pub fn default_path() -> Result<PathBuf> {
        let proj_dirs =
            ProjectDirs::from("", "", "ratado").ok_or(StorageError::NoConfigDir)?;
        let config_dir = proj_dirs.config_dir();
        std::fs::create_dir_all(config_dir)?;
        Ok(config_dir.join("ratado.db"))
    }

    /// Executes a SQL statement that doesn't return rows.
    ///
    /// Use this for INSERT, UPDATE, DELETE, and DDL statements.
    ///
    /// # Arguments
    ///
    /// * `sql` - The SQL statement to execute
    /// * `params` - Parameters to bind to the statement
    ///
    /// # Returns
    ///
    /// The number of rows affected by the statement.
    ///
    /// # Errors
    ///
    /// Returns an error if the statement fails to execute.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ratado::storage::Database;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let db = Database::open_in_memory().await?;
    /// let rows_affected = db.execute(
    ///     "INSERT INTO tasks (title) VALUES (?1)",
    ///     ["Buy groceries"]
    /// ).await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn execute(
        &self,
        sql: impl AsRef<str>,
        params: impl turso::IntoParams,
    ) -> Result<u64> {
        Ok(self.conn.execute(sql, params).await?)
    }

    /// Executes a batch of SQL statements.
    ///
    /// Useful for running multiple statements at once, such as migrations.
    /// Statements are separated by semicolons.
    ///
    /// # Arguments
    ///
    /// * `sql` - Multiple SQL statements separated by semicolons
    ///
    /// # Errors
    ///
    /// Returns an error if any statement fails to execute.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ratado::storage::Database;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let db = Database::open_in_memory().await?;
    /// db.execute_batch("
    ///     CREATE TABLE test1 (id INTEGER);
    ///     CREATE TABLE test2 (id INTEGER);
    /// ").await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn execute_batch(&self, sql: impl AsRef<str>) -> Result<()> {
        Ok(self.conn.execute_batch(sql).await?)
    }

    /// Executes a query and returns the result rows.
    ///
    /// Use this for SELECT statements.
    ///
    /// # Arguments
    ///
    /// * `sql` - The SQL query to execute
    /// * `params` - Parameters to bind to the query
    ///
    /// # Returns
    ///
    /// An iterator over the result rows.
    ///
    /// # Errors
    ///
    /// Returns an error if the query fails to execute.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use ratado::storage::Database;
    ///
    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
    /// let db = Database::open_in_memory().await?;
    /// let mut rows = db.query("SELECT * FROM tasks WHERE status = ?1", ["pending"]).await?;
    /// while let Some(row) = rows.next().await? {
    ///     // Process row...
    /// }
    /// # Ok(())
    /// # }
    /// ```
    pub async fn query(
        &self,
        sql: impl AsRef<str>,
        params: impl turso::IntoParams,
    ) -> Result<Rows> {
        Ok(self.conn.query(sql, params).await?)
    }

    /// Executes a query and returns the first row.
    ///
    /// # Arguments
    ///
    /// * `sql` - The SQL query to execute
    /// * `params` - Parameters to bind to the query
    ///
    /// # Returns
    ///
    /// The first row of the result, or `None` if no rows match.
    ///
    /// # Errors
    ///
    /// Returns an error if the query fails to execute.
    pub async fn query_one(
        &self,
        sql: impl AsRef<str>,
        params: impl turso::IntoParams,
    ) -> Result<Option<Row>> {
        let mut rows = self.query(sql, params).await?;
        Ok(rows.next().await?)
    }

    /// Executes a query and returns a single scalar value.
    ///
    /// Useful for COUNT, MAX, etc. queries that return a single value.
    ///
    /// # Arguments
    ///
    /// * `sql` - The SQL query to execute
    /// * `params` - Parameters to bind to the query
    ///
    /// # Returns
    ///
    /// The first column of the first row, or `None` if no rows match.
    ///
    /// # Errors
    ///
    /// Returns an error if the query fails to execute.
    pub async fn query_scalar(
        &self,
        sql: impl AsRef<str>,
        params: impl turso::IntoParams,
    ) -> Result<Option<Value>> {
        if let Some(row) = self.query_one(sql, params).await? {
            Ok(Some(row.get_value(0)?))
        } else {
            Ok(None)
        }
    }

    /// Returns a reference to the underlying connection.
    ///
    /// Use this for advanced operations not covered by the wrapper methods.
    pub fn connection(&self) -> &Connection {
        &self.conn
    }
}

impl std::fmt::Debug for Database {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Database").finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_open_in_memory() {
        let db = Database::open_in_memory().await.unwrap();
        db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)", ())
            .await
            .unwrap();
        db.execute("INSERT INTO test (name) VALUES (?1)", ["Alice"])
            .await
            .unwrap();

        let mut rows = db.query("SELECT name FROM test", ()).await.unwrap();
        let row = rows.next().await.unwrap().unwrap();
        let name = row.get_value(0).unwrap();
        assert_eq!(name, Value::Text("Alice".to_string()));
    }

    #[tokio::test]
    async fn test_execute_batch() {
        let db = Database::open_in_memory().await.unwrap();
        db.execute_batch(
            "
            CREATE TABLE test1 (id INTEGER PRIMARY KEY);
            CREATE TABLE test2 (id INTEGER PRIMARY KEY);
            INSERT INTO test1 (id) VALUES (1);
            INSERT INTO test2 (id) VALUES (2);
        ",
        )
        .await
        .unwrap();

        let value = db
            .query_scalar("SELECT COUNT(*) FROM test1", ())
            .await
            .unwrap();
        assert_eq!(value, Some(Value::Integer(1)));
    }

    #[tokio::test]
    async fn test_query_one() {
        let db = Database::open_in_memory().await.unwrap();
        db.execute("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)", ())
            .await
            .unwrap();
        db.execute("INSERT INTO test (name) VALUES (?1)", ["Bob"])
            .await
            .unwrap();

        let row = db
            .query_one("SELECT name FROM test WHERE id = 1", ())
            .await
            .unwrap();
        assert!(row.is_some());

        let row = db
            .query_one("SELECT name FROM test WHERE id = 999", ())
            .await
            .unwrap();
        assert!(row.is_none());
    }

    #[test]
    fn test_default_path() {
        let path = Database::default_path().unwrap();
        assert!(path.ends_with("ratado.db"));
        assert!(path.to_string_lossy().contains("ratado"));
    }
}