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}