oj-submit 0.1.0

A fast, simple CLI for submitting solutions to the UVA Online Judge (onlinejudge.org)
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

/// Stored credentials for authenticating with onlinejudge.org.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Credentials {
    pub username: String,
    pub password: String,
}

/// Errors that can occur when loading or saving credentials.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("No credentials found. Run `oj-submit login` first.")]
    NotFound,
    #[error("Config error: {0}")]
    ConfigError(String),
}

impl Credentials {
    /// Load stored credentials from the config file.
    pub fn load() -> Result<Self, ConfigError> {
        let path = config_path();
        if !path.exists() {
            return Err(ConfigError::NotFound);
        }

        let data = fs::read_to_string(&path)
            .map_err(|e| ConfigError::ConfigError(format!("failed to read config file: {e}")))?;

        serde_json::from_str(&data)
            .map_err(|e| ConfigError::ConfigError(format!("corrupted config file: {e}")))
    }

    /// Save credentials to the config file.
    pub fn save(username: &str, password: &str) -> Result<(), ConfigError> {
        if username.is_empty() {
            return Err(ConfigError::ConfigError(
                "username must not be empty".to_string(),
            ));
        }
        if password.is_empty() {
            return Err(ConfigError::ConfigError(
                "password must not be empty".to_string(),
            ));
        }

        let creds = Credentials {
            username: username.to_string(),
            password: password.to_string(),
        };
        let json = serde_json::to_string_pretty(&creds).expect("serialization should not fail");

        let path = config_path();
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(|e| {
                ConfigError::ConfigError(format!("failed to create config directory: {e}"))
            })?;
        }

        fs::write(&path, json)
            .map_err(|e| ConfigError::ConfigError(format!("failed to write config file: {e}")))?;

        // Set file permissions to owner-only read/write (Unix only)
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let perms = fs::Permissions::from_mode(0o600);
            fs::set_permissions(&path, perms).map_err(|e| {
                ConfigError::ConfigError(format!("failed to set file permissions: {e}"))
            })?;
        }

        Ok(())
    }

    /// Delete stored credentials.
    pub fn delete() -> Result<(), ConfigError> {
        let path = config_path();
        if !path.exists() {
            return Err(ConfigError::NotFound);
        }

        fs::remove_file(&path)
            .map_err(|e| ConfigError::ConfigError(format!("failed to delete config file: {e}")))?;

        Ok(())
    }

    /// Check whether credentials exist.
    pub fn exists() -> bool {
        config_path().exists()
    }
}

/// Get the path to the config file.
///
/// Always stored at `~/.config/oj-submit/credentials.json`.
fn config_path() -> PathBuf {
    home::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".config")
        .join("oj-submit")
        .join("credentials.json")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_username_rejected() {
        let result = Credentials::save("", "some-password");
        assert!(result.is_err());
        match result.unwrap_err() {
            ConfigError::ConfigError(msg) => {
                assert!(msg.contains("username must not be empty"));
            }
            other => panic!("expected ConfigError, got {other:?}"),
        }
    }

    #[test]
    fn empty_password_rejected() {
        let result = Credentials::save("some-user", "");
        assert!(result.is_err());
        match result.unwrap_err() {
            ConfigError::ConfigError(msg) => {
                assert!(msg.contains("password must not be empty"));
            }
            other => panic!("expected ConfigError, got {other:?}"),
        }
    }

    #[test]
    fn both_empty_rejected() {
        let result = Credentials::save("", "");
        assert!(result.is_err());
    }

    #[test]
    fn credentials_struct_equality() {
        let a = Credentials {
            username: "user".into(),
            password: "pass".into(),
        };
        let b = Credentials {
            username: "user".into(),
            password: "pass".into(),
        };
        assert_eq!(a, b);
    }

    #[test]
    fn credentials_serialize_roundtrip() {
        let creds = Credentials {
            username: "testuser".into(),
            password: "p@ssw0rd!".into(),
        };
        let json = serde_json::to_string(&creds).unwrap();
        let restored: Credentials = serde_json::from_str(&json).unwrap();
        assert_eq!(creds, restored);
    }

    #[test]
    fn config_path_has_correct_name() {
        let path = config_path();
        assert!(path.ends_with(".config/oj-submit/credentials.json"));
    }
}