tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! GitHub integration module.
//!
//! Provides issue fetching and action generation using `gh` CLI and `claude -p`.

mod action;
mod issue;

use std::future::Future;
use std::pin::Pin;

use crate::error::GitHubError;

pub use action::{ActionChoice, ActionType, build_prompt, generate_choices};
pub use issue::{GitHubIssue, GitHubLabel, IssueState, fetch_issue, fetch_issue_list};

/// Run command and parse JSON output
async fn run_command<T: serde::de::DeserializeOwned>(
    cmd: &str,
    args: &[&str],
    context: &str,
) -> Result<T, GitHubError> {
    use tokio::process::Command;

    let output =
        Command::new(cmd)
            .args(args)
            .output()
            .await
            .map_err(|e| GitHubError::CommandFailed {
                command: context.to_string(),
                source: e,
            })?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(GitHubError::CommandFailed {
            command: context.to_string(),
            source: std::io::Error::other(stderr.to_string()),
        });
    }

    serde_json::from_slice(&output.stdout).map_err(|e| GitHubError::ParseFailed {
        context: context.to_string(),
        message: e.to_string(),
    })
}

/// Trait for GitHub operations (enables mocking in tests)
pub trait GitHubClient: Send + Sync {
    /// Fetch list of open issues
    fn fetch_issues(
        &self,
    ) -> Pin<Box<dyn Future<Output = Result<Vec<GitHubIssue>, GitHubError>> + Send + '_>>;

    /// Fetch single issue by number
    fn fetch_issue(
        &self,
        number: u32,
    ) -> Pin<Box<dyn Future<Output = Result<GitHubIssue, GitHubError>> + Send + '_>>;

    /// Generate action choices for an issue
    fn generate_choices(
        &self,
        issue: &GitHubIssue,
    ) -> Pin<Box<dyn Future<Output = Result<Vec<ActionChoice>, GitHubError>> + Send + '_>>;
}

/// Native GitHub client using `gh` CLI and `claude -p`
#[derive(Debug, Default, Clone)]
pub struct NativeGitHubClient;

impl GitHubClient for NativeGitHubClient {
    fn fetch_issues(
        &self,
    ) -> Pin<Box<dyn Future<Output = Result<Vec<GitHubIssue>, GitHubError>> + Send + '_>> {
        Box::pin(fetch_issue_list())
    }

    fn fetch_issue(
        &self,
        number: u32,
    ) -> Pin<Box<dyn Future<Output = Result<GitHubIssue, GitHubError>> + Send + '_>> {
        Box::pin(fetch_issue(number))
    }

    fn generate_choices(
        &self,
        issue: &GitHubIssue,
    ) -> Pin<Box<dyn Future<Output = Result<Vec<ActionChoice>, GitHubError>> + Send + '_>> {
        let issue = issue.clone();
        Box::pin(async move { generate_choices(&issue).await })
    }
}

/// Mock GitHub client for testing
#[allow(clippy::unwrap_used)]
pub mod mock {
    use super::{ActionChoice, Future, GitHubClient, GitHubError, GitHubIssue, Pin};
    use std::sync::{Arc, Mutex};

    /// Mock GitHub client with configurable responses
    #[derive(Debug, Clone, Default)]
    pub struct MockGitHubClient {
        issues: Arc<Mutex<Vec<GitHubIssue>>>,
        choices: Arc<Mutex<Vec<ActionChoice>>>,
        fetch_error: Arc<Mutex<Option<String>>>,
    }

    impl MockGitHubClient {
        /// Create new mock client
        #[must_use]
        pub fn new() -> Self {
            Self::default()
        }

        /// Set issues to return
        #[must_use]
        pub fn with_issues(self, issues: Vec<GitHubIssue>) -> Self {
            *self.issues.lock().unwrap() = issues;
            self
        }

        /// Set choices to return
        #[must_use]
        pub fn with_choices(self, choices: Vec<ActionChoice>) -> Self {
            *self.choices.lock().unwrap() = choices;
            self
        }

        /// Set error to return
        #[must_use]
        pub fn with_error(self, error: &str) -> Self {
            *self.fetch_error.lock().unwrap() = Some(error.to_string());
            self
        }
    }

    impl GitHubClient for MockGitHubClient {
        fn fetch_issues(
            &self,
        ) -> Pin<Box<dyn Future<Output = Result<Vec<GitHubIssue>, GitHubError>> + Send + '_>>
        {
            let issues = self.issues.clone();
            let error = self.fetch_error.clone();
            Box::pin(async move {
                if let Some(e) = error.lock().unwrap().take() {
                    return Err(GitHubError::ParseFailed {
                        context: "mock".to_string(),
                        message: e,
                    });
                }
                Ok(issues.lock().unwrap().clone())
            })
        }

        fn fetch_issue(
            &self,
            number: u32,
        ) -> Pin<Box<dyn Future<Output = Result<GitHubIssue, GitHubError>> + Send + '_>> {
            let issues = self.issues.clone();
            let error = self.fetch_error.clone();
            Box::pin(async move {
                if let Some(e) = error.lock().unwrap().take() {
                    return Err(GitHubError::ParseFailed {
                        context: "mock".to_string(),
                        message: e,
                    });
                }
                issues
                    .lock()
                    .unwrap()
                    .iter()
                    .find(|i| i.number == number)
                    .cloned()
                    .ok_or_else(|| GitHubError::ParseFailed {
                        context: format!("issue #{number}"),
                        message: "not found".to_string(),
                    })
            })
        }

        fn generate_choices(
            &self,
            _issue: &GitHubIssue,
        ) -> Pin<Box<dyn Future<Output = Result<Vec<ActionChoice>, GitHubError>> + Send + '_>>
        {
            let choices = self.choices.clone();
            let error = self.fetch_error.clone();
            Box::pin(async move {
                if let Some(e) = error.lock().unwrap().take() {
                    return Err(GitHubError::ParseFailed {
                        context: "mock".to_string(),
                        message: e,
                    });
                }
                Ok(choices.lock().unwrap().clone())
            })
        }
    }
}

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

    #[tokio::test]
    async fn mock_client_fetch_issues() {
        let client = MockGitHubClient::new().with_issues(vec![GitHubIssue {
            number: 1,
            title: "Test Issue".to_string(),
            body: "Test body".to_string(),
            labels: vec![],
            state: IssueState::Open,
        }]);

        let issues = client.fetch_issues().await.unwrap();
        assert_eq!(issues.len(), 1);
        assert_eq!(issues[0].number, 1);
    }

    #[tokio::test]
    async fn mock_client_fetch_issue() {
        let client = MockGitHubClient::new().with_issues(vec![GitHubIssue {
            number: 42,
            title: "Issue 42".to_string(),
            body: String::new(),
            labels: vec![],
            state: IssueState::Open,
        }]);

        let issue = client.fetch_issue(42).await.unwrap();
        assert_eq!(issue.number, 42);
    }

    #[tokio::test]
    async fn mock_client_fetch_issue_not_found() {
        let client = MockGitHubClient::new();

        let result = client.fetch_issue(999).await;
        assert!(result.is_err());
    }

    #[tokio::test]
    async fn mock_client_generate_choices() {
        let client = MockGitHubClient::new().with_choices(vec![ActionChoice {
            branch: "feat/test".to_string(),
            action: ActionType::Implement,
            prompt: "Test".to_string(),
        }]);

        let issue = GitHubIssue {
            number: 1,
            title: "Test".to_string(),
            body: String::new(),
            labels: vec![],
            state: IssueState::Open,
        };

        let choices = client.generate_choices(&issue).await.unwrap();
        assert_eq!(choices.len(), 1);
        assert_eq!(choices[0].branch, "feat/test");
    }

    #[tokio::test]
    async fn mock_client_error_propagation() {
        // Each with_error() call returns error on next client method
        let issue = GitHubIssue {
            number: 1,
            title: "T".to_string(),
            body: String::new(),
            labels: vec![],
            state: IssueState::Open,
        };

        // fetch_issues error
        let c1 = MockGitHubClient::new().with_error("e1");
        assert!(c1.fetch_issues().await.is_err());

        // fetch_issue error
        let c2 = MockGitHubClient::new().with_error("e2");
        assert!(c2.fetch_issue(1).await.is_err());

        // generate_choices error
        let c3 = MockGitHubClient::new().with_error("e3");
        assert!(c3.generate_choices(&issue).await.is_err());
    }
}