importu.bend
cswap-bend-cli@0.1.0.0 · 440 lines · 68 definitions
1import Base
2import cswap-bend-cli@0.1.0.0/urec.bend as Urec
3import cswap-bend-cli@0.1.0.0/store/seq.bend as SSeq
4import cswap-bend-cli@0.1.0.0/usage/text.bend as UText
5import cswap-bend-cli@0.1.0.0/json.bend as Json
6import cswap-bend-cli@0.1.0.0/clock.bend as Clock
7import cswap-bend-cli@0.1.0.0/view.bend as View
8import cswap-bend-cli@0.1.0.0/ustore.bend as Ustore
9
10
11
12
13
14def jv(+k: String, +j: Json.Json) -> Json.Json:
15 Maybe.default(&2, Json.Json, Json.get(k, j), Json.JNull{})
16
17def neg.pick(neg: Bool, +s: String) -> Maybe<&2, String>:
18 match neg:
19 case True{}:
20 None{}
21 case False{}:
22 Some{s}
23
24def nonneg(m: Maybe<&2, String>) -> Maybe<&2, String>:
25 match m:
26 case Some{+s}:
27 neg.pick(String.starts_with(s, "-"), s)
28 case None{}:
29 None{}
30
31def ok_float(t: String) -> Maybe<&2, String>:
32 nonneg(UText.float(t))
33
34
35def num_text(j: Json.Json) -> Maybe<&2, String>:
36 match j:
37 case Json.JNum{t}:
38 ok_float(t)
39 case _:
40 None{}
41
42def with_reset(+s: String, +out: Json.Json, +now: U32, +off: U32) -> Json.Json:
43 View.with_cell(Json.set("resets_at", Json.JStr{s}, out), View.fresh(Json.of_pairs([("resets_at", Json.JStr{s})]), now, off))
44
45def win.parse(+label: String, +s: String, t: Maybe<&2, U32>, out: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
46 match t:
47 case None{}:
48 Fail{label ++ ".resetsAt is not an ISO-8601 time: '" ++ s ++ "'"}
49 case Some{u}:
50 Done{with_reset(s, out, now, off)}
51
52def win.reset(+label: String, +resets: Json.Json, out: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
53 match resets:
54 case Json.JNull{}:
55 Done{out}
56 case Json.JStr{s}:
57 win.parse(label, s, Clock.parse_iso8601(s), out, now, off)
58 case _:
59 Fail{label ++ ".resetsAt must be an ISO-8601 string"}
60
61def win.pct(m: Maybe<&2, String>, +label: String, +w: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
62 match m:
63 case None{}:
64 Fail{label ++ ".pct must be a non-negative number"}
65 case Some{p}:
66 win.reset(label, jv("resetsAt", w), Json.of_pairs([("pct", Json.JNum{p})]), now, off)
67
68def win.obj(w: Json.Json, +label: String, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
69 match w:
70 case Json.JObj{+f}:
71 win.pct(num_text(jv("pct", w)), label, w, now, off)
72 case _:
73 Fail{label ++ " must be an object"}
74
75
76def window(+w: Json.Json, +label: String, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
77 win.obj(w, label, now, off)
78
79def put.res(+k: String, r: Result<&2, &2, String, Json.Json>, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
80 match r acc:
81 case Fail{e} _:
82 Fail{e}
83 case Done{v} Fail{e}:
84 Fail{e}
85 case Done{v} Done{a}:
86 Done{Json.set(k, v, a)}
87
88def put_win(+k: String, +label: String, +w: Maybe<&2, Json.Json>, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
89 match w:
90 case None{}:
91 acc
92 case Some{x}:
93 put.res(k, window(x, label, now, off), acc)
94
95def spend.num2(m: Maybe<&2, String>, +k: String, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
96 match m:
97 case Some{t}:
98 Done{Json.set(k, Json.JNum{t}, out)}
99 case None{}:
100 Fail{"spend." ++ k ++ " must be a number"}
101
102
103def num_any(j: Json.Json) -> Maybe<&2, String>:
104 match j:
105 case Json.JNum{t}:
106 UText.float(t)
107 case _:
108 None{}
109
110def spend.num(+k: String, +s: Json.Json, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
111 spend.num2(num_any(jv(k, s)), k, out)
112
113def spend.cur(cur: Json.Json, r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
114 match cur r:
115 case _ Fail{e}:
116 Fail{e}
117 case Json.JStr{c} Done{o}:
118 Done{Json.set("currency", Json.JStr{c}, o)}
119 case _ Done{o}:
120 Fail{"spend.currency must be a string"}
121
122def spend.limit(+s: Json.Json, r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
123 match r:
124 case Fail{e}:
125 Fail{e}
126 case Done{o}:
127 spend.cur(jv("currency", s), spend.num("limit", s, o))
128
129def spend.chain(+s: Json.Json, w: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
130 match w:
131 case Fail{e}:
132 Fail{e}
133 case Done{o}:
134 spend.limit(s, spend.num("used", s, o))
135
136def spend.of(+s: Json.Json, w: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
137 spend.chain(s, w)
138
139def put_spend2(m: Maybe<&2, Json.Json>, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
140 match m:
141 case None{}:
142 acc
143 case Some{+s}:
144 put.res("spend", spend.of(s, window(s, "spend", now, off)), acc)
145
146def put_spend(+u: Json.Json, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
147 put_spend2(Json.get("spend", u), now, off, acc)
148
149def name.keep(empty: Bool, +n: String, +label: String, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
150 match empty:
151 case True{}:
152 Fail{label ++ ".name must be a non-empty string"}
153 case False{}:
154 Done{Json.set("name", Json.JStr{n}, out)}
155
156def name.of(m: Maybe<&2, String>, +label: String, out: Json.Json) -> Result<&2, &2, String, Json.Json>:
157 match m:
158 case Some{+n}:
159 name.keep(String.is_empty(n), n, label, out)
160 case None{}:
161 Fail{label ++ ".name must be a non-empty string"}
162
163def scoped.entry(r: Result<&2, &2, String, Json.Json>, +w: Json.Json, +label: String) -> Result<&2, &2, String, Json.Json>:
164 match r:
165 case Fail{e}:
166 Fail{e}
167 case Done{o}:
168 name.of(Json.as_string(jv("name", w)), label, o)
169
170def scoped.add(e: Result<&2, &2, String, Json.Json>, acc: Result<&2, &2, String, List<&2, Json.Json>>) -> Result<&2, &2, String, List<&2, Json.Json>>:
171 match e acc:
172 case Fail{m} _:
173 Fail{m}
174 case Done{v} Fail{m}:
175 Fail{m}
176 case Done{v} Done{xs}:
177 Done{List.append(&2, Json.Json, xs, [v])}
178
179def scoped.list(ls: List<&2, Json.Json>, +i: Nat, +now: U32, +off: U32, acc: Result<&2, &2, String, List<&2, Json.Json>>) -> Result<&2, &2, String, List<&2, Json.Json>>:
180 match ls:
181 case Nil{}:
182 acc
183 case Con{+w, t}:
184 scoped.list(t, 1n+i, now, off, scoped.add(scoped.entry(window(w, "scoped[" ++ Nat.show(i) ++ "]", now, off), w, "scoped[" ++ Nat.show(i) ++ "]"), acc))
185
186def scoped.put(r: Result<&2, &2, String, List<&2, Json.Json>>, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
187 match r acc:
188 case Fail{e} _:
189 Fail{e}
190 case Done{xs} Fail{e}:
191 Fail{e}
192 case Done{xs} Done{a}:
193 Done{Json.set("scoped", Json.arr(xs), a)}
194
195def put_scoped2(m: Maybe<&2, Json.Json>, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
196 match m:
197 case None{}:
198 acc
199 case Some{Json.JArr{items}}:
200 scoped.put(scoped.list(Json.to_list(Json.JArr{items}), 0n, now, off, Done{[]}), acc)
201 case Some{x}:
202 Fail{"scoped must be a list"}
203
204def put_scoped(+u: Json.Json, +now: U32, +off: U32, acc: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
205 put_scoped2(Json.get("scoped", u), now, off, acc)
206
207def nonempty_keys(ks: List<&2, String>, o: Json.Json) -> Result<&2, &2, String, Json.Json>:
208 match ks:
209 case Nil{}:
210 Fail{"usage carries no windows"}
211 case Con{h, t}:
212 Done{o}
213
214def nonempty_res(r: Result<&2, &2, String, Json.Json>) -> Result<&2, &2, String, Json.Json>:
215 match r:
216 case Fail{e}:
217 Fail{e}
218 case Done{+o}:
219 nonempty_keys(Json.keys(o), o)
220
221def usage.fields(+u: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
222 nonempty_res(put_scoped(u, now, off, put_spend(u, now, off,
223 put_win("seven_day", "sevenDay", Json.get("sevenDay", u), now, off,
224 put_win("five_hour", "fiveHour", Json.get("fiveHour", u), now, off, Done{Json.of_pairs([])})))))
225
226def usage.obj(+u: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
227 match u:
228 case Json.JObj{f}:
229 usage.fields(u, now, off)
230 case _:
231 Fail{"usage must be an object"}
232
233
234def usage_from_json(+u: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Json.Json>:
235 usage.obj(u, now, off)
236
237
238type IA is Data:
239 IA{readings: List<&2, Urec.Reading>, skipped: U32, slots: List<&2, String>, err: String}
240
241def ia.err(msg: String, ia: IA) -> IA:
242 match ia:
243 case IA{rs, sk, slots, err}:
244 IA{rs, sk, slots, msg}
245
246def ia.skip(ia: IA) -> IA:
247 match ia:
248 case IA{rs, sk, slots, err}:
249 IA{rs, (sk + 1 : U32), slots, err}
250
251def has_slot(+s: String, xs: List<&2, String>) -> Bool:
252 match xs:
253 case Nil{}:
254 False{}
255 case Con{h, t}:
256 String.eq(h, s) || has_slot(s, t)
257
258def org_label(+org: String) -> String:
259 Clock.sel.s(String.is_empty(org), "personal", org)
260
261def ia.add(+id: Ustore.Id, +usage: Json.Json, +age: U32, ia: IA) -> IA:
262 match ia:
263 case IA{rs, sk, slots, err}:
264 IA{Urec.Reading{id, usage, age} <> rs, sk, Ustore.slot_of(id) <> slots, err}
265
266def ia.dup(dup: Bool, +id: Ustore.Id, +email: String, +org: String, +usage: Json.Json, +age: U32, ia: IA) -> IA:
267 match dup:
268 case True{}:
269 ia.err("duplicate account in usage document: " ++ email ++ " (org=" ++ org_label(org) ++ ")", ia)
270 case False{}:
271 ia.add(id, usage, age, ia)
272
273def ia.dup2(+slot: String, +email: String, +org: String, +usage: Json.Json, +age: U32, ia: IA) -> IA:
274 match ia:
275 case IA{rs, sk, +slots, err}:
276 ia.dup(has_slot(slot, slots), Ustore.Id{slot, email, org}, email, org, usage, age, IA{rs, sk, slots, err})
277
278def ia.slot(m: Maybe<&2, String>, +email: String, +org: String, +usage: Json.Json, +age: U32, ia: IA) -> IA:
279 match m:
280 case None{}:
281 ia.skip(ia)
282 case Some{+slot}:
283 ia.dup2(slot, email, org, usage, age, ia)
284
285def ia.usage(r: Result<&2, &2, String, Json.Json>, +email: String, +org: String, +age: U32, +seq: Json.Json, ia: IA) -> IA:
286 match r:
287 case Fail{e}:
288 ia.err("usage for " ++ email ++ ": " ++ e, ia)
289 case Done{u}:
290 ia.slot(SSeq.find_slot(seq, email, org), email, org, u, age, ia)
291
292def age.int(ps: List<&2, String>) -> Maybe<&2, U32>:
293 match ps:
294 case Con{i, t}:
295 U32.read(i)
296 case Nil{}:
297 None{}
298
299def age_secs(m: Maybe<&2, String>) -> Maybe<&2, U32>:
300 match m:
301 case Some{t}:
302 age.int(String.split(t, '.'))
303 case None{}:
304 None{}
305
306def ia.age(m: Maybe<&2, U32>, +row: Json.Json, +email: String, +org: String, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
307 match m:
308 case None{}:
309 ia.err("usageAgeSeconds for " ++ email ++ " must be a non-negative number", ia)
310 case Some{+age}:
311 ia.usage(usage_from_json(jv("usage", row), now, off), email, org, age, seq, ia)
312
313def ia.status(ok: Bool, +row: Json.Json, +email: String, +org: String, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
314 match ok:
315 case False{}:
316 ia.skip(ia)
317 case True{}:
318 ia.age(age_secs(num_text(jv("usageAgeSeconds", row))), row, email, org, seq, now, off, ia)
319
320def is_ok_status(j: Json.Json) -> Bool:
321 match j:
322 case Json.JStr{s}:
323 String.eq(s, "ok")
324 case _:
325 False{}
326
327def ia.ident3(empty: Bool, +e: String, +o: String, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
328 match empty:
329 case True{}:
330 ia.err("each account row needs an email and an organizationUuid", ia)
331 case False{}:
332 ia.status(is_ok_status(jv("usageStatus", row)), row, e, o, seq, now, off, ia)
333
334def ia.ident2(+e: String, +o: String, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
335 ia.ident3(String.is_empty(e), e, o, row, seq, now, off, ia)
336
337def ia.identity(email: Maybe<&2, String>, org: Json.Json, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
338 match email org:
339 case Some{e} Json.JNull{}:
340 ia.ident2(e, "", row, seq, now, off, ia)
341 case Some{e} Json.JStr{o}:
342 ia.ident2(e, o, row, seq, now, off, ia)
343 case _ _:
344 ia.err("each account row needs an email and an organizationUuid", ia)
345
346def ia.row(+row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
347 match row:
348 case Json.JObj{f}:
349 ia.identity(Json.as_string(jv("email", row)), jv("organizationUuid", row), row, seq, now, off, ia)
350 case _:
351 ia.err("each account row must be a JSON object", ia)
352
353def ia.go1(live: Bool, +row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
354 match live:
355 case True{}:
356 ia.row(row, seq, now, off, ia)
357 case False{}:
358 ia
359
360def ia.step(+row: Json.Json, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
361 match ia:
362 case IA{rs, sk, slots, +err}:
363 ia.go1(String.is_empty(err), row, seq, now, off, IA{rs, sk, slots, err})
364
365def ia.rows(rs: List<&2, Json.Json>, +seq: Json.Json, +now: U32, +off: U32, ia: IA) -> IA:
366 match rs:
367 case Nil{}:
368 ia
369 case Con{h, t}:
370 ia.rows(t, seq, now, off, ia.step(h, seq, now, off, ia))
371
372
373
374type Plan is Data:
375 Plan{readings: List<&2, Urec.Reading>, skipped: U32}
376
377def plan.err(ok: Bool, err: String, p: Plan) -> Result<&2, &2, String, Plan>:
378 match ok:
379 case True{}:
380 Done{p}
381 case False{}:
382 Fail{err}
383
384def plan.of(ia: IA) -> Result<&2, &2, String, Plan>:
385 match ia:
386 case IA{rs, sk, slots, +err}:
387 plan.err(String.is_empty(err), err, Plan{List.reverse(&2, Urec.Reading, rs), sk})
388
389def plan.accounts(m: Maybe<&2, Json.Json>, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
390 match m:
391 case Some{Json.JArr{items}}:
392 plan.of(ia.rows(Json.to_list(Json.JArr{items}), seq, now, off, IA{[], 0, [], ""}))
393 case _:
394 Fail{"usage document has no accounts list"}
395
396def version_text(j: Json.Json) -> String:
397 match j:
398 case Json.JNum{t}:
399 t
400 case Json.JStr{s}:
401 "'" ++ s ++ "'"
402 case Json.JNull{}:
403 "None"
404 case _:
405 "?"
406
407def plan.version(ok: Bool, +t: String, +doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
408 match ok:
409 case True{}:
410 plan.accounts(Json.get("accounts", doc), seq, now, off)
411 case False{}:
412 Fail{"unsupported usage document schemaVersion: " ++ t ++ " (expected 1, as printed by 'cswap list --json')"}
413
414def plan.doc2(ver: Json.Json, +doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
415 match ver:
416 case Json.JNum{+t}:
417 plan.version(String.eq(t, "1"), t, doc, seq, now, off)
418 case v:
419 Fail{"unsupported usage document schemaVersion: " ++ version_text(v) ++ " (expected 1, as printed by 'cswap list --json')"}
420
421def plan.doc(+doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
422 plan.doc2(jv("schemaVersion", doc), doc, seq, now, off)
423
424def plan.obj(+doc: Json.Json, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
425 match doc:
426 case Json.JObj{f}:
427 plan.doc(doc, seq, now, off)
428 case _:
429 Fail{"usage document must be a JSON object"}
430
431def plan.parsed(r: Result<&2, &2, String, Json.Json>, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
432 match r:
433 case Fail{e}:
434 Fail{"usage document is not valid JSON: " ++ e}
435 case Done{doc}:
436 plan.obj(doc, seq, now, off)
437
438
439def import_plan(+text: String, +seq: Json.Json, +now: U32, +off: U32) -> Result<&2, &2, String, Plan>:
440 plan.parsed(Json.parse(text), seq, now, off)