sklink 0.2.3

Install skills into platform directories via a local store and symlinks
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;

use serde::Deserialize;

use crate::error::AppError;
use crate::path_utils;

#[derive(Debug, Deserialize)]
pub struct Config {
    pub platforms: HashMap<String, PlatformConfig>,
}

#[derive(Debug, Deserialize)]
pub struct PlatformConfig {
    pub targets: Vec<TargetConfig>,
}

#[derive(Debug, Deserialize)]
pub struct TargetConfig {
    pub dir: String,
}

pub fn load_default_config() -> Result<Config, AppError> {
    let config_path = default_config_path()?;
    load_config_from_path(&config_path)
}

pub fn default_config_path() -> Result<PathBuf, AppError> {
    let raw = "~/.config/sklink/config.toml";
    let cwd = std::env::current_dir().map_err(AppError::Io)?;
    path_utils::resolve_path(raw, &cwd)
}

fn load_config_from_path(path: &Path) -> Result<Config, AppError> {
    let content = std::fs::read_to_string(path).map_err(|e| AppError::ConfigRead {
        path: path.to_path_buf(),
        source: e,
    })?;

    toml::from_str::<Config>(&content).map_err(|e| AppError::ConfigParse {
        path: path.to_path_buf(),
        source: e,
    })
}

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

    #[test]
    fn load_config_from_path_parses_valid_toml() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("config.toml");
        std::fs::write(
            &config_path,
            r#"
[platforms.kimi]
targets = [
  { dir = "~/.kimi/skills" },
]
"#,
        )
        .unwrap();

        let config = load_config_from_path(&config_path).unwrap();
        assert!(config.platforms.contains_key("kimi"));
        let platform = config.platforms.get("kimi").unwrap();
        assert_eq!(platform.targets.len(), 1);
        assert_eq!(platform.targets[0].dir, "~/.kimi/skills");
    }

    #[test]
    fn load_config_from_path_returns_read_error_when_missing() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("missing.toml");

        let err = load_config_from_path(&config_path).unwrap_err();
        match err {
            AppError::ConfigRead { path, .. } => assert_eq!(path, config_path),
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn load_config_from_path_returns_parse_error_for_invalid_toml() {
        let dir = tempfile::tempdir().unwrap();
        let config_path = dir.path().join("config.toml");
        std::fs::write(&config_path, "not_toml = [").unwrap();

        let err = load_config_from_path(&config_path).unwrap_err();
        match err {
            AppError::ConfigParse { path, .. } => assert_eq!(path, config_path),
            other => panic!("unexpected error: {other:?}"),
        }
    }
}