新增项目配置功能
This commit is contained in:
75
internal/config/config.go
Normal file
75
internal/config/config.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppConfig 应用配置结构体,对应 config.yaml
|
||||||
|
type AppConfig struct {
|
||||||
|
// 日志设置
|
||||||
|
LogLevel string `yaml:"log_level"` // 日志级别: trace|debug|info|warn|error
|
||||||
|
LogFile string `yaml:"log_file"` // 日志文件路径(可选)
|
||||||
|
|
||||||
|
// 默认 Profile
|
||||||
|
DefaultProfile string `yaml:"default_profile"`
|
||||||
|
|
||||||
|
// 还原选项
|
||||||
|
Restore RestoreConfig `yaml:"restore"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreConfig 还原相关配置
|
||||||
|
type RestoreConfig struct {
|
||||||
|
ConflictStrategy string `yaml:"conflict_strategy"` // skip|overwrite|merge|prompt|newest
|
||||||
|
CreateRestorePoint bool `yaml:"create_restore_point"` // 是否创建还原点
|
||||||
|
DryRunFirst bool `yaml:"dry_run_first"` // 是否先执行干运行
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAppConfig 返回带有合理默认值的配置
|
||||||
|
func DefaultAppConfig() *AppConfig {
|
||||||
|
return &AppConfig{
|
||||||
|
LogLevel: "info",
|
||||||
|
DefaultProfile: "standard",
|
||||||
|
Restore: RestoreConfig{
|
||||||
|
ConflictStrategy: "prompt",
|
||||||
|
CreateRestorePoint: true,
|
||||||
|
DryRunFirst: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig 从指定路径加载配置文件,文件不存在时返回默认配置
|
||||||
|
func LoadConfig(path string) (*AppConfig, error) {
|
||||||
|
cfg := DefaultAppConfig()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// 配置文件不存在,返回默认配置
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("读取配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig 将配置保存到指定路径
|
||||||
|
func SaveConfig(path string, cfg *AppConfig) error {
|
||||||
|
data, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化配置失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("写入配置文件失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
329
internal/config/config_test.go
Normal file
329
internal/config/config_test.go
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== DefaultAppConfig 测试 ====================
|
||||||
|
|
||||||
|
func TestDefaultAppConfig(t *testing.T) {
|
||||||
|
cfg := DefaultAppConfig()
|
||||||
|
|
||||||
|
if cfg.LogLevel != "info" {
|
||||||
|
t.Errorf("默认 LogLevel 应为 info, got: %s", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.LogFile != "" {
|
||||||
|
t.Errorf("默认 LogFile 应为空, got: %s", cfg.LogFile)
|
||||||
|
}
|
||||||
|
if cfg.DefaultProfile != "standard" {
|
||||||
|
t.Errorf("默认 DefaultProfile 应为 standard, got: %s", cfg.DefaultProfile)
|
||||||
|
}
|
||||||
|
if cfg.Restore.ConflictStrategy != "prompt" {
|
||||||
|
t.Errorf("默认 ConflictStrategy 应为 prompt, got: %s", cfg.Restore.ConflictStrategy)
|
||||||
|
}
|
||||||
|
if !cfg.Restore.CreateRestorePoint {
|
||||||
|
t.Error("默认 CreateRestorePoint 应为 true")
|
||||||
|
}
|
||||||
|
if cfg.Restore.DryRunFirst {
|
||||||
|
t.Error("默认 DryRunFirst 应为 false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LoadConfig 测试 ====================
|
||||||
|
|
||||||
|
func TestLoadConfig_FileNotExist(t *testing.T) {
|
||||||
|
cfg, err := LoadConfig("/not/exist/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("文件不存在时不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
// 应返回默认配置
|
||||||
|
if cfg.LogLevel != "info" {
|
||||||
|
t.Errorf("应返回默认 LogLevel, got: %s", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.DefaultProfile != "standard" {
|
||||||
|
t.Errorf("应返回默认 DefaultProfile, got: %s", cfg.DefaultProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_ValidFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
|
content := `log_level: debug
|
||||||
|
log_file: /tmp/devpack.log
|
||||||
|
default_profile: golang-dev
|
||||||
|
restore:
|
||||||
|
conflict_strategy: overwrite
|
||||||
|
create_restore_point: false
|
||||||
|
dry_run_first: true
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("写入测试文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加载配置不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.LogLevel != "debug" {
|
||||||
|
t.Errorf("LogLevel 应为 debug, got: %s", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
if cfg.LogFile != "/tmp/devpack.log" {
|
||||||
|
t.Errorf("LogFile 应为 /tmp/devpack.log, got: %s", cfg.LogFile)
|
||||||
|
}
|
||||||
|
if cfg.DefaultProfile != "golang-dev" {
|
||||||
|
t.Errorf("DefaultProfile 应为 golang-dev, got: %s", cfg.DefaultProfile)
|
||||||
|
}
|
||||||
|
if cfg.Restore.ConflictStrategy != "overwrite" {
|
||||||
|
t.Errorf("ConflictStrategy 应为 overwrite, got: %s", cfg.Restore.ConflictStrategy)
|
||||||
|
}
|
||||||
|
if cfg.Restore.CreateRestorePoint {
|
||||||
|
t.Error("CreateRestorePoint 应为 false")
|
||||||
|
}
|
||||||
|
if !cfg.Restore.DryRunFirst {
|
||||||
|
t.Error("DryRunFirst 应为 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_PartialFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
|
// 只设置部分字段,其余应保留默认值
|
||||||
|
content := `log_level: warn
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("写入测试文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加载配置不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式设置的字段
|
||||||
|
if cfg.LogLevel != "warn" {
|
||||||
|
t.Errorf("LogLevel 应为 warn, got: %s", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
// 未设置的字段应保留默认值
|
||||||
|
if cfg.DefaultProfile != "standard" {
|
||||||
|
t.Errorf("未设置的 DefaultProfile 应保留默认值 standard, got: %s", cfg.DefaultProfile)
|
||||||
|
}
|
||||||
|
if cfg.Restore.ConflictStrategy != "prompt" {
|
||||||
|
t.Errorf("未设置的 ConflictStrategy 应保留默认值 prompt, got: %s", cfg.Restore.ConflictStrategy)
|
||||||
|
}
|
||||||
|
if !cfg.Restore.CreateRestorePoint {
|
||||||
|
t.Error("未设置的 CreateRestorePoint 应保留默认值 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_InvalidYAML(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
|
content := `log_level: [invalid yaml
|
||||||
|
this is broken`
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("写入测试文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(path)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("无效 YAML 应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_EmptyFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
|
||||||
|
t.Fatalf("写入测试文件失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("空文件不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
// 空文件应返回默认配置
|
||||||
|
if cfg.LogLevel != "info" {
|
||||||
|
t.Errorf("空文件应保留默认 LogLevel, got: %s", cfg.LogLevel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SaveConfig 测试 ====================
|
||||||
|
|
||||||
|
func TestSaveConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "config.yaml")
|
||||||
|
|
||||||
|
cfg := &AppConfig{
|
||||||
|
LogLevel: "debug",
|
||||||
|
LogFile: "/var/log/devpack.log",
|
||||||
|
DefaultProfile: "full",
|
||||||
|
Restore: RestoreConfig{
|
||||||
|
ConflictStrategy: "skip",
|
||||||
|
CreateRestorePoint: true,
|
||||||
|
DryRunFirst: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveConfig(path, cfg); err != nil {
|
||||||
|
t.Fatalf("保存配置不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件已创建
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
t.Fatal("配置文件应已被创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新加载验证内容一致
|
||||||
|
loaded, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("重新加载不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.LogLevel != cfg.LogLevel {
|
||||||
|
t.Errorf("LogLevel 不匹配: want %s, got %s", cfg.LogLevel, loaded.LogLevel)
|
||||||
|
}
|
||||||
|
if loaded.LogFile != cfg.LogFile {
|
||||||
|
t.Errorf("LogFile 不匹配: want %s, got %s", cfg.LogFile, loaded.LogFile)
|
||||||
|
}
|
||||||
|
if loaded.DefaultProfile != cfg.DefaultProfile {
|
||||||
|
t.Errorf("DefaultProfile 不匹配: want %s, got %s", cfg.DefaultProfile, loaded.DefaultProfile)
|
||||||
|
}
|
||||||
|
if loaded.Restore.ConflictStrategy != cfg.Restore.ConflictStrategy {
|
||||||
|
t.Errorf("ConflictStrategy 不匹配: want %s, got %s", cfg.Restore.ConflictStrategy, loaded.Restore.ConflictStrategy)
|
||||||
|
}
|
||||||
|
if loaded.Restore.CreateRestorePoint != cfg.Restore.CreateRestorePoint {
|
||||||
|
t.Errorf("CreateRestorePoint 不匹配: want %v, got %v", cfg.Restore.CreateRestorePoint, loaded.Restore.CreateRestorePoint)
|
||||||
|
}
|
||||||
|
if loaded.Restore.DryRunFirst != cfg.Restore.DryRunFirst {
|
||||||
|
t.Errorf("DryRunFirst 不匹配: want %v, got %v", cfg.Restore.DryRunFirst, loaded.Restore.DryRunFirst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveConfig_InvalidPath(t *testing.T) {
|
||||||
|
cfg := DefaultAppConfig()
|
||||||
|
// 写入不存在的目录应报错
|
||||||
|
err := SaveConfig("/not/exist/dir/config.yaml", cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("写入不存在的目录应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Save → Load 往返测试 ====================
|
||||||
|
|
||||||
|
func TestSaveAndLoad_RoundTrip(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "roundtrip.yaml")
|
||||||
|
|
||||||
|
original := &AppConfig{
|
||||||
|
LogLevel: "trace",
|
||||||
|
LogFile: "logs/app.log",
|
||||||
|
DefaultProfile: "minimal",
|
||||||
|
Restore: RestoreConfig{
|
||||||
|
ConflictStrategy: "newest",
|
||||||
|
CreateRestorePoint: false,
|
||||||
|
DryRunFirst: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save
|
||||||
|
if err := SaveConfig(path, original); err != nil {
|
||||||
|
t.Fatalf("SaveConfig 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load
|
||||||
|
loaded, err := LoadConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐字段对比
|
||||||
|
if *loaded != *original {
|
||||||
|
t.Errorf("往返后配置不一致:\n 原始: %+v\n 加载: %+v", original, loaded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DefaultPaths 测试 ====================
|
||||||
|
|
||||||
|
func TestDefaultPaths(t *testing.T) {
|
||||||
|
paths, err := DefaultPaths()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DefaultPaths 不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
expected := filepath.Join(home, ".devpack")
|
||||||
|
|
||||||
|
if paths.Home != expected {
|
||||||
|
t.Errorf("Home 应为 %s, got: %s", expected, paths.Home)
|
||||||
|
}
|
||||||
|
if paths.Config != filepath.Join(expected, "config.yaml") {
|
||||||
|
t.Errorf("Config 路径不正确, got: %s", paths.Config)
|
||||||
|
}
|
||||||
|
if paths.Profiles != filepath.Join(expected, "profiles") {
|
||||||
|
t.Errorf("Profiles 路径不正确, got: %s", paths.Profiles)
|
||||||
|
}
|
||||||
|
if paths.Packs != filepath.Join(expected, "packs") {
|
||||||
|
t.Errorf("Packs 路径不正确, got: %s", paths.Packs)
|
||||||
|
}
|
||||||
|
if paths.Logs != filepath.Join(expected, "logs") {
|
||||||
|
t.Errorf("Logs 路径不正确, got: %s", paths.Logs)
|
||||||
|
}
|
||||||
|
if paths.Temp != filepath.Join(expected, "tmp") {
|
||||||
|
t.Errorf("Temp 路径不正确, got: %s", paths.Temp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDirs(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
paths := &Paths{
|
||||||
|
Home: filepath.Join(tmpDir, ".devpack"),
|
||||||
|
Profiles: filepath.Join(tmpDir, ".devpack", "profiles"),
|
||||||
|
Packs: filepath.Join(tmpDir, ".devpack", "packs"),
|
||||||
|
Logs: filepath.Join(tmpDir, ".devpack", "logs"),
|
||||||
|
Temp: filepath.Join(tmpDir, ".devpack", "tmp"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := paths.EnsureDirs(); err != nil {
|
||||||
|
t.Fatalf("EnsureDirs 不应报错, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证所有目录都已创建
|
||||||
|
dirs := []string{paths.Home, paths.Profiles, paths.Packs, paths.Logs, paths.Temp}
|
||||||
|
for _, dir := range dirs {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
t.Errorf("目录应已创建: %s", dir)
|
||||||
|
}
|
||||||
|
if err == nil && !info.IsDir() {
|
||||||
|
t.Errorf("应为目录而非文件: %s", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDirs_Idempotent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
paths := &Paths{
|
||||||
|
Home: filepath.Join(tmpDir, ".devpack"),
|
||||||
|
Profiles: filepath.Join(tmpDir, ".devpack", "profiles"),
|
||||||
|
Packs: filepath.Join(tmpDir, ".devpack", "packs"),
|
||||||
|
Logs: filepath.Join(tmpDir, ".devpack", "logs"),
|
||||||
|
Temp: filepath.Join(tmpDir, ".devpack", "tmp"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用两次应幂等
|
||||||
|
if err := paths.EnsureDirs(); err != nil {
|
||||||
|
t.Fatalf("第一次 EnsureDirs 失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := paths.EnsureDirs(); err != nil {
|
||||||
|
t.Fatalf("第二次 EnsureDirs 应幂等, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user