1package spec23import (4 "fmt"5 "sort"6 "time"7)89// EventKind describes what happened to an item between two snapshots.10type EventKind int1112const (13 EventCreated EventKind = iota14 EventEdited15 EventProgress16 EventArchived17 EventRemoved18)1920// ItemKind identifies which list an event's item lives in.21type ItemKind int2223const (24 ItemChange ItemKind = iota25 ItemSpec26 ItemArchive27)2829// Event is one line in the activity feed.30type Event struct {31 Time time.Time32 Kind EventKind33 Item ItemKind34 ID string // change or spec ID35 Rel string // artifact path within the change, or "spec.md"36 Detail string37}3839// Diff describes what changed from prev to next, oldest-first within a call.40func Diff(prev, next *Snapshot, now time.Time) []Event {41 if prev == nil || next == nil {42 return nil43 }44 var events []Event45 emit := func(kind EventKind, item ItemKind, id, rel, format string, args ...any) {46 events = append(events, Event{47 Time: now, Kind: kind, Item: item, ID: id, Rel: rel,48 Detail: fmt.Sprintf(format, args...),49 })50 }5152 prevChanges := indexChanges(prev.Changes)53 nextChanges := indexChanges(next.Changes)54 // Map each archived name to the entry that best explains an active change55 // disappearing: prefer one new in this snapshot (older archives may reuse56 // the name), else the newest, which covers copy-then-delete archiving57 // where the copy landed in an earlier snapshot.58 prevArchive := indexChanges(prev.Archive)59 archivedNames := map[string]string{}60 for _, c := range next.Archive { // newest first61 cur, seen := archivedNames[c.Name]62 _, curOld := prevArchive[cur]63 _, isOld := prevArchive[c.ID]64 if !seen || (curOld && !isOld) {65 archivedNames[c.Name] = c.ID66 }67 }6869 for _, id := range sortedKeys(nextChanges) {70 c := nextChanges[id]71 old, ok := prevChanges[id]72 if !ok {73 emit(EventCreated, ItemChange, id, "", "new change")74 continue75 }76 diffArtifacts(old, c, emit)77 }78 for _, id := range sortedKeys(prevChanges) {79 if _, ok := nextChanges[id]; ok {80 continue81 }82 if archID, ok := archivedNames[id]; ok {83 emit(EventArchived, ItemArchive, archID, "", "archived")84 continue85 }86 emit(EventRemoved, ItemChange, id, "", "change removed")87 }8889 prevSpecs := map[string]Spec{}90 for _, s := range prev.Specs {91 prevSpecs[s.ID] = s92 }93 nextSpecs := map[string]bool{}94 for _, s := range next.Specs {95 nextSpecs[s.ID] = true96 old, ok := prevSpecs[s.ID]97 switch {98 case !ok:99 emit(EventCreated, ItemSpec, s.ID, "spec.md", "new spec, %d requirements", s.Requirements)100 case old.Requirements != s.Requirements:101 emit(EventEdited, ItemSpec, s.ID, "spec.md", "requirements %d → %d", old.Requirements, s.Requirements)102 case !old.ModTime.Equal(s.ModTime):103 emit(EventEdited, ItemSpec, s.ID, "spec.md", "spec.md")104 }105 }106 for _, s := range prev.Specs {107 if !nextSpecs[s.ID] {108 emit(EventRemoved, ItemSpec, s.ID, "", "spec removed")109 }110 }111 return events112}113114func diffArtifacts(old, c Change, emit func(EventKind, ItemKind, string, string, string, ...any)) {115 oldArts := map[string]Artifact{}116 for _, a := range old.Artifacts {117 oldArts[a.Rel] = a118 }119 seen := map[string]bool{}120 for _, a := range c.Artifacts {121 seen[a.Rel] = true122 prevArt, existed := oldArts[a.Rel]123 switch {124 case !existed:125 emit(EventCreated, ItemChange, c.ID, a.Rel, "%s", a.Rel)126 case a.Kind == KindTasks && old.Tasks != c.Tasks:127 emit(EventProgress, ItemChange, c.ID, a.Rel, "tasks %d/%d → %d/%d",128 old.Tasks.Done, old.Tasks.Total, c.Tasks.Done, c.Tasks.Total)129 case !prevArt.ModTime.Equal(a.ModTime):130 emit(EventEdited, ItemChange, c.ID, a.Rel, "%s", a.Rel)131 }132 }133 for _, a := range old.Artifacts {134 if !seen[a.Rel] {135 emit(EventRemoved, ItemChange, c.ID, "", "%s", a.Rel)136 }137 }138}139140func indexChanges(cs []Change) map[string]Change {141 m := make(map[string]Change, len(cs))142 for _, c := range cs {143 m[c.ID] = c144 }145 return m146}147148func sortedKeys(m map[string]Change) []string {149 keys := make([]string, 0, len(m))150 for k := range m {151 keys = append(keys, k)152 }153 sort.Strings(keys)154 return keys155}