use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub current: Option<String>,
#[serde(default)]
pub profiles: std::collections::BTreeMap<String, Profile>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub url: String,
pub token: String,
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path()?;
if !path.exists() {
return Ok(Self::default());
}
let text = fs::read_to_string(&path)
.with_context(|| format!("could not read {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("{} is not valid TOML", path.display()))
}
pub fn save(&self) -> Result<PathBuf> {
let path = config_path()?;
let directory = path
.parent()
.ok_or_else(|| anyhow!("the configuration path has no directory"))?;
fs::create_dir_all(directory)
.with_context(|| format!("could not create {}", directory.display()))?;
let text = toml::to_string_pretty(self).context("could not serialise the configuration")?;
write_private(&path, text.as_bytes())?;
Ok(path)
}
pub fn profile(&self, name: Option<&str>) -> Result<(&str, &Profile)> {
let chosen = name
.map(str::to_owned)
.or_else(|| self.current.clone())
.ok_or_else(|| anyhow!("no profile configured — run `fundaia login` first"))?;
let profile = self
.profiles
.get(&chosen)
.ok_or_else(|| anyhow!("there is no profile called `{chosen}`"))?;
let key = self
.profiles
.keys()
.find(|candidate| *candidate == &chosen)
.map(String::as_str)
.unwrap_or("default");
Ok((key, profile))
}
pub fn set(&mut self, name: &str, profile: Profile) {
self.profiles.insert(name.to_owned(), profile);
if self.current.is_none() {
self.current = Some(name.to_owned());
}
}
}
pub fn config_path() -> Result<PathBuf> {
if let Ok(explicit) = std::env::var("FUNDAIA_CONFIG") {
return Ok(PathBuf::from(explicit));
}
let base = dirs::config_dir()
.ok_or_else(|| anyhow!("could not work out where configuration files live"))?;
Ok(settled_at(
&base.join("fundaia").join("config.toml"),
&base.join("fundaia-infra").join("config.toml"),
|path| path.exists(),
))
}
fn settled_at(current: &Path, legacy: &Path, exists: impl Fn(&Path) -> bool) -> PathBuf {
if !exists(current) && exists(legacy) {
return legacy.to_path_buf();
}
current.to_path_buf()
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("could not write {}", path.display()))?;
file.write_all(bytes)
.with_context(|| format!("could not write {}", path.display()))
}
#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
fs::write(path, bytes).with_context(|| format!("could not write {}", path.display()))
}
pub fn normalise_url(url: &str) -> String {
url.trim().trim_end_matches('/').to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_drop_a_trailing_slash_from_a_base_address() {
assert_eq!(normalise_url("https://example.com/"), "https://example.com");
}
#[test]
fn it_should_leave_an_address_without_a_trailing_slash_alone() {
assert_eq!(normalise_url("https://example.com"), "https://example.com");
}
#[test]
fn it_should_trim_surrounding_whitespace_from_an_address() {
assert_eq!(
normalise_url(" https://example.com "),
"https://example.com"
);
}
#[test]
fn it_should_report_a_missing_profile_by_name() {
let config = Config::default();
let error = config.profile(Some("staging")).unwrap_err().to_string();
assert!(error.contains("staging"));
}
#[test]
fn it_should_ask_for_a_login_when_nothing_is_configured() {
let config = Config::default();
let error = config.profile(None).unwrap_err().to_string();
assert!(error.contains("login"));
}
#[test]
fn it_should_make_the_first_profile_the_current_one() {
let mut config = Config::default();
config.set(
"production",
Profile {
url: "https://example.com".into(),
token: "fdi_x".into(),
},
);
assert_eq!(config.current.as_deref(), Some("production"));
}
#[test]
fn it_should_not_move_the_current_profile_when_another_is_added() {
let mut config = Config::default();
config.set(
"production",
Profile {
url: "https://example.com".into(),
token: "fdi_x".into(),
},
);
config.set(
"staging",
Profile {
url: "https://staging.example.com".into(),
token: "fdi_y".into(),
},
);
assert_eq!(config.current.as_deref(), Some("production"));
}
#[test]
fn it_should_keep_the_profiles_where_the_old_name_left_them() {
assert_eq!(
settled_at(
Path::new("/home/alex/.config/fundaia/config.toml"),
Path::new("/home/alex/.config/fundaia-infra/config.toml"),
|path| path.ends_with("fundaia-infra/config.toml"),
),
PathBuf::from("/home/alex/.config/fundaia-infra/config.toml"),
);
}
#[test]
fn it_should_use_the_current_name_on_a_machine_that_has_neither() {
assert_eq!(
settled_at(
Path::new("/home/alex/.config/fundaia/config.toml"),
Path::new("/home/alex/.config/fundaia-infra/config.toml"),
|_| false,
),
PathBuf::from("/home/alex/.config/fundaia/config.toml"),
);
}
#[test]
fn it_should_ignore_the_old_directory_once_the_new_one_exists() {
assert_eq!(
settled_at(
Path::new("/home/alex/.config/fundaia/config.toml"),
Path::new("/home/alex/.config/fundaia-infra/config.toml"),
|_| true,
),
PathBuf::from("/home/alex/.config/fundaia/config.toml"),
);
}
}