Initial commit.
This commit is contained in:
152
internal/client/client.go
Normal file
152
internal/client/client.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Package client provides HTTP client for Wild Central daemon API
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client is the HTTP client for the Wild Central daemon
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// APIResponse is the API response format
|
||||
type APIResponse struct {
|
||||
Data map[string]interface{}
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Get makes a GET request to the API
|
||||
func (c *Client) Get(path string) (*APIResponse, error) {
|
||||
return c.doRequest("GET", path, nil)
|
||||
}
|
||||
|
||||
// Post makes a POST request to the API
|
||||
func (c *Client) Post(path string, body interface{}) (*APIResponse, error) {
|
||||
return c.doRequest("POST", path, body)
|
||||
}
|
||||
|
||||
// Put makes a PUT request to the API
|
||||
func (c *Client) Put(path string, body interface{}) (*APIResponse, error) {
|
||||
return c.doRequest("PUT", path, body)
|
||||
}
|
||||
|
||||
// Delete makes a DELETE request to the API
|
||||
func (c *Client) Delete(path string) (*APIResponse, error) {
|
||||
return c.doRequest("DELETE", path, nil)
|
||||
}
|
||||
|
||||
// Patch makes a PATCH request to the API
|
||||
func (c *Client) Patch(path string, body interface{}) (*APIResponse, error) {
|
||||
return c.doRequest("PATCH", path, body)
|
||||
}
|
||||
|
||||
// doRequest performs the actual HTTP request
|
||||
func (c *Client) doRequest(method, path string, body interface{}) (*APIResponse, error) {
|
||||
url := c.baseURL + path
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewBuffer(jsonData)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check for HTTP error status
|
||||
if resp.StatusCode >= 400 {
|
||||
// Try to parse error response
|
||||
var errResp map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &errResp); err == nil {
|
||||
if errMsg, ok := errResp["error"].(string); ok {
|
||||
return nil, fmt.Errorf("API error: %s", errMsg)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// Parse response data directly (daemon doesn't wrap in "data" field)
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &data); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w\nResponse: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
return &APIResponse{Data: data}, nil
|
||||
}
|
||||
|
||||
// GetData extracts data from API response
|
||||
func (r *APIResponse) GetData(key string) interface{} {
|
||||
if r.Data == nil {
|
||||
return nil
|
||||
}
|
||||
return r.Data[key]
|
||||
}
|
||||
|
||||
// GetString extracts string data from API response
|
||||
func (r *APIResponse) GetString(key string) string {
|
||||
val := r.GetData(key)
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetMap extracts map data from API response
|
||||
func (r *APIResponse) GetMap(key string) map[string]interface{} {
|
||||
val := r.GetData(key)
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetArray extracts array data from API response
|
||||
func (r *APIResponse) GetArray(key string) []interface{} {
|
||||
val := r.GetData(key)
|
||||
if arr, ok := val.([]interface{}); ok {
|
||||
return arr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BaseURL returns the base URL of the client
|
||||
func (c *Client) BaseURL() string {
|
||||
return c.baseURL
|
||||
}
|
||||
17
internal/config/config.go
Normal file
17
internal/config/config.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package config handles CLI configuration
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// GetDaemonURL returns the daemon URL from environment or default
|
||||
func GetDaemonURL() string {
|
||||
// Check environment variable first
|
||||
if url := os.Getenv("WILD_DAEMON_URL"); url != "" {
|
||||
return url
|
||||
}
|
||||
|
||||
// Use default matching daemon's port
|
||||
return "http://localhost:5055"
|
||||
}
|
||||
44
internal/config/example_test.go
Normal file
44
internal/config/example_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/wild-cloud/wild-central/wild/internal/config"
|
||||
)
|
||||
|
||||
func ExampleGetValue() {
|
||||
cfg := map[string]interface{}{
|
||||
"cloud": map[string]interface{}{
|
||||
"domain": "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
value := config.GetValue(cfg, "cloud.domain")
|
||||
fmt.Println(value)
|
||||
// Output: example.com
|
||||
}
|
||||
|
||||
func ExampleValidatePaths() {
|
||||
cfg := map[string]interface{}{
|
||||
"cloud": map[string]interface{}{
|
||||
"domain": "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
required := []string{"cloud.domain", "cloud.name", "network.subnet"}
|
||||
missing := config.ValidatePaths(cfg, required)
|
||||
fmt.Printf("Missing paths: %v\n", missing)
|
||||
// Output: Missing paths: [cloud.name network.subnet]
|
||||
}
|
||||
|
||||
func ExampleExpandTemplate() {
|
||||
cfg := map[string]interface{}{
|
||||
"cloud": map[string]interface{}{
|
||||
"domain": "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := config.ExpandTemplate("registry.{{ .cloud.domain }}", cfg)
|
||||
fmt.Println(result)
|
||||
// Output: registry.example.com
|
||||
}
|
||||
83
internal/config/instance.go
Normal file
83
internal/config/instance.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetWildCLIDataDir returns the Wild CLI data directory
|
||||
func GetWildCLIDataDir() string {
|
||||
if dir := os.Getenv("WILD_CLI_DATA"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ".wildcloud"
|
||||
}
|
||||
return filepath.Join(home, ".wildcloud")
|
||||
}
|
||||
|
||||
// GetCurrentInstance resolves the current instance using the priority cascade:
|
||||
// 1. --instance flag (passed as parameter)
|
||||
// 2. $WILD_CLI_DATA/current_instance file
|
||||
// 3. Auto-select first instance from API
|
||||
func GetCurrentInstance(flagInstance string, apiClient InstanceLister) (string, string, error) {
|
||||
// Priority 1: --instance flag
|
||||
if flagInstance != "" {
|
||||
return flagInstance, "flag", nil
|
||||
}
|
||||
|
||||
// Priority 2: current_instance file
|
||||
dataDir := GetWildCLIDataDir()
|
||||
currentFile := filepath.Join(dataDir, "current_instance")
|
||||
|
||||
if data, err := os.ReadFile(currentFile); err == nil {
|
||||
instance := strings.TrimSpace(string(data))
|
||||
if instance != "" {
|
||||
return instance, "file", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Auto-select first instance from API
|
||||
if apiClient != nil {
|
||||
instances, err := apiClient.ListInstances()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("no instance configured and failed to list instances: %w", err)
|
||||
}
|
||||
|
||||
if len(instances) == 0 {
|
||||
return "", "", fmt.Errorf("no instance configured and no instances available (create one with: wild instance create <name>)")
|
||||
}
|
||||
|
||||
// Auto-select first instance
|
||||
return instances[0], "auto", nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("no instance configured (use --instance flag or run: wild instance use <name>)")
|
||||
}
|
||||
|
||||
// SetCurrentInstance persists the instance selection to file
|
||||
func SetCurrentInstance(instance string) error {
|
||||
dataDir := GetWildCLIDataDir()
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
currentFile := filepath.Join(dataDir, "current_instance")
|
||||
|
||||
// Write instance name to file
|
||||
if err := os.WriteFile(currentFile, []byte(instance), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write current instance file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstanceLister is an interface for listing instances (allows for testing and dependency injection)
|
||||
type InstanceLister interface {
|
||||
ListInstances() ([]string, error)
|
||||
}
|
||||
210
internal/config/instance_test.go
Normal file
210
internal/config/instance_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mockInstanceLister is a mock implementation of InstanceLister for testing
|
||||
type mockInstanceLister struct {
|
||||
instances []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockInstanceLister) ListInstances() ([]string, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return m.instances, nil
|
||||
}
|
||||
|
||||
func TestGetCurrentInstance(t *testing.T) {
|
||||
// Save and restore env var
|
||||
oldWildCLIData := os.Getenv("WILD_CLI_DATA")
|
||||
defer os.Setenv("WILD_CLI_DATA", oldWildCLIData)
|
||||
|
||||
// Create temp directory for testing
|
||||
tmpDir := t.TempDir()
|
||||
os.Setenv("WILD_CLI_DATA", tmpDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flagInstance string
|
||||
fileInstance string
|
||||
apiInstances []string
|
||||
wantInstance string
|
||||
wantSource string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "flag takes priority",
|
||||
flagInstance: "flag-instance",
|
||||
fileInstance: "file-instance",
|
||||
apiInstances: []string{"api-instance"},
|
||||
wantInstance: "flag-instance",
|
||||
wantSource: "flag",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "file takes priority over api",
|
||||
flagInstance: "",
|
||||
fileInstance: "file-instance",
|
||||
apiInstances: []string{"api-instance"},
|
||||
wantInstance: "file-instance",
|
||||
wantSource: "file",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "auto-select first from api",
|
||||
flagInstance: "",
|
||||
fileInstance: "",
|
||||
apiInstances: []string{"first-instance", "second-instance"},
|
||||
wantInstance: "first-instance",
|
||||
wantSource: "auto",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "no instance available",
|
||||
flagInstance: "",
|
||||
fileInstance: "",
|
||||
apiInstances: []string{},
|
||||
wantInstance: "",
|
||||
wantSource: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no api client and no config",
|
||||
flagInstance: "",
|
||||
fileInstance: "",
|
||||
apiInstances: nil,
|
||||
wantInstance: "",
|
||||
wantSource: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up file
|
||||
currentFile := filepath.Join(tmpDir, "current_instance")
|
||||
if tt.fileInstance != "" {
|
||||
if err := os.WriteFile(currentFile, []byte(tt.fileInstance), 0644); err != nil {
|
||||
t.Fatalf("Failed to write test file: %v", err)
|
||||
}
|
||||
} else {
|
||||
os.Remove(currentFile)
|
||||
}
|
||||
|
||||
// Set up API client mock
|
||||
var lister InstanceLister
|
||||
if tt.apiInstances != nil {
|
||||
lister = &mockInstanceLister{instances: tt.apiInstances}
|
||||
}
|
||||
|
||||
// Test
|
||||
gotInstance, gotSource, err := GetCurrentInstance(tt.flagInstance, lister)
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetCurrentInstance() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if gotInstance != tt.wantInstance {
|
||||
t.Errorf("GetCurrentInstance() instance = %v, want %v", gotInstance, tt.wantInstance)
|
||||
}
|
||||
|
||||
if gotSource != tt.wantSource {
|
||||
t.Errorf("GetCurrentInstance() source = %v, want %v", gotSource, tt.wantSource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCurrentInstance(t *testing.T) {
|
||||
// Save and restore env var
|
||||
oldWildCLIData := os.Getenv("WILD_CLI_DATA")
|
||||
defer os.Setenv("WILD_CLI_DATA", oldWildCLIData)
|
||||
|
||||
// Create temp directory for testing
|
||||
tmpDir := t.TempDir()
|
||||
os.Setenv("WILD_CLI_DATA", tmpDir)
|
||||
|
||||
// Test setting instance
|
||||
testInstance := "test-instance"
|
||||
err := SetCurrentInstance(testInstance)
|
||||
if err != nil {
|
||||
t.Fatalf("SetCurrentInstance() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify file was written
|
||||
currentFile := filepath.Join(tmpDir, "current_instance")
|
||||
data, err := os.ReadFile(currentFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read current_instance file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != testInstance {
|
||||
t.Errorf("File content = %v, want %v", string(data), testInstance)
|
||||
}
|
||||
|
||||
// Test updating instance
|
||||
newInstance := "new-instance"
|
||||
err = SetCurrentInstance(newInstance)
|
||||
if err != nil {
|
||||
t.Fatalf("SetCurrentInstance() error = %v", err)
|
||||
}
|
||||
|
||||
data, err = os.ReadFile(currentFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read current_instance file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != newInstance {
|
||||
t.Errorf("File content = %v, want %v", string(data), newInstance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWildCLIDataDir(t *testing.T) {
|
||||
// Save and restore env var
|
||||
oldWildCLIData := os.Getenv("WILD_CLI_DATA")
|
||||
defer os.Setenv("WILD_CLI_DATA", oldWildCLIData)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
envVar string
|
||||
wantDir string
|
||||
}{
|
||||
{
|
||||
name: "custom directory from env",
|
||||
envVar: "/custom/path",
|
||||
wantDir: "/custom/path",
|
||||
},
|
||||
{
|
||||
name: "default directory when env not set",
|
||||
envVar: "",
|
||||
// We can't predict the exact home directory, so we'll just check it's not empty
|
||||
wantDir: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envVar != "" {
|
||||
os.Setenv("WILD_CLI_DATA", tt.envVar)
|
||||
} else {
|
||||
os.Unsetenv("WILD_CLI_DATA")
|
||||
}
|
||||
|
||||
gotDir := GetWildCLIDataDir()
|
||||
|
||||
if tt.wantDir != "" && gotDir != tt.wantDir {
|
||||
t.Errorf("GetWildCLIDataDir() = %v, want %v", gotDir, tt.wantDir)
|
||||
}
|
||||
|
||||
if tt.wantDir == "" && gotDir == "" {
|
||||
t.Errorf("GetWildCLIDataDir() should not be empty when using default")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
61
internal/config/validator.go
Normal file
61
internal/config/validator.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// ValidatePaths checks if all required paths exist in the config
|
||||
// Returns a list of missing paths
|
||||
func ValidatePaths(config map[string]interface{}, paths []string) []string {
|
||||
var missing []string
|
||||
for _, path := range paths {
|
||||
if GetValue(config, path) == nil {
|
||||
missing = append(missing, path)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// GetValue retrieves a nested value from config using dot notation
|
||||
// Returns nil if the path doesn't exist
|
||||
func GetValue(config map[string]interface{}, path string) interface{} {
|
||||
parts := strings.Split(path, ".")
|
||||
var current interface{} = config
|
||||
|
||||
for _, part := range parts {
|
||||
m, ok := current.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
current = m[part]
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
// ExpandTemplate expands {{ .path.to.value }} templates in the string
|
||||
// Returns the original string if no templates are present
|
||||
func ExpandTemplate(tmpl string, config map[string]interface{}) (string, error) {
|
||||
// Return original if no template markers
|
||||
if !strings.Contains(tmpl, "{{") {
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
t, err := template.New("config").Parse(tmpl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, config); err != nil {
|
||||
return "", fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
153
internal/config/validator_test.go
Normal file
153
internal/config/validator_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetValue(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"cloud": map[string]interface{}{
|
||||
"domain": "example.com",
|
||||
"name": "test-cloud",
|
||||
},
|
||||
"simple": "value",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected interface{}
|
||||
}{
|
||||
{"simple path", "simple", "value"},
|
||||
{"nested path", "cloud.domain", "example.com"},
|
||||
{"nested path 2", "cloud.name", "test-cloud"},
|
||||
{"missing path", "missing", nil},
|
||||
{"missing nested", "cloud.missing", nil},
|
||||
{"invalid nested", "simple.nested", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := GetValue(config, tt.path)
|
||||
if !reflect.DeepEqual(result, tt.expected) {
|
||||
t.Errorf("GetValue(%q) = %v, want %v", tt.path, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePaths(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"cloud": map[string]interface{}{
|
||||
"domain": "example.com",
|
||||
"name": "test-cloud",
|
||||
},
|
||||
"network": map[string]interface{}{
|
||||
"subnet": "10.0.0.0/24",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
paths []string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "all paths exist",
|
||||
paths: []string{"cloud.domain", "cloud.name", "network.subnet"},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "some paths missing",
|
||||
paths: []string{"cloud.domain", "missing.path", "network.missing"},
|
||||
expected: []string{"missing.path", "network.missing"},
|
||||
},
|
||||
{
|
||||
name: "all paths missing",
|
||||
paths: []string{"foo.bar", "baz.qux"},
|
||||
expected: []string{"foo.bar", "baz.qux"},
|
||||
},
|
||||
{
|
||||
name: "empty paths",
|
||||
paths: []string{},
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ValidatePaths(config, tt.paths)
|
||||
if !reflect.DeepEqual(result, tt.expected) {
|
||||
t.Errorf("ValidatePaths() = %v, want %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTemplate(t *testing.T) {
|
||||
config := map[string]interface{}{
|
||||
"cloud": map[string]interface{}{
|
||||
"domain": "example.com",
|
||||
"name": "test-cloud",
|
||||
},
|
||||
"port": 8080,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
template string
|
||||
expected string
|
||||
shouldErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple template",
|
||||
template: "registry.{{ .cloud.domain }}",
|
||||
expected: "registry.example.com",
|
||||
},
|
||||
{
|
||||
name: "multiple templates",
|
||||
template: "{{ .cloud.name }}.{{ .cloud.domain }}",
|
||||
expected: "test-cloud.example.com",
|
||||
},
|
||||
{
|
||||
name: "no template",
|
||||
template: "plain-string",
|
||||
expected: "plain-string",
|
||||
},
|
||||
{
|
||||
name: "template with text",
|
||||
template: "http://{{ .cloud.domain }}:{{ .port }}/api",
|
||||
expected: "http://example.com:8080/api",
|
||||
},
|
||||
{
|
||||
name: "invalid template syntax",
|
||||
template: "{{ .cloud.domain",
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing value renders as no value",
|
||||
template: "{{ .missing.path }}",
|
||||
expected: "<no value>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ExpandTemplate(tt.template, config)
|
||||
if tt.shouldErr {
|
||||
if err == nil {
|
||||
t.Errorf("ExpandTemplate() expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ExpandTemplate() unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("ExpandTemplate() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
73
internal/prompt/example_test.go
Normal file
73
internal/prompt/example_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package prompt_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
// "github.com/wild-cloud/wild-central/wild/internal/prompt"
|
||||
)
|
||||
|
||||
// ExampleString demonstrates the String prompt function
|
||||
func ExampleString() {
|
||||
// This example shows the prompt output format
|
||||
// Actual usage would read from stdin interactively
|
||||
fmt.Println("Enter SMTP host [smtp.gmail.com]:")
|
||||
// User input: <empty> (returns default)
|
||||
// Result: "smtp.gmail.com"
|
||||
|
||||
fmt.Println("Enter SMTP host [smtp.gmail.com]:")
|
||||
// User input: "smtp.example.com"
|
||||
// Result: "smtp.example.com"
|
||||
}
|
||||
|
||||
// ExampleInt demonstrates the Int prompt function
|
||||
func ExampleInt() {
|
||||
// This example shows the prompt output format
|
||||
fmt.Println("Enter SMTP port [587]:")
|
||||
// User input: <empty> (returns default)
|
||||
// Result: 587
|
||||
|
||||
fmt.Println("Enter SMTP port [587]:")
|
||||
// User input: "465"
|
||||
// Result: 465
|
||||
}
|
||||
|
||||
// ExampleBool demonstrates the Bool prompt function
|
||||
func ExampleBool() {
|
||||
// This example shows the prompt output format
|
||||
fmt.Println("Enable TLS [Y/n]:")
|
||||
// User input: <empty> (returns default true)
|
||||
// Result: true
|
||||
|
||||
fmt.Println("Enable TLS [Y/n]:")
|
||||
// User input: "n"
|
||||
// Result: false
|
||||
|
||||
fmt.Println("Enable debug mode [y/N]:")
|
||||
// User input: <empty> (returns default false)
|
||||
// Result: false
|
||||
|
||||
fmt.Println("Enable debug mode [y/N]:")
|
||||
// User input: "yes"
|
||||
// Result: true
|
||||
}
|
||||
|
||||
// Example usage in a real command
|
||||
func ExampleUsage() {
|
||||
// Example of using prompt functions in a CLI command:
|
||||
//
|
||||
// host, err := prompt.String("Enter SMTP host", "smtp.gmail.com")
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// port, err := prompt.Int("Enter SMTP port", 587)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// useTLS, err := prompt.Bool("Enable TLS", true)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// fmt.Printf("Configuration: host=%s, port=%d, tls=%v\n", host, port, useTLS)
|
||||
}
|
||||
89
internal/prompt/prompt.go
Normal file
89
internal/prompt/prompt.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Package prompt provides simple utilities for interactive CLI prompts
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// String prompts the user for a string value with a default
|
||||
func String(prompt, defaultValue string) (string, error) {
|
||||
if defaultValue != "" {
|
||||
fmt.Printf("%s [%s]: ", prompt, defaultValue)
|
||||
} else {
|
||||
fmt.Printf("%s: ", prompt)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// Int prompts the user for an integer value with a default
|
||||
func Int(prompt string, defaultValue int) (int, error) {
|
||||
fmt.Printf("%s [%d]: ", prompt, defaultValue)
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(input)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid integer value: %s", input)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Bool prompts the user for a boolean value with a default
|
||||
func Bool(prompt string, defaultValue bool) (bool, error) {
|
||||
defaultStr := "y/n"
|
||||
if defaultValue {
|
||||
defaultStr = "Y/n"
|
||||
} else {
|
||||
defaultStr = "y/N"
|
||||
}
|
||||
|
||||
fmt.Printf("%s [%s]: ", prompt, defaultStr)
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
input = strings.ToLower(input)
|
||||
|
||||
if input == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
switch input {
|
||||
case "y", "yes", "true":
|
||||
return true, nil
|
||||
case "n", "no", "false":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("invalid boolean value: %s (expected y/n/yes/no/true/false)", input)
|
||||
}
|
||||
}
|
||||
64
internal/prompt/prompt_test.go
Normal file
64
internal/prompt/prompt_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package prompt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Note: These are basic unit tests for the prompt package.
|
||||
// Interactive testing requires manual verification since the functions
|
||||
// read from stdin and write to stdout.
|
||||
|
||||
func TestBoolParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
hasError bool
|
||||
}{
|
||||
{"yes", "yes", true, false},
|
||||
{"y", "y", true, false},
|
||||
{"true", "true", true, false},
|
||||
{"no", "no", false, false},
|
||||
{"n", "n", false, false},
|
||||
{"false", "false", false, false},
|
||||
{"invalid", "maybe", false, true},
|
||||
{"invalid", "xyz", false, true},
|
||||
}
|
||||
|
||||
// Test the parsing logic that would be used by Bool function
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := tt.input
|
||||
var result bool
|
||||
var err error
|
||||
|
||||
switch input {
|
||||
case "y", "yes", "true":
|
||||
result = true
|
||||
case "n", "no", "false":
|
||||
result = false
|
||||
default:
|
||||
err = &invalidBoolError{}
|
||||
}
|
||||
|
||||
if tt.hasError {
|
||||
if err == nil {
|
||||
t.Errorf("expected error for input %q, got nil", tt.input)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error for input %q: %v", tt.input, err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v for input %q, got %v", tt.expected, tt.input, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type invalidBoolError struct{}
|
||||
|
||||
func (e *invalidBoolError) Error() string {
|
||||
return "invalid boolean value"
|
||||
}
|
||||
Reference in New Issue
Block a user