diff --git a/backup/cmd/backup.go b/backup/cmd/backup.go index d27de13..0803363 100644 --- a/backup/cmd/backup.go +++ b/backup/cmd/backup.go @@ -52,7 +52,7 @@ var runCmd = &cobra.Command{ Use: "run", Short: "Execute the sync jobs", Run: func(cmd *cobra.Command, args []string) { - cfg := loadResolvedConfig(configPath) + cfg := internal.LoadResolvedConfig(configPath) executeSyncJobs(cfg, false) }, } @@ -61,7 +61,7 @@ var simulateCmd = &cobra.Command{ Use: "simulate", Short: "Simulate the sync jobs", Run: func(cmd *cobra.Command, args []string) { - cfg := loadResolvedConfig(configPath) + cfg := internal.LoadResolvedConfig(configPath) executeSyncJobs(cfg, true) }, } @@ -70,7 +70,7 @@ var listCmd = &cobra.Command{ Use: "list", Short: "List the commands that will be executed", Run: func(cmd *cobra.Command, args []string) { - cfg := loadResolvedConfig(configPath) + cfg := internal.LoadResolvedConfig(configPath) listCommands(cfg) }, } diff --git a/backup/cmd/check.go b/backup/cmd/check.go index b4c9265..54f9071 100644 --- a/backup/cmd/check.go +++ b/backup/cmd/check.go @@ -15,7 +15,7 @@ var checkCmd = &cobra.Command{ Use: "check-coverage", Short: "Check path coverage", Run: func(cmd *cobra.Command, args []string) { - cfg := loadResolvedConfig(configPath) + cfg := internal.LoadResolvedConfig(configPath) uncoveredPaths := internal.ListUncoveredPaths(AppFs, cfg) fmt.Println("Uncovered paths:") for _, path := range uncoveredPaths { diff --git a/backup/cmd/config.go b/backup/cmd/config.go index 72f3578..bf4f31b 100644 --- a/backup/cmd/config.go +++ b/backup/cmd/config.go @@ -1,158 +1,14 @@ package cmd import ( + "backup-rsync/backup/internal" "fmt" - "io" "log" - "os" - "strings" - - "path/filepath" - - "backup-rsync/backup/internal" "github.com/spf13/cobra" "gopkg.in/yaml.v3" ) -func loadConfig(reader io.Reader) (internal.Config, error) { - var cfg internal.Config - if err := yaml.NewDecoder(reader).Decode(&cfg); err != nil { - return internal.Config{}, err - } - return cfg, nil -} - -func substituteVariables(input string, variables map[string]string) string { - for key, value := range variables { - placeholder := fmt.Sprintf("${%s}", key) - input = strings.ReplaceAll(input, placeholder, value) - } - return input -} - -func resolveConfig(cfg internal.Config) internal.Config { - resolvedCfg := cfg - for i, job := range resolvedCfg.Jobs { - resolvedCfg.Jobs[i].Source = substituteVariables(job.Source, cfg.Variables) - resolvedCfg.Jobs[i].Target = substituteVariables(job.Target, cfg.Variables) - } - return resolvedCfg -} - -func validateJobNames(jobs []internal.Job) error { - invalidNames := []string{} - nameSet := make(map[string]bool) - - for _, job := range jobs { - if nameSet[job.Name] { - invalidNames = append(invalidNames, fmt.Sprintf("duplicate job name: %s", job.Name)) - } else { - nameSet[job.Name] = true - } - - for _, r := range job.Name { - if r > 127 || r == ' ' { - invalidNames = append(invalidNames, fmt.Sprintf("invalid characters in job name: %s", job.Name)) - break - } - } - } - - if len(invalidNames) > 0 { - return fmt.Errorf("job validation errors: %v", invalidNames) - } - return nil -} - -func validatePath(jobPath string, paths []internal.Path, pathType string, jobName string) error { - for _, path := range paths { - if strings.HasPrefix(jobPath, path.Path) { - return nil - } - } - return fmt.Errorf("invalid %s path for job '%s': %s", pathType, jobName, jobPath) -} - -func validatePaths(cfg internal.Config) error { - invalidPaths := []string{} - - for _, job := range cfg.Jobs { - if err := validatePath(job.Source, cfg.Sources, "source", job.Name); err != nil { - invalidPaths = append(invalidPaths, err.Error()) - } - if err := validatePath(job.Target, cfg.Targets, "target", job.Name); err != nil { - invalidPaths = append(invalidPaths, err.Error()) - } - } - - if len(invalidPaths) > 0 { - return fmt.Errorf("path validation errors: %v", invalidPaths) - } - return nil -} - -func validateJobPaths(jobs []internal.Job, pathType string, getPath func(job internal.Job) string) error { - for i, job1 := range jobs { - for j, job2 := range jobs { - if i != j { - path1, path2 := internal.NormalizePath(getPath(job1)), internal.NormalizePath(getPath(job2)) - - // Check if path2 is part of job1's exclusions - excluded := false - if pathType == "source" { - for _, exclusion := range job2.Exclusions { - exclusionPath := internal.NormalizePath(filepath.Join(job2.Source, exclusion)) - // log.Printf("job2: %s %s\n", job2.Name, exclusionPath) - if strings.HasPrefix(path1, exclusionPath) { - excluded = true - break - } - } - } - - if !excluded && strings.HasPrefix(path1, path2) { - return fmt.Errorf("Job '%s' has a %s path overlapping with job '%s'", job1.Name, pathType, job2.Name) - } - } - } - } - return nil -} - -func loadResolvedConfig(configPath string) internal.Config { - f, err := os.Open(configPath) - if err != nil { - log.Fatalf("Failed to open config: %v", err) - } - defer f.Close() - - cfg, err := loadConfig(f) - if err != nil { - log.Fatalf("Failed to parse YAML: %v", err) - } - - if err := validateJobNames(cfg.Jobs); err != nil { - log.Fatalf("Job validation failed: %v", err) - } - - resolvedCfg := resolveConfig(cfg) - - if err := validatePaths(resolvedCfg); err != nil { - log.Fatalf("Path validation failed: %v", err) - } - - if err := validateJobPaths(resolvedCfg.Jobs, "source", func(job internal.Job) string { return job.Source }); err != nil { - log.Fatalf("Job source path validation failed: %v", err) - } - - if err := validateJobPaths(resolvedCfg.Jobs, "target", func(job internal.Job) string { return job.Target }); err != nil { - log.Fatalf("Job target path validation failed: %v", err) - } - - return resolvedCfg -} - // configCmd represents the config command var configCmd = &cobra.Command{ Use: "config", @@ -168,7 +24,7 @@ var showCmd = &cobra.Command{ Use: "show", Short: "Show resolved configuration", Run: func(cmd *cobra.Command, args []string) { - cfg := loadResolvedConfig(configPath) + cfg := internal.LoadResolvedConfig(configPath) out, err := yaml.Marshal(cfg) if err != nil { log.Fatalf("Failed to marshal resolved configuration: %v", err) @@ -182,7 +38,7 @@ var validateCmd = &cobra.Command{ Use: "validate", Short: "Validate configuration", Run: func(cmd *cobra.Command, args []string) { - loadResolvedConfig(configPath) + internal.LoadResolvedConfig(configPath) fmt.Println("Configuration is valid.") }, } diff --git a/backup/cmd/root_test.go b/backup/cmd/root_test.go deleted file mode 100644 index f105c44..0000000 --- a/backup/cmd/root_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package cmd - -import ( - "testing" -) - -func TestExecute(t *testing.T) { - // TODO: Add tests for the Execute function -} diff --git a/backup/internal/config.go b/backup/internal/config.go new file mode 100644 index 0000000..ab79d90 --- /dev/null +++ b/backup/internal/config.go @@ -0,0 +1,153 @@ +package internal + +import ( + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +func LoadConfig(reader io.Reader) (Config, error) { + var cfg Config + if err := yaml.NewDecoder(reader).Decode(&cfg); err != nil { + return Config{}, err + } + + // Defaults are handled in Job.UnmarshalYAML + + return cfg, nil +} + +func substituteVariables(input string, variables map[string]string) string { + for key, value := range variables { + placeholder := fmt.Sprintf("${%s}", key) + input = strings.ReplaceAll(input, placeholder, value) + } + return input +} + +func resolveConfig(cfg Config) Config { + resolvedCfg := cfg + for i, job := range resolvedCfg.Jobs { + resolvedCfg.Jobs[i].Source = substituteVariables(job.Source, cfg.Variables) + resolvedCfg.Jobs[i].Target = substituteVariables(job.Target, cfg.Variables) + } + return resolvedCfg +} + +func validateJobNames(jobs []Job) error { + invalidNames := []string{} + nameSet := make(map[string]bool) + + for _, job := range jobs { + if nameSet[job.Name] { + invalidNames = append(invalidNames, fmt.Sprintf("duplicate job name: %s", job.Name)) + } else { + nameSet[job.Name] = true + } + + for _, r := range job.Name { + if r > 127 || r == ' ' { + invalidNames = append(invalidNames, fmt.Sprintf("invalid characters in job name: %s", job.Name)) + break + } + } + } + + if len(invalidNames) > 0 { + return fmt.Errorf("job validation errors: %v", invalidNames) + } + return nil +} + +func validatePath(jobPath string, paths []Path, pathType string, jobName string) error { + for _, path := range paths { + if strings.HasPrefix(jobPath, path.Path) { + return nil + } + } + return fmt.Errorf("invalid %s path for job '%s': %s", pathType, jobName, jobPath) +} + +func validatePaths(cfg Config) error { + invalidPaths := []string{} + + for _, job := range cfg.Jobs { + if err := validatePath(job.Source, cfg.Sources, "source", job.Name); err != nil { + invalidPaths = append(invalidPaths, err.Error()) + } + if err := validatePath(job.Target, cfg.Targets, "target", job.Name); err != nil { + invalidPaths = append(invalidPaths, err.Error()) + } + } + + if len(invalidPaths) > 0 { + return fmt.Errorf("path validation errors: %v", invalidPaths) + } + return nil +} + +func validateJobPaths(jobs []Job, pathType string, getPath func(job Job) string) error { + for i, job1 := range jobs { + for j, job2 := range jobs { + if i != j { + path1, path2 := NormalizePath(getPath(job1)), NormalizePath(getPath(job2)) + + // Check if path2 is part of job1's exclusions + excluded := false + if pathType == "source" { + for _, exclusion := range job2.Exclusions { + exclusionPath := NormalizePath(filepath.Join(job2.Source, exclusion)) + // log.Printf("job2: %s %s\n", job2.Name, exclusionPath) + if strings.HasPrefix(path1, exclusionPath) { + excluded = true + break + } + } + } + + if !excluded && strings.HasPrefix(path1, path2) { + return fmt.Errorf("Job '%s' has a %s path overlapping with job '%s'", job1.Name, pathType, job2.Name) + } + } + } + } + return nil +} + +func LoadResolvedConfig(configPath string) Config { + f, err := os.Open(configPath) + if err != nil { + log.Fatalf("Failed to open config: %v", err) + } + defer f.Close() + + cfg, err := LoadConfig(f) + if err != nil { + log.Fatalf("Failed to parse YAML: %v", err) + } + + if err := validateJobNames(cfg.Jobs); err != nil { + log.Fatalf("Job validation failed: %v", err) + } + + resolvedCfg := resolveConfig(cfg) + + if err := validatePaths(resolvedCfg); err != nil { + log.Fatalf("Path validation failed: %v", err) + } + + if err := validateJobPaths(resolvedCfg.Jobs, "source", func(job Job) string { return job.Source }); err != nil { + log.Fatalf("Job source path validation failed: %v", err) + } + + if err := validateJobPaths(resolvedCfg.Jobs, "target", func(job Job) string { return job.Target }); err != nil { + log.Fatalf("Job target path validation failed: %v", err) + } + + return resolvedCfg +} diff --git a/backup/cmd/config_test.go b/backup/internal/config_test.go similarity index 65% rename from backup/cmd/config_test.go rename to backup/internal/config_test.go index 93d974b..c67d921 100644 --- a/backup/cmd/config_test.go +++ b/backup/internal/config_test.go @@ -1,26 +1,15 @@ -package cmd +package internal import ( "bytes" + "reflect" "strings" "testing" - "backup-rsync/backup/internal" + "gopkg.in/yaml.v3" ) -func TestSubstituteVariables(t *testing.T) { - variables := map[string]string{ - "target_base": "/mnt/backup1", - } - input := "${target_base}/user/music/home" - expected := "/mnt/backup1/user/music/home" - result := substituteVariables(input, variables) - if result != expected { - t.Errorf("Expected %s, got %s", expected, result) - } -} - -func TestLoadConfig(t *testing.T) { +func TestLoadConfig1(t *testing.T) { yamlData := ` variables: target_base: "/mnt/backup1" @@ -32,7 +21,7 @@ jobs: enabled: true ` reader := bytes.NewReader([]byte(yamlData)) - cfg, err := loadConfig(reader) + cfg, err := LoadConfig(reader) if err != nil { t.Fatalf("Failed to load config: %v", err) } @@ -57,16 +46,140 @@ jobs: } } +func TestLoadConfig2(t *testing.T) { + yamlData := ` +jobs: + - name: "job1" + source: "/source1" + target: "/target1" + - name: "job2" + source: "/source2" + target: "/target2" + delete: false + enabled: false +` + + // Use a reader instead of a mock file + reader := bytes.NewReader([]byte(yamlData)) + cfg, err := LoadConfig(reader) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + expected := []Job{ + { + Name: "job1", + Source: "/source1", + Target: "/target1", + Delete: true, + Enabled: true, + }, + { + Name: "job2", + Source: "/source2", + Target: "/target2", + Delete: false, + Enabled: false, + }, + } + + if !reflect.DeepEqual(cfg.Jobs, expected) { + t.Errorf("got %+v, want %+v", cfg.Jobs, expected) + } +} + +func TestYAMLUnmarshalingDefaults(t *testing.T) { + tests := []struct { + name string + yamlData string + expected Job + }{ + { + name: "Defaults applied when fields omitted", + yamlData: ` +name: "test_job" +source: "/source" +target: "/target" +`, + expected: Job{ + Name: "test_job", + Source: "/source", + Target: "/target", + Delete: true, + Enabled: true, + }, + }, + { + name: "Explicit false values preserved", + yamlData: ` +name: "test_job" +source: "/source" +target: "/target" +delete: false +enabled: false +`, + expected: Job{ + Name: "test_job", + Source: "/source", + Target: "/target", + Delete: false, + Enabled: false, + }, + }, + { + name: "Mixed explicit and default values", + yamlData: ` +name: "test_job" +source: "/source" +target: "/target" +delete: false +`, + expected: Job{ + Name: "test_job", + Source: "/source", + Target: "/target", + Delete: false, + Enabled: true, // default + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var job Job + err := yaml.Unmarshal([]byte(tt.yamlData), &job) + if err != nil { + t.Fatalf("Failed to unmarshal YAML: %v", err) + } + if !reflect.DeepEqual(job, tt.expected) { + t.Errorf("got %+v, want %+v", job, tt.expected) + } + }) + } +} + +func TestSubstituteVariables(t *testing.T) { + variables := map[string]string{ + "target_base": "/mnt/backup1", + } + input := "${target_base}/user/music/home" + expected := "/mnt/backup1/user/music/home" + result := substituteVariables(input, variables) + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} + func TestValidateJobNames(t *testing.T) { tests := []struct { name string - jobs []internal.Job + jobs []Job expectsError bool errorMessage string }{ { name: "Valid job names", - jobs: []internal.Job{ + jobs: []Job{ {Name: "job1"}, {Name: "job2"}, }, @@ -74,7 +187,7 @@ func TestValidateJobNames(t *testing.T) { }, { name: "Duplicate job names", - jobs: []internal.Job{ + jobs: []Job{ {Name: "job1"}, {Name: "job1"}, }, @@ -83,7 +196,7 @@ func TestValidateJobNames(t *testing.T) { }, { name: "Invalid characters in job name", - jobs: []internal.Job{ + jobs: []Job{ {Name: "job 1"}, }, expectsError: true, @@ -91,7 +204,7 @@ func TestValidateJobNames(t *testing.T) { }, { name: "Mixed errors", - jobs: []internal.Job{ + jobs: []Job{ {Name: "job1"}, {Name: "job 1"}, {Name: "job1"}, @@ -123,7 +236,7 @@ func TestValidatePath(t *testing.T) { tests := []struct { name string jobPath string - paths []internal.Path + paths []Path pathType string jobName string expectsError bool @@ -132,7 +245,7 @@ func TestValidatePath(t *testing.T) { { name: "Valid source path", jobPath: "/home/user/documents", - paths: []internal.Path{{Path: "/home/user"}}, + paths: []Path{{Path: "/home/user"}}, pathType: "source", jobName: "job1", expectsError: false, @@ -140,7 +253,7 @@ func TestValidatePath(t *testing.T) { { name: "Invalid source path", jobPath: "/invalid/source", - paths: []internal.Path{{Path: "/home/user"}}, + paths: []Path{{Path: "/home/user"}}, pathType: "source", jobName: "job1", expectsError: true, @@ -149,7 +262,7 @@ func TestValidatePath(t *testing.T) { { name: "Valid target path", jobPath: "/mnt/backup/documents", - paths: []internal.Path{{Path: "/mnt/backup"}}, + paths: []Path{{Path: "/mnt/backup"}}, pathType: "target", jobName: "job1", expectsError: false, @@ -157,7 +270,7 @@ func TestValidatePath(t *testing.T) { { name: "Invalid target path", jobPath: "/invalid/target", - paths: []internal.Path{{Path: "/mnt/backup"}}, + paths: []Path{{Path: "/mnt/backup"}}, pathType: "target", jobName: "job1", expectsError: true, @@ -186,20 +299,20 @@ func TestValidatePath(t *testing.T) { func TestValidatePaths(t *testing.T) { tests := []struct { name string - cfg internal.Config + cfg Config expectsError bool errorMessage string }{ { name: "Valid paths", - cfg: internal.Config{ - Sources: []internal.Path{ + cfg: Config{ + Sources: []Path{ {Path: "/home/user"}, }, - Targets: []internal.Path{ + Targets: []Path{ {Path: "/mnt/backup"}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "job1", Source: "/home/user/documents", Target: "/mnt/backup/documents"}, }, }, @@ -207,14 +320,14 @@ func TestValidatePaths(t *testing.T) { }, { name: "Invalid paths", - cfg: internal.Config{ - Sources: []internal.Path{ + cfg: Config{ + Sources: []Path{ {Path: "/home/user"}, }, - Targets: []internal.Path{ + Targets: []Path{ {Path: "/mnt/backup"}, }, - Jobs: []internal.Job{ + Jobs: []Job{ {Name: "job1", Source: "/invalid/source", Target: "/invalid/target"}, }, }, diff --git a/backup/internal/job.go b/backup/internal/job.go index f75bc03..650b9e8 100644 --- a/backup/internal/job.go +++ b/backup/internal/job.go @@ -10,7 +10,7 @@ var execCommand = exec.Command func buildRsyncCmd(job Job, simulate bool, logPath string) []string { args := []string{"-aiv", "--stats"} - if job.Delete == nil || *job.Delete { + if job.Delete { args = append(args, "--delete") } if logPath != "" { @@ -27,7 +27,7 @@ func buildRsyncCmd(job Job, simulate bool, logPath string) []string { } func ExecuteJob(job Job, simulate bool, show bool, logPath string) string { - if job.Enabled != nil && !*job.Enabled { + if !job.Enabled { return "SKIPPED" } diff --git a/backup/internal/job_test.go b/backup/internal/job_test.go index eeb03a0..e9485c5 100644 --- a/backup/internal/job_test.go +++ b/backup/internal/job_test.go @@ -6,11 +6,6 @@ import ( "testing" ) -// Helper function to create a pointer to a boolean value -func boolPtr(b bool) *bool { - return &b -} - var capturedArgs []string var mockExecCommand = func(name string, args ...string) *exec.Cmd { @@ -35,7 +30,7 @@ func TestBuildRsyncCmd(t *testing.T) { job := Job{ Source: "/home/user/Music/", Target: "/target/user/music/home", - Delete: nil, + Delete: true, Exclusions: []string{"*.tmp", "node_modules/"}, } args := buildRsyncCmd(job, true, "") @@ -56,7 +51,8 @@ func TestExecuteJob(t *testing.T) { Name: "test_job", Source: "/home/test/", Target: "/mnt/backup1/test/", - Delete: nil, + Delete: true, + Enabled: true, Exclusions: []string{"*.tmp"}, } simulate := true @@ -70,7 +66,7 @@ func TestExecuteJob(t *testing.T) { Name: "disabled_job", Source: "/home/disabled/", Target: "/mnt/backup1/disabled/", - Enabled: boolPtr(false), + Enabled: false, } status = ExecuteJob(disabledJob, simulate, false, "") @@ -80,9 +76,11 @@ func TestExecuteJob(t *testing.T) { // Test case for failure (simulate by providing invalid source path) invalidJob := Job{ - Name: "invalid_job", - Source: "/invalid/source/path", - Target: "/mnt/backup1/invalid/", + Name: "invalid_job", + Source: "/invalid/source/path", + Target: "/mnt/backup1/invalid/", + Delete: true, + Enabled: true, } status = ExecuteJob(invalidJob, false, false, "") @@ -96,7 +94,7 @@ func TestJobSkippedEnabledTrue(t *testing.T) { Name: "test_job", Source: "/home/test/", Target: "/mnt/backup1/test/", - Enabled: boolPtr(true), + Enabled: true, } status := ExecuteJob(job, true, false, "") if status != "SUCCESS" { @@ -109,7 +107,7 @@ func TestJobSkippedEnabledFalse(t *testing.T) { Name: "disabled_job", Source: "/home/disabled/", Target: "/mnt/backup1/disabled/", - Enabled: boolPtr(false), + Enabled: false, } status := ExecuteJob(disabledJob, true, false, "") if status != "SKIPPED" { @@ -119,9 +117,11 @@ func TestJobSkippedEnabledFalse(t *testing.T) { func TestJobSkippedEnabledOmitted(t *testing.T) { job := Job{ - Name: "omitted_enabled_job", - Source: "/home/omitted/", - Target: "/mnt/backup1/omitted/", + Name: "omitted_enabled_job", + Source: "/home/omitted/", + Target: "/mnt/backup1/omitted/", + Delete: true, + Enabled: true, } status := ExecuteJob(job, true, false, "") if status != "SUCCESS" { @@ -137,7 +137,8 @@ func TestExecuteJobWithMockedRsync(t *testing.T) { Name: "test_job", Source: "/home/test/", Target: "/mnt/backup1/test/", - Delete: nil, + Delete: true, + Enabled: true, Exclusions: []string{"*.tmp"}, } status := ExecuteJob(job, true, false, "") diff --git a/backup/internal/types.go b/backup/internal/types.go index 32f1626..1549261 100644 --- a/backup/internal/types.go +++ b/backup/internal/types.go @@ -1,5 +1,9 @@ package internal +import ( + "gopkg.in/yaml.v3" +) + // Centralized type definitions type Path struct { @@ -15,10 +19,49 @@ type Config struct { } type Job struct { + Name string `yaml:"name"` + Source string `yaml:"source"` + Target string `yaml:"target"` + Delete bool `yaml:"delete"` + Enabled bool `yaml:"enabled"` + Exclusions []string `yaml:"exclusions,omitempty"` +} + +// JobYAML is a helper struct for proper YAML unmarshaling with defaults +type JobYAML struct { Name string `yaml:"name"` Source string `yaml:"source"` Target string `yaml:"target"` Delete *bool `yaml:"delete"` - Exclusions []string `yaml:"exclusions"` Enabled *bool `yaml:"enabled"` + Exclusions []string `yaml:"exclusions,omitempty"` +} + +// UnmarshalYAML implements custom YAML unmarshaling to handle defaults properly +func (j *Job) UnmarshalYAML(node *yaml.Node) error { + var jobYAML JobYAML + if err := node.Decode(&jobYAML); err != nil { + return err + } + + // Copy basic fields + j.Name = jobYAML.Name + j.Source = jobYAML.Source + j.Target = jobYAML.Target + j.Exclusions = jobYAML.Exclusions + + // Handle boolean fields with defaults + if jobYAML.Delete != nil { + j.Delete = *jobYAML.Delete + } else { + j.Delete = true // default value + } + + if jobYAML.Enabled != nil { + j.Enabled = *jobYAML.Enabled + } else { + j.Enabled = true // default value + } + + return nil }