feat(cli): add --watch mode to convert (#39)

Poll source files/directories and regenerate output on change.
Output path is excluded from watching; errors don't stop the loop.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Hermes Agent
2026-10-03 10:29:42 +02:00
co-authored by Claude Sonnet 5.5
parent b38f53c603
commit d36806047b
4 changed files with 270 additions and 1 deletions
+17 -1
View File
@@ -59,6 +59,8 @@ var (
convertContinueOnError bool
convertExtraFields string
convertDryRun bool
convertWatch bool
convertWatchInterval time.Duration
)
var convertCmd = &cobra.Command{
@@ -167,7 +169,11 @@ Examples:
# Convert SQLite to PostgreSQL SQL
relspec convert --from sqlite --from-path database.db \
--to pgsql --to-path schema.sql`,
--to pgsql --to-path schema.sql
# Regenerate GORM models every time the DBML file changes
relspec convert --from dbml --from-path schema.dbml \
--to gorm --to-path models/ --package models --watch`,
RunE: runConvert,
}
@@ -189,6 +195,9 @@ func init() {
convertCmd.Flags().BoolVar(&convertDryRun, "dry-run", false, "Read and validate the input and print the plan without writing any output")
convertCmd.Flags().BoolVar(&convertWatch, "watch", false, "Watch the source files (--from-path or --from-list) and regenerate the output whenever they change")
convertCmd.Flags().DurationVar(&convertWatchInterval, "watch-interval", 500*time.Millisecond, "Polling interval used by --watch")
err := convertCmd.MarkFlagRequired("from")
if err != nil {
fmt.Fprintf(os.Stderr, "Error marking from flag as required: %v\n", err)
@@ -204,6 +213,13 @@ func init() {
}
func runConvert(cmd *cobra.Command, args []string) error {
if convertWatch {
return runConvertWatch(cmd.Context(), os.Stderr, func() error { return runConvertOnce(cmd) })
}
return runConvertOnce(cmd)
}
func runConvertOnce(cmd *cobra.Command) error {
fmt.Fprintf(os.Stderr, "\n=== RelSpec Schema Converter ===\n")
fmt.Fprintf(os.Stderr, "Started at: %s\n\n", getCurrentTimestamp())
+152
View File
@@ -0,0 +1,152 @@
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
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
)
func TestWatchSnapshotExcludesOutput(t *testing.T) {
dir := t.TempDir()
out := filepath.Join(dir, "out")
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "schema.dbml")
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
before := takeWatchSnapshot([]string{dir}, out)
if err := os.WriteFile(filepath.Join(out, "gen.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if after := takeWatchSnapshot([]string{dir}, out); !before.equal(after) {
t.Errorf("writing into the excluded output path changed the snapshot")
}
if err := os.WriteFile(src, []byte("changed"), 0o644); err != nil {
t.Fatal(err)
}
if after := takeWatchSnapshot([]string{dir}, out); before.equal(after) {
t.Errorf("modifying a source file did not change the snapshot")
}
}
func TestWatchLoopRerunsOnChange(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "schema.dbml")
if err := os.WriteFile(src, []byte("a"), 0o644); err != nil {
t.Fatal(err)
}
var runs atomic.Int32
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
defer close(done)
watchLoop(ctx, io.Discard, []string{src}, "", 10*time.Millisecond, func() error {
runs.Add(1)
return nil
})
}()
waitFor(t, func() bool { return runs.Load() == 1 })
if err := os.WriteFile(src, []byte("changed content"), 0o644); err != nil {
t.Fatal(err)
}
waitFor(t, func() bool { return runs.Load() == 2 })
cancel()
<-done
}
func TestRunConvertWatchValidation(t *testing.T) {
oldPath, oldList, oldDry, oldInt := convertSourcePath, convertFromList, convertDryRun, convertWatchInterval
defer func() {
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = oldPath, oldList, oldDry, oldInt
}()
convertSourcePath, convertFromList, convertDryRun, convertWatchInterval = "", nil, false, time.Second
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
t.Error("expected error without --from-path/--from-list")
}
convertSourcePath, convertDryRun = "x.dbml", true
if err := runConvertWatch(context.Background(), &bytes.Buffer{}, nil); err == nil {
t.Error("expected error combining --watch with --dry-run")
}
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition not met in time")
}