fundaia 0.7.1

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};

/// Where the tool remembers which instance it talks to, and how.
///
/// One file, several profiles, because the interesting case is not one server —
/// it is a laptop that reaches production and a staging box and wants `--profile
/// staging` rather than a second config file and a wrapper script.
///
/// The token lives here in the clear, the same way `gh` and `docker` keep
/// theirs. Encrypting it would need a key, and the only place to put the key is
/// next to the file it protects.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
    /// Which profile a bare invocation uses.
    #[serde(default)]
    pub current: Option<String>,
    #[serde(default)]
    pub profiles: std::collections::BTreeMap<String, Profile>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
    /// Base address, with no trailing slash: `https://infrastructure.fundaia.com`.
    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()))
    }

    /// Writes the file with owner-only permissions, creating the directory.
    ///
    /// The mode is set before the token is written rather than after: a file
    /// that is briefly world-readable while holding a credential is a file that
    /// was world-readable.
    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}`"))?;

        // The key is returned rather than the caller's argument so a lookup
        // through `current` still reports which profile it actually used.
        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(),
    ))
}

/// Where this tool's things actually live, which is not always where a fresh
/// install would put them.
///
/// The command used to be called `fundaia-infra` and kept its profiles under
/// that name. Renaming the binary must not sign anybody out, so an existing
/// directory keeps being used and only a machine with neither gets the new one.
/// Nothing is moved: a rename in a function everything calls is a side effect
/// hiding inside a getter, and there is no reason to pay it — the file works
/// perfectly well where it is.
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()))
}

/// Trims a trailing slash so every joined path has exactly one separator.
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"),
        );
    }
}