#![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 {
#[serde(default)]
pub files: Vec<FileMapping>,
#[serde(default)]
pub hooks: HookConfig,
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"))
}
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()
);
}
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");
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");
Config::init(Some(config_path.clone()), false)
.await
.unwrap();
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");
Config::init(Some(config_path.clone()), false)
.await
.unwrap();
tokio::fs::write(&config_path, "# custom content\n")
.await
.unwrap();
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();
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();
}
}