mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-30 19:59:40 +02:00
673 lines
18 KiB
Go
673 lines
18 KiB
Go
// Package config provides validation for langgraph.json configuration files.
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
MinNodeVersion = "20"
|
|
DefaultNodeVersion = "20"
|
|
MinPythonVersion = "3.11"
|
|
DefaultPythonVersion = "3.11"
|
|
DefaultImageDistro = "debian"
|
|
)
|
|
|
|
var validDistros = []string{"debian", "wolfi", "bookworm"}
|
|
|
|
var knownConfigKeys = map[string]bool{
|
|
"python_version": true,
|
|
"node_version": true,
|
|
"api_version": true,
|
|
"base_image": true,
|
|
"image_distro": true,
|
|
"pip_config_file": true,
|
|
"pip_installer": true,
|
|
"source": true,
|
|
"dependencies": true,
|
|
"dockerfile_lines": true,
|
|
"graphs": true,
|
|
"env": true,
|
|
"store": true,
|
|
"auth": true,
|
|
"encryption": true,
|
|
"http": true,
|
|
"webhooks": true,
|
|
"checkpointer": true,
|
|
"ui": true,
|
|
"ui_config": true,
|
|
"keep_pkg_tools": true,
|
|
"_INTERNAL_docker_tag": true,
|
|
"project_root": true,
|
|
"package": true,
|
|
}
|
|
|
|
var nodeExtensions = map[string]bool{
|
|
".ts": true, ".mts": true, ".cts": true,
|
|
".js": true, ".mjs": true, ".cjs": true,
|
|
}
|
|
|
|
// isNodeGraph checks whether a graph spec refers to a Node.js file.
|
|
func isNodeGraph(spec any) bool {
|
|
var filePath string
|
|
switch v := spec.(type) {
|
|
case string:
|
|
filePath = strings.SplitN(v, ":", 2)[0]
|
|
case map[string]any:
|
|
if p, _ := v["path"].(string); p != "" {
|
|
filePath = strings.SplitN(p, ":", 2)[0]
|
|
}
|
|
}
|
|
return nodeExtensions[filepath.Ext(filePath)]
|
|
}
|
|
|
|
// getSourceKind extracts source.kind from a raw config.
|
|
func getSourceKind(raw map[string]any) string {
|
|
source, ok := raw["source"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
m, ok := source.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
kind, _ := m["kind"].(string)
|
|
return kind
|
|
}
|
|
|
|
// getString returns the string value for key, or "" if missing/wrong type.
|
|
func getString(raw map[string]any, key string) string {
|
|
v, _ := raw[key].(string)
|
|
return v
|
|
}
|
|
|
|
// parseVersion parses "3.11" or "0.8.1" into integer parts.
|
|
func parseVersion(s string) ([]int, error) {
|
|
s = strings.SplitN(s, "-", 2)[0]
|
|
parts := strings.Split(s, ".")
|
|
result := make([]int, len(parts))
|
|
for i, p := range parts {
|
|
n, err := strconv.Atoi(p)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid version part: %s", p)
|
|
}
|
|
result[i] = n
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// versionLessThan returns true if a < b (component-wise).
|
|
func versionLessThan(a, b []int) bool {
|
|
for i := 0; i < len(a) && i < len(b); i++ {
|
|
if a[i] < b[i] {
|
|
return true
|
|
}
|
|
if a[i] > b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return len(a) < len(b)
|
|
}
|
|
|
|
// ValidateConfig validates a raw config map and returns a normalised copy.
|
|
// Errors match the Python CLI's click.UsageError messages exactly.
|
|
func ValidateConfig(raw map[string]any) (map[string]any, error) {
|
|
// --- detect graph types ---
|
|
graphs, _ := raw["graphs"].(map[string]any)
|
|
hasNode, hasPython := false, false
|
|
for _, spec := range graphs {
|
|
if isNodeGraph(spec) {
|
|
hasNode = true
|
|
} else {
|
|
hasPython = true
|
|
}
|
|
}
|
|
|
|
// --- version defaults ---
|
|
nodeVersion := getString(raw, "node_version")
|
|
pythonVersion := getString(raw, "python_version")
|
|
|
|
if hasNode && nodeVersion == "" {
|
|
nodeVersion = DefaultNodeVersion
|
|
}
|
|
if hasPython && pythonVersion == "" {
|
|
pythonVersion = DefaultPythonVersion
|
|
}
|
|
|
|
imageDistro := getString(raw, "image_distro")
|
|
if imageDistro == "" {
|
|
imageDistro = DefaultImageDistro
|
|
}
|
|
|
|
// --- mutual exclusion: _INTERNAL_docker_tag vs api_version ---
|
|
_, hasInternalTag := raw["_INTERNAL_docker_tag"]
|
|
_, hasAPIVersion := raw["api_version"]
|
|
if hasInternalTag && hasAPIVersion {
|
|
return nil, fmt.Errorf("Cannot specify both _INTERNAL_docker_tag and api_version.")
|
|
}
|
|
|
|
// --- api_version format ---
|
|
if apiVersion := getString(raw, "api_version"); apiVersion != "" {
|
|
base := strings.SplitN(apiVersion, "-", 2)[0]
|
|
parts := strings.Split(base, ".")
|
|
if len(parts) > 3 {
|
|
return nil, fmt.Errorf("Version must be major or major.minor or major.minor.patch.")
|
|
}
|
|
for _, p := range parts {
|
|
if _, err := strconv.Atoi(p); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"Invalid version format: %s.\n\n"+
|
|
"Pin to a minor version, e.g.:\n"+
|
|
" \"api_version\": \"0.8\"", apiVersion)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- build result config with defaults ---
|
|
config := map[string]any{
|
|
"node_version": nodeVersion,
|
|
"python_version": pythonVersion,
|
|
"pip_config_file": raw["pip_config_file"],
|
|
"pip_installer": "auto",
|
|
"source": raw["source"],
|
|
"base_image": raw["base_image"],
|
|
"image_distro": imageDistro,
|
|
"dependencies": raw["dependencies"],
|
|
"dockerfile_lines": raw["dockerfile_lines"],
|
|
"graphs": raw["graphs"],
|
|
"env": raw["env"],
|
|
"store": raw["store"],
|
|
"auth": raw["auth"],
|
|
"encryption": raw["encryption"],
|
|
"http": raw["http"],
|
|
"webhooks": raw["webhooks"],
|
|
"checkpointer": raw["checkpointer"],
|
|
"ui": raw["ui"],
|
|
"ui_config": raw["ui_config"],
|
|
"keep_pkg_tools": raw["keep_pkg_tools"],
|
|
}
|
|
if raw["pip_installer"] != nil {
|
|
config["pip_installer"] = raw["pip_installer"]
|
|
}
|
|
if hasInternalTag {
|
|
config["_INTERNAL_docker_tag"] = raw["_INTERNAL_docker_tag"]
|
|
}
|
|
if hasAPIVersion {
|
|
config["api_version"] = raw["api_version"]
|
|
}
|
|
|
|
// Apply list defaults.
|
|
if config["dependencies"] == nil {
|
|
config["dependencies"] = []any{}
|
|
}
|
|
if config["dockerfile_lines"] == nil {
|
|
config["dockerfile_lines"] = []any{}
|
|
}
|
|
if config["graphs"] == nil {
|
|
config["graphs"] = map[string]any{}
|
|
}
|
|
if config["env"] == nil {
|
|
config["env"] = map[string]any{}
|
|
}
|
|
|
|
// --- node_version validation ---
|
|
if nodeVersion != "" {
|
|
if strings.Contains(nodeVersion, ".") {
|
|
return nil, fmt.Errorf("Node.js version must be major version only")
|
|
}
|
|
major, err := strconv.Atoi(nodeVersion)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"Invalid Node.js version format: %s. Use major version only (e.g., '20').",
|
|
nodeVersion)
|
|
}
|
|
minMajor, _ := strconv.Atoi(MinNodeVersion)
|
|
if major < minMajor {
|
|
return nil, fmt.Errorf(
|
|
"Node.js version %s is not supported. "+
|
|
"Minimum required version is %s.\n\n"+
|
|
"Set node_version to %s or higher:\n"+
|
|
" \"node_version\": \"%s\"",
|
|
nodeVersion, MinNodeVersion, MinNodeVersion, MinNodeVersion)
|
|
}
|
|
}
|
|
|
|
// --- pip_installer validation ---
|
|
if pi, ok := raw["pip_installer"].(string); ok {
|
|
switch pi {
|
|
case "auto", "pip", "uv":
|
|
// valid
|
|
default:
|
|
return nil, fmt.Errorf(
|
|
"Invalid pip_installer: '%s'. "+
|
|
"Consider using uv-based source management instead:\n\n"+
|
|
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}",
|
|
pi)
|
|
}
|
|
}
|
|
|
|
// --- source validation ---
|
|
sourceKind := getSourceKind(raw)
|
|
if source := raw["source"]; source != nil {
|
|
if _, ok := source.(map[string]any); !ok {
|
|
return nil, fmt.Errorf(
|
|
"`source` must be an object, e.g.:\n" +
|
|
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
|
|
}
|
|
if sourceKind != "uv" {
|
|
return nil, fmt.Errorf(
|
|
"Invalid source.kind. The only supported value is 'uv':\n" +
|
|
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
|
|
}
|
|
}
|
|
|
|
// --- python_version validation ---
|
|
if pythonVersion != "" {
|
|
base := strings.SplitN(pythonVersion, "-", 2)[0]
|
|
dotParts := strings.Split(base, ".")
|
|
allDigits := true
|
|
for _, p := range dotParts {
|
|
if _, err := strconv.Atoi(p); err != nil {
|
|
allDigits = false
|
|
break
|
|
}
|
|
}
|
|
if len(dotParts) != 2 || !allDigits {
|
|
fix := MinPythonVersion
|
|
if len(dotParts) >= 2 {
|
|
fix = dotParts[0] + "." + dotParts[1]
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"Invalid Python version format: %s. "+
|
|
"Use 'major.minor' format — patch version cannot be specified.\n\n"+
|
|
" \"python_version\": \"%s\"",
|
|
pythonVersion, fix)
|
|
}
|
|
pyParsed, _ := parseVersion(pythonVersion)
|
|
minParsed, _ := parseVersion(MinPythonVersion)
|
|
if versionLessThan(pyParsed, minParsed) {
|
|
return nil, fmt.Errorf(
|
|
"Python version %s is not supported. "+
|
|
"Minimum required version is %s.\n\n"+
|
|
" \"python_version\": \"%s\"",
|
|
pythonVersion, MinPythonVersion, MinPythonVersion)
|
|
}
|
|
if strings.Contains(pythonVersion, "bullseye") {
|
|
return nil, fmt.Errorf(
|
|
"Bullseye images were deprecated in version 0.4.13. " +
|
|
"Please use 'bookworm' or 'debian' instead.")
|
|
}
|
|
|
|
// dependencies required when not uv
|
|
deps, _ := config["dependencies"].([]any)
|
|
if sourceKind != "uv" && len(deps) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"No dependencies found in config. " +
|
|
"Consider using uv-based source management:\n\n" +
|
|
" \"source\": {\"kind\": \"uv\", \"root\": \"..\"}")
|
|
}
|
|
}
|
|
|
|
// --- graphs required ---
|
|
graphMap, _ := config["graphs"].(map[string]any)
|
|
if len(graphMap) == 0 {
|
|
return nil, fmt.Errorf(
|
|
"No graphs found in config. Add at least one graph, e.g.:\n" +
|
|
" \"graphs\": {\n" +
|
|
" \"agent\": \"./my_agent/graph.py:graph\"\n" +
|
|
" }")
|
|
}
|
|
|
|
// --- image_distro validation ---
|
|
if imageDistro == "bullseye" {
|
|
return nil, fmt.Errorf(
|
|
"Bullseye images were deprecated in version 0.4.13. " +
|
|
"Please use 'bookworm' or 'debian' instead.")
|
|
}
|
|
validDistro := false
|
|
for _, d := range validDistros {
|
|
if imageDistro == d {
|
|
validDistro = true
|
|
break
|
|
}
|
|
}
|
|
if !validDistro {
|
|
quoted := make([]string, len(validDistros))
|
|
for i, d := range validDistros {
|
|
quoted[i] = fmt.Sprintf("'%s'", d)
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"Invalid image_distro: '%s'. "+
|
|
"Must be one of: %s.\n\n"+
|
|
" \"image_distro\": \"wolfi\" (recommended)",
|
|
imageDistro, strings.Join(quoted, ", "))
|
|
}
|
|
|
|
// --- uv source mode validation ---
|
|
if sourceKind == "uv" {
|
|
var errs []string
|
|
if pythonVersion == "" {
|
|
errs = append(errs, "source.kind 'uv' requires `python_version` — it is a Python-only deployment mode. Node.js-only graphs are not supported.")
|
|
}
|
|
|
|
deps, _ := raw["dependencies"].([]any)
|
|
if deps != nil && len(deps) > 0 {
|
|
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
|
|
}
|
|
// Also check if dependencies key exists even if empty array.
|
|
if deps == nil {
|
|
if rawDeps, exists := raw["dependencies"]; exists && rawDeps != nil {
|
|
// dependencies key present but not an array — still flag it
|
|
if depsArr, ok := rawDeps.([]any); ok && len(depsArr) > 0 {
|
|
errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.")
|
|
}
|
|
}
|
|
}
|
|
|
|
sourceMap, _ := raw["source"].(map[string]any)
|
|
if root, exists := sourceMap["root"]; exists {
|
|
rootStr, ok := root.(string)
|
|
if !ok {
|
|
errs = append(errs, fmt.Sprintf("`source.root` must be a string, got %T.", root))
|
|
} else if rootStr == "" {
|
|
errs = append(errs, "`source.root` must be a non-empty string. Use `\".\"`.")
|
|
}
|
|
}
|
|
|
|
if pkg, exists := sourceMap["package"]; exists {
|
|
if pkg != nil {
|
|
pkgStr, ok := pkg.(string)
|
|
if !ok {
|
|
errs = append(errs, "`source.package` must be a non-empty string.")
|
|
} else if pkgStr == "" {
|
|
errs = append(errs, "`source.package` must be a non-empty string.")
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
formatted := ""
|
|
for i, e := range errs {
|
|
formatted += fmt.Sprintf("\n %d. %s", i+1, e)
|
|
}
|
|
return nil, fmt.Errorf(
|
|
"source.kind 'uv' requires a different config shape than dependency-based installs:%s",
|
|
formatted)
|
|
}
|
|
}
|
|
|
|
// --- legacy project_root / package ---
|
|
_, hasProjectRoot := raw["project_root"]
|
|
_, hasPackage := raw["package"]
|
|
if hasProjectRoot || hasPackage {
|
|
return nil, fmt.Errorf(
|
|
"Top-level `project_root` and `package` are no longer supported. " +
|
|
"Use `source.root` and `source.package` instead.")
|
|
}
|
|
|
|
// --- auth path validation ---
|
|
if auth, ok := raw["auth"].(map[string]any); ok {
|
|
if authPath, _ := auth["path"].(string); authPath != "" {
|
|
if !strings.Contains(authPath, ":") {
|
|
return nil, fmt.Errorf(
|
|
"Invalid auth.path format: '%s'. "+
|
|
"Must be in format './path/to/file.py:attribute_name'",
|
|
authPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- encryption path validation ---
|
|
if enc, ok := raw["encryption"].(map[string]any); ok {
|
|
if encPath, _ := enc["path"].(string); encPath != "" {
|
|
if !strings.Contains(encPath, ":") {
|
|
return nil, fmt.Errorf(
|
|
"Invalid encryption.path format: '%s'. "+
|
|
"Must be in format './path/to/file.py:attribute_name'",
|
|
encPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- http.app path validation ---
|
|
if httpConf, ok := raw["http"].(map[string]any); ok {
|
|
if app, _ := httpConf["app"].(string); app != "" {
|
|
if !strings.Contains(app, ":") {
|
|
return nil, fmt.Errorf(
|
|
"Invalid http.app format: '%s'. "+
|
|
"Must be in format './path/to/file.py:attribute_name'",
|
|
app)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- keep_pkg_tools validation ---
|
|
if kpt := raw["keep_pkg_tools"]; kpt != nil {
|
|
validBuildTools := map[string]bool{"pip": true, "setuptools": true, "wheel": true}
|
|
switch v := kpt.(type) {
|
|
case bool:
|
|
// ok
|
|
case []any:
|
|
for _, item := range v {
|
|
tool, ok := item.(string)
|
|
if !ok || !validBuildTools[tool] {
|
|
return nil, fmt.Errorf(
|
|
"Invalid keep_pkg_tools: '%v'. "+
|
|
"Must be one of 'pip', 'setuptools', 'wheel'.",
|
|
item)
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf(
|
|
"Invalid keep_pkg_tools: '%v'. "+
|
|
"Must be bool or list[str] (with values 'pip', 'setuptools', and/or 'wheel').",
|
|
kpt)
|
|
}
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// ValidateConfigFile loads a config file, validates it, and returns the result.
|
|
func ValidateConfigFile(configPath string) (map[string]any, error) {
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not read config file: %w", err)
|
|
}
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.Error())
|
|
}
|
|
|
|
validated, err := ValidateConfig(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check package.json node version if node_version is set.
|
|
if nv, _ := validated["node_version"].(string); nv != "" {
|
|
dir := filepath.Dir(configPath)
|
|
pkgJSONPath := filepath.Join(dir, "package.json")
|
|
if info, statErr := os.Stat(pkgJSONPath); statErr == nil && !info.IsDir() {
|
|
if pkgErr := validatePackageJSON(pkgJSONPath); pkgErr != nil {
|
|
return nil, pkgErr
|
|
}
|
|
}
|
|
}
|
|
|
|
return validated, nil
|
|
}
|
|
|
|
func validatePackageJSON(path string) error {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
var pkg map[string]any
|
|
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
return fmt.Errorf("Invalid package.json: %s", err.Error())
|
|
}
|
|
|
|
enginesRaw, ok := pkg["engines"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
engines, ok := enginesRaw.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
for k := range engines {
|
|
if k != "node" {
|
|
keys := make([]string, 0, len(engines))
|
|
for ek := range engines {
|
|
keys = append(keys, ek)
|
|
}
|
|
return fmt.Errorf(
|
|
"Only 'node' engine is supported in package.json engines. Got engines: %v",
|
|
keys)
|
|
}
|
|
}
|
|
|
|
if nodeVer, ok := engines["node"].(string); ok && nodeVer != "" {
|
|
if strings.Contains(nodeVer, ".") {
|
|
return fmt.Errorf(
|
|
"Node.js version in package.json engines must be >= %s "+
|
|
"(major version only), got '%s'. "+
|
|
"Minor/patch versions (like '20.x.y') are not supported to "+
|
|
"prevent deployment issues when new Node.js versions are released.",
|
|
MinNodeVersion, nodeVer)
|
|
}
|
|
major, err := strconv.Atoi(nodeVer)
|
|
if err == nil {
|
|
minMajor, _ := strconv.Atoi(MinNodeVersion)
|
|
if major < minMajor {
|
|
return fmt.Errorf(
|
|
"Node.js version in package.json engines must be >= %s "+
|
|
"(major version only), got '%s'. "+
|
|
"Minor/patch versions (like '20.x.y') are not supported to "+
|
|
"prevent deployment issues when new Node.js versions are released.",
|
|
MinNodeVersion, nodeVer)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetUnknownKeys returns warnings for unrecognised top-level keys.
|
|
func GetUnknownKeys(raw map[string]any) []string {
|
|
var unknown []string
|
|
for k := range raw {
|
|
if !knownConfigKeys[k] {
|
|
unknown = append(unknown, k)
|
|
}
|
|
}
|
|
sortStrings(unknown)
|
|
|
|
var warnings []string
|
|
knownList := make([]string, 0, len(knownConfigKeys))
|
|
for k := range knownConfigKeys {
|
|
knownList = append(knownList, k)
|
|
}
|
|
|
|
for _, key := range unknown {
|
|
if close := closestMatch(key, knownList); close != "" {
|
|
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' — did you mean '%s'?", key, close))
|
|
} else {
|
|
warnings = append(warnings, fmt.Sprintf("Unknown key '%s' is not a recognized config field.", key))
|
|
}
|
|
}
|
|
return warnings
|
|
}
|
|
|
|
// closestMatch finds the best match for word among candidates using edit distance.
|
|
// Returns "" if no match is close enough (ratio >= 0.6).
|
|
func closestMatch(word string, candidates []string) string {
|
|
best := ""
|
|
bestRatio := 0.6 // minimum threshold
|
|
for _, c := range candidates {
|
|
ratio := similarity(word, c)
|
|
if ratio > bestRatio {
|
|
bestRatio = ratio
|
|
best = c
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// similarity returns a ratio in [0,1] based on Levenshtein distance.
|
|
func similarity(a, b string) float64 {
|
|
maxLen := len(a)
|
|
if len(b) > maxLen {
|
|
maxLen = len(b)
|
|
}
|
|
if maxLen == 0 {
|
|
return 1.0
|
|
}
|
|
dist := editDistance(a, b)
|
|
return 1.0 - float64(dist)/float64(maxLen)
|
|
}
|
|
|
|
// editDistance computes Levenshtein distance between two strings.
|
|
func editDistance(a, b string) int {
|
|
la, lb := len(a), len(b)
|
|
if la == 0 {
|
|
return lb
|
|
}
|
|
if lb == 0 {
|
|
return la
|
|
}
|
|
|
|
prev := make([]int, lb+1)
|
|
curr := make([]int, lb+1)
|
|
|
|
for j := 0; j <= lb; j++ {
|
|
prev[j] = j
|
|
}
|
|
for i := 1; i <= la; i++ {
|
|
curr[0] = i
|
|
for j := 1; j <= lb; j++ {
|
|
cost := 1
|
|
if a[i-1] == b[j-1] {
|
|
cost = 0
|
|
}
|
|
ins := curr[j-1] + 1
|
|
del := prev[j] + 1
|
|
sub := prev[j-1] + cost
|
|
curr[j] = min3(ins, del, sub)
|
|
}
|
|
prev, curr = curr, prev
|
|
}
|
|
return prev[lb]
|
|
}
|
|
|
|
func min3(a, b, c int) int {
|
|
if a < b {
|
|
if a < c {
|
|
return a
|
|
}
|
|
return c
|
|
}
|
|
if b < c {
|
|
return b
|
|
}
|
|
return c
|
|
}
|
|
|
|
// sortStrings sorts a slice of strings in place (simple insertion sort, fine for small n).
|
|
func sortStrings(s []string) {
|
|
for i := 1; i < len(s); i++ {
|
|
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
|
s[j], s[j-1] = s[j-1], s[j]
|
|
}
|
|
}
|
|
}
|