1package vnext23import (4 "encoding/hex"5 "fmt"6 "sort"7 "strings"8 "time"9)1011const Extension = ".atlas"1213// Release is the small, schema-independent identity used while scanning a14// library. CreatedAt is source capture time, not build time.15type Release struct {16 Title string `json:"title"`17 CreatedAt string `json:"createdAt"`18 Revision int `json:"revision,omitempty"`19 Stamp string `json:"stamp"`20 Worlds int `json:"worlds"`21}2223func (release Release) Validate() error { return release.validate(true) }2425func (release Release) validate(requireStamp bool) error {26 if release.Title == "" {27 return fmt.Errorf("title is empty")28 }29 if _, err := time.Parse(time.RFC3339, release.CreatedAt); err != nil {30 return fmt.Errorf("createdAt is not RFC 3339: %w", err)31 }32 if release.Revision < 0 {33 return fmt.Errorf("revision is negative")34 }35 if release.Worlds < 1 {36 return fmt.Errorf("world count is less than one")37 }38 if requireStamp {39 decoded, err := hex.DecodeString(release.Stamp)40 if err != nil || len(decoded) != 32 {41 return fmt.Errorf("stamp is not a SHA-256 digest")42 }43 }44 return nil45}4647// ValidSlug keeps file and URL identities deliberately smaller than paths.48func ValidSlug(slug string) error {49 if slug == "" {50 return fmt.Errorf("empty")51 }52 for _, character := range slug {53 if character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '_' {54 continue55 }56 return fmt.Errorf("%q contains %q", slug, character)57 }58 if strings.HasPrefix(slug, "-") || strings.HasPrefix(slug, "_") {59 return fmt.Errorf("%q starts with a separator", slug)60 }61 return nil62}6364type Descriptor struct {65 Locator string66 Slug string67 Title string68 Stamp string69 CreatedAt string70 Revision int71 Size int6472 Worlds int73}7475func DescriptorOf(locator, slug string, release Release, size int64) Descriptor {76 return Descriptor{77 Locator: locator, Slug: slug, Title: release.Title, Stamp: release.Stamp,78 CreatedAt: release.CreatedAt, Revision: release.Revision, Size: size, Worlds: release.Worlds,79 }80}8182func Newer(left, right Descriptor) bool {83 if left.CreatedAt != right.CreatedAt {84 return left.CreatedAt > right.CreatedAt85 }86 if left.Revision != right.Revision {87 return left.Revision > right.Revision88 }89 if left.Stamp != right.Stamp {90 return left.Stamp > right.Stamp91 }92 return left.Locator > right.Locator93}9495func Fold(candidates []Descriptor) map[string]Descriptor {96 winners := make(map[string]Descriptor, len(candidates))97 for _, candidate := range candidates {98 standing, held := winners[candidate.Slug]99 if !held || Newer(candidate, standing) {100 winners[candidate.Slug] = candidate101 }102 }103 return winners104}105106func Shadowed(candidates []Descriptor) []Descriptor {107 winners := Fold(candidates)108 shadowed := make([]Descriptor, 0)109 for _, candidate := range candidates {110 if winner := winners[candidate.Slug]; winner.Locator != candidate.Locator {111 shadowed = append(shadowed, candidate)112 }113 }114 sort.Slice(shadowed, func(i, j int) bool {115 if shadowed[i].Slug != shadowed[j].Slug {116 return shadowed[i].Slug < shadowed[j].Slug117 }118 return Newer(shadowed[i], shadowed[j])119 })120 return shadowed121}122123func Changed(before, after map[string]Descriptor) []string {124 changed := make([]string, 0)125 for slug, winner := range after {126 if standing, held := before[slug]; !held || standing.Stamp != winner.Stamp {127 changed = append(changed, slug)128 }129 }130 for slug := range before {131 if _, held := after[slug]; !held {132 changed = append(changed, slug)133 }134 }135 sort.Strings(changed)136 return changed137}138139func ShortStamp(stamp string) string {140 if len(stamp) > 12 {141 return stamp[:12]142 }143 return stamp144}145146func CaptureDay(createdAt string) string {147 var day strings.Builder148 for _, character := range createdAt {149 if character >= '0' && character <= '9' {150 day.WriteRune(character)151 if day.Len() == 8 {152 break153 }154 }155 }156 return day.String()157}158159func VersionedFileName(slug string, release Release) string {160 name := slug161 if day := CaptureDay(release.CreatedAt); day != "" {162 name += "-" + day163 }164 return name + "-" + ShortStamp(release.Stamp) + Extension165}