next.rs

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