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, pathFn := range map[string]func() string{ "paths.build": p.BuildDir, } { path := pathFn() if path == "" || filepath.Clean(path) == p.Root { continue } 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 { var sourceFiles []string var sourceExts []string sourceDir := p.SourceDir() if sourceDir != "" && filepath.Clean(sourceDir) != p.Root { var err error sourceFiles, sourceExts, err = scanDir(sourceDir, func(path string) bool { ext := strings.ToLower(filepath.Ext(path)) return ext == ".json" || ext == ".nss" }) if err != nil { if !errors.Is(err, os.ErrNotExist) { 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 { if errors.Is(err, os.ErrNotExist) { assetFiles = nil } else { 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{ Build: "build", }, TopData: TopDataConfig{ Build: ".cache", }, } } 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 }