Files
sow-tools/internal/project/project.go
T
2026-04-14 00:47:11 +02:00

387 lines
8.7 KiB
Go

package project
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
)
const ConfigFile = "nwn-tool.json"
var SourceExtensions = []string{
".are", ".dlg", ".fac", ".git", ".ifo", ".jrl",
".utc", ".utd", ".ute", ".uti", ".utm", ".utp", ".uts", ".utt", ".utw",
}
var AssetExtensions = []string{
".2da", ".bik", ".bmp", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mtr", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
}
type Project struct {
Root string
Config Config
Inventory Inventory
}
type Config struct {
Module ModuleConfig `json:"module"`
Paths PathConfig `json:"paths"`
HAKs []HAKConfig `json:"haks"`
TopData TopDataConfig `json:"topdata"`
}
type ModuleConfig struct {
Name string `json:"name"`
ResRef string `json:"resref"`
Description string `json:"description"`
HAKOrder []string `json:"hak_order"`
}
type PathConfig struct {
Source string `json:"source"`
Assets string `json:"assets"`
Build string `json:"build"`
}
type HAKConfig struct {
Name string `json:"name"`
Priority int `json:"priority"`
MaxBytes int64 `json:"max_bytes"`
Split bool `json:"split"`
Optional bool `json:"optional,omitempty"`
Include []string `json:"include"`
}
type TopDataConfig struct {
Source string `json:"source"`
Build string `json:"build"`
ReferenceBuilder string `json:"reference_builder"`
Assets string `json:"assets"`
}
type Inventory struct {
SourceFiles []string
ScriptFiles []string
AssetFiles []string
Extensions []string
}
type InventoryReport struct {
SourceFiles int
ScriptFiles int
AssetFiles int
Extensions []string
}
func FindRoot(start string) (string, error) {
current := start
for {
candidate := filepath.Join(current, ConfigFile)
if _, err := os.Stat(candidate); err == nil {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", fmt.Errorf("could not find %s from %s", ConfigFile, start)
}
current = parent
}
}
func Load(root string) (*Project, error) {
raw, err := os.ReadFile(filepath.Join(root, ConfigFile))
if err != nil {
return nil, fmt.Errorf("read %s: %w", ConfigFile, err)
}
cfg := defaultConfig()
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("parse %s: %w", ConfigFile, err)
}
return &Project{
Root: root,
Config: cfg,
}, nil
}
func (p *Project) ValidateLayout() error {
var failures []error
if strings.TrimSpace(p.Config.Module.Name) == "" {
failures = append(failures, errors.New("module.name is required"))
}
if strings.TrimSpace(p.Config.Module.ResRef) == "" {
failures = append(failures, errors.New("module.resref is required"))
}
if len(p.Config.Module.ResRef) > 16 {
failures = append(failures, fmt.Errorf("module.resref %q exceeds 16 characters", p.Config.Module.ResRef))
}
for index, hak := range p.Config.HAKs {
if strings.TrimSpace(hak.Name) == "" {
failures = append(failures, fmt.Errorf("haks[%d].name is required", index))
}
if hak.Priority <= 0 {
failures = append(failures, fmt.Errorf("haks[%d].priority must be greater than zero", index))
}
if hak.MaxBytes < 0 {
failures = append(failures, fmt.Errorf("haks[%d].max_bytes must be zero or greater", index))
}
if len(hak.Include) == 0 {
failures = append(failures, fmt.Errorf("haks[%d].include must have at least one pattern", index))
}
}
for label, path := range map[string]string{
"paths.source": p.SourceDir(),
"paths.assets": p.AssetsDir(),
"paths.build": p.BuildDir(),
} {
info, err := os.Stat(path)
if err != nil {
failures = append(failures, fmt.Errorf("%s: %w", label, err))
continue
}
if !info.IsDir() {
failures = append(failures, fmt.Errorf("%s must be a directory: %s", label, path))
}
}
if len(failures) > 0 {
return errors.Join(failures...)
}
return nil
}
func (p *Project) Scan() error {
sourceFiles, sourceExts, err := scanDir(p.SourceDir(), func(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return ext == ".json" || ext == ".nss"
})
if err != nil {
return fmt.Errorf("scan source tree: %w", err)
}
assetFiles, _, err := scanDir(p.AssetsDir(), func(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
return slices.Contains(AssetExtensions, ext)
})
if err != nil {
return fmt.Errorf("scan assets tree: %w", err)
}
var scripts []string
var sources []string
extensionSet := map[string]struct{}{}
for _, ext := range sourceExts {
extensionSet[ext] = struct{}{}
}
for _, path := range sourceFiles {
switch strings.ToLower(filepath.Ext(path)) {
case ".nss":
scripts = append(scripts, path)
case ".json":
sources = append(sources, path)
}
}
extensions := make([]string, 0, len(extensionSet))
for ext := range extensionSet {
extensions = append(extensions, ext)
}
slices.Sort(extensions)
p.Inventory = Inventory{
SourceFiles: sources,
ScriptFiles: scripts,
AssetFiles: assetFiles,
Extensions: extensions,
}
return nil
}
func (p *Project) SourceDir() string {
return filepath.Join(p.Root, p.Config.Paths.Source)
}
func (p *Project) AssetsDir() string {
return filepath.Join(p.Root, p.Config.Paths.Assets)
}
func (p *Project) BuildDir() string {
return filepath.Join(p.Root, p.Config.Paths.Build)
}
func (p *Project) CloneWithHAKNames(names []string) (*Project, error) {
if len(names) == 0 {
return p, nil
}
wanted := make(map[string]struct{}, len(names))
for _, name := range names {
trimmed := strings.TrimSpace(strings.ToLower(name))
if trimmed == "" {
continue
}
wanted[trimmed] = struct{}{}
}
if len(wanted) == 0 {
return p, nil
}
filtered := make([]HAKConfig, 0, len(p.Config.HAKs))
found := make(map[string]struct{}, len(wanted))
for _, hak := range p.Config.HAKs {
key := strings.ToLower(strings.TrimSpace(hak.Name))
if _, ok := wanted[key]; !ok {
continue
}
filtered = append(filtered, hak)
found[key] = struct{}{}
}
var missing []string
for _, name := range names {
key := strings.TrimSpace(strings.ToLower(name))
if key == "" {
continue
}
if _, ok := found[key]; !ok {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return nil, fmt.Errorf("unknown hak name(s): %s", strings.Join(missing, ", "))
}
clone := *p
clone.Config = p.Config
clone.Config.HAKs = filtered
clone.Inventory = p.Inventory
return &clone, nil
}
func (p *Project) HasTopData() bool {
return strings.TrimSpace(p.Config.TopData.Source) != ""
}
func (p *Project) TopDataSourceDir() string {
if !p.HasTopData() {
return ""
}
return filepath.Join(p.Root, p.Config.TopData.Source)
}
func (p *Project) TopDataBuildDir() string {
buildPath := strings.TrimSpace(p.Config.TopData.Build)
if buildPath == "" {
buildPath = filepath.Join(p.Config.Paths.Build, "topdata")
}
if filepath.IsAbs(buildPath) {
return buildPath
}
return filepath.Join(p.Root, buildPath)
}
func (p *Project) TopDataReferenceBuilderDir() string {
ref := strings.TrimSpace(p.Config.TopData.ReferenceBuilder)
if ref == "" {
return ""
}
if filepath.IsAbs(ref) {
return ref
}
return filepath.Join(p.Root, ref)
}
func (p *Project) TopDataAssetsDir() string {
assets := strings.TrimSpace(p.Config.TopData.Assets)
if assets == "" {
return ""
}
if filepath.IsAbs(assets) {
return assets
}
absPath := filepath.Join(p.Root, assets)
if _, err := os.Stat(absPath); err == nil {
return absPath
}
return absPath
}
func (i Inventory) Report() InventoryReport {
return InventoryReport{
SourceFiles: len(i.SourceFiles),
ScriptFiles: len(i.ScriptFiles),
AssetFiles: len(i.AssetFiles),
Extensions: i.Extensions,
}
}
func defaultConfig() Config {
return Config{
Paths: PathConfig{
Source: "src",
Assets: "assets",
Build: "build",
},
TopData: TopDataConfig{
Build: "build/topdata",
},
}
}
func scanDir(root string, include func(path string) bool) ([]string, []string, error) {
var files []string
extSet := map[string]struct{}{}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if !include(path) {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
files = append(files, rel)
if strings.HasSuffix(rel, ".json") {
base := strings.TrimSuffix(filepath.Base(rel), ".json")
ext := strings.ToLower(filepath.Ext(base))
if ext != "" {
extSet[ext] = struct{}{}
}
}
return nil
})
if err != nil {
return nil, nil, err
}
slices.Sort(files)
exts := make([]string, 0, len(extSet))
for ext := range extSet {
exts = append(exts, ext)
}
slices.Sort(exts)
return files, exts, nil
}