tuning 0.4.0

ansible-like tool with a smaller scope, focused primarily on complementing dotfiles for cross-machine bliss
use std::{fs, io, mem};

use camino::Utf8PathBuf;
use thiserror::Error as ThisError;

use crate::{
    facts::Facts,
    preflight::{self, RawMain},
};

const MAIN_TOML_FILE: &str = "main.toml";

#[derive(Debug, ThisError)]
pub(crate) enum Error {
    #[error("valid config file not found")]
    ConfigNotFound,
    #[error("expected an absolute path, received a relative path '{0}'")]
    RelativePath(Utf8PathBuf),
    #[error(transparent)]
    Io {
        #[from]
        source: io::Error,
    },
    #[error(transparent)]
    Preflight {
        #[from]
        source: preflight::Error,
    },
}

type Result<T> = std::result::Result<T, Error>;

pub(crate) fn find_config_file(facts: &Facts) -> Result<Utf8PathBuf> {
    let config_paths = [
        facts
            .config_dir
            .join(env!("CARGO_PKG_NAME"))
            .join(MAIN_TOML_FILE),
        facts
            .home_dir
            .join(".dotfiles")
            .join(env!("CARGO_PKG_NAME"))
            .join(MAIN_TOML_FILE),
    ];
    for config_path in config_paths {
        if config_path.exists() {
            return Ok(config_path);
        }
    }
    Err(Error::ConfigNotFound)
}

fn read_config_toml(src: Utf8PathBuf, visited: &mut Vec<Utf8PathBuf>) -> Result<RawMain> {
    if !src.is_absolute() {
        return Err(Error::RelativePath(src));
    }

    let src = src.canonicalize_utf8()?;
    visited.push(src.clone());

    let text = fs::read_to_string(&src)?;
    let mut config = RawMain::try_from(text.as_str())?;

    // panic: we read files, not directories, and every valid file path has a parent directory,
    // so let's bail early if we somehow get into this state
    let cwd = src
        .parent()
        .expect("cannot determine parent directory for {src}");

    let mut includes = vec![];
    mem::swap(&mut includes, &mut config.includes);

    for include in includes {
        let next_src = if include.src.is_absolute() {
            include.src.clone()
        } else {
            cwd.join(include.src)
        };
        if visited.contains(&next_src) {
            break;
        }
        let mut next_cfg = read_config_toml(next_src, visited)?;
        config.jobs.append(&mut next_cfg.jobs);
    }

    Ok(config)
}

pub(crate) fn read_main_toml(src: Utf8PathBuf) -> Result<RawMain> {
    let src = if src.is_absolute() {
        src
    } else {
        // panic: let's bail early if we somehow get into this state
        crate::env::current_dir().join(src)
    };

    let mut visited = Vec::new();
    read_config_toml(src, &mut visited)
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use toml::value::Value;

    use crate::preflight::RawJob;

    use super::*;

    #[test]
    fn read_main_toml_with_no_includes() -> Result<()> {
        let src = crate::env::current_dir()
            .join("tests")
            .join("fixtures")
            .join("config_no_includes.toml");
        let want = RawMain {
            jobs: vec![RawJob {
                name: String::from("config_no_includes"),
                needs: vec![],
                spec: HashMap::from([(String::from("type"), Value::from("fake"))]),
            }],
            ..Default::default()
        };

        let got = read_main_toml(src)?;

        assert_eq!(got, want);
        Ok(())
    }

    #[test]
    fn read_main_toml_with_includes_not_found() {
        let src = crate::env::current_dir()
            .join("tests")
            .join("fixtures")
            .join("config_not_found.toml");

        let got = read_main_toml(src);

        assert!(got.is_err());
        assert!(matches!(got.unwrap_err(), Error::Io { .. }));
    }

    #[test]
    fn read_main_toml_with_deep_includes() -> Result<()> {
        let src = crate::env::current_dir()
            .join("tests")
            .join("fixtures")
            .join("config_deep_includes.toml");
        let want = RawMain {
            jobs: vec![
                RawJob {
                    name: String::from("config_deep_includes"),
                    needs: vec![],
                    spec: HashMap::from([(String::from("type"), Value::from("fake"))]),
                },
                RawJob {
                    name: String::from("config_includes"),
                    needs: vec![],
                    spec: HashMap::from([(String::from("type"), Value::from("fake"))]),
                },
                RawJob {
                    name: String::from("config_no_includes"),
                    needs: vec![],
                    spec: HashMap::from([(String::from("type"), Value::from("fake"))]),
                },
            ],
            ..Default::default()
        };

        let got = read_main_toml(src)?;

        assert_eq!(got, want);
        Ok(())
    }

    #[test]
    fn read_main_toml_with_recursive_includes() -> Result<()> {
        let src = crate::env::current_dir()
            .join("tests")
            .join("fixtures")
            .join("config_recursive_includes.toml");
        let want = RawMain {
            jobs: vec![RawJob {
                name: String::from("config_recursive_includes"),
                needs: vec![],
                spec: HashMap::from([(String::from("type"), Value::from("fake"))]),
            }],
            ..Default::default()
        };

        let got = read_main_toml(src)?;

        assert_eq!(got, want);
        Ok(())
    }
}