From baaa2a95b323296992bcce9c8cdc789c1c52d917 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 19 Apr 2026 21:58:20 +0300 Subject: u4: add core infrastructure scaffolding --- internal/config/config.go | 327 +++++++++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 148 +++++++++++++++++++ internal/config/home.go | 22 +++ 3 files changed, 497 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/home.go (limited to 'internal/config') diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9267ff0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,327 @@ +// Package config provides application configuration loading and prompt template +// helpers for ComicForge. +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/spf13/viper" + + "codeberg.org/snonux/comicforge/internal/provider" +) + +const ( + // DefaultPromptsDir is the fallback directory for Go template prompt files. + DefaultPromptsDir = "./prompts" +) + +// Config holds ComicForge settings loaded from YAML, environment variables, and defaults. +type Config struct { + Provider ProviderConfig `mapstructure:"provider" yaml:"provider"` + API APIConfig `mapstructure:"api" yaml:"api"` + Models ModelConfig `mapstructure:"models" yaml:"models"` + Comic ComicConfig `mapstructure:"comic" yaml:"comic"` + Language LanguageConfig `mapstructure:"language" yaml:"language"` + Story StoryConfig `mapstructure:"story" yaml:"story"` + Styles StyleConfig `mapstructure:"styles" yaml:"styles"` + Narration NarrationConfig `mapstructure:"narration" yaml:"narration"` + + PromptsDir string `mapstructure:"prompts_dir" yaml:"prompts_dir"` +} + +var ( + _ provider.TextConfig = (*Config)(nil) + _ provider.ImageConfig = (*Config)(nil) + _ provider.TTSConfig = (*Config)(nil) +) + +// ProviderConfig stores the selected provider name for each capability. +type ProviderConfig struct { + Text string `mapstructure:"text" yaml:"text"` + Image string `mapstructure:"image" yaml:"image"` + TTS string `mapstructure:"tts" yaml:"tts"` +} + +// APIConfig stores API keys and related secrets. +type APIConfig struct { + GoogleAPIKey string `mapstructure:"google_api_key" yaml:"google_api_key"` +} + +// ModelConfig stores the model IDs used by each capability. +type ModelConfig struct { + Text string `mapstructure:"text" yaml:"text"` + Image string `mapstructure:"image" yaml:"image"` + ImageText string `mapstructure:"image_text" yaml:"image_text"` + TTS string `mapstructure:"tts" yaml:"tts"` +} + +// ComicConfig stores comic generation knobs. +type ComicConfig struct { + StoryPages int `mapstructure:"story_pages" yaml:"story_pages"` + GalleryPages int `mapstructure:"gallery_pages" yaml:"gallery_pages"` + PanelsPerPage int `mapstructure:"panels_per_page" yaml:"panels_per_page"` + AspectRatio string `mapstructure:"aspect_ratio" yaml:"aspect_ratio"` + PromptMaxChars int `mapstructure:"prompt_max_chars" yaml:"prompt_max_chars"` + PageMaxRetries int `mapstructure:"page_max_retries" yaml:"page_max_retries"` + PageRetryBaseSeconds int `mapstructure:"page_retry_base_seconds" yaml:"page_retry_base_seconds"` +} + +// LanguageConfig stores language and script labels used by prompts. +type LanguageConfig struct { + Input string `mapstructure:"input" yaml:"input"` + Output string `mapstructure:"output" yaml:"output"` + Story string `mapstructure:"story_language" yaml:"story_language"` + Script string `mapstructure:"script" yaml:"script"` +} + +// StoryConfig stores story prompt knobs. +type StoryConfig struct { + Genres []string `mapstructure:"genres" yaml:"genres"` + RealisticWeight float64 `mapstructure:"realistic_weight" yaml:"realistic_weight"` +} + +// StyleConfig stores prompt style pools. +type StyleConfig struct { + Comic []string `mapstructure:"comic" yaml:"comic"` + Realistic []string `mapstructure:"realistic" yaml:"realistic"` +} + +// NarrationConfig stores narration prompt knobs. +type NarrationConfig struct { + Voices []string `mapstructure:"voices" yaml:"voices"` + ChunkWords int `mapstructure:"chunk_words" yaml:"chunk_words"` +} + +// DefaultConfig returns a configuration populated with the initial Gemini-first defaults. +func DefaultConfig() *Config { + return &Config{ + Provider: ProviderConfig{ + Text: provider.Gemini, + Image: provider.Gemini, + TTS: provider.Gemini, + }, + API: APIConfig{}, + Models: ModelConfig{ + Text: "gemini-2.5-flash", + Image: "gemini-3.1-flash-image-preview", + ImageText: "gemini-2.5-flash", + TTS: "gemini-2.5-flash-preview-tts", + }, + Comic: ComicConfig{ + StoryPages: 5, + GalleryPages: 5, + PanelsPerPage: 4, + AspectRatio: "16:9", + PromptMaxChars: 900, + PageMaxRetries: 5, + PageRetryBaseSeconds: 15, + }, + Language: LanguageConfig{ + Input: "Vocabulary", + Output: "Story", + Story: "Story", + Script: "Latin", + }, + Story: StoryConfig{ + Genres: []string{ + "a warm slice-of-life story", + "a heartfelt family drama", + "an exciting science-fiction adventure", + }, + RealisticWeight: 0.4, + }, + Styles: StyleConfig{ + Comic: []string{ + "classic comic book with bold ink outlines", + "graphic novel with dramatic shadows", + }, + Realistic: []string{ + "ultra-realistic DSLR photography, cinematic 35mm lens", + "cinematic realism with natural light", + }, + }, + Narration: NarrationConfig{ + Voices: []string{ + "Charon", + "Fenrir", + }, + ChunkWords: 100, + }, + PromptsDir: DefaultPromptsDir, + } +} + +// Load reads configuration from YAML, environment variables, and defaults. +func Load(configPath string) (*Config, error) { + cfg := DefaultConfig() + + v := viper.New() + v.SetEnvPrefix("COMICFORGE") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + v.SetConfigType("yaml") + setDefaults(v, cfg) + + if configPath != "" { + v.SetConfigFile(configPath) + } else { + if homeDir, err := HomeDir(); err == nil { + v.AddConfigPath(filepath.Join(homeDir, ".config", "comicforge")) + v.AddConfigPath(homeDir) + } + v.AddConfigPath(".") + v.SetConfigName("config") + } + + if err := v.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if configPath != "" || !errors.As(err, ¬Found) { + return nil, fmt.Errorf("read config: %w", err) + } + } + + if err := v.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + + cfg.normalize() + if err := cfg.validate(); err != nil { + return nil, err + } + return cfg, nil +} + +// TextProviderName returns the configured text provider name. +func (c *Config) TextProviderName() string { + return provider.NormalizeName(c.Provider.Text) +} + +// ImageProviderName returns the configured image provider name. +func (c *Config) ImageProviderName() string { + return provider.NormalizeName(c.Provider.Image) +} + +// TTSProviderName returns the configured TTS provider name. +func (c *Config) TTSProviderName() string { + return provider.NormalizeName(c.Provider.TTS) +} + +// PromptDir returns the configured prompts directory or the default fallback. +func (c *Config) PromptDir() string { + if c == nil || strings.TrimSpace(c.PromptsDir) == "" { + return DefaultPromptsDir + } + return strings.TrimSpace(c.PromptsDir) +} + +// PromptPath joins the configured prompts directory with the requested template file. +func (c *Config) PromptPath(name string) string { + return filepath.Join(c.PromptDir(), name) +} + +// LoadPromptTemplate parses a Go text/template prompt file from the configured prompts directory. +func (c *Config) LoadPromptTemplate(name string) (*template.Template, error) { + path := c.PromptPath(name) + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read prompt %q: %w", path, err) + } + + tmpl, err := template.New(filepath.Base(path)).Option("missingkey=error").Parse(string(content)) + if err != nil { + return nil, fmt.Errorf("parse prompt %q: %w", path, err) + } + + return tmpl, nil +} + +// RenderPrompt executes a prompt template with the provided data. +func (c *Config) RenderPrompt(name string, data any) (string, error) { + tmpl, err := c.LoadPromptTemplate(name) + if err != nil { + return "", err + } + + var builder strings.Builder + if err := tmpl.Execute(&builder, data); err != nil { + return "", fmt.Errorf("render prompt %q: %w", name, err) + } + + return builder.String(), nil +} + +func (c *Config) normalize() { + c.Provider.Text = provider.NormalizeName(c.Provider.Text) + c.Provider.Image = provider.NormalizeName(c.Provider.Image) + c.Provider.TTS = provider.NormalizeName(c.Provider.TTS) + + if c.Provider.Text == "" { + c.Provider.Text = provider.Gemini + } + if c.Provider.Image == "" { + c.Provider.Image = provider.Gemini + } + if c.Provider.TTS == "" { + c.Provider.TTS = provider.Gemini + } + + if c.PromptsDir == "" { + c.PromptsDir = DefaultPromptsDir + } +} + +func (c *Config) validate() error { + if !provider.IsKnownName(c.Provider.Text) { + return fmt.Errorf("unknown text provider: %s", c.Provider.Text) + } + if !provider.IsKnownName(c.Provider.Image) { + return fmt.Errorf("unknown image provider: %s", c.Provider.Image) + } + if !provider.IsKnownName(c.Provider.TTS) { + return fmt.Errorf("unknown TTS provider: %s", c.Provider.TTS) + } + + return nil +} + +func setDefaults(v *viper.Viper, cfg *Config) { + v.SetDefault("provider.text", cfg.Provider.Text) + v.SetDefault("provider.image", cfg.Provider.Image) + v.SetDefault("provider.tts", cfg.Provider.TTS) + + v.SetDefault("api.google_api_key", cfg.API.GoogleAPIKey) + + v.SetDefault("models.text", cfg.Models.Text) + v.SetDefault("models.image", cfg.Models.Image) + v.SetDefault("models.image_text", cfg.Models.ImageText) + v.SetDefault("models.tts", cfg.Models.TTS) + + v.SetDefault("comic.story_pages", cfg.Comic.StoryPages) + v.SetDefault("comic.gallery_pages", cfg.Comic.GalleryPages) + v.SetDefault("comic.panels_per_page", cfg.Comic.PanelsPerPage) + v.SetDefault("comic.aspect_ratio", cfg.Comic.AspectRatio) + v.SetDefault("comic.prompt_max_chars", cfg.Comic.PromptMaxChars) + v.SetDefault("comic.page_max_retries", cfg.Comic.PageMaxRetries) + v.SetDefault("comic.page_retry_base_seconds", cfg.Comic.PageRetryBaseSeconds) + + v.SetDefault("language.input", cfg.Language.Input) + v.SetDefault("language.output", cfg.Language.Output) + v.SetDefault("language.story_language", cfg.Language.Story) + v.SetDefault("language.script", cfg.Language.Script) + + v.SetDefault("story.genres", cfg.Story.Genres) + v.SetDefault("story.realistic_weight", cfg.Story.RealisticWeight) + + v.SetDefault("styles.comic", cfg.Styles.Comic) + v.SetDefault("styles.realistic", cfg.Styles.Realistic) + + v.SetDefault("narration.voices", cfg.Narration.Voices) + v.SetDefault("narration.chunk_words", cfg.Narration.ChunkWords) + + v.SetDefault("prompts_dir", cfg.PromptsDir) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..d0a4efe --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,148 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/comicforge/internal/provider" +) + +func TestLoadReturnsDefaultsWhenConfigMissing(t *testing.T) { + t.Parallel() + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if got, want := cfg.Provider.Text, provider.Gemini; got != want { + t.Fatalf("Provider.Text = %q, want %q", got, want) + } + if got, want := cfg.PromptsDir, DefaultPromptsDir; got != want { + t.Fatalf("PromptsDir = %q, want %q", got, want) + } +} + +func TestLoadReadsFileAndEnvOverrides(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(strings.TrimSpace(` +provider: + text: openai + image: openai + tts: openai +prompts_dir: ./custom-prompts +comic: + story_pages: 7 +`)), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + t.Setenv("COMICFORGE_PROVIDER_TEXT", "gemini") + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if got, want := cfg.Provider.Text, provider.Gemini; got != want { + t.Fatalf("Provider.Text = %q, want %q", got, want) + } + if got, want := cfg.Provider.Image, provider.OpenAI; got != want { + t.Fatalf("Provider.Image = %q, want %q", got, want) + } + if got, want := cfg.Comic.StoryPages, 7; got != want { + t.Fatalf("Comic.StoryPages = %d, want %d", got, want) + } + if got, want := cfg.PromptsDir, "./custom-prompts"; got != want { + t.Fatalf("PromptsDir = %q, want %q", got, want) + } +} + +func TestRenderPrompt(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := DefaultConfig() + cfg.PromptsDir = tmpDir + + if err := os.WriteFile(filepath.Join(tmpDir, "story.md"), []byte("{{.Word}} -> {{.Translation}}"), 0o644); err != nil { + t.Fatalf("write prompt: %v", err) + } + + got, err := cfg.RenderPrompt("story.md", map[string]string{ + "Word": "ябълка", + "Translation": "apple", + }) + if err != nil { + t.Fatalf("RenderPrompt() error = %v", err) + } + if got != "ябълка -> apple" { + t.Fatalf("RenderPrompt() = %q, want %q", got, "ябълка -> apple") + } +} + +func TestRenderPromptMissingKeyReturnsError(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := DefaultConfig() + cfg.PromptsDir = tmpDir + + if err := os.WriteFile(filepath.Join(tmpDir, "story.md"), []byte("{{.Word}} -> {{.Translation}}"), 0o644); err != nil { + t.Fatalf("write prompt: %v", err) + } + + _, err := cfg.RenderPrompt("story.md", map[string]string{"Word": "ябълка"}) + if err == nil { + t.Fatal("RenderPrompt() error = nil, want error") + } + if !strings.Contains(err.Error(), "render prompt") { + t.Fatalf("RenderPrompt() error = %v, want wrapped render error", err) + } +} + +func TestLoadRejectsUnknownProvider(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.yaml") + if err := os.WriteFile(configPath, []byte(strings.TrimSpace(` +provider: + text: mystery +`)), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := Load(configPath) + if err == nil { + t.Fatal("Load() error = nil, want error") + } + if !strings.Contains(err.Error(), "unknown text provider") { + t.Fatalf("Load() error = %v, want unknown provider error", err) + } +} + +func TestHomeDirReturnsFallbackWhenResolutionFails(t *testing.T) { + t.Parallel() + + oldUserHomeDir := userHomeDir + t.Cleanup(func() { + userHomeDir = oldUserHomeDir + }) + + userHomeDir = func() (string, error) { + return "", errors.New("boom") + } + + homeDir, err := HomeDir() + if err == nil { + t.Fatal("HomeDir() error = nil, want error") + } + if homeDir != "." { + t.Fatalf("HomeDir() homeDir = %q, want %q", homeDir, ".") + } +} diff --git a/internal/config/home.go b/internal/config/home.go new file mode 100644 index 0000000..8089b6e --- /dev/null +++ b/internal/config/home.go @@ -0,0 +1,22 @@ +package config + +import ( + "fmt" + "os" +) + +var userHomeDir = os.UserHomeDir + +// HomeDir returns the user's home directory. +// +// It falls back to "." when the home directory cannot be resolved so callers +// can still build a safe relative path instead of joining against an empty +// string. +func HomeDir() (string, error) { + homeDir, err := userHomeDir() + if err != nil { + return ".", fmt.Errorf("resolve home directory: %w", err) + } + + return homeDir, nil +} -- cgit v1.2.3