config.gleam

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5import envoy
  6import filepath
  7import gleam/dict.{type Dict}
  8
  9import gleam/result
 10import gleam/string
 11import simplifile
 12import tom.{type Toml}
 13
 14/// Reasoning effort level for thinking models.
 15pub type ReasoningEffort {
 16  ReasoningLow
 17  ReasoningMedium
 18  ReasoningHigh
 19}
 20
 21/// User's reasoning preference: not specified, explicitly disabled, or a specific level.
 22pub type ReasoningSetting {
 23  ReasoningNotSet
 24  ReasoningDisabled
 25  ReasoningEnabled(ReasoningEffort)
 26}
 27
 28/// Parse a reasoning effort string into a ReasoningSetting.
 29pub fn parse_reasoning(value: String) -> ReasoningSetting {
 30  case string.lowercase(value) {
 31    "" -> ReasoningNotSet
 32    "none" | "off" | "disabled" -> ReasoningDisabled
 33    "low" -> ReasoningEnabled(ReasoningLow)
 34    "medium" -> ReasoningEnabled(ReasoningMedium)
 35    "high" -> ReasoningEnabled(ReasoningHigh)
 36    _ -> ReasoningNotSet
 37  }
 38}
 39
 40/// Convert ReasoningEffort to the API string representation.
 41pub fn reasoning_to_string(effort: ReasoningEffort) -> String {
 42  case effort {
 43    ReasoningLow -> "low"
 44    ReasoningMedium -> "medium"
 45    ReasoningHigh -> "high"
 46  }
 47}
 48
 49pub type Config {
 50  Config(
 51    provider: String,
 52    model: String,
 53    api_key: String,
 54    api_key_cmd: String,
 55    directions: String,
 56    reasoning: ReasoningSetting,
 57    endpoint: String,
 58  )
 59}
 60
 61pub fn default() -> Config {
 62  Config(
 63    provider: "",
 64    model: "",
 65    api_key: "",
 66    api_key_cmd: "",
 67    directions: "",
 68    reasoning: ReasoningNotSet,
 69    endpoint: "",
 70  )
 71}
 72
 73/// Load config from XDG_CONFIG_HOME/garble/config.toml or ~/.config/garble/config.toml
 74pub fn load() -> Config {
 75  case config_path() {
 76    Error(_) -> default()
 77    Ok(path) ->
 78      case simplifile.read(path) {
 79        Error(_) -> default()
 80        Ok(content) ->
 81          case tom.parse(content) {
 82            Error(_) -> default()
 83            Ok(parsed) -> parse_config(parsed)
 84          }
 85      }
 86  }
 87}
 88
 89fn config_path() -> Result(String, Nil) {
 90  let config_dir = case envoy.get("XDG_CONFIG_HOME") {
 91    Ok(xdg) -> xdg
 92    Error(_) ->
 93      case envoy.get("HOME") {
 94        Ok(home) -> filepath.join(home, ".config")
 95        Error(_) -> ""
 96      }
 97  }
 98
 99  case config_dir {
100    "" -> Error(Nil)
101    dir -> {
102      let path = filepath.join(dir, "garble/config.toml")
103      case simplifile.is_file(path) {
104        Ok(True) -> Ok(path)
105        _ -> Error(Nil)
106      }
107    }
108  }
109}
110
111fn parse_config(parsed: Dict(String, Toml)) -> Config {
112  Config(
113    provider: get_string(parsed, "provider"),
114    model: get_string(parsed, "model"),
115    api_key: get_string(parsed, "api_key"),
116    api_key_cmd: get_string(parsed, "api_key_cmd"),
117    directions: get_string(parsed, "directions"),
118    reasoning: parse_reasoning(get_string(parsed, "reasoning")),
119    endpoint: get_string(parsed, "endpoint"),
120  )
121}
122
123fn get_string(parsed: Dict(String, Toml), key: String) -> String {
124  tom.get_string(parsed, [key])
125  |> result.unwrap("")
126}
127
128/// Merge CLI flags over config values. CLI takes precedence when non-empty.
129pub fn merge(
130  cfg: Config,
131  cli_provider cli_provider: String,
132  cli_model cli_model: String,
133  cli_directions cli_directions: String,
134  cli_reasoning cli_reasoning: String,
135  cli_endpoint cli_endpoint: String,
136) -> Config {
137  Config(
138    provider: prefer_nonempty(cli_provider, cfg.provider),
139    model: prefer_nonempty(cli_model, cfg.model),
140    api_key: cfg.api_key,
141    api_key_cmd: cfg.api_key_cmd,
142    directions: prefer_nonempty(cli_directions, cfg.directions),
143    reasoning: prefer_reasoning(cli_reasoning, cfg.reasoning),
144    endpoint: prefer_nonempty(cli_endpoint, cfg.endpoint),
145  )
146}
147
148fn prefer_reasoning(cli: String, fallback: ReasoningSetting) -> ReasoningSetting {
149  case parse_reasoning(cli) {
150    ReasoningNotSet -> fallback
151    setting -> setting
152  }
153}
154
155fn prefer_nonempty(cli: String, fallback: String) -> String {
156  case cli {
157    "" -> fallback
158    val -> val
159  }
160}