1use miyabi_types::error::{MiyabiError, Result};
7use std::fs;
8use std::process::Command;
9
10pub fn discover_token() -> Result<String> {
33 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
35 if !token.is_empty() && token.starts_with("ghp_") {
36 tracing::debug!("Found GitHub token from GITHUB_TOKEN environment variable");
37 return Ok(token);
38 }
39 }
40
41 if let Ok(token) = get_token_from_gh_cli() {
43 tracing::debug!("Found GitHub token from gh CLI");
44 return Ok(token);
45 }
46
47 if let Ok(token) = get_token_from_gh_config() {
49 tracing::debug!("Found GitHub token from gh config file");
50 return Ok(token);
51 }
52
53 Err(MiyabiError::Auth(
55 "GitHub token not found. Please set up authentication:\n\n\
56 Option 1: Set environment variable\n\
57 \x20 export GITHUB_TOKEN=ghp_your_token_here\n\
58 \x20 # Add to ~/.zshrc or ~/.bashrc for persistence\n\n\
59 Option 2: Use GitHub CLI (recommended)\n\
60 \x20 gh auth login\n\
61 \x20 # Follow the interactive prompts\n\n\
62 Option 3: Create a Personal Access Token\n\
63 \x20 1. Go to https://github.com/settings/tokens\n\
64 \x20 2. Generate new token (classic) with 'repo' scope\n\
65 \x20 3. Set GITHUB_TOKEN environment variable\n\n\
66 For more help, see: https://docs.github.com/en/authentication"
67 .to_string(),
68 ))
69}
70
71fn get_token_from_gh_cli() -> Result<String> {
73 let output = Command::new("gh")
74 .args(["auth", "token"])
75 .output()
76 .map_err(|e| MiyabiError::Auth(format!("Failed to execute gh command: {}", e)))?;
77
78 if output.status.success() {
79 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
80 if !token.is_empty() && token.starts_with("ghp_") {
81 return Ok(token);
82 }
83 }
84
85 Err(MiyabiError::Auth(
86 "gh CLI is available but not authenticated".to_string(),
87 ))
88}
89
90fn get_token_from_gh_config() -> Result<String> {
92 let config_path = dirs::home_dir()
93 .ok_or_else(|| MiyabiError::Auth("Could not determine home directory".to_string()))?
94 .join(".config")
95 .join("gh")
96 .join("hosts.yml");
97
98 if !config_path.exists() {
99 return Err(MiyabiError::Auth("gh config file not found".to_string()));
100 }
101
102 let content = fs::read_to_string(&config_path)
103 .map_err(|e| MiyabiError::Auth(format!("Failed to read gh config file: {}", e)))?;
104
105 for line in content.lines() {
109 let trimmed = line.trim();
110 if trimmed.starts_with("oauth_token:") {
111 if let Some(token) = trimmed.split(':').nth(1) {
112 let token = token.trim().to_string();
113 if token.starts_with("ghp_") {
114 return Ok(token);
115 }
116 }
117 }
118 }
119
120 Err(MiyabiError::Auth(
121 "No oauth_token found in gh config file".to_string(),
122 ))
123}
124
125pub fn validate_token_format(token: &str) -> Result<()> {
129 if token.is_empty() {
130 return Err(MiyabiError::Auth("Token is empty".to_string()));
131 }
132
133 if !token.starts_with("ghp_") && !token.starts_with("gho_") && !token.starts_with("ghs_") {
134 return Err(MiyabiError::Auth(
135 "Token does not start with expected prefix (ghp_, gho_, or ghs_)".to_string(),
136 ));
137 }
138
139 if token.len() < 20 {
140 return Err(MiyabiError::Auth(
141 "Token is too short (expected at least 20 characters)".to_string(),
142 ));
143 }
144
145 Ok(())
146}
147
148pub fn check_gh_cli_status() -> GhCliStatus {
150 let output = Command::new("which").arg("gh").output();
152 if output.is_err() || !output.as_ref().unwrap().status.success() {
153 return GhCliStatus::NotInstalled;
154 }
155
156 let output = Command::new("gh").args(["auth", "status"]).output();
158 if let Ok(output) = output {
159 if output.status.success() {
160 return GhCliStatus::Authenticated;
161 }
162 }
163
164 GhCliStatus::NotAuthenticated
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum GhCliStatus {
170 NotInstalled,
172 NotAuthenticated,
174 Authenticated,
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn test_validate_token_format_valid() {
184 assert!(validate_token_format("ghp_1234567890abcdefghij").is_ok());
185 assert!(validate_token_format("gho_1234567890abcdefghij").is_ok());
186 assert!(validate_token_format("ghs_1234567890abcdefghij").is_ok());
187 }
188
189 #[test]
190 fn test_validate_token_format_invalid() {
191 assert!(validate_token_format("").is_err());
192 assert!(validate_token_format("invalid").is_err());
193 assert!(validate_token_format("ghp_123").is_err()); assert!(validate_token_format("xyz_1234567890abcdefghij").is_err());
195 }
196
197 #[test]
198 fn test_gh_cli_status() {
199 let status = check_gh_cli_status();
201 assert!(matches!(
203 status,
204 GhCliStatus::NotInstalled | GhCliStatus::NotAuthenticated | GhCliStatus::Authenticated
205 ));
206 }
207
208 #[test]
209 fn test_discover_token_doesnt_panic() {
210 let _ = discover_token();
212 }
213}