Skip to main content

hj_doob/
lib.rs

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}