package recipe import ( "bufio" "fmt" "io" "os" "path/filepath" "strings" "zsvo/pkg/deps" ) // Recipe represents a package recipe. type Recipe struct { Name string Version string Description string Source Source Build []string Install []string Deps []string Dir string } // Source represents source information. type Source struct { URL string Sha256 string DebianDSC string Patches []string } // ParseRecipe parses a recipe from a YAML file. func ParseRecipe(path string) (*Recipe, error) { if path == "" { return nil, fmt.Errorf("recipe path cannot be empty") } path = filepath.Clean(path) file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open recipe file %s: %w", path, err) } defer file.Close() rcp, err := ParseRecipeFromReader(file) if err != nil { return nil, err } rcp.Dir = filepath.Dir(path) return rcp, nil } // ParseRecipeFromReader parses a recipe from an io.Reader. func ParseRecipeFromReader(r io.Reader) (*Recipe, error) { rcp := &Recipe{} scanner := bufio.NewScanner(r) section := "" sourceSubsection := "" lineNo := 0 for scanner.Scan() { lineNo++ raw := strings.TrimRight(scanner.Text(), "\r") trimmed := strings.TrimSpace(raw) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } indent := countLeadingSpaces(raw) if strings.HasPrefix(trimmed, "- ") { item := decodeYAMLScalar(strings.TrimSpace(trimmed[2:])) if item == "" { return nil, fmt.Errorf("invalid empty list item at line %d", lineNo) } switch section { case "build": if item == "" { return nil, fmt.Errorf("build command cannot be empty at line %d", lineNo) } rcp.Build = append(rcp.Build, item) case "install": if item == "" { return nil, fmt.Errorf("install command cannot be empty at line %d", lineNo) } rcp.Install = append(rcp.Install, item) case "deps": if item == "" { return nil, fmt.Errorf("dependency cannot be empty at line %d", lineNo) } rcp.Deps = append(rcp.Deps, item) case "source": if sourceSubsection != "patches" { return nil, fmt.Errorf("unexpected source list item at line %d", lineNo) } if item == "" { return nil, fmt.Errorf("patch path cannot be empty at line %d", lineNo) } rcp.Source.Patches = append(rcp.Source.Patches, item) default: return nil, fmt.Errorf("list item outside section at line %d", lineNo) } continue } if strings.HasSuffix(trimmed, ":") { key := strings.TrimSpace(strings.TrimSuffix(trimmed, ":")) if key == "" { return nil, fmt.Errorf("invalid key at line %d", lineNo) } if indent == 0 { section = key sourceSubsection = "" continue } if section == "source" && indent >= 2 { sourceSubsection = key continue } return nil, fmt.Errorf("unsupported nested section at line %d", lineNo) } key, val, ok := splitYAMLKeyValue(trimmed) if !ok { return nil, fmt.Errorf("invalid recipe line %d: %s", lineNo, trimmed) } value := decodeYAMLScalar(val) if indent == 0 { section = "" sourceSubsection = "" switch key { case "name": rcp.Name = value case "version": rcp.Version = value case "description": rcp.Description = value case "source", "build", "install", "deps": // Allowed as section headers only. return nil, fmt.Errorf("section %q must be a block (line %d)", key, lineNo) default: return nil, fmt.Errorf("unknown top-level key %q at line %d", key, lineNo) } continue } if section == "source" { sourceSubsection = "" switch key { case "url": rcp.Source.URL = value case "sha256": rcp.Source.Sha256 = value case "debian_dsc", "dsc_url": rcp.Source.DebianDSC = value case "patches": return nil, fmt.Errorf("source.patches must be a list (line %d)", lineNo) default: return nil, fmt.Errorf("unknown source key %q at line %d", key, lineNo) } continue } return nil, fmt.Errorf("unexpected nested key at line %d", lineNo) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("failed to read recipe: %w", err) } if rcp.Name == "" { return nil, fmt.Errorf("recipe must have a name") } if rcp.Version == "" { return nil, fmt.Errorf("recipe must have a version") } if rcp.Source.URL != "" && rcp.Source.DebianDSC != "" { return nil, fmt.Errorf("source.url and source.debian_dsc are mutually exclusive") } sourceURL := strings.TrimSpace(rcp.Source.URL) if rcp.Source.DebianDSC != "" { if !isDebianDSCURL(rcp.Source.DebianDSC) { return nil, fmt.Errorf("source.debian_dsc must point to a .dsc file") } sourceURL = strings.TrimSpace(rcp.Source.DebianDSC) } if sourceURL == "" { return nil, fmt.Errorf("recipe must have source.url or source.debian_dsc") } // SHA256 is mandatory for direct archives, optional for Debian .dsc flow. if rcp.Source.Sha256 == "" && !isDebianDSCURL(sourceURL) { return nil, fmt.Errorf("recipe must have a source SHA256 checksum") } if len(rcp.Build) == 0 { return nil, fmt.Errorf("recipe must have build commands") } if len(rcp.Install) == 0 { return nil, fmt.Errorf("recipe must have install commands") } for _, dep := range rcp.Deps { dep = strings.TrimSpace(dep) if dep == "" { return nil, fmt.Errorf("recipe deps cannot contain empty values") } if _, err := deps.ParseRequirement(dep); err != nil { return nil, fmt.Errorf("invalid recipe dependency %q: %w", dep, err) } } return rcp, nil } func splitYAMLKeyValue(line string) (string, string, bool) { idx := strings.Index(line, ":") if idx <= 0 { return "", "", false } key := strings.TrimSpace(line[:idx]) val := strings.TrimSpace(line[idx+1:]) if key == "" { return "", "", false } return key, val, true } func decodeYAMLScalar(v string) string { v = strings.TrimSpace(v) if len(v) >= 2 { if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') { return v[1 : len(v)-1] } } return v } func countLeadingSpaces(s string) int { count := 0 for _, ch := range s { if ch != ' ' { break } count++ } return count } func isDebianDSCURL(raw string) bool { raw = strings.TrimSpace(strings.ToLower(raw)) if raw == "" { return false } if idx := strings.IndexAny(raw, "?#"); idx >= 0 { raw = raw[:idx] } return strings.HasSuffix(raw, ".dsc") } // GetPackageName returns the package name in format name-version. func (r *Recipe) GetPackageName() string { return fmt.Sprintf("%s-%s", r.Name, r.Version) } // GetPackageFileName returns the package file name. func (r *Recipe) GetPackageFileName() string { return fmt.Sprintf("%s.pkg.tar.zst", r.GetPackageName()) } // GetPackageDir returns the package directory path. func (r *Recipe) GetPackageDir(baseDir string) string { return filepath.Join(baseDir, "packages", r.GetPackageName()) } // GetSourceDir returns the source directory path. func (r *Recipe) GetSourceDir(baseDir string) string { return filepath.Join(baseDir, "sources", r.GetPackageName()) } // GetStagingDir returns the staging directory path. func (r *Recipe) GetStagingDir(baseDir string) string { return filepath.Join(baseDir, "staging", r.GetPackageName()) }