next.rs

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