cp-cli 0.9.1

Human-first command-line client for competitive-programming platforms
Documentation
use std::io::IsTerminal;

use console::Term;

use crate::{
    domain::ToolAction,
    error::{Error, Result},
};

#[cfg(feature = "codeforces")]
const KEYRING_SERVICE: &str = "cp-cli";
#[cfg(feature = "codeforces")]
const CODEFORCES_KEYRING_USER: &str = "codeforces";
#[cfg(feature = "codeforces")]
const CODEFORCES_CREDENTIAL_VERSION: u8 = 1;

pub(crate) async fn codeforces_login(
    progress: impl FnMut(usize, Option<u64>),
) -> Result<ToolAction> {
    #[cfg(feature = "codeforces")]
    {
        require_terminal()?;
        let terminal = Term::stderr();
        terminal.write_line("Enter the API key and secret issued for your Codeforces account.")?;
        terminal.write_str("Codeforces API key: ")?;
        let key = terminal.read_line()?;
        terminal.write_str("Codeforces API secret: ")?;
        let secret = terminal.read_secure_line()?;
        let credentials = platform_codeforces::ApiCredentials::new(key.trim(), secret.trim())?;
        platform_codeforces::Client::new()?
            .authenticated_friends(&credentials, progress)
            .await?;
        save_codeforces_credentials(key.trim(), secret.trim())?;
        Ok(action(
            "Codeforces",
            "Credentials saved",
            "Codeforces accepted the signed API request. The key and secret are stored in the operating system credential store.",
        ))
    }
    #[cfg(not(feature = "codeforces"))]
    {
        let _ = progress;
        Err(Error::CodeforcesDisabled)
    }
}

pub(crate) async fn codeforces_status(
    progress: impl FnMut(usize, Option<u64>),
) -> Result<ToolAction> {
    #[cfg(feature = "codeforces")]
    {
        let Some((credentials, source)) = codeforces_credentials()? else {
            return Ok(action(
                "Codeforces",
                "Not configured",
                "Run `cp-cli --platform codeforces auth login`; it accepts an issued API key and hides the secret while you paste it. Automation can set CODEFORCES_API_KEY and CODEFORCES_API_SECRET.",
            ));
        };
        match platform_codeforces::Client::new()?
            .authenticated_friends(&credentials, progress)
            .await
        {
            Ok(_) => Ok(action(
                "Codeforces",
                "Ready",
                &format!("Codeforces accepted the {source} API credentials."),
            )),
            Err(platform_codeforces::Error::ApiFailure) => Ok(action(
                "Codeforces",
                "Rejected",
                &format!("Codeforces rejected the {source} API credentials."),
            )),
            Err(error) => Err(error.into()),
        }
    }
    #[cfg(not(feature = "codeforces"))]
    {
        let _ = progress;
        Err(Error::CodeforcesDisabled)
    }
}

pub(crate) fn codeforces_logout() -> Result<ToolAction> {
    #[cfg(feature = "codeforces")]
    {
        let removed = match codeforces_keyring_entry()?.delete_credential() {
            Ok(()) => true,
            Err(keyring::Error::NoEntry) => false,
            Err(error) => return Err(Error::CredentialStore(error)),
        };
        let environment_present = codeforces_environment_present();
        let detail = match (removed, environment_present) {
            (true, true) => {
                "Saved credentials were removed. CODEFORCES_API_KEY and CODEFORCES_API_SECRET remain active in this shell."
            }
            (true, false) => {
                "Saved credentials were removed from the operating system credential store."
            }
            (false, true) => {
                "No saved credentials were found. CODEFORCES_API_KEY and CODEFORCES_API_SECRET remain active in this shell."
            }
            (false, false) => "No saved Codeforces credentials were found.",
        };
        Ok(action(
            "Codeforces",
            if removed {
                "Credentials removed"
            } else {
                "Nothing to remove"
            },
            detail,
        ))
    }
    #[cfg(not(feature = "codeforces"))]
    {
        Err(Error::CodeforcesDisabled)
    }
}

pub(crate) fn exercism_login() -> Result<ToolAction> {
    require_terminal()?;
    let terminal = Term::stderr();
    terminal.write_line("Enter the API token issued for your Exercism account.")?;
    terminal.write_str("Exercism API token: ")?;
    let token = terminal.read_secure_line()?;
    crate::exercism_cli::Client::new().configure_token(token.trim())?;
    Ok(action(
        "Exercism",
        "Token configured",
        "The official Exercism CLI saved the token in its own configuration.",
    ))
}

pub(crate) fn exercism_status() -> Result<ToolAction> {
    crate::exercism_cli::Client::new().show_configuration()?;
    Ok(action(
        "Exercism",
        "Configuration readable",
        "The official Exercism CLI can read its configuration. Exercism validates the token on the next authenticated command.",
    ))
}

fn require_terminal() -> Result<()> {
    if std::io::stderr().is_terminal() {
        Ok(())
    } else {
        Err(Error::InteractiveLoginRequired)
    }
}

fn action(platform: &str, action: &str, detail: &str) -> ToolAction {
    ToolAction {
        platform: platform.into(),
        action: action.into(),
        detail: detail.into(),
    }
}

#[cfg(feature = "codeforces")]
#[derive(serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
struct StoredCodeforcesCredentials {
    version: u8,
    key: String,
    secret: String,
}

#[cfg(feature = "codeforces")]
fn codeforces_keyring_entry() -> Result<keyring::Entry> {
    keyring::Entry::new(KEYRING_SERVICE, CODEFORCES_KEYRING_USER).map_err(Error::CredentialStore)
}

#[cfg(feature = "codeforces")]
fn save_codeforces_credentials(key: &str, secret: &str) -> Result<()> {
    let encoded = serde_json::to_vec(&StoredCodeforcesCredentials {
        version: CODEFORCES_CREDENTIAL_VERSION,
        key: key.to_owned(),
        secret: secret.to_owned(),
    })
    .map_err(|_| Error::InvalidCodeforcesStoredCredentials)?;
    codeforces_keyring_entry()?
        .set_secret(&encoded)
        .map_err(Error::CredentialStore)
}

#[cfg(feature = "codeforces")]
fn codeforces_credentials() -> Result<Option<(platform_codeforces::ApiCredentials, &'static str)>> {
    if let Some(credentials) = codeforces_environment_credentials()? {
        return Ok(Some((credentials, "environment")));
    }
    let encoded = match codeforces_keyring_entry()?.get_secret() {
        Ok(encoded) => encoded,
        Err(keyring::Error::NoEntry) => return Ok(None),
        Err(error) => return Err(Error::CredentialStore(error)),
    };
    decode_codeforces_credentials(&encoded).map(|credentials| Some((credentials, "saved")))
}

#[cfg(feature = "codeforces")]
fn codeforces_environment_credentials() -> Result<Option<platform_codeforces::ApiCredentials>> {
    let key = std::env::var_os("CODEFORCES_API_KEY");
    let secret = std::env::var_os("CODEFORCES_API_SECRET");
    match (key, secret) {
        (None, None) => Ok(None),
        (Some(key), Some(secret)) => {
            let key = key
                .into_string()
                .map_err(|_| Error::InvalidCodeforcesAuthenticationEnvironment)?;
            let secret = secret
                .into_string()
                .map_err(|_| Error::InvalidCodeforcesAuthenticationEnvironment)?;
            platform_codeforces::ApiCredentials::new(key, secret)
                .map(Some)
                .map_err(Into::into)
        }
        _ => Err(Error::InvalidCodeforcesAuthenticationEnvironment),
    }
}

#[cfg(feature = "codeforces")]
fn codeforces_environment_present() -> bool {
    ["CODEFORCES_API_KEY", "CODEFORCES_API_SECRET"]
        .iter()
        .any(|name| std::env::var_os(name).is_some())
}

#[cfg(feature = "codeforces")]
fn decode_codeforces_credentials(encoded: &[u8]) -> Result<platform_codeforces::ApiCredentials> {
    let stored: StoredCodeforcesCredentials =
        serde_json::from_slice(encoded).map_err(|_| Error::InvalidCodeforcesStoredCredentials)?;
    if stored.version != CODEFORCES_CREDENTIAL_VERSION {
        return Err(Error::InvalidCodeforcesStoredCredentials);
    }
    platform_codeforces::ApiCredentials::new(stored.key, stored.secret)
        .map_err(|_| Error::InvalidCodeforcesStoredCredentials)
}

#[cfg(all(test, feature = "codeforces"))]
mod tests {
    use super::*;

    #[test]
    fn stored_codeforces_credentials_are_versioned_and_validated() -> Result<()> {
        let encoded = serde_json::to_vec(&StoredCodeforcesCredentials {
            version: CODEFORCES_CREDENTIAL_VERSION,
            key: "api-key".to_owned(),
            secret: "api-secret".to_owned(),
        })?;
        assert!(decode_codeforces_credentials(&encoded).is_ok());
        assert!(
            decode_codeforces_credentials(
                br#"{"version":2,"key":"api-key","secret":"api-secret"}"#
            )
            .is_err()
        );
        Ok(())
    }
}