src/pub/plan.bend

ezx@1.6.0.0 · 1,318 lines · 0 definitions

1# pub/plan: the planner for `ez publish`, pure. It reads a World
2# (pub/world.bend) and either asks the questions still open or makes the plan.
3# The package is sent to the hub by `bend --publish`, which is the only thing
4# that can send it, but the `0x` name ez reports is the one the package walk
5# (`pkg/pkg.bend`) computed from the files, never the one bend printed.
6# Reading bend's output is a check, not a lookup: ez already knows the answer
7# before bend runs, and what bend says is compared against it.
8#
9# Publishing is irreversible and public, so every check that can be made
10# before the upload is made first, in this order: there is a ledger that
11# parses, and it names the entry, and the name it publishes under, if any, is
12# one bend takes (EZ-PUB-3); git answers `git status --porcelain
13# --untracked-files=normal` about the project and names no path (EZ-PUB-1);
14# the walk from the entry makes a package; git tracks every file of that
15# package; and the hub the ledger names has every hub package the package
16# imports (EZ-PUB-5). Only then is the upload asked for, and its answer is checked
17# against ez's hash (EZ-PUB-2). bend cannot report a package's hash without
18# uploading it, so that check comes after the upload, and a disagreement
19# exits 1 with no import line rather than taking the upload back.
20#
21# Each stage of `decide` reads what it needs from the World itself rather
22# than from the stage before, so a law about one stage holds of every World
23# whatever the others say.
24import Base
25import ezx@1.6.0.0/src/lock/plan.bend as P
26import ezx@1.6.0.0/src/tool/world.bend as TW
27import ezx@1.6.0.0/src/ledger/manifest.bend as M
28import ezx@1.6.0.0/src/pkg/pkg.bend as K
29import ezx@1.6.0.0/src/pkg/path.bend as Path
30import ezx@1.6.0.0/src/git/git.bend as G
31import ezx@1.6.0.0/src/pub/world.bend as PW
32import ezx@1.6.0.0/src/pub/blurb.bend as B
33import ezx@1.6.0.0/src/lock/lock.bend as L
34import ezx@1.6.0.0/src/share/sha.bend as Sha
35import ezx@1.6.0.0/src/io/file.bend as F
36
37# ---------------------------------------------------------------------------
38# bend's answer
39
40# how many characters a `0x` name is written with: the two of the prefix and
41# the thirty-two `pkg/pkg.bend`'s `hash_of` takes off a sha256
42def name.length() -> Nat:
43  34n
44
45# whether a line is a `0x` package name and nothing else. Anchored at both
46# ends: the prefix, the exact length, and every character after the prefix one
47# a lowercase hex digest may hold. `git/git.bend` already walks a hex string
48# for `is_rev`, and its walk stops at the first character that is not hex.
49#
50# The lines bend prints around its answer fail this, and `pub/LAWS.bend` says
51# so for the two that hold the name, rather than leaving it to a reading of
52# bend's source: `publishing N files, ... as 0x... (mining its proof of work)`
53# and `import 0x.../entry.bend as Name` each start with something that is not
54# `0x`. The terms notice and the license line bend 2.0.27 prints first
55# (`Publishing to BendHub: ...`, `License: ...`) start with a word too.
56def is_name(+word: String) -> Bool:
57  Bool.and(String.starts_with(word, "0x"),
58    Bool.and(Nat.is_eq(String.length(word), name.length()),
59      G.hex.all(String.to_list(String.drop(word, 2n)))))
60
61# one line of bend's output weighed. A line that is a name must be ours, and
62# makes the rest of the walk one that has seen a name; any other line is
63# passed over. `Bool.and` builds both of its sides, so the rest of the walk
64# arrives as a function of whether a name was seen and is applied once.
65def agrees.step(name: Bool, same: Bool, seen: Bool, rest: Bool -> Bool) -> Bool:
66  match name:
67    case True{}:
68      Bool.and(same, rest(True{}))
69    case False{}:
70      rest(seen)
71
72# whether bend's output, from this line on, agrees with ez's hash, `seen`
73# saying whether a line before it was a name
74def agrees.go(+ours: String, ls: List<&2, String>, seen: Bool) -> Bool:
75  match ls:
76    case []:
77      seen
78    case +h <> t:
79      agrees.step(is_name(h), String.eq(ours, h), seen,
80        s => agrees.go(ours, t, s))
81
82# whether bend's output agrees with ez's hash: at least one line is a `0x`
83# name, and every line that is one is ez's hash. There is no second pattern
84# behind this one. An output with no bare name answers nothing, and one that
85# names two packages names one that is not ours, whichever it names first.
86def agrees(+ours: String, ls: List<&2, String>) -> Bool:
87  agrees.go(ours, ls, False{})
88
89# the first line that is a `0x` name and not ours, "" when there is none
90def other.step(odd: Bool, line: String, rest: Unit -> String) -> String:
91  match odd:
92    case True{}:
93      line
94    case False{}:
95      rest(Unit{})
96
97# the walk that finds it, one line at a time
98def other(+ours: String, ls: List<&2, String>) -> String:
99  match ls:
100    case []:
101      ""
102    case +h <> t:
103      other.step(Bool.and(is_name(h), Bool.not(String.eq(ours, h))), h,
104        _u => other(ours, t))
105
106# ---------------------------------------------------------------------------
107# the working tree
108
109# one line of `git status --porcelain` weighed, with the rest of the walk
110# behind a thunk so a status that names a hundred files stops at the first.
111# Every line git prints there names a path whose state on disk is not the
112# state the commit holds, so every one of them counts, whatever its two-letter
113# code says, `??` for an untracked file included.
114def dirty.step(blank: Bool, rest: Unit -> Bool) -> Bool:
115  match blank:
116    case True{}:
117      rest(Unit{})
118    case False{}:
119      True{}
120
121# whether any line of the status says anything. The only empty line git status
122# ever produces is the one `String.lines` makes of the trailing newline, so an
123# empty line is the absence of a change rather than a change with no name.
124def dirty.lines(ls: List<&2, String>) -> Bool:
125  match ls:
126    case []:
127      False{}
128    case +h <> t:
129      dirty.step(String.is_empty(h), _u => dirty.lines(t))
130
131# one line of the other walk, the same way round
132def blanks.step(blank: Bool, rest: Unit -> Bool) -> Bool:
133  match blank:
134    case True{}:
135      rest(Unit{})
136    case False{}:
137      False{}
138
139# whether the status named nothing. Stated as its own walk rather than as the
140# negation of the one above, so `pub/LAWS.bend` can hold the two to each other:
141# a tree is clean exactly when git named no path at all.
142def blanks(ls: List<&2, String>) -> Bool:
143  match ls:
144    case []:
145      True{}
146    case +h <> t:
147      blanks.step(String.is_empty(h), _u => blanks(t))
148
149# whether `git status --porcelain` named a path. Ignored files are not named,
150# and that is the project's call, written in its own `.gitignore`: `.ez/` and
151# `bin/` hold build output that no commit should. What the package itself
152# holds is checked apart from this (`stray` below), so an ignored file the
153# entry imports is refused there.
154def dirty(text: String) -> Bool:
155  dirty.lines(String.lines(text))
156
157# ---------------------------------------------------------------------------
158# the name a package is published under
159
160# one character of a version: a digit or a dot
161def semver.char(+ch: Char) -> Bool:
162  Bool.or(Char.is_digit(ch), Char.is_eq(ch, '.'))
163
164# whether every character of a version is a digit or a dot. A pre-release
165# (`1.0.0-rc.1`) or build (`1.0.0+abc`) suffix holds a character that is
166# neither, so this is where one is refused, whatever follows it.
167def semver.chars(text: String) -> Bool:
168  match text:
169    case SNil{}:
170      True{}
171    case SCon{+h, t}:
172      +rest = semver.chars(t)
173      Bool.and(semver.char(h), rest)
174
175# whether a version is MAJOR.MINOR.PATCH: digits and dots only, and three
176# numbers, none with a leading zero (`K.ver.num`, the rule bend's NAMED
177# holds each of its four numbers to)
178def semver(+text: String) -> Bool:
179  +ps = String.split(text, '.')
180  Bool.and(semver.chars(text),
181    Bool.and(Nat.is_eq(List.length(&2, String, ps), 3n), K.ver.nums(ps)))
182
183# how the ledger names the package on the hub: not at all, so it is
184# published by its hash alone; by `<name>@<version>`, as bend's `--publish`
185# takes it; or in a way ez refuses, and why
186type Named is Data:
187  ByHash{}
188  ByName{nv: String}
189  Unnamed{why: String}
190
191# why a `publish-as` with no `version` is refused
192def why.nover(+name: String) -> String:
193  "ez: ez.toml's [package] has publish-as = \"" ++ name ++ "\" and no version; " ++
194    "a named publish needs both, and one with neither publishes by hash"
195
196# why a `version` with no `publish-as` is refused
197def why.noname(+ver: String) -> String:
198  "ez: ez.toml's [package] has version = \"" ++ ver ++ "\" and no publish-as; " ++
199    "a named publish needs both, and one with neither publishes by hash"
200
201# why a `publish-as` the hub would not take is refused
202def why.name(+name: String) -> String:
203  "ez: publish-as = \"" ++ name ++ "\" is not a hub name: a lowercase letter, " ++
204    "then a-z, 0-9 and -, 1 to 64 characters in all"
205
206# why a `version` that is not MAJOR.MINOR.PATCH is refused
207def why.ver(+ver: String) -> String:
208  "ez: version = \"" ++ ver ++ "\" is not MAJOR.MINOR.PATCH: three numbers " ++
209    "with no leading zeros and no pre-release or build suffix, which ez " ++
210    "publishes as bend's four-part MAJOR.MINOR.PATCH.0"
211
212# both keys given: the name first, then the version, then the name bend is
213# asked for, the version with bend's fourth number, `0`, after it
214def naming.both(name: Bool, ver: Bool, +pa: String, +pv: String) -> Named:
215  match name:
216    case False{}:
217      Unnamed{why.name(pa)}
218    case True{}:
219      match ver:
220        case False{}:
221          Unnamed{why.ver(pv)}
222        case True{}:
223          ByName{pa ++ "@" ++ pv ++ ".0"}
224
225# which of the two keys the ledger gives. Neither is a publish by hash, as
226# before the keys existed; one without the other is refused rather than
227# guessed at, as `cargo publish` refuses a package with no version.
228def naming.go(noname: Bool, nover: Bool, +pa: String, +pv: String) -> Named:
229  match noname:
230    case True{}:
231      match nover:
232        case True{}:
233          ByHash{}
234        case False{}:
235          Unnamed{why.noname(pv)}
236    case False{}:
237      match nover:
238        case True{}:
239          Unnamed{why.nover(pa)}
240        case False{}:
241          naming.both(K.name.part(pa), semver(pv), pa, pv)
242
243# how `publish-as` and `version` name the package
244def naming(+pa: String, +pv: String) -> Named:
245  naming.go(String.is_empty(pa), String.is_empty(pv), pa, pv)
246
247# ---------------------------------------------------------------------------
248# the ledger
249
250# what the ledger says: the entry it names and how the package is named on
251# the hub, by its hash or by a `<name>@<version>`, or why there is none
252type Led is Data:
253  Led{entry: String, named: Named}
254  LedNo{why: String}
255
256# the entry, with the name the package goes under, or why it goes under none
257def led.named(+entry: String, got: Named) -> Led:
258  match got:
259    case ByHash{}:
260      Led{entry, ByHash{}}
261    case ByName{nv}:
262      Led{entry, ByName{nv}}
263    case Unnamed{why}:
264      LedNo{why}
265
266# the entry a parsed ledger names, `main.bend` when it names none, and the
267# name its `publish-as` and `version` give it
268def led.read(ledger: M.Read) -> Led:
269  match ledger:
270    case M.Bad{why}:
271      LedNo{"ez: " ++ M.show(M.Bad{why})}
272    case M.Good{m}:
273      M.Manifest{_n, +e, _b, _h, +pa, +pv, _ds, _ts} = m
274      led.named(Bool.pick(String, String.is_empty(e), "main.bend", e), naming(pa, pv))
275
276# the ledger read. With no ez.toml there is no package, and nothing is
277# guessed in its place (`cargo publish` refuses a directory with no
278# Cargo.toml the same way).
279def led.of(got: Maybe<&2, String>) -> Led:
280  match got:
281    case None{}:
282      LedNo{"ez: no " ++ PW.toml() ++ " here; ez publish sends the package " ++
283        "a project's ledger names, and ez init makes one"}
284    case Some{text}:
285      led.read(M.parse(text))
286
287# what the World's ledger says
288def led(world: PW.World) -> Led:
289  led.of(PW.ledger(world))
290
291# the entry, as the walk takes it, "" when the ledger names none
292def entry.of(ledger: Led) -> String:
293  match ledger:
294    case Led{e, _nv}:
295      Path.norm(e)
296    case LedNo{_why}:
297      ""
298
299# how the package goes on the hub; a ledger that names no entry sends
300# nothing, so it is taken for one that goes by hash
301def named.of(ledger: Led) -> Named:
302  match ledger:
303    case Led{_e, got}:
304      got
305    case LedNo{_why}:
306      ByHash{}
307
308# how a World's package goes on the hub
309def named(world: PW.World) -> Named:
310  named.of(led(world))
311
312# the `<name>@<version>` the upload names the package by, "" for none
313def named.word(got: Named) -> String:
314  match got:
315    case ByName{nv}:
316      nv
317    case ByHash{}:
318      ""
319    case Unnamed{_why}:
320      ""
321
322# the entry of a World
323def entry(world: PW.World) -> String:
324  entry.of(led(world))
325
326# ---------------------------------------------------------------------------
327# git status
328
329# what `git status` said of the project, still to ask, or why it said
330# nothing
331type Status is Data:
332  Said{text: String}
333  StatusWait{}
334  StatusNo{why: String}
335
336# the question: what git says of the directory the project is in
337def locate(world: PW.World) -> PW.Ask:
338  PW.Tool{TW.Locate{PW.here(world)}}
339
340# a status that git answered, or the command stopped with what git said. A
341# directory that is not in a git repository has no commit for what goes to
342# the hub to exist in, so it is refused rather than taken for clean.
343def status.ran(ok: Bool, +text: String) -> Status:
344  match ok:
345    case True{}:
346      Said{text}
347    case False{}:
348      StatusNo{"ez: git status: " ++ text}
349
350def status.told(got: TW.Answer) -> Status:
351  match got:
352    case TW.Located{_abs, _top, _head, ok, text}:
353      status.ran(ok, text)
354    case TW.Miss{why}:
355      StatusNo{why}
356    case _:
357      StatusNo{"ez: git was not asked about the project"}
358
359def status.answer(got: PW.Answer) -> Status:
360  match got:
361    case PW.Told{t}:
362      status.told(t)
363    case PW.Ran{_ok, _text}:
364      StatusNo{"ez: git was not asked about the project"}
365
366def status.of(got: PW.Heard) -> Status:
367  match got:
368    case PW.Open{}:
369      StatusWait{}
370    case PW.Heard{a}:
371      status.answer(a)
372
373# what git status said of the project
374def status(+world: PW.World) -> Status:
375  status.of(PW.heard(world, locate(world)))
376
377# ---------------------------------------------------------------------------
378# the package
379
380# a file the walk asked for, added to what it is given: its text, or gone
381# when there is none
382def tree.told(got: TW.Answer, +path: String, rest: K.Tree) -> K.Tree:
383  match got:
384    case TW.Text{m}:
385      K.tree.add(m, rest, path)
386    case _:
387      K.tree.gone(rest, path)
388
389def tree.read(got: PW.Answer, +path: String, rest: K.Tree) -> K.Tree:
390  match got:
391    case PW.Told{t}:
392      tree.told(t, path, rest)
393    case PW.Ran{_ok, _text}:
394      K.tree.gone(rest, path)
395
396def tree.tool(ask: TW.Ask, got: PW.Answer, rest: K.Tree) -> K.Tree:
397  match ask:
398    case TW.Read{path}:
399      tree.read(got, path, rest)
400    case _:
401      rest
402
403def tree.one(ask: PW.Ask, got: PW.Answer, rest: K.Tree) -> K.Tree:
404  match ask:
405    case PW.Tool{q}:
406      tree.tool(q, got, rest)
407    case _:
408      rest
409
410# every file read so far, as the walk is given them: not a whole checkout, so
411# a file not read yet is a question
412def tree.of(rs: List<&2, PW.Reply>) -> K.Tree:
413  match rs:
414    case []:
415      K.Tree{False{}, [], []}
416    case PW.Reply{ask, got} <> t:
417      tree.one(ask, got, tree.of(t))
418
419# the files the walk has been given
420def tree(world: PW.World) -> K.Tree:
421  tree.of(PW.replies(world))
422
423# the walk's fuel: one step for the entry and one per line of the files it
424# was given, since every import it can queue is a line of one of them
425def fuel(fs: List<&2, K.Source>) -> Nat:
426  match fs:
427    case []:
428      1n
429    case K.Source{_at, text} <> t:
430      (List.length(&2, String, String.lines(text)) + fuel(t) : Nat)
431
432def fuel.of(tr: K.Tree) -> Nat:
433  K.Tree{_whole, fs, _gone} = tr
434  fuel(fs)
435
436# the package the entry is published as, over the files read so far
437def walked(+world: PW.World) -> K.Walked:
438  K.of.tree(fuel.of(tree(world)), entry(world), tree(world))
439
440# the package's hash, "" while there is none
441def hash.of(got: K.Walked) -> String:
442  match got:
443    case K.Walked{h, _root, _fs, _sums}:
444      h
445    case K.Asks{_at}:
446      ""
447    case K.Refused{_why}:
448      ""
449
450# the hash ez computed for the package
451def hash(+world: PW.World) -> String:
452  hash.of(walked(world))
453
454# the directory the package's paths are written from, "." for the project's
455def root.of(got: K.Walked) -> String:
456  match got:
457    case K.Walked{_h, root, _fs, _sums}:
458      root
459    case K.Asks{_at}:
460      "."
461    case K.Refused{_why}:
462      "."
463
464# the entry's path inside the package, which is where the hub serves it: the
465# entry's name, under the directories the package re-rooted above it when an
466# import climbs out of the entry's directory
467def at(+world: PW.World) -> String:
468  K.inside(root.of(walked(world)), entry(world))
469
470# the files of a package, as paths of the project
471def paths(+root: String, fs: List<&2, K.Source>) -> List<&2, String>:
472  match fs:
473    case []:
474      []
475    case K.Source{at, _text} <> t:
476      Path.join(root, at) <> paths(root, t)
477
478# ---------------------------------------------------------------------------
479# what git tracks
480
481# whether `git ls-files -v` names a path as tracked in the ordinary way. `H`
482# is a file git holds and watches; a file marked `--assume-unchanged` (`h`) or
483# `--skip-worktree` (`S`) is one whose changes `git status` does not report,
484# so its text on disk need not be the commit's, and it counts as untracked.
485def tracked.step(hit: Bool, rest: Unit -> Bool) -> Bool:
486  match hit:
487    case True{}:
488      True{}
489    case False{}:
490      rest(Unit{})
491
492def tracked.has(ls: List<&2, String>, +path: String) -> Bool:
493  match ls:
494    case []:
495      False{}
496    case +h <> t:
497      tracked.step(String.eq(h, "H " ++ path), _u => tracked.has(t, path))
498
499# the first path of the package that git does not track, "" when every one
500# is tracked
501def stray.step(kept: Bool, +path: String, rest: Unit -> String) -> String:
502  match kept:
503    case True{}:
504      rest(Unit{})
505    case False{}:
506      path
507
508def stray.first(+ls: List<&2, String>, ps: List<&2, String>) -> String:
509  match ps:
510    case []:
511      ""
512    case +h <> t:
513      stray.step(tracked.has(ls, h), h, _u => stray.first(ls, t))
514
515# what git said of the package's files: every one tracked, the first one it
516# does not track, still to ask, or why it said nothing
517type Kept is Data:
518  Kept{}
519  Stray{path: String}
520  KeptWait{}
521  KeptNo{why: String}
522
523def kept.of(+first: String) -> Kept:
524  Bool.pick(Kept, String.is_empty(first), Kept{}, Stray{first})
525
526def kept.ran(ok: Bool, +text: String, +ps: List<&2, String>) -> Kept:
527  match ok:
528    case True{}:
529      kept.of(stray.first(String.lines(text), ps))
530    case False{}:
531      KeptNo{"ez: git ls-files: " ++ text}
532
533def kept.answer(got: PW.Answer, +ps: List<&2, String>) -> Kept:
534  match got:
535    case PW.Ran{ok, text}:
536      kept.ran(ok, text, ps)
537    case PW.Told{_t}:
538      KeptNo{"ez: git was not asked what it tracks"}
539
540def kept.heard(got: PW.Heard, +ps: List<&2, String>) -> Kept:
541  match got:
542    case PW.Open{}:
543      KeptWait{}
544    case PW.Heard{a}:
545      kept.answer(a, ps)
546
547def kept.walked(got: K.Walked, +world: PW.World) -> Kept:
548  match got:
549    case K.Walked{_h, +root, fs, _sums}:
550      kept.heard(PW.heard(world, PW.Tracked{}), paths(root, fs))
551    case K.Asks{_at}:
552      KeptNo{"ez: the package was not walked"}
553    case K.Refused{why}:
554      KeptNo{why}
555
556# whether git tracks every file of the package. An ignored file the entry
557# imports is one `git status` never names, and one the upload would send
558# though no commit holds it, so it is refused here.
559def stray(+world: PW.World) -> Kept:
560  kept.walked(walked(world), world)
561
562# ---------------------------------------------------------------------------
563# the upload
564
565# the words the upload runs: the entry, and after `--publish` the
566# `<name>@<version>` the package goes under when it has one, which is how
567# bend 2.0.27 publishes and names a package in one run (`bend <file>
568# --publish <name>@<version>`). With no name it is the upload by hash alone.
569def upload.go(bare: Bool, +entry: String, +named: String) -> List<&2, String>:
570  match bare:
571    case True{}:
572      ["bend", entry, "--publish"]
573    case False{}:
574      ["bend", entry, "--publish", named]
575
576def upload.line(+entry: String, +named: String) -> List<&2, String>:
577  upload.go(String.is_empty(named), entry, named)
578
579# the files of a package, with their texts, at the paths the hub holds them
580# at; none while there is no package
581def files.of(got: K.Walked) -> List<&2, K.Source>:
582  match got:
583    case K.Walked{_h, _root, fs, _sums}:
584      fs
585    case K.Asks{_at}:
586      []
587    case K.Refused{_why}:
588      []
589
590# what is said just before the upload: the line the hub will describe the
591# package by (`B.blurb`). A package whose first file opens with `import Base`
592# would be listed that way for good, so the author sees the line while the
593# upload can still be stopped.
594def said(+world: PW.World) -> String:
595  "hub description: " ++ B.blurb(files.of(walked(world)))
596
597# the question: the entry sent, under the name the ledger gives it, with the
598# line said before it
599def upload(+world: PW.World) -> PW.Ask:
600  PW.Upload{entry(world), named.word(named(world)), said(world)}
601
602# the upload's answer, looked up
603def uploaded(+world: PW.World) -> PW.Heard:
604  PW.heard(world, upload(world))
605
606# whether an upload's answer agrees with ez's hash: bend exited 0, and its
607# output agrees (`agrees`)
608def agreed.answer(got: PW.Answer, +ours: String) -> Bool:
609  match got:
610    case PW.Ran{ok, text}:
611      Bool.and(ok, agrees(ours, String.lines(text)))
612    case PW.Told{_t}:
613      False{}
614
615def agreed.of(got: PW.Heard, +ours: String) -> Bool:
616  match got:
617    case PW.Open{}:
618      False{}
619    case PW.Heard{a}:
620      agreed.answer(a, ours)
621
622# whether the World's upload agrees with the hash ez computed
623def agreed(+world: PW.World) -> Bool:
624  agreed.of(uploaded(world), hash(world))
625
626# why an upload that bend finished does not agree: it printed no line that
627# is a name, or a name that is not ours. Both numbers are named, since the
628# next question is which walk drifted, and neither is reported as the
629# package's name.
630def why.read(+ours: String, +text: String) -> String:
631  +theirs = other(ours, String.lines(text))
632  Bool.pick(String, String.is_empty(theirs),
633    "ez: bend printed no line that is a 0x name, so there is nothing to " ++
634      "check ez's hash against. bend said:\n" ++ text,
635    "ez: ez computed " ++ ours ++ " and bend published " ++ theirs ++
636      ". One of the two package walks has drifted from the other, so " ++
637      "neither name is safe to write down and ez has reported none.")
638
639# why an upload refuses, `words` being the line it ran. bend has sent the
640# package by the time it answers, since it cannot report the hash without
641# sending it, so this is said after the fact.
642def why.ran(ok: Bool, +words: String, +ours: String, +text: String) -> String:
643  match ok:
644    case False{}:
645      "ez: " ++ words ++ ": " ++ text
646    case True{}:
647      why.read(ours, text)
648
649def why.answer(got: PW.Answer, +words: String, +ours: String) -> String:
650  match got:
651    case PW.Ran{ok, text}:
652      why.ran(ok, words, ours, text)
653    case PW.Told{_t}:
654      "ez: bend was not asked to publish"
655
656def why.of(got: PW.Heard, +words: String, +ours: String) -> String:
657  match got:
658    case PW.Open{}:
659      "ez: bend was not asked to publish"
660    case PW.Heard{a}:
661      why.answer(a, words, ours)
662
663# why the World's upload refuses
664def why.upload(+world: PW.World) -> String:
665  why.of(uploaded(world),
666    String.join(upload.line(entry(world), named.word(named(world))), " "),
667    hash(world))
668
669# ---------------------------------------------------------------------------
670# the hub packages the package imports
671
672# A package on the hub is built from the hub: whoever imports it has bend
673# fetch every hub package it imports in turn. So a package that imports one
674# the hub does not have is one no one can build, and `ez publish` asks the
675# hub about each before it sends anything, as `cargo publish` refuses a
676# dependency that is not on crates.io (EZ-PUB-5). It asks the hub the ledger
677# names, the one `ez lock` resolved the imports against.
678
679# a hub package the package imports, as the hub is asked about it: by its
680# `0x` name, whose manifest the hub serves at `<hub>/<hash>/manifest`; or by
681# its `<name>@<version>`, which the hub resolves at `<hub>/name/<nv>`, with
682# the hash ez.lock.toml or ez.toml resolved the name to, "" when neither
683# records one
684type Probe is Data:
685  ProbeHash{hash: String}
686  ProbeName{nv: String, hash: String}
687
688# the texts of the package's files
689def texts(fs: List<&2, K.Source>) -> List<&2, String>:
690  match fs:
691    case []:
692      []
693    case K.Source{_at, text} <> t:
694      text <> texts(t)
695
696# a string kept unless it was seen before
697def once.pick(dup: Bool, +word: String, rest: List<&2, String>) -> List<&2, String>:
698  match dup:
699    case True{}:
700      rest
701    case False{}:
702      word <> rest
703
704# each string of a list once, where it first appears
705def once.go(xs: List<&2, String>, +seen: List<&2, String>) -> List<&2, String>:
706  match xs:
707    case []:
708      []
709    case +h <> t:
710      once.pick(L.seen.holds(seen, h), h, once.go(t, h <> seen))
711
712# each string of a list once
713def once(xs: List<&2, String>) -> List<&2, String>:
714  once.go(xs, [])
715
716# the texts of the package the walk made, none while there is none
717def sources(+world: PW.World) -> List<&2, String>:
718  texts(files.of(walked(world)))
719
720# every `0x` name the package's files import, once each
721def hashes(+world: PW.World) -> List<&2, String>:
722  once(L.kids(sources(world)))
723
724# every `<name>@<version>` they import, once each
725def nvs(+world: PW.World) -> List<&2, String>:
726  once(L.kids.named(sources(world)))
727
728# whether a list holds anything
729def any(xs: List<&2, String>) -> Bool:
730  match xs:
731    case []:
732      False{}
733    case _h <> _t:
734      True{}
735
736# the ledger read, a bad one when there is none; the planner reaches this
737# stage only past a ledger that parsed
738def parsed.of(got: Maybe<&2, String>) -> M.Read:
739  match got:
740    case None{}:
741      M.Bad{"no " ++ PW.toml()}
742    case Some{text}:
743      M.parse(text)
744
745# the World's ledger, read
746def parsed(world: PW.World) -> M.Read:
747  parsed.of(PW.ledger(world))
748
749# the hub the ledger names, bend's own when it names none
750def hub(world: PW.World) -> String:
751  M.hub_of(parsed(world))
752
753# the dependencies the ledger records
754def deps.of(got: M.Read) -> List<&2, M.Dep>:
755  match got:
756    case M.Bad{_why}:
757      []
758    case M.Good{m}:
759      M.Manifest{_n, _e, _b, _h, _pa, _pv, ds, _ts} = m
760      ds
761
762# the dependencies the World's ledger records
763def deps(world: PW.World) -> List<&2, M.Dep>:
764  deps.of(parsed(world))
765
766# the lock's text, "" when there is none or it was not read
767def lock.told(got: TW.Answer) -> String:
768  match got:
769    case TW.Text{m}:
770      F.text_of(m)
771    case _:
772      ""
773
774def lock.answer(got: PW.Answer) -> String:
775  match got:
776    case PW.Told{t}:
777      lock.told(t)
778    case PW.Ran{_ok, _text}:
779      ""
780
781def lock.of(got: PW.Heard) -> String:
782  match got:
783    case PW.Open{}:
784      ""
785    case PW.Heard{a}:
786      lock.answer(a)
787
788# ez.lock.toml as the planner was given it
789def lock(+world: PW.World) -> String:
790  lock.of(PW.heard(world, PW.Lock{}))
791
792# every name the lock resolves, then every one the ledger records with the
793# hash it named when it was added
794def names(+world: PW.World) -> List<&2, L.Name>:
795  List.append(&2, L.Name, L.names.read(lock(world)), L.ledger.names(parsed(world)))
796
797# each `0x` name as the hub is asked about it
798def probes.hash(hs: List<&2, String>) -> List<&2, Probe>:
799  match hs:
800    case []:
801      []
802    case h <> t:
803      ProbeHash{h} <> probes.hash(t)
804
805# each name, with the hash the lock or the ledger resolves it to
806def probes.name(+ns: List<&2, L.Name>, vs: List<&2, String>) -> List<&2, Probe>:
807  match vs:
808    case []:
809      []
810    case +h <> t:
811      ProbeName{h, L.name.find(ns, h)} <> probes.name(ns, t)
812
813# every hub package the package imports, as the hub is asked about it
814def probes(+world: PW.World) -> List<&2, Probe>:
815  List.append(&2, Probe, probes.hash(hashes(world)), probes.name(names(world), nvs(world)))
816
817# the url the hub is asked
818def probe.url(+hub: String, one: Probe) -> String:
819  match one:
820    case ProbeHash{h}:
821      L.url_of(hub, h, "manifest")
822    case ProbeName{nv, _h}:
823      hub ++ "/name/" ++ nv
824
825# the question: a GET of that url
826def probe.ask(+hub: String, one: Probe) -> PW.Ask:
827  PW.Hub{probe.url(hub, one)}
828
829# the questions for a list of them
830def probe.asks(+hub: String, ps: List<&2, Probe>) -> List<&2, PW.Ask>:
831  match ps:
832    case []:
833      []
834    case h <> t:
835      probe.ask(hub, h) <> probe.asks(hub, t)
836
837# the import, as the package's files write it
838def probe.word(one: Probe) -> String:
839  match one:
840    case ProbeHash{h}:
841      h
842    case ProbeName{nv, _h}:
843      nv
844
845# where the ledger says a dependency comes from
846def origin.say(src: M.Source) -> String:
847  match src:
848    case M.Hub{_named}:
849      "hub"
850    case M.Git{url, _rev, _tag, _root, _nar, _vend}:
851      "git " ++ url
852
853# a dependency as a refusal names it: its key and its origin
854def dep.say(item: M.Dep) -> String:
855  M.Dep{n, _h, _e, src} = item
856  n ++ ", " ++ origin.say(src)
857
858# whether the ledger's dependency is the one a probe is about: the same hash,
859# or for a name, the same `<name>@<version>` or the hash it resolved to
860def dep.is.go(one: Probe, +hash: String, src: M.Source) -> Bool:
861  match one:
862    case ProbeHash{ph}:
863      String.eq(hash, ph)
864    case ProbeName{nv, +ph}:
865      Bool.or(String.eq(M.source.nv(src), nv),
866        Bool.and(Bool.not(String.is_empty(ph)), String.eq(hash, ph)))
867
868def dep.is(one: Probe, item: M.Dep) -> Bool:
869  M.Dep{_n, h, _e, src} = item
870  dep.is.go(one, h, src)
871
872def dep.find.pick(hit: Bool, item: M.Dep, rest: Unit -> String) -> String:
873  match hit:
874    case True{}:
875      dep.say(item)
876    case False{}:
877      rest(Unit{})
878
879# the first dependency the ledger records for a probe, said, "" for none
880def dep.find(+one: Probe, ds: List<&2, M.Dep>) -> String:
881  match ds:
882    case []:
883      ""
884    case +h <> t:
885      dep.find.pick(dep.is(one, h), h, _u => dep.find(one, t))
886
887# the import a refusal names, with the dependency key and origin the ledger
888# records for it when it records one
889def who.of(+word: String, +dep: String) -> String:
890  Bool.pick(String, String.is_empty(dep), word, word ++ " (" ++ dep ++ ")")
891
892# a probe as a refusal names it
893def who(+one: Probe, ds: List<&2, M.Dep>) -> String:
894  who.of(probe.word(one), dep.find(one, ds))
895
896# where one probe stands: on the hub, not asked yet, or off it, with the line
897# a refusal says of it
898type Seen is Data:
899  There{}
900  Unasked{}
901  Off{line: String}
902
903# what the hub answers for a url it has nothing at: status 404, as
904# `Web.get` says it
905def absent(+text: String) -> Bool:
906  String.eq(text, "the server answered 404")
907
908# the line for a package the hub does not have
909def why.absent(+who: String) -> String:
910  "  " ++ who ++ " is not on the hub; publish it first"
911
912# the line for one the hub could not be asked about
913def why.unasked(+who: String, +text: String) -> String:
914  "  " ++ who ++ ": the hub could not be asked: " ++ text
915
916# a GET that did not come back 200: the hub does not have it, or it could not
917# be asked. Neither is the hub having it, so both refuse, and nothing is sent
918# on a guess.
919def seen.miss(gone: Bool, +text: String, +who: String) -> Seen:
920  match gone:
921    case True{}:
922      Off{why.absent(who)}
923    case False{}:
924      Off{why.unasked(who, text)}
925
926# a manifest the hub served for a `0x` name: the package is there when the
927# manifest hashes to the name, as `ez lock` checks it
928def seen.hash(ok: Bool, +hash: String, +text: String, +who: String) -> Seen:
929  match ok:
930    case True{}:
931      Bool.pick(Seen, String.starts_with(Sha.hex(text), L.want(hash)), There{},
932        Off{"  " ++ who ++ ": the hub served a manifest that does not hash to it"})
933    case False{}:
934      seen.miss(absent(text), text, who)
935
936# why a name the hub answered is not the package imported
937def why.named(+got: String, +want: String, +who: String) -> String:
938  Bool.pick(String, String.is_empty(want),
939    "  " ++ who ++ ": the hub answered `" ++ got ++ "`, which is not a 0x name",
940    "  " ++ who ++ ": the hub names `" ++ got ++ "` by it, and the lock " ++
941      "resolved it to " ++ want)
942
943# a name the hub resolved: the package is there when the hub names a `0x`
944# name by it, and the one the lock resolved it to when the lock resolved it
945def seen.named.go(good: Bool, +got: String, +want: String, +who: String) -> Seen:
946  match good:
947    case True{}:
948      There{}
949    case False{}:
950      Off{why.named(got, want, who)}
951
952def seen.named(+got: String, +want: String, +who: String) -> Seen:
953  seen.named.go(Bool.and(Bool.or(String.eq(got, want), String.is_empty(want)), L.hash.ok(got)),
954    got, want, who)
955
956# a name asked of the hub, answered
957def seen.name(ok: Bool, +want: String, +text: String, +who: String) -> Seen:
958  match ok:
959    case True{}:
960      seen.named(String.trim(text), want, who)
961    case False{}:
962      seen.miss(absent(text), text, who)
963
964# a probe's GET answered: whether it came back 200, and what it said
965def seen.ran(one: Probe, ok: Bool, +text: String, +who: String) -> Seen:
966  match one:
967    case ProbeHash{h}:
968      seen.hash(ok, h, text, who)
969    case ProbeName{_nv, h}:
970      seen.name(ok, h, text, who)
971
972def seen.answer(one: Probe, got: PW.Answer, +who: String) -> Seen:
973  match got:
974    case PW.Ran{ok, text}:
975      seen.ran(one, ok, text, who)
976    case PW.Told{_t}:
977      Off{"  " ++ who ++ ": the hub was not asked"}
978
979def seen.heard(got: PW.Heard, one: Probe, +who: String) -> Seen:
980  match got:
981    case PW.Open{}:
982      Unasked{}
983    case PW.Heard{a}:
984      seen.answer(one, a, who)
985
986# where a probe stands in a World
987def seen(+world: PW.World, +hub: String, +ds: List<&2, M.Dep>, +one: Probe) -> Seen:
988  seen.heard(PW.heard(world, probe.ask(hub, one)), one, who(one, ds))
989
990# the probes not asked yet, and the lines for the ones off the hub
991type Tally is Data:
992  Tally{open: List<&2, Probe>, offs: List<&2, String>}
993
994# a probe still to ask about, added
995def tally.unasked(one: Probe, sofar: Tally) -> Tally:
996  Tally{open, offs} = sofar
997  Tally{one <> open, offs}
998
999# a line for a probe off the hub, added
1000def tally.offhub(line: String, sofar: Tally) -> Tally:
1001  Tally{open, offs} = sofar
1002  Tally{open, line <> offs}
1003
1004# one probe's standing added to the rest's
1005def tally.put(standing: Seen, one: Probe, sofar: Tally) -> Tally:
1006  match standing:
1007    case There{}:
1008      sofar
1009    case Unasked{}:
1010      tally.unasked(one, sofar)
1011    case Off{line}:
1012      tally.offhub(line, sofar)
1013
1014# every probe'standing standing
1015def tally(+world: PW.World, +hub: String, +ds: List<&2, M.Dep>, ps: List<&2, Probe>) -> Tally:
1016  match ps:
1017    case []:
1018      Tally{[], []}
1019    case +h <> t:
1020      tally.put(seen(world, hub, ds, h), h, tally(world, hub, ds, t))
1021
1022# every hub package the package imports on the hub, some still to ask about,
1023# or why the command refuses
1024type Hubbed is Data:
1025  OnHub{}
1026  HubWait{open: List<&2, Probe>}
1027  OffHub{why: String}
1028
1029# why a package with imports off the hub is refused: every one of them, and
1030# the hub that was asked
1031def why.hub(+hub: String, lines: List<&2, String>) -> String:
1032  "ez: the package imports hub packages that " ++ hub ++ " does not have, or " ++
1033    "could not be asked about, and a package on the hub has to build from the " ++
1034    "hub alone, so nothing was sent:\n" ++ String.join(lines, "\n")
1035
1036def hubbed.offs(offs: List<&2, String>, +hub: String) -> Hubbed:
1037  match offs:
1038    case []:
1039      OnHub{}
1040    case h <> t:
1041      OffHub{why.hub(hub, h <> t)}
1042
1043# the tally read: every probe asked before any is judged, then every one off
1044# the hub named at once
1045def hubbed.go(open: List<&2, Probe>, offs: List<&2, String>, +hub: String) -> Hubbed:
1046  match open:
1047    case []:
1048      hubbed.offs(offs, hub)
1049    case h <> rest:
1050      HubWait{h <> rest}
1051
1052def hubbed.of(sofar: Tally, +hub: String) -> Hubbed:
1053  Tally{open, offs} = sofar
1054  hubbed.go(open, offs, hub)
1055
1056# where the World's hub imports stand
1057def hubbed(+world: PW.World) -> Hubbed:
1058  +hub = hub(world)
1059  hubbed.of(tally(world, hub, deps(world), probes(world)), hub)
1060
1061# whether they are all on the hub
1062def on.hub.v(got: Hubbed) -> Bool:
1063  match got:
1064    case OnHub{}:
1065      True{}
1066    case HubWait{_open}:
1067      False{}
1068    case OffHub{_why}:
1069      False{}
1070
1071def on.hub(+world: PW.World) -> Bool:
1072  on.hub.v(hubbed(world))
1073
1074# ---------------------------------------------------------------------------
1075# where a World stands
1076
1077# every check passed, questions still open, or why the command refuses
1078type Verdict is Data:
1079  Go{}
1080  Pending{asks: List<&2, PW.Ask>}
1081  Stop{why: String}
1082
1083# what a refusal for a dirty tree says. The lines git printed come with it,
1084# since the next question is always which file.
1085def why.dirty(+text: String) -> String:
1086  "ez: the working tree is dirty, and publishing is public and cannot be " ++
1087    "taken back, so what goes to the hub has to exist in a commit. git " ++
1088    "status says:\n" ++ text
1089
1090# what a refusal for a file git does not track says
1091def why.stray(+path: String) -> String:
1092  "ez: " ++ path ++ " is part of the package and git does not track it, " ++
1093    "so what goes to the hub would not exist in a commit"
1094
1095# the upload answered: bend's answer agrees, or the command refuses
1096def decide.agreed(ok: Bool, +world: PW.World) -> Verdict:
1097  match ok:
1098    case True{}:
1099      Go{}
1100    case False{}:
1101      Stop{why.upload(world)}
1102
1103# the upload, asked for once every check before it passed
1104def decide.upload(got: PW.Heard, +world: PW.World) -> Verdict:
1105  match got:
1106    case PW.Open{}:
1107      Pending{[upload(world)]}
1108    case PW.Heard{_a}:
1109      decide.agreed(agreed(world), world)
1110
1111# the hub imports: every one on the hub goes on to the upload, one still to
1112# ask about is asked, and any other refuses, sending nothing (EZ-PUB-5)
1113def decide.hub(on: Hubbed, +world: PW.World) -> Verdict:
1114  match on:
1115    case OnHub{}:
1116      decide.upload(uploaded(world), world)
1117    case HubWait{open}:
1118      Pending{probe.asks(hub(world), open)}
1119    case OffHub{why}:
1120      Stop{why}
1121
1122# the lock, read first when the package imports a package by name, since it
1123# holds the hash the name was resolved to
1124def decide.lock(need: Bool, got: PW.Heard, +world: PW.World) -> Verdict:
1125  match need:
1126    case False{}:
1127      decide.hub(hubbed(world), world)
1128    case True{}:
1129      match got:
1130        case PW.Open{}:
1131          Pending{[PW.Lock{}]}
1132        case PW.Heard{_a}:
1133          decide.hub(hubbed(world), world)
1134
1135# every file of the package tracked, or the first that is not refused
1136def decide.kept(kept: Kept, +world: PW.World) -> Verdict:
1137  match kept:
1138    case Kept{}:
1139      decide.lock(any(nvs(world)), PW.heard(world, PW.Lock{}), world)
1140    case Stray{path}:
1141      Stop{why.stray(path)}
1142    case KeptWait{}:
1143      Pending{[PW.Tracked{}]}
1144    case KeptNo{why}:
1145      Stop{why}
1146
1147# the walk: a package, a file it asks for, or why there is none
1148def decide.walk(got: K.Walked, +world: PW.World) -> Verdict:
1149  match got:
1150    case K.Walked{_h, _root, _fs, _sums}:
1151      decide.kept(stray(world), world)
1152    case K.Asks{at}:
1153      Pending{[PW.Tool{TW.Read{at}}]}
1154    case K.Refused{why}:
1155      Stop{why}
1156
1157# a status that named a path refuses, and nothing is read or sent
1158def decide.clean(bad: Bool, +text: String, +world: PW.World) -> Verdict:
1159  match bad:
1160    case True{}:
1161      Stop{why.dirty(text)}
1162    case False{}:
1163      decide.walk(walked(world), world)
1164
1165# git status: asked, answered, or failed
1166def decide.status(said: Status, +world: PW.World) -> Verdict:
1167  match said:
1168    case Said{+text}:
1169      decide.clean(dirty(text), text, world)
1170    case StatusWait{}:
1171      Pending{[locate(world)]}
1172    case StatusNo{why}:
1173      Stop{why}
1174
1175# the ledger: an entry, or why there is none
1176def decide.led(ledger: Led, +world: PW.World) -> Verdict:
1177  match ledger:
1178    case Led{_e, _nv}:
1179      decide.status(status(world), world)
1180    case LedNo{why}:
1181      Stop{why}
1182
1183# where a World stands
1184def decide(+world: PW.World) -> Verdict:
1185  decide.led(led(world), world)
1186
1187# ---------------------------------------------------------------------------
1188# the plan
1189
1190# what the import line names the package by: its `<name>@<version>` when it
1191# was published under one, its hash otherwise
1192def called.of(got: Named, +hash: String) -> String:
1193  match got:
1194    case ByName{nv}:
1195      nv
1196    case ByHash{}:
1197      hash
1198    case Unnamed{_why}:
1199      hash
1200
1201# what a World's import line names the package by
1202def called(+world: PW.World) -> String:
1203  called.of(named(world), hash(world))
1204
1205# what a publish that agreed says: the hash ez computed, then the import line
1206# for the entry's path inside the package (`G.import.line`, which `ez add`
1207# prints too), by the name the package was published under when it has one.
1208# It writes nothing.
1209def made(+world: PW.World) -> P.Plan:
1210  P.Plan{[P.Say{hash(world)}, P.Say{G.import.line(called(world), at(world))}],
1211    P.Success{}}
1212
1213# the plan for where a World stands. One that still asks is a World the
1214# interpreter did not finish, and is refused rather than guessed at.
1215def plan.v(verdict: Verdict, +world: PW.World) -> P.Plan:
1216  match verdict:
1217    case Go{}:
1218      made(world)
1219    case Pending{_asks}:
1220      P.Plan{[], P.Refused{"ez: the publish of " ++ entry(world) ++
1221        " was never resolved"}}
1222    case Stop{why}:
1223      P.Plan{[], P.Refused{why}}
1224
1225# `ez publish` over a World
1226def plan(+world: PW.World) -> P.Plan:
1227  plan.v(decide(world), world)
1228
1229# the questions still open, or the plan once there are none
1230type Step is Data:
1231  Asking{asks: List<&2, PW.Ask>}
1232  Run{plan: P.Plan}
1233
1234# the step for where the World stands
1235def step.v(verdict: Verdict, +world: PW.World) -> Step:
1236  match verdict:
1237    case Go{}:
1238      Run{made(world)}
1239    case Pending{asks}:
1240      Asking{asks}
1241    case Stop{why}:
1242      Run{plan.v(Stop{why}, world)}
1243
1244# what the interpreter does next on a World
1245def step(+world: PW.World) -> Step:
1246  step.v(decide(world), world)
1247
1248# ---------------------------------------------------------------------------
1249# law vocabulary
1250
1251# whether a plan ends in success
1252def succeeds.plan(pl: P.Plan) -> Bool:
1253  P.Plan{_es, outcome} = pl
1254  match outcome:
1255    case P.Success{}:
1256      True{}
1257    case P.Refused{_why}:
1258      False{}
1259
1260# whether `ez publish` succeeds on a World
1261def succeeds(+world: PW.World) -> Bool:
1262  succeeds.plan(plan(world))
1263
1264# whether `ez publish` refuses on a World
1265def refuses(+world: PW.World) -> Bool:
1266  Bool.not(succeeds(world))
1267
1268# whether a verdict refuses outright: it asks nothing and sends nothing
1269def stops.v(verdict: Verdict) -> Bool:
1270  match verdict:
1271    case Go{}:
1272      False{}
1273    case Pending{_asks}:
1274      False{}
1275    case Stop{_why}:
1276      True{}
1277
1278# whether `ez publish` refuses a World outright, asking nothing more
1279def stops(+world: PW.World) -> Bool:
1280  stops.v(decide(world))
1281
1282# whether a question is the upload
1283def is.upload(ask: PW.Ask) -> Bool:
1284  match ask:
1285    case PW.Upload{_entry, _named, _says}:
1286      True{}
1287    case PW.Tool{_q}:
1288      False{}
1289    case PW.Tracked{}:
1290      False{}
1291    case PW.Lock{}:
1292      False{}
1293    case PW.Hub{_url}:
1294      False{}
1295
1296# whether any of these questions is the upload
1297def any.upload(asks: List<&2, PW.Ask>) -> Bool:
1298  match asks:
1299    case []:
1300      False{}
1301    case h <> t:
1302      +rest = any.upload(t)
1303      Bool.or(is.upload(h), rest)
1304
1305# whether a verdict is at the upload or past it: it asks for the upload, or
1306# has its answer and goes on
1307def sends.v(verdict: Verdict) -> Bool:
1308  match verdict:
1309    case Go{}:
1310      True{}
1311    case Pending{asks}:
1312      any.upload(asks)
1313    case Stop{_why}:
1314      False{}
1315
1316# whether `ez publish` gets as far as the upload on a World
1317def sends(+world: PW.World) -> Bool:
1318  sends.v(decide(world))