1pub mod paint;
73
74macro_rules! a_refusal_is_not_an_artifact {
94 () => {
95 "A run that refuses writes nothing here: the account is one English sentence on standard \
96 error and the status is 1"
97 };
98}
99
100const JSON_BESIDE_FORMAT: &str = concat!(
116 "write this run as one JSON document on standard output. It is the artifact `--format json` \
117 writes, byte for byte. A run that states both is refused rather than resolved, because two \
118 names for one target is a question answered twice. ",
119 a_refusal_is_not_an_artifact!()
120);
121
122const JSON_ALONE: &str = concat!(
130 "write this run as one JSON document on standard output, instead of the report a person \
131 reads. The document names its own shape in a `version` member, so a consumer pins that \
132 rather than the version of this engine. It moves no exit status. ",
133 a_refusal_is_not_an_artifact!()
134);
135
136const ROOT_TEXT: &str = "the repository to read. Defaults to the working directory";
141
142const VERSION_TEXT: &str = "the version of this engine. It is the number a package's \
144 `requires_engine` range is read against, and it is the number to quote in a bug report. One \
145 line on standard output, and no repository is needed to ask";
146
147const WIDE_TEXT: &str = "lay the help, and the report of `headwater check`, out at the width \
149 `COLUMNS` states, held to the range 80 to 120. A reading that is absent or is not a number \
150 gives 80, which is what a run with no flag gives. Without it nothing reads `COLUMNS`, so a run \
151 piped into a file and a run under a terminal write the same bytes. A shell keeps `COLUMNS` to \
152 itself, so the form that carries it is `COLUMNS=100 headwater --wide --help`. A run that lays \
153 nothing out, a machine format included, refuses it rather than accepting a flag that does \
154 nothing";
155
156const NO_COLOR_TEXT: &str = "force plain text on both streams: bold and dim weight and glyphs, no \
162 escape sequence. Without it, this binary senses whether each stream is a terminal and renders \
163 color there, plain text otherwise. `NO_COLOR`, set to any value, has the same effect. It is \
164 declared so that a caller who writes it out of habit is answered rather than refused";
165
166const NO_BANNER_TEXT: &str = "suppress the masthead: the line naming this binary and its version, \
173 and the rule beneath it, that the root help screen alone prints above `Usage:`. \
174 `HEADWATER_NO_BANNER`, set to any value, has the same effect. It is accepted, and inert, on \
175 every verb's own page, the same posture `--no-color` already takes for a flag that changes \
176 nothing there";
177
178#[derive(Debug)]
194pub struct Global {
195 pub id: &'static str,
201 pub name: &'static str,
203 pub summary: &'static str,
205 pub description: &'static str,
207}
208
209impl Global {
210 #[must_use]
212 pub fn covers(&self, id: &str) -> bool {
213 self.id == id
214 }
215}
216
217const ROOT: Global = Global {
218 id: "root",
219 name: "--root <path>",
220 summary: ROOT_TEXT,
221 description: ROOT_TEXT,
222};
223
224const VERSION: Global = Global {
225 id: "version",
226 name: "-V, --version",
227 summary: "the version of this engine, on one line, from anywhere",
228 description: VERSION_TEXT,
229};
230
231const WIDE: Global = Global {
232 id: "wide",
233 name: "--wide",
234 summary: "lay the help and the check report out at `COLUMNS`, 80 to 120",
235 description: WIDE_TEXT,
236};
237
238const NO_COLOR: Global = Global {
239 id: "no_color",
242 name: "--no-color",
243 summary: "force plain text, no matter what either stream senses",
244 description: NO_COLOR_TEXT,
245};
246
247const NO_BANNER: Global = Global {
248 id: "no_banner",
249 name: "--no-banner",
250 summary: "suppress the masthead this binary prints on the root screen",
251 description: NO_BANNER_TEXT,
252};
253
254const HELP: Global = Global {
264 id: "help",
265 name: "-h, --help",
266 summary: "this screen, or the long form of one verb",
267 description: "Print help",
268};
269
270pub const GLOBALS: &[&Global] = &[&ROOT, &VERSION, &WIDE, &NO_COLOR, &NO_BANNER, &HELP];
275
276use clap::{Command, CommandFactory, FromArgMatches, Parser, Subcommand};
277use headwater_check::Date;
278use std::path::PathBuf;
279
280#[derive(Parser, Debug)]
286#[command(
287 name = headwater_verbs::BINARY,
288 bin_name = headwater_verbs::BINARY,
289 color = clap::ColorChoice::Never,
290 disable_help_subcommand = true,
291 disable_version_flag = true
292)]
293pub struct Cli {
294 #[arg(
295 long,
296 global = true,
297 value_name = "path",
298 help = ROOT_TEXT
299 )]
300 pub root: Option<PathBuf>,
301
302 #[arg(
309 short = 'V',
310 long,
311 global = true,
312 help = VERSION_TEXT
313 )]
314 pub version: bool,
315
316 #[arg(
323 long,
324 global = true,
325 help = WIDE_TEXT
326 )]
327 pub wide: bool,
328
329 #[arg(
341 long = "no-color",
342 global = true,
343 help = NO_COLOR_TEXT
344 )]
345 pub no_color: bool,
346
347 #[arg(
354 long = "no-banner",
355 global = true,
356 help = NO_BANNER_TEXT
357 )]
358 pub no_banner: bool,
359
360 #[command(subcommand)]
361 pub verb: Option<Verb>,
362}
363
364#[derive(Subcommand, Debug)]
369pub enum Verb {
370 Check {
371 #[arg(
372 long,
373 help = "exit non-zero when a finding is an error. Without it the run is advisory and \
374 always exits 0, which is the default spec 6 fixes"
375 )]
376 strict: bool,
377 #[arg(
378 long,
379 help = "write the patch that rides with a finding, in this working tree. A finding \
380 carries one only when the fix is mechanical and total, and a finding an author \
381 suppressed carries none. Every patch is held against the bytes it names and \
382 the result is read back before it lands, so a file whose shape this engine \
383 guessed wrong is refused with nothing written. The report that follows is the \
384 run after the write, and the account of what was written goes to standard \
385 error. It exits non-zero on a refusal"
386 )]
387 fix: bool,
388 #[arg(
389 long = "no-cache",
390 help = "read and write no cache, and evaluate every instance. This run and a cached \
391 one write the same bytes to standard output, and a difference between them is \
392 a defect in the cache rather than a result"
393 )]
394 no_cache: bool,
395 #[arg(
396 long,
397 value_name = "date",
398 value_parser = a_date,
399 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today. Spec 12 \
400 makes the clock an injected value rather than a syscall inside a check, and \
401 this flag is where it is injected: same corpus, same lock, same date, same \
402 bytes"
403 )]
404 now: Option<Date>,
405 #[arg(
406 long,
407 value_name = "manifest",
408 help = "the manifest of the change this run is scoped to. The first line is \
409 `headwater change 1`, and a file that opens with anything else is refused \
410 rather than read. Each line after it names one document \
411 the change carries, as `added<tab><path>` or `prior<tab><path><tab><file>`, \
412 and the second form names a file holding the bytes that stood before the \
413 change. A document the manifest does not name did not change. It is what a \
414 rule that reads a transition needs, and without it every instance of such a \
415 rule is reported as skipped rather than passed. This engine walks no history: \
416 the caller anchors the prior version to the state on the branch where the \
417 change lands, which spec 12 fixes as the merge base of a proposed change and \
418 the committed `HEAD` of a working-tree hook. Every path is held against the \
419 corpus this run walks, and one that reaches no row of it is counted and named \
420 in the report rather than absorbed. No path is normalized, so `./docs/a.md` \
421 reaches no row. What this engine cannot check is whether the manifest tells \
422 the truth: a line that says `added` for a document that already stood, and a \
423 document the change carried and the manifest omits, are both invisible without \
424 the history that spec 12 rules out as an input"
425 )]
426 change: Option<PathBuf>,
427 #[arg(
428 long = "read-set",
429 value_name = "path",
430 help = "write the read set of this run to a file as well as to the report. The \
431 artifact is what decides whether a verdict survives a merge without running \
432 the checks again, and `headwater gate` is what reads it"
433 )]
434 read_set: Option<PathBuf>,
435 #[arg(
436 long,
437 value_name = "path",
438 help = "write the register of this run to a file as well as to the report. Spec 4 \
439 makes it a projection of the `obligations` and `controls` declarations, \
440 generated and never authored: every obligation with its disposition, every \
441 control with its health, and what escaped under each"
442 )]
443 register: Option<PathBuf>,
444 #[arg(
445 long,
446 value_name = "text|json|sarif|markdown",
447 help = concat!(
448 "which vocabulary to write the run in. `text` is the report a person reads and \
449 the default. `sarif` is what a forge ingests as a check run, `markdown` is a \
450 job summary or a review comment, and `json` is the finding shape spec 4 \
451 declares, for an adapter nobody here wrote. `sarif` writes its own loss set \
452 into the artifact. `markdown` declares one in the source and not in the \
453 artifact, because nothing it writes is machine-readable. `text` declares one \
454 drop there too, the routing of each skip, and carries the census and the graph \
455 that no other format holds. `json` declares one there as well, the \
456 per-document account of coverage, and writes no loss set of its own. ",
457 a_refusal_is_not_an_artifact!()
458 )
459 )]
460 format: Option<String>,
461 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
462 json: bool,
463 },
464 Gate {
465 #[arg(
469 long = "read-set",
470 value_name = "path",
471 help = "the read set to hold against this tree, and it is required here. \
472 `headwater check --read-set <path>` is what writes one. The artifact is what \
473 decides whether a verdict survives a merge without running the checks again"
474 )]
475 read_set: Option<PathBuf>,
476 #[arg(
477 long,
478 value_name = "date",
479 value_parser = a_date,
480 help = "the day the question is asked about, as `YYYY-MM-DD`. Defaults to today. A run \
481 that read the clock is void on any other day"
482 )]
483 now: Option<Date>,
484 #[arg(long, help = JSON_ALONE)]
485 json: bool,
486 },
487 Conformance {
488 #[arg(
489 long,
490 value_name = "name",
491 help = "the rung to ask about, by the name the package declares. It exits non-zero on \
492 a gap under that rung that no live waiver covers. It never moves the level the \
493 report states, which is computed from met rules alone"
494 )]
495 level: Option<String>,
496 #[arg(
497 long,
498 value_name = "date",
499 value_parser = a_date,
500 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
501 )]
502 now: Option<Date>,
503 #[arg(long, help = JSON_ALONE)]
504 json: bool,
505 },
506 Route {
507 #[arg(
508 value_name = "task description",
509 help = "what you are about to do, in your own words. Every word after the verb is one \
510 description, so it needs no quoting to hold together"
511 )]
512 task: Vec<String>,
513 #[arg(
514 long,
515 value_name = "n",
516 value_parser = a_budget,
517 help = "how many ranked pointers it may offer. It never removes a document that \
518 governs a path the task named, and it says how many it withheld. Five by \
519 default"
520 )]
521 budget: Option<usize>,
522 #[arg(long, help = JSON_ALONE)]
523 json: bool,
524 },
525 Explain {
526 #[arg(
527 value_name = "path|identifier",
528 help = "the document to explain, as a path under the corpus root or as the identifier \
529 it declares"
530 )]
531 target: Option<String>,
532 #[arg(long, help = JSON_ALONE)]
533 json: bool,
534 },
535 Query {
536 #[arg(
537 value_name = "expression",
538 help = "the expression to run, and no document of this repository states what one is"
539 )]
540 expression: Vec<String>,
541 },
542 Capture {
543 #[arg(
544 long,
545 value_name = "text|json",
546 help = concat!(
547 "`text` is the report a person reads and the default, and `json` is the same \
548 numbers for a program. Neither carries a reading the store does not hold. ",
549 a_refusal_is_not_an_artifact!()
550 )
551 )]
552 format: Option<String>,
553 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
554 json: bool,
555 },
556 Mcp {
557 #[arg(
558 long,
559 value_name = "date",
560 value_parser = a_date,
561 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today. It is read \
562 once and fixed for the life of the server, and every result states it"
563 )]
564 now: Option<Date>,
565 #[arg(
566 long,
567 help = "register the working-tree write class, which is `new` and `fix`. Spec 5 keeps \
568 it off by default, because a client may connect to a checkout that the user \
569 did not intend to change, so the consent is a word somebody typed rather than \
570 a setting a tree carries. A tool that lands a change is registered by no \
571 switch. The first call that moves a byte ends the server: it walked the corpus \
572 once, so every later answer would be about a tree that is gone"
573 )]
574 write: bool,
575 },
576 New {
577 #[arg(
578 value_name = "kind",
579 help = "the kind of document to scaffold, by the name the resolved taxonomy declares \
580 for it"
581 )]
582 kind: Option<String>,
583 #[arg(
584 long,
585 value_name = "text",
586 help = "what the document is called. Required, because the file name and the facet in \
587 the `name` role both come from it"
588 )]
589 title: Option<String>,
590 #[arg(
591 long,
592 value_name = "text",
593 help = "the one sentence a reader meets where a list of documents is rendered. \
594 Fills the facet in the `scent` role directly, exactly as `--title` fills the \
595 one in the `name` role. Without it the field carries a prompt, and a person \
596 edits the front matter by hand before the document is current"
597 )]
598 summary: Option<String>,
599 #[arg(
600 long,
601 value_name = "relation=identifier",
602 value_parser = a_pair,
603 help = "an edge to propose, as a relation and the identifier of the document at the \
604 other end. Repeatable. It is refused unless the taxonomy declares \
605 `created_by: scaffold` on the relation, unless both ends are kinds the \
606 relation permits, and unless the target resolves. Where reciprocity is \
607 required the far half is written into the target document"
608 )]
609 relates: Vec<(String, String)>,
610 #[arg(
611 long,
612 value_name = "facet=value",
613 value_parser = a_pair,
614 help = "a value for a facet this kind requires, as `<facet>=<value>`. Repeatable. A \
615 facet the kind does not require is refused, and so is a value outside a \
616 closed set, with the set printed. A facet that a declaration decides is \
617 refused too: an engine role decides its facet's value, and the kind decides \
618 the discriminator of a heterogeneous shelf"
619 )]
620 facet: Vec<(String, String)>,
621 #[arg(
622 long,
623 value_name = "date",
624 value_parser = a_date,
625 help = "the date the document is stamped with, as `YYYY-MM-DD`. Defaults to today"
626 )]
627 now: Option<Date>,
628 },
629 Infer {
630 #[arg(
631 long,
632 value_name = "name",
633 help = "who owns the debt it proposes. Required with `--write`, because an owner is \
634 the field that ranks declared debt above a suppression and this engine will \
635 not invent one"
636 )]
637 owner: Option<String>,
638 #[arg(
639 long,
640 value_name = "date",
641 value_parser = a_date,
642 help = "the last day the tasks it proposes hold, as `YYYY-MM-DD`. Ninety days out by \
643 default"
644 )]
645 until: Option<Date>,
646 #[arg(
647 long,
648 help = "put the payload in the lock, which is committed and reviewed. Without it \
649 nothing is written"
650 )]
651 write: bool,
652 #[arg(
653 long,
654 value_name = "date",
655 value_parser = a_date,
656 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
657 )]
658 now: Option<Date>,
659 },
660 Generate {
661 #[arg(
662 long,
663 help = "write nothing and exit non-zero when what is committed is not what a run \
664 produces. It reads the corpus through the lock, so it answers whether a \
665 derived artifact is current"
666 )]
667 check: bool,
668 },
669 Import {
670 #[arg(
671 value_name = "name",
672 help = "which declared import to read, by the name its block carries in \
673 `.headwater/taxonomy.yml`. One declared import needs no name and two do, \
674 because choosing for the caller would import whichever the file listed first"
675 )]
676 name: Option<String>,
677 #[arg(
678 long,
679 value_name = "digest",
680 help = "the digest to check the snapshot against. It defaults to the `digest` of the \
681 import block in `.headwater/taxonomy.yml`, and the verb refuses when neither \
682 is there rather than reading an unpinned directory"
683 )]
684 expect: Option<String>,
685 #[arg(
686 long,
687 help = "write the edge halves into the documents at their near ends. Without it the \
688 edges are reported and nothing is touched"
689 )]
690 write: bool,
691 },
692 Export {
693 #[arg(
694 long,
695 value_name = "name",
696 help = "which declared export profile to emit. Every declared profile by default, so \
697 a filtered audience is never omitted by accident"
698 )]
699 profile: Option<String>,
700 #[arg(
701 long,
702 value_name = "json|jsonschema",
703 help = concat!(
704 "the emitter target. `json` is the native property graph with no loss and \
705 `jsonschema` constrains front matter. The other five targets of spec 6 parse \
706 and report the consumer each one waits on. With this flag the artifact goes to \
707 standard output and no declared output path is touched. ",
708 a_refusal_is_not_an_artifact!()
709 )
710 )]
711 format: Option<String>,
712 #[arg(
713 long,
714 value_name = "date",
715 value_parser = a_date_as_written,
716 help = "the generation time the artifact states, as `YYYY-MM-DD`. Absent by default, \
717 because an artifact that `--check` compares by byte cannot carry a clock \
718 reading. Spec 6 asks a filtered export that leaves the repository to state \
719 one, and this is where it is injected"
720 )]
721 at: Option<String>,
722 #[arg(
723 long,
724 help = "write nothing and exit non-zero when a declared output is not what a run \
725 produces. It holds every export the taxonomy names a path for to regeneration"
726 )]
727 check: bool,
728 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
729 json: bool,
730 },
731 Sweep {
732 #[command(subcommand)]
733 word: Option<SweepWord>,
734 },
735 Probe {
736 #[command(subcommand)]
737 word: Option<ProbeWord>,
738 },
739 Init {
740 #[arg(
741 long,
742 value_name = "dir",
743 help = "the corpus root to declare. Proposed from the tree by default"
744 )]
745 corpus: Option<String>,
746 #[arg(
747 long,
748 value_name = "name",
749 help = "the package to take. `headwater/standard` by default"
750 )]
751 package: Option<String>,
752 },
753 Taxonomy {
754 #[command(subcommand)]
755 word: Option<TaxonomyWord>,
756 },
757 Json {
761 #[command(subcommand)]
762 word: Option<JsonWord>,
763 },
764 Help {
773 #[arg(
774 value_name = "verb",
775 help = "the verb to describe, with its second word where it takes one: \
776 `headwater help taxonomy diff`. Without one this screen is printed"
777 )]
778 verb: Vec<String>,
779 },
780 Completions {
785 #[arg(
786 value_name = "shell",
787 help = "the shell to write a script for. A name outside the four is refused with the \
788 four printed, and no script is written"
789 )]
790 shell: Option<Shell>,
791 },
792 #[command(external_subcommand)]
799 Other(Vec<String>),
800}
801
802#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
818pub enum Shell {
819 Bash,
820 Zsh,
821 Fish,
822 Powershell,
823}
824
825impl From<Shell> for clap_complete::Shell {
826 fn from(shell: Shell) -> Self {
827 match shell {
828 Shell::Bash => clap_complete::Shell::Bash,
829 Shell::Zsh => clap_complete::Shell::Zsh,
830 Shell::Fish => clap_complete::Shell::Fish,
831 Shell::Powershell => clap_complete::Shell::PowerShell,
832 }
833 }
834}
835
836impl Shell {
837 pub fn typed(self) -> &'static str {
839 match self {
840 Shell::Bash => "bash",
841 Shell::Zsh => "zsh",
842 Shell::Fish => "fish",
843 Shell::Powershell => "powershell",
844 }
845 }
846
847 pub const ALL: &'static [Shell] = &[Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Powershell];
849}
850
851#[derive(Subcommand, Debug)]
853pub enum JsonWord {
854 Field {
855 #[arg(
856 value_name = "key",
857 help = "the path of keys to the member, outermost first. \
858 `headwater json field tool_input file_path` reads the `file_path` member of \
859 the `tool_input` member. Without one, the object is read and no member of it \
860 is named, which is refused"
861 )]
862 path: Vec<String>,
863 },
864 Count {
865 #[arg(
866 value_name = "key",
867 help = "the path of keys to the array or the object whose elements are counted, \
868 outermost first. Without one, the object on standard input is the one counted"
869 )]
870 path: Vec<String>,
871 },
872 Quote,
873 #[command(external_subcommand)]
874 Other(Vec<String>),
875}
876
877#[derive(Subcommand, Debug)]
879pub enum SweepWord {
880 Plan {
881 #[arg(
882 long,
883 value_name = "path",
884 help = "the slice, as a path prefix under the repository root. The whole corpus by \
885 default. There is no sampling rule here: a slice this engine picked would be \
886 an unreproducible sample dressed as a reproducible one, and the plan reports \
887 its own extent instead"
888 )]
889 under: Option<String>,
890 },
891 Report {
892 #[arg(
893 value_name = "path",
894 help = "the file an agent wrote back. `headwater sweep plan` prints the shape of it"
895 )]
896 path: Option<String>,
897 #[arg(
898 long,
899 value_name = "text|json",
900 help = concat!(
901 "`text` is the report a person reads and the default, and `json` is the finding \
902 shape spec 4 declares with the provenance and the evidence a sweep adds. ",
903 a_refusal_is_not_an_artifact!()
904 )
905 )]
906 format: Option<String>,
907 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
908 json: bool,
909 },
910 #[command(external_subcommand)]
911 Other(Vec<String>),
912}
913
914#[derive(Subcommand, Debug)]
916pub enum ProbeWord {
917 Plan {
918 #[arg(
919 long,
920 value_name = "regression|campaign",
921 help = "which tier of `.headwater/probe.yml` to plan against. A tier declares the \
922 ceiling, the session cost, the repetitions and the arms, and the plan is \
923 projected against all four. `regression` by default"
924 )]
925 tier: Option<String>,
926 #[arg(
927 long,
928 value_name = "present|absent",
929 help = "narrow the selection to one arm the tier declares. Every arm the tier \
930 declares by default, which is one for `regression` and two for `campaign`. \
931 An arm the tier does not declare refuses the run rather than planning \
932 another one, and the refusal names the arms the tier declares"
933 )]
934 arm: Option<String>,
935 #[arg(
936 long,
937 value_name = "name",
938 help = "narrow the selection to one probe category, by the name this engine declares \
939 for it. Every category by default, a name outside the closed set is refused \
940 with the set printed, and a category no probe of this corpus carries is \
941 refused rather than planned as a run of nothing"
942 )]
943 category: Option<String>,
944 #[arg(
954 long,
955 value_name = "n",
956 default_value_t = 0,
957 help = "the rotation seed, which is a member of the run identity spec 5 declares. It \
958 is the caller's number: a run that states none states zero, and it is \
959 recorded as stated. No selection is drawn from it — every declared probe is \
960 selected — so it identifies a run rather than choosing one"
961 )]
962 seed: u64,
963 },
964 Record {
965 #[arg(
966 value_name = "path",
967 help = "the transcript a recorder wrote. `headwater probe plan` prints the run \
968 identity it has to carry"
969 )]
970 path: Option<String>,
971 },
972 Grade {
973 #[arg(
974 value_name = "path",
975 help = "the transcript a recorder wrote. It is graded against the probes this corpus \
976 declares, re-derived here rather than taken from the transcript"
977 )]
978 path: Option<String>,
979 },
980 Stale,
981 #[command(external_subcommand)]
982 Other(Vec<String>),
983}
984
985#[derive(Subcommand, Debug)]
987pub enum TaxonomyWord {
988 Validate,
989 Resolve {
990 #[arg(
991 long,
992 help = "write nothing and exit non-zero when what is committed is not what a run \
993 produces. It reads the taxonomy sources, so it answers whether the lock is \
994 current"
995 )]
996 check: bool,
997 },
998 Audit {
999 #[arg(
1000 long,
1001 value_name = "date",
1002 value_parser = a_date,
1003 help = "the date a staleness reading and a dwell reading are taken at, as \
1004 `YYYY-MM-DD`. Defaults to today, and two audits of one tree at one date write \
1005 the same bytes"
1006 )]
1007 now: Option<Date>,
1008 #[arg(
1009 long,
1010 help = "append this run's adoption reading to `.headwater/adoption.jsonl`. Without it \
1011 the verb writes nothing. A reading the store already holds at this lock and \
1012 this date is not appended twice, so two recorded audits of one tree at one \
1013 date still write the same bytes"
1014 )]
1015 record: bool,
1016 },
1017 Publish {
1018 #[arg(
1019 long,
1020 value_name = "name",
1021 help = "the package to publish. The one this repository's own declaration takes, by \
1022 default, because a publisher usually publishes what it also consumes. Refused \
1023 together with `--from`, which names the same thing by its directory instead"
1024 )]
1025 package: Option<String>,
1026 #[arg(
1027 long,
1028 value_name = "dir",
1029 help = "read the manifest at this directory directly, bypassing the lookup by name \
1030 under `packages/` that `--package` drives. For a repository that both \
1031 publishes a package and consumes it: `taxonomy vendor` refuses to install over \
1032 a directory that carries no release record, so a maintained source cannot sit \
1033 where its own artifact would be installed. This reads it from wherever it \
1034 actually sits instead"
1035 )]
1036 from: Option<PathBuf>,
1037 #[arg(
1038 long,
1039 value_name = "name",
1040 help = "derive and publish this named assembly from the source package. It uses the \
1041 same source selection as `--package` or `--from`, and produces one flattened \
1042 package with no runtime bundle selection"
1043 )]
1044 assembly: Option<String>,
1045 #[arg(
1046 long,
1047 value_name = "dir",
1048 help = "where to write the artifact. The directory must be empty or absent, because a \
1049 published artifact is every file under its root and a stray one would be a \
1050 member the publisher never shipped. A run that cannot finish leaves it as it \
1051 found it, so a second run meets the same precondition the first one did"
1052 )]
1053 out: Option<PathBuf>,
1054 #[arg(
1055 long,
1056 help = "remove what a publish killed part-way left at `--out`, and publish in the \
1057 same run. It removes one state and nothing else: files at `--out` with no \
1058 release record, beside a `<out>~staging` directory holding both the files a \
1059 publish writes there to say it could not move the artifact into place and is \
1060 writing into `--out` one file at a time. Only a killed publish leaves those \
1061 two together, and the second one names the output path it was writing. A \
1062 directory holding anything else, and an `--out` that carries a release \
1063 record, are left exactly as they are and the publish refuses as it does \
1064 without this flag"
1065 )]
1066 clear_killed: bool,
1067 #[arg(long, help = JSON_ALONE)]
1068 json: bool,
1069 },
1070 Vendor {
1071 #[arg(
1072 value_name = "dir",
1073 help = "the directory of an artifact somebody already fetched. This engine opens no \
1074 socket, so the verb takes a path and never a location"
1075 )]
1076 path: Option<String>,
1077 #[arg(
1078 long,
1079 value_name = "digest",
1080 help = "the digest to check the artifact against. It defaults to `taxonomy.digest` in \
1081 `.headwater/taxonomy.yml`, and the verb refuses when neither is there. A pin \
1082 the engine took from the artifact in front of it would be a pin against itself"
1083 )]
1084 expect: Option<String>,
1085 },
1086 Diff {
1087 #[arg(
1088 value_name = "dir",
1089 help = "the directory of an artifact somebody already fetched. This engine opens no \
1090 socket, so the verb takes a path and never a location"
1091 )]
1092 path: Option<String>,
1093 #[arg(
1094 long,
1095 value_name = "version",
1096 help = "the version the artifact is expected to be, written as a version or as a \
1097 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs. This engine \
1098 fetches nothing, so the directory decides which artifact is compared and this \
1099 flag holds it to what the caller meant. It is read by the one range reader \
1100 the engine has, which is what reads `requires_engine`"
1101 )]
1102 to: Option<String>,
1103 #[arg(
1104 long,
1105 value_name = "date",
1106 value_parser = a_date,
1107 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1108 )]
1109 now: Option<Date>,
1110 },
1111 Migrate {
1112 #[arg(
1113 value_name = "dir",
1114 help = "the directory of an artifact somebody already fetched. This engine opens no \
1115 socket, so the verb takes a path and never a location"
1116 )]
1117 path: Option<String>,
1118 #[arg(
1119 long,
1120 value_name = "version",
1121 help = "the version the artifact is expected to be, written as a version or as a \
1122 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs"
1123 )]
1124 to: Option<String>,
1125 #[arg(
1126 long,
1127 help = "write the files each step names. Without it every file each step would write \
1128 is reported and nothing is written"
1129 )]
1130 apply: bool,
1131 #[arg(
1132 long,
1133 value_name = "date",
1134 value_parser = a_date,
1135 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1136 )]
1137 now: Option<Date>,
1138 },
1139 #[command(external_subcommand)]
1140 Other(Vec<String>),
1141}
1142
1143pub fn command() -> Command {
1155 command_at(paint::width())
1156}
1157
1158pub fn command_at(width: usize) -> Command {
1165 let mut root = Cli::command()
1166 .about(format!(
1167 "{} — {}",
1168 headwater_verbs::BINARY,
1169 headwater_verbs::TAGLINE
1170 ))
1171 .help_template(first_screen(width));
1172 for verb in headwater_verbs::VERBS {
1173 if root.find_subcommand(verb.name).is_some() {
1184 root = root.mut_subcommand(verb.name, |one| described(one, verb, width));
1185 }
1186 }
1187 paint::painted(root, width)
1188}
1189
1190pub fn parsed() -> Result<Cli, clap::Error> {
1198 let matches = command().try_get_matches()?;
1199 if let Some(message) = a_width_for_a_run_that_lays_nothing_out(&matches) {
1200 return Err(clap::Error::raw(
1201 clap::error::ErrorKind::ArgumentConflict,
1202 message,
1203 ));
1204 }
1205 Cli::from_arg_matches(&matches)
1206}
1207
1208fn a_width_for_a_run_that_lays_nothing_out(matches: &clap::ArgMatches) -> Option<String> {
1263 let mut leaf = matches;
1264 while let Some((_, inner)) = leaf.subcommand() {
1265 leaf = inner;
1266 }
1267 if leaf.try_get_one::<bool>("wide").ok().flatten() != Some(&true) {
1268 return None;
1269 }
1270 if matches.subcommand_name() == Some("help") {
1271 return None;
1272 }
1273 let format = leaf.try_get_one::<String>("format").ok().flatten();
1274 let json = leaf.try_get_one::<bool>("json").ok().flatten() == Some(&true);
1280 let laid_out = matches.subcommand_name() == Some("check")
1283 && !json
1284 && matches!(format.map(String::as_str), None | Some("text"));
1285 if laid_out {
1286 return None;
1287 }
1288 let says = match format.filter(|value| value.as_str() != "text") {
1289 Some(format) => format!("`--format {format}` writes an artifact that nothing lays out"),
1290 None if json => "`--json` writes an artifact that nothing lays out".to_string(),
1291 None => "this run lays nothing out".to_string(),
1292 };
1293 Some(format!(
1294 "`--wide` says how wide the help and the report of `{0} check` are laid out, and {says}. A \
1295 run carrying it would carry one flag that does nothing, so it is refused rather than run. \
1296 The runs it widens are `{0} --wide --help`, `{0} <verb> --wide --help`, `{0} --wide help \
1297 <verb>` and `{0} check --wide`",
1298 headwater_verbs::BINARY
1299 ))
1300}
1301
1302fn described(command: Command, verb: &headwater_verbs::Verb, width: usize) -> Command {
1304 let mut one = command.about(verb.description);
1305 if !verb.words.is_empty() {
1306 one = one.help_template(second_words(verb, width));
1307 for word in verb.words {
1308 if one.find_subcommand(word.name).is_none() {
1309 continue;
1310 }
1311 one = one.mut_subcommand(word.name, |inner| inner.about(word.description));
1312 }
1313 }
1314 one
1315}
1316
1317const COLUMN: usize = 15;
1319
1320fn first_screen(width: usize) -> String {
1337 let mut out = String::from("{usage-heading} {usage}\n\nExamples:\n");
1352 for (line, says) in [
1353 (
1354 "headwater check --strict",
1355 "run the checks, and fail on an error",
1356 ),
1357 (
1358 "headwater route \"add rate limiting\"",
1359 "the documents that govern a task",
1360 ),
1361 (
1362 "headwater explain HW-DR-0033",
1363 "why a document is the kind it is",
1364 ),
1365 (
1366 "headwater new decision --title \"Adopt an overlay\"",
1367 "scaffold a document of a kind",
1368 ),
1369 (
1370 "headwater help taxonomy diff",
1371 "the long description of one verb",
1372 ),
1373 ] {
1374 out.push_str(&format!(" {line}\n"));
1379 out.push_str(&paint::fold_indented(says, width, 6));
1380 }
1381 for group in headwater_verbs::groups() {
1382 out.push_str(&format!("\n{group}:\n"));
1383 for verb in headwater_verbs::VERBS
1384 .iter()
1385 .filter(|one| one.group == group)
1386 {
1387 out.push_str(&paint::row(verb.name, verb.summary, COLUMN, width));
1388 }
1389 }
1390 let longest = GLOBALS
1407 .iter()
1408 .map(|one| one.name.chars().count())
1409 .max()
1410 .unwrap_or(0);
1411 let at = 2 + longest + 2;
1412 out.push_str("\nGlobal flags:\n");
1413 for one in GLOBALS {
1414 out.push_str(&paint::row(one.name, one.summary, at, width));
1415 }
1416 out.push('\n');
1417 out.push_str(&paint::fold_indented(
1418 &format!(
1419 "Run `{0} help <verb>` for the long description of one verb, or `{0} <verb> --help`.",
1420 headwater_verbs::BINARY
1421 ),
1422 width,
1423 0,
1424 ));
1425 out
1426}
1427
1428fn second_words(verb: &headwater_verbs::Verb, width: usize) -> String {
1436 let mut out = String::from("{about}\n\n{usage-heading} {usage}\n\nSecond words:\n");
1437 for word in verb.words {
1438 out.push_str(&paint::row(word.name, word.summary, COLUMN, width));
1439 }
1440 out.push_str("\nFlags:\n{options}\n\n");
1441 out.push_str(&paint::fold_indented(
1442 &format!(
1443 "Run `{} help {} <word>` for the long description of one.",
1444 headwater_verbs::BINARY,
1445 verb.name
1446 ),
1447 width,
1448 0,
1449 ));
1450 out
1451}
1452
1453fn a_date(text: &str) -> Result<Date, String> {
1455 Date::parse(text).ok_or_else(|| "a date written `YYYY-MM-DD`".to_string())
1456}
1457
1458fn a_date_as_written(text: &str) -> Result<String, String> {
1463 a_date(text).map(|_| text.to_string())
1464}
1465
1466fn a_budget(text: &str) -> Result<usize, String> {
1468 match text.parse::<usize>() {
1469 Ok(value) if value > 0 => Ok(value),
1470 _ => Err("a whole number above zero".to_string()),
1471 }
1472}
1473
1474fn a_pair(text: &str) -> Result<(String, String), String> {
1480 match text.split_once('=') {
1481 Some((left, right)) if !left.is_empty() && !right.is_empty() => {
1482 Ok((left.to_string(), right.to_string()))
1483 }
1484 _ => Err(
1485 "`<left>=<right>`, as in `--relates supersedes=HW-DR-0007` or \
1486 `--facet probe_category=discovery`"
1487 .to_string(),
1488 ),
1489 }
1490}
1491
1492pub fn headline(error: &clap::Error) -> String {
1506 let rendered = error.render().to_string();
1507 let head: Vec<String> = rendered
1508 .lines()
1509 .take_while(|line| !line.starts_with("Usage:") && !line.starts_with("For more information"))
1510 .map(str::trim)
1511 .filter(|line| !line.is_empty())
1512 .map(|line| line.strip_prefix("error: ").unwrap_or(line).to_string())
1513 .collect();
1514 match head.is_empty() {
1515 true => rendered.split_whitespace().collect::<Vec<_>>().join(" "),
1516 false => head.join("\n"),
1517 }
1518}
1519
1520#[cfg(test)]
1521mod tests {
1522 use super::{a_budget, a_date, a_pair, command, headline, Cli};
1523 use clap::{CommandFactory, Parser};
1524
1525 #[test]
1526 fn the_declared_parse_is_a_command_clap_can_build() {
1527 Cli::command().debug_assert();
1528 command().debug_assert();
1529 }
1530
1531 #[test]
1532 fn a_value_a_flag_cannot_take_is_named_rather_than_defaulted() {
1533 assert!(a_date("2026-13-45").is_err());
1534 assert!(a_date("2026-01-01").is_ok());
1535 assert!(a_budget("0").is_err());
1536 assert!(a_budget("x").is_err());
1537 assert_eq!(a_budget("3"), Ok(3));
1538 assert!(a_pair("nope").is_err());
1539 assert!(a_pair("=right").is_err());
1540 assert!(a_pair("left=").is_err());
1541 assert_eq!(
1542 a_pair("supersedes=HW-DR-0007"),
1543 Ok(("supersedes".to_string(), "HW-DR-0007".to_string()))
1544 );
1545 }
1546
1547 #[test]
1554 fn a_refusal_carries_the_message_and_not_the_grammar() {
1555 let error = Cli::try_parse_from(["headwater", "check", "--nonsense"])
1556 .expect_err("`--nonsense` is not a flag `check` reads");
1557 let headline = headline(&error);
1558 assert!(
1559 headline.contains("--nonsense"),
1560 "the refusal names the offending word: {headline}"
1561 );
1562 assert!(
1563 !headline.contains("--root <path>"),
1564 "the refusal does not reprint the grammar: {headline}"
1565 );
1566 assert!(
1567 !headline.contains("Usage:"),
1568 "the refusal does not reprint the usage block: {headline}"
1569 );
1570 assert!(
1571 !headline.contains("For more information"),
1572 "the pointer is `fail`'s and is written once: {headline}"
1573 );
1574 }
1575
1576 #[test]
1581 fn a_flag_of_another_verb_does_not_reach_this_one() {
1582 assert!(Cli::try_parse_from(["headwater", "check", "--level", "L0"]).is_err());
1583 assert!(Cli::try_parse_from(["headwater", "conformance", "--level", "L0"]).is_ok());
1584 assert!(Cli::try_parse_from(["headwater", "sweep", "report", "f", "--strict"]).is_err());
1585 assert!(Cli::try_parse_from(["headwater", "check", "--strict"]).is_ok());
1586 }
1587
1588 #[test]
1592 fn the_corpus_flag_reaches_every_verb_from_either_side_of_it() {
1593 for arguments in [
1594 ["headwater", "check", "--root", "/tmp"],
1595 ["headwater", "--root", "/tmp", "check"],
1596 ] {
1597 let cli = Cli::try_parse_from(arguments).expect("`--root` is global");
1598 assert_eq!(cli.root.as_deref(), Some(std::path::Path::new("/tmp")));
1599 }
1600 }
1601}