From 1665ae0fc74a7ef8212b2de44f98672c1f5bc9b5 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 25 Feb 2026 21:02:23 +0000 Subject: [PATCH] Wire next subcommand with --mode, --verbose, -n flags Add next to cli.rs, cmd/next.rs, and cmd/mod.rs. Queries open tasks and blocker edges from the DB, feeds them to score::rank(), and renders results using comfy-table with a header row. --- src/cli.rs | 15 ++++++ src/cmd/mod.rs | 9 ++++ src/cmd/next.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/cmd/next.rs diff --git a/src/cli.rs b/src/cli.rs index 3101d3097851adef69f431d2834a10a26a0bfe67..a032c27d49f88b4c66466577324f489669ad33aa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -139,6 +139,21 @@ pub enum Command { /// Show tasks with no open blockers Ready, + /// Recommend next task(s) to work on + Next { + /// Scoring strategy: impact (default) or effort + #[arg(short, long, default_value = "impact")] + mode: String, + + /// Show signal breakdown and equation + #[arg(short, long)] + verbose: bool, + + /// Maximum number of results + #[arg(short = 'n', long = "limit", default_value = "5")] + limit: usize, + }, + /// Show task statistics (always JSON) Stats, diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 34560faa7f7642a48f850cd94db394fc0e14720d..9d5cf06da5c7d0e38e898e4c8feec8895b8ca5a8 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -7,6 +7,7 @@ mod import; mod init; mod label; mod list; +mod next; mod ready; mod reopen; mod search; @@ -123,6 +124,14 @@ pub fn dispatch(cli: &Cli) -> Result<()> { let root = require_root()?; ready::run(&root, cli.json) } + Command::Next { + mode, + verbose, + limit, + } => { + let root = require_root()?; + next::run(&root, mode, *verbose, *limit, cli.json) + } Command::Stats => { let root = require_root()?; stats::run(&root) diff --git a/src/cmd/next.rs b/src/cmd/next.rs new file mode 100644 index 0000000000000000000000000000000000000000..904a7616a6368ebc5aa46ef8861fcbcad4be39bb --- /dev/null +++ b/src/cmd/next.rs @@ -0,0 +1,130 @@ +use anyhow::{bail, Result}; +use comfy_table::presets::NOTHING; +use comfy_table::Table; +use std::path::Path; + +use crate::db; +use crate::score::{self, Mode}; + +/// Parse the mode string from the CLI. +fn parse_mode(s: &str) -> Result { + match s { + "impact" => Ok(Mode::Impact), + "effort" => Ok(Mode::Effort), + _ => bail!("invalid mode '{s}': expected impact or effort"), + } +} + +pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) -> Result<()> { + let mode = parse_mode(mode_str)?; + let conn = db::open(root)?; + + // Load all open tasks. + let mut stmt = conn.prepare( + "SELECT id, title, priority, effort + FROM tasks + WHERE status = 'open'", + )?; + let open_tasks: Vec<(String, String, i32, i32)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))? + .collect::>()?; + + // Load all blocker edges between open tasks. + let mut edge_stmt = conn.prepare( + "SELECT b.task_id, b.blocker_id + FROM blockers b + JOIN tasks t1 ON b.task_id = t1.id + JOIN tasks t2 ON b.blocker_id = t2.id + WHERE t1.status = 'open' AND t2.status = 'open'", + )?; + let edges: Vec<(String, String)> = edge_stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()?; + + let scored = score::rank(&open_tasks, &edges, mode, limit); + + if scored.is_empty() { + if json { + println!("[]"); + } else { + println!("No open tasks."); + } + return Ok(()); + } + + if json { + let items: Vec = scored + .iter() + .enumerate() + .map(|(i, s)| { + serde_json::json!({ + "rank": i + 1, + "id": s.id, + "title": s.title, + "score": s.score, + "priority": db::priority_label(s.priority), + "effort": db::effort_label(s.effort), + "downstream_score": s.downstream_score, + "priority_weight": s.priority_weight, + "effort_weight": s.effort_weight, + "total_unblocked": s.total_unblocked, + "direct_unblocked": s.direct_unblocked, + }) + }) + .collect(); + println!("{}", serde_json::to_string(&items)?); + } else { + let mut table = Table::new(); + table.load_preset(NOTHING); + table.set_header(vec!["#", "ID", "SCORE", "TITLE"]); + + for (i, s) in scored.iter().enumerate() { + let c = crate::color::stdout_theme(); + table.add_row(vec![ + format!("{}", i + 1), + format!("{}{}{}", c.bold, s.id, c.reset), + format!("{:.2}", s.score), + s.title.clone(), + ]); + } + println!("{table}"); + + if verbose { + let mode_label = match mode { + Mode::Impact => "impact", + Mode::Effort => "effort", + }; + println!(); + println!("mode: {mode_label}"); + println!(); + for (i, s) in scored.iter().enumerate() { + println!("{}. {} — score: {:.2}", i + 1, s.id, s.score); + match mode { + Mode::Impact => { + println!( + " ({:.2} + 1.00) × {:.0} / {:.0} = {:.2}", + s.downstream_score, s.priority_weight, s.effort_weight, s.score + ); + } + Mode::Effort => { + println!( + " ({:.2} × 0.25 + 1.00) × {:.0} / {:.0}² = {:.2}", + s.downstream_score, s.priority_weight, s.effort_weight, s.score + ); + } + } + let unblocked = if s.direct_unblocked == s.total_unblocked { + format!("{} tasks", s.total_unblocked) + } else { + format!( + "{} tasks ({} directly)", + s.total_unblocked, s.direct_unblocked + ) + }; + println!(" Unblocks: {unblocked}"); + } + } + } + + Ok(()) +}