1 import Base 2 import ./fs/write.bend as FWrite 3 import ./json.bend as Json 4 import ./fs.bend as Fs 5 import ./act.bend as Act 6 import ./cmd.bend as Cmd 7 8 # settings.json and `cswap config`, as the reference's settings.py and 9 # cli.py `_config_command` (claude-swap 0.27.2). 10 # 11 # Numbers stay decimal TEXT. `Dec` is a canonical decimal (no leading zeros in 12 # the integer part, no trailing zeros in the fraction); comparison is on the 13 # digit strings, so clamping needs no floats. Python's float repr is rebuilt 14 # from the text: `85` -> `85.0`, `85.50` -> `85.5`, `0.00001` -> `1e-05`. 15 # Known gaps, all rare: `1_0` style underscores, more than 15 significant 16 # digits (Python rounds to 17), and a file number such as `1.50` that the 17 # reference would rewrite as `1.5` on an unrelated `set`. 18 19 # ---- decimals -------------------------------------------------------------- 20 21 # ---- the settings registry ------------------------------------------------- 22 23 # ---- lenient load (settings.py `_clamped`, `load_settings`) ----------------- 24 25 # ---- accessors on a loaded (normalized) settings object ---------------------- 26 27 # ---- CLI override clamps (merged_with_cli) ----------------------------------- 28 29 # ---- strict write (settings.py `parse_setting_value`) ------------------------- 30 31 # ---- editing the raw object -------------------------------------------------- 32 33 # ---- effective settings and their text ---------------------------------------- 34 35 # ---- the config command --------------------------------------------------------- 36 37 # ---- argv ----------------------------------------------------------------------- 38 39 type Dec is Data: 40 Dec{neg: Bool, i: String, f: String} 41 42 type Exp is Data: 43 Exp{neg: Bool, n: Nat} 44 45 def sel.s(c: Bool, x: String, y: String) -> String: 46 match c: 47 case True{}: 48 x 49 case False{}: 50 y 51 52 def all_digits(s: String) -> Bool: 53 match s: 54 case SNil{}: 55 True{} 56 case SCon{c, t}: 57 Char.is_digit(c) && all_digits(t) 58 59 def drop0.pick(z: Bool, c: Char, t: String, rest: String) -> String: 60 match z: 61 case True{}: 62 rest 63 case False{}: 64 SCon{c, t} 65 66 # Leading '0' characters dropped. 67 def drop0(s: String) -> String: 68 match s: 69 case SNil{}: 70 SNil{} 71 case SCon{+c, +t}: 72 drop0.pick(Char.is_eq(c, '0'), c, t, drop0(t)) 73 74 def canon(neg: Bool, +i: String, f: String) -> Dec: 75 Dec{neg, sel.s(String.is_empty(drop0(i)), "0", drop0(i)), String.reverse(drop0(String.reverse(f)))} 76 77 # Move the point n digits right (digits come from the fraction, zeros pad). 78 def shr(n: Nat, neg: Bool, i: String, f: String) -> Dec: 79 match n f: 80 case 0n _: 81 canon(neg, i, f) 82 case 1n+p SNil{}: 83 shr(p, neg, i ++ "0", "") 84 case 1n+p SCon{c, t}: 85 shr(p, neg, i ++ SCon{c, SNil{}}, t) 86 87 # Move the point n digits left; `ri` is the integer part reversed. 88 def shl(n: Nat, neg: Bool, ri: String, f: String) -> Dec: 89 match n ri: 90 case 0n _: 91 canon(neg, String.reverse(ri), f) 92 case 1n+p SNil{}: 93 shl(p, neg, "", "0" ++ f) 94 case 1n+p SCon{c, t}: 95 shl(p, neg, t, SCon{c, f}) 96 97 def shift.pick(eneg: Bool, n: Nat, neg: Bool, i: String, f: String) -> Dec: 98 match eneg: 99 case True{}: 100 shl(n, neg, String.reverse(i), f) 101 case False{}: 102 shr(n, neg, i, f) 103 104 def shift(d: Dec, e: Exp) -> Dec: 105 match d e: 106 case Dec{neg, i, f} Exp{eneg, n}: 107 shift.pick(eneg, n, neg, i, f) 108 109 def exp.cap2(+d: String) -> Nat: 110 U32.to_nat(U32.min(Maybe.default(&2, U32, U32.read(d), 400), 400)) 111 112 # The exponent digits as a Nat, capped at 400 (beyond that the value clamps 113 # or vanishes the same way). 114 def exp.cap(+d: String) -> Nat: 115 exp.cap2(sel.s(Nat.is_gt(String.length(d), 3n), "400", d)) 116 117 def exp.pick(ok: Bool, neg: Bool, d: String) -> Maybe<&2, Exp>: 118 match ok: 119 case True{}: 120 Some{Exp{neg, exp.cap(d)}} 121 case False{}: 122 None{} 123 124 def exp.of3(neg: Bool, +d: String) -> Maybe<&2, Exp>: 125 exp.pick(all_digits(d) && Bool.not(String.is_empty(d)), neg, d) 126 127 def exp.of2(+neg: Bool, plus: Bool, +x: String) -> Maybe<&2, Exp>: 128 exp.of3(neg, sel.s(neg || plus, String.drop(x, 1n), x)) 129 130 # The exponent text after `e`: an optional sign and at least one digit. 131 def exp.of(+x: String) -> Maybe<&2, Exp>: 132 exp.of2(String.starts_with(x, "-"), String.starts_with(x, "+"), x) 133 134 def mant.pick(ok: Bool, neg: Bool, i: String, f: String) -> Maybe<&2, Dec>: 135 match ok: 136 case True{}: 137 Some{canon(neg, i, f)} 138 case False{}: 139 None{} 140 141 def mant.mk(neg: Bool, +i: String, +f: String) -> Maybe<&2, Dec>: 142 mant.pick(all_digits(i) && all_digits(f) && Bool.not(String.is_empty(i) && String.is_empty(f)), neg, i, f) 143 144 def mant.parts(neg: Bool, ps: List<&2, String>) -> Maybe<&2, Dec>: 145 match ps: 146 case Con{i, Nil{}}: 147 mant.mk(neg, i, "") 148 case Con{i, Con{f, Nil{}}}: 149 mant.mk(neg, i, f) 150 case _: 151 None{} 152 153 def num.join(m: Maybe<&2, Dec>, e: Maybe<&2, Exp>) -> Maybe<&2, Dec>: 154 match m e: 155 case Some{d} Some{x}: 156 Some{shift(d, x)} 157 case _ _: 158 None{} 159 160 def num.parts(neg: Bool, ps: List<&2, String>) -> Maybe<&2, Dec>: 161 match ps: 162 case Con{m, Nil{}}: 163 num.join(mant.parts(neg, String.split(m, '.')), Some{Exp{False{}, 0n}}) 164 case Con{m, Con{x, Nil{}}}: 165 num.join(mant.parts(neg, String.split(m, '.')), exp.of(x)) 166 case _: 167 None{} 168 169 def num.sign(+neg: Bool, plus: Bool, +s: String) -> Maybe<&2, Dec>: 170 num.parts(neg, String.split(String.to_lower(sel.s(neg || plus, String.drop(s, 1n), s)), 'e')) 171 172 def num.trimmed(+s: String) -> Maybe<&2, Dec>: 173 num.sign(String.starts_with(s, "-"), String.starts_with(s, "+"), s) 174 175 # Python's float() / JSON number text as a decimal: optional sign, digits with 176 # an optional point, optional exponent; surrounding spaces ignored. Not `inf` 177 # or `nan` (see `special`). 178 def num(+s: String) -> Maybe<&2, Dec>: 179 num.trimmed(String.trim(s)) 180 181 def lit(+s: String) -> Dec: 182 Maybe.default(&2, Dec, num(s), Dec{False{}, "0", ""}) 183 184 def is_zero(d: Dec) -> Bool: 185 match d: 186 case Dec{neg, i, f}: 187 String.eq(i, "0") && String.is_empty(f) 188 189 # a < b on magnitudes. 190 def mag_lt(a: Dec, b: Dec) -> Bool: 191 match a b: 192 case Dec{na, +ia, +fa} Dec{nb, +ib, +fb}: 193 Nat.is_lt(String.length(ia), String.length(ib)) 194 || (Nat.is_eq(String.length(ia), String.length(ib)) 195 && (String.is_lt(ia, ib) || (String.eq(ia, ib) && String.is_lt(fa, fb)))) 196 197 def neg_nonzero(+d: Dec) -> Bool: 198 match d: 199 case Dec{neg, i, f}: 200 neg && Bool.not(is_zero(Dec{neg, i, f})) 201 202 def below(+v: Dec, +lo: Dec) -> Bool: 203 neg_nonzero(v) || mag_lt(v, lo) 204 205 def above(+v: Dec, +hi: Dec) -> Bool: 206 Bool.not(neg_nonzero(v)) && mag_lt(hi, v) 207 208 def clamp.pick(b: Bool, a: Bool, v: Dec, lo: Dec, hi: Dec) -> Dec: 209 match b a: 210 case True{} _: 211 lo 212 case False{} True{}: 213 hi 214 case False{} False{}: 215 v 216 217 # min(max(v, lo), hi) 218 def clamp(+v: Dec, +lo: Dec, +hi: Dec) -> Dec: 219 clamp.pick(below(v, lo), above(v, hi), v, lo, hi) 220 221 def lead0.pick(z: Bool, rest: Nat) -> Nat: 222 match z: 223 case True{}: 224 1n+rest 225 case False{}: 226 0n 227 228 def lead0(s: String) -> Nat: 229 match s: 230 case SNil{}: 231 0n 232 case SCon{+c, +t}: 233 lead0.pick(Char.is_eq(c, '0'), lead0(t)) 234 235 def pad2(+n: Nat) -> String: 236 sel.s(Nat.is_lt(n, 10n), "0", "") ++ U32.show(U32.from_nat(n)) 237 238 def sci.tail(t: String) -> String: 239 match t: 240 case SNil{}: 241 "" 242 case SCon{h, r}: 243 "." ++ SCon{h, r} 244 245 def sci.mant(sig: String) -> String: 246 match sig: 247 case SNil{}: 248 "0" 249 case SCon{h, r}: 250 SCon{h, sci.tail(r)} 251 252 # 1e-05 style, for 0 < value < 1e-4 (the fraction has four leading zeros). 253 def sci(neg: Bool, +f: String) -> String: 254 sel.s(neg, "-", "") ++ sci.mant(drop0(f)) ++ "e-" ++ pad2(1n+lead0(f)) 255 256 def repr.pick(tiny: Bool, neg: Bool, i: String, +f: String) -> String: 257 match tiny: 258 case True{}: 259 sci(neg, f) 260 case False{}: 261 sel.s(neg, "-", "") ++ i ++ "." ++ sel.s(String.is_empty(f), "0", f) 262 263 # Python's repr(float) of the decimal. 264 def repr(d: Dec) -> String: 265 match d: 266 case Dec{neg, +i, +f}: 267 repr.pick(String.eq(i, "0") && String.starts_with(f, "0000"), neg, i, f) 268 269 def fmt.pick(whole: Bool, d: Dec) -> String: 270 match d: 271 case Dec{neg, +i, +f}: 272 sel.s(whole, i, repr(Dec{neg, i, f})) 273 274 # format_setting_value for a float: `85.0` prints as `85`. 275 def fmt(d: Dec) -> String: 276 match d: 277 case Dec{neg, i, +f}: 278 fmt.pick(String.is_empty(f), Dec{neg, i, f}) 279 280 # The integer part times ten plus the first fraction digit (tenths, floored). 281 def tenths.fin(i: String, f: String) -> U32: 282 (Maybe.default(&2, U32, U32.read(i), 0) * 10 + Maybe.default(&2, U32, U32.read(String.take(f, 1n)), 0) : U32) 283 284 def tenths.dec(d: Dec) -> U32: 285 match d: 286 case Dec{neg, i, f}: 287 tenths.fin(i, f) 288 289 # Tenths of a decimal text (floored); 0 for text that is not a number. 290 def tenths(s: String) -> U32: 291 tenths.dec(lit(s)) 292 293 type Sort is Data: 294 KFloat{} 295 KInt{} 296 KBool{} 297 KChoice{} 298 KString{} 299 300 # `lo`/`hi` are decimal text exactly as the reference prints them; `choices` 301 # is comma separated; `dflt` is the default as a JSON value. 302 type Spec is Data: 303 Spec{section: String, key: String, kind: Sort, lo: String, hi: String, choices: String, dflt: Json.Json} 304 305 def spec.num(key: String, kind: Sort, lo: String, hi: String, dflt: String) -> Spec: 306 Spec{"autoswitch", key, kind, lo, hi, "", Json.JNum{dflt}} 307 308 # Registry order, as SETTING_SPECS. 309 def specs() -> List<&2, Spec>: 310 [spec.num("threshold", KFloat{}, "50", "99.9", "90.0"), 311 spec.num("intervalSeconds", KFloat{}, "15", "3600", "60.0"), 312 spec.num("cooldownSeconds", KFloat{}, "0", "86400", "300.0"), 313 spec.num("hysteresisPct", KFloat{}, "0", "50", "10.0"), 314 Spec{"autoswitch", "strategy", KChoice{}, "", "", "best,consume-first", Json.JStr{"best"}}, 315 Spec{"autoswitch", "includeApiKeyAccounts", KBool{}, "", "", "", Json.JBool{False{}}}, 316 spec.num("unhealthyTicks", KInt{}, "1", "100", "3"), 317 Spec{"autoswitch", "model", KString{}, "", "", "", Json.JNull{}}, 318 Spec{"ui", "theme", KChoice{}, "", "", "dark,light,auto", Json.JStr{"auto"}}] 319 320 def dotted(+sp: Spec) -> String: 321 match sp: 322 case Spec{section, key, kind, lo, hi, choices, dflt}: 323 section ++ "." ++ key 324 325 def find.pick(hit: Bool, sp: Spec, rest: Maybe<&2, Spec>) -> Maybe<&2, Spec>: 326 match hit: 327 case True{}: 328 Some{sp} 329 case False{}: 330 rest 331 332 def find(+key: String, xs: List<&2, Spec>) -> Maybe<&2, Spec>: 333 match xs: 334 case Nil{}: 335 None{} 336 case Con{+h, t}: 337 find.pick(String.eq(dotted(h), key), h, find(key, t)) 338 339 def dotted_all(xs: List<&2, Spec>) -> List<&2, String>: 340 match xs: 341 case Nil{}: 342 [] 343 case Con{+h, t}: 344 dotted(h) <> dotted_all(t) 345 346 # setting_spec: the spec of a dotted key, or the reference's error text. 347 def spec_of.pick(m: Maybe<&2, Spec>, key: String) -> Result<&2, &2, String, Spec>: 348 match m: 349 case Some{sp}: 350 Done{sp} 351 case None{}: 352 Fail{"unknown setting '" ++ key ++ "'\nValid keys: " ++ String.join(dotted_all(specs()), ", ")} 353 354 def spec_of(+key: String) -> Result<&2, &2, String, Spec>: 355 spec_of.pick(find(key, specs()), key)