From d76a593428419ca3ecbec2c6e34d21ff221f55cb Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 2 Mar 2026 12:37:43 -0700 Subject: [PATCH] Fix next --json emitting priority/effort as integers instead of labels --- src/cmd/next.rs | 16 +++++------ src/score.rs | 72 +++++++++++++++++++++++++++++++++++++---------- tests/cli_next.rs | 21 ++++++++++++++ 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/src/cmd/next.rs b/src/cmd/next.rs index 1da1d3711b567fe0a29909f9dbff43e5f8a27a2d..42ca063662c62797980b5f74a2138676dd1c87c5 100644 --- a/src/cmd/next.rs +++ b/src/cmd/next.rs @@ -21,16 +21,16 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) let store = db::open(root)?; let all = store.list_tasks()?; - let open_tasks: Vec<(String, String, i32, i32)> = all + let open_tasks: Vec = all .iter() .filter(|t| t.status == db::Status::Open) - .map(|t| { - ( - t.id.as_str().to_string(), - t.title.clone(), - t.priority.score(), - t.effort.score(), - ) + .map(|t| score::TaskInput { + id: t.id.as_str().to_string(), + title: t.title.clone(), + priority_score: t.priority.score(), + effort_score: t.effort.score(), + priority_label: db::priority_label(t.priority).to_string(), + effort_label: db::effort_label(t.effort).to_string(), }) .collect(); diff --git a/src/score.rs b/src/score.rs index 2ec790d014842fa84a90b6462cf063380479de72..5a1792d700e7a6a1868dcdb9ae3d327f35a8725f 100644 --- a/src/score.rs +++ b/src/score.rs @@ -19,6 +19,24 @@ pub enum Mode { Effort, } +/// Input task supplied by callers to [`rank`]. +/// +/// Separates the label strings (for output) from the integer scores (for +/// computation), so callers don't have to reverse-engineer labels from ints. +#[derive(Debug, Clone)] +pub struct TaskInput { + pub id: String, + pub title: String, + /// Integer score used for weighting (1=high, 2=medium, 3=low for priority). + pub priority_score: i32, + /// Integer score used for weighting (1=low, 2=medium, 3=high for effort). + pub effort_score: i32, + /// Human-readable label, e.g. `"high"`, `"medium"`, `"low"`. + pub priority_label: String, + /// Human-readable label, e.g. `"low"`, `"medium"`, `"high"`. + pub effort_label: String, +} + /// A lightweight snapshot of a task for scoring purposes. #[derive(Debug, Clone)] pub struct TaskNode { @@ -32,8 +50,10 @@ pub struct TaskNode { pub struct ScoredTask { pub id: String, pub title: String, - pub priority: i32, - pub effort: i32, + /// Original priority label (`"high"` / `"medium"` / `"low"`). + pub priority: String, + /// Original effort label (`"low"` / `"medium"` / `"high"`). + pub effort: String, pub score: f64, pub downstream_score: f64, pub priority_weight: f64, @@ -112,32 +132,36 @@ fn downstream( /// Score and rank ready tasks. /// /// # Arguments -/// * `open_tasks` — all open tasks (id, title, priority, effort) +/// * `open_tasks` — all open tasks, including both scoring integers and display labels /// * `blocker_edges` — `(task_id, blocker_id)` pairs among open tasks /// * `exclude` — task IDs to exclude from candidates (still counted in /// downstream scores). Used to filter parent tasks that have open subtasks. /// * `mode` — scoring strategy /// * `limit` — maximum number of results to return pub fn rank( - open_tasks: &[(String, String, i32, i32)], + open_tasks: &[TaskInput], blocker_edges: &[(String, String)], exclude: &HashSet, mode: Mode, limit: usize, ) -> Vec { - // Build node map. + // Build node map and label lookup. let mut nodes: HashMap = HashMap::new(); let mut titles: HashMap = HashMap::new(); - for (id, title, priority, effort) in open_tasks { + let mut priority_labels: HashMap = HashMap::new(); + let mut effort_labels: HashMap = HashMap::new(); + for t in open_tasks { nodes.insert( - id.clone(), + t.id.clone(), TaskNode { - id: id.clone(), - priority: *priority, - effort: *effort, + id: t.id.clone(), + priority: t.priority_score, + effort: t.effort_score, }, ); - titles.insert(id.clone(), title.clone()); + titles.insert(t.id.clone(), t.title.clone()); + priority_labels.insert(t.id.clone(), t.priority_label.clone()); + effort_labels.insert(t.id.clone(), t.effort_label.clone()); } // Build adjacency lists. @@ -183,8 +207,8 @@ pub fn rank( ScoredTask { id: node.id.clone(), title: titles.get(&node.id).cloned().unwrap_or_default(), - priority: node.priority, - effort: node.effort, + priority: priority_labels.get(&node.id).cloned().unwrap_or_default(), + effort: effort_labels.get(&node.id).cloned().unwrap_or_default(), score, downstream_score: ds, priority_weight: pw, @@ -211,8 +235,26 @@ pub fn rank( mod tests { use super::*; - fn task(id: &str, title: &str, pri: i32, eff: i32) -> (String, String, i32, i32) { - (id.to_string(), title.to_string(), pri, eff) + fn task(id: &str, title: &str, pri: i32, eff: i32) -> TaskInput { + // Map integer scores back to labels for test convenience. + let priority_label = match pri { + 1 => "high", + 2 => "medium", + _ => "low", + }; + let effort_label = match eff { + 1 => "low", + 2 => "medium", + _ => "high", + }; + TaskInput { + id: id.to_string(), + title: title.to_string(), + priority_score: pri, + effort_score: eff, + priority_label: priority_label.to_string(), + effort_label: effort_label.to_string(), + } } fn edge(task_id: &str, blocker_id: &str) -> (String, String) { diff --git a/tests/cli_next.rs b/tests/cli_next.rs index 02693ba29f87736e94f68c0e59023fef38e32d3f..18de6d379a3a57d5466dd1261b64998a412327ba 100644 --- a/tests/cli_next.rs +++ b/tests/cli_next.rs @@ -161,6 +161,27 @@ fn next_json_empty() { assert_eq!(v.as_array().unwrap().len(), 0); } +#[test] +fn next_json_priority_and_effort_are_strings() { + // Regression: next --json was emitting priority/effort as raw integers + // (1/2/3) instead of string labels ("high"/"medium"/"low"). Every other + // JSON endpoint uses string labels; next must match. + let tmp = init_tmp(); + create_task(&tmp, "Task", "high", "low"); + + let out = td(&tmp) + .args(["--json", "next"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let results = v.as_array().unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0]["priority"].as_str().unwrap(), "high"); + assert_eq!(results[0]["effort"].as_str().unwrap(), "low"); +} + #[test] fn next_invalid_mode_fails() { let tmp = init_tmp();