-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathresolver.go
More file actions
153 lines (131 loc) · 4.35 KB
/
resolver.go
File metadata and controls
153 lines (131 loc) · 4.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
package cooklang
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// RecipeResolver resolves recipe references to parsed Recipe objects.
type RecipeResolver interface {
// Resolve takes a relative recipe path (e.g., "./sauces/Hollandaise") and returns
// the parsed Recipe. The path is relative to the recipe root, without the .cook extension.
Resolve(path string) (*Recipe, error)
}
// FileSystemResolver resolves recipe references by reading .cook files from disk.
type FileSystemResolver struct {
BasePath string
cache map[string]*Recipe
stack map[string]bool // cycle detection
}
// NewFileSystemResolver creates a new resolver rooted at the given base directory.
func NewFileSystemResolver(basePath string) *FileSystemResolver {
return &FileSystemResolver{
BasePath: basePath,
cache: make(map[string]*Recipe),
stack: make(map[string]bool),
}
}
// Resolve reads and parses a .cook file relative to the base path.
// It caches results and detects cycles.
func (r *FileSystemResolver) Resolve(path string) (*Recipe, error) {
// Normalize path
cleanPath := filepath.Clean(path)
// Check cache
if recipe, ok := r.cache[cleanPath]; ok {
return recipe, nil
}
// Cycle detection
if r.stack[cleanPath] {
return nil, fmt.Errorf("cycle detected: recipe %q references itself", cleanPath)
}
r.stack[cleanPath] = true
defer func() { delete(r.stack, cleanPath) }()
// Build file path
filePath := filepath.Join(r.BasePath, cleanPath+".cook")
recipe, err := ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to resolve recipe %q: %w", path, err)
}
r.cache[cleanPath] = recipe
return recipe, nil
}
// ParseYield parses a yield metadata value in Cooklang format (e.g., "500%ml", "2%loaves").
// Returns quantity and unit, or 0 and empty string if parsing fails.
func ParseYield(yieldStr string) (float64, string) {
if yieldStr == "" {
return 0, ""
}
// Handle "quantity%unit" format
if idx := strings.Index(yieldStr, "%"); idx >= 0 {
qtyStr := strings.TrimSpace(yieldStr[:idx])
unit := strings.TrimSpace(yieldStr[idx+1:])
qty, err := strconv.ParseFloat(qtyStr, 64)
if err != nil {
return 0, ""
}
return qty, unit
}
// Try plain number
qty, err := strconv.ParseFloat(strings.TrimSpace(yieldStr), 64)
if err != nil {
return 0, ""
}
return qty, ""
}
// ScaleByYield calculates a scaling factor for a recipe reference based on yield metadata.
// If the recipe has yield metadata matching the requested unit, it calculates
// targetQuantity / yieldQuantity as the scaling factor.
//
// This is an experimental feature per the Cooklang spec.
func ScaleByYield(recipe *Recipe, targetQuantity float64, targetUnit string) (float64, error) {
yieldStr, ok := recipe.Metadata["yield"]
if !ok {
return 0, fmt.Errorf("recipe has no yield metadata")
}
yieldQty, yieldUnit := ParseYield(yieldStr)
if yieldQty <= 0 {
return 0, fmt.Errorf("invalid yield quantity in %q", yieldStr)
}
if !strings.EqualFold(yieldUnit, targetUnit) {
return 0, fmt.Errorf("incompatible units: recipe yields %q but %q requested", yieldUnit, targetUnit)
}
return targetQuantity / yieldQty, nil
}
// ResolveAndScale resolves a recipe reference and scales it according to the reference's
// quantity and unit. It supports three scaling modes:
// 1. No unit — scales by the given factor
// 2. "servings" unit — scales to target servings
// 3. Other units — uses yield-based scaling (experimental)
func ResolveAndScale(resolver RecipeResolver, ref *RecipeReference) (*Recipe, error) {
recipe, err := resolver.Resolve(ref.Path)
if err != nil {
return nil, err
}
if ref.Quantity <= 0 {
return recipe, nil // No scaling
}
switch {
case ref.Unit == "":
// Factor-based scaling
return recipe.Scale(float64(ref.Quantity)), nil
case strings.EqualFold(ref.Unit, "servings"):
// Servings-based scaling
return recipe.ScaleToServings(float64(ref.Quantity)), nil
default:
// Units-based scaling (experimental)
factor, err := ScaleByYield(recipe, float64(ref.Quantity), ref.Unit)
if err != nil {
return nil, fmt.Errorf("cannot scale %q: %w", ref.Path, err)
}
return recipe.Scale(factor), nil
}
}
// writeFile is a helper for tests — not exported
func writeFile(path, content string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(path, []byte(content), 0644)
}