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 and the new document opens at an initial state, the far half is \
683 owed until the document leaves that state, and `headwater check --fix` \
684 writes it then. In every other case the far half is written into the \
685 target document at once"
686 )]
687 relates: Vec<(String, String)>,
688 #[arg(
689 long,
690 value_name = "facet=value",
691 value_parser = a_pair,
692 help = "a value for a facet this kind requires, as `<facet>=<value>`. Repeatable. A \
693 facet the kind does not require is refused, and so is a value outside a \
694 closed set, with the set printed. A facet that a declaration decides is \
695 refused too: an engine role decides its facet's value, and the kind decides \
696 the discriminator of a heterogeneous shelf"
697 )]
698 facet: Vec<(String, String)>,
699 #[arg(
700 long,
701 value_name = "path",
702 help = "the directory to write into, relative to the root, for a kind whose shelf \
703 fixes the file name under a glob, such as `docs/modules/*/README.md`. The \
704 document is written as `<path>/` and the file name the shelf fixes, and must match the \
705 shelf. Refused on any other shelf, whose path already decides the directory"
706 )]
707 directory: Option<String>,
708 #[arg(
709 long,
710 value_name = "date",
711 value_parser = a_date,
712 help = "the date the document is stamped with, as `YYYY-MM-DD`. Defaults to today"
713 )]
714 now: Option<Date>,
715 },
716 Infer {
717 #[arg(
718 long,
719 value_name = "name",
720 help = "who owns the debt it proposes. Required with `--write`, because an owner is \
721 the field that ranks declared debt above a suppression and this engine will \
722 not invent one"
723 )]
724 owner: Option<String>,
725 #[arg(
726 long,
727 value_name = "date",
728 value_parser = a_date,
729 help = "the last day the tasks it proposes hold, as `YYYY-MM-DD`. Ninety days out by \
730 default"
731 )]
732 until: Option<Date>,
733 #[arg(
734 long,
735 help = "put the payload in the lock, which is committed and reviewed. Without it \
736 nothing is written"
737 )]
738 write: bool,
739 #[arg(
740 long,
741 value_name = "date",
742 value_parser = a_date,
743 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
744 )]
745 now: Option<Date>,
746 },
747 Generate {
748 #[arg(
749 long,
750 help = "write nothing and exit non-zero when what is committed is not what a run \
751 produces. It reads the corpus through the lock, so it answers whether a \
752 derived artifact is current"
753 )]
754 check: bool,
755 },
756 Import {
757 #[arg(
758 value_name = "name",
759 help = "which declared import to read, by the name its block carries in \
760 `.headwater/taxonomy.yml`. One declared import needs no name and two do, \
761 because choosing for the caller would import whichever the file listed first"
762 )]
763 name: Option<String>,
764 #[arg(
765 long,
766 value_name = "digest",
767 help = "the digest to check the snapshot against. It defaults to the `digest` of the \
768 import block in `.headwater/taxonomy.yml`, and the verb refuses when neither \
769 is there rather than reading an unpinned directory"
770 )]
771 expect: Option<String>,
772 #[arg(
773 long,
774 help = "write the edge halves into the documents at their near ends. Without it the \
775 edges are reported and nothing is touched"
776 )]
777 write: bool,
778 },
779 Export {
780 #[arg(
781 long,
782 value_name = "name",
783 help = "which declared export profile to emit. Every declared profile by default, so \
784 a filtered audience is never omitted by accident"
785 )]
786 profile: Option<String>,
787 #[arg(
788 long,
789 value_name = "json|jsonschema",
790 help = concat!(
791 "the emitter target. `json` is the native property graph with no loss and \
792 `jsonschema` constrains front matter. The other five targets of spec 6 parse \
793 and report the consumer each one waits on. With this flag the artifact goes to \
794 standard output and no declared output path is touched. ",
795 a_refusal_is_not_an_artifact!()
796 )
797 )]
798 format: Option<String>,
799 #[arg(
800 long,
801 value_name = "date",
802 value_parser = a_date_as_written,
803 help = "the generation time the artifact states, as `YYYY-MM-DD`. Absent by default, \
804 because an artifact that `--check` compares by byte cannot carry a clock \
805 reading. Spec 6 asks a filtered export that leaves the repository to state \
806 one, and this is where it is injected"
807 )]
808 at: Option<String>,
809 #[arg(
810 long,
811 help = "write nothing and exit non-zero when a declared output is not what a run \
812 produces. It holds every export the taxonomy names a path for to regeneration"
813 )]
814 check: bool,
815 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
816 json: bool,
817 },
818 Sweep {
819 #[command(subcommand)]
820 word: Option<SweepWord>,
821 },
822 Probe {
823 #[command(subcommand)]
824 word: Option<ProbeWord>,
825 },
826 Init {
827 #[arg(
828 long,
829 value_name = "dir",
830 help = "the corpus root to declare. Proposed from the tree by default"
831 )]
832 corpus: Option<String>,
833 #[arg(
834 long,
835 value_name = "name",
836 help = "the package to take. `headwater/standard` by default"
837 )]
838 package: Option<String>,
839 #[arg(
840 long,
841 help = "append a `-merge` line to `.gitattributes` for each fold \
842 `headwater taxonomy resolve` and `headwater generate` write in this tree, and \
843 print the two `git config` lines that name `headwater merge-driver` and the \
844 `info/attributes` lines that select it. A clone that already names the driver \
845 gets those lines written. It runs after the first `headwater generate`, and on \
846 a repository that is already bound it does this and nothing else"
847 )]
848 git: bool,
849 #[arg(
850 long = "git-config",
851 requires = "git",
852 help = "also run the two `git config` lines `--git` prints, in this clone, and then \
853 write the `info/attributes` lines that select the driver. Git takes no driver \
854 from a repository, so without this flag the lines are printed and the adopter \
855 runs them"
856 )]
857 git_config: bool,
858 },
859 Taxonomy {
860 #[command(subcommand)]
861 word: Option<TaxonomyWord>,
862 },
863 Json {
867 #[command(subcommand)]
868 word: Option<JsonWord>,
869 },
870 Help {
879 #[arg(
880 value_name = "verb",
881 help = "the verb to describe, with its second word where it takes one: \
882 `headwater help taxonomy diff`. Without one this screen is printed"
883 )]
884 verb: Vec<String>,
885 },
886 Completions {
891 #[arg(
892 value_name = "shell",
893 help = "the shell to write a script for. A name outside the four is refused with the \
894 four printed, and no script is written"
895 )]
896 shell: Option<Shell>,
897 },
898 #[command(external_subcommand)]
905 Other(Vec<String>),
906}
907
908#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
924pub enum Shell {
925 Bash,
926 Zsh,
927 Fish,
928 Powershell,
929}
930
931impl From<Shell> for clap_complete::Shell {
932 fn from(shell: Shell) -> Self {
933 match shell {
934 Shell::Bash => clap_complete::Shell::Bash,
935 Shell::Zsh => clap_complete::Shell::Zsh,
936 Shell::Fish => clap_complete::Shell::Fish,
937 Shell::Powershell => clap_complete::Shell::PowerShell,
938 }
939 }
940}
941
942impl Shell {
943 pub fn typed(self) -> &'static str {
945 match self {
946 Shell::Bash => "bash",
947 Shell::Zsh => "zsh",
948 Shell::Fish => "fish",
949 Shell::Powershell => "powershell",
950 }
951 }
952
953 pub const ALL: &'static [Shell] = &[Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Powershell];
955}
956
957#[derive(Subcommand, Debug)]
959pub enum JsonWord {
960 Field {
961 #[arg(
962 value_name = "key",
963 help = "the path of steps to the member, outermost first. A step into an object \
964 is a key, and a step into an array is a decimal index counted from 0. \
965 `headwater json field tool_input file_path` reads the `file_path` member of \
966 the `tool_input` member, and `headwater json field related 0 target` reads \
967 the `target` member of the first element of `related`. Without one, the object is read and no member of it \
968 is named, which is refused"
969 )]
970 path: Vec<String>,
971 },
972 Count {
973 #[arg(
974 value_name = "key",
975 help = "the path of steps to the array or the object whose elements are counted, \
976 outermost first, where a step into an array is a decimal index. Without one, the object on standard input is the one counted"
977 )]
978 path: Vec<String>,
979 },
980 Quote,
981 #[command(external_subcommand)]
982 Other(Vec<String>),
983}
984
985#[derive(Subcommand, Debug)]
987pub enum SweepWord {
988 Plan {
989 #[arg(
990 long,
991 value_name = "path",
992 help = "the slice, as a path prefix under the repository root. The whole corpus by \
993 default. There is no sampling rule here: a slice this engine picked would be \
994 an unreproducible sample dressed as a reproducible one, and the plan reports \
995 its own extent instead"
996 )]
997 under: Option<String>,
998 },
999 Report {
1000 #[arg(
1001 value_name = "path",
1002 help = "the file an agent wrote back. `headwater sweep plan` prints the shape of it"
1003 )]
1004 path: Option<String>,
1005 #[arg(
1006 long,
1007 value_name = "text|json",
1008 help = concat!(
1009 "`text` is the report a person reads and the default, and `json` is the finding \
1010 shape spec 4 declares with the provenance and the evidence a sweep adds. ",
1011 a_refusal_is_not_an_artifact!()
1012 )
1013 )]
1014 format: Option<String>,
1015 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
1016 json: bool,
1017 },
1018 #[command(external_subcommand)]
1019 Other(Vec<String>),
1020}
1021
1022#[derive(Subcommand, Debug)]
1024pub enum ProbeWord {
1025 Plan {
1026 #[arg(
1027 long,
1028 value_name = "regression|campaign",
1029 help = "which tier of `.headwater/probe.yml` to plan against. A tier declares the \
1030 ceiling, the session cost, the repetitions and the arms, and the plan is \
1031 projected against all four. `regression` by default"
1032 )]
1033 tier: Option<String>,
1034 #[arg(
1035 long,
1036 value_name = "present|absent",
1037 help = "narrow the selection to one arm the tier declares. Every arm the tier \
1038 declares by default, which is one for `regression` and two for `campaign`. \
1039 An arm the tier does not declare refuses the run rather than planning \
1040 another one, and the refusal names the arms the tier declares"
1041 )]
1042 arm: Option<String>,
1043 #[arg(
1044 long,
1045 value_name = "name",
1046 help = "narrow the selection to one probe category, by the name this engine declares \
1047 for it. Every category by default, a name outside the closed set is refused \
1048 with the set printed, and a category no probe of this corpus carries is \
1049 refused rather than planned as a run of nothing"
1050 )]
1051 category: Option<String>,
1052 #[arg(
1062 long,
1063 value_name = "n",
1064 default_value_t = 0,
1065 help = "the rotation seed, which is a member of the run identity spec 5 declares. It \
1066 is the caller's number: a run that states none states zero, and it is \
1067 recorded as stated. No selection is drawn from it — every declared probe is \
1068 selected — so it identifies a run rather than choosing one"
1069 )]
1070 seed: u64,
1071 },
1072 Record {
1073 #[arg(
1074 value_name = "path",
1075 help = "the transcript a recorder wrote. `headwater probe plan` prints the run \
1076 identity it has to carry"
1077 )]
1078 path: Option<String>,
1079 },
1080 Grade {
1081 #[arg(
1082 value_name = "path",
1083 help = "the transcript a recorder wrote. It is graded against the probes this corpus \
1084 declares, re-derived here rather than taken from the transcript"
1085 )]
1086 path: Option<String>,
1087 },
1088 Stale,
1089 #[command(external_subcommand)]
1090 Other(Vec<String>),
1091}
1092
1093#[derive(Subcommand, Debug)]
1095pub enum TaxonomyWord {
1096 Validate,
1097 Resolve {
1098 #[arg(
1099 long,
1100 help = "write nothing and exit non-zero when what is committed is not what a run \
1101 produces. It reads the taxonomy sources, so it answers whether the lock is \
1102 current"
1103 )]
1104 check: bool,
1105 },
1106 Audit {
1107 #[arg(
1108 long,
1109 value_name = "date",
1110 value_parser = a_date,
1111 help = "the date a staleness reading and a dwell reading are taken at, as \
1112 `YYYY-MM-DD`. Defaults to today, and two audits of one tree at one date write \
1113 the same bytes"
1114 )]
1115 now: Option<Date>,
1116 #[arg(
1117 long,
1118 help = "append this run's adoption reading to `.headwater/adoption.jsonl`. Without it \
1119 the verb writes nothing. A reading the store already holds at this lock and \
1120 this date is not appended twice, so two recorded audits of one tree at one \
1121 date still write the same bytes"
1122 )]
1123 record: bool,
1124 },
1125 Publish {
1126 #[arg(
1127 long,
1128 value_name = "name",
1129 help = "the package to publish. The one this repository's own declaration takes, by \
1130 default, because a publisher usually publishes what it also consumes. Refused \
1131 together with `--from`, which names the same thing by its directory instead"
1132 )]
1133 package: Option<String>,
1134 #[arg(
1135 long,
1136 value_name = "dir",
1137 help = "read the manifest at this directory directly, bypassing the lookup by name \
1138 under `.headwater/packages/` that `--package` drives. For a repository that both \
1139 publishes a package and consumes it: `taxonomy vendor` refuses to install over \
1140 a directory that carries no release record, so a maintained source cannot sit \
1141 where its own artifact would be installed. This reads it from wherever it \
1142 actually sits instead"
1143 )]
1144 from: Option<PathBuf>,
1145 #[arg(
1146 long,
1147 value_name = "name",
1148 help = "derive and publish this named assembly from the source package. It uses the \
1149 same source selection as `--package` or `--from`, and produces one flattened \
1150 package with no runtime bundle selection"
1151 )]
1152 assembly: Option<String>,
1153 #[arg(
1154 long,
1155 value_name = "dir",
1156 help = "where to write the artifact. The directory must be empty or absent, because a \
1157 published artifact is every file under its root and a stray one would be a \
1158 member the publisher never shipped. A run that cannot finish leaves it as it \
1159 found it, so a second run meets the same precondition the first one did"
1160 )]
1161 out: Option<PathBuf>,
1162 #[arg(
1163 long,
1164 help = "remove what a publish killed part-way left at `--out`, and publish in the \
1165 same run. It removes one state and nothing else: files at `--out` with no \
1166 release record, beside a `<out>~staging` directory holding both the files a \
1167 publish writes there to say it could not move the artifact into place and is \
1168 writing into `--out` one file at a time. Only a killed publish leaves those \
1169 two together, and the second one names the output path it was writing. A \
1170 directory holding anything else, and an `--out` that carries a release \
1171 record, are left exactly as they are and the publish refuses as it does \
1172 without this flag"
1173 )]
1174 clear_killed: bool,
1175 #[arg(
1176 long,
1177 help = "write nothing, and exit 1 when the vendored copy under `.headwater/packages/` \
1178 is not what a fresh publish of the source at `--from` produces. It publishes \
1179 into a private directory outside the tree, removes it, and names each member \
1180 that moved. For a repository that maintains a package and also consumes it, \
1181 so that a source changed without a republish fails a gate. Needs `--from`, \
1182 and is refused with `--out`, `--package`, `--assembly`, `--clear-killed` and \
1183 `--json`"
1184 )]
1185 check: bool,
1186 #[arg(long, help = JSON_ALONE)]
1187 json: bool,
1188 },
1189 Vendor {
1190 #[arg(
1191 value_name = "dir-or-location",
1192 help = "the directory of an artifact somebody already fetched, or the https:// \
1193 location of a published artifact zip, which this verb fetches through \
1194 `headwater-fetch`, a crate nothing in the checking loop links (HW-DR-0075)"
1195 )]
1196 path: Option<String>,
1197 #[arg(
1198 long,
1199 value_name = "digest",
1200 help = "the digest to check the artifact against. It defaults to `taxonomy.digest` in \
1201 `.headwater/taxonomy.yml`, and the verb refuses when neither is there. A pin \
1202 the engine took from the artifact in front of it would be a pin against itself"
1203 )]
1204 expect: Option<String>,
1205 },
1206 Diff {
1207 #[arg(
1208 value_name = "dir",
1209 help = "the directory of an artifact somebody already fetched. This verb opens no \
1210 socket, so it takes a path and never a location"
1211 )]
1212 path: Option<String>,
1213 #[arg(
1214 long,
1215 value_name = "version",
1216 help = "the version the artifact is expected to be, written as a version or as a \
1217 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs. This verb \
1218 fetches nothing, so the directory decides which artifact is compared and this \
1219 flag holds it to what the caller meant. It is read by the one range reader \
1220 the engine has, which is what reads `requires_engine`"
1221 )]
1222 to: Option<String>,
1223 #[arg(
1224 long,
1225 value_name = "date",
1226 value_parser = a_date,
1227 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1228 )]
1229 now: Option<Date>,
1230 },
1231 Migrate {
1232 #[arg(
1233 value_name = "dir",
1234 help = "the directory of an artifact somebody already fetched. This verb opens no \
1235 socket, so it takes a path and never a location"
1236 )]
1237 path: Option<String>,
1238 #[arg(
1239 long,
1240 value_name = "version",
1241 help = "the version the artifact is expected to be, written as a version or as a \
1242 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs"
1243 )]
1244 to: Option<String>,
1245 #[arg(
1246 long,
1247 help = "write the files each step names. Without it every file each step would write \
1248 is reported and nothing is written"
1249 )]
1250 apply: bool,
1251 #[arg(
1252 long,
1253 value_name = "date",
1254 value_parser = a_date,
1255 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1256 )]
1257 now: Option<Date>,
1258 },
1259 Graph {
1260 #[arg(
1261 long,
1262 value_enum,
1263 default_value_t,
1264 help = "which drawing to print. `concrete` draws the concrete kinds, the anchors and \
1265 the relations between them. `abstract` draws each abstract kind, the kinds \
1266 declared under it and the relations that name it, which `concrete` leaves out"
1267 )]
1268 view: crate::taxonomy_graph::View,
1269 #[arg(
1270 long,
1271 help = "add a key that draws each shape and each edge style the drawing uses, and \
1272 names the family each edge color stands for"
1273 )]
1274 legend: bool,
1275 },
1276 #[command(external_subcommand)]
1277 Other(Vec<String>),
1278}
1279
1280pub fn command() -> Command {
1292 command_at(paint::width())
1293}
1294
1295pub fn command_at(width: usize) -> Command {
1302 command_in(width, paint::stdout_color())
1303}
1304
1305pub fn command_in(width: usize, mode: paint::ColorMode) -> Command {
1314 let mut root = Cli::command()
1315 .about(format!(
1316 "{} — {}",
1317 headwater_verbs::BINARY,
1318 headwater_verbs::TAGLINE
1319 ))
1320 .color(paint::color_choice(mode))
1326 .styles(paint::help_styles(mode))
1327 .help_template(first_screen(width, mode));
1328 for verb in headwater_verbs::VERBS {
1329 if root.find_subcommand(verb.name).is_some() {
1340 root = root.mut_subcommand(verb.name, |one| described(one, verb, width, mode));
1341 }
1342 }
1343 paint::painted(root, width)
1344}
1345
1346pub fn parsed() -> Result<Cli, clap::Error> {
1354 let matches = command().try_get_matches()?;
1355 if let Some(message) = a_width_for_a_run_that_lays_nothing_out(&matches) {
1356 return Err(clap::Error::raw(
1357 clap::error::ErrorKind::ArgumentConflict,
1358 message,
1359 ));
1360 }
1361 Cli::from_arg_matches(&matches)
1362}
1363
1364fn a_width_for_a_run_that_lays_nothing_out(matches: &clap::ArgMatches) -> Option<String> {
1419 let mut leaf = matches;
1420 while let Some((_, inner)) = leaf.subcommand() {
1421 leaf = inner;
1422 }
1423 if leaf.try_get_one::<bool>("wide").ok().flatten() != Some(&true) {
1424 return None;
1425 }
1426 if matches.subcommand_name() == Some("help") {
1427 return None;
1428 }
1429 let format = leaf.try_get_one::<String>("format").ok().flatten();
1430 let json = leaf.try_get_one::<bool>("json").ok().flatten() == Some(&true);
1436 let laid_out = matches.subcommand_name() == Some("check")
1439 && !json
1440 && matches!(format.map(String::as_str), None | Some("text"));
1441 if laid_out {
1442 return None;
1443 }
1444 let says = match format.filter(|value| value.as_str() != "text") {
1445 Some(format) => format!("`--format {format}` writes an artifact that nothing lays out"),
1446 None if json => "`--json` writes an artifact that nothing lays out".to_string(),
1447 None => "this run lays nothing out".to_string(),
1448 };
1449 Some(format!(
1450 "`--wide` says how wide the help and the report of `{0} check` are laid out, and {says}. A \
1451 run carrying it would carry one flag that does nothing, so it is refused rather than run. \
1452 The runs it widens are `{0} --wide --help`, `{0} <verb> --wide --help`, `{0} --wide help \
1453 <verb>` and `{0} check --wide`",
1454 headwater_verbs::BINARY
1455 ))
1456}
1457
1458fn described(
1460 command: Command,
1461 verb: &headwater_verbs::Verb,
1462 width: usize,
1463 mode: paint::ColorMode,
1464) -> Command {
1465 let mut one = command.about(verb.description);
1466 if !verb.words.is_empty() {
1467 one = one.help_template(second_words(verb, width, mode));
1468 for word in verb.words {
1469 if one.find_subcommand(word.name).is_none() {
1470 continue;
1471 }
1472 one = one.mut_subcommand(word.name, |inner| inner.about(word.description));
1473 }
1474 }
1475 one
1476}
1477
1478const COLUMN: usize = 15;
1480
1481fn first_screen(width: usize, mode: paint::ColorMode) -> String {
1498 let mut out = format!(
1513 "{{usage-heading}} {{usage}}\n\n{}:\n",
1514 paint::paint(paint::Role::Heading, "Examples", mode)
1515 );
1516 for (line, says) in [
1517 (
1518 "headwater check --strict",
1519 "run the checks, and fail on an error",
1520 ),
1521 (
1522 "headwater route \"add rate limiting\"",
1523 "the documents that govern a task",
1524 ),
1525 (
1526 "headwater explain HW-DR-0033",
1527 "why a document is the kind it is",
1528 ),
1529 (
1530 "headwater new decision --title \"Adopt an overlay\"",
1531 "scaffold a document of a kind",
1532 ),
1533 (
1534 "headwater help taxonomy diff",
1535 "the long description of one verb",
1536 ),
1537 ] {
1538 out.push_str(&format!(" {line}\n"));
1543 out.push_str(&paint::fold_indented(says, width, 6));
1544 }
1545 for group in headwater_verbs::groups() {
1553 out.push_str(&format!(
1554 "\n{}:\n",
1555 paint::paint(paint::Role::Heading, group, mode)
1556 ));
1557 for verb in headwater_verbs::VERBS
1558 .iter()
1559 .filter(|one| one.group == group)
1560 {
1561 out.push_str(&paint::painted_row(
1562 verb.name,
1563 verb.summary,
1564 COLUMN,
1565 width,
1566 paint::Role::Verb,
1567 mode,
1568 ));
1569 }
1570 }
1571 let longest = GLOBALS
1588 .iter()
1589 .map(|one| one.name.chars().count())
1590 .max()
1591 .unwrap_or(0);
1592 let at = 2 + longest + 2;
1593 out.push_str(&format!(
1594 "\n{}:\n",
1595 paint::paint(paint::Role::Heading, "Global flags", mode)
1596 ));
1597 for one in GLOBALS {
1598 out.push_str(&paint::painted_row(
1599 one.name,
1600 one.summary,
1601 at,
1602 width,
1603 paint::Role::Path,
1604 mode,
1605 ));
1606 }
1607 out.push('\n');
1608 out.push_str(&paint::fold_indented(
1609 &format!(
1610 "Run `{0} help <verb>` for the long description of one verb, or `{0} <verb> --help`.",
1611 headwater_verbs::BINARY
1612 ),
1613 width,
1614 0,
1615 ));
1616 out
1617}
1618
1619fn second_words(verb: &headwater_verbs::Verb, width: usize, mode: paint::ColorMode) -> String {
1627 let mut out = String::from("{about}\n\n{usage-heading} {usage}\n\nSecond words:\n");
1628 for word in verb.words {
1629 out.push_str(&paint::painted_row(
1630 word.name,
1631 word.summary,
1632 COLUMN,
1633 width,
1634 paint::Role::Verb,
1635 mode,
1636 ));
1637 }
1638 out.push_str("\nFlags:\n{options}\n\n");
1639 out.push_str(&paint::fold_indented(
1640 &format!(
1641 "Run `{} help {} <word>` for the long description of one.",
1642 headwater_verbs::BINARY,
1643 verb.name
1644 ),
1645 width,
1646 0,
1647 ));
1648 out
1649}
1650
1651fn a_date(text: &str) -> Result<Date, String> {
1653 Date::parse(text).ok_or_else(|| "a date written `YYYY-MM-DD`".to_string())
1654}
1655
1656fn a_date_as_written(text: &str) -> Result<String, String> {
1661 a_date(text).map(|_| text.to_string())
1662}
1663
1664fn a_budget(text: &str) -> Result<usize, String> {
1666 match text.parse::<usize>() {
1667 Ok(value) if value > 0 => Ok(value),
1668 _ => Err("a whole number above zero".to_string()),
1669 }
1670}
1671
1672fn a_pair(text: &str) -> Result<(String, String), String> {
1678 match text.split_once('=') {
1679 Some((left, right)) if !left.is_empty() && !right.is_empty() => {
1680 Ok((left.to_string(), right.to_string()))
1681 }
1682 _ => Err(
1683 "`<left>=<right>`, as in `--relates supersedes=HW-DR-0007` or \
1684 `--facet probe_category=discovery`"
1685 .to_string(),
1686 ),
1687 }
1688}
1689
1690pub fn headline(error: &clap::Error) -> String {
1704 let rendered = error.render().to_string();
1705 let head: Vec<String> = rendered
1706 .lines()
1707 .take_while(|line| !line.starts_with("Usage:") && !line.starts_with("For more information"))
1708 .map(str::trim)
1709 .filter(|line| !line.is_empty())
1710 .map(|line| line.strip_prefix("error: ").unwrap_or(line).to_string())
1711 .collect();
1712 match head.is_empty() {
1713 true => rendered.split_whitespace().collect::<Vec<_>>().join(" "),
1714 false => head.join("\n"),
1715 }
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720 use super::{a_budget, a_date, a_pair, command, headline, Cli};
1721 use clap::{CommandFactory, Parser};
1722
1723 #[test]
1724 fn the_declared_parse_is_a_command_clap_can_build() {
1725 Cli::command().debug_assert();
1726 command().debug_assert();
1727 }
1728
1729 #[test]
1730 fn a_value_a_flag_cannot_take_is_named_rather_than_defaulted() {
1731 assert!(a_date("2026-13-45").is_err());
1732 assert!(a_date("2026-01-01").is_ok());
1733 assert!(a_budget("0").is_err());
1734 assert!(a_budget("x").is_err());
1735 assert_eq!(a_budget("3"), Ok(3));
1736 assert!(a_pair("nope").is_err());
1737 assert!(a_pair("=right").is_err());
1738 assert!(a_pair("left=").is_err());
1739 assert_eq!(
1740 a_pair("supersedes=HW-DR-0007"),
1741 Ok(("supersedes".to_string(), "HW-DR-0007".to_string()))
1742 );
1743 }
1744
1745 #[test]
1752 fn a_refusal_carries_the_message_and_not_the_grammar() {
1753 let error = Cli::try_parse_from(["headwater", "check", "--nonsense"])
1754 .expect_err("`--nonsense` is not a flag `check` reads");
1755 let headline = headline(&error);
1756 assert!(
1757 headline.contains("--nonsense"),
1758 "the refusal names the offending word: {headline}"
1759 );
1760 assert!(
1761 !headline.contains("--root <path>"),
1762 "the refusal does not reprint the grammar: {headline}"
1763 );
1764 assert!(
1765 !headline.contains("Usage:"),
1766 "the refusal does not reprint the usage block: {headline}"
1767 );
1768 assert!(
1769 !headline.contains("For more information"),
1770 "the pointer is `fail`'s and is written once: {headline}"
1771 );
1772 }
1773
1774 #[test]
1779 fn a_flag_of_another_verb_does_not_reach_this_one() {
1780 assert!(Cli::try_parse_from(["headwater", "check", "--level", "L0"]).is_err());
1781 assert!(Cli::try_parse_from(["headwater", "conformance", "--level", "L0"]).is_ok());
1782 assert!(Cli::try_parse_from(["headwater", "sweep", "report", "f", "--strict"]).is_err());
1783 assert!(Cli::try_parse_from(["headwater", "check", "--strict"]).is_ok());
1784 }
1785
1786 #[test]
1790 fn the_corpus_flag_reaches_every_verb_from_either_side_of_it() {
1791 for arguments in [
1792 ["headwater", "check", "--root", "/tmp"],
1793 ["headwater", "--root", "/tmp", "check"],
1794 ] {
1795 let cli = Cli::try_parse_from(arguments).expect("`--root` is global");
1796 assert_eq!(cli.root.as_deref(), Some(std::path::Path::new("/tmp")));
1797 }
1798 }
1799}