Files
glpi-ai-agent-controller/internal/envfile/document.go
jbergner fe32431d21
Some checks failed
release-tag / release-image (push) Failing after 55s
init
2026-08-01 23:54:45 +02:00

343 lines
7.5 KiB
Go

package envfile
import (
"bufio"
"errors"
"fmt"
"strconv"
"strings"
"unicode"
)
type LineKind int
const (
LineOther LineKind = iota
LineAssignment
)
type Line struct {
Raw string
Kind LineKind
Key string
Prefix string
RawValue string
Comment string
LineIndex int
}
type Entry struct {
Key string
Value string
RawValue string
Description string
Secret bool
Missing bool
Extra bool
Duplicate bool
}
type Document struct {
Lines []Line
Newline string
TrailingNL bool
}
func Parse(data []byte) (*Document, error) {
text := string(data)
newline := "\n"
if strings.Contains(text, "\r\n") {
newline = "\r\n"
}
trailing := strings.HasSuffix(text, "\n")
text = strings.ReplaceAll(text, "\r\n", "\n")
parts := strings.Split(text, "\n")
if trailing && len(parts) > 0 {
parts = parts[:len(parts)-1]
}
doc := &Document{Newline: newline, TrailingNL: trailing}
for i, raw := range parts {
line := Line{Raw: raw, Kind: LineOther, LineIndex: i}
key, prefix, rawValue, ok := parseAssignment(raw)
if ok {
line.Kind = LineAssignment
line.Key = key
line.Prefix = prefix
line.RawValue = rawValue
}
doc.Lines = append(doc.Lines, line)
}
return doc, nil
}
func parseAssignment(raw string) (key, prefix, value string, ok bool) {
trimmedLeft := strings.TrimLeftFunc(raw, unicode.IsSpace)
if trimmedLeft == "" || strings.HasPrefix(trimmedLeft, "#") {
return "", "", "", false
}
pos := 0
if strings.HasPrefix(trimmedLeft, "export ") {
pos = len("export ")
}
rest := trimmedLeft[pos:]
eq := strings.IndexByte(rest, '=')
if eq <= 0 {
return "", "", "", false
}
candidate := strings.TrimSpace(rest[:eq])
if !validKey(candidate) {
return "", "", "", false
}
absoluteEq := len(raw) - len(trimmedLeft) + pos + eq
return candidate, raw[:absoluteEq+1], raw[absoluteEq+1:], true
}
func validKey(key string) bool {
if key == "" {
return false
}
for i, r := range key {
if i == 0 {
if !(r == '_' || unicode.IsLetter(r)) {
return false
}
continue
}
if !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
return false
}
}
return true
}
func (d *Document) Effective() map[string]string {
out := make(map[string]string)
for _, line := range d.Lines {
if line.Kind == LineAssignment {
out[line.Key] = DecodeValue(line.RawValue)
}
}
return out
}
func (d *Document) Occurrences() map[string]int {
out := make(map[string]int)
for _, line := range d.Lines {
if line.Kind == LineAssignment {
out[line.Key]++
}
}
return out
}
func (d *Document) Set(key, value string) error {
if !validKey(key) {
return fmt.Errorf("invalid environment key %q", key)
}
encoded := EncodeValue(value)
last := -1
for i := range d.Lines {
if d.Lines[i].Kind == LineAssignment && d.Lines[i].Key == key {
last = i
}
}
if last >= 0 {
line := &d.Lines[last]
line.RawValue = encoded
line.Raw = line.Prefix + encoded
return nil
}
if len(d.Lines) > 0 && strings.TrimSpace(d.Lines[len(d.Lines)-1].Raw) != "" {
d.Lines = append(d.Lines, Line{Raw: "", Kind: LineOther})
}
d.Lines = append(d.Lines, Line{Raw: key + "=" + encoded, Kind: LineAssignment, Key: key, Prefix: key + "=", RawValue: encoded})
return nil
}
func (d *Document) Render() []byte {
lines := make([]string, 0, len(d.Lines))
for _, line := range d.Lines {
lines = append(lines, line.Raw)
}
text := strings.Join(lines, d.Newline)
if d.TrailingNL || len(lines) > 0 {
text += d.Newline
}
return []byte(text)
}
func DecodeValue(raw string) string {
raw = strings.TrimSpace(raw)
if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' {
return raw[1 : len(raw)-1]
}
if len(raw) >= 2 && raw[0] == '"' && raw[len(raw)-1] == '"' {
if v, err := strconv.Unquote(raw); err == nil {
return v
}
}
return raw
}
func EncodeValue(value string) string {
if value == "" {
return ""
}
needsQuote := strings.ContainsAny(value, "\n\r\t#\"'") || strings.TrimSpace(value) != value
if !needsQuote {
return value
}
return strconv.Quote(value)
}
func (d *Document) ImportMissing(example *Document, marker string) ([]string, error) {
if example == nil {
return nil, errors.New("example document is nil")
}
existing := d.Effective()
var missing []string
for _, line := range example.Lines {
if line.Kind == LineAssignment {
if _, ok := existing[line.Key]; !ok {
missing = append(missing, line.Key)
}
}
}
if len(missing) == 0 {
return nil, nil
}
if len(d.Lines) > 0 && strings.TrimSpace(d.Lines[len(d.Lines)-1].Raw) != "" {
d.Lines = append(d.Lines, Line{Raw: "", Kind: LineOther})
}
if marker != "" {
d.Lines = append(d.Lines, Line{Raw: marker, Kind: LineOther})
}
missingSet := make(map[string]struct{}, len(missing))
for _, key := range missing {
missingSet[key] = struct{}{}
}
pendingComments := []string{}
added := make(map[string]struct{})
for _, line := range example.Lines {
trimmed := strings.TrimSpace(line.Raw)
if line.Kind != LineAssignment {
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
pendingComments = append(pendingComments, line.Raw)
} else {
pendingComments = nil
}
continue
}
if _, ok := missingSet[line.Key]; !ok {
pendingComments = nil
continue
}
if _, done := added[line.Key]; done {
pendingComments = nil
continue
}
for _, comment := range trimBoundaryBlanks(pendingComments) {
d.Lines = append(d.Lines, Line{Raw: comment, Kind: LineOther})
}
copyLine := line
copyLine.LineIndex = len(d.Lines)
d.Lines = append(d.Lines, copyLine)
added[line.Key] = struct{}{}
pendingComments = nil
}
return missing, nil
}
func trimBoundaryBlanks(lines []string) []string {
start, end := 0, len(lines)
for start < end && strings.TrimSpace(lines[start]) == "" {
start++
}
for end > start && strings.TrimSpace(lines[end-1]) == "" {
end--
}
return lines[start:end]
}
func Descriptions(example *Document) map[string]string {
out := make(map[string]string)
var comments []string
for _, line := range example.Lines {
trimmed := strings.TrimSpace(line.Raw)
if line.Kind == LineAssignment {
var cleaned []string
for _, c := range comments {
c = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c), "#"))
if c != "" && !allRune(c, '#') {
cleaned = append(cleaned, c)
}
}
if len(cleaned) > 0 {
out[line.Key] = strings.Join(cleaned, " ")
}
comments = nil
continue
}
if strings.HasPrefix(trimmed, "#") || trimmed == "" {
comments = append(comments, line.Raw)
} else {
comments = nil
}
}
return out
}
func allRune(s string, r rune) bool {
if s == "" {
return false
}
for _, got := range s {
if got != r {
return false
}
}
return true
}
func Compare(current, example *Document) (missing, extra []string) {
cur := current.Effective()
ex := example.Effective()
for key := range ex {
if _, ok := cur[key]; !ok {
missing = append(missing, key)
}
}
for key := range cur {
if _, ok := ex[key]; !ok {
extra = append(extra, key)
}
}
sortStrings(missing)
sortStrings(extra)
return missing, extra
}
func sortStrings(values []string) {
for i := 1; i < len(values); i++ {
for j := i; j > 0 && values[j] < values[j-1]; j-- {
values[j], values[j-1] = values[j-1], values[j]
}
}
}
func ReadScanner(scanner *bufio.Scanner) (*Document, error) {
var b strings.Builder
first := true
for scanner.Scan() {
if !first {
b.WriteByte('\n')
}
first = false
b.WriteString(scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
return Parse([]byte(b.String()))
}