tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Action generation using claude -p.

use serde::Deserialize;

use super::GitHubIssue;
use crate::error::GitHubError;

/// Action type for issue work
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ActionType {
    /// Implement new feature
    Implement,
    /// Survey/research codebase
    Survey,
    /// Fix a bug
    Bugfix,
    /// Refactor existing code
    Refactor,
    /// Write documentation
    Docs,
}

impl ActionType {
    /// Returns display string for the action type
    #[must_use]
    pub const fn as_str(&self) -> &'static str {
        match self {
            Self::Implement => "implement",
            Self::Survey => "survey",
            Self::Bugfix => "bugfix",
            Self::Refactor => "refactor",
            Self::Docs => "docs",
        }
    }

    /// Returns icon for the action type
    #[must_use]
    pub const fn icon(&self) -> &'static str {
        match self {
            Self::Implement => "+",
            Self::Survey => "?",
            Self::Bugfix => "!",
            Self::Refactor => "~",
            Self::Docs => "#",
        }
    }
}

impl std::fmt::Display for ActionType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

/// Action choice generated by claude -p
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ActionChoice {
    /// Branch name for the work
    pub branch: String,
    /// Type of action
    pub action: ActionType,
    /// Initial prompt for the session (must include issue number)
    pub prompt: String,
}

/// Response from claude -p with JSON schema
#[derive(Debug, Deserialize)]
struct ClaudeResponse {
    structured_output: StructuredOutput,
}

#[derive(Debug, Deserialize)]
struct StructuredOutput {
    choices: Vec<ActionChoice>,
}

/// JSON schema for claude -p structured output
const JSON_SCHEMA: &str = r#"{"type":"object","properties":{"choices":{"type":"array","items":{"type":"object","properties":{"branch":{"type":"string"},"action":{"type":"string","enum":["implement","survey","bugfix","refactor","docs"]},"prompt":{"type":"string"}},"required":["branch","action","prompt"]},"minItems":2,"maxItems":3}},"required":["choices"]}"#;

/// Generates action choices using claude -p.
///
/// # Errors
///
/// Returns error if claude command fails or output parsing fails.
pub async fn generate_choices(issue: &GitHubIssue) -> Result<Vec<ActionChoice>, GitHubError> {
    let prompt = build_prompt(issue);
    let response: ClaudeResponse = super::run_command(
        "claude",
        &[
            "-p",
            "--output-format",
            "json",
            "--json-schema",
            JSON_SCHEMA,
            "--model",
            "haiku",
            &prompt,
        ],
        "claude -p",
    )
    .await?;
    Ok(response.structured_output.choices)
}

/// Build prompt for claude -p from issue
#[must_use]
pub fn build_prompt(issue: &GitHubIssue) -> String {
    let labels = issue.label_names().join(", ");
    format!(
        r#"GitHub Issue #{}: {}

{}

Labels: {}

IMPORTANT: DO NOT search or explore any codebase. Generate actions based ONLY on the issue information above.

Generate 2-3 action choices with different approaches for this issue.
Each choice must include:
- branch: descriptive branch name (e.g., "feat/issue-integration", "fix/auth-bug")
- action: one of implement, survey, bugfix, refactor, docs
- prompt: initial prompt for Claude Code session (MUST include issue number #{}, 10 words max)

Respond immediately with practical, actionable approaches."#,
        issue.number, issue.title, issue.body, labels, issue.number
    )
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use rstest::rstest;

    #[rstest]
    #[case(ActionType::Implement, "implement", "+")]
    #[case(ActionType::Survey, "survey", "?")]
    #[case(ActionType::Bugfix, "bugfix", "!")]
    #[case(ActionType::Refactor, "refactor", "~")]
    #[case(ActionType::Docs, "docs", "#")]
    fn action_type_variants(#[case] action: ActionType, #[case] str_val: &str, #[case] icon: &str) {
        #[derive(serde::Deserialize)]
        struct W {
            action: ActionType,
        }
        // as_str, Display, icon
        assert_eq!(action.as_str(), str_val);
        assert_eq!(format!("{action}"), str_val);
        assert_eq!(action.icon(), icon);
        // deserialize
        let w: W = serde_json::from_str(&format!(r#"{{"action": "{str_val}"}}"#)).unwrap();
        assert_eq!(w.action, action);
    }

    #[test]
    fn action_choice_deserialize() {
        let json = r#"{
            "branch": "feat/issue-22",
            "action": "implement",
            "prompt": "Implement issue #22"
        }"#;

        let choice: ActionChoice = serde_json::from_str(json).unwrap();
        assert_eq!(choice.branch, "feat/issue-22");
        assert_eq!(choice.action, ActionType::Implement);
        assert_eq!(choice.prompt, "Implement issue #22");
    }

    #[test]
    fn build_prompt_includes_issue_number() {
        let issue = GitHubIssue {
            number: 42,
            title: "Test Issue".to_string(),
            body: "Test body".to_string(),
            labels: vec![],
            state: super::super::IssueState::Open,
        };

        let prompt = build_prompt(&issue);
        assert!(prompt.contains("#42"));
        assert!(prompt.contains("Test Issue"));
        assert!(prompt.contains("Test body"));
    }

    #[test]
    fn claude_response_deserialize() {
        let json = r#"{
            "structured_output": {
                "choices": [
                    {"branch": "feat/test", "action": "implement", "prompt": "Test #1"},
                    {"branch": "survey/test", "action": "survey", "prompt": "Survey #1"}
                ]
            }
        }"#;

        let response: ClaudeResponse = serde_json::from_str(json).unwrap();
        assert_eq!(response.structured_output.choices.len(), 2);
    }

    #[test]
    fn build_prompt_with_labels() {
        use super::super::GitHubLabel;

        let issue = GitHubIssue {
            number: 42,
            title: "Test Issue".to_string(),
            body: "Test body".to_string(),
            labels: vec![
                GitHubLabel {
                    name: "bug".to_string(),
                },
                GitHubLabel {
                    name: "enhancement".to_string(),
                },
            ],
            state: super::super::IssueState::Open,
        };

        let prompt = build_prompt(&issue);
        assert!(prompt.contains("bug, enhancement"));
        assert!(prompt.contains("#42"));
    }
}