Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate a Markdown Table from a Module Configuration #318

Merged
merged 7 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/timoni/mod_show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
Copyright 2023 Stefan Prodan

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package main

import (
"github.com/spf13/cobra"
)

var showModCmd = &cobra.Command{
Use: "show",
Short: "Commands for showing module information",
}

func init() {
modCmd.AddCommand(showModCmd)
}
246 changes: 246 additions & 0 deletions cmd/timoni/mod_show_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/*
Copyright 2023 Stefan Prodan

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package main

import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"

"cuelang.org/go/cue/cuecontext"
"github.com/olekukonko/tablewriter"
"github.com/spf13/cobra"
apiv1 "github.com/stefanprodan/timoni/api/v1alpha1"
"github.com/stefanprodan/timoni/internal/engine"
"github.com/stefanprodan/timoni/internal/flags"
)

var configShowModCmd = &cobra.Command{
Use: "config [MODULE PATH]",
Short: "Output the #Config structure of a local module",
Long: `The config command parses the local module configuration structure and outputs the information to stdout.`,
Example: ` # print the config of a module in the current directory
timoni mod show config

# output the config to a file, if the file is markdown, the table will overwrite a table in a Configuration section or
# be appended to the end of the file
timoni mod show config --output ./README.md
`,
RunE: runConfigShowModCmd,
}

type configModFlags struct {
path string
pkg flags.Package
name string
output string
}

var configShowModArgs = configModFlags{
name: "module-name",
}

func init() {
configShowModCmd.Flags().StringVarP(&configShowModArgs.output, "output", "o", "", "The file to output the config Markdown to, defaults to stdout")
showModCmd.AddCommand(configShowModCmd)
}

func runConfigShowModCmd(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
configShowModArgs.path = "."
} else {
configShowModArgs.path = args[0]
}

if fs, err := os.Stat(configShowModArgs.path); err != nil || !fs.IsDir() {
return fmt.Errorf("module not found at path %s", configShowModArgs.path)
}

cuectx := cuecontext.New()

tmpDir, err := os.MkdirTemp("", apiv1.FieldManager)
if err != nil {
return err
}
defer os.RemoveAll(tmpDir)

ctxPull, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
defer cancel()

fetcher := engine.NewFetcher(
ctxPull,
configShowModArgs.path,
apiv1.LatestVersion,
tmpDir,
rootArgs.cacheDir,
"",
rootArgs.registryInsecure,
)
mod, err := fetcher.Fetch()
if err != nil {
return err
}

builder := engine.NewModuleBuilder(
cuectx,
configShowModArgs.name,
*kubeconfigArgs.Namespace,
fetcher.GetModuleRoot(),
configShowModArgs.pkg.String(),
)

if err := builder.WriteSchemaFile(); err != nil {
return err
}

mod.Name, err = builder.GetModuleName()
if err != nil {
return fmt.Errorf("build failed: %w", err)
}

buildResult, err := builder.Build()
if err != nil {
return describeErr(fetcher.GetModuleRoot(), "validation failed", err)
}

rows, err := builder.GetConfigStructure(buildResult)
if err != nil {
return describeErr(fetcher.GetModuleRoot(), "failed to get config structure", err)
}

header := []string{"Key", "Type", "Default", "Description"}

if configShowModArgs.output == "" {
printMarkDownTable(rootCmd.OutOrStdout(), header, rows)
} else {
tmpFile, err := writeFile(configShowModArgs.output, header, rows, fetcher)
if err != nil {
return err
}

err = os.Rename(tmpFile, configShowModArgs.output)
if err != nil {
return describeErr(fetcher.GetModuleRoot(), "Unable to rename file", err)
}
}

return nil
}

func writeFile(readFile string, header []string, rows [][]string, fetcher *engine.Fetcher) (string, error) {
// Generate the markdown table
var tableBuffer bytes.Buffer
tableWriter := bufio.NewWriter(&tableBuffer)
printMarkDownTable(tableWriter, header, rows)
tableWriter.Flush()
// get a temporary file name
tmpFileName := readFile + ".tmp"
// open the input file
inputFile, err := os.Open(readFile)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
inputFile, err = os.Create(readFile)

if err != nil {
return "", describeErr(fetcher.GetModuleRoot(), "Unable to create the temporary output file", err)
}
} else {
return "", describeErr(fetcher.GetModuleRoot(), "Unable to create the temporary output file", err)
}
}
defer inputFile.Close()

// open the output file
outputFile, err := os.Create(tmpFileName)
if err != nil {
return "", describeErr(fetcher.GetModuleRoot(), "Unable to create the temporary output file", err)
}
defer outputFile.Close()

// Create the scanner and writer
inputScanner := bufio.NewScanner(inputFile)
outputWriter := bufio.NewWriter(outputFile)
var configSection bool
var foundTable bool

// Scan the input file line by line to find the table and replace it or append it to the end
for inputScanner.Scan() {
line := inputScanner.Text()

if isMarkdownFile(readFile) {
if !configSection && line == "## Configuration" {
configSection = true
}

matched, err := regexp.MatchString(`^\|.*\|$`, line)
if err != nil {
return "", describeErr(fetcher.GetModuleRoot(), "Regex Match for table content failed", err)
}

if configSection && !foundTable && matched {
foundTable = true
outputWriter.WriteString(tableBuffer.String() + "\n")
} else if configSection && foundTable && matched {
} else if configSection && foundTable && !matched {
configSection = false
} else {
outputWriter.WriteString(line + "\n")
}
} else {
outputWriter.WriteString(line + "\n")
}
}

// If no table was found, append it to the end of the file
if !foundTable {
outputWriter.WriteString("\n" + tableBuffer.String())
}

outputWriter.Flush()
return tmpFileName, nil
}

func printMarkDownTable(writer io.Writer, header []string, rows [][]string) {
table := tablewriter.NewWriter(writer)
table.SetHeader(header)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("|")
table.SetColumnSeparator("|")
table.SetRowSeparator("-")
table.SetHeaderLine(true)
table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
table.SetTablePadding("\t")
table.SetNoWhiteSpace(false)
table.AppendBulk(rows)
table.Render()
}

func isMarkdownFile(filename string) bool {
extension := strings.ToLower(filepath.Ext(filename))
return extension == ".md" || extension == ".markdown"
}
68 changes: 68 additions & 0 deletions cmd/timoni/mod_show_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
Copyright 2023 Stefan Prodan

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package main

import (
"fmt"
"os"
"testing"

. "github.com/onsi/gomega"
)

func Test_ShowConfig(t *testing.T) {
modPath := "testdata/module"

g := NewWithT(t)

// Push the module to registry
output, err := executeCommand(fmt.Sprintf(
"mod show config %s",
modPath,
))
g.Expect(err).ToNot(HaveOccurred())

g.Expect(output).To(ContainSubstring("`client: enabled:`"))
g.Expect(output).To(ContainSubstring("`client: image: repository:`"))
g.Expect(output).To(ContainSubstring("`server: enabled:`"))
}

func Test_ShowConfigOutput(t *testing.T) {
modPath := "testdata/module"

g := NewWithT(t)

// Push the module to registry
_, err := executeCommand(fmt.Sprintf(
"mod show config %s --output %s/README.md",
modPath,
modPath,
))
g.Expect(err).ToNot(HaveOccurred())

rmFile, err := os.ReadFile(fmt.Sprintf("%s/README.md", modPath))
g.Expect(err).ToNot(HaveOccurred())

strContent := string(rmFile)

g.Expect(strContent).To(ContainSubstring("`client: enabled:`"))
g.Expect(strContent).To(ContainSubstring("`client: image: repository:`"))
g.Expect(strContent).To(ContainSubstring("`server: enabled:`"))

err = os.Remove(fmt.Sprintf("%s/README.md", modPath))
g.Expect(err).ToNot(HaveOccurred())
}
41 changes: 41 additions & 0 deletions internal/engine/module_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"path/filepath"
"reflect"
"slices"
"strings"

"cuelang.org/go/cue"
"cuelang.org/go/cue/ast"
Expand Down Expand Up @@ -305,3 +306,43 @@ func (b *ModuleBuilder) GetContainerImages(value cue.Value) ([]string, error) {

return images, nil
}

// GetConfigStructure extracts the config structure from the module.
func (b *ModuleBuilder) GetConfigStructure(value cue.Value) ([][]string, error) {
cfgValues := value.LookupPath(cue.ParsePath(apiv1.ConfigValuesSelector.String()))
if cfgValues.Err() != nil {
return nil, fmt.Errorf("lookup %s failed: %w", apiv1.ConfigValuesSelector, cfgValues.Err())
}

var rows [][]string
configDataInfo := func(v cue.Value) bool {
var row []string

if v.Kind().String() != "struct" && !v.IsConcrete() {
defaultVal, _ := v.Default()
valueBytes, _ := defaultVal.MarshalJSON()
valueType := strings.ReplaceAll(v.IncompleteKind().String(), "|", "\\|")
value := strings.ReplaceAll(string(valueBytes), "\":", "\": ")
value = strings.ReplaceAll(value, "\":[", "\": [")
value = strings.ReplaceAll(value, "},", "}, ")
value = strings.ReplaceAll(value, "|", "\\|")

row = append(row, fmt.Sprintf("`%s:`", strings.ReplaceAll(strings.Replace(v.Path().String(), "timoni.instance.config.", "", 1), ".", ": ")))
row = append(row, fmt.Sprintf("`%s`", valueType))
row = append(row, fmt.Sprintf("`%s`", value))

var doc string
for _, d := range v.Doc() {
doc += strings.ReplaceAll(strings.ReplaceAll(d.Text(), "+optional", ""), "+required", "")
}

row = append(row, fmt.Sprintf("%s", strings.ReplaceAll(doc, "\n", " ")))
rows = append(rows, row)
}
Nalum marked this conversation as resolved.
Show resolved Hide resolved

return true
}

cfgValues.Walk(configDataInfo, nil)
return rows, nil
}