diff --git a/cmd/vale/command.go b/cmd/vale/command.go index 677b9146..69c800e2 100644 --- a/cmd/vale/command.go +++ b/cmd/vale/command.go @@ -56,6 +56,13 @@ type command struct { // commands are the available CLI commands. var commands = map[string]command{ + "init": { + Run: initCmd, + Summary: "Initialize a new .vale.ini configuration file.", + Usage: "init [path]", + Detail: "Generates a default .vale.ini configuration file with baseline\n" + + "settings and creates the styles directory if no configuration exists.", + }, "ls-config": { Run: printConfig, Summary: "Print the current configuration to stdout.", diff --git a/cmd/vale/init_test.go b/cmd/vale/init_test.go new file mode 100644 index 00000000..9ddd4274 --- /dev/null +++ b/cmd/vale/init_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/errata-ai/ini" + "github.com/vale-cli/vale/v3/internal/core" + "github.com/vale-cli/vale/v3/internal/system" +) + +func TestInitCmd_CreatesValidConfig(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, ".vale.ini") + + if system.FileExists(cfgPath) { + t.Fatalf("expected config to not exist initially: %s", cfgPath) + } + + err := initCmd([]string{cfgPath}, &core.CLIFlags{}) + if err != nil { + t.Fatalf("initCmd failed: %v", err) + } + + if !system.FileExists(cfgPath) { + t.Fatalf("expected config file to be created: %s", cfgPath) + } + + stylesDir := filepath.Join(tmpDir, "styles") + if !system.IsDir(stylesDir) { + t.Fatalf("expected styles directory to be created: %s", stylesDir) + } + + // Validate INI syntax and keys + uCfg, err := ini.Load(cfgPath) + if err != nil { + t.Fatalf("failed to parse generated config with ini: %v", err) + } + + coreSec := uCfg.Section("") + if coreSec.Key("StylesPath").String() != "styles" { + t.Errorf("expected StylesPath = styles, got %s", coreSec.Key("StylesPath").String()) + } + if coreSec.Key("MinAlertLevel").String() != "suggestion" { + t.Errorf("expected MinAlertLevel = suggestion, got %s", coreSec.Key("MinAlertLevel").String()) + } + if coreSec.Key("Packages").String() != "Google" { + t.Errorf("expected Packages = Google, got %s", coreSec.Key("Packages").String()) + } + + formatSec := uCfg.Section("*.{md,txt}") + if formatSec.Key("BasedOnStyles").String() != "Vale, Google" { + t.Errorf("expected BasedOnStyles = Vale, Google, got %s", formatSec.Key("BasedOnStyles").String()) + } + + // Validate with Vale pipeline loader + cfg, err := core.ReadPipeline(&core.CLIFlags{Path: cfgPath, IgnoreGlobal: true}, true) + if err != nil { + t.Fatalf("failed to read generated config through ReadPipeline: %v", err) + } + + if cfg.MinAlertLevel != core.LevelToInt["suggestion"] { + t.Errorf("expected MinAlertLevel %d, got %d", core.LevelToInt["suggestion"], cfg.MinAlertLevel) + } + + pkgs, err := core.GetPackages(cfgPath) + if err != nil { + t.Fatalf("failed to get packages from generated config: %v", err) + } + if len(pkgs) != 1 || pkgs[0] != "Google" { + t.Errorf("expected packages [Google], got %v", pkgs) + } + + baseStyles := cfg.SBaseStyles["*.{md,txt}"] + if len(baseStyles) != 2 || baseStyles[0] != "Vale" || baseStyles[1] != "Google" { + t.Errorf("expected base styles [Vale Google], got %v", baseStyles) + } +} + +func TestInitCmd_ExistingConfigFails(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, ".vale.ini") + + err := os.WriteFile(cfgPath, []byte("StylesPath = styles\n"), 0o600) + if err != nil { + t.Fatalf("failed to create existing config: %v", err) + } + + err = initCmd([]string{cfgPath}, &core.CLIFlags{}) + if err == nil { + t.Fatal("expected initCmd to fail when config already exists, got nil") + } + + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected error message mentioning 'already exists', got: %v", err) + } +} + +func TestInitCmd_AlternativeConfigInDirFails(t *testing.T) { + tmpDir := t.TempDir() + altPath := filepath.Join(tmpDir, "vale.ini") + + err := os.WriteFile(altPath, []byte("StylesPath = styles\n"), 0o600) + if err != nil { + t.Fatalf("failed to create existing alt config: %v", err) + } + + targetPath := filepath.Join(tmpDir, ".vale.ini") + err = initCmd([]string{targetPath}, &core.CLIFlags{}) + if err == nil { + t.Fatal("expected initCmd to fail when alternative config exists in directory, got nil") + } + + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("expected error message mentioning 'already exists', got: %v", err) + } +} + +func TestInitCmd_DefaultTargetInCwd(t *testing.T) { + tmpDir := t.TempDir() + origWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get current working directory: %v", err) + } + defer func() { + _ = os.Chdir(origWd) + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change directory to temp dir: %v", err) + } + + err = initCmd(nil, &core.CLIFlags{}) + if err != nil { + t.Fatalf("initCmd without args failed: %v", err) + } + + expectedFile := filepath.Join(tmpDir, ".vale.ini") + if !system.FileExists(expectedFile) { + t.Fatalf("expected .vale.ini in cwd, file not found: %s", expectedFile) + } + + expectedStyles := filepath.Join(tmpDir, "styles") + if !system.IsDir(expectedStyles) { + t.Fatalf("expected styles dir in cwd, dir not found: %s", expectedStyles) + } +} + +func TestInitCmd_WithTargetDirectory(t *testing.T) { + tmpDir := t.TempDir() + subDir := filepath.Join(tmpDir, "subproject") + if err := os.MkdirAll(subDir, os.ModePerm); err != nil { + t.Fatalf("failed to create subproject dir: %v", err) + } + + err := initCmd([]string{subDir}, &core.CLIFlags{}) + if err != nil { + t.Fatalf("initCmd with directory argument failed: %v", err) + } + + expectedFile := filepath.Join(subDir, ".vale.ini") + if !system.FileExists(expectedFile) { + t.Fatalf("expected .vale.ini inside subproject, file not found: %s", expectedFile) + } + + expectedStyles := filepath.Join(subDir, "styles") + if !system.IsDir(expectedStyles) { + t.Fatalf("expected styles inside subproject, dir not found: %s", expectedStyles) + } +} + +func TestInitCmd_WithConfigFlag(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, ".custom.ini") + + err := initCmd(nil, &core.CLIFlags{Path: cfgPath}) + if err != nil { + t.Fatalf("initCmd with --config flag failed: %v", err) + } + + if !system.FileExists(cfgPath) { + t.Fatalf("expected custom config file to exist: %s", cfgPath) + } + + expectedStyles := filepath.Join(tmpDir, "styles") + if !system.IsDir(expectedStyles) { + t.Fatalf("expected styles dir in config dir, dir not found: %s", expectedStyles) + } +} + +func TestInitCmd_StylesDirAlreadyExists(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, ".vale.ini") + stylesDir := filepath.Join(tmpDir, "styles") + + if err := os.MkdirAll(stylesDir, os.ModePerm); err != nil { + t.Fatalf("failed to pre-create styles dir: %v", err) + } + + err := initCmd([]string{cfgPath}, &core.CLIFlags{}) + if err != nil { + t.Fatalf("initCmd failed when styles dir already exists: %v", err) + } + + if !system.FileExists(cfgPath) { + t.Fatalf("expected config file to be created: %s", cfgPath) + } +} + +func TestInitCmd_StylesPathIsFileFails(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, ".vale.ini") + stylesConflict := filepath.Join(tmpDir, "styles") + + if err := os.WriteFile(stylesConflict, []byte("conflict"), 0o600); err != nil { + t.Fatalf("failed to create conflicting styles file: %v", err) + } + + err := initCmd([]string{cfgPath}, &core.CLIFlags{}) + if err == nil { + t.Fatal("expected initCmd to fail when styles exists as regular file, got nil") + } +} + +func TestInitCmd_ValidatesFixture(t *testing.T) { + fixturePath := filepath.Join("..", "..", "testdata", "fixtures", "init", ".vale.ini") + if !system.FileExists(fixturePath) { + t.Fatalf("expected fixture to exist: %s", fixturePath) + } + + content, err := os.ReadFile(fixturePath) + if err != nil { + t.Fatalf("failed to read fixture: %v", err) + } + + if string(content) != core.DefaultConfigTemplate { + t.Errorf("expected fixture content to match DefaultConfigTemplate") + } + + cfg, err := core.ReadPipeline(&core.CLIFlags{Path: fixturePath, IgnoreGlobal: true}, true) + if err != nil { + t.Fatalf("failed to read fixture pipeline: %v", err) + } + + if cfg.MinAlertLevel != core.LevelToInt["suggestion"] { + t.Errorf("expected MinAlertLevel %d, got %d", core.LevelToInt["suggestion"], cfg.MinAlertLevel) + } +} diff --git a/cmd/vale/main.go b/cmd/vale/main.go index 98234207..a257da98 100755 --- a/cmd/vale/main.go +++ b/cmd/vale/main.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "os" + "path/filepath" + "github.com/pterm/pterm" "github.com/spf13/pflag" "github.com/vale-cli/vale/v3/internal/core" @@ -82,6 +84,26 @@ func handleError(err error) { os.Exit(2) } +func initCmd(args []string, flags *core.CLIFlags) error { + target := ".vale.ini" + if len(args) > 0 { + target = args[0] + } else if flags.Path != "" { + target = flags.Path + } + + if system.IsDir(target) { + target = filepath.Join(target, ".vale.ini") + } + + if err := core.InitConfig(target); err != nil { + return err + } + + pterm.Success.Println(fmt.Sprintf("Initialized configuration in '%s'.", target)) + return nil +} + func main() { // Every exit path below calls this explicitly: os.Exit skips deferred // functions, so a defer here would silently drop the profile. diff --git a/internal/core/config.go b/internal/core/config.go index 4a158dae..c1b416ad 100644 --- a/internal/core/config.go +++ b/internal/core/config.go @@ -467,3 +467,61 @@ func (c *Config) SphinxNames(key string) []string { } return out } + +// DefaultConfigTemplate is the default configuration template written by `vale init`. +const DefaultConfigTemplate = `# Core settings +# StylesPath defines where Vale looks for styles and downloaded packages. +StylesPath = styles + +# MinAlertLevel sets the minimum alert severity to display: suggestion, warning, or error. +MinAlertLevel = suggestion + +# External packages installed via 'vale sync'. +Packages = Google + +# Format-specific configuration +# Applies the Vale and Google styles to Markdown and text files. +[*.{md,txt}] +BasedOnStyles = Vale, Google +` + +// InitConfig creates a default .vale.ini configuration file at target and +// scaffolds the styles directory. +// Returns an error if a configuration file already exists at target or +// within target's directory. +func InitConfig(target string) error { + if target == "" { + target = ".vale.ini" + } else if system.IsDir(target) { + target = filepath.Join(target, ".vale.ini") + } + + if system.FileExists(target) { + return NewE100("init", fmt.Errorf("configuration file '%s' already exists", target)) + } + + dir := filepath.Dir(target) + for _, name := range configNames { + candidate := filepath.Join(dir, name) + if system.FileExists(candidate) { + return NewE100("init", fmt.Errorf("configuration file '%s' already exists", candidate)) + } + } + + if dir != "" && dir != "." && !system.IsDir(dir) { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return NewE100("init", fmt.Errorf("failed to create directory '%s': %w", dir, err)) + } + } + + if err := os.WriteFile(target, []byte(DefaultConfigTemplate), 0o644); err != nil { + return NewE100("init", fmt.Errorf("failed to write configuration file '%s': %w", target, err)) + } + + stylesDir := filepath.Join(dir, "styles") + if err := os.MkdirAll(stylesDir, os.ModePerm); err != nil { + return NewE100("init", fmt.Errorf("failed to create styles directory '%s': %w", stylesDir, err)) + } + + return nil +} diff --git a/internal/core/config_test.go b/internal/core/config_test.go index 0a6f190e..e23eabad 100644 --- a/internal/core/config_test.go +++ b/internal/core/config_test.go @@ -122,3 +122,60 @@ func TestFallbackToDefault(t *testing.T) { t.Fatal(err) } } + +func TestInitConfig(t *testing.T) { + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, ".vale.ini") + + err := InitConfig(cfgPath) + if err != nil { + t.Fatalf("InitConfig failed: %v", err) + } + + if !system.FileExists(cfgPath) { + t.Fatalf("expected config file to exist: %s", cfgPath) + } + + stylesDir := filepath.Join(tmpDir, "styles") + if !system.IsDir(stylesDir) { + t.Fatalf("expected styles directory to exist: %s", stylesDir) + } + + content, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("failed to read created config: %v", err) + } + if string(content) != DefaultConfigTemplate { + t.Fatalf("config content does not match template") + } + + // Should fail if called again on existing config + err = InitConfig(cfgPath) + if err == nil { + t.Fatal("expected InitConfig to fail on existing file, got nil") + } + + // Should fail if another valid config name already exists in dir + altDir := t.TempDir() + altPath := filepath.Join(altDir, "vale.ini") + if err := os.WriteFile(altPath, []byte("StylesPath = styles\n"), 0o600); err != nil { + t.Fatalf("failed to create alt config: %v", err) + } + err = InitConfig(filepath.Join(altDir, ".vale.ini")) + if err == nil { + t.Fatal("expected InitConfig to fail when alternative config exists in directory, got nil") + } + + // Target as existing directory + dirTarget := t.TempDir() + err = InitConfig(dirTarget) + if err != nil { + t.Fatalf("InitConfig with directory target failed: %v", err) + } + if !system.FileExists(filepath.Join(dirTarget, ".vale.ini")) { + t.Fatalf("expected .vale.ini in target directory") + } + if !system.IsDir(filepath.Join(dirTarget, "styles")) { + t.Fatalf("expected styles directory in target directory") + } +} diff --git a/testdata/fixtures/init/.vale.ini b/testdata/fixtures/init/.vale.ini new file mode 100644 index 00000000..5babcbe9 --- /dev/null +++ b/testdata/fixtures/init/.vale.ini @@ -0,0 +1,14 @@ +# Core settings +# StylesPath defines where Vale looks for styles and downloaded packages. +StylesPath = styles + +# MinAlertLevel sets the minimum alert severity to display: suggestion, warning, or error. +MinAlertLevel = suggestion + +# External packages installed via 'vale sync'. +Packages = Google + +# Format-specific configuration +# Applies the Vale and Google styles to Markdown and text files. +[*.{md,txt}] +BasedOnStyles = Vale, Google diff --git a/testdata/fixtures/init/test.md b/testdata/fixtures/init/test.md new file mode 100644 index 00000000..f0d5101b --- /dev/null +++ b/testdata/fixtures/init/test.md @@ -0,0 +1,3 @@ +# Sample Document + +This is a sample document for testing the initialized configuration.