1use anyhow::{bail, Result};
2use comfy_table::presets::NOTHING;
3use comfy_table::Table;
4use std::collections::HashSet;
5use std::path::Path;
6
7use crate::db;
8use crate::score::{self, Mode};
9
10/// Parse the mode string from the CLI.
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 conn = db::open(root)?;
22
23 // Load all open tasks.
24 let mut stmt = conn.prepare(
25 "SELECT id, title, priority, effort
26 FROM tasks
27 WHERE status = 'open'",
28 )?;
29 let open_tasks: Vec<(String, String, i32, i32)> = stmt
30 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
31 .collect::<rusqlite::Result<_>>()?;
32
33 // Load all blocker edges between open tasks.
34 let mut edge_stmt = conn.prepare(
35 "SELECT b.task_id, b.blocker_id
36 FROM blockers b
37 JOIN tasks t1 ON b.task_id = t1.id
38 JOIN tasks t2 ON b.blocker_id = t2.id
39 WHERE t1.status = 'open' AND t2.status = 'open'",
40 )?;
41 let edges: Vec<(String, String)> = edge_stmt
42 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
43 .collect::<rusqlite::Result<_>>()?;
44
45 // Parents with at least one open subtask are not actionable work
46 // units — exclude them from candidates while keeping them in the
47 // graph for downstream scoring.
48 let mut parent_stmt =
49 conn.prepare("SELECT DISTINCT parent FROM tasks WHERE parent != '' AND status = 'open'")?;
50 let parents_with_open_children: HashSet<String> = parent_stmt
51 .query_map([], |r| r.get(0))?
52 .collect::<rusqlite::Result<_>>()?;
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 items: Vec<serde_json::Value> = 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": db::priority_label(s.priority),
82 "effort": db::effort_label(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(&items)?);
92 } else {
93 let mut table = Table::new();
94 table.load_preset(NOTHING);
95 table.set_header(vec!["#", "ID", "SCORE", "TITLE"]);
96
97 for (i, s) in scored.iter().enumerate() {
98 let c = crate::color::stdout_theme();
99 table.add_row(vec![
100 format!("{}", i + 1),
101 format!("{}{}{}", c.bold, s.id, c.reset),
102 format!("{:.2}", s.score),
103 s.title.clone(),
104 ]);
105 }
106 println!("{table}");
107
108 if verbose {
109 let mode_label = match mode {
110 Mode::Impact => "impact",
111 Mode::Effort => "effort",
112 };
113 println!();
114 println!("mode: {mode_label}");
115 println!();
116 for (i, s) in scored.iter().enumerate() {
117 println!("{}. {} — score: {:.2}", i + 1, s.id, s.score);
118 match mode {
119 Mode::Impact => {
120 println!(
121 " ({:.2} + 1.00) × {:.0} / {:.0} = {:.2}",
122 s.downstream_score, s.priority_weight, s.effort_weight, s.score
123 );
124 }
125 Mode::Effort => {
126 println!(
127 " ({:.2} × 0.25 + 1.00) × {:.0} / {:.0}² = {:.2}",
128 s.downstream_score, s.priority_weight, s.effort_weight, s.score
129 );
130 }
131 }
132 let unblocked = if s.direct_unblocked == s.total_unblocked {
133 format!("{} tasks", s.total_unblocked)
134 } else {
135 format!(
136 "{} tasks ({} directly)",
137 s.total_unblocked, s.direct_unblocked
138 )
139 };
140 println!(" Unblocks: {unblocked}");
141 }
142 }
143 }
144
145 Ok(())
146}