nishikaze 0.3.1

Zephyr build system companion.
Documentation
//! Load and parse kaze.toml from disk.

use std::fs;
use std::path::{Path, PathBuf};

use crate::config::schema::FileConfig;

/// Errors while loading kaze.toml.
#[derive(Debug)]
pub enum LoadError {
    /// Failed to read the config file.
    Io {
        /// Path to the configuration file.
        path: PathBuf,
        /// Underlying I/O error.
        source: std::io::Error,
    },
    /// Failed to parse TOML.
    Toml {
        /// Path to the configuration file.
        path: PathBuf,
        /// Underlying TOML parse error.
        source: toml::de::Error,
    },
}

impl std::fmt::Display for LoadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match *self {
            Self::Io {
                ref path,
                ref source,
            } => write!(f, "failed to read {}: {source}", path.display()),
            Self::Toml {
                ref path,
                ref source,
            } => write!(f, "failed to parse {}: {source}", path.display()),
        }
    }
}

impl std::error::Error for LoadError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match *self {
            Self::Io { ref source, .. } => Some(source),
            Self::Toml { ref source, .. } => Some(source),
        }
    }
}

/**
 * Loads the kaze.toml configuration for a project.
 *
 * # Errors
 * Returns `LoadError::Io` when the file cannot be read and
 * `LoadError::Toml` when parsing fails.
 */
pub fn load_config(project_dir: &Path) -> Result<FileConfig, LoadError> {
    let path = project_dir.join("kaze.toml");
    let s = fs::read_to_string(&path).map_err(|e| LoadError::Io {
        path: path.clone(),
        source: e,
    })?;
    let value = toml::from_str::<toml::Value>(&s).map_err(|e| LoadError::Toml {
        path: path.clone(),
        source: e,
    })?;
    if value.get("args").is_some() {
        return Err(LoadError::Toml {
            path,
            source: <toml::de::Error as serde::de::Error>::custom(
                "top-level [args] is no longer supported; move args under [project.args]",
            ),
        });
    }
    toml::from_str::<FileConfig>(&s).map_err(|e| LoadError::Toml { path, source: e })
}

#[cfg(test)]
mod tests {
    use std::error::Error;
    use std::sync::atomic::{AtomicUsize, Ordering};

    use super::*;

    fn make_temp_dir(label: &str) -> PathBuf {
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let mut dir = std::env::temp_dir();
        dir.push(format!(
            "nishikaze-test-{}-{}",
            label,
            COUNTER.fetch_add(1, Ordering::Relaxed)
        ));
        if dir.exists() {
            fs::remove_dir_all(&dir).expect("clean temp dir");
        }
        fs::create_dir_all(&dir).expect("create temp dir");
        dir
    }

    #[test]
    fn load_config_success() {
        let dir = make_temp_dir("load-ok");
        let path = dir.join("kaze.toml");
        fs::write(&path, "").expect("write config");

        let cfg = load_config(&dir).expect("load config");
        assert_eq!(cfg.project.board, None);
    }

    #[test]
    fn load_config_missing_file() {
        let dir = make_temp_dir("load-missing");
        let err = load_config(&dir).expect_err("missing file");
        assert!(matches!(err, LoadError::Io { .. }));
    }

    #[test]
    fn load_config_invalid_toml() {
        let dir = make_temp_dir("load-invalid");
        let path = dir.join("kaze.toml");
        fs::write(&path, "not = [").expect("write config");

        let err = load_config(&dir).expect_err("invalid toml");
        assert!(matches!(err, LoadError::Toml { .. }));
    }

    #[test]
    fn load_error_display_includes_path() {
        let dir = make_temp_dir("load-display");
        let err = load_config(&dir).expect_err("missing file");
        let msg = err.to_string();
        assert!(msg.contains("kaze.toml"));
    }

    #[test]
    fn load_error_sources_are_exposed() {
        let dir_io = make_temp_dir("load-source-io");
        let err_io = load_config(&dir_io).expect_err("missing file");
        assert!(err_io.source().is_some());

        let dir_toml = make_temp_dir("load-source-toml");
        let path = dir_toml.join("kaze.toml");
        fs::write(&path, "not = [").expect("write config");
        let err_toml = load_config(&dir_toml).expect_err("invalid toml");
        assert!(err_toml.source().is_some());
    }

    #[test]
    fn load_config_rejects_legacy_args() {
        let dir = make_temp_dir("load-legacy-args");
        let path = dir.join("kaze.toml");
        fs::write(&path, "[args]\nconf = [\"-DOLD=1\"]\n").expect("write config");

        let err = load_config(&dir).expect_err("legacy args rejected");
        let msg = err.to_string();
        assert!(msg.contains("top-level [args] is no longer supported"));
    }

    #[test]
    fn load_config_parses_project_fields() {
        let dir = make_temp_dir("load-parse");
        let path = dir.join("kaze.toml");
        fs::write(
            &path,
            r#"[project]
board = "native_sim"
runner = "openocd"
name = "demo"
"#,
        )
        .expect("write config");

        let cfg = load_config(&dir).expect("load config");
        assert_eq!(cfg.project.board.as_deref(), Some("native_sim"));
        assert_eq!(cfg.project.runner.as_deref(), Some("openocd"));
        assert_eq!(cfg.project.name.as_deref(), Some("demo"));
    }
}