chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,862 @@
|
||||
// Package generate implements AI-powered service generation for go-micro.
|
||||
// It uses an LLM to design service architecture and generate handler code
|
||||
// with real business logic, then compiles and fixes errors iteratively.
|
||||
package generate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
|
||||
_ "go-micro.dev/v6/ai/anthropic"
|
||||
_ "go-micro.dev/v6/ai/atlascloud"
|
||||
_ "go-micro.dev/v6/ai/gemini"
|
||||
_ "go-micro.dev/v6/ai/groq"
|
||||
_ "go-micro.dev/v6/ai/mistral"
|
||||
_ "go-micro.dev/v6/ai/openai"
|
||||
_ "go-micro.dev/v6/ai/together"
|
||||
)
|
||||
|
||||
// goMicroVersion is the go-micro.dev/v6 release pinned into the go.mod of
|
||||
// every scaffolded project. This is the single source of truth — bump it
|
||||
// here when cutting a release so generated services and agents stay in
|
||||
// sync with the framework.
|
||||
const goMicroVersion = "v6.0.0"
|
||||
|
||||
const designPrompt = `You are a Go microservices architect using the go-micro framework.
|
||||
Given a system description, design the services needed.
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"name": "service-name",
|
||||
"description": "What this service does",
|
||||
"fields": [
|
||||
{"name": "field_name", "type": "string", "description": "What this field is"}
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
|
||||
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
|
||||
- Add 1-3 custom endpoints for real business logic (e.g. PlaceOrder, CheckInventory)
|
||||
- Field types: string, int64, bool, float64
|
||||
- Every service needs id (string), created (int64), updated (int64) fields
|
||||
- Endpoint names are PascalCase
|
||||
- Examples should be realistic JSON
|
||||
- 2-4 services max, focused on the domain
|
||||
- Keep services small and focused — one concern per service, max 5-8 fields
|
||||
- Services don't call each other; an AI agent orchestrates across them`
|
||||
|
||||
const designPromptWithExisting = `You are a Go microservices architect using the go-micro framework.
|
||||
The user has an EXISTING system with services already running. They want to extend or modify it.
|
||||
|
||||
Existing services:
|
||||
%s
|
||||
|
||||
Given the user's request, return the COMPLETE set of services (existing + new/modified).
|
||||
For existing services the user hasn't asked to change, return them as-is.
|
||||
For new or modified services, include the full specification.
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"name": "service-name",
|
||||
"description": "What this service does",
|
||||
"fields": [
|
||||
{"name": "field_name", "type": "string", "description": "What this field is"}
|
||||
],
|
||||
"endpoints": [
|
||||
{"name": "EndpointName", "description": "What this endpoint does", "example": "{\"key\": \"value\"}"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Service names are lowercase, hyphenated, WITHOUT a "-service" suffix (e.g. "task" not "task-service", "shipping" not "shipping-service")
|
||||
- Each service MUST have CRUD endpoints: Create, Read, Update, Delete, List
|
||||
- Add custom endpoints for real business logic
|
||||
- Field types: string, int64, bool, float64
|
||||
- Every service needs id (string), created (int64), updated (int64) fields
|
||||
- Endpoint names are PascalCase
|
||||
- Examples should be realistic JSON
|
||||
- Keep existing services unless the user explicitly asks to change them`
|
||||
|
||||
const handlerPrompt = `You are a Go developer writing a handler for a go-micro service.
|
||||
Generate a COMPLETE, COMPILABLE Go handler file.
|
||||
|
||||
The handler must:
|
||||
1. Use package "handler"
|
||||
2. Import the proto package as: pb "%s/proto"
|
||||
3. Import go-micro logger as: log "go-micro.dev/v6/logger"
|
||||
4. Import "github.com/google/uuid" for ID generation
|
||||
5. Use "go-micro.dev/v6/store" for persistent storage (NOT in-memory maps)
|
||||
6. Include REAL business logic — not just CRUD store operations
|
||||
7. Every exported method must have a doc comment explaining what it does
|
||||
8. Every method must have an @example tag with realistic JSON input
|
||||
9. Handle edge cases, validation, and return meaningful errors
|
||||
10. Keep the file under 200 lines — be concise, no boilerplate
|
||||
|
||||
For storage, use the go-micro store package:
|
||||
import "go-micro.dev/v6/store"
|
||||
import "encoding/json"
|
||||
|
||||
// In the struct:
|
||||
store store.Store
|
||||
|
||||
// In the constructor:
|
||||
func New() *%s { return &%s{store: store.DefaultStore} }
|
||||
|
||||
// Write a record:
|
||||
data, _ := json.Marshal(record)
|
||||
store.Write(&store.Record{Key: "prefix/" + id, Value: data})
|
||||
|
||||
// Read a record:
|
||||
recs, err := store.Read("prefix/" + id)
|
||||
json.Unmarshal(recs[0].Value, &record)
|
||||
|
||||
// List keys:
|
||||
keys, _ := store.List(store.ListPrefix("prefix/"))
|
||||
|
||||
// Delete:
|
||||
store.Delete("prefix/" + id)
|
||||
|
||||
Do NOT use sync.Mutex or in-memory maps. Use store for all data.
|
||||
|
||||
The struct name is %s.
|
||||
The constructor is func New() *%s.
|
||||
|
||||
Here is the proto definition:
|
||||
%s
|
||||
|
||||
Here is what each endpoint should do:
|
||||
%s
|
||||
|
||||
Return ONLY the Go code. No markdown, no explanation. Just the .go file content starting with "package handler".`
|
||||
|
||||
// ServiceDesign is the LLM's output.
|
||||
type ServiceDesign struct {
|
||||
Services []ServiceSpec `json:"services"`
|
||||
}
|
||||
|
||||
type ServiceSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Fields []FieldSpec `json:"fields"`
|
||||
Endpoints []EndpointSpec `json:"endpoints"`
|
||||
}
|
||||
|
||||
type FieldSpec struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type EndpointSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Example string `json:"example"`
|
||||
}
|
||||
|
||||
// Design calls an LLM to design services from a prompt.
|
||||
// If baseDir contains existing services, they are included as context
|
||||
// so the LLM extends the system rather than redesigning from scratch.
|
||||
func Design(ctx context.Context, provider, apiKey, model, baseDir, prompt string) (*ServiceDesign, error) {
|
||||
m := newModel(provider, apiKey, model)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("unknown provider: %s", provider)
|
||||
}
|
||||
|
||||
existing := discoverExisting(baseDir)
|
||||
|
||||
var sysPrompt, userPrompt string
|
||||
if len(existing) > 0 {
|
||||
sysPrompt = fmt.Sprintf(designPromptWithExisting, existing)
|
||||
userPrompt = fmt.Sprintf("Extend or modify the system: %s", prompt)
|
||||
} else {
|
||||
sysPrompt = designPrompt
|
||||
userPrompt = fmt.Sprintf("Design a microservices system for: %s", prompt)
|
||||
}
|
||||
|
||||
sp := startSpinner("designing services...")
|
||||
designCtx, designCancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer designCancel()
|
||||
resp, err := m.Generate(designCtx, &ai.Request{
|
||||
Prompt: userPrompt,
|
||||
SystemPrompt: sysPrompt,
|
||||
})
|
||||
sp.Stop()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("design failed: %w", err)
|
||||
}
|
||||
|
||||
reply := firstNonEmpty(resp.Answer, resp.Reply)
|
||||
reply = extractJSON(reply)
|
||||
|
||||
var design ServiceDesign
|
||||
if err := json.Unmarshal([]byte(reply), &design); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse design: %w\nResponse: %s", err, reply)
|
||||
}
|
||||
if len(design.Services) == 0 {
|
||||
return nil, fmt.Errorf("no services designed")
|
||||
}
|
||||
return &design, nil
|
||||
}
|
||||
|
||||
// discoverExisting scans a directory for existing go-micro services
|
||||
// and returns a summary string for inclusion in the design prompt.
|
||||
func discoverExisting(baseDir string) string {
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var summaries []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
svcDir := filepath.Join(baseDir, e.Name())
|
||||
|
||||
// Look for proto files as indicator of a go-micro service
|
||||
protoDir := filepath.Join(svcDir, "proto")
|
||||
protos, err := filepath.Glob(filepath.Join(protoDir, "*.proto"))
|
||||
if err != nil || len(protos) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
proto := readFile(protos[0])
|
||||
if proto == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
summaries = append(summaries, fmt.Sprintf("### %s\nProto:\n```\n%s\n```", e.Name(), proto))
|
||||
}
|
||||
|
||||
return strings.Join(summaries, "\n\n")
|
||||
}
|
||||
|
||||
// Generate creates go-micro service directories from a design.
|
||||
// If a service directory already exists, it skips structure generation
|
||||
// but regenerates the handler (allowing iterative improvement).
|
||||
func Generate(ctx context.Context, baseDir string, design *ServiceDesign, provider, apiKey, model string) error {
|
||||
m := newModel(provider, apiKey, model)
|
||||
|
||||
for i, svc := range design.Services {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
svcDir := filepath.Join(baseDir, svc.Name)
|
||||
handlerFile := filepath.Join(svcDir, "handler", svc.Name+".go")
|
||||
protoFile := filepath.Join(svcDir, "proto", svc.Name+".proto")
|
||||
|
||||
// Snapshot proto hash before structure generation
|
||||
protoBefore := fileHash(protoFile)
|
||||
|
||||
fmt.Printf(" \033[2m[%d/%d]\033[0m generating \033[36m%s\033[0m...\n", i+1, len(design.Services), svc.Name)
|
||||
|
||||
// Step 1: Generate proto (deterministic — from design spec)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
return fmt.Errorf("structure %s: %w", svc.Name, err)
|
||||
}
|
||||
|
||||
protoAfter := fileHash(protoFile)
|
||||
protoChanged := protoBefore != protoAfter
|
||||
|
||||
// If proto unchanged and handler unmodified, nothing to do
|
||||
if !protoChanged && protoBefore != "" && !handlerModified(svcDir, handlerFile) {
|
||||
fmt.Printf(" \033[32m✓\033[0m %s \033[2m(unchanged)\033[0m\n", svc.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2: Run go mod tidy + make proto to get compiled proto
|
||||
_ = runIn(svcDir, "go", "mod", "tidy")
|
||||
_ = runIn(svcDir, "make", "proto")
|
||||
|
||||
// Step 3: Generate handler with business logic (LLM)
|
||||
proto := readFile(protoFile)
|
||||
if err := generateHandler(ctx, m, svcDir, svc, proto); err != nil {
|
||||
return fmt.Errorf("handler %s: %w", svc.Name, err)
|
||||
}
|
||||
|
||||
// Step 4: Compile-fix loop
|
||||
if err := compileFix(ctx, m, svcDir, svc.Name, 3); err != nil {
|
||||
fmt.Printf(" \033[33m⚠\033[0m %s has compile errors (may need manual fix)\n", svc.Name)
|
||||
} else {
|
||||
fmt.Printf(" \033[32m✓\033[0m %s\n", svc.Name)
|
||||
}
|
||||
|
||||
// Record final handler hash (after any compile fixes)
|
||||
recordHandlerHash(svcDir, handlerFile)
|
||||
}
|
||||
|
||||
// Generate an agent that manages all the services
|
||||
var svcNames []string
|
||||
for _, svc := range design.Services {
|
||||
svcNames = append(svcNames, svc.Name)
|
||||
}
|
||||
if err := generateAgent(baseDir, design, svcNames); err != nil {
|
||||
fmt.Printf(" \033[33m⚠\033[0m agent generation failed: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateStructure creates the proto, main.go, go.mod, Makefile.
|
||||
// If the directory already exists, only regenerates the proto
|
||||
// (handler will be regenerated separately by the LLM).
|
||||
func generateStructure(dir string, svc ServiceSpec) error {
|
||||
exists := false
|
||||
if _, err := os.Stat(dir); err == nil {
|
||||
exists = true
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(dir, "handler"), 0755)
|
||||
_ = os.MkdirAll(filepath.Join(dir, "proto"), 0755)
|
||||
|
||||
name := svc.Name
|
||||
titleName := toTitle(name)
|
||||
dehyphen := strings.ReplaceAll(name, "-", "")
|
||||
|
||||
// Regenerate proto unless user has modified it
|
||||
protoPath := filepath.Join(dir, "proto", name+".proto")
|
||||
if !fileModified(dir, "proto_hash", protoPath) {
|
||||
writeFile(protoPath, buildProto(dehyphen, titleName, svc))
|
||||
recordFileHash(dir, "proto_hash", protoPath)
|
||||
} else {
|
||||
fmt.Printf(" \033[2mkeeping %s proto (modified)\033[0m\n", name)
|
||||
}
|
||||
|
||||
// Only write structural files if directory is new
|
||||
if !exists {
|
||||
writeFile(filepath.Join(dir, "main.go"), buildMain(name, titleName))
|
||||
|
||||
writeFile(filepath.Join(dir, "Makefile"),
|
||||
"GOPATH:=$(shell go env GOPATH)\n\n.PHONY: proto\nproto:\n\tprotoc --proto_path=. --micro_out=. --go_out=. proto/*.proto\n")
|
||||
|
||||
writeFile(filepath.Join(dir, "go.mod"),
|
||||
fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v6 %s\n", name, goMicroVersion))
|
||||
|
||||
writeFile(filepath.Join(dir, ".gitignore"),
|
||||
fmt.Sprintf("%s\n.micro\n", name))
|
||||
}
|
||||
|
||||
// Placeholder handler so go mod tidy works (will be overwritten by LLM)
|
||||
handlerPath := filepath.Join(dir, "handler", name+".go")
|
||||
if _, err := os.Stat(handlerPath); os.IsNotExist(err) {
|
||||
writeFile(handlerPath,
|
||||
fmt.Sprintf("package handler\n\ntype %s struct{}\n\nfunc New() *%s { return &%s{} }\n", titleName, titleName, titleName))
|
||||
recordHandlerHash(dir, handlerPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateHandler asks the LLM to write the handler with business logic.
|
||||
// If the handler exists and the user has modified it since generation,
|
||||
// it is left untouched.
|
||||
func generateHandler(ctx context.Context, m ai.Model, dir string, svc ServiceSpec, proto string) error {
|
||||
if m == nil {
|
||||
return nil // no LLM — keep the placeholder
|
||||
}
|
||||
|
||||
handlerFile := filepath.Join(dir, "handler", svc.Name+".go")
|
||||
|
||||
if handlerModified(dir, handlerFile) {
|
||||
fmt.Printf(" \033[2mkeeping %s handler (modified)\033[0m\n", svc.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
titleName := toTitle(svc.Name)
|
||||
|
||||
// Build endpoint descriptions
|
||||
var epDescs []string
|
||||
for _, ep := range svc.Endpoints {
|
||||
epDescs = append(epDescs, fmt.Sprintf("- %s: %s (example input: %s)", ep.Name, ep.Description, ep.Example))
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(handlerPrompt,
|
||||
svc.Name, titleName, titleName, titleName, titleName, proto, strings.Join(epDescs, "\n"))
|
||||
|
||||
sp := startSpinner(fmt.Sprintf("writing %s handler...", svc.Name))
|
||||
genCtx, genCancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer genCancel()
|
||||
resp, err := m.Generate(genCtx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic.", svc.Name),
|
||||
SystemPrompt: prompt,
|
||||
})
|
||||
sp.Stop()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code := firstNonEmpty(resp.Answer, resp.Reply)
|
||||
code = extractCode(code)
|
||||
|
||||
if !strings.HasPrefix(strings.TrimSpace(code), "package") {
|
||||
return fmt.Errorf("LLM did not return valid Go code")
|
||||
}
|
||||
|
||||
if isTruncated(code) {
|
||||
fmt.Printf(" \033[33m→\033[0m response truncated, retrying...\n")
|
||||
sp = startSpinner(fmt.Sprintf("rewriting %s handler...", svc.Name))
|
||||
retryCtx, retryCancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer retryCancel()
|
||||
resp, err = m.Generate(retryCtx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("Generate the handler for the %s service with real business logic. Keep it concise — no more than 200 lines.", svc.Name),
|
||||
SystemPrompt: prompt,
|
||||
})
|
||||
sp.Stop()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
code = firstNonEmpty(resp.Answer, resp.Reply)
|
||||
code = extractCode(code)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(strings.TrimSpace(code), "package") {
|
||||
return fmt.Errorf("LLM did not return valid Go code")
|
||||
}
|
||||
|
||||
writeFile(handlerFile, code)
|
||||
recordHandlerHash(dir, handlerFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileFix tries to compile, and if it fails, sends the error to
|
||||
// the LLM to fix. Up to maxAttempts iterations.
|
||||
func compileFix(ctx context.Context, m ai.Model, dir, name string, maxAttempts int) error {
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil // compiles!
|
||||
}
|
||||
|
||||
if m == nil {
|
||||
return fmt.Errorf("compile failed: %s", string(out))
|
||||
}
|
||||
|
||||
// Read current handler
|
||||
handlerPath := filepath.Join(dir, "handler", name+".go")
|
||||
currentCode := readFile(handlerPath)
|
||||
|
||||
sp := startSpinner(fmt.Sprintf("fixing compile errors (attempt %d/%d)...", attempt+1, maxAttempts))
|
||||
fixCtx, fixCancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
resp, fixErr := m.Generate(fixCtx, &ai.Request{
|
||||
Prompt: fmt.Sprintf("This Go code has compile errors. Fix ALL of them and return the COMPLETE corrected file.\n\nErrors:\n%s\n\nCode:\n%s",
|
||||
string(out), currentCode),
|
||||
SystemPrompt: "You are a Go expert. Return ONLY the corrected Go code. No markdown, no explanation. Start with 'package handler'.",
|
||||
})
|
||||
fixCancel()
|
||||
sp.Stop()
|
||||
if fixErr != nil {
|
||||
return fmt.Errorf("fix attempt failed: %w", fixErr)
|
||||
}
|
||||
|
||||
fixed := firstNonEmpty(resp.Answer, resp.Reply)
|
||||
fixed = extractCode(fixed)
|
||||
if strings.HasPrefix(strings.TrimSpace(fixed), "package") && !isTruncated(fixed) {
|
||||
writeFile(handlerPath, fixed)
|
||||
}
|
||||
}
|
||||
|
||||
// Final check
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = dir
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("still fails after %d attempts: %s", maxAttempts, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newModel(provider, apiKey, model string) ai.Model {
|
||||
if provider == "" {
|
||||
provider = ai.AutoDetectProvider("")
|
||||
}
|
||||
var opts []ai.Option
|
||||
opts = append(opts, ai.WithAPIKey(apiKey))
|
||||
if model != "" {
|
||||
opts = append(opts, ai.WithModel(model))
|
||||
}
|
||||
return ai.New(provider, opts...)
|
||||
}
|
||||
|
||||
func buildProto(dehyphen, titleName string, svc ServiceSpec) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "syntax = \"proto3\";\n\npackage %s;\n\noption go_package = \"./proto;%s\";\n\n", dehyphen, dehyphen)
|
||||
|
||||
fmt.Fprintf(&b, "service %s {\n", titleName)
|
||||
for _, ep := range svc.Endpoints {
|
||||
fmt.Fprintf(&b, "\trpc %s(%sRequest) returns (%sResponse) {}\n", ep.Name, ep.Name, ep.Name)
|
||||
}
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
// Record message
|
||||
fmt.Fprintf(&b, "message %sRecord {\n", titleName)
|
||||
for i, f := range svc.Fields {
|
||||
fmt.Fprintf(&b, "\t%s %s = %d; // %s\n", protoType(f.Type), f.Name, i+1, f.Description)
|
||||
}
|
||||
b.WriteString("}\n\n")
|
||||
|
||||
// Request/response for each endpoint
|
||||
for _, ep := range svc.Endpoints {
|
||||
switch ep.Name {
|
||||
case "Create":
|
||||
b.WriteString("message CreateRequest {\n")
|
||||
n := 1
|
||||
for _, f := range svc.Fields {
|
||||
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
|
||||
n++
|
||||
}
|
||||
fmt.Fprintf(&b, "}\n\nmessage CreateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
|
||||
case "Read":
|
||||
fmt.Fprintf(&b, "message ReadRequest {\n\tstring id = 1;\n}\n\nmessage ReadResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
|
||||
case "Update":
|
||||
b.WriteString("message UpdateRequest {\n\tstring id = 1;\n")
|
||||
n := 2
|
||||
for _, f := range svc.Fields {
|
||||
if f.Name == "id" || f.Name == "created" || f.Name == "updated" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
|
||||
n++
|
||||
}
|
||||
fmt.Fprintf(&b, "}\n\nmessage UpdateResponse {\n\t%sRecord record = 1;\n}\n\n", titleName)
|
||||
case "Delete":
|
||||
b.WriteString("message DeleteRequest {\n\tstring id = 1;\n}\n\nmessage DeleteResponse {\n\tbool deleted = 1;\n}\n\n")
|
||||
case "List":
|
||||
fmt.Fprintf(&b, "message ListRequest {\n\tint64 limit = 1;\n\tint64 offset = 2;\n\tstring query = 3;\n}\n\nmessage ListResponse {\n\trepeated %sRecord records = 1;\n\tint64 total = 2;\n}\n\n", titleName)
|
||||
default:
|
||||
// Custom endpoint — use all fields as input, record as output
|
||||
fmt.Fprintf(&b, "message %sRequest {\n", ep.Name)
|
||||
n := 1
|
||||
for _, f := range svc.Fields {
|
||||
if f.Name == "created" || f.Name == "updated" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\t%s %s = %d;\n", protoType(f.Type), f.Name, n)
|
||||
n++
|
||||
}
|
||||
fmt.Fprintf(&b, "}\n\nmessage %sResponse {\n\t%sRecord record = 1;\n\tstring message = 2;\n\tbool success = 3;\n}\n\n", ep.Name, titleName)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func buildMain(name, titleName string) string {
|
||||
svcName := strings.TrimSuffix(name, "-service")
|
||||
return fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"%s/handler"
|
||||
pb "%s/proto"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
"go-micro.dev/v6/gateway/mcp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("%s",
|
||||
mcp.WithMCP(":0"),
|
||||
)
|
||||
service.Init()
|
||||
pb.Register%sHandler(service.Server(), handler.New())
|
||||
service.Run()
|
||||
}
|
||||
`, name, name, svcName, titleName)
|
||||
}
|
||||
|
||||
func generateAgent(baseDir string, design *ServiceDesign, svcNames []string) error {
|
||||
agentName := "agent"
|
||||
agentDir := filepath.Join(baseDir, agentName)
|
||||
|
||||
if _, err := os.Stat(agentDir); err == nil {
|
||||
return nil // already exists
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(agentDir, 0755)
|
||||
|
||||
// Build a description of all services for the agent prompt
|
||||
var svcDescs []string
|
||||
for _, svc := range design.Services {
|
||||
var eps []string
|
||||
for _, ep := range svc.Endpoints {
|
||||
eps = append(eps, ep.Name)
|
||||
}
|
||||
svcDescs = append(svcDescs, fmt.Sprintf("- %s: %s (%s)", svc.Name, svc.Description, strings.Join(eps, ", ")))
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf("You manage these services:\\n%s\\nUse the available tools to fulfill requests. Be helpful and concise.", strings.Join(svcDescs, "\\n"))
|
||||
quoted := strings.Join(svcNames, `", "`)
|
||||
|
||||
writeFile(filepath.Join(agentDir, "main.go"), fmt.Sprintf(`package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"go-micro.dev/v6"
|
||||
)
|
||||
|
||||
func main() {
|
||||
agent := micro.NewAgent("agent",
|
||||
micro.AgentServices("%s"),
|
||||
micro.AgentPrompt("%s"),
|
||||
micro.AgentProvider(os.Getenv("MICRO_AI_PROVIDER")),
|
||||
micro.AgentAPIKey(os.Getenv("MICRO_AI_API_KEY")),
|
||||
)
|
||||
agent.Init()
|
||||
agent.Run()
|
||||
}
|
||||
`, quoted, prompt))
|
||||
|
||||
writeFile(filepath.Join(agentDir, "go.mod"),
|
||||
fmt.Sprintf("module %s\n\ngo 1.24\n\nrequire go-micro.dev/v6 %s\n", agentName, goMicroVersion))
|
||||
|
||||
_ = runIn(agentDir, "go", "mod", "tidy")
|
||||
|
||||
fmt.Printf(" \033[35m◆\033[0m agent \033[2m(manages: %s)\033[0m\n", strings.Join(svcNames, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractJSON(s string) string {
|
||||
if i := strings.Index(s, "```json"); i >= 0 {
|
||||
s = s[i+7:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
if i := strings.Index(s, "```"); i >= 0 {
|
||||
s = s[i+3:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
if i := strings.Index(s, "{"); i >= 0 {
|
||||
depth := 0
|
||||
for j := i; j < len(s); j++ {
|
||||
switch s[j] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return s[i : j+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func extractCode(s string) string {
|
||||
if i := strings.Index(s, "```go"); i >= 0 {
|
||||
s = s[i+5:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
if i := strings.Index(s, "```"); i >= 0 {
|
||||
s = s[i+3:]
|
||||
if j := strings.Index(s, "```"); j >= 0 {
|
||||
return strings.TrimSpace(s[:j])
|
||||
}
|
||||
}
|
||||
// Try to find raw package declaration
|
||||
if i := strings.Index(s, "package "); i >= 0 {
|
||||
return strings.TrimSpace(s[i:])
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func isTruncated(code string) bool {
|
||||
trimmed := strings.TrimSpace(code)
|
||||
if len(trimmed) == 0 {
|
||||
return true
|
||||
}
|
||||
// Valid Go files end with a closing brace
|
||||
if trimmed[len(trimmed)-1] != '}' {
|
||||
return true
|
||||
}
|
||||
// Check balanced braces
|
||||
depth := 0
|
||||
for _, c := range trimmed {
|
||||
switch c {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
}
|
||||
}
|
||||
return depth != 0
|
||||
}
|
||||
|
||||
func protoType(t string) string {
|
||||
switch t {
|
||||
case "int64":
|
||||
return "int64"
|
||||
case "int32":
|
||||
return "int32"
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "float64":
|
||||
return "double"
|
||||
default:
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
func toTitle(s string) string {
|
||||
words := strings.FieldsFunc(s, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
|
||||
for i, w := range words {
|
||||
if len(w) > 0 {
|
||||
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(words, "")
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readFile(path string) string {
|
||||
b, _ := os.ReadFile(path)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func writeFile(path, content string) {
|
||||
_ = os.WriteFile(path, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func runIn(dir string, name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "PATH="+os.Getenv("PATH")+":"+os.Getenv("GOPATH")+"/bin:"+os.Getenv("HOME")+"/go/bin")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
type spinner struct {
|
||||
msg string
|
||||
stop chan struct{}
|
||||
done sync.WaitGroup
|
||||
}
|
||||
|
||||
func isTTY() bool {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func startSpinner(msg string) *spinner {
|
||||
s := &spinner{msg: msg, stop: make(chan struct{})}
|
||||
if !isTTY() {
|
||||
fmt.Printf(" %s\n", msg)
|
||||
return s
|
||||
}
|
||||
s.done.Add(1)
|
||||
go func() {
|
||||
defer s.done.Done()
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
i := 0
|
||||
t := time.NewTicker(100 * time.Millisecond)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
fmt.Printf("\r\033[K")
|
||||
return
|
||||
case <-t.C:
|
||||
fmt.Printf("\r %s %s", frames[i%len(frames)], msg)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *spinner) Stop() {
|
||||
close(s.stop)
|
||||
s.done.Wait()
|
||||
}
|
||||
|
||||
func fileHash(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func metaPath(svcDir string) string {
|
||||
return filepath.Join(svcDir, ".micro")
|
||||
}
|
||||
|
||||
func readMeta(svcDir string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
b, err := os.ReadFile(metaPath(svcDir))
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
return m
|
||||
}
|
||||
|
||||
func writeMeta(svcDir string, m map[string]string) {
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
_ = os.WriteFile(metaPath(svcDir), b, 0644)
|
||||
}
|
||||
|
||||
func fileModified(svcDir, key, path string) bool {
|
||||
meta := readMeta(svcDir)
|
||||
savedHash, ok := meta[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return fileHash(path) != savedHash
|
||||
}
|
||||
|
||||
func recordFileHash(svcDir, key, path string) {
|
||||
meta := readMeta(svcDir)
|
||||
meta[key] = fileHash(path)
|
||||
writeMeta(svcDir, meta)
|
||||
}
|
||||
|
||||
func handlerModified(svcDir, handlerFile string) bool {
|
||||
return fileModified(svcDir, "handler_hash", handlerFile)
|
||||
}
|
||||
|
||||
func recordHandlerHash(svcDir, handlerFile string) {
|
||||
recordFileHash(svcDir, "handler_hash", handlerFile)
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"order-service", "OrderService"},
|
||||
{"task", "Task"},
|
||||
{"inventory_item", "InventoryItem"},
|
||||
{"hello world", "HelloWorld"},
|
||||
{"a-b-c", "ABC"},
|
||||
{"already", "Already"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := toTitle(tt.in); got != tt.want {
|
||||
t.Errorf("toTitle(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtoType(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"string", "string"},
|
||||
{"int64", "int64"},
|
||||
{"int32", "int32"},
|
||||
{"bool", "bool"},
|
||||
{"float64", "double"},
|
||||
{"unknown", "string"},
|
||||
{"", "string"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := protoType(tt.in); got != tt.want {
|
||||
t.Errorf("protoType(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstNonEmpty(t *testing.T) {
|
||||
if got := firstNonEmpty("", "", "c"); got != "c" {
|
||||
t.Errorf("got %q, want %q", got, "c")
|
||||
}
|
||||
if got := firstNonEmpty("a", "b"); got != "a" {
|
||||
t.Errorf("got %q, want %q", got, "a")
|
||||
}
|
||||
if got := firstNonEmpty("", ""); got != "" {
|
||||
t.Errorf("got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, in, want string
|
||||
}{
|
||||
{
|
||||
"fenced json",
|
||||
"Here's the design:\n```json\n{\"services\": []}\n```\nDone.",
|
||||
`{"services": []}`,
|
||||
},
|
||||
{
|
||||
"fenced no lang",
|
||||
"```\n{\"a\": 1}\n```",
|
||||
`{"a": 1}`,
|
||||
},
|
||||
{
|
||||
"raw json",
|
||||
`some text {"key": "val"} trailing`,
|
||||
`{"key": "val"}`,
|
||||
},
|
||||
{
|
||||
"nested braces",
|
||||
`{"a": {"b": 1}}`,
|
||||
`{"a": {"b": 1}}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractJSON(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractJSON() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, in string
|
||||
wantPrefix string
|
||||
}{
|
||||
{
|
||||
"go fence",
|
||||
"Here:\n```go\npackage handler\n\nfunc Foo() {}\n```\nDone.",
|
||||
"package handler",
|
||||
},
|
||||
{
|
||||
"generic fence",
|
||||
"```\npackage main\n```",
|
||||
"package main",
|
||||
},
|
||||
{
|
||||
"raw code",
|
||||
"Sure, here's the code:\npackage handler\n\ntype X struct{}",
|
||||
"package handler",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractCode(tt.in)
|
||||
if !strings.HasPrefix(got, tt.wantPrefix) {
|
||||
t.Errorf("extractCode() = %q, want prefix %q", got, tt.wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProto(t *testing.T) {
|
||||
svc := ServiceSpec{
|
||||
Name: "task-service",
|
||||
Description: "Manages tasks",
|
||||
Fields: []FieldSpec{
|
||||
{Name: "id", Type: "string", Description: "Task ID"},
|
||||
{Name: "title", Type: "string", Description: "Task title"},
|
||||
{Name: "done", Type: "bool", Description: "Completion status"},
|
||||
{Name: "created", Type: "int64", Description: "Created timestamp"},
|
||||
{Name: "updated", Type: "int64", Description: "Updated timestamp"},
|
||||
},
|
||||
Endpoints: []EndpointSpec{
|
||||
{Name: "Create", Description: "Create a task"},
|
||||
{Name: "Read", Description: "Get a task"},
|
||||
{Name: "Update", Description: "Update a task"},
|
||||
{Name: "Delete", Description: "Delete a task"},
|
||||
{Name: "List", Description: "List tasks"},
|
||||
{Name: "ToggleComplete", Description: "Toggle completion"},
|
||||
},
|
||||
}
|
||||
|
||||
proto := buildProto("taskservice", "TaskService", svc)
|
||||
|
||||
checks := []string{
|
||||
`syntax = "proto3"`,
|
||||
`package taskservice`,
|
||||
`service TaskService`,
|
||||
`rpc Create(CreateRequest) returns (CreateResponse)`,
|
||||
`rpc ToggleComplete(ToggleCompleteRequest) returns (ToggleCompleteResponse)`,
|
||||
`message TaskServiceRecord`,
|
||||
`string title = 2`,
|
||||
`bool done = 3`,
|
||||
`message CreateRequest`,
|
||||
`message ReadRequest`,
|
||||
`message DeleteRequest`,
|
||||
`message ListRequest`,
|
||||
`message ToggleCompleteRequest`,
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(proto, c) {
|
||||
t.Errorf("buildProto() missing %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Create should not include id, created, updated
|
||||
createIdx := strings.Index(proto, "message CreateRequest")
|
||||
createEnd := strings.Index(proto[createIdx:], "}")
|
||||
createBlock := proto[createIdx : createIdx+createEnd]
|
||||
for _, skip := range []string{"string id", "int64 created", "int64 updated"} {
|
||||
if strings.Contains(createBlock, skip) {
|
||||
t.Errorf("CreateRequest should not contain %q", skip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMain(t *testing.T) {
|
||||
// New naming: no -service suffix
|
||||
main := buildMain("order", "Order")
|
||||
checks := []string{
|
||||
`"order/handler"`,
|
||||
`pb "order/proto"`,
|
||||
`micro.NewService("order"`,
|
||||
`pb.RegisterOrderHandler`,
|
||||
`handler.New()`,
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(main, c) {
|
||||
t.Errorf("buildMain(order) missing %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy naming: -service suffix stripped
|
||||
main = buildMain("order-service", "OrderService")
|
||||
checks = []string{
|
||||
`"order-service/handler"`,
|
||||
`pb "order-service/proto"`,
|
||||
`micro.NewService("order"`,
|
||||
`pb.RegisterOrderServiceHandler`,
|
||||
`handler.New()`,
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(main, c) {
|
||||
t.Errorf("buildMain() missing %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerModifiedTracking(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
handlerDir := filepath.Join(dir, "handler")
|
||||
os.MkdirAll(handlerDir, 0755)
|
||||
handlerFile := filepath.Join(handlerDir, "test.go")
|
||||
|
||||
// No .micro file → not modified
|
||||
os.WriteFile(handlerFile, []byte("package handler\n"), 0644)
|
||||
if handlerModified(dir, handlerFile) {
|
||||
t.Error("expected not modified when no .micro exists")
|
||||
}
|
||||
|
||||
// Record hash → not modified
|
||||
recordHandlerHash(dir, handlerFile)
|
||||
if handlerModified(dir, handlerFile) {
|
||||
t.Error("expected not modified after recording hash")
|
||||
}
|
||||
|
||||
// Edit the file → modified
|
||||
os.WriteFile(handlerFile, []byte("package handler\n\nfunc Foo() {}\n"), 0644)
|
||||
if !handlerModified(dir, handlerFile) {
|
||||
t.Error("expected modified after editing file")
|
||||
}
|
||||
|
||||
// Re-record → not modified again
|
||||
recordHandlerHash(dir, handlerFile)
|
||||
if handlerModified(dir, handlerFile) {
|
||||
t.Error("expected not modified after re-recording hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaReadWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
m := readMeta(dir)
|
||||
if len(m) != 0 {
|
||||
t.Error("expected empty meta for new dir")
|
||||
}
|
||||
|
||||
m["handler_hash"] = "abc123"
|
||||
m["version"] = "1"
|
||||
writeMeta(dir, m)
|
||||
|
||||
m2 := readMeta(dir)
|
||||
if m2["handler_hash"] != "abc123" || m2["version"] != "1" {
|
||||
t.Errorf("readMeta() = %v, want handler_hash=abc123, version=1", m2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStructure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
svcDir := filepath.Join(dir, "test-svc")
|
||||
|
||||
svc := ServiceSpec{
|
||||
Name: "test-svc",
|
||||
Description: "Test service",
|
||||
Fields: []FieldSpec{
|
||||
{Name: "id", Type: "string"},
|
||||
{Name: "name", Type: "string"},
|
||||
},
|
||||
Endpoints: []EndpointSpec{
|
||||
{Name: "Create"},
|
||||
{Name: "Read"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Check files exist
|
||||
for _, f := range []string{
|
||||
"proto/test-svc.proto",
|
||||
"handler/test-svc.go",
|
||||
"main.go",
|
||||
"go.mod",
|
||||
"Makefile",
|
||||
".gitignore",
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(svcDir, f)); err != nil {
|
||||
t.Errorf("missing %s: %v", f, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check .micro was created with handler hash
|
||||
meta := readMeta(svcDir)
|
||||
if meta["handler_hash"] == "" {
|
||||
t.Error("expected handler_hash in .micro after generateStructure")
|
||||
}
|
||||
|
||||
// Run again — should not overwrite main.go
|
||||
mainBefore, _ := os.ReadFile(filepath.Join(svcDir, "main.go"))
|
||||
os.WriteFile(filepath.Join(svcDir, "main.go"), []byte("// user edited\n"), 0644)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainAfter, _ := os.ReadFile(filepath.Join(svcDir, "main.go"))
|
||||
if string(mainAfter) == string(mainBefore) {
|
||||
t.Error("expected main.go to keep user edit on re-run")
|
||||
}
|
||||
|
||||
// Proto should be protected if user modified it
|
||||
protoFile := filepath.Join(svcDir, "proto", "test-svc.proto")
|
||||
os.WriteFile(protoFile, []byte("// user-edited proto\n"), 0644)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protoAfter, _ := os.ReadFile(protoFile)
|
||||
if string(protoAfter) != "// user-edited proto\n" {
|
||||
t.Error("expected proto to be preserved after user edit")
|
||||
}
|
||||
|
||||
// Proto should regenerate if NOT modified
|
||||
recordFileHash(svcDir, "proto_hash", protoFile)
|
||||
if err := generateStructure(svcDir, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
protoAfter2, _ := os.ReadFile(protoFile)
|
||||
_ = protoAfter2
|
||||
}
|
||||
|
||||
func TestFileModified(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "test.txt")
|
||||
os.WriteFile(f, []byte("original"), 0644)
|
||||
|
||||
// No hash → not modified
|
||||
if fileModified(dir, "test_hash", f) {
|
||||
t.Error("expected not modified with no saved hash")
|
||||
}
|
||||
|
||||
recordFileHash(dir, "test_hash", f)
|
||||
|
||||
// Same content → not modified
|
||||
if fileModified(dir, "test_hash", f) {
|
||||
t.Error("expected not modified with matching hash")
|
||||
}
|
||||
|
||||
// Changed content → modified
|
||||
os.WriteFile(f, []byte("changed"), 0644)
|
||||
if !fileModified(dir, "test_hash", f) {
|
||||
t.Error("expected modified after content change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Empty directory → empty string
|
||||
if got := discoverExisting(dir); got != "" {
|
||||
t.Errorf("expected empty for empty dir, got %q", got)
|
||||
}
|
||||
|
||||
// Non-service directory (no proto) → empty
|
||||
os.MkdirAll(filepath.Join(dir, "not-a-service"), 0755)
|
||||
if got := discoverExisting(dir); got != "" {
|
||||
t.Errorf("expected empty for dir without proto, got %q", got)
|
||||
}
|
||||
|
||||
// Create a real service directory with proto
|
||||
svcDir := filepath.Join(dir, "order-service")
|
||||
os.MkdirAll(filepath.Join(svcDir, "proto"), 0755)
|
||||
os.WriteFile(filepath.Join(svcDir, "proto", "order-service.proto"),
|
||||
[]byte("syntax = \"proto3\";\nservice OrderService {}"), 0644)
|
||||
|
||||
got := discoverExisting(dir)
|
||||
if !strings.Contains(got, "order-service") {
|
||||
t.Errorf("expected to find order-service, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "OrderService") {
|
||||
t.Errorf("expected to find proto content, got %q", got)
|
||||
}
|
||||
|
||||
// Add a second service
|
||||
svc2Dir := filepath.Join(dir, "user-service")
|
||||
os.MkdirAll(filepath.Join(svc2Dir, "proto"), 0755)
|
||||
os.WriteFile(filepath.Join(svc2Dir, "proto", "user-service.proto"),
|
||||
[]byte("syntax = \"proto3\";\nservice UserService {}"), 0644)
|
||||
|
||||
got = discoverExisting(dir)
|
||||
if !strings.Contains(got, "order-service") || !strings.Contains(got, "user-service") {
|
||||
t.Errorf("expected both services, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTruncated(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
want bool
|
||||
}{
|
||||
{"complete", "package handler\n\nfunc New() *H { return &H{} }\n", false},
|
||||
{"empty", "", true},
|
||||
{"no closing brace", "package handler\n\nfunc Foo() {", true},
|
||||
{"unbalanced", "package handler\n\nfunc Foo() {\n\tif true {", true},
|
||||
{"balanced", "package handler\n\nfunc Foo() {\n\tif true {\n\t}\n}", false},
|
||||
{"trailing whitespace ok", "package handler\n\ntype X struct{}\n\n", false},
|
||||
{"mid-expression", "package handler\n\nfunc F() {\n\tx := 1 +", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isTruncated(tt.code); got != tt.want {
|
||||
t.Errorf("isTruncated() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user