Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package tomlfile writes TOML files atomically with owner-only
|
||||
// permissions, shared by the users, tokens and template stores.
|
||||
package tomlfile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||
)
|
||||
|
||||
// Write persists rows as the array of tables name, and replaces path
|
||||
// atomically at mode 0600.
|
||||
//
|
||||
// The rows are []map[string]any rather than []any on purpose: interpres
|
||||
// renders an array of tables as one [[name]] block per row, which is the
|
||||
// form these files are documented in and the form a reader can edit, while
|
||||
// a []any of tables renders as a single inline array.
|
||||
func Write(path, name string, rows []map[string]any) error {
|
||||
payload, err := interpres.Marshal(map[string]any{name: rows})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode toml: %w", err)
|
||||
}
|
||||
parent := filepath.Dir(path)
|
||||
if parent == "" {
|
||||
parent = "."
|
||||
}
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(parent, ".toml-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(payload); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("sync: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("close: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("replace: %w", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
return fmt.Errorf("chmod: %w", err)
|
||||
}
|
||||
syncDir(parent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDir(dir string) {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer d.Close()
|
||||
_ = d.Sync()
|
||||
}
|
||||
|
||||
// Tables normalises a TOML array of tables to a slice of maps.
|
||||
// interpres decodes [[section]] arrays as []map[string]any, while
|
||||
// inline arrays decode to []any; both are accepted.
|
||||
func Tables(value any) []map[string]any {
|
||||
switch list := value.(type) {
|
||||
case []map[string]any:
|
||||
return list
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for _, item := range list {
|
||||
if table, ok := item.(map[string]any); ok {
|
||||
out = append(out, table)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strings normalises a TOML array of strings.
|
||||
func Strings(value any) []string {
|
||||
var list []any
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return v
|
||||
case []any:
|
||||
list = v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
} else {
|
||||
out = append(out, "")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// String returns the value of a decoded TOML string field, and "" for a
|
||||
// missing or non-string value. It is the shared accessor for the files
|
||||
// that read hand-edited TOML.
|
||||
func String(value any) string {
|
||||
s, _ := value.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Bool returns the value of a decoded TOML boolean field, and fallback
|
||||
// for a missing or non-boolean value, so an omitted key can mean the
|
||||
// value the reader wants.
|
||||
func Bool(value any, fallback bool) bool {
|
||||
b, ok := value.(bool)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Int64 returns the value of a decoded TOML integer field, and fallback
|
||||
// for a missing or non-integer value.
|
||||
func Int64(value any, fallback int64) int64 {
|
||||
if i, ok := value.(int64); ok {
|
||||
return i
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package tomlfile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||
)
|
||||
|
||||
func TestWriteAndReload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sub", "data.toml")
|
||||
if err := Write(path, "users", []map[string]any{
|
||||
{"username": "admin", "role": "admin"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode = %v", info.Mode().Perm())
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "[[users]]") {
|
||||
t.Fatalf("content = %s", raw)
|
||||
}
|
||||
parsed, err := interpres.ParseMap(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
tables := Tables(parsed["users"])
|
||||
if len(tables) != 1 || tables[0]["username"] != "admin" {
|
||||
t.Fatalf("tables = %v", tables)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesShapes(t *testing.T) {
|
||||
if Tables(nil) != nil {
|
||||
t.Fatal("nil input should yield nil")
|
||||
}
|
||||
if got := Tables("nope"); got != nil {
|
||||
t.Fatalf("got = %v", got)
|
||||
}
|
||||
fromAny := Tables([]any{map[string]any{"a": 1}, "skip"})
|
||||
if len(fromAny) != 1 || fromAny[0]["a"] != 1 {
|
||||
t.Fatalf("fromAny = %v", fromAny)
|
||||
}
|
||||
fromMaps := Tables([]map[string]any{{"b": 2}})
|
||||
if len(fromMaps) != 1 || fromMaps[0]["b"] != 2 {
|
||||
t.Fatalf("fromMaps = %v", fromMaps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringsShapes(t *testing.T) {
|
||||
if Strings(nil) != nil {
|
||||
t.Fatal("nil input should yield nil")
|
||||
}
|
||||
if Strings(42) != nil {
|
||||
t.Fatal("non-array input should yield nil")
|
||||
}
|
||||
got := Strings([]any{"a", 1, "b"})
|
||||
if len(got) != 3 || got[0] != "a" || got[2] != "b" {
|
||||
t.Fatalf("got = %v", got)
|
||||
}
|
||||
if direct := Strings([]string{"x"}); len(direct) != 1 || direct[0] != "x" {
|
||||
t.Fatalf("direct = %v", direct)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user