From 52ad23efe69127ac1a7d013836c8ed772801ede2 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Sun, 28 Jun 2026 12:19:02 -0300 Subject: [PATCH 1/3] chore(deps): add TOML parser dependency --- src/go.mod | 5 ++++- src/go.sum | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/go.mod b/src/go.mod index e88f74f..dd16041 100644 --- a/src/go.mod +++ b/src/go.mod @@ -2,7 +2,10 @@ module github.com/puff-lang/puff go 1.26.2 -require github.com/spf13/cobra v1.10.2 +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/spf13/cobra v1.10.2 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/src/go.sum b/src/go.sum index a6ee3e0..c09a7f6 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= From 2332b69afd0f574527c8c69c8dc9bc42e2e9d9f4 Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Sun, 28 Jun 2026 12:19:25 -0300 Subject: [PATCH 2/3] feat(project): load Puff project config --- src/internal/project/config.go | 41 ++++++++++++++++++++++++ src/internal/project/find.go | 55 ++++++++++++++++++++++++++++++++ src/internal/project/load.go | 38 ++++++++++++++++++++++ src/internal/project/validate.go | 41 ++++++++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 src/internal/project/config.go create mode 100644 src/internal/project/find.go create mode 100644 src/internal/project/load.go create mode 100644 src/internal/project/validate.go diff --git a/src/internal/project/config.go b/src/internal/project/config.go new file mode 100644 index 0000000..b2c80f7 --- /dev/null +++ b/src/internal/project/config.go @@ -0,0 +1,41 @@ +package project + +const ( + DefaultSourceDir = "src" + DefaultOutputDir = "dist" +) + +type Config struct { + Pack PackConfig `toml:"pack"` + Minecraft MinecraftConfig `toml:"minecraft"` + Build BuildConfig `toml:"build"` +} + +type PackConfig struct { + ID string `toml:"id"` + Name string `toml:"name"` +} + +type MinecraftConfig struct { + Versions string `toml:"versions"` + Target string `toml:"target"` + PackFormat int `toml:"pack_format"` +} + +type BuildConfig struct { + Source string `toml:"source"` + Output string `toml:"output"` +} + +func (config *Config) ApplyDefaults() { + if config.Pack.Name == "" { + config.Pack.Name = config.Pack.ID + } + + if config.Build.Source == "" { + config.Build.Source = DefaultSourceDir + } + if config.Build.Output == "" { + config.Build.Output = DefaultOutputDir + } +} diff --git a/src/internal/project/find.go b/src/internal/project/find.go new file mode 100644 index 0000000..96cc17f --- /dev/null +++ b/src/internal/project/find.go @@ -0,0 +1,55 @@ +package project + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +var ErrConfigNotFound = errors.New("puff.toml not found") + +func FindConfigPath(startDir string) (string, error) { + dir, err := filepath.Abs(startDir) + if err != nil { + return "", fmt.Errorf("resolve start directory: %w", err) + } + + for { + path := filepath.Join(dir, ConfigFileName) + + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + return "", fmt.Errorf("config path is a directory: %s", path) + } + + return path, nil + } + + if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("check config path: %w", err) + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", ErrConfigNotFound + } + + dir = parent + } +} + +func LoadNearestConfig(startDir string) (*Config, string, error) { + path, err := FindConfigPath(startDir) + if err != nil { + return nil, "", err + } + + config, err := LoadConfig(path) + if err != nil { + return nil, "", err + } + + return config, filepath.Dir(path), nil +} diff --git a/src/internal/project/load.go b/src/internal/project/load.go new file mode 100644 index 0000000..44e5350 --- /dev/null +++ b/src/internal/project/load.go @@ -0,0 +1,38 @@ +package project + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +const ConfigFileName = "puff.toml" + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var config Config + + if err := toml.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + config.ApplyDefaults() + + if err := ValidateConfig(&config); err != nil { + return nil, fmt.Errorf("validate config: %w", err) + } + + return &config, nil +} + +func LoadConfigFromDir(dir string) (*Config, error) { + path := filepath.Join(dir, ConfigFileName) + + return LoadConfig(path) +} diff --git a/src/internal/project/validate.go b/src/internal/project/validate.go new file mode 100644 index 0000000..ed36725 --- /dev/null +++ b/src/internal/project/validate.go @@ -0,0 +1,41 @@ +package project + +import ( + "errors" + "fmt" + "regexp" +) + +var packIDPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*`) + +func ValidateConfig(config *Config) error { + if config == nil { + return errors.New("config is nil") + } + + if config.Pack.ID == "" { + return errors.New("pack.id is required") + } + + if !packIDPattern.MatchString(config.Pack.ID) { + return fmt.Errorf("pack.id must be snake_case, got %q", config.Pack.ID) + } + + if config.Minecraft.Versions == "" { + return errors.New("minecraft.versions is required") + } + + if config.Build.Source == "" { + return errors.New("build.source is required") + } + + if config.Build.Output == "" { + return errors.New("build.output is required") + } + + if config.Minecraft.PackFormat < 0 { + return errors.New("minecraft.pack_format cannot be negative") + } + + return nil +} From c5ce43514c59d7be338fe2a71e42d87dd8ab0c5a Mon Sep 17 00:00:00 2001 From: ofabiodev Date: Sun, 28 Jun 2026 12:19:37 -0300 Subject: [PATCH 3/3] test(project): add config loading tests --- src/internal/project/config_test.go | 207 ++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 src/internal/project/config_test.go diff --git a/src/internal/project/config_test.go b/src/internal/project/config_test.go new file mode 100644 index 0000000..2d70196 --- /dev/null +++ b/src/internal/project/config_test.go @@ -0,0 +1,207 @@ +package project + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func writeConfig(t *testing.T, dir string, content string) string { + t.Helper() + + path := filepath.Join(dir, ConfigFileName) + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + return path +} + +func TestLoadConfig(t *testing.T) { + dir := t.TempDir() + + path := writeConfig(t, dir, ` + [pack] + id = "example" + + [minecraft] + versions = ">=1.21 <=1.21.6" + target = "1.21.6" + `) + + config, err := LoadConfig(path) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if config.Pack.ID != "example" { + t.Fatalf("expected pack id %q, got %q", "example", config.Pack.ID) + } + + if config.Pack.Name != "example" { + t.Fatalf("expected default pack name %q, got %q", "example", config.Pack.Name) + } + + if config.Build.Source != DefaultSourceDir { + t.Fatalf("expected default source %q, got %q", DefaultSourceDir, config.Build.Source) + } + + if config.Build.Output != DefaultOutputDir { + t.Fatalf("expected default output %q, got %q", DefaultOutputDir, config.Build.Output) + } +} + +func TestLoadConfigWithBuildSection(t *testing.T) { + dir := t.TempDir() + + path := writeConfig(t, dir, ` + [pack] + id = "example" + name = "Example Pack" + + [minecraft] + versions = ">=1.21 <=1.21.6" + target = "1.21.6" + pack_format = 48 + + [build] + source = "packs" + output = "build" + `) + + config, err := LoadConfig(path) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if config.Pack.Name != "Example Pack" { + t.Fatalf("expected pack name %q, got %q", "Example Pack", config.Pack.Name) + } + + if config.Build.Source != "packs" { + t.Fatalf("expected source %q, got %q", "packs", config.Build.Source) + } + + if config.Build.Output != "build" { + t.Fatalf("expected output %q, got %q", "build", config.Build.Output) + } + + if config.Minecraft.PackFormat != 48 { + t.Fatalf("expected pack format %d, got %d", 48, config.Minecraft.PackFormat) + } +} + +func TestLoadConfigRequiresPackID(t *testing.T) { + dir := t.TempDir() + + path := writeConfig(t, dir, ` + [pack] + name = "Example" + + [minecraft] + versions = ">=1.21 <=1.21.6" + `) + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestLoadConfigRejectsInvalidPackID(t *testing.T) { + dir := t.TempDir() + + path := writeConfig(t, dir, ` + [pack] + id = "Example-Pack" + + [minecraft] + versions = ">=1.21 <=1.21.6" + `) + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestLoadConfigRequiresMinecraftVersions(t *testing.T) { + dir := t.TempDir() + + path := writeConfig(t, dir, ` + [pack] + id = "example" + `) + + _, err := LoadConfig(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestFindConfigPath(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "src", "nested") + + if err := os.MkdirAll(child, 0755); err != nil { + t.Fatalf("failed to create child directory: %v", err) + } + + expectedPath := writeConfig(t, root, ` + [pack] + id = "example" + + [minecraft] + versions = ">=1.21 <=1.21.6" + `) + + path, err := FindConfigPath(child) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if path != expectedPath { + t.Fatalf("expected path %q, got %q", expectedPath, path) + } +} + +func TestFindConfigPathNotFound(t *testing.T) { + dir := t.TempDir() + + _, err := FindConfigPath(dir) + if !errors.Is(err, ErrConfigNotFound) { + t.Fatalf("expected ErrConfigNotFound, got %v", err) + } +} + +func TestLoadNearestConfig(t *testing.T) { + root := t.TempDir() + child := filepath.Join(root, "src", "nested") + + if err := os.MkdirAll(child, 0755); err != nil { + t.Fatalf("failed to create child directory: %v", err) + } + + writeConfig(t, root, ` + [pack] + id = "example" + + [minecraft] + versions = ">=1.21 <=1.21.6" + `) + + config, projectDir, err := LoadNearestConfig(child) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if projectDir != root { + t.Fatalf("expected project dir %q, got %q", root, projectDir) + } + + if config.Pack.ID != "example" { + t.Fatalf("expected pack id %q, got %q", "example", config.Pack.ID) + } +}