1use std::{collections::BTreeSet, path::Path, process::Command};
2
3use anyhow::{Context, Result, bail};
4use hj_core::TodoSnapshot;
5use serde::Deserialize;
6
7#[derive(Debug, Clone, Copy, Eq, PartialEq)]
8pub enum TodoStatus {
9 Pending,
10 InProgress,
11 Completed,
12 Cancelled,
13}
14
15impl TodoStatus {
16 pub fn as_str(self) -> &'static str {
17 match self {
18 Self::Pending => "pending",
19 Self::InProgress => "in_progress",
20 Self::Completed => "completed",
21 Self::Cancelled => "cancelled",
22 }
23 }
24}
25
26#[derive(Debug, Clone)]
27pub struct DoobClient {
28 cwd: std::path::PathBuf,
29}
30
31#[derive(Debug, Deserialize)]
32struct TodoList {
33 #[serde(default)]
34 todos: Vec<Todo>,
35}
36
37#[derive(Debug, Deserialize)]
38struct Todo {
39 #[serde(default)]
40 content: String,
41}
42
43impl DoobClient {
44 pub fn new(cwd: impl Into<std::path::PathBuf>) -> Self {
45 Self { cwd: cwd.into() }
46 }
47
48 pub fn list_titles(&self, project: &str, status: TodoStatus) -> Result<Vec<String>> {
49 let output = Command::new("doob")
50 .args([
51 "todo",
52 "list",
53 "-p",
54 project,
55 "--status",
56 status.as_str(),
57 "--json",
58 ])
59 .current_dir(&self.cwd)
60 .output()
61 .context("failed to run doob todo list")?;
62
63 if !output.status.success() {
64 return Ok(Vec::new());
65 }
66
67 let parsed: TodoList = serde_json::from_slice(&output.stdout)
68 .context("failed to parse doob todo list output")?;
69 Ok(parsed
70 .todos
71 .into_iter()
72 .map(|todo| todo.content)
73 .filter(|content| !content.is_empty())
74 .collect())
75 }
76
77 pub fn snapshot(&self, project: &str) -> Result<TodoSnapshot> {
78 Ok(TodoSnapshot {
79 active_titles: unique_titles(
80 self.list_titles(project, TodoStatus::Pending)?
81 .into_iter()
82 .chain(self.list_titles(project, TodoStatus::InProgress)?)
83 .collect::<Vec<_>>(),
84 ),
85 closed_titles: unique_titles(
86 self.list_titles(project, TodoStatus::Completed)?
87 .into_iter()
88 .chain(self.list_titles(project, TodoStatus::Cancelled)?)
89 .collect::<Vec<_>>(),
90 ),
91 })
92 }
93
94 pub fn add(&self, project: &str, title: &str, priority: u8, tags: &[String]) -> Result<()> {
95 let mut command = Command::new("doob");
96 command
97 .args(["todo", "add", title, "--priority"])
98 .arg(priority.to_string())
99 .args(["-p", project]);
100 if !tags.is_empty() {
101 command.args(["-t", &tags.join(",")]);
102 }
103
104 let status = command
105 .current_dir(&self.cwd)
106 .status()
107 .context("failed to run doob todo add")?;
108 if !status.success() {
109 bail!("doob todo add failed for `{title}`");
110 }
111 Ok(())
112 }
113}
114
115pub fn map_priority(priority: Option<&str>) -> u8 {
116 match priority {
117 Some("P0") => 5,
118 Some("P1") => 4,
119 Some("P2") => 3,
120 _ => 1,
121 }
122}
123
124pub fn unique_titles<I>(titles: I) -> Vec<String>
125where
126 I: IntoIterator<Item = String>,
127{
128 let mut set = BTreeSet::new();
129 for title in titles {
130 if !title.is_empty() {
131 set.insert(title);
132 }
133 }
134 set.into_iter().collect()
135}
136
137pub fn ensure_doob_on_path(cwd: &Path) -> Result<()> {
138 ensure_command("doob", cwd)
139}
140
141fn ensure_command(program: &str, cwd: &Path) -> Result<()> {
142 let output = Command::new("sh")
143 .args(["-c", &format!("command -v {program}")])
144 .current_dir(cwd)
145 .output()
146 .with_context(|| format!("failed to probe {program}"))?;
147 if !output.status.success() {
148 bail!("{program} not on PATH");
149 }
150 Ok(())
151}
152
153#[cfg(test)]
154mod tests {
155 use super::{map_priority, unique_titles};
156
157 #[test]
158 fn priority_mapping_matches_shell_script() {
159 assert_eq!(map_priority(Some("P0")), 5);
160 assert_eq!(map_priority(Some("P1")), 4);
161 assert_eq!(map_priority(Some("P2")), 3);
162 assert_eq!(map_priority(Some("other")), 1);
163 }
164
165 #[test]
166 fn deduplicates_titles() {
167 let values = unique_titles(vec![
168 "A".to_string(),
169 "B".to_string(),
170 "A".to_string(),
171 String::new(),
172 ]);
173 assert_eq!(values, vec!["A".to_string(), "B".to_string()]);
174 }
175}