Make task state a proper enum
Strings offered no compile-time protection against invalid states.
State enum now implements sqlx Encode/Decode/Type so it round-trips
through SQLite. CLI parses strings at the command boundary.

Assisted-by: Claude Opus 4.6 via pi
change zxlzqysqtnkxktrklynpultprzoplxkk
commit 142df5655dff129df9fa24e8a4d95aa04a45c597
author Alpha Chen <alpha@kejadlen.dev>
date
parent kvqmulwr
diff --git a/AGENTS.md b/AGENTS.md
index 495646f..cb0451f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -74,6 +74,8 @@ ranger task move <key> --backlog <key> --after <key>          # place after a ta
 
 Top of the queue = most important. Ask the user where a task should go if priority isn't obvious. Don't just append everything to the bottom — a backlog that isn't ordered isn't useful.
 
+**Bias toward quick wins**: Small, easy tasks should be prioritized higher by default — even if they're just nice-to-haves. A 5-minute fix that improves quality of life is worth doing before a multi-hour feature. When suggesting priority, bump quick wins up rather than defaulting them to the bottom.
+
 Use `--json` on any command when you need structured output.
 
 ### Working in the Open
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index b6950ab..762acc0 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -1,6 +1,6 @@
 use clap::Subcommand;
 use ranger::db::SqlitePool;
-use ranger::models::Task;
+use ranger::models::{State, Task};
 use ranger::ops;
 
 use crate::output;
@@ -107,11 +107,16 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> anyhow
                 None
             };
 
+            let state = state
+                .map(|s| s.parse::<State>())
+                .transpose()
+                .map_err(|e| anyhow::anyhow!(e))?;
+
             let task = ops::task::create(
                 pool,
                 &title,
                 bl.id,
-                state.as_deref(),
+                state,
                 parent_id,
                 description.as_deref(),
             )
@@ -127,16 +132,21 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> anyhow
             output::print(&task, json, print_task);
         }
         TaskCommands::List { backlog, state } => {
+            let state = state
+                .map(|s| s.parse::<State>())
+                .transpose()
+                .map_err(|e| anyhow::anyhow!(e))?;
+
             if let Some(backlog_key) = &backlog {
                 let bl = ops::backlog::get_by_key_prefix(pool, backlog_key).await?;
-                let tasks = ops::task::list(pool, bl.id, state.as_deref()).await?;
+                let tasks = ops::task::list(pool, bl.id, state).await?;
                 output::print_list(&tasks, json, print_task);
             } else {
                 // List all tasks (no backlog filter)
                 let backlogs = ops::backlog::list(pool).await?;
                 let mut all_tasks = Vec::new();
                 for bl in &backlogs {
-                    let tasks = ops::task::list(pool, bl.id, state.as_deref()).await?;
+                    let tasks = ops::task::list(pool, bl.id, state.clone()).await?;
                     for t in tasks {
                         if !all_tasks.iter().any(|at: &Task| at.id == t.id) {
                             all_tasks.push(t);
@@ -189,13 +199,18 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> anyhow
             description,
             state,
         } => {
+            let state = state
+                .map(|s| s.parse::<State>())
+                .transpose()
+                .map_err(|e| anyhow::anyhow!(e))?;
+
             let task = ops::task::get_by_key_prefix(pool, &key).await?;
             let updated = ops::task::edit(
                 pool,
                 task.id,
                 title.as_deref(),
                 description.as_deref(),
-                state.as_deref(),
+                state,
             )
             .await?;
             output::print(&updated, json, print_task);
diff --git a/src/models.rs b/src/models.rs
index a27c812..5c51891 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -40,6 +40,29 @@ impl std::fmt::Display for State {
     }
 }
 
+impl sqlx::Type<sqlx::Sqlite> for State {
+    fn type_info() -> sqlx::sqlite::SqliteTypeInfo {
+        <str as sqlx::Type<sqlx::Sqlite>>::type_info()
+    }
+}
+
+impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for State {
+    fn decode(value: sqlx::sqlite::SqliteValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> {
+        let s = <&str as sqlx::Decode<sqlx::Sqlite>>::decode(value)?;
+        s.parse::<State>()
+            .map_err(|e| Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e)).into())
+    }
+}
+
+impl sqlx::Encode<'_, sqlx::Sqlite> for State {
+    fn encode_by_ref(
+        &self,
+        buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'_>,
+    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
+        <&str as sqlx::Encode<sqlx::Sqlite>>::encode_by_ref(&self.as_str(), buf)
+    }
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
 pub struct Backlog {
     pub id: i64,
@@ -56,7 +79,7 @@ pub struct Task {
     pub parent_id: Option<i64>,
     pub title: String,
     pub description: Option<String>,
-    pub state: String,
+    pub state: State,
     pub created_at: String,
     pub updated_at: String,
 }
diff --git a/src/ops/task.rs b/src/ops/task.rs
index dc487b3..18e59dd 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -1,6 +1,6 @@
 use crate::error::RangerError;
 use crate::key;
-use crate::models::Task;
+use crate::models::{State, Task};
 use crate::position;
 use sqlx::SqlitePool;
 
@@ -8,12 +8,12 @@ pub async fn create(
     pool: &SqlitePool,
     title: &str,
     backlog_id: i64,
-    state: Option<&str>,
+    state: Option<State>,
     parent_id: Option<i64>,
     description: Option<&str>,
 ) -> Result<Task, RangerError> {
     let key = key::generate_key();
-    let state = state.unwrap_or("icebox");
+    let state = state.unwrap_or(State::Icebox);
 
     let task = sqlx::query_as::<_, Task>(
         "INSERT INTO tasks (key, parent_id, title, description, state) \
@@ -24,7 +24,7 @@ pub async fn create(
     .bind(parent_id)
     .bind(title)
     .bind(description)
-    .bind(state)
+    .bind(state.as_str())
     .fetch_one(pool)
     .await?;
 
@@ -36,7 +36,7 @@ pub async fn create(
          ORDER BY bt.position DESC LIMIT 1",
     )
     .bind(backlog_id)
-    .bind(state)
+    .bind(state.as_str())
     .fetch_optional(pool)
     .await?;
 
@@ -55,7 +55,7 @@ pub async fn create(
 pub async fn list(
     pool: &SqlitePool,
     backlog_id: i64,
-    state_filter: Option<&str>,
+    state_filter: Option<State>,
 ) -> Result<Vec<Task>, RangerError> {
     let tasks = if let Some(state) = state_filter {
         sqlx::query_as::<_, Task>(
@@ -67,7 +67,7 @@ pub async fn list(
              ORDER BY bt.position",
         )
         .bind(backlog_id)
-        .bind(state)
+        .bind(state.as_str())
         .fetch_all(pool)
         .await?
     } else {
@@ -119,7 +119,7 @@ pub async fn edit(
     task_id: i64,
     title: Option<&str>,
     description: Option<&str>,
-    state: Option<&str>,
+    state: Option<State>,
 ) -> Result<Task, RangerError> {
     if let Some(title) = title {
         sqlx::query("UPDATE tasks SET title = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?")
@@ -135,9 +135,9 @@ pub async fn edit(
             .execute(pool)
             .await?;
     }
-    if let Some(state) = state {
+    if let Some(state) = &state {
         sqlx::query("UPDATE tasks SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?")
-            .bind(state)
+            .bind(state.as_str())
             .bind(task_id)
             .execute(pool)
             .await?;
@@ -255,6 +255,7 @@ pub async fn delete(pool: &SqlitePool, task_id: i64) -> Result<(), RangerError>
 mod tests {
     use super::*;
     use crate::db;
+    use crate::models::State;
     use crate::ops::backlog;
     use tempfile::tempdir;
 
@@ -273,7 +274,7 @@ mod tests {
             .unwrap();
 
         assert_eq!(task.title, "My Task");
-        assert_eq!(task.state, "icebox");
+        assert_eq!(task.state, State::Icebox);
         assert!(!task.key.is_empty());
 
         // Verify backlog_tasks join row exists
@@ -316,15 +317,15 @@ mod tests {
         create(&pool, "Icebox task", bl.id, None, None, None)
             .await
             .unwrap();
-        create(&pool, "Queued task", bl.id, Some("queued"), None, None)
+        create(&pool, "Queued task", bl.id, Some(State::Queued), None, None)
             .await
             .unwrap();
 
-        let icebox = list(&pool, bl.id, Some("icebox")).await.unwrap();
+        let icebox = list(&pool, bl.id, Some(State::Icebox)).await.unwrap();
         assert_eq!(icebox.len(), 1);
         assert_eq!(icebox[0].title, "Icebox task");
 
-        let queued = list(&pool, bl.id, Some("queued")).await.unwrap();
+        let queued = list(&pool, bl.id, Some(State::Queued)).await.unwrap();
         assert_eq!(queued.len(), 1);
         assert_eq!(queued[0].title, "Queued task");
     }
@@ -354,14 +355,14 @@ mod tests {
             task.id,
             Some("Updated"),
             Some("A description"),
-            Some("queued"),
+            Some(State::Queued),
         )
         .await
         .unwrap();
 
         assert_eq!(updated.title, "Updated");
         assert_eq!(updated.description.as_deref(), Some("A description"));
-        assert_eq!(updated.state, "queued");
+        assert_eq!(updated.state, State::Queued);
     }
 
     #[tokio::test]
diff --git a/tests/cli.rs b/tests/cli.rs
index ea57721..0eb181f 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -1,5 +1,5 @@
-use assert_cmd::cargo::cargo_bin_cmd;
 use assert_cmd::Command;
+use assert_cmd::cargo::cargo_bin_cmd;
 use tempfile::tempdir;
 
 fn ranger(db_path: &str) -> Command {