File order decided precedence, so a narrow rule had to be written above the broad one it carves an exception out of -- an ordering constraint the file cannot show and the user has to remember. *NIKOLA* below *NIK* silently matched nothing, and a catch-all * could only ever be the last line. Engine.New now sorts once and MatchIndex walks that order: most literal characters first, then fewest *, then account-scoped over unscoped. Literals are what a rule commits to and a * is what it gives up, so a bare * is tried last wherever it sits. The sort is stable, so equally specific rules keep file order and the earlier one wins -- which is all position decides now, and why AppendRule can keep appending without displacing a rule written by hand. The two orders must not be confused: Rules(), Usage and MatchIndex still speak in file positions, because that is what the rules screen numbers and what DeleteRules deletes by. A shadowed rule still reports zero usage, but a zero no longer says anything about where the rule sits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
585 lines
19 KiB
Go
585 lines
19 KiB
Go
// Package config loads the two hand-edited files the tool reads: the
|
|
// data-root-wide rules.toml, and one account.toml per account folder.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
const (
|
|
// RulesFile is the rules file at the root of the data directory.
|
|
RulesFile = "rules.toml"
|
|
// AccountFile is the per-account config inside each account folder.
|
|
AccountFile = "account.toml"
|
|
// IndexFile is the rebuildable SQLite index, at the root of the data
|
|
// directory alongside rules.toml.
|
|
IndexFile = "index.db"
|
|
)
|
|
|
|
// Rule is one entry in rules.toml. The most specific rule that matches wins,
|
|
// with file order breaking ties between equally specific ones; rules.Engine
|
|
// owns that ordering.
|
|
type Rule struct {
|
|
Match string `toml:"match"`
|
|
Tag string `toml:"tag"`
|
|
Account string `toml:"account"` // optional: restrict to one account slug
|
|
// Type matches the bank's own classification, e.g. Revolut's CARD_PAYMENT.
|
|
// Optional; when set, it must match as well as Match.
|
|
Type string `toml:"type"`
|
|
// Note is free text for the reader: why the rule exists, or what the
|
|
// unrecognisable payee behind the glob actually is. It never affects
|
|
// matching. It is a key rather than a comment so it survives a round trip
|
|
// through LoadRules and can be shown on the rules screen.
|
|
Note string `toml:"note"`
|
|
}
|
|
|
|
// Transfer is one entry in rules.toml describing money moved between two
|
|
// accounts the user owns. Both sides are named: a transfer is only ever a pair,
|
|
// which is what lets the report drop it without leaving half a movement behind.
|
|
//
|
|
// All four patterns are required. A one-sided definition would be a rule.
|
|
type Transfer struct {
|
|
FromAccount string `toml:"from_account"`
|
|
FromDesc string `toml:"from_desc"` // glob vs. the leaving leg's description
|
|
ToAccount string `toml:"to_account"`
|
|
ToDesc string `toml:"to_desc"` // glob vs. the arriving leg's description
|
|
// TolerancePct is how far short (or over) the arriving leg may be and still
|
|
// count as the same movement, as a percentage of the leg that left. It exists
|
|
// for routes where the bank takes a fee on the way, so the two statements
|
|
// genuinely disagree about the amount.
|
|
//
|
|
// Zero — the default, and what every definition written before this key
|
|
// existed means — requires the exact opposite amount. Keep it that way unless
|
|
// a route actually charges: the wider the tolerance, the more likely two
|
|
// unrelated movements in one window pair with each other.
|
|
//
|
|
// It applies within one currency only. Across currencies the amount is not
|
|
// checked at all, so there is nothing for a tolerance to loosen.
|
|
TolerancePct float64 `toml:"tolerance_pct"`
|
|
// Note is free text for the reader, exactly as on a Rule: it never takes
|
|
// part in matching.
|
|
Note string `toml:"note"`
|
|
}
|
|
|
|
// Rules is the parsed rules.toml: the tagging rules and the transfer
|
|
// definitions, which share the file because they are both hand-maintained
|
|
// statements about the same transactions.
|
|
type Rules struct {
|
|
Rule []Rule `toml:"rule"`
|
|
Transfer []Transfer `toml:"transfer"`
|
|
}
|
|
|
|
// LoadRules reads rules.toml from the data root. A missing file is not an
|
|
// error: it just means nothing is tagged automatically yet.
|
|
func LoadRules(root string) (*Rules, error) {
|
|
path := filepath.Join(root, RulesFile)
|
|
var r Rules
|
|
if _, err := toml.DecodeFile(path, &r); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &r, nil
|
|
}
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
for i, rule := range r.Rule {
|
|
if rule.Match == "" && rule.Type == "" {
|
|
return nil, fmt.Errorf("%s: rule %d has no match or type pattern", path, i+1)
|
|
}
|
|
if rule.Tag == "" {
|
|
return nil, fmt.Errorf("%s: rule %d (%q) sets no tag", path, i+1, rule.Match)
|
|
}
|
|
}
|
|
for i, t := range r.Transfer {
|
|
if err := checkTransfer(t); err != nil {
|
|
return nil, fmt.Errorf("%s: transfer %d: %w", path, i+1, err)
|
|
}
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// checkTransfer rejects a half-written definition. Both sides are needed to
|
|
// pair anything at all, so a missing one is refused on load rather than
|
|
// silently matching nothing.
|
|
func checkTransfer(t Transfer) error {
|
|
switch {
|
|
case t.FromAccount == "":
|
|
return fmt.Errorf("no from_account")
|
|
case t.FromDesc == "":
|
|
return fmt.Errorf("no from_desc pattern")
|
|
case t.ToAccount == "":
|
|
return fmt.Errorf("no to_account")
|
|
case t.ToDesc == "":
|
|
return fmt.Errorf("no to_desc pattern")
|
|
}
|
|
// A negative tolerance is a typo, and one at 100% or beyond would let any
|
|
// amount pair with any other — at which point the dates decide alone, which
|
|
// is the cross-currency rule and not something to arrive at by accident.
|
|
if t.TolerancePct < 0 || t.TolerancePct >= 100 {
|
|
return fmt.Errorf("tolerance_pct %g is not between 0 and 100", t.TolerancePct)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AppendRule adds a rule to the end of rules.toml, creating the file if it is
|
|
// not there yet. Position no longer decides precedence — the most specific rule
|
|
// wins, see rules.Engine — but it still breaks ties, so appending rather than
|
|
// inserting keeps a saved rule from displacing an equally specific one the user
|
|
// wrote by hand.
|
|
//
|
|
// The file is rewritten through a temporary file so a failure part-way cannot
|
|
// leave the user with a truncated config.
|
|
func AppendRule(root string, r Rule) error {
|
|
if r.Match == "" && r.Type == "" {
|
|
return fmt.Errorf("a rule needs a match or type pattern")
|
|
}
|
|
if r.Tag == "" {
|
|
return fmt.Errorf("a rule needs a tag")
|
|
}
|
|
|
|
return appendBlock(root, formatRule(r))
|
|
}
|
|
|
|
// AppendTransfer adds a transfer definition to the end of rules.toml. Order
|
|
// matters for transfers as it does for rules: an earlier definition claims a
|
|
// transaction first, so appending cannot steal a leg from one already written.
|
|
func AppendTransfer(root string, t Transfer) error {
|
|
if err := checkTransfer(t); err != nil {
|
|
// A missing side reads better as what the form still wants; anything
|
|
// else already says what is wrong with what was typed.
|
|
if rest, ok := strings.CutPrefix(err.Error(), "no "); ok {
|
|
return fmt.Errorf("a transfer needs %s", rest)
|
|
}
|
|
return err
|
|
}
|
|
return appendBlock(root, formatTransfer(t))
|
|
}
|
|
|
|
// appendBlock adds a rendered TOML table to the end of rules.toml, creating the
|
|
// file if it is not there yet.
|
|
//
|
|
// The file is rewritten through a temporary file so a failure part-way cannot
|
|
// leave the user with a truncated config.
|
|
func appendBlock(root, block string) error {
|
|
path := filepath.Join(root, RulesFile)
|
|
existing, err := os.ReadFile(path)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Write(existing)
|
|
if len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") {
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(block)
|
|
|
|
return writeFileAtomic(root, path, b.String())
|
|
}
|
|
|
|
// DeleteRules removes the rules at the given positions (0-based, as loaded by
|
|
// LoadRules) from rules.toml.
|
|
func DeleteRules(root string, positions []int) (int, error) {
|
|
return deleteBlocks(root, "rule", positions)
|
|
}
|
|
|
|
// DeleteTransfers removes the transfer definitions at the given positions
|
|
// (0-based, as loaded by LoadRules) from rules.toml.
|
|
func DeleteTransfers(root string, positions []int) (int, error) {
|
|
return deleteBlocks(root, "transfer", positions)
|
|
}
|
|
|
|
// blockStart is where one array-of-tables entry begins in rules.toml.
|
|
type blockStart struct {
|
|
table string // "rule" or "transfer"
|
|
line int
|
|
}
|
|
|
|
// blockStarts finds every [[table]] header in the file. Every kind is
|
|
// collected, not just the one being deleted: a block ends where the *next*
|
|
// block of any kind begins, so deleting a rule that happens to sit above a
|
|
// transfer must not swallow it.
|
|
func blockStarts(lines []string) []blockStart {
|
|
var out []blockStart
|
|
for i, line := range lines {
|
|
s := strings.TrimSpace(line)
|
|
if strings.HasPrefix(s, "[[") && strings.HasSuffix(s, "]]") {
|
|
out = append(out, blockStart{table: strings.TrimSpace(s[2 : len(s)-2]), line: i})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// deleteBlocks removes entries of one table from rules.toml.
|
|
//
|
|
// The file is edited textually rather than re-serialised from the parsed
|
|
// values, so comments, ordering and formatting the user put there by hand
|
|
// survive. A comment block sitting directly above a deleted entry goes with it,
|
|
// since it documents that entry; a comment separated by a blank line is treated
|
|
// as a section heading and left alone.
|
|
func deleteBlocks(root, table string, positions []int) (int, error) {
|
|
if len(positions) == 0 {
|
|
return 0, nil
|
|
}
|
|
doomed := map[int]bool{}
|
|
for _, p := range positions {
|
|
doomed[p] = true
|
|
}
|
|
|
|
path := filepath.Join(root, RulesFile)
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
lines := strings.Split(string(raw), "\n")
|
|
|
|
starts := blockStarts(lines)
|
|
// mine[p] is where the p-th entry of this table sits among all the blocks.
|
|
var mine []int
|
|
for k, s := range starts {
|
|
if s.table == table {
|
|
mine = append(mine, k)
|
|
}
|
|
}
|
|
for _, p := range positions {
|
|
if p < 0 || p >= len(mine) {
|
|
return 0, fmt.Errorf("%s %d is out of range; %s holds %d %ss",
|
|
table, p+1, path, len(mine), table)
|
|
}
|
|
}
|
|
|
|
// An entry owns the run of comment lines directly above it, with no blank
|
|
// line in between. Anything further up is a heading for what follows.
|
|
prefix := func(k int) int {
|
|
i := starts[k].line
|
|
for i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "#") {
|
|
i--
|
|
}
|
|
return i
|
|
}
|
|
|
|
drop := map[int]bool{}
|
|
for p := range doomed {
|
|
k := mine[p]
|
|
// The block runs up to the next block's comment prefix, so a comment
|
|
// introducing the following one is not swept up with this one.
|
|
end := len(lines)
|
|
if k+1 < len(starts) {
|
|
end = prefix(k + 1)
|
|
}
|
|
for i := prefix(k); i < end; i++ {
|
|
drop[i] = true
|
|
}
|
|
// Blank lines are the gap between blocks, not part of either; leaving
|
|
// them avoids gluing the neighbours together.
|
|
for i := end - 1; i >= starts[k].line && strings.TrimSpace(lines[i]) == ""; i-- {
|
|
delete(drop, i)
|
|
}
|
|
}
|
|
|
|
kept := make([]string, 0, len(lines))
|
|
for i, line := range lines {
|
|
if !drop[i] {
|
|
kept = append(kept, line)
|
|
}
|
|
}
|
|
out := collapseBlankRuns(kept)
|
|
|
|
// Never write something that will not load again, and never let deleting
|
|
// one kind of block take a different kind with it.
|
|
var check Rules
|
|
if _, err := toml.Decode(out, &check); err != nil {
|
|
return 0, fmt.Errorf("deleting from %s would produce invalid TOML: %w", path, err)
|
|
}
|
|
counts := map[string]int{"rule": len(check.Rule), "transfer": len(check.Transfer)}
|
|
for _, kind := range []string{"rule", "transfer"} {
|
|
want := 0
|
|
for _, s := range starts {
|
|
if s.table == kind {
|
|
want++
|
|
}
|
|
}
|
|
if kind == table {
|
|
want -= len(doomed)
|
|
}
|
|
if counts[kind] != want {
|
|
return 0, fmt.Errorf("deleting from %s would leave %d %ss, expected %d",
|
|
path, counts[kind], kind, want)
|
|
}
|
|
}
|
|
|
|
if err := writeFileAtomic(root, path, out); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(doomed), nil
|
|
}
|
|
|
|
// collapseBlankRuns squeezes the runs of blank lines that deletion leaves
|
|
// behind down to one.
|
|
func collapseBlankRuns(lines []string) string {
|
|
out := make([]string, 0, len(lines))
|
|
blank := false
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "" {
|
|
if blank {
|
|
continue
|
|
}
|
|
blank = true
|
|
} else {
|
|
blank = false
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
// Drop leading blank lines outright.
|
|
for len(out) > 0 && strings.TrimSpace(out[0]) == "" {
|
|
out = out[1:]
|
|
}
|
|
text := strings.Join(out, "\n")
|
|
return strings.TrimRight(text, "\n") + "\n"
|
|
}
|
|
|
|
// writeFileAtomic replaces path via a temporary file in the same directory, so
|
|
// a failure part-way cannot truncate the user's config.
|
|
func writeFileAtomic(dir, path, content string) error {
|
|
tmp, err := os.CreateTemp(dir, ".rules-*.toml")
|
|
if err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
|
|
if _, err := tmp.WriteString(content); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
|
return fmt.Errorf("replace %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeKey renders one TOML key, skipping it when empty. The column is wide
|
|
// enough for the longest key either block uses, so the values line up.
|
|
func writeKey(b *strings.Builder, key, value string) {
|
|
if value != "" {
|
|
fmt.Fprintf(b, "%-12s = %s\n", key, strconv.Quote(value))
|
|
}
|
|
}
|
|
|
|
// formatTransfer renders a transfer as a TOML table, the two sides in the
|
|
// order money travels.
|
|
func formatTransfer(t Transfer) string {
|
|
var b strings.Builder
|
|
b.WriteString("[[transfer]]\n")
|
|
writeKey(&b, "from_account", t.FromAccount)
|
|
writeKey(&b, "from_desc", t.FromDesc)
|
|
writeKey(&b, "to_account", t.ToAccount)
|
|
writeKey(&b, "to_desc", t.ToDesc)
|
|
// Only when set: a zero written out would suggest the key is doing something
|
|
// when it is exactly the default every other definition already has.
|
|
if t.TolerancePct != 0 {
|
|
fmt.Fprintf(&b, "%-12s = %s\n", "tolerance_pct",
|
|
strconv.FormatFloat(t.TolerancePct, 'f', -1, 64))
|
|
}
|
|
writeKey(&b, "note", t.Note)
|
|
return b.String()
|
|
}
|
|
|
|
// formatRule renders a rule as a TOML table, omitting empty fields.
|
|
func formatRule(r Rule) string {
|
|
var b strings.Builder
|
|
b.WriteString("[[rule]]\n")
|
|
write := func(key, value string) { writeKey(&b, key, value) }
|
|
write("match", r.Match)
|
|
write("type", r.Type)
|
|
write("account", r.Account)
|
|
write("tag", r.Tag)
|
|
// Last, so the patterns and the tag stay lined up above it however long
|
|
// the note runs.
|
|
write("note", r.Note)
|
|
return b.String()
|
|
}
|
|
|
|
// Account is a parsed account.toml.
|
|
type Account struct {
|
|
Slug string // folder name, filled in by LoadAccounts
|
|
Dir string // absolute path to the account folder
|
|
Name string `toml:"name"`
|
|
Currency string `toml:"currency"`
|
|
MinorDigits *int `toml:"minor_digits"`
|
|
Parser string `toml:"parser"`
|
|
// Include restricts which files in the folder are treated as statements.
|
|
// Defaults to every regular file except account.toml and dotfiles.
|
|
Include []string `toml:"include"`
|
|
}
|
|
|
|
// Digits returns the configured minor-unit scale, defaulting to 2.
|
|
func (a *Account) Digits() int {
|
|
if a.MinorDigits != nil {
|
|
return *a.MinorDigits
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// LoadAccounts finds every account folder under root. A folder is an account
|
|
// if it contains an account.toml.
|
|
func LoadAccounts(root string) ([]*Account, error) {
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read data root %s: %w", root, err)
|
|
}
|
|
var accounts []*Account
|
|
for _, e := range entries {
|
|
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
dir := filepath.Join(root, e.Name())
|
|
cfgPath := filepath.Join(dir, AccountFile)
|
|
if _, err := os.Stat(cfgPath); err != nil {
|
|
continue // not an account folder
|
|
}
|
|
a, err := loadAccount(dir, e.Name(), cfgPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accounts = append(accounts, a)
|
|
}
|
|
sort.Slice(accounts, func(i, j int) bool { return accounts[i].Slug < accounts[j].Slug })
|
|
return accounts, nil
|
|
}
|
|
|
|
func loadAccount(dir, slug, cfgPath string) (*Account, error) {
|
|
var a Account
|
|
if _, err := toml.DecodeFile(cfgPath, &a); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", cfgPath, err)
|
|
}
|
|
a.Slug = slug
|
|
a.Dir = dir
|
|
if a.Name == "" {
|
|
a.Name = slug
|
|
}
|
|
if a.Currency == "" {
|
|
return nil, fmt.Errorf("%s: currency is required", cfgPath)
|
|
}
|
|
if a.Parser == "" {
|
|
return nil, fmt.Errorf("%s: parser is required", cfgPath)
|
|
}
|
|
if a.Digits() < 0 || a.Digits() > 8 {
|
|
return nil, fmt.Errorf("%s: minor_digits must be between 0 and 8", cfgPath)
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
// IndexPath returns the location of the SQLite index for a data root.
|
|
func IndexPath(root string) string {
|
|
return filepath.Join(root, IndexFile)
|
|
}
|
|
|
|
// UserConfig is the small file in the user's config directory that says where
|
|
// the data root lives, so the tool can be run from anywhere without flags.
|
|
type UserConfig struct {
|
|
Root string `toml:"root"`
|
|
}
|
|
|
|
// UserConfigPath returns the config file location, following the XDG base
|
|
// directory spec: $XDG_CONFIG_HOME/money/config.toml, falling back to
|
|
// ~/.config/money/config.toml.
|
|
func UserConfigPath() (string, error) {
|
|
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
|
return filepath.Join(dir, "money", "config.toml"), nil
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot locate the home directory: %w", err)
|
|
}
|
|
return filepath.Join(home, ".config", "money", "config.toml"), nil
|
|
}
|
|
|
|
// LoadUserConfig reads the config file. A missing file is not an error: it
|
|
// just means nothing overrides the default data root.
|
|
func LoadUserConfig() (*UserConfig, string, error) {
|
|
path, err := UserConfigPath()
|
|
if err != nil {
|
|
return &UserConfig{}, "", err
|
|
}
|
|
var c UserConfig
|
|
if _, err := toml.DecodeFile(path, &c); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &UserConfig{}, path, nil
|
|
}
|
|
return nil, path, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
return &c, path, nil
|
|
}
|
|
|
|
// RootSource records where a resolved data root came from, so the tool can
|
|
// explain itself when the path is not what the user expected.
|
|
type RootSource string
|
|
|
|
// The ways a data root can be chosen, in order of precedence.
|
|
const (
|
|
RootFromFlag RootSource = "--root flag"
|
|
RootFromEnv RootSource = "MONEY_ROOT"
|
|
RootFromConfig RootSource = "config file"
|
|
RootFromDefault RootSource = "default"
|
|
)
|
|
|
|
// ResolveRoot decides which data root to use. An explicit flag wins, then
|
|
// $MONEY_ROOT, then the config file, then ~/money.
|
|
func ResolveRoot(flagRoot string) (root string, source RootSource, err error) {
|
|
switch {
|
|
case flagRoot != "":
|
|
root, source = flagRoot, RootFromFlag
|
|
default:
|
|
if env := os.Getenv("MONEY_ROOT"); env != "" {
|
|
root, source = env, RootFromEnv
|
|
} else {
|
|
c, _, err := LoadUserConfig()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if c.Root != "" {
|
|
root, source = c.Root, RootFromConfig
|
|
} else {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("cannot locate the home directory: %w", err)
|
|
}
|
|
root, source = filepath.Join(home, "money"), RootFromDefault
|
|
}
|
|
}
|
|
}
|
|
|
|
if root, err = expandHome(root); err != nil {
|
|
return "", "", err
|
|
}
|
|
if root, err = filepath.Abs(root); err != nil {
|
|
return "", "", err
|
|
}
|
|
return root, source, nil
|
|
}
|
|
|
|
// expandHome resolves a leading ~, which a hand-written config file is likely
|
|
// to contain and which the shell does not expand for us.
|
|
func expandHome(path string) (string, error) {
|
|
if path != "~" && !strings.HasPrefix(path, "~/") {
|
|
return path, nil
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot expand %q: %w", path, err)
|
|
}
|
|
return filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(path, "~"), "/")), nil
|
|
}
|