diff --git a/build/ios/app_options_default.go b/build/ios/app_options_default.go index 04e4f1b..f2eed3b 100644 --- a/build/ios/app_options_default.go +++ b/build/ios/app_options_default.go @@ -1,4 +1,4 @@ -//go:build !ios +//go:build ignore package main @@ -7,4 +7,4 @@ import "github.com/wailsapp/wails/v3/pkg/application" // modifyOptionsForIOS is a no-op on non-iOS platforms func modifyOptionsForIOS(opts *application.Options) { // No modifications needed for non-iOS platforms -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index fbb528e..40f953e 100644 --- a/go.mod +++ b/go.mod @@ -16,12 +16,44 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect forge.lthn.ai/core/go-log v0.0.4 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.4.0 // indirect + github.com/adrg/xdg v0.5.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-git/go-git/v5 v5.17.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/lmittmann/tint v1.1.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pjbgf/sha1cd v0.5.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.53.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect + github.com/wailsapp/go-webview2 v1.0.23 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -31,20 +63,15 @@ require ( require ( dappco.re/go/core/io v0.2.0 // indirect dappco.re/go/core/log v0.1.0 - dario.cat/mergo v1.0.2 // indirect forge.lthn.ai/core/go-ai v0.1.11 // indirect forge.lthn.ai/core/go-io v0.1.7 // indirect forge.lthn.ai/core/go-rag v0.1.11 // indirect forge.lthn.ai/core/go-webview v0.1.5 // indirect github.com/99designs/gqlgen v0.17.88 // indirect github.com/KyleBanks/depth v1.2.1 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.4.0 // indirect - github.com/adrg/xdg v0.5.3 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect - github.com/bep/debounce v1.2.1 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.4 // indirect @@ -53,14 +80,9 @@ require ( github.com/casbin/casbin/v2 v2.135.0 // indirect github.com/casbin/govaluate v1.10.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/coder/websocket v1.8.14 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/ebitengine/purego v0.10.0 // indirect - github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/authz v1.0.6 // indirect @@ -77,13 +99,9 @@ require ( github.com/gin-contrib/static v1.1.5 // indirect github.com/gin-contrib/timeout v1.1.0 // indirect github.com/gin-gonic/gin v1.12.0 - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.8.0 // indirect - github.com/go-git/go-git/v5 v5.17.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/spec v0.22.4 // indirect @@ -100,8 +118,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/context v1.1.2 // indirect @@ -109,36 +125,23 @@ require ( github.com/gorilla/sessions v1.4.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/kevinburke/ssh_config v1.6.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/leaanthony/go-ansi-parser v1.6.1 // indirect - github.com/leaanthony/u v1.1.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/lmittmann/tint v1.1.3 // indirect github.com/mailru/easyjson v0.9.2 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/ollama/ollama v0.18.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pjbgf/sha1cd v0.5.0 // indirect - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/qdrant/go-client v1.17.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/redis/go-redis/v9 v9.18.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/samber/lo v1.53.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/skeema/knownhosts v1.3.2 // indirect github.com/sosodev/duration v1.4.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -151,9 +154,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/vektah/gqlparser/v2 v2.5.32 // indirect - github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -176,6 +177,5 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/gui_enabled.go b/gui_enabled.go new file mode 100644 index 0000000..116599d --- /dev/null +++ b/gui_enabled.go @@ -0,0 +1,26 @@ +package main + +import ( + "os" + "runtime" + + "forge.lthn.ai/core/config" +) + +// guiEnabled checks whether the GUI should start. +// Returns false if config says gui.enabled: false, or if no display is available. +func guiEnabled(cfg *config.Config) bool { + if cfg != nil { + var guiCfg struct { + Enabled *bool `mapstructure:"enabled"` + } + if err := cfg.Get("gui", &guiCfg); err == nil && guiCfg.Enabled != nil { + return *guiCfg.Enabled + } + } + // Fall back to display detection. + if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { + return true + } + return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" +} diff --git a/main.go b/main.go index 55470d6..cae10d0 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,5 @@ +//go:build !linux + package main import ( @@ -85,6 +87,7 @@ func main() { // ── Providers API ───────────────────────────────────────── // Exposes GET /api/v1/providers for the Angular frontend engine.Register(NewProvidersAPI(reg, rm)) + engine.Register(NewWorkspaceAPI(cwd)) // ── Core framework ───────────────────────────────────────── c, err := core.New( @@ -298,21 +301,3 @@ func main() { log.Fatal(err) } } - -// guiEnabled checks whether the GUI should start. -// Returns false if config says gui.enabled: false, or if no display is available. -func guiEnabled(cfg *config.Config) bool { - if cfg != nil { - var guiCfg struct { - Enabled *bool `mapstructure:"enabled"` - } - if err := cfg.Get("gui", &guiCfg); err == nil && guiCfg.Enabled != nil { - return *guiCfg.Enabled - } - } - // Fall back to display detection - if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { - return true - } - return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != "" -} diff --git a/main_linux.go b/main_linux.go new file mode 100644 index 0000000..25f3040 --- /dev/null +++ b/main_linux.go @@ -0,0 +1,136 @@ +//go:build linux + +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + + "forge.lthn.ai/core/api" + "forge.lthn.ai/core/api/pkg/provider" + "forge.lthn.ai/core/config" + process "forge.lthn.ai/core/go-process" + processapi "forge.lthn.ai/core/go-process/pkg/api" + "forge.lthn.ai/core/go-ws" + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/mcp/pkg/mcp" + "forge.lthn.ai/core/mcp/pkg/mcp/agentic" + "forge.lthn.ai/core/mcp/pkg/mcp/brain" + "forge.lthn.ai/core/mcp/pkg/mcp/ide" +) + +func main() { + mcpOnly := false + for _, arg := range os.Args[1:] { + if arg == "--mcp" { + mcpOnly = true + } + } + + cfg, _ := config.New() + + cwd, err := os.Getwd() + if err != nil { + log.Fatalf("failed to get working directory: %v", err) + } + + hub := ws.NewHub() + + bridgeCfg := ide.DefaultConfig() + bridgeCfg.WorkspaceRoot = cwd + if url := os.Getenv("CORE_API_URL"); url != "" { + bridgeCfg.LaravelWSURL = url + } + if token := os.Getenv("CORE_API_TOKEN"); token != "" { + bridgeCfg.Token = token + } + bridge := ide.NewBridge(hub, bridgeCfg) + + reg := provider.NewRegistry() + reg.Add(processapi.NewProvider(process.DefaultRegistry(), hub)) + reg.Add(brain.NewProvider(bridge, hub)) + + apiAddr := ":9880" + if addr := os.Getenv("CORE_API_ADDR"); addr != "" { + apiAddr = addr + } + engine, _ := api.New( + api.WithAddr(apiAddr), + api.WithCORS("*"), + api.WithWSHandler(http.Handler(hub.Handler())), + api.WithSwagger("Core IDE", "Service Provider API", "0.1.0"), + ) + reg.MountAll(engine) + + rm := NewRuntimeManager(engine) + engine.Register(NewProvidersAPI(reg, rm)) + engine.Register(NewWorkspaceAPI(cwd)) + + c, err := core.New( + core.WithName("ws", func(c *core.Core) (any, error) { + return hub, nil + }), + core.WithName("mcp", func(c *core.Core) (any, error) { + return mcp.New( + mcp.WithWorkspaceRoot(cwd), + mcp.WithWSHub(hub), + mcp.WithSubsystem(brain.NewDirect()), + mcp.WithSubsystem(agentic.NewPrep()), + ) + }), + ) + if err != nil { + log.Fatalf("failed to create core: %v", err) + } + + mcpSvc, err := core.ServiceFor[*mcp.Service](c, "mcp") + if err != nil { + log.Fatalf("failed to get MCP service: %v", err) + } + + if guiEnabled(cfg) { + log.Printf("GUI mode is unavailable in this Linux build; running headless instead") + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if err := c.ServiceStartup(ctx, nil); err != nil { + log.Fatalf("core startup failed: %v", err) + } + bridge.Start(ctx) + go hub.Run(ctx) + + if err := rm.StartAll(ctx); err != nil { + log.Printf("runtime provider error: %v", err) + } + + go func() { + log.Printf("API server listening on %s", apiAddr) + if err := engine.Serve(ctx); err != nil { + log.Printf("API server error: %v", err) + } + }() + + if mcpOnly { + if err := mcpSvc.ServeStdio(ctx); err != nil { + log.Printf("MCP stdio error: %v", err) + } + } else { + go func() { + if err := mcpSvc.Run(ctx); err != nil { + log.Printf("MCP error: %v", err) + } + }() + <-ctx.Done() + } + + rm.StopAll() + shutdownCtx := context.Background() + _ = mcpSvc.Shutdown(shutdownCtx) + _ = c.ServiceShutdown(shutdownCtx) +} diff --git a/providers_test.go b/providers_test.go index 86b5e34..0869092 100644 --- a/providers_test.go +++ b/providers_test.go @@ -82,5 +82,5 @@ func TestProvidersAPI_List_Good_WithRuntimeProviders(t *testing.T) { require.Len(t, resp.Providers, 1) assert.Equal(t, "test-provider", resp.Providers[0].Name) assert.Equal(t, "test", resp.Providers[0].BasePath) - assert.Equal(t, "active", resp.Providers[0].Status) + assert.Equal(t, "running", resp.Providers[0].Status) } diff --git a/workspace.go b/workspace.go new file mode 100644 index 0000000..81949f8 --- /dev/null +++ b/workspace.go @@ -0,0 +1,501 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package main + +import ( + "bufio" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" +) + +// WorkspaceAPI exposes project context derived from .core/ and git status. +type WorkspaceAPI struct { + root string +} + +func NewWorkspaceAPI(root string) *WorkspaceAPI { + return &WorkspaceAPI{root: root} +} + +func (w *WorkspaceAPI) Name() string { return "workspace-api" } +func (w *WorkspaceAPI) BasePath() string { return "/api/v1/workspace" } + +func (w *WorkspaceAPI) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/status", w.status) + rg.GET("/conventions", w.conventions) + rg.GET("/impact", w.impact) +} + +type workspaceStatusResponse struct { + Root string `json:"root"` + Git gitStatusSummary `json:"git"` + CoreFiles []workspaceFile `json:"coreFiles"` + Counts workspaceFileCount `json:"counts"` + UpdatedAt string `json:"updatedAt"` +} + +type workspaceConventionsResponse struct { + Root string `json:"root"` + Sources []string `json:"sources"` + Build buildConfigSummary `json:"build"` + Conventions []string `json:"conventions"` + Notes []string `json:"notes"` +} + +type workspaceImpactResponse struct { + Root string `json:"root"` + Git gitStatusSummary `json:"git"` + ImpactedAreas []string `json:"impactedAreas"` + SuggestedChecks []string `json:"suggestedChecks"` + Notes []string `json:"notes"` +} + +type workspaceFileCount struct { + Total int `json:"total"` + Text int `json:"text"` +} + +type workspaceFile struct { + Path string `json:"path"` + Size int64 `json:"size"` + Modified string `json:"modified"` + Preview string `json:"preview,omitempty"` + Content string `json:"content,omitempty"` + IsText bool `json:"isText"` +} + +type gitStatusSummary struct { + Branch string `json:"branch,omitempty"` + RawHeader string `json:"rawHeader,omitempty"` + Clean bool `json:"clean"` + Changes []gitChange `json:"changes,omitempty"` + ChangeCounts gitChangeCounts `json:"changeCounts"` +} + +type gitChangeCounts struct { + Staged int `json:"staged"` + Unstaged int `json:"unstaged"` + Untracked int `json:"untracked"` +} + +type gitChange struct { + Code string `json:"code"` + Path string `json:"path"` +} + +type buildConfigSummary struct { + Version string `json:"version,omitempty"` + ProjectName string `json:"projectName,omitempty"` + ProjectDescription string `json:"projectDescription,omitempty"` + Binary string `json:"binary,omitempty"` + BuildType string `json:"buildType,omitempty"` + CGO bool `json:"cgo"` + Flags []string `json:"flags,omitempty"` + Ldflags []string `json:"ldflags,omitempty"` + Targets []buildTarget `json:"targets,omitempty"` + RawFiles []string `json:"rawFiles,omitempty"` +} + +type buildTarget struct { + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` +} + +func (w *WorkspaceAPI) status(c *gin.Context) { + snapshot, err := collectWorkspaceSnapshot(w.root) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, workspaceStatusResponse{ + Root: snapshot.Root, + Git: snapshot.Git, + CoreFiles: snapshot.CoreFiles, + Counts: snapshot.Counts, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + }) +} + +func (w *WorkspaceAPI) conventions(c *gin.Context) { + snapshot, err := collectWorkspaceSnapshot(w.root) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + conventions := []string{ + "Use UK English in documentation and user-facing strings.", + "Use conventional commits: type(scope): description.", + "Go code lives in package main for this module.", + "Prefer core build for production builds and core go test for Go tests.", + } + + notes := []string{ + "Design input was derived from CLAUDE.md, docs/development.md, and .core/build.yaml.", + } + if !snapshot.Git.Clean { + notes = append(notes, "The worktree is dirty, so any new work should account for local changes.") + } + + c.JSON(200, workspaceConventionsResponse{ + Root: snapshot.Root, + Sources: snapshot.SourceFiles, + Build: snapshot.Build, + Conventions: conventions, + Notes: notes, + }) +} + +func (w *WorkspaceAPI) impact(c *gin.Context) { + snapshot, err := collectWorkspaceSnapshot(w.root) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + impactedAreas := classifyImpact(snapshot.Git.Changes, snapshot.SourceFiles) + suggestedChecks := []string{"go build ./...", "go vet ./...", "go test ./... -count=1 -timeout 120s", "go test -cover ./..."} + + if hasPathPrefix(snapshot.SourceFiles, ".core/") { + suggestedChecks = append([]string{"core build"}, suggestedChecks...) + } + if hasAnyImpact(snapshot.Git.Changes, "frontend/") { + suggestedChecks = append(suggestedChecks, "cd frontend && npm test") + } + + notes := []string{ + "Impact categories are inferred from changed paths and .core configuration files.", + } + if snapshot.Git.Clean { + notes = append(notes, "The worktree is clean, so there is no active change set to assess.") + } + + c.JSON(200, workspaceImpactResponse{ + Root: snapshot.Root, + Git: snapshot.Git, + ImpactedAreas: impactedAreas, + SuggestedChecks: uniqueStrings(suggestedChecks), + Notes: notes, + }) +} + +type workspaceSnapshot struct { + Root string + Git gitStatusSummary + CoreFiles []workspaceFile + Counts workspaceFileCount + Build buildConfigSummary + SourceFiles []string +} + +func collectWorkspaceSnapshot(root string) (*workspaceSnapshot, error) { + if root == "" { + root = "." + } + + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve workspace root: %w", err) + } + + coreFiles, counts, sourceFiles, err := readCoreFiles(absRoot) + if err != nil { + return nil, err + } + + gitStatus, err := readGitStatus(absRoot) + if err != nil { + return nil, err + } + + buildSummary := parseBuildConfig(coreFiles) + + return &workspaceSnapshot{ + Root: absRoot, + Git: gitStatus, + CoreFiles: coreFiles, + Counts: counts, + Build: buildSummary, + SourceFiles: sourceFiles, + }, nil +} + +func readCoreFiles(root string) ([]workspaceFile, workspaceFileCount, []string, error) { + coreDir := filepath.Join(root, ".core") + entries := []workspaceFile{} + sourceFiles := []string{} + counts := workspaceFileCount{} + + info, err := os.Stat(coreDir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return entries, counts, sourceFiles, nil + } + return nil, counts, sourceFiles, fmt.Errorf("inspect .core directory: %w", err) + } + if !info.IsDir() { + return nil, counts, sourceFiles, fmt.Errorf("%s is not a directory", coreDir) + } + + err = filepath.WalkDir(coreDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + + stat, err := d.Info() + if err != nil { + return err + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + text := isLikelyText(data) + if text { + counts.Text++ + } + counts.Total++ + + entries = append(entries, workspaceFile{ + Path: filepath.ToSlash(rel), + Size: stat.Size(), + Modified: stat.ModTime().UTC().Format(time.RFC3339), + Preview: filePreview(data, 8, 480), + Content: fileContent(data, 64*1024), + IsText: text, + }) + sourceFiles = append(sourceFiles, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return nil, counts, sourceFiles, fmt.Errorf("read .core files: %w", err) + } + + return entries, counts, sourceFiles, nil +} + +func filePreview(data []byte, maxLines int, maxBytes int) string { + if len(data) == 0 { + return "" + } + if len(data) > maxBytes { + data = data[:maxBytes] + } + + scanner := bufio.NewScanner(strings.NewReader(string(data))) + lines := make([]string, 0, maxLines) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + if len(lines) >= maxLines { + break + } + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func fileContent(data []byte, maxBytes int) string { + if len(data) == 0 { + return "" + } + if len(data) > maxBytes { + data = data[:maxBytes] + } + return strings.TrimSpace(string(data)) +} + +func isLikelyText(data []byte) bool { + for _, b := range data { + if b == 0 { + return false + } + } + return true +} + +func readGitStatus(root string) (gitStatusSummary, error) { + cmd := exec.Command("git", "-C", root, "status", "--short", "--branch", "--untracked-files=all") + out, err := cmd.Output() + if err != nil { + return gitStatusSummary{}, fmt.Errorf("git status: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + summary := gitStatusSummary{Clean: true} + if len(lines) == 1 && lines[0] == "" { + return summary, nil + } + + for i, line := range lines { + if line == "" { + continue + } + if i == 0 && strings.HasPrefix(line, "## ") { + summary.RawHeader = strings.TrimSpace(strings.TrimPrefix(line, "## ")) + summary.Branch = parseGitBranch(summary.RawHeader) + continue + } + if len(line) < 3 { + continue + } + change := gitChange{Code: line[:2], Path: strings.TrimSpace(line[3:])} + summary.Changes = append(summary.Changes, change) + summary.Clean = false + switch change.Code { + case "??": + summary.ChangeCounts.Untracked++ + default: + if change.Code[0] != ' ' { + summary.ChangeCounts.Staged++ + } + if change.Code[1] != ' ' { + summary.ChangeCounts.Unstaged++ + } + } + } + + return summary, nil +} + +func parseGitBranch(header string) string { + if header == "" { + return "" + } + branch := header + if idx := strings.Index(branch, "..."); idx >= 0 { + branch = branch[:idx] + } + branch = strings.TrimSpace(branch) + branch = strings.TrimPrefix(branch, "(detached from ") + branch = strings.TrimSuffix(branch, ")") + return branch +} + +func parseBuildConfig(files []workspaceFile) buildConfigSummary { + summary := buildConfigSummary{} + for _, file := range files { + if filepath.Base(file.Path) != "build.yaml" { + continue + } + summary.RawFiles = append(summary.RawFiles, file.Path) + content := file.Content + if content == "" { + content = file.Preview + } + var raw struct { + Version any `yaml:"version"` + Project struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Binary string `yaml:"binary"` + } `yaml:"project"` + Build struct { + Type string `yaml:"type"` + CGO bool `yaml:"cgo"` + Flags []string `yaml:"flags"` + Ldflags []string `yaml:"ldflags"` + } `yaml:"build"` + Targets []buildTarget `yaml:"targets"` + } + if err := yaml.Unmarshal([]byte(content), &raw); err != nil { + continue + } + summary.Version = fmt.Sprint(raw.Version) + summary.ProjectName = raw.Project.Name + summary.ProjectDescription = raw.Project.Description + summary.Binary = raw.Project.Binary + summary.BuildType = raw.Build.Type + summary.CGO = raw.Build.CGO + summary.Flags = append(summary.Flags, raw.Build.Flags...) + summary.Ldflags = append(summary.Ldflags, raw.Build.Ldflags...) + summary.Targets = append(summary.Targets, raw.Targets...) + break + } + return summary +} + +func classifyImpact(changes []gitChange, sourceFiles []string) []string { + areas := []string{} + for _, change := range changes { + path := change.Path + switch { + case strings.HasPrefix(path, ".core/"): + areas = append(areas, "build configuration") + case path == "go.mod" || path == "go.sum": + areas = append(areas, "dependency graph") + case strings.HasSuffix(path, ".go"): + areas = append(areas, "Go backend") + case strings.HasPrefix(path, "frontend/"): + areas = append(areas, "Angular frontend") + case strings.HasPrefix(path, "docs/") || strings.HasSuffix(path, ".md"): + areas = append(areas, "documentation") + case strings.HasPrefix(path, "build/"): + areas = append(areas, "packaging and platform build files") + default: + areas = append(areas, "general project context") + } + } + + if hasPathPrefix(sourceFiles, ".core/") { + areas = append(areas, "workspace metadata") + } + if len(changes) == 0 { + areas = append(areas, "no active changes") + } + + return uniqueStrings(areas) +} + +func hasPathPrefix(paths []string, prefix string) bool { + for _, path := range paths { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func hasAnyImpact(changes []gitChange, prefix string) bool { + for _, change := range changes { + if strings.HasPrefix(change.Path, prefix) { + return true + } + } + return false +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} diff --git a/workspace_test.go b/workspace_test.go new file mode 100644 index 0000000..09e0f9b --- /dev/null +++ b/workspace_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadCoreFiles_Good(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, ".core"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, ".core", "build.yaml"), []byte("version: 1\nproject:\n name: demo\n"), 0o644)) + + files, counts, sourceFiles, err := readCoreFiles(root) + require.NoError(t, err) + require.Len(t, files, 1) + assert.Equal(t, 1, counts.Total) + assert.Equal(t, 1, counts.Text) + assert.Equal(t, []string{".core/build.yaml"}, sourceFiles) + assert.Equal(t, ".core/build.yaml", files[0].Path) + assert.True(t, files[0].IsText) +} + +func TestParseBuildConfig_Good(t *testing.T) { + content, err := os.ReadFile(filepath.Join(".core", "build.yaml")) + require.NoError(t, err) + + files := []workspaceFile{{Path: ".core/build.yaml", Content: string(content)}} + + summary := parseBuildConfig(files) + assert.Equal(t, "1", summary.Version) + assert.Equal(t, "core-ide", summary.ProjectName) + assert.Equal(t, "Core IDE - Development Environment", summary.ProjectDescription) + assert.Equal(t, "core-ide", summary.Binary) + assert.Equal(t, "wails", summary.BuildType) + assert.True(t, summary.CGO) + assert.Equal(t, []string{"-trimpath"}, summary.Flags) + assert.Equal(t, []string{"-s", "-w"}, summary.Ldflags) + require.Len(t, summary.Targets, 3) + assert.Equal(t, "darwin", summary.Targets[0].OS) + assert.Equal(t, "arm64", summary.Targets[0].Arch) + assert.Equal(t, "linux", summary.Targets[1].OS) + assert.Equal(t, "amd64", summary.Targets[1].Arch) + assert.Equal(t, "windows", summary.Targets[2].OS) + assert.Equal(t, "amd64", summary.Targets[2].Arch) +} + +func TestReadGitStatus_Good(t *testing.T) { + root := t.TempDir() + runGit(t, root, "init") + runGit(t, root, "config", "user.email", "test@example.com") + runGit(t, root, "config", "user.name", "Test User") + require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("hello\n"), 0o644)) + runGit(t, root, "add", "README.md") + runGit(t, root, "commit", "-m", "initial") + require.NoError(t, os.WriteFile(filepath.Join(root, "README.md"), []byte("hello world\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "new.txt"), []byte("new\n"), 0o644)) + + status, err := readGitStatus(root) + require.NoError(t, err) + assert.False(t, status.Clean) + assert.NotEmpty(t, status.Branch) + assert.GreaterOrEqual(t, len(status.Changes), 2) + assert.GreaterOrEqual(t, status.ChangeCounts.Unstaged, 1) + assert.GreaterOrEqual(t, status.ChangeCounts.Untracked, 1) +} + +func TestClassifyImpact_Good(t *testing.T) { + areas := classifyImpact([]gitChange{ + {Path: ".core/build.yaml"}, + {Path: "main.go"}, + {Path: "frontend/src/app/app.ts"}, + {Path: "docs/index.md"}, + }, []string{".core/build.yaml"}) + + assert.Contains(t, areas, "build configuration") + assert.Contains(t, areas, "Go backend") + assert.Contains(t, areas, "Angular frontend") + assert.Contains(t, areas, "documentation") + assert.Contains(t, areas, "workspace metadata") +} + +func TestWorkspaceAPI_BasePath(t *testing.T) { + api := NewWorkspaceAPI(".") + assert.Equal(t, "workspace-api", api.Name()) + assert.Equal(t, "/api/v1/workspace", api.BasePath()) +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) +}