From a3b5e831e71948b7d61a3a869437c7ed98356163 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Mon, 8 Jun 2026 11:46:16 +0300 Subject: [PATCH 1/4] - Add format auto detection and format hint comments --- cmd/cli.go | 26 ++++---- cmd/cli_get_list_test.go | 40 +++++++++++- cmd/cli_help_test.go | 2 + format/document.go | 85 +++++++++++++++++++++++-- format/document_test.go | 131 +++++++++++++++++++++++++++++++++++++++ format/inidoc/doc.go | 33 ++++++++++ format/jsondoc/list.go | 13 ++++ format/tomldoc/set.go | 4 ++ format/yamldoc/doc.go | 8 +++ help/topics/formats.txt | 14 +++++ 10 files changed, 337 insertions(+), 19 deletions(-) diff --git a/cmd/cli.go b/cmd/cli.go index 4d0a3e8..fd56428 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -634,15 +634,15 @@ func runGet(opts getOptions, stdout io.Writer) error { if err != nil { return err } - doc, _, err := format.Resolve(configFile) + source, err := os.ReadFile(configFile) if err != nil { return err } - - source, err := os.ReadFile(configFile) + doc, _, err := format.ResolveSource(configFile, source) if err != nil { return err } + var value string if opts.in != "" { value, err = doc.GetIn(string(source), opts.in, opts.on, opts.key) @@ -661,15 +661,15 @@ func runList(opts listOptions, stdout io.Writer) error { if err != nil { return err } - doc, _, err := format.Resolve(configFile) + source, err := os.ReadFile(configFile) if err != nil { return err } - - source, err := os.ReadFile(configFile) + doc, _, err := format.ResolveSource(configFile, source) if err != nil { return err } + entries, err := doc.List(string(source), opts.key) if err != nil { return err @@ -683,15 +683,15 @@ func runDump(opts dumpOptions, stdout io.Writer) error { if err != nil { return err } - doc, _, err := format.Resolve(configFile) + source, err := os.ReadFile(configFile) if err != nil { return err } - - source, err := os.ReadFile(configFile) + doc, _, err := format.ResolveSource(configFile, source) if err != nil { return err } + value, err := doc.Dump(string(source), opts.key) if err != nil { return err @@ -766,16 +766,16 @@ func runEdit(command, configFile string, dry, diff, color bool, stdout, stderr i return err } log.Debug("resolved config file", "command", command, "path", configFile) - doc, formatName, err := format.Resolve(configFile) + + source, err := os.ReadFile(configFile) if err != nil { return err } - log.Debug("detected format", "format", formatName) - - source, err := os.ReadFile(configFile) + doc, formatName, err := format.ResolveSource(configFile, source) if err != nil { return err } + log.Debug("detected format", "format", formatName) log.Debug("read config", "bytes", len(source)) updated, err := edit(doc, string(source)) if err != nil { diff --git a/cmd/cli_get_list_test.go b/cmd/cli_get_list_test.go index 833fef4..beae7b9 100644 --- a/cmd/cli_get_list_test.go +++ b/cmd/cli_get_list_test.go @@ -96,7 +96,7 @@ func TestGetFailsWhenConfigFileIsNotSpecified(t *testing.T) { } } -func TestGetUnsupportedExplicitConfigFileReportsUnsupportedFormat(t *testing.T) { +func TestGetAmbiguousUnknownExtensionReportsFormatHint(t *testing.T) { clearConfigFileEnv(t) var stdout, stderr bytes.Buffer path := filepath.Join(t.TempDir(), "config.conf") @@ -112,11 +112,47 @@ func TestGetUnsupportedExplicitConfigFileReportsUnsupportedFormat(t *testing.T) if stdout.Len() != 0 { t.Fatalf("stdout = %q", stdout.String()) } - if err.Error() != "unsupported config format for "+path { + if err.Error() != "ambiguous config format for "+path+"; add # format: toml or # format: ini" { t.Fatalf("unexpected error: %v", err) } } +func TestGetDetectsUnknownExtensionWithFormatHint(t *testing.T) { + clearConfigFileEnv(t) + var stdout, stderr bytes.Buffer + path := filepath.Join(t.TempDir(), "settings.conf") + if err := os.WriteFile(path, []byte("# format: ini\n[database]\nport = 5432\n"), 0644); err != nil { + t.Fatal(err) + } + + err := Execute([]string{"get", "-f", path, "database.port"}, "1.2.3", &stdout, &stderr) + + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if stdout.String() != "5432\n" { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestListDetectsConfigFileEnvWithUnknownExtension(t *testing.T) { + var stdout, stderr bytes.Buffer + path := filepath.Join(t.TempDir(), "settings.conf") + if err := os.WriteFile(path, []byte("# format: ini\n[database]\nport = 5432\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("CONFIG_FILE", path) + + err := Execute([]string{"list", "database"}, "1.2.3", &stdout, &stderr) + + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if stdout.String() != "database.port=5432\n" { + t.Fatalf("stdout = %q", stdout.String()) + } +} + func TestGetPrintsJSONValue(t *testing.T) { var stdout, stderr bytes.Buffer path := writeTempJSON(t, `{"database":{"port":5432}}`) diff --git a/cmd/cli_help_test.go b/cmd/cli_help_test.go index b6a692f..25e6481 100644 --- a/cmd/cli_help_test.go +++ b/cmd/cli_help_test.go @@ -110,6 +110,8 @@ func TestHelpCommandShowsFormatsTopic(t *testing.T) { assertContains(t, stdout.String(), "Topic: formats") assertContains(t, stdout.String(), "TOML") assertContains(t, stdout.String(), ".json") + assertContains(t, stdout.String(), "Unknown extensions") + assertContains(t, stdout.String(), "# format: ini") assertContains(t, stdout.String(), "canonical pretty JSON") } diff --git a/format/document.go b/format/document.go index f11f914..4053924 100644 --- a/format/document.go +++ b/format/document.go @@ -4,6 +4,11 @@ import ( "fmt" "path/filepath" "strings" + + "github.com/dannyben/config/format/inidoc" + "github.com/dannyben/config/format/jsondoc" + "github.com/dannyben/config/format/tomldoc" + "github.com/dannyben/config/format/yamldoc" ) type Entry struct { @@ -34,18 +39,90 @@ func Resolve(path string) (Document, string, error) { ext := extension(path) switch ext { case ".toml": - return tomlDocument{}, "toml", nil + return documentForFormat("toml") case ".yaml", ".yml": - return yamlDocument{}, "yaml", nil + return documentForFormat("yaml") case ".json": - return jsonDocument{}, "json", nil + return documentForFormat("json") case ".ini": - return iniDocument{}, "ini", nil + return documentForFormat("ini") default: return nil, "", fmt.Errorf("unsupported config format for %s", path) } } +func ResolveSource(path string, source []byte) (Document, string, error) { + if doc, name, err := Resolve(path); err == nil { + return doc, name, nil + } + + text := string(source) + if hinted, ok := formatHint(text); ok { + if hinted == "json" { + return nil, "", fmt.Errorf("unsupported format hint %q for %s; JSON files cannot contain comments", hinted, path) + } + doc, name, err := documentForFormat(hinted) + if err != nil { + return nil, "", fmt.Errorf("unsupported format hint %q for %s", hinted, path) + } + return doc, name, nil + } + + possibleTOML := tomldoc.Valid(text) + possibleINI := inidoc.Valid(text) + switch { + case possibleTOML && possibleINI: + return nil, "", fmt.Errorf("ambiguous config format for %s; add # format: toml or # format: ini", path) + case possibleTOML: + return documentForFormat("toml") + case possibleINI: + return documentForFormat("ini") + } + + if jsondoc.Valid(text) { + return documentForFormat("json") + } + if yamldoc.Valid(text) { + return documentForFormat("yaml") + } + + return nil, "", fmt.Errorf("cannot determine config format for %s; add # format: toml, # format: ini, or # format: yaml", path) +} + +func documentForFormat(format string) (Document, string, error) { + switch format { + case "toml": + return tomlDocument{}, "toml", nil + case "yaml", "yml": + return yamlDocument{}, "yaml", nil + case "json": + return jsonDocument{}, "json", nil + case "ini": + return iniDocument{}, "ini", nil + default: + return nil, "", fmt.Errorf("unsupported config format %q", format) + } +} + +func formatHint(source string) (string, bool) { + for _, line := range strings.Split(source, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if !strings.HasPrefix(trimmed, "#") { + return "", false + } + hint := strings.TrimSpace(strings.TrimPrefix(trimmed, "#")) + name, ok := strings.CutPrefix(hint, "format:") + if !ok { + return "", false + } + return strings.ToLower(strings.TrimSpace(name)), true + } + return "", false +} + func extension(path string) string { ext := strings.ToLower(filepath.Ext(path)) return ext diff --git a/format/document_test.go b/format/document_test.go index a037d51..8488ca2 100644 --- a/format/document_test.go +++ b/format/document_test.go @@ -94,3 +94,134 @@ func TestResolveUnsupportedFormat(t *testing.T) { t.Fatal("expected error") } } + +func TestResolveSourceUsesKnownExtension(t *testing.T) { + _, name, err := ResolveSource("config.toml", []byte("server: localhost\n")) + + if err != nil { + t.Fatalf("ResolveSource returned error: %v", err) + } + if name != "toml" { + t.Fatalf("name = %q, want toml", name) + } +} + +func TestResolveSourceDetectsTOML(t *testing.T) { + doc, name, err := ResolveSource("config.conf", []byte("[[servers]]\nname = \"api\"\nport = 3000\n")) + + if err != nil { + t.Fatalf("ResolveSource returned error: %v", err) + } + if name != "toml" { + t.Fatalf("name = %q, want toml", name) + } + value, err := doc.Get("[[servers]]\nname = \"api\"\nport = 3000\n", "servers.0.port") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if value != "3000" { + t.Fatalf("value = %q, want 3000", value) + } +} + +func TestResolveSourceDetectsINI(t *testing.T) { + doc, name, err := ResolveSource("config.conf", []byte("[server]\nhost = localhost\n")) + + if err != nil { + t.Fatalf("ResolveSource returned error: %v", err) + } + if name != "ini" { + t.Fatalf("name = %q, want ini", name) + } + value, err := doc.Get("[server]\nhost = localhost\n", "server.host") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if value != "localhost" { + t.Fatalf("value = %q, want localhost", value) + } +} + +func TestResolveSourceDetectsJSONBeforeYAML(t *testing.T) { + _, name, err := ResolveSource("config.conf", []byte(`{"server":{"port":3000}}`)) + + if err != nil { + t.Fatalf("ResolveSource returned error: %v", err) + } + if name != "json" { + t.Fatalf("name = %q, want json", name) + } +} + +func TestResolveSourceDetectsYAML(t *testing.T) { + doc, name, err := ResolveSource("config.conf", []byte("server:\n port: 3000\n")) + + if err != nil { + t.Fatalf("ResolveSource returned error: %v", err) + } + if name != "yaml" { + t.Fatalf("name = %q, want yaml", name) + } + value, err := doc.Get("server:\n port: 3000\n", "server.port") + if err != nil { + t.Fatalf("Get returned error: %v", err) + } + if value != "3000" { + t.Fatalf("value = %q, want 3000", value) + } +} + +func TestResolveSourceRejectsAmbiguousTOMLAndINI(t *testing.T) { + _, _, err := ResolveSource("config.conf", []byte("port = 3000\n")) + + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "ambiguous config format for config.conf; add # format: toml or # format: ini" { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveSourceUsesFormatHint(t *testing.T) { + _, name, err := ResolveSource("config.conf", []byte("# format: ini\nport = 3000\n")) + + if err != nil { + t.Fatalf("ResolveSource returned error: %v", err) + } + if name != "ini" { + t.Fatalf("name = %q, want ini", name) + } +} + +func TestResolveSourceRejectsUnknownFormatHint(t *testing.T) { + _, _, err := ResolveSource("config.conf", []byte("# format: xml\n\n")) + + if err == nil { + t.Fatal("expected error") + } + if err.Error() != `unsupported format hint "xml" for config.conf` { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveSourceRejectsJSONFormatHint(t *testing.T) { + _, _, err := ResolveSource("config.conf", []byte("# format: json\n{\"port\":3000}\n")) + + if err == nil { + t.Fatal("expected error") + } + if err.Error() != `unsupported format hint "json" for config.conf; JSON files cannot contain comments` { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveSourceRejectsUnknownFormat(t *testing.T) { + _, _, err := ResolveSource("config.conf", []byte("not config\n")) + + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "cannot determine config format for config.conf; add # format: toml, # format: ini, or # format: yaml" { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/format/inidoc/doc.go b/format/inidoc/doc.go index 77ec536..2ea9b59 100644 --- a/format/inidoc/doc.go +++ b/format/inidoc/doc.go @@ -467,6 +467,39 @@ func parse(source string) (document, error) { return doc, nil } +func Valid(source string) bool { + doc, err := parse(source) + if err != nil { + return false + } + return len(doc.entries) > 0 && hasINIShape(source) && !hasTOMLArrayTable(source) +} + +func hasINIShape(source string) bool { + lines, _ := splitLines(source) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";") { + continue + } + if strings.HasPrefix(trimmed, "[") || strings.Contains(line, "=") { + return true + } + } + return false +} + +func hasTOMLArrayTable(source string) bool { + lines, _ := splitLines(source) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[[") { + return true + } + } + return false +} + func replaceLineValue(source string, index int, value string) (string, error) { lines, trailing := splitLines(source) if index < 0 || index >= len(lines) { diff --git a/format/jsondoc/list.go b/format/jsondoc/list.go index 1237bfa..bdaeeee 100644 --- a/format/jsondoc/list.go +++ b/format/jsondoc/list.go @@ -51,6 +51,19 @@ func parseJSON(source string) (any, error) { return value, nil } +func Valid(source string) bool { + value, err := parseJSON(source) + if err != nil { + return false + } + switch value.(type) { + case object, []any: + return true + default: + return false + } +} + func parseJSONValue(decoder *json.Decoder) (any, error) { token, err := decoder.Token() if err != nil { diff --git a/format/tomldoc/set.go b/format/tomldoc/set.go index 33e6386..860b616 100644 --- a/format/tomldoc/set.go +++ b/format/tomldoc/set.go @@ -56,6 +56,10 @@ func validateTOML(source string) error { return nil } +func Valid(source string) bool { + return parseTOMLSource(source) == nil +} + func parseTOMLSource(source string) error { if err := validateTOML(source); err != nil { return err diff --git a/format/yamldoc/doc.go b/format/yamldoc/doc.go index 38342c0..71d12c6 100644 --- a/format/yamldoc/doc.go +++ b/format/yamldoc/doc.go @@ -18,6 +18,14 @@ func validateYAML(source string) error { return err } +func Valid(source string) bool { + root, err := parseYAML(source) + if err != nil || root == nil { + return false + } + return root.Kind == yaml.MappingNode || root.Kind == yaml.SequenceNode +} + func parseYAML(source string) (*yaml.Node, error) { var doc yaml.Node if err := yaml.Unmarshal([]byte(source), &doc); err != nil { diff --git a/help/topics/formats.txt b/help/topics/formats.txt index ca4d07e..ce5636a 100644 --- a/help/topics/formats.txt +++ b/help/topics/formats.txt @@ -16,6 +16,20 @@ The `config` command supports TOML, YAML, JSON, and INI files. INI `.ini` +**Unknown extensions** + + Files with unknown extensions are detected from their contents. TOML and INI + are checked first because their syntax can overlap. If both match, `config` + refuses the file and asks for a format hint. + + Add a format hint as the first non-empty line when needed: + + `# format: toml` + `# format: ini` + `# format: yaml` + + JSON does not support format hints because JSON files cannot contain comments. + **Write behavior** TOML and YAML From f455a3ccfa0097bbda54c1e5040f93fe938062b9 Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Mon, 8 Jun 2026 12:31:36 +0300 Subject: [PATCH 2/4] improve feature test runner to allow custom config names --- cmd/features_test.go | 122 ++++++++++++++++++++++++------- features/README.md | 43 +++++++++++ features/formats/auto-detect.md | 54 ++++++++++++++ features/formats/hint-comment.md | 36 +++++++++ features/set/stdin.md | 2 +- 5 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 features/formats/auto-detect.md create mode 100644 features/formats/hint-comment.md diff --git a/cmd/features_test.go b/cmd/features_test.go index 6381e66..a25d1ee 100644 --- a/cmd/features_test.go +++ b/cmd/features_test.go @@ -23,12 +23,17 @@ type featureSpec struct { name string pending bool pendingReason string - sources map[string]string + sources map[string]featureSource files map[string]string results map[string]string commands []featureCommand } +type featureSource struct { + filename string + content string +} + func TestFeatures(t *testing.T) { t.Setenv("NO_COLOR", "1") t.Setenv("CONFIG_LOG_LEVEL", "") @@ -115,9 +120,9 @@ func runFeatureSpec(t *testing.T, spec featureSpec) { } } -func runFeatureFormat(t *testing.T, spec featureSpec, formatName, source string) { +func runFeatureFormat(t *testing.T, spec featureSpec, formatName string, source featureSource) { t.Helper() - temp, target, targetName := writeFeatureFiles(t, spec, formatName, source) + temp, target, targetName := writeFeatureFiles(t, spec, source) var allStdout, allStderr bytes.Buffer for _, command := range spec.commands { @@ -128,7 +133,7 @@ func runFeatureFormat(t *testing.T, spec featureSpec, formatName, source string) } } - verifyFeatureResult(t, spec, formatName, source, target, targetName) + verifyFeatureResult(t, spec, formatName, source.content, target, targetName) if allStdout.Len() != 0 { t.Fatalf("unexpected stdout\n%s", unifiedDiff("stdout", "", allStdout.String())) } @@ -137,12 +142,12 @@ func runFeatureFormat(t *testing.T, spec featureSpec, formatName, source string) } } -func writeFeatureFiles(t *testing.T, spec featureSpec, formatName, source string) (string, string, string) { +func writeFeatureFiles(t *testing.T, spec featureSpec, source featureSource) (string, string, string) { t.Helper() - targetName := "config." + formatName temp := t.TempDir() + targetName := source.filename target := filepath.Join(temp, targetName) - if err := os.WriteFile(target, []byte(source), 0644); err != nil { + if err := os.WriteFile(target, []byte(source.content), 0644); err != nil { t.Fatal(err) } for name, content := range spec.files { @@ -247,6 +252,11 @@ func expectedFeatureOutput(common string, byFormat map[string]string, formatName if value, ok := byFormat[formatName]; ok { return value } + if base, _, ok := strings.Cut(formatName, "/"); ok { + if value, ok := byFormat[base]; ok { + return value + } + } return common } @@ -258,6 +268,7 @@ func parseFeatureSpec(t *testing.T, path string) featureSpec { } spec := newFeatureSpec(path) section := "" + subsection := "" lines := strings.Split(string(content), "\n") for i := 0; i < len(lines); i++ { line := lines[i] @@ -272,16 +283,21 @@ func parseFeatureSpec(t *testing.T, path string) featureSpec { } if strings.HasPrefix(line, "## ") { section = strings.TrimSpace(strings.TrimPrefix(line, "## ")) + subsection = "" validateFeatureSection(t, path, section) continue } + if strings.HasPrefix(line, "### ") { + subsection = strings.TrimSpace(strings.TrimPrefix(line, "### ")) + continue + } if strings.HasPrefix(line, "```") { - language, name := parseFenceInfo(strings.TrimSpace(strings.TrimPrefix(line, "```"))) + language := parseFenceLanguage(strings.TrimSpace(strings.TrimPrefix(line, "```"))) if language == "" { - t.Fatalf("%s: fenced code block missing format at line %d", path, i+1) + t.Fatalf("%s: fenced code block missing language at line %d", path, i+1) } block, next := readFeatureFence(t, path, lines, i+1) - parseFeatureFenceBlock(t, path, &spec, section, language, name, block) + parseFeatureFenceBlock(t, path, &spec, section, subsection, language, block) i = next continue } @@ -300,7 +316,7 @@ func parseFeatureSpec(t *testing.T, path string) featureSpec { func newFeatureSpec(path string) featureSpec { spec := featureSpec{ name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), - sources: make(map[string]string), + sources: make(map[string]featureSource), files: make(map[string]string), results: make(map[string]string), } @@ -317,20 +333,28 @@ func validateFeatureSection(t *testing.T, path, section string) { } } -func parseFeatureFenceBlock(t *testing.T, path string, spec *featureSpec, section, language, name, block string) { +func parseFeatureFenceBlock(t *testing.T, path string, spec *featureSpec, section, subsection, language, block string) { t.Helper() switch section { case "Source Files": - if name == "" { - spec.sources[language] = block + if subsection == "" { + t.Fatalf("%s: source file block missing ### heading", path) + } + file := parseFeatureFileHeading(t, path, subsection) + if file.config { + spec.sources[file.key] = featureSource{filename: file.filename, content: block} } else { - spec.files[name] = block + spec.files[file.filename] = block } case "Result Files": - if name != "" { - t.Fatalf("%s: result file block cannot have filename %q", path, name) + if subsection == "" { + t.Fatalf("%s: result file block missing ### heading", path) + } + file := parseFeatureFileHeading(t, path, subsection) + if !file.config { + t.Fatalf("%s: result file heading %q is not a config file", path, subsection) } - spec.results[language] = block + spec.results[file.key] = block case "Commands": if language != "shell" && language != "sh" { t.Fatalf("%s: command block must use shell, got %q", path, language) @@ -341,6 +365,51 @@ func parseFeatureFenceBlock(t *testing.T, path string, spec *featureSpec, sectio } } +type featureFileHeading struct { + key string + filename string + config bool +} + +func parseFeatureFileHeading(t *testing.T, path, heading string) featureFileHeading { + t.Helper() + kind, filename, explicit := splitFeatureFileHeading(heading) + if ext, ok := configExtension(kind); ok { + if !explicit { + filename = "config." + ext + return featureFileHeading{key: kind, filename: filename, config: true} + } + return featureFileHeading{key: kind + "/" + filename, filename: filename, config: true} + } + if explicit { + t.Fatalf("%s: unknown config file heading %q", path, heading) + } + return featureFileHeading{filename: heading} +} + +func splitFeatureFileHeading(heading string) (string, string, bool) { + trimmed := strings.TrimSpace(heading) + if before, after, ok := strings.Cut(trimmed, " ("); ok && strings.HasSuffix(after, ")") { + return strings.ToLower(strings.TrimSpace(before)), strings.TrimSpace(strings.TrimSuffix(after, ")")), true + } + return strings.ToLower(trimmed), "", false +} + +func configExtension(kind string) (string, bool) { + switch strings.ToLower(kind) { + case "toml": + return "toml", true + case "yaml": + return "yaml", true + case "json": + return "json", true + case "ini": + return "ini", true + default: + return "", false + } +} + func validateFeatureSpec(t *testing.T, path string, spec featureSpec) { t.Helper() if len(spec.sources) == 0 { @@ -460,15 +529,12 @@ func isFeatureFormat(name string) bool { } } -func parseFenceInfo(info string) (string, string) { +func parseFenceLanguage(info string) string { parts := strings.Fields(info) if len(parts) == 0 { - return "", "" - } - if len(parts) == 1 { - return parts[0], "" + return "" } - return parts[0], parts[1] + return parts[0] } func readFeatureFence(t *testing.T, path string, lines []string, start int) (string, int) { @@ -488,7 +554,7 @@ func TestParseFeatureSpec(t *testing.T) { path := filepath.Join("..", "features", "set", "basic.md") spec := parseFeatureSpec(t, path) - if len(spec.sources) != 4 || spec.sources["yaml"] == "" || spec.sources["toml"] == "" || spec.sources["json"] == "" || spec.sources["ini"] == "" { + if len(spec.sources) != 4 || spec.sources["yaml"].content == "" || spec.sources["toml"].content == "" || spec.sources["json"].content == "" || spec.sources["ini"].content == "" { t.Fatalf("sources not parsed: %#v", spec.sources) } if len(spec.commands) != 3 { @@ -517,7 +583,7 @@ func TestParseFeatureSpec(t *testing.T) { } pendingPath := filepath.Join(t.TempDir(), "PENDING-example.md") - if err := os.WriteFile(pendingPath, []byte("# pending/example\n\n> PENDING Example pending reason.\n\n## Source Files\n\n```yaml\nvalue: old\n```\n\n## Commands\n\n```shell\nconfig set value new\n```\n"), 0644); err != nil { + if err := os.WriteFile(pendingPath, []byte("# pending/example\n\n> PENDING Example pending reason.\n\n## Source Files\n\n### YAML\n\n```yaml\nvalue: old\n```\n\n## Commands\n\n```shell\nconfig set value new\n```\n"), 0644); err != nil { t.Fatal(err) } pending := parseFeatureSpec(t, pendingPath) @@ -528,8 +594,8 @@ func TestParseFeatureSpec(t *testing.T) { t.Fatalf("pending reason = %q", pending.pendingReason) } - if language, name := parseFenceInfo("text value.txt"); language != "text" || name != "value.txt" { - t.Fatalf("parseFenceInfo = %q %q", language, name) + if language := parseFenceLanguage("text value.txt"); language != "text" { + t.Fatalf("parseFenceLanguage = %q", language) } } diff --git a/features/README.md b/features/README.md index a141938..69eb1cd 100644 --- a/features/README.md +++ b/features/README.md @@ -10,6 +10,49 @@ The same files are also the acceptance tests. Running `op acceptance` executes the command transcripts and checks the results for every format shown in the feature file. +## Development + +Under `Source Files` and `Result Files`, the `###` heading identifies the file. +Code fence languages are only for Markdown highlighting. + +Config file headings use the supported format names: + +````markdown +### TOML + +```toml +title = "config" +``` +```` + +This writes `config.toml` and runs the command transcript with `CONFIG_FILE` +set to that file. `YAML`, `JSON`, and `INI` work the same way. + +Use an explicit filename in parentheses when the test needs a different config +file name: + +````markdown +### TOML (settings.conf) + +```toml +title = "config" +``` +```` + +Other headings create fixture files without changing `CONFIG_FILE`: + +````markdown +### value.txt + +```text +line one +line two +``` +```` + +Example commands should normally rely on the runner-provided `CONFIG_FILE` +instead of passing `-f` or `--file`. + Files whose names start with `PENDING-`, or files that contain a leading `> PENDING ...` note before the first section, are documented future behavior and are skipped by the acceptance runner. diff --git a/features/formats/auto-detect.md b/features/formats/auto-detect.md new file mode 100644 index 0000000..683403c --- /dev/null +++ b/features/formats/auto-detect.md @@ -0,0 +1,54 @@ +# formats/auto-detect + +Unknown-extension config files are detected from their contents. + +## Source Files + +### TOML (settings.conf) + +```toml +[[servers]] +name = "api" +port = 3000 +``` + +### INI (settings.conf) + +```ini +[servers] +host = localhost +``` + +### YAML (settings.conf) + +```yaml +servers: +- name: api + port: 3000 +``` + +### JSON (settings.conf) + +```json +{ + "servers": [ + { + "name": "api", + "port": 3000 + } + ] +} +``` + +## Commands + +```shell +config list servers +toml -> servers.0.name=api +toml -> servers.0.port=3000 +ini -> servers.host=localhost +yaml -> servers.0.name=api +yaml -> servers.0.port=3000 +json -> servers.0.name=api +json -> servers.0.port=3000 +``` diff --git a/features/formats/hint-comment.md b/features/formats/hint-comment.md new file mode 100644 index 0000000..86a2075 --- /dev/null +++ b/features/formats/hint-comment.md @@ -0,0 +1,36 @@ +# formats/hint-comment + +Format hints disambiguate unknown-extension config files. + +## Source Files + +### TOML (settings.conf) + +```toml +# format: toml +port = 3000 +``` + +### INI (settings.conf) + +```ini +# format: ini +port = 3000 +``` + +### YAML (settings.conf) + +```yaml +# format: yaml +server: + port: 3000 +``` + +## Commands + +```shell +config list +toml -> port=3000 +ini -> port=3000 +yaml -> server.port=3000 +``` diff --git a/features/set/stdin.md b/features/set/stdin.md index e5dbc67..268720e 100644 --- a/features/set/stdin.md +++ b/features/set/stdin.md @@ -6,7 +6,7 @@ Set reads a string value from stdin when the value argument is `-`. ### value.txt -```text value.txt +```text line one line two ``` From 9251e6d5d0e1ec8d5dd5d8558c4952a991af7ecf Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Mon, 8 Jun 2026 12:38:19 +0300 Subject: [PATCH 3/4] add format refusal spec --- cmd/features_test.go | 13 ++++++++----- features/formats/refusals.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 features/formats/refusals.md diff --git a/cmd/features_test.go b/cmd/features_test.go index a25d1ee..3360bbd 100644 --- a/cmd/features_test.go +++ b/cmd/features_test.go @@ -495,9 +495,9 @@ func parseFeatureArrowDirective(line string) (string, string, string, bool) { return "stderr", "", parseFeatureDirectiveText(strings.TrimPrefix(line, "!->")), true } if before, after, ok := strings.Cut(line, " !->"); ok { - formatName := strings.TrimSpace(before) - if isFeatureFormat(formatName) { - return "stderr", formatName, parseFeatureDirectiveText(after), true + target := strings.TrimSpace(before) + if isFeatureOutputTarget(target) { + return "stderr", target, parseFeatureDirectiveText(after), true } } if before, after, ok := strings.Cut(line, " ->"); ok { @@ -505,7 +505,7 @@ func parseFeatureArrowDirective(line string) (string, string, string, bool) { if prefix == "exit" { return "exit", "", parseFeatureDirectiveText(after), true } - if isFeatureFormat(prefix) { + if isFeatureOutputTarget(prefix) { return "stdout", prefix, parseFeatureDirectiveText(after), true } } @@ -520,7 +520,10 @@ func parseFeatureDirectiveText(text string) string { return text } -func isFeatureFormat(name string) bool { +func isFeatureOutputTarget(name string) bool { + if strings.Contains(name, "/") { + return true + } switch name { case "yaml", "toml", "json", "ini": return true diff --git a/features/formats/refusals.md b/features/formats/refusals.md new file mode 100644 index 0000000..bffd4d6 --- /dev/null +++ b/features/formats/refusals.md @@ -0,0 +1,35 @@ +# formats/refusals + +Unknown-extension files are refused when the format cannot be selected safely. + +## Source Files + +### TOML (ambiguous.conf) + +```toml +port = 3000 +``` + +### YAML (unknown.conf) + +```yaml +not config +``` + +### YAML (bad-hint.conf) + +```yaml +# format: xml +server: + port: 3000 +``` + +## Commands + +```shell +config list +exit -> 1 +toml/ambiguous.conf !-> ERROR ambiguous config format for ambiguous.conf; add # format: toml or # format: ini +yaml/unknown.conf !-> ERROR cannot determine config format for unknown.conf; add # format: toml, # format: ini, or # format: yaml +yaml/bad-hint.conf !-> ERROR unsupported format hint "xml" for bad-hint.conf +``` From a5cb025bc952ef96b7d81f39d51d13f656f4989a Mon Sep 17 00:00:00 2001 From: Danny Ben Shitrit Date: Mon, 8 Jun 2026 12:39:31 +0300 Subject: [PATCH 4/4] document auto detect format --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 4077762..6f92a8e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Linux `.deb`, `.rpm`, and `.apk` packages, are in [INSTALL.md](INSTALL.md). - Set, unset, delete, and list values by dot path. - Replace, add, and remove scalar array values with `config array` in TOML, YAML, and JSON files. +- Detect config formats for files with unknown extensions, with explicit + comment hints for ambiguous TOML/INI files. - Preserve comments and source formatting where possible. - Infer common value types such as numbers, booleans, nulls, and dates where the file format supports them.