Files
tf_provider/universal_rebuild/tools/docs_template_gen/main.go
T

745 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"flag"
"fmt"
"html"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
type ParamSpec struct {
ID int `yaml:"id"`
Code string `yaml:"code"`
DataType string `yaml:"data_type"`
Type string `yaml:"type"`
Required bool `yaml:"required"`
Default interface{} `yaml:"default"`
Descr string `yaml:"descr"`
Man string `yaml:"man"`
RefSvcID *int `yaml:"ref_svc_id"`
Func string `yaml:"func"`
MinValue *int `yaml:"minvalue"`
MaxValue *int `yaml:"maxvalue"`
Regex string `yaml:"regex"`
ValueList []string `yaml:"value_list"`
Unique string `yaml:"unique_scope"`
MaxLength *int `yaml:"maxlength"`
MinLength *int `yaml:"minlength"`
}
type OperationSpec struct {
Name string `yaml:"name"`
ID int `yaml:"id"`
Kind string `yaml:"kind"`
Action string `yaml:"action"`
Subresource string `yaml:"subresource"`
Man string `yaml:"man"`
Params []ParamSpec `yaml:"params"`
}
type OutputParam struct {
Code string `yaml:"code"`
Type string `yaml:"type"`
Sensitive bool `yaml:"sensitive"`
}
type ServiceSpec struct {
Name string `yaml:"name"`
ServiceID int `yaml:"service_id"`
ServiceDisplayName string `yaml:"service_display_name"`
ServiceMan string `yaml:"service_man"`
Lifecycle struct {
DeleteModeDefault string `yaml:"delete_mode_default"`
ResumeIfExistsDefault bool `yaml:"resume_if_exists_default"`
} `yaml:"lifecycle"`
Outputs struct {
Params []OutputParam `yaml:"params"`
} `yaml:"outputs"`
Operations []OperationSpec `yaml:"operations"`
}
type ServiceMeta struct {
ID int
Name string
}
func main() {
resourcesDirFlag := flag.String("resources", "", "Path to resources_yaml")
docsDirFlag := flag.String("docs", "", "Path to docs/30_registry/resources")
servicesListFlag := flag.String("services", "", "Path to devops/config/services_list.txt")
excludeFlag := flag.String("exclude", "clickhouse", "Comma-separated resource names to skip")
versionFlag := flag.String("version", "", "Provider version for example block")
flag.Parse()
root := detectRoot()
resourcesDir := pickPath(*resourcesDirFlag, filepath.Join(root, "universal_rebuild", "resources_yaml"))
docsDir := pickPath(*docsDirFlag, filepath.Join(root, "docs", "30_registry", "resources"))
servicesList := pickPath(*servicesListFlag, filepath.Join(root, "devops", "config", "services_list.txt"))
excludeSet := toSet(*excludeFlag)
version := *versionFlag
if version == "" {
version = detectVersion(filepath.Join(root, "universal_rebuild", "main.go"))
}
servicesOrder := loadServicesList(servicesList)
specs := loadSpecs(resourcesDir, servicesOrder)
if len(specs) == 0 {
fmt.Fprintf(os.Stderr, "No specs found in %s\n", resourcesDir)
os.Exit(1)
}
if err := os.MkdirAll(docsDir, 0o755); err != nil {
panic(err)
}
for _, spec := range specs {
if excludeSet[spec.Name] {
continue
}
writeResourceDocs(docsDir, spec, version)
}
}
func detectRoot() string {
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
if filepath.Base(cwd) == "universal_rebuild" {
return filepath.Dir(cwd)
}
return cwd
}
func pickPath(value, fallback string) string {
if value != "" {
return value
}
return fallback
}
func toSet(csv string) map[string]bool {
out := map[string]bool{}
for _, item := range strings.Split(csv, ",") {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
out[trimmed] = true
}
return out
}
func detectVersion(mainPath string) string {
b, err := os.ReadFile(mainPath)
if err != nil {
return "2.x"
}
re := regexp.MustCompile(`version string\s*=\s*"([0-9.]+)"`)
match := re.FindStringSubmatch(string(b))
if len(match) > 1 {
return match[1]
}
return "2.x"
}
func loadServicesList(path string) []ServiceMeta {
b, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(b), "\n")
out := []ServiceMeta{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
head := strings.SplitN(line, "#", 2)[0]
parts := strings.Fields(head)
if len(parts) < 2 {
continue
}
id, err := strconv.Atoi(parts[0])
if err != nil {
continue
}
out = append(out, ServiceMeta{ID: id, Name: parts[1]})
}
return out
}
func loadSpecs(dir string, ordered []ServiceMeta) []ServiceSpec {
specsByID := map[int]ServiceSpec{}
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".yaml") {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
var spec ServiceSpec
if err := yaml.Unmarshal(b, &spec); err != nil {
return err
}
specsByID[spec.ServiceID] = spec
return nil
})
if walkErr != nil {
panic(walkErr)
}
if len(ordered) == 0 {
specs := make([]ServiceSpec, 0, len(specsByID))
for _, spec := range specsByID {
specs = append(specs, spec)
}
sort.Slice(specs, func(i, j int) bool { return specs[i].ServiceID < specs[j].ServiceID })
return specs
}
specs := []ServiceSpec{}
for _, meta := range ordered {
spec, ok := specsByID[meta.ID]
if !ok {
continue
}
specs = append(specs, spec)
}
return specs
}
func writeResourceDocs(docsDir string, spec ServiceSpec, version string) {
base := spec.Name
nav := fmt.Sprintf("[Manual](%s.md) | [Create params](%s_params_create.md) | [Modify params](%s_params_modify.md) | [Output params](%s_outputs.md) | [Operations](%s_ops.md) | [Example](%s_example.md)", base, base, base, base, base, base)
writeFile(filepath.Join(docsDir, base+".md"), buildManualPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_example.md"), buildExamplePage(spec, nav, version))
writeFile(filepath.Join(docsDir, base+"_params_create.md"), buildCreateParamsPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_params_modify.md"), buildModifyParamsPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_outputs.md"), buildOutputsPage(spec, nav))
writeFile(filepath.Join(docsDir, base+"_ops.md"), buildOpsPage(spec, nav, docsDir))
writeFile(filepath.Join(docsDir, base+"_params.md"), buildParamsLandingPage(spec, nav))
}
func writeFile(path, content string) {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
panic(err)
}
}
func buildHeader(spec ServiceSpec, nav string) string {
name := spec.ServiceDisplayName
if name == "" {
name = spec.Name
}
return fmt.Sprintf("# Resource nubes_%s\n\nService ID: `%d`\n\nService Name: %s\n\n%s\n\n", spec.Name, spec.ServiceID, name, nav)
}
func buildManualPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## MAN\n\n")
man := strings.TrimSpace(spec.ServiceMan)
if man == "" {
man = "Manual not available."
}
b.WriteString("<div class=\"man-content\">\n")
b.WriteString(man)
b.WriteString("\n</div>\n")
return b.String()
}
func buildExamplePage(spec ServiceSpec, nav, version string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString(fmt.Sprintf("## Copy-ready manifest (`%s_main.tf`)\n\n", spec.Name))
b.WriteString("<div class=\"copy-ready-code\"><pre><code>")
b.WriteString(exampleBlock(spec, version))
b.WriteString("</code></pre></div>\n\n")
b.WriteString("## Outputs usage\n\n")
b.WriteString("<div class=\"copy-ready-code\"><pre><code>")
b.WriteString("# Выходные параметры можно использовать как\n")
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_params_flat[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.state_out_flat[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_secrets[\"имя_ключа\"]\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_url\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_user_path\n", spec.Name))
b.WriteString(fmt.Sprintf("nubes_%s.baza.vault_fields\n", spec.Name))
b.WriteString("</code></pre></div>\n")
return b.String()
}
func exampleBlock(spec ServiceSpec, version string) string {
createParams := findParams(spec.Operations, "create")
requiredParams, defaultParams := splitParams(createParams)
var b strings.Builder
b.WriteString("terraform {\n")
b.WriteString(" required_providers {\n")
b.WriteString(" nubes = {\n")
b.WriteString(" source = \"terra.k8c.ru/nubes/nubes\"\n")
b.WriteString(fmt.Sprintf(" version = \"%s\"\n", version))
b.WriteString(" }\n")
b.WriteString(" }\n")
b.WriteString("}\n\n")
b.WriteString("# Токен доступа api_token к Nubes API — замените на реальный.\n")
b.WriteString("provider \"nubes\" {\n")
b.WriteString(" api_token = \"token_***\"\n")
b.WriteString(" api_endpoint = \"https://deck-api.ngcloud.ru/api/v1/index.cfm\"\n")
b.WriteString("}\n")
b.WriteString(fmt.Sprintf("resource \"nubes_%s\" \"baza\" {\n", spec.Name))
if len(requiredParams) > 0 {
b.WriteString(" # TODO - обязательный параметр, задаваемый пользователем\n")
}
for _, p := range requiredParams {
b.WriteString(formatParamLine(p, " "))
}
if len(defaultParams) > 0 {
b.WriteString("\n # Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте\n")
for _, p := range defaultParams {
b.WriteString(formatParamLine(p, " "))
}
}
b.WriteString("}\n")
return escapeCode(b.String())
}
func buildCreateParamsPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Create params\n\n")
createParams := findParams(spec.Operations, "create")
requiredParams, defaultParams := splitParams(createParams)
b.WriteString("**Обязательные параметры (вводимые пользователем)**\n\n")
b.WriteString(renderParamTable(requiredParams, true, true))
b.WriteString("\n**Параметры, имеющие значение по умолчанию, если не меняете - эти параметры не обязательно прописывать в манифесте**\n")
b.WriteString(renderParamTable(defaultParams, false, false))
lifecycle := spec.Lifecycle
if lifecycle.DeleteModeDefault != "" || lifecycle.ResumeIfExistsDefault {
b.WriteString("\n<div class=\"lifecycle-note\">\n")
b.WriteString("<strong>Параметры жизненного цикла (Lifecycle defaults)</strong><br/>\n")
b.WriteString(fmt.Sprintf("По умолчанию: `delete_mode_default = %s`, `resume_if_exists_default = %v`.<br/>\n", lifecycle.DeleteModeDefault, lifecycle.ResumeIfExistsDefault))
b.WriteString("Если для сервиса доступны операции `suspend` и `resume`, то `delete` выполняет приостановку (без удаления данных),<br/>\n")
b.WriteString("а `apply` при необходимости возобновляет работу существующего ресурса.<br/>\n")
b.WriteString("Менять эти значения стоит только в продвинутых сценариях — через параметры `delete_mode` и `resume_if_exists` в манифесте.\n")
b.WriteString("</div>\n")
}
return b.String()
}
func buildModifyParamsPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Modify params\n\n")
modifyParams := findParams(spec.Operations, "modify")
b.WriteString(renderModifyTable(modifyParams))
return b.String()
}
func buildOutputsPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Output params\n\n")
if len(spec.Outputs.Params) == 0 {
b.WriteString("None.\n")
return b.String()
}
for _, p := range spec.Outputs.Params {
b.WriteString(fmt.Sprintf("- `%s` (%s)\n", p.Code, p.Type))
}
return b.String()
}
func buildOpsPage(spec ServiceSpec, nav, docsDir string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Operations\n\n")
for _, op := range spec.Operations {
name := op.Name
if name == "" {
name = op.Action
}
descr := strings.TrimSpace(stripHTML(op.Man))
if descr != "" {
descr = ": " + descr
}
switch op.Action {
case "create":
b.WriteString(fmt.Sprintf("- `%s` — Создание кластера. Параметры: [%s params](%s_params_create.md)\n", name, "Create", spec.Name))
continue
case "modify":
b.WriteString(fmt.Sprintf("- `%s` — Изменение кластера. Параметры: [%s params](%s_params_modify.md)\n", name, "Modify", spec.Name))
continue
}
if op.Kind == "subresource" && op.Subresource != "" {
subFile := fmt.Sprintf("%s_%s.md", spec.Name, slug(op.Subresource))
if fileExists(filepath.Join(docsDir, subFile)) {
b.WriteString(fmt.Sprintf("- `%s` — %s. См. [nubes_%s_%s](%s)\n", name, strings.TrimPrefix(descr, ": "), spec.Name, slug(op.Subresource), subFile))
continue
}
}
if descr != "" {
b.WriteString(fmt.Sprintf("- `%s` — %s\n", name, strings.TrimPrefix(descr, ": ")))
} else {
b.WriteString(fmt.Sprintf("- `%s`\n", name))
}
}
return b.String()
}
func buildParamsLandingPage(spec ServiceSpec, nav string) string {
var b strings.Builder
b.WriteString(buildHeader(spec, nav))
b.WriteString("## Parameters\n\n")
b.WriteString(fmt.Sprintf("- [Create params](%s_params_create.md)\n", spec.Name))
b.WriteString(fmt.Sprintf("- [Modify params](%s_params_modify.md)\n", spec.Name))
return b.String()
}
func findParams(ops []OperationSpec, action string) []ParamSpec {
for _, op := range ops {
if op.Kind == "instance" && op.Action == action {
return op.Params
}
}
return nil
}
func splitParams(params []ParamSpec) ([]ParamSpec, []ParamSpec) {
required := []ParamSpec{}
defaults := []ParamSpec{}
for _, p := range params {
if hasDefault(p.Default) {
defaults = append(defaults, p)
continue
}
required = append(required, p)
}
return required, defaults
}
func hasDefault(value interface{}) bool {
if value == nil {
return false
}
if s, ok := value.(string); ok {
return strings.TrimSpace(s) != ""
}
return true
}
func renderParamTable(params []ParamSpec, requiredTable, noDefault bool) string {
if len(params) == 0 {
return "None.\n"
}
var b strings.Builder
tableClass := "resource-table resource-table-compact"
if requiredTable {
tableClass += " resource-table-required"
}
b.WriteString(fmt.Sprintf("<table class=\"%s\">\n", tableClass))
if noDefault {
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
} else {
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Default</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
}
for _, p := range params {
b.WriteString("<tr>")
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(p.ID))))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatCode(p.Code)))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(p)))
if !noDefault {
b.WriteString(fmt.Sprintf("<td>%s</td>", defaultCell(p.Default)))
}
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickText(p))))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(p))))
b.WriteString("</tr>\n")
}
b.WriteString("</tbody></table>\n")
return b.String()
}
func renderModifyTable(params []ParamSpec) string {
if len(params) == 0 {
return "None.\n"
}
var b strings.Builder
b.WriteString("<table class=\"resource-table resource-table-compact\">\n")
b.WriteString("<thead><tr><th>ID</th><th>Code</th><th>Type</th><th>Description</th><th>Constraints</th></tr></thead>\n<tbody>\n")
for _, p := range params {
b.WriteString("<tr>")
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(formatID(p.ID))))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatCode(p.Code)))
b.WriteString(fmt.Sprintf("<td>%s</td>", formatTypeCell(p)))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(pickText(p))))
b.WriteString(fmt.Sprintf("<td>%s</td>", escapeText(collectConstraints(p))))
b.WriteString("</tr>\n")
}
b.WriteString("</tbody></table>\n")
return b.String()
}
func formatParamLine(p ParamSpec, indent string) string {
value := sampleValue(p)
comment := strings.TrimSpace(stripHTML(p.Man))
if comment == "" {
comment = strings.TrimSpace(stripHTML(p.Descr))
}
if comment != "" {
return fmt.Sprintf("%s%s = %s # %s\n", indent, p.Code, value, comment)
}
return fmt.Sprintf("%s%s = %s\n", indent, p.Code, value)
}
func sampleValue(p ParamSpec) string {
if hasDefault(p.Default) {
return formatLiteral(p.Default)
}
if len(p.ValueList) > 0 {
return formatLiteral(p.ValueList[0])
}
dtype := strings.ToLower(firstType(p))
switch {
case strings.Contains(dtype, "bool"):
return "false"
case strings.Contains(dtype, "int") || strings.Contains(dtype, "number"):
return "1"
case strings.Contains(dtype, "json"):
return "{}"
}
return "\"TODO\""
}
func formatLiteral(value interface{}) string {
switch v := value.(type) {
case bool:
if v {
return "true"
}
return "false"
case int:
return strconv.Itoa(v)
case int64:
return strconv.FormatInt(v, 10)
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case string:
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
return v
}
if strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") {
return v
}
if looksLikeNumber(v) || v == "true" || v == "false" {
return v
}
return fmt.Sprintf("\"%s\"", v)
default:
return fmt.Sprintf("\"%v\"", v)
}
}
func looksLikeNumber(value string) bool {
if value == "" {
return false
}
if value[0] == '-' {
value = value[1:]
}
for _, ch := range value {
if ch == '.' {
continue
}
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func formatID(id int) string {
if id == 0 {
return ""
}
return strconv.Itoa(id)
}
func formatTypeCell(p ParamSpec) string {
dtype := firstType(p)
if dtype == "" {
return ""
}
return "<code>" + escapeText(dtype) + "</code>"
}
func firstType(p ParamSpec) string {
if p.DataType != "" {
return normalizeType(p.DataType)
}
return normalizeType(p.Type)
}
func normalizeType(dtype string) string {
cleaned := strings.ReplaceAll(dtype, "&gt;", ">")
if idx := strings.Index(cleaned, ">"); idx >= 0 {
cleaned = cleaned[:idx]
}
return strings.TrimSpace(cleaned)
}
func defaultCell(value interface{}) string {
if !hasDefault(value) {
return ""
}
return "<code>" + escapeText(formatLiteral(value)) + "</code>"
}
func pickText(p ParamSpec) string {
if p.Descr == "" && p.Man == "" {
return ""
}
if p.Man == "" {
return p.Descr
}
if p.Descr == "" {
return p.Man
}
if len(p.Man) > len(p.Descr) {
return p.Man
}
return p.Descr
}
func collectConstraints(p ParamSpec) string {
parts := []string{}
if p.MinValue != nil {
parts = append(parts, fmt.Sprintf("minvalue=%d", *p.MinValue))
}
if p.MaxValue != nil {
parts = append(parts, fmt.Sprintf("maxvalue=%d", *p.MaxValue))
}
if p.Regex != "" {
parts = append(parts, fmt.Sprintf("regex=%s", p.Regex))
}
if len(p.ValueList) > 0 {
parts = append(parts, fmt.Sprintf("value_list=%s", strings.Join(p.ValueList, ", ")))
}
if p.Func != "" {
parts = append(parts, fmt.Sprintf("func=%s", p.Func))
}
if p.RefSvcID != nil {
parts = append(parts, fmt.Sprintf("ref_svc_id=%d", *p.RefSvcID))
}
if p.Unique != "" {
parts = append(parts, fmt.Sprintf("unique_scope=%s", p.Unique))
}
if p.MaxLength != nil {
parts = append(parts, fmt.Sprintf("maxlength=%d", *p.MaxLength))
}
if p.MinLength != nil {
parts = append(parts, fmt.Sprintf("minlength=%d", *p.MinLength))
}
return strings.Join(parts, "; ")
}
func escapeText(value string) string {
if value == "" {
return ""
}
text := html.EscapeString(value)
text = strings.ReplaceAll(text, "&lt;br/&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br /&gt;", "<br/>")
text = strings.ReplaceAll(text, "&lt;br&gt;", "<br/>")
return text
}
func escapeCode(value string) string {
buf := bytes.NewBuffer(nil)
for _, line := range strings.Split(value, "\n") {
buf.WriteString(html.EscapeString(line))
buf.WriteString("\n")
}
return buf.String()
}
func formatCode(code string) string {
if code == "" {
return ""
}
if len(code) <= 40 {
return "<strong><code class=\"code-no-wrap\">" + code + "</code></strong>"
}
out := []string{}
prev := rune(0)
for _, ch := range code {
if ch == '_' {
out = append(out, "_<wbr>")
prev = ch
continue
}
if prev != 0 && (unicodeIsLower(prev) || unicodeIsDigit(prev)) && unicodeIsUpper(ch) {
out = append(out, "<wbr>")
}
out = append(out, string(ch))
prev = ch
}
return "<strong><code class=\"code-wrap\">" + strings.Join(out, "") + "</code></strong>"
}
func unicodeIsLower(r rune) bool { return r >= 'a' && r <= 'z' }
func unicodeIsUpper(r rune) bool { return r >= 'A' && r <= 'Z' }
func unicodeIsDigit(r rune) bool { return r >= '0' && r <= '9' }
func stripHTML(value string) string {
if value == "" {
return ""
}
re := regexp.MustCompile("<[^>]+>")
cleaned := re.ReplaceAllString(value, " ")
cleaned = strings.Join(strings.Fields(cleaned), " ")
return html.UnescapeString(cleaned)
}
func slug(value string) string {
out := []rune{}
for _, ch := range value {
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
out = append(out, ch)
} else if ch >= 'A' && ch <= 'Z' {
out = append(out, ch+'a'-'A')
} else {
out = append(out, '_')
}
}
return strings.Trim(string(out), "_")
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}