From d36806047b2cd4c868f3a3e71289836aa788fae9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 3 Oct 2026 10:29:42 +0200 Subject: [PATCH] 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 --- README.md | 9 +++ cmd/relspec/convert.go | 18 ++++- cmd/relspec/watch.go | 152 ++++++++++++++++++++++++++++++++++++++ cmd/relspec/watch_test.go | 92 +++++++++++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 cmd/relspec/watch.go create mode 100644 cmd/relspec/watch_test.go diff --git a/README.md b/README.md index 6d43f99..72de8b3 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,17 @@ relspec convert --from pgsql --from-conn "postgres://..." --to sqlite --to-path # Multiple input files merged relspec convert --from json --from-list "a.json,b.json" --to yaml --to-path merged.yaml + +# Watch mode: regenerate whenever the source file(s) change (Ctrl-C to stop) +relspec convert --from dbml --from-path schema.dbml --to gorm --to-path models/ --package models --watch ``` +`--watch` works with `--from-path` and `--from-list` (not live database +connections or `--dry-run`). Source files are polled every `--watch-interval` +(default 500ms), a directory source is watched recursively, and the output path +is ignored so generating into the source tree does not loop. Conversion errors +are printed and watching continues. + PostgreSQL connections opened by relspec set `application_name` by default to `relspecgo/` (with component suffixes internally, e.g. readers/writers). If you need a custom value, provide `application_name` explicitly in the connection diff --git a/cmd/relspec/convert.go b/cmd/relspec/convert.go index 0f1c950..1772a71 100644 --- a/cmd/relspec/convert.go +++ b/cmd/relspec/convert.go @@ -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()) diff --git a/cmd/relspec/watch.go b/cmd/relspec/watch.go new file mode 100644 index 0000000..91fe1b3 --- /dev/null +++ b/cmd/relspec/watch.go @@ -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 +} diff --git a/cmd/relspec/watch_test.go b/cmd/relspec/watch_test.go new file mode 100644 index 0000000..301eb43 --- /dev/null +++ b/cmd/relspec/watch_test.go @@ -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") +} -- 2.54.0