@@ -4,58 +4,90 @@ import (
44 "fmt"
55 "os"
66 "path/filepath"
7- "strings"
7+
8+ "github.com/gastownhall/gascity/internal/formula"
89)
910
10- // ResolveFormulas computes per-filename winners from layered formula
11- // directories and creates symlinks in targetDir/.beads/formulas/.
11+ // ResolveFormulas computes per-formula-name winners from layered formula
12+ // directories and creates canonical .toml symlinks in targetDir/.beads/formulas/.
1213//
13- // Layers are ordered lowest→highest priority. For each *.formula.toml file
14- // found across all layers, the highest-priority layer wins. Winners are
15- // symlinked into targetDir/.beads/formulas/ so bd finds them natively.
14+ // Layers are ordered lowest→highest priority. For each formula name (derived
15+ // from either canonical or legacy filename form), the highest-priority layer
16+ // wins. Winners are symlinked into targetDir/.beads/formulas/<name>.toml so
17+ // bd finds them natively using the canonical filename, regardless of the
18+ // source file's on-disk name.
1619//
1720// Idempotent: correct symlinks are left alone, stale ones are updated,
18- // and symlinks for formulas no longer in any layer are removed. Real files
21+ // and symlinks for formulas no longer in any layer are removed (including
22+ // any stray legacy-suffixed symlinks from earlier runs). Real files
1923// (non-symlinks) in the target directory are never overwritten.
2024func ResolveFormulas (targetDir string , layers []string ) error {
2125 if len (layers ) == 0 {
2226 return nil
2327 }
2428
25- // Build winner map: filename → absolute source path.
26- // Later layers overwrite earlier ones (higher priority).
29+ // Build winner map keyed by formula NAME (not filename). Later layers
30+ // overwrite earlier ones (higher priority). Within a single layer, the
31+ // canonical .toml form wins over the legacy .formula.toml form so a
32+ // partially-migrated layer does not shadow its own canonical file.
2733 winners := make (map [string ]string )
2834 for _ , layerDir := range layers {
2935 entries , err := os .ReadDir (layerDir )
3036 if err != nil {
3137 continue // Layer dir doesn't exist — skip (not an error).
3238 }
39+ // Resolve within-layer winners first so canonical beats legacy
40+ // sibling regardless of ReadDir order, then merge into the
41+ // cross-layer winners map (overwriting lower layers).
42+ layerPick := make (map [string ]string )
43+ layerLegacy := make (map [string ]bool )
3344 for _ , e := range entries {
34- if e .IsDir () || ! strings .HasSuffix (e .Name (), ".formula.toml" ) {
45+ if e .IsDir () {
46+ continue
47+ }
48+ name , ok := formula .TrimTOMLFilename (e .Name ())
49+ if ! ok {
3550 continue
3651 }
52+ legacy := e .Name () == name + formula .LegacyTOMLExt
53+ if _ , exists := layerPick [name ]; exists && legacy && ! layerLegacy [name ] {
54+ continue // Canonical already picked in this layer — skip legacy sibling.
55+ }
3756 abs , err := filepath .Abs (filepath .Join (layerDir , e .Name ()))
3857 if err != nil {
3958 continue
4059 }
41- winners [e .Name ()] = abs
60+ layerPick [name ] = abs
61+ layerLegacy [name ] = legacy
4262 }
63+ for name , abs := range layerPick {
64+ winners [name ] = abs
65+ }
66+ }
67+
68+ // Build the set of canonical filenames we will emit. cleanStaleFormulaSymlinks
69+ // uses this to garbage-collect any legacy-suffixed symlinks from prior runs.
70+ canonicalNames := make (map [string ]string , len (winners ))
71+ for name , src := range winners {
72+ canonicalNames [name + formula .CanonicalTOMLExt ] = src
4373 }
4474
4575 symlinkDir := filepath .Join (targetDir , ".beads" , "formulas" )
4676
4777 if len (winners ) == 0 {
48- return cleanStaleFormulaSymlinks (symlinkDir , winners )
78+ return cleanStaleFormulaSymlinks (symlinkDir , canonicalNames )
4979 }
5080
5181 // Ensure target symlink directory exists.
5282 if err := os .MkdirAll (symlinkDir , 0o755 ); err != nil {
5383 return fmt .Errorf ("creating formula symlink dir: %w" , err )
5484 }
5585
56- // Create/update symlinks for winners.
57- for name , srcPath := range winners {
58- linkPath := filepath .Join (symlinkDir , name )
86+ // Create/update canonical symlinks for winners. The link is always named
87+ // <formula-name>.toml regardless of whether the winning source file on
88+ // disk uses the canonical or legacy extension.
89+ for linkName , srcPath := range canonicalNames {
90+ linkPath := filepath .Join (symlinkDir , linkName )
5991
6092 // Check if a real file (non-symlink) exists — don't overwrite.
6193 fi , err := os .Lstat (linkPath )
@@ -74,11 +106,11 @@ func ResolveFormulas(targetDir string, layers []string) error {
74106 }
75107
76108 if err := os .Symlink (srcPath , linkPath ); err != nil {
77- return fmt .Errorf ("creating formula symlink %q → %q: %w" , name , srcPath , err )
109+ return fmt .Errorf ("creating formula symlink %q → %q: %w" , linkName , srcPath , err )
78110 }
79111 }
80112
81- return cleanStaleFormulaSymlinks (symlinkDir , winners )
113+ return cleanStaleFormulaSymlinks (symlinkDir , canonicalNames )
82114}
83115
84116// cleanStaleFormulaSymlinks removes symlinks in symlinkDir that are not in
@@ -91,7 +123,7 @@ func cleanStaleFormulaSymlinks(symlinkDir string, winners map[string]string) err
91123 return nil // Can't read — nothing to clean up.
92124 }
93125 for _ , e := range entries {
94- if e .IsDir () || ! strings . HasSuffix (e .Name (), ".formula.toml" ) {
126+ if e .IsDir () || ! formula . IsTOMLFilename (e .Name ()) {
95127 continue
96128 }
97129 linkPath := filepath .Join (symlinkDir , e .Name ())
0 commit comments