twin-cli 0.2.0

Git worktree wrapper with side effects (symlinks and hooks)
Documentation
#![allow(clippy::all)]
#![allow(dead_code)]
/// 設定管理モジュール
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;

use crate::core::{FileMapping, HookCommand, HookConfig, MappingType};

/// アプリケーション全体の設定
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Git管理外ファイルのマッピング設定
    #[serde(default)]
    pub files: Vec<FileMapping>,

    /// フック設定(環境作成・削除時に実行するコマンド)
    #[serde(default)]
    pub hooks: HookConfig,

    /// Worktreeのベースディレクトリ
    pub worktree_base: Option<PathBuf>,

    /// デフォルトのブランチプレフィックス
    #[serde(default = "default_branch_prefix")]
    pub branch_prefix: String,
}

fn default_branch_prefix() -> String {
    "agent/".to_string()
}

impl Default for Config {
    fn default() -> Self {
        Self {
            files: Vec::new(),
            hooks: HookConfig::default(),
            worktree_base: Some(PathBuf::from("worktrees")),
            branch_prefix: default_branch_prefix(),
        }
    }
}

impl Config {
    /// 設定ファイルを読み込む(ファイルが存在しない場合はデフォルト値を返す)
    pub async fn load(path: Option<&Path>) -> Result<Self> {
        // パスが指定されていて、ファイルが存在する場合は読み込む
        if let Some(p) = path {
            if p.exists() {
                let content = fs::read_to_string(p)
                    .await
                    .with_context(|| format!("Failed to read config file: {}", p.display()))?;
                return toml::from_str(&content)
                    .with_context(|| format!("Failed to parse config file: {}", p.display()));
            }
        }

        // プロジェクト設定を探す
        if let Some(config_path) = Self::find_config_path(Path::new(".")).await {
            let content = fs::read_to_string(&config_path).await.with_context(|| {
                format!("Failed to read config file: {}", config_path.display())
            })?;
            return toml::from_str(&content).with_context(|| {
                format!("Failed to parse config file: {}", config_path.display())
            });
        }

        // グローバル設定を試す
        if let Ok(global_path) = Self::global_config_path() {
            if global_path.exists() {
                let content = fs::read_to_string(&global_path).await.with_context(|| {
                    format!("Failed to read config file: {}", global_path.display())
                })?;
                return toml::from_str(&content).with_context(|| {
                    format!("Failed to parse config file: {}", global_path.display())
                });
            }
        }

        // デフォルト設定を返す
        Ok(Self::default())
    }

    /// 設定ファイルを保存
    pub async fn save(&self, path: &Path) -> Result<()> {
        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;

        // 親ディレクトリが存在しない場合は作成
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).await?;
        }

        fs::write(path, content)
            .await
            .with_context(|| format!("Failed to write config file: {}", path.display()))
    }

    /// デフォルト設定ファイルを作成
    pub fn example() -> Self {
        let mut env_vars = HashMap::new();
        env_vars.insert("NODE_ENV".to_string(), "production".to_string());

        Self {
            files: vec![
                FileMapping {
                    path: PathBuf::from(".env"),
                    mapping_type: MappingType::Symlink,
                    description: Some("環境変数ファイル(共有)".to_string()),
                    skip_if_exists: false,
                },
                FileMapping {
                    path: PathBuf::from(".env.local"),
                    mapping_type: MappingType::Copy,
                    description: Some("ローカル環境変数(各環境で独立)".to_string()),
                    skip_if_exists: false,
                },
                FileMapping {
                    path: PathBuf::from(".vscode/settings.local.json"),
                    mapping_type: MappingType::Symlink,
                    description: Some("VS Codeローカル設定".to_string()),
                    skip_if_exists: true,
                },
            ],
            hooks: HookConfig {
                pre_create: vec![],
                post_create: vec![
                    HookCommand {
                        command: "echo".to_string(),
                        args: vec!["Setting up environment...".to_string()],
                        env: HashMap::new(),
                        timeout: 60,
                        continue_on_error: false,
                    },
                    HookCommand {
                        command: "npm".to_string(),
                        args: vec!["install".to_string()],
                        env: env_vars.clone(),
                        timeout: 300,
                        continue_on_error: false,
                    },
                ],
                pre_remove: vec![HookCommand {
                    command: "echo".to_string(),
                    args: vec!["Cleaning up environment...".to_string()],
                    env: HashMap::new(),
                    timeout: 60,
                    continue_on_error: true,
                }],
                post_remove: vec![],
            },
            worktree_base: Some(PathBuf::from("./worktrees")),
            branch_prefix: "agent/".to_string(),
        }
    }

    /// グローバル設定とプロジェクト設定をマージ
    pub fn merge(global: Self, project: Self) -> Self {
        // プロジェクト設定を優先し、未設定の項目はグローバル設定を使用
        Self {
            files: if !project.files.is_empty() {
                project.files
            } else {
                global.files
            },
            hooks: if project.hooks != HookConfig::default() {
                project.hooks
            } else {
                global.hooks
            },
            worktree_base: project.worktree_base.or(global.worktree_base),
            branch_prefix: if project.branch_prefix != default_branch_prefix() {
                project.branch_prefix
            } else {
                global.branch_prefix
            },
        }
    }

    /// 設定ファイルのパスを取得(プロジェクトルートから検索)
    pub async fn find_config_path(start_path: &Path) -> Option<PathBuf> {
        let mut current = start_path.to_path_buf();

        loop {
            let config_path = current.join("twin.toml");
            if config_path.exists() {
                return Some(config_path);
            }

            let dot_config_path = current.join(".twin.toml");
            if dot_config_path.exists() {
                return Some(dot_config_path);
            }

            if !current.pop() {
                break;
            }
        }

        None
    }

    /// グローバル設定ファイルのパスを取得
    pub fn global_config_path() -> Result<PathBuf> {
        let proj_dirs = directories::ProjectDirs::from("com", "twin", "twin")
            .context("Failed to get project directories")?;
        Ok(proj_dirs.config_dir().join("config.toml"))
    }

    /// 設定ファイルを初期化(twin initコマンド用)
    pub async fn init(path: Option<PathBuf>, force: bool) -> Result<PathBuf> {
        let config_path = path.unwrap_or_else(|| PathBuf::from("twin.toml"));

        // ファイルが既に存在する場合
        if config_path.exists() && !force {
            anyhow::bail!(
                "Config file already exists: {}. Use --force to overwrite.",
                config_path.display()
            );
        }

        // デフォルト設定を作成
        // twin initでは実用的なサンプル設定を生成して、
        // ユーザーが参考にできるようにする
        let config = if cfg!(test) {
            // テスト時はシンプルなデフォルト設定を使用
            Self::default()
        } else {
            // 本番環境では実用的なサンプル設定を使用
            Self::example()
        };
        config.save(&config_path).await?;

        Ok(config_path)
    }
}

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

    #[test]
    fn test_default_config() {
        let config = Config::default();
        assert_eq!(config.branch_prefix, "agent/");
        assert!(config.files.is_empty());
    }

    #[test]
    fn test_example_config() {
        let config = Config::example();
        assert!(!config.files.is_empty());
        assert_eq!(config.files[0].path, PathBuf::from(".env"));
        assert_eq!(config.files[0].mapping_type, MappingType::Symlink);
        assert_eq!(config.files[1].mapping_type, MappingType::Copy);
        assert!(!config.hooks.post_create.is_empty());
    }

    #[test]
    fn test_hook_command_example() {
        let config = Config::example();
        let first_hook = &config.hooks.post_create[0];
        assert_eq!(first_hook.command, "echo");
        assert_eq!(first_hook.timeout, 60);
        assert!(!first_hook.continue_on_error);
    }

    #[tokio::test]
    async fn test_init_creates_file() {
        use tempfile::TempDir;

        // 一時ディレクトリを作成
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("twin.toml");

        // initを実行
        let result_path = Config::init(Some(config_path.clone()), false)
            .await
            .unwrap();

        // ファイルが作成されたことを確認
        assert_eq!(result_path, config_path);
        assert!(config_path.exists());

        // ファイルの内容を読み込んで解析できることを確認
        let content = tokio::fs::read_to_string(&config_path).await.unwrap();
        let _config: Config = toml::from_str(&content).unwrap();
    }

    #[tokio::test]
    async fn test_init_fails_if_exists() {
        use tempfile::TempDir;

        // 一時ディレクトリを作成
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("twin.toml");

        // 最初のinitは成功するはず
        Config::init(Some(config_path.clone()), false)
            .await
            .unwrap();

        // 2回目のinitは失敗するはず(forceなし)
        let result = Config::init(Some(config_path.clone()), false).await;
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("already exists"));
    }

    #[tokio::test]
    async fn test_init_force_overwrites() {
        use tempfile::TempDir;

        // 一時ディレクトリを作成
        let temp_dir = TempDir::new().unwrap();
        let config_path = temp_dir.path().join("twin.toml");

        // 最初のinitを実行
        Config::init(Some(config_path.clone()), false)
            .await
            .unwrap();

        // カスタム内容でファイルを上書き
        tokio::fs::write(&config_path, "# custom content\n")
            .await
            .unwrap();

        // forceフラグ付きでinitを実行
        Config::init(Some(config_path.clone()), true).await.unwrap();

        // ファイルが新しい設定で上書きされたことを確認
        let content = tokio::fs::read_to_string(&config_path).await.unwrap();
        assert!(!content.starts_with("# custom content"));
        let _config: Config = toml::from_str(&content).unwrap();
    }

    #[tokio::test]
    async fn test_init_default_path() {
        use std::env;
        use tempfile::TempDir;

        // 一時ディレクトリを作成して作業ディレクトリを変更
        let temp_dir = TempDir::new().unwrap();
        let original_dir = env::current_dir().unwrap();
        env::set_current_dir(temp_dir.path()).unwrap();

        // パスを指定せずにinitを実行
        let result_path = Config::init(None, false).await.unwrap();

        // デフォルトのファイル名が使われることを確認
        assert_eq!(result_path.file_name().unwrap(), "twin.toml");
        assert!(result_path.exists());

        // 元のディレクトリに戻す
        env::set_current_dir(original_dir).unwrap();
    }
}