use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use crate::exit::Refused;
#[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"))?;
create_private_directory(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(|| {
Refused::not_signed_in("no profile configured — run `fundaia login` first")
})?;
let profile = self.profiles.get(&chosen).ok_or_else(|| {
Refused::not_signed_in(format!("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, PermissionsExt};
let staged = path.with_extension("toml.tmp");
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&staged)
.with_context(|| format!("could not write {}", staged.display()))?;
let written = file
.write_all(bytes)
.and_then(|()| file.set_permissions(fs::Permissions::from_mode(0o600)))
.and_then(|()| file.sync_all())
.and_then(|()| fs::rename(&staged, path));
if let Err(error) = written {
let _ = fs::remove_file(&staged);
return Err(error).with_context(|| format!("could not write {}", path.display()));
}
Ok(())
}
#[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()
}
pub fn host_of(url: &str) -> String {
let rest = url.trim();
let rest = rest.split_once("://").map_or(rest, |(_, after)| after);
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
host.to_ascii_lowercase()
}
pub fn same_host(left: &str, right: &str) -> bool {
let (left, right) = (host_of(left), host_of(right));
!left.is_empty() && left == right
}
#[cfg(unix)]
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(directory)?;
fs::set_permissions(directory, fs::Permissions::from_mode(0o700))
}
#[cfg(not(unix))]
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
fs::create_dir_all(directory)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_read_the_host_out_of_an_address() {
assert_eq!(
host_of("https://Infra.Example.com:8443/api/"),
"infra.example.com:8443"
);
}
#[test]
fn it_should_ignore_userinfo_when_reading_the_host() {
assert_eq!(host_of("https://user:pw@evil.example/"), "evil.example");
}
#[test]
fn it_should_treat_two_addresses_on_one_host_as_the_same() {
assert!(same_host(
"https://infra.example.com/",
"https://infra.example.com/a"
));
}
#[test]
fn it_should_treat_a_different_port_as_a_different_host() {
assert!(!same_host(
"https://infra.example.com",
"https://infra.example.com:8443"
));
}
#[test]
fn it_should_never_match_an_empty_host() {
assert!(!same_host("", ""));
}
#[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"),
);
}
}