gitlab-tracker-redmine 0.3.1

Optional Redmine integration plugin for gitlab-tracker
use zeroize::Zeroizing;

/// Keyring service name for the Redmine token — distinct from the GitLab one.
const KEYRING_SERVICE: &str = "gitlab-tracker-redmine";
const KEYRING_ACCOUNT: &str = "redmine_token";

/// Retrieves the Redmine API token using the following priority chain:
///
/// 1. `REDMINE_TOKEN` environment variable.
/// 2. OS keyring (via the `keyring` crate).
/// 3. Interactive hidden prompt (`rpassword`).
///
/// Returns `None` when the user explicitly skips the prompt (empty input),
/// which causes the Redmine feature to stay inactive for this session.
/// The token is wrapped in [`Zeroizing`] to erase it from memory on drop.
pub fn get_or_prompt_token() -> Option<Zeroizing<String>> {
    // 1. Environment variable — highest priority (CI / dotenv workflows).
    if let Ok(tok) = std::env::var("REDMINE_TOKEN") {
        let tok = Zeroizing::new(tok.trim().to_string());
        if !tok.is_empty() {
            tracing::info!("REDMINE_TOKEN loaded from environment variable");
            return Some(tok);
        }
    }

    // 2. OS keyring.
    match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) {
        Ok(entry) => match entry.get_password() {
            Ok(pwd) => {
                let pwd = Zeroizing::new(pwd.trim().to_string());
                if !pwd.is_empty() {
                    tracing::info!("REDMINE_TOKEN loaded from OS keyring");
                    return Some(pwd);
                }
                tracing::debug!("Redmine keyring entry found but token is empty");
            }
            Err(e) => {
                tracing::debug!(error = %e, "No Redmine token in OS keyring");
            }
        },
        Err(e) => {
            tracing::warn!(error = %e, "Failed to open Redmine keyring entry");
        }
    }

    // 3. Interactive prompt — the user may leave it empty to skip.
    println!("🔑 No REDMINE_TOKEN found in environment or OS keyring.");
    println!("   Leave empty to disable Redmine integration for this session.");
    match rpassword::prompt_password("Redmine API token: ") {
        Ok(raw) => {
            let token = Zeroizing::new(raw.trim().to_string());
            if token.is_empty() {
                tracing::info!("Redmine integration disabled — no token provided");
                return None;
            }
            // Persist to keyring so the user is not prompted again.
            match keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT) {
                Ok(entry) => match entry.set_password(&token) {
                    Ok(_) => {
                        tracing::info!("Redmine token saved to OS keyring");
                        println!("✅ Redmine token securely saved to OS Keyring!\n");
                    }
                    Err(e) => {
                        tracing::error!(error = %e, "Failed to save Redmine token to OS keyring");
                    }
                },
                Err(e) => {
                    tracing::error!(error = %e, "Failed to open Redmine keyring entry for writing");
                }
            }
            Some(token)
        }
        Err(e) => {
            tracing::error!(error = %e, "Failed to read Redmine token from prompt");
            None
        }
    }
}

/// Removes the stored Redmine token from the OS keyring.
///
/// Returns `true` if the entry was deleted successfully, `false` otherwise.
pub fn delete_token() -> bool {
    keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
        .and_then(|e| e.delete_password())
        .is_ok()
}