next.rs

  1use anyhow::{bail, Result};
  2use comfy_table::presets::NOTHING;
  3use comfy_table::{Cell, Table};
  4use std::collections::{HashMap, HashSet};
  5use std::path::Path;
  6
  7use crate::color::{cell_bold, stdout_use_color};
  8use crate::db;
  9use crate::model::{Status, TaskId};
 10use crate::score::{self, Mode};
 11
 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 store = db::open(root)?;
 23    let all = store.list_tasks()?;
 24
 25    let open_tasks: Vec<score::TaskInput> = all
 26        .iter()
 27        .filter(|t| t.status == Status::Open)
 28        .map(|t| score::TaskInput {
 29            id: t.id.to_string(),
 30            title: t.title.clone(),
 31            priority_score: t.priority.score(),
 32            effort_score: t.effort.score(),
 33            priority_label: t.priority.as_str().to_string(),
 34            effort_label: t.effort.as_str().to_string(),
 35        })
 36        .collect();
 37
 38    let edges: Vec<(String, String)> = all
 39        .iter()
 40        .filter(|t| t.status == Status::Open)
 41        .flat_map(|t| {
 42            t.blockers
 43                .iter()
 44                .map(|b| (t.id.to_string(), b.to_string()))
 45                .collect::<Vec<_>>()
 46        })
 47        .collect();
 48
 49    let parents_with_open_children: HashSet<String> = all
 50        .iter()
 51        .filter(|t| t.status == Status::Open)
 52        .filter_map(|t| t.parent.as_ref().map(ToString::to_string))
 53        .collect();
 54
 55    let scored = score::rank(
 56        &open_tasks,
 57        &edges,
 58        &parents_with_open_children,
 59        mode,
 60        limit,
 61    );
 62
 63    // Build a lookup from task ID to labels for display.
 64    let labels_by_id: HashMap<&str, &[String]> = all
 65        .iter()
 66        .map(|t| (t.id.as_str(), t.labels.as_slice()))
 67        .collect();
 68
 69    if scored.is_empty() {
 70        if json {
 71            println!("[]");
 72        } else {
 73            println!("No open tasks.");
 74        }
 75        return Ok(());
 76    }
 77
 78    if json {
 79        let out: Vec<_> = scored
 80            .iter()
 81            .enumerate()
 82            .map(|(i, s)| {
 83                serde_json::json!({
 84                    "rank": i + 1,
 85                    "id": s.id,
 86                    "title": s.title,
 87                    "score": s.score,
 88                    "priority": s.priority,
 89                    "effort": s.effort,
 90                    "downstream_score": s.downstream_score,
 91                    "priority_weight": s.priority_weight,
 92                    "effort_weight": s.effort_weight,
 93                    "total_unblocked": s.total_unblocked,
 94                    "direct_unblocked": s.direct_unblocked,
 95                })
 96            })
 97            .collect();
 98        println!("{}", serde_json::to_string(&out)?);
 99    } else {
100        let use_color = stdout_use_color();
101        let mut table = Table::new();
102        table.load_preset(NOTHING);
103        table.set_header(vec!["#", "ID", "SCORE", "LABELS", "TITLE"]);
104
105        for (i, s) in scored.iter().enumerate() {
106            let short = TaskId::display_id(&s.id);
107            let labels = labels_by_id
108                .get(s.id.as_str())
109                .map(|ls| ls.join(", "))
110                .unwrap_or_default();
111            table.add_row(vec![
112                Cell::new(i + 1),
113                cell_bold(&short, use_color),
114                Cell::new(format!("{:.2}", s.score)),
115                Cell::new(labels),
116                Cell::new(&s.title),
117            ]);
118        }
119        println!("{table}");
120
121        if verbose {
122            let mode_label = match mode {
123                Mode::Impact => "impact",
124                Mode::Effort => "effort",
125            };
126            println!("\nmode: {mode_label}");
127            for (i, s) in scored.iter().enumerate() {
128                let short = TaskId::display_id(&s.id);
129                let formula = match mode {
130                    Mode::Impact => format!(
131                        "({:.2} + 1.00) × {:.2} / {:.2}^0.25 = {:.2}",
132                        s.downstream_score, s.priority_weight, s.effort_weight, s.score
133                    ),
134                    Mode::Effort => format!(
135                        "({:.2} × 0.25 + 1.00) × {:.2} / {:.2}² = {:.2}",
136                        s.downstream_score, s.priority_weight, s.effort_weight, s.score
137                    ),
138                };
139                let task_word = if s.total_unblocked == 1 {
140                    "task"
141                } else {
142                    "tasks"
143                };
144                println!(
145                    "\n{}. {}\n   {}\n   Unblocks: {} {} ({} directly)",
146                    i + 1,
147                    short,
148                    formula,
149                    s.total_unblocked,
150                    task_word,
151                    s.direct_unblocked
152                );
153            }
154        }
155    }
156
157    Ok(())
158}