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
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 {