hjlib 0.1.3

Core library for hj handoff workflows
Documentation
use std::{collections::BTreeSet, path::Path, process::Command};

use anyhow::{Context, Result, bail};
use serde::Deserialize;

use crate::TodoSnapshot;

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum TodoStatus {
    Pending,
    InProgress,
    Completed,
    Cancelled,
}

impl TodoStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::InProgress => "in_progress",
            Self::Completed => "completed",
            Self::Cancelled => "cancelled",
        }
    }
}

#[derive(Debug, Clone)]
pub struct DoobClient {
    cwd: std::path::PathBuf,
}

#[derive(Debug, Deserialize)]
struct TodoList {
    #[serde(default)]
    todos: Vec<Todo>,
}

#[derive(Debug, Deserialize)]
struct Todo {
    #[serde(default)]
    content: String,
}

impl DoobClient {
    pub fn new(cwd: impl Into<std::path::PathBuf>) -> Self {
        Self { cwd: cwd.into() }
    }

    pub fn list_titles(&self, project: &str, status: TodoStatus) -> Result<Vec<String>> {
        let output = Command::new("doob")
            .args([
                "todo",
                "list",
                "-p",
                project,
                "--status",
                status.as_str(),
                "--json",
            ])
            .current_dir(&self.cwd)
            .output()
            .context("failed to run doob todo list")?;

        if !output.status.success() {
            return Ok(Vec::new());
        }

        let parsed: TodoList = serde_json::from_slice(&output.stdout)
            .context("failed to parse doob todo list output")?;
        Ok(parsed
            .todos
            .into_iter()
            .map(|todo| todo.content)
            .filter(|content| !content.is_empty())
            .collect())
    }

    pub fn snapshot(&self, project: &str) -> Result<TodoSnapshot> {
        Ok(TodoSnapshot {
            active_titles: unique_titles(
                self.list_titles(project, TodoStatus::Pending)?
                    .into_iter()
                    .chain(self.list_titles(project, TodoStatus::InProgress)?)
                    .collect::<Vec<_>>(),
            ),
            closed_titles: unique_titles(
                self.list_titles(project, TodoStatus::Completed)?
                    .into_iter()
                    .chain(self.list_titles(project, TodoStatus::Cancelled)?)
                    .collect::<Vec<_>>(),
            ),
        })
    }

    pub fn add(&self, project: &str, title: &str, priority: u8, tags: &[String]) -> Result<()> {
        let mut command = Command::new("doob");
        command
            .args(["todo", "add", title, "--priority"])
            .arg(priority.to_string())
            .args(["-p", project]);
        if !tags.is_empty() {
            command.args(["-t", &tags.join(",")]);
        }

        let status = command
            .current_dir(&self.cwd)
            .status()
            .context("failed to run doob todo add")?;
        if !status.success() {
            bail!("doob todo add failed for `{title}`");
        }
        Ok(())
    }
}

pub fn map_priority(priority: Option<&str>) -> u8 {
    match priority {
        Some("P0") => 5,
        Some("P1") => 4,
        Some("P2") => 3,
        _ => 1,
    }
}

pub fn unique_titles<I>(titles: I) -> Vec<String>
where
    I: IntoIterator<Item = String>,
{
    let mut set = BTreeSet::new();
    for title in titles {
        if !title.is_empty() {
            set.insert(title);
        }
    }
    set.into_iter().collect()
}

pub fn ensure_doob_on_path(cwd: &Path) -> Result<()> {
    ensure_command("doob", cwd)
}

fn ensure_command(program: &str, cwd: &Path) -> Result<()> {
    let output = Command::new("sh")
        .args(["-c", &format!("command -v {program}")])
        .current_dir(cwd)
        .output()
        .with_context(|| format!("failed to probe {program}"))?;
    if !output.status.success() {
        bail!("{program} not on PATH");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{map_priority, unique_titles};

    #[test]
    fn priority_mapping_matches_shell_script() {
        assert_eq!(map_priority(Some("P0")), 5);
        assert_eq!(map_priority(Some("P1")), 4);
        assert_eq!(map_priority(Some("P2")), 3);
        assert_eq!(map_priority(Some("other")), 1);
    }

    #[test]
    fn deduplicates_titles() {
        let values = unique_titles(vec![
            "A".to_string(),
            "B".to_string(),
            "A".to_string(),
            String::new(),
        ]);
        assert_eq!(values, vec!["A".to_string(), "B".to_string()]);
    }
}