From 4017e466e49817720a2d43ae45a0bc99ec33764b Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Tue, 28 Apr 2026 13:01:42 +0100 Subject: [PATCH] add model-cli config command with INI file support Introduce 'model-cli config' as a new top-level command with an interface and file format inspired by, but not referencing, 'git config'. - New cmd/cli/iniconfig package: parses and writes INI-style config files (section headers, subsections, boolean keys, inline comments, backslash escapes, quoted values, UTF-8 BOM). Writes are atomic via .lock + rename. - New 'config' command with subcommands: get, set, unset, list, edit. All subcommands accept --global (default per XDG_CONFIG_HOME or ~/.config/model-runner/config), --system (/etc/model-runner/config), and --file/-f flags. - Remove the 'config' alias from 'configure' to avoid a name collision; 'configure' remains hidden and undocumented for existing callers. - 'config' requires no running model-runner instance (pure local file I/O) and is registered outside the withStandaloneRunner group. - Parser: handle trailing comments on section headers ([core] # comment), raise a clear error on lines exceeding 1 MiB, preserve existing file permissions on write (default 0600 for new files). - Editor: split VISUAL/EDITOR on whitespace to support values like 'code --wait'. - Regenerate CLI reference docs. --- cmd/cli/commands/config.go | 351 +++++++++++ cmd/cli/commands/configure.go | 7 +- cmd/cli/commands/root.go | 1 + cmd/cli/docs/reference/docker_model.yaml | 2 + .../docs/reference/docker_model_config.yaml | 44 ++ .../reference/docker_model_config_edit.yaml | 48 ++ .../reference/docker_model_config_get.yaml | 79 +++ .../reference/docker_model_config_list.yaml | 55 ++ .../reference/docker_model_config_set.yaml | 47 ++ .../reference/docker_model_config_unset.yaml | 44 ++ .../reference/docker_model_configure.yaml | 1 - cmd/cli/docs/reference/model.md | 1 + cmd/cli/docs/reference/model_config.md | 38 ++ cmd/cli/docs/reference/model_config_edit.md | 19 + cmd/cli/docs/reference/model_config_get.md | 24 + cmd/cli/docs/reference/model_config_list.md | 21 + cmd/cli/docs/reference/model_config_set.md | 18 + cmd/cli/docs/reference/model_config_unset.md | 16 + cmd/cli/iniconfig/iniconfig.go | 565 ++++++++++++++++++ cmd/cli/iniconfig/iniconfig_test.go | 322 ++++++++++ cmd/cli/iniconfig/testmain_test.go | 12 + 21 files changed, 1710 insertions(+), 5 deletions(-) create mode 100644 cmd/cli/commands/config.go create mode 100644 cmd/cli/docs/reference/docker_model_config.yaml create mode 100644 cmd/cli/docs/reference/docker_model_config_edit.yaml create mode 100644 cmd/cli/docs/reference/docker_model_config_get.yaml create mode 100644 cmd/cli/docs/reference/docker_model_config_list.yaml create mode 100644 cmd/cli/docs/reference/docker_model_config_set.yaml create mode 100644 cmd/cli/docs/reference/docker_model_config_unset.yaml create mode 100644 cmd/cli/docs/reference/model_config.md create mode 100644 cmd/cli/docs/reference/model_config_edit.md create mode 100644 cmd/cli/docs/reference/model_config_get.md create mode 100644 cmd/cli/docs/reference/model_config_list.md create mode 100644 cmd/cli/docs/reference/model_config_set.md create mode 100644 cmd/cli/docs/reference/model_config_unset.md create mode 100644 cmd/cli/iniconfig/iniconfig.go create mode 100644 cmd/cli/iniconfig/iniconfig_test.go create mode 100644 cmd/cli/iniconfig/testmain_test.go diff --git a/cmd/cli/commands/config.go b/cmd/cli/commands/config.go new file mode 100644 index 000000000..f7a9a775b --- /dev/null +++ b/cmd/cli/commands/config.go @@ -0,0 +1,351 @@ +package commands + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/docker/model-runner/cmd/cli/iniconfig" + "github.com/spf13/cobra" +) + +// defaultConfigPath returns the default (global/user-level) config file path. +// It honours XDG_CONFIG_HOME when set: +// +// $XDG_CONFIG_HOME/model-runner/config +// ~/.config/model-runner/config (fallback) +func defaultConfigPath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "model-runner", "config") + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".config", "model-runner", "config") + } + return filepath.Join(home, ".config", "model-runner", "config") +} + +// systemConfigPath returns the system-wide config file path. +func systemConfigPath() string { + if runtime.GOOS == "windows" { + if pd := os.Getenv("ProgramData"); pd != "" { + return filepath.Join(pd, "model-runner", "config") + } + return `C:\ProgramData\model-runner\config` + } + return "/etc/model-runner/config" +} + +// resolveConfigPath picks the config file to operate on, given the flags. +// Exactly one of global, system, or file may be set. +func resolveConfigPath(global, system bool, file string) (string, error) { + count := 0 + if global { + count++ + } + if system { + count++ + } + if file != "" { + count++ + } + if count > 1 { + return "", fmt.Errorf("only one of --global, --system, or --file may be specified") + } + switch { + case system: + return systemConfigPath(), nil + case file != "": + return file, nil + default: + // --global is the default + return defaultConfigPath(), nil + } +} + +// addLocationFlags adds the standard --global/--system/--file flags to a command. +func addLocationFlags(cmd *cobra.Command, global, system *bool, file *string) { + cmd.Flags().BoolVar(global, "global", false, "use the global (user-level) config file") + cmd.Flags().BoolVar(system, "system", false, "use the system-wide config file") + cmd.Flags().StringVarP(file, "file", "f", "", "use a specific config file") +} + +// newConfigCmd returns the top-level "config" command. +func newConfigCmd() *cobra.Command { + c := &cobra.Command{ + Use: "config", + Short: "Read and write model-runner config file values", + Long: `Read and write model-runner config file values. + +The config file uses an INI format with sections and key=value pairs: + + [section] + key = value + [section "subsection"] + key = value + +Keys are specified in dot notation: section.key or section.subsection.key. + +The default file is $XDG_CONFIG_HOME/model-runner/config, falling back to +~/.config/model-runner/config when XDG_CONFIG_HOME is not set. + +Examples: + model-cli config set user.name "Alice" + model-cli config get user.name + model-cli config list + model-cli config unset user.name + model-cli config edit`, + // Do not run a PersistentPreRunE that requires a running model-runner; + // config is pure local-file work. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + + c.AddCommand( + newConfigGetCmd(), + newConfigSetCmd(), + newConfigUnsetCmd(), + newConfigListCmd(), + newConfigEditCmd(), + ) + return c +} + +// newConfigGetCmd implements "model-cli config get ". +func newConfigGetCmd() *cobra.Command { + var ( + global bool + system bool + file string + defaultVal string + hasDefault bool + showAll bool + showOrigin bool + ) + + c := &cobra.Command{ + Use: "get ", + Short: "Get the value of a config key", + Long: `Get the value of a config key. + +Prints the value of the given key to stdout. If the key appears multiple times +(multi-valued), the last value is printed. Use --all to print all values. + +Exit status is 1 if the key is not found (unless --default is given).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := resolveConfigPath(global, system, file) + if err != nil { + return err + } + f, err := iniconfig.Load(path) + if err != nil { + return err + } + + key := args[0] + + if showAll { + vals := f.GetAll(key) + if len(vals) == 0 { + if hasDefault { + cmd.Println(defaultVal) + return nil + } + return fmt.Errorf("key not found: %s", key) + } + for _, v := range vals { + if showOrigin { + cmd.Printf("file:%s\t%s\n", path, v) + } else { + cmd.Println(v) + } + } + return nil + } + + v, ok := f.Get(key) + if !ok { + if hasDefault { + cmd.Println(defaultVal) + return nil + } + return fmt.Errorf("key not found: %s", key) + } + if showOrigin { + cmd.Printf("file:%s\t%s\n", path, v) + } else { + cmd.Println(v) + } + return nil + }, + } + + addLocationFlags(c, &global, &system, &file) + c.Flags().StringVar(&defaultVal, "default", "", "value to emit if the key is not set") + c.Flags().BoolVar(&showAll, "all", false, "print all values for multi-valued keys") + c.Flags().BoolVar(&showOrigin, "show-origin", false, "show the origin (file path) of each value") + // Track whether --default was explicitly provided. + c.PreRunE = func(cmd *cobra.Command, args []string) error { + hasDefault = cmd.Flags().Changed("default") + return nil + } + + return c +} + +// newConfigSetCmd implements "model-cli config set ". +func newConfigSetCmd() *cobra.Command { + var global, system bool + var file string + + c := &cobra.Command{ + Use: "set ", + Short: "Set a config key to a value", + Long: `Set a config key to a value. + +If the key already exists its value is replaced. The file is written atomically.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := resolveConfigPath(global, system, file) + if err != nil { + return err + } + f, err := iniconfig.Load(path) + if err != nil { + return err + } + return f.Set(args[0], args[1]) + }, + } + + addLocationFlags(c, &global, &system, &file) + return c +} + +// newConfigUnsetCmd implements "model-cli config unset ". +func newConfigUnsetCmd() *cobra.Command { + var global, system bool + var file string + + c := &cobra.Command{ + Use: "unset ", + Short: "Remove a config key", + Long: `Remove a config key (and all its values) from the file.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path, err := resolveConfigPath(global, system, file) + if err != nil { + return err + } + f, err := iniconfig.Load(path) + if err != nil { + return err + } + return f.Unset(args[0]) + }, + } + + addLocationFlags(c, &global, &system, &file) + return c +} + +// newConfigListCmd implements "model-cli config list". +func newConfigListCmd() *cobra.Command { + var global, system bool + var file string + var showOrigin bool + + c := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List all config key/value pairs", + Long: `List all key=value pairs from the config file, one per line.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := resolveConfigPath(global, system, file) + if err != nil { + return err + } + f, err := iniconfig.Load(path) + if err != nil { + return err + } + if showOrigin { + for _, e := range f.Entries() { + cmd.Printf("file:%s\t%s=%s\n", path, e.Key, e.Value) + } + return nil + } + return f.List(cmd.OutOrStdout()) + }, + } + + addLocationFlags(c, &global, &system, &file) + c.Flags().BoolVar(&showOrigin, "show-origin", false, "show the origin (file path) of each value") + return c +} + +// newConfigEditCmd implements "model-cli config edit". +func newConfigEditCmd() *cobra.Command { + var global, system bool + var file string + + c := &cobra.Command{ + Use: "edit", + Short: "Open the config file in your editor", + Long: `Open the config file in the default editor. + +The editor is determined by the VISUAL or EDITOR environment variables, +falling back to vi on Unix and notepad on Windows.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + path, err := resolveConfigPath(global, system, file) + if err != nil { + return err + } + // Ensure the file (and its parent directory) exist so the editor + // has something to open. + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + // Create with 0600 — config files may hold sensitive values. + f, err2 := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) + if err2 != nil { + return err2 + } + _ = f.Close() + } + + editorStr := os.Getenv("VISUAL") + if editorStr == "" { + editorStr = os.Getenv("EDITOR") + } + if editorStr == "" { + if runtime.GOOS == "windows" { + editorStr = "notepad" + } else { + editorStr = "vi" + } + } + + // VISUAL/EDITOR may contain arguments (e.g. "code --wait"). + parts := strings.Fields(editorStr) + editorArgs := append(parts[1:], path) + //nolint:gosec // editor is a user-controlled input, which is intentional + editorCmd := exec.CommandContext(cmd.Context(), parts[0], editorArgs...) + editorCmd.Stdin = os.Stdin + editorCmd.Stdout = os.Stdout + editorCmd.Stderr = os.Stderr + return editorCmd.Run() + }, + } + + addLocationFlags(c, &global, &system, &file) + return c +} diff --git a/cmd/cli/commands/configure.go b/cmd/cli/commands/configure.go index 90fe8d0cd..c6ee97ee2 100644 --- a/cmd/cli/commands/configure.go +++ b/cmd/cli/commands/configure.go @@ -11,10 +11,9 @@ func newConfigureCmd() *cobra.Command { var flags ConfigureFlags c := &cobra.Command{ - Use: "configure [--context-size=] [--speculative-draft-model=] [--hf_overrides=] [--gpu-memory-utilization=] [--mode=] [--think] [--keep-alive=] MODEL [-- ]", - Aliases: []string{"config"}, - Short: "Manage model runtime configurations", - Hidden: true, + Use: "configure [--context-size=] [--speculative-draft-model=] [--hf_overrides=] [--gpu-memory-utilization=] [--mode=] [--think] [--keep-alive=] MODEL [-- ]", + Short: "Manage model runtime configurations", + Hidden: true, Args: func(cmd *cobra.Command, args []string) error { argsBeforeDash := cmd.ArgsLenAtDash() if argsBeforeDash == -1 { diff --git a/cmd/cli/commands/root.go b/cmd/cli/commands/root.go index 358e04e2f..a4d59ebd3 100644 --- a/cmd/cli/commands/root.go +++ b/cmd/cli/commands/root.go @@ -105,6 +105,7 @@ func NewRootCmd(cli *command.DockerCli) *cobra.Command { newReinstallRunner(), newSearchCmd(), newSkillsCmd(), + newConfigCmd(), ) rootCmd.AddCommand(newGatewayCmd()) diff --git a/cmd/cli/docs/reference/docker_model.yaml b/cmd/cli/docs/reference/docker_model.yaml index 6d1588f6f..830cd66df 100644 --- a/cmd/cli/docs/reference/docker_model.yaml +++ b/cmd/cli/docs/reference/docker_model.yaml @@ -7,6 +7,7 @@ pname: docker plink: docker.yaml cname: - docker model bench + - docker model config - docker model context - docker model df - docker model gateway @@ -37,6 +38,7 @@ cname: - docker model version clink: - docker_model_bench.yaml + - docker_model_config.yaml - docker_model_context.yaml - docker_model_df.yaml - docker_model_gateway.yaml diff --git a/cmd/cli/docs/reference/docker_model_config.yaml b/cmd/cli/docs/reference/docker_model_config.yaml new file mode 100644 index 000000000..bc4b79192 --- /dev/null +++ b/cmd/cli/docs/reference/docker_model_config.yaml @@ -0,0 +1,44 @@ +command: docker model config +short: Read and write model-runner config file values +long: |- + Read and write model-runner config file values. + + The config file uses an INI format with sections and key=value pairs: + + [section] + key = value + [section "subsection"] + key = value + + Keys are specified in dot notation: section.key or section.subsection.key. + + The default file is $XDG_CONFIG_HOME/model-runner/config, falling back to + ~/.config/model-runner/config when XDG_CONFIG_HOME is not set. + + Examples: + model-cli config set user.name "Alice" + model-cli config get user.name + model-cli config list + model-cli config unset user.name + model-cli config edit +pname: docker model +plink: docker_model.yaml +cname: + - docker model config edit + - docker model config get + - docker model config list + - docker model config set + - docker model config unset +clink: + - docker_model_config_edit.yaml + - docker_model_config_get.yaml + - docker_model_config_list.yaml + - docker_model_config_set.yaml + - docker_model_config_unset.yaml +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/cmd/cli/docs/reference/docker_model_config_edit.yaml b/cmd/cli/docs/reference/docker_model_config_edit.yaml new file mode 100644 index 000000000..f9421c5f6 --- /dev/null +++ b/cmd/cli/docs/reference/docker_model_config_edit.yaml @@ -0,0 +1,48 @@ +command: docker model config edit +short: Open the config file in your editor +long: |- + Open the config file in the default editor. + + The editor is determined by the VISUAL or EDITOR environment variables, + falling back to vi on Unix and notepad on Windows. +usage: docker model config edit +pname: docker model config +plink: docker_model_config.yaml +options: + - option: file + shorthand: f + value_type: string + description: use a specific config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: global + value_type: bool + default_value: "false" + description: use the global (user-level) config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: system + value_type: bool + default_value: "false" + description: use the system-wide config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/cmd/cli/docs/reference/docker_model_config_get.yaml b/cmd/cli/docs/reference/docker_model_config_get.yaml new file mode 100644 index 000000000..3baf54ce2 --- /dev/null +++ b/cmd/cli/docs/reference/docker_model_config_get.yaml @@ -0,0 +1,79 @@ +command: docker model config get +short: Get the value of a config key +long: |- + Get the value of a config key. + + Prints the value of the given key to stdout. If the key appears multiple times + (multi-valued), the last value is printed. Use --all to print all values. + + Exit status is 1 if the key is not found (unless --default is given). +usage: docker model config get +pname: docker model config +plink: docker_model_config.yaml +options: + - option: all + value_type: bool + default_value: "false" + description: print all values for multi-valued keys + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: default + value_type: string + description: value to emit if the key is not set + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: file + shorthand: f + value_type: string + description: use a specific config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: global + value_type: bool + default_value: "false" + description: use the global (user-level) config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: show-origin + value_type: bool + default_value: "false" + description: show the origin (file path) of each value + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: system + value_type: bool + default_value: "false" + description: use the system-wide config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/cmd/cli/docs/reference/docker_model_config_list.yaml b/cmd/cli/docs/reference/docker_model_config_list.yaml new file mode 100644 index 000000000..1d0112733 --- /dev/null +++ b/cmd/cli/docs/reference/docker_model_config_list.yaml @@ -0,0 +1,55 @@ +command: docker model config list +aliases: docker model config list, docker model config ls +short: List all config key/value pairs +long: List all key=value pairs from the config file, one per line. +usage: docker model config list +pname: docker model config +plink: docker_model_config.yaml +options: + - option: file + shorthand: f + value_type: string + description: use a specific config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: global + value_type: bool + default_value: "false" + description: use the global (user-level) config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: show-origin + value_type: bool + default_value: "false" + description: show the origin (file path) of each value + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: system + value_type: bool + default_value: "false" + description: use the system-wide config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/cmd/cli/docs/reference/docker_model_config_set.yaml b/cmd/cli/docs/reference/docker_model_config_set.yaml new file mode 100644 index 000000000..84bd1b97f --- /dev/null +++ b/cmd/cli/docs/reference/docker_model_config_set.yaml @@ -0,0 +1,47 @@ +command: docker model config set +short: Set a config key to a value +long: |- + Set a config key to a value. + + If the key already exists its value is replaced. The file is written atomically. +usage: docker model config set +pname: docker model config +plink: docker_model_config.yaml +options: + - option: file + shorthand: f + value_type: string + description: use a specific config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: global + value_type: bool + default_value: "false" + description: use the global (user-level) config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: system + value_type: bool + default_value: "false" + description: use the system-wide config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/cmd/cli/docs/reference/docker_model_config_unset.yaml b/cmd/cli/docs/reference/docker_model_config_unset.yaml new file mode 100644 index 000000000..778067f2b --- /dev/null +++ b/cmd/cli/docs/reference/docker_model_config_unset.yaml @@ -0,0 +1,44 @@ +command: docker model config unset +short: Remove a config key +long: Remove a config key (and all its values) from the file. +usage: docker model config unset +pname: docker model config +plink: docker_model_config.yaml +options: + - option: file + shorthand: f + value_type: string + description: use a specific config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: global + value_type: bool + default_value: "false" + description: use the global (user-level) config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false + - option: system + value_type: bool + default_value: "false" + description: use the system-wide config file + deprecated: false + hidden: false + experimental: false + experimentalcli: false + kubernetes: false + swarm: false +deprecated: false +hidden: false +experimental: false +experimentalcli: false +kubernetes: false +swarm: false + diff --git a/cmd/cli/docs/reference/docker_model_configure.yaml b/cmd/cli/docs/reference/docker_model_configure.yaml index 77f914fdc..9849785b9 100644 --- a/cmd/cli/docs/reference/docker_model_configure.yaml +++ b/cmd/cli/docs/reference/docker_model_configure.yaml @@ -1,5 +1,4 @@ command: docker model configure -aliases: docker model configure, docker model config short: Manage model runtime configurations long: Manage model runtime configurations usage: docker model configure [--context-size=] [--speculative-draft-model=] [--hf_overrides=] [--gpu-memory-utilization=] [--mode=] [--think] [--keep-alive=] MODEL [-- ] diff --git a/cmd/cli/docs/reference/model.md b/cmd/cli/docs/reference/model.md index e26c01924..af79fca7d 100644 --- a/cmd/cli/docs/reference/model.md +++ b/cmd/cli/docs/reference/model.md @@ -8,6 +8,7 @@ Docker Model Runner | Name | Description | |:------------------------------------------------|:-----------------------------------------------------------------------| | [`bench`](model_bench.md) | Benchmark a model's performance at different concurrency levels | +| [`config`](model_config.md) | Read and write model-runner config file values | | [`context`](model_context.md) | Manage Docker Model Runner contexts | | [`df`](model_df.md) | Show Docker Model Runner disk usage | | [`gateway`](model_gateway.md) | Run an OpenAI-compatible LLM gateway | diff --git a/cmd/cli/docs/reference/model_config.md b/cmd/cli/docs/reference/model_config.md new file mode 100644 index 000000000..31b59f577 --- /dev/null +++ b/cmd/cli/docs/reference/model_config.md @@ -0,0 +1,38 @@ +# docker model config + + +Read and write model-runner config file values. + +The config file uses an INI format with sections and key=value pairs: + + [section] + key = value + [section "subsection"] + key = value + +Keys are specified in dot notation: section.key or section.subsection.key. + +The default file is $XDG_CONFIG_HOME/model-runner/config, falling back to +~/.config/model-runner/config when XDG_CONFIG_HOME is not set. + +Examples: + model-cli config set user.name "Alice" + model-cli config get user.name + model-cli config list + model-cli config unset user.name + model-cli config edit + +### Subcommands + +| Name | Description | +|:---------------------------------|:------------------------------------| +| [`edit`](model_config_edit.md) | Open the config file in your editor | +| [`get`](model_config_get.md) | Get the value of a config key | +| [`list`](model_config_list.md) | List all config key/value pairs | +| [`set`](model_config_set.md) | Set a config key to a value | +| [`unset`](model_config_unset.md) | Remove a config key | + + + + + diff --git a/cmd/cli/docs/reference/model_config_edit.md b/cmd/cli/docs/reference/model_config_edit.md new file mode 100644 index 000000000..fe5d8a93a --- /dev/null +++ b/cmd/cli/docs/reference/model_config_edit.md @@ -0,0 +1,19 @@ +# docker model config edit + + +Open the config file in the default editor. + +The editor is determined by the VISUAL or EDITOR environment variables, +falling back to vi on Unix and notepad on Windows. + +### Options + +| Name | Type | Default | Description | +|:---------------|:---------|:--------|:----------------------------------------| +| `-f`, `--file` | `string` | | use a specific config file | +| `--global` | `bool` | | use the global (user-level) config file | +| `--system` | `bool` | | use the system-wide config file | + + + + diff --git a/cmd/cli/docs/reference/model_config_get.md b/cmd/cli/docs/reference/model_config_get.md new file mode 100644 index 000000000..f79bf7800 --- /dev/null +++ b/cmd/cli/docs/reference/model_config_get.md @@ -0,0 +1,24 @@ +# docker model config get + + +Get the value of a config key. + +Prints the value of the given key to stdout. If the key appears multiple times +(multi-valued), the last value is printed. Use --all to print all values. + +Exit status is 1 if the key is not found (unless --default is given). + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:------------------------------------------| +| `--all` | `bool` | | print all values for multi-valued keys | +| `--default` | `string` | | value to emit if the key is not set | +| `-f`, `--file` | `string` | | use a specific config file | +| `--global` | `bool` | | use the global (user-level) config file | +| `--show-origin` | `bool` | | show the origin (file path) of each value | +| `--system` | `bool` | | use the system-wide config file | + + + + diff --git a/cmd/cli/docs/reference/model_config_list.md b/cmd/cli/docs/reference/model_config_list.md new file mode 100644 index 000000000..60bdd297d --- /dev/null +++ b/cmd/cli/docs/reference/model_config_list.md @@ -0,0 +1,21 @@ +# docker model config list + + +List all key=value pairs from the config file, one per line. + +### Aliases + +`docker model config list`, `docker model config ls` + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:------------------------------------------| +| `-f`, `--file` | `string` | | use a specific config file | +| `--global` | `bool` | | use the global (user-level) config file | +| `--show-origin` | `bool` | | show the origin (file path) of each value | +| `--system` | `bool` | | use the system-wide config file | + + + + diff --git a/cmd/cli/docs/reference/model_config_set.md b/cmd/cli/docs/reference/model_config_set.md new file mode 100644 index 000000000..f826d8dab --- /dev/null +++ b/cmd/cli/docs/reference/model_config_set.md @@ -0,0 +1,18 @@ +# docker model config set + + +Set a config key to a value. + +If the key already exists its value is replaced. The file is written atomically. + +### Options + +| Name | Type | Default | Description | +|:---------------|:---------|:--------|:----------------------------------------| +| `-f`, `--file` | `string` | | use a specific config file | +| `--global` | `bool` | | use the global (user-level) config file | +| `--system` | `bool` | | use the system-wide config file | + + + + diff --git a/cmd/cli/docs/reference/model_config_unset.md b/cmd/cli/docs/reference/model_config_unset.md new file mode 100644 index 000000000..512e0ec21 --- /dev/null +++ b/cmd/cli/docs/reference/model_config_unset.md @@ -0,0 +1,16 @@ +# docker model config unset + + +Remove a config key (and all its values) from the file. + +### Options + +| Name | Type | Default | Description | +|:---------------|:---------|:--------|:----------------------------------------| +| `-f`, `--file` | `string` | | use a specific config file | +| `--global` | `bool` | | use the global (user-level) config file | +| `--system` | `bool` | | use the system-wide config file | + + + + diff --git a/cmd/cli/iniconfig/iniconfig.go b/cmd/cli/iniconfig/iniconfig.go new file mode 100644 index 000000000..7658ed557 --- /dev/null +++ b/cmd/cli/iniconfig/iniconfig.go @@ -0,0 +1,565 @@ +// Package iniconfig implements reading and writing of INI-style config files. +// The format uses sections, optional subsections, and key=value pairs: +// +// [section] +// key = value +// [section "subsection"] +// key = value +// +// Key names are of the form "section.key" or "section.subsection.key". +// Section names and variable names are case-insensitive; subsection names are +// case-sensitive. +package iniconfig + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unicode" +) + +// maxConfigLineBytes is the hard cap on a single config line length (1 MiB). +// This guards against unbounded memory use on pathological inputs while still +// accommodating large values such as certificates or long tokens. +const maxConfigLineBytes = 1 << 20 + +// Entry is a single key/value pair from a config file. +type Entry struct { + // Key is the canonical dotted key: "section.variable" or + // "section.subsection.variable". Section and variable are lowercased; + // subsection preserves its original case. + Key string + Value string +} + +// File represents a parsed config file and the path it was read from. +type File struct { + path string + entries []Entry +} + +// Path returns the file path associated with this File. +func (f *File) Path() string { return f.path } + +// Entries returns all key/value pairs in file order. +func (f *File) Entries() []Entry { return f.entries } + +// ---------------------------------------------------------------------------- +// Reading +// ---------------------------------------------------------------------------- + +// Load reads the config file at path. If the file does not exist an empty File +// is returned without error. +func Load(path string) (*File, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &File{path: path}, nil + } + return nil, err + } + entries, err := parse(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return &File{path: path, entries: entries}, nil +} + +// parse parses INI bytes into a slice of Entries. +func parse(data []byte) ([]Entry, error) { + // Strip UTF-8 BOM if present. + data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) + + var entries []Entry + var section, subsection string + + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 0, 64*1024), maxConfigLineBytes) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + // Empty line or comment. + if trimmed == "" || trimmed[0] == '#' || trimmed[0] == ';' { + continue + } + + if trimmed[0] == '[' { + // Section header. + var err error + section, subsection, err = parseSectionHeader(trimmed) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNum, err) + } + continue + } + + // Key-value (or boolean key). + if section == "" { + return nil, fmt.Errorf("line %d: key outside of section", lineNum) + } + key, value, err := parseKeyValue(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", lineNum, err) + } + canonical := canonicalKey(section, subsection, key) + entries = append(entries, Entry{Key: canonical, Value: value}) + } + if err := scanner.Err(); err != nil { + if errors.Is(err, bufio.ErrTooLong) { + return nil, fmt.Errorf("line %d: config line too long (max %d bytes)", lineNum+1, maxConfigLineBytes) + } + return nil, err + } + return entries, nil +} + +// parseSectionHeader parses "[section]" or `[section "subsection"]`. +// Anything after the closing ']' (e.g. inline comments) is ignored. +// section is returned lowercased; subsection preserves case. +func parseSectionHeader(line string) (section, subsection string, err error) { + // Find the closing bracket; ignore anything that follows (inline comment). + closeIdx := strings.IndexByte(line, ']') + if closeIdx < 0 { + return "", "", fmt.Errorf("invalid section header: %q", line) + } + inner := line[1:closeIdx] + + // Check for subsection: section "subsection" + if idx := strings.Index(inner, "\""); idx >= 0 { + rawSection := strings.TrimRight(inner[:idx], " \t") + rawSubsection := inner[idx:] + if !strings.HasPrefix(rawSubsection, "\"") || !strings.HasSuffix(rawSubsection, "\"") || len(rawSubsection) < 2 { + return "", "", fmt.Errorf("invalid section header: %q", line) + } + sub, err2 := unescapeSubsection(rawSubsection[1 : len(rawSubsection)-1]) + if err2 != nil { + return "", "", fmt.Errorf("invalid subsection in %q: %w", line, err2) + } + return strings.ToLower(rawSection), sub, nil + } + + return strings.ToLower(strings.TrimSpace(inner)), "", nil +} + +// unescapeSubsection handles backslash escapes inside subsection names. +// \\ and \" are unescaped; a lone \ followed by any other char is silently +// dropped (the character after it is kept). +func unescapeSubsection(s string) (string, error) { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+1 < len(s) { + i++ + switch s[i] { + case '\\': + b.WriteByte('\\') + case '"': + b.WriteByte('"') + default: + // Unknown escape: drop the backslash, keep the character. + b.WriteByte(s[i]) + } + continue + } + b.WriteByte(s[i]) + } + return b.String(), nil +} + +// parseKeyValue parses a line of the form " key = value # comment" or a +// boolean " key". Supports line continuation with trailing backslash. +func parseKeyValue(line string) (key, value string, err error) { + trimmed := strings.TrimLeft(line, " \t") + + eqIdx := strings.IndexByte(trimmed, '=') + if eqIdx < 0 { + // Boolean key: no "=", value is implicitly "true". + key = strings.TrimRight(trimmed, " \t") + if err2 := validateVarName(key); err2 != nil { + return "", "", err2 + } + return strings.ToLower(key), "true", nil + } + + key = strings.TrimRight(trimmed[:eqIdx], " \t") + if err2 := validateVarName(key); err2 != nil { + return "", "", err2 + } + + raw := strings.TrimLeft(trimmed[eqIdx+1:], " \t") + val, err2 := parseValue(raw) + if err2 != nil { + return "", "", err2 + } + return strings.ToLower(key), val, nil +} + +// parseValue decodes the value portion of a key-value line, handling quoting, +// escape sequences, and inline comments. +func parseValue(raw string) (string, error) { + var b strings.Builder + inQuotes := false + i := 0 + for i < len(raw) { + c := raw[i] + switch { + case !inQuotes && (c == '#' || c == ';'): + // Inline comment — stop. + goto done + case !inQuotes && c == '"': + inQuotes = true + i++ + case inQuotes && c == '"': + inQuotes = false + i++ + case c == '\\': + if i+1 >= len(raw) { + // Trailing backslash = line continuation (we don't handle + // multi-line here; treat as end of value). + goto done + } + i++ + switch raw[i] { + case 'n': + b.WriteByte('\n') + case 't': + b.WriteByte('\t') + case 'b': + b.WriteByte('\b') + case '"': + b.WriteByte('"') + case '\\': + b.WriteByte('\\') + default: + return "", fmt.Errorf("unknown escape sequence \\%c", raw[i]) + } + i++ + default: + b.WriteByte(c) + i++ + } + } +done: + if inQuotes { + return "", fmt.Errorf("unterminated quoted string") + } + result := b.String() + if !inQuotes { + result = strings.TrimRight(result, " \t") + } + return result, nil +} + +// validateVarName ensures a variable name contains only [A-Za-z0-9-] and +// starts with a letter. +func validateVarName(name string) error { + if name == "" { + return fmt.Errorf("empty variable name") + } + if !unicode.IsLetter(rune(name[0])) { + return fmt.Errorf("variable name %q must start with a letter", name) + } + for _, c := range name { + if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '-' { + return fmt.Errorf("invalid character %q in variable name %q", c, name) + } + } + return nil +} + +// canonicalKey assembles the canonical dotted key. +func canonicalKey(section, subsection, variable string) string { + section = strings.ToLower(section) + variable = strings.ToLower(variable) + if subsection == "" { + return section + "." + variable + } + return section + "." + subsection + "." + variable +} + +// ---------------------------------------------------------------------------- +// Key parsing (for CLI inputs) +// ---------------------------------------------------------------------------- + +// ParseKey splits a dotted key "section.variable" or +// "section.subsection.variable" into its components. Section and variable are +// lowercased; subsection preserves case. The split point is the last dot. +func ParseKey(key string) (section, subsection, variable string, err error) { + // The last dot separates the variable from the section[.subsection] part. + lastDot := strings.LastIndex(key, ".") + if lastDot < 0 { + return "", "", "", fmt.Errorf("invalid key %q: must contain at least one dot", key) + } + variable = strings.ToLower(key[lastDot+1:]) + prefix := key[:lastDot] + + // The first dot (if any) separates section from subsection. + firstDot := strings.Index(prefix, ".") + if firstDot < 0 { + section = strings.ToLower(prefix) + subsection = "" + } else { + section = strings.ToLower(prefix[:firstDot]) + subsection = prefix[firstDot+1:] // subsection preserves case + } + + if section == "" { + return "", "", "", fmt.Errorf("invalid key %q: empty section", key) + } + if variable == "" { + return "", "", "", fmt.Errorf("invalid key %q: empty variable", key) + } + if err2 := validateVarName(variable); err2 != nil { + return "", "", "", fmt.Errorf("invalid key %q: %w", key, err2) + } + return section, subsection, variable, nil +} + +// ---------------------------------------------------------------------------- +// Querying +// ---------------------------------------------------------------------------- + +// Get returns the last value for the given canonical key. The second return +// value is false if the key is not present. +func (f *File) Get(key string) (string, bool) { + section, subsection, variable, err := ParseKey(key) + if err != nil { + return "", false + } + canonical := canonicalKey(section, subsection, variable) + found := false + last := "" + for _, e := range f.entries { + if e.Key == canonical { + last = e.Value + found = true + } + } + return last, found +} + +// GetAll returns all values for the given canonical key. +func (f *File) GetAll(key string) []string { + section, subsection, variable, err := ParseKey(key) + if err != nil { + return nil + } + canonical := canonicalKey(section, subsection, variable) + var vals []string + for _, e := range f.entries { + if e.Key == canonical { + vals = append(vals, e.Value) + } + } + return vals +} + +// ---------------------------------------------------------------------------- +// Writing +// ---------------------------------------------------------------------------- + +// Set writes key=value to the file, replacing the last existing occurrence or +// appending if absent. The file is written atomically via a lock file. +func (f *File) Set(key, value string) error { + section, subsection, variable, err := ParseKey(key) + if err != nil { + return err + } + canonical := canonicalKey(section, subsection, variable) + return f.writeAtomic(func(entries []Entry) []Entry { + replaced := false + for i := len(entries) - 1; i >= 0; i-- { + if entries[i].Key == canonical { + entries[i].Value = value + replaced = true + break + } + } + if !replaced { + entries = append(entries, Entry{Key: canonical, Value: value}) + } + return entries + }) +} + +// Unset removes all occurrences of key from the file. +func (f *File) Unset(key string) error { + section, subsection, variable, err := ParseKey(key) + if err != nil { + return err + } + canonical := canonicalKey(section, subsection, variable) + return f.writeAtomic(func(entries []Entry) []Entry { + out := entries[:0] + for _, e := range entries { + if e.Key != canonical { + out = append(out, e) + } + } + return out + }) +} + +// writeAtomic applies transform to the in-memory entries, serialises the +// result to disk atomically (write to .lock → rename), and updates f.entries. +func (f *File) writeAtomic(transform func([]Entry) []Entry) error { + if err := os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil { + return err + } + lockPath := f.path + ".lock" + + // Preserve the existing file's permissions; default to 0600 for new files + // so that config files containing sensitive values are not world-readable. + mode := os.FileMode(0o600) + if info, err := os.Stat(f.path); err == nil { + mode = info.Mode() + } + + newEntries := transform(append([]Entry(nil), f.entries...)) + + data := serialise(newEntries) + if err := os.WriteFile(lockPath, data, mode); err != nil { + return err + } + if err := os.Rename(lockPath, f.path); err != nil { + _ = os.Remove(lockPath) + return err + } + f.entries = newEntries + return nil +} + +// serialise converts a slice of Entries to INI-format bytes. +// Sections are grouped; within each group, key-value lines are tab-indented. +func serialise(entries []Entry) []byte { + var buf bytes.Buffer + + type sectionKey struct { + section string + subsection string + } + + // Preserve insertion order of sections while grouping entries. + type sectionEntry struct { + key sectionKey + items []Entry + } + + var order []sectionKey + groups := map[sectionKey]*sectionEntry{} + + for _, e := range entries { + sec, sub, _, _ := splitCanonical(e.Key) + sk := sectionKey{sec, sub} + if _, ok := groups[sk]; !ok { + order = append(order, sk) + groups[sk] = §ionEntry{key: sk} + } + groups[sk].items = append(groups[sk].items, e) + } + + for _, sk := range order { + g := groups[sk] + buf.WriteString(formatSectionHeader(g.key.section, g.key.subsection)) + for _, e := range g.items { + _, _, variable, _ := splitCanonical(e.Key) + buf.WriteString(formatKeyValue(variable, e.Value)) + } + } + + return buf.Bytes() +} + +// splitCanonical splits a canonical key "section[.subsection].variable" into +// its three parts using the same last-dot logic as ParseKey. +func splitCanonical(canonical string) (section, subsection, variable string, err error) { + lastDot := strings.LastIndex(canonical, ".") + if lastDot < 0 { + return "", "", "", fmt.Errorf("bad canonical key %q", canonical) + } + variable = canonical[lastDot+1:] + prefix := canonical[:lastDot] + firstDot := strings.Index(prefix, ".") + if firstDot < 0 { + section = prefix + } else { + section = prefix[:firstDot] + subsection = prefix[firstDot+1:] + } + return section, subsection, variable, nil +} + +// formatSectionHeader formats a section header line. +func formatSectionHeader(section, subsection string) string { + if subsection == "" { + return fmt.Sprintf("[%s]\n", section) + } + return "[" + section + ` "` + escapeSubsection(subsection) + "\"]\n" +} + +// escapeSubsection escapes backslashes and double-quotes in a subsection name. +func escapeSubsection(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return s +} + +// formatKeyValue formats a key = value line with proper quoting. +func formatKeyValue(variable, value string) string { + return fmt.Sprintf("\t%s = %s\n", variable, quoteValue(value)) +} + +// quoteValue wraps a value in double-quotes when it contains characters that +// would be misinterpreted by the parser (leading/trailing space, #, ;, \r). +// It also applies backslash escaping inside quoted strings. +func quoteValue(v string) string { + needsQuote := v != "" && (v[0] == ' ' || v[0] == '\t' || v[len(v)-1] == ' ' || v[len(v)-1] == '\t') + for _, c := range v { + if c == '#' || c == ';' || c == '\r' || c == '\n' || c == '\\' || c == '"' { + needsQuote = true + break + } + } + if !needsQuote { + return v + } + var b strings.Builder + b.WriteByte('"') + for _, c := range v { + switch c { + case '\\': + b.WriteString(`\\`) + case '"': + b.WriteString(`\"`) + case '\n': + b.WriteString(`\n`) + case '\t': + b.WriteString(`\t`) + default: + b.WriteRune(c) + } + } + b.WriteByte('"') + return b.String() +} + +// ---------------------------------------------------------------------------- +// Listing +// ---------------------------------------------------------------------------- + +// List writes all key=value pairs to w, one per line. +func (f *File) List(w io.Writer) error { + for _, e := range f.entries { + if _, err := fmt.Fprintf(w, "%s=%s\n", e.Key, e.Value); err != nil { + return err + } + } + return nil +} diff --git a/cmd/cli/iniconfig/iniconfig_test.go b/cmd/cli/iniconfig/iniconfig_test.go new file mode 100644 index 000000000..299b86509 --- /dev/null +++ b/cmd/cli/iniconfig/iniconfig_test.go @@ -0,0 +1,322 @@ +package iniconfig_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/docker/model-runner/cmd/cli/iniconfig" +) + +// roundTrip writes entries to a temp file, reads them back, and checks they +// match the expected key/value pairs. +func roundTrip(t *testing.T, content string, wantEntries []iniconfig.Entry) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + f, err := iniconfig.Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(f.Entries()) != len(wantEntries) { + t.Fatalf("got %d entries, want %d\nentries: %v", len(f.Entries()), len(wantEntries), f.Entries()) + } + for i, e := range f.Entries() { + if e.Key != wantEntries[i].Key || e.Value != wantEntries[i].Value { + t.Errorf("entry[%d]: got {%q %q}, want {%q %q}", i, e.Key, e.Value, wantEntries[i].Key, wantEntries[i].Value) + } + } +} + +func TestParse_SimpleSection(t *testing.T) { + roundTrip(t, ` +[core] + bare = false + filemode = true +`, []iniconfig.Entry{ + {Key: "core.bare", Value: "false"}, + {Key: "core.filemode", Value: "true"}, + }) +} + +func TestParse_Subsection(t *testing.T) { + roundTrip(t, ` +[branch "main"] + remote = origin + merge = refs/heads/main +`, []iniconfig.Entry{ + {Key: "branch.main.remote", Value: "origin"}, + {Key: "branch.main.merge", Value: "refs/heads/main"}, + }) +} + +func TestParse_CaseInsensitiveSection(t *testing.T) { + roundTrip(t, ` +[Core] + Bare = false +`, []iniconfig.Entry{ + {Key: "core.bare", Value: "false"}, + }) +} + +func TestParse_SubsectionCaseSensitive(t *testing.T) { + roundTrip(t, ` +[branch "Main"] + remote = origin +[branch "main"] + remote = upstream +`, []iniconfig.Entry{ + {Key: "branch.Main.remote", Value: "origin"}, + {Key: "branch.main.remote", Value: "upstream"}, + }) +} + +func TestParse_BooleanKey(t *testing.T) { + roundTrip(t, ` +[core] + bare +`, []iniconfig.Entry{ + {Key: "core.bare", Value: "true"}, + }) +} + +func TestParse_InlineComment(t *testing.T) { + roundTrip(t, ` +[core] + name = hello # world +`, []iniconfig.Entry{ + {Key: "core.name", Value: "hello"}, + }) +} + +func TestParse_QuotedValue(t *testing.T) { + roundTrip(t, ` +[core] + name = "hello world" +`, []iniconfig.Entry{ + {Key: "core.name", Value: "hello world"}, + }) +} + +func TestParse_EscapeSequences(t *testing.T) { + roundTrip(t, ` +[core] + name = "hello\nworld" +`, []iniconfig.Entry{ + {Key: "core.name", Value: "hello\nworld"}, + }) +} + +func TestParse_BOM(t *testing.T) { + content := "\xEF\xBB\xBF[core]\n\tbare = false\n" + roundTrip(t, content, []iniconfig.Entry{ + {Key: "core.bare", Value: "false"}, + }) +} + +func TestParse_Comments(t *testing.T) { + roundTrip(t, ` +# This is a comment +; This is also a comment +[core] + # inline section comment + bare = false +`, []iniconfig.Entry{ + {Key: "core.bare", Value: "false"}, + }) +} + +func TestParse_SectionHeaderTrailingComment(t *testing.T) { + roundTrip(t, ` +[core] # this is a trailing comment + bare = false +`, []iniconfig.Entry{ + {Key: "core.bare", Value: "false"}, + }) +} + +func TestParse_FilePermissionsPreserved(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + // Create with restrictive permissions. + if err := os.WriteFile(path, []byte("[core]\n\tbare = false\n"), 0o600); err != nil { + t.Fatal(err) + } + f, err := iniconfig.Load(path) + if err != nil { + t.Fatal(err) + } + if err := f.Set("core.filemode", "true"); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode() != 0o600 { + t.Errorf("expected mode 0600, got %v", info.Mode()) + } +} + +func TestLoadMissing(t *testing.T) { + f, err := iniconfig.Load("/nonexistent/path/to/config") + if err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } + if len(f.Entries()) != 0 { + t.Fatalf("expected empty entries for missing file, got: %v", f.Entries()) + } +} + +func TestGetAndSet(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + f, err := iniconfig.Load(path) + if err != nil { + t.Fatal(err) + } + + if err := f.Set("core.bare", "false"); err != nil { + t.Fatal(err) + } + if v, ok := f.Get("core.bare"); !ok || v != "false" { + t.Fatalf("Get after Set: got %q, %v; want %q, true", v, ok, "false") + } + + // Overwrite + if err := f.Set("core.bare", "true"); err != nil { + t.Fatal(err) + } + if v, ok := f.Get("core.bare"); !ok || v != "true" { + t.Fatalf("Get after overwrite: got %q, %v; want %q, true", v, ok, "true") + } +} + +func TestSetSubsection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + f, _ := iniconfig.Load(path) + + if err := f.Set(`branch.main.remote`, "origin"); err != nil { + t.Fatal(err) + } + if v, ok := f.Get("branch.main.remote"); !ok || v != "origin" { + t.Fatalf("got %q, %v", v, ok) + } +} + +func TestUnset(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + f, _ := iniconfig.Load(path) + _ = f.Set("core.bare", "false") + _ = f.Set("core.filemode", "true") + + if err := f.Unset("core.bare"); err != nil { + t.Fatal(err) + } + if _, ok := f.Get("core.bare"); ok { + t.Fatal("expected core.bare to be removed") + } + if v, ok := f.Get("core.filemode"); !ok || v != "true" { + t.Fatalf("core.filemode should still be present, got %q, %v", v, ok) + } +} + +func TestAtomicWrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + f, _ := iniconfig.Load(path) + _ = f.Set("user.name", "Alice") + + // Reload from disk and verify. + f2, err := iniconfig.Load(path) + if err != nil { + t.Fatal(err) + } + if v, ok := f2.Get("user.name"); !ok || v != "Alice" { + t.Fatalf("reload: got %q, %v", v, ok) + } +} + +func TestParseKey(t *testing.T) { + tests := []struct { + input string + section string + subsection string + variable string + wantErr bool + }{ + {"core.bare", "core", "", "bare", false}, + {"branch.main.remote", "branch", "main", "remote", false}, + {"url.https://example.com/.insteadof", "url", "https://example.com/", "insteadof", false}, + {"nokey", "", "", "", true}, + {"section.", "", "", "", true}, + } + for _, tt := range tests { + sec, sub, vari, err := iniconfig.ParseKey(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseKey(%q): err=%v, wantErr=%v", tt.input, err, tt.wantErr) + continue + } + if !tt.wantErr && (sec != tt.section || sub != tt.subsection || vari != tt.variable) { + t.Errorf("ParseKey(%q) = (%q, %q, %q), want (%q, %q, %q)", + tt.input, sec, sub, vari, tt.section, tt.subsection, tt.variable) + } + } +} + +func TestList(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + f, _ := iniconfig.Load(path) + _ = f.Set("user.name", "Alice") + _ = f.Set("user.email", "alice@example.com") + + var sb strings.Builder + if err := f.List(&sb); err != nil { + t.Fatal(err) + } + got := sb.String() + if !strings.Contains(got, "user.name=Alice\n") { + t.Errorf("missing user.name in list output:\n%s", got) + } + if !strings.Contains(got, "user.email=alice@example.com\n") { + t.Errorf("missing user.email in list output:\n%s", got) + } +} + +func TestSerialiseRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + f, _ := iniconfig.Load(path) + _ = f.Set("core.bare", "false") + _ = f.Set("core.filemode", "true") + _ = f.Set("branch.main.remote", "origin") + + // Reload and verify structure is preserved. + f2, err := iniconfig.Load(path) + if err != nil { + t.Fatal(err) + } + entries := f2.Entries() + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d: %v", len(entries), entries) + } +} + +func TestQuotedValueWithSpecialChars(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + f, _ := iniconfig.Load(path) + _ = f.Set("url.value", "value with # hash") + + f2, _ := iniconfig.Load(path) + if v, ok := f2.Get("url.value"); !ok || v != "value with # hash" { + t.Fatalf("got %q, %v", v, ok) + } +} diff --git a/cmd/cli/iniconfig/testmain_test.go b/cmd/cli/iniconfig/testmain_test.go new file mode 100644 index 000000000..27951055e --- /dev/null +++ b/cmd/cli/iniconfig/testmain_test.go @@ -0,0 +1,12 @@ +package iniconfig_test + +import ( + "testing" + + "go.uber.org/goleak" +) + +// TestMain runs goleak after the test suite to detect goroutine leaks. +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +}