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 Show {
611 #[arg(
612 value_name = "path|identifier",
613 help = "the document to print, as a path under the corpus root or as the identifier \
614 it declares"
615 )]
616 target: Option<String>,
617 },
618 Query {
619 #[arg(
620 value_name = "expression",
621 help = "the expression to run, and no document of this repository states what one is"
622 )]
623 expression: Vec<String>,
624 },
625 Capture {
626 #[arg(
627 long,
628 value_name = "text|json",
629 help = concat!(
630 "`text` is the report a person reads and the default, and `json` is the same \
631 numbers for a program. Neither carries a reading the store does not hold. ",
632 a_refusal_is_not_an_artifact!()
633 )
634 )]
635 format: Option<String>,
636 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
637 json: bool,
638 },
639 Mcp {
640 #[arg(
641 long,
642 value_name = "date",
643 value_parser = a_date,
644 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today. It is read \
645 once and fixed for the life of the server, and every result states it"
646 )]
647 now: Option<Date>,
648 #[arg(
649 long,
650 help = "register the working-tree write class, which is `new` and `fix`. Spec 5 keeps \
651 it off by default, because a client may connect to a checkout that the user \
652 did not intend to change, so the consent is a word somebody typed rather than \
653 a setting a tree carries. A tool that lands a change is registered by no \
654 switch. The first call that moves a byte ends the server: it walked the corpus \
655 once, so every later answer would be about a tree that is gone"
656 )]
657 write: bool,
658 },
659 New {
660 #[arg(
661 value_name = "kind",
662 help = "the kind of document to scaffold, by the name the resolved taxonomy declares \
663 for it"
664 )]
665 kind: Option<String>,
666 #[arg(
667 long,
668 value_name = "text",
669 help = "what the document is called. Required, because the file name and the facet in \
670 the `name` role both come from it"
671 )]
672 title: Option<String>,
673 #[arg(
674 long,
675 value_name = "text",
676 help = "the one sentence a reader meets where a list of documents is rendered. \
677 Fills the facet in the `scent` role directly, exactly as `--title` fills the \
678 one in the `name` role. Without it the field carries a prompt, and a person \
679 edits the front matter by hand before the document is current"
680 )]
681 summary: Option<String>,
682 #[arg(
683 long,
684 value_name = "relation=identifier",
685 value_parser = a_pair,
686 help = "an edge to propose, as a relation and the identifier of the document at the \
687 other end. Repeatable. It is refused unless the taxonomy declares \
688 `created_by: scaffold` on the relation, unless both ends are kinds the \
689 relation permits, and unless the target resolves. Where reciprocity is \
690 required and the new document opens at an initial state, the far half is \
691 owed until the document leaves that state, and `headwater check --fix` \
692 writes it then. In every other case the far half is written into the \
693 target document at once"
694 )]
695 relates: Vec<(String, String)>,
696 #[arg(
697 long,
698 value_name = "facet=value",
699 value_parser = a_pair,
700 help = "a value for a facet this kind requires, as `<facet>=<value>`. Repeatable. A \
701 facet the kind does not require is refused, and so is a value outside a \
702 closed set, with the set printed. A facet that a declaration decides is \
703 refused too: an engine role decides its facet's value, and the kind decides \
704 the discriminator of a heterogeneous shelf"
705 )]
706 facet: Vec<(String, String)>,
707 #[arg(
708 long,
709 value_name = "path",
710 help = "the directory to write into, relative to the root, for a kind whose shelf \
711 fixes the file name under a glob, such as `docs/modules/*/README.md`. The \
712 document is written as `<path>/` and the file name the shelf fixes, and must match the \
713 shelf. Refused on any other shelf, whose path already decides the directory"
714 )]
715 directory: Option<String>,
716 #[arg(
717 long,
718 value_name = "date",
719 value_parser = a_date,
720 help = "the date the document is stamped with, as `YYYY-MM-DD`. Defaults to today"
721 )]
722 now: Option<Date>,
723 },
724 Infer {
725 #[arg(
726 long,
727 value_name = "name",
728 help = "who owns the debt it proposes. Required with `--write`, because an owner is \
729 the field that ranks declared debt above a suppression and this engine will \
730 not invent one"
731 )]
732 owner: Option<String>,
733 #[arg(
734 long,
735 value_name = "date",
736 value_parser = a_date,
737 help = "the last day the tasks it proposes hold, as `YYYY-MM-DD`. Ninety days out by \
738 default"
739 )]
740 until: Option<Date>,
741 #[arg(
742 long,
743 help = "put the payload in the lock, which is committed and reviewed. Without it \
744 nothing is written"
745 )]
746 write: bool,
747 #[arg(
748 long,
749 value_name = "date",
750 value_parser = a_date,
751 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
752 )]
753 now: Option<Date>,
754 },
755 Generate {
756 #[arg(
757 long,
758 help = "write nothing and exit non-zero when what is committed is not what a run \
759 produces. It reads the corpus through the lock, so it answers whether a \
760 derived artifact is current"
761 )]
762 check: bool,
763 },
764 Import {
765 #[arg(
766 value_name = "name",
767 help = "which declared import to read, by the name its block carries in \
768 `.headwater/taxonomy.yml`. One declared import needs no name and two do, \
769 because choosing for the caller would import whichever the file listed first"
770 )]
771 name: Option<String>,
772 #[arg(
773 long,
774 value_name = "digest",
775 help = "the digest to check the snapshot against. It defaults to the `digest` of the \
776 import block in `.headwater/taxonomy.yml`, and the verb refuses when neither \
777 is there rather than reading an unpinned directory"
778 )]
779 expect: Option<String>,
780 #[arg(
781 long,
782 help = "write the edge halves into the documents at their near ends. Without it the \
783 edges are reported and nothing is touched"
784 )]
785 write: bool,
786 },
787 Export {
788 #[arg(
789 long,
790 value_name = "name",
791 help = "which declared export profile to emit. Every declared profile by default, so \
792 a filtered audience is never omitted by accident"
793 )]
794 profile: Option<String>,
795 #[arg(
796 long,
797 value_name = "json|jsonschema",
798 help = concat!(
799 "the emitter target. `json` is the native property graph with no loss and \
800 `jsonschema` constrains front matter. The other five targets of spec 6 parse \
801 and report the consumer each one waits on. With this flag the artifact goes to \
802 standard output and no declared output path is touched. ",
803 a_refusal_is_not_an_artifact!()
804 )
805 )]
806 format: Option<String>,
807 #[arg(
808 long,
809 value_name = "date",
810 value_parser = a_date_as_written,
811 help = "the generation time the artifact states, as `YYYY-MM-DD`. Absent by default, \
812 because an artifact that `--check` compares by byte cannot carry a clock \
813 reading. Spec 6 asks a filtered export that leaves the repository to state \
814 one, and this is where it is injected"
815 )]
816 at: Option<String>,
817 #[arg(
818 long,
819 help = "write nothing and exit non-zero when a declared output is not what a run \
820 produces. It holds every export the taxonomy names a path for to regeneration"
821 )]
822 check: bool,
823 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
824 json: bool,
825 },
826 Sweep {
827 #[command(subcommand)]
828 word: Option<SweepWord>,
829 },
830 Probe {
831 #[command(subcommand)]
832 word: Option<ProbeWord>,
833 },
834 Init {
835 #[arg(
836 long,
837 value_name = "dir",
838 help = "the corpus root to declare. Proposed from the tree by default"
839 )]
840 corpus: Option<String>,
841 #[arg(
842 long,
843 value_name = "name",
844 help = "the package to take. `headwater/standard` by default"
845 )]
846 package: Option<String>,
847 #[arg(
848 long,
849 help = "append a `-merge` line to `.gitattributes` for each fold \
850 `headwater taxonomy resolve` and `headwater generate` write in this tree and a \
851 `merge=union` line for each append-only store the engine writes, and \
852 print the two `git config` lines that name `headwater merge-driver` and the \
853 `info/attributes` lines that select it. A clone that already names the driver \
854 gets those lines written. It runs after the first `headwater generate`, and on \
855 a repository that is already bound it does this and nothing else"
856 )]
857 git: bool,
858 #[arg(
859 long = "git-config",
860 requires = "git",
861 help = "also run the two `git config` lines `--git` prints, in this clone, and then \
862 write the `info/attributes` lines that select the driver. Git takes no driver \
863 from a repository, so without this flag the lines are printed and the adopter \
864 runs them"
865 )]
866 git_config: bool,
867 },
868 Taxonomy {
869 #[command(subcommand)]
870 word: Option<TaxonomyWord>,
871 },
872 Json {
876 #[command(subcommand)]
877 word: Option<JsonWord>,
878 },
879 Help {
888 #[arg(
889 value_name = "verb",
890 help = "the verb to describe, with its second word where it takes one: \
891 `headwater help taxonomy diff`. Without one this screen is printed"
892 )]
893 verb: Vec<String>,
894 },
895 Completions {
900 #[arg(
901 value_name = "shell",
902 help = "the shell to write a script for. A name outside the four is refused with the \
903 four printed, and no script is written"
904 )]
905 shell: Option<Shell>,
906 },
907 #[command(external_subcommand)]
914 Other(Vec<String>),
915}
916
917#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
933pub enum Shell {
934 Bash,
935 Zsh,
936 Fish,
937 Powershell,
938}
939
940impl From<Shell> for clap_complete::Shell {
941 fn from(shell: Shell) -> Self {
942 match shell {
943 Shell::Bash => clap_complete::Shell::Bash,
944 Shell::Zsh => clap_complete::Shell::Zsh,
945 Shell::Fish => clap_complete::Shell::Fish,
946 Shell::Powershell => clap_complete::Shell::PowerShell,
947 }
948 }
949}
950
951impl Shell {
952 pub fn typed(self) -> &'static str {
954 match self {
955 Shell::Bash => "bash",
956 Shell::Zsh => "zsh",
957 Shell::Fish => "fish",
958 Shell::Powershell => "powershell",
959 }
960 }
961
962 pub const ALL: &'static [Shell] = &[Shell::Bash, Shell::Zsh, Shell::Fish, Shell::Powershell];
964}
965
966#[derive(Subcommand, Debug)]
968pub enum JsonWord {
969 Field {
970 #[arg(
971 value_name = "key",
972 help = "the path of steps to the member, outermost first. A step into an object \
973 is a key, and a step into an array is a decimal index counted from 0. \
974 `headwater json field tool_input file_path` reads the `file_path` member of \
975 the `tool_input` member, and `headwater json field related 0 target` reads \
976 the `target` member of the first element of `related`. Without one, the object is read and no member of it \
977 is named, which is refused"
978 )]
979 path: Vec<String>,
980 },
981 Count {
982 #[arg(
983 value_name = "key",
984 help = "the path of steps to the array or the object whose elements are counted, \
985 outermost first, where a step into an array is a decimal index. Without one, the object on standard input is the one counted"
986 )]
987 path: Vec<String>,
988 },
989 Quote,
990 #[command(external_subcommand)]
991 Other(Vec<String>),
992}
993
994#[derive(Subcommand, Debug)]
996pub enum SweepWord {
997 Plan {
998 #[arg(
999 long,
1000 value_name = "path",
1001 help = "the slice, as a path prefix under the repository root. The whole corpus by \
1002 default. There is no sampling rule here: a slice this engine picked would be \
1003 an unreproducible sample dressed as a reproducible one, and the plan reports \
1004 its own extent instead"
1005 )]
1006 under: Option<String>,
1007 },
1008 Report {
1009 #[arg(
1010 value_name = "path",
1011 help = "the file an agent wrote back. `headwater sweep plan` prints the shape of it"
1012 )]
1013 path: Option<String>,
1014 #[arg(
1015 long,
1016 value_name = "text|json",
1017 help = concat!(
1018 "`text` is the report a person reads and the default, and `json` is the finding \
1019 shape spec 4 declares with the provenance and the evidence a sweep adds. ",
1020 a_refusal_is_not_an_artifact!()
1021 )
1022 )]
1023 format: Option<String>,
1024 #[arg(long, conflicts_with = "format", help = JSON_BESIDE_FORMAT)]
1025 json: bool,
1026 },
1027 #[command(external_subcommand)]
1028 Other(Vec<String>),
1029}
1030
1031#[derive(Subcommand, Debug)]
1033pub enum ProbeWord {
1034 Plan {
1035 #[arg(
1036 long,
1037 value_name = "regression|campaign|documentation",
1038 help = "which tier of `.headwater/probe.yml` to plan against. A tier declares the \
1039 ceiling, the session cost, the repetitions, the arms and the ablation its \
1040 absent arm removes, and the plan is projected against them. A paired tier \
1041 refuses a probe whose predicate names a document its own ablation removes. \
1042 `regression` by default"
1043 )]
1044 tier: Option<String>,
1045 #[arg(
1046 long,
1047 value_name = "present|absent",
1048 help = "narrow the selection to one arm the tier declares. Every arm the tier \
1049 declares by default, which is one for `regression` and two for `campaign` and \
1050 `documentation`. \
1051 An arm the tier does not declare refuses the run rather than planning \
1052 another one, and the refusal names the arms the tier declares"
1053 )]
1054 arm: Option<String>,
1055 #[arg(
1056 long,
1057 value_name = "name",
1058 help = "narrow the selection to one probe category, by the name this engine declares \
1059 for it. Every category by default, a name outside the closed set is refused \
1060 with the set printed, and a category no probe of this corpus carries is \
1061 refused rather than planned as a run of nothing"
1062 )]
1063 category: Option<String>,
1064 #[arg(
1074 long,
1075 value_name = "n",
1076 default_value_t = 0,
1077 help = "the rotation seed, which is a member of the run identity spec 5 declares. It \
1078 is the caller's number: a run that states none states zero, and it is \
1079 recorded as stated. No selection is drawn from it — every declared probe is \
1080 selected — so it identifies a run rather than choosing one"
1081 )]
1082 seed: u64,
1083 #[arg(
1084 long,
1085 value_name = "probe",
1086 help = "leave one probe out of the selection, by its identifier. Repeat it for more \
1087 than one. The selection digest is taken after the exclusion, so it states \
1088 the run that happens, and an identifier the selection does not hold is \
1089 refused rather than ignored"
1090 )]
1091 exclude: Vec<String>,
1092 #[arg(
1093 long,
1094 value_name = "n",
1095 help = "run fewer repetitions than the tier declares, for a pilot of the same \
1096 selection and arms. It may only lower the count: a higher one is refused, \
1097 because spending more is a change to `.headwater/probe.yml` that a person \
1098 makes"
1099 )]
1100 repetitions: Option<u32>,
1101 },
1102 Record {
1103 #[arg(
1104 value_name = "path",
1105 help = "the transcript a recorder wrote. `headwater probe plan` prints the run \
1106 identity it has to carry"
1107 )]
1108 path: Option<String>,
1109 },
1110 Grade {
1111 #[arg(
1112 value_name = "path",
1113 help = "the transcript a recorder wrote. It is graded against the probes this corpus \
1114 declares, re-derived here rather than taken from the transcript"
1115 )]
1116 path: Option<String>,
1117 },
1118 Stale,
1119 #[command(external_subcommand)]
1120 Other(Vec<String>),
1121}
1122
1123#[derive(Subcommand, Debug)]
1125pub enum TaxonomyWord {
1126 Validate,
1127 Resolve {
1128 #[arg(
1129 long,
1130 help = "write nothing and exit non-zero when what is committed is not what a run \
1131 produces. It reads the taxonomy sources, so it answers whether the lock is \
1132 current"
1133 )]
1134 check: bool,
1135 },
1136 Audit {
1137 #[arg(
1138 long,
1139 value_name = "date",
1140 value_parser = a_date,
1141 help = "the date a staleness reading and a dwell reading are taken at, as \
1142 `YYYY-MM-DD`. Defaults to today, and two audits of one tree at one date write \
1143 the same bytes"
1144 )]
1145 now: Option<Date>,
1146 #[arg(
1147 long,
1148 help = "append this run's adoption reading to `.headwater/adoption.jsonl`. Without it \
1149 the verb writes nothing. A reading the store already holds at this lock and \
1150 this date is not appended twice, so two recorded audits of one tree at one \
1151 date still write the same bytes"
1152 )]
1153 record: bool,
1154 },
1155 Publish {
1156 #[arg(
1157 long,
1158 value_name = "name",
1159 help = "the package to publish. The one this repository's own declaration takes, by \
1160 default, because a publisher usually publishes what it also consumes. Refused \
1161 together with `--from`, which names the same thing by its directory instead"
1162 )]
1163 package: Option<String>,
1164 #[arg(
1165 long,
1166 value_name = "dir",
1167 help = "read the manifest at this directory directly, bypassing the lookup by name \
1168 under `.headwater/packages/` that `--package` drives. For a repository that both \
1169 publishes a package and consumes it: `taxonomy vendor` refuses to install over \
1170 a directory that carries no release record, so a maintained source cannot sit \
1171 where its own artifact would be installed. This reads it from wherever it \
1172 actually sits instead"
1173 )]
1174 from: Option<PathBuf>,
1175 #[arg(
1176 long,
1177 value_name = "name",
1178 help = "derive and publish this named assembly from the source package. It uses the \
1179 same source selection as `--package` or `--from`, and produces one flattened \
1180 package with no runtime bundle selection"
1181 )]
1182 assembly: Option<String>,
1183 #[arg(
1184 long,
1185 value_name = "dir",
1186 help = "where to write the artifact. The directory must be empty or absent, because a \
1187 published artifact is every file under its root and a stray one would be a \
1188 member the publisher never shipped. A run that cannot finish leaves it as it \
1189 found it, so a second run meets the same precondition the first one did"
1190 )]
1191 out: Option<PathBuf>,
1192 #[arg(
1193 long,
1194 help = "remove what a publish killed part-way left at `--out`, and publish in the \
1195 same run. It removes one state and nothing else: files at `--out` with no \
1196 release record, beside a `<out>~staging` directory holding both the files a \
1197 publish writes there to say it could not move the artifact into place and is \
1198 writing into `--out` one file at a time. Only a killed publish leaves those \
1199 two together, and the second one names the output path it was writing. A \
1200 directory holding anything else, and an `--out` that carries a release \
1201 record, are left exactly as they are and the publish refuses as it does \
1202 without this flag"
1203 )]
1204 clear_killed: bool,
1205 #[arg(
1206 long,
1207 help = "write nothing, and exit 1 when the vendored copy under `.headwater/packages/` \
1208 is not what a fresh publish of the source at `--from` produces. It publishes \
1209 into a private directory outside the tree, removes it, and names each member \
1210 that moved. For a repository that maintains a package and also consumes it, \
1211 so that a source changed without a republish fails a gate. Needs `--from`, \
1212 and is refused with `--out`, `--package`, `--assembly`, `--clear-killed` and \
1213 `--json`"
1214 )]
1215 check: bool,
1216 #[arg(long, help = JSON_ALONE)]
1217 json: bool,
1218 },
1219 Vendor {
1220 #[arg(
1221 value_name = "dir-or-location",
1222 help = "the directory of an artifact somebody already fetched, or the https:// \
1223 location of a published artifact zip, which this verb fetches through \
1224 `headwater-fetch`, a crate nothing in the checking loop links (HW-DR-0075)"
1225 )]
1226 path: Option<String>,
1227 #[arg(
1228 long,
1229 value_name = "digest",
1230 help = "the digest to check the artifact against. It defaults to `taxonomy.digest` in \
1231 `.headwater/taxonomy.yml`, and the verb refuses when neither is there. A pin \
1232 the engine took from the artifact in front of it would be a pin against itself"
1233 )]
1234 expect: Option<String>,
1235 },
1236 Diff {
1237 #[arg(
1238 value_name = "dir",
1239 help = "the directory of an artifact somebody already fetched. This verb opens no \
1240 socket, so it takes a path and never a location"
1241 )]
1242 path: Option<String>,
1243 #[arg(
1244 long,
1245 value_name = "version",
1246 help = "the version the artifact is expected to be, written as a version or as a \
1247 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs. This verb \
1248 fetches nothing, so the directory decides which artifact is compared and this \
1249 flag holds it to what the caller meant. It is read by the one range reader \
1250 the engine has, which is what reads `requires_engine`"
1251 )]
1252 to: Option<String>,
1253 #[arg(
1254 long,
1255 value_name = "date",
1256 value_parser = a_date,
1257 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1258 )]
1259 now: Option<Date>,
1260 },
1261 Migrate {
1262 #[arg(
1263 value_name = "dir",
1264 help = "the directory of an artifact somebody already fetched. This verb opens no \
1265 socket, so it takes a path and never a location"
1266 )]
1267 path: Option<String>,
1268 #[arg(
1269 long,
1270 value_name = "version",
1271 help = "the version the artifact is expected to be, written as a version or as a \
1272 range: `4.0.0`, or `>=4 <5` with the quoting your shell needs"
1273 )]
1274 to: Option<String>,
1275 #[arg(
1276 long,
1277 help = "write the files each step names. Without it every file each step would write \
1278 is reported and nothing is written"
1279 )]
1280 apply: bool,
1281 #[arg(
1282 long,
1283 value_name = "date",
1284 value_parser = a_date,
1285 help = "the date to evaluate against, as `YYYY-MM-DD`. Defaults to today"
1286 )]
1287 now: Option<Date>,
1288 },
1289 Graph {
1290 #[arg(
1291 long,
1292 value_enum,
1293 default_value_t,
1294 help = "which drawing to print. `concrete` draws the concrete kinds, the anchors and \
1295 the relations between them. `abstract` draws each abstract kind, the kinds \
1296 declared under it and the relations that name it, which `concrete` leaves out"
1297 )]
1298 view: crate::taxonomy_graph::View,
1299 #[arg(
1300 long,
1301 help = "add a key that draws each shape and each edge style the drawing uses, and \
1302 names the family each edge color stands for"
1303 )]
1304 legend: bool,
1305 },
1306 #[command(external_subcommand)]
1307 Other(Vec<String>),
1308}
1309
1310pub fn command() -> Command {
1322 command_at(paint::width())
1323}
1324
1325pub fn command_at(width: usize) -> Command {
1332 command_in(width, paint::stdout_color())
1333}
1334
1335pub fn command_in(width: usize, mode: paint::ColorMode) -> Command {
1344 let mut root = Cli::command()
1345 .about(format!(
1346 "{} — {}",
1347 headwater_verbs::BINARY,
1348 headwater_verbs::TAGLINE
1349 ))
1350 .color(paint::color_choice(mode))
1356 .styles(paint::help_styles(mode))
1357 .help_template(first_screen(width, mode));
1358 for verb in headwater_verbs::VERBS {
1359 if root.find_subcommand(verb.name).is_some() {
1370 root = root.mut_subcommand(verb.name, |one| described(one, verb, width, mode));
1371 }
1372 }
1373 paint::painted(root, width)
1374}
1375
1376pub fn parsed() -> Result<Cli, clap::Error> {
1384 let matches = command().try_get_matches()?;
1385 if let Some(message) = a_width_for_a_run_that_lays_nothing_out(&matches) {
1386 return Err(clap::Error::raw(
1387 clap::error::ErrorKind::ArgumentConflict,
1388 message,
1389 ));
1390 }
1391 Cli::from_arg_matches(&matches)
1392}
1393
1394fn a_width_for_a_run_that_lays_nothing_out(matches: &clap::ArgMatches) -> Option<String> {
1449 let mut leaf = matches;
1450 while let Some((_, inner)) = leaf.subcommand() {
1451 leaf = inner;
1452 }
1453 if leaf.try_get_one::<bool>("wide").ok().flatten() != Some(&true) {
1454 return None;
1455 }
1456 if matches.subcommand_name() == Some("help") {
1457 return None;
1458 }
1459 let format = leaf.try_get_one::<String>("format").ok().flatten();
1460 let json = leaf.try_get_one::<bool>("json").ok().flatten() == Some(&true);
1466 let laid_out = matches.subcommand_name() == Some("check")
1469 && !json
1470 && matches!(format.map(String::as_str), None | Some("text"));
1471 if laid_out {
1472 return None;
1473 }
1474 let says = match format.filter(|value| value.as_str() != "text") {
1475 Some(format) => format!("`--format {format}` writes an artifact that nothing lays out"),
1476 None if json => "`--json` writes an artifact that nothing lays out".to_string(),
1477 None => "this run lays nothing out".to_string(),
1478 };
1479 Some(format!(
1480 "`--wide` says how wide the help and the report of `{0} check` are laid out, and {says}. A \
1481 run carrying it would carry one flag that does nothing, so it is refused rather than run. \
1482 The runs it widens are `{0} --wide --help`, `{0} <verb> --wide --help`, `{0} --wide help \
1483 <verb>` and `{0} check --wide`",
1484 headwater_verbs::BINARY
1485 ))
1486}
1487
1488fn described(
1490 command: Command,
1491 verb: &headwater_verbs::Verb,
1492 width: usize,
1493 mode: paint::ColorMode,
1494) -> Command {
1495 let mut one = command.about(verb.description);
1496 if !verb.words.is_empty() {
1497 one = one.help_template(second_words(verb, width, mode));
1498 for word in verb.words {
1499 if one.find_subcommand(word.name).is_none() {
1500 continue;
1501 }
1502 one = one.mut_subcommand(word.name, |inner| inner.about(word.description));
1503 }
1504 }
1505 one
1506}
1507
1508const COLUMN: usize = 15;
1510
1511fn first_screen(width: usize, mode: paint::ColorMode) -> String {
1528 let mut out = format!(
1543 "{{usage-heading}} {{usage}}\n\n{}:\n",
1544 paint::paint(paint::Role::Heading, "Examples", mode)
1545 );
1546 for (line, says) in [
1547 (
1548 "headwater check --strict",
1549 "run the checks, and fail on an error",
1550 ),
1551 (
1552 "headwater route \"add rate limiting\"",
1553 "the documents that govern a task",
1554 ),
1555 (
1556 "headwater explain HW-DR-0033",
1557 "why a document is the kind it is",
1558 ),
1559 (
1560 "headwater new decision --title \"Adopt an overlay\"",
1561 "scaffold a document of a kind",
1562 ),
1563 (
1564 "headwater help taxonomy diff",
1565 "the long description of one verb",
1566 ),
1567 ] {
1568 out.push_str(&format!(" {line}\n"));
1573 out.push_str(&paint::fold_indented(says, width, 6));
1574 }
1575 for group in headwater_verbs::groups() {
1583 out.push_str(&format!(
1584 "\n{}:\n",
1585 paint::paint(paint::Role::Heading, group, mode)
1586 ));
1587 for verb in headwater_verbs::VERBS
1588 .iter()
1589 .filter(|one| one.group == group)
1590 {
1591 out.push_str(&paint::painted_row(
1592 verb.name,
1593 verb.summary,
1594 COLUMN,
1595 width,
1596 paint::Role::Verb,
1597 mode,
1598 ));
1599 }
1600 }
1601 let longest = GLOBALS
1618 .iter()
1619 .map(|one| one.name.chars().count())
1620 .max()
1621 .unwrap_or(0);
1622 let at = 2 + longest + 2;
1623 out.push_str(&format!(
1624 "\n{}:\n",
1625 paint::paint(paint::Role::Heading, "Global flags", mode)
1626 ));
1627 for one in GLOBALS {
1628 out.push_str(&paint::painted_row(
1629 one.name,
1630 one.summary,
1631 at,
1632 width,
1633 paint::Role::Path,
1634 mode,
1635 ));
1636 }
1637 out.push('\n');
1638 out.push_str(&paint::fold_indented(
1639 &format!(
1640 "Run `{0} help <verb>` for the long description of one verb, or `{0} <verb> --help`.",
1641 headwater_verbs::BINARY
1642 ),
1643 width,
1644 0,
1645 ));
1646 out
1647}
1648
1649fn second_words(verb: &headwater_verbs::Verb, width: usize, mode: paint::ColorMode) -> String {
1657 let mut out = String::from("{about}\n\n{usage-heading} {usage}\n\nSecond words:\n");
1658 for word in verb.words {
1659 out.push_str(&paint::painted_row(
1660 word.name,
1661 word.summary,
1662 COLUMN,
1663 width,
1664 paint::Role::Verb,
1665 mode,
1666 ));
1667 }
1668 out.push_str("\nFlags:\n{options}\n\n");
1669 out.push_str(&paint::fold_indented(
1670 &format!(
1671 "Run `{} help {} <word>` for the long description of one.",
1672 headwater_verbs::BINARY,
1673 verb.name
1674 ),
1675 width,
1676 0,
1677 ));
1678 out
1679}
1680
1681fn a_date(text: &str) -> Result<Date, String> {
1683 Date::parse(text).ok_or_else(|| "a date written `YYYY-MM-DD`".to_string())
1684}
1685
1686fn a_date_as_written(text: &str) -> Result<String, String> {
1691 a_date(text).map(|_| text.to_string())
1692}
1693
1694fn a_budget(text: &str) -> Result<usize, String> {
1696 match text.parse::<usize>() {
1697 Ok(value) if value > 0 => Ok(value),
1698 _ => Err("a whole number above zero".to_string()),
1699 }
1700}
1701
1702fn a_pair(text: &str) -> Result<(String, String), String> {
1708 match text.split_once('=') {
1709 Some((left, right)) if !left.is_empty() && !right.is_empty() => {
1710 Ok((left.to_string(), right.to_string()))
1711 }
1712 _ => Err(
1713 "`<left>=<right>`, as in `--relates supersedes=HW-DR-0007` or \
1714 `--facet probe_category=discovery`"
1715 .to_string(),
1716 ),
1717 }
1718}
1719
1720pub fn headline(error: &clap::Error) -> String {
1734 let rendered = error.render().to_string();
1735 let head: Vec<String> = rendered
1736 .lines()
1737 .take_while(|line| !line.starts_with("Usage:") && !line.starts_with("For more information"))
1738 .map(str::trim)
1739 .filter(|line| !line.is_empty())
1740 .map(|line| line.strip_prefix("error: ").unwrap_or(line).to_string())
1741 .collect();
1742 match head.is_empty() {
1743 true => rendered.split_whitespace().collect::<Vec<_>>().join(" "),
1744 false => head.join("\n"),
1745 }
1746}
1747
1748#[cfg(test)]
1749mod tests {
1750 use super::{a_budget, a_date, a_pair, command, headline, Cli};
1751 use clap::{CommandFactory, Parser};
1752
1753 #[test]
1754 fn the_declared_parse_is_a_command_clap_can_build() {
1755 Cli::command().debug_assert();
1756 command().debug_assert();
1757 }
1758
1759 #[test]
1760 fn a_value_a_flag_cannot_take_is_named_rather_than_defaulted() {
1761 assert!(a_date("2026-13-45").is_err());
1762 assert!(a_date("2026-01-01").is_ok());
1763 assert!(a_budget("0").is_err());
1764 assert!(a_budget("x").is_err());
1765 assert_eq!(a_budget("3"), Ok(3));
1766 assert!(a_pair("nope").is_err());
1767 assert!(a_pair("=right").is_err());
1768 assert!(a_pair("left=").is_err());
1769 assert_eq!(
1770 a_pair("supersedes=HW-DR-0007"),
1771 Ok(("supersedes".to_string(), "HW-DR-0007".to_string()))
1772 );
1773 }
1774
1775 #[test]
1782 fn a_refusal_carries_the_message_and_not_the_grammar() {
1783 let error = Cli::try_parse_from(["headwater", "check", "--nonsense"])
1784 .expect_err("`--nonsense` is not a flag `check` reads");
1785 let headline = headline(&error);
1786 assert!(
1787 headline.contains("--nonsense"),
1788 "the refusal names the offending word: {headline}"
1789 );
1790 assert!(
1791 !headline.contains("--root <path>"),
1792 "the refusal does not reprint the grammar: {headline}"
1793 );
1794 assert!(
1795 !headline.contains("Usage:"),
1796 "the refusal does not reprint the usage block: {headline}"
1797 );
1798 assert!(
1799 !headline.contains("For more information"),
1800 "the pointer is `fail`'s and is written once: {headline}"
1801 );
1802 }
1803
1804 #[test]
1809 fn a_flag_of_another_verb_does_not_reach_this_one() {
1810 assert!(Cli::try_parse_from(["headwater", "check", "--level", "L0"]).is_err());
1811 assert!(Cli::try_parse_from(["headwater", "conformance", "--level", "L0"]).is_ok());
1812 assert!(Cli::try_parse_from(["headwater", "sweep", "report", "f", "--strict"]).is_err());
1813 assert!(Cli::try_parse_from(["headwater", "check", "--strict"]).is_ok());
1814 }
1815
1816 #[test]
1820 fn the_corpus_flag_reaches_every_verb_from_either_side_of_it() {
1821 for arguments in [
1822 ["headwater", "check", "--root", "/tmp"],
1823 ["headwater", "--root", "/tmp", "check"],
1824 ] {
1825 let cli = Cli::try_parse_from(arguments).expect("`--root` is global");
1826 assert_eq!(cli.root.as_deref(), Some(std::path::Path::new("/tmp")));
1827 }
1828 }
1829}