next.rs

  1use anyhow::{bail, Result};
  2use comfy_table::presets::NOTHING;
  3use comfy_table::{Cell, Table};
  4use std::collections::HashSet;
  5use std::path::Path;
  6
  7use crate::color::{cell_bold, stdout_use_color};
  8use crate::db;
  9use crate::score::{self, Mode};
 10
 11/// Parse the mode string from the CLI.
 12fn parse_mode(s: &str) -> Result<Mode> {
 13    match s {
 14        "impact" => Ok(Mode::Impact),
 15        "effort" => Ok(Mode::Effort),
 16        _ => bail!("invalid mode '{s}': expected impact or effort"),
 17    }
 18}
 19
 20pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) -> Result<()> {
 21    let mode = parse_mode(mode_str)?;
 22    let conn = db::open(root)?;
 23
 24    // Load all open tasks.
 25    let mut stmt = conn.prepare(
 26        "SELECT id, title, priority, effort
 27         FROM tasks
 28         WHERE status = 'open'",
 29    )?;
 30    let open_tasks: Vec<(String, String, i32, i32)> = stmt
 31        .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
 32        .collect::<rusqlite::Result<_>>()?;
 33
 34    // Load all blocker edges between open tasks.
 35    let mut edge_stmt = conn.prepare(
 36        "SELECT b.task_id, b.blocker_id
 37         FROM blockers b
 38         JOIN tasks t1 ON b.task_id = t1.id
 39         JOIN tasks t2 ON b.blocker_id = t2.id
 40         WHERE t1.status = 'open' AND t2.status = 'open'",
 41    )?;
 42    let edges: Vec<(String, String)> = edge_stmt
 43        .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
 44        .collect::<rusqlite::Result<_>>()?;
 45
 46    // Parents with at least one open subtask are not actionable work
 47    // units — exclude them from candidates while keeping them in the
 48    // graph for downstream scoring.
 49    let mut parent_stmt =
 50        conn.prepare("SELECT DISTINCT parent FROM tasks WHERE parent != '' AND status = 'open'")?;
 51    let parents_with_open_children: HashSet<String> = parent_stmt
 52        .query_map([], |r| r.get(0))?
 53        .collect::<rusqlite::Result<_>>()?;
 54
 55    let scored = score::rank(
 56        &open_tasks,
 57        &edges,
 58        &parents_with_open_children,
 59        mode,
 60        limit,
 61    );
 62
 63    if scored.is_empty() {
 64        if json {
 65            println!("[]");
 66        } else {
 67            println!("No open tasks.");
 68        }
 69        return Ok(());
 70    }
 71
 72    if json {
 73        let items: Vec<serde_json::Value> = scored
 74            .iter()
 75            .enumerate()
 76            .map(|(i, s)| {
 77                serde_json::json!({
 78                    "rank": i + 1,
 79                    "id": s.id,
 80                    "title": s.title,
 81                    "score": s.score,
 82                    "priority": db::priority_label(s.priority),
 83                    "effort": db::effort_label(s.effort),
 84                    "downstream_score": s.downstream_score,
 85                    "priority_weight": s.priority_weight,
 86                    "effort_weight": s.effort_weight,
 87                    "total_unblocked": s.total_unblocked,
 88                    "direct_unblocked": s.direct_unblocked,
 89                })
 90            })
 91            .collect();
 92        println!("{}", serde_json::to_string(&items)?);
 93    } else {
 94        let use_color = stdout_use_color();
 95        let mut table = Table::new();
 96        table.load_preset(NOTHING);
 97        table.set_header(vec!["#", "ID", "SCORE", "TITLE"]);
 98
 99        for (i, s) in scored.iter().enumerate() {
100            table.add_row(vec![
101                Cell::new(i + 1),
102                cell_bold(&s.id, use_color),
103                Cell::new(format!("{:.2}", s.score)),
104                Cell::new(&s.title),
105            ]);
106        }
107        println!("{table}");
108
109        if verbose {
110            let mode_label = match mode {
111                Mode::Impact => "impact",
112                Mode::Effort => "effort",
113            };
114            println!();
115            println!("mode: {mode_label}");
116            println!();
117            for (i, s) in scored.iter().enumerate() {
118                println!("{}. {} — score: {:.2}", i + 1, s.id, s.score);
119                match mode {
120                    Mode::Impact => {
121                        println!(
122                            "   ({:.2} + 1.00) × {:.0} / {:.0} = {:.2}",
123                            s.downstream_score, s.priority_weight, s.effort_weight, s.score
124                        );
125                    }
126                    Mode::Effort => {
127                        println!(
128                            "   ({:.2} × 0.25 + 1.00) × {:.0} / {:.0}² = {:.2}",
129                            s.downstream_score, s.priority_weight, s.effort_weight, s.score
130                        );
131                    }
132                }
133                let unblocked = if s.direct_unblocked == s.total_unblocked {
134                    format!("{} tasks", s.total_unblocked)
135                } else {
136                    format!(
137                        "{} tasks ({} directly)",
138                        s.total_unblocked, s.direct_unblocked
139                    )
140                };
141                println!("   Unblocks: {unblocked}");
142            }
143        }
144    }
145
146    Ok(())
147}