package main import ( "context" "fmt" "io" "io/fs" "os" "os/signal" "path/filepath" "strings" "syscall" "time" ) // watchSnapshot maps a file path to its modification time and size. type watchSnapshot map[string]string // takeWatchSnapshot records the state of every file under the given paths. // Directories are walked recursively. Anything at or below the excluded path // (typically the output path) is skipped so regenerating output does not // retrigger the watcher. Missing paths are simply absent from the snapshot, so // creating them later counts as a change. func takeWatchSnapshot(paths []string, exclude string) watchSnapshot { snap := watchSnapshot{} exclude = absPathOrSelf(exclude) for _, root := range paths { _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil } if exclude != "" && isWithin(absPathOrSelf(p), exclude) { if d.IsDir() { return filepath.SkipDir } return nil } if d.IsDir() { return nil } info, err := d.Info() if err != nil { return nil } snap[p] = fmt.Sprintf("%d-%d", info.ModTime().UnixNano(), info.Size()) return nil }) } return snap } func (s watchSnapshot) equal(o watchSnapshot) bool { if len(s) != len(o) { return false } for k, v := range s { if o[k] != v { return false } } return true } func absPathOrSelf(p string) string { if p == "" { return "" } if abs, err := filepath.Abs(p); err == nil { return abs } return p } // isWithin reports whether path equals dir or is located below it. func isWithin(path, dir string) bool { if path == dir { return true } return strings.HasPrefix(path, dir+string(filepath.Separator)) } // watchLoop runs fn once immediately and again whenever the watched paths // change, until ctx is cancelled. Changes are debounced: fn runs only after // the snapshot has stayed unchanged for one poll interval. Errors from fn are // reported to w and do not stop the loop. func watchLoop(ctx context.Context, w io.Writer, paths []string, exclude string, interval time.Duration, fn func() error) { run := func() { if err := fn(); err != nil { fmt.Fprintf(w, "Error: %v\n", err) } fmt.Fprintf(w, "Watching for changes (Ctrl-C to stop)...\n") } last := takeWatchSnapshot(paths, exclude) run() ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: } cur := takeWatchSnapshot(paths, exclude) if cur.equal(last) { continue } // Debounce: wait until writes settle. for settled := false; !settled; { select { case <-ctx.Done(): return case <-time.After(interval): } next := takeWatchSnapshot(paths, exclude) settled = next.equal(cur) cur = next } fmt.Fprintf(w, "\nChange detected, regenerating...\n") last = cur run() } } // runConvertWatch runs the conversion once and then again whenever the source // files change, until interrupted. func runConvertWatch(parent context.Context, w io.Writer, run func() error) error { var paths []string switch { case len(convertFromList) > 0: paths = convertFromList case convertSourcePath != "": paths = []string{convertSourcePath} default: return fmt.Errorf("--watch requires --from-path or --from-list (live database connections cannot be watched)") } if convertDryRun { return fmt.Errorf("--watch cannot be combined with --dry-run") } if convertWatchInterval <= 0 { return fmt.Errorf("--watch-interval must be positive") } if parent == nil { parent = context.Background() } ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) defer stop() watchLoop(ctx, w, paths, convertTargetPath, convertWatchInterval, run) return nil }