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}