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 the change this run is scoped to. The first line is \
410 `headwater change 1`, and a file that opens with anything else is refused \
411 rather than read. Each line after it names one document \
412 the change carries, as `added<tab><path>` or `prior<tab><path><tab><file>`, \
413 and the second form names a file holding the bytes that stood before the \
414 change. A document the manifest does not name did not change. It is what a \
415 rule that reads a transition needs, and without it every instance of such a \
416 rule is reported as skipped rather than passed. This engine walks no history: \
417 the caller anchors the prior version to the state on the branch where the \
418 change lands, which spec 12 fixes as the merge base of a proposed change and \
419 the committed `HEAD` of a working-tree hook. Every path is held against the \
420 corpus this run walks, and one that reaches no row of it is counted and named \
421 in the report rather than absorbed. No path is normalized, so `./docs/a.md` \
422 reaches no row. What this engine cannot check is whether the manifest tells \
423 the truth: a line that says `added` for a document that already stood, and a \
424 document the change carried and the manifest omits, are both invisible without \
425 the history that spec 12 rules out as an input"
426 )]
427 change: Option<PathBuf>,
428 #[arg(
429 long = "read-set",
430 value_name = "path",
431 help = "write the read set of this run to a file as well as to the report. The \
432 artifact is what decides whether a verdict survives a merge without running \
433 the checks again, and `headwater gate` is what reads it"
434 )]
435 read_set: Option<PathBuf>,
436 #[arg(
437 long,
438 value_name = "path",
439 help = "write the register of this run to a file as well as to the report. Spec 4 \
440 makes it a projection of the `obligations` and `controls` declarations, \
441 generated and never authored: every obligation with its disposition, every \
442 control with its health, and what escaped under each"
443 )]
444 register: Option<PathBuf>,
445 #[arg(
446 long,
447 value_name = "text|json|sarif|markdown",
448 help = concat!(
449 "which vocabulary to write the run in. `text` is the report a person reads and \
450 the default. `sarif` is what a forge ingests as a check run, `markdown` is a \
451 job summary or a review comment, and `json` is the finding shape spec 4 \
452 declares, for an adapter nobody here wrote. `sarif` writes its own loss set \
453 into the artifact. `markdown` declares one in the source and not in the \
454 artifact, because nothing it writes is machine-readable. `text` declares one \
455 drop there too, the routing of each skip, and carries the census and the graph \
456 that no other format holds. `json` declares one there as well, the \
457 per-document account of coverage, and writes no loss set of its own. ",
458 a_refusal_is_not_an_artifact!()
459 )
460 )]
461 format: Option<String>,
462 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
463 json: bool,
464 },
465 Change {
466 #[arg(
471 value_name = "base-rev",
472 help = "the revision to compare the working tree against. Spec 12 fixes two: the \
473 committed `HEAD` for a working-tree hook, and the merge base of a proposed \
474 change for a CI job. This verb runs no history walk beyond `git diff` and \
475 `git show` against this one revision"
476 )]
477 base: Option<String>,
478 #[arg(
479 value_name = "out-dir",
480 help = "the directory to write the manifest and the prior versions into. The caller \
481 owns this directory and removes it; this verb only ever creates inside it. \
482 The manifest's own path, `<out-dir>/manifest`, is printed on standard output, \
483 which is the file `headwater check --change` takes"
484 )]
485 out: Option<PathBuf>,
486 },
487 Gate {
488 #[arg(
492 long = "read-set",
493 value_name = "path",
494 help = "the read set to hold against this tree, and it is required here. \
495 `headwater check --read-set <path>` is what writes one. The artifact is what \
496 decides whether a verdict survives a merge without running the checks again"
497 )]
498 read_set: Option<PathBuf>,
499 #[arg(
500 long,
501 value_name = "date",
502 value_parser = a_date,
503 help = "the day the question is asked about, as `YYYY-MM-DD`. Defaults to today. A run \
504 that read the clock is void on any other day"
505 )]
506 now: Option<Date>,
507 #[arg(long, help = JSON_ALONE)]
508 json: bool,
509 },
510 Conformance {
511 #[arg(
512 long,
513 value_name = "name",
514 help = "the rung to ask about, by the name the package declares. It exits non-zero on \
515 a gap under that rung that no live waiver covers. It never moves the level the \
516 report states, which is computed from met rules alone"
517 )]
518 level: Option<String>,
519 #[arg(
520 long,
521 value_name = "date",
522 value_parser = a_date,
523 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
524 )]
525 now: Option<Date>,
526 #[arg(long, help = JSON_ALONE)]
527 json: bool,
528 },
529 Derived {},
532 MergeDriver {
536 #[arg(
537 value_name = "ancestor",
538 help = "the file git wrote the common ancestor's version into, `%O`. Not read"
539 )]
540 ancestor: Option<String>,
541 #[arg(
542 value_name = "current",
543 help = "the file holding the current side's version, `%A`. Left byte for byte, because git reads the merge result from it"
544 )]
545 current: Option<String>,
546 #[arg(
547 value_name = "other",
548 help = "the file holding the other side's version, `%B`. Not read"
549 )]
550 other: Option<String>,
551 #[arg(
552 value_name = "path",
553 help = "the path of the artifact in the tree, `%P`. It decides which producer the message names"
554 )]
555 path: Option<String>,
556 },
557 Route {
558 #[arg(
559 value_name = "task description",
560 help = "what you are about to do, in your own words. Every word after the verb is one \
561 description, so it needs no quoting to hold together"
562 )]
563 task: Vec<String>,
564 #[arg(
565 long,
566 value_name = "n",
567 value_parser = a_budget,
568 help = "how many ranked pointers it may offer. It never removes a document that \
569 governs a path the task named, and it says how many it withheld. Five by \
570 default"
571 )]
572 budget: Option<usize>,
573 #[arg(long, help = JSON_ALONE)]
574 json: bool,
575 },
576 Neighbors {
577 #[arg(
578 value_name = "task description",
579 help = "what you are about to do, in your own words. Every word after the verb is one \
580 description, so it needs no quoting to hold together"
581 )]
582 task: Vec<String>,
583 #[arg(
584 long,
585 value_name = "dir",
586 help = "the directory holding the fetched model files the pin in \
587 `.headwater/embedding.yml` names. `.headwater/models` by default"
588 )]
589 model: Option<PathBuf>,
590 #[arg(
591 long,
592 value_name = "n",
593 value_parser = a_budget,
594 help = "how many documents it prints, nearest first. Ten by default"
595 )]
596 top: Option<usize>,
597 #[arg(long, help = JSON_ALONE)]
598 json: bool,
599 },
600 Explain {
601 #[arg(
602 value_name = "path|identifier",
603 help = "the document to explain, as a path under the corpus root or as the identifier \
604 it declares"
605 )]
606 target: Option<String>,
607 #[arg(long, help = JSON_ALONE)]
608 json: bool,
609 },
610 Query {
611 #[arg(
612 value_name = "expression",
613 help = "the expression to run, and no document of this repository states what one is"
614 )]
615 expression: Vec<String>,
616 },
617 Capture {
618 #[arg(
619 long,
620 value_name = "text|json",
621 help = concat!(
622 "`text` is the report a person reads and the default, and `json` is the same \
623 numbers for a program. Neither carries a reading the store does not hold. ",
624 a_refusal_is_not_an_artifact!()
625 )
626 )]
627 format: Option<String>,
628 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
629 json: bool,
630 },
631 Mcp {
632 #[arg(
633 long,
634 value_name = "date",
635 value_parser = a_date,
636 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today. It is read \
637 once and fixed for the life of the server, and every result states it"
638 )]
639 now: Option<Date>,
640 #[arg(
641 long,
642 help = "register the working-tree write class, which is `new` and `fix`. Spec 5 keeps \
643 it off by default, because a client may connect to a checkout that the user \
644 did not intend to change, so the consent is a word somebody typed rather than \
645 a setting a tree carries. A tool that lands a change is registered by no \
646 switch. The first call that moves a byte ends the server: it walked the corpus \
647 once, so every later answer would be about a tree that is gone"
648 )]
649 write: bool,
650 },
651 New {
652 #[arg(
653 value_name = "kind",
654 help = "the kind of document to scaffold, by the name the resolved taxonomy declares \
655 for it"
656 )]
657 kind: Option<String>,
658 #[arg(
659 long,
660 value_name = "text",
661 help = "what the document is called. Required, because the file name and the facet in \
662 the `name` role both come from it"
663 )]
664 title: Option<String>,
665 #[arg(
666 long,
667 value_name = "text",
668 help = "the one sentence a reader meets where a list of documents is rendered. \
669 Fills the facet in the `scent` role directly, exactly as `--title` fills the \
670 one in the `name` role. Without it the field carries a prompt, and a person \
671 edits the front matter by hand before the document is current"
672 )]
673 summary: Option<String>,
674 #[arg(
675 long,
676 value_name = "relation=identifier",
677 value_parser = a_pair,
678 help = "an edge to propose, as a relation and the identifier of the document at the \
679 other end. Repeatable. It is refused unless the taxonomy declares \
680 `created_by: scaffold` on the relation, unless both ends are kinds the \
681 relation permits, and unless the target resolves. Where reciprocity is \
682 required the far half is written into the target document"
683 )]
684 relates: Vec<(String, String)>,
685 #[arg(
686 long,
687 value_name = "facet=value",
688 value_parser = a_pair,
689 help = "a value for a facet this kind requires, as `<facet>=<value>`. Repeatable. A \
690 facet the kind does not require is refused, and so is a value outside a \
691 closed set, with the set printed. A facet that a declaration decides is \
692 refused too: an engine role decides its facet's value, and the kind decides \
693 the discriminator of a heterogeneous shelf"
694 )]
695 facet: Vec<(String, String)>,
696 #[arg(
697 long,
698 value_name = "date",
699 value_parser = a_date,
700 help = "the date the document is stamped with, as `YYYY-MM-DD`. Defaults to today"
701 )]
702 now: Option<Date>,
703 },
704 Infer {
705 #[arg(
706 long,
707 value_name = "name",
708 help = "who owns the debt it proposes. Required with `--write`, because an owner is \
709 the field that ranks declared debt above a suppression and this engine will \
710 not invent one"
711 )]
712 owner: Option<String>,
713 #[arg(
714 long,
715 value_name = "date",
716 value_parser = a_date,
717 help = "the last day the tasks it proposes hold, as `YYYY-MM-DD`. Ninety days out by \
718 default"
719 )]
720 until: Option<Date>,
721 #[arg(
722 long,
723 help = "put the payload in the lock, which is committed and reviewed. Without it \
724 nothing is written"
725 )]
726 write: bool,
727 #[arg(
728 long,
729 value_name = "date",
730 value_parser = a_date,
731 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
732 )]
733 now: Option<Date>,
734 },
735 Generate {
736 #[arg(
737 long,
738 help = "write nothing and exit non-zero when what is committed is not what a run \
739 produces. It reads the corpus through the lock, so it answers whether a \
740 derived artifact is current"
741 )]
742 check: bool,
743 },
744 Import {
745 #[arg(
746 value_name = "name",
747 help = "which declared import to read, by the name its block carries in \
748 `.headwater/taxonomy.yml`. One declared import needs no name and two do, \
749 because choosing for the caller would import whichever the file listed first"
750 )]
751 name: Option<String>,
752 #[arg(
753 long,
754 value_name = "digest",
755 help = "the digest to check the snapshot against. It defaults to the `digest` of the \
756 import block in `.headwater/taxonomy.yml`, and the verb refuses when neither \
757 is there rather than reading an unpinned directory"
758 )]
759 expect: Option<String>,
760 #[arg(
761 long,
762 help = "write the edge halves into the documents at their near ends. Without it the \
763 edges are reported and nothing is touched"
764 )]
765 write: bool,
766 },
767 Export {
768 #[arg(
769 long,
770 value_name = "name",
771 help = "which declared export profile to emit. Every declared profile by default, so \
772 a filtered audience is never omitted by accident"
773 )]
774 profile: Option<String>,
775 #[arg(
776 long,
777 value_name = "json|jsonschema",
778 help = concat!(
779 "the emitter target. `json` is the native property graph with no loss and \
780 `jsonschema` constrains front matter. The other five targets of spec 6 parse \
781 and report the consumer each one waits on. With this flag the artifact goes to \
782 standard output and no declared output path is touched. ",
783 a_refusal_is_not_an_artifact!()
784 )
785 )]
786 format: Option<String>,
787 #[arg(
788 long,
789 value_name = "date",
790 value_parser = a_date_as_written,
791 help = "the generation time the artifact states, as `YYYY-MM-DD`. Absent by default, \
792 because an artifact that `--check` compares by byte cannot carry a clock \
793 reading. Spec 6 asks a filtered export that leaves the repository to state \
794 one, and this is where it is injected"
795 )]
796 at: Option<String>,
797 #[arg(
798 long,
799 help = "write nothing and exit non-zero when a declared output is not what a run \
800 produces. It holds every export the taxonomy names a path for to regeneration"
801 )]
802 check: bool,
803 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
804 json: bool,
805 },
806 Sweep {
807 #[command(subcommand)]
808 word: Option<SweepWord>,
809 },
810 Probe {
811 #[command(subcommand)]
812 word: Option<ProbeWord>,
813 },
814 Init {
815 #[arg(
816 long,
817 value_name = "dir",
818 help = "the corpus root to declare. Proposed from the tree by default"
819 )]
820 corpus: Option<String>,
821 #[arg(
822 long,
823 value_name = "name",
824 help = "the package to take. `headwater/standard` by default"
825 )]
826 package: Option<String>,
827 #[arg(
828 long,
829 help = "append a `-merge` line to `.gitattributes` for each fold \
830 `headwater taxonomy resolve` and `headwater generate` write in this tree, and \
831 print the two `git config` lines that name `headwater merge-driver` and the \
832 `info/attributes` lines that select it. A clone that already names the driver \
833 gets those lines written. It runs after the first `headwater generate`, and on \
834 a repository that is already bound it does this and nothing else"
835 )]
836 git: bool,
837 #[arg(
838 long = "git-config",
839 requires = "git",
840 help = "also run the two `git config` lines `--git` prints, in this clone, and then \
841 write the `info/attributes` lines that select the driver. Git takes no driver \
842 from a repository, so without this flag the lines are printed and the adopter \
843 runs them"
844 )]
845 git_config: bool,
846 },
847 Taxonomy {
848 #[command(subcommand)]
849 word: Option<TaxonomyWord>,
850 },
851 Json {
855 #[command(subcommand)]
856 word: Option<JsonWord>,
857 },
858 Help {
867 #[arg(
868 value_name = "verb",
869 help = "the verb to describe, with its second word where it takes one: \
870 `headwater help taxonomy diff`. Without one this screen is printed"
871 )]
872 verb: Vec<String>,
873 },
874 Completions {
879 #[arg(
880 value_name = "shell",
881 help = "the shell to write a script for. A name outside the four is refused with the \
882 four printed, and no script is written"
883 )]
884 shell: Option<Shell>,
885 },
886 #[command(external_subcommand)]
893 Other(Vec<String>),
894}
895
896#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
912pub enum Shell {
913 Bash,
914 Zsh,
915 Fish,
916 Powershell,
917}
918
919impl From<Shell> for clap_complete::Shell {
920 fn from(shell: Shell) -> Self {
921 match shell {
922 Shell::Bash => clap_complete::Shell::Bash,
923 Shell::Zsh => clap_complete::Shell::Zsh,
924 Shell::Fish => clap_complete::Shell::Fish,
925 Shell::Powershell => clap_complete::Shell::PowerShell,
926 }
927 }
928}
929
930impl Shell {
931 pub fn typed(self) -> &'static str {
933 match self {
934 Shell::Bash => "bash",
935 Shell::Zsh => "zsh",
936 Shell::Fish => "fish",
937 Shell::Powershell => "powershell",
938 }
939 }
940
941 pub const ALL: &'static [Shell] = &[Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Powershell];
943}
944
945#[derive(Subcommand, Debug)]
947pub enum JsonWord {
948 Field {
949 #[arg(
950 value_name = "key",
951 help = "the path of steps to the member, outermost first. A step into an object \
952 is a key, and a step into an array is a decimal index counted from 0. \
953 `headwater json field tool_input file_path` reads the `file_path` member of \
954 the `tool_input` member, and `headwater json field related 0 target` reads \
955 the `target` member of the first element of `related`. Without one, the object is read and no member of it \
956 is named, which is refused"
957 )]
958 path: Vec<String>,
959 },
960 Count {
961 #[arg(
962 value_name = "key",
963 help = "the path of steps to the array or the object whose elements are counted, \
964 outermost first, where a step into an array is a decimal index. Without one, the object on standard input is the one counted"
965 )]
966 path: Vec<String>,
967 },
968 Quote,
969 #[command(external_subcommand)]
970 Other(Vec<String>),
971}
972
973#[derive(Subcommand, Debug)]
975pub enum SweepWord {
976 Plan {
977 #[arg(
978 long,
979 value_name = "path",
980 help = "the slice, as a path prefix under the repository root. The whole corpus by \
981 default. There is no sampling rule here: a slice this engine picked would be \
982 an unreproducible sample dressed as a reproducible one, and the plan reports \
983 its own extent instead"
984 )]
985 under: Option<String>,
986 },
987 Report {
988 #[arg(
989 value_name = "path",
990 help = "the file an agent wrote back. `headwater sweep plan` prints the shape of it"
991 )]
992 path: Option<String>,
993 #[arg(
994 long,
995 value_name = "text|json",
996 help = concat!(
997 "`text` is the report a person reads and the default, and `json` is the finding \
998 shape spec 4 declares with the provenance and the evidence a sweep adds. ",
999 a_refusal_is_not_an_artifact!()
1000 )
1001 )]
1002 format: Option<String>,
1003 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
1004 json: bool,
1005 },
1006 #[command(external_subcommand)]
1007 Other(Vec<String>),
1008}
1009
1010#[derive(Subcommand, Debug)]
1012pub enum ProbeWord {
1013 Plan {
1014 #[arg(
1015 long,
1016 value_name = "regression|campaign",
1017 help = "which tier of `.headwater/probe.yml` to plan against. A tier declares the \
1018 ceiling, the session cost, the repetitions and the arms, and the plan is \
1019 projected against all four. `regression` by default"
1020 )]
1021 tier: Option<String>,
1022 #[arg(
1023 long,
1024 value_name = "present|absent",
1025 help = "narrow the selection to one arm the tier declares. Every arm the tier \
1026 declares by default, which is one for `regression` and two for `campaign`. \
1027 An arm the tier does not declare refuses the run rather than planning \
1028 another one, and the refusal names the arms the tier declares"
1029 )]
1030 arm: Option<String>,
1031 #[arg(
1032 long,
1033 value_name = "name",
1034 help = "narrow the selection to one probe category, by the name this engine declares \
1035 for it. Every category by default, a name outside the closed set is refused \
1036 with the set printed, and a category no probe of this corpus carries is \
1037 refused rather than planned as a run of nothing"
1038 )]
1039 category: Option<String>,
1040 #[arg(
1050 long,
1051 value_name = "n",
1052 default_value_t = 0,
1053 help = "the rotation seed, which is a member of the run identity spec 5 declares. It \
1054 is the caller's number: a run that states none states zero, and it is \
1055 recorded as stated. No selection is drawn from it — every declared probe is \
1056 selected — so it identifies a run rather than choosing one"
1057 )]
1058 seed: u64,
1059 },
1060 Record {
1061 #[arg(
1062 value_name = "path",
1063 help = "the transcript a recorder wrote. `headwater probe plan` prints the run \
1064 identity it has to carry"
1065 )]
1066 path: Option<String>,
1067 },
1068 Grade {
1069 #[arg(
1070 value_name = "path",
1071 help = "the transcript a recorder wrote. It is graded against the probes this corpus \
1072 declares, re-derived here rather than taken from the transcript"
1073 )]
1074 path: Option<String>,
1075 },
1076 Stale,
1077 #[command(external_subcommand)]
1078 Other(Vec<String>),
1079}
1080
1081#[derive(Subcommand, Debug)]
1083pub enum TaxonomyWord {
1084 Validate,
1085 Resolve {
1086 #[arg(
1087 long,
1088 help = "write nothing and exit non-zero when what is committed is not what a run \
1089 produces. It reads the taxonomy sources, so it answers whether the lock is \
1090 current"
1091 )]
1092 check: bool,
1093 },
1094 Audit {
1095 #[arg(
1096 long,
1097 value_name = "date",
1098 value_parser = a_date,
1099 help = "the date a staleness reading and a dwell reading are taken at, as \
1100 `YYYY-MM-DD`. Defaults to today, and two audits of one tree at one date write \
1101 the same bytes"
1102 )]
1103 now: Option<Date>,
1104 #[arg(
1105 long,
1106 help = "append this run's adoption reading to `.headwater/adoption.jsonl`. Without it \
1107 the verb writes nothing. A reading the store already holds at this lock and \
1108 this date is not appended twice, so two recorded audits of one tree at one \
1109 date still write the same bytes"
1110 )]
1111 record: bool,
1112 },
1113 Publish {
1114 #[arg(
1115 long,
1116 value_name = "name",
1117 help = "the package to publish. The one this repository's own declaration takes, by \
1118 default, because a publisher usually publishes what it also consumes. Refused \
1119 together with `--from`, which names the same thing by its directory instead"
1120 )]
1121 package: Option<String>,
1122 #[arg(
1123 long,
1124 value_name = "dir",
1125 help = "read the manifest at this directory directly, bypassing the lookup by name \
1126 under `.headwater/packages/` that `--package` drives. For a repository that both \
1127 publishes a package and consumes it: `taxonomy vendor` refuses to install over \
1128 a directory that carries no release record, so a maintained source cannot sit \
1129 where its own artifact would be installed. This reads it from wherever it \
1130 actually sits instead"
1131 )]
1132 from: Option<PathBuf>,
1133 #[arg(
1134 long,
1135 value_name = "name",
1136 help = "derive and publish this named assembly from the source package. It uses the \
1137 same source selection as `--package` or `--from`, and produces one flattened \
1138 package with no runtime bundle selection"
1139 )]
1140 assembly: Option<String>,
1141 #[arg(
1142 long,
1143 value_name = "dir",
1144 help = "where to write the artifact. The directory must be empty or absent, because a \
1145 published artifact is every file under its root and a stray one would be a \
1146 member the publisher never shipped. A run that cannot finish leaves it as it \
1147 found it, so a second run meets the same precondition the first one did"
1148 )]
1149 out: Option<PathBuf>,
1150 #[arg(
1151 long,
1152 help = "remove what a publish killed part-way left at `--out`, and publish in the \
1153 same run. It removes one state and nothing else: files at `--out` with no \
1154 release record, beside a `<out>~staging` directory holding both the files a \
1155 publish writes there to say it could not move the artifact into place and is \
1156 writing into `--out` one file at a time. Only a killed publish leaves those \
1157 two together, and the second one names the output path it was writing. A \
1158 directory holding anything else, and an `--out` that carries a release \
1159 record, are left exactly as they are and the publish refuses as it does \
1160 without this flag"
1161 )]
1162 clear_killed: bool,
1163 #[arg(long, help = JSON_ALONE)]
1164 json: bool,
1165 },
1166 Vendor {
1167 #[arg(
1168 value_name = "dir-or-location",
1169 help = "the directory of an artifact somebody already fetched, or the https:// \
1170 location of a published artifact zip, which this verb fetches through \
1171 `headwater-fetch`, a crate nothing in the checking loop links (HW-DR-0075)"
1172 )]
1173 path: Option<String>,
1174 #[arg(
1175 long,
1176 value_name = "digest",
1177 help = "the digest to check the artifact against. It defaults to `taxonomy.digest` in \
1178 `.headwater/taxonomy.yml`, and the verb refuses when neither is there. A pin \
1179 the engine took from the artifact in front of it would be a pin against itself"
1180 )]
1181 expect: Option<String>,
1182 },
1183 Diff {
1184 #[arg(
1185 value_name = "dir",
1186 help = "the directory of an artifact somebody already fetched. This verb opens no \
1187 socket, so it takes a path and never a location"
1188 )]
1189 path: Option<String>,
1190 #[arg(
1191 long,
1192 value_name = "version",
1193 help = "the version the artifact is expected to be, written as a version or as a \
1194 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs. This verb \
1195 fetches nothing, so the directory decides which artifact is compared and this \
1196 flag holds it to what the caller meant. It is read by the one range reader \
1197 the engine has, which is what reads `requires_engine`"
1198 )]
1199 to: Option<String>,
1200 #[arg(
1201 long,
1202 value_name = "date",
1203 value_parser = a_date,
1204 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1205 )]
1206 now: Option<Date>,
1207 },
1208 Migrate {
1209 #[arg(
1210 value_name = "dir",
1211 help = "the directory of an artifact somebody already fetched. This verb opens no \
1212 socket, so it takes a path and never a location"
1213 )]
1214 path: Option<String>,
1215 #[arg(
1216 long,
1217 value_name = "version",
1218 help = "the version the artifact is expected to be, written as a version or as a \
1219 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs"
1220 )]
1221 to: Option<String>,
1222 #[arg(
1223 long,
1224 help = "write the files each step names. Without it every file each step would write \
1225 is reported and nothing is written"
1226 )]
1227 apply: bool,
1228 #[arg(
1229 long,
1230 value_name = "date",
1231 value_parser = a_date,
1232 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1233 )]
1234 now: Option<Date>,
1235 },
1236 Graph {
1237 #[arg(
1238 long,
1239 value_enum,
1240 default_value_t,
1241 help = "which drawing to print. `concrete` draws the concrete kinds, the anchors and \
1242 the relations between them. `abstract` draws each abstract kind, the kinds \
1243 declared under it and the relations that name it, which `concrete` leaves out"
1244 )]
1245 view: crate::taxonomy_graph::View,
1246 #[arg(
1247 long,
1248 help = "add a key that draws each shape and each edge style the drawing uses, and \
1249 names the family each edge color stands for"
1250 )]
1251 legend: bool,
1252 },
1253 #[command(external_subcommand)]
1254 Other(Vec<String>),
1255}
1256
1257pub fn command() -> Command {
1269 command_at(paint::width())
1270}
1271
1272pub fn command_at(width: usize) -> Command {
1279 command_in(width, paint::stdout_color())
1280}
1281
1282pub fn command_in(width: usize, mode: paint::ColorMode) -> Command {
1291 let mut root = Cli::command()
1292 .about(format!(
1293 "{} — {}",
1294 headwater_verbs::BINARY,
1295 headwater_verbs::TAGLINE
1296 ))
1297 .color(paint::color_choice(mode))
1303 .styles(paint::help_styles(mode))
1304 .help_template(first_screen(width, mode));
1305 for verb in headwater_verbs::VERBS {
1306 if root.find_subcommand(verb.name).is_some() {
1317 root = root.mut_subcommand(verb.name, |one| described(one, verb, width, mode));
1318 }
1319 }
1320 paint::painted(root, width)
1321}
1322
1323pub fn parsed() -> Result<Cli, clap::Error> {
1331 let matches = command().try_get_matches()?;
1332 if let Some(message) = a_width_for_a_run_that_lays_nothing_out(&matches) {
1333 return Err(clap::Error::raw(
1334 clap::error::ErrorKind::ArgumentConflict,
1335 message,
1336 ));
1337 }
1338 Cli::from_arg_matches(&matches)
1339}
1340
1341fn a_width_for_a_run_that_lays_nothing_out(matches: &clap::ArgMatches) -> Option<String> {
1396 let mut leaf = matches;
1397 while let Some((_, inner)) = leaf.subcommand() {
1398 leaf = inner;
1399 }
1400 if leaf.try_get_one::<bool>("wide").ok().flatten() != Some(&true) {
1401 return None;
1402 }
1403 if matches.subcommand_name() == Some("help") {
1404 return None;
1405 }
1406 let format = leaf.try_get_one::<String>("format").ok().flatten();
1407 let json = leaf.try_get_one::<bool>("json").ok().flatten() == Some(&true);
1413 let laid_out = matches.subcommand_name() == Some("check")
1416 && !json
1417 && matches!(format.map(String::as_str), None | Some("text"));
1418 if laid_out {
1419 return None;
1420 }
1421 let says = match format.filter(|value| value.as_str() != "text") {
1422 Some(format) => format!("`--format {format}` writes an artifact that nothing lays out"),
1423 None if json => "`--json` writes an artifact that nothing lays out".to_string(),
1424 None => "this run lays nothing out".to_string(),
1425 };
1426 Some(format!(
1427 "`--wide` says how wide the help and the report of `{0} check` are laid out, and {says}. A \
1428 run carrying it would carry one flag that does nothing, so it is refused rather than run. \
1429 The runs it widens are `{0} --wide --help`, `{0} <verb> --wide --help`, `{0} --wide help \
1430 <verb>` and `{0} check --wide`",
1431 headwater_verbs::BINARY
1432 ))
1433}
1434
1435fn described(
1437 command: Command,
1438 verb: &headwater_verbs::Verb,
1439 width: usize,
1440 mode: paint::ColorMode,
1441) -> Command {
1442 let mut one = command.about(verb.description);
1443 if !verb.words.is_empty() {
1444 one = one.help_template(second_words(verb, width, mode));
1445 for word in verb.words {
1446 if one.find_subcommand(word.name).is_none() {
1447 continue;
1448 }
1449 one = one.mut_subcommand(word.name, |inner| inner.about(word.description));
1450 }
1451 }
1452 one
1453}
1454
1455const COLUMN: usize = 15;
1457
1458fn first_screen(width: usize, mode: paint::ColorMode) -> String {
1475 let mut out = format!(
1490 "{{usage-heading}} {{usage}}\n\n{}:\n",
1491 paint::paint(paint::Role::Heading, "Examples", mode)
1492 );
1493 for (line, says) in [
1494 (
1495 "headwater check --strict",
1496 "run the checks, and fail on an error",
1497 ),
1498 (
1499 "headwater route \"add rate limiting\"",
1500 "the documents that govern a task",
1501 ),
1502 (
1503 "headwater explain HW-DR-0033",
1504 "why a document is the kind it is",
1505 ),
1506 (
1507 "headwater new decision --title \"Adopt an overlay\"",
1508 "scaffold a document of a kind",
1509 ),
1510 (
1511 "headwater help taxonomy diff",
1512 "the long description of one verb",
1513 ),
1514 ] {
1515 out.push_str(&format!(" {line}\n"));
1520 out.push_str(&paint::fold_indented(says, width, 6));
1521 }
1522 for group in headwater_verbs::groups() {
1530 out.push_str(&format!(
1531 "\n{}:\n",
1532 paint::paint(paint::Role::Heading, group, mode)
1533 ));
1534 for verb in headwater_verbs::VERBS
1535 .iter()
1536 .filter(|one| one.group == group)
1537 {
1538 out.push_str(&paint::painted_row(
1539 verb.name,
1540 verb.summary,
1541 COLUMN,
1542 width,
1543 paint::Role::Verb,
1544 mode,
1545 ));
1546 }
1547 }
1548 let longest = GLOBALS
1565 .iter()
1566 .map(|one| one.name.chars().count())
1567 .max()
1568 .unwrap_or(0);
1569 let at = 2 + longest + 2;
1570 out.push_str(&format!(
1571 "\n{}:\n",
1572 paint::paint(paint::Role::Heading, "Global flags", mode)
1573 ));
1574 for one in GLOBALS {
1575 out.push_str(&paint::painted_row(
1576 one.name,
1577 one.summary,
1578 at,
1579 width,
1580 paint::Role::Path,
1581 mode,
1582 ));
1583 }
1584 out.push('\n');
1585 out.push_str(&paint::fold_indented(
1586 &format!(
1587 "Run `{0} help <verb>` for the long description of one verb, or `{0} <verb> --help`.",
1588 headwater_verbs::BINARY
1589 ),
1590 width,
1591 0,
1592 ));
1593 out
1594}
1595
1596fn second_words(verb: &headwater_verbs::Verb, width: usize, mode: paint::ColorMode) -> String {
1604 let mut out = String::from("{about}\n\n{usage-heading} {usage}\n\nSecond words:\n");
1605 for word in verb.words {
1606 out.push_str(&paint::painted_row(
1607 word.name,
1608 word.summary,
1609 COLUMN,
1610 width,
1611 paint::Role::Verb,
1612 mode,
1613 ));
1614 }
1615 out.push_str("\nFlags:\n{options}\n\n");
1616 out.push_str(&paint::fold_indented(
1617 &format!(
1618 "Run `{} help {} <word>` for the long description of one.",
1619 headwater_verbs::BINARY,
1620 verb.name
1621 ),
1622 width,
1623 0,
1624 ));
1625 out
1626}
1627
1628fn a_date(text: &str) -> Result<Date, String> {
1630 Date::parse(text).ok_or_else(|| "a date written `YYYY-MM-DD`".to_string())
1631}
1632
1633fn a_date_as_written(text: &str) -> Result<String, String> {
1638 a_date(text).map(|_| text.to_string())
1639}
1640
1641fn a_budget(text: &str) -> Result<usize, String> {
1643 match text.parse::<usize>() {
1644 Ok(value) if value > 0 => Ok(value),
1645 _ => Err("a whole number above zero".to_string()),
1646 }
1647}
1648
1649fn a_pair(text: &str) -> Result<(String, String), String> {
1655 match text.split_once('=') {
1656 Some((left, right)) if !left.is_empty() && !right.is_empty() => {
1657 Ok((left.to_string(), right.to_string()))
1658 }
1659 _ => Err(
1660 "`<left>=<right>`, as in `--relates supersedes=HW-DR-0007` or \
1661 `--facet probe_category=discovery`"
1662 .to_string(),
1663 ),
1664 }
1665}
1666
1667pub fn headline(error: &clap::Error) -> String {
1681 let rendered = error.render().to_string();
1682 let head: Vec<String> = rendered
1683 .lines()
1684 .take_while(|line| !line.starts_with("Usage:") && !line.starts_with("For more information"))
1685 .map(str::trim)
1686 .filter(|line| !line.is_empty())
1687 .map(|line| line.strip_prefix("error: ").unwrap_or(line).to_string())
1688 .collect();
1689 match head.is_empty() {
1690 true => rendered.split_whitespace().collect::<Vec<_>>().join(" "),
1691 false => head.join("\n"),
1692 }
1693}
1694
1695#[cfg(test)]
1696mod tests {
1697 use super::{a_budget, a_date, a_pair, command, headline, Cli};
1698 use clap::{CommandFactory, Parser};
1699
1700 #[test]
1701 fn the_declared_parse_is_a_command_clap_can_build() {
1702 Cli::command().debug_assert();
1703 command().debug_assert();
1704 }
1705
1706 #[test]
1707 fn a_value_a_flag_cannot_take_is_named_rather_than_defaulted() {
1708 assert!(a_date("2026-13-45").is_err());
1709 assert!(a_date("2026-01-01").is_ok());
1710 assert!(a_budget("0").is_err());
1711 assert!(a_budget("x").is_err());
1712 assert_eq!(a_budget("3"), Ok(3));
1713 assert!(a_pair("nope").is_err());
1714 assert!(a_pair("=right").is_err());
1715 assert!(a_pair("left=").is_err());
1716 assert_eq!(
1717 a_pair("supersedes=HW-DR-0007"),
1718 Ok(("supersedes".to_string(), "HW-DR-0007".to_string()))
1719 );
1720 }
1721
1722 #[test]
1729 fn a_refusal_carries_the_message_and_not_the_grammar() {
1730 let error = Cli::try_parse_from(["headwater", "check", "--nonsense"])
1731 .expect_err("`--nonsense` is not a flag `check` reads");
1732 let headline = headline(&error);
1733 assert!(
1734 headline.contains("--nonsense"),
1735 "the refusal names the offending word: {headline}"
1736 );
1737 assert!(
1738 !headline.contains("--root <path>"),
1739 "the refusal does not reprint the grammar: {headline}"
1740 );
1741 assert!(
1742 !headline.contains("Usage:"),
1743 "the refusal does not reprint the usage block: {headline}"
1744 );
1745 assert!(
1746 !headline.contains("For more information"),
1747 "the pointer is `fail`'s and is written once: {headline}"
1748 );
1749 }
1750
1751 #[test]
1756 fn a_flag_of_another_verb_does_not_reach_this_one() {
1757 assert!(Cli::try_parse_from(["headwater", "check", "--level", "L0"]).is_err());
1758 assert!(Cli::try_parse_from(["headwater", "conformance", "--level", "L0"]).is_ok());
1759 assert!(Cli::try_parse_from(["headwater", "sweep", "report", "f", "--strict"]).is_err());
1760 assert!(Cli::try_parse_from(["headwater", "check", "--strict"]).is_ok());
1761 }
1762
1763 #[test]
1767 fn the_corpus_flag_reaches_every_verb_from_either_side_of_it() {
1768 for arguments in [
1769 ["headwater", "check", "--root", "/tmp"],
1770 ["headwater", "--root", "/tmp", "check"],
1771 ] {
1772 let cli = Cli::try_parse_from(arguments).expect("`--root` is global");
1773 assert_eq!(cli.root.as_deref(), Some(std::path::Path::new("/tmp")));
1774 }
1775 }
1776}