Skip to content

Add fsnotify on configuration files through Options struct Watch bool #17

@andreimerlescu

Description

@andreimerlescu

Developer Experience

figs := figtree.With(figtree.Options{
    ConfigFile: "config.yaml",
    Watch:      true,
    Tracking:   true,
})
if err := figs.Load(); err != nil {
    log.Fatal(err)
}

// Everything below already existed — no new API needed
for mutation := range figs.Mutations() {
    log.Printf("%s changed: %v → %v", mutation.Property, mutation.Old, mutation.New)
}

Watcher watcher.go

// watcher.go
package figtree

import (
	"fmt"
	"log"
	"path/filepath"
	"sync"
	"time"

	"github.com/fsnotify/fsnotify"
)

// ============================================================================
// OPTIONS ADDITIONS
// ============================================================================

// These fields are added to the existing Options struct.
// Shown here isolated for clarity — merge into your existing Options{}.
//
//   type Options struct {
//       // ... existing fields ...
//       Watch         bool          // enable fsnotify file watching
//       WatchDebounce time.Duration // how long to wait before reloading after a change (default 100ms)
//       OnWatchError  func(err error) // optional hook for watch errors; defaults to log.Println
//   }

const defaultWatchDebounce = 100 * time.Millisecond

// ============================================================================
// WATCHER STATE — lives inside the tree struct
// ============================================================================

// watcherState is embedded in the tree struct.
// Add `watcher watcherState` to your existing tree struct definition.
type watcherState struct {
	mu          sync.Mutex
	fsw         *fsnotify.Watcher
	watchedPath string   // absolute path of the file being watched
	active      bool
	stopCh      chan struct{}
}

// ============================================================================
// INTERNAL: called at the end of any successful Load/Parse that resolved a file
// ============================================================================

// activateWatch is called internally after any successful file-based load.
// path is the resolved absolute path of the config file that was loaded.
// It is a no-op if Options.Watch is false or if already watching.
func (t *tree) activateWatch(path string) {
	if !t.options.Watch {
		return
	}

	absPath, err := filepath.Abs(path)
	if err != nil {
		t.handleWatchError(fmt.Errorf("figtree watcher: could not resolve absolute path for %q: %w", path, err))
		return
	}

	t.watcher.mu.Lock()
	defer t.watcher.mu.Unlock()

	// Already watching this exact file — nothing to do
	if t.watcher.active && t.watcher.watchedPath == absPath {
		return
	}

	// Clean up any prior watcher
	t.teardownWatchLocked()

	fsw, err := fsnotify.NewWatcher()
	if err != nil {
		t.handleWatchError(fmt.Errorf("figtree watcher: failed to create fsnotify watcher: %w", err))
		return
	}

	if err := fsw.Add(absPath); err != nil {
		_ = fsw.Close()
		t.handleWatchError(fmt.Errorf("figtree watcher: failed to watch %q: %w", absPath, err))
		return
	}

	stopCh := make(chan struct{})
	t.watcher.fsw         = fsw
	t.watcher.watchedPath = absPath
	t.watcher.active      = true
	t.watcher.stopCh      = stopCh

	go t.watchLoop(fsw, absPath, stopCh)
}

// ============================================================================
// WATCH LOOP — goroutine that processes fsnotify events
// ============================================================================

func (t *tree) watchLoop(fsw *fsnotify.Watcher, path string, stopCh <-chan struct{}) {
	debounce := t.options.WatchDebounce
	if debounce <= 0 {
		debounce = defaultWatchDebounce
	}

	var (
		debounceTimer *time.Timer
		timerMu       sync.Mutex
	)

	scheduleReload := func() {
		timerMu.Lock()
		defer timerMu.Unlock()
		if debounceTimer != nil {
			debounceTimer.Reset(debounce)
			return
		}
		debounceTimer = time.AfterFunc(debounce, func() {
			timerMu.Lock()
			debounceTimer = nil
			timerMu.Unlock()
			t.reloadFromWatch(path)
		})
	}

	for {
		select {
		case <-stopCh:
			timerMu.Lock()
			if debounceTimer != nil {
				debounceTimer.Stop()
				debounceTimer = nil
			}
			timerMu.Unlock()
			return

		case event, ok := <-fsw.Events:
			if !ok {
				return
			}
			// React to writes and renames (rename covers atomic-write editors)
			if event.Has(fsnotify.Write) || event.Has(fsnotify.Rename) {
				scheduleReload()
			}
			// If the file was removed entirely, surface an error but keep watching
			if event.Has(fsnotify.Remove) {
				t.handleWatchError(fmt.Errorf("figtree watcher: watched file %q was removed", path))
			}

		case err, ok := <-fsw.Errors:
			if !ok {
				return
			}
			t.handleWatchError(fmt.Errorf("figtree watcher: fsnotify error on %q: %w", path, err))
		}
	}
}

// ============================================================================
// RELOAD — re-parses the file and feeds diffs into the mutation pipeline
// ============================================================================

// reloadFromWatch re-parses the watched config file.
// It compares new values against current values and calls Store() for any
// that changed — which naturally triggers validators, callbacks, rules,
// and the Mutations() channel exactly as a programmatic Store() would.
func (t *tree) reloadFromWatch(path string) {
	// Load the raw key-value map from the file without touching the live tree
	incoming, err := t.parseFileToMap(path)
	if err != nil {
		t.handleWatchError(fmt.Errorf("figtree watcher: reload parse error on %q: %w", path, err))
		return
	}

	for key, newRawValue := range incoming {
		fig := t.Fig(key)
		if fig == nil {
			// Key in file not registered in tree — skip silently
			// (same behaviour as initial Load)
			continue
		}

		// Convert and store — Store() handles rules, validators, callbacks,
		// and emits to Mutations() if tracking is enabled.
		// A RulePreventChange or RulePanicOnChange will reject it here
		// exactly as it would reject a manual Store() call.
		storeErr := t.storeFromRaw(fig.Mutagenesis, key, newRawValue)
		if storeErr != nil {
			t.handleWatchError(fmt.Errorf(
				"figtree watcher: could not apply reloaded value for key %q: %w",
				key, storeErr,
			))
		}
	}
}

// parseFileToMap re-parses the file at path and returns a flat
// map[string]string of raw string values, routing to the correct
// parser based on file extension. This is the same routing table
// used by LoadFile/ParseFile but returns raw values rather than
// immediately storing them.
func (t *tree) parseFileToMap(path string) (map[string]interface{}, error) {
	ext := strings.ToLower(filepath.Ext(path))

	f, err := os.Open(path)
	if err != nil {
		return nil, fmt.Errorf("could not open %q: %w", path, err)
	}
	defer f.Close()

	// Route to the parser table you build per the format discussion
	parser, ok := t.parserFor(ext)
	if !ok {
		return nil, fmt.Errorf("no parser registered for extension %q", ext)
	}

	return parser(f)
}

// ============================================================================
// TEARDOWN
// ============================================================================

// StopWatch gracefully shuts down the file watcher.
// Safe to call multiple times. Exported so the caller can shut down
// cleanly on SIGINT/SIGTERM alongside context cancellation.
func (t *tree) StopWatch() {
	t.watcher.mu.Lock()
	defer t.watcher.mu.Unlock()
	t.teardownWatchLocked()
}

// teardownWatchLocked stops the watch goroutine and closes fsnotify.
// Caller must hold t.watcher.mu.
func (t *tree) teardownWatchLocked() {
	if !t.watcher.active {
		return
	}
	close(t.watcher.stopCh)
	_ = t.watcher.fsw.Close()
	t.watcher.active      = false
	t.watcher.watchedPath = ""
	t.watcher.fsw         = nil
	t.watcher.stopCh      = nil
}

// ============================================================================
// ERROR HANDLER
// ============================================================================

// handleWatchError routes watch errors to the user-supplied OnWatchError
// hook if set, otherwise falls back to log.Println.
func (t *tree) handleWatchError(err error) {
	if t.options.OnWatchError != nil {
		t.options.OnWatchError(err)
		return
	}
	log.Println(err)
}

Modify loading.go

// In loading.go — end of Load()
func (t *tree) Load() error {
    // ... existing logic that resolves t.resolvedConfigPath ...
    if err := t.loadFromFile(t.resolvedConfigPath); err != nil {
        return err
    }
    t.activateWatch(t.resolvedConfigPath) // ADD THIS
    return nil
}

// In loading.go — end of LoadFile()
func (t *tree) LoadFile(path string) error {
    if err := t.loadFromFile(path); err != nil {
        return err
    }
    t.activateWatch(path) // ADD THIS
    return nil
}

// In parsing.go — end of ParseFile()
func (t *tree) ParseFile(path string) error {
    if err := t.parseFromFile(path); err != nil {
        return err
    }
    t.activateWatch(path) // ADD THIS
    return nil
}

// Parse() with no file is a no-op for the watcher — flags and env
// have no filesystem path to watch.

Modify Fruit Interface

// Add to the Fruit interface in types.go
type Fruit interface {
    // ... existing methods ...
    StopWatch()
}

Dependency

Update go.mod with:

require (
    github.com/fsnotify/fsnotify v1.7.0
)

Update vars.go

// vars.go — additions only, merge into existing file

const (
    // ... existing consts unchanged ...

    defaultWatchDebounce = 100 * time.Millisecond
)

// WatchDebounce is the default delay between a filesystem event and a reload.
// Override before calling Load/LoadFile/ParseFile if your editor needs longer.
var WatchDebounce = defaultWatchDebounce

Update types.go

type Loadable interface {
    Load() error
    LoadFile(path string) error
    Reload() error
    StopWatch() // ADDED — graceful shutdown of the fsnotify watcher
}



// watcherState holds all fsnotify lifecycle state for the figTree.
// It is embedded by value in figTree so zero value is safe — active=false
// means the watcher is dormant and no goroutine is running.
type watcherState struct {
    mu          sync.Mutex
    fsw         *fsnotify.Watcher // nil when inactive
    watchedPath string            // absolute path currently watched
    active      bool
    stopCh      chan struct{}      // closed to signal watchLoop to exit
}

// Options allow you enable mutation tracking on your figs.Grow
type Options struct {
    ConfigFile string

    // Tracking creates a buffered channel that allows you to select { case mutation, ok := <-figs.Mutations(): }
    Tracking bool

    // Germinate enables the option to filter os.Args that begin with -test. prefix
    Germinate bool

    // Harvest allows you to set the buffer size of the Mutations channel
    Harvest int

    // Pollinate will enable Getters to lookup the environment for changes on every read
    Pollinate bool

    // IgnoreEnvironment is a part of free will, it lets us disregard our environment (ENV vars)
    IgnoreEnvironment bool

    // Watch enables automatic fsnotify watching of whatever config file was
    // resolved during Load(), LoadFile(), ParseFile(). When a change is
    // detected the file is re-parsed and diffed against live values; any
    // changed key flows through the normal Store() pipeline — validators,
    // callbacks, rules, and the Mutations() channel all fire exactly as they
    // would for a programmatic Store() call.
    Watch bool

    // WatchDebounce is how long to wait after a filesystem event before
    // triggering a reload. Defaults to WatchDebounce (100ms). Increase this
    // if your editor writes config files in multiple flushes (vim, some IDEs).
    WatchDebounce time.Duration

    // OnWatchError is an optional hook called whenever the watcher encounters
    // an error — parse failures, fsnotify errors, rejected Store() calls on
    // reload. If nil, errors are written to log.Println.
    OnWatchError func(err error)
}

// figTree stores figs that are defined by their name and figFruit as well as
// a mutations channel and tracking bool for Options.Tracking
type figTree struct {
    ConfigFilePath string
    GlobalRules    []RuleKind
    harvest        int
    pollinate      bool
    figs           map[string]*figFruit
    values         *sync.Map
    withered       map[string]witheredFig
    aliases        map[string]string
    sourceLocker   sync.RWMutex
    mu             sync.RWMutex
    tracking       bool
    problems       []error
    mutationsCh    chan Mutation
    flagSet        *flag.FlagSet
    filterTests    bool
    angel          *atomic.Bool
    ignoreEnv      bool

    // watcher holds fsnotify state; zero value is safe (inactive)
    watcher      watcherState

    // watchDebounce and onWatchError are copied out of Options at With() time
    watchEnabled  bool
    watchDebounce time.Duration
    onWatchError  func(err error)
}

Update figtree.go

func With(opts Options) Plant {
    angel := atomic.Bool{}
    angel.Store(true)

    debounce := opts.WatchDebounce
    if debounce <= 0 {
        debounce = WatchDebounce
    }

    fig := &figTree{
        ConfigFilePath: opts.ConfigFile,
        ignoreEnv:      opts.IgnoreEnvironment,
        filterTests:    opts.Germinate,
        pollinate:      opts.Pollinate,
        tracking:       opts.Tracking,
        harvest:        opts.Harvest,
        angel:          &angel,
        problems:       make([]error, 0),
        aliases:        make(map[string]string),
        figs:           make(map[string]*figFruit),
        values:         &sync.Map{},
        withered:       make(map[string]witheredFig),
        mu:             sync.RWMutex{},
        mutationsCh:    make(chan Mutation),
        flagSet:        flag.NewFlagSet(os.Args[0], flag.ContinueOnError),

        // watch options
        watchEnabled:  opts.Watch,
        watchDebounce: debounce,
        onWatchError:  opts.OnWatchError,
    }
    fig.flagSet.Usage = fig.Usage
    angel.Store(false)

    if opts.IgnoreEnvironment {
        os.Clearenv()
    }
    return fig
}

Add watcher.go

package figtree

import (
    "fmt"
    "log"
    "os"
    "path/filepath"
    "strings"
    "sync"
    "time"

    "github.com/fsnotify/fsnotify"
)

// ============================================================================
// PUBLIC: StopWatch
// ============================================================================

// StopWatch gracefully shuts down the file watcher. It is safe to call
// multiple times and safe to call when Watch was never activated.
// Call this alongside your context cancellation on SIGINT/SIGTERM:
//
//	case sig := <-sigCh:
//	    figs.StopWatch()
//	    cancel()
func (tree *figTree) StopWatch() {
    tree.watcher.mu.Lock()
    defer tree.watcher.mu.Unlock()
    tree.teardownWatchLocked()
}

// ============================================================================
// INTERNAL: activateWatch
// ============================================================================

// activateWatch is called at the end of any successful file-based load.
// It is a no-op when Options.Watch is false or when the same file is already
// being watched. When a different file was previously watched, the old watcher
// is cleanly torn down before the new one starts.
func (tree *figTree) activateWatch(path string) {
    if !tree.watchEnabled {
        return
    }

    absPath, err := filepath.Abs(path)
    if err != nil {
        tree.handleWatchError(fmt.Errorf(
            "figtree watcher: could not resolve absolute path for %q: %w", path, err,
        ))
        return
    }

    tree.watcher.mu.Lock()
    defer tree.watcher.mu.Unlock()

    // Already watching this exact file — nothing to do
    if tree.watcher.active && tree.watcher.watchedPath == absPath {
        return
    }

    // Tear down any prior watcher cleanly before starting a new one
    tree.teardownWatchLocked()

    fsw, err := fsnotify.NewWatcher()
    if err != nil {
        tree.handleWatchError(fmt.Errorf(
            "figtree watcher: failed to create fsnotify watcher: %w", err,
        ))
        return
    }

    if err := fsw.Add(absPath); err != nil {
        _ = fsw.Close()
        tree.handleWatchError(fmt.Errorf(
            "figtree watcher: failed to add %q to watcher: %w", absPath, err,
        ))
        return
    }

    stopCh := make(chan struct{})
    tree.watcher.fsw         = fsw
    tree.watcher.watchedPath = absPath
    tree.watcher.active      = true
    tree.watcher.stopCh      = stopCh

    go tree.watchLoop(fsw, absPath, stopCh)
}

// ============================================================================
// INTERNAL: watchLoop
// ============================================================================

// watchLoop runs in its own goroutine for the lifetime of the watch.
// It debounces rapid filesystem events — editors like vim and many IDEs
// write files in multiple flushes; without debouncing a single save would
// trigger several redundant reloads.
func (tree *figTree) watchLoop(fsw *fsnotify.Watcher, path string, stopCh <-chan struct{}) {
    debounce := tree.watchDebounce
    if debounce <= 0 {
        debounce = WatchDebounce
    }

    var (
        timerMu       sync.Mutex
        debounceTimer *time.Timer
    )

    scheduleReload := func() {
        timerMu.Lock()
        defer timerMu.Unlock()
        if debounceTimer != nil {
            // Push the deadline forward — still one reload after activity stops
            debounceTimer.Reset(debounce)
            return
        }
        debounceTimer = time.AfterFunc(debounce, func() {
            timerMu.Lock()
            debounceTimer = nil
            timerMu.Unlock()
            tree.reloadFromWatch(path)
        })
    }

    cancelTimer := func() {
        timerMu.Lock()
        defer timerMu.Unlock()
        if debounceTimer != nil {
            debounceTimer.Stop()
            debounceTimer = nil
        }
    }

    for {
        select {
        case <-stopCh:
            cancelTimer()
            return

        case event, ok := <-fsw.Events:
            if !ok {
                // fsnotify closed its own channel — watcher is gone
                cancelTimer()
                return
            }
            switch {
            case event.Has(fsnotify.Write):
                // Standard in-place save
                scheduleReload()

            case event.Has(fsnotify.Rename):
                // Atomic-write editors (vim, IntelliJ) rename a temp file
                // over the target; re-add the path so we keep watching it
                // then schedule a reload
                _ = fsw.Add(path)
                scheduleReload()

            case event.Has(fsnotify.Remove):
                // File was deleted — surface error but stay alive in case it
                // is recreated (e.g. a deployment pipeline replacing the file)
                tree.handleWatchError(fmt.Errorf(
                    "figtree watcher: watched file %q was removed", path,
                ))
            }

        case err, ok := <-fsw.Errors:
            if !ok {
                cancelTimer()
                return
            }
            tree.handleWatchError(fmt.Errorf(
                "figtree watcher: fsnotify error on %q: %w", path, err,
            ))
        }
    }
}

// ============================================================================
// INTERNAL: reloadFromWatch
// ============================================================================

// reloadFromWatch re-parses the watched file and feeds changed values back
// through the normal Store() pipeline. This means:
//   - RulePreventChange rejects a file-driven update the same as a manual one
//   - RulePanicOnChange panics on a file-driven update the same as a manual one
//   - WithValidator() runs on every reloaded value
//   - CallbackAfterChange fires for every key whose value changed
//   - Mutations() channel receives every file-driven change automatically
//
// Keys present in the file but not registered in the tree are silently skipped,
// matching the behaviour of the initial Load/ParseFile call.
func (tree *figTree) reloadFromWatch(path string) {
    incoming, err := tree.parseFileToRawMap(path)
    if err != nil {
        tree.handleWatchError(fmt.Errorf(
            "figtree watcher: reload parse error on %q: %w", path, err,
        ))
        return
    }

    for key, rawValue := range incoming {
        // Resolve alias to canonical name if needed
        canonicalKey := key
        tree.mu.RLock()
        for alias, name := range tree.aliases {
            if strings.EqualFold(alias, key) {
                canonicalKey = name
                break
            }
        }
        tree.mu.RUnlock()

        fig := tree.Fig(canonicalKey)
        if fig == nil {
            // Not registered — skip, same as initial load behaviour
            continue
        }

        // Use the existing Set path on the live Value so validators,
        // callbacks, rules, and mutation tracking all fire naturally
        value, fromErr := tree.from(canonicalKey)
        if fromErr != nil || value == nil {
            tree.handleWatchError(fmt.Errorf(
                "figtree watcher: could not retrieve fig %q during reload: %w",
                canonicalKey, fromErr,
            ))
            continue
        }

        setErr := value.Set(fmt.Sprintf("%v", rawValue))
        if setErr != nil {
            tree.handleWatchError(fmt.Errorf(
                "figtree watcher: could not apply reloaded value for key %q: %w",
                canonicalKey, setErr,
            ))
            continue
        }

        tree.values.Store(canonicalKey, value)
    }
}

// ============================================================================
// INTERNAL: parseFileToRawMap
// ============================================================================

// parseFileToRawMap opens the file at path, detects its format from the
// extension, and returns a flat map[string]interface{} of raw values.
// It uses the same extension routing as loadFile() so behaviour is consistent.
func (tree *figTree) parseFileToRawMap(path string) (map[string]interface{}, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("could not open %q: %w", path, err)
    }
    defer f.Close()

    ext := strings.ToLower(filepath.Ext(path))

    switch ext {
    case ".yaml", ".yml":
        return parseYAMLReader(f)
    case ".json":
        return parseJSONReader(f)
    case ".ini":
        return parseINIReader(f)
    default:
        return nil, fmt.Errorf("unsupported config file extension %q", ext)
    }
}

// ============================================================================
// INTERNAL: teardownWatchLocked
// ============================================================================

// teardownWatchLocked stops the watch goroutine and closes the fsnotify
// watcher. Caller MUST hold tree.watcher.mu before calling this.
func (tree *figTree) teardownWatchLocked() {
    if !tree.watcher.active {
        return
    }
    // Signal the watchLoop goroutine to exit
    close(tree.watcher.stopCh)
    // Close the fsnotify watcher — this also closes fsw.Events and fsw.Errors,
    // causing the goroutine's channel selects to drain and exit cleanly
    _ = tree.watcher.fsw.Close()

    tree.watcher.active      = false
    tree.watcher.watchedPath = ""
    tree.watcher.fsw         = nil
    tree.watcher.stopCh      = nil
}

// ============================================================================
// INTERNAL: handleWatchError
// ============================================================================

// handleWatchError routes watch errors to the user-supplied OnWatchError hook
// when set, otherwise falls back to log.Println. This keeps watch errors
// visible without forcing a fatal or panic on what are often transient issues.
func (tree *figTree) handleWatchError(err error) {
    if tree.onWatchError != nil {
        tree.onWatchError(err)
        return
    }
    log.Println(err)
}

Update loading.go

package figtree

import (
    "errors"
    "flag"
    "fmt"
    "os"
    "path/filepath"
    "strings"

    check "github.com/andreimerlescu/checkfs"
    "github.com/andreimerlescu/checkfs/file"
)

// Reload will readEnv on each flag in the configurable package
func (tree *figTree) Reload() error {
    tree.readEnv()
    return tree.validateAll()
}

func (tree *figTree) preLoadOrParse() error {
    tree.mu.RLock()
    defer tree.mu.RUnlock()
    for name, fig := range tree.figs {
        value, err := tree.from(name)
        if err != nil {
            return err
        }
        if value.Err != nil {
            return value.Err
        }
        if fig.Error != nil {
            return fig.Error
        }
    }
    return tree.checkFigErrors()
}

// Load uses the EnvironmentKey and the DefaultJSONFile, DefaultYAMLFile, and
// DefaultINIFile to run ParseFile if it exists. When Options.Watch is true,
// the first config file that successfully loads is automatically watched for
// changes via fsnotify.
func (tree *figTree) Load() (err error) {
    preloadErr := tree.preLoadOrParse()
    if preloadErr != nil {
        return preloadErr
    }
    if !tree.HasRule(RuleNoFlags) {
        tree.activateFlagSet()
        args := os.Args[1:]
        if tree.filterTests {
            args = filterTestFlags(args)
        }
        err = tree.flagSet.Parse(args)
        if err != nil {
            err2 := tree.checkFigErrors()
            if err2 != nil {
                err = errors.Join(err, err2)
            }
            return ErrLoadFailure{"flags", err}
        }
        err = tree.loadFlagSet()
        if err != nil {
            return err
        }
    }
    first := ""
    if !tree.HasRule(RuleNoEnv) {
        first = os.Getenv(EnvironmentKey)
    }
    files := []string{
        first,
        tree.ConfigFilePath,
        ConfigFilePath,
        filepath.Join(".", DefaultJSONFile),
        filepath.Join(".", DefaultINIFile),
    }
    loadedPath := "" // track which file actually loaded
    for i := 0; i < len(files); i++ {
        f := files[i]
        if f == "" {
            continue
        }
        if err := check.File(f, file.Options{Exists: true}); err == nil {
            if err := tree.loadFile(f); err != nil {
                return ErrLoadFailure{f, err}
            }
            if loadedPath == "" {
                loadedPath = f // record the first file that succeeded
            }
        }
    }
    tree.readEnv()
    err = tree.checkFigErrors()
    if err != nil {
        return fmt.Errorf("checkFigErrors() threw err: %w", err)
    }
    if err = tree.validateAll(); err != nil {
        return err
    }

    // Activate fsnotify watch on the resolved config file — no-op when
    // Options.Watch is false or when no file was found
    if loadedPath != "" {
        tree.activateWatch(loadedPath) // ADDED
    }

    return nil
}

// LoadFile accepts a path and uses it to populate the figTree.
// When Options.Watch is true and the file exists, it is automatically
// watched for changes via fsnotify after a successful load.
func (tree *figTree) LoadFile(path string) (err error) {
    preloadErr := tree.preLoadOrParse()
    if preloadErr != nil {
        return preloadErr
    }
    if !tree.HasRule(RuleNoFlags) {
        tree.activateFlagSet()
        args := os.Args[1:]
        if tree.filterTests {
            args = filterTestFlags(args)
        }
        err = tree.flagSet.Parse(args)
        if err != nil {
            err2 := tree.checkFigErrors()
            if err2 != nil {
                err = errors.Join(err, err2)
            }
            return err
        }
    }
    var loadErr error
    if loadErr = check.File(path, file.Options{Exists: true}); loadErr == nil {
        if err2 := tree.loadFile(path); err2 != nil {
            return ErrLoadFailure{path, err2}
        }
        tree.readEnv()
        err3 := tree.loadFlagSet()
        if err3 != nil {
            return err3
        }
        err4 := tree.validateAll()
        if err4 != nil {
            return ErrValidationFailure{err4}
        }

        tree.activateWatch(path) // ADDED — file confirmed to exist and loaded

        return nil
    }
    err3 := tree.loadFlagSet()
    if err3 != nil {
        return err3
    }
    tree.readEnv()
    err4 := tree.checkFigErrors()
    if err4 != nil {
        return fmt.Errorf("failed to checkFigErrors: %w", err4)
    }
    err5 := tree.validateAll()
    if err5 != nil {
        return ErrValidationFailure{err5}
    }
    return ErrLoadFailure{path, loadErr}
    // Note: no activateWatch here — we only watch files that actually loaded
}

func (tree *figTree) loadFlagSet() (e error) {
    defer func() {
        if e != nil {
            _, _ = fmt.Fprintf(os.Stderr, "loadFlagSet() err: %s", e.Error())
        }
    }()
    tree.flagSet.VisitAll(func(f *flag.Flag) {
        flagName := f.Name
        for alias, name := range tree.aliases {
            if strings.EqualFold(alias, f.Name) {
                flagName = name
            }
        }
        value, err := tree.from(flagName)
        if err != nil || value == nil {
            e = ErrLoadFailure{flagName, err}
            return
        }
        switch value.Mutagensis {
        case tMap:
            merged := value.Flesh().ToMap()
            withered := tree.withered[flagName]
            witheredValue := withered.Value.Flesh().ToMap()
            flagged, err := toStringMap(f.Value)
            if err != nil {
                e = ErrLoadFailure{flagName, err}
                return
            }
            result := make(map[string]string)
            if PolicyMapAppend {
                for k, v := range witheredValue {
                    result[k] = v
                }
            }
            for k, v := range merged {
                result[k] = v
            }
            for k, v := range flagged {
                result[k] = v
            }
            err = value.Assign(result)
            if err != nil {
                e = ErrLoadFailure{flagName, err}
                return
            }
        case tList:
            merged, err := toStringSlice(value.Value)
            if err != nil {
                e = ErrLoadFailure{flagName, err}
                return
            }
            flagged, err := toStringSlice(f.Value)
            if err != nil {
                e = ErrLoadFailure{flagName, err}
                return
            }
            unique := make(map[string]bool)
            for _, v := range merged {
                unique[v] = true
            }
            for _, v := range flagged {
                unique[v] = true
            }
            var newValue []string
            for k := range unique {
                newValue = append(newValue, k)
            }
            err = value.Assign(newValue)
            if err != nil {
                e = ErrLoadFailure{flagName, err}
                return
            }
        default:
            v := f.Value.String()
            err := value.Set(v)
            if err != nil {
                e = ErrLoadFailure{flagName, fmt.Errorf("failed to value.Set(%s): %w", f.Value.String(), err)}
                return
            }
        }
        tree.values.Store(flagName, value)
    })
    return nil
}

Update parsing.go

func (tree *figTree) ParseFile(filename string) error {
    // ... your existing ParseFile body unchanged ...

    // At the point where you would have returned nil after a successful parse:
    tree.activateWatch(filename) // ADDED
    return nil
}

Metadata

Metadata

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions