fundaia 0.55.3

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
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.or(self.current.as_deref()).ok_or_else(|| {
            Refused::not_signed_in("no profile configured — run `fundaia login` first")
        })?;

        let (key, profile) = self.profiles.get_key_value(chosen).ok_or_else(|| {
            Refused::not_signed_in(format!("there is no profile called `{chosen}`"))
        })?;

        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 _ = fs::remove_file(&staged);
    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 switched_on(variable: &str) -> bool {
    std::env::var(variable).is_ok_and(|value| truthy(&value))
}

fn truthy(value: &str) -> bool {
    let value = value.trim();
    !value.is_empty() && value != "0"
}

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_be_switched_on_by_a_one() {
        assert!(truthy("1"));
    }

    #[test]
    fn it_should_read_a_true_as_on() {
        assert!(truthy("true"));
    }

    #[test]
    fn it_should_not_be_switched_on_by_a_zero() {
        assert!(!truthy("0"));
    }

    #[test]
    fn it_should_not_be_switched_on_by_an_empty_value() {
        assert!(!truthy(""));
    }

    #[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"),
        );
    }

    #[cfg(unix)]
    #[test]
    fn it_should_save_over_a_staged_file_a_crash_left_behind() {
        let directory = std::env::temp_dir().join(format!("fundaia-config-{}", std::process::id()));
        fs::create_dir_all(&directory).unwrap();
        let path = directory.join("config.toml");
        fs::write(path.with_extension("toml.tmp"), "half written").unwrap();

        let saved = write_private(&path, b"current = \"casa\"\n").is_ok();
        let _ = fs::remove_dir_all(&directory);

        assert!(saved);
    }
}