1package workbench23import (4 "os"5 "path/filepath"6 "sort"7 "sync"8 "time"910 "github.com/FelineStateMachine/atlas/format/vnext"11 "github.com/FelineStateMachine/atlas/internal/enrich/maturity"12)1314// Reading the registry.15//16// This is the one part of the workbench that touches a filesystem, and it is17// kept to one file for that reason: everything above it works on scores that18// are already in memory. The registry is read whole on every ask -- a build19// installed by an operation is visible on the next page load, with no watcher20// and no cache to invalidate by hand -- and scoring is cached by the file's21// size and modification time, the same test the format's own registry uses to22// tell an untouched file from one rewritten under its name. A page load after23// the first is a stat per file.2425// library is a registry directory, scored.26type library struct {27 dir string28 table maturity.Table2930 mu sync.Mutex31 cache map[string]scored32}3334type scored struct {35 size int6436 modTime time.Time37 score *maturity.Score38}3940// volume is every build of one slug, newest first: the registry's own fold, so41// the first build of every volume is the one a reader is served.42type volume struct {43 Slug string44 Title string45 Builds []*maturity.Score46}4748// Serving is the build a reader would be given.49func (v *volume) Serving() *maturity.Score { return v.Builds[0] }5051// Previous is the build before the serving one, or nil when there is only one.52func (v *volume) Previous() *maturity.Score {53 if len(v.Builds) < 2 {54 return nil55 }56 return v.Builds[1]57}5859// Build finds one build by file name, which is how a diff URL names them.60func (v *volume) Build(file string) *maturity.Score {61 for _, build := range v.Builds {62 if build.File == file {63 return build64 }65 }66 return nil67}6869// Movement is what the serving build did to the one before it: the score delta70// that headlines a measurement page. A volume with one build has none.71func (v *volume) Movement() *maturity.Comparison {72 previous := v.Previous()73 if previous == nil {74 return nil75 }76 moved := maturity.Compare(previous, v.Serving())77 return &moved78}7980// Sources are the readings the serving build carries, in the order its ledger81// records them and without repetition: one badge per source, whatever number of82// worlds it contributed to.83func (v *volume) Sources() []string {84 var seen []string85 held := map[string]bool{}86 for _, line := range v.Serving().Ledger {87 name := line.Account.Source88 if name == "" || held[name] {89 continue90 }91 held[name] = true92 seen = append(seen, name)93 }94 return seen95}9697// volumes scores every bundle in the directory and groups the builds by volume.98//99// A bundle that will not measure is reported in skipped rather than failing the100// scan: one half-written file should not take the workbench down.101func (l *library) volumes() (volumes []*volume, skipped []string, err error) {102 paths, err := filepath.Glob(filepath.Join(l.dir, "*"+vnext.Extension))103 if err != nil {104 return nil, nil, err105 }106 sort.Strings(paths)107108 bySlug := make(map[string]*volume)109 for _, path := range paths {110 score, err := l.score(path)111 if err != nil {112 skipped = append(skipped, filepath.Base(path)+": "+err.Error())113 continue114 }115 held, ok := bySlug[score.Volume]116 if !ok {117 held = &volume{Slug: score.Volume}118 bySlug[score.Volume] = held119 volumes = append(volumes, held)120 }121 held.Builds = append(held.Builds, score)122 }123124 for _, held := range bySlug {125 sort.Slice(held.Builds, func(a, b int) bool {126 return vnext.Newer(descriptorOf(held.Builds[a]), descriptorOf(held.Builds[b]))127 })128 held.Title = held.Serving().Title129 }130 sort.Slice(volumes, func(a, b int) bool {131 if volumes[a].Title != volumes[b].Title {132 return volumes[a].Title < volumes[b].Title133 }134 return volumes[a].Slug < volumes[b].Slug135 })136 return volumes, skipped, nil137}138139// volumeBySlug scans and returns one volume, or nil when the slug has no builds.140func (l *library) volumeBySlug(slug string) (*volume, []string, error) {141 volumes, skipped, err := l.volumes()142 if err != nil {143 return nil, nil, err144 }145 for _, held := range volumes {146 if held.Slug == slug {147 return held, skipped, nil148 }149 }150 return nil, skipped, nil151}152153func (l *library) score(path string) (*maturity.Score, error) {154 info, err := os.Stat(path)155 if err != nil {156 return nil, err157 }158 l.mu.Lock()159 held, ok := l.cache[path]160 l.mu.Unlock()161 if ok && held.size == info.Size() && held.modTime.Equal(info.ModTime()) {162 return held.score, nil163 }164 measured, err := maturity.Measure(path, l.table)165 if err != nil {166 return nil, err167 }168 l.mu.Lock()169 if l.cache == nil {170 l.cache = make(map[string]scored)171 }172 l.cache[path] = scored{size: info.Size(), modTime: info.ModTime(), score: measured}173 l.mu.Unlock()174 return measured, nil175}176177// descriptorOf is a scored build as the format's registry sees it, so newest178// first means here exactly what it means to a reader opening the library.179// Ordering has one definition and it lives in format/vnext.180func descriptorOf(s *maturity.Score) vnext.Descriptor {181 return vnext.Descriptor{182 Locator: s.Path,183 Slug: s.Volume,184 Title: s.Title,185 Stamp: s.Stamp,186 CreatedAt: s.CreatedAt,187 Revision: s.Revision,188 }189}190191// features reads one build's point features, by world, for a diff. Only a192// comparison ever needs them, so they are unpacked on demand rather than kept193// beside every score.194func features(score *maturity.Score) (map[string]map[string]string, error) {195 reader, err := vnext.OpenFile(score.Path, vnext.StandardSchema())196 if err != nil {197 return nil, err198 }199 defer reader.Close()200201 volume, err := reader.Volume()202 if err != nil {203 return nil, err204 }205 byWorld := make(map[string]map[string]string, len(volume.Worlds))206 for _, world := range volume.Worlds {207 held := make(map[string]string)208 for _, set := range world.FeatureSets {209 for _, feature := range set.Features {210 held[feature.ID] = feature.Title211 }212 }213 byWorld[world.ID] = held214 }215 return byWorld, nil216}