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