1use anyhow::{bail, Result};
2use comfy_table::presets::NOTHING;
3use comfy_table::Table;
4use std::path::Path;
5
6use crate::db;
7use crate::score::{self, Mode};
8
9/// Parse the mode string from the CLI.
10fn parse_mode(s: &str) -> Result<Mode> {
11 match s {
12 "impact" => Ok(Mode::Impact),
13 "effort" => Ok(Mode::Effort),
14 _ => bail!("invalid mode '{s}': expected impact or effort"),
15 }
16}
17
18pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool) -> Result<()> {
19 let mode = parse_mode(mode_str)?;
20 let conn = db::open(root)?;
21
22 // Load all open tasks.
23 let mut stmt = conn.prepare(
24 "SELECT id, title, priority, effort
25 FROM tasks
26 WHERE status = 'open'",
27 )?;
28 let open_tasks: Vec<(String, String, i32, i32)> = stmt
29 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?
30 .collect::<rusqlite::Result<_>>()?;
31
32 // Load all blocker edges between open tasks.
33 let mut edge_stmt = conn.prepare(
34 "SELECT b.task_id, b.blocker_id
35 FROM blockers b
36 JOIN tasks t1 ON b.task_id = t1.id
37 JOIN tasks t2 ON b.blocker_id = t2.id
38 WHERE t1.status = 'open' AND t2.status = 'open'",
39 )?;
40 let edges: Vec<(String, String)> = edge_stmt
41 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
42 .collect::<rusqlite::Result<_>>()?;
43
44 let scored = score::rank(&open_tasks, &edges, mode, limit);
45
46 if scored.is_empty() {
47 if json {
48 println!("[]");
49 } else {
50 println!("No open tasks.");
51 }
52 return Ok(());
53 }
54
55 if json {
56 let items: Vec<serde_json::Value> = scored
57 .iter()
58 .enumerate()
59 .map(|(i, s)| {
60 serde_json::json!({
61 "rank": i + 1,
62 "id": s.id,
63 "title": s.title,
64 "score": s.score,
65 "priority": db::priority_label(s.priority),
66 "effort": db::effort_label(s.effort),
67 "downstream_score": s.downstream_score,
68 "priority_weight": s.priority_weight,
69 "effort_weight": s.effort_weight,
70 "total_unblocked": s.total_unblocked,
71 "direct_unblocked": s.direct_unblocked,
72 })
73 })
74 .collect();
75 println!("{}", serde_json::to_string(&items)?);
76 } else {
77 let mut table = Table::new();
78 table.load_preset(NOTHING);
79 table.set_header(vec!["#", "ID", "SCORE", "TITLE"]);
80
81 for (i, s) in scored.iter().enumerate() {
82 let c = crate::color::stdout_theme();
83 table.add_row(vec![
84 format!("{}", i + 1),
85 format!("{}{}{}", c.bold, s.id, c.reset),
86 format!("{:.2}", s.score),
87 s.title.clone(),
88 ]);
89 }
90 println!("{table}");
91
92 if verbose {
93 let mode_label = match mode {
94 Mode::Impact => "impact",
95 Mode::Effort => "effort",
96 };
97 println!();
98 println!("mode: {mode_label}");
99 println!();
100 for (i, s) in scored.iter().enumerate() {
101 println!("{}. {} — score: {:.2}", i + 1, s.id, s.score);
102 match mode {
103 Mode::Impact => {
104 println!(
105 " ({:.2} + 1.00) × {:.0} / {:.0} = {:.2}",
106 s.downstream_score, s.priority_weight, s.effort_weight, s.score
107 );
108 }
109 Mode::Effort => {
110 println!(
111 " ({:.2} × 0.25 + 1.00) × {:.0} / {:.0}² = {:.2}",
112 s.downstream_score, s.priority_weight, s.effort_weight, s.score
113 );
114 }
115 }
116 let unblocked = if s.direct_unblocked == s.total_unblocked {
117 format!("{} tasks", s.total_unblocked)
118 } else {
119 format!(
120 "{} tasks ({} directly)",
121 s.total_unblocked, s.direct_unblocked
122 )
123 };
124 println!(" Unblocks: {unblocked}");
125 }
126 }
127 }
128
129 Ok(())
130}