ajhahn.de
← eeco
Go 98 lines
package workflow

import (
	"embed"
	"fmt"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strings"

	"github.com/ajhahnde/eeco/internal/config"
)

//go:embed template
var templateFS embed.FS

var workflowNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)

// namePlaceholder is replaced with the workflow name in every templated
// file as it is written.
const namePlaceholder = "__NAME__"

// Scaffold creates a new user workflow directory at
// <workspace>/workflows/<name>/ from the embedded template and returns
// its absolute path. It writes only inside the workspace (Constraint 1)
// and refuses to overwrite an existing workflow.
func Scaffold(cfg *config.Config, name string) (string, error) {
	if cfg == nil {
		return "", fmt.Errorf("scaffold: nil config")
	}
	if !workflowNameRE.MatchString(name) {
		return "", fmt.Errorf("workflow name %q: must be lower-kebab-case (a-z, 0-9, '-')", name)
	}

	workflowsDir := filepath.Join(cfg.Workspace, "workflows")
	dst := filepath.Join(workflowsDir, name)

	// Defence in depth: the regex already forbids separators and dots,
	// but verify the cleaned target stays inside the workspace before
	// any write.
	rel, err := filepath.Rel(cfg.Workspace, dst)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
		return "", fmt.Errorf("workflow path %q escapes the workspace", name)
	}

	if _, err := os.Stat(dst); err == nil {
		return "", fmt.Errorf("workflow %q already exists at %s", name, dst)
	} else if !os.IsNotExist(err) {
		return "", err
	}

	if err := os.MkdirAll(dst, 0o755); err != nil {
		return "", fmt.Errorf("scaffold: create dir: %w", err)
	}

	walkRoot := path.Join("template", profileSubdir(cfg.Profile))
	err = fs.WalkDir(templateFS, walkRoot, func(p string, de fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if de.IsDir() {
			return nil
		}
		data, rerr := templateFS.ReadFile(p)
		if rerr != nil {
			return rerr
		}
		body := strings.ReplaceAll(string(data), namePlaceholder, name)
		base := filepath.Base(p)
		// The runnable entry must be executable; embed cannot carry the
		// mode bit, so it is set explicitly here.
		mode := fs.FileMode(0o644)
		if base == EntryName {
			mode = 0o755
		}
		return os.WriteFile(filepath.Join(dst, base), []byte(body), mode)
	})
	if err != nil {
		return "", fmt.Errorf("scaffold: write template: %w", err)
	}
	return dst, nil
}

// profileSubdir maps a config.Profile to the template subdirectory its
// scaffold uses. Profiles without a dedicated template fall back to
// "generic" — identical to the pre-per-profile-templates behaviour,
// where every project got the same stub.
func profileSubdir(p config.Profile) string {
	switch p {
	case config.ProfileGo, config.ProfilePython, config.ProfileGeneric:
		return string(p)
	default:
		return string(config.ProfileGeneric)
	}
}