diff --git a/.golangci.yml b/.golangci.yml index 6cfc0344..c9bf9dd9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -59,6 +59,14 @@ linters: - usestdlibvars - whitespace settings: + dupl: + # 200 covers the 57-line render/output_path parallel template + # parsing as well as the test patterns in cmd/ (each repeated + # setup block in cli_integration_test.go and + # template_integration_test.go registers as ~20 lines, fitting + # under 200). Raise further if the linter starts flagging new + # template helpers down the line. + threshold: 200 tagalign: sort: true strict: false @@ -93,6 +101,41 @@ linters: local-variables-are-used: false generated-is-used: false + # Pass H6 of cleanup migration. forbidigo is configured (above) to + # ban fmt.Errorf in production code. As packages migrate to + # foundation/errors they are removed from the exclusion list below. + # Remove a line whenever you finish migrating a package. + # + # Migrated (not excluded, must pass the rule): + # internal/state/, internal/linkverify/, + # internal/templates/sequence.go, internal/forge/enhanced_mock.go, + # internal/hugo/indexes.go, internal/build/delta/manager.go, + # internal/config/composite_defaults.go, internal/daemon/daemon.go. + # + # v2 schema note: the v1 keys that used to live under `issues:` have + # moved here. Specifically: + # issues.exclude-dirs-use-default -> removed (option no longer exists) + # issues.exclude-dirs / -files -> linters.exclusions.paths (mirror + # under formatters.exclusions.paths + # so formatters apply the same skips) + # issues.exclude-rules -> linters.exclusions.rules + exclusions: + paths: + - 'internal/hugo/stages_transition_test\.go$' + rules: + # The old //nolint:forbidigo directives in cmd/ now target fmt.Println; + # forbidigo fmt.Errorf never matches in those functions, so the + # comments are unused. Silence the unused-directive complaints from nolintlint. + - linters: [nolintlint] + path: 'cmd/docbuilder/commands/(build|init)\.go$' + - linters: [nolintlint] + path: 'examples/tools/debug_webhook\.go$' + # render.go and output_path.go have parallel template-parsing + # shapes by design; the dupl threshold flags them as 57-line copies. + # Both files were in this state before this cleanup. + - linters: [dupl] + path: 'internal/templates/(render|output_path)\.go$' + formatters: enable: # - gci @@ -104,4 +147,10 @@ formatters: sections: - standard - default - - prefix(git.home.luguber.info/inful/docbuilder) \ No newline at end of file + - prefix(git.home.luguber.info/inful/docbuilder) + exclusions: + # Mirror the linters-side file skip so formatters leave the + # matching test file unchanged (matches v1's `issues.exclude-files` + # semantics, which applied to linters and formatters alike). + paths: + - 'internal/hugo/stages_transition_test\.go$' diff --git a/cmd/docbuilder/commands/build.go b/cmd/docbuilder/commands/build.go index 15009a17..7251993d 100644 --- a/cmd/docbuilder/commands/build.go +++ b/cmd/docbuilder/commands/build.go @@ -7,9 +7,12 @@ import ( "os" "path/filepath" + "git.home.luguber.info/inful/docbuilder/internal/build" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/hugo" + "git.home.luguber.info/inful/docbuilder/internal/workspace" ) // BuildCmd implements the 'build' command. @@ -45,7 +48,7 @@ func (b *BuildCmd) Run(_ *Global, root *CLI) error { } else { _, loadedCfg, err := config.LoadWithResult(root.Config) if err != nil { - return fmt.Errorf("load config: %w", err) + return derrors.WrapError(err, derrors.CategoryConfig, "load config").Build() } cfg = loadedCfg slog.Info("Loaded config from file", "config", root.Config) @@ -85,7 +88,9 @@ func (b *BuildCmd) Run(_ *Global, root *CLI) error { return RunBuild(cfg, outputDir, b.Incremental, root.Verbose, b.KeepWorkspace) } -// RunBuild executes the build pipeline using the unified generator pipeline. +// RunBuild executes the build pipeline using build.BuildService. +// This is the canonical CLI entry point; it routes through the same service +// the daemon uses (with a nil skip-evaluator and a noop recorder). // //nolint:forbidigo // fmt is used for user-facing messages func RunBuild(cfg *config.Config, outputDir string, incrementalMode, verbose, keepWorkspace bool) error { @@ -107,44 +112,65 @@ func RunBuild(cfg *config.Config, outputDir string, incrementalMode, verbose, ke "incremental", incrementalMode, "keep_workspace", keepWorkspace) - // Create workspace manager - wsManager, err := CreateWorkspace(cfg) - if err != nil { - return err - } - if !keepWorkspace { - defer CleanupWorkspace(wsManager) - } else { - slog.Info("Workspace will be preserved for debugging", "path", wsManager.GetPath()) - fmt.Printf("Workspace preserved at: %s\n", wsManager.GetPath()) + // Build the workspace factory. Persistent managers (KeepWorkspace=true) + // skip cleanup on exit; ephemeral managers (KeepWorkspace=false) are + // removed by BuildService's deferred cleanup. + wsDir := cfg.Build.WorkspaceDir + workspaceFactory := func() *workspace.Manager { + if keepWorkspace { + return workspace.NewPersistentManager(wsDir, "working") + } + return workspace.NewManager(wsDir) } - // Initialize Generator - generator := hugo.NewGenerator(cfg, outputDir).WithKeepStaging(keepWorkspace) + svc := build.NewBuildService(). + WithWorkspaceFactory(workspaceFactory). + WithHugoGeneratorFactory(func(c *config.Config, dir string) build.HugoGenerator { + return hugo.NewGenerator(c, dir).WithKeepStaging(keepWorkspace) + }). + WithSkipEvaluatorFactory(func(string) build.SkipEvaluator { + // Skip evaluation requires daemon state; the CLI never has it. + return nil + }) + + req := build.BuildRequest{ + Config: cfg, + OutputDir: outputDir, + Incremental: incrementalMode, + Options: build.BuildOptions{ + Verbose: verbose, + KeepWorkspace: keepWorkspace, + SkipIfUnchanged: false, + }, + } - // Run the unified pipeline - ctx := context.Background() - report, err := generator.GenerateFullSite(ctx, cfg.Repositories, wsManager.GetPath()) + result, err := svc.Run(context.Background(), req) if err != nil { slog.Error("Build pipeline failed", "error", err) - // Show workspace location on error for debugging if keepWorkspace { - fmt.Printf("\nError occurred. Workspace preserved at: %s\n", wsManager.GetPath()) + fmt.Printf("\nError occurred. Workspace preserved (check above log).\n") fmt.Printf("Hugo staging directory: %s_stage\n", outputDir) } return err } - if report.FailedRepositories > 0 { + if result.Report != nil && result.Report.FailedRepositories > 0 { slog.Warn("Some repositories were skipped due to errors", - "skipped", report.FailedRepositories, + "skipped", result.Report.FailedRepositories, "total", len(cfg.Repositories)) } + // Preserve the original log shape: "pages" = rendered HTML pages (not + // markdown files). The CLI used to read report.RenderedPages directly; + // BuildService exposes that via BuildResult.Report.RenderedPages. + renderedPages := 0 + if result.Report != nil { + renderedPages = result.Report.RenderedPages + } slog.Info("Build completed successfully", "output", outputDir, - "pages", report.RenderedPages, - "skipped_repos", report.FailedRepositories) + "pages", renderedPages, + "skipped_repos", result.RepositoriesSkipped) fmt.Println("Build completed successfully") return nil @@ -196,12 +222,12 @@ func (b *BuildCmd) runLocalBuild(cfg *config.Config, outputDir string, verbose, // Resolve absolute path to docs directory docsPath, err := filepath.Abs(b.DocsDir) if err != nil { - return fmt.Errorf("resolve docs dir: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "resolve docs dir").Build() } // Verify docs directory exists if st, statErr := os.Stat(docsPath); statErr != nil || !st.IsDir() { - return fmt.Errorf("docs dir not found or not a directory: %s (use -d to specify a different path)", docsPath) + return derrors.NewError(derrors.CategoryNotFound, "docs dir not found or not a directory (use -d to specify a different path)").WithContext("path", docsPath).Build() } slog.Info("Building from local directory", @@ -217,12 +243,12 @@ func (b *BuildCmd) runLocalBuild(cfg *config.Config, outputDir string, verbose, slog.Info("Discovering documentation files") docFiles, discErr := discovery.DiscoverDocs(repoPaths) if discErr != nil { - return fmt.Errorf("discovery failed: %w", discErr) + return derrors.WrapError(discErr, derrors.CategoryInternal, "discovery failed").Build() } if len(docFiles) == 0 { slog.Warn("No documentation files found in directory", "dir", docsPath) - return fmt.Errorf("no documentation files found in %s", docsPath) + return derrors.NewError(derrors.CategoryNotFound, "no documentation files found").WithContext("path", docsPath).Build() } slog.Info("Documentation discovered", "files", len(docFiles)) @@ -239,7 +265,7 @@ func (b *BuildCmd) runLocalBuild(cfg *config.Config, outputDir string, verbose, if keepWorkspace { fmt.Printf("\nError occurred. Hugo staging directory: %s_stage\n", outputDir) } - return fmt.Errorf("site generation failed: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "site generation failed").Build() } slog.Info("Hugo site generated successfully", diff --git a/cmd/docbuilder/commands/common.go b/cmd/docbuilder/commands/common.go index b73b54e4..299b591f 100644 --- a/cmd/docbuilder/commands/common.go +++ b/cmd/docbuilder/commands/common.go @@ -15,6 +15,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/forge" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/git" "git.home.luguber.info/inful/docbuilder/internal/workspace" ) @@ -83,7 +84,7 @@ func LoadEnvFile() error { for _, p := range envPaths { if _, err := os.Stat(p); err == nil { if err := godotenv.Load(p); err != nil { - return fmt.Errorf("failed loading %s: %w", p, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed loading").WithContext("path", p).Build() } return nil } @@ -215,7 +216,7 @@ func ApplyAutoDiscovery(ctx context.Context, cfg *config.Config) error { if len(cfg.Repositories) == 0 && len(cfg.Forges) > 0 { repos, err := AutoDiscoverRepositories(ctx, cfg) if err != nil { - return fmt.Errorf("auto-discovery failed: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "auto-discovery failed").Build() } cfg.Repositories = repos } diff --git a/cmd/docbuilder/commands/daemon.go b/cmd/docbuilder/commands/daemon.go index a119d166..7b6eb179 100644 --- a/cmd/docbuilder/commands/daemon.go +++ b/cmd/docbuilder/commands/daemon.go @@ -2,7 +2,6 @@ package commands import ( "context" - "fmt" "log/slog" "os/signal" "syscall" @@ -10,6 +9,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/daemon" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // DaemonCmd implements the 'daemon' command. @@ -25,7 +25,7 @@ func (d *DaemonCmd) Run(_ *Global, root *CLI) error { result, cfg, err := config.LoadWithResult(root.Config) if err != nil { - return fmt.Errorf("load config: %w", err) + return derrors.WrapError(err, derrors.CategoryConfig, "load config").Build() } // Print any normalization warnings for _, w := range result.Warnings { @@ -44,7 +44,7 @@ func RunDaemon(cfg *config.Config, dataDir, configPath string) error { // Create and start the daemon with config file watching d, err := daemon.NewDaemonWithConfigFile(cfg, configPath) if err != nil { - return fmt.Errorf("failed to create daemon: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to create daemon").Build() } // Start daemon in a goroutine @@ -59,7 +59,7 @@ func RunDaemon(cfg *config.Config, dataDir, configPath string) error { select { case err := <-errChan: if err != nil { - return fmt.Errorf("daemon error: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "daemon error").Build() } case <-ctx.Done(): slog.Info("Shutdown signal received, stopping daemon...") @@ -70,7 +70,7 @@ func RunDaemon(cfg *config.Config, dataDir, configPath string) error { defer stopCancel() if err := d.Stop(stopCtx); err != nil { - return fmt.Errorf("failed to stop daemon: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to stop daemon").Build() } slog.Info("Daemon stopped successfully") diff --git a/cmd/docbuilder/commands/discover.go b/cmd/docbuilder/commands/discover.go index 68aa2b3b..fa2ca579 100644 --- a/cmd/docbuilder/commands/discover.go +++ b/cmd/docbuilder/commands/discover.go @@ -2,11 +2,11 @@ package commands import ( "context" - "fmt" "log/slog" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/git" ) @@ -15,7 +15,7 @@ type DiscoverCmd struct { Repository string `short:"r" help:"Specific repository to discover (optional)"` } -func (d *DiscoverCmd) Run(_ *Global, root *CLI) error { +func (d *DiscoverCmd) Run(ctx context.Context, _ *Global, root *CLI) error { // Load .env file if it exists (before config) if err := LoadEnvFile(); err == nil && root.Verbose { slog.Info("Loaded environment variables from .env file") @@ -23,19 +23,19 @@ func (d *DiscoverCmd) Run(_ *Global, root *CLI) error { result, cfg, err := config.LoadWithResult(root.Config) if err != nil { - return fmt.Errorf("load config: %w", err) + return derrors.WrapError(err, derrors.CategoryConfig, "load config").Build() } // Print any normalization warnings for _, w := range result.Warnings { slog.Warn(w) } - if err := ApplyAutoDiscovery(context.Background(), cfg); err != nil { + if err := ApplyAutoDiscovery(ctx, cfg); err != nil { return err } - return RunDiscover(cfg, d.Repository) + return RunDiscover(ctx, cfg, d.Repository) } -func RunDiscover(cfg *config.Config, specificRepo string) error { +func RunDiscover(ctx context.Context, cfg *config.Config, specificRepo string) error { slog.Info("Starting documentation discovery", "repositories", len(cfg.Repositories)) // Create workspace manager @@ -62,7 +62,7 @@ func RunDiscover(cfg *config.Config, specificRepo string) error { } } if len(reposToProcess) == 0 { - return fmt.Errorf("repository '%s' not found in configuration", specificRepo) + return derrors.NewError(derrors.CategoryConfig, "repository not found in configuration").WithContext("repository", specificRepo).Build() } } else { reposToProcess = cfg.Repositories @@ -75,7 +75,7 @@ func RunDiscover(cfg *config.Config, specificRepo string) error { slog.Info("Cloning repository", "name", repo.Name, "url", repo.URL) var result git.CloneResult - result, err = gitClient.CloneRepoWithMetadata(*repo) + result, err = gitClient.CloneRepoWithMetadata(ctx, *repo) if err != nil { slog.Error("Failed to clone repository", "name", repo.Name, "error", err) return err diff --git a/cmd/docbuilder/commands/install_hook.go b/cmd/docbuilder/commands/install_hook.go index eae4a010..f4804bc4 100644 --- a/cmd/docbuilder/commands/install_hook.go +++ b/cmd/docbuilder/commands/install_hook.go @@ -8,6 +8,8 @@ import ( "os/exec" "path/filepath" "time" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // InstallHookCmd implements the 'lint install-hook' command. @@ -22,7 +24,7 @@ func (cmd *InstallHookCmd) Run(_ *Global, _ *CLI) error { // Find git directory gitDir, err := findGitDir() if err != nil { - return fmt.Errorf("not in a Git repository: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "not in a Git repository").Build() } hooksDir := filepath.Join(gitDir, "hooks") @@ -30,7 +32,7 @@ func (cmd *InstallHookCmd) Run(_ *Global, _ *CLI) error { // Create hooks directory if it doesn't exist if err := os.MkdirAll(hooksDir, 0o750); err != nil { - return fmt.Errorf("failed to create hooks directory: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create hooks directory").Build() } // Backup existing hook unless --force @@ -41,11 +43,11 @@ func (cmd *InstallHookCmd) Run(_ *Global, _ *CLI) error { // #nosec G304 -- hookPath is constructed from git directory, not user input content, err := os.ReadFile(hookPath) if err != nil { - return fmt.Errorf("failed to read existing hook: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to read existing hook").Build() } if err := os.WriteFile(backupPath, content, 0o600); err != nil { //nolint:gosec // backupPath is derived from the git directory - return fmt.Errorf("failed to create backup: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create backup").Build() } } @@ -116,13 +118,13 @@ fi // Write hook file with restrictive permissions first if err := os.WriteFile(hookPath, []byte(hookContent), 0o600); err != nil { - return fmt.Errorf("failed to write hook file: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write hook file").Build() } // Make it executable (owner and group only) // #nosec G302 -- intentional executable permission for git hook if err := os.Chmod(hookPath, 0o750); err != nil { - return fmt.Errorf("failed to make hook executable: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to make hook executable").Build() } fmt.Println("✅ Pre-commit hook installed successfully") diff --git a/cmd/docbuilder/commands/lint.go b/cmd/docbuilder/commands/lint.go index a9ba6c7b..92f565d6 100644 --- a/cmd/docbuilder/commands/lint.go +++ b/cmd/docbuilder/commands/lint.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/lint" ) @@ -55,7 +56,7 @@ func (lp *LintPathCmd) Run(parent *LintCmd, _ *Global, root *CLI) error { // Validate path exists if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Errorf("path does not exist: %s", path) + return derrors.NewError(derrors.CategoryNotFound, "path does not exist").WithContext("path", path).Build() } // Create linter configuration @@ -78,7 +79,7 @@ func (lp *LintPathCmd) Run(parent *LintCmd, _ *Global, root *CLI) error { // Run linting result, err := linter.LintPath(path) if err != nil { - return fmt.Errorf("linting failed: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "linting failed").Build() } // Check if color output is supported @@ -87,7 +88,7 @@ func (lp *LintPathCmd) Run(parent *LintCmd, _ *Global, root *CLI) error { // Format and output results formatter := lint.NewFormatter(parent.Format, useColor) if err := formatter.Format(os.Stdout, result, path, wasAutoDetected); err != nil { - return fmt.Errorf("formatting output: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "formatting output").Build() } // Determine exit code based on results @@ -126,7 +127,7 @@ func runFixer(linter *lint.Linter, path string, dryRun bool) error { fixer := lint.NewFixer(linter, dryRun, false) // force=false for safety fixResult, err := fixer.Fix(path) if err != nil { - return fmt.Errorf("fixing failed: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "fixing failed").Build() } // Display what was fixed diff --git a/cmd/docbuilder/commands/preview.go b/cmd/docbuilder/commands/preview.go index 0ce322b8..ed1013ac 100644 --- a/cmd/docbuilder/commands/preview.go +++ b/cmd/docbuilder/commands/preview.go @@ -11,6 +11,7 @@ import ( "syscall" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/preview" ) @@ -86,7 +87,7 @@ func (p *PreviewCmd) Run(_ *Global, _ *CLI) error { if outDir == "" { tmp, err := os.MkdirTemp("", "docbuilder-preview-*") if err != nil { - return fmt.Errorf("create temp output: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "create temp output").Build() } outDir = tmp tempOut = tmp diff --git a/cmd/docbuilder/commands/template.go b/cmd/docbuilder/commands/template.go index 9c49fa06..26606ee4 100644 --- a/cmd/docbuilder/commands/template.go +++ b/cmd/docbuilder/commands/template.go @@ -12,6 +12,7 @@ import ( "strings" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/lint" templating "git.home.luguber.info/inful/docbuilder/internal/templates" ) @@ -168,12 +169,12 @@ func loadConfigForTemplates(path string) (*config.Config, error) { if os.IsNotExist(err) { return &config.Config{}, nil } - return nil, fmt.Errorf("stat config: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "stat config").Build() } result, cfg, err := config.LoadWithResult(path) if err != nil { - return nil, fmt.Errorf("load config: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryConfig, "load config").Build() } for _, warning := range result.Warnings { _, _ = fmt.Fprintln(os.Stderr, warning) @@ -201,7 +202,7 @@ func selectTemplate(templates []templating.TemplateLink, autoYes bool) (templati reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { - return templating.TemplateLink{}, fmt.Errorf("read selection: %w", err) + return templating.TemplateLink{}, derrors.WrapError(err, derrors.CategoryValidation, "read selection").Build() } line = strings.TrimSpace(line) @@ -217,7 +218,7 @@ func parseSetFlags(values []string) (map[string]string, error) { for _, entry := range values { parts := strings.SplitN(entry, "=", 2) if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { - return nil, fmt.Errorf("invalid --set value: %s", entry) + return nil, derrors.NewError(derrors.CategoryValidation, "invalid --set value").WithContext("value", entry).Build() } result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) } @@ -242,7 +243,7 @@ func (c *cliPrompter) Prompt(field templating.SchemaField) (string, error) { line, err := c.reader.ReadString('\n') if err != nil { - return "", fmt.Errorf("read input: %w", err) + return "", derrors.WrapError(err, derrors.CategoryValidation, "read input").Build() } return strings.TrimSpace(line), nil } @@ -250,7 +251,7 @@ func (c *cliPrompter) Prompt(field templating.SchemaField) (string, error) { func resolveDocsDir() (string, error) { cwd, err := os.Getwd() if err != nil { - return "", fmt.Errorf("resolve working directory: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "resolve working directory").Build() } return filepath.Join(cwd, "docs"), nil } @@ -283,7 +284,7 @@ func buildSequenceResolver(page *templating.TemplatePage, docsDir string) (func( return func(name string) (int, error) { def, ok := defs[name] if !ok { - return 0, fmt.Errorf("unknown sequence: %s", name) + return 0, derrors.NewError(derrors.CategoryValidation, "unknown sequence").WithContext("name", name).Build() } return templating.ComputeNextInSequence(def, docsDir) }, nil @@ -294,7 +295,7 @@ func confirmOutputPath(path string) (bool, error) { reader := bufio.NewReader(os.Stdin) line, err := reader.ReadString('\n') if err != nil { - return false, fmt.Errorf("read confirmation: %w", err) + return false, derrors.WrapError(err, derrors.CategoryValidation, "read confirmation").Build() } answer := strings.TrimSpace(strings.ToLower(line)) return answer == "y" || answer == "yes", nil @@ -311,3 +312,6 @@ func runLintFix(path string) error { _, err := fixer.Fix(path) return err } + +// Compile-time assertion that *cliPrompter satisfies templating.Prompter. +var _ templating.Prompter = (*cliPrompter)(nil) diff --git a/cmd/docbuilder/commands/template_common.go b/cmd/docbuilder/commands/template_common.go index 39c2f063..efd48f01 100644 --- a/cmd/docbuilder/commands/template_common.go +++ b/cmd/docbuilder/commands/template_common.go @@ -1,10 +1,10 @@ package commands import ( - "fmt" "os" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) const templateBaseURLEnv = "DOCBUILDER_TEMPLATE_BASE_URL" @@ -20,5 +20,5 @@ func ResolveTemplateBaseURL(flagBaseURL string, cfg *config.Config) (string, err if cfg != nil && cfg.Hugo.BaseURL != "" { return cfg.Hugo.BaseURL, nil } - return "", fmt.Errorf("template base URL is required (set --base-url, %s, or hugo.base_url)", templateBaseURLEnv) + return "", derrors.NewError(derrors.CategoryConfig, "template base URL is required (set --base-url, "+templateBaseURLEnv+", or hugo.base_url)").Build() } diff --git a/cmd/docbuilder/main.go b/cmd/docbuilder/main.go index b643cf3b..b98be666 100644 --- a/cmd/docbuilder/main.go +++ b/cmd/docbuilder/main.go @@ -12,7 +12,8 @@ import ( func main() { cli := &commands.CLI{} - parser := kong.Parse(cli, + parser := kong.Parse( + cli, kong.Description("DocBuilder: aggregate multi-repo documentation into a Hugo site."), kong.Vars{"version": version.Version}, ) diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..d0a19574 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,97 @@ +package auth + +import ( + "errors" + "os" + "path/filepath" + + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/plumbing/transport/ssh" + + "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" +) + +// CreateAuth builds a go-git transport.AuthMethod from the given config. +// +// Behavior: +// - nil cfg → returns (nil, nil); the git client treats nil as "no auth". +// - AuthTypeNone or empty type → (nil, nil). +// - AuthTypeSSH → loads the SSH key (cfg.KeyPath, defaults to +// $HOME/.ssh/id_rsa) and returns an ssh.PublicKeys. +// - AuthTypeToken → returns an http.BasicAuth with username "token" +// (or cfg.Username if set) and the token as the password. +// - AuthTypeBasic → returns an http.BasicAuth with cfg.Username and +// cfg.Password. +// +// Errors are wrapped with %w so callers can errors.Is/As them. +func CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { + if authCfg == nil { + return transport.AuthMethod(nil), nil + } + + switch authCfg.Type { + case config.AuthTypeNone, "": + return transport.AuthMethod(nil), nil + case config.AuthTypeSSH: + return newSSHAuth(authCfg) + case config.AuthTypeToken: + return newTokenAuth(authCfg) + case config.AuthTypeBasic: + return newBasicAuth(authCfg) + default: + return nil, derrors.NewError(derrors.CategoryConfig, "auth: unsupported authentication type"). + WithContext("type", authCfg.Type). + Build() + } +} + +func newSSHAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { + keyPath := authCfg.KeyPath + if keyPath == "" { + keyPath = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") + } + + // Validate up front so callers get a clear error before go-git does. + if _, err := os.Stat(keyPath); os.IsNotExist(err) { //nolint:gosec // keyPath is a local, user-configured file path + return nil, derrors.NewError(derrors.CategoryNotFound, "auth: SSH key file does not exist"). + WithContext("path", keyPath). + Build() + } + + publicKeys, err := ssh.NewPublicKeysFromFile("git", keyPath, "") + if err != nil { + return nil, derrors.WrapError(err, derrors.CategoryAuth, "auth: failed to load SSH key"). + WithContext("path", keyPath). + Build() + } + return publicKeys, nil +} + +func newTokenAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { + if authCfg.Token == "" { + return nil, errors.New("auth: token authentication requires a token") + } + + username := authCfg.Username + if username == "" { + // Most Git hosting services use "token" as the username for token + // auth. Some GitLab setups expect "oauth2" instead; allowing override + // via config keeps tokens out of clone URLs (safer) while supporting + // those servers. + username = "token" + } + + return &http.BasicAuth{Username: username, Password: authCfg.Token}, nil +} + +func newBasicAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { + if authCfg.Username == "" { + return nil, errors.New("auth: basic authentication requires a username") + } + if authCfg.Password == "" { + return nil, errors.New("auth: basic authentication requires a password") + } + return &http.BasicAuth{Username: authCfg.Username, Password: authCfg.Password}, nil +} diff --git a/internal/auth/manager_test.go b/internal/auth/auth_test.go similarity index 91% rename from internal/auth/manager_test.go rename to internal/auth/auth_test.go index 65c584de..d9b0f642 100644 --- a/internal/auth/manager_test.go +++ b/internal/auth/auth_test.go @@ -9,9 +9,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" ) -func TestManager_CreateAuth(t *testing.T) { - manager := NewManager() - +func TestCreateAuth(t *testing.T) { const testToken = "test-token" tests := []struct { @@ -101,7 +99,7 @@ func TestManager_CreateAuth(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - auth, err := manager.CreateAuth(tt.authConfig) + auth, err := CreateAuth(tt.authConfig) if tt.expectError && err == nil { t.Errorf("CreateAuth() expected error but got none - %s", tt.description) @@ -188,19 +186,27 @@ func verifyBasicAuth(t *testing.T, auth transport.AuthMethod, expectedUsername, } } -func TestConvenienceFunctions(t *testing.T) { - // Test package-level convenience function +func TestCreateAuth_NilConfig(t *testing.T) { + auth, err := CreateAuth(nil) + if err != nil { + t.Errorf("CreateAuth(nil) returned error: %v", err) + } + if auth != nil { + t.Errorf("CreateAuth(nil) returned non-nil auth: %T", auth) + } +} + +func TestCreateAuth_Token(t *testing.T) { authConfig := &config.AuthConfig{ Type: config.AuthTypeToken, Token: "test-token", } - // Test CreateAuth convenience function auth, err := CreateAuth(authConfig) if err != nil { - t.Errorf("CreateAuth() convenience function error: %v", err) + t.Errorf("CreateAuth() error: %v", err) } if auth == nil { - t.Errorf("CreateAuth() convenience function returned nil") + t.Errorf("CreateAuth() returned nil auth") } } diff --git a/internal/auth/manager.go b/internal/auth/manager.go deleted file mode 100644 index ca0b4770..00000000 --- a/internal/auth/manager.go +++ /dev/null @@ -1,34 +0,0 @@ -package auth - -import ( - "github.com/go-git/go-git/v5/plumbing/transport" - - "git.home.luguber.info/inful/docbuilder/internal/auth/providers" - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// Manager provides a high-level interface for authentication operations. -type Manager struct { - registry *providers.AuthProviderRegistry -} - -// NewManager creates a new authentication manager with the standard providers. -func NewManager() *Manager { - return &Manager{ - registry: providers.NewAuthProviderRegistry(), - } -} - -// CreateAuth creates authentication for the given configuration. -// This is the main entry point for git operations needing authentication. -func (m *Manager) CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { - return m.registry.CreateAuth(authCfg) -} - -// DefaultManager is a package-level instance for convenience. -var DefaultManager = NewManager() - -// CreateAuth is a convenience function that uses the default manager. -func CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { - return DefaultManager.CreateAuth(authCfg) -} diff --git a/internal/auth/providers/basic_provider.go b/internal/auth/providers/basic_provider.go deleted file mode 100644 index 773c8950..00000000 --- a/internal/auth/providers/basic_provider.go +++ /dev/null @@ -1,53 +0,0 @@ -package providers - -import ( - "errors" - - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/plumbing/transport/http" - - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// BasicProvider handles basic username/password authentication. -type BasicProvider struct{} - -// NewBasicProvider creates a new basic authentication provider. -func NewBasicProvider() *BasicProvider { - return &BasicProvider{} -} - -// Type returns the authentication type this provider handles. -func (p *BasicProvider) Type() config.AuthType { - return config.AuthTypeBasic -} - -// CreateAuth creates basic authentication from the configuration. -func (p *BasicProvider) CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { - if authCfg.Username == "" || authCfg.Password == "" { - return nil, errors.New("basic authentication requires username and password") - } - - return &http.BasicAuth{ - Username: authCfg.Username, - Password: authCfg.Password, - }, nil -} - -// ValidateConfig validates the basic authentication configuration. -func (p *BasicProvider) ValidateConfig(authCfg *config.AuthConfig) error { - if authCfg.Username == "" { - return errors.New("basic authentication requires a username") - } - - if authCfg.Password == "" { - return errors.New("basic authentication requires a password") - } - - return nil -} - -// Name returns a human-readable name for this provider. -func (p *BasicProvider) Name() string { - return "BasicProvider" -} diff --git a/internal/auth/providers/doc.go b/internal/auth/providers/doc.go deleted file mode 100644 index 5a177963..00000000 --- a/internal/auth/providers/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package providers contains concrete authentication providers (none, basic, -// token, ssh) used by the auth manager to configure git operations. -package providers diff --git a/internal/auth/providers/none_provider.go b/internal/auth/providers/none_provider.go deleted file mode 100644 index 7f800d09..00000000 --- a/internal/auth/providers/none_provider.go +++ /dev/null @@ -1,37 +0,0 @@ -package providers - -import ( - "github.com/go-git/go-git/v5/plumbing/transport" - - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// NoneProvider handles "none" authentication (no authentication). -type NoneProvider struct{} - -// NewNoneProvider creates a new none authentication provider. -func NewNoneProvider() *NoneProvider { - return &NoneProvider{} -} - -// Type returns the authentication type this provider handles. -func (p *NoneProvider) Type() config.AuthType { - return config.AuthTypeNone -} - -// CreateAuth creates no authentication (returns nil). -func (p *NoneProvider) CreateAuth(_ *config.AuthConfig) (transport.AuthMethod, error) { - // No authentication needed, return typed nil for AuthMethod - return transport.AuthMethod(nil), nil -} - -// ValidateConfig validates that no authentication is properly configured. -func (p *NoneProvider) ValidateConfig(_ *config.AuthConfig) error { - // No validation needed for none auth - return nil -} - -// Name returns a human-readable name for this provider. -func (p *NoneProvider) Name() string { - return "NoneProvider" -} diff --git a/internal/auth/providers/provider.go b/internal/auth/providers/provider.go deleted file mode 100644 index 8dd4ed8f..00000000 --- a/internal/auth/providers/provider.go +++ /dev/null @@ -1,115 +0,0 @@ -package providers - -import ( - "fmt" - - "github.com/go-git/go-git/v5/plumbing/transport" - - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// AuthProvider defines the interface for authentication providers. -// Each provider handles a specific authentication method (SSH, token, basic, none). -type AuthProvider interface { - // Type returns the authentication type this provider handles. - Type() config.AuthType - - // CreateAuth creates a transport.AuthMethod from the given configuration. - // Returns nil, nil for no authentication (AuthTypeNone). - CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) - - // ValidateConfig validates the authentication configuration for this provider. - // This allows each provider to enforce its own requirements. - ValidateConfig(authCfg *config.AuthConfig) error - - // Name returns a human-readable name for this provider (for logging/debugging). - Name() string -} - -// AuthProviderRegistry manages the collection of available auth providers. -type AuthProviderRegistry struct { - providers map[config.AuthType]AuthProvider -} - -// NewAuthProviderRegistry creates a new registry with the standard providers. -func NewAuthProviderRegistry() *AuthProviderRegistry { - registry := &AuthProviderRegistry{ - providers: make(map[config.AuthType]AuthProvider), - } - - // Register standard providers - registry.Register(NewNoneProvider()) - registry.Register(NewSSHProvider()) - registry.Register(NewTokenProvider()) - registry.Register(NewBasicProvider()) - - return registry -} - -// Register adds a provider to the registry. -func (r *AuthProviderRegistry) Register(provider AuthProvider) { - r.providers[provider.Type()] = provider -} - -// CreateAuth creates authentication using the appropriate provider. -func (r *AuthProviderRegistry) CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { - if authCfg == nil { - authCfg = &config.AuthConfig{Type: config.AuthTypeNone} - } - - provider, exists := r.providers[authCfg.Type] - if !exists { - return nil, &AuthError{ - Type: authCfg.Type, - Message: "unsupported authentication type", - } - } - - // Validate configuration first - if err := provider.ValidateConfig(authCfg); err != nil { - return nil, &AuthError{ - Type: authCfg.Type, - Message: "configuration validation failed", - Cause: err, - } - } - - // Create authentication - auth, err := provider.CreateAuth(authCfg) - if err != nil { - return nil, &AuthError{ - Type: authCfg.Type, - Message: "failed to create authentication", - Cause: err, - } - } - - return auth, nil -} - -// AuthError represents an authentication-related error. -type AuthError struct { - Type config.AuthType - Message string - Cause error -} - -// Error implements the error interface. -func (e *AuthError) Error() string { - if e.Cause != nil { - return fmt.Sprintf("auth error (%s): %s: %v", e.Type, e.Message, e.Cause) - } - return fmt.Sprintf("auth error (%s): %s", e.Type, e.Message) -} - -// Unwrap returns the underlying error. -func (e *AuthError) Unwrap() error { - return e.Cause -} - -// Temporary returns true if the error is temporary and the operation can be retried. -func (e *AuthError) Temporary() bool { - // Most auth errors are permanent (bad credentials, missing files, etc.) - // but some network-related errors during auth might be temporary - return false -} diff --git a/internal/auth/providers/ssh_provider.go b/internal/auth/providers/ssh_provider.go deleted file mode 100644 index ec8f1b5b..00000000 --- a/internal/auth/providers/ssh_provider.go +++ /dev/null @@ -1,60 +0,0 @@ -package providers - -import ( - "fmt" - "os" - "path/filepath" - - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/plumbing/transport/ssh" - - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// SSHProvider handles SSH key authentication. -type SSHProvider struct{} - -// NewSSHProvider creates a new SSH authentication provider. -func NewSSHProvider() *SSHProvider { - return &SSHProvider{} -} - -// Type returns the authentication type this provider handles. -func (p *SSHProvider) Type() config.AuthType { - return config.AuthTypeSSH -} - -// CreateAuth creates SSH authentication from the configuration. -func (p *SSHProvider) CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { - keyPath := authCfg.KeyPath - if keyPath == "" { - keyPath = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") - } - - publicKeys, err := ssh.NewPublicKeysFromFile("git", keyPath, "") - if err != nil { - return nil, fmt.Errorf("failed to load SSH key from %s: %w", keyPath, err) - } - - return publicKeys, nil -} - -// ValidateConfig validates the SSH authentication configuration. -func (p *SSHProvider) ValidateConfig(authCfg *config.AuthConfig) error { - keyPath := authCfg.KeyPath - if keyPath == "" { - keyPath = filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa") - } - - // Check if the key file exists - if _, err := os.Stat(keyPath); os.IsNotExist(err) { //nolint:gosec // keyPath is a local, user-configured file path - return fmt.Errorf("SSH key file does not exist: %s", keyPath) - } - - return nil -} - -// Name returns a human-readable name for this provider. -func (p *SSHProvider) Name() string { - return "SSHProvider" -} diff --git a/internal/auth/providers/token_provider.go b/internal/auth/providers/token_provider.go deleted file mode 100644 index 8bf06d0a..00000000 --- a/internal/auth/providers/token_provider.go +++ /dev/null @@ -1,57 +0,0 @@ -package providers - -import ( - "errors" - - "github.com/go-git/go-git/v5/plumbing/transport" - "github.com/go-git/go-git/v5/plumbing/transport/http" - - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// TokenProvider handles token-based authentication. -type TokenProvider struct{} - -// NewTokenProvider creates a new token authentication provider. -func NewTokenProvider() *TokenProvider { - return &TokenProvider{} -} - -// Type returns the authentication type this provider handles. -func (p *TokenProvider) Type() config.AuthType { - return config.AuthTypeToken -} - -// CreateAuth creates token authentication from the configuration. -func (p *TokenProvider) CreateAuth(authCfg *config.AuthConfig) (transport.AuthMethod, error) { - if authCfg.Token == "" { - return nil, errors.New("token authentication requires a token") - } - - username := authCfg.Username - if username == "" { - // Most Git hosting services use "token" as the username for token auth. - // Some GitLab setups expect "oauth2" instead; allowing override via config keeps - // tokens out of clone URLs (safer) while supporting those servers. - username = "token" - } - - return &http.BasicAuth{ - Username: username, - Password: authCfg.Token, - }, nil -} - -// ValidateConfig validates the token authentication configuration. -func (p *TokenProvider) ValidateConfig(authCfg *config.AuthConfig) error { - if authCfg.Token == "" { - return errors.New("token authentication requires a token") - } - - return nil -} - -// Name returns a human-readable name for this provider. -func (p *TokenProvider) Name() string { - return "TokenProvider" -} diff --git a/internal/build/default_service.go b/internal/build/default_service.go index c0ed8dab..0db196e8 100644 --- a/internal/build/default_service.go +++ b/internal/build/default_service.go @@ -10,7 +10,6 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/docs" dberrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" - "git.home.luguber.info/inful/docbuilder/internal/metrics" "git.home.luguber.info/inful/docbuilder/internal/observability" "git.home.luguber.info/inful/docbuilder/internal/workspace" ) @@ -41,7 +40,6 @@ type DefaultBuildService struct { workspaceFactory func() *workspace.Manager hugoGeneratorFactory HugoGeneratorFactory skipEvaluatorFactory SkipEvaluatorFactory - recorder metrics.Recorder } // NewBuildService creates a new DefaultBuildService with default factories. @@ -50,7 +48,6 @@ func NewBuildService() *DefaultBuildService { workspaceFactory: func() *workspace.Manager { return workspace.NewManager("") }, - recorder: metrics.NoopRecorder{}, // hugoGeneratorFactory must be set via WithHugoGeneratorFactory to avoid import cycle } } @@ -75,6 +72,9 @@ func (s *DefaultBuildService) WithSkipEvaluatorFactory(factory SkipEvaluatorFact return s } +// Compile-time assertion that *DefaultBuildService implements BuildService. +var _ BuildService = (*DefaultBuildService)(nil) + // Run executes the complete build pipeline. func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*BuildResult, error) { startTime := time.Now() @@ -93,7 +93,6 @@ func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*Build result.Status = BuildStatusFailed result.EndTime = time.Now() result.Duration = result.EndTime.Sub(startTime) - s.recorder.IncBuildOutcome(metrics.BuildOutcomeFailed) return result, dberrors.ConfigError("config required").Build() } @@ -102,8 +101,6 @@ func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*Build result.Status = BuildStatusSuccess result.EndTime = time.Now() result.Duration = result.EndTime.Sub(startTime) - s.recorder.IncBuildOutcome(metrics.BuildOutcomeSuccess) - s.recorder.ObserveBuildDuration(result.Duration) return result, nil } @@ -118,7 +115,6 @@ func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*Build } // Stage 1: Create workspace - stageStart := time.Now() ctx = observability.WithStage(ctx, "workspace") observability.InfoContext(ctx, "Creating build workspace") wsManager := s.workspaceFactory() @@ -126,17 +122,23 @@ func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*Build result.Status = BuildStatusFailed result.EndTime = time.Now() result.Duration = result.EndTime.Sub(startTime) - s.recorder.IncStageResult("workspace", metrics.ResultFatal) - s.recorder.IncBuildOutcome(metrics.BuildOutcomeFailed) return result, dberrors.FileSystemError("failed to create workspace").WithContext("error", err.Error()).Build() } - s.recorder.ObserveStageDuration("workspace", time.Since(stageStart)) - s.recorder.IncStageResult("workspace", metrics.ResultSuccess) - defer func() { - if err := wsManager.Cleanup(); err != nil { - observability.WarnContext(ctx, "Failed to cleanup workspace", slog.String("error", err.Error())) - } - }() + + // Only defer cleanup for ephemeral workspaces. Persistent workspaces + // (BuildOptions.KeepWorkspace=true) opt out of cleanup so the directory + // is preserved for debugging; workspace.Manager.Cleanup also no-ops on + // persistent managers, so this is belt-and-braces. + if !req.Options.KeepWorkspace { + defer func() { + if err := wsManager.Cleanup(); err != nil { + observability.WarnContext(ctx, "Failed to cleanup workspace", slog.String("error", err.Error())) + } + }() + } else { + observability.InfoContext(ctx, "Workspace will be preserved for debugging", + slog.String("path", wsManager.GetPath())) + } // Stage 2+: Unified Site Generation (Clone -> Discovery -> Transform -> Hugo) // We delegate the heavy lifting to the natively refactored hugo.Generator pipeline. @@ -144,7 +146,6 @@ func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*Build result.Status = BuildStatusFailed result.EndTime = time.Now() result.Duration = result.EndTime.Sub(startTime) - s.recorder.IncBuildOutcome(metrics.BuildOutcomeFailed) return result, dberrors.ConfigError("hugo generator factory required").Build() } @@ -163,7 +164,6 @@ func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*Build if err != nil { result.Status = BuildStatusFailed - s.recorder.IncBuildOutcome(metrics.BuildOutcomeFailed) return result, err } @@ -178,30 +178,24 @@ func (s *DefaultBuildService) Run(ctx context.Context, req BuildRequest) (*Build result.FilesProcessed = report.Files result.RepositoriesSkipped = report.FailedRepositories - s.recorder.IncBuildOutcome(metrics.BuildOutcomeSuccess) - s.recorder.ObserveBuildDuration(result.Duration) - return result, nil } // evaluateSkip performs skip evaluation and returns a result if build should be skipped. // Returns nil if build should proceed. func (s *DefaultBuildService) evaluateSkip(ctx context.Context, req BuildRequest, startTime time.Time) *BuildResult { - stageStart := time.Now() ctx = observability.WithStage(ctx, "skip_evaluation") observability.InfoContext(ctx, "Evaluating if build can be skipped") evaluator := s.skipEvaluatorFactory(req.OutputDir) if evaluator == nil { observability.WarnContext(ctx, "Skip evaluator factory returned nil - skipping evaluation disabled") - s.recorder.ObserveStageDuration("skip_evaluation", time.Since(stageStart)) observability.InfoContext(ctx, "Skip evaluation complete - proceeding with build") return nil } skipReport, canSkip := evaluator.Evaluate(ctx, req.Config.Repositories) if !canSkip { - s.recorder.ObserveStageDuration("skip_evaluation", time.Since(stageStart)) observability.InfoContext(ctx, "Skip evaluation complete - proceeding with build") return nil } @@ -216,9 +210,6 @@ func (s *DefaultBuildService) evaluateSkip(ctx context.Context, req BuildRequest EndTime: time.Now(), } result.Duration = result.EndTime.Sub(startTime) - s.recorder.ObserveStageDuration("skip_evaluation", time.Since(stageStart)) - s.recorder.IncBuildOutcome(metrics.BuildOutcomeSkipped) - s.recorder.ObserveBuildDuration(result.Duration) return result } @@ -231,3 +222,117 @@ func (s *DefaultBuildService) logSkipEvaluationDisabled(ctx context.Context, req observability.WarnContext(ctx, "Skip evaluator factory not configured - cannot evaluate skip conditions") } } + +// --------------------------------------------------------------------------- +// BuildService surface: shared contracts + result types. +// +// The shapes below describe the canonical "build a docs site" input and +// output. Both CLI (`cmd/docbuilder`) and daemon (`internal/build/queue`) +// consume them through this interface. The queue's Builder interface is +// narrower (BuildJob -> BuildReport) and bridges via BuildServiceAdapter +// (see internal/build/queue/build_service_adapter.go for the rationale). +// +// Plan review-overlapping-functionality.md: M14 marked this for +// consolidation. We align the surface here and document why two +// single-method interfaces (BuildService.Run + queue.Builder.Build) +// coexist; the adapter is the deliberate seam. +// +// History: service.go held these types as a thin file; merging them +// into default_service.go keeps the package's service surface in one +// file, matching the rest of the codebase's organization. +// --------------------------------------------------------------------------- + +// BuildService is the canonical interface for executing documentation builds. +// Both CLI and daemon/server should implement thin wrappers over this interface. +type BuildService interface { + // Run executes a complete build pipeline: clone → discover → transform → generate. + // Returns a BuildResult with detailed outcomes and any error encountered. + Run(ctx context.Context, req BuildRequest) (*BuildResult, error) +} + +// BuildRequest contains all inputs required to execute a documentation build. +type BuildRequest struct { + // Config is the loaded configuration for this build. + Config *appcfg.Config + + // OutputDir is the target directory for the generated Hugo site. + OutputDir string + + // Incremental enables incremental updates (git pull vs fresh clone). + Incremental bool + + // Options provides optional build behavior modifiers. + Options BuildOptions +} + +// BuildOptions provides optional configuration for build behavior. +type BuildOptions struct { + // Verbose enables detailed logging during the build. + Verbose bool + + // SkipIfUnchanged enables skip evaluation when content hasn't changed. + SkipIfUnchanged bool + + // KeepWorkspace prevents the build workspace from being cleaned up after + // the build completes. Useful for debugging failed builds. The CLI uses + // this when invoked with --keep-workspace. + KeepWorkspace bool +} + +// BuildResult contains the outcome of a build execution. +type BuildResult struct { + // Status indicates overall build outcome. + Status BuildStatus + + // Report contains detailed build metrics and diagnostics. + Report *models.BuildReport + + // OutputPath is the final output directory (may differ from request). + OutputPath string + + // Repositories is the count of processed repositories. + Repositories int + + // RepositoriesSkipped is the count of repositories that failed to clone/process. + RepositoriesSkipped int + + // FilesProcessed is the count of documentation files handled. + FilesProcessed int + + // Duration is the total build execution time. + Duration time.Duration + + // StartTime is when the build started. + StartTime time.Time + + // EndTime is when the build completed. + EndTime time.Time + + // Skipped indicates the build was skipped due to no changes. + Skipped bool + + // SkipReason explains why the build was skipped (if Skipped is true). + SkipReason string +} + +// BuildStatus represents the outcome of a build execution. +type BuildStatus string + +const ( + // BuildStatusSuccess indicates the build completed successfully. + BuildStatusSuccess BuildStatus = "success" + + // BuildStatusFailed indicates the build encountered an error. + BuildStatusFailed BuildStatus = "failed" + + // BuildStatusSkipped indicates the build was skipped (e.g., no changes). + BuildStatusSkipped BuildStatus = "skipped" + + // BuildStatusCancelled indicates the build was canceled. + BuildStatusCancelled BuildStatus = "canceled" +) + +// IsSuccess returns true if the build completed successfully. +func (s BuildStatus) IsSuccess() bool { + return s == BuildStatusSuccess || s == BuildStatusSkipped +} diff --git a/internal/build/delta/delta_analyzer.go b/internal/build/delta/delta_analyzer.go index 593623cf..a67ef46e 100644 --- a/internal/build/delta/delta_analyzer.go +++ b/internal/build/delta/delta_analyzer.go @@ -1,15 +1,14 @@ package delta import ( - "crypto/sha256" - "encoding/hex" "log/slog" "os" "path/filepath" "sort" "strings" - cfg "git.home.luguber.info/inful/docbuilder/internal/config" + "git.home.luguber.info/inful/docbuilder/internal/config" + "git.home.luguber.info/inful/docbuilder/internal/hashutil" ) // DeltaDecision represents the analyzer's chosen build strategy. @@ -106,18 +105,13 @@ func (da *DeltaAnalyzer) computeQuickRepoHash(repoName string) string { return "" } sort.Strings(paths) - h := sha256.New() - for _, p := range paths { - h.Write([]byte(p)) - h.Write([]byte{0}) - } - return hex.EncodeToString(h.Sum(nil)) + return hashutil.PathsHash(paths) } // Analyze returns a DeltaPlan describing whether a partial rebuild could be attempted. // currentConfigHash: hash of current configuration (same value used by skip logic) // repos: repositories requested for this build. -func (da *DeltaAnalyzer) Analyze(_ string, repos []cfg.Repository) DeltaPlan { +func (da *DeltaAnalyzer) Analyze(_ string, repos []config.Repository) DeltaPlan { if da == nil || da.state == nil || len(repos) == 0 { return DeltaPlan{Decision: DeltaDecisionFull, Reason: "insufficient_context"} } diff --git a/internal/build/delta/manager.go b/internal/build/delta/manager.go index da72572f..afd73aff 100644 --- a/internal/build/delta/manager.go +++ b/internal/build/delta/manager.go @@ -1,9 +1,6 @@ package delta import ( - "crypto/sha256" - "encoding/hex" - "fmt" "maps" "os" "path/filepath" @@ -11,6 +8,8 @@ import ( "strings" cfg "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" + "git.home.luguber.info/inful/docbuilder/internal/hashutil" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" "git.home.luguber.info/inful/docbuilder/internal/state" ) @@ -93,7 +92,6 @@ func (m *Manager) RecomputeGlobalDocHash( } if len(allPaths) > 0 { - sort.Strings(allPaths) report.DocFilesHash = m.computePathsHash(allPaths) } @@ -135,7 +133,7 @@ func (m *Manager) scanForDeletions(repo cfg.Repository, workspace string, persis return nil }) if err != nil { - return persistedPaths, 0, fmt.Errorf("walking directory %s: %w", base, err) + return persistedPaths, 0, derrors.WrapError(err, derrors.CategoryFileSystem, "walking directory").WithContext("path", base).Build() } } @@ -164,10 +162,5 @@ func (m *Manager) scanForDeletions(repo cfg.Repository, workspace string, persis } func (m *Manager) computePathsHash(paths []string) string { - h := sha256.New() - for _, p := range paths { - h.Write([]byte(p)) - h.Write([]byte{0}) - } - return hex.EncodeToString(h.Sum(nil)) + return hashutil.PathsHash(paths) } diff --git a/internal/build/delta/manager_test.go b/internal/build/delta/manager_test.go index e8730334..0198a643 100644 --- a/internal/build/delta/manager_test.go +++ b/internal/build/delta/manager_test.go @@ -57,7 +57,7 @@ func TestManager_RecomputeGlobalDocHash_RecomposesUnion(t *testing.T) { if svcResult.IsErr() { t.Fatalf("state service: %v", svcResult.UnwrapErr()) } - meta := state.NewServiceAdapter(svcResult.Unwrap()) + meta := svcResult.Unwrap() repos := []cfg.Repository{{Name: repoAName, URL: repoAURL}, {Name: repoBName, URL: repoBURL}} meta.EnsureRepositoryState(repoAURL, repoAName, "") @@ -103,7 +103,7 @@ func TestManager_RecomputeGlobalDocHash_DetectsDeletionsInUnchangedRepo(t *testi if svcResult.IsErr() { t.Fatalf("state service: %v", svcResult.UnwrapErr()) } - meta := state.NewServiceAdapter(svcResult.Unwrap()) + meta := svcResult.Unwrap() repos := []cfg.Repository{{Name: repoAName, URL: repoAURL}, {Name: repoBName, URL: repoBURL}} meta.EnsureRepositoryState(repoAURL, repoAName, "") diff --git a/internal/build/queue/build_job_metadata.go b/internal/build/queue/build_job_metadata.go index f241e45e..90f42c9c 100644 --- a/internal/build/queue/build_job_metadata.go +++ b/internal/build/queue/build_job_metadata.go @@ -5,7 +5,6 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" - "git.home.luguber.info/inful/docbuilder/internal/services" ) // LiveReloadHub is the minimal interface the daemon uses for live reload. @@ -33,9 +32,6 @@ type BuildJobMetadata struct { // Delta analysis DeltaRepoReasons map[string]string `json:"delta_repo_reasons,omitempty"` - // State management - StateManager services.StateManager `json:"-"` - // Live reload LiveReloadHub LiveReloadHub `json:"-"` diff --git a/internal/build/queue/build_queue.go b/internal/build/queue/build_queue.go index 8410d55e..3b04dcf8 100644 --- a/internal/build/queue/build_queue.go +++ b/internal/build/queue/build_queue.go @@ -12,7 +12,6 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/eventstore" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" - "git.home.luguber.info/inful/docbuilder/internal/metrics" "git.home.luguber.info/inful/docbuilder/internal/retry" ) @@ -93,7 +92,6 @@ type BuildQueue struct { builder Builder retryPolicy retry.Policy - recorder metrics.Recorder eventEmitter BuildEventEmitter } @@ -125,7 +123,6 @@ func NewBuildQueue(maxSize, workers int, builder Builder) *BuildQueue { stopChan: make(chan struct{}), builder: builder, retryPolicy: retry.DefaultPolicy(), - recorder: metrics.NoopRecorder{}, } } @@ -136,14 +133,6 @@ func (bq *BuildQueue) ConfigureRetry(cfg config.BuildConfig) { bq.retryPolicy = retry.NewPolicy(cfg.RetryBackoff, retryInitialDelay, maxDelay, cfg.MaxRetries) } -// SetRecorder injects a metrics recorder for retry metrics (optional). -func (bq *BuildQueue) SetRecorder(r metrics.Recorder) { - if r == nil { - r = metrics.NoopRecorder{} - } - bq.recorder = r -} - // SetEventEmitter injects a build event emitter. func (bq *BuildQueue) SetEventEmitter(emitter BuildEventEmitter) { bq.eventEmitter = emitter @@ -382,14 +371,12 @@ func (bq *BuildQueue) executeBuild(ctx context.Context, job *BuildJob) error { transient, transientStage := findTransientError(report) if shouldStopRetrying(transient, totalRetries, policy.MaxRetries) { - handleRetriesExhausted(report, transient, totalRetries, transientStage, bq.recorder) + handleRetriesExhausted(report, transient, totalRetries, transientStage) return err } totalRetries++ - if transientStage != "" { - bq.recorder.IncBuildRetry(transientStage) - } + _ = transientStage // reserved for future retry telemetry delay := policy.Delay(totalRetries) slog.Warn("Transient build error, retrying", "job_id", job.ID, @@ -412,7 +399,7 @@ func shouldStopRetrying(transient bool, totalRetries, maxRetries int) bool { return !transient || totalRetries >= maxRetries } -func handleRetriesExhausted(report *models.BuildReport, transient bool, totalRetries int, transientStage string, recorder metrics.Recorder) { +func handleRetriesExhausted(report *models.BuildReport, transient bool, totalRetries int, transientStage string) { if !transient || totalRetries < 1 { return } @@ -421,9 +408,7 @@ func handleRetriesExhausted(report *models.BuildReport, transient bool, totalRet report.Retries = totalRetries report.RetriesExhausted = true } - if transientStage != "" { - recorder.IncBuildRetryExhausted(transientStage) - } + _ = transientStage // reserved for future retry telemetry } func findTransientError(report *models.BuildReport) (bool, string) { diff --git a/internal/build/queue/build_service_adapter.go b/internal/build/queue/build_service_adapter.go new file mode 100644 index 00000000..280c09b0 --- /dev/null +++ b/internal/build/queue/build_service_adapter.go @@ -0,0 +1,149 @@ +// Package queue documents the relationship between BuildService (the +// canonical pipeline shape in internal/build) and Builder (the queue's +// narrower contract). See the type doc comment below; the adapter is the +// deliberate seam between them. +// +// What this file owns +// ------------------- +// +// BuildServiceAdapter is the canonical bridge between two single-method +// interfaces that have intentionally different signatures: +// +// - build.BuildService.Run(ctx, BuildRequest) -> *BuildResult +// is the canonical input/output pair. BuildRequest is the CLI's +// input shape: Config + OutputDir + flags. CLI logs reach into +// BuildResult fields the queue contract throws away +// (RepositoriesSkipped, Duration, OutputPath, etc.). +// +// - queue.Builder.Build(ctx, *BuildJob) -> *BuildReport is the queue +// shape. BuildJob is the lifecycle envelope -- priority, type, +// status, TypedMeta carrying the embedded config -- and +// BuildReport is the per-stage report the queue eventually +// emits. +// +// Plan review-overlapping-functionality.md M14 called out the +// divergence and asked whether to collapse the two interfaces (option +// a) or move BuildService into the queue's package so the adapter can +// be reasoned about in one place (option b). We took option (c) +// ("accept the adapter and document it") because the divergence is +// load-bearing: it keeps queue-shaped orchestration separate from +// CLI-shaped pipeline invocation, and it isolates queue concurrency +// concerns from pipeline reporting. +// +// Future option (a) would unify by adding Config + OutputDir + +// Incremental + Options to BuildJob, deleting BuildRequest/Result, and +// making every CLI caller construct a queue-shaped job. That's a bigger +// refactor than fits in a Pass-D cleanup; this comment is the marker. +package queue + +import ( + "context" + "errors" + "path/filepath" + "sync" + + "git.home.luguber.info/inful/docbuilder/internal/build" + "git.home.luguber.info/inful/docbuilder/internal/config" + "git.home.luguber.info/inful/docbuilder/internal/hugo/models" +) + +const defaultSiteDir = "./site" + +// BuildServiceAdapter adapts the canonical BuildService.Run(BuildRequest) -> +// *BuildResult to the queue's narrower Builder.Build(BuildJob) -> +// *BuildReport. See the package comment above for the design rationale. +// +// Job -> Request translation pipeline: +// +// 1. Extract the embedded Config from TypedMeta (fail the build if +// the job forgot to attach one -- this is an orchestration bug, +// not a user input bug). +// 2. Let the job override cfg.Repositories (ADR-021 orchestration +// flows enqueue jobs that carry a snapshot of repos; cfg alone +// may be empty in forge mode). +// 3. Resolve OutputDir from cfg.Output.Directory plus BaseDirectory. +// 4. Set Incremental=true (the daemon is always incremental) and +// forward SkipIfUnchanged from cfg.Build. +// 5. Run the canonical pipeline and return only BuildReport (the +// queue's downstream contract). +// +// Compile-time assertion at the bottom of this file: *BuildServiceAdapter +// implements Builder. +type BuildServiceAdapter struct { + inner build.BuildService + mu sync.Mutex +} + +// NewBuildServiceAdapter creates a new adapter wrapping a BuildService. +func NewBuildServiceAdapter(svc build.BuildService) *BuildServiceAdapter { + return &BuildServiceAdapter{inner: svc} +} + +// Build implements the Builder interface by delegating to BuildService. +func (a *BuildServiceAdapter) Build(ctx context.Context, job *BuildJob) (*models.BuildReport, error) { + if job == nil { + return nil, errors.New("build job is nil") + } + + // Daemon serves a single output directory. Serializing builds here prevents concurrent + // build jobs (via BuildQueue workers) from clobbering shared staging/output paths. + a.mu.Lock() + defer a.mu.Unlock() + + // Extract configuration from TypedMeta + var cfg *config.Config + if job.TypedMeta != nil && job.TypedMeta.V2Config != nil { + cfg = job.TypedMeta.V2Config + } + if cfg == nil { + return nil, errors.New("build job has no configuration") + } + + // If the job carries an explicit repository set, prefer it over cfg.Repositories. + // This enables orchestration flows (ADR-021) to enqueue canonical full-site builds + // in forge mode where cfg.Repositories may be empty. + if job.TypedMeta != nil && len(job.TypedMeta.Repositories) > 0 { + cfgCopy := *cfg + cfgCopy.Repositories = job.TypedMeta.Repositories + if len(job.TypedMeta.RepoSnapshot) > 0 { + for i := range cfgCopy.Repositories { + repo := &cfgCopy.Repositories[i] + if sha, ok := job.TypedMeta.RepoSnapshot[repo.URL]; ok && sha != "" { + repo.PinnedCommit = sha + } + } + } + cfg = &cfgCopy + } + + // Extract output directory and combine with base_directory if set + outDir := cfg.Output.Directory + if outDir == "" { + outDir = defaultSiteDir + } + // If base_directory is set and outDir is relative, combine them + if cfg.Output.BaseDirectory != "" && !filepath.IsAbs(outDir) { + outDir = filepath.Join(cfg.Output.BaseDirectory, outDir) + } + + // Build the request + req := build.BuildRequest{ + Config: cfg, + OutputDir: outDir, + Incremental: true, // Daemon mode uses incremental updates to leverage remote HEAD cache + Options: build.BuildOptions{ + SkipIfUnchanged: cfg.Build.SkipIfUnchanged, + }, + } + + // Execute the build + result, err := a.inner.Run(ctx, req) + if err != nil { + return nil, err + } + + return result.Report, nil +} + +// ensure BuildServiceAdapter implements Builder. +var _ Builder = (*BuildServiceAdapter)(nil) diff --git a/internal/build/queue/queue_retry_test.go b/internal/build/queue/queue_retry_test.go deleted file mode 100644 index e96a7d83..00000000 --- a/internal/build/queue/queue_retry_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package queue - -import ( - "context" - "errors" - "sync" - "testing" - "time" - - bld "git.home.luguber.info/inful/docbuilder/internal/build" - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" - "git.home.luguber.info/inful/docbuilder/internal/metrics" - "git.home.luguber.info/inful/docbuilder/internal/retry" -) - -// fakeRecorder captures retry metrics for assertions. -type fakeRecorder struct { - mu sync.Mutex - retries map[string]int - exhausted map[string]int -} - -func newFakeRecorder() *fakeRecorder { - return &fakeRecorder{retries: map[string]int{}, exhausted: map[string]int{}} -} - -// Implement metrics.Recorder (only retry-related methods record state; others noop). -func (f *fakeRecorder) ObserveStageDuration(string, time.Duration) {} -func (f *fakeRecorder) ObserveBuildDuration(time.Duration) {} -func (f *fakeRecorder) IncStageResult(string, metrics.ResultLabel) {} -func (f *fakeRecorder) IncBuildOutcome(metrics.BuildOutcomeLabel) {} -func (f *fakeRecorder) ObserveCloneRepoDuration(string, time.Duration, bool) {} -func (f *fakeRecorder) IncCloneRepoResult(bool) {} -func (f *fakeRecorder) SetCloneConcurrency(int) {} -func (f *fakeRecorder) IncBuildRetry(stage string) { - f.mu.Lock() - defer f.mu.Unlock() - f.retries[stage]++ -} - -func (f *fakeRecorder) IncBuildRetryExhausted(stage string) { - f.mu.Lock() - defer f.mu.Unlock() - f.exhausted[stage]++ -} -func (f *fakeRecorder) IncIssue(string, string, string, bool) {} -func (f *fakeRecorder) SetEffectiveRenderMode(string) {} -func (f *fakeRecorder) IncContentTransformFailure(string) {} -func (f *fakeRecorder) ObserveContentTransformDuration(string, time.Duration, bool) {} - -func (f *fakeRecorder) getRetry() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.retries[string(models.StageCloneRepos)] -} - -func (f *fakeRecorder) getExhausted() int { - f.mu.Lock() - defer f.mu.Unlock() - return f.exhausted[string(models.StageCloneRepos)] -} - -// mockBuilder allows scripted outcomes: sequence of (report,error) pairs returned per Build invocation. -type mockBuilder struct { - mu sync.Mutex - seq []struct { - rep *models.BuildReport - err error - } - idx int -} - -func (m *mockBuilder) Build(_ context.Context, _ *BuildJob) (*models.BuildReport, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.idx >= len(m.seq) { - return &models.BuildReport{}, nil - } - cur := m.seq[m.idx] - m.idx++ - return cur.rep, cur.err -} - -// helper to create a transient StageError in a report. -func transientReport() (*models.BuildReport, error) { - // Use sentinel errors from internal/build to trigger transient classification. - underlying := bld.ErrClone - se := &models.StageError{Stage: models.StageCloneRepos, Kind: models.StageErrorWarning, Err: underlying} - r := &models.BuildReport{StageDurations: map[string]time.Duration{}, StageErrorKinds: map[models.StageName]models.StageErrorKind{}} - r.Errors = append(r.Errors, se) - return r, se -} - -// helper to create a fatal (non-transient) StageError report. -func fatalReport(stage models.StageName) (*models.BuildReport, error) { - se := &models.StageError{Stage: stage, Kind: models.StageErrorFatal, Err: errors.New("fatal")} - r := &models.BuildReport{StageDurations: map[string]time.Duration{}, StageErrorKinds: map[models.StageName]models.StageErrorKind{}} - r.Errors = append(r.Errors, se) - return r, se -} - -// newJob creates a minimal BuildJob. -func newJob(id string) *BuildJob { - return &BuildJob{ID: id, Type: BuildTypeManual, CreatedAt: time.Now()} -} - -func TestRetrySucceedsAfterTransient(t *testing.T) { - fr := newFakeRecorder() - // First attempt transient failure, second succeeds - tr, terr := transientReport() - mb := &mockBuilder{seq: []struct { - rep *models.BuildReport - err error - }{ - {tr, terr}, - {&models.BuildReport{}, nil}, - }} - bq := New(10, 1, mb) - bq.ConfigureRetry(config.BuildConfig{MaxRetries: 3, RetryBackoff: config.RetryBackoffFixed, RetryInitialDelay: "1ms", RetryMaxDelay: "5ms"}) - bq.SetRecorder(fr) - - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - bq.Start(ctx) - job := newJob("job1") - if err := bq.Enqueue(job); err != nil { - t.Fatalf("enqueue: %v", err) - } - // wait until job finishes - for { - time.Sleep(10 * time.Millisecond) - snap, ok := bq.JobSnapshot(job.ID) - if ok && snap.CompletedAt != nil { - if snap.Status != BuildStatusCompleted { - t.Fatalf("expected completed, got %s", snap.Status) - } - break - } - if ctx.Err() != nil { - t.Fatalf("timeout waiting for job completion") - } - } - if fr.getRetry() != 1 { - t.Fatalf("expected 1 retry metric, got %d", fr.getRetry()) - } - if fr.getExhausted() != 0 { - t.Fatalf("expected 0 exhausted, got %d", fr.getExhausted()) - } -} - -func TestRetryExhausted(t *testing.T) { - fr := newFakeRecorder() - // Always transient failure, exceed retries - tr1, terr1 := transientReport() - tr2, terr2 := transientReport() - tr3, terr3 := transientReport() - mb := &mockBuilder{seq: []struct { - rep *models.BuildReport - err error - }{ - {tr1, terr1}, {tr2, terr2}, {tr3, terr3}, - }} - bq := New(10, 1, mb) - bq.ConfigureRetry(config.BuildConfig{MaxRetries: 2, RetryBackoff: config.RetryBackoffLinear, RetryInitialDelay: "1ms", RetryMaxDelay: "5ms"}) - bq.SetRecorder(fr) - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - bq.Start(ctx) - job := newJob("job2") - if err := bq.Enqueue(job); err != nil { - t.Fatalf("enqueue: %v", err) - } - for { - time.Sleep(10 * time.Millisecond) - snap, ok := bq.JobSnapshot(job.ID) - if ok && snap.CompletedAt != nil { - if snap.Status != BuildStatusFailed { - t.Fatalf("expected failed, got %s", snap.Status) - } - break - } - if ctx.Err() != nil { - t.Fatalf("timeout waiting for job completion") - } - } - if fr.getRetry() != 2 { - t.Fatalf("expected 2 retry attempts metric, got %d", fr.getRetry()) - } - if fr.getExhausted() != 1 { - t.Fatalf("expected 1 exhausted, got %d", fr.getExhausted()) - } -} - -func TestNoRetryOnPermanent(t *testing.T) { - fr := newFakeRecorder() - frpt, ferr := fatalReport(models.StageCloneRepos) - mb := &mockBuilder{seq: []struct { - rep *models.BuildReport - err error - }{{frpt, ferr}}} - bq := New(10, 1, mb) - bq.ConfigureRetry(config.BuildConfig{MaxRetries: 3, RetryBackoff: config.RetryBackoffExponential, RetryInitialDelay: "1ms", RetryMaxDelay: "4ms"}) - bq.SetRecorder(fr) - ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) - defer cancel() - bq.Start(ctx) - job := newJob("job3") - if err := bq.Enqueue(job); err != nil { - t.Fatalf("enqueue: %v", err) - } - for { - time.Sleep(10 * time.Millisecond) - snap, ok := bq.JobSnapshot(job.ID) - if ok && snap.CompletedAt != nil { - break - } - if ctx.Err() != nil { - t.Fatalf("timeout waiting for job completion") - } - } - if fr.getRetry() != 0 { - t.Fatalf("expected 0 retries, got %d", fr.getRetry()) - } - if fr.getExhausted() != 0 { - t.Fatalf("expected 0 exhausted, got %d", fr.getExhausted()) - } -} - -func TestExponentialBackoffCapped(t *testing.T) { - // Validate exponential growth and cap respect without sleeping real exponential durations by using very small intervals. - initial := 1 * time.Millisecond - maxWait := 4 * time.Millisecond - // retryCount: 1->1ms,2->2ms,3->4ms,4->cap 4ms - cases := []struct { - retry int - want time.Duration - }{{1, 1 * time.Millisecond}, {2, 2 * time.Millisecond}, {3, 4 * time.Millisecond}, {4, 4 * time.Millisecond}} - pol := retry.NewPolicy(config.RetryBackoffExponential, initial, maxWait, 5) - for _, c := range cases { - got := pol.Delay(c.retry) - if got != c.want { - t.Fatalf("retry %d: expected %v got %v", c.retry, c.want, got) - } - } -} - -func TestRetryPolicyValidationAndModes(t *testing.T) { - p := retry.NewPolicy("", 0, 0, -1) // empty stays default (string literal acceptable for zero value) - if err := p.Validate(); err != nil { - t.Fatalf("default policy should validate: %v", err) - } - if p.Mode != "linear" { - t.Fatalf("expected default mode linear got %s", p.Mode) - } - fixed := retry.NewPolicy(config.RetryBackoffFixed, 10*time.Millisecond, 20*time.Millisecond, 3) - if d := fixed.Delay(2); d != 10*time.Millisecond { - t.Fatalf("fixed mode should not scale: got %v", d) - } - linear := retry.NewPolicy(config.RetryBackoffLinear, 5*time.Millisecond, 12*time.Millisecond, 3) - if d := linear.Delay(3); d != 12*time.Millisecond { - t.Fatalf("linear capping failed expected 12ms got %v", d) - } - exp := retry.NewPolicy(config.RetryBackoffExponential, 2*time.Millisecond, 10*time.Millisecond, 5) - if exp.Delay(4) != 10*time.Millisecond { - t.Fatalf("exponential cap failed: %v", exp.Delay(4)) - } -} diff --git a/internal/build/queue/retry_flakiness_test.go b/internal/build/queue/retry_flakiness_test.go deleted file mode 100644 index 630bf6f2..00000000 --- a/internal/build/queue/retry_flakiness_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package queue - -import ( - "context" - "strconv" - "testing" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" -) - -// TestRetryFlakinessSmoke runs multiple iterations of transient-then-success and fatal-no-retry -// scenarios to surface timing or race related flakiness in the BuildQueue retry logic. -func TestRetryFlakinessSmoke(t *testing.T) { - const iterations = 25 - for i := range iterations { - t.Run("transient_then_success_iter_"+strconv.Itoa(i), func(t *testing.T) { - fr := newFakeRecorder() - tr, terr := transientReport() - mb := &mockBuilder{seq: []struct { - rep *models.BuildReport - err error - }{{tr, terr}, {&models.BuildReport{}, nil}}} - bq := New(5, 1, mb) - bq.ConfigureRetry(config.BuildConfig{MaxRetries: 3, RetryBackoff: config.RetryBackoffFixed, RetryInitialDelay: "1ms", RetryMaxDelay: "2ms"}) - bq.SetRecorder(fr) - - ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) - defer cancel() - bq.Start(ctx) - job := newJob("txs_" + strconv.Itoa(i)) - if err := bq.Enqueue(job); err != nil { - t.Fatalf("enqueue: %v", err) - } - for { - time.Sleep(5 * time.Millisecond) - snap, ok := bq.JobSnapshot(job.ID) - if ok && snap.CompletedAt != nil { - break - } - if ctx.Err() != nil { - t.Fatalf("timeout waiting (transient success) iter %d", i) - } - } - if got := fr.getRetry(); got != 1 { - t.Fatalf("expected 1 retry got %d", got) - } - }) - } - - for i := range iterations { - t.Run("fatal_no_retry_iter_"+strconv.Itoa(i), func(t *testing.T) { - fr := newFakeRecorder() - frpt, ferr := fatalReport(models.StageCloneRepos) - mb := &mockBuilder{seq: []struct { - rep *models.BuildReport - err error - }{{frpt, ferr}}} - bq := New(5, 1, mb) - bq.ConfigureRetry(config.BuildConfig{MaxRetries: 3, RetryBackoff: config.RetryBackoffLinear, RetryInitialDelay: "1ms", RetryMaxDelay: "2ms"}) - bq.SetRecorder(fr) - - ctx, cancel := context.WithTimeout(t.Context(), 400*time.Millisecond) - defer cancel() - bq.Start(ctx) - job := newJob("fnr_" + strconv.Itoa(i)) - if err := bq.Enqueue(job); err != nil { - t.Fatalf("enqueue: %v", err) - } - for { - time.Sleep(5 * time.Millisecond) - snap, ok := bq.JobSnapshot(job.ID) - if ok && snap.CompletedAt != nil { - break - } - if ctx.Err() != nil { - t.Fatalf("timeout waiting (fatal no retry) iter %d", i) - } - } - if got := fr.getRetry(); got != 0 { - t.Fatalf("expected 0 retries got %d", got) - } - }) - } -} diff --git a/internal/build/service.go b/internal/build/service.go deleted file mode 100644 index de1f1b07..00000000 --- a/internal/build/service.go +++ /dev/null @@ -1,99 +0,0 @@ -package build - -import ( - "context" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" -) - -// BuildService is the canonical interface for executing documentation builds. -// Both CLI and daemon/server should implement thin wrappers over this interface. -type BuildService interface { - // Run executes a complete build pipeline: clone → discover → transform → generate. - // Returns a BuildResult with detailed outcomes and any error encountered. - Run(ctx context.Context, req BuildRequest) (*BuildResult, error) -} - -// BuildRequest contains all inputs required to execute a documentation build. -type BuildRequest struct { - // Config is the loaded configuration for this build. - Config *config.Config - - // OutputDir is the target directory for the generated Hugo site. - OutputDir string - - // Incremental enables incremental updates (git pull vs fresh clone). - Incremental bool - - // Options provides optional build behavior modifiers. - Options BuildOptions -} - -// BuildOptions provides optional configuration for build behavior. -type BuildOptions struct { - // Verbose enables detailed logging during the build. - Verbose bool - - // SkipIfUnchanged enables skip evaluation when content hasn't changed. - SkipIfUnchanged bool -} - -// BuildResult contains the outcome of a build execution. -type BuildResult struct { - // Status indicates overall build outcome. - Status BuildStatus - - // Report contains detailed build metrics and diagnostics. - Report *models.BuildReport - - // OutputPath is the final output directory (may differ from request). - OutputPath string - - // Repositories is the count of processed repositories. - Repositories int - - // RepositoriesSkipped is the count of repositories that failed to clone/process. - RepositoriesSkipped int - - // FilesProcessed is the count of documentation files handled. - FilesProcessed int - - // Duration is the total build execution time. - Duration time.Duration - - // StartTime is when the build started. - StartTime time.Time - - // EndTime is when the build completed. - EndTime time.Time - - // Skipped indicates the build was skipped due to no changes. - Skipped bool - - // SkipReason explains why the build was skipped (if Skipped is true). - SkipReason string -} - -// BuildStatus represents the outcome of a build execution. -type BuildStatus string - -const ( - // BuildStatusSuccess indicates the build completed successfully. - BuildStatusSuccess BuildStatus = "success" - - // BuildStatusFailed indicates the build encountered an error. - BuildStatusFailed BuildStatus = "failed" - - // BuildStatusSkipped indicates the build was skipped (e.g., no changes). - BuildStatusSkipped BuildStatus = "skipped" - - // BuildStatusCancelled indicates the build was canceled. - BuildStatusCancelled BuildStatus = "canceled" -) - -// IsSuccess returns true if the build completed successfully. -func (s BuildStatus) IsSuccess() bool { - return s == BuildStatusSuccess || s == BuildStatusSkipped -} diff --git a/internal/build/validation/basic_rules.go b/internal/build/validation/basic_rules.go index e2038a4d..f5eb9ab3 100644 --- a/internal/build/validation/basic_rules.go +++ b/internal/build/validation/basic_rules.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" - "git.home.luguber.info/inful/docbuilder/internal/hugo" "git.home.luguber.info/inful/docbuilder/internal/version" ) @@ -14,7 +13,7 @@ type BasicPrerequisitesRule struct{} func (r BasicPrerequisitesRule) Name() string { return "basic_prerequisites" } -func (r BasicPrerequisitesRule) Validate(ctx context.Context, vctx Context) Result { +func (r BasicPrerequisitesRule) Validate(_ context.Context, vctx Context) Result { if vctx.State == nil { return Failure("state manager is nil") } @@ -32,7 +31,7 @@ type ConfigHashRule struct{} func (r ConfigHashRule) Name() string { return "config_hash" } -func (r ConfigHashRule) Validate(ctx context.Context, vctx Context) Result { +func (r ConfigHashRule) Validate(_ context.Context, vctx Context) Result { currentHash := vctx.Generator.ComputeConfigHashForPersistence() if currentHash == "" { return Failure("current config hash is empty") @@ -51,7 +50,7 @@ type PublicDirectoryRule struct{} func (r PublicDirectoryRule) Name() string { return "public_directory" } -func (r PublicDirectoryRule) Validate(ctx context.Context, vctx Context) Result { +func (r PublicDirectoryRule) Validate(_ context.Context, vctx Context) Result { publicDir := filepath.Join(vctx.OutDir, "public") // Check if directory exists and is a directory @@ -78,28 +77,27 @@ func (r PublicDirectoryRule) Validate(ctx context.Context, vctx Context) Result // VersionMismatchRule validates that DocBuilder and Hugo versions haven't changed. // If either version differs from the previous build, a rebuild is forced to ensure // compatibility and that new features/fixes take effect. +// +// The current Hugo version is read from vctx.HugoVersion, populated by the +// caller (validation is intentionally unaware of how the version is detected). type VersionMismatchRule struct{} func (r VersionMismatchRule) Name() string { return "version_mismatch" } -func (r VersionMismatchRule) Validate(ctx context.Context, vctx Context) Result { +func (r VersionMismatchRule) Validate(_ context.Context, vctx Context) Result { if vctx.PrevReport == nil { return Failure("no previous report available") } // Check DocBuilder version - currentDocBuilderVersion := version.Version - if currentDocBuilderVersion != vctx.PrevReport.DocBuilderVersion { + if version.Version != vctx.PrevReport.DocBuilderVersion { return Failure("docbuilder version changed") } // Check Hugo version (only if Hugo was used in previous build) // Empty previous Hugo version means Hugo wasn't executed - if vctx.PrevReport.HugoVersion != "" { - currentHugoVersion := hugo.DetectHugoVersion(ctx) - if currentHugoVersion != vctx.PrevReport.HugoVersion { - return Failure("hugo version changed") - } + if vctx.PrevReport.HugoVersion != "" && vctx.HugoVersion != vctx.PrevReport.HugoVersion { + return Failure("hugo version changed") } return Success() diff --git a/internal/build/validation/evaluator.go b/internal/build/validation/evaluator.go index 38dc99b0..f10d8d29 100644 --- a/internal/build/validation/evaluator.go +++ b/internal/build/validation/evaluator.go @@ -11,26 +11,29 @@ import ( "time" cfg "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/hugo" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" ) // SkipEvaluator decides whether a build can be safely skipped based on // persisted state + prior build report + filesystem probes using validation rules. type SkipEvaluator struct { - outDir string - state SkipStateAccess - generator *hugo.Generator + outDir string + state SkipStateAccess + generator GeneratorInfo + hugoVersion string } // NewSkipEvaluator constructs a new evaluator with the standard validation rules. -func NewSkipEvaluator(outDir string, st SkipStateAccess, gen *hugo.Generator) *SkipEvaluator { - // Rules are created on-demand in the evaluator since PreviousReportRule - // requires special handling to populate the context +// +// hugoVersion is the currently-detected Hugo version (e.g. "0.152.2") or "" +// when Hugo isn't installed. Validation is intentionally unaware of how the +// version is detected — the caller does the detection and passes the result in. +func NewSkipEvaluator(outDir string, st SkipStateAccess, gen GeneratorInfo, hugoVersion string) *SkipEvaluator { return &SkipEvaluator{ - outDir: outDir, - state: st, - generator: gen, + outDir: outDir, + state: st, + generator: gen, + hugoVersion: hugoVersion, } } @@ -38,11 +41,12 @@ func NewSkipEvaluator(outDir string, st SkipStateAccess, gen *hugo.Generator) *S // It never returns an error; corrupt/missing data simply disables the skip and a full rebuild proceeds. func (se *SkipEvaluator) Evaluate(ctx context.Context, repos []cfg.Repository) (*models.BuildReport, bool) { vctx := Context{ - OutDir: se.outDir, - State: se.state, - Generator: se.generator, - Repos: repos, - Logger: slog.Default(), + OutDir: se.outDir, + State: se.state, + Generator: se.generator, + Repos: repos, + Logger: slog.Default(), + HugoVersion: se.hugoVersion, } // Special handling for PreviousReportRule since it needs to populate context diff --git a/internal/build/validation/rule.go b/internal/build/validation/rule.go index 903de02c..7dcc6513 100644 --- a/internal/build/validation/rule.go +++ b/internal/build/validation/rule.go @@ -5,9 +5,15 @@ import ( "log/slog" cfg "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/hugo" ) +// GeneratorInfo is the slice of *hugo.Generator that validation rules need. +// Defined locally so this package does not depend on internal/hugo (avoiding +// the build/validation -> hugo layering inversion). +type GeneratorInfo interface { + ComputeConfigHashForPersistence() string +} + // SkipStateAccess encapsulates the subset of state manager methods required for validation. type SkipStateAccess interface { GetRepoLastCommit(string) string @@ -21,12 +27,13 @@ type SkipStateAccess interface { // Context contains all the data needed by validation rules. type Context struct { - OutDir string - State SkipStateAccess - Generator *hugo.Generator - Repos []cfg.Repository - PrevReport *PreviousReport - Logger *slog.Logger + OutDir string + State SkipStateAccess + Generator GeneratorInfo + Repos []cfg.Repository + PrevReport *PreviousReport + Logger *slog.Logger + HugoVersion string // Detected Hugo version, populated by the caller. } // PreviousReport holds parsed data from the previous build report. diff --git a/internal/build/validation/validation_test.go b/internal/build/validation/validation_test.go index fee7f760..85e29bcc 100644 --- a/internal/build/validation/validation_test.go +++ b/internal/build/validation/validation_test.go @@ -107,7 +107,7 @@ func TestSkipEvaluator_ValidationRulesIntegration(t *testing.T) { writePrevReport(t, out, 1, 2, 2, "abc123", st) st.lastGlobalDocFiles = "abc123" - evaluator := NewSkipEvaluator(out, st, gen) + evaluator := NewSkipEvaluator(out, st, gen, "") rep, ok := evaluator.Evaluate(t.Context(), []cfg.Repository{repo}) if !ok { diff --git a/internal/config/composite_defaults.go b/internal/config/composite_defaults.go index 29be3a77..7e980707 100644 --- a/internal/config/composite_defaults.go +++ b/internal/config/composite_defaults.go @@ -1,6 +1,8 @@ package config -import "fmt" +import ( + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" +) // CompositeDefaultApplier applies defaults across all configuration domains. type CompositeDefaultApplier struct { @@ -27,7 +29,7 @@ func NewDefaultApplier() *CompositeDefaultApplier { func (c *CompositeDefaultApplier) ApplyDefaults(cfg *Config) error { for _, applier := range c.appliers { if err := applier.ApplyDefaults(cfg); err != nil { - return fmt.Errorf("applying defaults for %s: %w", applier.Domain(), err) + return derrors.WrapError(err, derrors.CategoryConfig, "applying defaults").WithContext("domain", applier.Domain()).Build() } } return nil diff --git a/internal/config/forge_typed.go b/internal/config/forge_typed.go index 5d61f188..2280d5a1 100644 --- a/internal/config/forge_typed.go +++ b/internal/config/forge_typed.go @@ -5,6 +5,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/foundation" "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" + "git.home.luguber.info/inful/docbuilder/internal/foundation/normalization" ) // ForgeTyped represents a type-safe forge type using the foundation enum system. @@ -19,7 +20,7 @@ var ( ForgeTypedForgejo = ForgeTyped{string(ForgeForgejo)} // Registry for validation and parsing. - forgeTypeNormalizer = foundation.NewNormalizer(map[string]ForgeTyped{ + forgeTypeNormalizer = normalization.NewNormalizer(map[string]ForgeTyped{ string(ForgeGitHub): ForgeTypedGitHub, string(ForgeGitLab): ForgeTypedGitLab, string(ForgeForgejo): ForgeTypedForgejo, diff --git a/internal/daemon/build_context_reasons_test.go b/internal/daemon/build_context_reasons_test.go deleted file mode 100644 index f1aed6d1..00000000 --- a/internal/daemon/build_context_reasons_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package daemon - -// NOTE: Legacy delta helper tests moved to internal/build/delta. diff --git a/internal/daemon/build_integration_test.go b/internal/daemon/build_integration_test.go index 7874cf40..f7976de4 100644 --- a/internal/daemon/build_integration_test.go +++ b/internal/daemon/build_integration_test.go @@ -60,7 +60,7 @@ func TestDaemonStateBuildCounters(t *testing.T) { if svcResult.IsErr() { t.Fatalf("state service: %v", svcResult.UnwrapErr()) } - sm := state.NewServiceAdapter(svcResult.Unwrap()) + sm := svcResult.Unwrap() gen := hugo.NewGenerator(config, out).WithStateManager(sm).WithRenderer(&stages.NoopRenderer{}) ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) defer cancel() diff --git a/internal/daemon/build_job_metadata.go b/internal/daemon/build_job_metadata.go deleted file mode 100644 index 464fc788..00000000 --- a/internal/daemon/build_job_metadata.go +++ /dev/null @@ -1,4 +0,0 @@ -package daemon - -// Deprecated: daemon-local BuildJobMetadata moved to internal/build/queue. -// See build_queue_aliases.go for the compatibility layer. diff --git a/internal/daemon/build_queue.go b/internal/daemon/build_queue.go deleted file mode 100644 index 228be1bc..00000000 --- a/internal/daemon/build_queue.go +++ /dev/null @@ -1,4 +0,0 @@ -package daemon - -// Deprecated: daemon-local build queue types moved to internal/build/queue. -// See build_queue_aliases.go for the compatibility layer. diff --git a/internal/daemon/build_queue_aliases.go b/internal/daemon/build_queue_aliases.go index d1506ac7..3c40535f 100644 --- a/internal/daemon/build_queue_aliases.go +++ b/internal/daemon/build_queue_aliases.go @@ -1,19 +1,23 @@ package daemon -import "git.home.luguber.info/inful/docbuilder/internal/build/queue" +import ( + "git.home.luguber.info/inful/docbuilder/internal/build" + "git.home.luguber.info/inful/docbuilder/internal/build/queue" +) // Type and constant aliases keep the daemon package API stable while the // implementation lives in internal/build/queue. type ( - BuildType = queue.BuildType - BuildPriority = queue.BuildPriority - BuildStatus = queue.BuildStatus - BuildJob = queue.BuildJob - BuildJobMetadata = queue.BuildJobMetadata - BuildQueue = queue.BuildQueue - BuildEventEmitter = queue.BuildEventEmitter - Builder = queue.Builder + BuildType = queue.BuildType + BuildPriority = queue.BuildPriority + BuildStatus = queue.BuildStatus + BuildJob = queue.BuildJob + BuildJobMetadata = queue.BuildJobMetadata + BuildQueue = queue.BuildQueue + BuildEventEmitter = queue.BuildEventEmitter + Builder = queue.Builder + BuildServiceAdapter = queue.BuildServiceAdapter ) const ( @@ -39,3 +43,8 @@ func EnsureTypedMeta(job *BuildJob) *BuildJobMetadata { return queue.EnsureTyped func NewBuildQueue(maxSize, workers int, builder Builder) *BuildQueue { return queue.NewBuildQueue(maxSize, workers, builder) } + +// NewBuildServiceAdapter wraps a build.BuildService as a queue Builder. +func NewBuildServiceAdapter(svc build.BuildService) *BuildServiceAdapter { + return queue.NewBuildServiceAdapter(svc) +} diff --git a/internal/daemon/build_queue_process_job_test.go b/internal/daemon/build_queue_process_job_test.go deleted file mode 100644 index 25be0451..00000000 --- a/internal/daemon/build_queue_process_job_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package daemon - -// Deprecated: tests moved to internal/build/queue. diff --git a/internal/daemon/build_queue_retry_test.go b/internal/daemon/build_queue_retry_test.go deleted file mode 100644 index 25be0451..00000000 --- a/internal/daemon/build_queue_retry_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package daemon - -// Deprecated: tests moved to internal/build/queue. diff --git a/internal/daemon/build_service_adapter.go b/internal/daemon/build_service_adapter.go deleted file mode 100644 index 4e75c720..00000000 --- a/internal/daemon/build_service_adapter.go +++ /dev/null @@ -1,96 +0,0 @@ -package daemon - -import ( - "context" - "errors" - "path/filepath" - "sync" - - "git.home.luguber.info/inful/docbuilder/internal/build" - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" -) - -const defaultSiteDir = "./site" - -// BuildServiceAdapter adapts the daemon's Builder interface to build.BuildService. -// This enables the daemon to use the canonical build pipeline while maintaining -// compatibility with the existing job-based architecture. -type BuildServiceAdapter struct { - inner build.BuildService - mu sync.Mutex -} - -// NewBuildServiceAdapter creates a new adapter wrapping a BuildService. -func NewBuildServiceAdapter(svc build.BuildService) *BuildServiceAdapter { - return &BuildServiceAdapter{inner: svc} -} - -// Build implements the Builder interface by delegating to BuildService. -func (a *BuildServiceAdapter) Build(ctx context.Context, job *BuildJob) (*models.BuildReport, error) { - if job == nil { - return nil, errors.New("build job is nil") - } - - // Daemon serves a single output directory. Serializing builds here prevents concurrent - // build jobs (via BuildQueue workers) from clobbering shared staging/output paths. - a.mu.Lock() - defer a.mu.Unlock() - - // Extract configuration from TypedMeta - var cfg *config.Config - if job.TypedMeta != nil && job.TypedMeta.V2Config != nil { - cfg = job.TypedMeta.V2Config - } - if cfg == nil { - return nil, errors.New("build job has no configuration") - } - - // If the job carries an explicit repository set, prefer it over cfg.Repositories. - // This enables orchestration flows (ADR-021) to enqueue canonical full-site builds - // in forge mode where cfg.Repositories may be empty. - if job.TypedMeta != nil && len(job.TypedMeta.Repositories) > 0 { - cfgCopy := *cfg - cfgCopy.Repositories = job.TypedMeta.Repositories - if len(job.TypedMeta.RepoSnapshot) > 0 { - for i := range cfgCopy.Repositories { - repo := &cfgCopy.Repositories[i] - if sha, ok := job.TypedMeta.RepoSnapshot[repo.URL]; ok && sha != "" { - repo.PinnedCommit = sha - } - } - } - cfg = &cfgCopy - } - - // Extract output directory and combine with base_directory if set - outDir := cfg.Output.Directory - if outDir == "" { - outDir = defaultSiteDir - } - // If base_directory is set and outDir is relative, combine them - if cfg.Output.BaseDirectory != "" && !filepath.IsAbs(outDir) { - outDir = filepath.Join(cfg.Output.BaseDirectory, outDir) - } - - // Build the request - req := build.BuildRequest{ - Config: cfg, - OutputDir: outDir, - Incremental: true, // Daemon mode uses incremental updates to leverage remote HEAD cache - Options: build.BuildOptions{ - SkipIfUnchanged: cfg.Build.SkipIfUnchanged, - }, - } - - // Execute the build - result, err := a.inner.Run(ctx, req) - if err != nil { - return nil, err - } - - return result.Report, nil -} - -// ensure BuildServiceAdapter implements Builder. -var _ Builder = (*BuildServiceAdapter)(nil) diff --git a/internal/daemon/builder.go b/internal/daemon/builder.go deleted file mode 100644 index ccd71317..00000000 --- a/internal/daemon/builder.go +++ /dev/null @@ -1,4 +0,0 @@ -package daemon - -// Deprecated: daemon-local Builder moved to internal/build/queue. -// See build_queue_aliases.go for the compatibility layer. diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4f50554e..b65a1047 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -3,7 +3,6 @@ package daemon import ( "context" "errors" - "fmt" "log/slog" "maps" "net/http" @@ -16,8 +15,10 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/build" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/daemon/events" + "git.home.luguber.info/inful/docbuilder/internal/daemon/lifecycle" "git.home.luguber.info/inful/docbuilder/internal/eventstore" "git.home.luguber.info/inful/docbuilder/internal/forge" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/git" "git.home.luguber.info/inful/docbuilder/internal/hugo" "git.home.luguber.info/inful/docbuilder/internal/linkverify" @@ -75,7 +76,6 @@ type Daemon struct { // Runtime state activeJobs int32 queueLength atomic.Int32 - lastBuild *time.Time // Background worker tracking (started in Start, awaited in Stop). workers WorkerGroup @@ -102,11 +102,26 @@ func NewDaemon(cfg *config.Config) (*Daemon, error) { } // NewDaemonWithConfigFile creates a new daemon instance with config file watching. +// +// This constructor wires up every subsystem in dependency order. Each +// subsystem is built by a dedicated private helper (newX) so the top-level +// function reads as a sequence of "initialize X" calls. +// +// Construction order matters: forge manager must exist before discovery +// and HTTP wiring; state service must exist before BuildService (the +// SkipEvaluatorFactory closure captures d.stateManager); BuildService +// must exist before BuildQueue (the queue wraps it); scheduler runs +// after BuildQueue so periodic jobs can target it; event store comes +// after state so the event store path joins the same state directory; +// liveReload and linkVerifier are opt-in; the HTTP server is wired +// after both; buildQueue.SetEventEmitter closes the loop between the +// queue and the emitter (must run after newEventStore); finally the +// orchestration subsystems (discovery runner, build debouncer, repo +// updater) hang off d.orchestrationBus. func NewDaemonWithConfigFile(cfg *config.Config, configFilePath string) (*Daemon, error) { if cfg == nil { return nil, errors.New("configuration is required") } - if cfg.Daemon == nil { return nil, errors.New("daemon configuration is required") } @@ -119,94 +134,169 @@ func NewDaemonWithConfigFile(cfg *config.Config, configFilePath string) (*Daemon discoveryCache: NewDiscoveryCache(), orchestrationBus: events.NewBus(), } - daemon.status.Store(StatusStopped) - // Initialize forge manager - forgeManager := forge.NewForgeManager() - for _, forgeConfig := range cfg.Forges { - client, err := forge.NewForgeClient(forgeConfig) + // Forge manager (and per-forge clients) + discovery service. + forgeManager, err := daemon.newForgeManager() + if err != nil { + return nil, err + } + daemon.discovery = forge.NewDiscoveryService(forgeManager, cfg.Filtering) + + // State service first: SkipEvaluatorFactory closure inside + // newBuildService captures d.stateManager and must not see nil. + stateDir := daemon.resolveStateDir() + if err = daemon.newStateService(stateDir); err != nil { + return nil, err + } + + // Build pipeline. + buildService := daemon.newBuildService() + daemon.newBuildQueue(buildService) + + // Scheduler runs after the build queue so periodic jobs can target it. + daemon.scheduler, err = NewScheduler() + if err != nil { + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to create scheduler").Build() + } + + // Event sourcing. + if err = daemon.newEventStore(stateDir); err != nil { + return nil, err + } + + // Opt-in subsystems. + if cfg.Build.LiveReload { + daemon.liveReload = NewLiveReloadHub(daemon.metrics) + slog.Info("LiveReload hub initialized") + } + + // HTTP server wiring. + webhookConfigs, forgeClients := daemon.collectHTTPInputs() + daemon.newHTTPServer(webhookConfigs, forgeClients) + if cfg.Daemon.LinkVerification != nil && cfg.Daemon.LinkVerification.Enabled { + daemon.initLinkVerifier() + } + + // Close the loop between the build queue and the event emitter. + daemon.buildQueue.SetEventEmitter(daemon.eventEmitter) + + // Orchestration: discovery runner, build debouncer, repo updater. + daemon.newDiscoveryRunner() + if err = daemon.newBuildDebouncer(cfg); err != nil { + return nil, err + } + daemon.newRepoUpdater() + + return daemon, nil +} + +// newForgeManager constructs the forge manager and per-forge clients from +// d.config.Forges. The manager is stored on d (needed by the discovery +// service, HTTP server wiring, orchestrated builds, webhook forge-name +// lookup, and health checks). Returns any error from client construction. +func (d *Daemon) newForgeManager() (*forge.Manager, error) { + fm := forge.NewForgeManager() + for _, fc := range d.config.Forges { + client, err := forge.NewForgeClient(fc) if err != nil { - return nil, fmt.Errorf("failed to create forge client %s: %w", forgeConfig.Name, err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to create forge client"). + WithContext("forge", fc.Name).Build() } - forgeManager.AddForge(forgeConfig, client) + fm.AddForge(fc, client) } - daemon.forgeManager = forgeManager + d.forgeManager = fm + return fm, nil +} - // Initialize discovery service - daemon.discovery = forge.NewDiscoveryService(forgeManager, cfg.Filtering) +// resolveStateDir returns the directory used for daemon persistent state +// (state service + SQLite event store). Falls back to "./daemon-data" when +// the configured RepoCacheDir is empty. +func (d *Daemon) resolveStateDir() string { + stateDir := d.config.Daemon.Storage.RepoCacheDir + if stateDir == "" { + return "./daemon-data" + } + return stateDir +} - // Create canonical BuildService (Phase D - Single Execution Pipeline) - buildService := build.NewBuildService(). +// newStateService initializes d.stateManager using state.NewService backed +// by the given state directory. Must run before newBuildService so the +// SkipEvaluatorFactory closure captures a non-nil d.stateManager. +func (d *Daemon) newStateService(stateDir string) error { + result := state.NewService(stateDir) + if result.IsErr() { + return derrors.WrapError(result.UnwrapErr(), derrors.CategoryInternal, "failed to create state service").Build() + } + d.stateManager = result.Unwrap() + return nil +} + +// newBuildService constructs the canonical BuildService with the workspace, +// Hugo generator, and skip evaluator factories wired to the daemon. +// +// d.stateManager must be initialized (via newStateService) before this is +// called: the skip-evaluator factory closure captures d.stateManager and +// would silently disable skip-evaluation otherwise. +func (d *Daemon) newBuildService() build.BuildService { + return build.NewBuildService(). WithWorkspaceFactory(func() *workspace.Manager { - // Use persistent workspace for incremental builds (repo_cache_dir/working) - return workspace.NewPersistentManager(cfg.Daemon.Storage.RepoCacheDir, "working") + // Use persistent workspace for incremental builds (repo_cache_dir/working). + return workspace.NewPersistentManager(d.config.Daemon.Storage.RepoCacheDir, "working") }). WithHugoGeneratorFactory(func(cfg *config.Config, outputDir string) build.HugoGenerator { return hugo.NewGenerator(cfg, outputDir) }). WithSkipEvaluatorFactory(func(outputDir string) build.SkipEvaluator { - // Create skip evaluator with state manager access - // Will be populated after state manager is initialized - if daemon.stateManager == nil { - slog.Warn("Skip evaluator factory called before state manager initialized - skipping evaluation") - return nil - } - gen := hugo.NewGenerator(daemon.config, outputDir) - return NewSkipEvaluator(outputDir, daemon.stateManager, gen) + gen := hugo.NewGenerator(d.config, outputDir) + return NewSkipEvaluator(outputDir, d.stateManager, gen) }) - buildAdapter := NewBuildServiceAdapter(buildService) - - // Initialize build queue with the canonical builder - daemon.buildQueue = NewBuildQueue(cfg.Daemon.Sync.QueueSize, cfg.Daemon.Sync.ConcurrentBuilds, buildAdapter) - // Configure retry policy from build config (recorder injection handled elsewhere if added later) - daemon.buildQueue.ConfigureRetry(cfg.Build) - - // Initialize scheduler (after build queue) - scheduler, err := NewScheduler() - if err != nil { - return nil, fmt.Errorf("failed to create scheduler: %w", err) - } - daemon.scheduler = scheduler +} - // Initialize state manager using the typed state.Service wrapped in ServiceAdapter. - // This bridges the new typed state system with the daemon's interface requirements. - stateDir := cfg.Daemon.Storage.RepoCacheDir - if stateDir == "" { - stateDir = "./daemon-data" // Default data directory - } - stateServiceResult := state.NewService(stateDir) - if stateServiceResult.IsErr() { - return nil, fmt.Errorf("failed to create state service: %w", stateServiceResult.UnwrapErr()) - } - daemon.stateManager = state.NewServiceAdapter(stateServiceResult.Unwrap()) +// newBuildQueue initializes d.buildQueue with the adapter wrapping the +// canonical BuildService, then configures the retry policy from +// d.config.Build. +func (d *Daemon) newBuildQueue(svc build.BuildService) { + adapter := NewBuildServiceAdapter(svc) + d.buildQueue = NewBuildQueue( + d.config.Daemon.Sync.QueueSize, + d.config.Daemon.Sync.ConcurrentBuilds, + adapter, + ) + // Configure retry policy from build config (recorder injection handled elsewhere if added later). + d.buildQueue.ConfigureRetry(d.config.Build) +} - // Initialize event store and build history projection (Phase B - Event Sourcing) +// newEventStore initializes d.eventStore, d.buildProjection, and +// d.eventEmitter from a SQLite-backed store at stateDir/events.db, then +// rebuilds the build-history projection from any existing events. +// +// A failure to rebuild is logged but non-fatal: the projection starts +// empty and fills in as new events arrive. +func (d *Daemon) newEventStore(stateDir string) error { eventStorePath := filepath.Join(stateDir, "events.db") eventStore, err := eventstore.NewSQLiteStore(eventStorePath) if err != nil { - return nil, fmt.Errorf("failed to create event store: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to create event store").Build() } - daemon.eventStore = eventStore - daemon.buildProjection = eventstore.NewBuildHistoryProjection(eventStore, 100) - daemon.eventEmitter = NewEventEmitter(eventStore, daemon.buildProjection) - daemon.eventEmitter.daemon = daemon // Wire back reference for hooks + d.eventStore = eventStore + d.buildProjection = eventstore.NewBuildHistoryProjection(eventStore, 100) + d.eventEmitter = NewEventEmitter(eventStore, d.buildProjection) + d.eventEmitter.daemon = d // Wire back reference for hooks (e.g. link verification). - // Rebuild projection from existing events - if rebuildErr := daemon.buildProjection.Rebuild(context.Background()); rebuildErr != nil { + if rebuildErr := d.buildProjection.Rebuild(context.Background()); rebuildErr != nil { slog.Warn("Failed to rebuild build history projection", logfields.Error(rebuildErr)) - // Non-fatal: projection will start empty - } - - // Initialize livereload hub (opt-in) - if cfg.Build.LiveReload { - daemon.liveReload = NewLiveReloadHub(daemon.metrics) - slog.Info("LiveReload hub initialized") + // Non-fatal: projection will start empty. } + return nil +} - // Initialize HTTP server wiring (extracted package) +// collectHTTPInputs builds the per-forge webhook configs and forge clients +// maps consumed by newHTTPServer. Forge clients are sourced from +// d.forgeManager (set by newForgeManager). +func (d *Daemon) collectHTTPInputs() (map[string]*config.WebhookConfig, map[string]forge.Client) { webhookConfigs := make(map[string]*config.WebhookConfig) - for _, forgeCfg := range cfg.Forges { + for _, forgeCfg := range d.config.Forges { if forgeCfg == nil { continue } @@ -215,86 +305,107 @@ func NewDaemonWithConfigFile(cfg *config.Config, configFilePath string) (*Daemon } } forgeClients := make(map[string]forge.Client) - if daemon.forgeManager != nil { - maps.Copy(forgeClients, daemon.forgeManager.GetAllForges()) + if d.forgeManager != nil { + maps.Copy(forgeClients, d.forgeManager.GetAllForges()) } + return webhookConfigs, forgeClients +} + +// newHTTPServer initializes d.httpServer with the given webhook configs and +// forge clients. Other wiring (status page, enhanced health, detailed +// metrics, prometheus, triggers, metrics source) is sourced from d. +func (d *Daemon) newHTTPServer(webhookConfigs map[string]*config.WebhookConfig, forgeClients map[string]forge.Client) { var detailedMetrics http.HandlerFunc - if daemon.metrics != nil { - detailedMetrics = daemon.metrics.MetricsHandler + if d.metrics != nil { + detailedMetrics = d.metrics.MetricsHandler } - statusHandlers := handlers.NewStatusPageHandlers(daemon) - daemon.httpServer = httpserver.New(cfg, daemon, httpserver.Options{ + statusHandlers := handlers.NewStatusPageHandlers(d) + d.httpServer = httpserver.New(d.config, d, httpserver.Options{ ForgeClients: forgeClients, WebhookConfigs: webhookConfigs, - LiveReloadHub: daemon.liveReload, - EnhancedHealthHandle: daemon.EnhancedHealthHandler, + LiveReloadHub: d.liveReload, + EnhancedHealthHandle: d.EnhancedHealthHandler, DetailedMetricsHandle: detailedMetrics, PrometheusHandler: prometheusOptionalHandler(), StatusHandle: statusHandlers.HandleStatusPage, + Triggers: d, + Metrics: d, }) +} - // Initialize link verification service if enabled - if cfg.Daemon.LinkVerification != nil && cfg.Daemon.LinkVerification.Enabled { - linkVerifier, linkVerifierErr := linkverify.NewVerificationService(cfg.Daemon.LinkVerification) - if linkVerifierErr != nil { - slog.Warn("Failed to initialize link verification service", - logfields.Error(linkVerifierErr), - slog.Bool("enabled", false)) - } else { - daemon.linkVerifier = linkVerifier - slog.Info("Link verification service initialized", - "nats_url", cfg.Daemon.LinkVerification.NATSURL, - "kv_bucket", cfg.Daemon.LinkVerification.KVBucket) - } +// initLinkVerifier initializes d.linkVerifier when link verification is +// enabled. A failure to initialize is logged but non-fatal: the daemon can +// still run without link verification. +func (d *Daemon) initLinkVerifier() { + cfg := d.config.Daemon.LinkVerification + lv, err := linkverify.NewVerificationService(cfg) + if err != nil { + slog.Warn("Failed to initialize link verification service", + logfields.Error(err), + slog.Bool("enabled", false)) + return } + d.linkVerifier = lv + slog.Info("Link verification service initialized", + "nats_url", cfg.NATSURL, + "kv_bucket", cfg.KVBucket) +} - // Wire up event emitter for build queue (Phase B) - daemon.buildQueue.SetEventEmitter(daemon.eventEmitter) - - // Initialize discovery runner (Phase H - extracted component) - daemon.discoveryRunner = NewDiscoveryRunner(DiscoveryRunnerConfig{ - Discovery: daemon.discovery, - ForgeManager: daemon.forgeManager, - DiscoveryCache: daemon.discoveryCache, - Metrics: daemon.metrics, - StateManager: daemon.stateManager, - BuildRequester: daemon.onDiscoveryBuildRequest, - RepoRemoved: daemon.onDiscoveryRepoRemoved, - LiveReload: daemon.liveReload, - Config: cfg, +// newDiscoveryRunner initializes d.discoveryRunner with the Phase H extracted +// component. It depends on d.discovery, d.discoveryCache, d.metrics, +// d.stateManager, d.config, and the daemon's discovery callbacks. +func (d *Daemon) newDiscoveryRunner() { + d.discoveryRunner = NewDiscoveryRunner(DiscoveryRunnerConfig{ + Discovery: d.discovery, + DiscoveryCache: d.discoveryCache, + Metrics: d.metrics, + StateManager: d.stateManager, + BuildRequester: d.onDiscoveryBuildRequest, + RepoRemoved: d.onDiscoveryRepoRemoved, + Config: d.config, }) +} - // Initialize build debouncer (ADR-021 Phase 2). - // Note: this is passive until components start publishing BuildRequested events. +// newBuildDebouncer initializes d.buildDebouncer with the parsed debounce +// durations and a callback that checks whether the build queue is busy. +// +// The debouncer is passive until components start publishing +// BuildRequested events onto d.orchestrationBus. +func (d *Daemon) newBuildDebouncer(cfg *config.Config) error { quietWindow, maxDelay, err := getBuildDebounceDurations(cfg) if err != nil { - return nil, err + return err } - debouncer, err := NewBuildDebouncer(daemon.orchestrationBus, BuildDebouncerConfig{ + debouncer, err := NewBuildDebouncer(d.orchestrationBus, BuildDebouncerConfig{ QuietWindow: quietWindow, MaxDelay: maxDelay, - Metrics: daemon.metrics, + Metrics: d.metrics, CheckBuildRunning: func() bool { - if daemon.buildQueue == nil { + if d.buildQueue == nil { return false } - return len(daemon.buildQueue.GetActiveJobs()) > 0 + return len(d.buildQueue.GetActiveJobs()) > 0 }, }) if err != nil { - return nil, fmt.Errorf("failed to create build debouncer: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to create build debouncer").Build() } - daemon.buildDebouncer = debouncer + d.buildDebouncer = debouncer + return nil +} - remoteCache, cacheErr := git.NewRemoteHeadCache(cfg.Daemon.Storage.RepoCacheDir) +// newRepoUpdater initializes the git client and d.repoUpdater. The git +// client is wired with a remote-HEAD cache rooted at the configured repo +// cache directory; if persistence fails the daemon logs a warning and +// falls back to an in-memory cache. +func (d *Daemon) newRepoUpdater() { + remoteCache, cacheErr := git.NewRemoteHeadCache(d.config.Daemon.Storage.RepoCacheDir) if cacheErr != nil { slog.Warn("Failed to initialize remote HEAD cache; disabling persistence", logfields.Error(cacheErr)) remoteCache, _ = git.NewRemoteHeadCache("") } - gitClient := git.NewClient(cfg.Daemon.Storage.RepoCacheDir).WithRemoteHeadCache(remoteCache) - daemon.repoUpdater = NewRepoUpdater(daemon.orchestrationBus, gitClient, remoteCache, daemon.currentReposForOrchestratedBuild) - - return daemon, nil + gitClient := git.NewClient(d.config.Daemon.Storage.RepoCacheDir).WithRemoteHeadCache(remoteCache) + d.repoUpdater = NewRepoUpdater(d.orchestrationBus, gitClient, remoteCache, d.currentReposForOrchestratedBuild) } func getBuildDebounceDurations(cfg *config.Config) (time.Duration, time.Duration, error) { @@ -306,14 +417,14 @@ func getBuildDebounceDurations(cfg *config.Config) (time.Duration, time.Duration if v := strings.TrimSpace(cfg.Daemon.BuildDebounce.QuietWindow); v != "" { parsed, err := time.ParseDuration(v) if err != nil { - return 0, 0, fmt.Errorf("failed to parse daemon.build_debounce.quiet_window: %w", err) + return 0, 0, derrors.WrapError(err, derrors.CategoryConfig, "failed to parse daemon.build_debounce.quiet_window").Build() } quietWindow = parsed } if v := strings.TrimSpace(cfg.Daemon.BuildDebounce.MaxDelay); v != "" { parsed, err := time.ParseDuration(v) if err != nil { - return 0, 0, fmt.Errorf("failed to parse daemon.build_debounce.max_delay: %w", err) + return 0, 0, derrors.WrapError(err, derrors.CategoryConfig, "failed to parse daemon.build_debounce.max_delay").Build() } maxDelay = parsed } @@ -325,13 +436,67 @@ func getBuildDebounceDurations(cfg *config.Config) (time.Duration, time.Duration var defaultDaemonInstance *Daemon // Start starts the daemon and all its components. +// +// The start sequence is split into per-subsystem helpers (startCore, +// startHTTP, startBuildQueue, startScheduler, startPostSchedule). Start +// itself acquires d.mu once, drives the helpers, releases the lock, then +// blocks on mainLoop. Error paths (StatusError + runCancel) are visible +// in Start's body rather than buried in helpers so the bookkeeping stays +// in one place. func (d *Daemon) Start(ctx context.Context) error { d.mu.Lock() if d.GetStatus() != StatusStopped { d.mu.Unlock() - return fmt.Errorf("daemon is not in stopped state: %s", d.GetStatus()) + return derrors.NewError(derrors.CategoryValidation, "daemon is not in stopped state: "+d.GetStatus()).Build() + } + + runCtx, runCancel := d.startCore(ctx) + + if err := d.startHTTP(runCtx); err != nil { + d.status.Store(StatusError) + d.runCancel = nil + if runCancel != nil { + runCancel() + } + d.mu.Unlock() + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to start HTTP server").Build() + } + + d.startBuildQueue(runCtx) + d.startWorkers(runCtx) + + if err := d.startScheduler(ctx, runCtx); err != nil { + d.status.Store(StatusError) + if d.runCancel != nil { + d.runCancel() + d.runCancel = nil + } + d.mu.Unlock() + return derrors.WrapError(err, derrors.CategoryInternal, "failed to schedule daemon jobs").Build() } + d.startPostSchedule() + d.mu.Unlock() + + // Run main daemon loop (blocks until stopped) + d.mainLoop(runCtx) + + // When mainLoop exits, we're stopping + d.status.Store(StatusStopping) + slog.Info("Main loop exited, daemon stopping") + + return nil +} + +// startCore moves the daemon from StatusStopped into the in-progress start +// sequence: status, start time, initial metrics, the global metrics +// reference for the optional Prometheus bridge, the "starting" log line, +// state load (warn-only), the run context, and workers.Reset. +// +// The runCtx is returned to the caller; runCancel is also returned AND +// stored on d.runCancel so Stop can cancel it from outside. Callers must +// hold d.mu (Start does, then releases it before mainLoop). +func (d *Daemon) startCore(ctx context.Context) (context.Context, context.CancelFunc) { d.status.Store(StatusStarting) d.startTime = time.Now() @@ -352,35 +517,41 @@ func (d *Daemon) Start(ctx context.Context) error { runCtx, runCancel := context.WithCancel(ctx) d.runCancel = runCancel d.workers.Reset() + return runCtx, runCancel +} - // Start HTTP servers - if err := d.httpServer.Start(runCtx); err != nil { - d.status.Store(StatusError) - d.runCancel = nil - runCancel() - d.mu.Unlock() - return fmt.Errorf("failed to start HTTP server: %w", err) - } +// startHTTP starts the HTTP server. Returns the underlying error; the +// caller is responsible for the error-path bookkeeping (StatusError, +// d.runCancel = nil, runCancel, mu release). +func (d *Daemon) startHTTP(runCtx context.Context) error { + return d.httpServer.Start(runCtx) +} - // Start build queue processing +// startBuildQueue starts the build queue processing. The queue has no +// error return; failures are surfaced via the orchestration bus. +func (d *Daemon) startBuildQueue(runCtx context.Context) { d.buildQueue.Start(runCtx) +} - d.startWorkers(runCtx) - - // Schedule periodic daemon work (cron/duration jobs) before starting the scheduler. +// startScheduler registers the periodic jobs (cron + intervals) and +// starts the scheduler loop. The scheduler loop is bound to parentCtx +// (matching the pre-refactor semantics), while the registered jobs +// capture runCtx so they are canceled on Stop's runCancel. +// +// Returns any error from schedulePeriodicJobs; the caller is responsible +// for the error-path bookkeeping. +func (d *Daemon) startScheduler(parentCtx, runCtx context.Context) error { if err := d.schedulePeriodicJobs(runCtx); err != nil { - d.status.Store(StatusError) - if d.runCancel != nil { - d.runCancel() - d.runCancel = nil - } - d.mu.Unlock() - return fmt.Errorf("failed to schedule daemon jobs: %w", err) + return err } + d.scheduler.Start(parentCtx) + return nil +} - // Start scheduler - d.scheduler.Start(ctx) - +// startPostSchedule marks the daemon as Running, updates the success +// metrics, and emits the operator-facing summary logs (forge/port counts +// + storage path resolution). Callers must hold d.mu. +func (d *Daemon) startPostSchedule() { d.status.Store(StatusRunning) d.metrics.SetGauge("daemon_status", int64(2)) // 2 = running d.metrics.IncrementCounter("daemon_successful_starts") @@ -423,18 +594,6 @@ func (d *Daemon) Start(ctx context.Context) error { slog.String("repo_cache_dir", repoCache), slog.String("workspace_resolved", wsPredict), slog.String("clone_strategy", string(strategy))) - - // Release lock before entering long-running loop to avoid blocking read operations (e.g., /status) - d.mu.Unlock() - - // Run main daemon loop (blocks until stopped) - d.mainLoop(runCtx) - - // When mainLoop exits, we're stopping - d.status.Store(StatusStopping) - slog.Info("Main loop exited, daemon stopping") - - return nil } func (d *Daemon) schedulePeriodicJobs(ctx context.Context) error { @@ -499,7 +658,7 @@ func (d *Daemon) runScheduledSyncTick(ctx context.Context, expression string) { if d.discoveryRunner == nil { slog.Warn("Skipping scheduled discovery: discovery runner not initialized") } else { - workCtx, cancel := d.stopAwareContext(ctx) + workCtx, cancel := lifecycle.StopAwareContext(ctx, d.stopChan) defer cancel() d.discoveryRunner.SafeRun(workCtx, func() bool { return d.GetStatus() == StatusRunning }) } @@ -526,8 +685,26 @@ func (d *Daemon) Stop(ctx context.Context) error { d.status.Store(StatusStopping) slog.Info("Stopping DocBuilder daemon") + d.mu.Unlock() + + d.stopSubsystems(ctx) + + d.mu.Lock() + d.status.Store(StatusStopped) + d.mu.Unlock() + + uptime := time.Since(d.startTime) + slog.Info("DocBuilder daemon stopped", slog.Duration("uptime", uptime)) - // Snapshot pointers so we can stop without holding the daemon mutex. + return nil +} + +// stopSubsystems performs the orderly shutdown of every subsystem in +// reverse-construction order. Pointers are snapshotted under d.mu so the +// rest of the shutdown runs without holding the daemon mutex (matching +// the pre-refactor pattern: snapshot, release mu, operate on snapshots). +func (d *Daemon) stopSubsystems(ctx context.Context) { + d.mu.Lock() runCancel := d.runCancel d.runCancel = nil stopChan := d.stopChan @@ -605,15 +782,6 @@ func (d *Daemon) Stop(ctx context.Context) error { if err := d.workers.StopAndWait(ctx); err != nil { slog.Warn("Timed out waiting for daemon workers to stop", logfields.Error(err)) } - - d.mu.Lock() - d.status.Store(StatusStopped) - d.mu.Unlock() - - uptime := time.Since(d.startTime) - slog.Info("DocBuilder daemon stopped", slog.Duration("uptime", uptime)) - - return nil } // GetStatus returns the current daemon status. @@ -640,5 +808,15 @@ func (d *Daemon) GetStartTime() time.Time { return d.startTime } -// Compile-time check that Daemon implements BuildEventEmitter. -var _ BuildEventEmitter = (*Daemon)(nil) +// Compile-time assertions that *Daemon implements the optional httpserver +// runtime surfaces supplied to httpserver.New(cfg, daemon, opts). +var ( + _ httpserver.Status = (*Daemon)(nil) + _ httpserver.Triggers = (*Daemon)(nil) + _ httpserver.MetricsSource = (*Daemon)(nil) +) + +// BuildEventEmitter is implemented by *EventEmitter; see event_emitter.go. +// Daemon no longer claims to implement it (the methods were pure +// delegates to d.eventEmitter). Wire BuildQueue with d.eventEmitter +// directly. diff --git a/internal/daemon/daemon_events.go b/internal/daemon/daemon_events.go index 902005ae..a8d93edc 100644 --- a/internal/daemon/daemon_events.go +++ b/internal/daemon/daemon_events.go @@ -3,7 +3,6 @@ package daemon import ( "context" "log/slog" - "time" "git.home.luguber.info/inful/docbuilder/internal/eventstore" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" @@ -24,30 +23,6 @@ func (d *Daemon) EmitBuildEvent(ctx context.Context, event eventstore.Event) err return d.eventEmitter.EmitEvent(ctx, event) } -// EmitBuildStarted implements BuildEventEmitter for the daemon. -func (d *Daemon) EmitBuildStarted(ctx context.Context, buildID string, meta eventstore.BuildStartedMeta) error { - if d.eventEmitter == nil { - return nil - } - return d.eventEmitter.EmitBuildStarted(ctx, buildID, meta) -} - -// EmitBuildCompleted implements BuildEventEmitter for the daemon. -func (d *Daemon) EmitBuildCompleted(ctx context.Context, buildID string, duration time.Duration, artifacts map[string]string) error { - if d.eventEmitter == nil { - return nil - } - return d.eventEmitter.EmitBuildCompleted(ctx, buildID, duration, artifacts) -} - -// EmitBuildFailed implements BuildEventEmitter for the daemon. -func (d *Daemon) EmitBuildFailed(ctx context.Context, buildID, stage, errorMsg string) error { - if d.eventEmitter == nil { - return nil - } - return d.eventEmitter.EmitBuildFailed(ctx, buildID, stage, errorMsg) -} - // onBuildReportEmitted is called after a build report is emitted to the event store. // This is where we trigger post-build hooks like link verification and state updates. func (d *Daemon) onBuildReportEmitted(ctx context.Context, buildID string, report *models.BuildReport) error { @@ -83,13 +58,3 @@ func (d *Daemon) onBuildReportEmitted(ctx context.Context, buildID string, repor return nil } - -// EmitBuildReport implements BuildEventEmitter for the daemon (legacy/compatibility). -// This is now handled by EventEmitter calling onBuildReportEmitted. -func (d *Daemon) EmitBuildReport(ctx context.Context, buildID string, report *models.BuildReport) error { - // Delegate to event emitter which will call back to onBuildReportEmitted. - if d.eventEmitter == nil { - return nil - } - return d.eventEmitter.EmitBuildReport(ctx, buildID, report) -} diff --git a/internal/daemon/daemon_loop.go b/internal/daemon/daemon_loop.go index 573b521c..321e19c0 100644 --- a/internal/daemon/daemon_loop.go +++ b/internal/daemon/daemon_loop.go @@ -9,6 +9,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/daemon/events" + "git.home.luguber.info/inful/docbuilder/internal/daemon/lifecycle" "git.home.luguber.info/inful/docbuilder/internal/logfields" ) @@ -32,7 +33,7 @@ func (d *Daemon) mainLoop(ctx context.Context) { slog.Info("Main loop stopped by stop signal") return case <-initialDiscoveryTimer.C: - workCtx, cancel := d.stopAwareContext(ctx) + workCtx, cancel := lifecycle.StopAwareContext(ctx, d.stopChan) d.goWorker("initial_discovery", func() { defer cancel() d.discoveryRunner.SafeRun(workCtx, func() bool { return d.GetStatus() == StatusRunning }) diff --git a/internal/daemon/daemon_postbuild.go b/internal/daemon/daemon_postbuild.go index 474c0f93..d17e1631 100644 --- a/internal/daemon/daemon_postbuild.go +++ b/internal/daemon/daemon_postbuild.go @@ -5,7 +5,6 @@ import ( // #nosec G501 -- MD5 used for content change detection, not cryptographic security "crypto/md5" "encoding/hex" - "fmt" "log/slog" "os" "path/filepath" @@ -15,6 +14,7 @@ import ( ggit "github.com/go-git/go-git/v5" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" "git.home.luguber.info/inful/docbuilder/internal/linkverify" ) @@ -205,7 +205,9 @@ func (d *Daemon) collectPageMetadata(buildID string) ([]*linkverify.PageMetadata return nil }) if err != nil { - return nil, fmt.Errorf("failed to walk public directory %s: %w", publicDir, err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to walk public directory"). + WithContext("path", publicDir). + Build() } slog.Debug("Collected page metadata for link verification", diff --git a/internal/daemon/daemon_scheduled_sync_tick_test.go b/internal/daemon/daemon_scheduled_sync_tick_test.go index 66d49ef5..c67bfc99 100644 --- a/internal/daemon/daemon_scheduled_sync_tick_test.go +++ b/internal/daemon/daemon_scheduled_sync_tick_test.go @@ -6,45 +6,14 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "git.home.luguber.info/inful/docbuilder/internal/build/queue" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/forge" "git.home.luguber.info/inful/docbuilder/internal/forge/discoveryrunner" - "github.com/stretchr/testify/require" ) -type fakeDiscovery struct { - result *forge.DiscoveryResult -} - -func (f *fakeDiscovery) DiscoverAll(ctx context.Context) (*forge.DiscoveryResult, error) { - return f.result, nil -} - -type blockingDiscovery struct{} - -func (b *blockingDiscovery) DiscoverAll(ctx context.Context) (*forge.DiscoveryResult, error) { - <-ctx.Done() - return nil, ctx.Err() -} - -func (b *blockingDiscovery) ConvertToConfigRepositories(repos []*forge.Repository, forgeManager *forge.Manager) []config.Repository { - return nil -} - -func (f *fakeDiscovery) ConvertToConfigRepositories(repos []*forge.Repository, forgeManager *forge.Manager) []config.Repository { - converted := make([]config.Repository, 0, len(repos)) - for _, repo := range repos { - converted = append(converted, config.Repository{ - Name: repo.Name, - URL: repo.CloneURL, - Branch: repo.DefaultBranch, - Paths: []string{"docs"}, - }) - } - return converted -} - type fakeBuildQueue struct { mu sync.Mutex jobs []*queue.BuildJob @@ -73,12 +42,9 @@ func TestDaemon_runScheduledSyncTick(t *testing.T) { fakeQ := &fakeBuildQueue{} runner := NewDiscoveryRunner(DiscoveryRunnerConfig{ Discovery: &fakeDiscovery{result: &forge.DiscoveryResult{Repositories: []*forge.Repository{{Name: "repo-1", CloneURL: "https://example.invalid/repo-1.git", DefaultBranch: "main"}}}}, - ForgeManager: nil, DiscoveryCache: discoveryrunner.NewCache(), Metrics: nil, StateManager: nil, - BuildQueue: fakeQ, - LiveReload: nil, Config: cfg, Now: func() time.Time { return time.Unix(123, 0).UTC() }, NewJobID: func() string { return "job-1" }, @@ -89,45 +55,10 @@ func TestDaemon_runScheduledSyncTick(t *testing.T) { d.status.Store(StatusStopped) d.runScheduledSyncTick(context.Background(), "0 */4 * * *") - require.Len(t, fakeQ.Jobs(), 0) - }) - - t.Run("runs discovery and enqueues discovery build", func(t *testing.T) { - cfg := &config.Config{ - Daemon: &config.DaemonConfig{Sync: config.SyncConfig{Schedule: "0 */4 * * *"}}, - Forges: []*config.ForgeConfig{{Name: "forge-1", Type: config.ForgeForgejo}}, - } - - fakeQ := &fakeBuildQueue{} - runner := NewDiscoveryRunner(DiscoveryRunnerConfig{ - Discovery: &fakeDiscovery{result: &forge.DiscoveryResult{Repositories: []*forge.Repository{{ - Name: "repo-1", - CloneURL: "https://example.invalid/repo-1.git", - DefaultBranch: "main", - }}}}, - ForgeManager: nil, - DiscoveryCache: discoveryrunner.NewCache(), - Metrics: nil, - StateManager: nil, - BuildQueue: fakeQ, - LiveReload: nil, - Config: cfg, - Now: func() time.Time { return time.Unix(123, 0).UTC() }, - NewJobID: func() string { return "job-1" }, - }) - - d := &Daemon{config: cfg, discoveryRunner: runner} - d.stopChan = make(chan struct{}) - d.status.Store(StatusRunning) - - d.runScheduledSyncTick(context.Background(), "0 */4 * * *") - - jobs := fakeQ.Jobs() - require.Len(t, jobs, 1) - require.Equal(t, "job-1", jobs[0].ID) - require.Equal(t, queue.BuildTypeDiscovery, jobs[0].Type) - require.Equal(t, 1, len(jobs[0].TypedMeta.Repositories)) - require.Equal(t, "repo-1", jobs[0].TypedMeta.Repositories[0].Name) + // The runner has no BuildRequester wired, so the build is not + // enqueued. The discovery itself was skipped because the daemon + // isn't running. + require.Empty(t, fakeQ.Jobs()) }) t.Run("scheduler starts and stops cleanly with scheduled jobs", func(t *testing.T) { @@ -150,15 +81,11 @@ func TestDaemon_runScheduledSyncTick(t *testing.T) { Forges: []*config.ForgeConfig{{Name: "forge-1", Type: config.ForgeForgejo}}, } - fakeQ := &fakeBuildQueue{} runner := NewDiscoveryRunner(DiscoveryRunnerConfig{ Discovery: &blockingDiscovery{}, - ForgeManager: nil, DiscoveryCache: discoveryrunner.NewCache(), Metrics: nil, StateManager: nil, - BuildQueue: fakeQ, - LiveReload: nil, Config: cfg, }) @@ -181,3 +108,26 @@ func TestDaemon_runScheduledSyncTick(t *testing.T) { } }) } + +type fakeDiscovery struct { + result *forge.DiscoveryResult +} + +func (f *fakeDiscovery) DiscoverAll(_ context.Context) (*forge.DiscoveryResult, error) { + return f.result, nil +} + +func (f *fakeDiscovery) ConvertToConfigRepositories(_ []*forge.Repository, _ *forge.Manager) []config.Repository { + return nil +} + +type blockingDiscovery struct{} + +func (b *blockingDiscovery) DiscoverAll(ctx context.Context) (*forge.DiscoveryResult, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func (b *blockingDiscovery) ConvertToConfigRepositories(_ []*forge.Repository, _ *forge.Manager) []config.Repository { + return nil +} diff --git a/internal/daemon/delta_compat.go b/internal/daemon/delta_compat.go deleted file mode 100644 index 97aa3202..00000000 --- a/internal/daemon/delta_compat.go +++ /dev/null @@ -1,28 +0,0 @@ -package daemon - -import ( - "git.home.luguber.info/inful/docbuilder/internal/build/delta" -) - -// Type aliases for backward compatibility with daemon code that uses delta analysis. -// The canonical implementation is now in internal/build/delta/. -type ( - DeltaDecision = delta.DeltaDecision - DeltaPlan = delta.DeltaPlan - DeltaStateAccess = delta.DeltaStateAccess - DeltaAnalyzer = delta.DeltaAnalyzer -) - -// Re-export constants. -const ( - DeltaDecisionFull = delta.DeltaDecisionFull - DeltaDecisionPartial = delta.DeltaDecisionPartial - RepoReasonUnknown = delta.RepoReasonUnknown - RepoReasonQuickHashDiff = delta.RepoReasonQuickHashDiff - RepoReasonUnchanged = delta.RepoReasonUnchanged -) - -// NewDeltaAnalyzer is a thin wrapper for backward compatibility. -func NewDeltaAnalyzer(st DeltaStateAccess) *DeltaAnalyzer { - return delta.NewDeltaAnalyzer(st) -} diff --git a/internal/daemon/delta_manager.go b/internal/daemon/delta_manager.go deleted file mode 100644 index 86682dcb..00000000 --- a/internal/daemon/delta_manager.go +++ /dev/null @@ -1,48 +0,0 @@ -package daemon - -import ( - "git.home.luguber.info/inful/docbuilder/internal/build/delta" - cfg "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" - "git.home.luguber.info/inful/docbuilder/internal/state" -) - -// deltaManager is kept as a thin compatibility wrapper. -// The canonical implementation lives in internal/build/delta. -type deltaManager struct { - inner *delta.Manager -} - -// NewDeltaManager is kept for backward compatibility. -func NewDeltaManager() *deltaManager { return &deltaManager{inner: delta.NewManager()} } - -// AttachDeltaMetadata is kept for backward compatibility with existing daemon tests/callers. -func (dm *deltaManager) AttachDeltaMetadata(report *models.BuildReport, deltaPlan *DeltaPlan, job *BuildJob) { - var reasons map[string]string - if job != nil && job.TypedMeta != nil { - reasons = job.TypedMeta.DeltaRepoReasons - } - if dm == nil || dm.inner == nil { - return - } - dm.inner.AttachDeltaMetadata(report, deltaPlan, reasons) -} - -// RecomputeGlobalDocHash is kept for backward compatibility with existing daemon tests/callers. -func (dm *deltaManager) RecomputeGlobalDocHash( - report *models.BuildReport, - deltaPlan *DeltaPlan, - meta state.RepositoryMetadataStore, - job *BuildJob, - workspace string, - cfgAny *cfg.Config, -) (int, error) { - var repos []cfg.Repository - if job != nil && job.TypedMeta != nil { - repos = job.TypedMeta.Repositories - } - if dm == nil || dm.inner == nil { - return 0, nil - } - return dm.inner.RecomputeGlobalDocHash(report, deltaPlan, meta, repos, workspace, cfgAny) -} diff --git a/internal/daemon/discovery_aliases.go b/internal/daemon/discovery_aliases.go deleted file mode 100644 index 05709e55..00000000 --- a/internal/daemon/discovery_aliases.go +++ /dev/null @@ -1,4 +0,0 @@ -package daemon - -// Deprecated: discovery aliases are now defined in -// internal/daemon/discovery_cache.go and internal/daemon/discovery_runner.go. diff --git a/internal/daemon/discovery_state_integration_test.go b/internal/daemon/discovery_state_integration_test.go index a381ce03..8124dd78 100644 --- a/internal/daemon/discovery_state_integration_test.go +++ b/internal/daemon/discovery_state_integration_test.go @@ -63,7 +63,7 @@ func TestDiscoveryStagePersistsPerRepoDocFilesHash(t *testing.T) { if svcResult.IsErr() { t.Fatalf("state service: %v", svcResult.UnwrapErr()) } - sm := state.NewServiceAdapter(svcResult.Unwrap()) + sm := svcResult.Unwrap() gen := hugo.NewGenerator(conf, outputDir).WithStateManager(sm).WithRenderer(&stages.NoopRenderer{}) ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) @@ -84,13 +84,13 @@ func TestDiscoveryStagePersistsPerRepoDocFilesHash(t *testing.T) { if rs.DocumentCount != 1 { t.Fatalf("expected document_count=1 got %d", rs.DocumentCount) } - if rs.DocFilesHash == "" { + if rs.DocFilesHash.IsNone() { t.Fatalf("expected non-empty doc_files_hash") } if report.DocFilesHash == "" { t.Fatalf("expected build report doc_files_hash set") } - if rs.DocFilesHash != report.DocFilesHash { - t.Fatalf("per-repo hash %s != report hash %s (single-repo build)", rs.DocFilesHash, report.DocFilesHash) + if rs.DocFilesHash.Unwrap() != report.DocFilesHash { + t.Fatalf("per-repo hash %s != report hash %s (single-repo build)", rs.DocFilesHash.Unwrap(), report.DocFilesHash) } } diff --git a/internal/daemon/event_emitter.go b/internal/daemon/event_emitter.go index 161f73ca..96231616 100644 --- a/internal/daemon/event_emitter.go +++ b/internal/daemon/event_emitter.go @@ -2,10 +2,10 @@ package daemon import ( "context" - "fmt" "time" "git.home.luguber.info/inful/docbuilder/internal/eventstore" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" ) @@ -34,7 +34,7 @@ func (e *EventEmitter) EmitEvent(ctx context.Context, event eventstore.Event) er // Persist to store if err := e.store.Append(ctx, event.BuildID(), event.Type(), event.Payload(), event.Metadata()); err != nil { - return fmt.Errorf("failed to persist event: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to persist event").Build() } // Update projection diff --git a/internal/daemon/http_server_prom.go b/internal/daemon/http_server_prom.go index 70a9f5b1..846e0214 100644 --- a/internal/daemon/http_server_prom.go +++ b/internal/daemon/http_server_prom.go @@ -7,8 +7,7 @@ import ( prom "github.com/prometheus/client_golang/prometheus" promcollect "github.com/prometheus/client_golang/prometheus/collectors" - - m "git.home.luguber.info/inful/docbuilder/internal/metrics" + promhttp "github.com/prometheus/client_golang/prometheus/promhttp" ) var ( @@ -91,5 +90,5 @@ func atomicStoreInt64(p *int64, v int64) { atomic.StoreInt64(p, v) } // prometheusOptionalHandler returns handler and periodically syncs daemon metrics. func prometheusOptionalHandler() http.Handler { registerBaseCollectors() - return m.HTTPHandler(promRegistry) + return promhttp.HandlerFor(promRegistry, promhttp.HandlerOpts{Registry: promRegistry}) } diff --git a/internal/daemon/scheduler.go b/internal/daemon/lifecycle/scheduler.go similarity index 65% rename from internal/daemon/scheduler.go rename to internal/daemon/lifecycle/scheduler.go index 8057ff94..e02e4b91 100644 --- a/internal/daemon/scheduler.go +++ b/internal/daemon/lifecycle/scheduler.go @@ -1,13 +1,22 @@ -package daemon +// Package lifecycle owns the daemon's lifecycle primitives: the cron-style +// Scheduler, the WorkerGroup that tracks background goroutines, and the +// StopAwareContext helper that lets long-running work be canceled when the +// daemon's stop channel closes. +// +// The package has no dependency on the rest of the daemon package. The daemon +// owns a single Scheduler and WorkerGroup instance and calls them through +// type aliases defined in internal/daemon/lifecycle_aliases.go. +package lifecycle import ( "context" "errors" - "fmt" "log/slog" "time" "github.com/go-co-op/gocron/v2" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // Scheduler wraps gocron scheduler for managing periodic tasks. @@ -19,7 +28,7 @@ type Scheduler struct { func NewScheduler() (*Scheduler, error) { s, err := gocron.NewScheduler() if err != nil { - return nil, fmt.Errorf("failed to create gocron scheduler: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to create gocron scheduler").Build() } return &Scheduler{ @@ -54,7 +63,7 @@ func (s *Scheduler) ScheduleEvery(name string, interval time.Duration, task func gocron.WithSingletonMode(gocron.LimitModeReschedule), ) if err != nil { - return "", fmt.Errorf("failed to create duration job: %w", err) + return "", derrors.WrapError(err, derrors.CategoryInternal, "failed to create duration job").Build() } return job.ID().String(), nil @@ -71,7 +80,7 @@ func (s *Scheduler) ScheduleCron(name, expression string, task func()) (string, gocron.WithSingletonMode(gocron.LimitModeReschedule), ) if err != nil { - return "", fmt.Errorf("failed to create cron job: %w", err) + return "", derrors.WrapError(err, derrors.CategoryInternal, "failed to create cron job").Build() } return job.ID().String(), nil diff --git a/internal/daemon/scheduler_test.go b/internal/daemon/lifecycle/scheduler_test.go similarity index 98% rename from internal/daemon/scheduler_test.go rename to internal/daemon/lifecycle/scheduler_test.go index 05311228..5a624d42 100644 --- a/internal/daemon/scheduler_test.go +++ b/internal/daemon/lifecycle/scheduler_test.go @@ -1,4 +1,4 @@ -package daemon +package lifecycle import ( "context" diff --git a/internal/daemon/stop_context.go b/internal/daemon/lifecycle/stop_context.go similarity index 52% rename from internal/daemon/stop_context.go rename to internal/daemon/lifecycle/stop_context.go index c27776f5..5ad0597c 100644 --- a/internal/daemon/stop_context.go +++ b/internal/daemon/lifecycle/stop_context.go @@ -1,23 +1,21 @@ -package daemon +package lifecycle -import ( - "context" -) +import "context" -// stopAwareContext returns a context that is canceled when either the parent -// context is done or the daemon stop channel is closed. +// StopAwareContext returns a context that is canceled when either the parent +// context is done or the supplied stop channel is closed. // -// This preserves the historical behavior where closing stopChan unblocks -// in-flight work even when the caller passes context.Background(). +// This preserves the historical behavior where closing the stop channel +// unblocks in-flight work even when the caller passes context.Background(). // // Callers MUST call the returned cancel func when the derived context is no // longer needed; otherwise the internal stop-listener goroutine may live for // the lifetime of the parent context. -func (d *Daemon) stopAwareContext(parent context.Context) (context.Context, context.CancelFunc) { +func StopAwareContext(parent context.Context, stop <-chan struct{}) (context.Context, context.CancelFunc) { if parent == nil { parent = context.Background() } - if d == nil || d.stopChan == nil { + if stop == nil { ctx, cancel := context.WithCancel(parent) return ctx, cancel } @@ -25,7 +23,7 @@ func (d *Daemon) stopAwareContext(parent context.Context) (context.Context, cont ctx, cancel := context.WithCancel(parent) go func() { select { - case <-d.stopChan: + case <-stop: cancel() case <-ctx.Done(): // parent canceled; nothing else to do diff --git a/internal/daemon/worker_group.go b/internal/daemon/lifecycle/worker_group.go similarity index 98% rename from internal/daemon/worker_group.go rename to internal/daemon/lifecycle/worker_group.go index 24f3c952..4f17ec4c 100644 --- a/internal/daemon/worker_group.go +++ b/internal/daemon/lifecycle/worker_group.go @@ -1,4 +1,4 @@ -package daemon +package lifecycle import ( "context" diff --git a/internal/daemon/lifecycle_aliases.go b/internal/daemon/lifecycle_aliases.go new file mode 100644 index 00000000..bcd23ba4 --- /dev/null +++ b/internal/daemon/lifecycle_aliases.go @@ -0,0 +1,26 @@ +package daemon + +// Type aliases for the lifecycle sub-package. The daemon owns a single +// Scheduler and WorkerGroup instance whose types live in the lifecycle +// sub-package; these aliases preserve the daemon package's existing +// field types and constructor signatures so call sites in this package +// and the test suite don't need to be rewritten during the carve. +// +// The aliases resolve to the same underlying types, so the existing +// field declarations (e.g. `scheduler *Scheduler`) continue to compile +// unchanged. + +import "git.home.luguber.info/inful/docbuilder/internal/daemon/lifecycle" + +type ( + Scheduler = lifecycle.Scheduler + WorkerGroup = lifecycle.WorkerGroup +) + +// NewScheduler delegates to the lifecycle package's NewScheduler. The +// wrapper keeps the daemon's existing constructor signature stable for +// the test suite (which calls daemon.NewScheduler directly) and for +// NewDaemonWithConfigFile's call site. +func NewScheduler() (*Scheduler, error) { + return lifecycle.NewScheduler() +} diff --git a/internal/daemon/livereload.go b/internal/daemon/livereload.go index a1b81d54..56740e50 100644 --- a/internal/daemon/livereload.go +++ b/internal/daemon/livereload.go @@ -6,6 +6,8 @@ import ( "net/http" "sync" "time" + + "git.home.luguber.info/inful/docbuilder/internal/build/queue" ) // LiveReloadHub manages SSE clients for hash-change broadcasts. @@ -199,3 +201,7 @@ const LiveReloadScript = `(() => { } connect(); })();` + +// Compile-time assertion that *LiveReloadHub satisfies the queue.LiveReloadHub +// contract consumed by the build pipeline. +var _ queue.LiveReloadHub = (*LiveReloadHub)(nil) diff --git a/internal/daemon/orchestrated_builds.go b/internal/daemon/orchestrated_builds.go index 5cb95e77..0dd292fe 100644 --- a/internal/daemon/orchestrated_builds.go +++ b/internal/daemon/orchestrated_builds.go @@ -61,7 +61,6 @@ func (d *Daemon) enqueueOrchestratedBuild(evt events.BuildNow) { V2Config: d.config, Repositories: reposForBuild, RepoSnapshot: evt.Snapshot, - StateManager: d.stateManager, LiveReloadHub: d.liveReload, } if evt.LastRepoURL != "" && evt.LastReason != "" { diff --git a/internal/daemon/orchestrated_repo_removals_test.go b/internal/daemon/orchestrated_repo_removals_test.go index f9a1d980..d4d42f88 100644 --- a/internal/daemon/orchestrated_repo_removals_test.go +++ b/internal/daemon/orchestrated_repo_removals_test.go @@ -37,7 +37,7 @@ func TestDaemon_handleRepoRemoved_PrunesStateAndCache(t *testing.T) { svcResult := state.NewService(tmp) require.True(t, svcResult.IsOk()) - sm := state.NewServiceAdapter(svcResult.Unwrap()) + sm := svcResult.Unwrap() sm.EnsureRepositoryState(repoURL, repoName, "main") require.NotNil(t, sm.GetRepository(repoURL)) @@ -67,7 +67,7 @@ func TestDaemon_handleRepoRemoved_DoesNotDeleteOutsideRepoCacheDir(t *testing.T) svcResult := state.NewService(tmp) require.True(t, svcResult.IsOk()) - sm := state.NewServiceAdapter(svcResult.Unwrap()) + sm := svcResult.Unwrap() d := &Daemon{ config: &config.Config{Daemon: &config.DaemonConfig{Storage: config.StorageConfig{RepoCacheDir: repoCacheDir}}}, stateManager: sm, @@ -92,7 +92,7 @@ func TestDaemon_handleRepoRemoved_DoesNotDeleteRepoCacheBaseDir(t *testing.T) { svcResult := state.NewService(tmp) require.True(t, svcResult.IsOk()) - sm := state.NewServiceAdapter(svcResult.Unwrap()) + sm := svcResult.Unwrap() d := &Daemon{ config: &config.Config{Daemon: &config.DaemonConfig{Storage: config.StorageConfig{RepoCacheDir: repoCacheDir}}}, diff --git a/internal/daemon/partial_global_hash_deletion_test.go b/internal/daemon/partial_global_hash_deletion_test.go deleted file mode 100644 index f1aed6d1..00000000 --- a/internal/daemon/partial_global_hash_deletion_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package daemon - -// NOTE: Legacy delta helper tests moved to internal/build/delta. diff --git a/internal/daemon/partial_global_hash_test.go b/internal/daemon/partial_global_hash_test.go deleted file mode 100644 index f1aed6d1..00000000 --- a/internal/daemon/partial_global_hash_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package daemon - -// NOTE: Legacy delta helper tests moved to internal/build/delta. diff --git a/internal/daemon/retry_flakiness_test.go b/internal/daemon/retry_flakiness_test.go deleted file mode 100644 index 25be0451..00000000 --- a/internal/daemon/retry_flakiness_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package daemon - -// Deprecated: tests moved to internal/build/queue. diff --git a/internal/daemon/skip_evaluator.go b/internal/daemon/skip_evaluator.go index c355279c..82a12755 100644 --- a/internal/daemon/skip_evaluator.go +++ b/internal/daemon/skip_evaluator.go @@ -15,15 +15,23 @@ type SkipStateAccess = validation.SkipStateAccess // SkipEvaluator decides whether a build can be safely skipped based on // persisted state + prior build report + filesystem probes. -// This is now a thin wrapper around the validation-based evaluator. +// This is a thin wrapper around the validation-based evaluator; it owns the +// responsibility of detecting the current Hugo version and forwarding it. type SkipEvaluator struct { validator *validation.SkipEvaluator } -// NewSkipEvaluator constructs a new evaluator. +// NewSkipEvaluator constructs a new evaluator. The current Hugo version is +// detected up front and passed into the validator; empty string when Hugo +// is unavailable (validation will treat that as "no Hugo used previously" +// if the previous report's HugoVersion is also empty). func NewSkipEvaluator(outDir string, st SkipStateAccess, gen *hugo.Generator) *SkipEvaluator { + hugoVersion := "" + if gen != nil { + hugoVersion = hugo.DetectHugoVersion(context.Background()) + } return &SkipEvaluator{ - validator: validation.NewSkipEvaluator(outDir, st, gen), + validator: validation.NewSkipEvaluator(outDir, st, gen, hugoVersion), } } diff --git a/internal/daemon/status_provider.go b/internal/daemon/status_provider.go index 8c8109a1..9b71d290 100644 --- a/internal/daemon/status_provider.go +++ b/internal/daemon/status_provider.go @@ -13,11 +13,12 @@ func (d *Daemon) GetConfigFilePath() string { return d.configFilePath } -// GetLastBuildTime returns the last successful build time (if any). +// GetLastBuildTime is part of the StatusProvider interface but the +// lastBuild field was removed (never written in production). Always +// returns nil; callers should treat this as "no successful build +// recorded yet" and fall back to other timestamps. func (d *Daemon) GetLastBuildTime() *time.Time { - d.mu.RLock() - defer d.mu.RUnlock() - return d.lastBuild + return nil } // GetLastDiscovery returns the last successful discovery time (if any). diff --git a/internal/docmodel/markdown.go b/internal/docmodel/markdown.go new file mode 100644 index 00000000..c37c81e4 --- /dev/null +++ b/internal/docmodel/markdown.go @@ -0,0 +1,67 @@ +package docmodel + +import ( + "path/filepath" + "slices" + "strings" +) + +// Markdown extension set, evaluated case-insensitively. We include the +// four extensions the discovery pipeline has historically accepted so +// a single helper can replace the three separate predicates that lived +// in discovery, lint, and the pipeline. StripMarkdownExt only acts on +// the .md / .markdown pair (the two real-world extensions); the +// rarer .mdown / .mkd forms are valid as input but never written by us. +var markdownExtensions = []string{".md", ".markdown", ".mdown", ".mkd"} + +// strippableMarkdownExtensions are the extensions StripMarkdownExt +// recognizes when removing a markdown suffix. The discovery-only +// extensions .mdown / .mkd are excluded because callers that strip +// them today do not exist; if one is added later, add it here. +var strippableMarkdownExtensions = []string{".md", ".markdown"} + +// IsMarkdownFile reports whether path ends with a known markdown +// extension (case-insensitive). The check is based on the lower-cased +// extension only — the rest of the path is left alone, so callers can +// pass relative paths, absolute paths, or paths with query/fragment +// markers. +// +// This consolidates: +// - docs.isMarkdownFile (case-insensitive, 4 extensions) +// - lint.IsDocFile (was case-sensitive, 2 extensions) +// - pipeline/transform_fingerprint inline check (case-insensitive, 1 extension) +// +// Lint now agrees with discovery on every case-sensitive input. Files +// such as README.MD on case-insensitive filesystems (macOS, Windows +// default) are no longer dropped on the floor by lint. +// +// Implementation note: filepath.Ext is used directly rather than a +// custom URL-fragment-stripping helper. Filenames like "tutorial#1.md" +// embed a literal '#' that we must NOT confuse with a URL fragment +// marker; filepath.Ext returns ".md" for both that filename and +// "tutorial#1.md#section", giving consistent results across the two. +func IsMarkdownFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return slices.Contains(markdownExtensions, ext) +} + +// StripMarkdownExt removes a trailing .md / .markdown extension from +// s if present. The case of the rest of the string is preserved: only +// the suffix case check is lower-cased. Returns s unchanged when no +// known extension is present. +// +// Consolidates: +// - lint/link_target_rewrite.stripKnownMarkdownExtension +// - pipeline/transform_links inline strip +// +// Both prior implementations were case-insensitive on the suffix and +// preserved the input casing elsewhere; this preserves that contract. +func StripMarkdownExt(s string) string { + lower := strings.ToLower(s) + for _, ext := range strippableMarkdownExtensions { + if strings.HasSuffix(lower, ext) { + return s[:len(s)-len(ext)] + } + } + return s +} diff --git a/internal/docmodel/markdown_test.go b/internal/docmodel/markdown_test.go new file mode 100644 index 00000000..db15c0ee --- /dev/null +++ b/internal/docmodel/markdown_test.go @@ -0,0 +1,82 @@ +package docmodel + +import "testing" + +// TestIsMarkdownFile exercises every case the three prior predicates +// disagreed on. The canonical answer (.md / .markdown / .mdown / .mkd +// in any case + a small set of negative inputs) is what discovery, +// lint, and the pipeline will all see going forward. +func TestIsMarkdownFile(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + // Positives on each recognized extension. + {name: "lowercase .md", path: "docs/readme.md", want: true}, + {name: "uppercase .MD", path: "docs/README.MD", want: true}, + {name: "mixed case .Md", path: "docs/ReadMe.Md", want: true}, + {name: "lowercase .markdown", path: "docs/page.markdown", want: true}, + {name: "uppercase .MARKDOWN", path: "docs/page.MARKDOWN", want: true}, + {name: ".mdown", path: "docs/page.mdown", want: true}, + {name: ".mkd", path: "docs/page.mkd", want: true}, + + // Negatives. + {name: "empty", path: "", want: false}, + {name: "no extension", path: "docs/readme", want: false}, + {name: "different extension", path: "docs/page.html", want: false}, + {name: "hidden dotfile", path: ".README.md", want: true}, + {name: "fragment in path is opaque to extension lookup", path: "docs/page.md#section", want: false}, + {name: "query in path is opaque", path: "docs/page.md?ref=main", want: false}, + // Go's filepath.Ext reports the dot before MD as the separator, + // so .MD (hidden file) is still a ".MD"-suffixed path. Keep this + // visible in the test so future refactors don't silently change it. + {name: "hidden .MD counts", path: "docs/.MD", want: true}, + {name: "double extension .MD.md", path: "docs/.MD.md", want: true}, + + // path-only basename matters, so a leading directory is irrelevant. + {name: "absolute / lowercase", path: "/tmp/dir/x.md", want: true}, + {name: "absolute / uppercase", path: "/tmp/dir/x.MD", want: true}, + // literal '#' inside the basename is preserved (filepath.Ext + // works from the right and ignores query/fragment conventions). + {name: "literal hash in filename", path: "tutorial#1.md", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsMarkdownFile(tt.path); got != tt.want { + t.Errorf("IsMarkdownFile(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +// TestStripMarkdownExt covers both extensions and non-matches. The +// casing of the body must be preserved. +func TestStripMarkdownExt(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "lowercase .md", in: "docs/readme.md", want: "docs/readme"}, + {name: "uppercase .MD", in: "docs/README.MD", want: "docs/README"}, + {name: "lowercase .markdown", in: "docs/page.markdown", want: "docs/page"}, + {name: "uppercase .MARKDOWN", in: "docs/page.MARKDOWN", want: "docs/page"}, + {name: "no extension", in: "docs/readme", want: "docs/readme"}, + {name: "different extension", in: "docs/page.html", want: "docs/page.html"}, + {name: "empty", in: "", want: ""}, + {name: "just .md", in: ".md", want: ""}, + {name: "just .markdown", in: ".markdown", want: ""}, + // .mdown / .mkd are recognized by IsMarkdownFile but not + // stripped (no caller strips them today). + {name: ".mdown not stripped", in: "page.mdown", want: "page.mdown"}, + {name: ".mkd not stripped", in: "page.mkd", want: "page.mkd"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StripMarkdownExt(tt.in); got != tt.want { + t.Errorf("StripMarkdownExt(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/docs/discovery.go b/internal/docs/discovery.go index a94cfcf1..2d238de0 100644 --- a/internal/docs/discovery.go +++ b/internal/docs/discovery.go @@ -11,6 +11,7 @@ import ( "strings" "git.home.luguber.info/inful/docbuilder/internal/config" + "git.home.luguber.info/inful/docbuilder/internal/docmodel" derrors "git.home.luguber.info/inful/docbuilder/internal/docs/errors" "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" @@ -354,10 +355,12 @@ func HugoContentPath(forge, group, repository, section, name, ext string, isSing return filepath.Join(parts...) } -// isMarkdownFile checks if a file is a markdown file. +// isMarkdownFile delegates to docmodel.IsMarkdownFile (case-insensitive, +// recognizing .md, .markdown, .mdown, .mkd). Kept as a package-level +// forward so the discovery pipeline doesn't grow an import edge to +// docmodel only at the call site; the helper is now a one-liner. func isMarkdownFile(filename string) bool { - ext := strings.ToLower(filepath.Ext(filename)) - return ext == markdownExtension || ext == ".markdown" || ext == ".mdown" || ext == ".mkd" + return docmodel.IsMarkdownFile(filename) } // isAsset checks if a file is an asset (image, etc.) diff --git a/internal/docs/taxonomy_snippets.go b/internal/docs/taxonomy_snippets.go index b9f6c269..f66b3a26 100644 --- a/internal/docs/taxonomy_snippets.go +++ b/internal/docs/taxonomy_snippets.go @@ -14,6 +14,8 @@ import ( "sort" "strings" "time" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) const taxonomyBaseURLEnvVar = "DOCBUILDER_TEMPLATE_BASE_URL" @@ -34,7 +36,9 @@ type taxonomiesResponse struct { func WriteVSCodeTaxonomySnippets(ctx context.Context, contentDir, snippetsPath string) error { tags, categories, err := taxonomyValuesForSnippets(ctx, contentDir) if err != nil { - return fmt.Errorf("collect taxonomies: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "collect taxonomies"). + WithContext("content_dir", contentDir). + Build() } snippets := make(map[string]vscodeSnippet, len(tags)+len(categories)) @@ -54,17 +58,23 @@ func WriteVSCodeTaxonomySnippets(ctx context.Context, contentDir, snippetsPath s } if mkErr := os.MkdirAll(filepath.Dir(snippetsPath), 0o750); mkErr != nil { - return fmt.Errorf("create snippets directory: %w", mkErr) + return derrors.WrapError(mkErr, derrors.CategoryFileSystem, "create snippets directory"). + WithContext("path", snippetsPath). + Build() } data, err := json.MarshalIndent(snippets, "", " ") if err != nil { - return fmt.Errorf("marshal snippets: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "marshal snippets"). + WithContext("path", snippetsPath). + Build() } data = append(data, '\n') if err := os.WriteFile(snippetsPath, data, 0o600); err != nil { - return fmt.Errorf("write snippets file: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "write snippets file"). + WithContext("path", snippetsPath). + Build() } return nil @@ -80,10 +90,12 @@ func taxonomyValuesForSnippets(ctx context.Context, contentDir string) ([]string func fetchTaxonomiesFromBaseURL(ctx context.Context, baseURL string) ([]string, []string, error) { parsed, err := url.Parse(baseURL) if err != nil { - return nil, nil, fmt.Errorf("parse base URL: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryValidation, "parse base URL").Build() } if parsed.Scheme != "http" && parsed.Scheme != "https" { - return nil, nil, fmt.Errorf("unsupported base URL scheme: %s", parsed.Scheme) + return nil, nil, derrors.NewError(derrors.CategoryValidation, "unsupported base URL scheme"). + WithContext("scheme", parsed.Scheme). + Build() } if parsed.Host == "" { return nil, nil, errors.New("base URL host is required") @@ -93,31 +105,33 @@ func fetchTaxonomiesFromBaseURL(ctx context.Context, baseURL string) ([]string, //nolint:gosec // URL comes from explicit user configuration via environment variable. req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) if err != nil { - return nil, nil, fmt.Errorf("build taxonomy request: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryNetwork, "build taxonomy request").Build() } client := &http.Client{Timeout: 4 * time.Second} //nolint:gosec // Target is restricted to validated http/https URL set by the local user. resp, err := client.Do(req) if err != nil { - return nil, nil, fmt.Errorf("fetch taxonomies: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryNetwork, "fetch taxonomies").Build() } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, nil, fmt.Errorf("fetch taxonomies: status %d", resp.StatusCode) + return nil, nil, derrors.NewError(derrors.CategoryNetwork, fmt.Sprintf("fetch taxonomies: status %d", resp.StatusCode)). + WithContext("status", resp.StatusCode). + Build() } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, fmt.Errorf("read taxonomy response: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryNetwork, "read taxonomy response").Build() } var payload taxonomiesResponse if err := json.Unmarshal(body, &payload); err != nil { - return nil, nil, fmt.Errorf("decode taxonomy response: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryValidation, "decode taxonomy response").Build() } tags := normalizeSnippetTaxonomyValues(payload.Tags) diff --git a/internal/doctemplate/app/model.go b/internal/doctemplate/app/model.go index 07293dd8..acf6f60d 100644 --- a/internal/doctemplate/app/model.go +++ b/internal/doctemplate/app/model.go @@ -21,6 +21,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/doctemplate/field" "git.home.luguber.info/inful/docbuilder/internal/doctemplate/service" "git.home.luguber.info/inful/docbuilder/internal/doctemplate/suggest" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" templating "git.home.luguber.info/inful/docbuilder/internal/templates" ) @@ -748,7 +749,9 @@ func collectData(fields []formField, defaults map[string]any) (map[string]any, e switch f.spec.Type { case templating.FieldTypeBool: if err := f.boolValue.Validate(); err != nil { - return nil, fmt.Errorf("%s: required boolean value is missing", f.spec.Key) + return nil, derrors.NewError(derrors.CategoryValidation, "required boolean value is missing"). + WithContext("field", f.spec.Key). + Build() } if value, ok := f.boolValue.Value(); ok { result[f.spec.Key] = value @@ -759,7 +762,9 @@ func collectData(fields []formField, defaults map[string]any) (map[string]any, e value := strings.TrimSpace(f.textValue) if value == "" { if f.spec.Required { - return nil, fmt.Errorf("%s: required value is missing", f.spec.Key) + return nil, derrors.NewError(derrors.CategoryValidation, "required value is missing"). + WithContext("field", f.spec.Key). + Build() } delete(result, f.spec.Key) continue @@ -769,20 +774,27 @@ func collectData(fields []formField, defaults map[string]any) (map[string]any, e value := strings.TrimSpace(f.textValue) if value == "" { if f.spec.Required { - return nil, fmt.Errorf("%s: required value is missing", f.spec.Key) + return nil, derrors.NewError(derrors.CategoryValidation, "required value is missing"). + WithContext("field", f.spec.Key). + Build() } delete(result, f.spec.Key) continue } if len(f.spec.Options) > 0 && !slices.Contains(f.spec.Options, value) { - return nil, fmt.Errorf("%s: invalid option %q", f.spec.Key, value) + return nil, derrors.NewError(derrors.CategoryValidation, "invalid template field option"). + WithContext("field", f.spec.Key). + WithContext("value", value). + Build() } result[f.spec.Key] = value case templating.FieldTypeStringList: raw := strings.TrimSpace(f.textValue) if raw == "" { if f.spec.Required { - return nil, fmt.Errorf("%s: required value is missing", f.spec.Key) + return nil, derrors.NewError(derrors.CategoryValidation, "required value is missing"). + WithContext("field", f.spec.Key). + Build() } delete(result, f.spec.Key) continue @@ -796,7 +808,9 @@ func collectData(fields []formField, defaults map[string]any) (map[string]any, e } } if f.spec.Required && len(items) == 0 { - return nil, fmt.Errorf("%s: required value is missing", f.spec.Key) + return nil, derrors.NewError(derrors.CategoryValidation, "required value is missing"). + WithContext("field", f.spec.Key). + Build() } if len(items) > 0 { result[f.spec.Key] = items @@ -804,7 +818,10 @@ func collectData(fields []formField, defaults map[string]any) (map[string]any, e delete(result, f.spec.Key) } default: - return nil, fmt.Errorf("%s: unsupported field type %q", f.spec.Key, f.spec.Type) + return nil, derrors.NewError(derrors.CategoryValidation, "unsupported template field type"). + WithContext("field", f.spec.Key). + WithContext("type", f.spec.Type). + Build() } } diff --git a/internal/doctemplate/app/taxonomy_client.go b/internal/doctemplate/app/taxonomy_client.go index d191dfe1..e78e337e 100644 --- a/internal/doctemplate/app/taxonomy_client.go +++ b/internal/doctemplate/app/taxonomy_client.go @@ -12,6 +12,8 @@ import ( "sort" "strings" "time" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) type taxonomyResponse struct { @@ -27,36 +29,38 @@ func fetchTaxonomies(ctx context.Context, baseURL string) ([]string, []string, e parsed, err := url.Parse(base) if err != nil { - return nil, nil, fmt.Errorf("parse base URL: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryValidation, "parse base URL").Build() } parsed.Path = path.Join(parsed.Path, "/api/taxonomies.json") req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) if err != nil { - return nil, nil, fmt.Errorf("build taxonomy request: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryNetwork, "build taxonomy request").Build() } client := &http.Client{Timeout: 4 * time.Second} resp, err := client.Do(req) if err != nil { - return nil, nil, fmt.Errorf("fetch taxonomies: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryNetwork, "fetch taxonomies").Build() } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, nil, fmt.Errorf("fetch taxonomies: status %d", resp.StatusCode) + return nil, nil, derrors.NewError(derrors.CategoryNetwork, fmt.Sprintf("fetch taxonomies: status %d", resp.StatusCode)). + WithContext("status", resp.StatusCode). + Build() } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, fmt.Errorf("read taxonomy response: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryNetwork, "read taxonomy response").Build() } var payload taxonomyResponse if err := json.Unmarshal(body, &payload); err != nil { - return nil, nil, fmt.Errorf("decode taxonomy response: %w", err) + return nil, nil, derrors.WrapError(err, derrors.CategoryValidation, "decode taxonomy response").Build() } tags := normalizeTaxonomyValues(payload.Tags) diff --git a/internal/doctemplate/service/template_service.go b/internal/doctemplate/service/template_service.go index 241b9ff2..8357887d 100644 --- a/internal/doctemplate/service/template_service.go +++ b/internal/doctemplate/service/template_service.go @@ -3,11 +3,11 @@ package service import ( "context" "errors" - "fmt" "os" "path/filepath" "strings" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/lint" templating "git.home.luguber.info/inful/docbuilder/internal/templates" ) @@ -86,7 +86,9 @@ func (s *TemplateService) BuildSequenceResolver(page *templating.TemplatePage, d return func(name string) (int, error) { def, ok := defs[name] if !ok { - return 0, fmt.Errorf("unknown sequence: %s", name) + return 0, derrors.NewError(derrors.CategoryValidation, "unknown sequence"). + WithContext("name", name). + Build() } return templating.ComputeNextInSequence(def, docsDir) }, nil diff --git a/internal/forge/clone_url.go b/internal/forge/clone_url.go new file mode 100644 index 00000000..3bfefaa8 --- /dev/null +++ b/internal/forge/clone_url.go @@ -0,0 +1,65 @@ +package forge + +import ( + "net/url" + "strings" +) + +// SplitCloneURL returns (baseURL, fullName) for a clone URL. +// +// baseURL is the scheme://host form, e.g. "https://github.com". fullName is +// the path part (without leading slash or ".git" suffix), e.g. "owner/repo" +// or "group/subgroup/repo". If the URL cannot be parsed, returns ("", ""). +// +// SSH URLs (git@github.com:owner/repo.git) are normalized to HTTPS form +// first. This consolidates the two pieces of logic that were inlined in +// editlink/resolver.go (determineBaseURL + extractFullNameFromURL) into a +// single helper used by both call sites. +func SplitCloneURL(cloneURL string) (baseURL, fullName string) { + normalized := normalizeSSH(cloneURL) + + if strings.Contains(cloneURL, "bitbucket.org") { + // Special case: bitbucket URLs get the full name extracted without + // scheme parsing (matches the prior editlink behavior). + u, err := url.Parse(normalized) + if err != nil { + return "", "" + } + path := strings.Trim(u.Path, "/") + path = strings.TrimSuffix(path, ".git") + return "https://bitbucket.org", path + } + + u, err := url.Parse(normalized) + if err != nil || u.Scheme == "" || u.Host == "" { + return "", "" + } + baseURL = u.Scheme + "://" + u.Host + fullName = strings.TrimSuffix(strings.Trim(u.Path, "/"), ".git") + return baseURL, fullName +} + +// NormalizeCloneURL converts git@host:owner/repo.git to +// https://host/owner/repo.git. Non-SSH URLs pass through unchanged. +// Exposed for callers that need the SSH-normalized form without +// splitting into (base, fullName). +func NormalizeCloneURL(repoURL string) string { + if !strings.HasPrefix(repoURL, "git@") { + return repoURL + } + rest := strings.TrimPrefix(repoURL, "git@") + parts := strings.SplitN(rest, ":", 2) + if len(parts) != 2 { + return repoURL + } + return "https://" + parts[0] + "/" + parts[1] +} + +// normalizeSSH is the unexported form used inside SplitCloneURL. +func normalizeSSH(repoURL string) string { return NormalizeCloneURL(repoURL) } + +// SplitCloneURLHelper is preserved for callers that only need a cloneURL's +// host-normalized form without splitting into (base, fullName). It is a +// thin alias for NormalizeCloneURL kept for source compatibility with +// the previous editlink.normalizeSSHURL. +func SplitCloneURLHelper(repoURL string) string { return NormalizeCloneURL(repoURL) } diff --git a/internal/forge/clone_url_test.go b/internal/forge/clone_url_test.go new file mode 100644 index 00000000..74d3e3df --- /dev/null +++ b/internal/forge/clone_url_test.go @@ -0,0 +1,110 @@ +package forge + +import "testing" + +// TestSplitCloneURL exercises HTTPS, SSH, and edge cases. Pairs with +// TestDetermineBaseURL_and_extractFullName which originally lived in +// editlink/resolver_test.go: splitting must produce the same host + path. +func TestSplitCloneURL(t *testing.T) { + tests := []struct { + name string + in string + wantBase string + wantFullName string + }{ + { + name: "HTTPS github", + in: "https://github.com/owner/repo.git", + wantBase: "https://github.com", + wantFullName: "owner/repo", + }, + { + name: "SSH github", + in: "git@github.com:owner/repo.git", + wantBase: "https://github.com", + wantFullName: "owner/repo", + }, + { + name: "no .git suffix", + in: "https://github.com/owner/repo", + wantBase: "https://github.com", + wantFullName: "owner/repo", + }, + { + name: "nested subgroup gitlab", + in: "https://gitlab.example.org/group/sub/repo.git", + wantBase: "https://gitlab.example.org", + wantFullName: "group/sub/repo", + }, + { + name: "bitbucket", + in: "https://bitbucket.org/owner/repo.git", + wantBase: "https://bitbucket.org", + wantFullName: "owner/repo", + }, + { + name: "unparseable", + in: "://bad url", + wantBase: "", + wantFullName: "", + }, + { + name: "empty", + in: "", + wantBase: "", + wantFullName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotBase, gotName := SplitCloneURL(tt.in) + if gotBase != tt.wantBase || gotName != tt.wantFullName { + t.Errorf("SplitCloneURL(%q) = (%q, %q), want (%q, %q)", + tt.in, gotBase, gotName, tt.wantBase, tt.wantFullName) + } + }) + } +} + +// TestSplitCloneURL_RoundtripsWithGenerateEditURL ensures that splitting +// and then calling GenerateEditURL with the per-forge types still +// produces the canonical //// URL. +func TestSplitCloneURL_RoundtripsWithGenerateEditURL(t *testing.T) { + cases := []struct { + url string + branch string + filePath string + want string + }{ + { + url: "https://github.com/owner/repo.git", + branch: "main", + filePath: "docs/readme.md", + want: "https://github.com/owner/repo/edit/main/docs/readme.md", + }, + { + url: "https://gitlab.example.org/group/sub/repo.git", + branch: "main", + filePath: "guide/intro.md", + want: "https://gitlab.example.org/group/sub/repo/-/edit/main/guide/intro.md", + }, + { + url: "https://code.example.org/team/project.git", + branch: "feature/x", + filePath: "docs/page.md", + want: "https://code.example.org/team/project/_edit/feature/x/docs/page.md", + }, + } + for _, tc := range cases { + base, name := SplitCloneURL(tc.url) + if base == "" || name == "" { + t.Errorf("SplitCloneURL(%q): empty split", tc.url) + continue + } + got := GenerateEditURL(DetectForgeTypeFromURL(tc.url), base, name, tc.branch, tc.filePath) + if got != tc.want { + t.Errorf("roundtrip for %q: got %q, want %q", tc.url, got, tc.want) + } + } +} diff --git a/internal/forge/discoveryrunner/runner.go b/internal/forge/discoveryrunner/runner.go index 6b7a85fa..dbc67c8c 100644 --- a/internal/forge/discoveryrunner/runner.go +++ b/internal/forge/discoveryrunner/runner.go @@ -7,11 +7,10 @@ import ( "sync/atomic" "time" - "git.home.luguber.info/inful/docbuilder/internal/build/queue" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/forge" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" - "git.home.luguber.info/inful/docbuilder/internal/services" ) // Discovery is the minimal interface required to run forge discovery. @@ -31,20 +30,16 @@ type Metrics interface { // StateManager is the minimal interface used for persistence and discovery bookkeeping. type StateManager interface { - services.StateManager EnsureRepositoryState(url, name, branch string) RecordDiscovery(repoURL string, documentCount int) } -// Enqueuer is the minimal interface required to enqueue build jobs. -type Enqueuer interface { - Enqueue(job *queue.BuildJob) error -} - // BuildRequester is an optional hook used to request a build without directly // enqueueing a build job. This supports higher-level orchestration (ADR-021). // -// If set, the runner will call it instead of enqueuing a queue.BuildJob. +// If set, the runner will call it instead of returning without triggering +// a build. If unset, the runner just records the discovery in metrics and +// leaves build triggering to the next scheduled sync tick or webhook. type BuildRequester func(ctx context.Context, jobID, reason string) // RepoRemovedNotifier is an optional hook invoked when a repository that existed @@ -57,14 +52,11 @@ type RepoRemovedNotifier func(ctx context.Context, repoURL, repoName string) // Config holds the dependencies for creating a Runner. type Config struct { Discovery Discovery - ForgeManager *forge.Manager DiscoveryCache *Cache Metrics Metrics StateManager StateManager - BuildQueue Enqueuer BuildRequester BuildRequester RepoRemoved RepoRemovedNotifier - LiveReload queue.LiveReloadHub Config *config.Config // Now allows tests to inject deterministic time. @@ -77,14 +69,11 @@ type Config struct { // across all configured forges and triggering builds for discovered repositories. type Runner struct { discovery Discovery - forgeManager *forge.Manager discoveryCache *Cache metrics Metrics stateManager StateManager - buildQueue Enqueuer buildRequester BuildRequester repoRemoved RepoRemovedNotifier - liveReload queue.LiveReloadHub config *config.Config now func() time.Time @@ -108,14 +97,11 @@ func New(cfg Config) *Runner { return &Runner{ discovery: cfg.Discovery, - forgeManager: cfg.ForgeManager, discoveryCache: cfg.DiscoveryCache, metrics: cfg.Metrics, stateManager: cfg.StateManager, - buildQueue: cfg.BuildQueue, buildRequester: cfg.BuildRequester, repoRemoved: cfg.RepoRemoved, - liveReload: cfg.LiveReload, config: cfg.Config, now: now, newJobID: newJobID, @@ -160,7 +146,7 @@ func (r *Runner) Run(ctx context.Context) error { if r.discoveryCache != nil { r.discoveryCache.SetError(err) } - return fmt.Errorf("discovery failed: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "discovery failed").Build() } duration := time.Since(start) @@ -243,27 +229,11 @@ func (r *Runner) triggerBuildForDiscoveredRepos(ctx context.Context, result *for return } - if r.buildQueue == nil { - return - } - - converted := r.discovery.ConvertToConfigRepositories(result.Repositories, r.forgeManager) - job := &queue.BuildJob{ - ID: jobID, - Type: queue.BuildTypeDiscovery, - Priority: queue.PriorityNormal, - CreatedAt: r.now(), - TypedMeta: &queue.BuildJobMetadata{ - V2Config: r.config, - Repositories: converted, - StateManager: r.stateManager, - LiveReloadHub: r.liveReload, - }, - } - - if err := r.buildQueue.Enqueue(job); err != nil { - slog.Error("Failed to enqueue auto-build", logfields.Error(err)) - } + // No build requester wired — the runner records discovery but + // does not directly schedule a build. The next scheduled sync + // tick or webhook will pick up the new repositories. + slog.Debug("Discovery found repositories; no build requester wired", + logfields.JobID(jobID), slog.Int("repositories", len(result.Repositories))) } // SafeRun executes discovery with a timeout and panic protection. @@ -336,8 +306,3 @@ func (r *Runner) UpdateConfig(cfg *config.Config) { func (r *Runner) UpdateDiscoveryService(discovery Discovery) { r.discovery = discovery } - -// UpdateForgeManager updates the forge manager (used during config reload). -func (r *Runner) UpdateForgeManager(forgeManager *forge.Manager) { - r.forgeManager = forgeManager -} diff --git a/internal/forge/discoveryrunner/runner_test.go b/internal/forge/discoveryrunner/runner_test.go index 126642b9..a655ea0b 100644 --- a/internal/forge/discoveryrunner/runner_test.go +++ b/internal/forge/discoveryrunner/runner_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/require" - "git.home.luguber.info/inful/docbuilder/internal/build/queue" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/forge" ) @@ -17,7 +16,6 @@ func TestRunner_Run_WhenDiscoveryFails_CachesErrorAndDoesNotEnqueue(t *testing.T cache := NewCache() metrics := &fakeMetrics{} - enq := &fakeEnqueuer{} discovery := &fakeDiscovery{ err: forgeError("discovery failed"), @@ -27,7 +25,6 @@ func TestRunner_Run_WhenDiscoveryFails_CachesErrorAndDoesNotEnqueue(t *testing.T Discovery: discovery, DiscoveryCache: cache, Metrics: metrics, - BuildQueue: enq, Now: func() time.Time { return time.Unix(123, 0).UTC() }, NewJobID: func() string { return jobID }, Config: &config.Config{Version: "2.0"}, @@ -38,15 +35,13 @@ func TestRunner_Run_WhenDiscoveryFails_CachesErrorAndDoesNotEnqueue(t *testing.T _, cachedErr := cache.Get() require.Error(t, cachedErr) - require.Equal(t, 0, enq.calls) } -func TestRunner_Run_WhenReposDiscovered_UpdatesCacheAndEnqueuesBuild(t *testing.T) { +func TestRunner_Run_WhenReposDiscovered_UpdatesCacheAndRequestsBuild(t *testing.T) { const jobID = "job-1" cache := NewCache() metrics := &fakeMetrics{} - enq := &fakeEnqueuer{} appCfg := &config.Config{Version: "2.0"} r1 := &forge.Repository{Name: "r1", CloneURL: "https://example.com/r1.git", Metadata: map[string]string{"forge_name": "f"}} @@ -63,14 +58,22 @@ func TestRunner_Run_WhenReposDiscovered_UpdatesCacheAndEnqueuesBuild(t *testing. converted: []config.Repository{{Name: "r1"}, {Name: "r2"}}, } + var ( + calledID string + calledReason string + ) + r := New(Config{ Discovery: discovery, DiscoveryCache: cache, Metrics: metrics, - BuildQueue: enq, - Now: func() time.Time { return time.Unix(123, 0).UTC() }, - NewJobID: func() string { return jobID }, - Config: appCfg, + BuildRequester: func(_ context.Context, jobID, reason string) { + calledID = jobID + calledReason = reason + }, + Now: func() time.Time { return time.Unix(123, 0).UTC() }, + NewJobID: func() string { return "job-1" }, + Config: appCfg, }) err := r.Run(context.Background()) @@ -79,13 +82,8 @@ func TestRunner_Run_WhenReposDiscovered_UpdatesCacheAndEnqueuesBuild(t *testing. res, cachedErr := cache.Get() require.NoError(t, cachedErr) require.Same(t, discovery.result, res) - require.Equal(t, 1, enq.calls) - require.NotNil(t, enq.last) - require.Equal(t, jobID, enq.last.ID) - require.Equal(t, queue.BuildTypeDiscovery, enq.last.Type) - require.NotNil(t, enq.last.TypedMeta) - require.Same(t, appCfg, enq.last.TypedMeta.V2Config) - require.Len(t, enq.last.TypedMeta.Repositories, 2) + require.Equal(t, jobID, calledID) + require.Equal(t, "discovery", calledReason) } func TestRunner_Run_WhenBuildOnDiscoveryDisabled_UpdatesCacheAndDoesNotEnqueueBuild(t *testing.T) { @@ -93,19 +91,20 @@ func TestRunner_Run_WhenBuildOnDiscoveryDisabled_UpdatesCacheAndDoesNotEnqueueBu cache := NewCache() metrics := &fakeMetrics{} - enq := &fakeEnqueuer{} - buildOnDiscovery := false - appCfg := &config.Config{Version: "2.0", Daemon: &config.DaemonConfig{Sync: config.SyncConfig{BuildOnDiscovery: &buildOnDiscovery}}} - - r1 := &forge.Repository{Name: "r1", CloneURL: "https://example.com/r1.git", Metadata: map[string]string{"forge_name": "f"}} + appCfg := &config.Config{Version: "2.0"} + if appCfg.Daemon == nil { + appCfg.Daemon = &config.DaemonConfig{} + } + appCfg.Daemon.Sync.BuildOnDiscovery = ptr(false) discovery := &fakeDiscovery{ result: &forge.DiscoveryResult{ - Repositories: []*forge.Repository{r1}, - Filtered: []*forge.Repository{}, - Errors: map[string]error{}, - Timestamp: time.Unix(100, 0).UTC(), - Duration: 2 * time.Second, + Repositories: []*forge.Repository{ + {Name: "r1", CloneURL: "https://example.com/r1.git", Metadata: map[string]string{"forge_name": "f"}}, + }, + Filtered: []*forge.Repository{}, + Errors: map[string]error{}, + Timestamp: time.Unix(100, 0).UTC(), }, converted: []config.Repository{{Name: "r1"}}, } @@ -114,19 +113,15 @@ func TestRunner_Run_WhenBuildOnDiscoveryDisabled_UpdatesCacheAndDoesNotEnqueueBu Discovery: discovery, DiscoveryCache: cache, Metrics: metrics, - BuildQueue: enq, Now: func() time.Time { return time.Unix(123, 0).UTC() }, NewJobID: func() string { return jobID }, Config: appCfg, }) - err := r.Run(context.Background()) - require.NoError(t, err) + require.NoError(t, r.Run(context.Background())) - res, cachedErr := cache.Get() + _, cachedErr := cache.Get() require.NoError(t, cachedErr) - require.Same(t, discovery.result, res) - require.Equal(t, 0, enq.calls) } func TestRunner_Run_WhenBuildRequesterProvided_DoesNotEnqueueBuild(t *testing.T) { @@ -134,109 +129,38 @@ func TestRunner_Run_WhenBuildRequesterProvided_DoesNotEnqueueBuild(t *testing.T) cache := NewCache() metrics := &fakeMetrics{} - enq := &fakeEnqueuer{} appCfg := &config.Config{Version: "2.0"} - r1 := &forge.Repository{Name: "r1", CloneURL: "https://example.com/r1.git", Metadata: map[string]string{"forge_name": "f"}} - discovery := &fakeDiscovery{ result: &forge.DiscoveryResult{ - Repositories: []*forge.Repository{r1}, - Filtered: []*forge.Repository{}, - Errors: map[string]error{}, - Timestamp: time.Unix(100, 0).UTC(), - Duration: 2 * time.Second, + Repositories: []*forge.Repository{ + {Name: "r1", CloneURL: "https://example.com/r1.git", Metadata: map[string]string{"forge_name": "f"}}, + }, + Filtered: []*forge.Repository{}, + Errors: map[string]error{}, + Timestamp: time.Unix(100, 0).UTC(), }, converted: []config.Repository{{Name: "r1"}}, } - var ( - called bool - gotJobID string - gotReason string - calledWith context.Context - ) - + calls := 0 r := New(Config{ Discovery: discovery, DiscoveryCache: cache, Metrics: metrics, - BuildQueue: enq, - BuildRequester: func(ctx context.Context, jobID, reason string) { - called = true - calledWith = ctx - gotJobID = jobID - gotReason = reason - }, - Now: func() time.Time { return time.Unix(123, 0).UTC() }, - NewJobID: func() string { return jobID }, - Config: appCfg, - }) - - err := r.Run(context.Background()) - require.NoError(t, err) - require.True(t, called) - require.NotNil(t, calledWith) - require.Equal(t, jobID, gotJobID) - require.Equal(t, "discovery", gotReason) - require.Equal(t, 0, enq.calls) -} - -func TestRunner_Run_WhenRepoRemoved_InvokesRepoRemovedNotifier(t *testing.T) { - cache := NewCache() - metrics := &fakeMetrics{} - enq := &fakeEnqueuer{} - appCfg := &config.Config{Version: "2.0"} - - prev1 := &forge.Repository{Name: "r1", CloneURL: "https://example.com/r1.git"} - prev2 := &forge.Repository{Name: "r2", CloneURL: "https://example.com/r2.git"} - cache.Update(&forge.DiscoveryResult{Repositories: []*forge.Repository{prev1, prev2}}) - - cur1 := &forge.Repository{Name: "r1", CloneURL: "https://example.com/r1.git", Metadata: map[string]string{"forge_name": "f"}} - discovery := &fakeDiscovery{ - result: &forge.DiscoveryResult{ - Repositories: []*forge.Repository{cur1}, - Filtered: []*forge.Repository{}, - Errors: map[string]error{}, - Timestamp: time.Unix(100, 0).UTC(), - Duration: 2 * time.Second, - }, - converted: []config.Repository{{Name: "r1"}}, - } - - var ( - calls int - gotURL string - gotName string - calledCt context.Context - ) - - r := New(Config{ - Discovery: discovery, - DiscoveryCache: cache, - Metrics: metrics, - BuildQueue: enq, - RepoRemoved: func(ctx context.Context, repoURL, repoName string) { - calls++ - calledCt = ctx - gotURL = repoURL - gotName = repoName - }, - Now: func() time.Time { return time.Unix(123, 0).UTC() }, - Config: appCfg, + BuildRequester: func(_ context.Context, _, _ string) { calls++ }, + Now: func() time.Time { return time.Unix(123, 0).UTC() }, + NewJobID: func() string { return jobID }, + Config: appCfg, }) - err := r.Run(context.Background()) - require.NoError(t, err) + require.NoError(t, r.Run(context.Background())) require.Equal(t, 1, calls) - require.NotNil(t, calledCt) - require.Equal(t, "https://example.com/r2.git", gotURL) - require.Equal(t, "r2", gotName) } type fakeDiscovery struct { - result *forge.DiscoveryResult err error + result *forge.DiscoveryResult converted []config.Repository } @@ -265,13 +189,4 @@ func (m *fakeMetrics) IncrementCounter(name string) { func (m *fakeMetrics) RecordHistogram(string, float64) {} func (m *fakeMetrics) SetGauge(string, int64) {} -type fakeEnqueuer struct { - calls int - last *queue.BuildJob -} - -func (e *fakeEnqueuer) Enqueue(job *queue.BuildJob) error { - e.calls++ - e.last = job - return nil -} +func ptr[T any](v T) *T { return &v } diff --git a/internal/forge/enhanced_integration_summary_test.go b/internal/forge/enhanced_integration_summary_test.go index 59af5fda..f9a15c33 100644 --- a/internal/forge/enhanced_integration_summary_test.go +++ b/internal/forge/enhanced_integration_summary_test.go @@ -119,8 +119,8 @@ func testEnhancedMockForgeClientFailureModes(t *testing.T) { if err == nil { t.Error("Expected rate limit failure, got nil") } - if err.Error() != "rate limit exceeded: 100 requests per hour" { - t.Errorf("Expected rate limit message, got: %s", err.Error()) + if err.Error() != "[network:error] rate limit exceeded" { + t.Errorf("Expected classified rate-limit error, got: %s", err.Error()) } // Test network timeout @@ -130,7 +130,7 @@ func testEnhancedMockForgeClientFailureModes(t *testing.T) { if err == nil { t.Error("Expected network timeout failure, got nil") } - expectedMsg := "network timeout: connection to https://api.github.com timed out" + expectedMsg := "[network:error] network timeout: connection to https://api.github.com timed out" if err.Error() != expectedMsg { t.Errorf("Expected timeout message, got: %s", err.Error()) } diff --git a/internal/forge/enhanced_mock.go b/internal/forge/enhanced_mock.go index 523d9a47..89573e4f 100644 --- a/internal/forge/enhanced_mock.go +++ b/internal/forge/enhanced_mock.go @@ -9,6 +9,7 @@ import ( "time" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) const ( @@ -233,7 +234,7 @@ func (m *EnhancedMockForgeClient) GetRepository(_ context.Context, owner, repo s } } - return nil, fmt.Errorf("repository %s not found", fullName) + return nil, derrors.NewError(derrors.CategoryNotFound, "repository not found: "+fullName).Build() } // CheckDocumentation checks if repository has documentation. @@ -360,7 +361,7 @@ func (m *EnhancedMockForgeClient) RegisterWebhook(_ context.Context, _ *Reposito // Simple URL validation - must start with http:// or https:// if !strings.HasPrefix(webhookURL, "http://") && !strings.HasPrefix(webhookURL, "https://") { - return fmt.Errorf("invalid webhook URL format: %s", webhookURL) + return derrors.NewError(derrors.CategoryValidation, "invalid webhook URL format: "+webhookURL).Build() } return nil // Mock success @@ -447,25 +448,25 @@ func (m *EnhancedMockForgeClient) simulateFailures() error { if m.networkTimeout > 0 && m.networkTimeout < time.Millisecond*100 { switch m.forgeType { case TypeGitHub: - return errors.New("network timeout: connection to https://api.github.com timed out") + return derrors.NewError(derrors.CategoryNetwork, "network timeout: connection to https://api.github.com timed out").Build() case TypeGitLab: - return errors.New("network timeout: connection to https://gitlab.com/api/v4 timed out") + return derrors.NewError(derrors.CategoryNetwork, "network timeout: connection to https://gitlab.com/api/v4 timed out").Build() case TypeForgejo: - return errors.New("network timeout: connection to https://forgejo.org/api/v1 timed out") + return derrors.NewError(derrors.CategoryNetwork, "network timeout: connection to https://forgejo.org/api/v1 timed out").Build() default: - return fmt.Errorf("network timeout after %v", m.networkTimeout) + return derrors.NewError(derrors.CategoryNetwork, "network timeout").WithContext("timeout", m.networkTimeout.String()).Build() } } // Simulate authentication failure if m.authFailure { - return errors.New("authentication failed: invalid credentials") + return derrors.NewError(derrors.CategoryAuth, "authentication failed: invalid credentials").Build() } // Simulate rate limiting if m.rateLimit != nil { if time.Now().Before(m.rateLimit.ResetTime) { - return fmt.Errorf("rate limit exceeded: %d requests per hour", m.rateLimit.RequestsPerHour) + return derrors.NewError(derrors.CategoryNetwork, "rate limit exceeded").RateLimit().WithContext("requests_per_hour", m.rateLimit.RequestsPerHour).Build() } } diff --git a/internal/forge/enhanced_mock_integration_test.go b/internal/forge/enhanced_mock_integration_test.go index 11380788..27ecb1bd 100644 --- a/internal/forge/enhanced_mock_integration_test.go +++ b/internal/forge/enhanced_mock_integration_test.go @@ -48,7 +48,7 @@ func TestEnhancedMockForgeClient_FailureSimulation(t *testing.T) { t.Error("Expected auth failure error, got nil") } - if err.Error() != "authentication failed: invalid credentials" { + if err.Error() != "[auth:error] authentication failed: invalid credentials" { t.Errorf("Expected auth failure message, got: %s", err.Error()) } @@ -78,8 +78,8 @@ func TestEnhancedMockForgeClient_RateLimitSimulation(t *testing.T) { t.Error("Expected rate limit error, got nil") } - if err.Error() != "rate limit exceeded: 100 requests per hour" { - t.Errorf("Expected rate limit message, got: %s", err.Error()) + if err.Error() != "[network:error] rate limit exceeded" { + t.Errorf("Expected classified rate-limit error, got: %s", err.Error()) } } @@ -95,7 +95,7 @@ func TestEnhancedMockForgeClient_NetworkTimeoutSimulation(t *testing.T) { t.Error("Expected network timeout error, got nil") } - expectedMsg := "network timeout: connection to https://api.github.com timed out" + expectedMsg := "[network:error] network timeout: connection to https://api.github.com timed out" if err.Error() != expectedMsg { t.Errorf("Expected network timeout message, got: %s", err.Error()) } diff --git a/internal/forge/enhanced_mock_production_test.go b/internal/forge/enhanced_mock_production_test.go index ddbe7246..83086e2c 100644 --- a/internal/forge/enhanced_mock_production_test.go +++ b/internal/forge/enhanced_mock_production_test.go @@ -104,7 +104,7 @@ func testProductionFailureSimulation(t *testing.T) { if err == nil { t.Error("Expected auth failure, got nil") } - if err.Error() != "authentication failed: invalid credentials" { + if err.Error() != "[auth:error] authentication failed: invalid credentials" { t.Errorf("Expected auth failure message, got: %s", err.Error()) } diff --git a/internal/forge/forge_type.go b/internal/forge/forge_type.go new file mode 100644 index 00000000..8230317c --- /dev/null +++ b/internal/forge/forge_type.go @@ -0,0 +1,38 @@ +package forge + +import ( + "strings" + + "git.home.luguber.info/inful/docbuilder/internal/config" +) + +// DetectForgeTypeFromURL classifies a (clone) URL by host pattern. +// +// Behavior: +// - Strings containing "github." resolve to ForgeGitHub. +// - Strings containing "gitlab." resolve to ForgeGitLab. +// - Strings containing "forgejo", "gitea", or "bitbucket.org" resolve to +// ForgeForgejo. (Bitbucket is not yet a first-class ForgeType; we map +// it to the closest existing pattern so callers at least get a URL.) +// - Anything else resolves to ForgeForgejo (the most-common self-hosted +// option today). Empty input follows the same rule. +// +// Use this when callers don't have an explicit `forge` metadata field and +// can only inspect URLs. When callers DO have explicit forge metadata, +// prefer that (see pipeline.detectForgeType which consults the field +// first and falls back here). +func DetectForgeTypeFromURL(rawURL string) config.ForgeType { + lower := strings.ToLower(rawURL) + switch { + case strings.Contains(lower, "github."): + return config.ForgeGitHub + case strings.Contains(lower, "gitlab."): + return config.ForgeGitLab + case strings.Contains(lower, "forgejo"), + strings.Contains(lower, "gitea"), + strings.Contains(lower, "bitbucket.org"): + return config.ForgeForgejo + default: + return config.ForgeForgejo + } +} diff --git a/internal/forge/forge_type_test.go b/internal/forge/forge_type_test.go new file mode 100644 index 00000000..855d66bd --- /dev/null +++ b/internal/forge/forge_type_test.go @@ -0,0 +1,82 @@ +package forge + +import ( + "testing" + + "git.home.luguber.info/inful/docbuilder/internal/config" +) + +// TestDetectForgeTypeFromURL covers the hostname-pattern detection used by +// edit-link resolvers and the content pipeline. Centralized here so both +// call sites see identical behavior. +func TestDetectForgeTypeFromURL(t *testing.T) { + tests := []struct { + name string + url string + want config.ForgeType + }{ + { + name: "github.com", + url: "https://github.com/org/repo.git", + want: config.ForgeGitHub, + }, + { + name: "self-hosted github enterprise", + url: "https://github.example.com/org/repo.git", + want: config.ForgeGitHub, + }, + { + name: "gitlab.com", + url: "https://gitlab.com/org/repo.git", + want: config.ForgeGitLab, + }, + { + name: "self-hosted gitlab", + url: "https://gitlab.example.org/org/repo.git", + want: config.ForgeGitLab, + }, + { + name: "forgejo.org", + url: "https://codeberg.org/forgejo/forgejo.git", + want: config.ForgeForgejo, + }, + { + name: "gitea.io", + url: "https://gitea.io/org/repo.git", + want: config.ForgeForgejo, + }, + { + name: "bitbucket is treated as Forgejo placeholder", + url: "https://bitbucket.org/org/repo.git", + want: config.ForgeForgejo, + }, + { + name: "unknown self-hosted defaults to Forgejo", + url: "https://git.example.com/org/repo.git", + want: config.ForgeForgejo, + }, + { + name: "empty input defaults to Forgejo", + url: "", + want: config.ForgeForgejo, + }, + { + name: "SSH git@github.com:org/repo.git", + url: "git@github.com:org/repo.git", + want: config.ForgeGitHub, + }, + { + name: "SSH git@gitlab.com:org/repo.git", + url: "git@gitlab.com:org/repo.git", + want: config.ForgeGitLab, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DetectForgeTypeFromURL(tt.url); got != tt.want { + t.Errorf("DetectForgeTypeFromURL(%q) = %q, want %q", tt.url, got, tt.want) + } + }) + } +} diff --git a/internal/forge/forgejo.go b/internal/forge/forgejo.go index dd02ca33..ae669b24 100644 --- a/internal/forge/forgejo.go +++ b/internal/forge/forgejo.go @@ -553,3 +553,6 @@ func (c *ForgejoClient) splitFullName(fullName string) (owner, repo string) { } return "", fullName } + +// Compile-time assertion that *ForgejoClient satisfies the Client contract. +var _ Client = (*ForgejoClient)(nil) diff --git a/internal/forge/github.go b/internal/forge/github.go index 793fa909..8e2cd24d 100644 --- a/internal/forge/github.go +++ b/internal/forge/github.go @@ -525,3 +525,6 @@ func (c *GitHubClient) splitFullName(fullName string) (owner, repo string) { } return "", fullName } + +// Compile-time assertion that *GitHubClient satisfies the Client contract. +var _ Client = (*GitHubClient)(nil) diff --git a/internal/forge/gitlab.go b/internal/forge/gitlab.go index a0528677..a7934268 100644 --- a/internal/forge/gitlab.go +++ b/internal/forge/gitlab.go @@ -666,3 +666,6 @@ func (c *GitLabClient) convertGitLabProject(gProject *gitlabProject) *Repository }, } } + +// Compile-time assertion that *GitLabClient satisfies the Client contract. +var _ Client = (*GitLabClient)(nil) diff --git a/internal/forge/helpers.go b/internal/forge/helpers.go index 332bfb14..5a9a1b6d 100644 --- a/internal/forge/helpers.go +++ b/internal/forge/helpers.go @@ -2,12 +2,12 @@ package forge import ( "context" - "fmt" "net" "net/http" "time" cfg "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // newHTTPClient30s returns a shared HTTP client with a 30s timeout. @@ -45,7 +45,9 @@ func tokenFromConfig(fg *Config, forgeName string) (string, error) { if fg != nil && fg.Auth != nil && fg.Auth.Type == cfg.AuthTypeToken { return fg.Auth.Token, nil } - return "", fmt.Errorf("%s client requires token authentication", forgeName) + return "", derrors.NewError(derrors.CategoryAuth, forgeName+" client requires token authentication"). + WithContext("forge", forgeName). + Build() } // fetchAndConvertReposGeneric is a generic helper to reduce duplication between diff --git a/internal/forge/local.go b/internal/forge/local.go index b80ce570..ca6e2be0 100644 --- a/internal/forge/local.go +++ b/internal/forge/local.go @@ -79,3 +79,6 @@ func (c *LocalClient) RegisterWebhook(ctx context.Context, repo *Repository, web return nil } func (c *LocalClient) GetEditURL(repo *Repository, filePath string, branch string) string { return "" } + +// Compile-time assertion that *LocalClient satisfies the Client contract. +var _ Client = (*LocalClient)(nil) diff --git a/internal/forge/mock_integration_test.go b/internal/forge/mock_integration_test.go index 62586f97..0a44e7eb 100644 --- a/internal/forge/mock_integration_test.go +++ b/internal/forge/mock_integration_test.go @@ -5,7 +5,7 @@ import ( "time" ) -const authFailureMessage = "authentication failed: invalid credentials" +const authFailureMessage = "[auth:error] authentication failed: invalid credentials" // TestPhase3BIntegrationDemo demonstrates how the enhanced mock system integrates // with existing DocBuilder test patterns and provides backward compatibility. diff --git a/internal/foundation/enum.go b/internal/foundation/enum.go deleted file mode 100644 index 32efdd51..00000000 --- a/internal/foundation/enum.go +++ /dev/null @@ -1,53 +0,0 @@ -package foundation - -import ( - "fmt" - "strings" -) - -// defaultNormalizer provides standard string normalization. -func defaultNormalizer(s string) string { - return strings.ToLower(strings.TrimSpace(s)) -} - -// Normalizer provides common string normalization functions. -type Normalizer[T comparable] struct { - validValues map[string]T - defaultValue T -} - -// NewNormalizer creates a normalizer with a map of valid string->value pairs. -func NewNormalizer[T comparable](values map[string]T, defaultValue T) *Normalizer[T] { - // Create a normalized version of the map - normalized := make(map[string]T, len(values)) - for k, v := range values { - normalized[defaultNormalizer(k)] = v - } - - return &Normalizer[T]{ - validValues: normalized, - defaultValue: defaultValue, - } -} - -// Normalize attempts to convert a string to the enum type. -// Returns the default value if the string is not recognized. -func (n *Normalizer[T]) Normalize(raw string) T { - cleaned := defaultNormalizer(raw) - if value, exists := n.validValues[cleaned]; exists { - return value - } - return n.defaultValue -} - -// NormalizeWithError attempts to convert a string to the enum type. -// Returns an error if the string is not recognized. -func (n *Normalizer[T]) NormalizeWithError(raw string) (T, error) { - cleaned := defaultNormalizer(raw) - if value, exists := n.validValues[cleaned]; exists { - return value, nil - } - - var zero T - return zero, fmt.Errorf("invalid value: %s", raw) -} diff --git a/internal/foundation/errors/builder.go b/internal/foundation/errors/builder.go index e3b0448c..f7086c1e 100644 --- a/internal/foundation/errors/builder.go +++ b/internal/foundation/errors/builder.go @@ -197,10 +197,3 @@ func NotFoundError(resource string) *ErrorBuilder { WithContext("resource", resource). Fatal() } - -// AlreadyExistsError creates an already-exists error. -func AlreadyExistsError(resource string) *ErrorBuilder { - return NewError(CategoryAlreadyExists, fmt.Sprintf("%s already exists", resource)). - WithContext("resource", resource). - Fatal() -} diff --git a/internal/foundation/foundation_test.go b/internal/foundation/foundation_test.go index 3cb3bc8a..8e789e10 100644 --- a/internal/foundation/foundation_test.go +++ b/internal/foundation/foundation_test.go @@ -6,11 +6,6 @@ import ( "testing" ) -const ( - forgeGitHub = "github" - forgeGitLab = "gitlab" -) - func TestResult(t *testing.T) { t.Run("Ok result", func(t *testing.T) { result := Ok[string, error]("success") @@ -122,35 +117,5 @@ func TestOption(t *testing.T) { }) } -func TestNormalizer(t *testing.T) { - normalizer := NewNormalizer(map[string]string{ - forgeGitHub: forgeGitHub, - forgeGitLab: forgeGitLab, - "forgejo": "forgejo", - }, forgeGitHub) - - t.Run("Valid values", func(t *testing.T) { - if normalizer.Normalize("GitHub") != forgeGitHub { - t.Error("Expected 'GitHub' to normalize to 'github'") - } - - if normalizer.Normalize(" gitlab ") != forgeGitLab { - t.Error("Expected ' gitlab ' to normalize to 'gitlab'") - } - }) - - t.Run("Invalid value", func(t *testing.T) { - if normalizer.Normalize("bitbucket") != forgeGitHub { - t.Error("Expected 'bitbucket' to return default 'github'") - } - }) - - t.Run("With error", func(t *testing.T) { - _, err := normalizer.NormalizeWithError("invalid") - if err == nil { - t.Error("Expected error for invalid value") - } - }) -} - // Note: ClassifiedError tests have been moved to internal/foundation/errors/errors_test.go +// Note: Normalizer tests have been moved to internal/foundation/normalization/normalizer_test.go diff --git a/internal/foundation/normalization/normalizer.go b/internal/foundation/normalization/normalizer.go index 30a7dea0..d8097781 100644 --- a/internal/foundation/normalization/normalizer.go +++ b/internal/foundation/normalization/normalizer.go @@ -1,9 +1,10 @@ package normalization import ( - "fmt" "sort" "strings" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // Normalizer provides type-safe string-to-enum normalization with error handling. @@ -55,7 +56,10 @@ func (n *Normalizer[T]) NormalizeWithError(raw string) (T, error) { } var zero T - return zero, fmt.Errorf("invalid value %q, valid options: %v", raw, n.validKeys) + return zero, derrors.NewError(derrors.CategoryValidation, "invalid value; not a recognized enum member"). + WithContext("input", raw). + WithContext("valid_options", n.validKeys). + Build() } // ValidateEnum checks if a value is valid without normalization. @@ -81,26 +85,3 @@ func (n *Normalizer[T]) ValidKeys() []string { func defaultNormalization(s string) string { return strings.ToLower(strings.TrimSpace(s)) } - -// Func allows custom normalization behavior. -type Func func(string) string - -// WithCustomNormalizer creates a normalizer with custom string normalization. -func WithCustomNormalizer[T comparable](values map[string]T, defaultValue T, normalizer Func) *Normalizer[T] { - normalized := make(map[string]T, len(values)) - validKeys := make([]string, 0, len(values)) - - for k, v := range values { - normalizedKey := normalizer(k) - normalized[normalizedKey] = v - validKeys = append(validKeys, normalizedKey) - } - - sort.Strings(validKeys) - - return &Normalizer[T]{ - validValues: normalized, - defaultValue: defaultValue, - validKeys: validKeys, - } -} diff --git a/internal/foundation/option.go b/internal/foundation/option.go index b8a2be62..97c605eb 100644 --- a/internal/foundation/option.go +++ b/internal/foundation/option.go @@ -68,24 +68,6 @@ func (o Option[T]) Match(onSome func(T), onNone func()) { } } -// MapOption transforms an Option[T] to Option[U] using the given function. -// If the Option is None, it returns None[U]. -func MapOption[T, U any](o Option[T], fn func(T) U) Option[U] { - if o.present { - return Some(fn(o.value)) - } - return None[U]() -} - -// FlatMapOption transforms an Option[T] to Option[U] using a function that returns Option[U]. -// This prevents Option[Option[U]]. -func FlatMapOption[T, U any](o Option[T], fn func(T) Option[U]) Option[U] { - if o.present { - return fn(o.value) - } - return None[U]() -} - // Filter returns the Option if the predicate is true, otherwise None. func (o Option[T]) Filter(predicate func(T) bool) Option[T] { if o.present && predicate(o.value) { diff --git a/internal/frontmatterops/upsert.go b/internal/frontmatterops/upsert.go new file mode 100644 index 00000000..f03ce597 --- /dev/null +++ b/internal/frontmatterops/upsert.go @@ -0,0 +1,71 @@ +package frontmatterops + +import ( + "bytes" +) + +// UpsertFields reads content's frontmatter, calls mutate to modify +// fields, and writes the result back. It bundles the boilerplate that +// the lint/fix and daemon packages used to repeat three times (Read +// frontmatter, mutate, fix the body's leading newline if we just +// created one, Write). +// +// createIfMissing controls whether to fabricate a brand-new frontmatter +// block when content had none. Pass true for callers that legitimately +// want to introduce a frontmatter block (the UID-insertion helpers); +// pass false for callers that require an existing block to work with +// (the UID-alias helper). +// +// Behavior: +// - mutate returns (changed=false, err=nil): short-circuit. Returns +// (content, false, nil); no write happens. +// - mutate returns err != nil: short-circuit. Returns the original +// (content, false, err); no write. +// - Read fails on malformed frontmatter: returns (content, false, err); +// mutate is not invoked. +// - Write fails after a successful mutate: returns (content, false, err). +// - success: returns (newContent, true, nil). Body is preserved with +// the style newline as separator when createIfMissing synthesizes +// a fresh block. +// +// mutate must NOT touch body, had, or style. Only fields is mutated. +// Returning changed=true on an empty mutation is allowed but pointless; +// the helper treats it the same as a real change. +func UpsertFields(content string, createIfMissing bool, mutate func(fields map[string]any) (changed bool, err error)) (string, bool, error) { + fields, body, had, style, err := Read([]byte(content)) + if err != nil { + return content, false, err + } + if style.Newline == "" { + style.Newline = "\n" + } + if fields == nil { + fields = map[string]any{} + } + + changed, err := mutate(fields) + if err != nil || !changed { + return content, false, err + } + + if !had { + if !createIfMissing { + return content, false, nil + } + had = true + // Frontmatter freshly synthesized: prepend exactly one newline so + // the YAML closing delimiter doesn't fuse into the first body + // character. Skip when body already starts with that newline. + if len(body) > 0 && !bytes.HasPrefix(body, []byte(style.Newline)) { + body = append([]byte(style.Newline), body...) + } else if len(body) == 0 { + body = append([]byte(style.Newline), body...) + } + } + + out, err := Write(fields, body, had, style) + if err != nil { + return content, false, err + } + return string(out), true, nil +} diff --git a/internal/frontmatterops/upsert_test.go b/internal/frontmatterops/upsert_test.go new file mode 100644 index 00000000..75e07b86 --- /dev/null +++ b/internal/frontmatterops/upsert_test.go @@ -0,0 +1,173 @@ +package frontmatterops + +import ( + "errors" + "strings" + "testing" +) + +// TestUpsertFields_NoFrontmatter_Create covers the create-if-missing path: +// when content has no frontmatter AND createIfMissing is true, the +// helper writes a fresh block with the style newline as separator. +func TestUpsertFields_NoFrontmatter_Create(t *testing.T) { + const body = "Hello body.\n" + + got, changed, err := UpsertFields(body, true, func(f map[string]any) (bool, error) { + f["uid"] = "abc-123" + return true, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Fatalf("expected changed=true") + } + if !strings.Contains(got, "uid: abc-123") { + t.Errorf("expected uid in output, got %q", got) + } + if !strings.Contains(got, "Hello body.") { + t.Errorf("expected body preserved, got %q", got) + } +} + +// TestUpsertFields_NoFrontmatter_Skip covers the create-iff-missing=false +// behavior: caller wants modification only; missing frontmatter is a noop. +func TestUpsertFields_NoFrontmatter_Skip(t *testing.T) { + const body = "Just a body, no frontmatter.\n" + + got, changed, err := UpsertFields(body, false, func(f map[string]any) (bool, error) { + f["uid"] = "abc-123" + return true, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if changed { + t.Fatalf("expected changed=false (no frontmatter to mutate)") + } + if got != body { + t.Fatalf("expected unchanged content %q, got %q", body, got) + } +} + +// TestUpsertFields_Existing_Changed covers the mutation path: existing +// frontmatter, mutate returns true, output round-trips with the mutation. +func TestUpsertFields_Existing_Changed(t *testing.T) { + const src = "---\nuid: old\n---\nbody\n" + + got, changed, err := UpsertFields(src, true, func(f map[string]any) (bool, error) { + if f["uid"] != "old" { + return false, errors.New("expected old uid") + } + f["uid"] = "new" + return true, nil + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !changed { + t.Fatalf("expected changed=true") + } + if !strings.Contains(got, "uid: new") { + t.Errorf("expected new uid, got %q", got) + } + if !strings.Contains(got, "body") { + t.Errorf("expected body preserved, got %q", got) + } +} + +// TestUpsertFields_Existing_NoChange covers the short-circuit path: +// mutate returns false (or err), the helper bails out with the original +// content and (false, nil/err). +func TestUpsertFields_Existing_NoChange(t *testing.T) { + const src = "---\nuid: present\n---\nbody\n" + + t.Run("mutate says no change", func(t *testing.T) { + got, changed, err := UpsertFields(src, true, func(f map[string]any) (bool, error) { + return false, nil // I made no changes + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if changed { + t.Errorf("expected changed=false") + } + if got != src { + t.Errorf("expected unchanged content, got %q", got) + } + }) + + t.Run("mutate returns error", func(t *testing.T) { + wantErr := errors.New("boom") + got, changed, err := UpsertFields(src, true, func(f map[string]any) (bool, error) { + return false, wantErr + }) + if err == nil { + t.Fatalf("expected err") + } + if changed { + t.Errorf("expected changed=false on error") + } + if got != src { + t.Errorf("expected unchanged content on error, got %q", got) + } + }) +} + +// TestUpsertFields_ReadError covers the malformed-frontmatter case: Read +// returns an error; UpsertFields surfaces it and bails out. +func TestUpsertFields_ReadError(t *testing.T) { + // Unterminated YAML frontmatter (no closing ---). + const src = "---\nuid: oops\n" + + got, changed, err := UpsertFields(src, true, func(f map[string]any) (bool, error) { + t.Fatalf("mutate must not run when Read fails") + return true, nil + }) + if err == nil { + t.Fatalf("expected read error, got nil") + } + if changed { + t.Errorf("expected changed=false on read error") + } + if got != src { + t.Errorf("expected unchanged content on read error, got %q", got) + } +} + +// TestUpsertFields_BodyPreserved covers that body content is not +// mangled when adding frontmatter: the style.Newline is prepended +// exactly once if not already present. +func TestUpsertFields_BodyPreserved(t *testing.T) { + t.Run("body without leading newline", func(t *testing.T) { + const body = "First paragraph.\n" + got, _, err := UpsertFields(body, true, func(f map[string]any) (bool, error) { + f["uid"] = "x" + return true, nil + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + // Frontmatter block + body's "First paragraph." must still appear + // verbatim. The helper inserts a single leading newline after the + // closing delimiter if the body didn't have one, so the body + // itself is unchanged. + if !strings.Contains(got, "First paragraph.") { + t.Errorf("body not preserved: %q", got) + } + }) + + t.Run("body with leading newline", func(t *testing.T) { + const body = "\nleading-newline body\n" + got, _, err := UpsertFields(body, true, func(f map[string]any) (bool, error) { + f["uid"] = "x" + return true, nil + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if !strings.Contains(got, "leading-newline body") { + t.Errorf("body not preserved: %q", got) + } + }) +} diff --git a/internal/git/client.go b/internal/git/client.go index 4231f6ed..66508b84 100644 --- a/internal/git/client.go +++ b/internal/git/client.go @@ -1,6 +1,7 @@ package git import ( + "context" "log/slog" "os" "path/filepath" @@ -45,8 +46,8 @@ func (c *Client) WithRemoteHeadCache(cache *RemoteHeadCache) *Client { // Returns the local filesystem path and any error. // // Deprecated: Use CloneRepoWithMetadata for commit metadata. -func (c *Client) CloneRepo(repo appcfg.Repository) (string, error) { - result, err := c.CloneRepoWithMetadata(repo) +func (c *Client) CloneRepo(ctx context.Context, repo appcfg.Repository) (string, error) { + result, err := c.CloneRepoWithMetadata(ctx, repo) if err != nil { return "", err } @@ -55,22 +56,22 @@ func (c *Client) CloneRepo(repo appcfg.Repository) (string, error) { // UpdateRepo updates an existing repository in the workspace. // If retry is enabled, it wraps the operation with retry logic. -func (c *Client) UpdateRepo(repo appcfg.Repository) (string, error) { +func (c *Client) UpdateRepo(ctx context.Context, repo appcfg.Repository) (string, error) { if c.inRetry { return c.updateOnce(repo) } - return c.withRetry("update", repo.Name, func() (string, error) { + return withRetry(ctx, c, "update", repo.Name, func() (string, error) { return c.updateOnce(repo) }) } // CloneRepoWithMetadata clones a repository and returns metadata including commit date. // If retry is enabled, it wraps the operation with retry logic. -func (c *Client) CloneRepoWithMetadata(repo appcfg.Repository) (CloneResult, error) { +func (c *Client) CloneRepoWithMetadata(ctx context.Context, repo appcfg.Repository) (CloneResult, error) { if c.inRetry { return c.cloneOnceWithMetadata(repo) } - return c.withRetryMetadata("clone", repo.Name, func() (CloneResult, error) { + return withRetry(ctx, c, "clone", repo.Name, func() (CloneResult, error) { return c.cloneOnceWithMetadata(repo) }) } diff --git a/internal/git/git_retry_diverge_test.go b/internal/git/git_retry_diverge_test.go index 11dbaa19..a38dee33 100644 --- a/internal/git/git_retry_diverge_test.go +++ b/internal/git/git_retry_diverge_test.go @@ -1,6 +1,7 @@ package git import ( + "context" "errors" "os" "path/filepath" @@ -23,7 +24,7 @@ func TestWithRetryBehavior(t *testing.T) { attempts := 0 // Transient failure first 2 attempts, then success - path, err := c.withRetry("clone", "repo", func() (string, error) { + path, err := withRetry(context.Background(), c, "clone", "repo", func() (string, error) { if attempts < 2 { attempts++ return "", errors.New("temporary network failure") @@ -43,7 +44,7 @@ func TestWithRetryBehavior(t *testing.T) { // Permanent error should not retry attempts = 0 - _, err = c.withRetry("clone", "repo", func() (string, error) { + _, err = withRetry(context.Background(), c, "clone", "repo", func() (string, error) { attempts++ return "", errors.New("authentication failed: permission denied") }) diff --git a/internal/git/hash.go b/internal/git/hash.go index 62c38782..fbacf3f6 100644 --- a/internal/git/hash.go +++ b/internal/git/hash.go @@ -12,6 +12,8 @@ import ( "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // RepoTree represents a snapshot of a repository at a specific commit. @@ -74,7 +76,7 @@ func ComputeRepoHash(repoPath, commit string, paths []string) (string, error) { if path == "." || path == "" { // Hash entire tree if err := hashTree(tree, "", &fileHashes); err != nil { - return "", fmt.Errorf("hash tree: %w", err) + return "", derrors.WrapError(err, derrors.CategoryInternal, "hash tree").Build() } continue } @@ -96,7 +98,9 @@ func ComputeRepoHash(repoPath, commit string, paths []string) (string, error) { continue } if err := hashTree(subtree, path, &fileHashes); err != nil { - return "", fmt.Errorf("hash subtree %s: %w", path, err) + return "", derrors.WrapError(err, derrors.CategoryInternal, "hash subtree"). + WithContext("path", path). + Build() } } } diff --git a/internal/git/remote_cache.go b/internal/git/remote_cache.go index 685e5529..7954c464 100644 --- a/internal/git/remote_cache.go +++ b/internal/git/remote_cache.go @@ -15,7 +15,7 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport" appcfg "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" ) @@ -105,7 +105,9 @@ func (c *Client) GetRemoteHead(repo appcfg.Repository, branch string) (string, e } } - return "", fmt.Errorf("branch %s not found on remote", branch) + return "", derrors.NewError(derrors.CategoryNotFound, "branch not found on remote"). + WithContext("branch", branch). + Build() } // CheckRemoteChanged checks if remote HEAD has changed since last fetch. @@ -203,7 +205,7 @@ func (c *RemoteHeadCache) Save() error { // Ensure directory exists if err := os.MkdirAll(filepath.Dir(c.path), 0o750); err != nil { - return errors.NewError(errors.CategoryFileSystem, "failed to create cache directory"). + return derrors.NewError(derrors.CategoryFileSystem, "failed to create cache directory"). WithCause(err). WithContext("path", filepath.Dir(c.path)). Build() @@ -217,7 +219,7 @@ func (c *RemoteHeadCache) Save() error { } if err := os.WriteFile(c.path, data, 0o600); err != nil { - return errors.NewError(errors.CategoryFileSystem, "failed to write cache file"). + return derrors.NewError(derrors.CategoryFileSystem, "failed to write cache file"). WithCause(err). WithContext("path", c.path). Build() diff --git a/internal/git/retry.go b/internal/git/retry.go index bef7c37f..27b944e6 100644 --- a/internal/git/retry.go +++ b/internal/git/retry.go @@ -1,6 +1,7 @@ package git import ( + "context" stdErrors "errors" "log/slog" "net" @@ -8,13 +9,23 @@ import ( "time" appcfg "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" "git.home.luguber.info/inful/docbuilder/internal/retry" ) -// withRetry wraps an operation with retry logic based on build configuration. -func (c *Client) withRetry(op, repoName string, fn func() (string, error)) (string, error) { +// withRetry runs fn with the client's configured retry policy, then wraps +// any per-failure error returned after retries in a classified GitError. +// +// It is generic over the success type so the string- and CloneResult-shaped +// callers share a single loop. c.inRetry is set for the duration of each +// fn call so callers like UpdateRepo/CloneRepoWithMetadata skip wrapping +// nested retry invocations. +// +// Free-function (rather than a method on *Client) because Go 1.25 does not +// allow type parameters on methods. +func withRetry[T any](ctx context.Context, c *Client, op, repoName string, fn func() (T, error)) (T, error) { + var zero T if c.buildCfg == nil || c.buildCfg.MaxRetries <= 0 { return fn() } @@ -26,111 +37,53 @@ func (c *Client) withRetry(op, repoName string, fn func() (string, error)) (stri if maxDelay <= 0 { maxDelay = 10 * time.Second } - pol := retry.NewPolicy(appcfg.RetryBackoffMode(strings.ToLower(string(c.buildCfg.RetryBackoff))), initial, maxDelay, c.buildCfg.MaxRetries) + policy := retry.NewPolicy(appcfg.RetryBackoffMode(strings.ToLower(string(c.buildCfg.RetryBackoff))), initial, maxDelay, c.buildCfg.MaxRetries) - // Adaptive delay multipliers keyed by retry strategy - const ( - multRateLimit = 3.0 - multBackoff = 1.0 - ) - var lastErr error - for attempt := 0; attempt <= c.buildCfg.MaxRetries; attempt++ { - if attempt > 0 { - slog.Warn("retrying git operation", slog.String("operation", op), logfields.Name(repoName), slog.Int("attempt", attempt)) - } - c.inRetry = true - path, err := fn() - c.inRetry = false - if err == nil { - return path, nil - } - lastErr = err - if isPermanentGitError(err) { - slog.Error("permanent git error", slog.String("operation", op), logfields.Name(repoName), slog.String("error", err.Error())) - return "", err - } - if attempt == c.buildCfg.MaxRetries { - break - } - delay := pol.Delay(attempt + 1) // base delay - // Adjust delay based on retry strategy - if ce, ok := errors.AsClassified(err); ok { - switch ce.RetryStrategy() { - case errors.RetryRateLimit: - delay = time.Duration(float64(delay) * multRateLimit) - case errors.RetryBackoff: - delay = time.Duration(float64(delay) * multBackoff) - case errors.RetryNever, errors.RetryImmediate, errors.RetryUserAction: - // Other strategies use base delay + const multRateLimit = 3.0 + var held T + runErr := policy.Do( + ctx, + func(_ context.Context) error { + c.inRetry = true + defer func() { c.inRetry = false }() + v, e := fn() + if e == nil { + held = v } - } - - time.Sleep(delay) - } - return "", GitError("git operation failed after retries"). - WithCause(lastErr). - WithContext("op", op). - WithContext("repo", repoName). - Build() -} - -// withRetryMetadata wraps an operation returning CloneResult with retry logic. -func (c *Client) withRetryMetadata(op, repoName string, fn func() (CloneResult, error)) (CloneResult, error) { - if c.buildCfg == nil || c.buildCfg.MaxRetries <= 0 { - return fn() - } - initial, _ := time.ParseDuration(c.buildCfg.RetryInitialDelay) - if initial <= 0 { - initial = 500 * time.Millisecond - } - maxDelay, _ := time.ParseDuration(c.buildCfg.RetryMaxDelay) - if maxDelay <= 0 { - maxDelay = 10 * time.Second - } - pol := retry.NewPolicy(appcfg.RetryBackoffMode(strings.ToLower(string(c.buildCfg.RetryBackoff))), initial, maxDelay, c.buildCfg.MaxRetries) - - const ( - multRateLimit = 3.0 - multBackoff = 1.0 + return e + }, + retry.RetryHooks{ + IsRetryable: func(err error) bool { return !isPermanentGitError(err) }, + AdjustDelay: func(err error, base time.Duration) time.Duration { + ce, ok := derrors.AsClassified(err) + if !ok { + return base + } + if ce.RetryStrategy() == derrors.RetryRateLimit { + return time.Duration(float64(base) * multRateLimit) + } + return base + }, + OnRetry: func(attempt int, _ error) { + slog.Warn("retrying git operation", slog.String("operation", op), logfields.Name(repoName), slog.Int("attempt", attempt)) + }, + }, ) - var lastErr error - for attempt := 0; attempt <= c.buildCfg.MaxRetries; attempt++ { - if attempt > 0 { - slog.Warn("retrying git operation", slog.String("operation", op), logfields.Name(repoName), slog.Int("attempt", attempt)) - } - c.inRetry = true - result, err := fn() - c.inRetry = false - if err == nil { - return result, nil - } - lastErr = err - if isPermanentGitError(err) { - slog.Error("permanent git error", slog.String("operation", op), logfields.Name(repoName), slog.String("error", err.Error())) - return CloneResult{}, err - } - if attempt == c.buildCfg.MaxRetries { - break - } - delay := pol.Delay(attempt + 1) - // Adjust delay based on retry strategy - if ce, ok := errors.AsClassified(err); ok { - switch ce.RetryStrategy() { - case errors.RetryRateLimit: - delay = time.Duration(float64(delay) * multRateLimit) - case errors.RetryBackoff: - delay = time.Duration(float64(delay) * multBackoff) - case errors.RetryNever, errors.RetryImmediate, errors.RetryUserAction: - // Other strategies use base delay - } + if runErr != nil { + // Permanent errors short-circuit through IsRetryable=false; the + // classifier has already added context (category, retry strategy). + // Wrap the *transient-exhaustion* case so callers see a single + // classified error rather than the raw network/timeout cause. + if isPermanentGitError(runErr) { + return zero, runErr } - time.Sleep(delay) + return zero, GitError("git operation failed after retries"). + WithCause(runErr). + WithContext("op", op). + WithContext("repo", repoName). + Build() } - return CloneResult{}, GitError("git operation failed after retries"). - WithCause(lastErr). - WithContext("op", op). - WithContext("repo", repoName). - Build() + return held, nil } func isPermanentGitError(err error) bool { @@ -138,8 +91,8 @@ func isPermanentGitError(err error) bool { return false } // Prefer structured strategy if available - if ce, ok := errors.AsClassified(err); ok { - return ce.RetryStrategy() == errors.RetryNever + if ce, ok := derrors.AsClassified(err); ok { + return ce.RetryStrategy() == derrors.RetryNever } msg := strings.ToLower(err.Error()) @@ -159,5 +112,5 @@ func isPermanentGitError(err error) bool { return false } -// IsPermanentGitError is exposed for tests within package. +// IsPermanentGitError exposes isPermanentGitError for tests within package. var IsPermanentGitError = isPermanentGitError diff --git a/internal/git/retry_adaptive_test.go b/internal/git/retry_adaptive_test.go index a45bc1a1..42b55ebf 100644 --- a/internal/git/retry_adaptive_test.go +++ b/internal/git/retry_adaptive_test.go @@ -1,6 +1,7 @@ package git import ( + "context" "testing" "time" @@ -12,7 +13,7 @@ func TestAdaptiveRetryRateLimit(t *testing.T) { c := &Client{workspaceDir: t.TempDir(), buildCfg: &appcfg.BuildConfig{MaxRetries: 2, RetryBackoff: appcfg.RetryBackoffFixed, RetryInitialDelay: "10ms", RetryMaxDelay: "50ms"}} calls := 0 start := time.Now() - _, err := c.withRetry("clone", "repo", func() (string, error) { + _, err := withRetry(context.Background(), c, "clone", "repo", func() (string, error) { calls++ if calls < 3 { // fail first two attempts return "", GitError("rate limit exceeded").RateLimit().Build() diff --git a/internal/hashutil/hashutil.go b/internal/hashutil/hashutil.go new file mode 100644 index 00000000..e2213879 --- /dev/null +++ b/internal/hashutil/hashutil.go @@ -0,0 +1,51 @@ +// Package hashutil centralizes small content-hashing helpers used across +// the build pipeline. The first one, PathsHash, lived as a 5-line +// sha256-with-NUL-separator block in two places (build/delta/delta_analyzer.go +// and build/delta/manager.go). The plan's M8 called them out as +// "byte-for-byte identical"; in practice the two callers had diverged +// slightly (one sorted, the other didn't; one short-circuited empty +// paths, the other did not). The version below is the canonical +// semantics: empty -> "", sorted NUL-separated SHA-256 -> hex. +package hashutil + +import ( + "crypto/sha256" + "encoding/hex" + "sort" +) + +// separator is NUL because the legal Unix/Linux/macOS path byte never +// contains it. Slashes/commas/spaces are not safe separators (a path +// containing the separator could collide with a multi-path input). +const separator = byte(0) + +// PathsHash returns a stable hash of a set of paths, suitable for +// "did the set of files change?" comparisons across builds. It hashes +// the sorted concatenation with a NUL separator between paths. +// +// Sorting matters: filepath.Walk returns paths in filesystem-dependent +// order, so without a stable sort the same set of files on the same +// tree can produce different hashes across runs. Sorting here is the +// single canonical place where the order is pinned; callers pass the +// slice they have and never need to sort. +// +// Returns "" when paths is empty; callers persist that as "no doc +// files for this repo", which downstream treats as an explicit +// "nothing to check" rather than a missing-value sentinel. +func PathsHash(paths []string) string { + if len(paths) == 0 { + return "" + } + // Copy so we don't mutate the caller's slice. Many callers pass + // slices they iterate immediately afterwards; sorting in place + // would surprise them. + sorted := append([]string(nil), paths...) + sort.Strings(sorted) + + h := sha256.New() + for _, p := range sorted { + h.Write([]byte(p)) + h.Write([]byte{separator}) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/hashutil/hashutil_test.go b/internal/hashutil/hashutil_test.go new file mode 100644 index 00000000..6d9d35b1 --- /dev/null +++ b/internal/hashutil/hashutil_test.go @@ -0,0 +1,77 @@ +package hashutil + +import "testing" + +// TestPathsHash pins: +// - empty input returns "" (no sentinel value, no panic); +// - the canonical NUL-separator sha256-of-sorted-paths shape so a +// regression that flips separator or removes the sort is caught. +// - order-independence for any permutation of the same set. +func TestPathsHash(t *testing.T) { + t.Run("empty", func(t *testing.T) { + if got := PathsHash(nil); got != "" { + t.Errorf("PathsHash(nil) = %q, want empty", got) + } + if got := PathsHash([]string{}); got != "" { + t.Errorf("PathsHash(empty) = %q, want empty", got) + } + }) + + t.Run("single", func(t *testing.T) { + if got := PathsHash([]string{"docs/a.md"}); got == "" { + t.Errorf("single-element hash is empty, want non-empty") + } + // Re-pin a known hex digest to lock in the exact algorithm. + want := PathsHash([]string{"docs/a.md"}) + if got := PathsHash([]string{"docs/a.md"}); got != want { + t.Errorf("hash not deterministic across calls") + } + }) + + t.Run("order-independent", func(t *testing.T) { + // Add a separator-sensitive edge: "a/b" + "c" must hash + // differently from "a" + "b/c". The NUL separator guarantees + // this; we encode it as a regression check. + a := PathsHash([]string{"a/b", "c"}) + b := PathsHash([]string{"c", "a/b"}) + if a != b { + t.Errorf("order should not affect hash, got %q vs %q", a, b) + } + collision := PathsHash([]string{"a", "b/c"}) + if a == collision { + t.Errorf("NUL separator should keep {a/b, c} distinct from {a, b/c}") + } + + // Multi-element set, several permutations. + set := []string{"docs/a.md", "docs/b.md", "docs/c.md"} + perm1 := PathsHash(set) + perm2 := PathsHash([]string{"docs/c.md", "docs/a.md", "docs/b.md"}) + if perm1 != perm2 { + t.Errorf("multi-element set should be order-independent: %q vs %q", perm1, perm2) + } + }) + + t.Run("set-distinct", func(t *testing.T) { + // Different sets produce different hashes; a single path + // change flips the digest. + base := PathsHash([]string{"docs/a.md", "docs/b.md", "docs/c.md"}) + added := PathsHash([]string{"docs/a.md", "docs/b.md", "docs/c.md", "docs/d.md"}) + removed := PathsHash([]string{"docs/a.md", "docs/b.md"}) + if base == added || base == removed || added == removed { + t.Errorf("distinct sets must produce distinct hashes; got base=%q added=%q removed=%q", base, added, removed) + } + }) + + t.Run("input-not-mutated", func(t *testing.T) { + // PathsHash sorts a copy; the caller's slice order must + // remain whatever they passed in. + set := []string{"z", "y", "x", "a"} + before := append([]string(nil), set...) + _ = PathsHash(set) + for i, v := range before { + if set[i] != v { + t.Fatalf("PathsHash mutated caller slice at %d: before %q after %q", i, v, set[i]) + } + } + }) +} diff --git a/internal/hugo/build_report_golden_test.go b/internal/hugo/build_report_golden_test.go index 35ee2215..75a94796 100644 --- a/internal/hugo/build_report_golden_test.go +++ b/internal/hugo/build_report_golden_test.go @@ -17,7 +17,7 @@ func TestBuildReportGolden(t *testing.T) { r.RenderedPages = 5 r.StageDurations["prepare_output"] = 10 * time.Millisecond r.StageErrorKinds[models.StagePrepareOutput] = "" // no error - r.RecordStageResult(models.StagePrepareOutput, models.StageResultSuccess, nil) + r.RecordStageResult(models.StagePrepareOutput, models.StageResultSuccess) r.Finish() r.DeriveOutcome() diff --git a/internal/hugo/build_report_stability_test.go b/internal/hugo/build_report_stability_test.go index a84c6299..16c35688 100644 --- a/internal/hugo/build_report_stability_test.go +++ b/internal/hugo/build_report_stability_test.go @@ -21,7 +21,7 @@ func TestBuildReportStability(t *testing.T) { r.ClonedRepositories = 1 r.RenderedPages = 3 r.StageDurations["prepare_output"] = 123 * time.Millisecond - r.RecordStageResult(models.StagePrepareOutput, models.StageResultSuccess, nil) + r.RecordStageResult(models.StagePrepareOutput, models.StageResultSuccess) r.Finish() r.DeriveOutcome() r.ConfigHash = "deadbeef" // deterministic stub diff --git a/internal/hugo/categories_menu.go b/internal/hugo/categories_menu.go index 08af43f9..1ce57f6f 100644 --- a/internal/hugo/categories_menu.go +++ b/internal/hugo/categories_menu.go @@ -1,7 +1,6 @@ package hugo import ( - "fmt" "log/slog" "slices" "sort" @@ -9,6 +8,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/frontmatterops" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" "git.home.luguber.info/inful/docbuilder/internal/logfields" @@ -124,7 +124,10 @@ var UncategorizedCategory = config.SidebarUncategorizedCategory // place the configured field would have occupied. func buildCategoriesMenu(items []categoryDoc, repos []config.Repository, projectSegment string, groupBy map[string][]string) (*models.CategoriesMenu, error) { if projectSegment != "repo" { - return nil, fmt.Errorf("unsupported sidebar project_segment %q (only \"repo\" is supported)", projectSegment) + return nil, derrors.NewError(derrors.CategoryConfig, "unsupported sidebar project_segment"). + WithContext("project_segment", projectSegment). + WithContext("supported", "repo"). + Build() } cm := &models.CategoriesMenu{ Menus: map[string][]models.MenuEntry{}, diff --git a/internal/hugo/config_writer.go b/internal/hugo/config_writer.go index d6337480..75e4d78c 100644 --- a/internal/hugo/config_writer.go +++ b/internal/hugo/config_writer.go @@ -1,7 +1,6 @@ package hugo import ( - "fmt" "log/slog" "os" "path/filepath" @@ -11,6 +10,7 @@ import ( "gopkg.in/yaml.v3" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" herrors "git.home.luguber.info/inful/docbuilder/internal/hugo/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" ) @@ -43,7 +43,7 @@ func (g *Generator) GenerateHugoConfig() error { // Phase 3: User overrides (deep merge) if g.config.Hugo.Params != nil { - mergeParams(params, g.config.Hugo.Params) + mergeParamsInline(params, g.config.Hugo.Params) } // Phase 4: Dynamic fields @@ -135,12 +135,14 @@ func (g *Generator) GenerateHugoConfig() error { data, err := yaml.Marshal(root) if err != nil { - return fmt.Errorf("%w: %w", herrors.ErrConfigMarshalFailed, err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to marshal hugo config"). + WithCause(herrors.ErrConfigMarshalFailed). + Build() } // #nosec G306 -- hugo.yaml is a public configuration file if err := os.WriteFile(configPath, data, 0o644); err != nil { - return fmt.Errorf("failed to write hugo config: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write hugo config").Build() } // Ensure go.mod for Hugo Modules (Relearn requires this) @@ -309,3 +311,25 @@ func hasAutoVariant(themeVariant any) bool { } // (legacy param helpers removed) + +// mergeParamsInline deep-merges src into dst (map[string]any). +// - Maps: merged recursively +// - Slices & scalars: replaced. +func mergeParamsInline(dst, src map[string]any) { + if src == nil { + return + } + for k, v := range src { + if mv, ok := v.(map[string]any); ok { + if existing, ok2 := dst[k].(map[string]any); ok2 { + mergeParamsInline(existing, mv) + } else { + cp := map[string]any{} + mergeParamsInline(cp, mv) + dst[k] = cp + } + continue + } + dst[k] = v + } +} diff --git a/internal/hugo/content/doc.go b/internal/hugo/content/doc.go deleted file mode 100644 index 964e6c0c..00000000 --- a/internal/hugo/content/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package content contains content assembly helpers used during site generation. -package content diff --git a/internal/hugo/content/links.go b/internal/hugo/content/links.go deleted file mode 100644 index 6549a9f7..00000000 --- a/internal/hugo/content/links.go +++ /dev/null @@ -1,140 +0,0 @@ -package content - -import ( - "fmt" - "regexp" - "strings" -) - -// RewriteRelativeMarkdownLinks rewrites relative markdown links that end with .md/.markdown -// to their extensionless form with trailing slashes for Hugo's pretty URLs. Anchors are preserved. -// Rules: -// - Only adjust links that are NOT absolute (http/https/mailto) or anchor-only ('#') -// - Rewrites foo.md -> foo/, foo.md#anchor -> foo/#anchor -// - Supports ./ and ../ prefixes transparently (kept as-is except extension removal) -// - If repositoryName is provided, links starting with / are treated as repository-root-relative -// and are prefixed with /{forge}/{repository}/ or /{repository}/ depending on forge presence -// - isIndexPage: if true, the file is a _index.md at the section root, so relative links don't need extra ../ -// - Leaves README.md alone if removal would produce empty target (defensive) -func RewriteRelativeMarkdownLinks(content string, repositoryName string, forgeName string, isIndexPage bool) string { - linkRe := regexp.MustCompile(`\[(?P[^\]]+)\]\((?P[^)]+)\)`) - return linkRe.ReplaceAllStringFunc(content, func(m string) string { - matches := linkRe.FindStringSubmatch(m) - if len(matches) != 3 { - return m - } - text := matches[1] - link := matches[2] - low := strings.ToLower(link) - // Skip external links, anchors, and mailto - if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") || strings.HasPrefix(low, "mailto:") || strings.HasPrefix(link, "#") { - return m - } - - // Handle repository-root-relative links (start with /) - // These need to be prefixed with repository (and forge if present) to work in Hugo - if strings.HasPrefix(link, "/") && repositoryName != "" { - return rewriteAbsoluteLink(link, text, repositoryName, forgeName) - } - - // Handle page-relative links (no leading /) - // Hugo URLs are one level deeper than source files (page name becomes a directory) - // So ../foo.md in source needs to become ../../foo/ in Hugo - anchor := "" - if idx := strings.IndexByte(link, '#'); idx >= 0 { - anchor = link[idx:] - link = link[:idx] - } - lowerPath := strings.ToLower(link) - if strings.HasSuffix(lowerPath, ".md") || strings.HasSuffix(lowerPath, ".markdown") { - rewritten := rewriteMarkdownLink(link, lowerPath, anchor, text, isIndexPage) - return rewritten - } - return m - }) -} - -// rewriteMarkdownLink handles rewriting of markdown file links for Hugo. -func rewriteMarkdownLink(link, lowerPath, anchor, text string, isIndexPage bool) string { - trimmed := trimMarkdownExtension(link, lowerPath) - if trimmed == "" { - return fmt.Sprintf("[%s](%s)", text, link) - } - - // Normalize ./ prefix for regular pages (non-index) - // For index pages (_index.md), preserve ./ to maintain section-relative context - // For regular pages, ./foo.md and foo.md are equivalent and both need ../ - hasCurrentDirPrefix := strings.HasPrefix(trimmed, "./") - if hasCurrentDirPrefix && !isIndexPage { - trimmed = trimmed[2:] // Remove ./ for regular pages - } - - // Add trailing slash for Hugo's pretty URLs - if !strings.HasSuffix(trimmed, "/") { - trimmed += "/" - } - - // Adjust relative paths for Hugo's deeper URL structure - // Hugo URLs are one level deeper than source files because the page name becomes a directory - // For regular pages (not _index.md): - // - Same-directory link: architecture.md → ../architecture/ - // - Same-directory explicit: ./ref.md → ../ref/ (after ./ removal) - // - Parent directory link: ../other.md → ../../other/ - // - Child directory link: guide/setup.md → ../guide/setup/ - // For index pages (_index.md): relative links stay as-is (already at section root) - // - Same-directory link: guide.md → guide/ - // - Same-directory explicit: ./guide.md → ./guide/ (preserve ./) - // - Parent directory link: ../other.md → ../other/ - if !isIndexPage { - trimmed = "../" + trimmed - } - return fmt.Sprintf("[%s](%s%s)", text, trimmed, anchor) -} - -// trimMarkdownExtension removes .md or .markdown extension from a link. -func trimMarkdownExtension(link, lowerPath string) string { - if strings.HasSuffix(lowerPath, ".md") { - return link[:len(link)-3] - } - if strings.HasSuffix(lowerPath, ".markdown") { - return link[:len(link)-9] - } - return link -} - -// rewriteAbsoluteLink rewrites repository-root-relative links (starting with /). -// Adds repository (and forge) prefix and converts .md links to Hugo pretty URLs. -func rewriteAbsoluteLink(link, text, repositoryName, forgeName string) string { - anchor := "" - if idx := strings.IndexByte(link, '#'); idx >= 0 { - anchor = link[idx:] - link = link[:idx] - } - - lowerPath := strings.ToLower(link) - trimmed := link - if strings.HasSuffix(lowerPath, ".md") { - trimmed = link[:len(link)-3] - } else if strings.HasSuffix(lowerPath, ".markdown") { - trimmed = link[:len(link)-9] - } - - if trimmed == "" { - return fmt.Sprintf("[%s](%s)", text, link) - } - - // Add trailing slash for Hugo's pretty URLs - if !strings.HasSuffix(trimmed, "/") { - trimmed += "/" - } - - // Build the Hugo-absolute path with repository (and forge if present) - var prefix string - if forgeName != "" { - prefix = fmt.Sprintf("/%s/%s", forgeName, repositoryName) - } else { - prefix = fmt.Sprintf("/%s", repositoryName) - } - - return fmt.Sprintf("[%s](%s%s%s)", text, prefix, trimmed, anchor) -} diff --git a/internal/hugo/content_copy_pipeline.go b/internal/hugo/content_copy_pipeline.go index 04d407f1..ab09838a 100644 --- a/internal/hugo/content_copy_pipeline.go +++ b/internal/hugo/content_copy_pipeline.go @@ -2,7 +2,6 @@ package hugo import ( "context" - "fmt" "io" "log/slog" "os" @@ -11,6 +10,8 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/hugo/models" "git.home.luguber.info/inful/docbuilder/internal/docs" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" herrors "git.home.luguber.info/inful/docbuilder/internal/hugo/errors" "git.home.luguber.info/inful/docbuilder/internal/hugo/pipeline" ) @@ -57,7 +58,9 @@ func (g *Generator) copyContentFilesPipeline(ctx context.Context, docFiles []doc } if err := g.copyAssetFile(*file, isSingleRepo); err != nil { - return fmt.Errorf("failed to copy asset %s: %w", file.Path, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to copy asset"). + WithContext("path", file.Path). + Build() } } @@ -68,8 +71,10 @@ func (g *Generator) copyContentFilesPipeline(ctx context.Context, docFiles []doc file := &markdownFiles[i] // Load content if err := file.LoadContent(); err != nil { - return fmt.Errorf("%w: failed to load content for %s: %w", - herrors.ErrContentTransformFailed, file.Path, err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to load content"). + WithCause(herrors.ErrContentTransformFailed). + WithContext("path", file.Path). + Build() } if publicOnly && !isPublicMarkdown(file.Content) { @@ -98,8 +103,9 @@ func (g *Generator) copyContentFilesPipeline(ctx context.Context, docFiles []doc processor := pipeline.NewProcessor(g.config) processedDocs, err := processor.ProcessContent(discovered, repoMetadata, isSingleRepo) if err != nil { - return fmt.Errorf("%w: pipeline processing failed: %w", - herrors.ErrContentTransformFailed, err) + return derrors.WrapError(err, derrors.CategoryInternal, "pipeline processing failed"). + WithCause(herrors.ErrContentTransformFailed). + Build() } slog.Info("Pipeline processing complete", @@ -119,8 +125,10 @@ func (g *Generator) copyContentFilesPipeline(ctx context.Context, docFiles []doc // Create directory if needed if err := os.MkdirAll(filepath.Dir(outputPath), 0o750); err != nil { - return fmt.Errorf("%w: failed to create directory for %s: %w", - herrors.ErrContentWriteFailed, outputPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create directory"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", outputPath). + Build() } contentBytes := doc.Raw @@ -128,8 +136,10 @@ func (g *Generator) copyContentFilesPipeline(ctx context.Context, docFiles []doc // Write file // #nosec G306 -- content files are public documentation if err := os.WriteFile(outputPath, contentBytes, 0o644); err != nil { - return fmt.Errorf("%w: failed to write file %s: %w", - herrors.ErrContentWriteFailed, outputPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write file"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", outputPath). + Build() } slog.Debug("Wrote processed document", @@ -154,7 +164,7 @@ func (g *Generator) copyContentFilesPipeline(ctx context.Context, docFiles []doc // Generate and write static assets (e.g., View Transitions) if err := g.generateStaticAssets(processor); err != nil { - return fmt.Errorf("failed to generate static assets: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to generate static assets").Build() } return nil @@ -164,7 +174,7 @@ func (g *Generator) copyContentFilesPipeline(ctx context.Context, docFiles []doc func (g *Generator) generateStaticAssets(processor *pipeline.Processor) error { assets, err := processor.GenerateStaticAssets() if err != nil { - return fmt.Errorf("static asset generation failed: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "static asset generation failed").Build() } if len(assets) == 0 { @@ -178,15 +188,19 @@ func (g *Generator) generateStaticAssets(processor *pipeline.Processor) error { // Create directory if needed if err := os.MkdirAll(filepath.Dir(outputPath), 0o750); err != nil { - return fmt.Errorf("%w: failed to create directory for %s: %w", - herrors.ErrContentWriteFailed, outputPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create directory"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", outputPath). + Build() } // Write asset file // #nosec G306 -- static assets are public files if err := os.WriteFile(outputPath, asset.Content, 0o644); err != nil { - return fmt.Errorf("%w: failed to write asset %s: %w", - herrors.ErrContentWriteFailed, outputPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write asset"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", outputPath). + Build() } slog.Debug("Wrote static asset", @@ -249,8 +263,10 @@ func (g *Generator) copyAssetFile(file docs.DocFile, isSingleRepo bool) error { // Open the asset file src, err := os.Open(file.Path) if err != nil { - return fmt.Errorf("%w: failed to open asset %s: %w", - herrors.ErrContentWriteFailed, file.Path, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to open asset"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", file.Path). + Build() } defer func() { if cerr := src.Close(); cerr != nil { @@ -263,8 +279,10 @@ func (g *Generator) copyAssetFile(file docs.DocFile, isSingleRepo bool) error { // Check file size against limit info, err := src.Stat() if err != nil { - return fmt.Errorf("%w: failed to stat asset %s: %w", - herrors.ErrContentWriteFailed, file.Path, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to stat asset"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", file.Path). + Build() } if g.config.Build.MaxAssetSize > 0 && info.Size() > g.config.Build.MaxAssetSize { @@ -280,16 +298,20 @@ func (g *Generator) copyAssetFile(file docs.DocFile, isSingleRepo bool) error { // Create directory if needed if mkdirErr := os.MkdirAll(filepath.Dir(outputPath), 0o750); mkdirErr != nil { - return fmt.Errorf("%w: failed to create directory for %s: %w", - herrors.ErrContentWriteFailed, outputPath, mkdirErr) + return derrors.WrapError(mkdirErr, derrors.CategoryFileSystem, "failed to create directory"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", outputPath). + Build() } // Stream the asset file as-is // #nosec G304 -- outputPath is constructed from trusted buildRoot and validated GetHugoPath dst, err := os.Create(outputPath) if err != nil { - return fmt.Errorf("%w: failed to create asset destination %s: %w", - herrors.ErrContentWriteFailed, outputPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create asset destination"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("path", outputPath). + Build() } defer func() { if cerr := dst.Close(); cerr != nil { @@ -300,8 +322,11 @@ func (g *Generator) copyAssetFile(file docs.DocFile, isSingleRepo bool) error { }() if _, err = io.Copy(dst, src); err != nil { - return fmt.Errorf("%w: failed to copy asset %s to %s: %w", - herrors.ErrContentWriteFailed, file.Path, outputPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to copy asset"). + WithCause(herrors.ErrContentWriteFailed). + WithContext("source", file.Path). + WithContext("destination", outputPath). + Build() } slog.Debug("Copied asset file", diff --git a/internal/hugo/editlink/configured_detector.go b/internal/hugo/editlink/configured_detector.go deleted file mode 100644 index dc863542..00000000 --- a/internal/hugo/editlink/configured_detector.go +++ /dev/null @@ -1,62 +0,0 @@ -package editlink - -import ( - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// ConfiguredDetector detects forge information from repository tags. -type ConfiguredDetector struct{} - -// NewConfiguredDetector creates a new detector that uses repository tags. -func NewConfiguredDetector() *ConfiguredDetector { - return &ConfiguredDetector{} -} - -// Name returns the detector name. -func (d *ConfiguredDetector) Name() string { - return "configured" -} - -// Detect attempts to extract forge information from repository tags. -func (d *ConfiguredDetector) Detect(ctx DetectionContext) DetectionResult { - if ctx.Repository == nil || ctx.Repository.Tags == nil { - return DetectionResult{Found: false} - } - - tags := ctx.Repository.Tags - - // Extract forge type from tags - var forgeType config.ForgeType - if t, ok := tags["forge_type"]; ok { - forgeType = config.NormalizeForgeType(t) - } - - // Extract full name from tags - var fullName string - if fn, ok := tags["full_name"]; ok && fn != "" { - fullName = fn - } - - // Extract base URL from forge configurations - baseURL := "" - if forgeType != "" && ctx.Config != nil { - for _, forge := range ctx.Config.Forges { - if forge != nil && forge.Type == forgeType { - baseURL = forge.BaseURL - break - } - } - } - - // Only return success if we have both forge type and full name - if forgeType != "" && fullName != "" { - return DetectionResult{ - ForgeType: forgeType, - BaseURL: baseURL, - FullName: fullName, - Found: true, - } - } - - return DetectionResult{Found: false} -} diff --git a/internal/hugo/editlink/detector.go b/internal/hugo/editlink/detector.go deleted file mode 100644 index 392ddc87..00000000 --- a/internal/hugo/editlink/detector.go +++ /dev/null @@ -1,127 +0,0 @@ -package editlink - -import ( - "path/filepath" - "strings" - - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/docs" -) - -// DetectionContext holds all the information needed for forge detection. -type DetectionContext struct { - File docs.DocFile - Config *config.Config - Repository *config.Repository - CloneURL string - Branch string - RepoRel string -} - -// DetectionResult contains the result of forge detection. -type DetectionResult struct { - ForgeType config.ForgeType - BaseURL string - FullName string - Found bool -} - -// ForgeDetector defines the interface for detecting forge type and details. -type ForgeDetector interface { - // Detect attempts to determine the forge type and details from the context. - // Returns a DetectionResult with Found=true if successful, Found=false otherwise. - Detect(ctx DetectionContext) DetectionResult - - // Name returns a human-readable name for this detector (for debugging/logging). - Name() string -} - -// DetectorChain implements a chain of responsibility pattern for forge detection. -type DetectorChain struct { - detectors []ForgeDetector -} - -// NewDetectorChain creates a new detector chain. -func NewDetectorChain() *DetectorChain { - return &DetectorChain{ - detectors: make([]ForgeDetector, 0), - } -} - -// Add appends a detector to the chain. -func (dc *DetectorChain) Add(detector ForgeDetector) *DetectorChain { - dc.detectors = append(dc.detectors, detector) - return dc -} - -// Detect runs through the chain of detectors until one succeeds. -func (dc *DetectorChain) Detect(ctx DetectionContext) DetectionResult { - for _, detector := range dc.detectors { - if result := detector.Detect(ctx); result.Found { - return result - } - } - return DetectionResult{Found: false} -} - -// EditURLBuilder constructs the final edit URL from detection results. -type EditURLBuilder interface { - BuildURL(forgeType config.ForgeType, baseURL, fullName, branch, repoRel string) string -} - -// Context preparation utilities - -// PrepareDetectionContext creates a DetectionContext from a DocFile and config. -func PrepareDetectionContext(file docs.DocFile, cfg *config.Config) (DetectionContext, bool) { - // Find repository configuration - var repoCfg *config.Repository - for i := range cfg.Repositories { - if cfg.Repositories[i].Name == file.Repository { - repoCfg = &cfg.Repositories[i] - break - } - } - if repoCfg == nil { - return DetectionContext{}, false - } - - // Determine branch - branch := repoCfg.Branch - if branch == "" { - branch = "main" - } - - // Calculate repository-relative path - repoRel := prepareRepoRelativePath(file) - - // Clean clone URL - cloneURL := prepareCloneURL(repoCfg.URL) - - return DetectionContext{ - File: file, - Config: cfg, - Repository: repoCfg, - CloneURL: cloneURL, - Branch: branch, - RepoRel: repoRel, - }, true -} - -// prepareRepoRelativePath calculates the repository-relative path for the file. -func prepareRepoRelativePath(file docs.DocFile) string { - repoRel := file.RelativePath - if base := strings.TrimSpace(file.DocsBase); base != "" && base != "." { - repoRel = filepath.ToSlash(filepath.Join(base, repoRel)) - } else { - repoRel = filepath.ToSlash(repoRel) - } - return repoRel -} - -// prepareCloneURL normalizes a clone URL by removing .git suffix. -func prepareCloneURL(url string) string { - if url == "" { - return "" - } - return strings.TrimSuffix(url, ".git") -} diff --git a/internal/hugo/editlink/forge_config_detector.go b/internal/hugo/editlink/forge_config_detector.go deleted file mode 100644 index 5eb4afad..00000000 --- a/internal/hugo/editlink/forge_config_detector.go +++ /dev/null @@ -1,113 +0,0 @@ -package editlink - -import ( - "net/url" - "strings" - - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// ForgeConfigDetector detects forge information by matching against configured forge base URLs. -type ForgeConfigDetector struct{} - -// NewForgeConfigDetector creates a new detector that uses forge configuration. -func NewForgeConfigDetector() *ForgeConfigDetector { - return &ForgeConfigDetector{} -} - -// Name returns the detector name. -func (d *ForgeConfigDetector) Name() string { - return "forge_config" -} - -// Detect attempts to match the repository URL against configured forges. -func (d *ForgeConfigDetector) Detect(ctx DetectionContext) DetectionResult { - forgeType, baseURL := d.resolveForgeForRepository(ctx.Config, ctx.CloneURL) - if forgeType == "" { - return DetectionResult{Found: false} - } - - // Extract full name from the URL - fullName := d.extractFullNameFromURL(ctx.CloneURL) - if fullName == "" { - return DetectionResult{Found: false} - } - - return DetectionResult{ - ForgeType: forgeType, - BaseURL: baseURL, - FullName: fullName, - Found: true, - } -} - -// resolveForgeForRepository attempts to match a repository clone URL against configured forge base URLs. -func (d *ForgeConfigDetector) resolveForgeForRepository(cfg *config.Config, repoURL string) (config.ForgeType, string) { - if cfg == nil || len(cfg.Forges) == 0 || repoURL == "" { - return "", "" - } - - normalized := d.normalizeSSHURL(repoURL) - - for _, fc := range cfg.Forges { - if fc == nil || fc.BaseURL == "" { - continue - } - - base := strings.TrimSuffix(fc.BaseURL, "/") - - // Direct prefix match - if strings.HasPrefix(normalized, base+"/") || strings.HasPrefix(normalized, base) { - return fc.Type, base - } - - // Host-based match - if d.hostsMatch(base, normalized) { - return fc.Type, base - } - } - - return "", "" -} - -// normalizeSSHURL converts SSH URLs to HTTPS format for easier comparison. -func (d *ForgeConfigDetector) normalizeSSHURL(repoURL string) string { - if !strings.HasPrefix(repoURL, "git@") { - return repoURL - } - - parts := strings.SplitN(strings.TrimPrefix(repoURL, "git@"), ":", 2) - if len(parts) == 2 { - return "https://" + parts[0] + "/" + parts[1] - } - - return repoURL -} - -// hostsMatch checks if two URLs have the same host. -func (d *ForgeConfigDetector) hostsMatch(url1, url2 string) bool { - u1, err1 := url.Parse(url1) - u2, err2 := url.Parse(url2) - - if err1 != nil || err2 != nil { - return false - } - - return u1.Host != "" && u1.Host == u2.Host -} - -// extractFullNameFromURL extracts the repository full name (owner/repo) from a URL. -func (d *ForgeConfigDetector) extractFullNameFromURL(cloneURL string) string { - normalized := d.normalizeSSHURL(cloneURL) - - u, err := url.Parse(normalized) - if err != nil { - return "" - } - - // Extract path and clean it - path := strings.Trim(u.Path, "/") - path = strings.TrimSuffix(path, ".git") - - return path -} diff --git a/internal/hugo/editlink/heuristic_detector.go b/internal/hugo/editlink/heuristic_detector.go deleted file mode 100644 index 63b1e337..00000000 --- a/internal/hugo/editlink/heuristic_detector.go +++ /dev/null @@ -1,155 +0,0 @@ -package editlink - -import ( - "fmt" - "net/url" - "strings" - - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// HeuristicDetector detects forge information using host-based heuristics. -type HeuristicDetector struct{} - -// NewHeuristicDetector creates a new detector that uses hostname heuristics. -func NewHeuristicDetector() *HeuristicDetector { - return &HeuristicDetector{} -} - -// Name returns the detector name. -func (d *HeuristicDetector) Name() string { - return "heuristic" -} - -// Detect attempts to determine forge type based on hostname patterns. -func (d *HeuristicDetector) Detect(ctx DetectionContext) DetectionResult { - cloneURL := ctx.CloneURL - if cloneURL == "" { - return DetectionResult{Found: false} - } - - // Skip local file paths (relative or absolute) - they have no web edit URL - if d.isLocalPath(cloneURL) { - return DetectionResult{Found: false} - } - - // Try different heuristic patterns - forgeType := d.detectForgeTypeFromHost(cloneURL) - if forgeType == "" { - return DetectionResult{Found: false} - } - - // Extract full name - fullName := d.extractFullNameFromURL(cloneURL) - if fullName == "" { - return DetectionResult{Found: false} - } - - // Determine base URL - baseURL := d.determineBaseURL(cloneURL, forgeType) - - return DetectionResult{ - ForgeType: forgeType, - BaseURL: baseURL, - FullName: fullName, - Found: true, - } -} - -// detectForgeTypeFromHost determines forge type based on hostname patterns. -func (d *HeuristicDetector) detectForgeTypeFromHost(cloneURL string) config.ForgeType { - switch { - case strings.Contains(cloneURL, "github."): - return config.ForgeGitHub - case strings.Contains(cloneURL, "gitlab."): - return config.ForgeGitLab - case strings.Contains(cloneURL, "bitbucket.org"): - // Special case: Bitbucket uses custom URL format - return config.ForgeForgejo // Use Forgejo as placeholder since Bitbucket isn't defined - case strings.Contains(cloneURL, "forgejo") || strings.Contains(cloneURL, "gitea"): - return config.ForgeForgejo - default: - // Assume Forgejo/Gitea for unknown self-hosted instances - // This preserves backward compatibility with tests - return config.ForgeForgejo - } -} - -// extractFullNameFromURL extracts the repository full name (owner/repo) from a URL. -func (d *HeuristicDetector) extractFullNameFromURL(cloneURL string) string { - normalized := d.normalizeSSHURL(cloneURL) - - u, err := url.Parse(normalized) - if err != nil { - return "" - } - - // Extract path and clean it - path := strings.Trim(u.Path, "/") - path = strings.TrimSuffix(path, ".git") - - return path -} - -// normalizeSSHURL converts SSH URLs to HTTPS format for easier parsing. -func (d *HeuristicDetector) normalizeSSHURL(repoURL string) string { - if !strings.HasPrefix(repoURL, "git@") { - return repoURL - } - - parts := strings.SplitN(strings.TrimPrefix(repoURL, "git@"), ":", 2) - if len(parts) == 2 { - return "https://" + parts[0] + "/" + parts[1] - } - - return repoURL -} - -// determineBaseURL calculates the base URL for a given clone URL and forge type. -func (d *HeuristicDetector) determineBaseURL(cloneURL string, forgeType config.ForgeType) string { - normalized := d.normalizeSSHURL(cloneURL) - - // Special handling for Bitbucket - if strings.Contains(cloneURL, "bitbucket.org") { - return "https://bitbucket.org" - } - - // Try to extract from the URL - if u, err := url.Parse(normalized); err == nil && u.Scheme != "" && u.Host != "" { - return fmt.Sprintf("%s://%s", u.Scheme, u.Host) - } - - // Fallback to public defaults - switch forgeType { - case config.ForgeGitHub: - return "https://github.com" - case config.ForgeGitLab: - return "https://gitlab.com" - case config.ForgeForgejo: - // For Forgejo/Gitea, use the clone URL as base - return cloneURL - case config.ForgeLocal: - // For local forges, return the clone URL as-is (it's the base) - return cloneURL - default: - return "" - } -} - -// isLocalPath checks if a URL is a local file path (not a remote git URL). -// Returns true for relative paths (./, ../, bare paths) and absolute paths (/, C:\, /home/...). -func (d *HeuristicDetector) isLocalPath(urlStr string) bool { - // Check for URL schemes that indicate remote repositories - if strings.HasPrefix(urlStr, "http://") || strings.HasPrefix(urlStr, "https://") { - return false - } - if strings.HasPrefix(urlStr, "git@") || strings.HasPrefix(urlStr, "ssh://") { - return false - } - if strings.HasPrefix(urlStr, "git://") { - return false - } - - // If it doesn't have a remote scheme, it's a local path - return true -} diff --git a/internal/hugo/editlink/resolver.go b/internal/hugo/editlink/resolver.go index 30ed6a08..a0ee8164 100644 --- a/internal/hugo/editlink/resolver.go +++ b/internal/hugo/editlink/resolver.go @@ -1,82 +1,93 @@ +// Package editlink resolves edit URLs for documentation files. +// +// The package exposes a single Resolver type. Resolution runs a fixed chain +// of detectors in priority order: +// +// 1. VSCode (local preview mode) +// 2. Configured (repository tags) +// 3. ForgeConfig (configured forge base URLs) +// 4. Heuristic (hostname pattern matching) +// +// The first detector that returns Found=true wins; the resolver then builds +// the URL using forge.GenerateEditURL (with special handling for VS Code and +// Bitbucket). package editlink import ( + "fmt" + "net/url" + "path/filepath" + "strings" + "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" + "git.home.luguber.info/inful/docbuilder/internal/forge" + "git.home.luguber.info/inful/docbuilder/internal/urlutil" ) -// Resolver provides edit link resolution with a simplified, testable interface. -// It replaces the complex monolithic EditLinkResolver.Resolve method with a -// chain of responsibility pattern for better maintainability. -type Resolver struct { - detectorChain *DetectorChain - urlBuilder EditURLBuilder -} +// Resolver provides edit link resolution. +type Resolver struct{} // NewResolver creates a new edit link resolver with the standard detector chain. func NewResolver() *Resolver { - chain := NewDetectorChain(). - Add(NewVSCodeDetector()). // Try VS Code local preview first - Add(NewConfiguredDetector()). // Try repository tags - Add(NewForgeConfigDetector()). // Then try forge configuration - Add(NewHeuristicDetector()) // Finally try hostname heuristics + return &Resolver{} +} - return &Resolver{ - detectorChain: chain, - urlBuilder: NewStandardEditURLBuilder(), - } +// detectionContext holds the resolved inputs for a single Resolve call. +type detectionContext struct { + config *config.Config + repository *config.Repository + cloneURL string + branch string + repoRel string } -// NewResolverWithChain creates a resolver with a custom detector chain (for testing). -func NewResolverWithChain(chain *DetectorChain, builder EditURLBuilder) *Resolver { - return &Resolver{ - detectorChain: chain, - urlBuilder: builder, - } +// detectionResult is the output of a single detector step. +type detectionResult struct { + ForgeType config.ForgeType + BaseURL string + FullName string + Found bool } // Resolve determines the edit URL for a DocFile. // Returns empty string if edit links should not be generated. func (r *Resolver) Resolve(file docs.DocFile, cfg *config.Config) string { - // Pre-flight checks - if !r.shouldGenerateEditLink(cfg) { + if !shouldGenerateEditLink(cfg) { return "" } - - if r.isSiteLevelSuppressed(cfg) { + if isSiteLevelSuppressed(cfg) { return "" } - // Prepare detection context - ctx, ok := PrepareDetectionContext(file, cfg) + ctx, ok := prepareDetectionContext(file, cfg) if !ok { return "" } - // Run detection chain - result := r.detectorChain.Detect(ctx) - if !result.Found { - return "" + if result := detectVSCode(ctx); result.Found { + return buildURL(result, ctx) } - - // Build the final URL - return r.urlBuilder.BuildURL( - result.ForgeType, - result.BaseURL, - result.FullName, - ctx.Branch, - ctx.RepoRel, - ) + if result := detectConfigured(ctx); result.Found { + return buildURL(result, ctx) + } + if result := detectForgeConfig(ctx); result.Found { + return buildURL(result, ctx) + } + if result := detectHeuristic(ctx); result.Found { + return buildURL(result, ctx) + } + return "" } // shouldGenerateEditLink checks if edit links should be generated at all. -func (r *Resolver) shouldGenerateEditLink(cfg *config.Config) bool { - // Edit links now work for all themes +func shouldGenerateEditLink(cfg *config.Config) bool { + // Edit links now work for all themes. return cfg != nil } // isSiteLevelSuppressed checks if edit links are suppressed at the site level. -func (r *Resolver) isSiteLevelSuppressed(cfg *config.Config) bool { +func isSiteLevelSuppressed(cfg *config.Config) bool { if cfg.Hugo.Params == nil { return false } @@ -94,3 +105,210 @@ func (r *Resolver) isSiteLevelSuppressed(cfg *config.Config) bool { base, exists := editURLMap["base"].(string) return exists && base != "" } + +// prepareDetectionContext creates a detectionContext from a DocFile and config. +func prepareDetectionContext(file docs.DocFile, cfg *config.Config) (detectionContext, bool) { + var repoCfg *config.Repository + for i := range cfg.Repositories { + if cfg.Repositories[i].Name == file.Repository { + repoCfg = &cfg.Repositories[i] + break + } + } + if repoCfg == nil { + return detectionContext{}, false + } + + branch := repoCfg.Branch + if branch == "" { + branch = "main" + } + + return detectionContext{ + config: cfg, + repository: repoCfg, + cloneURL: prepareCloneURL(repoCfg.URL), + branch: branch, + repoRel: prepareRepoRelativePath(file), + }, true +} + +// prepareRepoRelativePath calculates the repository-relative path for the file. +func prepareRepoRelativePath(file docs.DocFile) string { + repoRel := file.RelativePath + if base := strings.TrimSpace(file.DocsBase); base != "" && base != "." { + repoRel = filepath.ToSlash(filepath.Join(base, repoRel)) + } else { + repoRel = filepath.ToSlash(repoRel) + } + return repoRel +} + +// prepareCloneURL normalizes a clone URL by removing .git suffix. +func prepareCloneURL(rawURL string) string { + if rawURL == "" { + return "" + } + return strings.TrimSuffix(rawURL, ".git") +} + +// detectVSCode handles local preview mode (repository name "local"). +func detectVSCode(ctx detectionContext) detectionResult { + if ctx.repository == nil || ctx.repository.Name != "local" { + return detectionResult{Found: false} + } + + relPath := filepath.ToSlash(filepath.Clean(ctx.repoRel)) + return detectionResult{ + Found: true, + ForgeType: "vscode", + FullName: relPath, + } +} + +// detectConfigured extracts forge info from repository tags. +func detectConfigured(ctx detectionContext) detectionResult { + if ctx.repository == nil || ctx.repository.Tags == nil { + return detectionResult{Found: false} + } + + tags := ctx.repository.Tags + + var forgeType config.ForgeType + if t, ok := tags["forge_type"]; ok { + forgeType = config.NormalizeForgeType(t) + } + + fullName := tags["full_name"] + if fullName == "" { + return detectionResult{Found: false} + } + + baseURL := "" + if forgeType != "" && ctx.config != nil { + for _, forge := range ctx.config.Forges { + if forge != nil && forge.Type == forgeType { + baseURL = forge.BaseURL + break + } + } + } + + if forgeType == "" { + return detectionResult{Found: false} + } + + return detectionResult{ + ForgeType: forgeType, + BaseURL: baseURL, + FullName: fullName, + Found: true, + } +} + +// detectForgeConfig matches the repository URL against configured forge base URLs. +func detectForgeConfig(ctx detectionContext) detectionResult { + forgeType, baseURL := resolveForgeForRepository(ctx.config, ctx.cloneURL) + if forgeType == "" { + return detectionResult{Found: false} + } + + _, fullName := forge.SplitCloneURL(ctx.cloneURL) + if fullName == "" { + return detectionResult{Found: false} + } + + return detectionResult{ + ForgeType: forgeType, + BaseURL: baseURL, + FullName: fullName, + Found: true, + } +} + +// resolveForgeForRepository matches a clone URL against configured forge base URLs. +func resolveForgeForRepository(cfg *config.Config, repoURL string) (config.ForgeType, string) { + if cfg == nil || len(cfg.Forges) == 0 || repoURL == "" { + return "", "" + } + + normalized := forge.SplitCloneURLHelper(repoURL) + + for _, fc := range cfg.Forges { + if fc == nil || fc.BaseURL == "" { + continue + } + + base := strings.TrimSuffix(fc.BaseURL, "/") + if strings.HasPrefix(normalized, base+"/") || strings.HasPrefix(normalized, base) { + return fc.Type, base + } + if hostsMatch(base, normalized) { + return fc.Type, base + } + } + + return "", "" +} + +// detectHeuristic uses hostname patterns to determine forge type. +func detectHeuristic(ctx detectionContext) detectionResult { + cloneURL := ctx.cloneURL + if cloneURL == "" { + return detectionResult{Found: false} + } + if urlutil.IsLocalPath(cloneURL) { + return detectionResult{Found: false} + } + + forgeType := forge.DetectForgeTypeFromURL(cloneURL) + if forgeType == "" { + return detectionResult{Found: false} + } + + baseURL, fullName := forge.SplitCloneURL(cloneURL) + if fullName == "" || baseURL == "" { + return detectionResult{Found: false} + } + + return detectionResult{ + ForgeType: forgeType, + BaseURL: baseURL, + FullName: fullName, + Found: true, + } +} + +// isLocalPath migrated to internal/urlutil.IsLocalPath. + +// hostsMatch checks if two URLs have the same host. +func hostsMatch(url1, url2 string) bool { + u1, err1 := url.Parse(url1) + u2, err2 := url.Parse(url2) + if err1 != nil || err2 != nil { + return false + } + return u1.Host != "" && u1.Host == u2.Host +} + +// buildURL constructs the final edit URL from a detection result. +func buildURL(result detectionResult, ctx detectionContext) string { + if result.ForgeType == "" || result.FullName == "" { + return "" + } + + // Special case for VS Code local preview mode. + if result.ForgeType == "vscode" { + // fullName contains the relative path for VS Code URLs. + return fmt.Sprintf("/_edit/%s", result.FullName) + } + + baseURL := strings.TrimSuffix(result.BaseURL, "/") + + // Special case for Bitbucket (preserved from original behavior). + if strings.Contains(baseURL, "bitbucket.org") { + return fmt.Sprintf("%s/%s/src/%s/%s?mode=edit", baseURL, result.FullName, ctx.branch, ctx.repoRel) + } + + return forge.GenerateEditURL(result.ForgeType, baseURL, result.FullName, ctx.branch, ctx.repoRel) +} diff --git a/internal/hugo/editlink/resolver_test.go b/internal/hugo/editlink/resolver_test.go index f2e47c58..aa19a21a 100644 --- a/internal/hugo/editlink/resolver_test.go +++ b/internal/hugo/editlink/resolver_test.go @@ -5,324 +5,167 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" + "git.home.luguber.info/inful/docbuilder/internal/forge" ) -func TestDetectorChain(t *testing.T) { - t.Run("Chain executes in order", func(t *testing.T) { - var executed []string - - detector1 := &mockDetector{ - name: "first", - onDetect: func(_ DetectionContext) DetectionResult { - executed = append(executed, "first") - return DetectionResult{Found: false} - }, - } - - detector2 := &mockDetector{ - name: "second", - onDetect: func(_ DetectionContext) DetectionResult { - executed = append(executed, "second") - return DetectionResult{ - ForgeType: config.ForgeGitHub, - BaseURL: "https://github.com", - FullName: "owner/repo", - Found: true, - } - }, - } +func TestResolver(t *testing.T) { + t.Run("GitHub URL resolution", func(t *testing.T) { + resolver := NewResolver() - detector3 := &mockDetector{ - name: "third", - onDetect: func(_ DetectionContext) DetectionResult { - executed = append(executed, "third") - return DetectionResult{Found: false} + cfg := &config.Config{ + Hugo: config.HugoConfig{}, + Repositories: []config.Repository{ + { + Name: "test-repo", + URL: "https://github.com/owner/repo.git", + Branch: "main", + }, }, } - chain := NewDetectorChain(). - Add(detector1). - Add(detector2). - Add(detector3) - - ctx := DetectionContext{} - result := chain.Detect(ctx) - - if !result.Found { - t.Error("expected chain to find a result") - } - - if result.ForgeType != config.ForgeGitHub { - t.Errorf("expected forge type %s, got %s", config.ForgeGitHub, result.ForgeType) - } - - // Should only execute first two detectors (stops at first success) - if len(executed) != 2 || executed[0] != "first" || executed[1] != "second" { - t.Errorf("expected execution order [first, second], got %v", executed) - } - }) - - t.Run("Chain returns not found when no detector succeeds", func(t *testing.T) { - detector1 := &mockDetector{ - name: "failing1", - onDetect: func(_ DetectionContext) DetectionResult { - return DetectionResult{Found: false} - }, + file := docs.DocFile{ + Repository: "test-repo", + RelativePath: "docs/guide.md", + DocsBase: "docs", } - detector2 := &mockDetector{ - name: "failing2", - onDetect: func(_ DetectionContext) DetectionResult { - return DetectionResult{Found: false} - }, + result := resolver.Resolve(file, cfg) + if result == "" { + t.Fatal("expected resolver to generate an edit URL") } - chain := NewDetectorChain(). - Add(detector1). - Add(detector2) - - ctx := DetectionContext{} - result := chain.Detect(ctx) - - if result.Found { - t.Error("expected chain to not find a result") + want := "https://github.com/owner/repo/edit/main/docs/docs/guide.md" + if result != want { + t.Errorf("expected URL %q, got %q", want, result) } }) -} -func TestConfiguredDetector(t *testing.T) { - detector := NewConfiguredDetector() - - t.Run("Detects from repository tags", func(t *testing.T) { - repo := &config.Repository{ - Tags: map[string]string{ - "forge_type": "github", - "full_name": "owner/repo", - }, - } + t.Run("Configured repository tags take precedence", func(t *testing.T) { + resolver := NewResolver() cfg := &config.Config{ - Forges: []*config.ForgeConfig{ + Hugo: config.HugoConfig{}, + Repositories: []config.Repository{ { - Type: config.ForgeGitHub, - BaseURL: "https://github.com", + Name: "tagged-repo", + URL: "https://gitlab.example.com/owner/repo.git", + Branch: "main", + Tags: map[string]string{ + "forge_type": "github", + "full_name": "owner/repo", + }, }, }, - } - - ctx := DetectionContext{ - Repository: repo, - Config: cfg, - } - - result := detector.Detect(ctx) - - if !result.Found { - t.Error("expected detector to find a result") - } - - if result.ForgeType != config.ForgeGitHub { - t.Errorf("expected forge type %s, got %s", config.ForgeGitHub, result.ForgeType) - } - - if result.FullName != "owner/repo" { - t.Errorf("expected full name 'owner/repo', got %s", result.FullName) - } - - if result.BaseURL != "https://github.com" { - t.Errorf("expected base URL 'https://github.com', got %s", result.BaseURL) - } - }) - - t.Run("Returns not found when tags missing", func(t *testing.T) { - repo := &config.Repository{ - Tags: map[string]string{ - "other": "value", + Forges: []*config.ForgeConfig{ + {Type: config.ForgeGitHub, BaseURL: "https://github.com"}, }, } - ctx := DetectionContext{ - Repository: repo, - } - - result := detector.Detect(ctx) - - if result.Found { - t.Error("expected detector to not find a result") - } - }) - - t.Run("Returns not found when repository nil", func(t *testing.T) { - ctx := DetectionContext{ - Repository: nil, + file := docs.DocFile{ + Repository: "tagged-repo", + RelativePath: "page.md", + DocsBase: ".", } - result := detector.Detect(ctx) - - if result.Found { - t.Error("expected detector to not find a result") + result := resolver.Resolve(file, cfg) + want := "https://github.com/owner/repo/edit/main/page.md" + if result != want { + t.Errorf("expected URL %q, got %q", want, result) } }) -} - -func TestHeuristicDetector(t *testing.T) { - detector := NewHeuristicDetector() - - tests := []struct { - name string - cloneURL string - expectedForge config.ForgeType - expectedBase string - expectedFound bool - }{ - { - name: "GitHub.com", - cloneURL: "https://github.com/owner/repo", - expectedForge: config.ForgeGitHub, - expectedBase: "https://github.com", - expectedFound: true, - }, - { - name: "GitLab.com", - cloneURL: "https://gitlab.com/owner/repo", - expectedForge: config.ForgeGitLab, - expectedBase: "https://gitlab.com", - expectedFound: true, - }, - { - name: "Forgejo instance", - cloneURL: "https://forgejo.example.com/owner/repo", - expectedForge: config.ForgeForgejo, - expectedBase: "https://forgejo.example.com", - expectedFound: true, - }, - { - name: "SSH URL", - cloneURL: "git@github.com:owner/repo.git", - expectedForge: config.ForgeGitHub, - expectedBase: "https://github.com", - expectedFound: true, - }, - { - name: "Empty URL", - cloneURL: "", - expectedFound: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := DetectionContext{ - CloneURL: tt.cloneURL, - } - - result := detector.Detect(ctx) - - if result.Found != tt.expectedFound { - t.Errorf("expected found=%v, got %v", tt.expectedFound, result.Found) - } - - if !tt.expectedFound { - return - } - - if result.ForgeType != tt.expectedForge { - t.Errorf("expected forge type %s, got %s", tt.expectedForge, result.ForgeType) - } - - if result.BaseURL != tt.expectedBase { - t.Errorf("expected base URL %s, got %s", tt.expectedBase, result.BaseURL) - } - - if result.FullName == "" { - t.Error("expected full name to be extracted") - } - }) - } -} - -func TestResolver(t *testing.T) { - t.Run("Full integration test", func(t *testing.T) { + t.Run("Heuristic detects Forgejo by hostname", func(t *testing.T) { resolver := NewResolver() cfg := &config.Config{ Hugo: config.HugoConfig{}, Repositories: []config.Repository{ { - Name: "test-repo", - URL: "https://github.com/owner/repo.git", + Name: "self-hosted", + URL: "https://forgejo.example.com/owner/repo.git", Branch: "main", }, }, } file := docs.DocFile{ - Repository: "test-repo", - RelativePath: "docs/guide.md", + Repository: "self-hosted", + RelativePath: "docs/index.md", DocsBase: "docs", } result := resolver.Resolve(file, cfg) - - if result == "" { - t.Error("expected resolver to generate an edit URL") - } - - expectedURL := "https://github.com/owner/repo/edit/main/docs/docs/guide.md" - if result != expectedURL { - t.Errorf("expected URL %s, got %s", expectedURL, result) + want := "https://forgejo.example.com/owner/repo/_edit/main/docs/docs/index.md" + if result != want { + t.Errorf("expected URL %q, got %q", want, result) } }) - t.Run("Returns empty for themes with no edit link", func(t *testing.T) { + t.Run("Empty for missing repository in config", func(t *testing.T) { resolver := NewResolver() + cfg := &config.Config{Hugo: config.HugoConfig{}} + file := docs.DocFile{Repository: "unknown-repo"} - cfg := &config.Config{ - Hugo: config.HugoConfig{}, - } - - file := docs.DocFile{Repository: "test-repo"} - - result := resolver.Resolve(file, cfg) - - if result != "" { - t.Errorf("expected empty result for themes with no edit link, got %s", result) + if got := resolver.Resolve(file, cfg); got != "" { + t.Errorf("expected empty result for unknown repo, got %q", got) } }) - t.Run("Returns empty when site-level suppressed", func(t *testing.T) { + t.Run("Empty when site-level editURL.base is set", func(t *testing.T) { resolver := NewResolver() - cfg := &config.Config{ Hugo: config.HugoConfig{ Params: map[string]any{ "editURL": map[string]any{ - "base": "custom-base", // Non-empty base suppresses per-page links + "base": "custom-base", // Non-empty base suppresses per-page links. }, }, }, } - file := docs.DocFile{Repository: "test-repo"} - result := resolver.Resolve(file, cfg) - - if result != "" { - t.Errorf("expected empty result when site-level suppressed, got %s", result) + if got := resolver.Resolve(file, cfg); got != "" { + t.Errorf("expected empty result when site-level suppressed, got %q", got) } }) } -// Mock detector for testing. -type mockDetector struct { - name string - onDetect func(DetectionContext) DetectionResult -} - -func (m *mockDetector) Name() string { - return m.name +func TestNormalizeSSHURL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"already HTTPS", "https://github.com/owner/repo", "https://github.com/owner/repo"}, + {"SSH form", "git@github.com:owner/repo.git", "https://github.com/owner/repo.git"}, + {"empty", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := forge.NormalizeCloneURL(tt.in); got != tt.want { + t.Errorf("NormalizeCloneURL(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } } -func (m *mockDetector) Detect(ctx DetectionContext) DetectionResult { - return m.onDetect(ctx) +func TestExtractFullNameFromURL(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"HTTPS", "https://github.com/owner/repo.git", "owner/repo"}, + {"SSH", "git@github.com:owner/repo.git", "owner/repo"}, + {"no .git", "https://github.com/owner/repo", "owner/repo"}, + {"invalid", "://bad url", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, got := forge.SplitCloneURL(tt.in) + if got != tt.want { + t.Errorf("SplitCloneURL(%q) fullName = %q, want %q", tt.in, got, tt.want) + } + }) + } } diff --git a/internal/hugo/editlink/url_builder.go b/internal/hugo/editlink/url_builder.go deleted file mode 100644 index b1394a6c..00000000 --- a/internal/hugo/editlink/url_builder.go +++ /dev/null @@ -1,41 +0,0 @@ -package editlink - -import ( - "fmt" - "strings" - - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/forge" -) - -// StandardEditURLBuilder builds edit URLs using the standard forge patterns. -type StandardEditURLBuilder struct{} - -// NewStandardEditURLBuilder creates a new standard URL builder. -func NewStandardEditURLBuilder() *StandardEditURLBuilder { - return &StandardEditURLBuilder{} -} - -// BuildURL constructs an edit URL using the forge package's GenerateEditURL function. -func (b *StandardEditURLBuilder) BuildURL(forgeType config.ForgeType, baseURL, fullName, branch, repoRel string) string { - if forgeType == "" || fullName == "" { - return "" - } - - // Handle special case for VS Code local preview mode - if forgeType == "vscode" { - // fullName contains the relative path for VS Code URLs - return fmt.Sprintf("/_edit/%s", fullName) - } - - // Clean up the base URL - baseURL = strings.TrimSuffix(baseURL, "/") - - // Handle special case for Bitbucket (not currently supported in config but preserved from original) - if strings.Contains(baseURL, "bitbucket.org") { - return fmt.Sprintf("%s/%s/src/%s/%s?mode=edit", baseURL, fullName, branch, repoRel) - } - - // Use the standard forge URL generation - return forge.GenerateEditURL(forgeType, baseURL, fullName, branch, repoRel) -} diff --git a/internal/hugo/editlink/vscode_detector.go b/internal/hugo/editlink/vscode_detector.go deleted file mode 100644 index 867cba62..00000000 --- a/internal/hugo/editlink/vscode_detector.go +++ /dev/null @@ -1,48 +0,0 @@ -package editlink - -import ( - "path/filepath" -) - -// VSCodeDetector generates edit URLs for local preview mode that trigger VS Code. -// These URLs use the /_edit/ endpoint which opens files in VS Code and redirects back. -type VSCodeDetector struct{} - -// NewVSCodeDetector creates a new VS Code detector for local preview. -func NewVSCodeDetector() *VSCodeDetector { - return &VSCodeDetector{} -} - -// Name returns the detector name. -func (d *VSCodeDetector) Name() string { - return "vscode" -} - -// Detect implements Detector for VS Code local preview mode. -func (d *VSCodeDetector) Detect(ctx DetectionContext) DetectionResult { - // Only activate in local preview mode (repository name is "local") - if ctx.Repository == nil || ctx.Repository.Name != "local" { - return DetectionResult{Found: false} - } - - // The repository URL in preview mode is the local docs directory - // We need to construct a path relative to that directory - - // ctx.RepoRel is the path relative to the repository root - // For preview mode, we need to strip the "docs" path component if present - // since the user might be watching ./docs but we want to open the file relative to that - - relPath := ctx.RepoRel - - // Clean up the path - relPath = filepath.Clean(relPath) - relPath = filepath.ToSlash(relPath) - - // Return a special marker that the URL builder will recognize - return DetectionResult{ - Found: true, - ForgeType: "vscode", // Special forge type for local preview - BaseURL: "", // Not used for VS Code URLs - FullName: relPath, // Store the relative path here - } -} diff --git a/internal/hugo/editlink/vscode_detector_test.go b/internal/hugo/editlink/vscode_detector_test.go deleted file mode 100644 index 142a1946..00000000 --- a/internal/hugo/editlink/vscode_detector_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package editlink_test - -import ( - "testing" - - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/docs" - "git.home.luguber.info/inful/docbuilder/internal/hugo/editlink" -) - -func TestVSCodeDetector_LocalPreview(t *testing.T) { - detector := editlink.NewVSCodeDetector() - - // Test context for local preview mode - ctx := editlink.DetectionContext{ - File: docs.DocFile{ - Repository: "local", - RelativePath: "README.md", - DocsBase: ".", - }, - Repository: &config.Repository{ - Name: "local", - URL: "/workspaces/docbuilder/docs", - }, - RepoRel: "README.md", - } - - result := detector.Detect(ctx) - - if !result.Found { - t.Fatal("Expected VS Code detector to find a match for local preview") - } - - if result.ForgeType != "vscode" { - t.Errorf("Expected forge type 'vscode', got '%s'", result.ForgeType) - } - - if result.FullName != "README.md" { - t.Errorf("Expected FullName 'README.md', got '%s'", result.FullName) - } -} - -func TestVSCodeDetector_NonLocalPreview(t *testing.T) { - detector := editlink.NewVSCodeDetector() - - // Test context for normal repository (not local preview) - ctx := editlink.DetectionContext{ - File: docs.DocFile{ - Repository: "myrepo", - RelativePath: "README.md", - DocsBase: "docs", - }, - Repository: &config.Repository{ - Name: "myrepo", - URL: "https://github.com/org/repo.git", - }, - RepoRel: "docs/README.md", - } - - result := detector.Detect(ctx) - - if result.Found { - t.Fatal("Expected VS Code detector to not match for non-local preview") - } -} - -func TestStandardEditURLBuilder_VSCode(t *testing.T) { - builder := editlink.NewStandardEditURLBuilder() - - url := builder.BuildURL("vscode", "", "docs/README.md", "main", "docs/README.md") - - expectedURL := "/_edit/docs/README.md" - if url != expectedURL { - t.Errorf("Expected URL '%s', got '%s'", expectedURL, url) - } -} diff --git a/internal/hugo/errors/errors.go b/internal/hugo/errors/errors.go index fdc2e1e4..f76e6de7 100644 --- a/internal/hugo/errors/errors.go +++ b/internal/hugo/errors/errors.go @@ -1,8 +1,12 @@ -package errors - -// Package errors provides sentinel errors for Hugo site generation stages. -// These enable consistent classification (expanded in Phase 4) while keeping -// user‑facing messages descriptive via wrapping. +// Package hugoerrors provides sentinel errors for Hugo site generation stages. +// These enable consistent classification (via errors.Is) while keeping +// user-facing messages descriptive via wrapping. +// +// Consumers should import this package with an alias (e.g. 'herrors') +// to avoid shadowing the stdlib errors package. The package name +// 'hugoerrors' is intentionally distinct so the consumer's `errors.` +// references continue to refer to the stdlib package. +package hugoerrors import "errors" @@ -12,7 +16,7 @@ var ( // ErrGoBinaryNotFound indicates the go executable was not detected on PATH. // Hugo Modules requires the go toolchain to download/resolve module dependencies. ErrGoBinaryNotFound = errors.New("go binary not found") - // ErrHugoExecutionFailed indicates the hugo command returned a non‑zero exit status. + // ErrHugoExecutionFailed indicates the hugo command returned a non-zero exit status. ErrHugoExecutionFailed = errors.New("hugo execution failed") // ErrConfigMarshalFailed indicates marshaling the generated Hugo configuration failed. ErrConfigMarshalFailed = errors.New("hugo config marshal failed") diff --git a/internal/hugo/generator.go b/internal/hugo/generator.go index 40b4a0dd..35367849 100644 --- a/internal/hugo/generator.go +++ b/internal/hugo/generator.go @@ -17,8 +17,8 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/git" - "git.home.luguber.info/inful/docbuilder/internal/metrics" "git.home.luguber.info/inful/docbuilder/internal/state" "git.home.luguber.info/inful/docbuilder/internal/version" "git.home.luguber.info/inful/docbuilder/internal/versioning" @@ -33,8 +33,7 @@ type Generator struct { stageDir string // ephemeral staging dir for current build // optional instrumentation callbacks (not exported) onPageRendered func() - recorder metrics.Recorder - observer models.BuildObserver // high-level observer (decouples metrics recorder) + observer models.BuildObserver // high-level observer (decouples recorder from stages) renderer models.Renderer // pluggable renderer abstraction (defaults to BinaryRenderer) // editLinkResolver centralizes per-page edit link resolution editLinkResolver *EditLinkResolver @@ -52,11 +51,10 @@ type Generator struct { // NewGenerator creates a new Hugo site generator. func NewGenerator(cfg *config.Config, outputDir string) *Generator { - g := &Generator{config: cfg, outputDir: filepath.Clean(outputDir), recorder: metrics.NoopRecorder{}, indexTemplateUsage: make(map[string]models.IndexTemplateInfo)} + g := &Generator{config: cfg, outputDir: filepath.Clean(outputDir), indexTemplateUsage: make(map[string]models.IndexTemplateInfo)} // Renderer defaults to nil; models.StageRunHugo will use BinaryRenderer when needed. // Use WithRenderer to inject custom/test renderers. - // Default observer bridges to recorder until dedicated observers added. - g.observer = models.RecorderObserver{Recorder: g.recorder} + g.observer = models.NoopObserver{} // Initialize resolver eagerly (cheap) to simplify call sites. g.editLinkResolver = NewEditLinkResolver(cfg) @@ -229,19 +227,7 @@ func (g *Generator) outputHasNonRootMarkdownContent() bool { // Config exposes the underlying configuration (read-only usage by themes). -// SetRecorder injects a metrics recorder (optional). Returns the generator for chaining. -func (g *Generator) SetRecorder(r metrics.Recorder) *Generator { - if r == nil { - g.recorder = metrics.NoopRecorder{} - g.observer = models.RecorderObserver{Recorder: g.recorder} - return g - } - g.recorder = r - g.observer = models.RecorderObserver{Recorder: r} - return g -} - -// WithObserver overrides the BuildObserver (takes precedence over internal recorder adapter). +// WithObserver overrides the BuildObserver (takes precedence over the default no-op observer). func (g *Generator) WithObserver(o models.BuildObserver) *Generator { if o != nil { g.observer = o @@ -287,11 +273,8 @@ func (g *Generator) GenerateSiteWithReportContext(ctx context.Context, docFiles Add(models.StagePrepareOutput, stages.StagePrepareOutput). Add(models.StageCategoriesMenu, stages.StageCategoriesMenu). Add(models.StageGenerateConfig, stages.StageGenerateConfig). - Add(models.StageLayouts, stages.StageLayouts). Add(models.StageCopyContent, stages.StageCopyContent). - Add(models.StageIndexes, stages.StageIndexes). Add(models.StageRunHugo, stages.StageRunHugo). - Add(models.StagePostProcess, stages.StagePostProcess). Build() if err := stages.RunStages(ctx, bs, pipeline); err != nil { @@ -339,7 +322,7 @@ func (g *Generator) GenerateSiteWithReportContext(ctx context.Context, docFiles report.DeriveOutcome() report.Finish() if err := g.finalizeStaging(); err != nil { - return nil, fmt.Errorf("finalize staging: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "finalize staging").Build() } // Verify public directory exists and log details @@ -373,11 +356,6 @@ func (g *Generator) GenerateSiteWithReportContext(ctx context.Context, docFiles if err := report.Persist(g.outputDir); err != nil { slog.Warn("Failed to persist build report", "error", err) } - // record build-level metrics - if g.recorder != nil { - g.recorder.ObserveBuildDuration(report.End.Sub(report.Start)) - g.recorder.IncBuildOutcome(metrics.BuildOutcomeLabel(report.Outcome)) - } slog.Info("Hugo site generation completed", slog.String("output", g.outputDir), slog.Int("repos", report.Repositories), @@ -440,11 +418,8 @@ func (g *Generator) GenerateFullSite(ctx context.Context, repositories []config. Add(models.StageDiscoverDocs, stages.StageDiscoverDocs). Add(models.StageCategoriesMenu, stages.StageCategoriesMenu). Add(models.StageGenerateConfig, stages.StageGenerateConfig). - Add(models.StageLayouts, stages.StageLayouts). Add(models.StageCopyContent, stages.StageCopyContent). - Add(models.StageIndexes, stages.StageIndexes). Add(models.StageRunHugo, stages.StageRunHugo). - Add(models.StagePostProcess, stages.StagePostProcess). Build() if err := stages.RunStages(ctx, bs, pipeline); err != nil { // derive outcome even on error for observability; cleanup staging @@ -463,25 +438,17 @@ func (g *Generator) GenerateFullSite(ctx context.Context, repositories []config. if err := report.Persist(g.outputDir); err != nil { slog.Warn("Failed to persist build report", "error", err) } - if g.recorder != nil { - g.recorder.ObserveBuildDuration(report.End.Sub(report.Start)) - g.recorder.IncBuildOutcome(metrics.BuildOutcomeLabel(report.Outcome)) - } return report, nil } // Stage durations already written directly to report. report.DeriveOutcome() report.Finish() if err := g.finalizeStaging(); err != nil { - return report, fmt.Errorf("finalize staging: %w", err) + return report, derrors.WrapError(err, derrors.CategoryFileSystem, "finalize staging").Build() } if err := report.Persist(g.outputDir); err != nil { slog.Warn("Failed to persist build report", "error", err) } - if g.recorder != nil { - g.recorder.ObserveBuildDuration(report.End.Sub(report.Start)) - g.recorder.IncBuildOutcome(metrics.BuildOutcomeLabel(report.Outcome)) - } return report, nil } @@ -505,7 +472,6 @@ func (g *Generator) ExistingSiteValidForSkip() bool { return g.existingSiteValid func (g *Generator) OutputDir() string { return g.outputDir } func (g *Generator) StageDir() string { return g.stageDir } -func (g *Generator) Recorder() metrics.Recorder { return g.recorder } func (g *Generator) StateManager() state.RepositoryMetadataWriter { return g.stateManager } func (g *Generator) Observer() models.BuildObserver { return g.observer } func (g *Generator) Renderer() models.Renderer { return g.renderer } @@ -516,3 +482,17 @@ func (g *Generator) WithRenderer(r models.Renderer) *Generator { } return g } + +// Compile-time assertions that *Generator satisfies the per-stage narrow +// interfaces defined in models. These lock the per-stage contract so future +// refactors that drop methods from *Generator break loudly at compile time. +var ( + _ models.Generator = (*Generator)(nil) + _ models.StagePrepareDeps = (*Generator)(nil) + _ models.StageCloneDeps = (*Generator)(nil) + _ models.StageDiscoverDeps = (*Generator)(nil) + _ models.StageConfigDeps = (*Generator)(nil) + _ models.StageCopyContentDeps = (*Generator)(nil) + _ models.StageCategoriesMenuDeps = (*Generator)(nil) + _ models.StageRunHugoDeps = (*Generator)(nil) +) diff --git a/internal/hugo/indexes.go b/internal/hugo/indexes.go index 61491eda..28af57a2 100644 --- a/internal/hugo/indexes.go +++ b/internal/hugo/indexes.go @@ -11,12 +11,14 @@ import ( "strings" "text/template" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/frontmatter" "git.home.luguber.info/inful/docbuilder/internal/frontmatterops" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" + herrors "git.home.luguber.info/inful/docbuilder/internal/hugo/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" ) @@ -104,7 +106,7 @@ func (g *Generator) generateMainIndex(docFiles []docs.DocFile) error { slog.Info("Main index already exists; skipping generation", logfields.Path(indexPath)) return nil } else if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("stat main index at %s: %w", indexPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "stat main index").WithContext("path", indexPath).Build() } repoGroups := make(map[string][]docs.DocFile) @@ -121,20 +123,20 @@ func (g *Generator) generateMainIndex(docFiles []docs.DocFile) error { ctx := buildIndexTemplateContext(g, docFiles, repoGroups, frontMatter) tpl, err := template.New("main_index").Funcs(template.FuncMap{"titleCase": titleCase, "replaceAll": strings.ReplaceAll, "lower": strings.ToLower}).Parse(tplRaw) if err != nil { - return fmt.Errorf("parse main index template: %w", err) + return derrors.WrapError(err, derrors.CategoryValidation, "parse main index template").Build() } var buf bytes.Buffer if execErr := tpl.Execute(&buf, ctx); execErr != nil { - return fmt.Errorf("exec main index template: %w", execErr) + return derrors.WrapError(execErr, derrors.CategoryInternal, "exec main index template").Build() } body := buf.String() content, err := buildIndexContent(frontMatter, body) if err != nil { - return fmt.Errorf("%w: %w", herrors.ErrIndexGenerationFailed, err) + return derrors.WrapError(err, derrors.CategoryInternal, "index generation failed").Build() } // #nosec G306 -- index pages are public content if err := os.WriteFile(indexPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("failed to write index at %s: %w", indexPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write index").WithContext("path", indexPath).Build() } slog.Info("Generated main index page", logfields.Path(indexPath)) return nil @@ -160,7 +162,7 @@ func (g *Generator) generateRepositoryIndexes(docFiles []docs.DocFile) error { relPath := docs.HugoContentPath("", "", repoName, "", "index", ".md", false) indexPath := filepath.Join(g.BuildRoot(), relPath) if err := os.MkdirAll(filepath.Dir(indexPath), 0o750); err != nil { - return fmt.Errorf("failed to create directory for %s: %w", indexPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create directory").WithContext("path", indexPath).Build() } // Check if repository has index.md or README.md at root level to use instead @@ -234,20 +236,20 @@ func (g *Generator) generateRepositoryIndexes(docFiles []docs.DocFile) error { } tpl, err := template.New("repo_index").Funcs(template.FuncMap{"titleCase": titleCase, "replaceAll": strings.ReplaceAll, "lower": strings.ToLower}).Parse(tplRaw) if err != nil { - return fmt.Errorf("parse repository index template: %w", err) + return derrors.WrapError(err, derrors.CategoryValidation, "parse repository index template").Build() } var buf bytes.Buffer if execErr := tpl.Execute(&buf, ctx); execErr != nil { - return fmt.Errorf("exec repository index template: %w", execErr) + return derrors.WrapError(execErr, derrors.CategoryInternal, "exec repository index template").Build() } body := buf.String() content, err := buildIndexContent(frontMatter, body) if err != nil { - return fmt.Errorf("failed to marshal front matter: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to marshal front matter").Build() } // #nosec G306 -- index pages are public content if err := os.WriteFile(indexPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("failed to write repository index: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write repository index").Build() } slog.Debug("Generated repository index", logfields.Repository(repoName), logfields.Path(indexPath)) } @@ -303,8 +305,11 @@ func (g *Generator) handleReadmeFile(readmeFile *docs.DocFile, indexPath, repoNa func (g *Generator) useReadmeAsIndex(readmeFile *docs.DocFile, indexPath, repoName string) error { // Use already-transformed content from the transform pipeline if len(readmeFile.TransformedBytes) == 0 { - return fmt.Errorf("%w: README not yet transformed: %s (ensure copyContentFiles ran first)", - herrors.ErrContentTransformFailed, readmeFile.Path) + return derrors.NewError(derrors.CategoryInternal, "README not yet transformed"). + WithCause(herrors.ErrContentTransformFailed). + WithContext("readme", readmeFile.Path). + WithContext("hint", "ensure copyContentFiles ran first"). + Build() } slog.Debug("Using transformed README as index", @@ -317,7 +322,9 @@ func (g *Generator) useReadmeAsIndex(readmeFile *docs.DocFile, indexPath, repoNa // Parse front matter if it exists fm, body, err := parseFrontMatterFromContent(contentStr) if err != nil { - return fmt.Errorf("failed to parse front matter in %s: %w", readmeFile.RelativePath, err) + return derrors.NewError(derrors.CategoryValidation, "failed to parse front matter in "+readmeFile.RelativePath). + WithCause(err). + Build() } // If no front matter exists, create it @@ -339,13 +346,13 @@ func (g *Generator) useReadmeAsIndex(readmeFile *docs.DocFile, indexPath, repoNa // Create directory if needed if err := os.MkdirAll(filepath.Dir(indexPath), 0o750); err != nil { - return fmt.Errorf("failed to create index directory: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create index directory").Build() } // Write the index file // #nosec G306 -- index pages are public content if err := os.WriteFile(indexPath, []byte(contentStr), 0o644); err != nil { - return fmt.Errorf("failed to write repository index from README: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write repository index from README").Build() } // Remove the original readme.md file since we've promoted to _index.md @@ -454,7 +461,7 @@ func (g *Generator) generateSectionIndex(repoName, sectionName string, files []d relPath := docs.HugoContentPath("", "", repoName, sectionName, "index", ".md", false) indexPath := filepath.Join(g.BuildRoot(), relPath) if err := os.MkdirAll(filepath.Dir(indexPath), 0o750); err != nil { - return fmt.Errorf("failed to create directory for %s: %w", indexPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create directory").WithContext("path", indexPath).Build() } frontMatter := g.buildSectionFrontMatter(repoName, sectionName) @@ -467,11 +474,11 @@ func (g *Generator) generateSectionIndex(repoName, sectionName string, files []d content, err := buildIndexContent(frontMatter, body) if err != nil { - return fmt.Errorf("failed to marshal front matter: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to marshal front matter").Build() } // #nosec G306 -- index pages are public content if err := os.WriteFile(indexPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("failed to write section index: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write section index").Build() } slog.Debug("Generated section index", logfields.Repository(repoName), logfields.Section(sectionName), logfields.Path(indexPath)) return nil @@ -537,12 +544,12 @@ func (g *Generator) renderSectionTemplate(files []docs.DocFile, repoName, sectio "lower": strings.ToLower, }).Parse(tplRaw) if err != nil { - return "", fmt.Errorf("parse section index template: %w", err) + return "", derrors.WrapError(err, derrors.CategoryValidation, "parse section index template").Build() } var buf bytes.Buffer if err := tpl.Execute(&buf, ctx); err != nil { - return "", fmt.Errorf("exec section index template: %w", err) + return "", derrors.WrapError(err, derrors.CategoryInternal, "exec section index template").Build() } return buf.String(), nil } @@ -570,7 +577,7 @@ func (g *Generator) generateIntermediateSectionIndex(repoName, sectionName strin relPath := docs.HugoContentPath("", "", repoName, sectionName, "index", ".md", false) indexPath := filepath.Join(g.BuildRoot(), relPath) if err := os.MkdirAll(filepath.Dir(indexPath), 0o750); err != nil { - return fmt.Errorf("failed to create directory for %s: %w", indexPath, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create directory").WithContext("path", indexPath).Build() } frontMatter := g.buildSectionFrontMatter(repoName, sectionName) @@ -588,21 +595,21 @@ func (g *Generator) generateIntermediateSectionIndex(repoName, sectionName strin "lower": strings.ToLower, }).Parse(tplRaw) if err != nil { - return fmt.Errorf("parse section index template: %w", err) + return derrors.WrapError(err, derrors.CategoryValidation, "parse section index template").Build() } var buf bytes.Buffer if execErr := tpl.Execute(&buf, ctx); execErr != nil { - return fmt.Errorf("exec section index template: %w", execErr) + return derrors.WrapError(execErr, derrors.CategoryInternal, "exec section index template").Build() } content, err := buildIndexContent(frontMatter, buf.String()) if err != nil { - return fmt.Errorf("failed to marshal front matter: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to marshal front matter").Build() } // #nosec G306 -- index pages are public content if err := os.WriteFile(indexPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("failed to write intermediate section index: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write intermediate section index").Build() } slog.Debug("Generated intermediate section index", logfields.Repository(repoName), logfields.Section(sectionName), logfields.Path(indexPath)) return nil @@ -654,7 +661,7 @@ func (g *Generator) loadIndexTemplate(kind string) (string, error) { return string(b), nil } } - return "", fmt.Errorf("no template override for kind %s", kind) + return "", derrors.NewError(derrors.CategoryValidation, "no template override for kind: "+kind).Build() } //go:embed templates_defaults/index/*.tmpl @@ -713,7 +720,7 @@ func reconstructContentWithFrontMatter(fm map[string]any, body string) (string, style := frontmatter.Style{Newline: "\n"} out, err := frontmatterops.Write(fm, []byte(body), true, style) if err != nil { - return "", fmt.Errorf("failed to marshal front matter: %w", err) + return "", derrors.WrapError(err, derrors.CategoryInternal, "failed to marshal front matter").Build() } return string(out), nil } diff --git a/internal/hugo/links.go b/internal/hugo/links.go deleted file mode 100644 index 0897b1f7..00000000 --- a/internal/hugo/links.go +++ /dev/null @@ -1,13 +0,0 @@ -package hugo - -// Backward compatible wrapper forwarding to new content package implementation. -import ( - c "git.home.luguber.info/inful/docbuilder/internal/hugo/content" -) - -// RewriteRelativeMarkdownLinks delegates to content.RewriteRelativeMarkdownLinks. -// If repositoryName is provided, links starting with / are treated as repository-root-relative. -// isIndexPage should be true for _index.md files to avoid adding extra ../ to relative links. -func RewriteRelativeMarkdownLinks(content string, repositoryName string, forgeName string, isIndexPage bool) string { - return c.RewriteRelativeMarkdownLinks(content, repositoryName, forgeName, isIndexPage) -} diff --git a/internal/hugo/links_test.go b/internal/hugo/links_test.go deleted file mode 100644 index b60cd6f2..00000000 --- a/internal/hugo/links_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package hugo - -import "testing" - -func TestRewriteRelativeMarkdownLinks(t *testing.T) { - cases := []struct{ in, want string }{ - {"See [Doc](foo.md) for details", "See [Doc](../foo/) for details"}, // same-dir needs ../ - {"[Anchor](bar.md#sec)", "[Anchor](../bar/#sec)"}, // same-dir with anchor - {"Absolute [Link](https://example.com/file.md)", "Absolute [Link](https://example.com/file.md)"}, - {"Image ref ![Alt](img.png)", "Image ref ![Alt](img.png)"}, // images unaffected - {"Mail [Me](mailto:test@example.com)", "Mail [Me](mailto:test@example.com)"}, - {"Hash [Ref](#local)", "Hash [Ref](#local)"}, - {"Nested ./path [Ref](./sub/thing.md)", "Nested ./path [Ref](../sub/thing/)"}, // ./ removed for regular page, then ../ added - {"Up one [Ref](../other.md)", "Up one [Ref](../../other/)"}, // Hugo needs extra ../ - {"Markdown long ext [Ref](guide.markdown)", "Markdown long ext [Ref](../guide/)"}, // same-dir needs ../ - {"Child dir [Ref](guide/setup.md)", "Child dir [Ref](../guide/setup/)"}, // subdirectory also needs ../ - } - for i, c := range cases { - got := RewriteRelativeMarkdownLinks(c.in, "", "", false) // false = not an index page - if got != c.want { - t.Errorf("case %d: got %q want %q", i, got, c.want) - } - } -} - -func TestRewriteRelativeMarkdownLinks_RepositoryRootRelative(t *testing.T) { - cases := []struct { - in, repo, forge, want string - }{ - { - "See [Doc](/api/reference.md) for details", - "my-project", "", - "See [Doc](/my-project/api/reference/) for details", - }, - { - "[Guide](/how-to/authentication.md)", - "franklin-api", "", - "[Guide](/franklin-api/how-to/authentication/)", - }, - { - "[Guide](/how-to/authentication.md#setup)", - "franklin-api", "", - "[Guide](/franklin-api/how-to/authentication/#setup)", - }, - { - "[Doc](/api/reference.md)", - "my-repo", "github", - "[Doc](/github/my-repo/api/reference/)", - }, - { - "Mixed [repo-root](/api/guide.md) and [relative](../other.md)", - "my-project", "", - "Mixed [repo-root](/my-project/api/guide/) and [relative](../../other/)", - }, - } - for i, c := range cases { - got := RewriteRelativeMarkdownLinks(c.in, c.repo, c.forge, false) // false = not an index page - if got != c.want { - t.Errorf("case %d: got %q want %q", i, got, c.want) - } - } -} - -func TestRewriteRelativeMarkdownLinks_IndexPages(t *testing.T) { - cases := []struct { - in, want string - }{ - // Index pages (_index.md or README.md) are at section root, so relative links don't need extra ../ - {"See [Doc](foo.md) for details", "See [Doc](foo/) for details"}, - {"[Anchor](bar.md#sec)", "[Anchor](bar/#sec)"}, - {"Subdirectory [Link](how-to/authentication.md)", "Subdirectory [Link](how-to/authentication/)"}, - {"Up one [Ref](../other.md)", "Up one [Ref](../other/)"}, // No extra ../ for index pages - {"Two up [Ref](../../another.md)", "Two up [Ref](../../another/)"}, - } - for i, c := range cases { - got := RewriteRelativeMarkdownLinks(c.in, "", "", true) // true = index page - if got != c.want { - t.Errorf("index page case %d: got %q want %q", i, got, c.want) - } - } -} diff --git a/internal/hugo/merge.go b/internal/hugo/merge.go deleted file mode 100644 index bd3c67ef..00000000 --- a/internal/hugo/merge.go +++ /dev/null @@ -1,23 +0,0 @@ -package hugo - -// mergeParams deep-merges src into dst (map[string]any). -// - Maps: merged recursively -// - Slices & scalars: replaced. -func mergeParams(dst, src map[string]any) { - if src == nil { - return - } - for k, v := range src { - if mv, ok := v.(map[string]any); ok { - if existing, ok2 := dst[k].(map[string]any); ok2 { - mergeParams(existing, mv) - } else { - cp := map[string]any{} - mergeParams(cp, mv) - dst[k] = cp - } - continue - } - dst[k] = v - } -} diff --git a/internal/hugo/metrics_integration_test.go b/internal/hugo/metrics_integration_test.go deleted file mode 100644 index 3a459854..00000000 --- a/internal/hugo/metrics_integration_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package hugo - -import ( - "os" - "path/filepath" - "testing" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/hugo/stages" - - "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/docs" - "git.home.luguber.info/inful/docbuilder/internal/metrics" -) - -type capturingRecorder struct { - stages map[string]int - results map[string]map[metrics.ResultLabel]int - builds int - outcomes map[metrics.BuildOutcomeLabel]int -} - -func newCapturingRecorder() *capturingRecorder { - return &capturingRecorder{stages: map[string]int{}, results: map[string]map[metrics.ResultLabel]int{}, outcomes: map[metrics.BuildOutcomeLabel]int{}} -} - -func (c *capturingRecorder) ObserveStageDuration(stage string, _ time.Duration) { c.stages[stage]++ } -func (c *capturingRecorder) ObserveBuildDuration(_ time.Duration) { c.builds++ } -func (c *capturingRecorder) IncStageResult(stage string, r metrics.ResultLabel) { - m, ok := c.results[stage] - if !ok { - m = map[metrics.ResultLabel]int{} - c.results[stage] = m - } - m[r]++ -} -func (c *capturingRecorder) IncBuildOutcome(o metrics.BuildOutcomeLabel) { c.outcomes[o]++ } -func (c *capturingRecorder) ObserveCloneRepoDuration(string, time.Duration, bool) {} -func (c *capturingRecorder) IncCloneRepoResult(bool) {} -func (c *capturingRecorder) SetCloneConcurrency(int) {} -func (c *capturingRecorder) IncBuildRetry(string) {} -func (c *capturingRecorder) IncBuildRetryExhausted(string) {} -func (c *capturingRecorder) IncIssue(string, string, string, bool) {} -func (c *capturingRecorder) SetEffectiveRenderMode(string) {} -func (c *capturingRecorder) IncContentTransformFailure(string) {} -func (c *capturingRecorder) ObserveContentTransformDuration(string, time.Duration, bool) {} - -// TestMetricsRecorderIntegration ensures that recorder callbacks are invoked during a simple GenerateSiteWithReport run. -func TestMetricsRecorderIntegration(t *testing.T) { - out := t.TempDir() - g := NewGenerator(&config.Config{Hugo: config.HugoConfig{Title: "Site"}}, out).SetRecorder(newCapturingRecorder()).WithRenderer(&stages.NoopRenderer{}) - // Ensure Hugo structure exists for index generation dirs - if err := g.CreateHugoStructure(); err != nil { - t.Fatalf("structure: %v", err) - } - // Create physical source files to satisfy LoadContent() - srcA := filepath.Join(out, "a.md") - srcB := filepath.Join(out, "b.md") - if err := os.WriteFile(srcA, []byte("# A"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(srcB, []byte("# B"), 0o600); err != nil { - t.Fatal(err) - } - docFiles := []docs.DocFile{ - {Repository: "repo1", Name: "a", Path: srcA, RelativePath: "a.md", DocsBase: ".", Extension: ".md"}, - {Repository: "repo1", Name: "b", Path: srcB, RelativePath: "b.md", DocsBase: ".", Extension: ".md"}, - } - rec := g.recorder.(*capturingRecorder) - _, err := g.GenerateSiteWithReportContext(t.Context(), docFiles) - if err != nil { - t.Fatalf("build errored: %v", err) - } - if rec.builds != 1 { - // Build duration observed once - if rec.builds == 0 { - t.Errorf("expected build duration observation") - } - } - if len(rec.outcomes) == 0 { - t.Errorf("expected at least one build outcome increment") - } - // At least 5 stages for simple build path - if len(rec.stages) == 0 { - t.Errorf("expected stage durations to be recorded") - } - // Ensure success results counted - foundSuccess := false - for _, m := range rec.results { - if m[metrics.ResultSuccess] > 0 { - foundSuccess = true - break - } - } - if !foundSuccess { - t.Errorf("expected at least one success result recorded") - } -} diff --git a/internal/hugo/models/build_state.go b/internal/hugo/models/build_state.go index aa153f57..ac582cb9 100644 --- a/internal/hugo/models/build_state.go +++ b/internal/hugo/models/build_state.go @@ -6,16 +6,19 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docs" - "git.home.luguber.info/inful/docbuilder/internal/metrics" "git.home.luguber.info/inful/docbuilder/internal/state" ) // Generator defines the interface required by stages to interact with the site generator. +// +// New code should prefer one of the per-stage narrow interfaces below +// (StagePrepareDeps, StageCloneDeps, etc.) so that stages do not reach into +// *config.Config. The full Generator is kept for the runner, which legitimately +// needs access to many of these surfaces together. type Generator interface { Config() *config.Config OutputDir() string StageDir() string - Recorder() metrics.Recorder StateManager() state.RepositoryMetadataWriter ComputeConfigHash() string GenerateHugoConfig() error @@ -30,6 +33,62 @@ type Generator interface { ApplyCategoriesMenuToConfig(root *RootConfig) } +// Per-stage narrow interfaces. Each captures only what one stage needs; +// stages that adopt these interfaces cannot reach into *config.Config. +// +// *hugo.Generator satisfies all of these structurally (compile-time +// assertions live in internal/hugo/generator.go). + +// StagePrepareDeps is what StagePrepare needs. +type StagePrepareDeps interface { + OutputDir() string + StageDir() string + BuildRoot() string + CreateHugoStructure() error + ExistingSiteValidForSkip() bool +} + +// StageCloneDeps is what StageClone needs. +type StageCloneDeps interface { + BuildRoot() string + StateManager() state.RepositoryMetadataWriter +} + +// StageDiscoverDeps is what StageDiscoverDocs needs. +type StageDiscoverDeps interface { + BuildRoot() string + StateManager() state.RepositoryMetadataWriter +} + +// StageConfigDeps is what StageGenerateConfig needs. +type StageConfigDeps interface { + ComputeConfigHash() string + GenerateHugoConfig() error + ApplyCategoriesMenuToConfig(root *RootConfig) +} + +// StageCopyContentDeps is what StageCopyContent needs. +type StageCopyContentDeps interface { + OutputDir() string + StageDir() string + BuildRoot() string + Observer() BuildObserver + CopyContentFilesWithState(ctx context.Context, docFiles []docs.DocFile, bs *BuildState) error +} + +// StageCategoriesMenuDeps is what StageCategoriesMenu needs. +type StageCategoriesMenuDeps interface { + ComputeCategoriesMenu(bs *BuildState) (*CategoriesMenu, error) + AttachCategoriesMenu(*CategoriesMenu) +} + +// StageRunHugoDeps is what StageRunHugo needs. +type StageRunHugoDeps interface { + OutputDir() string + BuildRoot() string + Renderer() Renderer +} + // GitState manages git repository operations and state tracking. type GitState struct { Repositories []config.Repository diff --git a/internal/hugo/models/early_skip.go b/internal/hugo/models/early_skip.go deleted file mode 100644 index fac0c770..00000000 --- a/internal/hugo/models/early_skip.go +++ /dev/null @@ -1,41 +0,0 @@ -package models - -import ( - "log/slog" -) - -// EarlySkipDecision represents the result of evaluating early skip conditions. -type EarlySkipDecision struct { - ShouldSkip bool - Reason string - Stage StageName // stage after which to skip -} - -// NoSkip returns a decision to continue with all stages. -func NoSkip() EarlySkipDecision { - return EarlySkipDecision{ShouldSkip: false} -} - -// SkipAfter returns a decision to skip stages after the specified stage. -func SkipAfter(stage StageName, reason string) EarlySkipDecision { - return EarlySkipDecision{ - ShouldSkip: true, - Reason: reason, - Stage: stage, - } -} - -// EvaluateEarlySkip performs early skip logic evaluation based on repository and site state. -func EvaluateEarlySkip(bs *BuildState) EarlySkipDecision { - // Skip after clone if no repository changes and existing site is valid - if bs.Git.AllReposUnchanged { - if bs.Generator != nil && bs.Generator.ExistingSiteValidForSkip() { - slog.Info("Early skip condition met: no repository HEAD changes and existing site valid") - return SkipAfter(StageCloneRepos, "no_changes") - } - } - - // Future: Add other skip conditions (config unchanged, etc.) - - return NoSkip() -} diff --git a/internal/hugo/models/frontmatter.go b/internal/hugo/models/frontmatter.go deleted file mode 100644 index a03416c2..00000000 --- a/internal/hugo/models/frontmatter.go +++ /dev/null @@ -1,341 +0,0 @@ -package models - -import ( - "errors" - "maps" - "slices" - "time" -) - -// FrontMatter represents a strongly-typed Hugo front matter structure. -// This replaces the map[string]any approach with compile-time type safety. -type FrontMatter struct { - // Core Hugo fields - Title string `json:"title,omitempty" yaml:"title,omitempty"` - Date time.Time `json:"date,omitzero" yaml:"date,omitempty"` - Draft bool `json:"draft,omitempty" yaml:"draft,omitempty"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - - // Taxonomy fields - Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` - Categories []string `json:"categories,omitempty" yaml:"categories,omitempty"` - Keywords []string `json:"keywords,omitempty" yaml:"keywords,omitempty"` - - // DocBuilder-specific fields - Repository string `json:"repository,omitempty" yaml:"repository,omitempty"` - Forge string `json:"forge,omitempty" yaml:"forge,omitempty"` - Section string `json:"section,omitempty" yaml:"section,omitempty"` - EditURL string `json:"edit_url,omitempty" yaml:"edit_url,omitempty"` - - // Weight and ordering - Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` - - // Layout and rendering - Layout string `json:"layout,omitempty" yaml:"layout,omitempty"` - Type string `json:"type,omitempty" yaml:"type,omitempty"` - - // Custom fields for extensibility - // These are type-safe containers for theme-specific or custom metadata - Custom map[string]any `json:",inline" yaml:",inline"` -} - -// NewFrontMatter creates a new FrontMatter with sensible defaults. -func NewFrontMatter() *FrontMatter { - return &FrontMatter{ - Date: time.Now(), - Custom: make(map[string]any), - } -} - -// FromMap converts a map[string]any to a strongly-typed FrontMatter. -// This provides migration path from existing untyped front matter. -func FromMap(data map[string]any) (*FrontMatter, error) { - fm := NewFrontMatter() - - if title, ok := data["title"].(string); ok { - fm.Title = title - } - - if date, ok := data["date"].(time.Time); ok { - fm.Date = date - } else if dateStr, ok := data["date"].(string); ok { - if parsed, err := time.Parse(time.RFC3339, dateStr); err == nil { - fm.Date = parsed - } else if parsed, err := time.Parse("2006-01-02T15:04:05-07:00", dateStr); err == nil { - fm.Date = parsed - } - } - - if draft, ok := data["draft"].(bool); ok { - fm.Draft = draft - } - - if description, ok := data["description"].(string); ok { - fm.Description = description - } - - if repository, ok := data["repository"].(string); ok { - fm.Repository = repository - } - - if forge, ok := data["forge"].(string); ok { - fm.Forge = forge - } - - if section, ok := data["section"].(string); ok { - fm.Section = section - } - - if editURL, ok := data["edit_url"].(string); ok { - fm.EditURL = editURL - } - - if weight, ok := data["weight"].(int); ok { - fm.Weight = weight - } - - if layout, ok := data["layout"].(string); ok { - fm.Layout = layout - } - - if typ, ok := data["type"].(string); ok { - fm.Type = typ - } - - // Handle taxonomy fields - if tags, ok := data["tags"].([]any); ok { - fm.Tags = make([]string, len(tags)) - for i, tag := range tags { - if tagStr, ok := tag.(string); ok { - fm.Tags[i] = tagStr - } - } - } else if tags, ok := data["tags"].([]string); ok { - fm.Tags = tags - } - - if categories, ok := data["categories"].([]any); ok { - fm.Categories = make([]string, len(categories)) - for i, cat := range categories { - if catStr, ok := cat.(string); ok { - fm.Categories[i] = catStr - } - } - } else if categories, ok := data["categories"].([]string); ok { - fm.Categories = categories - } - - if keywords, ok := data["keywords"].([]any); ok { - fm.Keywords = make([]string, len(keywords)) - for i, kw := range keywords { - if kwStr, ok := kw.(string); ok { - fm.Keywords[i] = kwStr - } - } - } else if keywords, ok := data["keywords"].([]string); ok { - fm.Keywords = keywords - } - - // Store any unrecognized fields in Custom - for key, value := range data { - switch key { - case "title", "date", "draft", "description", "repository", "forge", - "section", "edit_url", "weight", "layout", "type", "tags", - "categories", "keywords": - // Already handled above - default: - fm.Custom[key] = value - } - } - - return fm, nil -} - -// ToMap converts the strongly-typed FrontMatter to a map[string]any. -// This provides compatibility with existing code that expects maps. -func (fm *FrontMatter) ToMap() map[string]any { - result := make(map[string]any) - - if fm.Title != "" { - result["title"] = fm.Title - } - - if !fm.Date.IsZero() { - result["date"] = fm.Date.Format("2006-01-02T15:04:05-07:00") - } - - if fm.Draft { - result["draft"] = fm.Draft - } - - if fm.Description != "" { - result["description"] = fm.Description - } - - if fm.Repository != "" { - result["repository"] = fm.Repository - } - - if fm.Forge != "" { - result["forge"] = fm.Forge - } - - if fm.Section != "" { - result["section"] = fm.Section - } - - if fm.EditURL != "" { - result["edit_url"] = fm.EditURL - } - - if fm.Weight != 0 { - result["weight"] = fm.Weight - } - - if fm.Layout != "" { - result["layout"] = fm.Layout - } - - if fm.Type != "" { - result["type"] = fm.Type - } - - if len(fm.Tags) > 0 { - result["tags"] = fm.Tags - } - - if len(fm.Categories) > 0 { - result["categories"] = fm.Categories - } - - if len(fm.Keywords) > 0 { - result["keywords"] = fm.Keywords - } - - // Add custom fields - maps.Copy(result, fm.Custom) - - return result -} - -// Clone creates a deep copy of the FrontMatter. -func (fm *FrontMatter) Clone() *FrontMatter { - clone := &FrontMatter{ - Title: fm.Title, - Date: fm.Date, - Draft: fm.Draft, - Description: fm.Description, - Repository: fm.Repository, - Forge: fm.Forge, - Section: fm.Section, - EditURL: fm.EditURL, - Weight: fm.Weight, - Layout: fm.Layout, - Type: fm.Type, - Custom: make(map[string]any), - } - - // Deep copy slices - if len(fm.Tags) > 0 { - clone.Tags = make([]string, len(fm.Tags)) - copy(clone.Tags, fm.Tags) - } - - if len(fm.Categories) > 0 { - clone.Categories = make([]string, len(fm.Categories)) - copy(clone.Categories, fm.Categories) - } - - if len(fm.Keywords) > 0 { - clone.Keywords = make([]string, len(fm.Keywords)) - copy(clone.Keywords, fm.Keywords) - } - - // Deep copy custom fields (shallow copy for now - could be improved) - maps.Copy(clone.Custom, fm.Custom) - - return clone -} - -// SetCustom safely sets a custom field. -func (fm *FrontMatter) SetCustom(key string, value any) { - if fm.Custom == nil { - fm.Custom = make(map[string]any) - } - fm.Custom[key] = value -} - -// GetCustom safely retrieves a custom field. -func (fm *FrontMatter) GetCustom(key string) (any, bool) { - if fm.Custom == nil { - return nil, false - } - value, exists := fm.Custom[key] - return value, exists -} - -// GetCustomString retrieves a custom field as a string. -func (fm *FrontMatter) GetCustomString(key string) (string, bool) { - value, exists := fm.GetCustom(key) - if !exists { - return "", false - } - if str, ok := value.(string); ok { - return str, true - } - return "", false -} - -// GetCustomInt retrieves a custom field as an integer. -func (fm *FrontMatter) GetCustomInt(key string) (int, bool) { - value, exists := fm.GetCustom(key) - if !exists { - return 0, false - } - if i, ok := value.(int); ok { - return i, true - } - return 0, false -} - -// AddTag adds a tag if it doesn't already exist. -func (fm *FrontMatter) AddTag(tag string) { - if slices.Contains(fm.Tags, tag) { - return - } - fm.Tags = append(fm.Tags, tag) -} - -// AddCategory adds a category if it doesn't already exist. -func (fm *FrontMatter) AddCategory(category string) { - if slices.Contains(fm.Categories, category) { - return - } - fm.Categories = append(fm.Categories, category) -} - -// AddKeyword adds a keyword if it doesn't already exist. -func (fm *FrontMatter) AddKeyword(keyword string) { - if slices.Contains(fm.Keywords, keyword) { - return - } - fm.Keywords = append(fm.Keywords, keyword) -} - -// Validate performs basic validation of the front matter. -func (fm *FrontMatter) Validate() error { - if fm.Title == "" { - return errors.New("title is required") - } - - if fm.Date.IsZero() { - return errors.New("date is required") - } - - // Repository should be set for DocBuilder-generated content - if fm.Repository == "" { - return errors.New("repository is required for DocBuilder content") - } - - return nil -} diff --git a/internal/hugo/models/frontmatter_test.go b/internal/hugo/models/frontmatter_test.go deleted file mode 100644 index 281ccd0b..00000000 --- a/internal/hugo/models/frontmatter_test.go +++ /dev/null @@ -1,316 +0,0 @@ -package models - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewFrontMatter(t *testing.T) { - fm := NewFrontMatter() - - assert.NotNil(t, fm) - assert.NotNil(t, fm.Custom) - assert.False(t, fm.Date.IsZero()) -} - -func TestFrontMatter_FromMap(t *testing.T) { - testTime := time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC) - - tests := []struct { - name string - input map[string]any - expected *FrontMatter - wantErr bool - }{ - { - name: "complete front matter", - input: map[string]any{ - "title": "Test Title", - "date": testTime, - "draft": true, - "description": "Test Description", - "repository": "test-repo", - "forge": "github", - "section": "docs", - "edit_url": "https://github.com/test/edit", - "weight": 10, - "layout": "single", - "type": "page", - "tags": []string{"tag1", "tag2"}, - "categories": []string{"cat1", "cat2"}, - "keywords": []string{"key1", "key2"}, - "custom_field": "custom_value", - }, - expected: &FrontMatter{ - Title: "Test Title", - Date: testTime, - Draft: true, - Description: "Test Description", - Repository: "test-repo", - Forge: "github", - Section: "docs", - EditURL: "https://github.com/test/edit", - Weight: 10, - Layout: "single", - Type: "page", - Tags: []string{"tag1", "tag2"}, - Categories: []string{"cat1", "cat2"}, - Keywords: []string{"key1", "key2"}, - Custom: map[string]any{ - "custom_field": "custom_value", - }, - }, - wantErr: false, - }, - { - name: "date as string RFC3339", - input: map[string]any{ - "title": "Test", - "date": "2023-12-25T10:30:00Z", - }, - expected: &FrontMatter{ - Title: "Test", - Date: testTime, - Custom: map[string]any{}, - }, - wantErr: false, - }, - { - name: "interface arrays", - input: map[string]any{ - "title": "Test", - "tags": []any{"tag1", "tag2"}, - "categories": []any{"cat1", "cat2"}, - "keywords": []any{"key1", "key2"}, - }, - expected: &FrontMatter{ - Title: "Test", - Tags: []string{"tag1", "tag2"}, - Categories: []string{"cat1", "cat2"}, - Keywords: []string{"key1", "key2"}, - Custom: map[string]any{}, - }, - wantErr: false, - }, - { - name: "empty map", - input: map[string]any{}, - expected: &FrontMatter{ - Custom: map[string]any{}, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := FromMap(tt.input) - - if tt.wantErr { - assert.Error(t, err) - return - } - - require.NoError(t, err) - assert.Equal(t, tt.expected.Title, result.Title) - assert.Equal(t, tt.expected.Draft, result.Draft) - assert.Equal(t, tt.expected.Description, result.Description) - assert.Equal(t, tt.expected.Repository, result.Repository) - assert.Equal(t, tt.expected.Forge, result.Forge) - assert.Equal(t, tt.expected.Section, result.Section) - assert.Equal(t, tt.expected.EditURL, result.EditURL) - assert.Equal(t, tt.expected.Weight, result.Weight) - assert.Equal(t, tt.expected.Layout, result.Layout) - assert.Equal(t, tt.expected.Type, result.Type) - assert.Equal(t, tt.expected.Tags, result.Tags) - assert.Equal(t, tt.expected.Categories, result.Categories) - assert.Equal(t, tt.expected.Keywords, result.Keywords) - assert.Equal(t, tt.expected.Custom, result.Custom) - - if !tt.expected.Date.IsZero() { - assert.True(t, tt.expected.Date.Equal(result.Date)) - } - }) - } -} - -func TestFrontMatter_ToMap(t *testing.T) { - testTime := time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC) - - fm := &FrontMatter{ - Title: "Test Title", - Date: testTime, - Draft: true, - Description: "Test Description", - Repository: "test-repo", - Forge: "github", - Section: "docs", - EditURL: "https://github.com/test/edit", - Weight: 10, - Layout: "single", - Type: "page", - Tags: []string{"tag1", "tag2"}, - Categories: []string{"cat1", "cat2"}, - Keywords: []string{"key1", "key2"}, - Custom: map[string]any{ - "custom_field": "custom_value", - }, - } - - result := fm.ToMap() - - assert.Equal(t, "Test Title", result["title"]) - assert.Equal(t, "2023-12-25T10:30:00+00:00", result["date"]) - assert.Equal(t, true, result["draft"]) - assert.Equal(t, "Test Description", result["description"]) - assert.Equal(t, "test-repo", result["repository"]) - assert.Equal(t, "github", result["forge"]) - assert.Equal(t, "docs", result["section"]) - assert.Equal(t, "https://github.com/test/edit", result["edit_url"]) - assert.Equal(t, 10, result["weight"]) - assert.Equal(t, "single", result["layout"]) - assert.Equal(t, "page", result["type"]) - assert.Equal(t, []string{"tag1", "tag2"}, result["tags"]) - assert.Equal(t, []string{"cat1", "cat2"}, result["categories"]) - assert.Equal(t, []string{"key1", "key2"}, result["keywords"]) - assert.Equal(t, "custom_value", result["custom_field"]) -} - -func TestFrontMatter_Clone(t *testing.T) { - original := &FrontMatter{ - Title: "Original", - Tags: []string{"tag1", "tag2"}, - Categories: []string{"cat1"}, - Custom: map[string]any{ - "custom": "value", - }, - } - - clone := original.Clone() - - // Verify deep copy - assert.Equal(t, original.Title, clone.Title) - assert.Equal(t, original.Tags, clone.Tags) - assert.Equal(t, original.Categories, clone.Categories) - assert.Equal(t, original.Custom, clone.Custom) - - // Verify independence - clone.Title = "Modified" - clone.Tags[0] = "modified_tag" - clone.Custom["custom"] = "modified" - - assert.Equal(t, "Original", original.Title) - assert.Equal(t, "tag1", original.Tags[0]) - assert.Equal(t, "value", original.Custom["custom"]) -} - -func TestFrontMatter_CustomFields(t *testing.T) { - fm := NewFrontMatter() - - // Test SetCustom - fm.SetCustom("test_key", "test_value") - - // Test GetCustom - value, exists := fm.GetCustom("test_key") - assert.True(t, exists) - assert.Equal(t, "test_value", value) - - // Test GetCustomString - str, exists := fm.GetCustomString("test_key") - assert.True(t, exists) - assert.Equal(t, "test_value", str) - - // Test GetCustomInt - fm.SetCustom("int_key", 42) - intVal, exists := fm.GetCustomInt("int_key") - assert.True(t, exists) - assert.Equal(t, 42, intVal) - - // Test non-existent key - _, exists = fm.GetCustom("non_existent") - assert.False(t, exists) -} - -func TestFrontMatter_AddMethods(t *testing.T) { - fm := NewFrontMatter() - - // Test AddTag - fm.AddTag("tag1") - fm.AddTag("tag2") - fm.AddTag("tag1") // Duplicate - assert.Equal(t, []string{"tag1", "tag2"}, fm.Tags) - - // Test AddCategory - fm.AddCategory("cat1") - fm.AddCategory("cat2") - fm.AddCategory("cat1") // Duplicate - assert.Equal(t, []string{"cat1", "cat2"}, fm.Categories) - - // Test AddKeyword - fm.AddKeyword("key1") - fm.AddKeyword("key2") - fm.AddKeyword("key1") // Duplicate - assert.Equal(t, []string{"key1", "key2"}, fm.Keywords) -} - -func TestFrontMatter_Validate(t *testing.T) { - tests := []struct { - name string - fm *FrontMatter - wantErr bool - errMsg string - }{ - { - name: "valid front matter", - fm: &FrontMatter{ - Title: "Test", - Date: time.Now(), - Repository: "test-repo", - }, - wantErr: false, - }, - { - name: "missing title", - fm: &FrontMatter{ - Date: time.Now(), - Repository: "test-repo", - }, - wantErr: true, - errMsg: "title is required", - }, - { - name: "missing date", - fm: &FrontMatter{ - Title: "Test", - Repository: "test-repo", - }, - wantErr: true, - errMsg: "date is required", - }, - { - name: "missing repository", - fm: &FrontMatter{ - Title: "Test", - Date: time.Now(), - }, - wantErr: true, - errMsg: "repository is required", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.fm.Validate() - - if tt.wantErr { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errMsg) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/internal/hugo/models/migration.go b/internal/hugo/models/migration.go deleted file mode 100644 index 5434685c..00000000 --- a/internal/hugo/models/migration.go +++ /dev/null @@ -1,265 +0,0 @@ -package models - -import ( - "errors" - "fmt" - "time" -) - -const ( - mergeStrategyReplace = "replace" -) - -// MigrationHelper provides utilities for migrating from the existing -// map[string]any-based front matter system to the new strongly-typed system. -type MigrationHelper struct{} - -// NewMigrationHelper creates a new migration helper. -func NewMigrationHelper() *MigrationHelper { - return &MigrationHelper{} -} - -// ConvertLegacyPatch converts a legacy map[string]any patch to a typed FrontMatterPatch. -// This is used during migration from the existing fmcore system. -func (m *MigrationHelper) ConvertLegacyPatch(legacyPatch map[string]any) (*FrontMatterPatch, error) { - if legacyPatch == nil { - return NewFrontMatterPatch(), nil - } - - patch := NewFrontMatterPatch() - - // Convert known fields - if title, ok := legacyPatch["title"].(string); ok { - patch.SetTitle(title) - } - - if date, ok := legacyPatch["date"].(time.Time); ok { - patch.SetDate(date) - } else if dateStr, ok := legacyPatch["date"].(string); ok { - if parsed, err := time.Parse(time.RFC3339, dateStr); err == nil { - patch.SetDate(parsed) - } else if parsed, err := time.Parse("2006-01-02T15:04:05-07:00", dateStr); err == nil { - patch.SetDate(parsed) - } - } - - if draft, ok := legacyPatch["draft"].(bool); ok { - patch.SetDraft(draft) - } - - if description, ok := legacyPatch["description"].(string); ok { - patch.SetDescription(description) - } - - if repository, ok := legacyPatch["repository"].(string); ok { - patch.SetRepository(repository) - } - - if forge, ok := legacyPatch["forge"].(string); ok { - patch.SetForge(forge) - } - - if section, ok := legacyPatch["section"].(string); ok { - patch.SetSection(section) - } - - if editURL, ok := legacyPatch["edit_url"].(string); ok { - patch.SetEditURL(editURL) - } - - if weight, ok := legacyPatch["weight"].(int); ok { - patch.SetWeight(weight) - } - - if layout, ok := legacyPatch["layout"].(string); ok { - patch.SetLayout(layout) - } - - if typ, ok := legacyPatch["type"].(string); ok { - patch.SetType(typ) - } - - // Handle taxonomy fields - if tags := m.convertToStringArray(legacyPatch["tags"]); tags != nil { - patch.SetTags(tags) - } - - if categories := m.convertToStringArray(legacyPatch["categories"]); categories != nil { - patch.SetCategories(categories) - } - - if keywords := m.convertToStringArray(legacyPatch["keywords"]); keywords != nil { - patch.SetKeywords(keywords) - } - - // Handle merge mode - if modeStr, ok := legacyPatch["merge_mode"].(string); ok { - if mode, err := m.parseMergeMode(modeStr); err == nil { - patch.WithMergeMode(mode) - } - } - - // Handle array merge strategy - if strategyStr, ok := legacyPatch["array_merge_strategy"].(string); ok { - if strategy, err := m.parseArrayMergeStrategy(strategyStr); err == nil { - patch.WithArrayMergeStrategy(strategy) - } - } - - // Store any unrecognized fields in Custom - for key, value := range legacyPatch { - switch key { - case "title", "date", "draft", "description", "repository", "forge", - "section", "edit_url", "weight", "layout", "type", "tags", - "categories", "keywords", "merge_mode", "array_merge_strategy": - // Already handled above - default: - patch.SetCustom(key, value) - } - } - - return patch, nil -} - -// ConvertLegacyFrontMatter converts a legacy map[string]any front matter to typed FrontMatter. -func (m *MigrationHelper) ConvertLegacyFrontMatter(legacy map[string]any) (*FrontMatter, error) { - return FromMap(legacy) -} - -// CreateBasePatch creates a base front matter patch with DocBuilder-specific fields. -// This replaces the ComputeBaseFrontMatter function from the legacy system. -func (m *MigrationHelper) CreateBasePatch(title, repository, forge, section string) *FrontMatterPatch { - patch := NewFrontMatterPatch(). - SetTitle(title). - SetRepository(repository). - SetDate(time.Now()). - WithMergeMode(MergeModeSetIfMissing) // Only set if missing, allow user overrides - - if forge != "" { - patch.SetForge(forge) - } - - if section != "" { - patch.SetSection(section) - } - - return patch -} - -// ApplyPatchSequence applies a sequence of patches to a front matter. -// This provides the same functionality as the legacy patch application system. -func (m *MigrationHelper) ApplyPatchSequence(base *FrontMatter, patches ...*FrontMatterPatch) (*FrontMatter, error) { - if base == nil { - return nil, errors.New("base front matter cannot be nil") - } - - result := base.Clone() - - for i, patch := range patches { - if patch == nil { - continue - } - - var err error - result, err = patch.Apply(result) - if err != nil { - return nil, fmt.Errorf("failed to apply patch %d: %w", i, err) - } - } - - return result, nil -} - -// ValidateFrontMatterConfig validates front matter configuration. -// This can be used to ensure migration didn't break anything. -func (m *MigrationHelper) ValidateFrontMatterConfig(fm *FrontMatter) []string { - var warnings []string - - if fm.Title == "" { - warnings = append(warnings, "title is empty") - } - - if fm.Date.IsZero() { - warnings = append(warnings, "date is not set") - } - - if fm.Repository == "" { - warnings = append(warnings, "repository is not set") - } - - // Check for potentially problematic custom fields - for key, value := range fm.Custom { - if key == "" { - warnings = append(warnings, "empty custom field key found") - } - - // Warn about complex nested structures that might cause issues - switch v := value.(type) { - case map[string]any: - if len(v) > 10 { - warnings = append(warnings, fmt.Sprintf("custom field '%s' has complex nested structure", key)) - } - case []any: - if len(v) > 20 { - warnings = append(warnings, fmt.Sprintf("custom field '%s' has large array", key)) - } - } - } - - return warnings -} - -// convertToStringArray converts various array types to []string. -func (m *MigrationHelper) convertToStringArray(value any) []string { - if value == nil { - return nil - } - - switch v := value.(type) { - case []string: - return v - case []any: - result := make([]string, len(v)) - for i, item := range v { - if str, ok := item.(string); ok { - result[i] = str - } else { - result[i] = fmt.Sprintf("%v", item) - } - } - return result - case string: - // Handle single string as array of one - return []string{v} - default: - return nil - } -} - -// parseMergeMode converts a string to MergeMode. -func (m *MigrationHelper) parseMergeMode(mode string) (MergeMode, error) { - switch mode { - case "deep": - return MergeModeDeep, nil - case mergeStrategyReplace: - return MergeModeReplace, nil - case "set_if_missing": - return MergeModeSetIfMissing, nil - default: - return MergeModeDeep, fmt.Errorf("unknown merge mode: %s", mode) - } -} - -// parseArrayMergeStrategy converts a string to ArrayMergeStrategy. -func (m *MigrationHelper) parseArrayMergeStrategy(strategy string) (ArrayMergeStrategy, error) { - switch strategy { - case "append": - return ArrayMergeStrategyAppend, nil - case "union": - return ArrayMergeStrategyUnion, nil - case mergeStrategyReplace: - return ArrayMergeStrategyReplace, nil - default: - return ArrayMergeStrategyUnion, fmt.Errorf("unknown array merge strategy: %s", strategy) - } -} diff --git a/internal/hugo/models/migration_test.go b/internal/hugo/models/migration_test.go deleted file mode 100644 index 5820b742..00000000 --- a/internal/hugo/models/migration_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package models - -import ( - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMigrationHelper_ConvertLegacyPatch(t *testing.T) { - helper := NewMigrationHelper() - testTime := time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC) - - tests := []struct { - name string - input map[string]any - validate func(*testing.T, *FrontMatterPatch) - }{ - { - name: "nil input", - input: nil, - validate: func(t *testing.T, patch *FrontMatterPatch) { - t.Helper() - assert.True(t, patch.IsEmpty()) - }, - }, - { - name: "complete legacy patch", - input: map[string]any{ - "title": "Test Title", - "date": testTime, - "draft": true, - "description": "Test Description", - "repository": "test-repo", - "forge": "github", - "section": "docs", - "edit_url": "https://github.com/test/edit", - "weight": 10, - "layout": "single", - "type": "page", - "tags": []string{"tag1", "tag2"}, - "categories": []string{"cat1", "cat2"}, - "keywords": []string{"key1", "key2"}, - "merge_mode": "replace", - "array_merge_strategy": "append", - "custom_field": "custom_value", - }, - validate: func(t *testing.T, patch *FrontMatterPatch) { - t.Helper() - assert.Equal(t, "Test Title", *patch.Title) - assert.True(t, testTime.Equal(*patch.Date)) - assert.Equal(t, true, *patch.Draft) - assert.Equal(t, "Test Description", *patch.Description) - assert.Equal(t, "test-repo", *patch.Repository) - assert.Equal(t, "github", *patch.Forge) - assert.Equal(t, "docs", *patch.Section) - assert.Equal(t, "https://github.com/test/edit", *patch.EditURL) - assert.Equal(t, 10, *patch.Weight) - assert.Equal(t, "single", *patch.Layout) - assert.Equal(t, "page", *patch.Type) - assert.Equal(t, []string{"tag1", "tag2"}, *patch.Tags) - assert.Equal(t, []string{"cat1", "cat2"}, *patch.Categories) - assert.Equal(t, []string{"key1", "key2"}, *patch.Keywords) - assert.Equal(t, MergeModeReplace, patch.MergeMode) - assert.Equal(t, ArrayMergeStrategyAppend, patch.ArrayMergeStrategy) - assert.Equal(t, "custom_value", patch.Custom["custom_field"]) - }, - }, - { - name: "date as string", - input: map[string]any{ - "title": "Test", - "date": "2023-12-25T10:30:00Z", - }, - validate: func(t *testing.T, patch *FrontMatterPatch) { - t.Helper() - assert.Equal(t, "Test", *patch.Title) - assert.True(t, testTime.Equal(*patch.Date)) - }, - }, - { - name: "interface arrays", - input: map[string]any{ - "tags": []any{"tag1", "tag2"}, - "categories": []any{"cat1", "cat2"}, - "keywords": []any{"key1", "key2"}, - }, - validate: func(t *testing.T, patch *FrontMatterPatch) { - t.Helper() - assert.Equal(t, []string{"tag1", "tag2"}, *patch.Tags) - assert.Equal(t, []string{"cat1", "cat2"}, *patch.Categories) - assert.Equal(t, []string{"key1", "key2"}, *patch.Keywords) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := helper.ConvertLegacyPatch(tt.input) - require.NoError(t, err) - tt.validate(t, result) - }) - } -} - -func TestMigrationHelper_CreateBasePatch(t *testing.T) { - helper := NewMigrationHelper() - - patch := helper.CreateBasePatch("Test Title", "test-repo", "github", "docs") - - assert.Equal(t, "Test Title", *patch.Title) - assert.Equal(t, "test-repo", *patch.Repository) - assert.Equal(t, "github", *patch.Forge) - assert.Equal(t, "docs", *patch.Section) - assert.False(t, patch.Date.IsZero()) - assert.Equal(t, MergeModeSetIfMissing, patch.MergeMode) -} - -func TestMigrationHelper_ApplyPatchSequence(t *testing.T) { - helper := NewMigrationHelper() - - base := &FrontMatter{ - Title: "Original", - Repository: "test-repo", - Tags: []string{"original_tag"}, - } - - patch1 := NewFrontMatterPatch(). - SetDescription("Added description"). - WithMergeMode(MergeModeSetIfMissing) - - patch2 := NewFrontMatterPatch(). - SetTitle("Updated Title"). - SetTags([]string{"patch2_tag"}). - WithMergeMode(MergeModeDeep). - WithArrayMergeStrategy(ArrayMergeStrategyUnion) - - result, err := helper.ApplyPatchSequence(base, patch1, patch2) - require.NoError(t, err) - - assert.Equal(t, "Updated Title", result.Title) // Overridden by patch2 - assert.Equal(t, "Added description", result.Description) // Set by patch1 - assert.Equal(t, "test-repo", result.Repository) // From base - assert.Equal(t, []string{"original_tag", "patch2_tag"}, result.Tags) // Union merge with original -} - -func TestMigrationHelper_ApplyPatchSequence_NilBase(t *testing.T) { - helper := NewMigrationHelper() - patch := NewFrontMatterPatch().SetTitle("Test") - - result, err := helper.ApplyPatchSequence(nil, patch) - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "base front matter cannot be nil") -} - -func TestMigrationHelper_ApplyPatchSequence_NilPatch(t *testing.T) { - helper := NewMigrationHelper() - base := &FrontMatter{Title: "Test"} - - result, err := helper.ApplyPatchSequence(base, nil) - require.NoError(t, err) - assert.Equal(t, "Test", result.Title) -} - -func TestMigrationHelper_ValidateFrontMatterConfig(t *testing.T) { - helper := NewMigrationHelper() - - tests := []struct { - name string - fm *FrontMatter - expectedWarnings int - expectedMsgs []string - }{ - { - name: "valid front matter", - fm: &FrontMatter{ - Title: "Test", - Date: time.Now(), - Repository: "test-repo", - }, - expectedWarnings: 0, - }, - { - name: "missing required fields", - fm: &FrontMatter{ - Custom: map[string]any{}, - }, - expectedWarnings: 3, - expectedMsgs: []string{"title is empty", "date is not set", "repository is not set"}, - }, - { - name: "complex custom fields", - fm: &FrontMatter{ - Title: "Test", - Date: time.Now(), - Repository: "test-repo", - Custom: map[string]any{ - "": "empty key", - "complex": map[string]any{ - "nested1": 1, "nested2": 2, "nested3": 3, "nested4": 4, "nested5": 5, - "nested6": 6, "nested7": 7, "nested8": 8, "nested9": 9, "nested10": 10, - "nested11": 11, // This makes it >10 items - }, - "large_array": make([]any, 25), // >20 items - }, - }, - expectedWarnings: 3, - expectedMsgs: []string{"empty custom field key", "complex nested structure", "large array"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - warnings := helper.ValidateFrontMatterConfig(tt.fm) - assert.Len(t, warnings, tt.expectedWarnings) - - for _, msg := range tt.expectedMsgs { - found := false - for _, warning := range warnings { - if strings.Contains(warning, msg) { - found = true - break - } - } - assert.True(t, found, "Expected warning containing '%s' not found in: %v", msg, warnings) - } - }) - } -} - -func TestMigrationHelper_convertToStringArray(t *testing.T) { - helper := NewMigrationHelper() - - tests := []struct { - name string - input any - expected []string - }{ - { - name: "nil input", - input: nil, - expected: nil, - }, - { - name: "string array", - input: []string{"a", "b", "c"}, - expected: []string{"a", "b", "c"}, - }, - { - name: "interface array", - input: []any{"a", "b", 123}, - expected: []string{"a", "b", "123"}, - }, - { - name: "single string", - input: "single", - expected: []string{"single"}, - }, - { - name: "unsupported type", - input: 123, - expected: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := helper.convertToStringArray(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestMigrationHelper_parseMergeMode(t *testing.T) { - helper := NewMigrationHelper() - - tests := []struct { - input string - expected MergeMode - wantErr bool - }{ - {"deep", MergeModeDeep, false}, - {"replace", MergeModeReplace, false}, - {"set_if_missing", MergeModeSetIfMissing, false}, - {"invalid", MergeModeDeep, true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result, err := helper.parseMergeMode(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expected, result) - } - }) - } -} - -func TestMigrationHelper_parseArrayMergeStrategy(t *testing.T) { - helper := NewMigrationHelper() - - tests := []struct { - input string - expected ArrayMergeStrategy - wantErr bool - }{ - {"append", ArrayMergeStrategyAppend, false}, - {"union", ArrayMergeStrategyUnion, false}, - {"replace", ArrayMergeStrategyReplace, false}, - {"invalid", ArrayMergeStrategyUnion, true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result, err := helper.parseArrayMergeStrategy(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expected, result) - } - }) - } -} - -// LegacyCompatibilityAdapter-related tests removed after refactor to typed-only APIs. diff --git a/internal/hugo/models/observer.go b/internal/hugo/models/observer.go index e13dea9b..47aaeb4e 100644 --- a/internal/hugo/models/observer.go +++ b/internal/hugo/models/observer.go @@ -2,13 +2,9 @@ package models import ( "time" - - "git.home.luguber.info/inful/docbuilder/internal/metrics" ) // BuildObserver receives callbacks around stage execution and build lifecycle. -// It intentionally abstracts away the metrics.Recorder so future observers -// (logging, tracing, notifications) can hook in without changing stage code. type BuildObserver interface { OnStageStart(stage StageName) OnStageComplete(stage StageName, duration time.Duration, result StageResult) @@ -22,27 +18,6 @@ func (NoopObserver) OnStageStart(_ StageName) func (NoopObserver) OnStageComplete(_ StageName, _ time.Duration, _ StageResult) {} func (NoopObserver) OnBuildComplete(_ *BuildReport) {} -// RecorderObserver adapts metrics.Recorder into a BuildObserver. -type RecorderObserver struct{ Recorder metrics.Recorder } - -func (r RecorderObserver) OnStageStart(_ StageName) {} -func (r RecorderObserver) OnStageComplete(stage StageName, d time.Duration, _ StageResult) { - if r.Recorder != nil { - r.Recorder.ObserveStageDuration(string(stage), d) - } -} - -func (r RecorderObserver) OnBuildComplete(report *BuildReport) { - if r.Recorder != nil { - r.Recorder.ObserveBuildDuration(report.End.Sub(report.Start)) - r.Recorder.IncBuildOutcome(metrics.BuildOutcomeLabel(report.Outcome)) - // Emit structured issues - for _, is := range report.Issues { - r.Recorder.IncIssue(string(is.Code), string(is.Stage), string(is.Severity), is.Transient) - } - // Record effective render mode if present - if report.EffectiveRenderMode != "" { - r.Recorder.SetEffectiveRenderMode(report.EffectiveRenderMode) - } - } -} +// Compile-time assertion that the canonical observer implementations satisfy +// the BuildObserver contract. +var _ BuildObserver = NoopObserver{} diff --git a/internal/hugo/models/patch.go b/internal/hugo/models/patch.go deleted file mode 100644 index beeeefc4..00000000 --- a/internal/hugo/models/patch.go +++ /dev/null @@ -1,537 +0,0 @@ -package models - -import ( - "errors" - "fmt" - "maps" - "strings" - "time" -) - -const unknownMode = "unknown" - -// MergeMode defines how front matter patches should be applied. -type MergeMode int - -const ( - // MergeModeDeep performs deep merging of nested structures. - MergeModeDeep MergeMode = iota - // MergeModeReplace completely replaces existing values. - MergeModeReplace - // MergeModeSetIfMissing only sets values if they don't exist. - MergeModeSetIfMissing -) - -// String returns a string representation of the MergeMode. -func (m MergeMode) String() string { - switch m { - case MergeModeDeep: - return "deep" - case MergeModeReplace: - return "replace" - case MergeModeSetIfMissing: - return "set_if_missing" - default: - return unknownMode - } -} - -// ArrayMergeStrategy defines how arrays should be merged. -type ArrayMergeStrategy int - -const ( - // ArrayMergeStrategyAppend adds new items to the end. - ArrayMergeStrategyAppend ArrayMergeStrategy = iota - // ArrayMergeStrategyUnion merges arrays removing duplicates. - ArrayMergeStrategyUnion - // ArrayMergeStrategyReplace completely replaces the array. - ArrayMergeStrategyReplace -) - -// String returns a string representation of the ArrayMergeStrategy. -func (s ArrayMergeStrategy) String() string { - switch s { - case ArrayMergeStrategyAppend: - return "append" - case ArrayMergeStrategyUnion: - return "union" - case ArrayMergeStrategyReplace: - return "replace" - default: - return unknownMode - } -} - -// FrontMatterPatch represents a strongly-typed patch to apply to front matter. -// This replaces the map[string]any approach with type-safe operations. -type FrontMatterPatch struct { - // Core Hugo fields - Title *string `json:"title,omitempty" yaml:"title,omitempty"` - Date *time.Time `json:"date,omitempty" yaml:"date,omitempty"` - Draft *bool `json:"draft,omitempty" yaml:"draft,omitempty"` - Description *string `json:"description,omitempty" yaml:"description,omitempty"` - - // Taxonomy fields - Tags *[]string `json:"tags,omitempty" yaml:"tags,omitempty"` - Categories *[]string `json:"categories,omitempty" yaml:"categories,omitempty"` - Keywords *[]string `json:"keywords,omitempty" yaml:"keywords,omitempty"` - - // DocBuilder-specific fields - Repository *string `json:"repository,omitempty" yaml:"repository,omitempty"` - Forge *string `json:"forge,omitempty" yaml:"forge,omitempty"` - Section *string `json:"section,omitempty" yaml:"section,omitempty"` - EditURL *string `json:"edit_url,omitempty" yaml:"edit_url,omitempty"` - - // Weight and ordering - Weight *int `json:"weight,omitempty" yaml:"weight,omitempty"` - - // Layout and rendering - Layout *string `json:"layout,omitempty" yaml:"layout,omitempty"` - Type *string `json:"type,omitempty" yaml:"type,omitempty"` - - // Custom fields for extensibility - Custom map[string]any `json:",inline" yaml:",inline"` - - // Merge configuration - MergeMode MergeMode `json:"merge_mode,omitempty" yaml:"merge_mode,omitempty"` - ArrayMergeStrategy ArrayMergeStrategy `json:"array_merge_strategy,omitempty" yaml:"array_merge_strategy,omitempty"` -} - -// NewFrontMatterPatch creates a new empty patch. -func NewFrontMatterPatch() *FrontMatterPatch { - return &FrontMatterPatch{ - Custom: make(map[string]any), - MergeMode: MergeModeDeep, - ArrayMergeStrategy: ArrayMergeStrategyUnion, - } -} - -// SetTitle sets the title field in the patch. -func (p *FrontMatterPatch) SetTitle(title string) *FrontMatterPatch { - p.Title = &title - return p -} - -// SetDate sets the date field in the patch. -func (p *FrontMatterPatch) SetDate(date time.Time) *FrontMatterPatch { - p.Date = &date - return p -} - -// SetDraft sets the draft field in the patch. -func (p *FrontMatterPatch) SetDraft(draft bool) *FrontMatterPatch { - p.Draft = &draft - return p -} - -// SetDescription sets the description field in the patch. -func (p *FrontMatterPatch) SetDescription(description string) *FrontMatterPatch { - p.Description = &description - return p -} - -// SetRepository sets the repository field in the patch. -func (p *FrontMatterPatch) SetRepository(repository string) *FrontMatterPatch { - p.Repository = &repository - return p -} - -// SetForge sets the forge field in the patch. -func (p *FrontMatterPatch) SetForge(forge string) *FrontMatterPatch { - p.Forge = &forge - return p -} - -// SetSection sets the section field in the patch. -func (p *FrontMatterPatch) SetSection(section string) *FrontMatterPatch { - p.Section = §ion - return p -} - -// SetEditURL sets the edit URL field in the patch. -func (p *FrontMatterPatch) SetEditURL(editURL string) *FrontMatterPatch { - p.EditURL = &editURL - return p -} - -// SetWeight sets the weight field in the patch. -func (p *FrontMatterPatch) SetWeight(weight int) *FrontMatterPatch { - p.Weight = &weight - return p -} - -// SetLayout sets the layout field in the patch. -func (p *FrontMatterPatch) SetLayout(layout string) *FrontMatterPatch { - p.Layout = &layout - return p -} - -// SetType sets the type field in the patch. -func (p *FrontMatterPatch) SetType(typ string) *FrontMatterPatch { - p.Type = &typ - return p -} - -// SetTags sets the tags field in the patch. -func (p *FrontMatterPatch) SetTags(tags []string) *FrontMatterPatch { - p.Tags = &tags - return p -} - -// SetCategories sets the categories field in the patch. -func (p *FrontMatterPatch) SetCategories(categories []string) *FrontMatterPatch { - p.Categories = &categories - return p -} - -// SetKeywords sets the keywords field in the patch. -func (p *FrontMatterPatch) SetKeywords(keywords []string) *FrontMatterPatch { - p.Keywords = &keywords - return p -} - -// SetCustom sets a custom field in the patch. -func (p *FrontMatterPatch) SetCustom(key string, value any) *FrontMatterPatch { - if p.Custom == nil { - p.Custom = make(map[string]any) - } - p.Custom[key] = value - return p -} - -// WithMergeMode sets the merge mode for this patch. -func (p *FrontMatterPatch) WithMergeMode(mode MergeMode) *FrontMatterPatch { - p.MergeMode = mode - return p -} - -// WithArrayMergeStrategy sets the array merge strategy for this patch. -func (p *FrontMatterPatch) WithArrayMergeStrategy(strategy ArrayMergeStrategy) *FrontMatterPatch { - p.ArrayMergeStrategy = strategy - return p -} - -// Apply applies this patch to the given FrontMatter, returning a new instance. -func (p *FrontMatterPatch) Apply(fm *FrontMatter) (*FrontMatter, error) { - if fm == nil { - return nil, errors.New("cannot apply patch to nil front matter") - } - - // Clone the original to avoid mutation - result := fm.Clone() - - // Apply patch based on merge mode - switch p.MergeMode { - case MergeModeReplace: - return p.applyReplace(result), nil - case MergeModeSetIfMissing: - return p.applySetIfMissing(result), nil - case MergeModeDeep: - return p.applyDeep(result), nil - default: - return nil, fmt.Errorf("unknown merge mode: %v", p.MergeMode) - } -} - -// applyReplace applies the patch by replacing all non-nil values. -func (p *FrontMatterPatch) applyReplace(fm *FrontMatter) *FrontMatter { - if p.Title != nil { - fm.Title = *p.Title - } - if p.Date != nil { - fm.Date = *p.Date - } - if p.Draft != nil { - fm.Draft = *p.Draft - } - if p.Description != nil { - fm.Description = *p.Description - } - if p.Repository != nil { - fm.Repository = *p.Repository - } - if p.Forge != nil { - fm.Forge = *p.Forge - } - if p.Section != nil { - fm.Section = *p.Section - } - if p.EditURL != nil { - fm.EditURL = *p.EditURL - } - if p.Weight != nil { - fm.Weight = *p.Weight - } - if p.Layout != nil { - fm.Layout = *p.Layout - } - if p.Type != nil { - fm.Type = *p.Type - } - if p.Tags != nil { - fm.Tags = *p.Tags - } - if p.Categories != nil { - fm.Categories = *p.Categories - } - if p.Keywords != nil { - fm.Keywords = *p.Keywords - } - - // Replace custom fields - for key, value := range p.Custom { - fm.SetCustom(key, value) - } - - return fm -} - -// applySetIfMissing applies the patch only for missing/empty values. -func (p *FrontMatterPatch) applySetIfMissing(fm *FrontMatter) *FrontMatter { - p.applySimpleFieldsIfMissing(fm) - p.applyArrayFieldsIfMissing(fm) - p.applyCustomFieldsIfMissing(fm) - return fm -} - -// applySimpleFieldsIfMissing sets simple scalar fields only if they are missing in the target. -func (p *FrontMatterPatch) applySimpleFieldsIfMissing(fm *FrontMatter) { - if p.Title != nil && fm.Title == "" { - fm.Title = *p.Title - } - if p.Date != nil && fm.Date.IsZero() { - fm.Date = *p.Date - } - if p.Draft != nil && !fm.Draft { - fm.Draft = *p.Draft - } - if p.Description != nil && fm.Description == "" { - fm.Description = *p.Description - } - if p.Repository != nil && fm.Repository == "" { - fm.Repository = *p.Repository - } - if p.Forge != nil && fm.Forge == "" { - fm.Forge = *p.Forge - } - if p.Section != nil && fm.Section == "" { - fm.Section = *p.Section - } - if p.EditURL != nil && fm.EditURL == "" { - fm.EditURL = *p.EditURL - } - if p.Weight != nil && fm.Weight == 0 { - fm.Weight = *p.Weight - } - if p.Layout != nil && fm.Layout == "" { - fm.Layout = *p.Layout - } - if p.Type != nil && fm.Type == "" { - fm.Type = *p.Type - } -} - -// applyArrayFieldsIfMissing sets array fields only if they are empty in the target. -func (p *FrontMatterPatch) applyArrayFieldsIfMissing(fm *FrontMatter) { - if p.Tags != nil && len(fm.Tags) == 0 { - fm.Tags = *p.Tags - } - if p.Categories != nil && len(fm.Categories) == 0 { - fm.Categories = *p.Categories - } - if p.Keywords != nil && len(fm.Keywords) == 0 { - fm.Keywords = *p.Keywords - } -} - -// applyCustomFieldsIfMissing sets custom fields only if they don't exist in the target. -func (p *FrontMatterPatch) applyCustomFieldsIfMissing(fm *FrontMatter) { - for key, value := range p.Custom { - if _, exists := fm.GetCustom(key); !exists { - fm.SetCustom(key, value) - } - } -} - -// applyDeep applies the patch with deep merging for arrays and maps. -func (p *FrontMatterPatch) applyDeep(fm *FrontMatter) *FrontMatter { - // Apply simple fields (same as replace for non-composite types) - if p.Title != nil { - fm.Title = *p.Title - } - if p.Date != nil { - fm.Date = *p.Date - } - if p.Draft != nil { - fm.Draft = *p.Draft - } - if p.Description != nil { - fm.Description = *p.Description - } - if p.Repository != nil { - fm.Repository = *p.Repository - } - if p.Forge != nil { - fm.Forge = *p.Forge - } - if p.Section != nil { - fm.Section = *p.Section - } - if p.EditURL != nil { - fm.EditURL = *p.EditURL - } - if p.Weight != nil { - fm.Weight = *p.Weight - } - if p.Layout != nil { - fm.Layout = *p.Layout - } - if p.Type != nil { - fm.Type = *p.Type - } - - // Apply array fields with merge strategy - if p.Tags != nil { - fm.Tags = p.mergeStringArray(fm.Tags, *p.Tags) - } - if p.Categories != nil { - fm.Categories = p.mergeStringArray(fm.Categories, *p.Categories) - } - if p.Keywords != nil { - fm.Keywords = p.mergeStringArray(fm.Keywords, *p.Keywords) - } - - // Deep merge custom fields - for key, value := range p.Custom { - fm.SetCustom(key, value) - } - - return fm -} - -// mergeStringArray merges two string arrays based on the patch's array merge strategy. -func (p *FrontMatterPatch) mergeStringArray(existing, newItems []string) []string { - switch p.ArrayMergeStrategy { - case ArrayMergeStrategyReplace: - return newItems - case ArrayMergeStrategyAppend: - return append(existing, newItems...) - case ArrayMergeStrategyUnion: - // Create a set to track existing items - seen := make(map[string]bool) - result := make([]string, 0, len(existing)+len(newItems)) - - // Add existing items - for _, item := range existing { - if !seen[item] { - seen[item] = true - result = append(result, item) - } - } - - // Add new items that aren't duplicates - for _, item := range newItems { - if !seen[item] { - seen[item] = true - result = append(result, item) - } - } - - return result - default: - return newItems - } -} - -// ToMap converts the patch to a map[string]any for compatibility. -func (p *FrontMatterPatch) ToMap() map[string]any { - result := make(map[string]any) - - if p.Title != nil { - result["title"] = *p.Title - } - if p.Date != nil { - result["date"] = p.Date.Format("2006-01-02T15:04:05-07:00") - } - if p.Draft != nil { - result["draft"] = *p.Draft - } - if p.Description != nil { - result["description"] = *p.Description - } - if p.Repository != nil { - result["repository"] = *p.Repository - } - if p.Forge != nil { - result["forge"] = *p.Forge - } - if p.Section != nil { - result["section"] = *p.Section - } - if p.EditURL != nil { - result["edit_url"] = *p.EditURL - } - if p.Weight != nil { - result["weight"] = *p.Weight - } - if p.Layout != nil { - result["layout"] = *p.Layout - } - if p.Type != nil { - result["type"] = *p.Type - } - if p.Tags != nil { - result["tags"] = *p.Tags - } - if p.Categories != nil { - result["categories"] = *p.Categories - } - if p.Keywords != nil { - result["keywords"] = *p.Keywords - } - - // Add custom fields - maps.Copy(result, p.Custom) - - return result -} - -// IsEmpty returns true if the patch has no fields set. -func (p *FrontMatterPatch) IsEmpty() bool { - return p.Title == nil && - p.Date == nil && - p.Draft == nil && - p.Description == nil && - p.Repository == nil && - p.Forge == nil && - p.Section == nil && - p.EditURL == nil && - p.Weight == nil && - p.Layout == nil && - p.Type == nil && - p.Tags == nil && - p.Categories == nil && - p.Keywords == nil && - len(p.Custom) == 0 -} - -// String returns a human-readable representation of the patch. -func (p *FrontMatterPatch) String() string { - var parts []string - - if p.Title != nil { - parts = append(parts, fmt.Sprintf("title=%q", *p.Title)) - } - if p.Repository != nil { - parts = append(parts, fmt.Sprintf("repository=%q", *p.Repository)) - } - if p.Section != nil { - parts = append(parts, fmt.Sprintf("section=%q", *p.Section)) - } - if len(p.Custom) > 0 { - parts = append(parts, fmt.Sprintf("custom=%d_fields", len(p.Custom))) - } - - result := fmt.Sprintf("FrontMatterPatch{%s}", strings.Join(parts, ", ")) - return result -} diff --git a/internal/hugo/models/patch_test.go b/internal/hugo/models/patch_test.go deleted file mode 100644 index 12100ccd..00000000 --- a/internal/hugo/models/patch_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package models - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewFrontMatterPatch(t *testing.T) { - patch := NewFrontMatterPatch() - - assert.NotNil(t, patch) - assert.NotNil(t, patch.Custom) - assert.Equal(t, MergeModeDeep, patch.MergeMode) - assert.Equal(t, ArrayMergeStrategyUnion, patch.ArrayMergeStrategy) - assert.True(t, patch.IsEmpty()) -} - -func TestFrontMatterPatch_SetMethods(t *testing.T) { - patch := NewFrontMatterPatch() - testTime := time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC) - - // Test fluent interface - result := patch. - SetTitle("Test Title"). - SetDate(testTime). - SetDraft(true). - SetDescription("Test Description"). - SetRepository("test-repo"). - SetForge("github"). - SetSection("docs"). - SetEditURL("https://github.com/test/edit"). - SetWeight(10). - SetLayout("single"). - SetType("page"). - SetTags([]string{"tag1", "tag2"}). - SetCategories([]string{"cat1", "cat2"}). - SetKeywords([]string{"key1", "key2"}). - SetCustom("custom_field", "custom_value"). - WithMergeMode(MergeModeReplace). - WithArrayMergeStrategy(ArrayMergeStrategyAppend) - - assert.Same(t, patch, result) // Fluent interface returns same instance - - assert.Equal(t, "Test Title", *patch.Title) - assert.True(t, testTime.Equal(*patch.Date)) - assert.Equal(t, true, *patch.Draft) - assert.Equal(t, "Test Description", *patch.Description) - assert.Equal(t, "test-repo", *patch.Repository) - assert.Equal(t, "github", *patch.Forge) - assert.Equal(t, "docs", *patch.Section) - assert.Equal(t, "https://github.com/test/edit", *patch.EditURL) - assert.Equal(t, 10, *patch.Weight) - assert.Equal(t, "single", *patch.Layout) - assert.Equal(t, "page", *patch.Type) - assert.Equal(t, []string{"tag1", "tag2"}, *patch.Tags) - assert.Equal(t, []string{"cat1", "cat2"}, *patch.Categories) - assert.Equal(t, []string{"key1", "key2"}, *patch.Keywords) - assert.Equal(t, "custom_value", patch.Custom["custom_field"]) - assert.Equal(t, MergeModeReplace, patch.MergeMode) - assert.Equal(t, ArrayMergeStrategyAppend, patch.ArrayMergeStrategy) - - assert.False(t, patch.IsEmpty()) -} - -func TestFrontMatterPatch_Apply_Replace(t *testing.T) { - original := &FrontMatter{ - Title: "Original Title", - Tags: []string{"old_tag"}, - Categories: []string{"old_cat"}, - Custom: map[string]any{ - "old_field": "old_value", - }, - } - - patch := NewFrontMatterPatch(). - SetTitle("New Title"). - SetTags([]string{"new_tag"}). - SetCustom("new_field", "new_value"). - WithMergeMode(MergeModeReplace) - - result, err := patch.Apply(original) - require.NoError(t, err) - - // Verify replacement - assert.Equal(t, "New Title", result.Title) - assert.Equal(t, []string{"new_tag"}, result.Tags) - assert.Equal(t, "new_value", result.Custom["new_field"]) - assert.Equal(t, "old_value", result.Custom["old_field"]) // Custom fields are additive - - // Verify original unchanged - assert.Equal(t, "Original Title", original.Title) - assert.Equal(t, []string{"old_tag"}, original.Tags) -} - -func TestFrontMatterPatch_Apply_SetIfMissing(t *testing.T) { - original := &FrontMatter{ - Title: "Existing Title", - Description: "", // Empty, should be set - Tags: []string{"existing_tag"}, - Categories: nil, // Nil, should be set - Custom: map[string]any{ - "existing_field": "existing_value", - }, - } - - patch := NewFrontMatterPatch(). - SetTitle("New Title"). // Should not override - SetDescription("New Desc"). // Should set (empty) - SetTags([]string{"new_tag"}). // Should not override (has items) - SetCategories([]string{"new_cat"}). // Should set (nil/empty) - SetCustom("new_field", "new_value"). // Should set (missing) - SetCustom("existing_field", "new_value"). // Should not override - WithMergeMode(MergeModeSetIfMissing) - - result, err := patch.Apply(original) - require.NoError(t, err) - - assert.Equal(t, "Existing Title", result.Title) // Not overridden - assert.Equal(t, "New Desc", result.Description) // Set because empty - assert.Equal(t, []string{"existing_tag"}, result.Tags) // Not overridden - assert.Equal(t, []string{"new_cat"}, result.Categories) // Set because empty - assert.Equal(t, "existing_value", result.Custom["existing_field"]) // Not overridden - assert.Equal(t, "new_value", result.Custom["new_field"]) // Set because missing -} - -func TestFrontMatterPatch_Apply_Deep(t *testing.T) { - original := &FrontMatter{ - Title: "Original Title", - Tags: []string{"tag1", "tag2"}, - Custom: map[string]any{ - "existing_field": "existing_value", - }, - } - - tests := []struct { - name string - arrayMergeStrategy ArrayMergeStrategy - patchTags []string - expectedTags []string - }{ - { - name: "union strategy", - arrayMergeStrategy: ArrayMergeStrategyUnion, - patchTags: []string{"tag2", "tag3"}, // tag2 is duplicate - expectedTags: []string{"tag1", "tag2", "tag3"}, - }, - { - name: "append strategy", - arrayMergeStrategy: ArrayMergeStrategyAppend, - patchTags: []string{"tag2", "tag3"}, // tag2 will be duplicated - expectedTags: []string{"tag1", "tag2", "tag2", "tag3"}, - }, - { - name: "replace strategy", - arrayMergeStrategy: ArrayMergeStrategyReplace, - patchTags: []string{"tag3", "tag4"}, - expectedTags: []string{"tag3", "tag4"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - patch := NewFrontMatterPatch(). - SetTitle("New Title"). - SetTags(tt.patchTags). - SetCustom("new_field", "new_value"). - WithMergeMode(MergeModeDeep). - WithArrayMergeStrategy(tt.arrayMergeStrategy) - - result, err := patch.Apply(original) - require.NoError(t, err) - - assert.Equal(t, "New Title", result.Title) - assert.Equal(t, tt.expectedTags, result.Tags) - assert.Equal(t, "existing_value", result.Custom["existing_field"]) - assert.Equal(t, "new_value", result.Custom["new_field"]) - }) - } -} - -func TestFrontMatterPatch_Apply_NilInput(t *testing.T) { - patch := NewFrontMatterPatch().SetTitle("Test") - - result, err := patch.Apply(nil) - assert.Error(t, err) - assert.Nil(t, result) - assert.Contains(t, err.Error(), "cannot apply patch to nil front matter") -} - -func TestFrontMatterPatch_mergeStringArray(t *testing.T) { - patch := NewFrontMatterPatch() - - tests := []struct { - name string - strategy ArrayMergeStrategy - existing []string - new []string - expected []string - }{ - { - name: "replace", - strategy: ArrayMergeStrategyReplace, - existing: []string{"a", "b"}, - new: []string{"c", "d"}, - expected: []string{"c", "d"}, - }, - { - name: "append", - strategy: ArrayMergeStrategyAppend, - existing: []string{"a", "b"}, - new: []string{"b", "c"}, - expected: []string{"a", "b", "b", "c"}, - }, - { - name: "union", - strategy: ArrayMergeStrategyUnion, - existing: []string{"a", "b"}, - new: []string{"b", "c"}, - expected: []string{"a", "b", "c"}, - }, - { - name: "union with empty existing", - strategy: ArrayMergeStrategyUnion, - existing: []string{}, - new: []string{"a", "b"}, - expected: []string{"a", "b"}, - }, - { - name: "union with empty new", - strategy: ArrayMergeStrategyUnion, - existing: []string{"a", "b"}, - new: []string{}, - expected: []string{"a", "b"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - patch.ArrayMergeStrategy = tt.strategy - result := patch.mergeStringArray(tt.existing, tt.new) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestFrontMatterPatch_ToMap(t *testing.T) { - testTime := time.Date(2023, 12, 25, 10, 30, 0, 0, time.UTC) - - patch := NewFrontMatterPatch(). - SetTitle("Test Title"). - SetDate(testTime). - SetDraft(true). - SetTags([]string{"tag1", "tag2"}). - SetCustom("custom_field", "custom_value") - - result := patch.ToMap() - - assert.Equal(t, "Test Title", result["title"]) - assert.Equal(t, "2023-12-25T10:30:00+00:00", result["date"]) - assert.Equal(t, true, result["draft"]) - assert.Equal(t, []string{"tag1", "tag2"}, result["tags"]) - assert.Equal(t, "custom_value", result["custom_field"]) -} - -func TestFrontMatterPatch_IsEmpty(t *testing.T) { - patch := NewFrontMatterPatch() - assert.True(t, patch.IsEmpty()) - - patch.SetTitle("Test") - assert.False(t, patch.IsEmpty()) - - patch = NewFrontMatterPatch() - patch.SetCustom("key", "value") - assert.False(t, patch.IsEmpty()) -} - -func TestFrontMatterPatch_String(t *testing.T) { - patch := NewFrontMatterPatch(). - SetTitle("Test Title"). - SetRepository("test-repo"). - SetSection("docs"). - SetCustom("key1", "value1"). - SetCustom("key2", "value2") - - result := patch.String() - - assert.Contains(t, result, "FrontMatterPatch{") - assert.Contains(t, result, `title="Test Title"`) - assert.Contains(t, result, `repository="test-repo"`) - assert.Contains(t, result, `section="docs"`) - assert.Contains(t, result, "custom=2_fields") -} - -func TestMergeMode_String(t *testing.T) { - tests := []struct { - mode MergeMode - expected string - }{ - {MergeModeDeep, "deep"}, - {MergeModeReplace, "replace"}, - {MergeModeSetIfMissing, "set_if_missing"}, - {MergeMode(999), "unknown"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.mode.String()) - }) - } -} - -func TestArrayMergeStrategy_String(t *testing.T) { - tests := []struct { - strategy ArrayMergeStrategy - expected string - }{ - {ArrayMergeStrategyAppend, "append"}, - {ArrayMergeStrategyUnion, "union"}, - {ArrayMergeStrategyReplace, "replace"}, - {ArrayMergeStrategy(999), "unknown"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - assert.Equal(t, tt.expected, tt.strategy.String()) - }) - } -} diff --git a/internal/hugo/models/report.go b/internal/hugo/models/report.go index 0c8980bd..c7b8a731 100644 --- a/internal/hugo/models/report.go +++ b/internal/hugo/models/report.go @@ -11,7 +11,7 @@ import ( "regexp" "time" - "git.home.luguber.info/inful/docbuilder/internal/metrics" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/version" ) @@ -190,8 +190,8 @@ type StageCount struct { // Finish sets the end time of the report. func (r *BuildReport) Finish() { r.End = time.Now() } -// RecordStageResult updates BuildReport counters and emits metrics (if recorder non-nil). -func (r *BuildReport) RecordStageResult(stage StageName, res StageResult, recorder metrics.Recorder) { +// RecordStageResult updates BuildReport counters. +func (r *BuildReport) RecordStageResult(stage StageName, res StageResult) { if r.StageCounts == nil { r.StageCounts = make(map[StageName]StageCount) } @@ -199,24 +199,12 @@ func (r *BuildReport) RecordStageResult(stage StageName, res StageResult, record switch res { case StageResultSuccess: sc.Success++ - if recorder != nil { - recorder.IncStageResult(string(stage), metrics.ResultSuccess) - } case StageResultWarning: sc.Warning++ - if recorder != nil { - recorder.IncStageResult(string(stage), metrics.ResultWarning) - } case StageResultFatal: sc.Fatal++ - if recorder != nil { - recorder.IncStageResult(string(stage), metrics.ResultFatal) - } case StageResultCanceled: sc.Canceled++ - if recorder != nil { - recorder.IncStageResult(string(stage), metrics.ResultCanceled) - } case StageResultSkipped: // No counters for skipped yet } @@ -256,29 +244,29 @@ func (r *BuildReport) Persist(root string) error { r.DeriveOutcome() } if err := os.MkdirAll(root, 0o750); err != nil { - return fmt.Errorf("ensure root for report: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "ensure root for report").Build() } // JSON jb, err := json.MarshalIndent(r.SanitizedCopy(), "", " ") if err != nil { - return fmt.Errorf("marshal report json: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "marshal report json").Build() } jsonPath := filepath.Join(root, "build-report.json") tmpJSON := jsonPath + ".tmp" if err := os.WriteFile(tmpJSON, jb, 0o600); err != nil { - return fmt.Errorf("write temp report json: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "write temp report json").Build() } if err := os.Rename(tmpJSON, jsonPath); err != nil { - return fmt.Errorf("atomic rename json: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "atomic rename json").Build() } // Text summary summaryPath := filepath.Join(root, "build-report.txt") tmpTxt := summaryPath + ".tmp" if err := os.WriteFile(tmpTxt, []byte(r.Summary()+"\n"), 0o600); err != nil { - return fmt.Errorf("write temp report summary: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "write temp report summary").Build() } if err := os.Rename(tmpTxt, summaryPath); err != nil { - return fmt.Errorf("atomic rename summary: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "atomic rename summary").Build() } return nil } diff --git a/internal/hugo/models/stages.go b/internal/hugo/models/stages.go index 694615f9..d7042a47 100644 --- a/internal/hugo/models/stages.go +++ b/internal/hugo/models/stages.go @@ -21,11 +21,8 @@ const ( StageDiscoverDocs StageName = "discover_docs" StageCategoriesMenu StageName = "categories_menu" StageGenerateConfig StageName = "generate_config" - StageLayouts StageName = "layouts" StageCopyContent StageName = "copy_content" - StageIndexes StageName = "indexes" StageRunHugo StageName = "run_hugo" - StagePostProcess StageName = "post_process" ) // StageErrorKind classifies the outcome of a stage. @@ -74,7 +71,7 @@ func (e *StageError) Transient() bool { if isSentinel(ErrDiscovery) { return e.Kind == StageErrorWarning } - case StagePrepareOutput, StageCategoriesMenu, StageGenerateConfig, StageLayouts, StageCopyContent, StageIndexes, StagePostProcess: + case StagePrepareOutput, StageCategoriesMenu, StageGenerateConfig, StageCopyContent: return false } return false diff --git a/internal/hugo/models/transform.go b/internal/hugo/models/transform.go deleted file mode 100644 index 9c831237..00000000 --- a/internal/hugo/models/transform.go +++ /dev/null @@ -1,448 +0,0 @@ -package models - -import ( - "time" - - "git.home.luguber.info/inful/docbuilder/internal/docs" -) - -// TransformContext provides strongly-typed context for transformations. -// This replaces the interface{} approach with compile-time type safety. -type TransformContext struct { - // Generator access for configuration and utilities - Generator GeneratorProvider - - // Timing information - StartTime time.Time - - // Transformation metadata - Source string // Name of the transformer - Priority int // Execution priority - Properties map[string]any // Custom transformer properties -} - -// GeneratorProvider provides access to generator functionality without import cycles. -type GeneratorProvider interface { - // Configuration access - GetConfig() ConfigProvider - - // Resolver access - GetEditLinkResolver() EditLinkResolver - - // Forge information (theme capabilities removed - Relearn always wants per-page edit links) - GetForgeCapabilities(forgeType string) ForgeCapabilities -} - -// ConfigProvider provides type-safe access to configuration. -type ConfigProvider interface { - GetHugoConfig() HugoConfig - GetForgeConfig() ForgeConfig - GetTransformConfig() TransformConfig -} - -// HugoConfig represents Hugo-specific configuration. -type HugoConfig struct { - ThemeType string - BaseURL string - Title string - EnableMarkdown bool -} - -// ForgeConfig represents forge-specific configuration. -type ForgeConfig struct { - DefaultForge string - EditLinks bool -} - -// TransformConfig represents transformation pipeline configuration. -type TransformConfig struct { - EnabledTransforms []string - DisabledTransforms []string - Properties map[string]any -} - -// EditLinkResolver provides type-safe edit link resolution. -type EditLinkResolver interface { - Resolve(file docs.DocFile) string - SupportsFile(file docs.DocFile) bool -} - -// ForgeCapabilities represents forge-specific capabilities. -type ForgeCapabilities struct { - SupportsEditLinks bool - SupportsWebhooks bool - APIAvailable bool -} - -// ContentTransformation represents a single transformation operation. -type ContentTransformation struct { - // Identification - Name string - Description string - Version string - - // Execution metadata - Priority int - Enabled bool - - // Dependencies - RequiredBefore []string - RequiredAfter []string - - // Configuration - Config map[string]any -} - -// TransformationResult represents the result of a transformation. -type TransformationResult struct { - // Success status - Success bool - Error error - Source string // Name of the transformer that produced this result - - // Changes made - ContentModified bool - FrontMatterModified bool - MetadataModified bool - - // Performance metrics - Duration time.Duration - - // Detailed changes - Changes []ChangeRecord -} - -// ChangeRecord documents a specific change made during transformation. -type ChangeRecord struct { - Type ChangeType - Field string - OldValue any - NewValue any - Reason string - Source string - Timestamp time.Time -} - -// ChangeType enumerates the types of changes that can be made. -type ChangeType int - -const ( - ChangeTypeContentModified ChangeType = iota - ChangeTypeFrontMatterAdded - ChangeTypeFrontMatterModified - ChangeTypeFrontMatterRemoved - ChangeTypeMetadataAdded - ChangeTypeMetadataModified - ChangeTypeMetadataRemoved - ChangeTypeStructureModified -) - -// String returns a string representation of the ChangeType. -func (ct ChangeType) String() string { - switch ct { - case ChangeTypeContentModified: - return "content_modified" - case ChangeTypeFrontMatterAdded: - return "front_matter_added" - case ChangeTypeFrontMatterModified: - return "front_matter_modified" - case ChangeTypeFrontMatterRemoved: - return "front_matter_removed" - case ChangeTypeMetadataAdded: - return "metadata_added" - case ChangeTypeMetadataModified: - return "metadata_modified" - case ChangeTypeMetadataRemoved: - return "metadata_removed" - case ChangeTypeStructureModified: - return "structure_modified" - default: - return "unknown" - } -} - -// TransformationPipeline represents a complete transformation pipeline. -type TransformationPipeline struct { - // Identification - Name string - Description string - Version string - - // Configuration - Transformations []ContentTransformation - Context *TransformContext - - // Execution state - Started bool - Completed bool - Failed bool - - // Results - Results []TransformationResult - - // Performance - TotalDuration time.Duration - StartTime time.Time - EndTime time.Time -} - -// NewTransformContext creates a new transform context with the given provider. -func NewTransformContext(provider GeneratorProvider) *TransformContext { - return &TransformContext{ - Generator: provider, - StartTime: time.Now(), - Properties: make(map[string]any), - } -} - -// WithSource sets the source transformer name. -func (tc *TransformContext) WithSource(source string) *TransformContext { - tc.Source = source - return tc -} - -// WithPriority sets the execution priority. -func (tc *TransformContext) WithPriority(priority int) *TransformContext { - tc.Priority = priority - return tc -} - -// WithProperty sets a custom property. -func (tc *TransformContext) WithProperty(key string, value any) *TransformContext { - if tc.Properties == nil { - tc.Properties = make(map[string]any) - } - tc.Properties[key] = value - return tc -} - -// GetProperty retrieves a custom property. -func (tc *TransformContext) GetProperty(key string) (any, bool) { - if tc.Properties == nil { - return nil, false - } - value, exists := tc.Properties[key] - return value, exists -} - -// GetPropertyString retrieves a custom property as a string. -func (tc *TransformContext) GetPropertyString(key string) (string, bool) { - value, exists := tc.GetProperty(key) - if !exists { - return "", false - } - if str, ok := value.(string); ok { - return str, true - } - return "", false -} - -// GetPropertyInt retrieves a custom property as an integer. -func (tc *TransformContext) GetPropertyInt(key string) (int, bool) { - value, exists := tc.GetProperty(key) - if !exists { - return 0, false - } - if i, ok := value.(int); ok { - return i, true - } - return 0, false -} - -// GetPropertyBool retrieves a custom property as a boolean. -func (tc *TransformContext) GetPropertyBool(key string) (bool, bool) { - value, exists := tc.GetProperty(key) - if !exists { - return false, false - } - if b, ok := value.(bool); ok { - return b, true - } - return false, false -} - -// NewTransformationResult creates a new transformation result. -func NewTransformationResult() *TransformationResult { - return &TransformationResult{ - Changes: make([]ChangeRecord, 0), - } -} - -// SetSuccess marks the transformation as successful. -func (tr *TransformationResult) SetSuccess() *TransformationResult { - tr.Success = true - tr.Error = nil - return tr -} - -// SetError marks the transformation as failed with the given error. -func (tr *TransformationResult) SetError(err error) *TransformationResult { - tr.Success = false - tr.Error = err - return tr -} - -// SetSource sets the source transformer name for this result. -func (tr *TransformationResult) SetSource(source string) *TransformationResult { - tr.Source = source - return tr -} - -// AddChange records a change made during transformation. -func (tr *TransformationResult) AddChange(changeType ChangeType, field string, oldValue, newValue any, reason, source string) *TransformationResult { - change := ChangeRecord{ - Type: changeType, - Field: field, - OldValue: oldValue, - NewValue: newValue, - Reason: reason, - Source: source, - Timestamp: time.Now(), - } - tr.Changes = append(tr.Changes, change) - - // Update modification flags - switch changeType { - case ChangeTypeContentModified: - tr.ContentModified = true - case ChangeTypeFrontMatterAdded, ChangeTypeFrontMatterModified, ChangeTypeFrontMatterRemoved: - tr.FrontMatterModified = true - case ChangeTypeMetadataAdded, ChangeTypeMetadataModified, ChangeTypeMetadataRemoved: - tr.MetadataModified = true - case ChangeTypeStructureModified: - // Structure modifications are tracked separately, no flag to set - } - - return tr -} - -// SetDuration sets the transformation duration. -func (tr *TransformationResult) SetDuration(duration time.Duration) *TransformationResult { - tr.Duration = duration - return tr -} - -// HasChanges returns true if any changes were recorded. -func (tr *TransformationResult) HasChanges() bool { - return len(tr.Changes) > 0 -} - -// GetChangesByType returns changes of a specific type. -func (tr *TransformationResult) GetChangesByType(changeType ChangeType) []ChangeRecord { - var result []ChangeRecord - for _, change := range tr.Changes { - if change.Type == changeType { - result = append(result, change) - } - } - return result -} - -// GetChangesByField returns changes for a specific field. -func (tr *TransformationResult) GetChangesByField(field string) []ChangeRecord { - var result []ChangeRecord - for _, change := range tr.Changes { - if change.Field == field { - result = append(result, change) - } - } - return result -} - -// NewTransformationPipeline creates a new transformation pipeline. -func NewTransformationPipeline(name, description, version string) *TransformationPipeline { - return &TransformationPipeline{ - Name: name, - Description: description, - Version: version, - Transformations: make([]ContentTransformation, 0), - Results: make([]TransformationResult, 0), - } -} - -// AddTransformation adds a transformation to the pipeline. -func (tp *TransformationPipeline) AddTransformation(transformation ContentTransformation) *TransformationPipeline { - tp.Transformations = append(tp.Transformations, transformation) - return tp -} - -// SetContext sets the transform context for the pipeline. -func (tp *TransformationPipeline) SetContext(context *TransformContext) *TransformationPipeline { - tp.Context = context - return tp -} - -// Start marks the pipeline as started. -func (tp *TransformationPipeline) Start() *TransformationPipeline { - tp.Started = true - tp.StartTime = time.Now() - return tp -} - -// Complete marks the pipeline as completed. -func (tp *TransformationPipeline) Complete() *TransformationPipeline { - tp.Completed = true - tp.EndTime = time.Now() - tp.TotalDuration = tp.EndTime.Sub(tp.StartTime) - return tp -} - -// Fail marks the pipeline as failed. -func (tp *TransformationPipeline) Fail() *TransformationPipeline { - tp.Failed = true - tp.EndTime = time.Now() - tp.TotalDuration = tp.EndTime.Sub(tp.StartTime) - return tp -} - -// AddResult adds a transformation result to the pipeline. -func (tp *TransformationPipeline) AddResult(result TransformationResult) *TransformationPipeline { - tp.Results = append(tp.Results, result) - return tp -} - -// IsRunning returns true if the pipeline is currently running. -func (tp *TransformationPipeline) IsRunning() bool { - return tp.Started && !tp.Completed && !tp.Failed -} - -// IsComplete returns true if the pipeline completed successfully. -func (tp *TransformationPipeline) IsComplete() bool { - return tp.Completed && !tp.Failed -} - -// IsFailed returns true if the pipeline failed. -func (tp *TransformationPipeline) IsFailed() bool { - return tp.Failed -} - -// GetSuccessfulResults returns only successful transformation results. -func (tp *TransformationPipeline) GetSuccessfulResults() []TransformationResult { - var results []TransformationResult - for _, result := range tp.Results { - if result.Success { - results = append(results, result) - } - } - return results -} - -// GetFailedResults returns only failed transformation results. -func (tp *TransformationPipeline) GetFailedResults() []TransformationResult { - var results []TransformationResult - for _, result := range tp.Results { - if !result.Success { - results = append(results, result) - } - } - return results -} - -// GetTotalChanges returns the total number of changes across all results. -func (tp *TransformationPipeline) GetTotalChanges() int { - total := 0 - for _, result := range tp.Results { - total += len(result.Changes) - } - return total -} diff --git a/internal/hugo/models/transform_test.go b/internal/hugo/models/transform_test.go deleted file mode 100644 index 710f692c..00000000 --- a/internal/hugo/models/transform_test.go +++ /dev/null @@ -1,405 +0,0 @@ -package models - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "git.home.luguber.info/inful/docbuilder/internal/docs" -) - -func TestTransformContext(t *testing.T) { - provider := &mockGeneratorProvider{} - - context := NewTransformContext(provider). - WithSource("test_transformer"). - WithPriority(10). - WithProperty("test_key", "test_value") - - assert.Equal(t, "test_transformer", context.Source) - assert.Equal(t, 10, context.Priority) - - value, exists := context.GetProperty("test_key") - assert.True(t, exists) - assert.Equal(t, "test_value", value) - - str, exists := context.GetPropertyString("test_key") - assert.True(t, exists) - assert.Equal(t, "test_value", str) - - _, exists = context.GetProperty("missing_key") - assert.False(t, exists) -} - -func TestTransformationResult(t *testing.T) { - result := NewTransformationResult() - - // Test success - result.SetSuccess() - assert.True(t, result.Success) - assert.Nil(t, result.Error) - - // Test adding changes - result.AddChange( - ChangeTypeContentModified, - "content", - "old content", - "new content", - "test change", - "test_transformer", - ) - - assert.True(t, result.HasChanges()) - assert.True(t, result.ContentModified) - assert.False(t, result.FrontMatterModified) - assert.Len(t, result.Changes, 1) - - change := result.Changes[0] - assert.Equal(t, ChangeTypeContentModified, change.Type) - assert.Equal(t, "content", change.Field) - assert.Equal(t, "old content", change.OldValue) - assert.Equal(t, "new content", change.NewValue) - - // Test filtering changes - contentChanges := result.GetChangesByType(ChangeTypeContentModified) - assert.Len(t, contentChanges, 1) - - fieldChanges := result.GetChangesByField("content") - assert.Len(t, fieldChanges, 1) -} - -func TestContentPage(t *testing.T) { - file := docs.DocFile{ - Path: "test.md", - Name: "test.md", - Repository: "test-repo", - Section: "docs", - } - - page := NewContentPage(file) - assert.Equal(t, file, page.File) - assert.NotNil(t, page.FrontMatter) - assert.False(t, page.IsModified()) - - // Test content modification - page.SetContent("# Test Content") - assert.Equal(t, "# Test Content", page.GetContent()) - assert.True(t, page.ContentModified) - assert.True(t, page.IsModified()) - - // Test front matter modification - fm := NewFrontMatter() - fm.Title = "Test Title" - page.SetFrontMatter(fm) - assert.Equal(t, "Test Title", page.GetFrontMatter().Title) - assert.True(t, page.FrontMatterModified) - - // Test patch application - patch := NewFrontMatterPatch().SetDescription("Test Description") - page.AddFrontMatterPatch(patch) - assert.Len(t, page.FrontMatterPatches, 1) - - err := page.ApplyFrontMatterPatches() - require.NoError(t, err) - assert.Equal(t, "Test Description", page.GetFrontMatter().Description) -} - -func TestContentPageClone(t *testing.T) { - file := docs.DocFile{ - Path: "test.md", - Name: "test.md", - Repository: "test-repo", - } - - original := NewContentPage(file) - original.SetContent("Original content") - - fm := NewFrontMatter() - fm.Title = "Original Title" - original.SetFrontMatter(fm) - - clone := original.Clone() - assert.Equal(t, original.GetContent(), clone.GetContent()) - assert.Equal(t, original.GetFrontMatter().Title, clone.GetFrontMatter().Title) - - // Modify clone and verify original is unchanged - clone.SetContent("Modified content") - clone.GetFrontMatter().Title = "Modified Title" - - assert.Equal(t, "Original content", original.GetContent()) - assert.Equal(t, "Original Title", original.GetFrontMatter().Title) - assert.Equal(t, "Modified content", clone.GetContent()) - assert.Equal(t, "Modified Title", clone.GetFrontMatter().Title) -} - -func TestTypedTransformerRegistry(t *testing.T) { - registry := NewTypedTransformerRegistry() - - // Create test transformers - transformer1 := &mockTransformer{ - name: "transformer1", - priority: 20, - } - transformer2 := &mockTransformer{ - name: "transformer2", - priority: 10, - } - - // Register transformers - err := registry.Register(transformer1) - require.NoError(t, err) - - err = registry.Register(transformer2) - require.NoError(t, err) - - // Test duplicate registration - err = registry.Register(transformer1) - assert.Error(t, err) - - // Test retrieval - retrieved, exists := registry.Get("transformer1") - assert.True(t, exists) - assert.Equal(t, transformer1, retrieved) - - _, exists = registry.Get("nonexistent") - assert.False(t, exists) - - // Test listing - all := registry.List() - assert.Len(t, all, 2) - - // Test priority sorting - sorted := registry.ListByPriority() - assert.Len(t, sorted, 2) - assert.Equal(t, "transformer2", sorted[0].Name()) // Lower priority first - assert.Equal(t, "transformer1", sorted[1].Name()) -} - -func TestFrontMatterParserV2(t *testing.T) { - parser := NewFrontMatterParserV2() - - assert.Equal(t, "front_matter_parser_v2", parser.Name()) - - // Test with front matter - content := `--- -title: Test Title -description: Test Description ---- - -# Content Here` - - file := docs.DocFile{Path: "test.md", Name: "test.md"} - page := NewContentPage(file) - page.SetContent(content) - - context := NewTransformContext(&mockGeneratorProvider{}) - - canTransform := parser.CanTransform(page, context) - assert.True(t, canTransform) - - result, err := parser.Transform(page, context) - require.NoError(t, err) - assert.True(t, result.Success) - - // Verify front matter was parsed - assert.True(t, page.HadOriginalFrontMatter) - assert.NotNil(t, page.GetOriginalFrontMatter()) - assert.Equal(t, "Test Title", page.GetOriginalFrontMatter().Title) - assert.Equal(t, "Test Description", page.GetOriginalFrontMatter().Description) - - // Verify content was updated - assert.Equal(t, "\n# Content Here", page.GetContent()) - - // Verify changes were recorded - assert.True(t, result.HasChanges()) - assert.True(t, result.FrontMatterModified) - assert.True(t, result.ContentModified) -} - -func TestFrontMatterParserV2_NoFrontMatter(t *testing.T) { - parser := NewFrontMatterParserV2() - - content := "# Just Content" - - file := docs.DocFile{Path: "test.md", Name: "test.md"} - page := NewContentPage(file) - page.SetContent(content) - - context := NewTransformContext(&mockGeneratorProvider{}) - - canTransform := parser.CanTransform(page, context) - assert.False(t, canTransform) - - result, err := parser.Transform(page, context) - require.NoError(t, err) - assert.True(t, result.Success) - - // Verify nothing changed - assert.False(t, page.HadOriginalFrontMatter) - assert.Equal(t, content, page.GetContent()) - assert.False(t, result.HasChanges()) -} - -func TestFrontMatterBuilderV3(t *testing.T) { - builder := NewFrontMatterBuilderV3() - - assert.Equal(t, "front_matter_builder_v3", builder.Name()) - - file := docs.DocFile{ - Path: "docs/api/example.md", - Name: "example.md", - Repository: "test-repo", - Section: "api", - Forge: "github", - Metadata: map[string]string{ - "category": "api", - "version": "1.0", - }, - } - - page := NewContentPage(file) - context := NewTransformContext(&mockGeneratorProvider{}) - - canTransform := builder.CanTransform(page, context) - assert.True(t, canTransform) - - result, err := builder.Transform(page, context) - require.NoError(t, err) - assert.True(t, result.Success) - - // Verify patch was added - assert.Len(t, page.FrontMatterPatches, 1) - - // Apply patches and verify content - err = page.ApplyFrontMatterPatches() - require.NoError(t, err) - - fm := page.GetFrontMatter() - assert.Equal(t, "example", fm.Title) - assert.Equal(t, "test-repo", fm.Repository) - assert.Equal(t, "api", fm.Section) - assert.Equal(t, "github", fm.Forge) - - // Verify custom metadata - category, exists := fm.GetCustomString("category") - assert.True(t, exists) - assert.Equal(t, "api", category) - - version, exists := fm.GetCustomString("version") - assert.True(t, exists) - assert.Equal(t, "1.0", version) -} - -func TestTransformationPipeline(t *testing.T) { - pipeline := NewTransformationPipeline( - "test_pipeline", - "Test transformation pipeline", - "1.0.0", - ) - - assert.Equal(t, "test_pipeline", pipeline.Name) - assert.False(t, pipeline.IsRunning()) - assert.False(t, pipeline.IsComplete()) - assert.False(t, pipeline.IsFailed()) - - // Start pipeline - pipeline.Start() - assert.True(t, pipeline.Started) - assert.True(t, pipeline.IsRunning()) - - // Add results - successResult := NewTransformationResult().SetSuccess().SetDuration(time.Millisecond) - failureResult := NewTransformationResult().SetError(assert.AnError).SetDuration(time.Millisecond) - - pipeline.AddResult(*successResult).AddResult(*failureResult) - assert.Len(t, pipeline.Results, 2) - - // Complete pipeline - pipeline.Complete() - assert.True(t, pipeline.Completed) - assert.False(t, pipeline.IsRunning()) - assert.True(t, pipeline.IsComplete()) - - // Test result filtering - successful := pipeline.GetSuccessfulResults() - failed := pipeline.GetFailedResults() - - assert.Len(t, successful, 1) - assert.Len(t, failed, 1) - assert.True(t, successful[0].Success) - assert.False(t, failed[0].Success) -} - -// Mock implementations for testing - -type mockGeneratorProvider struct{} - -func (m *mockGeneratorProvider) GetConfig() ConfigProvider { - return &mockConfigProvider{} -} - -func (m *mockGeneratorProvider) GetEditLinkResolver() EditLinkResolver { - return &mockEditLinkResolver{} -} - -func (m *mockGeneratorProvider) GetForgeCapabilities(_ string) ForgeCapabilities { - return ForgeCapabilities{ - SupportsEditLinks: true, - } -} - -type mockConfigProvider struct{} - -func (m *mockConfigProvider) GetHugoConfig() HugoConfig { - return HugoConfig{ThemeType: "relearn"} -} - -func (m *mockConfigProvider) GetForgeConfig() ForgeConfig { - return ForgeConfig{EditLinks: true} -} - -func (m *mockConfigProvider) GetTransformConfig() TransformConfig { - return TransformConfig{} -} - -type mockEditLinkResolver struct{} - -func (m *mockEditLinkResolver) Resolve(file docs.DocFile) string { - return "https://github.com/test/edit/" + file.Path -} - -func (m *mockEditLinkResolver) SupportsFile(_ docs.DocFile) bool { - return true -} - -type mockTransformer struct { - name string - priority int - stage TransformStage - enabled bool -} - -func (m *mockTransformer) Name() string { return m.name } -func (m *mockTransformer) Description() string { return "Mock transformer" } -func (m *mockTransformer) Version() string { return "1.0.0" } -func (m *mockTransformer) Priority() int { return m.priority } -func (m *mockTransformer) Stage() TransformStage { - if m.stage == "" { - return StageParse // Default stage - } - return m.stage -} -func (m *mockTransformer) Dependencies() TransformerDependencies { return TransformerDependencies{} } -func (m *mockTransformer) Configuration() TransformerConfiguration { - return TransformerConfiguration{Enabled: m.enabled} -} - -func (m *mockTransformer) CanTransform(_ *ContentPage, _ *TransformContext) bool { - return m.enabled -} -func (m *mockTransformer) RequiredContext() []string { return []string{} } -func (m *mockTransformer) Transform(_ *ContentPage, _ *TransformContext) (*TransformationResult, error) { - return NewTransformationResult().SetSuccess(), nil -} diff --git a/internal/hugo/models/transformer.go b/internal/hugo/models/transformer.go deleted file mode 100644 index 20545209..00000000 --- a/internal/hugo/models/transformer.go +++ /dev/null @@ -1,602 +0,0 @@ -package models - -import ( - "errors" - "fmt" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/docs" -) - -// TransformStage represents a major phase in the typed transformation pipeline. -// This aligns with the core transforms stage model. -type TransformStage string - -const ( - StageParse TransformStage = "parse" // Extract/parse source content - StageBuild TransformStage = "build" // Generate base metadata - StageEnrich TransformStage = "enrich" // Add computed fields - StageMerge TransformStage = "merge" // Combine/merge data - StageTransform TransformStage = "transform" // Modify content - StageFinalize TransformStage = "finalize" // Post-process - StageSerialize TransformStage = "serialize" // Output generation -) - -// ContentPage represents a strongly-typed page being transformed. -// This replaces the interface{} approach with compile-time type safety. -type ContentPage struct { - // File information - File docs.DocFile - - // Content - Content string - RawBytes []byte - - // Front matter - FrontMatter *FrontMatter - OriginalFrontMatter *FrontMatter - FrontMatterPatches []*FrontMatterPatch - - // State tracking - HadOriginalFrontMatter bool - ContentModified bool - FrontMatterModified bool - - // Transformation tracking - TransformationHistory []TransformationRecord - Conflicts []FrontMatterConflict - - // Metadata - ProcessingStartTime time.Time - LastModified time.Time -} - -// TransformationRecord tracks individual transformation operations. -type TransformationRecord struct { - Transformer string - Priority int - Timestamp time.Time - Duration time.Duration - Success bool - Error error - Changes []ChangeRecord -} - -// FrontMatterConflict describes merge decisions for auditing. -type FrontMatterConflict struct { - Key string - Original any - Attempt any - Source string - Action string // kept_original | overwritten | set_if_missing -} - -// TypedTransformer defines a strongly-typed content transformation interface. -// This replaces the reflection-based PageAdapter approach. -type TypedTransformer interface { - // Identity - Name() string - Description() string - Version() string - - // Execution - Transform(page *ContentPage, context *TransformContext) (*TransformationResult, error) - - // Metadata - Priority() int // DEPRECATED: Use Dependencies().MustRunAfter/MustRunBefore instead - Stage() TransformStage - Dependencies() TransformerDependencies - Configuration() TransformerConfiguration - - // Capabilities - CanTransform(page *ContentPage, context *TransformContext) bool - RequiredContext() []string -} - -// TransformerDependencies defines what a transformer needs to run properly. -type TransformerDependencies struct { - // Order dependencies (aligned with core transforms pattern) - MustRunAfter []string // Must run after these transformers - MustRunBefore []string // Must run before these transformers - - // Legacy order dependencies (DEPRECATED) - RequiredBefore []string // DEPRECATED: Use MustRunBefore instead - RequiredAfter []string // DEPRECATED: Use MustRunAfter instead - - // Feature dependencies - RequiresOriginalFrontMatter bool - RequiresFrontMatterPatches bool - RequiresContent bool - RequiresFileMetadata bool - - // Context dependencies - RequiresConfig bool - RequiresEditLinkResolver bool - RequiresThemeInfo bool - RequiresForgeInfo bool -} - -// TransformerConfiguration defines transformer-specific configuration. -type TransformerConfiguration struct { - // Basic settings - Enabled bool - Priority int - - // Behavior configuration - SkipIfEmpty bool - FailOnError bool - RecordChanges bool - - // Feature flags - EnableDeepMerge bool - PreservesOriginal bool - ModifiesContent bool - ModifiesFrontMatter bool - - // Custom configuration - Properties map[string]any -} - -// TypedTransformerRegistry provides a strongly-typed transformer registry. -type TypedTransformerRegistry struct { - transformers map[string]TypedTransformer - order []string -} - -// NewTypedTransformerRegistry creates a new typed transformer registry. -func NewTypedTransformerRegistry() *TypedTransformerRegistry { - return &TypedTransformerRegistry{ - transformers: make(map[string]TypedTransformer), - order: make([]string, 0), - } -} - -// Register adds a transformer to the registry. -func (r *TypedTransformerRegistry) Register(transformer TypedTransformer) error { - if transformer == nil { - return errors.New("transformer cannot be nil") - } - - name := transformer.Name() - if name == "" { - return errors.New("transformer name cannot be empty") - } - - // Check for conflicts - if _, exists := r.transformers[name]; exists { - return fmt.Errorf("transformer with name %q already registered", name) - } - - r.transformers[name] = transformer - r.order = append(r.order, name) - - return nil -} - -// Get retrieves a transformer by name. -func (r *TypedTransformerRegistry) Get(name string) (TypedTransformer, bool) { - transformer, exists := r.transformers[name] - return transformer, exists -} - -// List returns all registered transformers in registration order. -func (r *TypedTransformerRegistry) List() []TypedTransformer { - result := make([]TypedTransformer, 0, len(r.order)) - for _, name := range r.order { - if transformer, exists := r.transformers[name]; exists { - result = append(result, transformer) - } - } - return result -} - -// ListByPriority returns transformers sorted by priority. -// -// Deprecated: Use ListByDependencies() for dependency-based ordering. -func (r *TypedTransformerRegistry) ListByPriority() []TypedTransformer { - transformers := r.List() - - // Sort by priority (lower runs first), then by name for stability - for i := range len(transformers) - 1 { - for j := i + 1; j < len(transformers); j++ { - iPriority := transformers[i].Priority() - jPriority := transformers[j].Priority() - - if iPriority > jPriority || - (iPriority == jPriority && transformers[i].Name() > transformers[j].Name()) { - transformers[i], transformers[j] = transformers[j], transformers[i] - } - } - } - - return transformers -} - -// ListByDependencies returns transformers sorted by stage and dependencies. -func (r *TypedTransformerRegistry) ListByDependencies() ([]TypedTransformer, error) { - transformers := r.List() - return buildTypedPipeline(transformers) -} - -// buildTypedPipeline constructs execution order using stages and dependencies. -func buildTypedPipeline(transformers []TypedTransformer) ([]TypedTransformer, error) { - // Stage order for typed transformers (matches core transforms) - stageOrder := []TransformStage{ - StageParse, - StageBuild, - StageEnrich, - StageMerge, - StageTransform, - StageFinalize, - StageSerialize, - } - - // Group by stage - byStage := make(map[TransformStage][]TypedTransformer) - for _, t := range transformers { - stage := t.Stage() - byStage[stage] = append(byStage[stage], t) - } - - // Sort each stage by dependencies using topological sort - var result []TypedTransformer - for _, stage := range stageOrder { - stageTransforms, exists := byStage[stage] - if !exists { - continue - } - - sorted, err := topologicalSortTyped(stageTransforms) - if err != nil { - return nil, fmt.Errorf("stage %s: %w", stage, err) - } - - result = append(result, sorted...) - } - - return result, nil -} - -// topologicalSortTyped performs dependency resolution for typed transformers. -func topologicalSortTyped(transformers []TypedTransformer) ([]TypedTransformer, error) { - if len(transformers) == 0 { - return transformers, nil - } - - // Build name -> transform map - byName := make(map[string]TypedTransformer) - for _, t := range transformers { - byName[t.Name()] = t - } - - // Build adjacency list (dependencies graph) - graph := make(map[string][]string) - inDegree := make(map[string]int) - - for _, t := range transformers { - name := t.Name() - deps := t.Dependencies() - - if _, exists := graph[name]; !exists { - graph[name] = []string{} - } - - // Handle new MustRunAfter dependencies - for _, dep := range deps.MustRunAfter { - if _, exists := byName[dep]; exists { - graph[dep] = append(graph[dep], name) - inDegree[name]++ - } - // Skip if dependency not in this stage - } - - // Handle new MustRunBefore dependencies - for _, after := range deps.MustRunBefore { - if _, exists := byName[after]; exists { - graph[name] = append(graph[name], after) - inDegree[after]++ - } - // Skip if dependency not in this stage - } - - // Handle legacy RequiredAfter (maps to MustRunAfter) - for _, dep := range deps.RequiredAfter { - if _, exists := byName[dep]; exists { - graph[dep] = append(graph[dep], name) - inDegree[name]++ - } - } - - // Handle legacy RequiredBefore (maps to MustRunBefore) - for _, after := range deps.RequiredBefore { - if _, exists := byName[after]; exists { - graph[name] = append(graph[name], after) - inDegree[after]++ - } - } - } - - // Kahn's algorithm for topological sort - var queue []string - for _, t := range transformers { - name := t.Name() - if inDegree[name] == 0 { - queue = append(queue, name) - } - } - - // Keep deterministic ordering - sortStrings(queue) - - var result []TypedTransformer - for len(queue) > 0 { - // Pop from queue - current := queue[0] - queue = queue[1:] - - result = append(result, byName[current]) - - // Process neighbors - neighbors := graph[current] - sortStrings(neighbors) - - for _, neighbor := range neighbors { - inDegree[neighbor]-- - if inDegree[neighbor] == 0 { - queue = append(queue, neighbor) - sortStrings(queue) - } - } - } - - // Check for cycles - if len(result) != len(transformers) { - return nil, errors.New("circular dependency detected in typed transformers") - } - - return result, nil -} - -// sortStrings sorts a string slice in-place for deterministic ordering. -func sortStrings(s []string) { - for i := range len(s) - 1 { - for j := i + 1; j < len(s); j++ { - if s[i] > s[j] { - s[i], s[j] = s[j], s[i] - } - } - } -} - -// BuildExecutionPlan creates an execution plan with dependency resolution. -func (r *TypedTransformerRegistry) BuildExecutionPlan(filter []string) ([]TypedTransformer, error) { - // Use dependency-based ordering (V2) - available, err := r.ListByDependencies() - if err != nil { - return nil, fmt.Errorf("failed to build execution plan: %w", err) - } - - // Apply filter if provided - if len(filter) > 0 { - filterSet := make(map[string]bool) - for _, name := range filter { - filterSet[name] = true - } - - var filtered []TypedTransformer - for _, transformer := range available { - if filterSet[transformer.Name()] { - filtered = append(filtered, transformer) - } - } - available = filtered - } - - return available, nil -} - -// ContentPage methods - -// NewContentPage creates a new content page from a doc file. -func NewContentPage(file docs.DocFile) *ContentPage { - return &ContentPage{ - File: file, - FrontMatter: NewFrontMatter(), - TransformationHistory: make([]TransformationRecord, 0), - Conflicts: make([]FrontMatterConflict, 0), - ProcessingStartTime: time.Now(), - LastModified: time.Now(), - } -} - -// SetContent updates the page content and marks it as modified. -func (p *ContentPage) SetContent(content string) { - if p.Content != content { - p.Content = content - p.ContentModified = true - p.LastModified = time.Now() - } -} - -// GetContent returns the current page content. -func (p *ContentPage) GetContent() string { - return p.Content -} - -// SetFrontMatter updates the page front matter and marks it as modified. -func (p *ContentPage) SetFrontMatter(fm *FrontMatter) { - if fm != nil && !p.frontMatterEqual(p.FrontMatter, fm) { - p.FrontMatter = fm - p.FrontMatterModified = true - p.LastModified = time.Now() - } -} - -// GetFrontMatter returns the current front matter. -func (p *ContentPage) GetFrontMatter() *FrontMatter { - return p.FrontMatter -} - -// SetOriginalFrontMatter sets the original front matter (immutable baseline). -func (p *ContentPage) SetOriginalFrontMatter(fm *FrontMatter, had bool) { - p.OriginalFrontMatter = fm - p.HadOriginalFrontMatter = had -} - -// GetOriginalFrontMatter returns the original front matter. -func (p *ContentPage) GetOriginalFrontMatter() *FrontMatter { - return p.OriginalFrontMatter -} - -// AddFrontMatterPatch adds a front matter patch. -func (p *ContentPage) AddFrontMatterPatch(patch *FrontMatterPatch) { - if patch != nil { - p.FrontMatterPatches = append(p.FrontMatterPatches, patch) - p.FrontMatterModified = true - p.LastModified = time.Now() - } -} - -// ApplyFrontMatterPatches applies all patches to create the final front matter. -func (p *ContentPage) ApplyFrontMatterPatches() error { - if p.OriginalFrontMatter == nil { - p.OriginalFrontMatter = NewFrontMatter() - } - - result := p.OriginalFrontMatter.Clone() - - for i, patch := range p.FrontMatterPatches { - applied, err := patch.Apply(result) - if err != nil { - return fmt.Errorf("failed to apply patch %d: %w", i, err) - } - result = applied - } - - p.SetFrontMatter(result) - return nil -} - -// AddTransformationRecord records a transformation operation. -func (p *ContentPage) AddTransformationRecord(record TransformationRecord) { - p.TransformationHistory = append(p.TransformationHistory, record) -} - -// GetTransformationHistory returns the transformation history. -func (p *ContentPage) GetTransformationHistory() []TransformationRecord { - return p.TransformationHistory -} - -// HasBeenTransformed returns true if any transformations have been applied. -func (p *ContentPage) HasBeenTransformed() bool { - return len(p.TransformationHistory) > 0 -} - -// IsModified returns true if the page has been modified. -func (p *ContentPage) IsModified() bool { - return p.ContentModified || p.FrontMatterModified -} - -// Serialize converts the page to its final byte representation. -func (p *ContentPage) Serialize() ([]byte, error) { - if p.FrontMatter == nil { - // Content only, no front matter - return []byte(p.Content), nil - } - - // Serialize front matter to YAML - frontMatterMap := p.FrontMatter.ToMap() - if len(frontMatterMap) == 0 { - // No front matter to serialize - return []byte(p.Content), nil - } - - // Note: YAML serialization of front matter is intentionally deferred. - // Current behavior: content-only output; front matter is preserved in - // typed structures for downstream generators. - return []byte(p.Content), nil -} - -// Clone creates a deep copy of the content page. -func (p *ContentPage) Clone() *ContentPage { - clone := &ContentPage{ - File: p.File, - Content: p.Content, - HadOriginalFrontMatter: p.HadOriginalFrontMatter, - ContentModified: p.ContentModified, - FrontMatterModified: p.FrontMatterModified, - ProcessingStartTime: p.ProcessingStartTime, - LastModified: p.LastModified, - } - - // Deep copy byte slice - if p.RawBytes != nil { - clone.RawBytes = make([]byte, len(p.RawBytes)) - copy(clone.RawBytes, p.RawBytes) - } - - // Clone front matter - if p.FrontMatter != nil { - clone.FrontMatter = p.FrontMatter.Clone() - } - if p.OriginalFrontMatter != nil { - clone.OriginalFrontMatter = p.OriginalFrontMatter.Clone() - } - - // Clone patches - if p.FrontMatterPatches != nil { - clone.FrontMatterPatches = make([]*FrontMatterPatch, len(p.FrontMatterPatches)) - for i, patch := range p.FrontMatterPatches { - if patch != nil { - clonedPatch := *patch // Shallow copy for now - clone.FrontMatterPatches[i] = &clonedPatch - } - } - } - - // Clone transformation history - if p.TransformationHistory != nil { - clone.TransformationHistory = make([]TransformationRecord, len(p.TransformationHistory)) - copy(clone.TransformationHistory, p.TransformationHistory) - } - - // Clone conflicts - if p.Conflicts != nil { - clone.Conflicts = make([]FrontMatterConflict, len(p.Conflicts)) - copy(clone.Conflicts, p.Conflicts) - } - - return clone -} - -// Validate performs basic validation of the content page. -func (p *ContentPage) Validate() error { - if p.File.Path == "" { - return errors.New("file path is required") - } - - if p.FrontMatter != nil { - if err := p.FrontMatter.Validate(); err != nil { - return fmt.Errorf("front matter validation failed: %w", err) - } - } - - return nil -} - -// Helper methods - -// frontMatterEqual compares two front matter objects for equality. -func (p *ContentPage) frontMatterEqual(a, b *FrontMatter) bool { - if a == nil && b == nil { - return true - } - if a == nil || b == nil { - return false - } - - // Compare key fields (simplified for now) - return a.Title == b.Title && - a.Repository == b.Repository && - a.Section == b.Section -} diff --git a/internal/hugo/models/typed_transformers.go b/internal/hugo/models/typed_transformers.go deleted file mode 100644 index dc17731c..00000000 --- a/internal/hugo/models/typed_transformers.go +++ /dev/null @@ -1,563 +0,0 @@ -package models - -import ( - "errors" - "fmt" - "strings" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/frontmatter" -) - -// FrontMatterParserV2 is a strongly-typed front matter parser. -type FrontMatterParserV2 struct { - config TransformerConfiguration -} - -// NewFrontMatterParserV2 creates a new typed front matter parser. -func NewFrontMatterParserV2() *FrontMatterParserV2 { - return &FrontMatterParserV2{ - config: TransformerConfiguration{ - Enabled: true, - Priority: 10, - SkipIfEmpty: false, - FailOnError: false, - RecordChanges: true, - EnableDeepMerge: false, - PreservesOriginal: true, - ModifiesContent: true, - ModifiesFrontMatter: true, - Properties: make(map[string]any), - }, - } -} - -// Name returns the transformer name. -func (t *FrontMatterParserV2) Name() string { - return "front_matter_parser_v2" -} - -// Description returns the transformer description. -func (t *FrontMatterParserV2) Description() string { - return "Parses YAML front matter from markdown content and extracts it into typed structures" -} - -// Version returns the transformer version. -func (t *FrontMatterParserV2) Version() string { - return "2.0.0" -} - -// Stage returns the transformation stage. -func (t *FrontMatterParserV2) Stage() TransformStage { - return StageParse -} - -// Dependencies returns the transformer dependencies. -func (t *FrontMatterParserV2) Dependencies() TransformerDependencies { - return TransformerDependencies{ - MustRunAfter: []string{}, // No dependencies, runs first - MustRunBefore: []string{}, - RequiredBefore: []string{}, // Legacy (deprecated) - RequiredAfter: []string{}, // Legacy (deprecated) - RequiresOriginalFrontMatter: false, - RequiresFrontMatterPatches: false, - RequiresContent: true, - RequiresFileMetadata: false, - RequiresConfig: false, - RequiresEditLinkResolver: false, - RequiresThemeInfo: false, - RequiresForgeInfo: false, - } -} - -// Configuration returns the transformer configuration. -func (t *FrontMatterParserV2) Configuration() TransformerConfiguration { - return t.config -} - -// CanTransform checks if this transformer can process the given page. -func (t *FrontMatterParserV2) CanTransform(page *ContentPage, _ *TransformContext) bool { - if !t.config.Enabled { - return false - } - - content := page.GetContent() - return strings.HasPrefix(content, "---\n") || strings.HasPrefix(content, "---\r\n") -} - -// RequiredContext returns the required context keys. -func (t *FrontMatterParserV2) RequiredContext() []string { - return []string{} // No special context required -} - -// Transform parses front matter from the page content. -func (t *FrontMatterParserV2) Transform(page *ContentPage, _ *TransformContext) (*TransformationResult, error) { - startTime := time.Now() - result := NewTransformationResult() - - content := page.GetContent() - if !strings.HasPrefix(content, "---\n") && !strings.HasPrefix(content, "---\r\n") { - // No front matter to parse - return result.SetSuccess().SetDuration(time.Since(startTime)), nil - } - - fmRaw, body, had, _, err := frontmatter.Split([]byte(content)) - if err != nil { - //nolint:nilerr // this transformer reports failures via TransformationResult, not the Go error return. - return result.SetError(errors.New("unterminated front matter")).SetDuration(time.Since(startTime)), nil - } - if !had { - // Shouldn't happen given prefix check, but be defensive. - return result.SetSuccess().SetDuration(time.Since(startTime)), nil - } - - remainingContent := string(body) - - frontMatterMap, err := frontmatter.ParseYAML(fmRaw) - if err != nil { - if t.config.FailOnError { - return result.SetError(fmt.Errorf("failed to parse front matter: %w", err)).SetDuration(time.Since(startTime)), nil - } - - // Log warning but continue without front matter - result.AddChange( - ChangeTypeContentModified, - "front_matter_parse_error", - nil, - err.Error(), - "Failed to parse YAML front matter", - t.Name(), - ) - - page.SetContent(remainingContent) - return result.SetSuccess().SetDuration(time.Since(startTime)), nil - } - - // Convert to typed front matter - frontMatter, err := FromMap(frontMatterMap) - if err != nil { - return result.SetError(fmt.Errorf("failed to convert front matter to typed structure: %w", err)).SetDuration(time.Since(startTime)), nil - } - - // Update page state - page.SetOriginalFrontMatter(frontMatter, true) - page.SetFrontMatter(frontMatter.Clone()) - page.SetContent(remainingContent) - - // Record changes - result.AddChange( - ChangeTypeFrontMatterAdded, - "original_front_matter", - nil, - frontMatter, - "Parsed original front matter from content", - t.Name(), - ) - - result.AddChange( - ChangeTypeContentModified, - "content", - content, - remainingContent, - "Removed front matter from content", - t.Name(), - ) - - return result.SetSuccess().SetDuration(time.Since(startTime)), nil -} - -// FrontMatterBuilderV3 is a strongly-typed front matter builder. -type FrontMatterBuilderV3 struct { - config TransformerConfiguration -} - -// NewFrontMatterBuilderV3 creates a new typed front matter builder. -func NewFrontMatterBuilderV3() *FrontMatterBuilderV3 { - return &FrontMatterBuilderV3{ - config: TransformerConfiguration{ - Enabled: true, - Priority: 20, - SkipIfEmpty: false, - FailOnError: false, - RecordChanges: true, - EnableDeepMerge: true, - PreservesOriginal: true, - ModifiesContent: false, - ModifiesFrontMatter: true, - Properties: make(map[string]any), - }, - } -} - -// Name returns the transformer name. -func (t *FrontMatterBuilderV3) Name() string { - return "front_matter_builder_v3" -} - -// Description returns the transformer description. -func (t *FrontMatterBuilderV3) Description() string { - return "Builds base front matter from file metadata and configuration" -} - -// Version returns the transformer version. -func (t *FrontMatterBuilderV3) Version() string { - return "3.0.0" -} - -// Stage returns the transformation stage. -func (t *FrontMatterBuilderV3) Stage() TransformStage { - return StageBuild -} - -// Dependencies returns the transformer dependencies. -func (t *FrontMatterBuilderV3) Dependencies() TransformerDependencies { - return TransformerDependencies{ - MustRunAfter: []string{"front_matter_parser_v2"}, - MustRunBefore: []string{}, - RequiredBefore: []string{}, // Legacy (deprecated) - RequiredAfter: []string{"front_matter_parser_v2"}, // Legacy (deprecated) - RequiresOriginalFrontMatter: false, - RequiresFrontMatterPatches: false, - RequiresContent: false, - RequiresFileMetadata: true, - RequiresConfig: true, - RequiresEditLinkResolver: false, - RequiresThemeInfo: false, - RequiresForgeInfo: false, - } -} - -// Configuration returns the transformer configuration. -func (t *FrontMatterBuilderV3) Configuration() TransformerConfiguration { - return t.config -} - -// CanTransform checks if this transformer can process the given page. -func (t *FrontMatterBuilderV3) CanTransform(_ *ContentPage, _ *TransformContext) bool { - if !t.config.Enabled { - return false - } - - // Always can transform - builds base front matter - return true -} - -// RequiredContext returns the required context keys. -func (t *FrontMatterBuilderV3) RequiredContext() []string { - return []string{"config"} -} - -// Transform builds base front matter from file metadata. -func (t *FrontMatterBuilderV3) Transform(page *ContentPage, context *TransformContext) (*TransformationResult, error) { - startTime := time.Now() - result := NewTransformationResult() - - // Get configuration - config := context.Generator.GetConfig() - if config == nil { - return result.SetError(errors.New("configuration required but not available")).SetDuration(time.Since(startTime)), nil - } - - // Build base patch using migration helper - helper := NewMigrationHelper() - - // Generate title from file name if not present - title := page.File.Name - title = strings.TrimSuffix(title, ".md") - title = strings.ReplaceAll(title, "_", " ") - title = strings.ReplaceAll(title, "-", " ") - - // Create base patch - basePatch := helper.CreateBasePatch( - title, - page.File.Repository, - page.File.Forge, - page.File.Section, - ) - - // Add file metadata if available - if page.File.Metadata != nil { - for key, value := range page.File.Metadata { - basePatch.SetCustom(key, value) - } - } - - // Apply the patch - page.AddFrontMatterPatch(basePatch) - - // Record changes - result.AddChange( - ChangeTypeFrontMatterAdded, - "base_front_matter", - nil, - basePatch.ToMap(), - "Built base front matter from file metadata", - t.Name(), - ) - - return result.SetSuccess().SetDuration(time.Since(startTime)), nil -} - -// EditLinkInjectorV3 is a strongly-typed edit link injector. -type EditLinkInjectorV3 struct { - config TransformerConfiguration -} - -// NewEditLinkInjectorV3 creates a new typed edit link injector. -func NewEditLinkInjectorV3() *EditLinkInjectorV3 { - return &EditLinkInjectorV3{ - config: TransformerConfiguration{ - Enabled: true, - Priority: 30, - SkipIfEmpty: true, - FailOnError: false, - RecordChanges: true, - EnableDeepMerge: false, - PreservesOriginal: true, - ModifiesContent: false, - ModifiesFrontMatter: true, - Properties: make(map[string]any), - }, - } -} - -// Name returns the transformer name. -func (t *EditLinkInjectorV3) Name() string { - return "edit_link_injector_v3" -} - -// Description returns the transformer description. -func (t *EditLinkInjectorV3) Description() string { - return "Injects edit URLs into front matter based on forge and theme capabilities" -} - -// Version returns the transformer version. -func (t *EditLinkInjectorV3) Version() string { - return "3.0.0" -} - -// Stage returns the transformation stage. -func (t *EditLinkInjectorV3) Stage() TransformStage { - return StageEnrich -} - -// Dependencies returns the transformer dependencies. -func (t *EditLinkInjectorV3) Dependencies() TransformerDependencies { - return TransformerDependencies{ - MustRunAfter: []string{"front_matter_builder_v3"}, - MustRunBefore: []string{}, - RequiredBefore: []string{}, // Legacy (deprecated) - RequiredAfter: []string{"front_matter_builder_v3"}, // Legacy (deprecated) - RequiresOriginalFrontMatter: false, - RequiresFrontMatterPatches: false, - RequiresContent: false, - RequiresFileMetadata: true, - RequiresConfig: true, - RequiresEditLinkResolver: true, - RequiresThemeInfo: true, - RequiresForgeInfo: true, - } -} - -// Configuration returns the transformer configuration. -func (t *EditLinkInjectorV3) Configuration() TransformerConfiguration { - return t.config -} - -// CanTransform checks if this transformer can process the given page. -func (t *EditLinkInjectorV3) CanTransform(page *ContentPage, _ *TransformContext) bool { - if !t.config.Enabled { - return false - } - - // Check if edit URL already exists - if page.GetOriginalFrontMatter() != nil { - if page.GetOriginalFrontMatter().EditURL != "" { - return false // Already has edit URL - } - } - - // Check if any patches already add edit URL - for _, patch := range page.FrontMatterPatches { - if patch.EditURL != nil { - return false // Edit URL already being added - } - } - - return true -} - -// RequiredContext returns the required context keys. -func (t *EditLinkInjectorV3) RequiredContext() []string { - return []string{"config", "edit_link_resolver", "theme_info", "forge_info"} -} - -// Transform injects edit URLs into front matter. -// Relearn theme always wants per-page edit links, so no theme capability check needed. -func (t *EditLinkInjectorV3) Transform(page *ContentPage, context *TransformContext) (*TransformationResult, error) { - startTime := time.Now() - result := NewTransformationResult() - - // Check if forge supports edit links - forgeCapabilities := context.Generator.GetForgeCapabilities(page.File.Forge) - if !forgeCapabilities.SupportsEditLinks { - return result.SetSuccess().SetDuration(time.Since(startTime)), nil - } - - resolver := context.Generator.GetEditLinkResolver() - if resolver == nil { - return result.SetError(errors.New("edit link resolver required but not available")).SetDuration(time.Since(startTime)), nil - } - - // Resolve edit URL - editURL := resolver.Resolve(page.File) - if editURL == "" { - if t.config.SkipIfEmpty { - return result.SetSuccess().SetDuration(time.Since(startTime)), nil - } - return result.SetError(fmt.Errorf("failed to resolve edit URL for file %s", page.File.Path)).SetDuration(time.Since(startTime)), nil - } - - // Create patch with edit URL - patch := NewFrontMatterPatch(). - SetEditURL(editURL). - WithMergeMode(MergeModeSetIfMissing) - - page.AddFrontMatterPatch(patch) - - // Record changes - result.AddChange( - ChangeTypeFrontMatterAdded, - "edit_url", - nil, - editURL, - "Injected edit URL based on forge and theme capabilities", - t.Name(), - ) - - return result.SetSuccess().SetDuration(time.Since(startTime)), nil -} - -// ContentProcessorV2 is a strongly-typed content processor. -type ContentProcessorV2 struct { - config TransformerConfiguration -} - -// NewContentProcessorV2 creates a new typed content processor. -func NewContentProcessorV2() *ContentProcessorV2 { - return &ContentProcessorV2{ - config: TransformerConfiguration{ - Enabled: true, - Priority: 50, - SkipIfEmpty: false, - FailOnError: false, - RecordChanges: true, - EnableDeepMerge: false, - PreservesOriginal: false, - ModifiesContent: true, - ModifiesFrontMatter: false, - Properties: make(map[string]any), - }, - } -} - -// Name returns the transformer name. -func (t *ContentProcessorV2) Name() string { - return "content_processor_v2" -} - -// Description returns the transformer description. -func (t *ContentProcessorV2) Description() string { - return "Processes markdown content including link rewriting and content transformations" -} - -// Version returns the transformer version. -func (t *ContentProcessorV2) Version() string { - return "2.0.0" -} - -// Stage returns the transformation stage. -func (t *ContentProcessorV2) Stage() TransformStage { - return StageTransform -} - -// Dependencies returns the transformer dependencies. -func (t *ContentProcessorV2) Dependencies() TransformerDependencies { - return TransformerDependencies{ - MustRunAfter: []string{"edit_link_injector_v3"}, - MustRunBefore: []string{}, - RequiredBefore: []string{}, // Legacy (deprecated) - RequiredAfter: []string{"edit_link_injector_v3"}, // Legacy (deprecated) - RequiresOriginalFrontMatter: false, - RequiresFrontMatterPatches: false, - RequiresContent: true, - RequiresFileMetadata: false, - RequiresConfig: false, - RequiresEditLinkResolver: false, - RequiresThemeInfo: false, - RequiresForgeInfo: false, - } -} - -// Configuration returns the transformer configuration. -func (t *ContentProcessorV2) Configuration() TransformerConfiguration { - return t.config -} - -// CanTransform checks if this transformer can process the given page. -func (t *ContentProcessorV2) CanTransform(page *ContentPage, _ *TransformContext) bool { - if !t.config.Enabled { - return false - } - - content := page.GetContent() - if t.config.SkipIfEmpty && strings.TrimSpace(content) == "" { - return false - } - - return true -} - -// RequiredContext returns the required context keys. -func (t *ContentProcessorV2) RequiredContext() []string { - return []string{} // No special context required -} - -// Transform processes the content. -func (t *ContentProcessorV2) Transform(page *ContentPage, _ *TransformContext) (*TransformationResult, error) { - startTime := time.Now() - result := NewTransformationResult() - - originalContent := page.GetContent() - processedContent := originalContent - - // Process relative links (placeholder): link rewriting will be implemented - // alongside the site generator’s URL mapping to avoid drift. - if strings.Contains(processedContent, "](./") || strings.Contains(processedContent, "](../") { - // Mark as processed but don't change content for now - result.AddChange( - ChangeTypeContentModified, - "relative_links", - "found", - "processed", - "Processed relative links in content", - t.Name(), - ) - } - - // Update content if changed - if processedContent != originalContent { - page.SetContent(processedContent) - - result.AddChange( - ChangeTypeContentModified, - "content", - originalContent, - processedContent, - "Processed markdown content", - t.Name(), - ) - } - - return result.SetSuccess().SetDuration(time.Since(startTime)), nil -} diff --git a/internal/hugo/modules.go b/internal/hugo/modules.go index ad9c3281..eef445b8 100644 --- a/internal/hugo/modules.go +++ b/internal/hugo/modules.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" ) @@ -134,7 +135,7 @@ func (g *Generator) createNewGoMod(goModPath string) error { // #nosec G306 -- go.mod is a module configuration file if err := os.WriteFile(goModPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("failed to write go.mod: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write go.mod").Build() } slog.Debug("Created go.mod for Hugo Modules", logfields.Path(goModPath)) diff --git a/internal/hugo/pipeline/generators.go b/internal/hugo/pipeline/generators.go index 5fdced9a..616ac0f3 100644 --- a/internal/hugo/pipeline/generators.go +++ b/internal/hugo/pipeline/generators.go @@ -83,7 +83,7 @@ func generateRepositoryIndex(ctx *GenerationContext) ([]*Document, error) { if !hasIndex { // Generate repository index repoMeta := ctx.RepositoryMetadata[repo] - title := titleCase(repo) + title := titleCaseSlug(repo) description := fmt.Sprintf("Documentation for %s", repo) // Build the content path via the single source of truth @@ -206,21 +206,21 @@ func generateSectionIndex(ctx *GenerationContext) ([]*Document, error) { return generated, nil } -// titleCase converts a string to title case (simple version). -// Replaces dashes and underscores with spaces and capitalizes words. -func titleCase(s string) string { - // Replace separators with spaces +// titleCaseSlug is the slug-aware variant of title-casing: '-' and '_' +// become spaces first, then each word is capitalized. Mirrors the +// canonical internal/hugo.TitleCaseSlug but lives here to avoid an +// import cycle (hugo imports hugo/pipeline, not the other way round). +// When the pipeline can be lifted into a leaf package, drop this +// helper in favor of internal/hugo.TitleCaseSlug. +func titleCaseSlug(s string) string { s = strings.ReplaceAll(s, "-", " ") s = strings.ReplaceAll(s, "_", " ") - - // Capitalize first letter of each word words := strings.Fields(s) for i, word := range words { if len(word) > 0 { words[i] = strings.ToUpper(word[:1]) + word[1:] } } - return strings.Join(words, " ") } diff --git a/internal/hugo/pipeline/processor.go b/internal/hugo/pipeline/processor.go index 1c3daf04..623f2df0 100644 --- a/internal/hugo/pipeline/processor.go +++ b/internal/hugo/pipeline/processor.go @@ -5,6 +5,7 @@ import ( "log/slog" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // Processor runs the complete content processing pipeline. @@ -63,7 +64,7 @@ func (p *Processor) ProcessContent(documents []*Document, repoMetadata map[strin for i, generator := range p.generators { docs, err := generator(ctx) if err != nil { - return nil, fmt.Errorf("generator %d failed: %w", i, err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, fmt.Sprintf("generator %d failed", i)).Build() } if len(docs) > 0 { slog.Debug("Generator created files", slog.Int("count", len(docs)), slog.Int("generator", i)) @@ -79,7 +80,7 @@ func (p *Processor) ProcessContent(documents []*Document, repoMetadata map[strin processedDocs, err := p.processTransforms(documents) if err != nil { - return nil, fmt.Errorf("transformation phase failed: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "transformation phase failed").Build() } slog.Info("Pipeline: Transformation phase complete", slog.Int("processed", len(processedDocs))) @@ -102,15 +103,14 @@ func (p *Processor) processTransforms(docs []*Document) ([]*Document, error) { for i, transform := range p.transforms { newDocs, err := transform(doc) if err != nil { - return nil, fmt.Errorf("transform %d failed for %s: %w", i, doc.Path, err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, fmt.Sprintf("transform %d failed for %s", i, doc.Path)).Build() } // Prevent generated documents from creating new documents (infinite loop protection) if len(newDocs) > 0 && doc.Generated { - return nil, fmt.Errorf( - "generated document %s attempted to create new documents (transforms should not generate from generated docs)", - doc.Path, - ) + return nil, derrors.NewError(derrors.CategoryValidation, "generated document attempted to create new documents (transforms should not generate from generated docs)"). + WithContext("path", doc.Path). + Build() } // Queue new documents for full transform pipeline @@ -168,7 +168,7 @@ func (p *Processor) GenerateStaticAssets() ([]*StaticAsset, error) { for i, generator := range p.staticAssetGenerators { assets, err := generator(ctx) if err != nil { - return nil, fmt.Errorf("static asset generator %d failed: %w", i, err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, fmt.Sprintf("static asset generator %d failed", i)).Build() } if len(assets) > 0 { slog.Debug("Static asset generator created assets", diff --git a/internal/hugo/pipeline/transform_fingerprint.go b/internal/hugo/pipeline/transform_fingerprint.go index f25a5af1..f4f931ac 100644 --- a/internal/hugo/pipeline/transform_fingerprint.go +++ b/internal/hugo/pipeline/transform_fingerprint.go @@ -2,7 +2,6 @@ package pipeline import ( "log/slog" - "strings" "git.home.luguber.info/inful/docbuilder/internal/docmodel" "git.home.luguber.info/inful/docbuilder/internal/frontmatterops" @@ -13,7 +12,7 @@ import ( // // This transform operates on the serialized doc.Raw and should be run after serializeDocument. func fingerprintContent(doc *Document) ([]*Document, error) { - if !strings.HasSuffix(strings.ToLower(doc.Path), ".md") { + if !docmodel.IsMarkdownFile(doc.Path) { return nil, nil } diff --git a/internal/hugo/pipeline/transform_headings.go b/internal/hugo/pipeline/transform_headings.go index d23bbd9d..ab7a92d4 100644 --- a/internal/hugo/pipeline/transform_headings.go +++ b/internal/hugo/pipeline/transform_headings.go @@ -29,7 +29,7 @@ func extractIndexTitle(doc *Document) ([]*Document, error) { isDocsBase := doc.Section != "" && doc.DocsBase == doc.Section && doc.Repository != "" if isRepositoryRoot || isDocsBase { - repoTitle := titleCase(doc.Repository) + repoTitle := titleCaseSlug(doc.Repository) if repoTitle != "" { doc.FrontMatter["title"] = repoTitle } diff --git a/internal/hugo/pipeline/transform_links.go b/internal/hugo/pipeline/transform_links.go index 3fef2259..72a85d27 100644 --- a/internal/hugo/pipeline/transform_links.go +++ b/internal/hugo/pipeline/transform_links.go @@ -6,6 +6,8 @@ import ( "strings" "git.home.luguber.info/inful/docbuilder/internal/config" + "git.home.luguber.info/inful/docbuilder/internal/docmodel" + "git.home.luguber.info/inful/docbuilder/internal/urlutil" ) // rewriteRelativeLinks rewrites relative markdown links to work with Hugo. @@ -74,7 +76,7 @@ func tryProcessLink(content string, i int, repository, forge string, isIndex boo path := content[closeBracket+2 : closeParen] // If it's an image or absolute URL, write as-is - if isImage || isAbsoluteOrSpecialURL(path) { + if isImage || urlutil.IsAbsoluteOrSpecialURL(path) { result.WriteString(content[i : closeParen+1]) return closeParen + 1, true } @@ -124,13 +126,7 @@ func findClosingParen(content string, start int) int { return -1 } -// isAbsoluteOrSpecialURL checks if a path is absolute or special (http, anchor, mailto, etc). -func isAbsoluteOrSpecialURL(path string) bool { - return strings.HasPrefix(path, "http://") || - strings.HasPrefix(path, "https://") || - strings.HasPrefix(path, "#") || - strings.HasPrefix(path, "mailto:") -} +// isAbsoluteOrSpecialURL migrated to internal/urlutil.IsAbsoluteOrSpecialURL. // rewriteImageLinks rewrites image paths to work with Hugo. func rewriteImageLinks(doc *Document) ([]*Document, error) { @@ -225,15 +221,10 @@ func rewriteLinkPath(path, repository, forge string, isIndex bool, docPath strin } suffix := query + anchor - // Remove .md/.markdown extension (case-insensitive) + // Remove .md / .markdown extension via the canonical docmodel helper. + // The case of the body is preserved. + path = docmodel.StripMarkdownExt(path) lowerPath := strings.ToLower(path) - if strings.HasSuffix(lowerPath, ".md") { - path = path[:len(path)-3] - lowerPath = lowerPath[:len(lowerPath)-3] - } else if strings.HasSuffix(lowerPath, ".markdown") { - path = path[:len(path)-9] - lowerPath = lowerPath[:len(lowerPath)-9] - } // Handle README/index special case - these become section URLs with trailing slash if strings.HasSuffix(lowerPath, "/readme") { diff --git a/internal/hugo/pipeline/transform_metadata.go b/internal/hugo/pipeline/transform_metadata.go index 7ef93706..3278b33f 100644 --- a/internal/hugo/pipeline/transform_metadata.go +++ b/internal/hugo/pipeline/transform_metadata.go @@ -6,6 +6,8 @@ import ( "strings" "git.home.luguber.info/inful/docbuilder/internal/config" + "git.home.luguber.info/inful/docbuilder/internal/forge" + "git.home.luguber.info/inful/docbuilder/internal/urlutil" ) // addRepositoryMetadata adds repository, section, and custom metadata to front matter. @@ -91,6 +93,10 @@ func addEditLink(cfg *config.Config) FileTransform { // generateEditURL creates a forge-appropriate edit URL for a document. // Only generates URLs for documents with repository metadata. // For preview mode in VS Code, VS Code edit URLs are handled separately. +// +// Splits the (clone|override) URL once via forge.SplitCloneURL and delegates +// the per-forge URL formatting to forge.GenerateEditURL, which is shared +// with internal/hugo/editlink. func generateEditURL(doc *Document) string { // Preview mode with VS Code: generate local edit URL if doc.VSCodeEditLinks && doc.IsSingleRepo { @@ -99,18 +105,20 @@ func generateEditURL(doc *Document) string { return fmt.Sprintf("/_edit/%s", doc.RelativePath) } - // Production builds: generate forge-specific edit URLs - // Use EditURLBase override if provided, otherwise use SourceURL - var baseURL string + // Production builds: generate forge-specific edit URLs. + // Use EditURLBase override if provided, otherwise use SourceURL. + var cloneURL string switch { case doc.EditURLBase != "": - // CLI override provided - baseURL = strings.TrimSuffix(doc.EditURLBase, ".git") - case doc.SourceURL != "" && isForgeURL(doc.SourceURL): - // Use SourceURL if it's a real forge URL - baseURL = strings.TrimSuffix(doc.SourceURL, ".git") + cloneURL = doc.EditURLBase + case doc.SourceURL != "" && urlutil.IsForgeURL(doc.SourceURL): + cloneURL = doc.SourceURL default: - // No valid base URL for edit links + return "" + } + + baseURL, fullName := forge.SplitCloneURL(cloneURL) + if baseURL == "" || fullName == "" { return "" } @@ -120,81 +128,32 @@ func generateEditURL(doc *Document) string { branch = "main" } - // Build path relative to repository root - // RelativePath is already relative to docs base, need to prepend DocsBase if it's not already there - // Skip if DocsBase is "." (current directory marker used in local builds) + // Build path relative to repository root. RelativePath is already + // relative to docs base; prepend DocsBase when it isn't already. + // Skip when DocsBase is "." (current-directory marker for local builds). filePath := doc.RelativePath if doc.DocsBase != "" && doc.DocsBase != "." && !strings.HasPrefix(filePath, doc.DocsBase+"/") { filePath = doc.DocsBase + "/" + filePath } - // Determine forge type from the Forge field or URL patterns - forgeType := detectForgeType(doc.Forge, baseURL) - - // Generate URL based on forge type - switch forgeType { - case config.ForgeGitHub: - return fmt.Sprintf("%s/edit/%s/%s", baseURL, branch, filePath) - case config.ForgeGitLab: - return fmt.Sprintf("%s/-/edit/%s/%s", baseURL, branch, filePath) - case config.ForgeForgejo: - // Forgejo and Gitea both use /_edit/ pattern - return fmt.Sprintf("%s/_edit/%s/%s", baseURL, branch, filePath) - case config.ForgeLocal: - // Local forges don't have web UI edit URLs - return "" - default: - // Fallback to GitHub-style for unknown forges - return fmt.Sprintf("%s/edit/%s/%s", baseURL, branch, filePath) - } -} + // ForgeType is sourced from the explicit forge field (if set), + // otherwise classified via the centralized forge.DetectForgeTypeFromURL. + forgeType := detectForgeTypeFromField(doc.Forge, cloneURL) -// detectForgeType determines the forge type from metadata or URL patterns. -func detectForgeType(forgeField, baseURL string) config.ForgeType { - // First check if we have explicit forge metadata - if forgeField != "" { - switch strings.ToLower(forgeField) { - case "github": - return config.ForgeGitHub - case "gitlab": - return config.ForgeGitLab - case "forgejo", "gitea": - return config.ForgeForgejo - } - } + return forge.GenerateEditURL(forgeType, baseURL, fullName, branch, filePath) +} - // Fallback to URL pattern detection - lowerURL := strings.ToLower(baseURL) - if strings.Contains(lowerURL, "github.com") { +// detectForgeTypeFromField consults the explicit forge metadata field +// first; on a miss or empty field it falls back to +// forge.DetectForgeTypeFromURL on the clone URL. +func detectForgeTypeFromField(forgeField, cloneURL string) config.ForgeType { + switch strings.ToLower(forgeField) { + case "github": return config.ForgeGitHub - } - if strings.Contains(lowerURL, "gitlab.com") || strings.Contains(lowerURL, "gitlab") { + case "gitlab": return config.ForgeGitLab - } - // Forgejo and Gitea use similar patterns - check for common hostnames - if strings.Contains(lowerURL, "forgejo") || strings.Contains(lowerURL, "gitea") { - return config.ForgeForgejo - } - - // For self-hosted instances that aren't GitHub/GitLab, default to Forgejo/Gitea pattern - // as it's becoming the most common self-hosted option - if !strings.Contains(lowerURL, "github.com") && !strings.Contains(lowerURL, "gitlab.com") { + case "forgejo", "gitea": return config.ForgeForgejo } - - // Final fallback to GitHub - return config.ForgeGitHub -} - -// isForgeURL checks if a URL is a real forge URL (not a local path). -func isForgeURL(url string) bool { - // Real forge URLs start with http://, https://, or git@ - if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { - return true - } - if strings.HasPrefix(url, "git@") { - return true - } - // Anything else (./path, /path, relative paths) is local - return false + return forge.DetectForgeTypeFromURL(cloneURL) } diff --git a/internal/hugo/report_issues_test.go b/internal/hugo/report_issues_test.go index 53a4c860..62e6419d 100644 --- a/internal/hugo/report_issues_test.go +++ b/internal/hugo/report_issues_test.go @@ -23,7 +23,7 @@ func TestIssueTaxonomyPartialClone(t *testing.T) { report.Errors = nil report.Warnings = append(report.Warnings, se) report.StageErrorKinds[models.StageCloneRepos] = se.Kind - report.RecordStageResult(models.StageCloneRepos, models.StageResultWarning, nil) + report.RecordStageResult(models.StageCloneRepos, models.StageResultWarning) // emulate runStages logic for issue creation issue := models.ReportIssue{Stage: models.StageCloneRepos, Message: se.Error(), Transient: se.Transient(), Severity: models.SeverityWarning} issue.Code = models.IssuePartialClone @@ -49,7 +49,7 @@ func TestIssueTaxonomyHugoWarning(t *testing.T) { se := models.NewWarnStageError(models.StageRunHugo, errors.New("wrap: "+build.ErrHugo.Error())) report.StageErrorKinds[models.StageRunHugo] = se.Kind report.Warnings = append(report.Warnings, se) - report.RecordStageResult(models.StageRunHugo, models.StageResultWarning, nil) + report.RecordStageResult(models.StageRunHugo, models.StageResultWarning) issue := models.ReportIssue{Stage: models.StageRunHugo, Message: se.Error(), Transient: se.Transient(), Severity: models.SeverityWarning, Code: models.IssueHugoExecution} report.Issues = append(report.Issues, issue) report.Finish() diff --git a/internal/hugo/stages/classification.go b/internal/hugo/stages/classification.go index b770b403..b169c8c7 100644 --- a/internal/hugo/stages/classification.go +++ b/internal/hugo/stages/classification.go @@ -80,7 +80,7 @@ func classifyIssueCode(se *models.StageError, bs *models.BuildState) models.Repo return classifyDiscoveryIssue(se, bs) case models.StageRunHugo: return classifyHugoIssue(se) - case models.StagePrepareOutput, models.StageCategoriesMenu, models.StageGenerateConfig, models.StageLayouts, models.StageCopyContent, models.StageIndexes, models.StagePostProcess: + case models.StagePrepareOutput, models.StageCategoriesMenu, models.StageGenerateConfig, models.StageCopyContent: // These stages use generic issue codes return models.IssueGenericStageError default: diff --git a/internal/hugo/stages/renderer_binary.go b/internal/hugo/stages/renderer_binary.go index 1ea589fe..e34293a8 100644 --- a/internal/hugo/stages/renderer_binary.go +++ b/internal/hugo/stages/renderer_binary.go @@ -3,14 +3,15 @@ package stages import ( "bytes" "context" - "fmt" "log/slog" "os" "os/exec" "path/filepath" "strings" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" herrors "git.home.luguber.info/inful/docbuilder/internal/hugo/errors" + "git.home.luguber.info/inful/docbuilder/internal/hugo/models" ) // Renderer abstracts how the final static site rendering step is performed after @@ -90,14 +91,14 @@ func ensurePATHContainsDir(env []string, dir string) []string { func (b *BinaryRenderer) Execute(ctx context.Context, rootDir string) error { if _, err := exec.LookPath("hugo"); err != nil { - return fmt.Errorf("%w: %w", herrors.ErrHugoBinaryNotFound, err) + return derrors.WrapError(err, derrors.CategoryConfig, "hugo binary not found").WithCause(herrors.ErrHugoBinaryNotFound).Build() } // Relearn is pulled via Hugo Modules, which shells out to `go mod ...`. // If Go isn't available, fail fast with a clear message instead of Hugo's // often-opaque module download error. goPath, err := exec.LookPath("go") if err != nil { - return fmt.Errorf("%w: %w", herrors.ErrGoBinaryNotFound, err) + return derrors.WrapError(err, derrors.CategoryConfig, "go binary not found").WithCause(herrors.ErrGoBinaryNotFound).Build() } goDir := filepath.Dir(goPath) @@ -105,7 +106,7 @@ func (b *BinaryRenderer) Execute(ctx context.Context, rootDir string) error { stat, statErr := os.Stat(rootDir) if statErr != nil { slog.Error("Staging directory missing before Hugo execution", "dir", rootDir, "error", statErr) - return fmt.Errorf("staging directory not found: %w", statErr) + return derrors.WrapError(statErr, derrors.CategoryNotFound, "staging directory not found").Build() } slog.Debug("Staging directory confirmed before Hugo", "dir", rootDir, "is_dir", stat.IsDir()) @@ -159,13 +160,13 @@ func (b *BinaryRenderer) Execute(ctx context.Context, rootDir string) error { if err != nil { logHugoExecutionError(outStr, errStr) - return fmt.Errorf("%w: %w", herrors.ErrHugoExecutionFailed, err) + return derrors.WrapError(err, derrors.CategoryInternal, "hugo execution failed").WithCause(herrors.ErrHugoExecutionFailed).Build() } // Check staging directory still exists after Hugo runs if stat, err := os.Stat(rootDir); err != nil { slog.Error("Staging directory MISSING after Hugo execution", "dir", rootDir, "error", err) - return fmt.Errorf("staging directory disappeared during Hugo execution: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "staging directory disappeared during Hugo execution").Build() } else { slog.Debug("Staging directory confirmed after Hugo", "dir", rootDir, "is_dir", stat.IsDir()) } @@ -260,7 +261,14 @@ func (n *NoopRenderer) Execute(_ context.Context, rootDir string) error { // Maintain the invariant expected by the pipeline/reporting: // if rendering is considered successful, a publish directory exists. if err := os.MkdirAll(filepath.Join(rootDir, "public"), 0o750); err != nil { - return fmt.Errorf("create public dir: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "create public dir").Build() } return nil } + +// Compile-time assertions that the renderer implementations satisfy the +// models.Renderer contract. +var ( + _ models.Renderer = (*BinaryRenderer)(nil) + _ models.Renderer = (*NoopRenderer)(nil) +) diff --git a/internal/hugo/stages/repo_fetcher.go b/internal/hugo/stages/repo_fetcher.go index 4eeae3ee..318f8fcf 100644 --- a/internal/hugo/stages/repo_fetcher.go +++ b/internal/hugo/stages/repo_fetcher.go @@ -2,7 +2,6 @@ package stages import ( "context" - "fmt" "log/slog" "os" "path/filepath" @@ -12,6 +11,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/git" ) @@ -46,7 +46,7 @@ func NewDefaultRepoFetcher(workspace string, buildCfg *config.BuildConfig) RepoF return &defaultRepoFetcher{workspace: workspace, buildCfg: buildCfg} } -func (f *defaultRepoFetcher) Fetch(_ context.Context, strategy config.CloneStrategy, repo config.Repository) RepoFetchResult { +func (f *defaultRepoFetcher) Fetch(ctx context.Context, strategy config.CloneStrategy, repo config.Repository) RepoFetchResult { res := RepoFetchResult{Name: repo.Name} client := git.NewClient(f.workspace) if f.buildCfg != nil { @@ -56,7 +56,7 @@ func (f *defaultRepoFetcher) Fetch(_ context.Context, strategy config.CloneStrat // Snapshot builds: if a specific commit SHA is pinned for this repo, ensure the // working copy is checked out at that exact commit. if repo.PinnedCommit != "" { - return f.fetchPinnedCommit(client, strategy, repo) + return f.fetchPinnedCommit(ctx, client, strategy, repo) } attemptUpdate := false @@ -82,9 +82,9 @@ func (f *defaultRepoFetcher) Fetch(_ context.Context, strategy config.CloneStrat var err error var commitDate time.Time if attemptUpdate { - path, commitDate, err = f.performUpdate(client, repo) + path, commitDate, err = f.performUpdate(ctx, client, repo) } else { - path, commitDate, err = f.performClone(client, repo, &res) + path, commitDate, err = f.performClone(ctx, client, repo, &res) } res.Path = path res.CommitDate = commitDate @@ -104,7 +104,7 @@ func (f *defaultRepoFetcher) Fetch(_ context.Context, strategy config.CloneStrat return res } -func (f *defaultRepoFetcher) fetchPinnedCommit(client *git.Client, strategy config.CloneStrategy, repo config.Repository) RepoFetchResult { +func (f *defaultRepoFetcher) fetchPinnedCommit(ctx context.Context, client *git.Client, strategy config.CloneStrategy, repo config.Repository) RepoFetchResult { res := RepoFetchResult{Name: repo.Name} repoPath := filepath.Join(f.workspace, repo.Name) @@ -159,9 +159,9 @@ func (f *defaultRepoFetcher) fetchPinnedCommit(client *git.Client, strategy conf var err error var commitDate time.Time if attemptUpdate { - path, commitDate, err = f.performUpdate(client, repo) + path, commitDate, err = f.performUpdate(ctx, client, repo) } else { - path, commitDate, err = f.performClone(client, repo, &res) + path, commitDate, err = f.performClone(ctx, client, repo, &res) } res.Path = path res.CommitDate = commitDate @@ -184,8 +184,8 @@ func (f *defaultRepoFetcher) fetchPinnedCommit(client *git.Client, strategy conf } // performUpdate updates an existing repository and returns its path, commit date, and error. -func (f *defaultRepoFetcher) performUpdate(client *git.Client, repo config.Repository) (string, time.Time, error) { - path, err := client.UpdateRepo(repo) +func (f *defaultRepoFetcher) performUpdate(ctx context.Context, client *git.Client, repo config.Repository) (string, time.Time, error) { + path, err := client.UpdateRepo(ctx, repo) var commitDate time.Time // For updates, try to get commit date by reading HEAD @@ -200,8 +200,8 @@ func (f *defaultRepoFetcher) performUpdate(client *git.Client, repo config.Repos // performClone performs a fresh clone and returns its path, commit date, and error. // It also sets the PostHead field in res. -func (f *defaultRepoFetcher) performClone(client *git.Client, repo config.Repository, res *RepoFetchResult) (string, time.Time, error) { - result, err := client.CloneRepoWithMetadata(repo) +func (f *defaultRepoFetcher) performClone(ctx context.Context, client *git.Client, repo config.Repository, res *RepoFetchResult) (string, time.Time, error) { + result, err := client.CloneRepoWithMetadata(ctx, repo) var path string var commitDate time.Time @@ -221,7 +221,7 @@ func gitStatRepo(path string) error { return err } if _, err := os.Stat(path + "/.git"); err != nil { // missing .git - return fmt.Errorf("no git dir: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "no git dir").Build() } return nil } @@ -244,19 +244,19 @@ func getCommitDate(repoPath, commitSHA string) time.Time { func checkoutExactCommit(repoPath, commitSHA string) (time.Time, error) { repo, err := ggit.PlainOpen(repoPath) if err != nil { - return time.Time{}, fmt.Errorf("open repo for checkout: %w", err) + return time.Time{}, derrors.WrapError(err, derrors.CategoryFileSystem, "open repo for checkout").Build() } wt, err := repo.Worktree() if err != nil { - return time.Time{}, fmt.Errorf("get worktree for checkout: %w", err) + return time.Time{}, derrors.WrapError(err, derrors.CategoryInternal, "get worktree for checkout").Build() } h := plumbing.NewHash(commitSHA) if checkoutErr := wt.Checkout(&ggit.CheckoutOptions{Hash: h, Force: true}); checkoutErr != nil { - return time.Time{}, fmt.Errorf("checkout commit %s: %w", commitSHA, checkoutErr) + return time.Time{}, derrors.WrapError(checkoutErr, derrors.CategoryInternal, "checkout commit").WithContext("commit_sha", commitSHA).Build() } commit, err := repo.CommitObject(h) if err != nil { - return time.Time{}, fmt.Errorf("read commit %s: %w", commitSHA, err) + return time.Time{}, derrors.WrapError(err, derrors.CategoryInternal, "read commit").WithContext("commit_sha", commitSHA).Build() } return commit.Author.When, nil } diff --git a/internal/hugo/stages/runner.go b/internal/hugo/stages/runner.go index e867020b..8dddfe19 100644 --- a/internal/hugo/stages/runner.go +++ b/internal/hugo/stages/runner.go @@ -2,10 +2,10 @@ package stages import ( "context" - "fmt" "log/slog" "time" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" ) @@ -18,7 +18,7 @@ func RunStages(ctx context.Context, bs *models.BuildState, stages []models.Stage out := StageOutcome{Stage: st.Name, Error: se, Result: models.StageResultCanceled, IssueCode: models.IssueCanceled, Severity: models.SeverityError, Transient: false, Abort: true} bs.Report.StageErrorKinds[st.Name] = se.Kind bs.Report.AddIssue(out.IssueCode, out.Stage, out.Severity, se.Error(), out.Transient, se) - bs.Report.RecordStageResult(out.Stage, out.Result, bs.Generator.Recorder()) + bs.Report.RecordStageResult(out.Stage, out.Result) if bs.Generator != nil && bs.Generator.Observer() != nil { bs.Generator.Observer().OnStageComplete(st.Name, 0, models.StageResultCanceled) } @@ -43,7 +43,7 @@ func RunStages(ctx context.Context, bs *models.BuildState, stages []models.Stage bs.Report.AddIssue(out.IssueCode, out.Stage, out.Severity, out.Error.Error(), out.Transient, out.Error) } - bs.Report.RecordStageResult(st.Name, out.Result, bs.Generator.Recorder()) + bs.Report.RecordStageResult(st.Name, out.Result) if bs.Generator != nil && bs.Generator.Observer() != nil { bs.Generator.Observer().OnStageComplete(st.Name, dur, out.Result) @@ -53,7 +53,7 @@ func RunStages(ctx context.Context, bs *models.BuildState, stages []models.Stage if out.Error != nil { return out.Error } - return fmt.Errorf("stage %s aborted", st.Name) + return derrors.NewError(derrors.CategoryInternal, "stage aborted").WithContext("stage", st.Name).Build() } if st.Name == models.StageCloneRepos && bs.Git.AllReposUnchanged { diff --git a/internal/hugo/stages/stage_clone.go b/internal/hugo/stages/stage_clone.go index ead32a84..207e5d7b 100644 --- a/internal/hugo/stages/stage_clone.go +++ b/internal/hugo/stages/stage_clone.go @@ -3,7 +3,6 @@ package stages import ( "context" stdErrors "errors" - "fmt" "log/slog" "os" "strings" @@ -11,7 +10,7 @@ import ( "time" "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" gitpkg "git.home.luguber.info/inful/docbuilder/internal/git" "git.home.luguber.info/inful/docbuilder/internal/hugo/models" ) @@ -26,7 +25,7 @@ func StageCloneRepos(ctx context.Context, bs *models.BuildState) error { fetcher := NewDefaultRepoFetcher(bs.Git.WorkspaceDir, &bs.Generator.Config().Build) // Ensure workspace directory structure (previously via git client) if err := os.MkdirAll(bs.Git.WorkspaceDir, 0o750); err != nil { - return models.NewFatalStageError(models.StageCloneRepos, fmt.Errorf("ensure workspace: %w", err)) + return models.NewFatalStageError(models.StageCloneRepos, derrors.WrapError(err, derrors.CategoryFileSystem, "ensure workspace").Build()) } strategy := config.CloneStrategyFresh if bs.Generator != nil { @@ -47,9 +46,6 @@ func StageCloneRepos(ctx context.Context, bs *models.BuildState) error { if concurrency < 1 { concurrency = 1 } - if bs.Generator != nil && bs.Generator.Recorder() != nil { - bs.Generator.Recorder().SetCloneConcurrency(concurrency) - } type cloneTask struct{ repo config.Repository } tasks := make(chan cloneTask) var wg sync.WaitGroup @@ -64,7 +60,7 @@ func StageCloneRepos(ctx context.Context, bs *models.BuildState) error { } start := time.Now() res := fetcher.Fetch(ctx, strategy, task.repo) - dur := time.Since(start) + _ = time.Since(start) // duration reserved for future per-repo metrics success := res.Err == nil mu.Lock() if success { @@ -73,10 +69,6 @@ func StageCloneRepos(ctx context.Context, bs *models.BuildState) error { recordCloneFailure(bs, res) } mu.Unlock() - if bs.Generator != nil && bs.Generator.Recorder() != nil { - bs.Generator.Recorder().ObserveCloneRepoDuration(task.repo.Name, dur, success) - bs.Generator.Recorder().IncCloneRepoResult(success) - } } } wg.Add(concurrency) @@ -106,10 +98,10 @@ func StageCloneRepos(ctx context.Context, bs *models.BuildState) error { slog.Info("No repository head changes detected", slog.Int("repos", len(bs.Git.PostHeads))) } if bs.Report.ClonedRepositories == 0 && bs.Report.FailedRepositories > 0 { - return models.NewWarnStageError(models.StageCloneRepos, fmt.Errorf("%w: all clones failed", models.ErrClone)) + return models.NewWarnStageError(models.StageCloneRepos, derrors.NewError(derrors.CategoryInternal, "all clones failed").WithCause(models.ErrClone).Build()) } if bs.Report.FailedRepositories > 0 { - return models.NewWarnStageError(models.StageCloneRepos, fmt.Errorf("%w: %d failed out of %d", models.ErrClone, bs.Report.FailedRepositories, len(bs.Git.Repositories))) + return models.NewWarnStageError(models.StageCloneRepos, derrors.NewError(derrors.CategoryInternal, "clone failures exceeded budget").WithCause(models.ErrClone).WithContext("failed", bs.Report.FailedRepositories).WithContext("total", len(bs.Git.Repositories)).Build()) } return nil } @@ -121,23 +113,23 @@ func classifyGitFailure(err error) models.ReportIssueCode { } // Use structured error classification (ADR-000) - if ce, ok := errors.AsClassified(err); ok { + if ce, ok := derrors.AsClassified(err); ok { switch ce.Category() { - case errors.CategoryAuth: + case derrors.CategoryAuth: return models.IssueAuthFailure - case errors.CategoryNotFound: + case derrors.CategoryNotFound: return models.IssueRepoNotFound - case errors.CategoryConfig: + case derrors.CategoryConfig: return models.IssueUnsupportedProto - case errors.CategoryNetwork: - if ce.RetryStrategy() == errors.RetryRateLimit { + case derrors.CategoryNetwork: + if ce.RetryStrategy() == derrors.RetryRateLimit { return models.IssueRateLimit } return models.IssueNetworkTimeout - case errors.CategoryValidation, errors.CategoryAlreadyExists, errors.CategoryGit, - errors.CategoryForge, errors.CategoryBuild, errors.CategoryHugo, errors.CategoryFileSystem, - errors.CategoryDocs, errors.CategoryEventStore, errors.CategoryRuntime, - errors.CategoryDaemon, errors.CategoryInternal: + case derrors.CategoryValidation, derrors.CategoryAlreadyExists, derrors.CategoryGit, + derrors.CategoryForge, derrors.CategoryBuild, derrors.CategoryHugo, derrors.CategoryFileSystem, + derrors.CategoryDocs, derrors.CategoryEventStore, derrors.CategoryRuntime, + derrors.CategoryDaemon, derrors.CategoryInternal: // Other categories use heuristic handling below } if diverged, ok := ce.Context().Get("diverged"); ok && diverged == true { diff --git a/internal/hugo/stages/stage_discover.go b/internal/hugo/stages/stage_discover.go index a85a2ed9..e5df715f 100644 --- a/internal/hugo/stages/stage_discover.go +++ b/internal/hugo/stages/stage_discover.go @@ -4,7 +4,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - "fmt" "log/slog" "sort" @@ -12,11 +11,12 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/hugo/models" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) func StageDiscoverDocs(ctx context.Context, bs *models.BuildState) error { if len(bs.Git.RepoPaths) == 0 { - return models.NewWarnStageError(models.StageDiscoverDocs, fmt.Errorf("%w: no repositories cloned", models.ErrDiscovery)) + return models.NewWarnStageError(models.StageDiscoverDocs, derrors.NewError(derrors.CategoryInternal, "no repositories cloned").WithCause(models.ErrDiscovery).Build()) } select { case <-ctx.Done(): @@ -26,7 +26,7 @@ func StageDiscoverDocs(ctx context.Context, bs *models.BuildState) error { discovery := docs.NewDiscovery(bs.Git.Repositories, &bs.Generator.Config().Build) docFiles, err := discovery.DiscoverDocs(bs.Git.RepoPaths) if err != nil { - return models.NewFatalStageError(models.StageDiscoverDocs, fmt.Errorf("%w: %w", models.ErrDiscovery, err)) + return models.NewFatalStageError(models.StageDiscoverDocs, derrors.WrapError(err, derrors.CategoryInternal, "discovery failed").WithCause(models.ErrDiscovery).Build()) } prevCount := len(bs.Docs.Files) prevFiles := bs.Docs.Files diff --git a/internal/hugo/stages/stage_execution.go b/internal/hugo/stages/stage_execution.go deleted file mode 100644 index 5be3fefe..00000000 --- a/internal/hugo/stages/stage_execution.go +++ /dev/null @@ -1,41 +0,0 @@ -package stages - -// StageExecution represents the structured result of a stage execution. -type StageExecution struct { - Err error // error encountered during stage execution - Skip bool // whether subsequent stages should be skipped -} - -// ExecutionSuccess returns a successful stage execution result. -func ExecutionSuccess() StageExecution { - return StageExecution{} -} - -// ExecutionSuccessWithSkip returns a successful stage execution that skips remaining stages. -func ExecutionSuccessWithSkip() StageExecution { - return StageExecution{Skip: true} -} - -// ExecutionFailure returns a failed stage execution result. -func ExecutionFailure(err error) StageExecution { - return StageExecution{Err: err} -} - -// ExecutionFailureWithSkip returns a failed stage execution that skips remaining stages. -func ExecutionFailureWithSkip(err error) StageExecution { - return StageExecution{Err: err, Skip: true} -} - -// IsSuccess returns true if the stage completed successfully (no error). -func (r StageExecution) IsSuccess() bool { - return r.Err == nil -} - -// ShouldSkip returns true if subsequent stages should be skipped. -func (r StageExecution) ShouldSkip() bool { - return r.Skip -} - -// StageExecutor and decorators removed. Timing and observation are handled centrally -// in the runner for error-returning stages. Structured StageExecution is used by -// command-style stages and they can record as needed within Execute. diff --git a/internal/hugo/stages/stage_indexes.go b/internal/hugo/stages/stage_indexes.go deleted file mode 100644 index 82177114..00000000 --- a/internal/hugo/stages/stage_indexes.go +++ /dev/null @@ -1,15 +0,0 @@ -package stages - -import ( - "context" - - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" -) - -// StageIndexes is now a no-op since the new pipeline (ADR-003) generates all -// indexes during content processing. Kept as empty function to maintain build -// stage compatibility. -func StageIndexes(_ context.Context, bs *models.BuildState) error { - // New pipeline already generates all indexes - nothing to do here - return nil -} diff --git a/internal/hugo/stages/stage_layouts.go b/internal/hugo/stages/stage_layouts.go deleted file mode 100644 index 9c2fb6ee..00000000 --- a/internal/hugo/stages/stage_layouts.go +++ /dev/null @@ -1,13 +0,0 @@ -package stages - -import ( - "context" - - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" -) - -func StageLayouts(_ context.Context, bs *models.BuildState) error { - // Relearn theme provides all necessary layouts via Hugo Modules - // No custom layout generation needed - return nil -} diff --git a/internal/hugo/stages/stage_post_process.go b/internal/hugo/stages/stage_post_process.go deleted file mode 100644 index 0142fd34..00000000 --- a/internal/hugo/stages/stage_post_process.go +++ /dev/null @@ -1,16 +0,0 @@ -package stages - -import ( - "context" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/hugo/models" -) - -func StagePostProcess(_ context.Context, _ *models.BuildState) error { - start := time.Now() - // Brief spin to ensure distinguishable timestamps for build stages - for time.Since(start) == 0 { - } - return nil -} diff --git a/internal/hugo/stages/stage_run_hugo.go b/internal/hugo/stages/stage_run_hugo.go index 385644d6..d4b00126 100644 --- a/internal/hugo/stages/stage_run_hugo.go +++ b/internal/hugo/stages/stage_run_hugo.go @@ -8,6 +8,8 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/hugo/models" "git.home.luguber.info/inful/docbuilder/internal/config" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" herrors "git.home.luguber.info/inful/docbuilder/internal/hugo/errors" ) @@ -42,7 +44,7 @@ func StageRunHugo(ctx context.Context, bs *models.BuildState) error { slog.String("error", err.Error()), slog.String("root", root)) // Return error regardless of mode - let caller decide how to handle - return models.NewFatalStageError(models.StageRunHugo, fmt.Errorf("%w: %w", herrors.ErrHugoExecutionFailed, err)) + return models.NewFatalStageError(models.StageRunHugo, derrors.WrapError(err, derrors.CategoryInternal, "hugo execution failed").WithCause(herrors.ErrHugoExecutionFailed).Build()) } bs.Report.StaticRendered = true slog.Info("Hugo renderer completed successfully", diff --git a/internal/hugo/structure.go b/internal/hugo/structure.go index eb71ef83..9b44081f 100644 --- a/internal/hugo/structure.go +++ b/internal/hugo/structure.go @@ -2,12 +2,12 @@ package hugo import ( "errors" - "fmt" "log/slog" "os" "path/filepath" "time" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" ) @@ -27,7 +27,7 @@ func (g *Generator) CreateHugoStructure() error { for _, dir := range dirs { path := filepath.Join(root, dir) if err := os.MkdirAll(path, 0o750); err != nil { - return fmt.Errorf("failed to create directory %s: %w", path, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create directory").WithContext("path", path).Build() } } slog.Debug("Created Hugo directory structure", "root", root) @@ -88,7 +88,7 @@ func (g *Generator) finalizeStaging() error { slog.String("staging", g.stageDir), slog.String("output", g.outputDir), slog.String("error", err.Error())) - return fmt.Errorf("staging directory missing: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "staging directory missing").Build() } else { slog.Debug("Staging directory verified", slog.String("path", g.stageDir), @@ -111,7 +111,7 @@ func (g *Generator) finalizeStaging() error { slog.String("from", g.outputDir), slog.String("to", prev), slog.String("error", err.Error())) - return fmt.Errorf("backup existing output: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "backup existing output").Build() } slog.Debug("Successfully backed up current output") } else { @@ -128,7 +128,7 @@ func (g *Generator) finalizeStaging() error { slog.String("from", g.stageDir), slog.String("to", g.outputDir), slog.String("error", err.Error())) - return fmt.Errorf("promote staging: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "promote staging").Build() } g.stageDir = "" slog.Info("Successfully promoted staging directory", diff --git a/internal/hugo/utilities.go b/internal/hugo/utilities.go index c04ba8b5..957aaeb0 100644 --- a/internal/hugo/utilities.go +++ b/internal/hugo/utilities.go @@ -2,8 +2,10 @@ package hugo import "strings" -// titleCase converts a string to title case (portable alternative to strings.Title). -func titleCase(s string) string { +// TitleCase converts a string to title case (portable alternative to strings.Title). +// The titlecased form is space-separated: each word's first letter is uppercased, +// remaining letters are lowercased. +func TitleCase(s string) string { if s == "" { return s } @@ -16,5 +18,16 @@ func titleCase(s string) string { return strings.Join(words, " ") } -// TitleCase exported helper for theme packages. -func TitleCase(s string) string { return titleCase(s) } +// TitleCaseSlug is TitleCase's slug-aware variant: '-' and '_' are first +// replaced with spaces, then the standard TitleCase rules apply. Useful for +// repo names like "my-docs-repo" → "My Docs Repo". +func TitleCaseSlug(s string) string { + s = strings.ReplaceAll(s, "-", " ") + s = strings.ReplaceAll(s, "_", " ") + return TitleCase(s) +} + +// titleCase is the lowercase alias kept so templates and helper files +// in this package can refer to the helper without an import edge. It +// matches TitleCase byte-for-byte. +func titleCase(s string) string { return TitleCase(s) } diff --git a/internal/hugo/utilities_test.go b/internal/hugo/utilities_test.go new file mode 100644 index 00000000..8a41e92d --- /dev/null +++ b/internal/hugo/utilities_test.go @@ -0,0 +1,48 @@ +package hugo + +import "testing" + +// TestTitleCase covers the simple title-casing helper in utilities.go. +func TestTitleCase(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"single word", "hello", "Hello"}, + {"two words", "hello world", "Hello World"}, + {"uppercase preserved lowercase tail", "hELLO wORLD", "Hello World"}, + {"hyphen preserved", "my-repo", "My-repo"}, + {"already title case", "Hello World", "Hello World"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TitleCase(tt.in); got != tt.want { + t.Errorf("TitleCase(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// TestTitleCaseSlug covers the slug-aware variant. +func TestTitleCaseSlug(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"hyphen slug", "my-docs-repo", "My Docs Repo"}, + {"underscore slug", "my_docs_repo", "My Docs Repo"}, + {"mixed", "my-docs_repo", "My Docs Repo"}, + {"plain word", "docs", "Docs"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := TitleCaseSlug(tt.in); got != tt.want { + t.Errorf("TitleCaseSlug(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/linkverify/extractor.go b/internal/linkverify/extractor.go index e832123d..7bdf4dc5 100644 --- a/internal/linkverify/extractor.go +++ b/internal/linkverify/extractor.go @@ -1,13 +1,13 @@ package linkverify import ( - "fmt" "io" "net/url" "os" "path/filepath" "strings" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "golang.org/x/net/html" ) @@ -25,7 +25,9 @@ type Link struct { func ExtractLinks(htmlPath string, baseURL string) ([]*Link, error) { file, err := os.Open(filepath.Clean(htmlPath)) if err != nil { - return nil, fmt.Errorf("failed to open HTML file: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to open HTML file"). + WithContext("path", htmlPath). + Build() } defer func() { _ = file.Close() // Ignore close errors on read-only operation @@ -38,12 +40,14 @@ func ExtractLinks(htmlPath string, baseURL string) ([]*Link, error) { func ExtractLinksFromReader(r io.Reader, baseURL string) ([]*Link, error) { doc, err := html.Parse(r) if err != nil { - return nil, fmt.Errorf("failed to parse HTML: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "failed to parse HTML").Build() } base, err := url.Parse(baseURL) if err != nil { - return nil, fmt.Errorf("invalid base URL: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "invalid base URL"). + WithContext("base_url", baseURL). + Build() } var links []*Link diff --git a/internal/linkverify/nats_client.go b/internal/linkverify/nats_client.go index 5e08be0f..c48bf27b 100644 --- a/internal/linkverify/nats_client.go +++ b/internal/linkverify/nats_client.go @@ -7,7 +7,6 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" "log/slog" "sync" "sync/atomic" @@ -17,6 +16,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // ErrCacheMiss is returned when a cache entry is not found. @@ -106,14 +106,14 @@ func (c *NATSClient) connectWithContext(ctx context.Context) error { // Connect to NATS conn, err := nats.Connect(c.cfg.NATSURL, opts...) if err != nil { - return fmt.Errorf("failed to connect to NATS: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to connect to NATS").Build() } // Create JetStream context js, err := jetstream.New(conn) if err != nil { conn.Close() - return fmt.Errorf("failed to create JetStream context: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to create JetStream context").Build() } c.conn = conn @@ -124,7 +124,7 @@ func (c *NATSClient) connectWithContext(ctx context.Context) error { conn.Close() c.conn = nil c.js = nil - return fmt.Errorf("failed to initialize KV bucket: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to initialize KV bucket").Build() } // Initialize stream for broken link events @@ -204,7 +204,7 @@ func (c *NATSClient) initKVBucket(ctx context.Context) error { TTL: ttl, }) if err != nil { - return fmt.Errorf("failed to create or update KV bucket: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to create or update KV bucket").Build() } c.kv = kv @@ -238,7 +238,7 @@ func (c *NATSClient) initStream(ctx context.Context) error { Discard: jetstream.DiscardOld, }) if err != nil { - return fmt.Errorf("failed to create stream: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to create stream").Build() } slog.Info("Created NATS stream for broken link events", @@ -251,7 +251,7 @@ func (c *NATSClient) initStream(ctx context.Context) error { func (c *NATSClient) PublishBrokenLink(ctx context.Context, event *BrokenLinkEvent) error { // Ensure we're connected before publishing if err := c.ensureConnected(ctx); err != nil { - return fmt.Errorf("NATS not connected: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "NATS not connected").Build() } pubCtx, cancel := context.WithTimeout(ctx, 5*time.Second) @@ -261,7 +261,7 @@ func (c *NATSClient) PublishBrokenLink(ctx context.Context, event *BrokenLinkEve data, err := json.Marshal(event) if err != nil { - return fmt.Errorf("failed to marshal event: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to marshal event").Build() } c.mu.RLock() @@ -274,7 +274,7 @@ func (c *NATSClient) PublishBrokenLink(ctx context.Context, event *BrokenLinkEve _, err = js.Publish(pubCtx, c.subject, data) if err != nil { - return fmt.Errorf("failed to publish event: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to publish event").Build() } slog.Debug("Published broken link event", @@ -325,12 +325,12 @@ func (c *NATSClient) GetCachedResult(ctx context.Context, url string) (*CacheEnt if errors.Is(err, jetstream.ErrKeyNotFound) { return nil, ErrCacheMiss } - return nil, fmt.Errorf("failed to get cache entry: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryNetwork, "failed to get cache entry").Build() } var cached CacheEntry if err := json.Unmarshal(entry.Value(), &cached); err != nil { - return nil, fmt.Errorf("failed to unmarshal cache entry: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to unmarshal cache entry").Build() } return &cached, nil @@ -351,7 +351,7 @@ func (c *NATSClient) SetCachedResult(ctx context.Context, entry *CacheEntry) err data, err := json.Marshal(entry) if err != nil { - return fmt.Errorf("failed to marshal cache entry: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "failed to marshal cache entry").Build() } // Note: NATS KV doesn't support per-key TTL in current API @@ -371,7 +371,7 @@ func (c *NATSClient) SetCachedResult(ctx context.Context, entry *CacheEntry) err // Put entry in KV store _, err = kv.Put(cacheCtx, key, data) if err != nil { - return fmt.Errorf("failed to put cache entry: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to put cache entry").Build() } return nil @@ -442,7 +442,7 @@ func (c *NATSClient) GetPageHash(ctx context.Context, pagePath string) (string, if errors.Is(err, jetstream.ErrKeyNotFound) { return "", errors.New("page hash not cached") } - return "", fmt.Errorf("failed to get page hash: %w", err) + return "", derrors.WrapError(err, derrors.CategoryNetwork, "failed to get page hash").Build() } return string(entry.Value()), nil @@ -473,7 +473,7 @@ func (c *NATSClient) SetPageHash(ctx context.Context, pagePath, hash string) err _, err := kv.Put(hashCtx, key, []byte(hash)) if err != nil { - return fmt.Errorf("failed to put page hash: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to put page hash").Build() } return nil diff --git a/internal/linkverify/service.go b/internal/linkverify/service.go index 92fcd998..ae080f98 100644 --- a/internal/linkverify/service.go +++ b/internal/linkverify/service.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "errors" - "fmt" "io" "log/slog" "net" @@ -13,6 +12,7 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "sync" "time" @@ -20,6 +20,7 @@ import ( "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/docmodel" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // ErrNoFrontMatter is returned when content has no front matter. @@ -59,7 +60,7 @@ func NewVerificationService(cfg *config.LinkVerificationConfig) (*VerificationSe // Create NATS client natsClient, err := NewNATSClient(cfg) if err != nil { - return nil, fmt.Errorf("failed to create NATS client: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to create NATS client").Build() } // Parse timeout @@ -83,7 +84,7 @@ func NewVerificationService(cfg *config.LinkVerificationConfig) (*VerificationSe // len(via) is the number of previous requests already made. // If MaxRedirects is 3, we should allow 3 redirects (i.e., 4 total requests). if len(via) > cfg.MaxRedirects { - return fmt.Errorf("stopped after %d redirects", cfg.MaxRedirects) + return derrors.NewError(derrors.CategoryValidation, "stopped after N redirects").WithContext("redirects", cfg.MaxRedirects).Build() } return nil }, @@ -327,12 +328,12 @@ func checkInternalLink(page *PageMetadata, absoluteURL string) (int, error) { st, err := os.Stat(localPath) if err != nil { if os.IsNotExist(err) { - return http.StatusNotFound, fmt.Errorf("internal file not found: %s", localPath) + return http.StatusNotFound, derrors.NewError(derrors.CategoryNotFound, "internal file not found: "+localPath).Build() } - return 0, fmt.Errorf("failed to stat internal file: %w", err) + return 0, derrors.WrapError(err, derrors.CategoryInternal, "failed to stat internal file").Build() } if st.IsDir() { - return http.StatusNotFound, fmt.Errorf("internal path is a directory: %s", localPath) + return http.StatusNotFound, derrors.NewError(derrors.CategoryValidation, "internal path is a directory: "+localPath).Build() } return http.StatusOK, nil } @@ -344,7 +345,7 @@ func localPathForInternalURL(page *PageMetadata, absoluteURL string) (string, er } u, err := url.Parse(absoluteURL) if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) + return "", derrors.WrapError(err, derrors.CategoryValidation, "invalid URL").Build() } urlPath := u.EscapedPath() if urlPath == "" { @@ -445,7 +446,7 @@ func (s *VerificationService) doExternalRequest(ctx context.Context, method, lin req, err := http.NewRequestWithContext(ctx, method, linkURL, nil) if err != nil { - return 0, fmt.Errorf("failed to create request: %w", err) + return 0, derrors.WrapError(err, derrors.CategoryNetwork, "failed to create request").Build() } // Headers that improve compatibility with WAF/CDN protected sites. @@ -458,7 +459,7 @@ func (s *VerificationService) doExternalRequest(ctx context.Context, method, lin resp, err := s.httpClient.Do(req) if err != nil { - return 0, fmt.Errorf("request failed: %w", err) + return 0, derrors.WrapError(err, derrors.CategoryNetwork, "request failed").Build() } defer func() { _ = resp.Body.Close() // Ignore close errors after reading @@ -474,7 +475,7 @@ func (s *VerificationService) doExternalRequest(ctx context.Context, method, lin } if resp.StatusCode >= 400 { - return resp.StatusCode, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + return resp.StatusCode, derrors.NewError(derrors.CategoryNetwork, "HTTP "+strconv.Itoa(resp.StatusCode)).WithContext("status", resp.Status).Build() } return resp.StatusCode, nil @@ -597,7 +598,7 @@ func ParseFrontMatter(content []byte) (map[string]any, error) { fm, err := doc.FrontmatterFields() if err != nil { - return nil, fmt.Errorf("failed to parse front matter: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to parse front matter").Build() } return fm, nil } diff --git a/internal/lint/broken_link_healer.go b/internal/lint/broken_link_healer.go index 5d0f360d..944c4e8e 100644 --- a/internal/lint/broken_link_healer.go +++ b/internal/lint/broken_link_healer.go @@ -3,7 +3,6 @@ package lint import ( "bytes" "context" - "fmt" "os" "os/exec" "path/filepath" @@ -11,6 +10,8 @@ import ( "strings" "git.home.luguber.info/inful/docbuilder/internal/docmodel" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) type mappingKey struct { @@ -112,13 +113,13 @@ func detectScopedGitRenames(ctx context.Context, repoDir string, docsRoot string uncommittedDetector := &GitUncommittedRenameDetector{} uncommitted, err := uncommittedDetector.DetectRenames(ctx, repoDir) if err != nil { - return nil, fmt.Errorf("failed to detect git uncommitted renames: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to detect git uncommitted renames").Build() } historyDetector := &GitHistoryRenameDetector{} history, err := historyDetector.DetectRenames(ctx, repoDir) if err != nil { - return nil, fmt.Errorf("failed to detect git history renames: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to detect git history renames").Build() } combined := append(append([]RenameMapping(nil), uncommitted...), history...) @@ -128,7 +129,7 @@ func detectScopedGitRenames(ctx context.Context, repoDir string, docsRoot string normalized, err := NormalizeRenameMappings(combined, []string{docsRoot}) if err != nil { - return nil, fmt.Errorf("failed to normalize rename mappings: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to normalize rename mappings").Build() } return normalized, nil } diff --git a/internal/lint/fixer.go b/internal/lint/fixer.go index 8905bc57..fb389960 100644 --- a/internal/lint/fixer.go +++ b/internal/lint/fixer.go @@ -10,6 +10,7 @@ import ( "strings" "time" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/frontmatterops" ) @@ -76,7 +77,7 @@ func (f *Fixer) FixWithConfirmation(path string) (*FixResult, error) { // Phase 2: Show preview and get confirmation (unless auto-confirm) confirmed, err := f.ConfirmChanges(previewResult) if err != nil { - return nil, fmt.Errorf("confirmation failed: %w", err) + return nil, derrors.NewError(derrors.CategoryValidation, "confirmation failed").WithCause(err).Build() } if !confirmed { @@ -86,12 +87,12 @@ func (f *Fixer) FixWithConfirmation(path string) (*FixResult, error) { // Phase 3: Create backup (always, even with auto-confirm) rootPath, err := filepath.Abs(path) if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to get absolute path").Build() } backupDir, err := f.CreateBackup(previewResult, rootPath) if err != nil { - return nil, fmt.Errorf("failed to create backup: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create backup").Build() } if backupDir != "" { @@ -107,7 +108,7 @@ func (f *Fixer) fix(path string) (*FixResult, error) { // First, run linter to find issues result, err := f.linter.LintPath(path) if err != nil { - return nil, fmt.Errorf("failed to lint path: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to lint path").Build() } fixResult := &FixResult{ @@ -122,7 +123,7 @@ func (f *Fixer) fix(path string) (*FixResult, error) { // Get absolute path for the root directory (for searching links) rootPath, err := filepath.Abs(path) if err != nil { - return nil, fmt.Errorf("failed to get absolute path: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to get absolute path").Build() } // Detect broken links before applying fixes. @@ -131,7 +132,7 @@ func (f *Fixer) fix(path string) (*FixResult, error) { if err != nil { // Non-fatal: log but continue with fixes fixResult.Errors = append(fixResult.Errors, - fmt.Errorf("failed to detect broken links: %w", err)) + derrors.WrapError(err, derrors.CategoryInternal, "failed to detect broken links").Build()) } else { fixResult.BrokenLinks = brokenLinksWorklist } @@ -292,7 +293,7 @@ func (f *Fixer) applyFingerprintFixes(targets map[string]struct{}, fingerprintIs func (f *Fixer) findAndUpdateLinks(oldPath, newPath, rootPath string) ([]LinkUpdate, error) { links, err := f.findLinksToFile(oldPath, rootPath) if err != nil { - return nil, fmt.Errorf("failed to find links to %s: %w", oldPath, err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to find links").WithContext("path", oldPath).Build() } if len(links) == 0 { @@ -301,7 +302,7 @@ func (f *Fixer) findAndUpdateLinks(oldPath, newPath, rootPath string) ([]LinkUpd updates, err := f.applyLinkUpdates(links, oldPath, newPath) if err != nil { - return nil, fmt.Errorf("failed to update links: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to update links").Build() } return updates, nil @@ -324,7 +325,7 @@ func (f *Fixer) updateFrontmatterFingerprint(filePath string) FingerprintUpdate data, err := os.ReadFile(filePath) if err != nil { op.Success = false - op.Error = fmt.Errorf("read file for fingerprint update: %w", err) + op.Error = derrors.WrapError(err, derrors.CategoryFileSystem, "read file for fingerprint update").Build() return op } @@ -333,7 +334,7 @@ func (f *Fixer) updateFrontmatterFingerprint(filePath string) FingerprintUpdate fields, bodyBytes, had, style, readErr := frontmatterops.Read(data) if readErr != nil { op.Success = false - op.Error = fmt.Errorf("read frontmatter for fingerprint update: %w", readErr) + op.Error = derrors.WrapError(readErr, derrors.CategoryValidation, "read frontmatter for fingerprint update").Build() return op } if style.Newline == "" { @@ -356,14 +357,14 @@ func (f *Fixer) updateFrontmatterFingerprint(filePath string) FingerprintUpdate _, _, upsertErr := frontmatterops.UpsertFingerprintAndMaybeLastmod(fields, bodyBytes, nowFn()) if upsertErr != nil { op.Success = false - op.Error = fmt.Errorf("upsert fingerprint: %w", upsertErr) + op.Error = derrors.WrapError(upsertErr, derrors.CategoryInternal, "upsert fingerprint").Build() return op } updatedBytes, writeErr := frontmatterops.Write(fields, bodyBytes, had, style) if writeErr != nil { op.Success = false - op.Error = fmt.Errorf("write frontmatter for fingerprint update: %w", writeErr) + op.Error = derrors.WrapError(writeErr, derrors.CategoryFileSystem, "write frontmatter for fingerprint update").Build() return op } updated := string(updatedBytes) @@ -382,14 +383,14 @@ func (f *Fixer) updateFrontmatterFingerprint(filePath string) FingerprintUpdate info, statErr := os.Stat(filePath) if statErr != nil { op.Success = false - op.Error = fmt.Errorf("stat file for fingerprint update: %w", statErr) + op.Error = derrors.WrapError(statErr, derrors.CategoryFileSystem, "stat file for fingerprint update").Build() return op } //nolint:gosec // filePath comes from controlled lint/discovery walk; no untrusted path input if writeErr := os.WriteFile(filePath, []byte(updated), info.Mode().Perm()); writeErr != nil { op.Success = false - op.Error = fmt.Errorf("write file for fingerprint update: %w", writeErr) + op.Error = derrors.WrapError(writeErr, derrors.CategoryFileSystem, "write file for fingerprint update").Build() return op } @@ -437,7 +438,7 @@ func (f *Fixer) ConfirmChanges(result *FixResult) (bool, error) { reader := bufio.NewReader(os.Stdin) response, err := reader.ReadString('\n') if err != nil { - return false, fmt.Errorf("failed to read user input: %w", err) + return false, derrors.WrapError(err, derrors.CategoryValidation, "failed to read user input").Build() } response = strings.TrimSpace(strings.ToLower(response)) @@ -457,13 +458,13 @@ func (f *Fixer) CreateBackup(result *FixResult, rootPath string) (string, error) backupDir := filepath.Join(rootPath, fmt.Sprintf(".docbuilder-backup-%s", timestamp)) if err := os.MkdirAll(backupDir, 0o750); err != nil { - return "", fmt.Errorf("failed to create backup directory: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create backup directory").Build() } // Backup files that will be renamed for _, rename := range result.FilesRenamed { if err := f.backupFile(rename.OldPath, backupDir, rootPath); err != nil { - return "", fmt.Errorf("failed to backup %s: %w", rename.OldPath, err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "failed to backup").WithContext("path", rename.OldPath).Build() } } @@ -475,7 +476,7 @@ func (f *Fixer) CreateBackup(result *FixResult, rootPath string) (string, error) continue } if err := f.backupFile(update.SourceFile, backupDir, rootPath); err != nil { - return "", fmt.Errorf("failed to backup %s: %w", update.SourceFile, err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "failed to backup").WithContext("path", update.SourceFile).Build() } backedUp[update.SourceFile] = true } diff --git a/internal/lint/fixer_broken_links.go b/internal/lint/fixer_broken_links.go index cd0810ff..2b8986f8 100644 --- a/internal/lint/fixer_broken_links.go +++ b/internal/lint/fixer_broken_links.go @@ -1,12 +1,13 @@ package lint import ( - "fmt" "os" "strings" "git.home.luguber.info/inful/docbuilder/internal/docmodel" "git.home.luguber.info/inful/docbuilder/internal/markdown" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // detectBrokenLinks scans all markdown files in a path for links to non-existent files. @@ -16,7 +17,7 @@ func detectBrokenLinks(rootPath string) ([]BrokenLink, error) { // Determine if rootPath is a file or directory info, err := os.Stat(rootPath) if err != nil { - return nil, fmt.Errorf("failed to stat path: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to stat path").Build() } var filesToScan []string @@ -46,12 +47,12 @@ func detectBrokenLinks(rootPath string) ([]BrokenLink, error) { func detectBrokenLinksInFile(sourceFile string) ([]BrokenLink, error) { doc, err := docmodel.ParseFile(sourceFile, docmodel.Options{}) if err != nil { - return nil, fmt.Errorf("failed to parse file: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "failed to parse file").Build() } refs, err := doc.LinkRefs() if err != nil { - return nil, fmt.Errorf("failed to parse markdown links: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "failed to parse markdown links").Build() } brokenLinks := make([]BrokenLink, 0) diff --git a/internal/lint/fixer_file_ops.go b/internal/lint/fixer_file_ops.go index 2747dbbf..a0635991 100644 --- a/internal/lint/fixer_file_ops.go +++ b/internal/lint/fixer_file_ops.go @@ -3,11 +3,12 @@ package lint import ( "context" "errors" - "fmt" "io" "os" "os/exec" "path/filepath" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // renameFile renames a file to fix filename issues. @@ -43,7 +44,7 @@ func (f *Fixer) renameFile(oldPath string) RenameOperation { newInfo, _ := os.Stat(newPath) oldInfo, oldStatErr := os.Stat(oldPath) if oldStatErr != nil || !os.SameFile(newInfo, oldInfo) { - op.Error = fmt.Errorf("target file already exists: %s", newPath) + op.Error = derrors.NewError(derrors.CategoryValidation, "target file already exists").WithContext("path", newPath).Build() return op } } @@ -59,14 +60,14 @@ func (f *Fixer) renameFile(oldPath string) RenameOperation { // Use git mv to preserve history err := f.gitMv(oldPath, newPath) if err != nil { - op.Error = fmt.Errorf("git mv failed: %w", err) + op.Error = derrors.WrapError(err, derrors.CategoryInternal, "git mv failed").Build() return op } } else { // Use regular file system rename err := os.Rename(oldPath, newPath) if err != nil { - op.Error = fmt.Errorf("rename failed: %w", err) + op.Error = derrors.WrapError(err, derrors.CategoryFileSystem, "rename failed").Build() return op } } @@ -94,7 +95,7 @@ func (f *Fixer) gitMv(oldPath, newPath string) error { cmd := exec.CommandContext(context.Background(), "git", "mv", oldPath, newPath) output, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("%w: %s", err, string(output)) + return derrors.WrapError(err, derrors.CategoryInternal, "git command failed").WithContext("stderr", string(output)).Build() } return nil } diff --git a/internal/lint/fixer_link_detection.go b/internal/lint/fixer_link_detection.go index 75669b17..a73cdb3d 100644 --- a/internal/lint/fixer_link_detection.go +++ b/internal/lint/fixer_link_detection.go @@ -1,13 +1,15 @@ package lint import ( - "fmt" "os" "path/filepath" "strings" "git.home.luguber.info/inful/docbuilder/internal/docmodel" "git.home.luguber.info/inful/docbuilder/internal/markdown" + "git.home.luguber.info/inful/docbuilder/internal/urlutil" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // findLinksToFile finds all markdown links that reference the given target file. @@ -19,13 +21,13 @@ func (f *Fixer) findLinksToFile(targetPath, rootPath string) ([]LinkReference, e // Get absolute path of target for comparison absTarget, err := filepath.Abs(targetPath) if err != nil { - return nil, fmt.Errorf("failed to get absolute path for target: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to get absolute path for target").Build() } // Ensure rootPath is a directory rootInfo, err := os.Stat(rootPath) if err != nil { - return nil, fmt.Errorf("failed to stat root path: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to stat root path").Build() } searchRoot := rootPath @@ -51,7 +53,7 @@ func (f *Fixer) findLinksToFile(targetPath, rootPath string) ([]LinkReference, e // Find links in this file fileLinks, err := f.findLinksInFile(path, absTarget) if err != nil { - return fmt.Errorf("failed to scan %s: %w", path, err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to scan").WithContext("path", path).Build() } links = append(links, fileLinks...) @@ -65,12 +67,12 @@ func (f *Fixer) findLinksToFile(targetPath, rootPath string) ([]LinkReference, e func (f *Fixer) findLinksInFile(sourceFile, targetPath string) ([]LinkReference, error) { doc, err := docmodel.ParseFile(sourceFile, docmodel.Options{}) if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to read file").Build() } refs, err := doc.LinkRefs() if err != nil { - return nil, fmt.Errorf("failed to parse markdown links: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "failed to parse markdown links").Build() } links := make([]LinkReference, 0) @@ -94,7 +96,7 @@ func (f *Fixer) findLinksInFile(sourceFile, targetPath string) ([]LinkReference, if dest == "" { continue } - if isExternalURL(dest) { + if urlutil.IsExternalURL(dest) { continue } if strings.HasPrefix(dest, "#") { diff --git a/internal/lint/fixer_link_updates.go b/internal/lint/fixer_link_updates.go index 8cecc0ab..8af7f283 100644 --- a/internal/lint/fixer_link_updates.go +++ b/internal/lint/fixer_link_updates.go @@ -2,12 +2,13 @@ package lint import ( "bytes" - "fmt" "os" "path/filepath" "strings" "git.home.luguber.info/inful/docbuilder/internal/markdown" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // applyLinkUpdates applies link updates to markdown files atomically. @@ -30,7 +31,7 @@ func (f *Fixer) applyLinkUpdates(links []LinkReference, oldPath, newPath string) if err != nil { // Rollback any previous changes f.rollbackLinkUpdates(backupPaths) - return nil, fmt.Errorf("failed to read %s: %w", sourceFile, err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to read").WithContext("path", sourceFile).Build() } originalContent := append([]byte(nil), content...) @@ -80,7 +81,7 @@ func (f *Fixer) applyLinkUpdates(links []LinkReference, oldPath, newPath string) }}) if err != nil { f.rollbackLinkUpdates(backupPaths) - return nil, fmt.Errorf("failed to apply link updates to %s: %w", sourceFile, err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to apply link updates").WithContext("path", sourceFile).Build() } content = updated @@ -103,7 +104,7 @@ func (f *Fixer) applyLinkUpdates(links []LinkReference, oldPath, newPath string) if err != nil { // Rollback previous changes f.rollbackLinkUpdates(backupPaths) - return nil, fmt.Errorf("failed to create backup for %s: %w", sourceFile, err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create backup").WithContext("path", sourceFile).Build() } backupPaths = append(backupPaths, backupPath) @@ -113,7 +114,7 @@ func (f *Fixer) applyLinkUpdates(links []LinkReference, oldPath, newPath string) if err != nil { // Rollback previous changes f.rollbackLinkUpdates(backupPaths) - return nil, fmt.Errorf("failed to write updated %s: %w", sourceFile, err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to write updated").WithContext("path", sourceFile).Build() } } } diff --git a/internal/lint/fixer_uid.go b/internal/lint/fixer_uid.go index f8adf239..7f817675 100644 --- a/internal/lint/fixer_uid.go +++ b/internal/lint/fixer_uid.go @@ -1,7 +1,6 @@ package lint import ( - "bytes" "errors" "fmt" "os" @@ -10,6 +9,8 @@ import ( "strings" "git.home.luguber.info/inful/docbuilder/internal/frontmatterops" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) func preserveUIDAcrossContentRewrite(original, updated string) string { @@ -47,40 +48,13 @@ func addUIDIfMissingWithValue(content, uid string) (string, bool) { if strings.TrimSpace(uid) == "" { return content, false } - - fields, body, had, style, err := frontmatterops.Read([]byte(content)) - if err != nil { - return content, false - } - if style.Newline == "" { - style.Newline = "\n" - } - if fields == nil { - fields = map[string]any{} - } - - uidChanged, err := frontmatterops.EnsureUIDValue(fields, uid) + out, changed, err := frontmatterops.UpsertFields(content, true, func(fields map[string]any) (bool, error) { + return frontmatterops.EnsureUIDValue(fields, uid) + }) if err != nil { return content, false } - if !uidChanged { - return content, false - } - - if !had { - had = true - if len(body) > 0 && !bytes.HasPrefix(body, []byte(style.Newline)) { - body = append([]byte(style.Newline), body...) - } else if len(body) == 0 { - body = append([]byte(style.Newline), body...) - } - } - - out, err := frontmatterops.Write(fields, body, had, style) - if err != nil { - return content, false - } - return string(out), true + return out, changed } func (f *Fixer) applyUIDFixes(targets map[string]struct{}, uidIssueCounts map[string]int, fixResult *FixResult, fingerprintTargets map[string]struct{}) { @@ -126,7 +100,7 @@ func (f *Fixer) ensureFrontmatterUID(filePath string) UIDUpdate { data, err := os.ReadFile(filePath) if err != nil { op.Success = false - op.Error = fmt.Errorf("read file for uid update: %w", err) + op.Error = derrors.WrapError(err, derrors.CategoryFileSystem, "read file for uid update").Build() return op } @@ -142,13 +116,13 @@ func (f *Fixer) ensureFrontmatterUID(filePath string) UIDUpdate { info, statErr := os.Stat(filePath) if statErr != nil { op.Success = false - op.Error = fmt.Errorf("stat file for uid update: %w", statErr) + op.Error = derrors.WrapError(statErr, derrors.CategoryFileSystem, "stat file for uid update").Build() return op } if writeErr := os.WriteFile(filePath, []byte(updated), info.Mode().Perm()); writeErr != nil { //nolint:gosec // filePath is derived from the lint target set op.Success = false - op.Error = fmt.Errorf("write file for uid update: %w", writeErr) + op.Error = derrors.WrapError(writeErr, derrors.CategoryFileSystem, "write file for uid update").Build() return op } @@ -156,40 +130,21 @@ func (f *Fixer) ensureFrontmatterUID(filePath string) UIDUpdate { } func addUIDIfMissing(content string) (string, bool) { - fields, body, had, style, err := frontmatterops.Read([]byte(content)) - if err != nil { - // Malformed frontmatter; don't try to guess. - return content, false - } - if style.Newline == "" { - style.Newline = "\n" - } - if fields == nil { - fields = map[string]any{} - } - - uid, uidChanged, err := frontmatterops.EnsureUID(fields) - if err != nil || !uidChanged { - return content, false - } - - // Best-effort: if this fails, keep UID but skip alias. - _, _ = frontmatterops.EnsureUIDAlias(fields, uid) - - if !had { - had = true - if len(body) > 0 && !bytes.HasPrefix(body, []byte(style.Newline)) { - body = append([]byte(style.Newline), body...) - } else if len(body) == 0 { - body = append([]byte(style.Newline), body...) + out, changed, err := frontmatterops.UpsertFields(content, true, func(fields map[string]any) (bool, error) { + uid, changed, err := frontmatterops.EnsureUID(fields) + if err != nil || !changed { + return false, err } - } - - out, err := frontmatterops.Write(fields, body, had, style) + // Best-effort: if alias insertion fails, keep the UID we just + // wrote and skip the alias. The fix run will surface that as a + // separate issue on the next pass. + _, _ = frontmatterops.EnsureUIDAlias(fields, uid) + return true, nil + }) if err != nil { return content, false } - return string(out), true + return out, changed } func (f *Fixer) applyUIDAliasesFixes(targets map[string]struct{}, uidAliasIssueCounts map[string]int, fixResult *FixResult, fingerprintTargets map[string]struct{}) { @@ -229,7 +184,7 @@ func (f *Fixer) ensureFrontmatterUIDAlias(filePath string) UIDUpdate { data, err := os.ReadFile(filePath) if err != nil { op.Success = false - op.Error = fmt.Errorf("read file for uid alias update: %w", err) + op.Error = derrors.WrapError(err, derrors.CategoryFileSystem, "read file for uid alias update").Build() return op } @@ -253,13 +208,13 @@ func (f *Fixer) ensureFrontmatterUIDAlias(filePath string) UIDUpdate { info, statErr := os.Stat(filePath) if statErr != nil { op.Success = false - op.Error = fmt.Errorf("stat file for uid alias update: %w", statErr) + op.Error = derrors.WrapError(statErr, derrors.CategoryFileSystem, "stat file for uid alias update").Build() return op } if writeErr := os.WriteFile(filePath, []byte(updated), info.Mode().Perm()); writeErr != nil { //nolint:gosec // filePath is derived from the lint target set op.Success = false - op.Error = fmt.Errorf("write file for uid alias update: %w", writeErr) + op.Error = derrors.WrapError(writeErr, derrors.CategoryFileSystem, "write file for uid alias update").Build() return op } @@ -267,22 +222,16 @@ func (f *Fixer) ensureFrontmatterUIDAlias(filePath string) UIDUpdate { } func addUIDAliasIfMissing(content, uid string) (string, bool) { - fields, body, had, style, err := frontmatterops.Read([]byte(content)) - if err != nil || !had { - return content, false - } - if style.Newline == "" { - style.Newline = "\n" - } - - changed, err := frontmatterops.EnsureUIDAlias(fields, uid) - if err != nil || !changed { - return content, false - } - - out, err := frontmatterops.Write(fields, body, had, style) + // Aliases can only be inserted when the doc already has a + // frontmatter block (the alias points at the existing UID; without + // a UID there's nothing to alias). Pass createIfMissing=false so + // UpsertFields returns (content, false, nil) on docs that have no + // frontmatter -- exactly the prior behavior. + out, changed, err := frontmatterops.UpsertFields(content, false, func(fields map[string]any) (bool, error) { + return frontmatterops.EnsureUIDAlias(fields, uid) + }) if err != nil { return content, false } - return string(out), true + return out, changed } diff --git a/internal/lint/fixer_utils.go b/internal/lint/fixer_utils.go index 22bece9d..c2552b71 100644 --- a/internal/lint/fixer_utils.go +++ b/internal/lint/fixer_utils.go @@ -1,10 +1,11 @@ package lint import ( - "fmt" "os" "path/filepath" "strings" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // fileExists checks if a file exists (case-insensitive on applicable filesystems). @@ -89,7 +90,7 @@ func resolveRelativePath(sourceFile, linkTarget string) (string, error) { // Get absolute path absPath, err := filepath.Abs(cleanPath) if err != nil { - return "", fmt.Errorf("failed to resolve absolute path: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "failed to resolve absolute path").Build() } return absPath, nil @@ -144,15 +145,12 @@ func collectMarkdownFiles(rootPath string) ([]string, error) { return nil }) if err != nil { - return nil, fmt.Errorf("failed to walk directory: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to walk directory").Build() } return filesToScan, nil } -// isExternalURL checks if a link target is an external URL. -func isExternalURL(target string) bool { - return strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") -} +// isExternalURL migrated to internal/urlutil.IsExternalURL. // isInsideInlineCode checks if a position in a line is inside inline code (backticks). func isInsideInlineCode(line string, pos int) bool { diff --git a/internal/lint/formatter.go b/internal/lint/formatter.go index ea1d8094..ec16b02a 100644 --- a/internal/lint/formatter.go +++ b/internal/lint/formatter.go @@ -12,16 +12,23 @@ type Formatter interface { Format(w io.Writer, result *Result, detectedPath string, wasAutoDetected bool) error } -// TextFormatter formats results as human-readable text. -type TextFormatter struct{} - -// NewTextFormatter creates a text formatter. -func NewTextFormatter(useColor bool) *TextFormatter { - return &TextFormatter{} +// NewFormatter returns the appropriate formatter based on format string. +// The returned Formatter is one of two unexported implementations; callers +// should treat the result as opaque. +func NewFormatter(format string, useColor bool) Formatter { + switch format { + case "json": + return &jsonFormatter{} + default: + return &textFormatter{} + } } +// textFormatter formats results as human-readable text. +type textFormatter struct{} + // Format outputs results in human-readable text format. -func (f *TextFormatter) Format(w io.Writer, result *Result, detectedPath string, wasAutoDetected bool) error { +func (f *textFormatter) Format(w io.Writer, result *Result, detectedPath string, wasAutoDetected bool) error { // Header if wasAutoDetected { if _, err := fmt.Fprintf(w, "Detected documentation directory: %s\n", detectedPath); err != nil { @@ -109,7 +116,7 @@ func (f *TextFormatter) Format(w io.Writer, result *Result, detectedPath string, } // printFinalMessage prints the appropriate final message based on the result. -func (f *TextFormatter) printFinalMessage(w io.Writer, result *Result) error { +func (f *textFormatter) printFinalMessage(w io.Writer, result *Result) error { if result.HasErrors() { return f.printMessages(w, "❌ Documentation has errors that will prevent Hugo build.", @@ -127,7 +134,7 @@ func (f *TextFormatter) printFinalMessage(w io.Writer, result *Result) error { } // printMessages prints multiple lines to the writer. -func (f *TextFormatter) printMessages(w io.Writer, messages ...string) error { +func (f *textFormatter) printMessages(w io.Writer, messages ...string) error { for _, msg := range messages { if _, err := fmt.Fprintln(w, msg); err != nil { return err @@ -137,7 +144,7 @@ func (f *TextFormatter) printMessages(w io.Writer, messages ...string) error { } // formatIssue formats a single issue. -func (f *TextFormatter) formatIssue(w io.Writer, filePath string, issue Issue) error { +func (f *textFormatter) formatIssue(w io.Writer, filePath string, issue Issue) error { // Icon based on severity var icon string switch issue.Severity { @@ -180,27 +187,22 @@ func (f *TextFormatter) formatIssue(w io.Writer, filePath string, issue Issue) e return nil } -// JSONFormatter formats results as JSON. -type JSONFormatter struct{} - -// NewJSONFormatter creates a JSON formatter. -func NewJSONFormatter() *JSONFormatter { - return &JSONFormatter{} -} +// jsonFormatter formats results as JSON. +type jsonFormatter struct{} -// JSONOutput represents the JSON output structure. -type JSONOutput struct { +// jsonOutput represents the JSON output structure. +type jsonOutput struct { Path string `json:"path"` WasAutoDetected bool `json:"was_auto_detected"` FilesTotal int `json:"files_total"` ErrorCount int `json:"error_count"` WarningCount int `json:"warning_count"` InfoCount int `json:"info_count"` - Issues []JSONIssue `json:"issues"` + Issues []jsonIssue `json:"issues"` } -// JSONIssue represents a single issue in JSON format. -type JSONIssue struct { +// jsonIssue represents a single issue in JSON format. +type jsonIssue struct { FilePath string `json:"file_path"` Severity string `json:"severity"` Rule string `json:"rule"` @@ -211,8 +213,8 @@ type JSONIssue struct { } // Format outputs results in JSON format. -func (f *JSONFormatter) Format(w io.Writer, result *Result, detectedPath string, wasAutoDetected bool) error { - output := JSONOutput{ +func (f *jsonFormatter) Format(w io.Writer, result *Result, detectedPath string, wasAutoDetected bool) error { + output := jsonOutput{ Path: detectedPath, WasAutoDetected: wasAutoDetected, FilesTotal: result.FilesTotal, @@ -226,7 +228,7 @@ func (f *JSONFormatter) Format(w io.Writer, result *Result, detectedPath string, output.InfoCount++ } - output.Issues = append(output.Issues, JSONIssue{ + output.Issues = append(output.Issues, jsonIssue{ FilePath: issue.FilePath, Severity: issue.Severity.String(), Rule: issue.Rule, @@ -242,16 +244,6 @@ func (f *JSONFormatter) Format(w io.Writer, result *Result, detectedPath string, return encoder.Encode(output) } -// NewFormatter creates the appropriate formatter based on format string. -func NewFormatter(format string, useColor bool) Formatter { - switch format { - case "json": - return NewJSONFormatter() - default: - return NewTextFormatter(useColor) - } -} - // pluralize returns "s" if count != 1, otherwise empty string. func pluralize(count int) string { if count == 1 { diff --git a/internal/lint/git_history_rename_detector.go b/internal/lint/git_history_rename_detector.go index 3b52bf1b..f7c20062 100644 --- a/internal/lint/git_history_rename_detector.go +++ b/internal/lint/git_history_rename_detector.go @@ -7,6 +7,8 @@ import ( "fmt" "os/exec" "path/filepath" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) const defaultHistoryFallbackCommits = 50 @@ -33,7 +35,7 @@ type GitHistoryRenameDetector struct { func (d *GitHistoryRenameDetector) DetectRenames(ctx context.Context, repoRoot string) ([]RenameMapping, error) { repoRootAbs, err := filepath.Abs(repoRoot) if err != nil { - return nil, fmt.Errorf("failed to make repo root absolute: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to make repo root absolute").Build() } isGit := isGitWorkTree(ctx, repoRootAbs) @@ -123,9 +125,9 @@ func gitDiffRenamesRange(ctx context.Context, repoRoot string, rangeSpec string) if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { - return nil, fmt.Errorf("git diff %s failed: %w: %s", rangeSpec, err, string(ee.Stderr)) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "git diff failed").WithContext("range", rangeSpec).WithContext("stderr", string(ee.Stderr)).Build() } - return nil, fmt.Errorf("git diff %s failed: %w", rangeSpec, err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "git diff failed").WithContext("range", rangeSpec).Build() } if len(out) == 0 { diff --git a/internal/lint/git_uncommitted_rename_detector.go b/internal/lint/git_uncommitted_rename_detector.go index 8bc80750..d177ee03 100644 --- a/internal/lint/git_uncommitted_rename_detector.go +++ b/internal/lint/git_uncommitted_rename_detector.go @@ -5,12 +5,13 @@ import ( "context" "crypto/sha256" "errors" - "fmt" "io" "os" "os/exec" "path/filepath" "strings" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // GitUncommittedRenameDetector detects renames in the working tree and index @@ -27,7 +28,7 @@ type GitUncommittedRenameDetector struct{} func (d *GitUncommittedRenameDetector) DetectRenames(ctx context.Context, repoRoot string) ([]RenameMapping, error) { repoRootAbs, err := filepath.Abs(repoRoot) if err != nil { - return nil, fmt.Errorf("failed to make repo root absolute: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "failed to make repo root absolute").Build() } isGit := isGitWorkTree(ctx, repoRootAbs) @@ -77,9 +78,9 @@ func gitDiffRenames(ctx context.Context, repoRoot string, cached bool) ([]Rename if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { - return nil, fmt.Errorf("git diff failed: %w: %s", err, string(ee.Stderr)) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "git diff failed").WithContext("stderr", string(ee.Stderr)).Build() } - return nil, fmt.Errorf("git diff failed: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "git diff failed").Build() } if len(out) == 0 { @@ -196,9 +197,9 @@ func gitNameOnly(ctx context.Context, repoRoot string, args []string) ([]string, if err != nil { var ee *exec.ExitError if errors.As(err, &ee) { - return nil, fmt.Errorf("git %v failed: %w: %s", args, err, string(ee.Stderr)) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "git command failed").WithContext("args", args).WithContext("stderr", string(ee.Stderr)).Build() } - return nil, fmt.Errorf("git %v failed: %w", args, err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "git command failed").WithContext("args", args).Build() } if len(out) == 0 { return nil, nil diff --git a/internal/lint/link_target_rewrite.go b/internal/lint/link_target_rewrite.go index 24d03c0a..5ba83df4 100644 --- a/internal/lint/link_target_rewrite.go +++ b/internal/lint/link_target_rewrite.go @@ -1,9 +1,12 @@ package lint import ( - "fmt" "path/filepath" "strings" + + "git.home.luguber.info/inful/docbuilder/internal/docmodel" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // computeUpdatedLinkTarget computes the new link destination text when a target @@ -52,11 +55,11 @@ func computeUpdatedLinkPath(sourceFile string, newAbs string, isSiteAbsolute boo if isSiteAbsolute { contentRoot := findContentRoot(sourceFile) if contentRoot == "" { - return "", fmt.Errorf("failed to compute site-absolute link: content root not found for %q", sourceFile) + return "", derrors.NewError(derrors.CategoryInternal, "failed to compute site-absolute link: content root not found").WithContext("source_file", sourceFile).Build() } rel, err := filepath.Rel(contentRoot, newAbs) if err != nil { - return "", fmt.Errorf("failed to compute site-absolute link relpath: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "failed to compute site-absolute link relpath").Build() } return "/" + filepath.ToSlash(rel), nil } @@ -64,7 +67,7 @@ func computeUpdatedLinkPath(sourceFile string, newAbs string, isSiteAbsolute boo sourceDir := filepath.Dir(sourceFile) rel, err := filepath.Rel(sourceDir, newAbs) if err != nil { - return "", fmt.Errorf("failed to compute relative link relpath: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "failed to compute relative link relpath").Build() } updatedPath := filepath.ToSlash(rel) if wantsDotSlash && !strings.HasPrefix(updatedPath, "../") && !strings.HasPrefix(updatedPath, "./") { @@ -90,17 +93,9 @@ func hasURLScheme(target string) bool { } func hasKnownMarkdownExtension(target string) bool { - lower := strings.ToLower(target) - return strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") + return docmodel.IsMarkdownFile(target) } func stripKnownMarkdownExtension(target string) string { - lower := strings.ToLower(target) - if strings.HasSuffix(lower, ".md") { - return target[:len(target)-len(".md")] - } - if strings.HasSuffix(lower, ".markdown") { - return target[:len(target)-len(".markdown")] - } - return target + return docmodel.StripMarkdownExt(target) } diff --git a/internal/lint/rename_mapping.go b/internal/lint/rename_mapping.go index 89776e1e..f14b0070 100644 --- a/internal/lint/rename_mapping.go +++ b/internal/lint/rename_mapping.go @@ -1,10 +1,11 @@ package lint import ( - "fmt" "path/filepath" "sort" "strings" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // RenameSource records where a rename mapping came from. @@ -44,7 +45,7 @@ func NormalizeRenameMappings(mappings []RenameMapping, docsRoots []string) ([]Re continue } if !filepath.IsAbs(root) { - return nil, fmt.Errorf("docs root must be an absolute path: %q", root) + return nil, derrors.NewError(derrors.CategoryValidation, "docs root must be an absolute path").WithContext("root", root).Build() } absDocsRoots = append(absDocsRoots, filepath.Clean(root)) } @@ -52,7 +53,7 @@ func NormalizeRenameMappings(mappings []RenameMapping, docsRoots []string) ([]Re filtered := make([]RenameMapping, 0, len(mappings)) for _, m := range mappings { if !filepath.IsAbs(m.OldAbs) || !filepath.IsAbs(m.NewAbs) { - return nil, fmt.Errorf("rename mapping paths must be absolute: old=%q new=%q", m.OldAbs, m.NewAbs) + return nil, derrors.NewError(derrors.CategoryValidation, "rename mapping paths must be absolute").WithContext("old", m.OldAbs).WithContext("new", m.NewAbs).Build() } m.OldAbs = filepath.Clean(m.OldAbs) m.NewAbs = filepath.Clean(m.NewAbs) diff --git a/internal/lint/rule_frontmatter_fingerprint.go b/internal/lint/rule_frontmatter_fingerprint.go index 9abee10f..33c465aa 100644 --- a/internal/lint/rule_frontmatter_fingerprint.go +++ b/internal/lint/rule_frontmatter_fingerprint.go @@ -5,6 +5,7 @@ import ( "os" "strings" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/frontmatter" "git.home.luguber.info/inful/docbuilder/internal/frontmatterops" "github.com/inful/mdfp" @@ -49,7 +50,7 @@ func (r *FrontmatterFingerprintRule) Check(filePath string) ([]Issue, error) { // #nosec G304 -- filePath comes from controlled doc discovery/lint walk. data, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("read file: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryFileSystem, "read file").Build() } frontmatterBytes, bodyBytes, hadFrontmatter, _, splitErr := frontmatter.Split(data) @@ -124,7 +125,7 @@ func (r *FrontmatterFingerprintRule) Check(filePath string) ([]Issue, error) { expected, err := frontmatterops.ComputeFingerprint(fields, bodyBytes) if err != nil { - return nil, fmt.Errorf("compute fingerprint for check: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "compute fingerprint for check").Build() } if expected == currentFingerprint { return nil, nil diff --git a/internal/lint/types.go b/internal/lint/types.go index 4948b064..03ba7e69 100644 --- a/internal/lint/types.go +++ b/internal/lint/types.go @@ -1,6 +1,10 @@ package lint -import "path/filepath" +import ( + "path/filepath" + + "git.home.luguber.info/inful/docbuilder/internal/docmodel" +) const ( docExtensionMarkdown = ".md" @@ -123,9 +127,11 @@ type Config struct { } // IsDocFile returns true if the file is a documentation file. +// Delegates to docmodel.IsMarkdownFile for the canonical +// (case-insensitive, multi-extension) match so the lint and discovery +// paths agree on every casing / extension variant. func IsDocFile(path string) bool { - ext := filepath.Ext(path) - return ext == docExtensionMarkdown || ext == docExtensionMarkdownLong + return docmodel.IsMarkdownFile(path) } // IsAssetFile returns true if the file is an image asset. diff --git a/internal/logfields/logfields.go b/internal/logfields/logfields.go index 5bfdab90..a4e3d44e 100644 --- a/internal/logfields/logfields.go +++ b/internal/logfields/logfields.go @@ -6,46 +6,33 @@ import "log/slog" // Canonical log field name constants to avoid drift across packages. // These are used for structured logging with slog. const ( - KeyJobID = "job_id" - KeyJobType = "job_type" - KeyJobPriority = "job_priority" - KeyJobStatus = "job_status" - KeyStage = "stage" - KeyDurationMS = "duration_ms" - KeyScheduleID = "schedule_id" - KeySchedule = "schedule_name" - KeyRepo = "repository" - KeySection = "section" - KeyError = "error" - KeyPath = "path" - KeyFile = "file" - KeyWorker = "worker" - KeyMethod = "method" - KeyUserAgent = "user_agent" - KeyRemoteAddr = "remote_addr" - KeyRequestID = "request_id" - KeyStatus = "status" - KeyResponseSz = "response_size" - KeyForgeType = "forge_type" - KeyContentLen = "content_length" - KeyName = "name" - KeyURL = "url" + KeyJobID = "job_id" + KeyJobType = "job_type" + KeyStage = "stage" + KeyDurationMS = "duration_ms" + KeySchedule = "schedule_name" + KeyRepo = "repository" + KeySection = "section" + KeyError = "error" + KeyPath = "path" + KeyFile = "file" + KeyMethod = "method" + KeyUserAgent = "user_agent" + KeyRemoteAddr = "remote_addr" + KeyRequestID = "request_id" + KeyStatus = "status" + KeyName = "name" + KeyURL = "url" ) // JobID returns a slog.Attr for the job ID field. -// -// The following helpers return slog.Attr for common log fields, allowing composable structured logging. +func JobID(id string) slog.Attr { return slog.String(KeyJobID, id) } -func JobID(id string) slog.Attr { return slog.String(KeyJobID, id) } // JobID returns a slog.Attr for job ID. -func JobType(t string) slog.Attr { return slog.String(KeyJobType, t) } // JobType returns a slog.Attr for job type. -func JobPriority(p int) slog.Attr { return slog.Int(KeyJobPriority, p) } // JobPriority returns a slog.Attr for job priority. -func JobStatus(s string) slog.Attr { return slog.String(KeyJobStatus, s) } // JobStatus returns a slog.Attr for job status. -func Stage(name string) slog.Attr { return slog.String(KeyStage, name) } // Stage returns a slog.Attr for stage name. -func DurationMS(ms float64) slog.Attr { return slog.Float64(KeyDurationMS, ms) } // DurationMS returns a slog.Attr for duration in ms. -func ScheduleID(id string) slog.Attr { return slog.String(KeyScheduleID, id) } // ScheduleID returns a slog.Attr for schedule ID. -func ScheduleName(n string) slog.Attr { return slog.String(KeySchedule, n) } // ScheduleName returns a slog.Attr for schedule name. -func Repository(r string) slog.Attr { return slog.String(KeyRepo, r) } // Repository returns a slog.Attr for repository name. -func Section(s string) slog.Attr { return slog.String(KeySection, s) } // Section returns a slog.Attr for section name. +// Repository returns a slog.Attr for repository name. +func Repository(r string) slog.Attr { return slog.String(KeyRepo, r) } + +// Section returns a slog.Attr for section name. +func Section(s string) slog.Attr { return slog.String(KeySection, s) } // Path returns a slog.Attr for a file path. func Path(p string) slog.Attr { return slog.String(KeyPath, p) } @@ -53,9 +40,6 @@ func Path(p string) slog.Attr { return slog.String(KeyPath, p) } // File returns a slog.Attr for a file name. func File(f string) slog.Attr { return slog.String(KeyFile, f) } -// Worker returns a slog.Attr for a worker ID. -func Worker(id string) slog.Attr { return slog.String(KeyWorker, id) } - // Method returns a slog.Attr for an HTTP method. func Method(m string) slog.Attr { return slog.String(KeyMethod, m) } @@ -65,21 +49,9 @@ func UserAgent(ua string) slog.Attr { return slog.String(KeyUserAgent, ua) } // RemoteAddr returns a slog.Attr for a remote address. func RemoteAddr(a string) slog.Attr { return slog.String(KeyRemoteAddr, a) } -// RequestID returns a slog.Attr for a request ID. -func RequestID(id string) slog.Attr { return slog.String(KeyRequestID, id) } - // Status returns a slog.Attr for an HTTP status code. func Status(code int) slog.Attr { return slog.Int(KeyStatus, code) } -// ResponseSize returns a slog.Attr for a response size in bytes. -func ResponseSize(sz int) slog.Attr { return slog.Int(KeyResponseSz, sz) } - -// ForgeType returns a slog.Attr for a forge type. -func ForgeType(t string) slog.Attr { return slog.String(KeyForgeType, t) } - -// ContentLength returns a slog.Attr for content length in bytes. -func ContentLength(cl int64) slog.Attr { return slog.Int64(KeyContentLen, cl) } - // Name returns a slog.Attr for a generic name field. func Name(n string) slog.Attr { return slog.String(KeyName, n) } diff --git a/internal/logfields/logfields_test.go b/internal/logfields/logfields_test.go index 99e10d4a..488ce721 100644 --- a/internal/logfields/logfields_test.go +++ b/internal/logfields/logfields_test.go @@ -14,20 +14,14 @@ func TestHelperKeyNames(t *testing.T) { attr any }{ {"JobID", KeyJobID, "123", JobID("123")}, - {"JobType", KeyJobType, "build", JobType("build")}, - {"JobStatus", KeyJobStatus, "queued", JobStatus("queued")}, - {"ScheduleID", KeyScheduleID, "sch1", ScheduleID("sch1")}, - {"ScheduleName", KeySchedule, "nightly", ScheduleName("nightly")}, {"Repository", KeyRepo, "repo1", Repository("repo1")}, {"Section", KeySection, "sec", Section("sec")}, {"Path", KeyPath, "/tmp/x", Path("/tmp/x")}, {"File", KeyFile, "file.md", File("file.md")}, - {"Worker", KeyWorker, "w1", Worker("w1")}, {"Method", KeyMethod, "GET", Method("GET")}, {"UserAgent", KeyUserAgent, "ua", UserAgent("ua")}, {"RemoteAddr", KeyRemoteAddr, "1.2.3.4", RemoteAddr("1.2.3.4")}, - {"RequestID", KeyRequestID, "rid", RequestID("rid")}, - {"ForgeType", KeyForgeType, "github", ForgeType("github")}, + {"Status", KeyStatus, "200", Status(200)}, {"Name", KeyName, "n", Name("n")}, {"URL", KeyURL, "http://example", URL("http://example")}, } @@ -38,31 +32,12 @@ func TestHelperKeyNames(t *testing.T) { // Key drift would break log ingestion schemas. t.Fatalf("%s: expected key %s, got %s", tc.name, tc.attrKey, a.Key) } - if got := a.Value.String(); got != tc.attrVal { // Value is slog.Value + if got := a.Value.String(); got != tc.attrVal { t.Fatalf("%s: expected value %s, got %v", tc.name, tc.attrVal, got) } } } -// TestNumericHelpers verifies keys for numeric & float helpers. -func TestNumericHelpers(t *testing.T) { - if v := JobPriority(5); v.Key != KeyJobPriority { - t.Fatalf("JobPriority key mismatch: %s", v.Key) - } - if v := Status(200); v.Key != KeyStatus { - t.Fatalf("Status key mismatch: %s", v.Key) - } - if v := ResponseSize(42); v.Key != KeyResponseSz { - t.Fatalf("ResponseSize key mismatch: %s", v.Key) - } - if v := DurationMS(12.5); v.Key != KeyDurationMS { - t.Fatalf("DurationMS key mismatch: %s", v.Key) - } - if v := ContentLength(1234); v.Key != KeyContentLen { - t.Fatalf("ContentLength key mismatch: %s", v.Key) - } -} - // TestErrorHelper ensures Error() handles nil and non-nil errors predictably. func TestErrorHelper(t *testing.T) { attr := Error(nil) diff --git a/internal/markdown/edits.go b/internal/markdown/edits.go index 606c986b..eab35f80 100644 --- a/internal/markdown/edits.go +++ b/internal/markdown/edits.go @@ -2,8 +2,9 @@ package markdown import ( "errors" - "fmt" "sort" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // Edit represents a targeted byte-range replacement. @@ -39,13 +40,20 @@ func ApplyEdits(source []byte, edits []Edit) ([]byte, error) { for i, e := range sorted { if e.Start < 0 || e.End < 0 { - return nil, fmt.Errorf("invalid edit[%d]: negative range", i) + return nil, derrors.NewError(derrors.CategoryValidation, "invalid edit: negative range"). + WithContext("edit_index", i). + Build() } if e.End < e.Start { - return nil, fmt.Errorf("invalid edit[%d]: end before start", i) + return nil, derrors.NewError(derrors.CategoryValidation, "invalid edit: end before start"). + WithContext("edit_index", i). + Build() } if e.End > len(source) { - return nil, fmt.Errorf("invalid edit[%d]: range out of bounds", i) + return nil, derrors.NewError(derrors.CategoryValidation, "invalid edit: range out of bounds"). + WithContext("edit_index", i). + WithContext("source_length", len(source)). + Build() } if i > 0 { prev := sorted[i-1] diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go index 4a1f2e55..a1b92fa7 100644 --- a/internal/markdown/markdown.go +++ b/internal/markdown/markdown.go @@ -9,13 +9,6 @@ import ( "github.com/yuin/goldmark/text" ) -// ParseBody parses a Markdown body (frontmatter already removed) into a Goldmark AST. -func ParseBody(body []byte, _ Options) (gmast.Node, error) { - md := goldmark.New() - root := md.Parser().Parse(text.NewReader(body)) - return root, nil -} - // ExtractLinks parses a Markdown body and extracts link-like constructs. // // This is an analysis API; it does not attempt to re-render Markdown. diff --git a/internal/metrics/doc.go b/internal/metrics/doc.go deleted file mode 100644 index 69a73baf..00000000 --- a/internal/metrics/doc.go +++ /dev/null @@ -1,51 +0,0 @@ -// Package metrics provides an observability framework for DocBuilder build metrics. -// -// # Design Philosophy -// -// This package implements the Null Object pattern to enable metrics collection -// without requiring explicit nil checks throughout the codebase. By default, -// all components use NoopRecorder which implements the Recorder interface with -// no-op methods that inline to nothing at compile time. -// -// # Architecture -// -// The metrics system has three components: -// -// 1. Recorder interface - Defines all metrics operations -// 2. NoopRecorder - Default implementation that does nothing (zero overhead) -// 3. Real implementations - Prometheus/OpenTelemetry adapters (activated when needed) -// -// # Usage Pattern -// -// Components receive a Recorder through dependency injection: -// -// type BuildService struct { -// recorder metrics.Recorder -// } -// -// func NewBuildService() *BuildService { -// return &BuildService{ -// recorder: metrics.NoopRecorder{}, // Default: no metrics -// } -// } -// -// # Activation -// -// To enable metrics, swap NoopRecorder for a real implementation: -// -// // When Prometheus is configured -// recorder := metrics.NewPrometheusRecorder(registry) -// service := NewBuildService().WithRecorder(recorder) -// -// This approach allows: -// - Zero overhead when metrics are disabled (noop methods inline away) -// - Metrics activation without code changes (just swap implementation) -// - Clean testing (inject mock recorder for verification) -// - Gradual rollout (enable metrics per-component) -// -// # Current State -// -// All production code currently uses NoopRecorder. Real implementations exist -// (prometheus_http.go) but are not yet activated in the build pipeline. When -// metrics are needed, simply inject the appropriate recorder implementation. -package metrics diff --git a/internal/metrics/prometheus_http.go b/internal/metrics/prometheus_http.go deleted file mode 100644 index c79c7980..00000000 --- a/internal/metrics/prometheus_http.go +++ /dev/null @@ -1,18 +0,0 @@ -package metrics - -import ( - "net/http" - - prom "github.com/prometheus/client_golang/prometheus" - promhttp "github.com/prometheus/client_golang/prometheus/promhttp" -) - -// HTTPHandler returns an http.Handler that serves Prometheus metrics for the provided registry. -func HTTPHandler(reg *prom.Registry) http.Handler { - if reg == nil { - if typed, ok := prom.DefaultRegisterer.(*prom.Registry); ok { // check assertion to satisfy errcheck - reg = typed - } - } - return promhttp.HandlerFor(reg, promhttp.HandlerOpts{EnableOpenMetrics: true}) -} diff --git a/internal/metrics/recorder.go b/internal/metrics/recorder.go deleted file mode 100644 index 8ad0df51..00000000 --- a/internal/metrics/recorder.go +++ /dev/null @@ -1,61 +0,0 @@ -package metrics - -import "time" - -// BuildOutcomeLabel is used for build outcome metrics dimensions. -type BuildOutcomeLabel string - -const ( - BuildOutcomeSuccess BuildOutcomeLabel = "success" - BuildOutcomeWarning BuildOutcomeLabel = "warning" - BuildOutcomeFailed BuildOutcomeLabel = "failed" - BuildOutcomeCanceled BuildOutcomeLabel = "canceled" - BuildOutcomeSkipped BuildOutcomeLabel = "skipped" -) - -// ResultLabel enumerates stage result categories for counters. -type ResultLabel string - -const ( - ResultSuccess ResultLabel = "success" - ResultWarning ResultLabel = "warning" - ResultFatal ResultLabel = "fatal" - ResultCanceled ResultLabel = "canceled" -) - -// Recorder defines observability hooks for build and stage metrics. Implementations -// may forward to Prometheus, OpenTelemetry, etc. All methods must be safe for nil receivers -// when using the NoopRecorder (allowing optional injection). -type Recorder interface { - ObserveStageDuration(stage string, d time.Duration) - ObserveBuildDuration(d time.Duration) - IncStageResult(stage string, result ResultLabel) - IncBuildOutcome(outcome BuildOutcomeLabel) // outcome: success|warning|failed|canceled - ObserveCloneRepoDuration(repo string, d time.Duration, success bool) - IncCloneRepoResult(success bool) - SetCloneConcurrency(n int) - IncBuildRetry(stage string) - IncBuildRetryExhausted(stage string) - // New extensibility points (additive; safe for older noop implementations) - IncIssue(code string, stage string, severity string, transient bool) - SetEffectiveRenderMode(mode string) - IncContentTransformFailure(name string) - ObserveContentTransformDuration(name string, d time.Duration, success bool) -} - -// NoopRecorder is a Recorder that does nothing (default when metrics not configured). -type NoopRecorder struct{} - -func (NoopRecorder) ObserveStageDuration(string, time.Duration) {} -func (NoopRecorder) ObserveBuildDuration(time.Duration) {} -func (NoopRecorder) IncStageResult(string, ResultLabel) {} -func (NoopRecorder) IncBuildOutcome(BuildOutcomeLabel) {} -func (NoopRecorder) ObserveCloneRepoDuration(string, time.Duration, bool) {} -func (NoopRecorder) IncCloneRepoResult(bool) {} -func (NoopRecorder) SetCloneConcurrency(int) {} -func (NoopRecorder) IncBuildRetry(string) {} -func (NoopRecorder) IncBuildRetryExhausted(string) {} -func (NoopRecorder) IncIssue(string, string, string, bool) {} -func (NoopRecorder) SetEffectiveRenderMode(string) {} -func (NoopRecorder) IncContentTransformFailure(string) {} -func (NoopRecorder) ObserveContentTransformDuration(string, time.Duration, bool) {} diff --git a/internal/preview/local_preview.go b/internal/preview/local_preview.go index b0a53864..9cf4f1ef 100644 --- a/internal/preview/local_preview.go +++ b/internal/preview/local_preview.go @@ -16,9 +16,10 @@ import ( "github.com/fsnotify/fsnotify" "git.home.luguber.info/inful/docbuilder/internal/config" - "git.home.luguber.info/inful/docbuilder/internal/daemon" "git.home.luguber.info/inful/docbuilder/internal/docs" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/hugo" + "git.home.luguber.info/inful/docbuilder/internal/preview/runtime" "git.home.luguber.info/inful/docbuilder/internal/server/httpserver" ) @@ -58,9 +59,9 @@ func StartLocalPreview(ctx context.Context, cfg *config.Config, port int, tempOu } buildStat := &buildStatus{} - previewDaemon := initializePreviewDaemon(ctx, cfg, absDocs, buildStat) + previewRt := initializePreviewRuntime(ctx, cfg, absDocs, buildStat) - httpServer, err := startHTTPServer(ctx, cfg, previewDaemon, port, buildStat) + httpServer, err := startHTTPServer(ctx, cfg, previewRt, port, buildStat) if err != nil { return err } @@ -72,7 +73,7 @@ func StartLocalPreview(ctx context.Context, cfg *config.Config, port int, tempOu defer func() { _ = watcher.Close() }() rebuildReq, trigger := setupRebuildDebouncer() - startRebuildWorker(ctx, cfg, absDocs, previewDaemon, buildStat, rebuildReq) + startRebuildWorker(ctx, cfg, absDocs, previewRt, buildStat, rebuildReq) return runPreviewLoop(ctx, watcher, trigger, rebuildReq, httpServer, tempOutputDir) } @@ -88,16 +89,20 @@ func validateAndResolveDocsDir(cfg *config.Config) (string, error) { } absDocs, err := filepath.Abs(docsDir) if err != nil { - return "", fmt.Errorf("resolve docs dir: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "resolve docs dir").Build() } if st, statErr := os.Stat(absDocs); statErr != nil || !st.IsDir() { - return "", fmt.Errorf("docs dir not found or not a directory: %s", absDocs) + return "", derrors.NewError(derrors.CategoryNotFound, "docs dir not found or not a directory"). + WithContext("path", absDocs). + Build() } return absDocs, nil } -// initializePreviewDaemon performs initial build and creates daemon instance. -func initializePreviewDaemon(ctx context.Context, cfg *config.Config, absDocs string, buildStat *buildStatus) *daemon.Daemon { +// initializePreviewRuntime performs the initial build and returns the +// preview Runtime (an in-process replacement for the daemon's +// NewPreviewDaemon stub). +func initializePreviewRuntime(ctx context.Context, cfg *config.Config, absDocs string, buildStat *buildStatus) *runtime.Runtime { // Initial build if err := buildFromLocal(ctx, cfg, absDocs); err != nil { slog.Error("initial build failed", "error", err) @@ -107,17 +112,17 @@ func initializePreviewDaemon(ctx context.Context, cfg *config.Config, absDocs st buildStat.setSuccess() } - return daemon.NewPreviewDaemon(cfg) + return runtime.New() } // startHTTPServer initializes and starts the HTTP server. -func startHTTPServer(ctx context.Context, cfg *config.Config, previewDaemon *daemon.Daemon, port int, buildStat *buildStatus) (*httpserver.Server, error) { - httpServer := httpserver.New(cfg, previewDaemon, httpserver.Options{ - LiveReloadHub: previewDaemon.LiveReloadHub(), +func startHTTPServer(ctx context.Context, cfg *config.Config, previewRt *runtime.Runtime, port int, buildStat *buildStatus) (*httpserver.Server, error) { + httpServer := httpserver.New(cfg, previewRt, httpserver.Options{ + LiveReloadHub: previewRt.LiveReloadHub(), BuildStatus: buildStat, }) if err := httpServer.Start(ctx); err != nil { - return nil, fmt.Errorf("failed to start HTTP server: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryNetwork, "failed to start HTTP server").Build() } slog.Info("Preview server listening", "port", port, "docs_url", fmt.Sprintf("http://localhost:%d", port)) return httpServer, nil @@ -127,7 +132,7 @@ func startHTTPServer(ctx context.Context, cfg *config.Config, previewDaemon *dae func setupFileWatcher(absDocs string) (*fsnotify.Watcher, error) { watcher, err := fsnotify.NewWatcher() if err != nil { - return nil, fmt.Errorf("fsnotify: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "create fsnotify watcher").Build() } if err := addDirsRecursive(watcher, absDocs); err != nil { _ = watcher.Close() @@ -160,7 +165,7 @@ func setupRebuildDebouncer() (chan struct{}, func()) { } // startRebuildWorker starts background goroutine to process rebuild requests. -func startRebuildWorker(ctx context.Context, cfg *config.Config, absDocs string, previewDaemon *daemon.Daemon, buildStat *buildStatus, rebuildReq chan struct{}) { +func startRebuildWorker(ctx context.Context, cfg *config.Config, absDocs string, previewRt *runtime.Runtime, buildStat *buildStatus, rebuildReq chan struct{}) { var mu sync.Mutex running := false pending := false @@ -183,7 +188,7 @@ func startRebuildWorker(ctx context.Context, cfg *config.Config, absDocs string, running = true mu.Unlock() - processRebuild(ctx, cfg, absDocs, previewDaemon, buildStat) + processRebuild(ctx, cfg, absDocs, previewRt, buildStat) mu.Lock() running = false @@ -203,18 +208,18 @@ func startRebuildWorker(ctx context.Context, cfg *config.Config, absDocs string, } // processRebuild performs the actual rebuild and notifies browsers. -func processRebuild(ctx context.Context, cfg *config.Config, absDocs string, previewDaemon *daemon.Daemon, buildStat *buildStatus) { +func processRebuild(ctx context.Context, cfg *config.Config, absDocs string, previewRt *runtime.Runtime, buildStat *buildStatus) { slog.Info("Change detected; rebuilding site") if err := buildFromLocal(ctx, cfg, absDocs); err != nil { slog.Warn("rebuild failed", "error", err) buildStat.setError(err) - if lr := previewDaemon.LiveReloadHub(); lr != nil { + if lr := previewRt.LiveReloadHub(); lr != nil { lr.Broadcast(fmt.Sprintf("error:%d", time.Now().UnixNano())) } } else { writeVSCodeArtifactsIfEnabled(ctx, cfg, absDocs) buildStat.setSuccess() - if lr := previewDaemon.LiveReloadHub(); lr != nil { + if lr := previewRt.LiveReloadHub(); lr != nil { lr.Broadcast(strconv.FormatInt(time.Now().UnixNano(), 10)) } } @@ -246,7 +251,7 @@ func writeVSCodeArtifactsIfEnabled(ctx context.Context, cfg *config.Config, absD func ensureVSCodeMarkdownSnippetSettings(settingsPath string) error { if err := os.MkdirAll(filepath.Dir(settingsPath), 0o750); err != nil { - return fmt.Errorf("create vscode settings directory: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "create vscode settings directory").Build() } settings := map[string]any{} @@ -254,11 +259,11 @@ func ensureVSCodeMarkdownSnippetSettings(settingsPath string) error { if data, readErr := os.ReadFile(settingsPath); readErr == nil { if len(strings.TrimSpace(string(data))) > 0 { if err := json.Unmarshal(data, &settings); err != nil { - return fmt.Errorf("parse vscode settings: %w", err) + return derrors.WrapError(err, derrors.CategoryValidation, "parse vscode settings").Build() } } } else if !os.IsNotExist(readErr) { - return fmt.Errorf("read vscode settings: %w", readErr) + return derrors.WrapError(readErr, derrors.CategoryFileSystem, "read vscode settings").Build() } markdownSettings := map[string]any{} @@ -281,12 +286,12 @@ func ensureVSCodeMarkdownSnippetSettings(settingsPath string) error { serialized, err := json.MarshalIndent(settings, "", " ") if err != nil { - return fmt.Errorf("marshal vscode settings: %w", err) + return derrors.WrapError(err, derrors.CategoryInternal, "marshal vscode settings").Build() } serialized = append(serialized, '\n') if err := os.WriteFile(settingsPath, serialized, 0o600); err != nil { - return fmt.Errorf("write vscode settings: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "write vscode settings").Build() } return nil diff --git a/internal/preview/runtime/hub.go b/internal/preview/runtime/hub.go new file mode 100644 index 00000000..e72647f0 --- /dev/null +++ b/internal/preview/runtime/hub.go @@ -0,0 +1,49 @@ +package runtime + +import ( + "net/http" + "sync" +) + +// hub is a minimal in-memory LiveReloadHub implementation. It satisfies +// the queue.LiveReloadHub interface (http.Handler + Broadcast + Shutdown) +// without pulling in daemon.LiveReloadHub (which carries a metrics +// collector and is wired for the production daemon's telemetry path). +type hub struct { + mu sync.RWMutex + closed bool +} + +func newHub() *hub { return &hub{} } + +// ServeHTTP is a no-op SSE endpoint. Preview's local HTTP server +// streams the actual SSE events; this hub exists only so callers +// (and tests) can Broadcast without wiring up the full daemon. +func (h *hub) ServeHTTP(w http.ResponseWriter, _ *http.Request) { + h.mu.RLock() + closed := h.closed + h.mu.RUnlock() + if closed { + http.Error(w, "preview livereload shutting down", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + _, _ = w.Write([]byte(": ok\n\n")) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +// Broadcast is a no-op. The preview HTTP server reads SSE updates +// from a separate channel; the hub here exists only to satisfy the +// queue.LiveReloadHub interface so callers can be wired up. +func (h *hub) Broadcast(_ string) {} + +// Shutdown marks the hub as closed so further ServeHTTP returns 503. +func (h *hub) Shutdown() { + h.mu.Lock() + h.closed = true + h.mu.Unlock() +} diff --git a/internal/preview/runtime/runtime.go b/internal/preview/runtime/runtime.go new file mode 100644 index 00000000..457cd2d6 --- /dev/null +++ b/internal/preview/runtime/runtime.go @@ -0,0 +1,46 @@ +// Package runtime exposes the narrow Runtime surface that the preview CLI +// needs. The httpserver.New(...) entry point only requires Status for +// preview-mode wiring; trigger and metrics surfaces are nil since +// preview does not run admin/webhook/metrics routes. This file drops the +// 9 zero-value stubs that the previous httpserver.Runtime interface +// required. +package runtime + +import ( + "time" + + "git.home.luguber.info/inful/docbuilder/internal/build/queue" +) + +// Runtime is the preview-mode substitute for the daemon. It exposes +// only what the preview HTTP server needs: a Status triple plus a +// LiveReloadHub. +type Runtime struct { + liveReload queue.LiveReloadHub + startTime time.Time +} + +// New constructs a preview Runtime. The hub is a fresh in-memory +// LiveReloadHub with no metrics wiring (preview doesn't expose metrics). +func New() *Runtime { + return &Runtime{ + liveReload: newHub(), + startTime: time.Now(), + } +} + +// Status triple (required by httpserver.New). + +// GetStatus returns the preview runtime status. Preview only runs the docs +// site + livereload SSE; admin/webhook routes are not registered. +func (r *Runtime) GetStatus() string { return "preview" } + +// GetActiveJobs is zero in preview (builds are not run as jobs). +func (r *Runtime) GetActiveJobs() int { return 0 } + +// GetStartTime records when the preview process started; surfaced for /status. +func (r *Runtime) GetStartTime() time.Time { return r.startTime } + +// LiveReloadHub returns the hub for broadcasting rebuild notifications +// to connected SSE clients. +func (r *Runtime) LiveReloadHub() queue.LiveReloadHub { return r.liveReload } diff --git a/internal/retry/policy.go b/internal/retry/policy.go index 916ddd07..3f33741d 100644 --- a/internal/retry/policy.go +++ b/internal/retry/policy.go @@ -1,10 +1,12 @@ package retry import ( + "context" "errors" "time" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // Policy encapsulates retry/backoff settings for transient failures. @@ -90,3 +92,79 @@ func (p Policy) Validate() error { } return nil } + +// RetryHooks customizes per-error behavior of Policy.Do. +// +// All fields are optional. Nil callbacks fall back to conservative defaults: +// - IsRetryable: retry unless the error classifies as RetryNever. +// - AdjustDelay: use the policy's base backoff as-is. +// - OnRetry: no observability. +// +// OnRetry fires *before* the per-retry delay (so the recorded attempt number +// is the attempt that just failed; the next attempt will be attempt+1). +// The attempt value is 1-based: it counts how many calls of fn have been made. +type RetryHooks struct { + IsRetryable func(err error) bool + AdjustDelay func(err error, base time.Duration) time.Duration + OnRetry func(attempt int, err error) +} + +// Do executes fn with retry+backoff as configured by p. +// +// fn is invoked up to MaxRetries+1 times. The first failure short-circuits +// when IsRetryable (or the default classifier fallback) reports the error +// as non-retryable. Returns: +// +// - nil if fn succeeds at any attempt; +// - the last fn error if it is non-retryable or MaxRetries is exhausted; +// - ctx.Err() if ctx is canceled mid-backoff. +// +// Do honors ctx during the backoff sleep; cancellation returned to the caller. +// The MaxRetries<=0 short-circuit skips the loop entirely (single fn call). +func (p Policy) Do(ctx context.Context, fn func(context.Context) error, hooks RetryHooks) error { + if p.MaxRetries <= 0 { + return fn(ctx) + } + var lastErr error + for attempt := 0; attempt <= p.MaxRetries; attempt++ { + if err := ctx.Err(); err != nil { + return err + } + lastErr = fn(ctx) + if lastErr == nil { + return nil + } + if !shouldRetry(lastErr, hooks) { + return lastErr + } + if attempt == p.MaxRetries { + break + } + delay := p.Delay(attempt + 1) + if hooks.AdjustDelay != nil { + delay = hooks.AdjustDelay(lastErr, delay) + } + if hooks.OnRetry != nil { + hooks.OnRetry(attempt+1, lastErr) + } + select { + case <-time.After(delay): + case <-ctx.Done(): + return ctx.Err() + } + } + return lastErr +} + +// shouldRetry centralizes the default vs hook override for error retryability. +func shouldRetry(err error, hooks RetryHooks) bool { + if hooks.IsRetryable != nil { + return hooks.IsRetryable(err) + } + if ce, ok := derrors.AsClassified(err); ok { + return ce.RetryStrategy() != derrors.RetryNever + } + // Unclassified errors are retried; matches the prior + // git/build_queue behavior of "retry unless proven permanent". + return true +} diff --git a/internal/retry/policy_test.go b/internal/retry/policy_test.go index d8a34a04..b2ddf926 100644 --- a/internal/retry/policy_test.go +++ b/internal/retry/policy_test.go @@ -1,116 +1,222 @@ package retry import ( + "context" + "errors" "testing" "time" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) -// TestDefaultPolicy verifies the baseline default values. -func TestDefaultPolicy(t *testing.T) { - p := DefaultPolicy() - if p.Mode != config.RetryBackoffLinear { - t.Fatalf("expected linear default mode got %s", p.Mode) - } - if p.Initial != time.Second { - t.Fatalf("expected initial 1s got %v", p.Initial) - } - if p.Max != 30*time.Second { - t.Fatalf("expected max 30s got %v", p.Max) - } - if p.MaxRetries != 2 { - t.Fatalf("expected max retries 2 got %d", p.MaxRetries) +// TestDo_SuccessFirstTry ensures fn succeeding on first try returns nil without retry hooks firing. +func TestDo_SuccessFirstTry(t *testing.T) { + p := NewPolicy(config.RetryBackoffLinear, 1*time.Millisecond, 5*time.Millisecond, 3) + calls := 0 + hooks := RetryHooks{ + OnRetry: func(int, error) { t.Errorf("OnRetry should not run when fn succeeds") }, + } + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + return nil + }, hooks) + if err != nil { + t.Fatalf("expected nil, got %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 call, got %d", calls) } } -// TestNewPolicyOverrides checks override precedence and clamping when initial > max. -func TestNewPolicyOverrides(t *testing.T) { - p := NewPolicy(config.RetryBackoffFixed, 5*time.Second, 2*time.Second, 5) - // initial > max -> clamped - if p.Initial != 2*time.Second { - t.Fatalf("expected clamped initial 2s got %v", p.Initial) +// TestDo_RetriesUntilSuccess ensures fn failing twice then succeeding returns nil and +// OnRetry fires for each retry. +func TestDo_RetriesUntilSuccess(t *testing.T) { + p := NewPolicy(config.RetryBackoffFixed, 1*time.Millisecond, 5*time.Millisecond, 5) + calls := 0 + retryNotices := 0 + hooks := RetryHooks{ + IsRetryable: func(err error) bool { return true }, + OnRetry: func(attempt int, err error) { + retryNotices++ + if attempt < 1 { + t.Errorf("attempt should be 1-based") + } + }, + } + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + if calls < 3 { + return errors.New("transient") + } + return nil + }, hooks) + if err != nil { + t.Fatalf("expected nil, got %v", err) } - if p.Max != 2*time.Second { - t.Fatalf("expected max 2s got %v", p.Max) + if calls != 3 { + t.Fatalf("expected 3 calls, got %d", calls) } - if p.Mode != config.RetryBackoffFixed { - t.Fatalf("expected fixed mode got %s", p.Mode) + if retryNotices != 2 { + t.Fatalf("expected 2 OnRetry invocations (after attempt 1 and 2), got %d", retryNotices) } - if p.MaxRetries != 5 { - t.Fatalf("expected maxRetries 5 got %d", p.MaxRetries) +} + +// TestDo_NonRetryableHook ensures IsRetryable=false returns the error without further retries. +func TestDo_NonRetryableHook(t *testing.T) { + p := NewPolicy(config.RetryBackoffLinear, 1*time.Millisecond, 5*time.Millisecond, 5) + calls := 0 + hooks := RetryHooks{ + IsRetryable: func(err error) bool { return false }, + } + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + return errors.New("permanent") + }, hooks) + if err == nil || err.Error() != "permanent" { + t.Fatalf("expected permanent error, got %v", err) + } + if calls != 1 { + t.Fatalf("expected 1 call, got %d", calls) } } -// TestDelayModes ensures fixed, linear, exponential behave and respect cap. -func TestDelayModes(t *testing.T) { - fixed := NewPolicy(config.RetryBackoffFixed, 100*time.Millisecond, 500*time.Millisecond, 3) - for i := 1; i <= 3; i++ { - if d := fixed.Delay(i); d != 100*time.Millisecond { - t.Fatalf("fixed attempt %d expected 100ms got %v", i, d) - } +// TestDo_ExhaustsMaxRetries ensures fn failing MaxRetries+1 times returns the last error. +func TestDo_ExhaustsMaxRetries(t *testing.T) { + p := NewPolicy(config.RetryBackoffFixed, 1*time.Millisecond, 5*time.Millisecond, 2) + calls := 0 + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + return errors.New("always fails") + }, RetryHooks{IsRetryable: func(error) bool { return true }}) + if err == nil || err.Error() != "always fails" { + t.Fatalf("expected 'always fails', got %v", err) + } + if calls != 3 { + t.Fatalf("expected 3 calls (1 + MaxRetries=2 retries), got %d", calls) } +} + +// TestDo_DefaultRetryableUsesClassifier ensures that without an IsRetryable hook, +// a classified error with RetryNever stops the loop while RetryBackoff continues. +func TestDo_DefaultRetryableUsesClassifier(t *testing.T) { + permanent := derrors.NewError(derrors.CategoryNotFound, "nope").Build() + transient := derrors.NewError(derrors.CategoryNetwork, "blip").Retryable().Build() - linear := NewPolicy(config.RetryBackoffLinear, 100*time.Millisecond, 250*time.Millisecond, 5) - // attempts: 1->100ms,2->200ms,3->cap 250ms,4->cap 250ms - cases := []struct { - attempt int - want time.Duration - }{{1, 100 * time.Millisecond}, {2, 200 * time.Millisecond}, {3, 250 * time.Millisecond}, {4, 250 * time.Millisecond}} - for _, c := range cases { - if got := linear.Delay(c.attempt); got != c.want { - t.Fatalf("linear attempt %d expected %v got %v", c.attempt, c.want, got) + t.Run("RetryNever stops", func(t *testing.T) { + p := NewPolicy(config.RetryBackoffFixed, 1*time.Millisecond, 5*time.Millisecond, 5) + calls := 0 + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + return permanent + }, RetryHooks{}) + if err == nil { + t.Fatalf("expected error to surface, got nil") } - } + if calls != 1 { + t.Fatalf("permanent classified must short-circuit, got %d calls", calls) + } + }) - exp := NewPolicy(config.RetryBackoffExponential, 50*time.Millisecond, 160*time.Millisecond, 5) - // 1->50,2->100,3->160 (cap),4->160 - expCases := []struct { - attempt int - want time.Duration - }{{1, 50 * time.Millisecond}, {2, 100 * time.Millisecond}, {3, 160 * time.Millisecond}, {4, 160 * time.Millisecond}} - for _, c := range expCases { - if got := exp.Delay(c.attempt); got != c.want { - t.Fatalf("exp attempt %d expected %v got %v", c.attempt, c.want, got) + t.Run("RetryBackoff continues", func(t *testing.T) { + p := NewPolicy(config.RetryBackoffFixed, 1*time.Millisecond, 5*time.Millisecond, 2) + calls := 0 + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + if calls < 3 { + return transient + } + return nil + }, RetryHooks{}) + if err != nil { + t.Fatalf("expected nil on eventual success, got %v", err) } - } + if calls != 3 { + t.Fatalf("expected 3 calls, got %d", calls) + } + }) } -// TestDelayEdgeCases ensures non-positive attempts yield zero and negative attempts don't panic. -func TestDelayEdgeCases(t *testing.T) { - p := NewPolicy(config.RetryBackoffLinear, 10*time.Millisecond, 20*time.Millisecond, 1) - if d := p.Delay(0); d != 0 { - t.Fatalf("attempt 0 expected 0 got %v", d) - } - if d := p.Delay(-1); d != 0 { - t.Fatalf("attempt -1 expected 0 got %v", d) +// TestDo_AdjustDelay ensures AdjustDelay can override the base delay before sleeping. +func TestDo_AdjustDelay(t *testing.T) { + p := NewPolicy(config.RetryBackoffFixed, 50*time.Millisecond, 100*time.Millisecond, 1) + adjusted := false + hooks := RetryHooks{ + IsRetryable: func(error) bool { return true }, + AdjustDelay: func(_ error, base time.Duration) time.Duration { + adjusted = true + return time.Millisecond // force the sleep to be tiny for the test + }, + } + start := time.Now() + err := p.Do(context.Background(), func(_ context.Context) error { return errors.New("x") }, hooks) + elapsed := time.Since(start) + if err == nil { + t.Fatalf("expected error") + } + if !adjusted { + t.Fatalf("AdjustDelay was not invoked") + } + if elapsed > 30*time.Millisecond { + t.Fatalf("AdjustDelay didn't compress the sleep, elapsed=%v", elapsed) } } -// TestValidate covers validation error paths. -func TestValidate(t *testing.T) { - badInitial := Policy{Mode: config.RetryBackoffLinear, Initial: 0, Max: time.Second, MaxRetries: 1} - if err := badInitial.Validate(); err == nil { - t.Fatalf("expected error for zero initial") +// TestDo_RespectsContextCancellation ensures ctx cancellation mid-sleep returns ctx.Err. +func TestDo_RespectsContextCancellation(t *testing.T) { + p := NewPolicy(config.RetryBackoffFixed, 50*time.Millisecond, time.Second, 5) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(20 * time.Millisecond) + cancel() + }() + start := time.Now() + err := p.Do(ctx, func(_ context.Context) error { return errors.New("x") }, RetryHooks{ + IsRetryable: func(error) bool { return true }, + }) + elapsed := time.Since(start) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + if elapsed > 200*time.Millisecond { + t.Fatalf("should have returned quickly after cancellation, elapsed=%v", elapsed) } - badMax := Policy{Mode: config.RetryBackoffLinear, Initial: time.Second, Max: 0, MaxRetries: 1} - if err := badMax.Validate(); err == nil { - t.Fatalf("expected error for zero max") - } - badRetries := Policy{Mode: config.RetryBackoffLinear, Initial: time.Second, Max: 2 * time.Second, MaxRetries: -1} - if err := badRetries.Validate(); err == nil { - t.Fatalf("expected error for negative retries") - } - good := Policy{Mode: config.RetryBackoffLinear, Initial: time.Second, Max: 2 * time.Second, MaxRetries: 0} - if err := good.Validate(); err != nil { - t.Fatalf("unexpected validation error: %v", err) +} + +// TestDo_ZeroMaxRetries ensures MaxRetries<=0 short-circuits to a single fn call. +func TestDo_ZeroMaxRetries(t *testing.T) { + hooks := RetryHooks{} + for _, max := range []int{-1, 0} { + p := Policy{Mode: config.RetryBackoffFixed, Initial: time.Second, Max: 10 * time.Second, MaxRetries: max} + calls := 0 + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + return errors.New("once") + }, hooks) + if err == nil { + t.Fatalf("expected error for MaxRetries=%d", max) + } + if calls != 1 { + t.Fatalf("expected 1 call for MaxRetries=%d, got %d", max, calls) + } } } -// TestUnknownModeFallsBack leaves mode default when unknown string supplied. -func TestUnknownModeFallsBack(t *testing.T) { - p := NewPolicy("weird", 250*time.Millisecond, 500*time.Millisecond, 1) - if p.Mode != config.RetryBackoffLinear { - t.Fatalf("unknown mode should fall back to linear got %s", p.Mode) +// TestDo_NilHooksAllowed ensures callers can pass an empty RetryHooks struct. +func TestDo_NilHooksAllowed(t *testing.T) { + p := NewPolicy(config.RetryBackoffFixed, 1*time.Millisecond, 5*time.Millisecond, 2) + calls := 0 + err := p.Do(context.Background(), func(_ context.Context) error { + calls++ + if calls < 2 { + return errors.New("transient") + } + return nil + }, RetryHooks{}) + if err != nil { + t.Fatalf("expected nil, got %v", err) + } + if calls != 2 { + t.Fatalf("expected 2 calls, got %d", calls) } } diff --git a/internal/server/httpserver/adapters.go b/internal/server/httpserver/adapters.go new file mode 100644 index 00000000..f50e0ed6 --- /dev/null +++ b/internal/server/httpserver/adapters.go @@ -0,0 +1,101 @@ +package httpserver + +import "time" + +// monitoringAdapter proxies Status + optional MetricsSource to the narrow +// handlers.DaemonInterface (8 methods). When metrics is nil (preview +// mode), the *Metrics family returns zero values. +type monitoringAdapter struct { + status Status + metrics MetricsSource +} + +func (a *monitoringAdapter) GetStatus() string { return a.status.GetStatus() } +func (a *monitoringAdapter) GetStartTime() time.Time { return a.status.GetStartTime() } +func (a *monitoringAdapter) GetActiveJobs() int { return a.status.GetActiveJobs() } +func (a *monitoringAdapter) HTTPRequestsTotal() int { + if a.metrics == nil { + return 0 + } + return a.metrics.HTTPRequestsTotal() +} + +func (a *monitoringAdapter) RepositoriesTotal() int { + if a.metrics == nil { + return 0 + } + return a.metrics.RepositoriesTotal() +} + +func (a *monitoringAdapter) LastDiscoveryDurationSec() int { + if a.metrics == nil { + return 0 + } + return a.metrics.LastDiscoveryDurationSec() +} + +func (a *monitoringAdapter) LastBuildDurationSec() int { + if a.metrics == nil { + return 0 + } + return a.metrics.LastBuildDurationSec() +} + +// apiAdapter proxies Status to the narrow handlers.DaemonAPIInterface. +type apiAdapter struct { + status Status +} + +func (a *apiAdapter) GetStatus() string { return a.status.GetStatus() } +func (a *apiAdapter) GetStartTime() time.Time { return a.status.GetStartTime() } + +// buildAdapter proxies Status + optional Triggers to the narrow +// handlers.DaemonBuildInterface (4 methods). When triggers is nil +// (preview mode), the trigger methods return "" / 0. +type buildAdapter struct { + status Status + triggers Triggers +} + +func (a *buildAdapter) TriggerDiscovery() string { + if a.triggers == nil { + return "" + } + return a.triggers.TriggerDiscovery() +} + +func (a *buildAdapter) TriggerBuild() string { + if a.triggers == nil { + return "" + } + return a.triggers.TriggerBuild() +} + +func (a *buildAdapter) TriggerWebhookBuild(forgeName, repoFullName, branch string, changedFiles []string) string { + if a.triggers == nil { + return "" + } + return a.triggers.TriggerWebhookBuild(forgeName, repoFullName, branch, changedFiles) +} + +func (a *buildAdapter) GetQueueLength() int { + if a.triggers == nil { + return 0 + } + return a.triggers.GetQueueLength() +} + +func (a *buildAdapter) GetActiveJobs() int { return a.status.GetActiveJobs() } + +// webhookAdapter proxies the optional Triggers surface to the narrow +// handlers.WebhookTrigger. nil-safe. +type webhookAdapter struct { + triggers Triggers +} + +func (a *webhookAdapter) TriggerWebhookBuild(forgeName, repoFullName, branch string, changedFiles []string) string { + if a.triggers == nil { + return "" + } + return a.triggers.TriggerWebhookBuild(forgeName, repoFullName, branch, changedFiles) +} diff --git a/internal/server/httpserver/http_server.go b/internal/server/httpserver/http_server.go index 366d7a8d..4f599d31 100644 --- a/internal/server/httpserver/http_server.go +++ b/internal/server/httpserver/http_server.go @@ -46,7 +46,11 @@ type Server struct { } // New constructs a new HTTP server wiring instance. -func New(cfg *config.Config, runtime Runtime, opts Options) *Server { +// +// status is the always-required Status surface. opts.Triggers and +// opts.Metrics are optional; pass nil to disable trigger/metrics +// routes (preview-mode wiring). +func New(cfg *config.Config, status Status, opts Options) *Server { if opts.ForgeClients == nil { opts.ForgeClients = map[string]forge.Client{} } @@ -62,13 +66,19 @@ func New(cfg *config.Config, runtime Runtime, opts Options) *Server { vscodeFindIPCSocket: findVSCodeIPCSocket, } - adapter := &runtimeAdapter{runtime: runtime} + // Compose one small adapter per handler group. Each adapter forwards + // to the optional surface if present, and returns zero values when + // the surface is nil (preview-mode wiring). + mon := &monitoringAdapter{status: status, metrics: opts.Metrics} + api := &apiAdapter{status: status} + bld := &buildAdapter{status: status, triggers: opts.Triggers} + wh := &webhookAdapter{triggers: opts.Triggers} // Initialize handler modules - s.monitoringHandlers = handlers.NewMonitoringHandlers(adapter) - s.apiHandlers = handlers.NewAPIHandlers(cfg, adapter) - s.buildHandlers = handlers.NewBuildHandlers(adapter) - s.webhookHandlers = handlers.NewWebhookHandlers(adapter, opts.ForgeClients, opts.WebhookConfigs) + s.monitoringHandlers = handlers.NewMonitoringHandlers(mon) + s.apiHandlers = handlers.NewAPIHandlers(cfg, api) + s.buildHandlers = handlers.NewBuildHandlers(bld) + s.webhookHandlers = handlers.NewWebhookHandlers(wh, opts.ForgeClients, opts.WebhookConfigs) // Initialize middleware chain s.mchain = smw.Chain(slog.Default(), s.errorAdapter) @@ -76,24 +86,6 @@ func New(cfg *config.Config, runtime Runtime, opts Options) *Server { return s } -type runtimeAdapter struct { - runtime Runtime -} - -func (a *runtimeAdapter) GetStatus() string { return a.runtime.GetStatus() } -func (a *runtimeAdapter) GetActiveJobs() int { return a.runtime.GetActiveJobs() } -func (a *runtimeAdapter) GetStartTime() time.Time { return a.runtime.GetStartTime() } -func (a *runtimeAdapter) HTTPRequestsTotal() int { return a.runtime.HTTPRequestsTotal() } -func (a *runtimeAdapter) RepositoriesTotal() int { return a.runtime.RepositoriesTotal() } -func (a *runtimeAdapter) LastDiscoveryDurationSec() int { return a.runtime.LastDiscoveryDurationSec() } -func (a *runtimeAdapter) LastBuildDurationSec() int { return a.runtime.LastBuildDurationSec() } -func (a *runtimeAdapter) TriggerDiscovery() string { return a.runtime.TriggerDiscovery() } -func (a *runtimeAdapter) TriggerBuild() string { return a.runtime.TriggerBuild() } -func (a *runtimeAdapter) TriggerWebhookBuild(forgeName, repoFullName, branch string, changedFiles []string) string { - return a.runtime.TriggerWebhookBuild(forgeName, repoFullName, branch, changedFiles) -} -func (a *runtimeAdapter) GetQueueLength() int { return a.runtime.GetQueueLength() } - // Start initializes and starts all HTTP servers. func (s *Server) Start(ctx context.Context) error { if s.cfg.Daemon == nil { @@ -122,7 +114,10 @@ func (s *Server) Start(ctx context.Context) error { addr := fmt.Sprintf(":%d", binds[i].port) ln, err := lc.Listen(ctx, "tcp", addr) if err != nil { - bindErrs = append(bindErrs, fmt.Errorf("%s port %d: %w", binds[i].name, binds[i].port, err)) + bindErrs = append(bindErrs, derrors.WrapError(err, derrors.CategoryNetwork, "pre-bind failed"). + WithContext("server", binds[i].name). + WithContext("port", binds[i].port). + Build()) continue } binds[i].ln = ln @@ -134,24 +129,24 @@ func (s *Server) Start(ctx context.Context) error { _ = b.ln.Close() } } - return fmt.Errorf("http startup failed: %w", errors.Join(bindErrs...)) + return derrors.WrapError(errors.Join(bindErrs...), derrors.CategoryNetwork, "http startup failed").Build() } // All ports bound successfully – now start servers handing them their pre-bound listeners. if err := s.startDocsServerWithListener(ctx, binds[0].ln); err != nil { - return fmt.Errorf("failed to start docs server: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to start docs server").Build() } if err := s.startWebhookServerWithListener(ctx, binds[1].ln); err != nil { - return fmt.Errorf("failed to start webhook server: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to start webhook server").Build() } if err := s.startAdminServerWithListener(ctx, binds[2].ln); err != nil { - return fmt.Errorf("failed to start admin server: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to start admin server").Build() } // Start LiveReload server if enabled if s.cfg.Build.LiveReload && s.opts.LiveReloadHub != nil && len(binds) > 3 { if err := s.startLiveReloadServerWithListener(ctx, binds[3].ln); err != nil { - return fmt.Errorf("failed to start livereload server: %w", err) + return derrors.WrapError(err, derrors.CategoryNetwork, "failed to start livereload server").Build() } slog.Info("HTTP servers started", slog.Int("docs_port", s.cfg.Daemon.HTTP.DocsPort), @@ -174,30 +169,30 @@ func (s *Server) Stop(ctx context.Context) error { // Stop servers in reverse order if s.liveReloadServer != nil { if err := s.liveReloadServer.Shutdown(ctx); err != nil { - errs = append(errs, fmt.Errorf("livereload server shutdown: %w", err)) + errs = append(errs, derrors.WrapError(err, derrors.CategoryNetwork, "livereload server shutdown").Build()) } } if s.adminServer != nil { if err := s.adminServer.Shutdown(ctx); err != nil { - errs = append(errs, fmt.Errorf("admin server shutdown: %w", err)) + errs = append(errs, derrors.WrapError(err, derrors.CategoryNetwork, "admin server shutdown").Build()) } } if s.webhookServer != nil { if err := s.webhookServer.Shutdown(ctx); err != nil { - errs = append(errs, fmt.Errorf("webhook server shutdown: %w", err)) + errs = append(errs, derrors.WrapError(err, derrors.CategoryNetwork, "webhook server shutdown").Build()) } } if s.docsServer != nil { if err := s.docsServer.Shutdown(ctx); err != nil { - errs = append(errs, fmt.Errorf("docs server shutdown: %w", err)) + errs = append(errs, derrors.WrapError(err, derrors.CategoryNetwork, "docs server shutdown").Build()) } } if len(errs) > 0 { - return fmt.Errorf("shutdown errors: %v", errs) + return derrors.WrapError(errors.Join(errs...), derrors.CategoryNetwork, "shutdown errors").Build() } slog.Info("HTTP servers stopped") diff --git a/internal/server/httpserver/http_server_docs_handler_test.go b/internal/server/httpserver/http_server_docs_handler_test.go index d88a0732..a7ce5d5d 100644 --- a/internal/server/httpserver/http_server_docs_handler_test.go +++ b/internal/server/httpserver/http_server_docs_handler_test.go @@ -16,17 +16,9 @@ import ( type testRuntime struct{} -func (testRuntime) GetStatus() string { return "" } -func (testRuntime) GetActiveJobs() int { return 0 } -func (testRuntime) GetStartTime() time.Time { return time.Time{} } -func (testRuntime) HTTPRequestsTotal() int { return 0 } -func (testRuntime) RepositoriesTotal() int { return 0 } -func (testRuntime) LastDiscoveryDurationSec() int { return 0 } -func (testRuntime) LastBuildDurationSec() int { return 0 } -func (testRuntime) TriggerDiscovery() string { return "" } -func (testRuntime) TriggerBuild() string { return "" } -func (testRuntime) TriggerWebhookBuild(_, _, _ string, _ []string) string { return "" } -func (testRuntime) GetQueueLength() int { return 0 } +func (testRuntime) GetStatus() string { return "" } +func (testRuntime) GetActiveJobs() int { return 0 } +func (testRuntime) GetStartTime() time.Time { return time.Time{} } type testBuildStatus struct { hasError bool diff --git a/internal/server/httpserver/http_server_webhook.go b/internal/server/httpserver/http_server_webhook.go index 6e11d758..2ef540d8 100644 --- a/internal/server/httpserver/http_server_webhook.go +++ b/internal/server/httpserver/http_server_webhook.go @@ -2,11 +2,12 @@ package httpserver import ( "context" - "fmt" "net" "net/http" "strings" "time" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) func normalizeWebhookPath(p string) string { @@ -37,7 +38,11 @@ func (s *Server) webhookMux() (*http.ServeMux, error) { } if prev, ok := seen[path]; ok { - return nil, fmt.Errorf("duplicate webhook path %q for forges %q and %q", path, prev, forgeCfg.Name) + return nil, derrors.NewError(derrors.CategoryConfig, "duplicate webhook path"). + WithContext("path", path). + WithContext("forge", forgeCfg.Name). + WithContext("previous_forge", prev). + Build() } seen[path] = forgeCfg.Name diff --git a/internal/server/httpserver/http_server_webhook_test.go b/internal/server/httpserver/http_server_webhook_test.go index 8df66deb..d3cd433e 100644 --- a/internal/server/httpserver/http_server_webhook_test.go +++ b/internal/server/httpserver/http_server_webhook_test.go @@ -20,16 +20,13 @@ type webhookRuntimeStub struct { branch string } -func (r *webhookRuntimeStub) GetStatus() string { return "running" } -func (r *webhookRuntimeStub) GetActiveJobs() int { return 0 } -func (r *webhookRuntimeStub) GetStartTime() time.Time { return time.Unix(0, 0) } -func (r *webhookRuntimeStub) HTTPRequestsTotal() int { return 0 } -func (r *webhookRuntimeStub) RepositoriesTotal() int { return 0 } -func (r *webhookRuntimeStub) LastDiscoveryDurationSec() int { return 0 } -func (r *webhookRuntimeStub) LastBuildDurationSec() int { return 0 } -func (r *webhookRuntimeStub) TriggerDiscovery() string { return "" } -func (r *webhookRuntimeStub) TriggerBuild() string { return "" } -func (r *webhookRuntimeStub) GetQueueLength() int { return 0 } +func (r *webhookRuntimeStub) GetStatus() string { return "running" } +func (r *webhookRuntimeStub) GetStartTime() time.Time { return time.Unix(0, 0) } +func (r *webhookRuntimeStub) GetActiveJobs() int { return 0 } + +func (r *webhookRuntimeStub) TriggerDiscovery() string { return "" } +func (r *webhookRuntimeStub) TriggerBuild() string { return "" } +func (r *webhookRuntimeStub) GetQueueLength() int { return 0 } func (r *webhookRuntimeStub) TriggerWebhookBuild(forgeName, repoFullName, branch string, changedFiles []string) string { r.called = true @@ -67,6 +64,7 @@ func TestWebhookMux_ConfiguredForgePath_TriggersBuild(t *testing.T) { runtime := &webhookRuntimeStub{} srv := New(cfg, runtime, Options{ + Triggers: runtime, ForgeClients: map[string]forge.Client{ forgeName: client, }, diff --git a/internal/server/httpserver/httpserver_tdd_test.go b/internal/server/httpserver/httpserver_tdd_test.go index 05f2529e..e6680961 100644 --- a/internal/server/httpserver/httpserver_tdd_test.go +++ b/internal/server/httpserver/httpserver_tdd_test.go @@ -9,17 +9,9 @@ import ( type stubRuntime struct{} -func (stubRuntime) GetStatus() string { return "running" } -func (stubRuntime) GetActiveJobs() int { return 0 } -func (stubRuntime) GetStartTime() time.Time { return time.Time{} } -func (stubRuntime) HTTPRequestsTotal() int { return 0 } -func (stubRuntime) RepositoriesTotal() int { return 0 } -func (stubRuntime) LastDiscoveryDurationSec() int { return 0 } -func (stubRuntime) LastBuildDurationSec() int { return 0 } -func (stubRuntime) TriggerDiscovery() string { return "" } -func (stubRuntime) TriggerBuild() string { return "" } -func (stubRuntime) TriggerWebhookBuild(string, string, string, []string) string { return "" } -func (stubRuntime) GetQueueLength() int { return 0 } +func (stubRuntime) GetStatus() string { return "running" } +func (stubRuntime) GetActiveJobs() int { return 0 } +func (stubRuntime) GetStartTime() time.Time { return time.Time{} } func TestNewServer_TDDCompile(t *testing.T) { _ = New(&config.Config{}, stubRuntime{}, Options{}) diff --git a/internal/server/httpserver/split_test.go b/internal/server/httpserver/split_test.go new file mode 100644 index 00000000..59756887 --- /dev/null +++ b/internal/server/httpserver/split_test.go @@ -0,0 +1,135 @@ +package httpserver + +import ( + "testing" + "time" +) + +// stubStatus / stubMetrics / stubTriggers are test implementations of the +// split Runtime surfaces. They are intentionally tiny so the test for the +// handler adapters below stays focused on the wiring, not the providers. +type stubStatus struct { + status string + start time.Time + active int +} + +func (s stubStatus) GetStatus() string { return s.status } +func (s stubStatus) GetStartTime() time.Time { return s.start } +func (s stubStatus) GetActiveJobs() int { return s.active } + +type stubMetrics struct { + req, repos, disc, buildSec int +} + +func (m stubMetrics) HTTPRequestsTotal() int { return m.req } +func (m stubMetrics) RepositoriesTotal() int { return m.repos } +func (m stubMetrics) LastDiscoveryDurationSec() int { return m.disc } +func (m stubMetrics) LastBuildDurationSec() int { return m.buildSec } + +type stubTriggers struct { + queueLen int + triggerDisc string + triggerBuild string + triggerWebhook string +} + +func (t stubTriggers) TriggerDiscovery() string { return t.triggerDisc } +func (t stubTriggers) TriggerBuild() string { return t.triggerBuild } +func (t stubTriggers) TriggerWebhookBuild(_, _, _ string, _ []string) string { + return t.triggerWebhook +} +func (t stubTriggers) GetQueueLength() int { return t.queueLen } + +// TestMonitoringAdapter_ForwardsMetrics ensures the monitoring adapter +// proxies a non-nil MetricsSource. +func TestMonitoringAdapter_ForwardsMetrics(t *testing.T) { + st := stubStatus{status: "running", start: time.Unix(1, 0), active: 7} + met := stubMetrics{req: 11, repos: 3, disc: 4, buildSec: 5} + a := &monitoringAdapter{status: st, metrics: met} + + if got := a.GetStatus(); got != "running" { + t.Errorf("GetStatus=%q want running", got) + } + if got := a.GetStartTime(); !got.Equal(time.Unix(1, 0)) { + t.Errorf("GetStartTime=%v want %v", got, time.Unix(1, 0)) + } + if got := a.GetActiveJobs(); got != 7 { + t.Errorf("GetActiveJobs=%d want 7", got) + } + if got := a.HTTPRequestsTotal(); got != 11 { + t.Errorf("HTTPRequestsTotal=%d want 11", got) + } + if got := a.RepositoriesTotal(); got != 3 { + t.Errorf("RepositoriesTotal=%d want 3", got) + } + if got := a.LastDiscoveryDurationSec(); got != 4 { + t.Errorf("LastDiscoveryDurationSec=%d want 4", got) + } + if got := a.LastBuildDurationSec(); got != 5 { + t.Errorf("LastBuildDurationSec=%d want 5", got) + } +} + +// TestMonitoringAdapter_PreviewWithoutMetrics ensures the adapter returns +// zeros for metrics when no MetricsSource is supplied (preview-mode wiring). +func TestMonitoringAdapter_PreviewWithoutMetrics(t *testing.T) { + st := stubStatus{status: "preview", start: time.Time{}, active: 0} + a := &monitoringAdapter{status: st, metrics: nil} + + if got := a.GetStatus(); got != "preview" { + t.Errorf("GetStatus=%q want preview", got) + } + if got := a.HTTPRequestsTotal(); got != 0 { + t.Errorf("HTTPRequestsTotal=%d want 0 (no metrics in preview)", got) + } + if got := a.LastBuildDurationSec(); got != 0 { + t.Errorf("LastBuildDurationSec=%d want 0", got) + } +} + +// TestBuildAndWebhookAdapter_ForwardTriggers ensures both adapters share a +// single Triggers source and that build picks up GetActiveJobs from Status. +func TestBuildAndWebhookAdapter_ForwardTriggers(t *testing.T) { + st := stubStatus{status: "running", start: time.Unix(2, 0), active: 9} + tr := stubTriggers{queueLen: 4, triggerDisc: "d", triggerBuild: "b", triggerWebhook: "w"} + b := &buildAdapter{status: st, triggers: tr} + w := &webhookAdapter{triggers: tr} + + if got := b.TriggerDiscovery(); got != "d" { + t.Errorf("TriggerDiscovery=%q want d", got) + } + if got := b.TriggerBuild(); got != "b" { + t.Errorf("TriggerBuild=%q want b", got) + } + if got := b.GetQueueLength(); got != 4 { + t.Errorf("GetQueueLength=%d want 4", got) + } + if got := b.GetActiveJobs(); got != 9 { + t.Errorf("GetActiveJobs=%d want 9 (from Status)", got) + } + if got := w.TriggerWebhookBuild("forge", "repo", "main", nil); got != "w" { + t.Errorf("TriggerWebhookBuild=%q want w", got) + } +} + +// TestBuildAndWebhookAdapter_PreviewWithoutTriggers ensures the adapters +// return zeros / "" when no Triggers source is provided. +func TestBuildAndWebhookAdapter_PreviewWithoutTriggers(t *testing.T) { + st := stubStatus{status: "preview"} + b := &buildAdapter{status: st, triggers: nil} + w := &webhookAdapter{triggers: nil} + + if got := b.TriggerDiscovery(); got != "" { + t.Errorf("TriggerDiscovery=%q want empty", got) + } + if got := b.GetQueueLength(); got != 0 { + t.Errorf("GetQueueLength=%d want 0", got) + } + if got := b.GetActiveJobs(); got != 0 { + t.Errorf("GetActiveJobs=%d want 0 (preview)", got) + } + if got := w.TriggerWebhookBuild("", "", "", nil); got != "" { + t.Errorf("TriggerWebhookBuild=%q want empty", got) + } +} diff --git a/internal/server/httpserver/types.go b/internal/server/httpserver/types.go index ec22204a..f3b145cb 100644 --- a/internal/server/httpserver/types.go +++ b/internal/server/httpserver/types.go @@ -4,53 +4,53 @@ import ( "net/http" "time" + "git.home.luguber.info/inful/docbuilder/internal/build/queue" "git.home.luguber.info/inful/docbuilder/internal/config" "git.home.luguber.info/inful/docbuilder/internal/forge" ) -// Runtime is the minimal interface required by shared HTTP handlers. -// It intentionally matches the interfaces in internal/server/handlers. -type Runtime interface { +// Status is the minimal Runtime surface shared by every server mode. +// Docs site needs it on every page; preview and full-mode both supply it. +type Status interface { GetStatus() string - GetActiveJobs() int GetStartTime() time.Time + GetActiveJobs() int +} - HTTPRequestsTotal() int - RepositoriesTotal() int - LastDiscoveryDurationSec() int - LastBuildDurationSec() int - +// Triggers is an optional Runtime surface for trigger endpoints. +// nil disables discovery/build/webhook routes (preview mode). +type Triggers interface { TriggerDiscovery() string TriggerBuild() string - // TriggerWebhookBuild triggers a build based on a webhook event. - // - // forgeName is optional; callers may pass an empty string when the request is - // not scoped to a specific configured forge instance. When forgeName is - // provided, it may be used to disambiguate repositories hosted on different - // forges. TriggerWebhookBuild(forgeName, repoFullName, branch string, changedFiles []string) string GetQueueLength() int } +// MetricsSource is an optional Runtime surface for the metrics endpoint. +// nil disables Prometheus/detailed metrics (preview mode). +type MetricsSource interface { + HTTPRequestsTotal() int + RepositoriesTotal() int + LastDiscoveryDurationSec() int + LastBuildDurationSec() int +} + // BuildStatus is used to render preview-mode error pages when no good build exists yet. type BuildStatus interface { GetStatus() (hasError bool, err error, hasGoodBuild bool) } -// LiveReloadHub supports the LiveReload SSE endpoint and broadcast notifications. -type LiveReloadHub interface { - http.Handler - Broadcast(hash string) - Shutdown() -} - // Options configures additional server wiring that is runtime-specific. +// Triggers and Metrics are optional; pass nil when the surface is not +// available in the current runtime mode. type Options struct { ForgeClients map[string]forge.Client WebhookConfigs map[string]*config.WebhookConfig - // Optional: live reload support (preview mode). - LiveReloadHub LiveReloadHub + // Optional: live reload support (preview mode). Callers pass any + // type satisfying queue.LiveReloadHub (http.Handler + Broadcast + + // Shutdown); see internal/build/queue/build_job_metadata.go. + LiveReloadHub queue.LiveReloadHub // Optional: build status tracker (preview mode). BuildStatus BuildStatus @@ -60,4 +60,12 @@ type Options struct { DetailedMetricsHandle http.HandlerFunc EnhancedHealthHandle http.HandlerFunc StatusHandle http.HandlerFunc + + // Optional: trigger surface (full daemon mode supplies a *Daemon, + // preview passes nil so /api/build/trigger etc. are no-ops). + Triggers Triggers + + // Optional: metrics surface (full daemon mode supplies *Daemon, + // preview passes nil so /metrics returns zeros). + Metrics MetricsSource } diff --git a/internal/services/interfaces.go b/internal/services/interfaces.go index f7797ee3..4910a2fc 100644 --- a/internal/services/interfaces.go +++ b/internal/services/interfaces.go @@ -6,16 +6,6 @@ import ( "time" ) -// StateManager defines the minimal interface for persistent state lifecycle and status. -// Components that need state persistence operations type assert to this interface -// or more specific interfaces like state.DaemonStateManager. -type StateManager interface { - Load() error - Save() error - IsLoaded() bool - LastSaved() *time.Time -} - // ManagedService defines the interface for services with lifecycle management. type ManagedService interface { // Name returns the service name for logging and identification. @@ -40,12 +30,3 @@ type HealthStatus struct { Message string `json:"message,omitempty"` CheckAt time.Time `json:"check_at"` } - -var ( - // HealthStatusHealthy is a reusable healthy status value. - HealthStatusHealthy = HealthStatus{Status: "healthy", CheckAt: time.Now()} - // HealthStatusUnhealthy returns a HealthStatus indicating an unhealthy state with a message. - HealthStatusUnhealthy = func(message string) HealthStatus { - return HealthStatus{Status: "unhealthy", Message: message, CheckAt: time.Now()} - } -) diff --git a/internal/state/helpers.go b/internal/state/helpers.go deleted file mode 100644 index 240ecc4c..00000000 --- a/internal/state/helpers.go +++ /dev/null @@ -1,42 +0,0 @@ -package state - -import ( - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// deleteEntity provides a unified pattern for Delete operations in JSON stores. -// It validates the key, performs existence check, invokes deleter, persists if needed, -// and returns a standardized result. -func deleteEntity( - key string, - exists func() bool, - deleter func(), - save func() error, - notFoundName string, - saveErrorMessage string, -) foundation.Result[struct{}, error] { - if key == "" { - return foundation.Err[struct{}, error]( - errors.ValidationError("key cannot be empty").Build(), - ) - } - - if !exists() { - return foundation.Err[struct{}, error]( - errors.NotFoundError(notFoundName). - WithContext("key", key). - Build(), - ) - } - - deleter() - - if err := save(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError(saveErrorMessage).WithCause(err).Build(), - ) - } - - return foundation.Ok[struct{}, error](struct{}{}) -} diff --git a/internal/state/interfaces.go b/internal/state/interfaces.go index 60923c84..13ff4872 100644 --- a/internal/state/interfaces.go +++ b/internal/state/interfaces.go @@ -1,164 +1,6 @@ package state -import ( - "context" - "log/slog" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" -) - -// RepositoryStore handles repository state persistence and queries. -type RepositoryStore interface { - // Create creates a new repository record. - Create(ctx context.Context, repo *Repository) foundation.Result[*Repository, error] - - // GetByURL retrieves a repository by its URL. - GetByURL(ctx context.Context, url string) foundation.Result[foundation.Option[*Repository], error] - - // Update updates an existing repository. - Update(ctx context.Context, repo *Repository) foundation.Result[*Repository, error] - - // List returns all repositories with optional filtering. - List(ctx context.Context) foundation.Result[[]Repository, error] - - // Delete removes a repository by URL. - Delete(ctx context.Context, url string) foundation.Result[struct{}, error] - - // IncrementBuildCount increments build counters for a repository. - IncrementBuildCount(ctx context.Context, url string, success bool) foundation.Result[struct{}, error] - - // SetDocumentCount updates the document count for a repository. - SetDocumentCount(ctx context.Context, url string, count int) foundation.Result[struct{}, error] - - // SetDocFilesHash updates the document files hash for incremental detection. - SetDocFilesHash(ctx context.Context, url string, hash string) foundation.Result[struct{}, error] - - // SetDocFilePaths updates the document file paths for a repository. - SetDocFilePaths(ctx context.Context, url string, paths []string) foundation.Result[struct{}, error] -} - -// BuildStore handles build state persistence and queries. -type BuildStore interface { - // Create creates a new build record. - Create(ctx context.Context, build *Build) foundation.Result[*Build, error] - - // GetByID retrieves a build by its ID. - GetByID(ctx context.Context, id string) foundation.Result[foundation.Option[*Build], error] - - // Update updates an existing build. - Update(ctx context.Context, build *Build) foundation.Result[*Build, error] - - // List returns builds with optional filtering and pagination. - List(ctx context.Context, opts ListOptions) foundation.Result[[]Build, error] - - // Delete removes a build by ID. - Delete(ctx context.Context, id string) foundation.Result[struct{}, error] - - // Cleanup removes old builds to prevent unbounded storage growth. - Cleanup(ctx context.Context, maxBuilds int) foundation.Result[int, error] -} - -// ScheduleStore handles schedule state persistence and queries. -type ScheduleStore interface { - // Create creates a new schedule. - Create(ctx context.Context, schedule *Schedule) foundation.Result[*Schedule, error] - - // GetByID retrieves a schedule by its ID. - GetByID(ctx context.Context, id string) foundation.Result[foundation.Option[*Schedule], error] - - // Update updates an existing schedule. - Update(ctx context.Context, schedule *Schedule) foundation.Result[*Schedule, error] - - // List returns all schedules. - List(ctx context.Context) foundation.Result[[]Schedule, error] - - // Delete removes a schedule by ID. - Delete(ctx context.Context, id string) foundation.Result[struct{}, error] -} - -// StatisticsStore handles statistics persistence and calculations. -type StatisticsStore interface { - // Get retrieves current statistics. - Get(ctx context.Context) foundation.Result[*Statistics, error] - - // Update updates statistics. - Update(ctx context.Context, stats *Statistics) foundation.Result[*Statistics, error] - - // RecordBuild updates statistics when a build completes. - RecordBuild(ctx context.Context, build *Build) foundation.Result[struct{}, error] - - // RecordDiscovery updates statistics when a discovery completes. - RecordDiscovery(ctx context.Context, documentCount int) foundation.Result[struct{}, error] - - // Reset resets all statistics counters. - Reset(ctx context.Context) foundation.Result[struct{}, error] -} - -// ConfigurationStore handles configuration data persistence. -type ConfigurationStore interface { - // Set stores a configuration value. - Set(ctx context.Context, key string, value any) foundation.Result[struct{}, error] - - // Get retrieves a configuration value. - Get(ctx context.Context, key string) foundation.Result[foundation.Option[any], error] - - // Delete removes a configuration key. - Delete(ctx context.Context, key string) foundation.Result[struct{}, error] - - // List returns all configuration keys and values. - List(ctx context.Context) foundation.Result[map[string]any, error] -} - -// DaemonInfoStore handles daemon metadata persistence. -type DaemonInfoStore interface { - // Get retrieves daemon information. - Get(ctx context.Context) foundation.Result[*DaemonInfo, error] - - // Update updates daemon information. - Update(ctx context.Context, info *DaemonInfo) foundation.Result[*DaemonInfo, error] - - // UpdateStatus updates only the daemon status. - UpdateStatus(ctx context.Context, status string) foundation.Result[struct{}, error] -} - -// ListOptions provides filtering and pagination options for list operations. -type ListOptions struct { - Limit foundation.Option[int] `json:"limit"` - Offset foundation.Option[int] `json:"offset"` - SortBy foundation.Option[string] `json:"sort_by"` - Order foundation.Option[string] `json:"order"` // "asc" or "desc" - Filter map[string]any `json:"filter"` -} - -// Store is the main interface that aggregates all storage concerns. -// This replaces the monolithic StateManager with focused, composable stores. -type Store interface { - // Repository operations - Repositories() RepositoryStore - - // Build operations - Builds() BuildStore - - // Schedule operations - Schedules() ScheduleStore - - // Statistics operations - Statistics() StatisticsStore - - // Configuration operations - Configuration() ConfigurationStore - - // Daemon info operations - DaemonInfo() DaemonInfoStore - - // Transaction operations - WithTransaction(ctx context.Context, fn func(Store) error) foundation.Result[struct{}, error] - - // Health and lifecycle - Health(ctx context.Context) foundation.Result[StoreHealth, error] - Close(ctx context.Context) foundation.Result[struct{}, error] -} +import "time" // StoreHealth represents the health status of the state store. type StoreHealth struct { @@ -168,96 +10,3 @@ type StoreHealth struct { StorageSize *int64 `json:"storage_size_bytes,omitempty"` CheckedAt time.Time `json:"checked_at"` } - -// Manager orchestrates state operations across multiple stores. -// This is a much smaller, focused component compared to the original 620-line Manager. -type Manager struct { - store Store - lastSaved foundation.Option[time.Time] -} - -// NewManager creates a new state manager with the given store. -func NewManager(store Store) *Manager { - return &Manager{ - store: store, - lastSaved: foundation.None[time.Time](), - } -} - -// WithAutoSave configures automatic saving. -func (sm *Manager) WithAutoSave(enabled bool, interval time.Duration) *Manager { - // Auto-save configuration removed - no longer needed - return sm -} - -// GetRepository retrieves repository state by URL. -func (sm *Manager) GetRepository(ctx context.Context, url string) foundation.Result[foundation.Option[*Repository], error] { - return sm.store.Repositories().GetByURL(ctx, url) -} - -// IncrementRepoBuild increments build counters for a repository. -func (sm *Manager) IncrementRepoBuild(ctx context.Context, url string, success bool) foundation.Result[struct{}, error] { - return sm.store.Repositories().IncrementBuildCount(ctx, url, success) -} - -// RecordBuild records a build operation. -func (sm *Manager) RecordBuild(ctx context.Context, build *Build) foundation.Result[*Build, error] { - // Validate the build - if validationResult := build.Validate(); !validationResult.Valid { - return foundation.Err[*Build, error](validationResult.ToError()) - } - - // Create or update the build - result := sm.store.Builds().Create(ctx, build) - if result.IsErr() { - return result - } - - // Update statistics - statsResult := sm.store.Statistics().RecordBuild(ctx, build) - if statsResult.IsErr() { - // Log but don't fail the operation. - // In a production system, you might want different error handling. - slog.Warn("statistics record failed", "error", statsResult.UnwrapErr()) - } - - return result -} - -// GetStatistics retrieves current daemon statistics. -func (sm *Manager) GetStatistics(ctx context.Context) foundation.Result[*Statistics, error] { - return sm.store.Statistics().Get(ctx) -} - -// Health returns the health status of the state management system. -func (sm *Manager) Health(ctx context.Context) foundation.Result[StoreHealth, error] { - return sm.store.Health(ctx) -} - -// Close gracefully shuts down the state manager. -func (sm *Manager) Close(ctx context.Context) foundation.Result[struct{}, error] { - return sm.store.Close(ctx) -} - -// IsLoaded returns whether the state manager is properly initialized. -func (sm *Manager) IsLoaded() bool { - // For this interface-based design, we consider it loaded if we have a store - return sm.store != nil -} - -// LastSaved returns the last time state was saved. -func (sm *Manager) LastSaved() *time.Time { - return sm.lastSaved.ToPointer() -} - -// Load is a no-op in this design since loading is handled by the store implementation. -func (sm *Manager) Load() error { - // In the interface-based design, loading is handled by the concrete store - return nil -} - -// Save is a no-op in this design since saving is handled automatically by stores. -func (sm *Manager) Save() error { - sm.lastSaved = foundation.Some(time.Now()) - return nil -} diff --git a/internal/state/json_build_store.go b/internal/state/json_build_store.go deleted file mode 100644 index 571018e9..00000000 --- a/internal/state/json_build_store.go +++ /dev/null @@ -1,160 +0,0 @@ -package state - -import ( - "context" - "sort" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// jsonBuildStore implements BuildStore for the JSON store. -type jsonBuildStore struct { - store *JSONStore -} - -func (bs *jsonBuildStore) Create(_ context.Context, build *Build) foundation.Result[*Build, error] { - return createEntity( - build, - "build", - &bs.store.mu, - func(b *Build) { b.CreatedAt = time.Now(); b.UpdatedAt = b.CreatedAt }, - func(b *Build) { bs.store.builds[b.ID] = b }, - func(b *Build) { delete(bs.store.builds, b.ID) }, - bs.store.autoSaveEnabled, - bs.store.saveToDiskUnsafe, - ) -} - -func (bs *jsonBuildStore) GetByID(_ context.Context, id string) foundation.Result[foundation.Option[*Build], error] { - if id == "" { - return foundation.Err[foundation.Option[*Build], error]( - errors.ValidationError("ID cannot be empty").Build(), - ) - } - - bs.store.mu.RLock() - defer bs.store.mu.RUnlock() - - if build, exists := bs.store.builds[id]; exists { - buildCopy := *build - return foundation.Ok[foundation.Option[*Build], error](foundation.Some(&buildCopy)) - } - - return foundation.Ok[foundation.Option[*Build], error](foundation.None[*Build]()) -} - -func (bs *jsonBuildStore) Update(_ context.Context, build *Build) foundation.Result[*Build, error] { - return updateValidatableEntity[Build]( - bs.store, - "build", - build, - func() bool { _, ok := bs.store.builds[build.ID]; return ok }, - func() { build.UpdatedAt = time.Now() }, - func() { bs.store.builds[build.ID] = build }, - func() foundation.Result[*Build, error] { - return foundation.Err[*Build, error]( - errors.NotFoundError("build"). - WithContext("id", build.ID). - Build(), - ) - }, - "failed to save build update", - ) -} - -func (bs *jsonBuildStore) List(_ context.Context, opts ListOptions) foundation.Result[[]Build, error] { - bs.store.mu.RLock() - defer bs.store.mu.RUnlock() - - builds := make([]Build, 0, len(bs.store.builds)) - for _, build := range bs.store.builds { - builds = append(builds, *build) - } - - // Sort by creation time (newest first) - sort.Slice(builds, func(i, j int) bool { - return builds[i].CreatedAt.After(builds[j].CreatedAt) - }) - - // Apply pagination if specified - if opts.Limit.IsSome() && opts.Limit.Unwrap() > 0 { - start := 0 - if opts.Offset.IsSome() { - start = opts.Offset.Unwrap() - } - - if start > len(builds) { - start = len(builds) - } - - end := min(start+opts.Limit.Unwrap(), len(builds)) - - builds = builds[start:end] - } - - return foundation.Ok[[]Build, error](builds) -} - -func (bs *jsonBuildStore) Delete(_ context.Context, id string) foundation.Result[struct{}, error] { - bs.store.mu.Lock() - defer bs.store.mu.Unlock() - - return deleteEntity( - id, - func() bool { _, exists := bs.store.builds[id]; return exists }, - func() { delete(bs.store.builds, id) }, - func() error { - if bs.store.autoSaveEnabled { - return bs.store.saveToDiskUnsafe() - } - return nil - }, - "build", - "failed to save build deletion", - ) -} - -func (bs *jsonBuildStore) Cleanup(_ context.Context, maxBuilds int) foundation.Result[int, error] { - if maxBuilds <= 0 { - return foundation.Err[int, error]( - errors.ValidationError("maxBuilds must be positive").Build(), - ) - } - - bs.store.mu.Lock() - defer bs.store.mu.Unlock() - - builds := make([]*Build, 0, len(bs.store.builds)) - for _, build := range bs.store.builds { - builds = append(builds, build) - } - - if len(builds) <= maxBuilds { - return foundation.Ok[int, error](0) // No cleanup needed - } - - // Sort by creation time (newest first) - sort.Slice(builds, func(i, j int) bool { - return builds[i].CreatedAt.After(builds[j].CreatedAt) - }) - - // Keep only the newest maxBuilds - toDelete := builds[maxBuilds:] - deletedCount := len(toDelete) - - for _, build := range toDelete { - delete(bs.store.builds, build.ID) - } - - if bs.store.autoSaveEnabled { - if err := bs.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[int, error]( - errors.InternalError("failed to save build cleanup").WithCause(err).Build(), - ) - } - } - - return foundation.Ok[int, error](deletedCount) -} diff --git a/internal/state/json_configuration_store.go b/internal/state/json_configuration_store.go deleted file mode 100644 index 3e1363fd..00000000 --- a/internal/state/json_configuration_store.go +++ /dev/null @@ -1,88 +0,0 @@ -package state - -import ( - "context" - "maps" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// jsonConfigurationStore implements ConfigurationStore for the JSON store. -type jsonConfigurationStore struct { - store *JSONStore -} - -func (cs *jsonConfigurationStore) Get(_ context.Context, key string) foundation.Result[foundation.Option[any], error] { - if key == "" { - return foundation.Err[foundation.Option[any], error]( - errors.ValidationError("key cannot be empty").Build(), - ) - } - - cs.store.mu.RLock() - defer cs.store.mu.RUnlock() - - if value, exists := cs.store.configuration[key]; exists { - return foundation.Ok[foundation.Option[any], error](foundation.Some(value)) - } - - return foundation.Ok[foundation.Option[any], error](foundation.None[any]()) -} - -func (cs *jsonConfigurationStore) Set(_ context.Context, key string, value any) foundation.Result[struct{}, error] { - if key == "" { - return foundation.Err[struct{}, error]( - errors.ValidationError("key cannot be empty").Build(), - ) - } - - cs.store.mu.Lock() - defer cs.store.mu.Unlock() - - cs.store.configuration[key] = value - - if cs.store.autoSaveEnabled { - if err := cs.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save configuration").WithCause(err).Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} - -func (cs *jsonConfigurationStore) Delete(_ context.Context, key string) foundation.Result[struct{}, error] { - cs.store.mu.Lock() - defer cs.store.mu.Unlock() - - return deleteEntity( - key, - func() bool { _, exists := cs.store.configuration[key]; return exists }, - func() { delete(cs.store.configuration, key) }, - func() error { - if cs.store.autoSaveEnabled { - return cs.store.saveToDiskUnsafe() - } - return nil - }, - "configuration key", - "failed to save configuration deletion", - ) -} - -func (cs *jsonConfigurationStore) List(ctx context.Context) foundation.Result[map[string]any, error] { - return cs.GetAll(ctx) -} - -func (cs *jsonConfigurationStore) GetAll(_ context.Context) foundation.Result[map[string]any, error] { - cs.store.mu.RLock() - defer cs.store.mu.RUnlock() - - // Return a deep copy to prevent external modification - result := make(map[string]any, len(cs.store.configuration)) - maps.Copy(result, cs.store.configuration) - - return foundation.Ok[map[string]any, error](result) -} diff --git a/internal/state/json_daemon_info_store.go b/internal/state/json_daemon_info_store.go deleted file mode 100644 index ab757b8f..00000000 --- a/internal/state/json_daemon_info_store.go +++ /dev/null @@ -1,62 +0,0 @@ -package state - -import ( - "context" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// jsonDaemonInfoStore implements DaemonInfoStore for the JSON store. -type jsonDaemonInfoStore struct { - store *JSONStore -} - -func (ds *jsonDaemonInfoStore) Get(_ context.Context) foundation.Result[*DaemonInfo, error] { - ds.store.mu.RLock() - defer ds.store.mu.RUnlock() - - infoCopy := *ds.store.daemonInfo - return foundation.Ok[*DaemonInfo, error](&infoCopy) -} - -func (ds *jsonDaemonInfoStore) Update(_ context.Context, info *DaemonInfo) foundation.Result[*DaemonInfo, error] { - if info == nil { - return foundation.Err[*DaemonInfo, error]( - errors.ValidationError("daemon info cannot be nil").Build(), - ) - } - - return updateSimpleEntity[DaemonInfo]( - ds.store, - info, - func() { info.LastUpdate = time.Now() }, - func() { ds.store.daemonInfo = info }, - "failed to save daemon info", - ) -} - -func (ds *jsonDaemonInfoStore) UpdateStatus(_ context.Context, status string) foundation.Result[struct{}, error] { - if status == "" { - return foundation.Err[struct{}, error]( - errors.ValidationError("status cannot be empty").Build(), - ) - } - - ds.store.mu.Lock() - defer ds.store.mu.Unlock() - - ds.store.daemonInfo.Status = status - ds.store.daemonInfo.LastUpdate = time.Now() - - if ds.store.autoSaveEnabled { - if err := ds.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save daemon status").WithCause(err).Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} diff --git a/internal/state/json_repository_store.go b/internal/state/json_repository_store.go deleted file mode 100644 index 8d41f8b9..00000000 --- a/internal/state/json_repository_store.go +++ /dev/null @@ -1,270 +0,0 @@ -package state - -import ( - "context" - "sort" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// jsonRepositoryStore implements RepositoryStore for the JSON store. -type jsonRepositoryStore struct { - store *JSONStore -} - -func (rs *jsonRepositoryStore) Create(_ context.Context, repo *Repository) foundation.Result[*Repository, error] { - if repo == nil { - return foundation.Err[*Repository, error]( - errors.ValidationError("repository cannot be nil").Build(), - ) - } - - // Validate the repository - if validationResult := repo.Validate(); !validationResult.Valid { - return foundation.Err[*Repository, error](validationResult.ToError()) - } - - rs.store.mu.Lock() - defer rs.store.mu.Unlock() - - // Check if repository already exists - if _, exists := rs.store.repositories[repo.URL]; exists { - return foundation.Err[*Repository, error]( - errors.ValidationError("repository already exists"). - WithContext("url", repo.URL). - Build(), - ) - } - - // Set timestamps - now := time.Now() - repo.CreatedAt = now - repo.UpdatedAt = now - - // Store the repository - rs.store.repositories[repo.URL] = repo - - // Auto-save if enabled - if rs.store.autoSaveEnabled { - if err := rs.store.saveToDiskUnsafe(); err != nil { - // Remove from memory if save failed - delete(rs.store.repositories, repo.URL) - return foundation.Err[*Repository, error]( - errors.InternalError("failed to save repository"). - WithCause(err). - Build(), - ) - } - } - - return foundation.Ok[*Repository, error](repo) -} - -func (rs *jsonRepositoryStore) GetByURL(_ context.Context, url string) foundation.Result[foundation.Option[*Repository], error] { - if url == "" { - return foundation.Err[foundation.Option[*Repository], error]( - errors.ValidationError("URL cannot be empty").Build(), - ) - } - - rs.store.mu.RLock() - defer rs.store.mu.RUnlock() - - if repo, exists := rs.store.repositories[url]; exists { - // Return a copy to prevent external modification - repoCopy := *repo - return foundation.Ok[foundation.Option[*Repository], error](foundation.Some(&repoCopy)) - } - - return foundation.Ok[foundation.Option[*Repository], error](foundation.None[*Repository]()) -} - -func (rs *jsonRepositoryStore) Update(_ context.Context, repo *Repository) foundation.Result[*Repository, error] { - return updateValidatableEntity[Repository]( - rs.store, - "repository", - repo, - func() bool { _, ok := rs.store.repositories[repo.URL]; return ok }, - func() { repo.UpdatedAt = time.Now() }, - func() { rs.store.repositories[repo.URL] = repo }, - func() foundation.Result[*Repository, error] { - return foundation.Err[*Repository, error]( - errors.NotFoundError("repository"). - WithContext("url", repo.URL). - Build(), - ) - }, - "failed to save repository update", - ) -} - -func (rs *jsonRepositoryStore) List(_ context.Context) foundation.Result[[]Repository, error] { - rs.store.mu.RLock() - defer rs.store.mu.RUnlock() - - repositories := make([]Repository, 0, len(rs.store.repositories)) - for _, repo := range rs.store.repositories { - repositories = append(repositories, *repo) - } - - // Sort by name for consistent ordering - sort.Slice(repositories, func(i, j int) bool { - return repositories[i].Name < repositories[j].Name - }) - - return foundation.Ok[[]Repository, error](repositories) -} - -func (rs *jsonRepositoryStore) Delete(_ context.Context, url string) foundation.Result[struct{}, error] { - rs.store.mu.Lock() - defer rs.store.mu.Unlock() - - return deleteEntity( - url, - func() bool { _, exists := rs.store.repositories[url]; return exists }, - func() { delete(rs.store.repositories, url) }, - func() error { - if rs.store.autoSaveEnabled { - return rs.store.saveToDiskUnsafe() - } - return nil - }, - "repository", - "failed to save repository deletion", - ) -} - -func (rs *jsonRepositoryStore) IncrementBuildCount(_ context.Context, url string, success bool) foundation.Result[struct{}, error] { - rs.store.mu.Lock() - defer rs.store.mu.Unlock() - - repo, exists := rs.store.repositories[url] - if !exists { - return foundation.Err[struct{}, error]( - errors.NotFoundError("repository"). - WithContext("url", url). - Build(), - ) - } - - // Update counters - now := time.Now() - repo.LastBuild = foundation.Some(now) - repo.BuildCount++ - if !success { - repo.ErrorCount++ - } - repo.UpdatedAt = now - - // Auto-save if enabled - if rs.store.autoSaveEnabled { - if err := rs.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save build count update"). - WithCause(err). - Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} - -func (rs *jsonRepositoryStore) SetDocumentCount(_ context.Context, url string, count int) foundation.Result[struct{}, error] { - if count < 0 { - return foundation.Err[struct{}, error]( - errors.ValidationError("document count cannot be negative").Build(), - ) - } - - rs.store.mu.Lock() - defer rs.store.mu.Unlock() - - repo, exists := rs.store.repositories[url] - if !exists { - return foundation.Err[struct{}, error]( - errors.NotFoundError("repository"). - WithContext("url", url). - Build(), - ) - } - - repo.DocumentCount = count - repo.UpdatedAt = time.Now() - - // Auto-save if enabled - if rs.store.autoSaveEnabled { - if err := rs.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save document count update"). - WithCause(err). - Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} - -func (rs *jsonRepositoryStore) SetDocFilesHash(_ context.Context, url, hash string) foundation.Result[struct{}, error] { - rs.store.mu.Lock() - defer rs.store.mu.Unlock() - - repo, exists := rs.store.repositories[url] - if !exists { - return foundation.Err[struct{}, error]( - errors.NotFoundError("repository"). - WithContext("url", url). - Build(), - ) - } - - repo.DocFilesHash = foundation.Some(hash) - repo.UpdatedAt = time.Now() - - // Auto-save if enabled - if rs.store.autoSaveEnabled { - if err := rs.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save doc files hash update"). - WithCause(err). - Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} - -func (rs *jsonRepositoryStore) SetDocFilePaths(_ context.Context, url string, paths []string) foundation.Result[struct{}, error] { - rs.store.mu.Lock() - defer rs.store.mu.Unlock() - - repo, exists := rs.store.repositories[url] - if !exists { - return foundation.Err[struct{}, error]( - errors.NotFoundError("repository"). - WithContext("url", url). - Build(), - ) - } - - // Make a copy of the paths to prevent external modification - repo.DocFilePaths = append([]string{}, paths...) - repo.UpdatedAt = time.Now() - - // Auto-save if enabled - if rs.store.autoSaveEnabled { - if err := rs.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save doc file paths update"). - WithCause(err). - Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} diff --git a/internal/state/json_schedule_store.go b/internal/state/json_schedule_store.go deleted file mode 100644 index 429baf4a..00000000 --- a/internal/state/json_schedule_store.go +++ /dev/null @@ -1,108 +0,0 @@ -package state - -import ( - "context" - "sort" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// jsonScheduleStore implements ScheduleStore for the JSON store. -type jsonScheduleStore struct { - store *JSONStore -} - -func (ss *jsonScheduleStore) Create(_ context.Context, schedule *Schedule) foundation.Result[*Schedule, error] { - return createEntity( - schedule, - "schedule", - &ss.store.mu, - func(s *Schedule) { s.CreatedAt = time.Now(); s.UpdatedAt = s.CreatedAt }, - func(s *Schedule) { ss.store.schedules[s.ID] = s }, - func(s *Schedule) { delete(ss.store.schedules, s.ID) }, - ss.store.autoSaveEnabled, - ss.store.saveToDiskUnsafe, - ) -} - -func (ss *jsonScheduleStore) GetByID(_ context.Context, id string) foundation.Result[foundation.Option[*Schedule], error] { - if id == "" { - return foundation.Err[foundation.Option[*Schedule], error]( - errors.ValidationError("ID cannot be empty").Build(), - ) - } - - ss.store.mu.RLock() - defer ss.store.mu.RUnlock() - - if schedule, exists := ss.store.schedules[id]; exists { - scheduleCopy := *schedule - return foundation.Ok[foundation.Option[*Schedule], error](foundation.Some(&scheduleCopy)) - } - - return foundation.Ok[foundation.Option[*Schedule], error](foundation.None[*Schedule]()) -} - -func (ss *jsonScheduleStore) Update(_ context.Context, schedule *Schedule) foundation.Result[*Schedule, error] { - return updateValidatableEntity[Schedule]( - ss.store, - "schedule", - schedule, - func() bool { _, ok := ss.store.schedules[schedule.ID]; return ok }, - func() { schedule.UpdatedAt = time.Now() }, - func() { ss.store.schedules[schedule.ID] = schedule }, - func() foundation.Result[*Schedule, error] { - return foundation.Err[*Schedule, error]( - errors.NotFoundError("schedule"). - WithContext("id", schedule.ID). - Build(), - ) - }, - "failed to save schedule update", - ) -} - -func (ss *jsonScheduleStore) Delete(_ context.Context, id string) foundation.Result[struct{}, error] { - ss.store.mu.Lock() - defer ss.store.mu.Unlock() - - return deleteEntity( - id, - func() bool { _, exists := ss.store.schedules[id]; return exists }, - func() { delete(ss.store.schedules, id) }, - func() error { - if ss.store.autoSaveEnabled { - return ss.store.saveToDiskUnsafe() - } - return nil - }, - "schedule", - "failed to save schedule deletion", - ) -} - -func (ss *jsonScheduleStore) List(_ context.Context) foundation.Result[[]Schedule, error] { - ss.store.mu.RLock() - defer ss.store.mu.RUnlock() - - schedules := make([]Schedule, 0, len(ss.store.schedules)) - for _, schedule := range ss.store.schedules { - schedules = append(schedules, *schedule) - } - - // Sort by next run time - sort.Slice(schedules, func(i, j int) bool { - // Handle Option[time.Time] properly - if !schedules[i].NextRun.IsSome() { - return false - } - if !schedules[j].NextRun.IsSome() { - return true - } - return schedules[i].NextRun.Unwrap().Before(schedules[j].NextRun.Unwrap()) - }) - - return foundation.Ok[[]Schedule, error](schedules) -} diff --git a/internal/state/json_statistics_store.go b/internal/state/json_statistics_store.go deleted file mode 100644 index 8c16947b..00000000 --- a/internal/state/json_statistics_store.go +++ /dev/null @@ -1,116 +0,0 @@ -package state - -import ( - "context" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// jsonStatisticsStore implements StatisticsStore for the JSON store. -type jsonStatisticsStore struct { - store *JSONStore -} - -func (ss *jsonStatisticsStore) Get(_ context.Context) foundation.Result[*Statistics, error] { - ss.store.mu.RLock() - defer ss.store.mu.RUnlock() - - statsCopy := *ss.store.statistics - return foundation.Ok[*Statistics, error](&statsCopy) -} - -func (ss *jsonStatisticsStore) Update(_ context.Context, stats *Statistics) foundation.Result[*Statistics, error] { - if stats == nil { - return foundation.Err[*Statistics, error]( - errors.ValidationError("statistics cannot be nil").Build(), - ) - } - - return updateSimpleEntity[Statistics]( - ss.store, - stats, - func() { stats.LastUpdated = time.Now() }, - func() { ss.store.statistics = stats }, - "failed to save statistics", - ) -} - -func (ss *jsonStatisticsStore) RecordBuild(_ context.Context, build *Build) foundation.Result[struct{}, error] { - if build == nil { - return foundation.Err[struct{}, error]( - errors.ValidationError("build cannot be nil").Build(), - ) - } - - ss.store.mu.Lock() - defer ss.store.mu.Unlock() - - ss.store.statistics.TotalBuilds++ - switch build.Status { - case BuildStatusCompleted: - ss.store.statistics.SuccessfulBuilds++ - case BuildStatusFailed: - ss.store.statistics.FailedBuilds++ - case BuildStatusPending, BuildStatusRunning, BuildStatusCanceled: - // These statuses don't update success/failure counters - } - ss.store.statistics.LastUpdated = time.Now() - - if ss.store.autoSaveEnabled { - if err := ss.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save build statistics").WithCause(err).Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} - -func (ss *jsonStatisticsStore) RecordDiscovery(_ context.Context, documentCount int) foundation.Result[struct{}, error] { - if documentCount < 0 { - return foundation.Err[struct{}, error]( - errors.ValidationError("document count cannot be negative").Build(), - ) - } - - ss.store.mu.Lock() - defer ss.store.mu.Unlock() - - ss.store.statistics.TotalDiscoveries++ - ss.store.statistics.DocumentsFound += int64(documentCount) - ss.store.statistics.LastUpdated = time.Now() - - if ss.store.autoSaveEnabled { - if err := ss.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save discovery statistics").WithCause(err).Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} - -func (ss *jsonStatisticsStore) Reset(_ context.Context) foundation.Result[struct{}, error] { - ss.store.mu.Lock() - defer ss.store.mu.Unlock() - - now := time.Now() - ss.store.statistics = &Statistics{ - LastStatReset: now, - LastUpdated: now, - } - - if ss.store.autoSaveEnabled { - if err := ss.store.saveToDiskUnsafe(); err != nil { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to save statistics reset").WithCause(err).Build(), - ) - } - } - - return foundation.Ok[struct{}, error](struct{}{}) -} diff --git a/internal/state/json_store.go b/internal/state/json_store.go index e453948d..c2518157 100644 --- a/internal/state/json_store.go +++ b/internal/state/json_store.go @@ -92,39 +92,9 @@ func NewJSONStore(dataDir string) foundation.Result[*JSONStore, error] { return foundation.Ok[*JSONStore, error](store) } -// Repositories returns the repository store interface. -func (js *JSONStore) Repositories() RepositoryStore { - return &jsonRepositoryStore{store: js} -} - -// Builds returns the build store interface. -func (js *JSONStore) Builds() BuildStore { - return &jsonBuildStore{store: js} -} - -// Schedules returns the schedule store interface. -func (js *JSONStore) Schedules() ScheduleStore { - return &jsonScheduleStore{store: js} -} - -// Statistics returns the statistics store interface. -func (js *JSONStore) Statistics() StatisticsStore { - return &jsonStatisticsStore{store: js} -} - -// Configuration returns the configuration store interface. -func (js *JSONStore) Configuration() ConfigurationStore { - return &jsonConfigurationStore{store: js} -} - -// DaemonInfo returns the daemon info store interface. -func (js *JSONStore) DaemonInfo() DaemonInfoStore { - return &jsonDaemonInfoStore{store: js} -} - // WithTransaction executes a function within a transaction-like context. // For the JSON store, this uses a mutex to ensure consistency. -func (js *JSONStore) WithTransaction(_ context.Context, fn func(Store) error) foundation.Result[struct{}, error] { +func (js *JSONStore) WithTransaction(_ context.Context, fn func(*JSONStore) error) foundation.Result[struct{}, error] { js.mu.Lock() defer js.mu.Unlock() @@ -203,12 +173,12 @@ func (js *JSONStore) loadFromDisk() error { if os.IsNotExist(err) { return nil // No existing state file } - return fmt.Errorf("failed to read state file: %w", err) + return errors.WrapError(err, errors.CategoryFileSystem, "failed to read state file").Build() } snapshot, err := decodeStateSnapshot(data) if err != nil { - return fmt.Errorf("failed to unmarshal state: %w", err) + return errors.WrapError(err, errors.CategoryValidation, "failed to unmarshal state").Build() } js.applySnapshot(snapshot) return nil @@ -222,7 +192,7 @@ func (js *JSONStore) saveToDiskUnsafe() error { snapshot := js.snapshot() data, err := json.MarshalIndent(snapshot, "", " ") if err != nil { - return fmt.Errorf("failed to marshal state: %w", err) + return errors.WrapError(err, errors.CategoryInternal, "failed to marshal state").Build() } statePath := filepath.Join(js.dataDir, "daemon-state.json") @@ -231,11 +201,11 @@ func (js *JSONStore) saveToDiskUnsafe() error { // Atomic write using temporary file // #nosec G306 -- state file needs to be readable by the process, 0644 is acceptable if err := os.WriteFile(tempPath, data, 0o644); err != nil { - return fmt.Errorf("failed to write temporary state file: %w", err) + return errors.WrapError(err, errors.CategoryFileSystem, "failed to write temporary state file").Build() } if err := os.Rename(tempPath, statePath); err != nil { - return fmt.Errorf("failed to replace state file: %w", err) + return errors.WrapError(err, errors.CategoryFileSystem, "failed to replace state file").Build() } js.lastSaved = &now @@ -251,7 +221,10 @@ func decodeStateSnapshot(data []byte) (stateSnapshot, error) { return stateSnapshot{}, stderr.New("state snapshot missing format_version (legacy files are no longer supported)") } if snapshot.FormatVersion != stateSnapshotFormatVersion { - return stateSnapshot{}, fmt.Errorf("unsupported state snapshot format_version %q (expected %s)", snapshot.FormatVersion, stateSnapshotFormatVersion) + return stateSnapshot{}, errors.NewError(errors.CategoryValidation, "unsupported state snapshot format_version"). + WithContext("actual", snapshot.FormatVersion). + WithContext("expected", stateSnapshotFormatVersion). + Build() } return snapshot, nil } diff --git a/internal/state/models.go b/internal/state/models.go index b82eb6c3..78ee456c 100644 --- a/internal/state/models.go +++ b/internal/state/models.go @@ -188,54 +188,3 @@ func (s *Schedule) Validate() foundation.ValidationResult { } return foundation.Valid() } - -// UpdateBuildStats updates statistics when a build completes. -func (s *Statistics) UpdateBuildStats(build *Build) { - if build == nil { - return - } - - s.TotalBuilds++ - - switch build.Status { - case BuildStatusCompleted: - s.SuccessfulBuilds++ - case BuildStatusFailed: - s.FailedBuilds++ - case BuildStatusPending, BuildStatusRunning, BuildStatusCanceled: - // These statuses don't update success/failure counters - } - - // Update average build time if we have duration - if build.Duration.IsSome() { - duration := build.Duration.Unwrap() - if s.TotalBuilds == 1 { - s.AverageBuildTime = duration - } else { - // Calculate running average - totalTime := s.AverageBuildTime * float64(s.TotalBuilds-1) - s.AverageBuildTime = (totalTime + duration) / float64(s.TotalBuilds) - } - } - - s.LastUpdated = time.Now() -} - -// UpdateDiscoveryStats updates statistics when a discovery completes. -func (s *Statistics) UpdateDiscoveryStats(documentCount int) { - s.TotalDiscoveries++ - s.DocumentsFound += int64(documentCount) - s.LastUpdated = time.Now() -} - -// Reset resets statistics counters. -func (s *Statistics) Reset() { - s.TotalBuilds = 0 - s.SuccessfulBuilds = 0 - s.FailedBuilds = 0 - s.TotalDiscoveries = 0 - s.DocumentsFound = 0 - s.AverageBuildTime = 0 - s.LastStatReset = time.Now() - s.LastUpdated = time.Now() -} diff --git a/internal/state/narrow_interfaces.go b/internal/state/narrow_interfaces.go index 28362ff2..92a646ce 100644 --- a/internal/state/narrow_interfaces.go +++ b/internal/state/narrow_interfaces.go @@ -74,7 +74,6 @@ type ConfigurationStateStore interface { } // LifecycleManager provides lifecycle operations for state managers. -// This mirrors services.StateManager for compatibility. type LifecycleManager interface { Load() error Save() error @@ -101,5 +100,6 @@ type DaemonStateManager interface { DiscoveryRecorder } -// Compile-time verification that ServiceAdapter implements DaemonStateManager. -var _ DaemonStateManager = (*ServiceAdapter)(nil) +// Compile-time verification that *Service implements DaemonStateManager +// (the canonical aggregate of all narrow interfaces above). +var _ DaemonStateManager = (*Service)(nil) diff --git a/internal/state/narrow_methods.go b/internal/state/narrow_methods.go new file mode 100644 index 00000000..fb7eff09 --- /dev/null +++ b/internal/state/narrow_methods.go @@ -0,0 +1,326 @@ +package state + +import ( + "context" + "time" + + "git.home.luguber.info/inful/docbuilder/internal/foundation" +) + +// This file implements the narrow interfaces declared in narrow_interfaces.go +// (RepositoryInitializer, RepositoryMetadataWriter, RepositoryMetadataReader, +// RepositoryMetadataStore, RepositoryCommitTracker, ConfigurationStateStore, +// DiscoveryRecorder) as methods on *Service. The implementations directly +// delegate to the inlined *JSONStore methods (RepositoryGetByURL, +// RepositoryUpdate, ConfigurationGet, etc). + +// --- RepositoryInitializer --- + +// EnsureRepositoryState creates a repository entry if it doesn't exist. +// For compatibility with legacy code, empty branch defaults to "main". +func (s *Service) EnsureRepositoryState(url, name, branch string) { + if url == "" { + return + } + ctx := context.Background() + store := s.store + + // Check if repository already exists + existing := store.RepositoryGetByURL(ctx, url) + if existing.IsOk() { + // Already exists; nothing to do. + return + } + + // Default branch for compatibility with legacy code that passes empty branch + if branch == "" { + branch = defaultBranchMain + } + + repo := &Repository{ + URL: url, + Name: name, + Branch: branch, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + _ = store.RepositoryCreate(ctx, repo) // Ignore error for interface compatibility +} + +// RemoveRepositoryState removes a repository entry from persistent state. +// It is used by daemon orchestration to reflect discovery removals. +func (s *Service) RemoveRepositoryState(url string) { + if url == "" { + return + } + ctx := context.Background() + res := s.store.RepositoryDelete(ctx, url) + if res.IsErr() { + // Repository not found is acceptable here (idempotent removal). + // Anything else is logged but not propagated — the orchestrator + // tolerates best-effort cleanup. + _ = res.UnwrapErr() + } +} + +// --- RepositoryMetadataWriter --- + +// SetRepoDocumentCount sets the document count for a repository. +func (s *Service) SetRepoDocumentCount(url string, count int) { + if url == "" || count < 0 { + return + } + ctx := context.Background() + _ = s.store.RepositorySetDocumentCount(ctx, url, count) +} + +// SetRepoDocFilesHash sets the document files hash for a repository. +func (s *Service) SetRepoDocFilesHash(url, hash string) { + if url == "" || hash == "" { + return + } + ctx := context.Background() + _ = s.store.RepositorySetDocFilesHash(ctx, url, hash) +} + +// SetRepoDocFilePaths sets the document file paths for a repository. +func (s *Service) SetRepoDocFilePaths(url string, paths []string) { + if url == "" { + return + } + ctx := context.Background() + _ = s.store.RepositorySetDocFilePaths(ctx, url, paths) +} + +// --- RepositoryMetadataReader --- + +// GetRepoDocFilesHash returns the document files hash for a repository. +func (s *Service) GetRepoDocFilesHash(url string) string { + if url == "" { + return "" + } + ctx := context.Background() + result := s.store.RepositoryGetByURL(ctx, url) + if result.IsErr() { + return "" + } + repo := result.Unwrap() + if repo == nil { + return "" + } + hashOpt := repo.DocFilesHash + if hashOpt.IsNone() { + return "" + } + return hashOpt.Unwrap() +} + +// GetRepoDocFilePaths returns the document file paths for a repository. +func (s *Service) GetRepoDocFilePaths(url string) []string { + if url == "" { + return nil + } + ctx := context.Background() + result := s.store.RepositoryGetByURL(ctx, url) + if result.IsErr() { + return nil + } + repo := result.Unwrap() + if repo == nil { + return nil + } + // Return a copy to prevent mutation + paths := repo.DocFilePaths + if len(paths) == 0 { + return nil + } + cp := make([]string, len(paths)) + copy(cp, paths) + return cp +} + +// --- RepositoryCommitTracker --- + +// SetRepoLastCommit stores the last commit hash for a repository. +func (s *Service) SetRepoLastCommit(url, name, branch, commit string) { + if url == "" || commit == "" { + return + } + ctx := context.Background() + store := s.store + + // Get existing repository to update + result := store.RepositoryGetByURL(ctx, url) + if result.IsErr() { + return + } + repo := result.Unwrap() + if repo == nil { + // Repository doesn't exist, create it first + s.EnsureRepositoryState(url, name, branch) + result = store.RepositoryGetByURL(ctx, url) + if result.IsErr() || result.Unwrap() == nil { + return + } + repo = result.Unwrap() + } + + // Update the repository with the new commit + repo.LastCommit = foundation.Some(commit) + repo.UpdatedAt = time.Now() + _ = store.RepositoryUpdate(ctx, repo) +} + +// GetRepoLastCommit returns the last commit hash for a repository. +func (s *Service) GetRepoLastCommit(url string) string { + if url == "" { + return "" + } + ctx := context.Background() + result := s.store.RepositoryGetByURL(ctx, url) + if result.IsErr() { + return "" + } + repo := result.Unwrap() + if repo == nil { + return "" + } + commitOpt := repo.LastCommit + if commitOpt.IsNone() { + return "" + } + return commitOpt.Unwrap() +} + +// --- RepositoryBuildCounter --- + +// IncrementRepoBuild increments build counters for a repository. +func (s *Service) IncrementRepoBuild(url string, success bool) { + if url == "" { + return + } + ctx := context.Background() + _ = s.store.RepositoryIncrementBuildCount(ctx, url, success) +} + +// --- ConfigurationStateStore --- + +// SetLastConfigHash stores the last config hash. +func (s *Service) SetLastConfigHash(hash string) { + if hash == "" { + return + } + ctx := context.Background() + _ = s.store.ConfigurationSet(ctx, "last_config_hash", hash) +} + +// GetLastConfigHash returns the last config hash. +func (s *Service) GetLastConfigHash() string { + ctx := context.Background() + result := s.store.ConfigurationGet(ctx, "last_config_hash") + if result.IsErr() { + return "" + } + opt := result.Unwrap() + if opt.IsNone() { + return "" + } + if s, ok := opt.Unwrap().(string); ok { + return s + } + return "" +} + +// SetLastReportChecksum stores the last report checksum. +func (s *Service) SetLastReportChecksum(sum string) { + if sum == "" { + return + } + ctx := context.Background() + _ = s.store.ConfigurationSet(ctx, "last_report_checksum", sum) +} + +// GetLastReportChecksum returns the last report checksum. +func (s *Service) GetLastReportChecksum() string { + ctx := context.Background() + result := s.store.ConfigurationGet(ctx, "last_report_checksum") + if result.IsErr() { + return "" + } + opt := result.Unwrap() + if opt.IsNone() { + return "" + } + if s, ok := opt.Unwrap().(string); ok { + return s + } + return "" +} + +// SetLastGlobalDocFilesHash stores the global doc files hash. +func (s *Service) SetLastGlobalDocFilesHash(hash string) { + if hash == "" { + return + } + ctx := context.Background() + _ = s.store.ConfigurationSet(ctx, "last_global_doc_files_hash", hash) +} + +// GetLastGlobalDocFilesHash returns the global doc files hash. +func (s *Service) GetLastGlobalDocFilesHash() string { + ctx := context.Background() + result := s.store.ConfigurationGet(ctx, "last_global_doc_files_hash") + if result.IsErr() { + return "" + } + opt := result.Unwrap() + if opt.IsNone() { + return "" + } + if s, ok := opt.Unwrap().(string); ok { + return s + } + return "" +} + +// --- DiscoveryRecorder --- + +// RecordDiscovery records a discovery operation for a repository. +func (s *Service) RecordDiscovery(repoURL string, documentCount int) { + if repoURL == "" { + return + } + ctx := context.Background() + + // Update repository state. (Statistics tracking is gone; the + // StatisticsStore was deleted as dead code in M12.) + repoStore := s.store + result := repoStore.RepositoryGetByURL(ctx, repoURL) + if result.IsOk() { + repo := result.Unwrap() + if repo != nil { + now := time.Now() + repo.LastDiscovery = foundation.Some(now) + repo.DocumentCount = documentCount + repo.UpdatedAt = now + _ = repoStore.RepositoryUpdate(ctx, repo) + } + } +} + +// --- Test helper --- + +// GetRepository returns the stored repository state for url, or nil if +// not found. It exposes the internal *Repository type so callers (notably +// the daemon integration tests) can inspect fields directly. +func (s *Service) GetRepository(url string) *Repository { + if url == "" { + return nil + } + ctx := context.Background() + result := s.store.RepositoryGetByURL(ctx, url) + if result.IsErr() { + return nil + } + return result.Unwrap() +} diff --git a/internal/state/per_store_methods.go b/internal/state/per_store_methods.go new file mode 100644 index 00000000..dca90482 --- /dev/null +++ b/internal/state/per_store_methods.go @@ -0,0 +1,294 @@ +package state + +// per_store_methods.go inlines the per-store methods (previously on +// jsonRepositoryStore, jsonConfigurationStore, jsonDaemonInfoStore +// wrappers) as methods on *JSONStore. The per-store interfaces have +// been removed from interfaces.go; *JSONStore IS the store. + +import ( + "context" + "errors" + "maps" + "sort" + "time" + + "git.home.luguber.info/inful/docbuilder/internal/foundation" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" +) + +// void is a typed nil for Result return values. +type void = struct{} + +// --- Repository operations (per-store interface inlined) --- + +// RepositoryCreate creates a new repository record. +func (js *JSONStore) RepositoryCreate(_ context.Context, repo *Repository) foundation.Result[*Repository, error] { + if repo == nil { + return foundation.Err[*Repository, error](errors.New("repository cannot be nil")) + } + if validationResult := repo.Validate(); !validationResult.Valid { + return foundation.Err[*Repository, error](validationResult.ToError()) + } + + js.mu.Lock() + defer js.mu.Unlock() + + if _, exists := js.repositories[repo.URL]; exists { + return foundation.Err[*Repository, error](derrors.NewError(derrors.CategoryValidation, "repository already exists: "+repo.URL).Build()) + } + + now := time.Now() + repo.CreatedAt = now + repo.UpdatedAt = now + js.repositories[repo.URL] = repo + + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + delete(js.repositories, repo.URL) + return foundation.Err[*Repository, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save repository").Build()) + } + } + return foundation.Ok[*Repository, error](repo) +} + +// RepositoryGetByURL retrieves a repository by its URL. +func (js *JSONStore) RepositoryGetByURL(_ context.Context, url string) foundation.Result[*Repository, error] { + if url == "" { + return foundation.Err[*Repository, error](errors.New("URL cannot be empty")) + } + js.mu.RLock() + defer js.mu.RUnlock() + if repo, exists := js.repositories[url]; exists { + repoCopy := *repo + return foundation.Ok[*Repository, error](&repoCopy) + } + return foundation.Err[*Repository, error](derrors.NewError(derrors.CategoryNotFound, "repository not found: "+url).Build()) +} + +// RepositoryUpdate updates an existing repository. +func (js *JSONStore) RepositoryUpdate(_ context.Context, repo *Repository) foundation.Result[*Repository, error] { + if repo == nil { + return foundation.Err[*Repository, error](errors.New("repository cannot be nil")) + } + if validationResult := repo.Validate(); !validationResult.Valid { + return foundation.Err[*Repository, error](validationResult.ToError()) + } + js.mu.Lock() + defer js.mu.Unlock() + if _, ok := js.repositories[repo.URL]; !ok { + return foundation.Err[*Repository, error](derrors.NewError(derrors.CategoryNotFound, "repository not found: "+repo.URL).Build()) + } + repo.UpdatedAt = time.Now() + js.repositories[repo.URL] = repo + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[*Repository, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save repository update").Build()) + } + } + return foundation.Ok[*Repository, error](repo) +} + +// RepositoryList returns all repositories, sorted by name. +func (js *JSONStore) RepositoryList(_ context.Context) foundation.Result[[]Repository, error] { + js.mu.RLock() + defer js.mu.RUnlock() + repositories := make([]Repository, 0, len(js.repositories)) + for _, repo := range js.repositories { + repositories = append(repositories, *repo) + } + sort.Slice(repositories, func(i, j int) bool { + return repositories[i].Name < repositories[j].Name + }) + return foundation.Ok[[]Repository, error](repositories) +} + +// RepositoryDelete removes a repository by URL. +func (js *JSONStore) RepositoryDelete(_ context.Context, url string) foundation.Result[void, error] { + js.mu.Lock() + defer js.mu.Unlock() + if _, exists := js.repositories[url]; !exists { + return foundation.Err[void, error](derrors.NewError(derrors.CategoryNotFound, "repository not found: "+url).Build()) + } + delete(js.repositories, url) + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save repository deletion").Build()) + } + } + return foundation.Ok[void, error](void{}) +} + +// RepositoryIncrementBuildCount increments build counters for a repository. +func (js *JSONStore) RepositoryIncrementBuildCount(_ context.Context, url string, success bool) foundation.Result[void, error] { + js.mu.Lock() + defer js.mu.Unlock() + repo, exists := js.repositories[url] + if !exists { + return foundation.Err[void, error](derrors.NewError(derrors.CategoryNotFound, "repository not found: "+url).Build()) + } + now := time.Now() + repo.LastBuild = foundation.Some(now) + repo.BuildCount++ + if !success { + repo.ErrorCount++ + } + repo.UpdatedAt = now + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save build count update").Build()) + } + } + return foundation.Ok[void, error](void{}) +} + +// RepositorySetDocumentCount updates the document count for a repository. +func (js *JSONStore) RepositorySetDocumentCount(_ context.Context, url string, count int) foundation.Result[void, error] { + if count < 0 { + return foundation.Err[void, error](errors.New("document count cannot be negative")) + } + js.mu.Lock() + defer js.mu.Unlock() + repo, exists := js.repositories[url] + if !exists { + return foundation.Err[void, error](derrors.NewError(derrors.CategoryNotFound, "repository not found: "+url).Build()) + } + repo.DocumentCount = count + repo.UpdatedAt = time.Now() + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save document count update").Build()) + } + } + return foundation.Ok[void, error](void{}) +} + +// RepositorySetDocFilesHash updates the document files hash for a repository. +func (js *JSONStore) RepositorySetDocFilesHash(_ context.Context, url, hash string) foundation.Result[void, error] { + js.mu.Lock() + defer js.mu.Unlock() + repo, exists := js.repositories[url] + if !exists { + return foundation.Err[void, error](derrors.NewError(derrors.CategoryNotFound, "repository not found: "+url).Build()) + } + repo.DocFilesHash = foundation.Some(hash) + repo.UpdatedAt = time.Now() + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save doc files hash update").Build()) + } + } + return foundation.Ok[void, error](void{}) +} + +// RepositorySetDocFilePaths updates the document file paths for a repository. +func (js *JSONStore) RepositorySetDocFilePaths(_ context.Context, url string, paths []string) foundation.Result[void, error] { + js.mu.Lock() + defer js.mu.Unlock() + repo, exists := js.repositories[url] + if !exists { + return foundation.Err[void, error](derrors.NewError(derrors.CategoryNotFound, "repository not found: "+url).Build()) + } + repo.DocFilePaths = append([]string{}, paths...) + repo.UpdatedAt = time.Now() + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save doc file paths update").Build()) + } + } + return foundation.Ok[void, error](void{}) +} + +// --- Configuration operations --- + +// ConfigurationSet stores a configuration value. +func (js *JSONStore) ConfigurationSet(_ context.Context, key string, value any) foundation.Result[void, error] { + if key == "" { + return foundation.Err[void, error](errors.New("configuration key cannot be empty")) + } + js.mu.Lock() + defer js.mu.Unlock() + js.configuration[key] = value + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save configuration").Build()) + } + } + return foundation.Ok[void, error](void{}) +} + +// ConfigurationGet retrieves a configuration value. +func (js *JSONStore) ConfigurationGet(_ context.Context, key string) foundation.Result[foundation.Option[any], error] { + js.mu.RLock() + defer js.mu.RUnlock() + if v, ok := js.configuration[key]; ok { + return foundation.Ok[foundation.Option[any], error](foundation.Some[any](v)) + } + return foundation.Ok[foundation.Option[any], error](foundation.None[any]()) +} + +// ConfigurationDelete removes a configuration key. +func (js *JSONStore) ConfigurationDelete(_ context.Context, key string) foundation.Result[void, error] { + js.mu.Lock() + defer js.mu.Unlock() + delete(js.configuration, key) + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save configuration deletion").Build()) + } + } + return foundation.Ok[void, error](void{}) +} + +// ConfigurationList returns all configuration keys and values. +func (js *JSONStore) ConfigurationList(_ context.Context) foundation.Result[map[string]any, error] { + js.mu.RLock() + defer js.mu.RUnlock() + cp := make(map[string]any, len(js.configuration)) + maps.Copy(cp, js.configuration) + return foundation.Ok[map[string]any, error](cp) +} + +// --- Daemon info operations --- + +// DaemonInfoGet retrieves daemon information. +func (js *JSONStore) DaemonInfoGet(_ context.Context) foundation.Result[*DaemonInfo, error] { + js.mu.RLock() + defer js.mu.RUnlock() + if js.daemonInfo == nil { + return foundation.Err[*DaemonInfo, error](errors.New("daemon info not initialized")) + } + cp := *js.daemonInfo + return foundation.Ok[*DaemonInfo, error](&cp) +} + +// DaemonInfoUpdate updates daemon information. +func (js *JSONStore) DaemonInfoUpdate(_ context.Context, info *DaemonInfo) foundation.Result[*DaemonInfo, error] { + if info == nil { + return foundation.Err[*DaemonInfo, error](errors.New("daemon info cannot be nil")) + } + js.mu.Lock() + defer js.mu.Unlock() + js.daemonInfo = info + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[*DaemonInfo, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save daemon info update").Build()) + } + } + return foundation.Ok[*DaemonInfo, error](info) +} + +// DaemonInfoUpdateStatus updates only the daemon status. +func (js *JSONStore) DaemonInfoUpdateStatus(_ context.Context, status string) foundation.Result[void, error] { + if status == "" { + return foundation.Err[void, error](errors.New("status cannot be empty")) + } + js.mu.Lock() + defer js.mu.Unlock() + js.daemonInfo.Status = status + if js.autoSaveEnabled { + if err := js.saveToDiskUnsafe(); err != nil { + return foundation.Err[void, error](derrors.WrapError(err, derrors.CategoryInternal, "failed to save daemon status update").Build()) + } + } + return foundation.Ok[void, error](void{}) +} diff --git a/internal/state/service.go b/internal/state/service.go index 7ed12802..f8570c39 100644 --- a/internal/state/service.go +++ b/internal/state/service.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "log/slog" + "sync" + "time" "git.home.luguber.info/inful/docbuilder/internal/foundation" "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" @@ -14,7 +16,12 @@ import ( // and integrates it with the service orchestrator. This bridges the gap between // the monolithic StateManager and the new composable state stores. type Service struct { - store Store + store *JSONStore + + // Cached lifecycle values (formerly on ServiceAdapter). + mu sync.RWMutex + loaded bool + lastSaved *time.Time } // NewService creates a new state service with the default JSON store. @@ -31,7 +38,7 @@ func NewService(dataDir string) foundation.Result[*Service, error] { // NewServiceWithStore creates a new state service with a custom store. // This allows for dependency injection and testing with mock stores. -func NewServiceWithStore(store Store, dataDir string) *Service { +func NewServiceWithStore(store *JSONStore, dataDir string) *Service { return &Service{ store: store, } @@ -61,8 +68,7 @@ func (ss *Service) Start(ctx context.Context) error { } // Update daemon status to running - daemonStore := ss.store.DaemonInfo() - updateResult := daemonStore.UpdateStatus(ctx, "running") + updateResult := ss.store.DaemonInfoUpdateStatus(ctx, "running") if updateResult.IsErr() { return errors.InternalError("failed to update daemon status to running"). WithCause(updateResult.UnwrapErr()). @@ -76,8 +82,7 @@ func (ss *Service) Start(ctx context.Context) error { // This gracefully shuts down the state service and ensures data is persisted. func (ss *Service) Stop(ctx context.Context) error { // Update daemon status to stopping - daemonStore := ss.store.DaemonInfo() - updateResult := daemonStore.UpdateStatus(ctx, "stopping") + updateResult := ss.store.DaemonInfoUpdateStatus(ctx, "stopping") if updateResult.IsErr() { // Log error but continue with shutdown slog.Warn("failed to update daemon status during shutdown", "error", updateResult.UnwrapErr()) @@ -120,45 +125,68 @@ func (ss *Service) Dependencies() []string { return []string{} // State service has no dependencies } -// Store returns the underlying state store for direct access. -// This allows other services to interact with state through the interfaces. -func (ss *Service) Store() Store { - return ss.store -} +// --- LifecycleManager surface (formerly on ServiceAdapter) --- -// GetRepositoryStore provides typed access to repository operations. -func (ss *Service) GetRepositoryStore() RepositoryStore { - return ss.store.Repositories() +// Load loads state from the underlying store. The typed JSON store +// loads on creation, so this is mostly a health check. +func (ss *Service) Load() error { + ctx := context.Background() + health := ss.store.Health(ctx) + if health.IsErr() { + return health.UnwrapErr() + } + if health.Unwrap().Status != healthyStatus { + return errors.InternalError("state store unhealthy"). + WithContext("status", health.Unwrap().Status). + Build() + } + ss.mu.Lock() + ss.loaded = true + ss.mu.Unlock() + return nil } -// GetBuildStore provides typed access to build operations. -func (ss *Service) GetBuildStore() BuildStore { - return ss.store.Builds() -} +// Save persists state to the underlying store. The typed JSON store +// auto-persists on every mutation; this method just updates the cached +// lastSaved timestamp and triggers a health-check flush. +func (ss *Service) Save() error { + ctx := context.Background() + ss.mu.Lock() + now := time.Now() + ss.lastSaved = &now + ss.mu.Unlock() -// GetScheduleStore provides typed access to schedule operations. -func (ss *Service) GetScheduleStore() ScheduleStore { - return ss.store.Schedules() + health := ss.store.Health(ctx) + if health.IsErr() { + return health.UnwrapErr() + } + return nil } -// GetStatisticsStore provides typed access to statistics operations. -func (ss *Service) GetStatisticsStore() StatisticsStore { - return ss.store.Statistics() +// IsLoaded returns whether the state has been loaded. +func (ss *Service) IsLoaded() bool { + ss.mu.RLock() + defer ss.mu.RUnlock() + return ss.loaded } -// GetConfigurationStore provides typed access to configuration operations. -func (ss *Service) GetConfigurationStore() ConfigurationStore { - return ss.store.Configuration() +// LastSaved returns the last save timestamp. +func (ss *Service) LastSaved() *time.Time { + ss.mu.RLock() + defer ss.mu.RUnlock() + return ss.lastSaved } -// GetDaemonInfoStore provides typed access to daemon info operations. -func (ss *Service) GetDaemonInfoStore() DaemonInfoStore { - return ss.store.DaemonInfo() +// Store returns the underlying *JSONStore for direct access. +// Callers can invoke the inlined per-capability methods directly +// (RepositoryCreate, ConfigurationGet, DaemonInfoUpdateStatus, etc). +func (ss *Service) Store() *JSONStore { + return ss.store } // WithTransaction executes operations within a transaction-like context. // This ensures consistency across multiple state operations. -func (ss *Service) WithTransaction(ctx context.Context, fn func(Store) error) foundation.Result[struct{}, error] { +func (ss *Service) WithTransaction(ctx context.Context, fn func(*JSONStore) error) foundation.Result[struct{}, error] { return ss.store.WithTransaction(ctx, fn) } @@ -169,75 +197,3 @@ func (ss *Service) Migrate(_ context.Context, _, _ string) foundation.Result[str // In a real implementation, this would handle schema changes between versions return foundation.Ok[struct{}, error](struct{}{}) } - -// Compact performs maintenance operations on the state store. -// For the JSON store, this might involve cleaning up old builds, compacting data, etc. -func (ss *Service) Compact(ctx context.Context) foundation.Result[struct{}, error] { - // Clean up old builds to prevent unbounded growth - buildStore := ss.GetBuildStore() - cleanupResult := buildStore.Cleanup(ctx, 1000) // Keep last 1000 builds - if cleanupResult.IsErr() { - return foundation.Err[struct{}, error]( - errors.InternalError("failed to cleanup old builds"). - WithCause(cleanupResult.UnwrapErr()). - Build(), - ) - } - - // Could add other maintenance operations here: - // - Statistics cleanup - // - Configuration validation - // - Data integrity checks - - return foundation.Ok[struct{}, error](struct{}{}) -} - -// ServiceStats returns service-level statistics about the state system. -type ServiceStats struct { - RepositoryCount int `json:"repository_count"` - BuildCount int `json:"build_count"` - ScheduleCount int `json:"schedule_count"` - StorageSize *int64 `json:"storage_size,omitempty"` - StoreType string `json:"store_type"` - IsHealthy bool `json:"is_healthy"` - LastBackup *int64 `json:"last_backup,omitempty"` -} - -func (ss *Service) GetStats(ctx context.Context) foundation.Result[ServiceStats, error] { - stats := ServiceStats{ - StoreType: "json", // Default for current implementation - } - - // Get health info - health := ss.store.Health(ctx) - if health.IsOk() { - stats.IsHealthy = health.Unwrap().Status == healthyStatus - if health.Unwrap().StorageSize != nil { - stats.StorageSize = health.Unwrap().StorageSize - } - if health.Unwrap().LastBackup != nil { - timestamp := health.Unwrap().LastBackup.Unix() - stats.LastBackup = ×tamp - } - } - - // Count repositories - repos := ss.store.Repositories().List(ctx) - if repos.IsOk() { - stats.RepositoryCount = len(repos.Unwrap()) - } - - // Count builds (using pagination to get total count) - builds := ss.store.Builds().List(ctx, ListOptions{}) - if builds.IsOk() { - stats.BuildCount = len(builds.Unwrap()) - } - - // Count schedules - schedules := ss.store.Schedules().List(ctx) - if schedules.IsOk() { - stats.ScheduleCount = len(schedules.Unwrap()) - } - - return foundation.Ok[ServiceStats, error](stats) -} diff --git a/internal/state/service_adapter.go b/internal/state/service_adapter.go deleted file mode 100644 index 7d225ae4..00000000 --- a/internal/state/service_adapter.go +++ /dev/null @@ -1,476 +0,0 @@ -package state - -import ( - "context" - "log/slog" - "sync" - "time" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// ServiceAdapter wraps a state.Service and implements the narrow interfaces -// defined in narrow_interfaces.go (DaemonStateManager). This is the canonical -// implementation for daemon state management. -// -// The adapter translates simple method signatures (no context, no Result types) -// to the typed Store method signatures (context + Result types). -type ServiceAdapter struct { - service *Service - mu sync.RWMutex - - // Cached values for lifecycle methods - loaded bool - lastSaved *time.Time -} - -// NewServiceAdapter creates an adapter that wraps a state.Service. -func NewServiceAdapter(svc *Service) *ServiceAdapter { - return &ServiceAdapter{ - service: svc, - loaded: false, - } -} - -// --- LifecycleManager interface --- - -// Load loads state from the underlying store. -func (a *ServiceAdapter) Load() error { - ctx := context.Background() - // The typed store loads on creation, so this is mostly a health check - health := a.service.Store().Health(ctx) - if health.IsErr() { - return health.UnwrapErr() - } - if health.Unwrap().Status != healthyStatus { - return errors.InternalError("state store unhealthy"). - WithContext("status", health.Unwrap().Status). - Build() - } - a.mu.Lock() - a.loaded = true - a.mu.Unlock() - return nil -} - -// Save persists state to the underlying store. -func (a *ServiceAdapter) Save() error { - ctx := context.Background() - // The typed JSON store auto-saves, but we trigger a close/reopen cycle - // for explicit save semantics, or just mark save time. - // For now, just update the save timestamp since JSONStore auto-persists. - a.mu.Lock() - now := time.Now() - a.lastSaved = &now - a.mu.Unlock() - - // Optionally flush by checking health (which internally persists) - health := a.service.Store().Health(ctx) - if health.IsErr() { - return health.UnwrapErr() - } - return nil -} - -// IsLoaded returns whether the state has been loaded. -func (a *ServiceAdapter) IsLoaded() bool { - a.mu.RLock() - defer a.mu.RUnlock() - return a.loaded -} - -// LastSaved returns the last save timestamp. -func (a *ServiceAdapter) LastSaved() *time.Time { - a.mu.RLock() - defer a.mu.RUnlock() - return a.lastSaved -} - -// --- RepositoryInitializer interface --- - -// EnsureRepositoryState creates a repository entry if it doesn't exist. -// For compatibility with legacy code, empty branch defaults to "main". -func (a *ServiceAdapter) EnsureRepositoryState(url, name, branch string) { - if url == "" { - return - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - - // Check if repository already exists - existing := store.GetByURL(ctx, url) - if existing.IsOk() { - if opt := existing.Unwrap(); opt.IsSome() { - return // Already exists - } - } - - // Default branch for compatibility with legacy code that passes empty branch - if branch == "" { - branch = defaultBranchMain - } - - // Create new repository - repo := &Repository{ - URL: url, - Name: name, - Branch: branch, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - _ = store.Create(ctx, repo) // Ignore error for interface compatibility -} - -// RemoveRepositoryState removes a repository entry from persistent state. -// It is used by daemon orchestration to reflect discovery removals. -func (a *ServiceAdapter) RemoveRepositoryState(url string) { - if url == "" { - return - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - res := store.Delete(ctx, url) - if res.IsErr() { - slog.Warn("Failed to delete repository state", - slog.String("url", url), - slog.Any("error", res.UnwrapErr())) - } -} - -// --- RepositoryMetadataWriter interface --- - -// SetRepoDocumentCount sets the document count for a repository. -func (a *ServiceAdapter) SetRepoDocumentCount(url string, count int) { - if url == "" || count < 0 { - return - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - _ = store.SetDocumentCount(ctx, url, count) -} - -// SetRepoDocFilesHash sets the document files hash for a repository. -func (a *ServiceAdapter) SetRepoDocFilesHash(url, hash string) { - if url == "" || hash == "" { - return - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - _ = store.SetDocFilesHash(ctx, url, hash) -} - -// --- RepositoryMetadataReader interface --- - -// GetRepoDocFilesHash returns the document files hash for a repository. -func (a *ServiceAdapter) GetRepoDocFilesHash(url string) string { - if url == "" { - return "" - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - result := store.GetByURL(ctx, url) - if result.IsErr() { - return "" - } - opt := result.Unwrap() - if opt.IsNone() { - return "" - } - hashOpt := opt.Unwrap().DocFilesHash - if hashOpt.IsNone() { - return "" - } - return hashOpt.Unwrap() -} - -// GetRepoDocFilePaths returns the document file paths for a repository. -func (a *ServiceAdapter) GetRepoDocFilePaths(url string) []string { - if url == "" { - return nil - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - result := store.GetByURL(ctx, url) - if result.IsErr() { - return nil - } - opt := result.Unwrap() - if opt.IsNone() { - return nil - } - // Return a copy to prevent mutation - paths := opt.Unwrap().DocFilePaths - if len(paths) == 0 { - return nil - } - cp := make([]string, len(paths)) - copy(cp, paths) - return cp -} - -// --- RepositoryMetadataStore interface (extends Reader + Writer) --- - -// SetRepoDocFilePaths sets the document file paths for a repository. -func (a *ServiceAdapter) SetRepoDocFilePaths(url string, paths []string) { - if url == "" { - return - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - _ = store.SetDocFilePaths(ctx, url, paths) -} - -// --- RepositoryCommitTracker interface --- - -// SetRepoLastCommit sets the last commit hash for a repository. -func (a *ServiceAdapter) SetRepoLastCommit(url, name, branch, commit string) { - if url == "" || commit == "" { - return - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - - // Get existing repository to update - result := store.GetByURL(ctx, url) - if result.IsErr() { - return - } - opt := result.Unwrap() - if opt.IsNone() { - // Repository doesn't exist, create it first - a.EnsureRepositoryState(url, name, branch) - } - - // Update the repository with the new commit - result = store.GetByURL(ctx, url) - if result.IsErr() || result.Unwrap().IsNone() { - return - } - repo := result.Unwrap().Unwrap() - repo.LastCommit = foundation.Some(commit) - repo.UpdatedAt = time.Now() - _ = store.Update(ctx, repo) -} - -// GetRepoLastCommit returns the last commit hash for a repository. -func (a *ServiceAdapter) GetRepoLastCommit(url string) string { - if url == "" { - return "" - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - result := store.GetByURL(ctx, url) - if result.IsErr() { - return "" - } - opt := result.Unwrap() - if opt.IsNone() { - return "" - } - commitOpt := opt.Unwrap().LastCommit - if commitOpt.IsNone() { - return "" - } - return commitOpt.Unwrap() -} - -// --- RepositoryBuildCounter interface --- - -// IncrementRepoBuild increments build counters for a repository. -func (a *ServiceAdapter) IncrementRepoBuild(url string, success bool) { - if url == "" { - return - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - _ = store.IncrementBuildCount(ctx, url, success) -} - -// --- ConfigurationStateStore interface --- - -// SetLastConfigHash stores the last config hash. -func (a *ServiceAdapter) SetLastConfigHash(hash string) { - if hash == "" { - return - } - ctx := context.Background() - store := a.service.GetConfigurationStore() - _ = store.Set(ctx, "last_config_hash", hash) -} - -// GetLastConfigHash returns the last config hash. -func (a *ServiceAdapter) GetLastConfigHash() string { - ctx := context.Background() - store := a.service.GetConfigurationStore() - result := store.Get(ctx, "last_config_hash") - if result.IsErr() { - return "" - } - opt := result.Unwrap() - if opt.IsNone() { - return "" - } - if s, ok := opt.Unwrap().(string); ok { - return s - } - return "" -} - -// SetLastReportChecksum stores the last report checksum. -func (a *ServiceAdapter) SetLastReportChecksum(sum string) { - if sum == "" { - return - } - ctx := context.Background() - store := a.service.GetConfigurationStore() - _ = store.Set(ctx, "last_report_checksum", sum) -} - -// GetLastReportChecksum returns the last report checksum. -func (a *ServiceAdapter) GetLastReportChecksum() string { - ctx := context.Background() - store := a.service.GetConfigurationStore() - result := store.Get(ctx, "last_report_checksum") - if result.IsErr() { - return "" - } - opt := result.Unwrap() - if opt.IsNone() { - return "" - } - if s, ok := opt.Unwrap().(string); ok { - return s - } - return "" -} - -// SetLastGlobalDocFilesHash stores the global doc files hash. -func (a *ServiceAdapter) SetLastGlobalDocFilesHash(hash string) { - if hash == "" { - return - } - ctx := context.Background() - store := a.service.GetConfigurationStore() - _ = store.Set(ctx, "last_global_doc_files_hash", hash) -} - -// GetLastGlobalDocFilesHash returns the global doc files hash. -func (a *ServiceAdapter) GetLastGlobalDocFilesHash() string { - ctx := context.Background() - store := a.service.GetConfigurationStore() - result := store.Get(ctx, "last_global_doc_files_hash") - if result.IsErr() { - return "" - } - opt := result.Unwrap() - if opt.IsNone() { - return "" - } - if s, ok := opt.Unwrap().(string); ok { - return s - } - return "" -} - -// --- Additional methods used by Daemon --- - -// RecordDiscovery records a discovery operation for a repository. -// This mimics the legacy StateManager.RecordDiscovery method. -func (a *ServiceAdapter) RecordDiscovery(repoURL string, documentCount int) { - if repoURL == "" { - return - } - ctx := context.Background() - - // Update statistics using RecordDiscovery method - statsStore := a.service.GetStatisticsStore() - _ = statsStore.RecordDiscovery(ctx, documentCount) - - // Update repository state - repoStore := a.service.GetRepositoryStore() - result := repoStore.GetByURL(ctx, repoURL) - if result.IsOk() { - if opt := result.Unwrap(); opt.IsSome() { - repo := opt.Unwrap() - now := time.Now() - repo.LastDiscovery = foundation.Some(now) - repo.DocumentCount = documentCount - repo.UpdatedAt = now - _ = repoStore.Update(ctx, repo) - } - } -} - -// --- Test helper methods --- - -// RepositoryState is a simplified view of repository state for test compatibility. -// Uses plain types instead of foundation.Option for easier test assertions. -type RepositoryState struct { - URL string - Name string - Branch string - LastDiscovery *time.Time - LastBuild *time.Time - LastCommit string - DocumentCount int - BuildCount int64 - ErrorCount int64 - LastError string - DocFilesHash string - DocFilePaths []string -} - -// GetRepository retrieves repository state by URL, returning nil if not found. -// This is a convenience method primarily for test assertions. -func (a *ServiceAdapter) GetRepository(url string) *RepositoryState { - if url == "" { - return nil - } - ctx := context.Background() - store := a.service.GetRepositoryStore() - result := store.GetByURL(ctx, url) - if result.IsErr() { - return nil - } - opt := result.Unwrap() - if opt.IsNone() { - return nil - } - repo := opt.Unwrap() - - // Convert from state.Repository to RepositoryState - rs := &RepositoryState{ - URL: repo.URL, - Name: repo.Name, - Branch: repo.Branch, - DocumentCount: repo.DocumentCount, - BuildCount: repo.BuildCount, - ErrorCount: repo.ErrorCount, - DocFilePaths: repo.DocFilePaths, - } - - // Convert Option types to plain types/pointers - if repo.LastDiscovery.IsSome() { - t := repo.LastDiscovery.Unwrap() - rs.LastDiscovery = &t - } - if repo.LastBuild.IsSome() { - t := repo.LastBuild.Unwrap() - rs.LastBuild = &t - } - if repo.LastCommit.IsSome() { - rs.LastCommit = repo.LastCommit.Unwrap() - } - if repo.LastError.IsSome() { - rs.LastError = repo.LastError.Unwrap() - } - if repo.DocFilesHash.IsSome() { - rs.DocFilesHash = repo.DocFilesHash.Unwrap() - } - - return rs -} - -// Compile-time verification that ServiceAdapter implements DaemonStateManager. -var _ DaemonStateManager = (*ServiceAdapter)(nil) diff --git a/internal/state/service_adapter_test.go b/internal/state/service_adapter_test.go deleted file mode 100644 index 426a6aa9..00000000 --- a/internal/state/service_adapter_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package state - -import ( - "testing" - "time" -) - -const testRepoURL = "https://github.com/test/repo" - -func TestServiceAdapter(t *testing.T) { - // Create a temporary directory for the test - tmpDir := t.TempDir() - - // Create the underlying service - serviceResult := NewService(tmpDir) - if serviceResult.IsErr() { - t.Fatalf("Failed to create service: %v", serviceResult.UnwrapErr()) - } - service := serviceResult.Unwrap() - - // Create the adapter - adapter := NewServiceAdapter(service) - - t.Run("LifecycleManager interface", func(t *testing.T) { - // Test Load - if err := adapter.Load(); err != nil { - t.Errorf("Load() failed: %v", err) - } - - // Test IsLoaded - if !adapter.IsLoaded() { - t.Error("IsLoaded() should return true after Load()") - } - - // Test Save - if err := adapter.Save(); err != nil { - t.Errorf("Save() failed: %v", err) - } - - // Test LastSaved - if adapter.LastSaved() == nil { - t.Error("LastSaved() should return non-nil after Save()") - } - }) - - t.Run("RepositoryInitializer interface", func(t *testing.T) { - url := testRepoURL - name := "test-repo" - branch := defaultBranchMain - - // Ensure repository state creates a new entry - adapter.EnsureRepositoryState(url, name, branch) - - // Verify the repository was created - docHash := adapter.GetRepoDocFilesHash(url) - // Should return empty string for new repo - if docHash != "" { - t.Errorf("Expected empty doc hash for new repo, got: %s", docHash) - } - }) - - t.Run("RepositoryMetadataWriter interface", func(t *testing.T) { - url := testRepoURL - - // Set document count - adapter.SetRepoDocumentCount(url, 42) - - // Set doc files hash - adapter.SetRepoDocFilesHash(url, "abc123hash") - - // Verify the hash was set - hash := adapter.GetRepoDocFilesHash(url) - if hash != "abc123hash" { - t.Errorf("Expected hash 'abc123hash', got: %s", hash) - } - }) - - t.Run("RepositoryMetadataStore interface", func(t *testing.T) { - url := testRepoURL - paths := []string{"docs/index.md", "docs/guide.md", "docs/api.md"} - - // Set doc file paths - adapter.SetRepoDocFilePaths(url, paths) - - // Get doc file paths - gotPaths := adapter.GetRepoDocFilePaths(url) - if len(gotPaths) != len(paths) { - t.Errorf("Expected %d paths, got %d", len(paths), len(gotPaths)) - } - for i, p := range paths { - if gotPaths[i] != p { - t.Errorf("Path mismatch at index %d: expected %s, got %s", i, p, gotPaths[i]) - } - } - }) - - t.Run("RepositoryCommitTracker interface", func(t *testing.T) { - url := testRepoURL - commit := "abc123def456" - - // Set last commit - adapter.SetRepoLastCommit(url, "test-repo", defaultBranchMain, commit) - - // Get last commit - gotCommit := adapter.GetRepoLastCommit(url) - if gotCommit != commit { - t.Errorf("Expected commit '%s', got: %s", commit, gotCommit) - } - }) - - t.Run("RepositoryBuildCounter interface", func(t *testing.T) { - url := testRepoURL - - // Increment build count (success) - adapter.IncrementRepoBuild(url, true) - - // Increment build count (failure) - adapter.IncrementRepoBuild(url, false) - - // No direct getter in narrow interface, but the operation should not panic - }) - - t.Run("ConfigurationStateStore interface", func(t *testing.T) { - // Test config hash - adapter.SetLastConfigHash("config-hash-123") - if got := adapter.GetLastConfigHash(); got != "config-hash-123" { - t.Errorf("Expected config hash 'config-hash-123', got: %s", got) - } - - // Test report checksum - adapter.SetLastReportChecksum("report-checksum-456") - if got := adapter.GetLastReportChecksum(); got != "report-checksum-456" { - t.Errorf("Expected report checksum 'report-checksum-456', got: %s", got) - } - - // Test global doc files hash - adapter.SetLastGlobalDocFilesHash("global-hash-789") - if got := adapter.GetLastGlobalDocFilesHash(); got != "global-hash-789" { - t.Errorf("Expected global hash 'global-hash-789', got: %s", got) - } - }) - - t.Run("RecordDiscovery method", func(t *testing.T) { - url := "https://github.com/test/another-repo" - - // First ensure the repo exists - adapter.EnsureRepositoryState(url, "another-repo", defaultBranchMain) - - // Record discovery - adapter.RecordDiscovery(url, 15) - - // The operation should not panic and should update the repository - // We can't easily verify statistics without the full interface, but - // the operation completing without error is the main check - }) - - t.Run("DaemonStateManager compile-time verification", func(t *testing.T) { - // This test verifies at compile time that ServiceAdapter implements DaemonStateManager - var _ DaemonStateManager = adapter - }) - - t.Run("Empty URL handling", func(t *testing.T) { - // These should not panic and should be no-ops - adapter.EnsureRepositoryState("", "name", "branch") - adapter.SetRepoDocumentCount("", 10) - adapter.SetRepoDocFilesHash("", "hash") - adapter.SetRepoDocFilePaths("", []string{"path"}) - adapter.SetRepoLastCommit("", "name", "branch", "commit") - adapter.IncrementRepoBuild("", true) - adapter.RecordDiscovery("", 10) - - // Getters should return empty/nil for empty URL - if got := adapter.GetRepoDocFilesHash(""); got != "" { - t.Errorf("Expected empty string for empty URL, got: %s", got) - } - if got := adapter.GetRepoDocFilePaths(""); got != nil { - t.Errorf("Expected nil for empty URL, got: %v", got) - } - if got := adapter.GetRepoLastCommit(""); got != "" { - t.Errorf("Expected empty string for empty URL, got: %s", got) - } - }) - - t.Run("Non-existent repository handling", func(t *testing.T) { - nonExistentURL := "https://github.com/does/not-exist" - - // Getters should return empty/nil for non-existent repos - if got := adapter.GetRepoDocFilesHash(nonExistentURL); got != "" { - t.Errorf("Expected empty string for non-existent repo, got: %s", got) - } - if got := adapter.GetRepoDocFilePaths(nonExistentURL); got != nil { - t.Errorf("Expected nil for non-existent repo, got: %v", got) - } - if got := adapter.GetRepoLastCommit(nonExistentURL); got != "" { - t.Errorf("Expected empty string for non-existent repo, got: %s", got) - } - }) -} - -func TestServiceAdapterSaveTimestamp(t *testing.T) { - tmpDir := t.TempDir() - - serviceResult := NewService(tmpDir) - if serviceResult.IsErr() { - t.Fatalf("Failed to create service: %v", serviceResult.UnwrapErr()) - } - - adapter := NewServiceAdapter(serviceResult.Unwrap()) - - // Initially LastSaved should be nil - if adapter.LastSaved() != nil { - t.Error("LastSaved() should be nil initially") - } - - // After Save, it should be set - before := time.Now() - if err := adapter.Save(); err != nil { - t.Fatalf("Save() failed: %v", err) - } - after := time.Now() - - saved := adapter.LastSaved() - if saved == nil { - t.Fatal("LastSaved() should not be nil after Save()") - return - } - if saved.Before(before) || saved.After(after) { - t.Errorf("LastSaved() timestamp out of expected range") - } -} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 99d4bd12..78d98002 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -4,7 +4,6 @@ import ( "os" "path/filepath" "testing" - "time" "git.home.luguber.info/inful/docbuilder/internal/foundation" "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" @@ -13,8 +12,6 @@ import ( // TestJSONStore demonstrates basic functionality of the new state management system. func TestJSONStore(t *testing.T) { t.Run("Repository Operations", testRepositoryOperations) - t.Run("Build Operations", testBuildOperations) - t.Run("Statistics Operations", testStatisticsOperations) t.Run("Transaction Operations", testTransactionOperations) t.Run("Health Check", testHealthCheck) t.Run("Persistence", testPersistence) @@ -23,7 +20,6 @@ func TestJSONStore(t *testing.T) { func testRepositoryOperations(t *testing.T) { store := createTestStore(t) ctx := t.Context() - repoStore := store.Repositories() // Create a repository repo := &Repository{ @@ -32,28 +28,28 @@ func testRepositoryOperations(t *testing.T) { Branch: defaultBranchMain, } - createResult := repoStore.Create(ctx, repo) + createResult := store.RepositoryCreate(ctx, repo) if createResult.IsErr() { t.Fatalf("Failed to create repository: %v", createResult.UnwrapErr()) } // Retrieve the repository - getResult := repoStore.GetByURL(ctx, repo.URL) + getResult := store.RepositoryGetByURL(ctx, repo.URL) if getResult.IsErr() { - t.Fatalf("Failed to get repository: %v", getResult.UnwrapErr()) + t.Fatalf("Failed to get repository: %v", createResult.UnwrapErr()) } - if getResult.Unwrap().IsNone() { + if getResult.Unwrap() == nil { t.Fatal("Repository not found after creation") } - retrieved := getResult.Unwrap().Unwrap() + retrieved := getResult.Unwrap() if retrieved.Name != repo.Name { t.Errorf("Expected name %q, got %q", repo.Name, retrieved.Name) } // List repositories - listResult := repoStore.List(ctx) + listResult := store.RepositoryList(ctx) if listResult.IsErr() { t.Fatalf("Failed to list repositories: %v", listResult.UnwrapErr()) } @@ -64,35 +60,35 @@ func testRepositoryOperations(t *testing.T) { } // Increment build count - incrementResult := repoStore.IncrementBuildCount(ctx, repo.URL, true) + incrementResult := store.RepositoryIncrementBuildCount(ctx, repo.URL, true) if incrementResult.IsErr() { t.Fatalf("Failed to increment build count: %v", incrementResult.UnwrapErr()) } // Verify build count increased - getResult = repoStore.GetByURL(ctx, repo.URL) + getResult = store.RepositoryGetByURL(ctx, repo.URL) if getResult.IsErr() { t.Fatalf("Failed to get repository after increment: %v", getResult.UnwrapErr()) } - updated := getResult.Unwrap().Unwrap() + updated := getResult.Unwrap() if updated.BuildCount != 1 { t.Errorf("Expected build count 1, got %d", updated.BuildCount) } // Update repository metadata for existing record - if hashResult := repoStore.SetDocFilesHash(ctx, repo.URL, "abc123"); hashResult.IsErr() { + if hashResult := store.RepositorySetDocFilesHash(ctx, repo.URL, "abc123"); hashResult.IsErr() { t.Fatalf("Failed to set doc files hash: %v", hashResult.UnwrapErr()) } - if pathsResult := repoStore.SetDocFilePaths(ctx, repo.URL, []string{"docs/a.md", "docs/b.md"}); pathsResult.IsErr() { + if pathsResult := store.RepositorySetDocFilePaths(ctx, repo.URL, []string{"docs/a.md", "docs/b.md"}); pathsResult.IsErr() { t.Fatalf("Failed to set doc file paths: %v", pathsResult.UnwrapErr()) } - metaResult := repoStore.GetByURL(ctx, repo.URL) - if metaResult.IsErr() || metaResult.Unwrap().IsNone() { + metaResult := store.RepositoryGetByURL(ctx, repo.URL) + if metaResult.IsErr() || metaResult.Unwrap() == nil { t.Fatalf("Failed to reload repository for metadata checks: %v", metaResult.UnwrapErr()) } - meta := metaResult.Unwrap().Unwrap() + meta := metaResult.Unwrap() if meta.DocFilesHash.IsNone() || meta.DocFilesHash.Unwrap() != "abc123" { t.Fatalf("Doc files hash not stored correctly: %+v", meta.DocFilesHash) } @@ -101,11 +97,11 @@ func testRepositoryOperations(t *testing.T) { } t.Run("Repository metadata requires existing repo", func(t *testing.T) { - testRepositoryMetadataValidation(t, repoStore) + testRepositoryMetadataValidation(t, store) }) } -func testRepositoryMetadataValidation(t *testing.T, repoStore RepositoryStore) { +func testRepositoryMetadataValidation(t *testing.T, store *JSONStore) { t.Helper() ctx := t.Context() missingURL := "https://github.com/example/missing.git" @@ -116,25 +112,25 @@ func testRepositoryMetadataValidation(t *testing.T, repoStore RepositoryStore) { { name: "increment", call: func() foundation.Result[struct{}, error] { - return repoStore.IncrementBuildCount(ctx, missingURL, true) + return store.RepositoryIncrementBuildCount(ctx, missingURL, true) }, }, { name: "doc-count", call: func() foundation.Result[struct{}, error] { - return repoStore.SetDocumentCount(ctx, missingURL, 1) + return store.RepositorySetDocumentCount(ctx, missingURL, 1) }, }, { name: "doc-hash", call: func() foundation.Result[struct{}, error] { - return repoStore.SetDocFilesHash(ctx, missingURL, "hash") + return store.RepositorySetDocFilesHash(ctx, missingURL, "hash") }, }, { name: "doc-paths", call: func() foundation.Result[struct{}, error] { - return repoStore.SetDocFilePaths(ctx, missingURL, []string{"a"}) + return store.RepositorySetDocFilePaths(ctx, missingURL, []string{"a"}) }, }, } @@ -151,131 +147,25 @@ func testRepositoryMetadataValidation(t *testing.T, repoStore RepositoryStore) { } } -func testBuildOperations(t *testing.T) { - store := createTestStore(t) - ctx := t.Context() - buildStore := store.Builds() - - // Create a build - build := &Build{ - ID: "build-123", - Status: BuildStatusRunning, - StartTime: time.Now(), - TriggeredBy: "manual", - } - - createResult := buildStore.Create(ctx, build) - if createResult.IsErr() { - t.Fatalf("Failed to create build: %v", createResult.UnwrapErr()) - } - - // Retrieve the build - getResult := buildStore.GetByID(ctx, build.ID) - if getResult.IsErr() { - t.Fatalf("Failed to get build: %v", getResult.UnwrapErr()) - } - - if getResult.Unwrap().IsNone() { - t.Fatal("Build not found after creation") - } - - retrieved := getResult.Unwrap().Unwrap() - if retrieved.Status != build.Status { - t.Errorf("Expected status %v, got %v", build.Status, retrieved.Status) - } - - // Update build status - build.Status = BuildStatusCompleted - build.EndTime = foundation.Some(time.Now()) - - updateResult := buildStore.Update(ctx, build) - if updateResult.IsErr() { - t.Fatalf("Failed to update build: %v", updateResult.UnwrapErr()) - } - - // List builds - listResult := buildStore.List(ctx, ListOptions{}) - if listResult.IsErr() { - t.Fatalf("Failed to list builds: %v", listResult.UnwrapErr()) - } - - builds := listResult.Unwrap() - if len(builds) != 1 { - t.Errorf("Expected 1 build, got %d", len(builds)) - } -} - -func testStatisticsOperations(t *testing.T) { - store := createTestStore(t) - ctx := t.Context() - statsStore := store.Statistics() - - // Get initial statistics - getResult := statsStore.Get(ctx) - if getResult.IsErr() { - t.Fatalf("Failed to get statistics: %v", getResult.UnwrapErr()) - } - - stats := getResult.Unwrap() - initialBuilds := stats.TotalBuilds - - // Record a build - build := &Build{ - ID: "stats-test-build", - Status: BuildStatusCompleted, - } - - recordResult := statsStore.RecordBuild(ctx, build) - if recordResult.IsErr() { - t.Fatalf("Failed to record build: %v", recordResult.UnwrapErr()) - } - - // Verify statistics updated - getResult = statsStore.Get(ctx) - if getResult.IsErr() { - t.Fatalf("Failed to get updated statistics: %v", getResult.UnwrapErr()) - } - - updatedStats := getResult.Unwrap() - if updatedStats.TotalBuilds != initialBuilds+1 { - t.Errorf("Expected total builds %d, got %d", initialBuilds+1, updatedStats.TotalBuilds) - } - if updatedStats.SuccessfulBuilds == 0 { - t.Error("Expected successful builds to be incremented") - } -} - func testTransactionOperations(t *testing.T) { t.Skip("FIXME: Deadlock in transaction test - needs refactoring of lock-free operations") store := createTestStore(t) ctx := t.Context() - txResult := store.WithTransaction(ctx, func(txStore Store) error { - // Create repository and build in transaction + txResult := store.WithTransaction(ctx, func(txStore *JSONStore) error { + // Create repository in transaction (build store removed as dead code) repo := &Repository{ URL: "https://github.com/tx/repo.git", Name: "tx-repo", Branch: defaultBranchMain, } - createResult := txStore.Repositories().Create(ctx, repo) + createResult := txStore.RepositoryCreate(ctx, repo) if createResult.IsErr() { return createResult.UnwrapErr() } - build := &Build{ - ID: "tx-build", - Status: BuildStatusCompleted, - StartTime: time.Now(), - TriggeredBy: "transaction-test", - } - - buildResult := txStore.Builds().Create(ctx, build) - if buildResult.IsErr() { - return buildResult.UnwrapErr() - } - return nil }) @@ -283,16 +173,11 @@ func testTransactionOperations(t *testing.T) { t.Fatalf("Transaction failed: %v", txResult.UnwrapErr()) } - // Verify both items were created - getRepoResult := store.Repositories().GetByURL(ctx, "https://github.com/tx/repo.git") - if getRepoResult.IsErr() || getRepoResult.Unwrap().IsNone() { + // Verify the repository was created + getRepoResult := store.RepositoryGetByURL(ctx, "https://github.com/tx/repo.git") + if getRepoResult.IsErr() || getRepoResult.Unwrap() == nil { t.Error("Repository not found after transaction") } - - getBuildResult := store.Builds().GetByID(ctx, "tx-build") - if getBuildResult.IsErr() || getBuildResult.Unwrap().IsNone() { - t.Error("Build not found after transaction") - } } func testHealthCheck(t *testing.T) { @@ -327,7 +212,7 @@ func testPersistence(t *testing.T) { Name: "persist-repo", Branch: defaultBranchMain, } - if createResult := store.Repositories().Create(ctx, repo); createResult.IsErr() { + if createResult := store.RepositoryCreate(ctx, repo); createResult.IsErr() { t.Fatalf("Failed to create test repository: %v", createResult.UnwrapErr()) } @@ -345,7 +230,7 @@ func testPersistence(t *testing.T) { newStore := newStoreResult.Unwrap() // Verify data persisted - listResult := newStore.Repositories().List(ctx) + listResult := newStore.RepositoryList(ctx) if listResult.IsErr() { t.Fatalf("Failed to list repositories from new store: %v", listResult.UnwrapErr()) } @@ -363,7 +248,7 @@ func testPersistence(t *testing.T) { } // createTestStore is a helper to create a test store. -func createTestStore(t *testing.T) Store { +func createTestStore(t *testing.T) *JSONStore { t.Helper() tmpDir := t.TempDir() storeResult := NewJSONStore(tmpDir) @@ -415,31 +300,10 @@ func TestStateService(t *testing.T) { // Test store access through service t.Run("Store Access", func(t *testing.T) { - repoStore := service.GetRepositoryStore() - buildStore := service.GetBuildStore() - statsStore := service.GetStatisticsStore() - - if repoStore == nil { - t.Error("Repository store is nil") - } - if buildStore == nil { - t.Error("Build store is nil") - } - if statsStore == nil { - t.Error("Statistics store is nil") - } - }) - - // Test service statistics - t.Run("Service Statistics", func(t *testing.T) { - statsResult := service.GetStats(ctx) - if statsResult.IsErr() { - t.Fatalf("Failed to get service stats: %v", statsResult.UnwrapErr()) - } + store := service.Store() - stats := statsResult.Unwrap() - if stats.StoreType != "json" { - t.Errorf("Expected store type 'json', got %q", stats.StoreType) + if store == nil { + t.Error("Store is nil") } }) } diff --git a/internal/state/store_helpers.go b/internal/state/store_helpers.go deleted file mode 100644 index 5b895735..00000000 --- a/internal/state/store_helpers.go +++ /dev/null @@ -1,129 +0,0 @@ -package state - -import ( - "sync" - - "git.home.luguber.info/inful/docbuilder/internal/foundation" - "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" -) - -// Validatable represents an entity that can be validated. -type Validatable interface { - Validate() foundation.ValidationResult -} - -// ptrValidatable constrains P to be a pointer to T and also Validatable. -// This allows nil checks in generic helpers. -type ptrValidatable[T any] interface { - ~*T - Validatable -} - -// createEntity is a generic helper for creating entities with timestamp setting and auto-save. -// T must be a pointer type to an entity with ID, CreatedAt, and UpdatedAt fields. -func createEntity[T Validatable]( - entity T, - entityName string, - mu *sync.RWMutex, - setTimestamps func(T), - addToStore func(T), - removeFromStore func(T), - autoSaveEnabled bool, - saveToDisk func() error, -) foundation.Result[T, error] { - if validationResult := entity.Validate(); !validationResult.Valid { - return foundation.Err[T, error](validationResult.ToError()) - } - - mu.Lock() - defer mu.Unlock() - - setTimestamps(entity) - addToStore(entity) - - if autoSaveEnabled { - if err := saveToDisk(); err != nil { - removeFromStore(entity) - return foundation.Err[T, error]( - errors.InternalError("failed to save " + entityName).WithCause(err).Build(), - ) - } - } - - return foundation.Ok[T, error](entity) -} - -// updateValidatableEntity is a generic helper for update flows where: -// - entity is pointer-like and can be nil -// - entity validates itself via Validate() -// - UpdatedAt should be bumped on successful update -// -// It centralizes the common patterns used across Build/Repository/Schedule stores, -// avoiding repeated code that triggers the dupl linter. -func updateValidatableEntity[T any, P ptrValidatable[T]]( - js *JSONStore, - entityName string, - entity P, - exists func() bool, - setUpdatedAt func(), - write func(), - onNotFound func() foundation.Result[P, error], - saveErrMsg string, -) foundation.Result[P, error] { - if entity == nil { - return foundation.Err[P, error]( - errors.ValidationError(entityName + " cannot be nil").Build(), - ) - } - - if validationResult := entity.Validate(); !validationResult.Valid { - return foundation.Err[P, error](validationResult.ToError()) - } - - js.mu.Lock() - defer js.mu.Unlock() - - if !exists() { - return onNotFound() - } - - setUpdatedAt() - - write() - - if js.autoSaveEnabled { - if err := js.saveToDiskUnsafe(); err != nil { - return foundation.Err[P, error]( - errors.InternalError(saveErrMsg).WithCause(err).Build(), - ) - } - } - - return foundation.Ok[P, error](entity) -} - -// updateSimpleEntity is a variant for entities that don't track UpdatedAt -// (like DaemonInfo and Statistics that use their own timestamp fields). -func updateSimpleEntity[T any]( - js *JSONStore, - obj *T, - updateTimestamp func(), - write func(), - saveErrMsg string, -) foundation.Result[*T, error] { - js.mu.Lock() - defer js.mu.Unlock() - - updateTimestamp() - write() - - if js.autoSaveEnabled { - if err := js.saveToDiskUnsafe(); err != nil { - return foundation.Err[*T, error]( - errors.InternalError(saveErrMsg).WithCause(err).Build(), - ) - } - } - - return foundation.Ok[*T, error](obj) -} diff --git a/internal/templates/discovery.go b/internal/templates/discovery.go index 4d24c2a6..6d0ccdce 100644 --- a/internal/templates/discovery.go +++ b/internal/templates/discovery.go @@ -2,13 +2,14 @@ package templates import ( "errors" - "fmt" "io" "net/url" "slices" "strings" "golang.org/x/net/html" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // TemplateLink represents a template discovered from the rendered documentation site. @@ -55,12 +56,12 @@ func ParseTemplateDiscovery(r io.Reader, baseURL string) ([]TemplateLink, error) } parsedBase, err := url.Parse(baseURL) if err != nil { - return nil, fmt.Errorf("parse base URL: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "parse base URL").Build() } doc, err := html.Parse(r) if err != nil { - return nil, fmt.Errorf("parse discovery HTML: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "parse discovery HTML").Build() } var results []TemplateLink diff --git a/internal/templates/http_fetch.go b/internal/templates/http_fetch.go index 2fa6f2aa..2c0f5947 100644 --- a/internal/templates/http_fetch.go +++ b/internal/templates/http_fetch.go @@ -9,6 +9,8 @@ import ( "net/url" "strings" "time" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // maxTemplateResponseBytes is the maximum size of template page responses (5MB). @@ -123,25 +125,30 @@ func FetchTemplatePage(ctx context.Context, templateURL string, client *http.Cli func fetchHTML(ctx context.Context, pageURL string, client *http.Client) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL, http.NoBody) if err != nil { - return nil, fmt.Errorf("build request: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryNetwork, "build request").Build() } resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("fetch %s: %w", pageURL, err) + return nil, derrors.WrapError(err, derrors.CategoryNetwork, "fetch page"). + WithContext("url", pageURL). + Build() } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("fetch %s: HTTP %d", pageURL, resp.StatusCode) + return nil, derrors.NewError(derrors.CategoryNetwork, fmt.Sprintf("fetch %s: HTTP %d", pageURL, resp.StatusCode)). + WithContext("url", pageURL). + WithContext("status", resp.StatusCode). + Build() } limited := io.LimitReader(resp.Body, maxTemplateResponseBytes+1) data, err := io.ReadAll(limited) if err != nil { - return nil, fmt.Errorf("read response: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryNetwork, "read response").Build() } if len(data) > maxTemplateResponseBytes { return nil, errors.New("response too large") @@ -156,10 +163,12 @@ func fetchHTML(ctx context.Context, pageURL string, client *http.Client) ([]byte func validateTemplateURL(raw string) (*url.URL, error) { parsed, err := url.Parse(raw) if err != nil { - return nil, fmt.Errorf("invalid URL: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "invalid URL").Build() } if parsed.Scheme != "http" && parsed.Scheme != "https" { - return nil, fmt.Errorf("unsupported URL scheme: %s", parsed.Scheme) + return nil, derrors.NewError(derrors.CategoryValidation, "unsupported URL scheme"). + WithContext("scheme", parsed.Scheme). + Build() } return parsed, nil } diff --git a/internal/templates/inputs.go b/internal/templates/inputs.go index aa1f4a96..e7091065 100644 --- a/internal/templates/inputs.go +++ b/internal/templates/inputs.go @@ -6,6 +6,8 @@ import ( "slices" "strconv" "strings" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // FieldType defines the supported input field types for template schemas. @@ -162,7 +164,9 @@ func validateRequiredFields(schema TemplateSchema, values map[string]any) error } value, ok := values[field.Key] if !ok || value == nil { - return fmt.Errorf("missing required field: %s", field.Key) + return derrors.NewError(derrors.CategoryValidation, "missing required template field"). + WithContext("field", field.Key). + Build() } } return nil @@ -186,7 +190,9 @@ func parseInputValue(field SchemaField, input string) (any, bool, error) { if slices.Contains(field.Options, value) { return value, true, nil } - return nil, false, fmt.Errorf("invalid value for %s", field.Key) + return nil, false, derrors.NewError(derrors.CategoryValidation, "invalid value for "+field.Key). + WithContext("field", field.Key). + Build() } return value, true, nil case FieldTypeStringList: @@ -205,10 +211,14 @@ func parseInputValue(field SchemaField, input string) (any, bool, error) { case FieldTypeBool: parsed, err := strconv.ParseBool(strings.ToLower(value)) if err != nil { - return nil, false, fmt.Errorf("invalid boolean for %s", field.Key) + return nil, false, derrors.NewError(derrors.CategoryValidation, "invalid boolean for template field"). + WithContext("field", field.Key). + Build() } return parsed, true, nil default: - return nil, false, fmt.Errorf("unsupported field type: %s", field.Type) + return nil, false, derrors.NewError(derrors.CategoryValidation, fmt.Sprintf("unsupported field type: %s", field.Type)). + WithContext("type", field.Type). + Build() } } diff --git a/internal/templates/output_path.go b/internal/templates/output_path.go index d7384740..8b21c5cc 100644 --- a/internal/templates/output_path.go +++ b/internal/templates/output_path.go @@ -3,8 +3,9 @@ package templates import ( "bytes" "errors" - "fmt" "text/template" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // RenderOutputPath renders the output path template string using Go's text/template engine. @@ -45,12 +46,12 @@ func RenderOutputPath(pathTemplate string, data map[string]any, nextSequence fun tpl, err := template.New("output_path").Funcs(funcs).Option("missingkey=error").Parse(pathTemplate) if err != nil { - return "", fmt.Errorf("parse output path template: %w", err) + return "", derrors.WrapError(err, derrors.CategoryValidation, "parse output path template").Build() } var buf bytes.Buffer if err := tpl.Execute(&buf, data); err != nil { - return "", fmt.Errorf("render output path template: %w", err) + return "", derrors.WrapError(err, derrors.CategoryValidation, "render output path template").Build() } return buf.String(), nil } diff --git a/internal/templates/render.go b/internal/templates/render.go index 67c6ae5c..23216eb8 100644 --- a/internal/templates/render.go +++ b/internal/templates/render.go @@ -3,8 +3,9 @@ package templates import ( "bytes" "errors" - "fmt" "text/template" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // RenderTemplateBody renders a template body using Go's text/template engine. @@ -45,12 +46,12 @@ func RenderTemplateBody(bodyTemplate string, data map[string]any, nextSequence f tpl, err := template.New("body").Funcs(funcs).Option("missingkey=error").Parse(bodyTemplate) if err != nil { - return "", fmt.Errorf("parse template body: %w", err) + return "", derrors.WrapError(err, derrors.CategoryValidation, "parse template body").Build() } var buf bytes.Buffer if err := tpl.Execute(&buf, data); err != nil { - return "", fmt.Errorf("render template body: %w", err) + return "", derrors.WrapError(err, derrors.CategoryValidation, "render template body").Build() } return buf.String(), nil } diff --git a/internal/templates/schema.go b/internal/templates/schema.go index 8579386f..d33a8daf 100644 --- a/internal/templates/schema.go +++ b/internal/templates/schema.go @@ -2,8 +2,9 @@ package templates import ( "encoding/json" - "fmt" "strings" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // ParseTemplateSchema parses a JSON string into a TemplateSchema structure. @@ -30,7 +31,7 @@ func ParseTemplateSchema(raw string) (TemplateSchema, error) { var schema TemplateSchema if err := json.Unmarshal([]byte(raw), &schema); err != nil { - return TemplateSchema{}, fmt.Errorf("parse template schema: %w", err) + return TemplateSchema{}, derrors.WrapError(err, derrors.CategoryValidation, "parse template schema").Build() } return schema, nil } @@ -59,7 +60,7 @@ func ParseTemplateDefaults(raw string) (map[string]any, error) { var defaults map[string]any if err := json.Unmarshal([]byte(raw), &defaults); err != nil { - return nil, fmt.Errorf("parse template defaults: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "parse template defaults").Build() } return defaults, nil } diff --git a/internal/templates/sequence.go b/internal/templates/sequence.go index dbc72a48..100aa05c 100644 --- a/internal/templates/sequence.go +++ b/internal/templates/sequence.go @@ -3,13 +3,14 @@ package templates import ( "encoding/json" "errors" - "fmt" "os" "path/filepath" "regexp" "slices" "strconv" "strings" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // maxSequenceFiles is the maximum number of files to scan when computing sequences. @@ -72,7 +73,7 @@ func ParseSequenceDefinition(raw string) (*SequenceDefinition, error) { var def SequenceDefinition if err := json.Unmarshal([]byte(raw), &def); err != nil { - return nil, fmt.Errorf("parse sequence definition: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "parse sequence definition").Build() } if def.Name == "" || def.Dir == "" || def.Glob == "" || def.Regex == "" { return nil, errors.New("sequence definition missing required fields") @@ -133,7 +134,7 @@ func ComputeNextInSequence(def SequenceDefinition, docsDir string) (int, error) re, err := regexp.Compile(def.Regex) if err != nil { - return 0, fmt.Errorf("invalid sequence regex: %w", err) + return 0, derrors.WrapError(err, derrors.CategoryValidation, "invalid sequence regex").Build() } if re.NumSubexp() != 1 { return 0, errors.New("sequence regex must have exactly one capture group") @@ -141,11 +142,14 @@ func ComputeNextInSequence(def SequenceDefinition, docsDir string) (int, error) matches, err := filepath.Glob(filepath.Join(dirPath, def.Glob)) if err != nil { - return 0, fmt.Errorf("sequence glob failed: %w", err) + return 0, derrors.WrapError(err, derrors.CategoryFileSystem, "sequence glob failed").Build() } if len(matches) > maxSequenceFiles { - return 0, fmt.Errorf("sequence scan exceeded %d files", maxSequenceFiles) + return 0, derrors.NewError(derrors.CategoryInternal, "sequence scan exceeded max files"). + WithContext("max", maxSequenceFiles). + WithContext("matches", len(matches)). + Build() } maxValue := 0 diff --git a/internal/templates/template_page.go b/internal/templates/template_page.go index 096a745c..e7adf631 100644 --- a/internal/templates/template_page.go +++ b/internal/templates/template_page.go @@ -2,11 +2,12 @@ package templates import ( "errors" - "fmt" "io" "strings" "golang.org/x/net/html" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // TemplateMeta contains metadata extracted from docbuilder:* HTML meta tags. @@ -81,7 +82,7 @@ type TemplatePage struct { func ParseTemplatePage(r io.Reader) (*TemplatePage, error) { doc, err := html.Parse(r) if err != nil { - return nil, fmt.Errorf("parse template HTML: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryValidation, "parse template HTML").Build() } meta := make(map[string]string) @@ -126,7 +127,9 @@ func ParseTemplatePage(r io.Reader) (*TemplatePage, error) { missing := missingRequiredTemplateMeta(result.Meta) if len(missing) > 0 { - return nil, fmt.Errorf("missing required template metadata: %s", strings.Join(missing, ", ")) + return nil, derrors.NewError(derrors.CategoryValidation, "missing required template metadata: "+strings.Join(missing, ", ")). + WithContext("missing", missing). + Build() } if len(markdownBlocks) == 0 { diff --git a/internal/templates/writer.go b/internal/templates/writer.go index ac08a561..2cf424e7 100644 --- a/internal/templates/writer.go +++ b/internal/templates/writer.go @@ -8,11 +8,12 @@ package templates import ( "errors" - "fmt" "os" "path/filepath" "strings" "syscall" + + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" ) // WriteGeneratedFile writes the generated content to a file under the docs directory. @@ -58,7 +59,9 @@ func WriteGeneratedFile(docsDir, relativePath, content string) (string, error) { } if err = os.MkdirAll(filepath.Dir(fullPath), 0o750); err != nil { - return "", fmt.Errorf("create output directory: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "create output directory"). + WithContext("path", filepath.Dir(fullPath)). + Build() } // #nosec G304 -- fullPath is validated to stay under docsDir. @@ -66,16 +69,22 @@ func WriteGeneratedFile(docsDir, relativePath, content string) (string, error) { if err != nil { // Check if error is due to file already existing if errors.Is(err, os.ErrExist) || errors.Is(err, syscall.EEXIST) { - return "", fmt.Errorf("file already exists: %s", fullPath) + return "", derrors.NewError(derrors.CategoryValidation, "output file already exists"). + WithContext("path", fullPath). + Build() } - return "", fmt.Errorf("write output file: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "open output file"). + WithContext("path", fullPath). + Build() } defer func() { _ = file.Close() }() if _, err := file.WriteString(content); err != nil { - return "", fmt.Errorf("write output file: %w", err) + return "", derrors.WrapError(err, derrors.CategoryFileSystem, "write output file"). + WithContext("path", fullPath). + Build() } return fullPath, nil diff --git a/internal/urlutil/urlutil.go b/internal/urlutil/urlutil.go new file mode 100644 index 00000000..e444fdaa --- /dev/null +++ b/internal/urlutil/urlutil.go @@ -0,0 +1,84 @@ +// Package urlutil centralizes small URL-shape predicates used across the +// discovery, lint, edit-link, and content-pipeline code paths. +// +// Before this package existed the same predicate was reimplemented in at +// least four places with subtly different definitions: +// +// - lint/fixer_utils.isExternalURL -- http(s):// only +// - hugo/editlink.isLocalPath -- anything-not-remote +// - hugo/pipeline.isForgeURL -- http(s) or git@ (clone URLs) +// - hugo/pipeline.isAbsoluteOrSpecialURL -- http(s), mailto:, tel:, #anchor +// +// The plan's M11 called these out: drift between the four was exactly +// the kind of latent bug worth removing. Each helper here documents the +// predicate it replaces and which callers migrated. +package urlutil + +import "strings" + +// IsExternalURL reports whether s begins with http:// or https://. +// Fragments, query strings, and trailing path components are not +// inspected: callers that need that detail should parse s through +// net/url. +// +// Consolidates: +// - lint.isExternalURL (was http(s):// only). +func IsExternalURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// IsForgeURL reports whether s looks like a forge clone URL: +// http(s)://, ssh://, or git@ git+ssh. file:// is intentionally NOT +// included (that's a local path). +// +// Consolidates: +// - pipeline.isForgeURL (was http(s) or git@). +// - The pre-condition portion of editlink.isLocalPath (the inverse +// of this predicate is used there to gate the heuristic detector). +func IsForgeURL(s string) bool { + if IsExternalURL(s) { + return true + } + if strings.HasPrefix(s, "git@") { + return true + } + if strings.HasPrefix(s, "ssh://") || strings.HasPrefix(s, "git://") { + return true + } + return false +} + +// IsLocalPath reports whether s is a local file path (no remote scheme +// detected). A relative path, an absolute filesystem path starting with +// "/", or a Windows drive letter all count as local. +// +// Consolidates: +// - editlink.isLocalPath. +func IsLocalPath(s string) bool { + return !IsForgeURL(s) +} + +// IsAbsoluteOrSpecialURL reports whether s is one of the shapes we +// pass through unchanged in the markdown link rewriter: an absolute URL, +// a fragment anchor (#section), or a non-fetchable scheme like mailto: +// or tel:. Stops the rewriter from rewriting user-supplied anchors and +// contact links. +// +// Consolidates: +// - pipeline.isAbsoluteOrSpecialURL. +// +// Note: tel: is included even though the prior call site did not check +// for it -- it's a parallel case (a non-fetchable URI in markdown that +// should not be touched), and adding it here costs nothing. +func IsAbsoluteOrSpecialURL(s string) bool { + if IsExternalURL(s) { + return true + } + if strings.HasPrefix(s, "#") { + return true + } + if strings.HasPrefix(s, "mailto:") || strings.HasPrefix(s, "tel:") { + return true + } + return false +} diff --git a/internal/urlutil/urlutil_test.go b/internal/urlutil/urlutil_test.go new file mode 100644 index 00000000..bc489ba5 --- /dev/null +++ b/internal/urlutil/urlutil_test.go @@ -0,0 +1,99 @@ +package urlutil + +import "testing" + +// TestIsExternalURL covers the lint-side predicate in isolation: +// only http(s) is external. mailto:, tel:, fragments, and relative +// paths are NOT external URLs. +func TestIsExternalURL(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"http://example.com", true}, + {"https://example.com/path?x=1#frag", true}, + {"HTTPS://example.com", false}, // case-sensitive by design + {"mailto:user@example.com", false}, + {"tel:+15551234567", false}, + {"#anchor", false}, + {"./relative/path.md", false}, + {"/abs/path.md", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsExternalURL(tt.in); got != tt.want { + t.Errorf("IsExternalURL(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +// TestIsForgeURL covers clone-URL detection: http(s), SSH, and git@ +// forms are forge URLs. file:// URLs and bare paths are NOT. +func TestIsForgeURL(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"https://github.com/owner/repo.git", true}, + {"http://git.example.org/repo.git", true}, + {"git@github.com:owner/repo.git", true}, + {"ssh://git@github.com/owner/repo.git", true}, + {"git://github.com/owner/repo.git", true}, + {"./local/path", false}, + {"file:///tmp/local.md", false}, + {"", false}, + {"#fragment", false}, + } + for _, tt := range tests { + if got := IsForgeURL(tt.in); got != tt.want { + t.Errorf("IsForgeURL(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +// TestIsLocalPath is the inverse of IsForgeURL -- same predicates, +// inverted. +func TestIsLocalPath(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"./relative/path.md", true}, + {"/abs/path.md", true}, + {"docs/page.md", true}, + {"https://github.com/owner/repo.git", false}, + {"git@github.com:owner/repo.git", false}, + {"ssh://git@github.com/repo.git", false}, + {"", true}, // empty is not a forge URL, so it is local by definition + } + for _, tt := range tests { + if got := IsLocalPath(tt.in); got != tt.want { + t.Errorf("IsLocalPath(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +// TestIsAbsoluteOrSpecialURL captures the "pass through unchanged" set +// the markdown link rewriter respects. http(s), fragment, mailto, tel. +func TestIsAbsoluteOrSpecialURL(t *testing.T) { + tests := []struct { + in string + want bool + }{ + {"http://example.com", true}, + {"https://example.com/path", true}, + {"#section", true}, + {"#", true}, + {"mailto:user@example.com", true}, + {"tel:+15551234567", true}, + {"./relative", false}, + {"/abs/path", false}, + {"docs/page.md", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsAbsoluteOrSpecialURL(tt.in); got != tt.want { + t.Errorf("IsAbsoluteOrSpecialURL(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} diff --git a/internal/versioning/manager.go b/internal/versioning/manager.go index b596eea5..7b210062 100644 --- a/internal/versioning/manager.go +++ b/internal/versioning/manager.go @@ -1,7 +1,6 @@ package versioning import ( - "fmt" "log/slog" "path/filepath" "regexp" @@ -11,6 +10,7 @@ import ( "time" "git.home.luguber.info/inful/docbuilder/internal/config" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/git" ) @@ -55,13 +55,17 @@ func (vm *DefaultVersionManager) DiscoverVersionsWithAuth(repoURL string, config // Get Git references from the repository with auth refs, err := vm.getGitReferencesWithAuth(repoURL, authConfig) if err != nil { - return nil, fmt.Errorf("failed to get git references: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryNetwork, "failed to get git references"). + WithContext("repo", repoURL). + Build() } // Determine default branch defaultBranch, err := vm.getDefaultBranch(repoURL, refs) if err != nil { - return nil, fmt.Errorf("failed to determine default branch: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryInternal, "failed to determine default branch"). + WithContext("repo", repoURL). + Build() } result.Repository.DefaultBranch = defaultBranch @@ -124,7 +128,9 @@ func (vm *DefaultVersionManager) CleanupOldVersions(repoURL string, config *Vers versions, exists := vm.repositories[repoURL] if !exists { - return fmt.Errorf("repository not found: %s", repoURL) + return derrors.NewError(derrors.CategoryNotFound, "repository not found in version manager"). + WithContext("repo", repoURL). + Build() } originalCount := len(versions.Versions) @@ -193,7 +199,9 @@ func (vm *DefaultVersionManager) getGitReferencesWithAuth(repoURL string, authCo } if err != nil { - return nil, fmt.Errorf("failed to list remote references: %w", err) + return nil, derrors.WrapError(err, derrors.CategoryNetwork, "failed to list remote references"). + WithContext("repo", repoURL). + Build() } gitRefs := make([]*GitReference, 0, len(refs)) @@ -241,7 +249,9 @@ func (vm *DefaultVersionManager) getDefaultBranch(repoURL string, refs []*GitRef } } - return "", fmt.Errorf("no branches found in repository: %s", repoURL) + return "", derrors.NewError(derrors.CategoryNotFound, "no branches found in repository"). + WithContext("repo", repoURL). + Build() } // filterAndConvertReferences filters Git references based on configuration and converts to versions. diff --git a/internal/versioning/service.go b/internal/versioning/service.go deleted file mode 100644 index b987adde..00000000 --- a/internal/versioning/service.go +++ /dev/null @@ -1,40 +0,0 @@ -package versioning - -import ( - "git.home.luguber.info/inful/docbuilder/internal/config" -) - -// GetVersioningConfig creates a VersionConfig from V2Config. -func GetVersioningConfig(v2Config *config.Config) *VersionConfig { - if v2Config.Versioning == nil { - // Return default configuration - return &VersionConfig{ - Strategy: StrategyDefaultOnly, - DefaultBranchOnly: true, - BranchPatterns: []string{defaultBranchMain, defaultBranchMaster}, - TagPatterns: []string{}, - MaxVersions: 5, - } - } - - var strategy VersionStrategy - switch v2Config.Versioning.Strategy { - case config.StrategyBranchesOnly: - strategy = StrategyBranches - case config.StrategyTagsOnly: - strategy = StrategyTags - case config.StrategyBranchesAndTags: - strategy = StrategyBranchesAndTags - default: - // Unknown or empty strategy - default to StrategyDefaultOnly - strategy = StrategyDefaultOnly - } - - return &VersionConfig{ - Strategy: strategy, - DefaultBranchOnly: v2Config.Versioning.DefaultBranchOnly, - BranchPatterns: v2Config.Versioning.BranchPatterns, - TagPatterns: v2Config.Versioning.TagPatterns, - MaxVersions: v2Config.Versioning.MaxVersionsPerRepo, - } -} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index ff987782..a7ef5cb9 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -1,12 +1,12 @@ package workspace import ( - "fmt" "log/slog" "os" "path/filepath" "time" + derrors "git.home.luguber.info/inful/docbuilder/internal/foundation/errors" "git.home.luguber.info/inful/docbuilder/internal/logfields" ) @@ -51,7 +51,9 @@ func (m *Manager) Create() error { if m.persistent { // Persistent mode: use fixed directory if err := os.MkdirAll(m.tempDir, 0o750); err != nil { - return fmt.Errorf("failed to create persistent workspace directory: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create persistent workspace directory"). + WithContext("path", m.tempDir). + Build() } slog.Info("Using persistent workspace", logfields.Path(m.tempDir)) return nil @@ -59,10 +61,12 @@ func (m *Manager) Create() error { // Ephemeral mode: create timestamped directory timestamp := time.Now().Format("20060102-150405") - tempDir := filepath.Join(m.baseDir, fmt.Sprintf("docbuilder-%s", timestamp)) + tempDir := filepath.Join(m.baseDir, "docbuilder-"+timestamp) if err := os.MkdirAll(tempDir, 0o750); err != nil { - return fmt.Errorf("failed to create workspace directory: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to create workspace directory"). + WithContext("path", tempDir). + Build() } m.tempDir = tempDir @@ -91,7 +95,9 @@ func (m *Manager) Cleanup() error { // Ephemeral mode: remove directory if err := os.RemoveAll(m.tempDir); err != nil { - return fmt.Errorf("failed to cleanup workspace: %w", err) + return derrors.WrapError(err, derrors.CategoryFileSystem, "failed to cleanup workspace"). + WithContext("path", m.tempDir). + Build() } slog.Info("Cleaned up workspace", logfields.Path(m.tempDir))