use crate::{Error, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::{
env,
fmt::{self, Display},
fs,
path::{Path, PathBuf},
str::FromStr,
time::{SystemTime, UNIX_EPOCH},
};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Provider {
#[default]
Codex,
Grok,
Kiro,
Cursor,
}
impl Provider {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Grok => "grok",
Self::Kiro => "kiro",
Self::Cursor => "cursor",
}
}
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::Codex => "Codex",
Self::Grok => "Grok",
Self::Kiro => "Kiro",
Self::Cursor => "Cursor",
}
}
}
impl Display for Provider {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for Provider {
type Err = Error;
fn from_str(value: &str) -> Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"codex" | "openai-codex" | "openai" => Ok(Self::Codex),
"grok" | "xai" | "xai-oauth" | "grok-oauth" => Ok(Self::Grok),
"kiro" | "kiro-cli" | "kiro-desktop" => Ok(Self::Kiro),
"cursor" => Ok(Self::Cursor),
other => Err(Error::config(format!("unknown provider: {other}"))),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Credentials {
#[serde(default)]
pub provider: Provider,
pub access_token: String,
pub refresh_token: String,
pub expires_at: i64,
#[serde(default)]
pub account_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct AuthFile {
version: u8,
#[serde(default)]
active_provider: Provider,
#[serde(default)]
providers: BTreeMap<Provider, Credentials>,
}
impl AuthFile {
fn single(credentials: Credentials) -> Self {
let provider = credentials.provider;
let mut providers = BTreeMap::new();
providers.insert(provider, credentials);
Self {
version: 2,
active_provider: provider,
providers,
}
}
}
impl Credentials {
#[must_use]
pub const fn is_expired_at(&self, now_unix: i64, skew_secs: i64) -> bool {
self.expires_at.saturating_sub(skew_secs) <= now_unix
}
#[must_use]
pub fn is_expired(&self, skew_secs: i64) -> bool {
self.is_expired_at(now_unix(), skew_secs)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct AppConfig {
#[serde(default)]
pub bind_host: Option<String>,
#[serde(default)]
pub bind_port: Option<u16>,
#[serde(default)]
pub auth_file: Option<PathBuf>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub model_fallback: Option<String>,
#[serde(default)]
pub provider: Option<Provider>,
}
#[derive(Debug, Clone)]
pub struct AuthStore {
path: PathBuf,
}
impl AuthStore {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn default_path() -> Result<PathBuf> {
if let Ok(path) = env::var("ROTOM_AUTH_FILE") {
return Ok(PathBuf::from(path));
}
let home = env::var("ROTOM_HOME")
.or_else(|_| env::var("HOME"))
.map_err(|_| Error::config("HOME is not set; pass --auth-file explicitly"))?;
Ok(PathBuf::from(home).join(".rotom").join("auth.json"))
}
pub fn from_default_path() -> Result<Self> {
Ok(Self::new(Self::default_path()?))
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<Option<Credentials>> {
Ok(self
.load_file()?
.and_then(|file| file.providers.get(&file.active_provider).cloned()))
}
pub fn load_provider(&self, provider: Provider) -> Result<Option<Credentials>> {
Ok(self
.load_file()?
.and_then(|file| file.providers.get(&provider).cloned()))
}
pub fn load_all(&self) -> Result<Vec<Credentials>> {
Ok(self
.load_file()?
.map(|file| file.providers.into_values().collect())
.unwrap_or_default())
}
fn load_file(&self) -> Result<Option<AuthFile>> {
match fs::read_to_string(&self.path) {
Ok(raw) => parse_auth_file(&raw).map(Some),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
pub fn save(&self, credentials: &Credentials) -> Result<()> {
let mut file = self
.load_file()?
.unwrap_or_else(|| AuthFile::single(credentials.clone()));
let mut credentials = credentials.clone();
if credentials.account_id.is_empty() && credentials.provider == Provider::Codex {
credentials.provider = Provider::Codex;
}
file.active_provider = credentials.provider;
file.providers.insert(credentials.provider, credentials);
self.save_file(&file)
}
fn save_file(&self, file: &AuthFile) -> Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| Error::config("auth file path has no parent directory"))?;
fs::create_dir_all(parent)?;
let tmp = self.path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(file)?;
write_secret_file(&tmp, &bytes)?;
fs::rename(tmp, &self.path)?;
Ok(())
}
}
fn parse_auth_file(raw: &str) -> Result<AuthFile> {
if let Ok(file) = serde_json::from_str::<AuthFile>(raw) {
return Ok(file);
}
let credentials = serde_json::from_str::<Credentials>(raw)?;
Ok(AuthFile::single(credentials))
}
#[derive(Debug, Clone)]
pub struct AppConfigStore {
path: PathBuf,
}
impl AppConfigStore {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn default_path() -> Result<PathBuf> {
Ok(rotom_home()?.join("config.json"))
}
pub fn from_default_path() -> Result<Self> {
Ok(Self::new(Self::default_path()?))
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self) -> Result<Option<AppConfig>> {
match fs::read_to_string(&self.path) {
Ok(raw) => Ok(Some(serde_json::from_str(&raw)?)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
pub fn save(&self, config: &AppConfig) -> Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| Error::config("config file path has no parent directory"))?;
fs::create_dir_all(parent)?;
let tmp = self.path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(config)?;
write_secret_file(&tmp, &bytes)?;
fs::rename(tmp, &self.path)?;
Ok(())
}
pub fn delete(&self) -> Result<()> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
}
#[must_use]
pub fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_secs()).ok())
.unwrap_or_default()
}
#[cfg(unix)]
fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::{fs::OpenOptions, io::Write, os::unix::fs::OpenOptionsExt};
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(not(unix))]
fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
fs::write(path, bytes)
}
fn rotom_home() -> Result<PathBuf> {
if let Ok(path) = env::var("ROTOM_HOME") {
return Ok(PathBuf::from(path));
}
let home = env::var("HOME")
.map_err(|_| Error::config("HOME is not set; pass --auth-file explicitly"))?;
Ok(PathBuf::from(home).join(".rotom"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testsupport::TempDir;
fn sample_credentials() -> Credentials {
Credentials {
provider: crate::config::Provider::Codex,
access_token: "access".into(),
refresh_token: "refresh".into(),
expires_at: 123,
account_id: "acc_1".into(),
}
}
fn sample_app_config() -> AppConfig {
AppConfig {
bind_host: Some("127.0.0.1".into()),
bind_port: Some(14550),
auth_file: Some(PathBuf::from("/tmp/auth.json")),
api_key: Some("secret".into()),
model_fallback: Some("gpt-5.5".into()),
provider: Some(Provider::Codex),
}
}
#[test]
fn detects_expiry_with_skew() {
let credentials = Credentials {
provider: crate::config::Provider::Codex,
expires_at: 100,
..sample_credentials()
};
assert!(credentials.is_expired_at(95, 10));
assert!(!credentials.is_expired_at(80, 10));
}
#[test]
fn cursor_provider_does_not_accept_local_cli_aliases() {
assert_eq!(Provider::from_str("cursor").unwrap(), Provider::Cursor);
let local_agent_alias = ["cursor", "agent"].join("-");
let local_cli_alias = ["cursor", "cli"].join("-");
assert!(Provider::from_str(&local_agent_alias).is_err());
assert!(Provider::from_str(&local_cli_alias).is_err());
}
#[test]
fn missing_auth_file_loads_as_none() {
let dir = TempDir::new().unwrap();
let store = AuthStore::new(dir.path().join("missing.json"));
assert_eq!(store.load().unwrap(), None);
}
#[test]
fn saves_and_loads_credentials() {
let dir = TempDir::new().unwrap();
let store = AuthStore::new(dir.path().join("auth.json"));
let credentials = sample_credentials();
store.save(&credentials).unwrap();
assert_eq!(store.load().unwrap(), Some(credentials));
}
#[test]
fn missing_app_config_loads_as_none() {
let dir = TempDir::new().unwrap();
let store = AppConfigStore::new(dir.path().join("missing.json"));
assert_eq!(store.load().unwrap(), None);
}
#[test]
fn saves_and_loads_app_config() {
let dir = TempDir::new().unwrap();
let store = AppConfigStore::new(dir.path().join("config.json"));
let config = sample_app_config();
store.save(&config).unwrap();
assert_eq!(store.load().unwrap(), Some(config));
}
#[test]
fn deletes_app_config() {
let dir = TempDir::new().unwrap();
let store = AppConfigStore::new(dir.path().join("config.json"));
let config = sample_app_config();
store.save(&config).unwrap();
store.delete().unwrap();
assert_eq!(store.load().unwrap(), None);
}
}