src/tool/plan.bend

ezx@1.4.0.0 · 1,332 lines · 0 definitions

1# tool/plan: `ez tool run`, `install` and `upgrade` as a pure planner, and
2# `ez tool sync` as the list of installs it runs. The planner reads a World
3# (tool/world.bend) and returns either the questions it still needs answered
4# (`step`) or a Plan. The interpreter (tool/run.bend) loops on `step`,
5# answering each question by IO, then executes the plan.
6#
7# Before it asks anything the planner refuses what needs no answer: an
8# install or upgrade with nowhere to link (EZ-TOOL-1), no cache root, a
9# project ledger that does not parse, a target that names nothing or a cache
10# slug that could leave the cache (EZ-TOOL-8), a name the ledger pins that
11# the lock does not, or pins at no commit. Then it resolves the commit
12# (EZ-TOOL-4): a `[tools.*]` pin is the lock's rev, url, entry and bin; a
13# remote is what its HEAD names; a path is its clean HEAD, and a path with
14# uncommitted or untracked changes, or one that is not the top of a
15# checkout, is no commit (EZ-TOOL-3). Then the checkout: a remote's is
16# cached per commit and read from there when its record says it finished,
17# otherwise asked of git with every file's text; a path is read where it is.
18# Then its ledger, which names the file built (EZ-TOOL-7) and the link, and
19# a name that is not a TOML bare key is refused. A checkout with no ez.toml
20# is a plain Bend repository: it builds `main.bend`, or the file `--entry`
21# names, needs no lock, and is linked under that file's stem, or under the
22# repository's name when the stem is `main`. A file to build that is not in
23# the checkout is refused. Then the binary's record:
24# the binary is reused only when it is there and its record is this commit,
25# this file and this bend (EZ-TOOL-2). Every binary lives in a directory
26# named by its record, so no two records share one, and a link made by an
27# install is never rebuilt under it by a run of another commit or file.
28#
29# The plan of a command that goes on, in order: the checkout laid under the
30# cache when it was cloned, with its record written last; the lock fetched,
31# for an ez project, and the binary built with its record, unless it is
32# reused; then the
33# program started, for `run`, or the link written, for `install` and
34# `upgrade`. A refusal has no effect at all. The program is started only as
35# the last thing, so its status is the command's (EZ-TOOL-5), and `install`
36# and `upgrade` never start it (EZ-TOOL-6).
37import Base
38import ezx@1.4.0.0/src/tool/world.bend as TW
39import ezx@1.4.0.0/src/lock/plan.bend as P
40import ezx@1.4.0.0/src/lock/up.bend as Up
41import ezx@1.4.0.0/src/ledger/manifest.bend as M
42import ezx@1.4.0.0/src/toml/toml.bend as T
43import ezx@1.4.0.0/src/pkg/pkg.bend as K
44import ezx@1.4.0.0/src/pkg/path.bend as Path
45import ezx@1.4.0.0/src/git/git.bend as Git
46import ezx@1.4.0.0/src/ez/target.bend as Tgt
47import ezx@1.4.0.0/src/ez/named.bend as Named
48import ezx@1.4.0.0/src/ez/key.bend as Key
49import ezx@1.4.0.0/src/share/say.bend as Say
50import ezx@1.4.0.0/src/share/sha.bend as Sha
51
52# ---------------------------------------------------------------------------
53# small pieces
54
55# whether a Maybe holds nothing
56def none(got: Maybe<&2, String>) -> Bool:
57  match got:
58    case None{}:
59      True{}
60    case Some{_t}:
61      False{}
62
63# whether an act links the binary: `install` and `upgrade` do, `run` does not
64def links.act(act: TW.Act) -> Bool:
65  match act:
66    case TW.Run{_args}:
67      False{}
68    case TW.Install{}:
69      True{}
70    case TW.Upgrade{}:
71      True{}
72
73# the tools of a ledger that was read
74def tools.of(manifest: M.Manifest) -> List<&2, M.Tool>:
75  M.Manifest{_n, _e, _b, _h, _pa, _pv, _ds, ts} = manifest
76  ts
77
78# the pins a lock's text records
79def locked(+body: String) -> List<&2, M.Tool>:
80  M.tools(T.sects(T.parse(body)))
81
82# ---------------------------------------------------------------------------
83# what the target names
84
85# a target resolved as far as the project can say: a `[tools.*]` pin, with
86# the lock's url and rev and the file its `bin` or `entry` names; a free
87# target, with the place its word names; or why there is none
88type Src is Data:
89  Pin{name: String, url: String, rev: String, over: String}
90  Free{place: Tgt.Place}
91  NoSrc{why: String}
92
93# the file a pin names: its `bin`, else its `entry`, else "" so the
94# checkout's own ledger chooses
95def over.of(+binary: String, +entry: String) -> String:
96  Bool.pick(String, String.is_empty(binary), entry, binary)
97
98# a pin the lock has, or a refusal when it has none by that name
99def pin.go(empty: Bool, +tool: M.Tool, +name: String) -> Src:
100  match empty:
101    case True{}:
102      NoSrc{"ez: " ++ name ++ " is not pinned in ez.lock.toml"}
103    case False{}:
104      Pin{name, M.source.url(M.tool.source(tool)), M.source.rev(M.tool.source(tool)),
105        over.of(M.tool.bin(tool), M.tool.entry(tool))}
106
107# the pin the lock has for a name, or a refusal
108def pin.of(+tool: M.Tool, +name: String) -> Src:
109  pin.go(String.is_empty(M.tool.name(tool)), tool, name)
110
111# the pin the lock records under a name, one with empty fields for none
112def pin.in(+body: String, +name: String) -> M.Tool:
113  M.tool.find(locked(body), name)
114
115# a name the ledger pins is built as the lock pins it
116def pinned(got: Maybe<&2, String>, +name: String) -> Src:
117  match got:
118    case None{}:
119      NoSrc{"ez: ez.lock.toml is not there"}
120    case Some{body}:
121      pin.of(pin.in(body, name), name)
122
123# a name the ledger pins, or a free target
124def src.pick(free: Bool, +world: TW.World) -> Src:
125  match free:
126    case True{}:
127      Free{Tgt.classify(TW.target(world))}
128    case False{}:
129      pinned(TW.lock(world), TW.target(world))
130
131# whether a ledger pins nothing under this name
132def unpinned(+manifest: M.Manifest, +name: String) -> Bool:
133  String.is_empty(M.tool.name(M.tool.find(tools.of(manifest), name)))
134
135# a project ledger that does not parse is refused
136def src.read(led: M.Read, +world: TW.World) -> Src:
137  match led:
138    case M.Bad{why}:
139      NoSrc{"ez: error: " ++ why}
140    case M.Good{m}:
141      src.pick(unpinned(m, TW.target(world)), world)
142
143# what the target names (EZ-TOOL-4)
144def src(+world: TW.World) -> Src:
145  src.read(TW.project(world), world)
146
147# a pin names a git repository; a path is no pin
148def place.pin(pl: Tgt.Place) -> Tgt.Place:
149  match pl:
150    case Tgt.Bad{why}:
151      Tgt.Bad{why}
152    case Tgt.Local{_p}:
153      Tgt.Bad{"a pinned tool names a git repo"}
154    case Tgt.Remote{url, slug}:
155      Tgt.Remote{url, slug}
156
157# where a resolved target is
158def place.of(found: Src) -> Tgt.Place:
159  match found:
160    case Pin{_n, url, _r, _o}:
161      place.pin(Tgt.classify(url))
162    case Free{pl}:
163      pl
164    case NoSrc{why}:
165      Tgt.Bad{why}
166
167# where the target is: a remote with its cache slug, a path, or nowhere
168def place(+world: TW.World) -> Tgt.Place:
169  place.of(src(world))
170
171# the file a pin names, "" for a free target
172def over.src(found: Src) -> String:
173  match found:
174    case Pin{_n, _u, _r, o}:
175      o
176    case Free{_pl}:
177      ""
178    case NoSrc{_why}:
179      ""
180
181# the file the target names for itself, "" when the checkout chooses
182def over(+world: TW.World) -> String:
183  over.src(src(world))
184
185# ---------------------------------------------------------------------------
186# the commit
187
188# what git said of a local path, still to ask, or why there is nothing
189type Loc is Data:
190  Loc{abs: String, top: String, head: String, ok: Bool, status: String}
191  LocWait{ask: TW.Ask}
192  LocNo{why: String}
193
194# what git said of a path, looked up
195def loc.of(got: TW.Heard, ask: TW.Ask) -> Loc:
196  match got:
197    case TW.Open{}:
198      LocWait{ask}
199    case TW.Heard{answer}:
200      match answer:
201        case TW.Located{abs, top, head, ok, status}:
202          Loc{abs, top, head, ok, status}
203        case TW.Miss{why}:
204          LocNo{why}
205        case _:
206          LocNo{"ez: a path was not answered with what git says of it"}
207
208# the question about a local path, `~` expanded
209def locate(+path: String, +world: TW.World) -> TW.Ask:
210  TW.Locate{Tgt.expand(TW.home(world), path)}
211
212# what git said of a local path
213def loc(+path: String, +world: TW.World) -> Loc:
214  loc.of(TW.heard(world, locate(path, world)), locate(path, world))
215
216# the real path of a local target, "" while it is not known
217def loc.abs(spot: Loc) -> String:
218  match spot:
219    case Loc{abs, _t, _h, _ok, _s}:
220      abs
221    case LocWait{_a}:
222      ""
223    case LocNo{_why}:
224      ""
225
226# the commit a local path builds: HEAD, when `git status` answered and named
227# nothing, and the path is the top of its checkout. A path in a checkout
228# whose top is elsewhere may be ignored there, or hold files the commit does
229# not; either way it is no commit (EZ-TOOL-3).
230def local.same(same: Bool, +head: String) -> String:
231  match same:
232    case True{}:
233      head
234    case False{}:
235      ""
236
237def local.clean(clean: Bool, same: Bool, +head: String) -> String:
238  match clean:
239    case True{}:
240      local.same(same, head)
241    case False{}:
242      ""
243
244def local.go(ok: Bool, clean: Bool, same: Bool, +head: String) -> String:
245  match ok:
246    case True{}:
247      local.clean(clean, same, head)
248    case False{}:
249      ""
250
251def local.rev(+abs: String, +top: String, +head: String, ok: Bool, +status: String) -> String:
252  local.go(ok, String.is_empty(status), String.eq(top, abs), head)
253
254# the commit of a local target, "" while it is not known
255def loc.rev(spot: Loc) -> String:
256  match spot:
257    case Loc{abs, top, head, ok, status}:
258      local.rev(abs, top, head, ok, status)
259    case LocWait{_a}:
260      ""
261    case LocNo{_why}:
262      ""
263
264# what a remote's HEAD printed
265def head.said(+world: TW.World, +url: String) -> Up.Said:
266  Up.printed(TW.ups(world), Up.Head{url}, "ls-remote " ++ url)
267
268# the commit HEAD names, "" while it is not known
269def head.rev(said: Up.Said) -> String:
270  match said:
271    case Up.Said{text}:
272      Git.tip.rev(Git.rows(String.lines(text)))
273    case Up.Unsaid{_why}:
274      ""
275    case Up.Unasked{_ask}:
276      ""
277
278# the commit of a free target
279def rev.free(pl: Tgt.Place, +world: TW.World) -> String:
280  match pl:
281    case Tgt.Bad{_why}:
282      ""
283    case Tgt.Local{p}:
284      loc.rev(loc(p, world))
285    case Tgt.Remote{url, _slug}:
286      head.rev(head.said(world, url))
287
288# the commit of a resolved target
289def rev.of(found: Src, +world: TW.World) -> String:
290  match found:
291    case Pin{_n, _u, r, _o}:
292      r
293    case Free{pl}:
294      rev.free(pl, world)
295    case NoSrc{_why}:
296      ""
297
298# the commit built, "" for none (EZ-TOOL-4)
299def rev(+world: TW.World) -> String:
300  rev.of(src(world), world)
301
302# ---------------------------------------------------------------------------
303# where things are
304
305# the cache root, "" for none
306def root(+world: TW.World) -> String:
307  TW.text.or(TW.cache.of(world))
308
309# the cache directory of a target: `<root>/<slug>` for a remote, and
310# `<root>/local<abs>` for a path
311def dir.of(pl: Tgt.Place, +root: String, +world: TW.World) -> String:
312  match pl:
313    case Tgt.Bad{_why}:
314      root
315    case Tgt.Local{p}:
316      root ++ "/local" ++ loc.abs(loc(p, world))
317    case Tgt.Remote{_url, slug}:
318      root ++ "/" ++ slug
319
320# the target's cache directory
321def dir(+world: TW.World) -> String:
322  dir.of(place(world), root(world), world)
323
324# a remote's checkout of a commit lives in its own directory, so a pin and a
325# free run at other commits never clone over each other
326def revdir(+world: TW.World) -> String:
327  dir(world) ++ "/" ++ rev(world)
328
329# the checkout's record, written once every file of it is laid
330def stamp.at(+world: TW.World) -> String:
331  revdir(world) ++ "/rev"
332
333# the checkout: under the cache for a remote, the path itself for a path
334def top.of(pl: Tgt.Place, +world: TW.World) -> String:
335  match pl:
336    case Tgt.Bad{_why}:
337      ""
338    case Tgt.Local{p}:
339      loc.abs(loc(p, world))
340    case Tgt.Remote{_url, _slug}:
341      revdir(world) ++ "/src"
342
343# where the checkout is
344def top(+world: TW.World) -> String:
345  top.of(place(world), world)
346
347# the library the checkout's lock is fetched into: the checkout's own for a
348# remote, and one under the cache for a path, which is never written into
349def lib.of(pl: Tgt.Place, +world: TW.World) -> String:
350  match pl:
351    case Tgt.Bad{_why}:
352      ""
353    case Tgt.Local{_p}:
354      dir(world) ++ "/lib"
355    case Tgt.Remote{_url, _slug}:
356      top(world) ++ "/.ez/lib"
357
358# ---------------------------------------------------------------------------
359# answers read
360
361# a file's text as it was answered, None when it is missing or not answered
362def text.of(got: TW.Heard) -> Maybe<&2, String>:
363  match got:
364    case TW.Open{}:
365      None{}
366    case TW.Heard{answer}:
367      match answer:
368        case TW.Text{m}:
369          m
370        case _:
371          None{}
372
373def read.got(+world: TW.World, +path: String) -> Maybe<&2, String>:
374  text.of(TW.heard(world, TW.Read{path}))
375
376# whether a question is still open
377def open.of(got: TW.Heard) -> Bool:
378  match got:
379    case TW.Open{}:
380      True{}
381    case TW.Heard{_a}:
382      False{}
383
384# whether a question of ours is still open
385def open(+world: TW.World, +ask: TW.Ask) -> Bool:
386  open.of(TW.heard(world, ask))
387
388# a question kept when it is still open
389def opens.step(open: Bool, ask: TW.Ask, rest: List<&2, TW.Ask>) -> List<&2, TW.Ask>:
390  match open:
391    case True{}:
392      ask <> rest
393    case False{}:
394      rest
395
396# the questions of these still open
397def opens(+world: TW.World, asks: List<&2, TW.Ask>) -> List<&2, TW.Ask>:
398  match asks:
399    case []:
400      []
401    case +h <> t:
402      opens.step(open(world, h), h, opens(world, t))
403
404# whether an answer said yes
405def yes.of(got: TW.Heard) -> Bool:
406  match got:
407    case TW.Open{}:
408      False{}
409    case TW.Heard{answer}:
410      match answer:
411        case TW.Is{yes}:
412          yes
413        case _:
414          False{}
415
416# ---------------------------------------------------------------------------
417# the checkout
418
419# the checkout's ledger and lock, read where it is; the files of one cloned
420# now; the questions still open; or why there is none
421type Co is Data:
422  Kept{toml: Maybe<&2, String>, lock: Maybe<&2, String>}
423  Laid{files: List<&2, K.Source>}
424  CoWait{asks: List<&2, TW.Ask>, ups: List<&2, Up.Ask>}
425  CoNo{why: String}
426
427# the files a checkout at a rev is made of, asked of git
428def clone.ask(+world: TW.World, +url: String) -> Up.Ask:
429  Up.Clone{url, rev(world)}
430
431# the checkout git gave, still to ask, or why there is none
432def co.clone(got: Up.Heard, ask: Up.Ask) -> Co:
433  match got:
434    case Up.Open{}:
435      CoWait{[], [ask]}
436    case Up.Heard{answer}:
437      match answer:
438        case Up.Cloned{_nar, fs}:
439          Laid{fs}
440        case Up.Miss{why}:
441          CoNo{why}
442        case _:
443          CoNo{"ez: a checkout was not answered with one"}
444
445# the three files of a cached checkout
446def reads(+world: TW.World) -> List<&2, TW.Ask>:
447  [TW.Read{stamp.at(world)}, TW.Read{top(world) ++ "/ez.toml"},
448    TW.Read{top(world) ++ "/ez.lock.toml"}]
449
450# the cached checkout is this commit and finished, or it is cloned again
451def co.fresh(fresh: Bool, +url: String, +world: TW.World) -> Co:
452  match fresh:
453    case True{}:
454      Kept{read.got(world, top(world) ++ "/ez.toml"), read.got(world, top(world) ++ "/ez.lock.toml")}
455    case False{}:
456      co.clone(Up.heard(TW.ups(world), clone.ask(world, url)), clone.ask(world, url))
457
458# whether the cached checkout's record names this commit. The record is
459# written after every file, so a checkout it names is whole, with or without
460# a ledger.
461def fresh(+world: TW.World) -> Bool:
462  Key.checkout(String.trim(TW.text.or(read.got(world, stamp.at(world)))), rev(world))
463
464# a remote's cached checkout, once its three files are read
465def co.remote(open: List<&2, TW.Ask>, +url: String, +world: TW.World) -> Co:
466  match open:
467    case []:
468      co.fresh(fresh(world), url, world)
469    case h <> t:
470      CoWait{h <> t, []}
471
472# a path's ledger and lock, once they are read
473def co.local(open: List<&2, TW.Ask>, +world: TW.World) -> Co:
474  match open:
475    case []:
476      Kept{read.got(world, top(world) ++ "/ez.toml"), read.got(world, top(world) ++ "/ez.lock.toml")}
477    case h <> t:
478      CoWait{h <> t, []}
479
480# the checkout of a place
481def co.of(pl: Tgt.Place, +world: TW.World) -> Co:
482  match pl:
483    case Tgt.Bad{why}:
484      CoNo{"ez: " ++ why}
485    case Tgt.Local{_p}:
486      co.local(opens(world, [TW.Read{top(world) ++ "/ez.toml"},
487        TW.Read{top(world) ++ "/ez.lock.toml"}]), world)
488    case Tgt.Remote{url, _slug}:
489      co.remote(opens(world, reads(world)), url, world)
490
491# the checkout of the target
492def co(+world: TW.World) -> Co:
493  co.of(place(world), world)
494
495# the checkout's ez.toml
496def co.toml(checkout: Co) -> Maybe<&2, String>:
497  match checkout:
498    case Kept{t, _l}:
499      t
500    case Laid{fs}:
501      K.look(fs, "ez.toml")
502    case CoWait{_as, _us}:
503      None{}
504    case CoNo{_why}:
505      None{}
506
507# the checkout's ez.lock.toml
508def co.lock(checkout: Co) -> Maybe<&2, String>:
509  match checkout:
510    case Kept{_t, l}:
511      l
512    case Laid{fs}:
513      K.look(fs, "ez.lock.toml")
514    case CoWait{_as, _us}:
515      None{}
516    case CoNo{_why}:
517      None{}
518
519# ---------------------------------------------------------------------------
520# the checkout's ledger, and the binary
521
522# the checkout, as a person knows it: a remote at its commit, or a path
523def label.of(pl: Tgt.Place, +world: TW.World) -> String:
524  match pl:
525    case Tgt.Bad{why}:
526      why
527    case Tgt.Local{_p}:
528      top(world)
529    case Tgt.Remote{url, _slug}:
530      url ++ " at " ++ rev(world)
531
532# the checkout, as a person knows it
533def label(+world: TW.World) -> String:
534  label.of(place(world), world)
535
536# the checkout's ledger as read. A checkout with no ez.toml is a plain Bend
537# repository, whose ledger names nothing: no package, no entry, no deps.
538def led.of(got: Maybe<&2, String>) -> M.Read:
539  match got:
540    case None{}:
541      M.Good{M.Manifest{"", "", "", "", "", "", [], []}}
542    case Some{text}:
543      M.parse(text)
544
545# the checkout's ledger, read
546def led(+world: TW.World) -> M.Read:
547  led.of(co.toml(co(world)))
548
549# whether the checkout is a plain Bend repository: it has no ez.toml, so no
550# git dependencies and no lock. Its program's hub imports are bend's to
551# fetch, into a library of its own under the cache.
552def plain(+world: TW.World) -> Bool:
553  none(co.toml(co(world)))
554
555# where the checkout's lock is fetched, or, for a plain repository, where
556# bend fetches its hub imports: a library of the target's own under the
557# cache, beside its binaries
558def lib(+world: TW.World) -> String:
559  Bool.pick(String, plain(world), dir(world) ++ "/lib", lib.of(place(world), world))
560
561# the model of a ledger that read, an empty one otherwise
562def man.of(parsed: M.Read) -> M.Manifest:
563  match parsed:
564    case M.Bad{_why}:
565      M.Manifest{"", "", "", "", "", "", [], []}
566    case M.Good{m}:
567      m
568
569# the checkout's ledger, empty when it did not read
570def man(+world: TW.World) -> M.Manifest:
571  man.of(led(world))
572
573# the checkout's `[package] name`, `bin` and `entry`
574def man.name(manifest: M.Manifest) -> String:
575  M.Manifest{n, _e, _b, _h, _pa, _pv, _ds, _ts} = manifest
576  n
577
578# a ledger's `bin`
579def man.bin(manifest: M.Manifest) -> String:
580  M.Manifest{_n, _e, b, _h, _pa, _pv, _ds, _ts} = manifest
581  b
582
583# a ledger's `entry`
584def man.entry(manifest: M.Manifest) -> String:
585  M.Manifest{_n, e, _b, _h, _pa, _pv, _ds, _ts} = manifest
586  e
587
588# a file's last component without its `.bend`: `src/cli.bend` is `cli`
589def stem(+file: String) -> String:
590  Path.unbend(Path.base(file))
591
592# a repository's name: the last component of its URL or path, a trailing
593# slash and `.git` taken off, so `https://github.com/o/bolt.git` is `bolt`
594def repo.name(+url: String) -> String:
595  Path.base(Tgt.ungit(Tgt.unslash(url)))
596
597# the link's name for a plain repository: the stem of the file built, or the
598# repository's name when that is `main`, since every plain repository has one
599def plain.name(+file: String, +url: String) -> String:
600  Bool.pick(String, String.eq(stem(file), "main"), repo.name(url), stem(file))
601
602# where the checkout came from, as a person named it: a remote's URL, or a
603# path's real path
604def origin.of(pl: Tgt.Place, +world: TW.World) -> String:
605  match pl:
606    case Tgt.Bad{_why}:
607      ""
608    case Tgt.Local{_p}:
609      top(world)
610    case Tgt.Remote{url, _slug}:
611      url
612
613# where the checkout came from
614def origin(+world: TW.World) -> String:
615  origin.of(place(world), world)
616
617# the checkout's `bin`
618def bin(+world: TW.World) -> String:
619  man.bin(man(world))
620
621# the checkout's `entry`
622def entry(+world: TW.World) -> String:
623  man.entry(man(world))
624
625# whether a package name can name the link and the binary: none at all, or a
626# TOML bare key, so it holds no `/` and cannot be `..`
627def name.ok(+name: String) -> Bool:
628  Bool.or(String.is_empty(name), Named.bare(name))
629
630# the file built: the pin's `bin`, then its `entry`, then the checkout's
631# `bin`, then its `entry`, then `main.bend` (EZ-TOOL-7)
632def chosen(+over: String, +binary: String, +entry: String) -> String:
633  Bool.pick(String, String.is_empty(over), Tgt.file.of(binary, entry), over)
634
635# the file asked for ahead of the checkout's own: the pin's `bin` or
636# `entry`, else the file `--entry` names, else "" so the checkout chooses. A
637# pin is what the project decided, so a flag does not move it; over a
638# checkout's ledger the flag wins, as cargo's `--bin` does.
639def wish(+over: String, +want: String) -> String:
640  Bool.pick(String, String.is_empty(over), want, over)
641
642# the file built, relative to the checkout: `main.bend` for a plain
643# repository unless `--entry` names another (EZ-TOOL-7)
644def choice(+world: TW.World) -> String:
645  chosen(wish(over(world), TW.want(world)), bin(world), entry(world))
646
647# the file built, in the checkout
648def file(+world: TW.World) -> String:
649  Path.join(top(world), choice(world))
650
651# a path's part under a directory, "" when it is not under it
652def inside(+dir: String, +path: String) -> String:
653  Bool.pick(String, String.starts_with(path, dir ++ "/"),
654    String.drop(path, Nat.add(String.length(dir), 1n)), "")
655
656# whether the file built is in the checkout: among the files git gave, or
657# read where the checkout is. A file outside the checkout is not in it.
658def present.of(checkout: Co, +world: TW.World) -> Bool:
659  match checkout:
660    case Laid{fs}:
661      Bool.not(none(K.look(fs, inside(top(world), file(world)))))
662    case Kept{_t, _l}:
663      Bool.and(Bool.not(String.is_empty(inside(top(world), file(world)))),
664        Bool.not(none(read.got(world, file(world)))))
665    case CoWait{_as, _us}:
666      False{}
667    case CoNo{_why}:
668      False{}
669
670# whether the file built is there
671def present(+world: TW.World) -> Bool:
672  present.of(co(world), world)
673
674# the question whether the file built is there, for a checkout read where it
675# is; git gave every file of one cloned now
676def file.asks.of(checkout: Co, +world: TW.World) -> List<&2, TW.Ask>:
677  match checkout:
678    case Kept{_t, _l}:
679      opens(world, [TW.Read{file(world)}])
680    case Laid{_fs}:
681      []
682    case CoWait{_as, _us}:
683      []
684    case CoNo{_why}:
685      []
686
687def file.asks(+world: TW.World) -> List<&2, TW.Ask>:
688  file.asks.of(co(world), world)
689
690# the name the link and the binary are given: for a plain repository the
691# one `plain.name` gives it, and the package's for an ez project
692def name.of(bare: Bool, +world: TW.World) -> String:
693  match bare:
694    case True{}:
695      plain.name(choice(world), origin(world))
696    case False{}:
697      man.name(man(world))
698
699# the name the link and the binary are given
700def name.raw(+world: TW.World) -> String:
701  name.of(plain(world), world)
702
703# the link's name: the package's, or `app` (EZ-TOOL-7)
704def name(+world: TW.World) -> String:
705  Tgt.out.name(name.raw(world))
706
707# what the binary is made from: the commit, the file and the bend (EZ-TOOL-2)
708def key(+world: TW.World) -> Key.Stamp:
709  Key.Stamp{rev(world), file(world), TW.bend(world)}
710
711# the directory a record's binary lives in, named by the record
712def stamp.of(key: Key.Stamp) -> String:
713  String.take(Sha.hex(Key.show(key)), 16n)
714
715# the directory of this record's binary
716def bdir(+world: TW.World) -> String:
717  dir(world) ++ "/bin/" ++ stamp.of(key(world))
718
719# the binary
720def out(+world: TW.World) -> String:
721  bdir(world) ++ "/" ++ name(world) ++ ".out"
722
723def key.at(+world: TW.World) -> String:
724  bdir(world) ++ "/key"
725
726# the binary's record as it was written, none when there is none
727def was(+world: TW.World) -> Key.Stamp:
728  Key.read(String.trim(TW.text.or(read.got(world, key.at(world)))))
729
730# whether the binary is there
731def there(+world: TW.World) -> Bool:
732  yes.of(TW.heard(world, TW.Exec{out(world)}))
733
734# the binary is reused when it is there and its record is this one
735def reuse(+world: TW.World) -> Bool:
736  Bool.and(there(world), Key.reuse(was(world), key(world)))
737
738# ---------------------------------------------------------------------------
739# the plan
740
741# the checkout laid under the cache, its record last, or nothing laid
742type Lay is Data:
743  Lay{dir: String, writes: List<&2, P.Effect>}
744  Still{}
745
746# the binary: the lock of the checkout at `top` fetched into the library
747# when `fetch` says it has one, the file built into `out` with that library
748# as BEND_LIB, and the record written at `at`; or the one there reused; or
749# nothing, for a command that refuses
750type Make is Data:
751  Make{top: String, lib: String, fetch: Bool, file: String, out: String, at: String,
752    key: String, name: String}
753  Reuse{name: String}
754  Skip{}
755
756# what is done with the binary: started with these words, as the last thing;
757# linked into `dir` under `name`, with the lines a person is told; or nothing
758type Finish is Data:
759  Launch{bin: String, args: List<&2, String>}
760  Link{bin: String, dir: String, name: String, says: List<&2, String>}
761  Idle{}
762
763# everything a tool command does, in order, and how it ends
764type Plan is Data:
765  Plan{lay: Lay, make: Make, finish: Finish, outcome: P.Outcome}
766
767# every file of a checkout written under its top
768def writes.of(+top: String, fs: List<&2, K.Source>) -> List<&2, P.Effect>:
769  match fs:
770    case []:
771      []
772    case K.Source{at, text} <> t:
773      P.Write{top ++ "/" ++ at, text} <> writes.of(top, t)
774
775# the checkout laid, when git gave it now
776def lay.of(checkout: Co, +world: TW.World) -> Lay:
777  match checkout:
778    case Laid{fs}:
779      Lay{revdir(world), List.append(&2, P.Effect, writes.of(top(world), fs),
780        [P.Write{stamp.at(world), rev(world) ++ "\n"}])}
781    case Kept{_t, _l}:
782      Still{}
783    case CoWait{_as, _us}:
784      Still{}
785    case CoNo{_why}:
786      Still{}
787
788# the checkout laid under the cache, when it was cloned now
789def lay(+world: TW.World) -> Lay:
790  lay.of(co(world), world)
791
792# the binary reused, or built with its record
793def make.of(reused: Bool, +world: TW.World) -> Make:
794  match reused:
795    case True{}:
796      Reuse{name(world)}
797    case False{}:
798      Make{top(world), lib(world), Bool.not(plain(world)), file(world), out(world),
799        key.at(world), Key.show(key(world)), name(world)}
800
801# the binary built, or reused
802def make(+world: TW.World) -> Make:
803  make.of(reuse(world), world)
804
805# one step of a scan that may stop at the first hit
806def seen.step(here: Bool, rest: Unit -> Bool) -> Bool:
807  match here:
808    case True{}:
809      True{}
810    case False{}:
811      rest(Unit{})
812
813# a directory with no trailing slash
814def bare(+dir: String) -> String:
815  Bool.pick(String, String.ends_with(dir, "/"),
816    String.take(dir, Nat.sub(String.length(dir), 1n)), dir)
817
818# whether this directory is one of the entries
819def seen(+dir: String, ps: List<&2, String>) -> Bool:
820  match ps:
821    case []:
822      False{}
823    case +h <> t:
824      seen.step(String.eq(bare(h), bare(dir)), _u => seen(dir, t))
825
826# whether a directory is an entry of `PATH`
827def on.path(+dir: String, +path: String) -> Bool:
828  seen(dir, String.split(path, ':'))
829
830# the link directory, "" for none
831def bin.at(+world: TW.World) -> String:
832  TW.text.or(TW.bin.of(TW.env(world)))
833
834# what a person is told once the link is written: that the directory is not
835# on PATH, when it is not, and what was linked where
836def says(install: Bool, +world: TW.World) -> List<&2, String>:
837  +called = name(world)
838  +at = bin.at(world) ++ "/" ++ called
839  +done = Bool.pick(String, install, Say.installed(called, at), Say.linked(called, at))
840  Bool.pick(List<&2, String>, on.path(bin.at(world), TW.env.path(TW.env(world))), [done],
841    ["warning: " ++ bin.at(world) ++ " is not on PATH", done])
842
843# the program started, or the link written
844def finish.of(act: TW.Act, +world: TW.World) -> Finish:
845  match act:
846    case TW.Run{args}:
847      Launch{out(world), args}
848    case TW.Install{}:
849      Link{out(world), bin.at(world), name(world), says(True{}, world)}
850    case TW.Upgrade{}:
851      Link{out(world), bin.at(world), name(world), says(False{}, world)}
852
853# what is done with the binary
854def finish(+world: TW.World) -> Finish:
855  finish.of(TW.act(world), world)
856
857# the plan once every answer is in and nothing refused
858def made(+world: TW.World) -> Plan:
859  Plan{lay(world), make(world), finish(world), P.Success{}}
860
861# ---------------------------------------------------------------------------
862# where a World stands
863
864# every answer is in, a question is still open, or the command refuses
865type Verdict is Data:
866  Go{}
867  Wait{asks: List<&2, TW.Ask>, ups: List<&2, Up.Ask>}
868  Stop{why: String}
869
870# a lock is needed only for a binary that is built, and only by an ez
871# project: a plain repository has none to have
872def decide.lock(missing: Bool, +world: TW.World) -> Verdict:
873  match missing:
874    case True{}:
875      Stop{"ez: " ++ label(world) ++ " has an ez.toml but no ez.lock.toml; its author " ++
876        "runs ez lock to write one"}
877    case False{}:
878      Go{}
879
880# whether the binary is to be built from an ez project with no lock
881def unlocked(+world: TW.World) -> Bool:
882  Bool.and(Bool.not(reuse(world)),
883    Bool.and(Bool.not(plain(world)), none(co.lock(co(world)))))
884
885# the binary's record and whether it is there, asked together
886def cache.asks(+world: TW.World) -> List<&2, TW.Ask>:
887  opens(world, [TW.Read{key.at(world)}, TW.Exec{out(world)}])
888
889# the binary's record and whether it is there, still to ask
890def decide.cache(open: List<&2, TW.Ask>, +world: TW.World) -> Verdict:
891  match open:
892    case []:
893      decide.lock(unlocked(world), world)
894    case h <> t:
895      Wait{h <> t, []}
896
897# why a missing file is refused: a file a pin names is the project's to fix,
898# and any other can be named with `--entry`
899def missing.why(+world: TW.World) -> String:
900  +why = "ez: " ++ label(world) ++ " has no " ++ choice(world)
901  Bool.pick(String, String.is_empty(over(world)),
902    why ++ "; name the file to build with --entry <path.bend>", why)
903
904# a file to build that is not in the checkout is refused
905def decide.present(ok: Bool, +world: TW.World) -> Verdict:
906  match ok:
907    case True{}:
908      decide.cache(cache.asks(world), world)
909    case False{}:
910      Stop{missing.why(world)}
911
912# whether the file to build is there, still to ask
913def decide.file(open: List<&2, TW.Ask>, +world: TW.World) -> Verdict:
914  match open:
915    case []:
916      decide.present(present(world), world)
917    case h <> t:
918      Wait{h <> t, []}
919
920# what a name that could not name a file is: a package's, or the one a
921# plain repository would be linked as
922def name.what(+world: TW.World) -> String:
923  Bool.pick(String, plain(world), " would be linked as ", " names its package ")
924
925# a name that could not name a file is refused
926def decide.name(ok: Bool, +world: TW.World) -> Verdict:
927  match ok:
928    case True{}:
929      decide.file(file.asks(world), world)
930    case False{}:
931      Stop{"ez: " ++ label(world) ++ name.what(world) ++ name.raw(world) ++
932        ", which is not a TOML bare key"}
933
934# a checkout ledger that does not parse is refused
935def decide.led(parsed: M.Read, +world: TW.World) -> Verdict:
936  match parsed:
937    case M.Bad{why}:
938      Stop{"ez: error: " ++ why}
939    case M.Good{_m}:
940      decide.name(name.ok(name.raw(world)), world)
941
942# the checkout answered, still to ask, or not to be had
943def decide.co(checkout: Co, +world: TW.World) -> Verdict:
944  match checkout:
945    case Kept{_t, _l}:
946      decide.led(led(world), world)
947    case Laid{_fs}:
948      decide.led(led(world), world)
949    case CoWait{as, us}:
950      Wait{as, us}
951    case CoNo{why}:
952      Stop{why}
953
954# what git says of a path, still to ask, or not to be had
955def decide.loc(spot: Loc, +world: TW.World) -> Verdict:
956  match spot:
957    case Loc{_a, _t, _h, _ok, _s}:
958      decide.co(co(world), world)
959    case LocWait{ask}:
960      Wait{[ask], []}
961    case LocNo{why}:
962      Stop{why}
963
964# a remote's HEAD has to name a commit
965def decide.tip(ok: Bool, +url: String, +world: TW.World) -> Verdict:
966  match ok:
967    case True{}:
968      decide.co(co(world), world)
969    case False{}:
970      Stop{"ez: " ++ url ++ " has no HEAD"}
971
972def decide.said(said: Up.Said, +url: String, +world: TW.World) -> Verdict:
973  match said:
974    case Up.Said{_text}:
975      decide.tip(Git.is_rev(rev(world)), url, world)
976    case Up.Unsaid{why}:
977      Stop{why}
978    case Up.Unasked{ask}:
979      Wait{[], [ask]}
980
981# a pin's commit is the lock's; a free remote's is asked of its HEAD
982def decide.head(found: Src, +url: String, +world: TW.World) -> Verdict:
983  match found:
984    case Pin{_n, _u, _r, _o}:
985      decide.co(co(world), world)
986    case Free{_pl}:
987      decide.said(head.said(world, url), url, world)
988    case NoSrc{why}:
989      Stop{why}
990
991# a slug that could leave the cache is refused (EZ-TOOL-8)
992def decide.slug(ok: Bool, +url: String, +world: TW.World) -> Verdict:
993  match ok:
994    case True{}:
995      decide.head(src(world), url, world)
996    case False{}:
997      Stop{"ez: not a repository"}
998
999def decide.place(pl: Tgt.Place, +world: TW.World) -> Verdict:
1000  match pl:
1001    case Tgt.Bad{why}:
1002      Stop{"ez: " ++ why}
1003    case Tgt.Local{p}:
1004      decide.loc(loc(p, world), world)
1005    case Tgt.Remote{url, slug}:
1006      decide.slug(Tgt.slug.ok(slug), url, world)
1007
1008# a pin at no commit cannot be built; its rev is a directory name, so it has
1009# to be one
1010def decide.pin(ok: Bool, +name: String, +rev: String, +world: TW.World) -> Verdict:
1011  match ok:
1012    case True{}:
1013      decide.place(place(world), world)
1014    case False{}:
1015      Stop{"ez: tools." ++ name ++ " in ez.lock.toml has no commit" ++
1016        Bool.pick(String, String.is_empty(rev), "", ": " ++ rev)}
1017
1018# what the target names, or why it names nothing
1019def decide.src(found: Src, +world: TW.World) -> Verdict:
1020  match found:
1021    case Pin{+n, _u, +r, _o}:
1022      decide.pin(Git.is_rev(r), n, r, world)
1023    case Free{_pl}:
1024      decide.place(place(world), world)
1025    case NoSrc{why}:
1026      Stop{why}
1027
1028# no cache root is refused
1029def decide.root(got: Maybe<&2, String>, +world: TW.World) -> Verdict:
1030  match got:
1031    case None{}:
1032      Stop{"ez: HOME is unset, so there is no cache"}
1033    case Some{_r}:
1034      decide.src(src(world), world)
1035
1036# nowhere to link is refused before anything is built
1037def decide.bin(nowhere: Bool, +world: TW.World) -> Verdict:
1038  match nowhere:
1039    case True{}:
1040      Stop{"ez: HOME is unset, so there is no bin directory"}
1041    case False{}:
1042      decide.root(TW.cache.of(world), world)
1043
1044# where a World stands
1045def decide(+world: TW.World) -> Verdict:
1046  decide.bin(Bool.and(links.act(TW.act(world)), none(TW.bin.of(TW.env(world)))), world)
1047
1048# the plan for where the World stands. One that still asks is a World the
1049# interpreter did not finish, and is refused rather than guessed at.
1050def plan.v(verdict: Verdict, +world: TW.World) -> Plan:
1051  match verdict:
1052    case Go{}:
1053      made(world)
1054    case Wait{_as, _us}:
1055      Plan{Still{}, Skip{}, Idle{}, P.Refused{"ez: " ++ TW.target(world) ++ " was never resolved"}}
1056    case Stop{why}:
1057      Plan{Still{}, Skip{}, Idle{}, P.Refused{why}}
1058
1059# `ez tool run`, `install` or `upgrade` over a World
1060def plan(+world: TW.World) -> Plan:
1061  plan.v(decide(world), world)
1062
1063# whether a plan ends in a refusal
1064def refuses.plan(pl: Plan) -> Bool:
1065  Plan{_l, _m, _f, outcome} = pl
1066  match outcome:
1067    case P.Success{}:
1068      False{}
1069    case P.Refused{_why}:
1070      True{}
1071
1072# whether a tool command refuses on a World
1073def refuses(+world: TW.World) -> Bool:
1074  refuses.plan(plan(world))
1075
1076# the questions still open, or the plan once there are none
1077type Step is Data:
1078  Asking{asks: List<&2, TW.Ask>, ups: List<&2, Up.Ask>}
1079  Run{plan: Plan}
1080
1081# the step for where the World stands
1082def step.v(verdict: Verdict, +world: TW.World) -> Step:
1083  match verdict:
1084    case Go{}:
1085      Run{made(world)}
1086    case Wait{as, us}:
1087      Asking{as, us}
1088    case Stop{why}:
1089      Run{plan.v(Stop{why}, world)}
1090
1091# what the interpreter does next on a World
1092def step(+world: TW.World) -> Step:
1093  step.v(decide(world), world)
1094
1095# ---------------------------------------------------------------------------
1096# `ez tool sync`
1097
1098# the pins a sync installs, in the order the ledger names them, or why it
1099# installs none
1100type Sync is Data:
1101  Sync{names: List<&2, String>}
1102  SyncNo{why: String}
1103
1104# a pin the lock lacks, or has at no commit, stops the scan; any other goes
1105# on to the rest
1106def sync.step(bad: Bool, +name: String, rest: Unit -> String) -> String:
1107  match bad:
1108    case True{}:
1109      "ez: tools." ++ name ++ " is not pinned at a commit in ez.lock.toml"
1110    case False{}:
1111      rest(Unit{})
1112
1113# whether the lock pins a tool at a commit
1114def sync.pinned(+tool: M.Tool) -> Bool:
1115  Bool.and(Bool.not(String.is_empty(M.tool.name(tool))),
1116    Git.is_rev(M.source.rev(M.tool.source(tool))))
1117
1118# every tool the ledger names has to be pinned at a commit in the lock; why
1119# the first that is not is refused, "" when every one is
1120def sync.check(ts: List<&2, M.Tool>, +body: String) -> String:
1121  match ts:
1122    case []:
1123      ""
1124    case +h <> t:
1125      sync.step(Bool.not(sync.pinned(pin.in(body, M.tool.name(h)))), M.tool.name(h),
1126        _u => sync.check(t, body))
1127
1128# the names of the tools a ledger pins
1129def names.of(ts: List<&2, M.Tool>) -> List<&2, String>:
1130  match ts:
1131    case []:
1132      []
1133    case h <> t:
1134      M.tool.name(h) <> names.of(t)
1135
1136# every name installed, or the refusal the scan found
1137def sync.why(ok: Bool, +why: String, ts: List<&2, M.Tool>) -> Sync:
1138  match ok:
1139    case True{}:
1140      Sync{names.of(ts)}
1141    case False{}:
1142      SyncNo{why}
1143
1144# a ledger with pins needs a lock that pins each at a commit
1145def sync.lock(got: Maybe<&2, String>, +ts: List<&2, M.Tool>) -> Sync:
1146  match got:
1147    case None{}:
1148      Bool.pick(Sync, List.is_empty(&2, M.Tool, ts), Sync{[]},
1149        SyncNo{"ez: ez.lock.toml is not there"})
1150    case Some{body}:
1151      +why = sync.check(ts, body)
1152      sync.why(String.is_empty(why), why, ts)
1153
1154# a project ledger that does not parse is refused
1155def sync.read(led: M.Read, got: Maybe<&2, String>) -> Sync:
1156  match led:
1157    case M.Bad{why}:
1158      SyncNo{"ez: error: " ++ why}
1159    case M.Good{m}:
1160      sync.lock(got, tools.of(m))
1161
1162# `ez tool sync` over the project's ledger and lock. With no ledger there is
1163# no project to sync, and it is refused, as `ez lock` and `ez fetch` refuse
1164# one.
1165def sync(ledger: Maybe<&2, String>, lock: Maybe<&2, String>) -> Sync:
1166  match ledger:
1167    case None{}:
1168      SyncNo{"ez: no ez.toml here; ez tool sync installs the tools a project's " ++
1169        "ledger pins, and ez init makes one"}
1170    case Some{text}:
1171      sync.read(M.parse(text), lock)
1172
1173# how a sync ends once every install it runs has ended: 1 with the reason
1174# when it refused, and 0 when it did not (EZ-OUT-1). An install that refuses
1175# ends the sync there, with its own status.
1176def sync.outcome(synced: Sync) -> P.Outcome:
1177  match synced:
1178    case Sync{_ns}:
1179      P.Success{}
1180    case SyncNo{why}:
1181      P.Refused{why}
1182
1183# whether a sync refuses
1184def sync.refuses(synced: Sync) -> Bool:
1185  match synced:
1186    case Sync{_ns}:
1187      False{}
1188    case SyncNo{_why}:
1189      True{}
1190
1191# the names a sync installs, none when it refuses
1192def sync.names(synced: Sync) -> List<&2, String>:
1193  match synced:
1194    case Sync{ns}:
1195      ns
1196    case SyncNo{_why}:
1197      []
1198
1199# ---------------------------------------------------------------------------
1200# law vocabulary
1201
1202# whether a checkout is laid
1203def lays(layout: Lay) -> Bool:
1204  match layout:
1205    case Lay{_d, _ws}:
1206      True{}
1207    case Still{}:
1208      False{}
1209
1210# whether a binary is built
1211def makes(making: Make) -> Bool:
1212  match making:
1213    case Make{_lk, _lb, _fe, _f, _o, _at, _k, _n}:
1214      True{}
1215    case Reuse{_n}:
1216      False{}
1217    case Skip{}:
1218      False{}
1219
1220# whether a link is written
1221def linked(fin: Finish) -> Bool:
1222  match fin:
1223    case Launch{_b, _as}:
1224      False{}
1225    case Link{_b, _d, _n, _s}:
1226      True{}
1227    case Idle{}:
1228      False{}
1229
1230# whether a plan writes anything: a checkout laid, a binary built, a link
1231def writes(pl: Plan) -> Bool:
1232  Plan{l, m, f, _o} = pl
1233  Bool.or(lays(l), Bool.or(makes(m), linked(f)))
1234
1235# whether a plan builds the binary
1236def builds(pl: Plan) -> Bool:
1237  Plan{_l, m, _f, _o} = pl
1238  makes(m)
1239
1240# the file a plan builds, "" when it builds none
1241def built(pl: Plan) -> String:
1242  Plan{_l, m, _f, _o} = pl
1243  match m:
1244    case Make{_lk, _lb, _fe, f, _o, _at, _k, _n}:
1245      f
1246    case Reuse{_n}:
1247      ""
1248    case Skip{}:
1249      ""
1250
1251# whether a plan fetches a lock before it builds
1252def fetches(pl: Plan) -> Bool:
1253  Plan{_l, m, _f, _o} = pl
1254  match m:
1255    case Make{_lk, _lb, fe, _f, _o, _at, _k, _n}:
1256      fe
1257    case Reuse{_n}:
1258      False{}
1259    case Skip{}:
1260      False{}
1261
1262# the library a plan builds with, "" when it builds nothing
1263def built.lib(pl: Plan) -> String:
1264  Plan{_l, m, _f, _o} = pl
1265  match m:
1266    case Make{_lk, lb, _fe, _f, _o, _at, _k, _n}:
1267      lb
1268    case Reuse{_n}:
1269      ""
1270    case Skip{}:
1271      ""
1272
1273# whether a plan starts the program
1274def launches(pl: Plan) -> Bool:
1275  Plan{_l, _m, f, _o} = pl
1276  match f:
1277    case Launch{_b, _as}:
1278      True{}
1279    case Link{_b, _d, _n, _s}:
1280      False{}
1281    case Idle{}:
1282      False{}
1283
1284# the path a plan links, "" when it links none
1285def link.at(pl: Plan) -> String:
1286  Plan{_l, _m, f, _o} = pl
1287  match f:
1288    case Launch{_b, _as}:
1289      ""
1290    case Link{_b, d, n, _s}:
1291      d ++ "/" ++ n
1292    case Idle{}:
1293      ""
1294
1295# how a command ends: exit 1, having started nothing; exit 0; or with the
1296# status of this program, started with these words as the last thing
1297type Exit is Data:
1298  Refusal{}
1299  Finished{}
1300  Exits{bin: String, args: List<&2, String>}
1301
1302# how a plan that goes on ends
1303def exit.of(fin: Finish) -> Exit:
1304  match fin:
1305    case Launch{b, as}:
1306      Exits{b, as}
1307    case Link{_b, _d, _n, _s}:
1308      Finished{}
1309    case Idle{}:
1310      Finished{}
1311
1312# how a plan ends
1313def exit(pl: Plan) -> Exit:
1314  Plan{_l, _m, f, o} = pl
1315  match o:
1316    case P.Success{}:
1317      exit.of(f)
1318    case P.Refused{_why}:
1319      Refusal{}
1320
1321# the status a tool command exits with, given the status the program it
1322# started exited with (EZ-OUT-1): 1 for a refusal, 0 for a command that
1323# ends without starting one, and the program's own status for `ez tool run`.
1324# A command that starts nothing is handed 0 for it.
1325def code(ended: Exit, program: U32) -> U32:
1326  match ended:
1327    case Refusal{}:
1328      1
1329    case Finished{}:
1330      0
1331    case Exits{_b, _as}:
1332      program