use std::fs;
use std::path::PathBuf;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::keychain;
const CONFIG_FILENAME: &str = ".formanator.toml";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
pub access_token: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_update_check_timestamp: Option<u64>,
}
fn config_path() -> Result<PathBuf> {
if let Some(path) = std::env::var_os("FORMANATOR_CONFIG_PATH") {
return Ok(PathBuf::from(path));
}
let home = dirs::home_dir().context("Could not determine your home directory")?;
Ok(home.join(CONFIG_FILENAME))
}
pub fn read_config() -> Result<Option<Config>> {
let path = config_path()?;
if !path.exists() {
return Ok(None);
}
let raw = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file at {}", path.display()))?;
let parsed: Config = toml::from_str(&raw)
.with_context(|| format!("Failed to parse config file at {}", path.display()))?;
Ok(Some(parsed))
}
pub fn get_access_token() -> Result<Option<String>> {
if let Some(token) = keychain::get_access_token()? {
if !token.is_empty() {
return Ok(Some(token));
}
}
Ok(read_config()?
.map(|c| c.access_token)
.filter(|t| !t.is_empty()))
}
pub fn resolve_access_token(explicit: Option<&str>) -> Result<String> {
if let Some(token) = explicit {
return Ok(token.to_string());
}
match get_access_token()? {
Some(t) if !t.is_empty() => Ok(t),
_ => anyhow::bail!("You aren't logged in to Forma. Please run `formanator login` first."),
}
}
pub fn store_config(config: &Config) -> Result<()> {
#[cfg(target_os = "macos")]
if !config.access_token.is_empty() {
keychain::store_access_token(&config.access_token)?;
}
let path = config_path()?;
#[cfg(target_os = "macos")]
let config_to_write = Config {
access_token: String::new(),
email: config.email.clone(),
last_update_check_timestamp: config.last_update_check_timestamp,
};
#[cfg(not(target_os = "macos"))]
let config_to_write = config.clone();
let serialised =
toml::to_string(&config_to_write).context("Failed to serialize config to TOML")?;
fs::write(&path, serialised)
.with_context(|| format!("Failed to write config file at {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_serializes_with_snake_case_access_token_and_omits_email() {
let config = Config {
access_token: "tok".to_string(),
email: None,
..Config::default()
};
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("access_token = \"tok\""), "{toml_str}");
assert!(!toml_str.contains("email"), "{toml_str}");
}
#[test]
fn config_serializes_email_when_present() {
let config = Config {
access_token: "tok".to_string(),
email: Some("user@example.com".to_string()),
..Config::default()
};
let toml_str = toml::to_string(&config).unwrap();
assert!(
toml_str.contains("email = \"user@example.com\""),
"{toml_str}"
);
}
#[test]
fn config_round_trips_through_toml() {
let original = Config {
access_token: "tok".to_string(),
email: Some("user@example.com".to_string()),
last_update_check_timestamp: Some(1_700_000_000),
};
let toml_str = toml::to_string(&original).unwrap();
assert!(
toml_str.contains("last_update_check_timestamp = 1700000000"),
"{toml_str}"
);
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.access_token, original.access_token);
assert_eq!(parsed.email, original.email);
assert_eq!(
parsed.last_update_check_timestamp,
original.last_update_check_timestamp
);
}
#[test]
#[serial_test::serial]
fn store_config_separates_token_from_file() {
unsafe {
std::env::set_var("FORMANATOR_USE_MOCK_KEYCHAIN", "1");
}
keychain::init();
let tmpdir = tempfile::tempdir().unwrap();
let config_path = tmpdir.path().join(".formanator.toml");
unsafe {
std::env::set_var("FORMANATOR_CONFIG_PATH", &config_path);
}
let config = Config {
access_token: "secret-token-12345".to_string(),
email: Some("user@example.com".to_string()),
last_update_check_timestamp: Some(1_700_000_000),
};
store_config(&config).unwrap();
let file_content = std::fs::read_to_string(&config_path).unwrap();
#[cfg(target_os = "macos")]
{
assert!(!file_content.contains("secret-token-12345"));
assert!(file_content.contains("user@example.com"));
assert!(file_content.contains("1700000000"));
let keychain_token = keychain::get_access_token().unwrap();
assert_eq!(keychain_token, Some("secret-token-12345".to_string()));
}
#[cfg(not(target_os = "macos"))]
{
assert!(file_content.contains("secret-token-12345"));
assert!(file_content.contains("user@example.com"));
assert!(file_content.contains("1700000000"));
}
unsafe {
std::env::remove_var("FORMANATOR_CONFIG_PATH");
std::env::remove_var("FORMANATOR_USE_MOCK_KEYCHAIN");
}
}
#[test]
fn resolve_access_token_prefers_explicit_value() {
let token = resolve_access_token(Some("from-cli")).unwrap();
assert_eq!(token, "from-cli");
}
}