use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Credentials {
pub username: String,
pub password: String,
}
#[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 {
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}")))
}
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}")))?;
#[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(())
}
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(())
}
pub fn exists() -> bool {
config_path().exists()
}
}
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"));
}
}