From 744ae45c15c8e5049360669a576238622a2d9097 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 12 Sep 2025 11:40:52 +0800 Subject: [PATCH] feat: add utility functions for environment variable handling, file operations, pointer manipulation, random generation, and string processing --- utils/envx/envx.go | 101 +++++++++++++++++++++ utils/filex/filex.go | 209 +++++++++++++++++++++++++++++++++++++++++++ utils/jsonx/json.go | 28 ++++++ utils/ptr/ptr.go | 16 ++++ utils/randx/randx.go | 77 ++++++++++++++++ utils/strx/strx.go | 60 +++++++++++++ 6 files changed, 491 insertions(+) create mode 100644 utils/envx/envx.go create mode 100644 utils/filex/filex.go create mode 100644 utils/jsonx/json.go create mode 100644 utils/ptr/ptr.go create mode 100644 utils/randx/randx.go create mode 100644 utils/strx/strx.go diff --git a/utils/envx/envx.go b/utils/envx/envx.go new file mode 100644 index 0000000..59e63a2 --- /dev/null +++ b/utils/envx/envx.go @@ -0,0 +1,101 @@ +package envx + +import ( + "os" + "strconv" + "strings" + "time" +) + +// Get returns the environment variable or the provided default if unset. +func Get(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +// MustGet returns the environment variable or panics if it is not set. +func MustGet(key string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + panic("required env var not set: " + key) +} + +// Bool parses a boolean-like env var with a default fallback. +// Recognized true values: 1, t, true, y, yes, on (case-insensitive) +// Recognized false values: 0, f, false, n, no, off +func Bool(key string, def bool) bool { + v, ok := os.LookupEnv(key) + if !ok { + return def + } + s := strings.TrimSpace(strings.ToLower(v)) + switch s { + case "1", "t", "true", "y", "yes", "on": + return true + case "0", "f", "false", "n", "no", "off": + return false + default: + return def + } +} + +// Int parses an int env var with a default fallback. +func Int(key string, def int) int { + v, ok := os.LookupEnv(key) + if !ok { + return def + } + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil { + return def + } + return n +} + +// Int64 parses an int64 env var with a default fallback. +func Int64(key string, def int64) int64 { + v, ok := os.LookupEnv(key) + if !ok { + return def + } + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return def + } + return n +} + +// Float64 parses a float64 env var with a default fallback. +func Float64(key string, def float64) float64 { + v, ok := os.LookupEnv(key) + if !ok { + return def + } + n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return def + } + return n +} + +// Duration parses a time.Duration env var with a default fallback. +func Duration(key string, def time.Duration) time.Duration { + v, ok := os.LookupEnv(key) + if !ok { + return def + } + d, err := time.ParseDuration(strings.TrimSpace(v)) + if err != nil { + return def + } + return d +} + +// Expand expands ${var} or $var in the string according to the values of the current environment. +func Expand(s string) string { + return os.ExpandEnv(s) +} + diff --git a/utils/filex/filex.go b/utils/filex/filex.go new file mode 100644 index 0000000..0ceef58 --- /dev/null +++ b/utils/filex/filex.go @@ -0,0 +1,209 @@ +package filex + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// Exists reports whether the given path exists (file or directory). +func Exists(path string) bool { + if path == "" { + return false + } + _, err := os.Stat(path) + return err == nil +} + +// IsFile reports whether the path exists and is a regular file. +func IsFile(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.Mode().IsRegular() +} + +// IsDir reports whether the path exists and is a directory. +func IsDir(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +// IsReadable reports whether the target can be opened for reading. +func IsReadable(path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + _ = f.Close() + return true +} + +// IsWritable reports whether a file can be written to. If the file does not exist, +// it checks writability of the parent directory by attempting to create a temp file. +func IsWritable(path string) bool { + if path == "" { + return false + } + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + // Try writing a temp file in the directory + f, err := os.CreateTemp(path, ".wtmp-*") + if err != nil { + return false + } + name := f.Name() + _ = f.Close() + _ = os.Remove(name) + return true + } + // Try opening for write + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + return false + } + _ = f.Close() + return true + } + if !errors.Is(err, os.ErrNotExist) { + return false + } + // Not exist: check parent dir is writable by creating a temp file + dir := filepath.Dir(path) + if dir == "." || dir == "" { + dir = "." + } + f, err := os.CreateTemp(dir, ".wtmp-*") + if err != nil { + return false + } + name := f.Name() + _ = f.Close() + _ = os.Remove(name) + return true +} + +// IsExecutable reports whether a file is considered executable on the current OS. +// On Unix, checks the executable bits; on Windows, checks the extension against PATHEXT. +func IsExecutable(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false + } + if runtime.GOOS == "windows" { + ext := strings.ToLower(filepath.Ext(path)) + if ext == ".exe" || ext == ".bat" || ext == ".cmd" || ext == ".com" || ext == ".ps1" { + return true + } + pathext := strings.ToLower(os.Getenv("PATHEXT")) + for _, e := range strings.Split(pathext, ";") { + if e != "" && ext == strings.ToLower(e) { + return true + } + } + return false + } + // Unix permissions: any execute bit set + return info.Mode()&0o111 != 0 +} + +// Size returns the size of the file at path. +func Size(path string) (int64, error) { + info, err := os.Stat(path) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// Touch creates the file if it does not exist, or updates its modification time if it does. +func Touch(path string) error { + now := time.Now() + if Exists(path) { + return os.Chtimes(path, now, now) + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + return f.Close() +} + +// EnsureDir ensures that a directory exists with the specified permission bits. +func EnsureDir(path string, perm fs.FileMode) error { + if path == "" { + return nil + } + if Exists(path) { + if !IsDir(path) { + return errors.New("path exists and is not a directory") + } + return nil + } + return os.MkdirAll(path, perm) +} + +// FindInPath searches for an executable named name in the system PATH. +func FindInPath(name string) (string, bool) { + p, err := exec.LookPath(name) + if err != nil { + return "", false + } + return p, true +} + +// ReadFileString reads a whole file into a string. +func ReadFileString(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + return string(b), nil +} + +// WriteFileString writes a string to a file with the given permissions, creating or truncating it. +func WriteFileString(path, s string, perm fs.FileMode) error { + return os.WriteFile(path, []byte(s), perm) +} + +// WriteFileAtomic writes data to a temporary file in the same directory and renames it into place. +func WriteFileAtomic(path string, data []byte, perm fs.FileMode) error { + dir := filepath.Dir(path) + if err := EnsureDir(dir, 0o755); err != nil { + return err + } + f, err := os.CreateTemp(dir, ".atomic-*") + if err != nil { + return err + } + tmp := f.Name() + // Best-effort cleanup on error + defer func() { _ = os.Remove(tmp) }() + if _, err := f.Write(data); err != nil { + _ = f.Close() + return err + } + if err := f.Chmod(perm); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, path) +} + diff --git a/utils/jsonx/json.go b/utils/jsonx/json.go new file mode 100644 index 0000000..2692e1a --- /dev/null +++ b/utils/jsonx/json.go @@ -0,0 +1,28 @@ +package jsonx + +import "encoding/json" + +func Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func Unmarshal[T any](data []byte) (*T, error) { + var v T + err := json.Unmarshal(data, &v) + if err != nil { + return nil, err + } + return &v, nil +} + +func UnmarshalTo(data []byte, a any) error { + return json.Unmarshal(data, a) +} + +func MustUnmarshal[T any](data []byte) *T { + v, err := Unmarshal[T](data) + if err != nil { + panic(err) + } + return v +} diff --git a/utils/ptr/ptr.go b/utils/ptr/ptr.go new file mode 100644 index 0000000..dbb2fb5 --- /dev/null +++ b/utils/ptr/ptr.go @@ -0,0 +1,16 @@ +package ptr + +// Of returns a pointer to v. +func Of[T any](v T) *T { return &v } + +// Deref returns the value pointed to by p, or def if p is nil. +func Deref[T any](p *T, def T) T { + if p == nil { + return def + } + return *p +} + +// Zero returns the zero value for type T. +func Zero[T any]() (z T) { return } + diff --git a/utils/randx/randx.go b/utils/randx/randx.go new file mode 100644 index 0000000..2aad797 --- /dev/null +++ b/utils/randx/randx.go @@ -0,0 +1,77 @@ +package randx + +import ( + crand "crypto/rand" + "encoding/hex" + "io" +) + +// Bytes returns n cryptographically secure random bytes. +func Bytes(n int) ([]byte, error) { + if n <= 0 { + return []byte{}, nil + } + b := make([]byte, n) + _, err := io.ReadFull(crand.Reader, b) + return b, err +} + +var urlSafe = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + +// String returns a URL-safe random string of length n using a cryptographically secure source. +func String(n int) (string, error) { + if n <= 0 { + return "", nil + } + out := make([]byte, n) + // rejection sampling to avoid modulo bias + // we want uniform selection from urlSafe (len m) + m := len(urlSafe) + threshold := 256 - (256 % m) + var buf [1]byte + for i := 0; i < n; i++ { + for { + if _, err := io.ReadFull(crand.Reader, buf[:]); err != nil { + return "", err + } + b := int(buf[0]) + if b < threshold { + out[i] = urlSafe[b%m] + break + } + } + } + return string(out), nil +} + +// MustString is like String but panics on error. +func MustString(n int) string { + s, err := String(n) + if err != nil { + panic(err) + } + return s +} + +// UUIDv4 generates a RFC 4122 version 4 UUID string. +func UUIDv4() (string, error) { + b := make([]byte, 16) + if _, err := io.ReadFull(crand.Reader, b); err != nil { + return "", err + } + // Set version (4) and variant (10) + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + // Format 8-4-4-4-12 + dst := make([]byte, 36) + hex.Encode(dst[0:8], b[0:4]) + dst[8] = '-' + hex.Encode(dst[9:13], b[4:6]) + dst[13] = '-' + hex.Encode(dst[14:18], b[6:8]) + dst[18] = '-' + hex.Encode(dst[19:23], b[8:10]) + dst[23] = '-' + hex.Encode(dst[24:36], b[10:16]) + return string(dst), nil +} diff --git a/utils/strx/strx.go b/utils/strx/strx.go new file mode 100644 index 0000000..4edfa16 --- /dev/null +++ b/utils/strx/strx.go @@ -0,0 +1,60 @@ +package strx + +import ( + "strings" +) + +// Coalesce returns the first non-empty string from vals, or "" if all are empty. +func Coalesce(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// ContainsFold reports whether substr is within s, case-insensitively. +func ContainsFold(s, substr string) bool { + if substr == "" { + return true + } + return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) +} + +// SplitAndTrim splits s by sep, trims spaces for each part, and filters empty items. +func SplitAndTrim(s, sep string) []string { + if s == "" { + return nil + } + parts := strings.Split(s, sep) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// JoinNonEmpty joins parts using sep, skipping empty strings. +func JoinNonEmpty(sep string, parts ...string) string { + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + return strings.Join(out, sep) +} + +// TrimAll collapses any whitespace runs to single spaces and trims both ends. +func TrimAll(s string) string { + if s == "" { + return "" + } + fields := strings.Fields(s) + return strings.Join(fields, " ") +} +