ostool 0.15.0

A tool for operating system development
Documentation
use std::{
    env, fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};

pub const DEFAULT_BOARD_SERVER_IP: &str = "localhost";
pub const DEFAULT_BOARD_SERVER_PORT: u16 = 2999;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct BoardGlobalConfigFile {
    #[serde(default)]
    pub board: BoardGlobalConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BoardGlobalConfig {
    #[serde(default = "default_server_ip")]
    pub server_ip: String,
    #[serde(default = "default_server_port")]
    pub port: u16,
}

impl Default for BoardGlobalConfig {
    fn default() -> Self {
        Self {
            server_ip: DEFAULT_BOARD_SERVER_IP.to_string(),
            port: DEFAULT_BOARD_SERVER_PORT,
        }
    }
}

#[derive(Debug, Clone)]
pub struct LoadedBoardGlobalConfig {
    pub path: PathBuf,
    pub board: BoardGlobalConfig,
    pub created: bool,
}

impl LoadedBoardGlobalConfig {
    pub fn load_or_create() -> anyhow::Result<Self> {
        let path = default_config_path()?;
        Self::load_or_create_at(&path)
    }

    pub fn load_or_create_at(path: &Path) -> anyhow::Result<Self> {
        match fs::read_to_string(path) {
            Ok(content) => {
                let file: BoardGlobalConfigFile = toml::from_str(&content)
                    .with_context(|| format!("failed to parse {}", path.display()))?;
                file.board.validate(path)?;
                Ok(Self {
                    path: path.to_path_buf(),
                    board: file.board,
                    created: false,
                })
            }
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                let file = BoardGlobalConfigFile::default();
                write_config_file(path, &file)?;
                Ok(Self {
                    path: path.to_path_buf(),
                    board: file.board,
                    created: true,
                })
            }
            Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
        }
    }

    pub fn save(&self) -> anyhow::Result<()> {
        write_config_file(
            &self.path,
            &BoardGlobalConfigFile {
                board: self.board.clone(),
            },
        )
    }

    pub fn resolve_server(&self, cli_server: Option<&str>, cli_port: Option<u16>) -> (String, u16) {
        self.board.resolve_server(cli_server, cli_port)
    }
}

impl BoardGlobalConfig {
    pub fn resolve_server(&self, cli_server: Option<&str>, cli_port: Option<u16>) -> (String, u16) {
        let server_ip = cli_server
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_string)
            .unwrap_or_else(|| self.server_ip.clone());
        let port = cli_port.unwrap_or(self.port);
        (server_ip, port)
    }

    pub fn validate(&self, path: &Path) -> anyhow::Result<()> {
        if self.server_ip.trim().is_empty() {
            bail!("`board.server_ip` must not be empty in {}", path.display());
        }
        if self.port == 0 {
            bail!("`board.port` must be in 1..=65535 in {}", path.display());
        }
        Ok(())
    }
}

fn default_server_ip() -> String {
    DEFAULT_BOARD_SERVER_IP.to_string()
}

const fn default_server_port() -> u16 {
    DEFAULT_BOARD_SERVER_PORT
}

fn default_config_path() -> anyhow::Result<PathBuf> {
    let home = env::var_os("HOME").context("HOME is not set")?;
    Ok(PathBuf::from(home).join(".ostool").join("config.toml"))
}

fn write_config_file(path: &Path, file: &BoardGlobalConfigFile) -> anyhow::Result<()> {
    file.board.validate(path)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    fs::write(path, toml::to_string_pretty(file)?)
        .with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::{BoardGlobalConfig, LoadedBoardGlobalConfig};

    #[test]
    fn load_or_create_creates_default_config_when_missing() {
        let temp = tempdir().unwrap();
        let path = temp.path().join(".ostool/config.toml");

        let loaded = LoadedBoardGlobalConfig::load_or_create_at(&path).unwrap();

        assert!(loaded.created);
        assert_eq!(loaded.board.server_ip, "localhost");
        assert_eq!(loaded.board.port, 2999);
        let content = std::fs::read_to_string(&path).unwrap();
        assert!(content.contains("[board]"));
        assert!(content.contains("server_ip = \"localhost\""));
        assert!(content.contains("port = 2999"));
    }

    #[test]
    fn resolve_server_prefers_cli_over_global_defaults() {
        let config = BoardGlobalConfig {
            server_ip: "10.0.0.2".into(),
            port: 8000,
        };

        assert_eq!(
            config.resolve_server(Some("192.168.1.2"), Some(9000)),
            ("192.168.1.2".to_string(), 9000)
        );
        assert_eq!(
            config.resolve_server(None, None),
            ("10.0.0.2".to_string(), 8000)
        );
    }

    #[test]
    fn save_persists_updated_values() {
        let temp = tempdir().unwrap();
        let path = temp.path().join(".ostool/config.toml");
        let mut loaded = LoadedBoardGlobalConfig::load_or_create_at(&path).unwrap();

        loaded.board.server_ip = "10.0.0.2".into();
        loaded.board.port = 9000;
        loaded.save().unwrap();

        let reloaded = LoadedBoardGlobalConfig::load_or_create_at(&path).unwrap();
        assert!(!reloaded.created);
        assert_eq!(reloaded.board.server_ip, "10.0.0.2");
        assert_eq!(reloaded.board.port, 9000);
    }
}