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
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 store = db::open(root)?;
22 let all = store.list_tasks()?;
23
24 let open_tasks: Vec<score::TaskInput> = all
25 .iter()
26 .filter(|t| t.status == db::Status::Open)
27 .map(|t| score::TaskInput {
28 id: t.id.as_str().to_string(),
29 title: t.title.clone(),
30 priority_score: t.priority.score(),
31 effort_score: t.effort.score(),
32 priority_label: db::priority_label(t.priority).to_string(),
33 effort_label: db::effort_label(t.effort).to_string(),
34 })
35 .collect();
36
37 let edges: Vec<(String, String)> = all
38 .iter()
39 .filter(|t| t.status == db::Status::Open)
40 .flat_map(|t| {
41 t.blockers
42 .iter()
43 .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
44 .collect::<Vec<_>>()
45 })
46 .collect();
47
48 let parents_with_open_children: HashSet<String> = all
49 .iter()
50 .filter(|t| t.status == db::Status::Open)
51 .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
52 .collect();
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 out: Vec<_> = 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": s.priority,
82 "effort": 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(&out)?);
92 } else {
93 let use_color = stdout_use_color();
94 let mut table = Table::new();
95 table.load_preset(NOTHING);
96 table.set_header(vec!["#", "ID", "SCORE", "TITLE"]);
97
98 for (i, s) in scored.iter().enumerate() {
99 let short = db::TaskId::display_id(&s.id);
100 table.add_row(vec![
101 Cell::new(i + 1),
102 cell_bold(&short, 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!("\nmode: {mode_label}");
115 for (i, s) in scored.iter().enumerate() {
116 let short = db::TaskId::display_id(&s.id);
117 let formula = match mode {
118 Mode::Impact => format!(
119 "({:.2} + 1.00) × {:.2} / {:.2}^0.25 = {:.2}",
120 s.downstream_score, s.priority_weight, s.effort_weight, s.score
121 ),
122 Mode::Effort => format!(
123 "({:.2} × 0.25 + 1.00) × {:.2} / {:.2}² = {:.2}",
124 s.downstream_score, s.priority_weight, s.effort_weight, s.score
125 ),
126 };
127 let task_word = if s.total_unblocked == 1 {
128 "task"
129 } else {
130 "tasks"
131 };
132 println!(
133 "\n{}. {}\n {}\n Unblocks: {} {} ({} directly)",
134 i + 1,
135 short,
136 formula,
137 s.total_unblocked,
138 task_word,
139 s.direct_unblocked
140 );
141 }
142 }
143 }
144
145 Ok(())
146}