diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..efebcea --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e2d9826 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +}