package motd import ( "fmt" "regexp" "slices" "sort" "strconv" "strings" ) // DesiredEntry beschreibt den Soll-Zustand eines Scripts. // Remove markiert ein vorhandenes (gebündeltes) Script zur Deinstallation. type DesiredEntry struct { Name string Enabled bool Install string Remove bool } // Plan-Ops: Es werden nur Modi, Namen und gebündelte Installationen // geändert — niemals Inhalte fremder Scripte. type ( ChmodOp struct { Name string `json:"name"` Enable bool `json:"enable"` } RenameOp struct { From string `json:"from"` To string `json:"to"` } InstallOp struct { Name string `json:"name"` Source string `json:"source,omitempty"` Content string `json:"content,omitempty"` Enabled bool `json:"enabled"` } RemoveOp struct { Name string `json:"name"` } ) // Plan ist der validier- und ausführbare Unterschied zwischen Ist und Soll. type Plan struct { Chmod []ChmodOp `json:"chmod,omitempty"` Renames []RenameOp `json:"renames,omitempty"` Installs []InstallOp `json:"installs,omitempty"` Removes []RemoveOp `json:"removes,omitempty"` } // Empty meldet, ob der Plan nichts tut. func (p Plan) Empty() bool { return p.NumOps() == 0 } // NumOps zählt alle Operationen. func (p Plan) NumOps() int { return len(p.Chmod) + len(p.Renames) + len(p.Installs) + len(p.Removes) } // Summary liefert eine Kurzbeschreibung für Statusmeldungen. func (p Plan) Summary() string { var parts []string if n := len(p.Removes); n > 0 { parts = append(parts, fmt.Sprintf("%d entfernt", n)) } if n := len(p.Installs); n > 0 { parts = append(parts, fmt.Sprintf("%d installiert", n)) } if n := len(p.Renames); n > 0 { parts = append(parts, fmt.Sprintf("%d umbenannt", n)) } if n := len(p.Chmod); n > 0 { parts = append(parts, fmt.Sprintf("%d an/ausgeschaltet", n)) } if len(parts) == 0 { return "keine Änderungen" } return strings.Join(parts, ", ") } var prefixRe = regexp.MustCompile(`^([0-9]+)-`) // suffixOf entfernt den führenden Nummern-Präfix ("00-header" → "header"). func suffixOf(name string) string { if loc := prefixRe.FindStringIndex(name); loc != nil { return name[loc[1]:] } return name } func prefixNum(name string) (float64, bool) { m := prefixRe.FindStringSubmatch(name) if m == nil { return 0, false } n, err := strconv.ParseFloat(m[1], 64) if err != nil { return 0, false } return n, true } // targetName baut den kanonischen Namen für Position i (Schrittweite 10). func targetName(i int, name string) string { return fmt.Sprintf("%02d-%s", i*10, suffixOf(name)) } // DesiredFromPreset baut die vollständige Soll-Liste aus einem Preset: // Gelistete Scripte kommen in Preset-Reihenfolge, ungelistete System-Scripte // behalten Zustand und Position (interleaved über ihren Nummern-Präfix). // Ungelistete gebündelte Scripte werden zur Deinstallation markiert. func DesiredFromPreset(current []Script, entries []DesiredEntry) []DesiredEntry { curByName := make(map[string]Script, len(current)) for _, c := range current { curByName[c.Name] = c } type slot struct { key float64 entry DesiredEntry } var slots []slot seen := make(map[string]bool) for i, e := range entries { if _, exists := curByName[e.Name]; e.Install == "" && !exists { continue // Script existiert auf diesem System nicht } if seen[e.Name] || e.Remove { continue } seen[e.Name] = true slots = append(slots, slot{float64(i)*10 + 0.5, e}) } for i, c := range current { if seen[c.Name] { continue } key := float64(i) * 10 if n, ok := prefixNum(c.Name); ok { key = n } e := DesiredEntry{Name: c.Name, Enabled: c.Enabled} if c.Bundled { e.Remove = true // gebündelte Scripte verwaltet ausschließlich das Preset } slots = append(slots, slot{key, e}) } sort.SliceStable(slots, func(i, j int) bool { return slots[i].key < slots[j].key }) out := make([]DesiredEntry, len(slots)) for i, s := range slots { out[i] = s.entry } return out } // Diff berechnet den Plan von current nach desired. func Diff(current []Script, desired []DesiredEntry) Plan { var plan Plan curByName := make(map[string]Script, len(current)) for _, c := range current { curByName[c.Name] = c } var final []string // Reihenfolge der Dateien nach dem Apply installIdx := make(map[string]int) // gewünschter Name → Index in plan.Installs for _, e := range desired { if e.Remove { if _, exists := curByName[e.Name]; exists { plan.Removes = append(plan.Removes, RemoveOp{Name: e.Name}) } continue } final = append(final, e.Name) cur, exists := curByName[e.Name] if !exists { if e.Install != "" { installIdx[e.Name] = len(plan.Installs) plan.Installs = append(plan.Installs, InstallOp{Name: e.Name, Source: e.Install, Enabled: e.Enabled}) } continue } if cur.Enabled != e.Enabled { plan.Chmod = append(plan.Chmod, ChmodOp{Name: e.Name, Enable: e.Enabled}) } } // Die Wunsch-Reihenfolge muss der lexikalischen Sortierung der Dateinamen // entsprechen (run-parts-Reihenfolge). Tut sie das nicht, werden alle // Positionen neu nummeriert – vorhandene Dateien umbenannt und // Installationen direkt unter dem Zielnamen angelegt. if !slices.IsSorted(final) { for i, name := range final { target := targetName(i, name) if cur, exists := curByName[name]; exists { if target != cur.Name { plan.Renames = append(plan.Renames, RenameOp{From: cur.Name, To: target}) } } else if idx, ok := installIdx[name]; ok { plan.Installs[idx].Name = target } } } return plan }