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 import ./settings.bend as Settings 8 9 def truthy.items(j: Json.Json) -> Bool: 10 match j: 11 case Json.JNil{}: 12 False{} 13 case _: 14 True{} 15 16 # Python truthiness of a JSON value. 17 def truthy(j: Json.Json) -> Bool: 18 match j: 19 case Json.JNull{}: 20 False{} 21 case Json.JBool{b}: 22 b 23 case Json.JNum{t}: 24 Bool.not(Settings.is_zero(Settings.lit(t))) 25 case Json.JStr{s}: 26 Bool.not(String.is_empty(s)) 27 case Json.JArr{items}: 28 truthy.items(items) 29 case Json.JObj{fields}: 30 truthy.items(fields) 31 case _: 32 False{} 33 34 def empty() -> Json.Json: 35 Json.of_pairs([]) 36 37 def obj_or_empty(j: Json.Json) -> Json.Json: 38 match j: 39 case Json.JObj{f}: 40 Json.JObj{f} 41 case _: 42 empty() 43 44 def has_str(xs: List<&2, String>, +v: String) -> Bool: 45 match xs: 46 case Nil{}: 47 False{} 48 case Con{h, t}: 49 String.eq(h, v) || has_str(t, v) 50 51 def get_or_null(+key: String, +j: Json.Json) -> Json.Json: 52 Maybe.default(&2, Json.Json, Json.get(key, j), Json.JNull{}) 53 54 def int_text(d: Settings.Dec) -> String: 55 match d: 56 case Settings.Dec{neg, i, f}: 57 i 58 59 def norm.show(+isint: Bool, +d: Settings.Dec) -> Json.Json: 60 Json.JNum{Settings.sel.s(isint, int_text(d), Settings.repr(d))} 61 62 def norm.fin(m: Maybe<&2, Settings.Dec>, isint: Bool, lo: String, hi: String, dflt: Json.Json) -> Json.Json: 63 match m: 64 case Some{d}: 65 norm.show(isint, Settings.clamp(d, Settings.lit(lo), Settings.lit(hi))) 66 case None{}: 67 dflt 68 69 def norm.numv(isint: Bool, lo: String, hi: String, dflt: Json.Json, v: Json.Json) -> Json.Json: 70 match v: 71 case Json.JNum{t}: 72 norm.fin(Settings.num(t), isint, lo, hi, dflt) 73 case _: 74 dflt 75 76 def norm.pick(ok: Bool, v: Json.Json, dflt: Json.Json) -> Json.Json: 77 match ok: 78 case True{}: 79 v 80 case False{}: 81 dflt 82 83 def norm.choice(choices: String, dflt: Json.Json, v: Json.Json) -> Json.Json: 84 match v: 85 case Json.JStr{+s}: 86 norm.pick(has_str(String.split(choices, ','), s), Json.JStr{s}, dflt) 87 case _: 88 dflt 89 90 def norm.str(dflt: Json.Json, v: Json.Json) -> Json.Json: 91 match v: 92 case Json.JStr{+s}: 93 norm.pick(Bool.not(String.is_empty(s)), Json.JStr{s}, dflt) 94 case _: 95 dflt 96 97 # One setting's lenient value: clamped into range, or the default for a value 98 # of the wrong type. 99 def norm.kind(kind: Settings.Sort, lo: String, hi: String, choices: String, dflt: Json.Json, v: Json.Json) -> Json.Json: 100 match kind: 101 case Settings.KFloat{}: 102 norm.numv(False{}, lo, hi, dflt, v) 103 case Settings.KInt{}: 104 norm.numv(True{}, lo, hi, dflt, v) 105 case Settings.KBool{}: 106 Json.JBool{truthy(v)} 107 case Settings.KChoice{}: 108 norm.choice(choices, dflt, v) 109 case Settings.KString{}: 110 norm.str(dflt, v) 111 112 def norm.one2(hit: Bool, key: String, v: Json.Json, out: Json.Json) -> Json.Json: 113 match hit: 114 case True{}: 115 Json.set(key, v, out) 116 case False{}: 117 out 118 119 def norm.one(sp: Settings.Spec, sec: String, src: Json.Json, out: Json.Json) -> Json.Json: 120 match sp: 121 case Settings.Spec{section, +key, kind, lo, hi, choices, dflt}: 122 norm.one2(String.eq(section, sec), key, norm.kind(kind, lo, hi, choices, dflt, get_or_null(key, src)), out) 123 124 def norm.sec(xs: List<&2, Settings.Spec>, +sec: String, +src: Json.Json, out: Json.Json) -> Json.Json: 125 match xs: 126 case Nil{}: 127 out 128 case Con{h, t}: 129 norm.sec(t, sec, src, norm.one(h, sec, src, out)) 130 131 def sect_obj(m: Maybe<&2, Json.Json>) -> Json.Json: 132 match m: 133 case Some{j}: 134 obj_or_empty(j) 135 case None{}: 136 empty() 137 138 # The section object (`{}` when missing or not an object). 139 def section_of(+raw: Json.Json, +sec: String) -> Json.Json: 140 sect_obj(Json.get(sec, raw)) 141 142 def norm.section2(+sec: String, base: Json.Json, +s: Json.Json) -> Json.Json: 143 Json.set(sec, norm.sec(Settings.specs(), sec, s, s), base) 144 145 def norm.section(+sec: String, +base: Json.Json) -> Json.Json: 146 norm.section2(sec, base, section_of(base, sec)) 147 148 def ver.pick(m: Maybe<&2, Json.Json>, raw: Json.Json) -> Json.Json: 149 match m: 150 case Some{v}: 151 raw 152 case None{}: 153 Json.set("schemaVersion", Json.JNum{"1"}, raw) 154 155 # raw.setdefault("schemaVersion", 1) 156 def with_version(+raw: Json.Json) -> Json.Json: 157 ver.pick(Json.get("schemaVersion", raw), raw) 158 159 # The whole settings object with every known key present: defaults filled in, 160 # numbers clamped, wrong types replaced. Unknown keys and sections survive. 161 def normalize(raw: Json.Json) -> Json.Json: 162 norm.section("ui", norm.section("autoswitch", with_version(obj_or_empty(raw)))) 163 164 def path(+root: String) -> String: 165 root ++ "/settings.json" 166 167 def raw.parsed(r: Result<&2, &2, String, Json.Json>) -> Json.Json: 168 match r: 169 case Fail{e}: 170 empty() 171 case Done{j}: 172 obj_or_empty(j) 173 174 def raw.of(r: Fs.Res(String)) -> Json.Json: 175 match r: 176 case Fail{e}: 177 empty() 178 case Done{t}: 179 raw.parsed(Json.parse(t)) 180 181 # settings.json as read by the lenient paths: `{}` for a missing, unreadable, 182 # torn or non-object file. 183 def load_raw(+root: String) -> IO(Json.Json): 184 IO.bind(Fs.Res(String), Json.Json, Fs.read_file(path(root)), r => IO.pure(Json.Json, raw.of(r))) 185 186 # Lenient load: the full object, defaults filled, numbers clamped. 187 def load(+root: String) -> IO(Json.Json): 188 IO.bind(Json.Json, Json.Json, load_raw(root), raw => IO.pure(Json.Json, normalize(raw))) 189 190 def autoswitch(+s: Json.Json, +key: String) -> Json.Json: 191 get_or_null(key, get_or_null("autoswitch", s)) 192 193 def text_of(j: Json.Json) -> String: 194 match j: 195 case Json.JNum{t}: 196 t 197 case _: 198 "" 199 200 # Decimal text as Python prints the float: "90.0". 201 def threshold(s: Json.Json) -> String: 202 text_of(autoswitch(s, "threshold")) 203 204 def interval_seconds(s: Json.Json) -> String: 205 text_of(autoswitch(s, "intervalSeconds")) 206 207 def cooldown_seconds(s: Json.Json) -> String: 208 text_of(autoswitch(s, "cooldownSeconds")) 209 210 def hysteresis_pct(s: Json.Json) -> String: 211 text_of(autoswitch(s, "hysteresisPct")) 212 213 def strategy.of(j: Json.Json) -> String: 214 match j: 215 case Json.JStr{s}: 216 s 217 case _: 218 "best" 219 220 def strategy(s: Json.Json) -> String: 221 strategy.of(autoswitch(s, "strategy")) 222 223 def include_api_key_accounts(s: Json.Json) -> Bool: 224 truthy(autoswitch(s, "includeApiKeyAccounts")) 225 226 def unhealthy_ticks(s: Json.Json) -> U32: 227 Maybe.default(&2, U32, U32.read(text_of(autoswitch(s, "unhealthyTicks"))), 3) 228 229 # Tenths of a percent or second (floored) for integer comparisons. 230 def threshold_tenths(s: Json.Json) -> U32: 231 Settings.tenths(threshold(s)) 232 233 def interval_tenths(s: Json.Json) -> U32: 234 Settings.tenths(interval_seconds(s)) 235 236 def cooldown_tenths(s: Json.Json) -> U32: 237 Settings.tenths(cooldown_seconds(s)) 238 239 def hysteresis_tenths(s: Json.Json) -> U32: 240 Settings.tenths(hysteresis_pct(s)) 241 242 def models.keep2(skip: Bool, +q: String, rest: List<&2, String>) -> List<&2, String>: 243 match skip: 244 case True{}: 245 rest 246 case False{}: 247 q <> rest 248 249 def models.keep(+q: String, rest: List<&2, String>) -> List<&2, String>: 250 models.keep2(String.is_empty(q), q, rest) 251 252 # Each part trimmed, empties dropped. 253 def models.clean(ps: List<&2, String>) -> List<&2, String>: 254 match ps: 255 case Nil{}: 256 [] 257 case Con{p, t}: 258 models.keep(String.trim(p), models.clean(t)) 259 260 def models.without2(same: Bool, +h: String, rest: List<&2, String>) -> List<&2, String>: 261 match same: 262 case True{}: 263 rest 264 case False{}: 265 h <> rest 266 267 # The list without the entries equal to `low` ignoring case. 268 def models.without(xs: List<&2, String>, +low: String) -> List<&2, String>: 269 match xs: 270 case Nil{}: 271 [] 272 case Con{+h, t}: 273 models.without2(String.eq(String.to_lower(h), low), h, models.without(t, low)) 274 275 def models.dedup(fuel: Nat, xs: List<&2, String>) -> List<&2, String>: 276 match fuel xs: 277 case 0n _: 278 xs 279 case 1n+p Nil{}: 280 [] 281 case 1n+p Con{+h, t}: 282 h <> models.dedup(p, models.without(t, String.to_lower(h))) 283 284 def models.dedup2(+xs: List<&2, String>) -> List<&2, String>: 285 models.dedup(List.length(&2, String, xs), xs) 286 287 # parse_model_names: split on `,`, trim, drop empties, de-duplicate 288 # case-insensitively (the first spelling wins). 289 def model_names(v: String) -> List<&2, String>: 290 models.dedup2(models.clean(String.split(v, ','))) 291 292 def model.of(j: Json.Json) -> List<&2, String>: 293 match j: 294 case Json.JStr{s}: 295 model_names(s) 296 case _: 297 [] 298 299 # `autoswitch.model` as a list of names ([] = account-wide windows only). 300 def model(s: Json.Json) -> List<&2, String>: 301 model.of(autoswitch(s, "model")) 302 303 # A numeric flag clamped into the setting's range, as Settings.repr text; None for 304 # text that is not a number. 305 def clamp_text.fin(m: Maybe<&2, Settings.Dec>, lo: String, hi: String) -> Maybe<&2, String>: 306 match m: 307 case Some{d}: 308 Some{Settings.repr(Settings.clamp(d, Settings.lit(lo), Settings.lit(hi)))} 309 case None{}: 310 None{} 311 312 def clamp_text(+s: String, lo: String, hi: String) -> Maybe<&2, String>: 313 clamp_text.fin(Settings.num(s), lo, hi) 314 315 # `--threshold 120` -> "99.9" 316 def clamp_threshold(s: String) -> Maybe<&2, String>: 317 clamp_text(s, "50", "99.9") 318 319 # `--interval 5` -> "15.0" 320 def clamp_interval(s: String) -> Maybe<&2, String>: 321 clamp_text(s, "15", "3600") 322 323 def clamp_cooldown(s: String) -> Maybe<&2, String>: 324 clamp_text(s, "0", "86400") 325 326 def clamp_hysteresis(s: String) -> Maybe<&2, String>: 327 clamp_text(s, "0", "50") 328 329 # A --strategy flag: an unknown value falls back to the default. 330 def clamp_strategy.pick(ok: Bool, s: String) -> String: 331 match ok: 332 case True{}: 333 s 334 case False{}: 335 "best" 336 337 def clamp_strategy(+s: String) -> String: 338 clamp_strategy.pick(has_str(["best", "consume-first"], s), s) 339