setimp.bend

cswap-bend-cli@0.1.0.0 · 388 lines · 50 definitions

1import Base
2import cswap-bend-cli@0.1.0.0/json.bend as Json
3import cswap-bend-cli@0.1.0.0/setload.bend as Setload
4import cswap-bend-cli@0.1.0.0/settings.bend as Settings
5import cswap-bend-cli@0.1.0.0/setwrite.bend as Setwrite
6import cswap-bend-cli@0.1.0.0/profile.bend as P
7import cswap-bend-cli@0.1.0.0/policy/safe.bend as SF
8import cswap-bend-cli@0.1.0.0/fmt/yaml.bend as Yaml
9import cswap-bend-cli@0.1.0.0/fmt/toon.bend as Toon
10import cswap-bend-cli@0.1.0.0/fmt/csv.bend as Csv
11import cswap-bend-cli@0.1.0.0/fmt/ylex.bend as L
12import cswap-bend-cli@0.1.0.0/fmt/yscal.bend as S
13
14# The pure half of `config import` / `profile import`: the format of the input,
15# the document it holds, the checks `config set` and `profile set` make on
16# each known key (unknown keys pass through), and the merge into the settings
17# already on disk. No effects.
18
19def or_null(m: Maybe<&2, Json.Json>) -> Json.Json:
20  match m:
21    case Some{v}:
22      v
23    case None{}:
24      Json.JNull{}
25
26# ---- which format is the input? -------------------------------------------------------
27
28def last_part(ps: List<&2, String>) -> String:
29  match ps:
30    case Nil{}:
31      ""
32    case Con{h, Nil{}}:
33      h
34    case Con{h, Con{h2, t}}:
35      last_part(Con{h2, t})
36
37def ext_of2(ps: List<&2, String>) -> String:
38  match ps:
39    case Con{h, Con{h2, t}}:
40      String.to_lower(last_part(Con{h2, t}))
41    case _:
42      ""
43
44# The lower-case extension of a path ("" when there is none).
45def ext_of(+path: String) -> String:
46  ext_of2(String.split(path, '.'))
47
48def known_name(+n: String) -> Maybe<&2, String>:
49  Bool.pick(Maybe<&2, String>, String.eq(n, "json"), Some{"json"},
50  Bool.pick(Maybe<&2, String>, String.eq(n, "yaml") || String.eq(n, "yml"), Some{"yaml"},
51  Bool.pick(Maybe<&2, String>, String.eq(n, "toon"), Some{"toon"},
52  Bool.pick(Maybe<&2, String>, String.eq(n, "csv"), Some{"csv"}, None{}))))
53
54def fmt.named(m: Maybe<&2, String>, +n: String) -> Result<&2, &2, String, String>:
55  match m:
56    case Some{f}:
57      Done{f}
58    case None{}:
59      Fail{"unknown format '" ++ n ++ "' (json, yaml, toon, csv)"}
60
61def fmt.ext(m: Maybe<&2, String>, +path: String) -> Result<&2, &2, String, String>:
62  match m:
63    case Some{f}:
64      Done{f}
65    case None{}:
66      Fail{"cannot tell the format of '" ++ path ++ "' from its extension; pass --format json|yaml|toon|csv"}
67
68# `--format NAME` when given, else the file's extension.
69def pick_format(explicit: Maybe<&2, String>, +path: String) -> Result<&2, &2, String, String>:
70  match explicit:
71    case Some{+n}:
72      fmt.named(known_name(n), n)
73    case None{}:
74      fmt.ext(known_name(ext_of(path)), path)
75
76# ---- the document -----------------------------------------------------------------------
77
78def set_path(ks: List<&2, String>, v: Json.Json, +j: Json.Json) -> Json.Json:
79  match ks:
80    case Nil{}:
81      v
82    case Con{+k, t}:
83      Json.set(k, set_path(t, v, Setload.obj_or_empty(or_null(Json.get(k, j)))), j)
84
85# A CSV cell as a value: `{}` and `[]` are the empty collections; everything
86# else reads as a YAML plain word (null, true/false, a number, a string).
87def typed(+v: String) -> Json.Json:
88  Bool.pick(Json.Json, String.eq(v, "{}"), Json.JObj{Json.JNil{}},
89  Bool.pick(Json.Json, String.eq(v, "[]"), Json.JArr{Json.JNil{}}, L.resolve(v)))
90
91def csv_rows(rows: List<&2, List<&2, String>>, +doc: Json.Json) -> Result<&2, &2, String, Json.Json>:
92  match rows:
93    case Nil{}:
94      Done{doc}
95    case Con{Con{k, Con{v, Nil{}}}, t}:
96      csv_rows(t, set_path(String.split(k, '.'), typed(v), doc))
97    case Con{r, t}:
98      Fail{"every csv row must be key,value"}
99
100def csv_head_ok(h: List<&2, String>) -> Bool:
101  match h:
102    case Con{a, Con{b, Nil{}}}:
103      String.eq(a, "key") && String.eq(b, "value")
104    case _:
105      False{}
106
107def csv_body(ok: Bool, t: List<&2, List<&2, String>>) -> Result<&2, &2, String, Json.Json>:
108  match ok:
109    case True{}:
110      csv_rows(t, Setload.empty())
111    case False{}:
112      Fail{"a csv settings file starts with the header key,value"}
113
114def csv_doc(rows: Result<&2, &2, String, List<&2, List<&2, String>>>) -> Result<&2, &2, String, Json.Json>:
115  match rows:
116    case Fail{e}:
117      Fail{"csv: " ++ e}
118    case Done{Con{h, t}}:
119      csv_body(csv_head_ok(h), t)
120    case Done{Nil{}}:
121      Done{Setload.empty()}
122
123def parse_named(+f: String, +text: String) -> Result<&2, &2, String, Json.Json>:
124  Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(f, "json"), Json.parse(text),
125  Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(f, "yaml"), Yaml.parse(text),
126  Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(f, "toon"), Toon.parse(text), csv_doc(Csv.parse(text)))))
127
128def must_obj(r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
129  match r:
130    case Done{Json.JObj{f}}:
131      Done{Json.JObj{f}}
132    case Done{x}:
133      Fail{"the settings document must be a mapping of keys to values"}
134    case Fail{e}:
135      Fail{e}
136
137# The document in `text`, which is in format `f` (json, yaml, toon or csv).
138def parse_doc(+f: String, +text: String) -> Result<&2, &2, String, Json.Json>:
139  must_obj(parse_named(f, text))
140
141# ---- checking the values ----------------------------------------------------------------------
142
143def join_more(+s: String, r: Result<&2, &2, String, String>) -> Result<&2, &2, String, String>:
144  match r:
145    case Done{rest}:
146      Done{s ++ "," ++ rest}
147    case Fail{e}:
148      Fail{e}
149
150def list_text(items: Json.Json) -> Result<&2, &2, String, String>:
151  match items:
152    case Json.JNil{}:
153      Done{""}
154    case Json.JCons{Json.JStr{s}, Json.JNil{}}:
155      Done{s}
156    case Json.JCons{Json.JStr{s}, t}:
157      join_more(s, list_text(t))
158    case _:
159      Fail{"expected a list of names"}
160
161def scal_text(+j: Json.Json) -> Result<&2, &2, String, String>:
162  match j:
163    case Json.JStr{s}:
164      Done{s}
165    case Json.JNum{t}:
166      Done{t}
167    case Json.JBool{b}:
168      Done{Json.bool.show(b)}
169    case Json.JArr{items}:
170      list_text(items)
171    case _:
172      Fail{"expected a single value"}
173
174def parsed_json(p: Setwrite.Parsed) -> Result<&2, &2, String, Json.Json>:
175  match p:
176    case Setwrite.Bad{m}:
177      Fail{m}
178    case Setwrite.Good{j}:
179      Done{j}
180
181def known_val2(sp: Settings.Spec, r: Result<&2, &2, String, String>) -> Result<&2, &2, String, Json.Json>:
182  match r:
183    case Fail{e}:
184      Fail{Settings.dotted(sp) ++ ": " ++ e}
185    case Done{t}:
186      parsed_json(Setwrite.parse_value(sp, t))
187
188# A value of a known setting, checked as `config set` checks it (null stays null: unset).
189def known_val(sp: Settings.Spec, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
190  match v:
191    case Json.JNull{}:
192      Done{Json.JNull{}}
193    case _:
194      known_val2(sp, scal_text(v))
195
196def sec_val(m: Maybe<&2, Settings.Spec>, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
197  match m:
198    case None{}:
199      Done{v}
200    case Some{sp}:
201      known_val(sp, v)
202
203def sec_fields(+sec: String, fields: Json.Json) -> Result<&2, &2, String, Json.Json>:
204  match fields:
205    case Json.JField{+k, v, t}:
206      S.field.cons(k, sec_val(Settings.find(sec ++ "." ++ k, Settings.specs()), v), sec_fields(sec, t))
207    case _:
208      Done{fields}
209
210def sec_obj(+sec: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
211  match v:
212    case Json.JObj{f}:
213      S.wrap.obj(sec_fields(sec, f))
214    case Json.JNull{}:
215      Done{Json.JNull{}}
216    case _:
217      Fail{sec ++ " must be a mapping of settings"}
218
219# ---- profiles ------------------------------------------------------------------------------------
220
221# One profile key, checked as `profile set` checks it; the stored value comes
222# back as the profile file would hold it ("inherit" and null are null).
223def prof_upd(+k: String, u: P.Upd) -> Result<&2, &2, String, Json.Json>:
224  match u:
225    case P.UBad{m}:
226      Fail{m}
227    case P.UOk{p}:
228      Done{or_null(Json.get(k, P.to_json(p, Setload.empty())))}
229
230def semi(+t: String) -> String:
231  String.join(String.split(t, ';'), ",")
232
233def prof_text(+k: String, r: Result<&2, &2, String, String>) -> Result<&2, &2, String, Json.Json>:
234  match r:
235    case Fail{e}:
236      Fail{k ++ ": " ++ e}
237    case Done{+t}:
238      prof_upd(k, P.update(P.bare("x", SF.ABest{}), k, Bool.pick(String, String.eq(k, "models"), semi(t), t)))
239
240def prof_known(+k: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
241  match v:
242    case Json.JNull{}:
243      Done{Json.JNull{}}
244    case _:
245      prof_text(k, scal_text(v))
246
247def prof_key(+k: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
248  Bool.pick(Result<&2, &2, String, Json.Json>, Setload.has_str(P.keys(), k), prof_known(k, v), Done{v})
249
250def prof_fields(fields: Json.Json) -> Result<&2, &2, String, Json.Json>:
251  match fields:
252    case Json.JField{+k, v, t}:
253      S.field.cons(k, prof_key(k, v), prof_fields(t))
254    case _:
255      Done{fields}
256
257def name_msg(+name: String) -> String:
258  "invalid profile name '" ++ name ++ "': use letters, digits, '-' and '_', starting with a letter or digit"
259
260def prof_body(+name: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
261  match v:
262    case Json.JObj{f}:
263      S.wrap.obj(prof_fields(f))
264    case Json.JNull{}:
265      Done{Json.JNull{}}
266    case _:
267      Fail{"profile '" ++ name ++ "' must be a mapping"}
268
269def prof_one(+name: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
270  Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(name, "default"), Fail{"profile 'default' follows the autoswitch settings and cannot be changed"},
271  Bool.pick(Result<&2, &2, String, Json.Json>, P.valid_name(name), prof_body(name, v), Fail{name_msg(name)}))
272
273def prof_list(fields: Json.Json) -> Result<&2, &2, String, Json.Json>:
274  match fields:
275    case Json.JField{+name, v, t}:
276      S.field.cons(name, prof_one(name, v), prof_list(t))
277    case _:
278      Done{fields}
279
280def profiles_val(+v: Json.Json) -> Result<&2, &2, String, Json.Json>:
281  match v:
282    case Json.JObj{f}:
283      S.wrap.obj(prof_list(f))
284    case Json.JNull{}:
285      Done{Json.JNull{}}
286    case _:
287      Fail{"profiles must be a mapping of names to profiles"}
288
289def active_val(+v: Json.Json) -> Result<&2, &2, String, Json.Json>:
290  match v:
291    case Json.JStr{s}:
292      Done{v}
293    case Json.JNull{}:
294      Done{v}
295    case _:
296      Fail{"active_profile must be a profile name"}
297
298# ---- the whole document -------------------------------------------------------------------------------
299
300def top_val(+k: String, +v: Json.Json) -> Result<&2, &2, String, Json.Json>:
301  Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(k, "autoswitch") || String.eq(k, "ui"), sec_obj(k, v),
302  Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(k, "profiles"), profiles_val(v),
303  Bool.pick(Result<&2, &2, String, Json.Json>, String.eq(k, "active_profile"), active_val(v), Done{v})))
304
305def top(fields: Json.Json) -> Result<&2, &2, String, Json.Json>:
306  match fields:
307    case Json.JField{+k, v, t}:
308      S.field.cons(k, top_val(k, v), top(t))
309    case _:
310      Done{fields}
311
312def top_obj(+doc: Json.Json) -> Result<&2, &2, String, Json.Json>:
313  match doc:
314    case Json.JObj{f}:
315      S.wrap.obj(top(f))
316    case _:
317      Fail{"the settings document must be a mapping of keys to values"}
318
319# The document with every known key checked and written the way
320# `config set` / `profile set` would write it. A bad value is the Fail.
321# Unknown keys, sections and profile keys pass through.
322def normalize(+doc: Json.Json) -> Result<&2, &2, String, Json.Json>:
323  top_obj(doc)
324
325# `profile import` takes only these top-level keys.
326def profile_key_ok(ok: Bool, +k: String, rest: Result<&2, &2, String, Unit>) -> Result<&2, &2, String, Unit>:
327  match ok:
328    case True{}:
329      rest
330    case False{}:
331      Fail{"profile import takes profiles and active_profile only; found '" ++ k ++ "'"}
332
333def profile_only(ks: List<&2, String>) -> Result<&2, &2, String, Unit>:
334  match ks:
335    case Nil{}:
336      Done{Unit{}}
337    case Con{+k, t}:
338      profile_key_ok(Setload.has_str(["profiles", "active_profile", "schemaVersion"], k), k, profile_only(t))
339
340# ---- merging --------------------------------------------------------------------------------------------
341
342def is_obj(j: Json.Json) -> Bool:
343  match j:
344    case Json.JObj{f}:
345      True{}
346    case _:
347      False{}
348
349def is_null(j: Json.Json) -> Bool:
350  match j:
351    case Json.JNull{}:
352      True{}
353    case _:
354      False{}
355
356def strip.pick(skip: Bool, +k: String, v: Json.Json, rest: Json.Json) -> Json.Json:
357  match skip:
358    case True{}:
359      rest
360    case False{}:
361      Json.JField{k, v, rest}
362
363# The value without its null fields (a new profile, a replaced file: null means "not set").
364def strip(j: Json.Json) -> Json.Json:
365  match j:
366    case Json.JObj{f}:
367      Json.JObj{strip(f)}
368    case Json.JField{k, +v, t}:
369      strip.pick(is_null(v), k, strip(v), strip(t))
370    case _:
371      j
372
373def put(+k: String, +v: Json.Json, +base: Json.Json, merged: Json.Json) -> Json.Json:
374  Bool.pick(Json.Json, is_null(v), Json.del(k, base),
375  Bool.pick(Json.Json, is_obj(v) && is_obj(or_null(Json.get(k, base))), Json.set(k, merged, base), Json.set(k, strip(v), base)))
376
377# `over` laid on `base`: objects merge key by key, anything else replaces, and
378# null removes the key.
379def merge(over: Json.Json, +base: Json.Json) -> Json.Json:
380  match over:
381    case Json.JObj{f}:
382      merge(f, base)
383    case Json.JField{+k, +v, t}:
384      merge(t, put(k, v, base, merge(v, or_null(Json.get(k, base)))))
385    case Json.JNil{}:
386      base
387    case _:
388      over