1pub mod paint;
73pub mod taxonomy_graph;
74
75macro_rules! a_refusal_is_not_an_artifact {
95 () => {
96 "A run that refuses writes nothing here: the account is one English sentence on standard \
97 error and the status is 1"
98 };
99}
100
101const JSON_BESIDE_FORMAT: &str = concat!(
117 "write this run as one JSON document on standard output. It is the artifact `--format json` \
118 writes, byte for byte. A run that states both is refused rather than resolved, because two \
119 names for one target is a question answered twice. ",
120 a_refusal_is_not_an_artifact!()
121);
122
123const JSON_ALONE: &str = concat!(
131 "write this run as one JSON document on standard output, instead of the report a person \
132 reads. The document names its own shape in a `version` member, so a consumer pins that \
133 rather than the version of this engine. It moves no exit status. ",
134 a_refusal_is_not_an_artifact!()
135);
136
137const ROOT_TEXT: &str = "the repository to read. Defaults to the working directory";
142
143const VERSION_TEXT: &str = "the version of this engine. It is the number a package's \
145 `requires_engine` range is read against, and it is the number to quote in a bug report. One \
146 line on standard output, and no repository is needed to ask";
147
148const WIDE_TEXT: &str = "lay the help, and the report of `headwater check`, out at the width \
150 `COLUMNS` states, held to the range 80 to 120. A reading that is absent or is not a number \
151 gives 80, which is what a run with no flag gives. Without it nothing reads `COLUMNS`, so a run \
152 piped into a file and a run under a terminal write the same bytes. A shell keeps `COLUMNS` to \
153 itself, so the form that carries it is `COLUMNS=100 headwater --wide --help`. A run that lays \
154 nothing out, a machine format included, refuses it rather than accepting a flag that does \
155 nothing";
156
157const NO_COLOR_TEXT: &str = "force plain text on both streams: bold and dim weight and glyphs, no \
163 escape sequence. Without it, this binary senses whether each stream is a terminal and renders \
164 color there, plain text otherwise. `NO_COLOR`, set to any value, has the same effect. It is \
165 declared so that a caller who writes it out of habit is answered rather than refused";
166
167const NO_BANNER_TEXT: &str = "suppress the masthead: the line naming this binary and its version, \
174 and the rule beneath it, that the root help screen alone prints above `Usage:`. \
175 `HEADWATER_NO_BANNER`, set to any value, has the same effect. It is accepted, and inert, on \
176 every verb's own page, the same posture `--no-color` already takes for a flag that changes \
177 nothing there";
178
179#[derive(Debug)]
195pub struct Global {
196 pub id: &'static str,
202 pub name: &'static str,
204 pub summary: &'static str,
206 pub description: &'static str,
208}
209
210impl Global {
211 #[must_use]
213 pub fn covers(&self, id: &str) -> bool {
214 self.id == id
215 }
216}
217
218const ROOT: Global = Global {
219 id: "root",
220 name: "--root <path>",
221 summary: ROOT_TEXT,
222 description: ROOT_TEXT,
223};
224
225const VERSION: Global = Global {
226 id: "version",
227 name: "-V, --version",
228 summary: "the version of this engine, on one line, from anywhere",
229 description: VERSION_TEXT,
230};
231
232const WIDE: Global = Global {
233 id: "wide",
234 name: "--wide",
235 summary: "lay the help and the check report out at `COLUMNS`, 80 to 120",
236 description: WIDE_TEXT,
237};
238
239const NO_COLOR: Global = Global {
240 id: "no_color",
243 name: "--no-color",
244 summary: "force plain text, no matter what either stream senses",
245 description: NO_COLOR_TEXT,
246};
247
248const NO_BANNER: Global = Global {
249 id: "no_banner",
250 name: "--no-banner",
251 summary: "suppress the masthead this binary prints on the root screen",
252 description: NO_BANNER_TEXT,
253};
254
255const HELP: Global = Global {
265 id: "help",
266 name: "-h, --help",
267 summary: "this screen, or the long form of one verb",
268 description: "Print help",
269};
270
271pub const GLOBALS: &[&Global] = &[&ROOT, &VERSION, &WIDE, &NO_COLOR, &NO_BANNER, &HELP];
276
277use clap::{Command, CommandFactory, FromArgMatches, Parser, Subcommand};
278use headwater_check::Date;
279use std::path::PathBuf;
280
281#[derive(Parser, Debug)]
287#[command(
288 name = headwater_verbs::BINARY,
289 bin_name = headwater_verbs::BINARY,
290 color = clap::ColorChoice::Never,
291 disable_help_subcommand = true,
292 disable_version_flag = true
293)]
294pub struct Cli {
295 #[arg(
296 long,
297 global = true,
298 value_name = "path",
299 help = ROOT_TEXT
300 )]
301 pub root: Option<PathBuf>,
302
303 #[arg(
310 short = 'V',
311 long,
312 global = true,
313 help = VERSION_TEXT
314 )]
315 pub version: bool,
316
317 #[arg(
324 long,
325 global = true,
326 help = WIDE_TEXT
327 )]
328 pub wide: bool,
329
330 #[arg(
342 long = "no-color",
343 global = true,
344 help = NO_COLOR_TEXT
345 )]
346 pub no_color: bool,
347
348 #[arg(
355 long = "no-banner",
356 global = true,
357 help = NO_BANNER_TEXT
358 )]
359 pub no_banner: bool,
360
361 #[command(subcommand)]
362 pub verb: Option<Verb>,
363}
364
365#[derive(Subcommand, Debug)]
370pub enum Verb {
371 Check {
372 #[arg(
373 long,
374 help = "exit non-zero when a finding is an error. Without it the run is advisory and \
375 always exits 0, which is the default spec 6 fixes"
376 )]
377 strict: bool,
378 #[arg(
379 long,
380 help = "write the patch that rides with a finding, in this working tree. A finding \
381 carries one only when the fix is mechanical and total, and a finding an author \
382 suppressed carries none. Every patch is held against the bytes it names and \
383 the result is read back before it lands, so a file whose shape this engine \
384 guessed wrong is refused with nothing written. The report that follows is the \
385 run after the write, and the account of what was written goes to standard \
386 error. It exits non-zero on a refusal"
387 )]
388 fix: bool,
389 #[arg(
390 long = "no-cache",
391 help = "read and write no cache, and evaluate every instance. This run and a cached \
392 one write the same bytes to standard output, and a difference between them is \
393 a defect in the cache rather than a result"
394 )]
395 no_cache: bool,
396 #[arg(
397 long,
398 value_name = "date",
399 value_parser = a_date,
400 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today. Spec 12 \
401 makes the clock an injected value rather than a syscall inside a check, and \
402 this flag is where it is injected: same corpus, same lock, same date, same \
403 bytes"
404 )]
405 now: Option<Date>,
406 #[arg(
407 long,
408 value_name = "manifest",
409 help = "the manifest of a change, which the rules that read a transition use. It \
410 does not narrow the documents that the run checks. The first line is \
411 `headwater change 1`, and a file that opens with anything else is refused \
412 rather than read. Each line after it names one document \
413 the change carries, as `added<tab><path>` or `prior<tab><path><tab><file>`, \
414 and the second form names a file holding the bytes that stood before the \
415 change. A document the manifest does not name did not change. It is what a \
416 rule that reads a transition needs, and without it every instance of such a \
417 rule is reported as skipped rather than passed. This engine walks no history: \
418 the caller anchors the prior version to the state on the branch where the \
419 change lands, which spec 12 fixes as the merge base of a proposed change and \
420 the committed `HEAD` of a working-tree hook. Every path is held against the \
421 corpus this run walks, and one that reaches no row of it is counted and named \
422 in the report rather than absorbed. No path is normalized, so `./docs/a.md` \
423 reaches no row. What this engine cannot check is whether the manifest tells \
424 the truth: a line that says `added` for a document that already stood, and a \
425 document the change carried and the manifest omits, are both invisible without \
426 the history that spec 12 rules out as an input"
427 )]
428 change: Option<PathBuf>,
429 #[arg(
430 long = "read-set",
431 value_name = "path",
432 help = "write the read set of this run to a file as well as to the report. The \
433 artifact is what decides whether a verdict survives a merge without running \
434 the checks again, and `headwater gate` is what reads it"
435 )]
436 read_set: Option<PathBuf>,
437 #[arg(
438 long,
439 value_name = "path",
440 help = "write the register of this run to a file as well as to the report. Spec 4 \
441 makes it a projection of the `obligations` and `controls` declarations, \
442 generated and never authored: every obligation with its disposition, every \
443 control with its health, and what escaped under each"
444 )]
445 register: Option<PathBuf>,
446 #[arg(
447 long,
448 value_name = "text|json|sarif|markdown",
449 help = concat!(
450 "which vocabulary to write the run in. `text` is the report a person reads and \
451 the default. `sarif` is what a forge ingests as a check run, `markdown` is a \
452 job summary or a review comment, and `json` is the finding shape spec 4 \
453 declares, for an adapter nobody here wrote. `sarif` writes its own loss set \
454 into the artifact. `markdown` declares one in the source and not in the \
455 artifact, because nothing it writes is machine-readable. `text` declares one \
456 drop there too, the routing of each skip, and carries the census and the graph \
457 that no other format holds. `json` declares one there as well, the \
458 per-document account of coverage, and writes no loss set of its own. ",
459 a_refusal_is_not_an_artifact!()
460 )
461 )]
462 format: Option<String>,
463 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
464 json: bool,
465 },
466 Change {
467 #[arg(
472 value_name = "base-rev",
473 help = "the revision to compare the working tree against. Spec 12 fixes two: the \
474 committed `HEAD` for a working-tree hook, and the merge base of a proposed \
475 change for a CI job. This verb runs no history walk beyond `git diff` and \
476 `git show` against this one revision"
477 )]
478 base: Option<String>,
479 #[arg(
480 value_name = "out-dir",
481 help = "the directory to write the manifest and the prior versions into. The caller \
482 owns this directory and removes it; this verb only ever creates inside it. \
483 The manifest's own path, `<out-dir>/manifest`, is printed on standard output, \
484 which is the file `headwater check --change` takes"
485 )]
486 out: Option<PathBuf>,
487 },
488 Gate {
489 #[arg(
493 long = "read-set",
494 value_name = "path",
495 help = "the read set to hold against this tree, and it is required here. \
496 `headwater check --read-set <path>` is what writes one. The artifact is what \
497 decides whether a verdict survives a merge without running the checks again"
498 )]
499 read_set: Option<PathBuf>,
500 #[arg(
501 long,
502 value_name = "date",
503 value_parser = a_date,
504 help = "the day the question is asked about, as `YYYY-MM-DD`. Defaults to today. A run \
505 that read the clock is void on any other day"
506 )]
507 now: Option<Date>,
508 #[arg(long, help = JSON_ALONE)]
509 json: bool,
510 },
511 Conformance {
512 #[arg(
513 long,
514 value_name = "name",
515 help = "the rung to ask about, by the name the package declares. It exits non-zero on \
516 a gap under that rung that no live waiver covers. It never moves the level the \
517 report states, which is computed from met rules alone"
518 )]
519 level: Option<String>,
520 #[arg(
521 long,
522 value_name = "date",
523 value_parser = a_date,
524 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
525 )]
526 now: Option<Date>,
527 #[arg(long, help = JSON_ALONE)]
528 json: bool,
529 },
530 Derived {},
533 Site {
536 #[arg(
537 value_name = "site-dir",
538 help = "the directory a site generator wrote, such as MkDocs' `site/`"
539 )]
540 dir: Option<PathBuf>,
541 },
542 MergeDriver {
546 #[arg(
547 value_name = "ancestor",
548 help = "the file git wrote the common ancestor's version into, `%O`. Not read"
549 )]
550 ancestor: Option<String>,
551 #[arg(
552 value_name = "current",
553 help = "the file holding the current side's version, `%A`. Left byte for byte, because git reads the merge result from it"
554 )]
555 current: Option<String>,
556 #[arg(
557 value_name = "other",
558 help = "the file holding the other side's version, `%B`. Not read"
559 )]
560 other: Option<String>,
561 #[arg(
562 value_name = "path",
563 help = "the path of the artifact in the tree, `%P`. It decides which producer the message names"
564 )]
565 path: Option<String>,
566 },
567 Route {
568 #[arg(
569 value_name = "task description",
570 help = "what you are about to do, in your own words. Every word after the verb is one \
571 description, so it needs no quoting to hold together"
572 )]
573 task: Vec<String>,
574 #[arg(
575 long,
576 value_name = "n",
577 value_parser = a_budget,
578 help = "how many ranked pointers it may offer. It never removes a document that \
579 governs a path the task named, and it says how many it withheld. Five by \
580 default"
581 )]
582 budget: Option<usize>,
583 #[arg(long, help = JSON_ALONE)]
584 json: bool,
585 },
586 Neighbors {
587 #[arg(
588 value_name = "task description",
589 help = "what you are about to do, in your own words. Every word after the verb is one \
590 description, so it needs no quoting to hold together"
591 )]
592 task: Vec<String>,
593 #[arg(
594 long,
595 value_name = "dir",
596 help = "the directory holding the fetched model files the pin in \
597 `.headwater/embedding.yml` names. `.headwater/models` by default"
598 )]
599 model: Option<PathBuf>,
600 #[arg(
601 long,
602 value_name = "n",
603 value_parser = a_budget,
604 help = "how many documents it prints, nearest first. Ten by default"
605 )]
606 top: Option<usize>,
607 #[arg(long, help = JSON_ALONE)]
608 json: bool,
609 },
610 Explain {
611 #[arg(
612 value_name = "path|identifier",
613 help = "the document to explain, as a path under the corpus root or as the identifier \
614 it declares"
615 )]
616 target: Option<String>,
617 #[arg(long, help = JSON_ALONE)]
618 json: bool,
619 },
620 Show {
621 #[arg(
622 value_name = "path|identifier",
623 help = "the document to print, as a path under the corpus root or as the identifier \
624 it declares"
625 )]
626 target: Option<String>,
627 },
628 Query {
629 #[arg(
630 value_name = "expression",
631 help = "the expression to run, and no document of this repository states what one is"
632 )]
633 expression: Vec<String>,
634 },
635 Capture {
636 #[arg(
637 long,
638 value_name = "text|json",
639 help = concat!(
640 "`text` is the report a person reads and the default, and `json` is the same \
641 numbers for a program. Neither carries a reading the store does not hold. ",
642 a_refusal_is_not_an_artifact!()
643 )
644 )]
645 format: Option<String>,
646 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
647 json: bool,
648 },
649 Mcp {
650 #[arg(
651 long,
652 value_name = "date",
653 value_parser = a_date,
654 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today. It is read \
655 once and fixed for the life of the server, and every result states it"
656 )]
657 now: Option<Date>,
658 #[arg(
659 long,
660 help = "register the working-tree write class, which is `new` and `fix`. Spec 5 keeps \
661 it off by default, because a client may connect to a checkout that the user \
662 did not intend to change, so the consent is a word somebody typed rather than \
663 a setting a tree carries. A tool that lands a change is registered by no \
664 switch. The first call that moves a byte ends the server: it walked the corpus \
665 once, so every later answer would be about a tree that is gone"
666 )]
667 write: bool,
668 },
669 New {
670 #[arg(
671 value_name = "kind",
672 help = "the kind of document to scaffold, by the name the resolved taxonomy declares \
673 for it"
674 )]
675 kind: Option<String>,
676 #[arg(
677 long,
678 value_name = "text",
679 help = "what the document is called. Required, because the file name and the facet in \
680 the `name` role both come from it"
681 )]
682 title: Option<String>,
683 #[arg(
684 long,
685 value_name = "text",
686 help = "the one sentence a reader meets where a list of documents is rendered. \
687 Fills the facet in the `scent` role directly, exactly as `--title` fills the \
688 one in the `name` role. Without it the field carries a prompt, and a person \
689 edits the front matter by hand before the document is current"
690 )]
691 summary: Option<String>,
692 #[arg(
693 long,
694 value_name = "relation=identifier",
695 value_parser = a_pair,
696 help = "an edge to propose, as a relation and the identifier of the document at the \
697 other end. Repeatable. It is refused unless the taxonomy declares \
698 `created_by: scaffold` on the relation, unless both ends are kinds the \
699 relation permits, and unless the target resolves. Where reciprocity is \
700 required and the new document opens at an initial state, the far half is \
701 owed until the document leaves that state, and `headwater check --fix` \
702 writes it then. In every other case the far half is written into the \
703 target document at once"
704 )]
705 relates: Vec<(String, String)>,
706 #[arg(
707 long,
708 value_name = "facet=value",
709 value_parser = a_pair,
710 help = "a value for a facet this kind requires, as `<facet>=<value>`. Repeatable. A \
711 facet the kind does not require is refused, and so is a value outside a \
712 closed set, with the set printed. A facet that a declaration decides is \
713 refused too: an engine role decides its facet's value, and the kind decides \
714 the discriminator of a heterogeneous shelf"
715 )]
716 facet: Vec<(String, String)>,
717 #[arg(
718 long,
719 value_name = "path",
720 help = "the directory to write into, relative to the root, for a kind whose shelf \
721 fixes the file name under a glob, such as `docs/modules/*/README.md`. The \
722 document is written as `<path>/` and the file name the shelf fixes, and must match the \
723 shelf. Refused on any other shelf, whose path already decides the directory"
724 )]
725 directory: Option<String>,
726 #[arg(
727 long,
728 value_name = "date",
729 value_parser = a_date,
730 help = "the date the document is stamped with, as `YYYY-MM-DD`. Defaults to today"
731 )]
732 now: Option<Date>,
733 },
734 Infer {
735 #[arg(
736 long,
737 value_name = "name",
738 help = "who owns the debt it proposes. Required with `--write`, because an owner is \
739 the field that ranks declared debt above a suppression and this engine will \
740 not invent one"
741 )]
742 owner: Option<String>,
743 #[arg(
744 long,
745 value_name = "date",
746 value_parser = a_date,
747 help = "the last day the tasks it proposes hold, as `YYYY-MM-DD`. Ninety days out by \
748 default"
749 )]
750 until: Option<Date>,
751 #[arg(
752 long,
753 help = "put the payload in the lock, which is committed and reviewed. Without it \
754 nothing is written"
755 )]
756 write: bool,
757 #[arg(
758 long,
759 value_name = "date",
760 value_parser = a_date,
761 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
762 )]
763 now: Option<Date>,
764 },
765 Generate {
766 #[arg(
767 long,
768 help = "write nothing and exit non-zero when what is committed is not what a run \
769 produces. It reads the corpus through the lock, so it answers whether a \
770 derived artifact is current"
771 )]
772 check: bool,
773 },
774 Import {
775 #[arg(
776 value_name = "name",
777 help = "which declared import to read, by the name its block carries in \
778 `.headwater/taxonomy.yml`. One declared import needs no name and two do, \
779 because choosing for the caller would import whichever the file listed first"
780 )]
781 name: Option<String>,
782 #[arg(
783 long,
784 value_name = "digest",
785 help = "the digest to check the snapshot against. It defaults to the `digest` of the \
786 import block in `.headwater/taxonomy.yml`, and the verb refuses when neither \
787 is there rather than reading an unpinned directory"
788 )]
789 expect: Option<String>,
790 #[arg(
791 long,
792 help = "write the edge halves into the documents at their near ends. Without it the \
793 edges are reported and nothing is touched"
794 )]
795 write: bool,
796 },
797 Export {
798 #[arg(
799 long,
800 value_name = "name",
801 help = "which declared export profile to emit. Every declared profile by default, so \
802 a filtered audience is never omitted by accident"
803 )]
804 profile: Option<String>,
805 #[arg(
806 long,
807 value_name = "json|jsonschema",
808 help = concat!(
809 "the emitter target. `json` is the native property graph with no loss and \
810 `jsonschema` constrains front matter. The other five targets of spec 6 parse \
811 and report the consumer each one waits on. With this flag the artifact goes to \
812 standard output and no declared output path is touched. ",
813 a_refusal_is_not_an_artifact!()
814 )
815 )]
816 format: Option<String>,
817 #[arg(
818 long,
819 value_name = "date",
820 value_parser = a_date_as_written,
821 help = "the generation time the artifact states, as `YYYY-MM-DD`. Absent by default, \
822 because an artifact that `--check` compares by byte cannot carry a clock \
823 reading. Spec 6 asks a filtered export that leaves the repository to state \
824 one, and this is where it is injected"
825 )]
826 at: Option<String>,
827 #[arg(
828 long,
829 help = "write nothing and exit non-zero when a declared output is not what a run \
830 produces. It holds every export the taxonomy names a path for to regeneration"
831 )]
832 check: bool,
833 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
834 json: bool,
835 },
836 Sweep {
837 #[command(subcommand)]
838 word: Option<SweepWord>,
839 },
840 Probe {
841 #[command(subcommand)]
842 word: Option<ProbeWord>,
843 },
844 Init {
845 #[arg(
846 long,
847 value_name = "dir",
848 help = "the corpus root to declare. Proposed from the tree by default"
849 )]
850 corpus: Option<String>,
851 #[arg(
852 long,
853 value_name = "name",
854 help = "the package to take. `headwater/standard` by default"
855 )]
856 package: Option<String>,
857 #[arg(
858 long,
859 help = "append a `-merge` line to `.gitattributes` for each fold \
860 `headwater taxonomy resolve` and `headwater generate` write in this tree and a \
861 `merge=union` line for each append-only store the engine writes, and \
862 print the two `git config` lines that name `headwater merge-driver` and the \
863 `info/attributes` lines that select it. A clone that already names the driver \
864 gets those lines written. It runs after the first `headwater generate`, and on \
865 a repository that is already bound it does this and nothing else"
866 )]
867 git: bool,
868 #[arg(
869 long = "git-config",
870 requires = "git",
871 help = "also run the two `git config` lines `--git` prints, in this clone, and then \
872 write the `info/attributes` lines that select the driver. Git takes no driver \
873 from a repository, so without this flag the lines are printed and the adopter \
874 runs them"
875 )]
876 git_config: bool,
877 },
878 Taxonomy {
879 #[command(subcommand)]
880 word: Option<TaxonomyWord>,
881 },
882 Json {
886 #[command(subcommand)]
887 word: Option<JsonWord>,
888 },
889 Help {
898 #[arg(
899 value_name = "verb",
900 help = "the verb to describe, with its second word where it takes one: \
901 `headwater help taxonomy diff`. Without one this screen is printed"
902 )]
903 verb: Vec<String>,
904 },
905 Completions {
910 #[arg(
911 value_name = "shell",
912 help = "the shell to write a script for. A name outside the four is refused with the \
913 four printed, and no script is written"
914 )]
915 shell: Option<Shell>,
916 },
917 #[command(external_subcommand)]
924 Other(Vec<String>),
925}
926
927#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
943pub enum Shell {
944 Bash,
945 Zsh,
946 Fish,
947 Powershell,
948}
949
950impl From<Shell> for clap_complete::Shell {
951 fn from(shell: Shell) -> Self {
952 match shell {
953 Shell::Bash => clap_complete::Shell::Bash,
954 Shell::Zsh => clap_complete::Shell::Zsh,
955 Shell::Fish => clap_complete::Shell::Fish,
956 Shell::Powershell => clap_complete::Shell::PowerShell,
957 }
958 }
959}
960
961impl Shell {
962 pub fn typed(self) -> &'static str {
964 match self {
965 Shell::Bash => "bash",
966 Shell::Zsh => "zsh",
967 Shell::Fish => "fish",
968 Shell::Powershell => "powershell",
969 }
970 }
971
972 pub const ALL: &'static [Shell] = &[Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Powershell];
974}
975
976#[derive(Subcommand, Debug)]
978pub enum JsonWord {
979 Field {
980 #[arg(
981 value_name = "key",
982 help = "the path of steps to the member, outermost first. A step into an object \
983 is a key, and a step into an array is a decimal index counted from 0. \
984 `headwater json field tool_input file_path` reads the `file_path` member of \
985 the `tool_input` member, and `headwater json field related 0 target` reads \
986 the `target` member of the first element of `related`. Without one, the object is read and no member of it \
987 is named, which is refused"
988 )]
989 path: Vec<String>,
990 },
991 Count {
992 #[arg(
993 value_name = "key",
994 help = "the path of steps to the array or the object whose elements are counted, \
995 outermost first, where a step into an array is a decimal index. Without one, the object on standard input is the one counted"
996 )]
997 path: Vec<String>,
998 },
999 Quote,
1000 #[command(external_subcommand)]
1001 Other(Vec<String>),
1002}
1003
1004#[derive(Subcommand, Debug)]
1006pub enum SweepWord {
1007 Plan {
1008 #[arg(
1009 long,
1010 value_name = "path",
1011 help = "the slice, as a path prefix under the repository root. The whole corpus by \
1012 default. There is no sampling rule here: a slice this engine picked would be \
1013 an unreproducible sample dressed as a reproducible one, and the plan reports \
1014 its own extent instead"
1015 )]
1016 under: Option<String>,
1017 },
1018 Report {
1019 #[arg(
1020 value_name = "path",
1021 help = "the file an agent wrote back. `headwater sweep plan` prints the shape of it"
1022 )]
1023 path: Option<String>,
1024 #[arg(
1025 long,
1026 value_name = "text|json",
1027 help = concat!(
1028 "`text` is the report a person reads and the default, and `json` is the finding \
1029 shape spec 4 declares with the provenance and the evidence a sweep adds. ",
1030 a_refusal_is_not_an_artifact!()
1031 )
1032 )]
1033 format: Option<String>,
1034 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
1035 json: bool,
1036 },
1037 #[command(external_subcommand)]
1038 Other(Vec<String>),
1039}
1040
1041#[derive(Subcommand, Debug)]
1043pub enum ProbeWord {
1044 Plan {
1045 #[arg(
1046 long,
1047 value_name = "regression|campaign|documentation",
1048 help = "which tier of `.headwater/probe.yml` to plan against. A tier declares the \
1049 ceiling, the session cost, the repetitions, the arms and the ablation its \
1050 absent arm removes, and the plan is projected against them. A paired tier \
1051 refuses a probe whose predicate names a document its own ablation removes. \
1052 `regression` by default"
1053 )]
1054 tier: Option<String>,
1055 #[arg(
1056 long,
1057 value_name = "present|absent",
1058 help = "narrow the selection to one arm the tier declares. Every arm the tier \
1059 declares by default, which is one for `regression` and two for `campaign` and \
1060 `documentation`. \
1061 An arm the tier does not declare refuses the run rather than planning \
1062 another one, and the refusal names the arms the tier declares"
1063 )]
1064 arm: Option<String>,
1065 #[arg(
1066 long,
1067 value_name = "name",
1068 help = "narrow the selection to one probe category, by the name this engine declares \
1069 for it. Every category by default, a name outside the closed set is refused \
1070 with the set printed, and a category no probe of this corpus carries is \
1071 refused rather than planned as a run of nothing"
1072 )]
1073 category: Option<String>,
1074 #[arg(
1084 long,
1085 value_name = "n",
1086 default_value_t = 0,
1087 help = "the rotation seed, which is a member of the run identity spec 5 declares. It \
1088 is the caller's number: a run that states none states zero, and it is \
1089 recorded as stated. No selection is drawn from it — every declared probe is \
1090 selected — so it identifies a run rather than choosing one"
1091 )]
1092 seed: u64,
1093 #[arg(
1094 long,
1095 value_name = "probe",
1096 help = "leave one probe out of the selection, by its identifier. Repeat it for more \
1097 than one. The selection digest is taken after the exclusion, so it states \
1098 the run that happens, and an identifier the selection does not hold is \
1099 refused rather than ignored"
1100 )]
1101 exclude: Vec<String>,
1102 #[arg(
1103 long,
1104 value_name = "n",
1105 help = "run fewer repetitions than the tier declares, for a pilot of the same \
1106 selection and arms. It may only lower the count: a higher one is refused, \
1107 because spending more is a change to `.headwater/probe.yml` that a person \
1108 makes"
1109 )]
1110 repetitions: Option<u32>,
1111 },
1112 Record {
1113 #[arg(
1114 value_name = "path",
1115 help = "the transcript a recorder wrote. `headwater probe plan` prints the run \
1116 identity it has to carry"
1117 )]
1118 path: Option<String>,
1119 },
1120 Grade {
1121 #[arg(
1122 value_name = "path",
1123 help = "the transcript a recorder wrote. It is graded against the probes this corpus \
1124 declares, re-derived here rather than taken from the transcript"
1125 )]
1126 path: Option<String>,
1127 },
1128 Stale,
1129 #[command(external_subcommand)]
1130 Other(Vec<String>),
1131}
1132
1133#[derive(Subcommand, Debug)]
1135pub enum TaxonomyWord {
1136 Validate,
1137 Resolve {
1138 #[arg(
1139 long,
1140 help = "write nothing and exit non-zero when what is committed is not what a run \
1141 produces. It reads the taxonomy sources, so it answers whether the lock is \
1142 current"
1143 )]
1144 check: bool,
1145 },
1146 Audit {
1147 #[arg(
1148 long,
1149 value_name = "date",
1150 value_parser = a_date,
1151 help = "the date a staleness reading and a dwell reading are taken at, as \
1152 `YYYY-MM-DD`. Defaults to today, and two audits of one tree at one date write \
1153 the same bytes"
1154 )]
1155 now: Option<Date>,
1156 #[arg(
1157 long,
1158 help = "append this run's adoption reading to `.headwater/adoption.jsonl`. Without it \
1159 the verb writes nothing. A reading the store already holds at this lock and \
1160 this date is not appended twice, so two recorded audits of one tree at one \
1161 date still write the same bytes"
1162 )]
1163 record: bool,
1164 },
1165 Publish {
1166 #[arg(
1167 long,
1168 value_name = "name",
1169 help = "the package to publish. The one this repository's own declaration takes, by \
1170 default, because a publisher usually publishes what it also consumes. Refused \
1171 together with `--from`, which names the same thing by its directory instead"
1172 )]
1173 package: Option<String>,
1174 #[arg(
1175 long,
1176 value_name = "dir",
1177 help = "read the manifest at this directory directly, bypassing the lookup by name \
1178 under `.headwater/packages/` that `--package` drives. For a repository that both \
1179 publishes a package and consumes it: `taxonomy vendor` refuses to install over \
1180 a directory that carries no release record, so a maintained source cannot sit \
1181 where its own artifact would be installed. This reads it from wherever it \
1182 actually sits instead"
1183 )]
1184 from: Option<PathBuf>,
1185 #[arg(
1186 long,
1187 value_name = "name",
1188 help = "derive and publish this named assembly from the source package. It uses the \
1189 same source selection as `--package` or `--from`, and produces one flattened \
1190 package with no runtime bundle selection"
1191 )]
1192 assembly: Option<String>,
1193 #[arg(
1194 long,
1195 value_name = "dir",
1196 help = "where to write the artifact. The directory must be empty or absent, because a \
1197 published artifact is every file under its root and a stray one would be a \
1198 member the publisher never shipped. A run that cannot finish leaves it as it \
1199 found it, so a second run meets the same precondition the first one did"
1200 )]
1201 out: Option<PathBuf>,
1202 #[arg(
1203 long,
1204 help = "remove what a publish killed part-way left at `--out`, and publish in the \
1205 same run. It removes one state and nothing else: files at `--out` with no \
1206 release record, beside a `<out>~staging` directory holding both the files a \
1207 publish writes there to say it could not move the artifact into place and is \
1208 writing into `--out` one file at a time. Only a killed publish leaves those \
1209 two together, and the second one names the output path it was writing. A \
1210 directory holding anything else, and an `--out` that carries a release \
1211 record, are left exactly as they are and the publish refuses as it does \
1212 without this flag"
1213 )]
1214 clear_killed: bool,
1215 #[arg(
1216 long,
1217 help = "write nothing, and exit 1 when the vendored copy under `.headwater/packages/` \
1218 is not what a fresh publish of the source at `--from` produces. It publishes \
1219 into a private directory outside the tree, removes it, and names each member \
1220 that moved. For a repository that maintains a package and also consumes it, \
1221 so that a source changed without a republish fails a gate. Needs `--from`, \
1222 and is refused with `--out`, `--package`, `--assembly`, `--clear-killed` and \
1223 `--json`"
1224 )]
1225 check: bool,
1226 #[arg(long, help = JSON_ALONE)]
1227 json: bool,
1228 },
1229 Vendor {
1230 #[arg(
1231 value_name = "dir-or-location",
1232 help = "the directory of an artifact somebody already fetched, or the https:// \
1233 location of a published artifact zip, which this verb fetches through \
1234 `headwater-fetch`, a crate nothing in the checking loop links (HW-DR-0075)"
1235 )]
1236 path: Option<String>,
1237 #[arg(
1238 long,
1239 value_name = "digest",
1240 help = "the digest to check the artifact against. It defaults to `taxonomy.digest` in \
1241 `.headwater/taxonomy.yml`, and the verb refuses when neither is there. A pin \
1242 the engine took from the artifact in front of it would be a pin against itself"
1243 )]
1244 expect: Option<String>,
1245 },
1246 Diff {
1247 #[arg(
1248 value_name = "dir",
1249 help = "the directory of an artifact somebody already fetched. This verb opens no \
1250 socket, so it takes a path and never a location"
1251 )]
1252 path: Option<String>,
1253 #[arg(
1254 long,
1255 value_name = "version",
1256 help = "the version the artifact is expected to be, written as a version or as a \
1257 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs. This verb \
1258 fetches nothing, so the directory decides which artifact is compared and this \
1259 flag holds it to what the caller meant. It is read by the one range reader \
1260 the engine has, which is what reads `requires_engine`"
1261 )]
1262 to: Option<String>,
1263 #[arg(
1264 long,
1265 value_name = "date",
1266 value_parser = a_date,
1267 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1268 )]
1269 now: Option<Date>,
1270 },
1271 Migrate {
1272 #[arg(
1273 value_name = "dir",
1274 help = "the directory of an artifact somebody already fetched. This verb opens no \
1275 socket, so it takes a path and never a location"
1276 )]
1277 path: Option<String>,
1278 #[arg(
1279 long,
1280 value_name = "version",
1281 help = "the version the artifact is expected to be, written as a version or as a \
1282 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs"
1283 )]
1284 to: Option<String>,
1285 #[arg(
1286 long,
1287 help = "write the files each step names. Without it every file each step would write \
1288 is reported and nothing is written"
1289 )]
1290 apply: bool,
1291 #[arg(
1292 long,
1293 value_name = "date",
1294 value_parser = a_date,
1295 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1296 )]
1297 now: Option<Date>,
1298 },
1299 Graph {
1300 #[arg(
1301 long,
1302 value_enum,
1303 default_value_t,
1304 help = "which drawing to print. `concrete` draws the concrete kinds, the anchors and \
1305 the relations between them. `abstract` draws each abstract kind, the kinds \
1306 declared under it and the relations that name it, which `concrete` leaves out"
1307 )]
1308 view: crate::taxonomy_graph::View,
1309 #[arg(
1310 long,
1311 help = "add a key that draws each shape and each edge style the drawing uses, and \
1312 names the family each edge color stands for"
1313 )]
1314 legend: bool,
1315 },
1316 #[command(external_subcommand)]
1317 Other(Vec<String>),
1318}
1319
1320pub fn command() -> Command {
1332 command_at(paint::width())
1333}
1334
1335pub fn command_at(width: usize) -> Command {
1342 command_in(width, paint::stdout_color())
1343}
1344
1345pub fn command_in(width: usize, mode: paint::ColorMode) -> Command {
1354 let mut root = Cli::command()
1355 .about(format!(
1356 "{} — {}",
1357 headwater_verbs::BINARY,
1358 headwater_verbs::TAGLINE
1359 ))
1360 .color(paint::color_choice(mode))
1366 .styles(paint::help_styles(mode))
1367 .help_template(first_screen(width, mode));
1368 for verb in headwater_verbs::VERBS {
1369 if root.find_subcommand(verb.name).is_some() {
1380 root = root.mut_subcommand(verb.name, |one| described(one, verb, width, mode));
1381 }
1382 }
1383 paint::painted(root, width)
1384}
1385
1386pub fn parsed() -> Result<Cli, clap::Error> {
1394 let matches = command().try_get_matches()?;
1395 if let Some(message) = a_width_for_a_run_that_lays_nothing_out(&matches) {
1396 return Err(clap::Error::raw(
1397 clap::error::ErrorKind::ArgumentConflict,
1398 message,
1399 ));
1400 }
1401 Cli::from_arg_matches(&matches)
1402}
1403
1404fn a_width_for_a_run_that_lays_nothing_out(matches: &clap::ArgMatches) -> Option<String> {
1459 let mut leaf = matches;
1460 while let Some((_, inner)) = leaf.subcommand() {
1461 leaf = inner;
1462 }
1463 if leaf.try_get_one::<bool>("wide").ok().flatten() != Some(&true) {
1464 return None;
1465 }
1466 if matches.subcommand_name() == Some("help") {
1467 return None;
1468 }
1469 let format = leaf.try_get_one::<String>("format").ok().flatten();
1470 let json = leaf.try_get_one::<bool>("json").ok().flatten() == Some(&true);
1476 let laid_out = matches.subcommand_name() == Some("check")
1479 && !json
1480 && matches!(format.map(String::as_str), None | Some("text"));
1481 if laid_out {
1482 return None;
1483 }
1484 let says = match format.filter(|value| value.as_str() != "text") {
1485 Some(format) => format!("`--format {format}` writes an artifact that nothing lays out"),
1486 None if json => "`--json` writes an artifact that nothing lays out".to_string(),
1487 None => "this run lays nothing out".to_string(),
1488 };
1489 Some(format!(
1490 "`--wide` says how wide the help and the report of `{0} check` are laid out, and {says}. A \
1491 run carrying it would carry one flag that does nothing, so it is refused rather than run. \
1492 The runs it widens are `{0} --wide --help`, `{0} <verb> --wide --help`, `{0} --wide help \
1493 <verb>` and `{0} check --wide`",
1494 headwater_verbs::BINARY
1495 ))
1496}
1497
1498fn described(
1500 command: Command,
1501 verb: &headwater_verbs::Verb,
1502 width: usize,
1503 mode: paint::ColorMode,
1504) -> Command {
1505 let mut one = command.about(verb.description);
1506 if !verb.words.is_empty() {
1507 one = one.help_template(second_words(verb, width, mode));
1508 for word in verb.words {
1509 if one.find_subcommand(word.name).is_none() {
1510 continue;
1511 }
1512 one = one.mut_subcommand(word.name, |inner| inner.about(word.description));
1513 }
1514 }
1515 one
1516}
1517
1518const COLUMN: usize = 15;
1520
1521fn first_screen(width: usize, mode: paint::ColorMode) -> String {
1538 let mut out = format!(
1553 "{{usage-heading}} {{usage}}\n\n{}:\n",
1554 paint::paint(paint::Role::Heading, "Examples", mode)
1555 );
1556 for (line, says) in [
1557 (
1558 "headwater check --strict",
1559 "run the checks, and fail on an error",
1560 ),
1561 (
1562 "headwater route \"add rate limiting\"",
1563 "the documents that govern a task",
1564 ),
1565 (
1566 "headwater explain HW-DR-0033",
1567 "why a document is the kind it is",
1568 ),
1569 (
1570 "headwater new decision --title \"Adopt an overlay\"",
1571 "scaffold a document of a kind",
1572 ),
1573 (
1574 "headwater help taxonomy diff",
1575 "the long description of one verb",
1576 ),
1577 ] {
1578 out.push_str(&format!(" {line}\n"));
1583 out.push_str(&paint::fold_indented(says, width, 6));
1584 }
1585 for group in headwater_verbs::groups() {
1593 out.push_str(&format!(
1594 "\n{}:\n",
1595 paint::paint(paint::Role::Heading, group, mode)
1596 ));
1597 for verb in headwater_verbs::VERBS
1598 .iter()
1599 .filter(|one| one.group == group)
1600 {
1601 out.push_str(&paint::painted_row(
1602 verb.name,
1603 verb.summary,
1604 COLUMN,
1605 width,
1606 paint::Role::Verb,
1607 mode,
1608 ));
1609 }
1610 }
1611 let longest = GLOBALS
1628 .iter()
1629 .map(|one| one.name.chars().count())
1630 .max()
1631 .unwrap_or(0);
1632 let at = 2 + longest + 2;
1633 out.push_str(&format!(
1634 "\n{}:\n",
1635 paint::paint(paint::Role::Heading, "Global flags", mode)
1636 ));
1637 for one in GLOBALS {
1638 out.push_str(&paint::painted_row(
1639 one.name,
1640 one.summary,
1641 at,
1642 width,
1643 paint::Role::Path,
1644 mode,
1645 ));
1646 }
1647 out.push('\n');
1648 out.push_str(&paint::fold_indented(
1649 &format!(
1650 "Run `{0} help <verb>` for the long description of one verb, or `{0} <verb> --help`.",
1651 headwater_verbs::BINARY
1652 ),
1653 width,
1654 0,
1655 ));
1656 out
1657}
1658
1659fn second_words(verb: &headwater_verbs::Verb, width: usize, mode: paint::ColorMode) -> String {
1667 let mut out = String::from("{about}\n\n{usage-heading} {usage}\n\nSecond words:\n");
1668 for word in verb.words {
1669 out.push_str(&paint::painted_row(
1670 word.name,
1671 word.summary,
1672 COLUMN,
1673 width,
1674 paint::Role::Verb,
1675 mode,
1676 ));
1677 }
1678 out.push_str("\nFlags:\n{options}\n\n");
1679 out.push_str(&paint::fold_indented(
1680 &format!(
1681 "Run `{} help {} <word>` for the long description of one.",
1682 headwater_verbs::BINARY,
1683 verb.name
1684 ),
1685 width,
1686 0,
1687 ));
1688 out
1689}
1690
1691fn a_date(text: &str) -> Result<Date, String> {
1693 Date::parse(text).ok_or_else(|| "a date written `YYYY-MM-DD`".to_string())
1694}
1695
1696fn a_date_as_written(text: &str) -> Result<String, String> {
1701 a_date(text).map(|_| text.to_string())
1702}
1703
1704fn a_budget(text: &str) -> Result<usize, String> {
1706 match text.parse::<usize>() {
1707 Ok(value) if value > 0 => Ok(value),
1708 _ => Err("a whole number above zero".to_string()),
1709 }
1710}
1711
1712fn a_pair(text: &str) -> Result<(String, String), String> {
1718 match text.split_once('=') {
1719 Some((left, right)) if !left.is_empty() && !right.is_empty() => {
1720 Ok((left.to_string(), right.to_string()))
1721 }
1722 _ => Err(
1723 "`<left>=<right>`, as in `--relates supersedes=HW-DR-0007` or \
1724 `--facet probe_category=discovery`"
1725 .to_string(),
1726 ),
1727 }
1728}
1729
1730pub fn headline(error: &clap::Error) -> String {
1744 let rendered = error.render().to_string();
1745 let head: Vec<String> = rendered
1746 .lines()
1747 .take_while(|line| !line.starts_with("Usage:") && !line.starts_with("For more information"))
1748 .map(str::trim)
1749 .filter(|line| !line.is_empty())
1750 .map(|line| line.strip_prefix("error: ").unwrap_or(line).to_string())
1751 .collect();
1752 match head.is_empty() {
1753 true => rendered.split_whitespace().collect::<Vec<_>>().join(" "),
1754 false => head.join("\n"),
1755 }
1756}
1757
1758#[cfg(test)]
1759mod tests {
1760 use super::{a_budget, a_date, a_pair, command, headline, Cli};
1761 use clap::{CommandFactory, Parser};
1762
1763 #[test]
1764 fn the_declared_parse_is_a_command_clap_can_build() {
1765 Cli::command().debug_assert();
1766 command().debug_assert();
1767 }
1768
1769 #[test]
1770 fn a_value_a_flag_cannot_take_is_named_rather_than_defaulted() {
1771 assert!(a_date("2026-13-45").is_err());
1772 assert!(a_date("2026-01-01").is_ok());
1773 assert!(a_budget("0").is_err());
1774 assert!(a_budget("x").is_err());
1775 assert_eq!(a_budget("3"), Ok(3));
1776 assert!(a_pair("nope").is_err());
1777 assert!(a_pair("=right").is_err());
1778 assert!(a_pair("left=").is_err());
1779 assert_eq!(
1780 a_pair("supersedes=HW-DR-0007"),
1781 Ok(("supersedes".to_string(), "HW-DR-0007".to_string()))
1782 );
1783 }
1784
1785 #[test]
1792 fn a_refusal_carries_the_message_and_not_the_grammar() {
1793 let error = Cli::try_parse_from(["headwater", "check", "--nonsense"])
1794 .expect_err("`--nonsense` is not a flag `check` reads");
1795 let headline = headline(&error);
1796 assert!(
1797 headline.contains("--nonsense"),
1798 "the refusal names the offending word: {headline}"
1799 );
1800 assert!(
1801 !headline.contains("--root <path>"),
1802 "the refusal does not reprint the grammar: {headline}"
1803 );
1804 assert!(
1805 !headline.contains("Usage:"),
1806 "the refusal does not reprint the usage block: {headline}"
1807 );
1808 assert!(
1809 !headline.contains("For more information"),
1810 "the pointer is `fail`'s and is written once: {headline}"
1811 );
1812 }
1813
1814 #[test]
1819 fn a_flag_of_another_verb_does_not_reach_this_one() {
1820 assert!(Cli::try_parse_from(["headwater", "check", "--level", "L0"]).is_err());
1821 assert!(Cli::try_parse_from(["headwater", "conformance", "--level", "L0"]).is_ok());
1822 assert!(Cli::try_parse_from(["headwater", "sweep", "report", "f", "--strict"]).is_err());
1823 assert!(Cli::try_parse_from(["headwater", "check", "--strict"]).is_ok());
1824 }
1825
1826 #[test]
1830 fn the_corpus_flag_reaches_every_verb_from_either_side_of_it() {
1831 for arguments in [
1832 ["headwater", "check", "--root", "/tmp"],
1833 ["headwater", "--root", "/tmp", "check"],
1834 ] {
1835 let cli = Cli::try_parse_from(arguments).expect("`--root` is global");
1836 assert_eq!(cli.root.as_deref(), Some(std::path::Path::new("/tmp")));
1837 }
1838 }
1839}