diff --git a/internal/files/daemon.go b/internal/files/daemon.go index 1575a12..8cc1f74 100644 --- a/internal/files/daemon.go +++ b/internal/files/daemon.go @@ -25,7 +25,10 @@ type DaemonConfig struct { FSWatch bool PollInterval time.Duration LogFunc func(string, ...interface{}) - OnReady func() + // OnReady is called once after the initial generate completes. + OnReady func(GraphStats) + // OnUpdate is called after each incremental update completes. + OnUpdate func(GraphStats) } // Daemon watches for file changes and keeps sidecars fresh. @@ -35,9 +38,10 @@ type Daemon struct { cache *Cache logf func(string, ...interface{}) - mu sync.Mutex - ir *api.SidecarIR - notifyCh chan string + mu sync.Mutex + ir *api.SidecarIR + notifyCh chan string + loadedCache bool // true if startup data came from local cache } // NewDaemon creates a daemon with the given config and API client. @@ -70,7 +74,12 @@ func (d *Daemon) Run(ctx context.Context) error { if err := d.loadOrGenerate(ctx); err != nil { return fmt.Errorf("startup: %w", err) } - d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", + + d.mu.Lock() + stats := computeStats(d.ir, d.cache) + stats.FromCache = d.loadedCache + d.mu.Unlock() + d.writeStatus(fmt.Sprintf("ready — %s — %d files", time.Now().Format(time.RFC3339), len(d.ir.Graph.Nodes))) d.logf("[step:2] Starting listeners") @@ -90,7 +99,7 @@ func (d *Daemon) Run(ctx context.Context) error { d.logf("[step:3] Ready — listening on UDP :%d (debounce %s)", d.cfg.NotifyPort, d.cfg.Debounce) } if d.cfg.OnReady != nil { - d.cfg.OnReady() + d.cfg.OnReady(stats) } var ( @@ -147,6 +156,7 @@ func (d *Daemon) loadOrGenerate(ctx context.Context) error { d.ir = &ir d.cache = NewCache() d.cache.Build(&ir) + d.loadedCache = true d.mu.Unlock() files := d.cache.SourceFiles() @@ -241,16 +251,20 @@ func (d *Daemon) incrementalUpdate(ctx context.Context, changedFiles []string) { d.logf("Updated %d sidecars", written) - var nodeCount int + var updateStats GraphStats func() { d.mu.Lock() defer d.mu.Unlock() d.saveCache() - nodeCount = len(d.ir.Graph.Nodes) + updateStats = computeStats(d.ir, d.cache) }() - d.writeStatus(fmt.Sprintf("ready — %s — %d nodes", - time.Now().Format(time.RFC3339), nodeCount)) + d.writeStatus(fmt.Sprintf("ready — %s — %d files", + time.Now().Format(time.RFC3339), updateStats.SourceFiles)) + + if d.cfg.OnUpdate != nil { + d.cfg.OnUpdate(updateStats) + } } // saveCache writes the current merged SidecarIR to the cache file. Must be called with d.mu held. diff --git a/internal/files/graph.go b/internal/files/graph.go index be22f5d..1bf3096 100644 --- a/internal/files/graph.go +++ b/internal/files/graph.go @@ -45,6 +45,34 @@ type Cache struct { FileDomain map[string]string // filePath → domain name } +// GraphStats summarises what was mapped after a generate or incremental update. +type GraphStats struct { + SourceFiles int + Functions int + Relationships int + DeadFunctionCount int // functions with no callers (proxy for unreachable code) + FromCache bool // true when data was loaded from a local cache +} + +// computeStats derives a GraphStats from a SidecarIR and its built Cache. +func computeStats(ir *api.SidecarIR, c *Cache) GraphStats { + s := GraphStats{ + Relationships: len(ir.Graph.Relationships), + } + for _, n := range ir.Graph.Nodes { + switch { + case n.HasLabel("File"): + s.SourceFiles++ + case n.HasLabel("Function"): + s.Functions++ + if len(c.Callers[n.ID]) == 0 { + s.DeadFunctionCount++ + } + } + } + return s +} + // NewCache creates an empty Cache. func NewCache() *Cache { return &Cache{ diff --git a/internal/files/handler.go b/internal/files/handler.go index 63df2ac..3e40897 100644 --- a/internal/files/handler.go +++ b/internal/files/handler.go @@ -16,6 +16,15 @@ import ( "github.com/supermodeltools/cli/internal/ui" ) +// ANSI helpers used only for watch summary output. +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiGreen = "\033[32m" + ansiBGreen = "\033[1;32m" + ansiDim = "\033[2m" +) + // GenerateOptions configures the generate command. type GenerateOptions struct { Force bool @@ -161,6 +170,31 @@ func Watch(ctx context.Context, cfg *config.Config, dir string, opts WatchOption FSWatch: opts.FSWatch, PollInterval: pollInterval, LogFunc: logf, + OnReady: func(s GraphStats) { + src := "fetched" + if s.FromCache { + src = "cached" + } + line := fmt.Sprintf("\n %s✓%s %s%d files%s · %s%d functions%s · %s%d relationships%s", + ansiBGreen, ansiReset, + ansiBold, s.SourceFiles, ansiReset, + ansiBold, s.Functions, ansiReset, + ansiBold, s.Relationships, ansiReset, + ) + if s.DeadFunctionCount > 0 { + line += fmt.Sprintf(" · %s%d uncalled%s", ansiBold, s.DeadFunctionCount, ansiReset) + } + line += fmt.Sprintf(" %s(%s)%s\n\n", ansiDim, src, ansiReset) + fmt.Print(line) + }, + OnUpdate: func(s GraphStats) { + fmt.Printf(" %s✓%s Updated — %s%d files%s · %s%d functions%s · %s%d relationships%s\n", + ansiGreen, ansiReset, + ansiBold, s.SourceFiles, ansiReset, + ansiBold, s.Functions, ansiReset, + ansiBold, s.Relationships, ansiReset, + ) + }, } d := NewDaemon(daemonCfg, client)