ide/workspace.go

502 lines
13 KiB
Go
Raw Permalink Normal View History

// 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
}