auberge 0.12.2

CLI tool for managing self-hosted infrastructure with Ansible
use crate::output;
use eyre::{Context, Result};
use include_dir::{Dir, include_dir};
use std::path::{Path, PathBuf};

const VERSION: &str = env!("CARGO_PKG_VERSION");
const VERSION_STAMP: &str = ".auberge-version";

static EMBEDDED_ANSIBLE: Dir = include_dir!("$CARGO_MANIFEST_DIR/ansible");

pub struct AnsibleAssets {
    ansible_dir: PathBuf,
}

impl AnsibleAssets {
    pub fn prepare() -> Result<Self> {
        Self::prepare_impl(std::env::var("AUBERGE_DEV").is_ok())
    }

    fn prepare_impl(dev_mode: bool) -> Result<Self> {
        if dev_mode {
            let dev_dir = PathBuf::from("ansible");
            if dev_dir.join("playbooks").exists() && dev_dir.join("roles").exists() {
                return Ok(Self {
                    ansible_dir: dev_dir,
                });
            }
        }

        if cfg!(test) {
            let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ansible");
            return Ok(Self {
                ansible_dir: dev_dir,
            });
        }

        let ansible_dir = crate::config::Config::data_dir()?.join("ansible");
        let stamp = ansible_dir.join(VERSION_STAMP);

        let needs_extract = !stamp.exists()
            || std::fs::read_to_string(&stamp)
                .map(|v| v.trim() != VERSION)
                .unwrap_or(true);

        if needs_extract {
            if ansible_dir.exists() {
                std::fs::remove_dir_all(&ansible_dir)
                    .wrap_err("Failed to remove stale ansible dir")?;
            }
            std::fs::create_dir_all(&ansible_dir).wrap_err("Failed to create ansible dir")?;
            extract_dir(&EMBEDDED_ANSIBLE, &ansible_dir)?;
            write_ansible_cfg(&ansible_dir)?;
            std::fs::write(&stamp, VERSION).wrap_err("Failed to write version stamp")?;
            eprintln!("Extracted ansible assets for v{}", VERSION);
        }

        Ok(Self { ansible_dir })
    }

    pub fn ansible_dir(&self) -> &Path {
        &self.ansible_dir
    }

    pub fn playbooks_dir(&self) -> PathBuf {
        self.ansible_dir.join("playbooks")
    }

    pub fn ensure_collections(&self) -> Result<()> {
        let collections_dir = self.ansible_dir.join(".ansible/collections");
        if collections_dir.join("ansible_collections").exists() {
            return Ok(());
        }

        let requirements = self.ansible_dir.join("requirements.yml");
        if !requirements.exists() {
            return Ok(());
        }

        eprintln!("Installing ansible collections (one-time)...");
        let result = output::run_piped(
            "ansible-galaxy",
            std::process::Command::new("ansible-galaxy")
                .arg("collection")
                .arg("install")
                .arg("-r")
                .arg(&requirements)
                .arg("-p")
                .arg(&collections_dir),
        )
        .wrap_err("Failed to run ansible-galaxy. Is ansible installed?")?;
        if result.status.success() {
            output::clear_subprocess_lines(result.lines_written);
        }

        if !result.status.success() {
            eyre::bail!(
                "ansible-galaxy collection install failed with exit code {}",
                result.status.code().unwrap_or(-1)
            );
        }

        Ok(())
    }
}

fn extract_dir(dir: &Dir, base: &Path) -> Result<()> {
    for entry in dir.entries() {
        extract_entry(entry, base)?;
    }
    Ok(())
}

fn extract_entry(entry: &include_dir::DirEntry, base: &Path) -> Result<()> {
    match entry {
        include_dir::DirEntry::Dir(dir) => {
            let dest = base.join(dir.path());
            std::fs::create_dir_all(&dest)
                .wrap_err_with(|| format!("Failed to create dir: {}", dest.display()))?;
            for sub in dir.entries() {
                extract_entry(sub, base)?;
            }
        }
        include_dir::DirEntry::File(file) => {
            let dest = base.join(file.path());
            if let Some(parent) = dest.parent() {
                std::fs::create_dir_all(parent)?;
            }
            std::fs::write(&dest, file.contents())
                .wrap_err_with(|| format!("Failed to write: {}", dest.display()))?;
        }
    }
    Ok(())
}

fn write_ansible_cfg(ansible_dir: &Path) -> Result<()> {
    let cfg = format!(
        "[defaults]\n\
         inventory = inventory.yml\n\
         roles_path = {roles}\n\
         remote_tmp = /tmp\n\
         collections_path = {collections}\n",
        roles = ansible_dir.join("roles").display(),
        collections = ansible_dir.join(".ansible/collections").display(),
    );
    std::fs::write(ansible_dir.join("ansible.cfg"), cfg).wrap_err("Failed to write ansible.cfg")?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_prepare_impl_uses_dev_mode() {
        let assets = AnsibleAssets::prepare_impl(true).unwrap();
        assert_eq!(assets.ansible_dir(), Path::new("ansible"));
    }

    #[test]
    fn test_embedded_ansible_has_roles() {
        assert!(EMBEDDED_ANSIBLE.get_dir("roles").is_some());
    }

    #[test]
    fn test_embedded_ansible_has_playbooks() {
        assert!(EMBEDDED_ANSIBLE.get_dir("playbooks").is_some());
    }

    #[test]
    fn test_extract_dir_writes_files_to_correct_paths() {
        let tmp = tempfile::tempdir().unwrap();
        let base = tmp.path().join("ansible");
        std::fs::create_dir_all(&base).unwrap();
        extract_dir(&EMBEDDED_ANSIBLE, &base).unwrap();

        assert!(base.join("playbooks").is_dir());
        assert!(base.join("roles").is_dir());
        assert!(base.join("requirements.yml").is_file());
        assert!(base.join("playbooks/apps.yml").is_file());
    }

    #[test]
    fn test_embedded_paths_have_no_ansible_prefix() {
        for entry in EMBEDDED_ANSIBLE.entries() {
            let path = match entry {
                include_dir::DirEntry::Dir(d) => d.path().to_path_buf(),
                include_dir::DirEntry::File(f) => f.path().to_path_buf(),
            };
            assert!(
                !path.starts_with("ansible"),
                "Entry path {path:?} should NOT start with 'ansible/' — include_dir paths are relative to the included directory"
            );
        }
    }
}