use serde::Deserialize; #[cfg(feature = "tauri-runtime")] use tauri::State; use crate::app_error::AppCommandError; use crate::db::service::app_metadata_service; #[cfg(feature = "tauri-runtime")] use crate::db::AppDatabase; use crate::models::GitDetectResult; #[cfg(feature = "tauri-runtime")] use crate::models::GitHubAccountsSettings; use crate::models::{GitHubTokenValidation, GitSettings}; const GIT_SETTINGS_KEY: &str = "git_settings"; #[cfg(feature = "tauri-runtime")] const GITHUB_ACCOUNTS_KEY: &str = "github_accounts"; // --------------------------------------------------------------------------- // Git detection // --------------------------------------------------------------------------- async fn run_git_version(git_path: &str) -> Result { let output = crate::process::tokio_command(git_path) .arg("--version") .output() .await .map_err(|_| AppCommandError::not_found(format!("Cannot execute git at: {git_path}")))?; if !output.status.success() { return Ok(GitDetectResult { installed: false, version: None, path: Some(git_path.to_string()), }); } let stdout = String::from_utf8_lossy(&output.stdout); let version = stdout .trim() .strip_prefix("git version ") .unwrap_or(stdout.trim()) .to_string(); Ok(GitDetectResult { installed: true, version: Some(version), path: Some(git_path.to_string()), }) } async fn detect_git_path() -> Option { let which_cmd = if cfg!(target_os = "windows") { "where" } else { "which" }; let output = crate::process::tokio_command(which_cmd) .arg("git") .output() .await .ok()?; if !output.status.success() { return None; } let path = String::from_utf8_lossy(&output.stdout) .lines() .next()? .trim() .to_string(); if path.is_empty() { None } else { Some(path) } } pub(crate) async fn detect_git_core( conn: &sea_orm::DatabaseConnection, ) -> Result { let settings = load_git_settings(conn).await?; if let Some(custom) = &settings.custom_path { let trimmed = custom.trim(); if !trimmed.is_empty() { return run_git_version(trimmed).await; } } if let Some(path) = detect_git_path().await { return run_git_version(&path).await; } match run_git_version("git").await { Ok(result) if result.installed => Ok(result), _ => Ok(GitDetectResult { installed: false, version: None, path: None, }), } } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn detect_git(db: State<'_, AppDatabase>) -> Result { detect_git_core(&db.conn).await } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn test_git_path(path: String) -> Result { let trimmed = path.trim(); if trimmed.is_empty() { return Err(AppCommandError::invalid_input("Git path cannot be empty")); } run_git_version(trimmed).await } // --------------------------------------------------------------------------- // Git settings // --------------------------------------------------------------------------- async fn load_git_settings( conn: &sea_orm::DatabaseConnection, ) -> Result { let raw = app_metadata_service::get_value(conn, GIT_SETTINGS_KEY) .await .map_err(AppCommandError::from)?; match raw { Some(raw) => serde_json::from_str::(&raw).map_err(|e| { AppCommandError::configuration_invalid("Failed to parse stored git settings") .with_detail(e.to_string()) }), None => Ok(GitSettings::default()), } } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_git_settings(db: State<'_, AppDatabase>) -> Result { load_git_settings(&db.conn).await } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn update_git_settings( settings: GitSettings, db: State<'_, AppDatabase>, ) -> Result { let serialized = serde_json::to_string(&settings).map_err(|e| { AppCommandError::invalid_input("Failed to serialize git settings") .with_detail(e.to_string()) })?; app_metadata_service::upsert_value(&db.conn, GIT_SETTINGS_KEY, &serialized) .await .map_err(AppCommandError::from)?; Ok(settings) } // --------------------------------------------------------------------------- // GitHub accounts // --------------------------------------------------------------------------- #[cfg(feature = "tauri-runtime")] async fn load_github_accounts( conn: &sea_orm::DatabaseConnection, ) -> Result { let raw = app_metadata_service::get_value(conn, GITHUB_ACCOUNTS_KEY) .await .map_err(AppCommandError::from)?; match raw { Some(raw) => serde_json::from_str::(&raw).map_err(|e| { AppCommandError::configuration_invalid("Failed to parse stored GitHub accounts") .with_detail(e.to_string()) }), None => Ok(GitHubAccountsSettings::default()), } } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_github_accounts( db: State<'_, AppDatabase>, ) -> Result { load_github_accounts(&db.conn).await } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn update_github_accounts( settings: GitHubAccountsSettings, db: State<'_, AppDatabase>, ) -> Result { let serialized = serde_json::to_string(&settings).map_err(|e| { AppCommandError::invalid_input("Failed to serialize GitHub accounts") .with_detail(e.to_string()) })?; app_metadata_service::upsert_value(&db.conn, GITHUB_ACCOUNTS_KEY, &serialized) .await .map_err(AppCommandError::from)?; Ok(settings) } // --------------------------------------------------------------------------- // Keyring token management // --------------------------------------------------------------------------- #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn save_account_token(account_id: String, token: String) -> Result<(), AppCommandError> { crate::keyring_store::set_token(&account_id, &token) .map_err(|e| AppCommandError::io_error("Failed to save token to keyring").with_detail(e)) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn get_account_token(account_id: String) -> Result, AppCommandError> { Ok(crate::keyring_store::get_token(&account_id)) } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn delete_account_token(account_id: String) -> Result<(), AppCommandError> { crate::keyring_store::delete_token(&account_id).map_err(|e| { AppCommandError::io_error("Failed to delete token from keyring").with_detail(e) }) } // --------------------------------------------------------------------------- // GitHub token validation // --------------------------------------------------------------------------- #[derive(Debug, Deserialize)] struct GitHubUserResponse { login: String, avatar_url: Option, } #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn validate_github_token( server_url: String, token: String, ) -> Result { let trimmed_url = server_url.trim().trim_end_matches('/'); let trimmed_token = token.trim(); if trimmed_token.is_empty() { return Err(AppCommandError::invalid_input("Token cannot be empty")); } // Build API URL: github.com uses api.github.com, enterprise uses {url}/api/v3 let api_url = if trimmed_url.is_empty() || trimmed_url == "https://github.com" || trimmed_url == "http://github.com" { "https://api.github.com/user".to_string() } else { format!("{trimmed_url}/api/v3/user") }; let response = reqwest::Client::new() .get(&api_url) .header("Authorization", format!("Bearer {trimmed_token}")) .header("User-Agent", "codeg") .header("Accept", "application/vnd.github+json") .send() .await .map_err(|e| { AppCommandError::network("Failed to connect to GitHub API").with_detail(e.to_string()) })?; if !response.status().is_success() { let status = response.status().as_u16(); let body = response.text().await.unwrap_or_default(); let message = if status == 401 { "Invalid or expired token".to_string() } else { format!("GitHub API returned status {status}: {body}") }; return Ok(GitHubTokenValidation { success: false, username: None, scopes: vec![], avatar_url: None, message: Some(message), }); } // Parse scopes from x-oauth-scopes header let scopes: Vec = response .headers() .get("x-oauth-scopes") .and_then(|v| v.to_str().ok()) .map(|s| { s.split(',') .map(|scope| scope.trim().to_string()) .filter(|scope| !scope.is_empty()) .collect() }) .unwrap_or_default(); let user = response.json::().await.map_err(|e| { AppCommandError::network("Failed to parse GitHub API response").with_detail(e.to_string()) })?; Ok(GitHubTokenValidation { success: true, username: Some(user.login), scopes, avatar_url: user.avatar_url, message: None, }) }