mcp-framework/generate/generate.go

214 lines
5.3 KiB
Go

package generate
import (
"bytes"
"errors"
"fmt"
"go/format"
"go/token"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"gitea.lclr.dev/AI/mcp-framework/manifest"
)
var ErrGeneratedFilesOutdated = errors.New("generated files are not up to date")
type Options struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type Result struct {
Root string
Files []string
}
func Generate(options Options) (Result, error) {
normalized, err := normalizeOptions(options)
if err != nil {
return Result{}, err
}
if _, err := manifest.Load(normalized.ManifestPath); err != nil {
return Result{}, err
}
manifestContent, err := os.ReadFile(normalized.ManifestPath)
if err != nil {
return Result{}, fmt.Errorf("read manifest %s: %w", normalized.ManifestPath, err)
}
content, err := renderManifestLoader(normalized.PackageName, string(manifestContent))
if err != nil {
return Result{}, err
}
files := []generatedFile{
{
Path: filepath.Join(normalized.PackageDir, "manifest.go"),
Content: content,
Mode: 0o644,
},
}
written := make([]string, 0, len(files))
for _, file := range files {
target := filepath.Join(normalized.ProjectDir, file.Path)
if normalized.Check {
current, err := os.ReadFile(target)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
return Result{}, fmt.Errorf("read generated file %q: %w", target, err)
}
if !bytes.Equal(current, []byte(file.Content)) {
return Result{}, fmt.Errorf("%w: %s", ErrGeneratedFilesOutdated, file.Path)
}
written = append(written, file.Path)
continue
}
if err := writeGeneratedFile(target, file.Content, file.Mode); err != nil {
return Result{}, err
}
written = append(written, file.Path)
}
sort.Strings(written)
return Result{
Root: normalized.ProjectDir,
Files: written,
}, nil
}
type normalizedOptions struct {
ProjectDir string
ManifestPath string
PackageDir string
PackageName string
Check bool
}
type generatedFile struct {
Path string
Content string
Mode os.FileMode
}
func normalizeOptions(options Options) (normalizedOptions, error) {
manifestPath := strings.TrimSpace(options.ManifestPath)
projectDir := strings.TrimSpace(options.ProjectDir)
if manifestPath == "" {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifest.DefaultFile)
} else if !filepath.IsAbs(manifestPath) {
baseDir := projectDir
if baseDir == "" {
wd, err := os.Getwd()
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve working directory: %w", err)
}
baseDir = wd
}
manifestPath = filepath.Join(baseDir, manifestPath)
}
resolvedManifest, err := filepath.Abs(manifestPath)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve manifest path %q: %w", manifestPath, err)
}
if projectDir == "" {
projectDir = filepath.Dir(resolvedManifest)
}
resolvedProjectDir, err := filepath.Abs(projectDir)
if err != nil {
return normalizedOptions{}, fmt.Errorf("resolve project dir %q: %w", projectDir, err)
}
packageDir := filepath.Clean(strings.TrimSpace(options.PackageDir))
if packageDir == "." || packageDir == "" {
packageDir = "mcpgen"
}
if filepath.IsAbs(packageDir) || packageDir == ".." || strings.HasPrefix(packageDir, ".."+string(filepath.Separator)) {
return normalizedOptions{}, fmt.Errorf("package dir %q must be relative to the project", options.PackageDir)
}
packageName := strings.TrimSpace(options.PackageName)
if packageName == "" {
packageName = filepath.Base(packageDir)
}
if !token.IsIdentifier(packageName) {
return normalizedOptions{}, fmt.Errorf("package name %q is not a valid Go identifier", packageName)
}
return normalizedOptions{
ProjectDir: resolvedProjectDir,
ManifestPath: resolvedManifest,
PackageDir: packageDir,
PackageName: packageName,
Check: options.Check,
}, nil
}
func renderManifestLoader(packageName, manifestContent string) (string, error) {
source := fmt.Sprintf(`// Code generated by mcp-framework generate. DO NOT EDIT.
package %s
import fwmanifest "gitea.lclr.dev/AI/mcp-framework/manifest"
const embeddedManifest = %s
func LoadManifest(startDir string) (fwmanifest.File, string, error) {
return fwmanifest.LoadDefaultOrEmbedded(startDir, embeddedManifest)
}
`, packageName, strconv.Quote(manifestContent))
formatted, err := format.Source([]byte(source))
if err != nil {
return "", fmt.Errorf("format generated manifest loader: %w", err)
}
return string(formatted), nil
}
func writeGeneratedFile(path, content string, mode os.FileMode) error {
current, err := os.ReadFile(path)
if err == nil && bytes.Equal(current, []byte(content)) {
return nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read generated file %q: %w", path, err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create generated directory %q: %w", dir, err)
}
if mode == 0 {
mode = 0o644
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
return fmt.Errorf("write generated file %q: %w", path, err)
}
return nil
}