headwater_cli/paint.rs
1// SPDX-License-Identifier: Apache-2.0
2//! How wide the help is, and who lays it out.
3//!
4//! # `clap` cannot wrap in this workspace, and `term_width` will not make it
5//!
6//! `StyledStr::wrap` is `pub(crate) fn wrap(&mut self, _hard_width: usize) {}`
7//! under `#[cfg(not(feature = "wrap_help"))]`, and this workspace takes `clap`
8//! with `derive` alone. So `Command::term_width` sets a number every renderer
9//! reads and nothing acts on, and every help string reached a caller on one
10//! line however long it was — 1,126 columns at the widest.
11//!
12//! Taking the `wrap_help` feature is the route the crate offers and it is the
13//! wrong one here. It pulls `terminal_size`, which measures the terminal the
14//! process is attached to, and a width that depends on the terminal makes a
15//! piped run and a run under a terminal write different bytes. Every recorded
16//! fixture and every test that reads this help would then be reading the
17//! terminal of whoever ran it.
18//!
19//! So the strings are folded here, before `clap` sees them, at a width this
20//! module decides. Nothing in the path reads a terminal: `dimensions()` is
21//! `(None, None)` without `wrap_help`, so `clap` asks no question about the
22//! stream it is writing to, and neither does this.
23//!
24//! # The one indent, and how it is known
25//!
26//! [`painted`] declares `next_line_help` on every command of the tree, which
27//! puts an argument's help on the line under the argument rather than in a
28//! column whose width is a function of the longest argument at that node. The
29//! indent is then `TAB` plus `NEXT_LINE_INDENT` — two spaces and eight — at
30//! every node, so [`INDENT`] is a constant rather than a computation, and one
31//! folded string is right wherever `clap` decides to print it.
32//!
33//! # What `clap` appends after a help string, and why folding has to know
34//!
35//! `HelpTemplate::help` writes the string this module folded and then appends
36//! the spec values — `[default: 0]`, `[possible values: …]`, `[aliases: …]` —
37//! on the same line after a space. A fold that did not account for them would
38//! be right about the text and wrong about the line. [`reserved`] measures what
39//! is coming and [`fold_at`] keeps the last word of the text and that suffix on
40//! one line together.
41//!
42//! # Color reads the terminal on purpose, and the masthead is why it must
43//!
44//! [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
45//! departs from the rule two sections up, deliberately: an escape sequence
46//! leaked into a pipe or a log file actively harms whoever reads it, where a
47//! column-wrap choice never did, and every fixture this corpus pins already
48//! runs headless. So [`color_of`] is a pure function in exactly the shape
49//! [`width_of`] already is — unit-testable with a table and no real terminal —
50//! and its one live caller, [`stdout_color`] or [`stderr_color`], reads a
51//! stream's own terminal state, which nothing above this line ever does.
52//!
53//! `--no-color`, `--no-banner` and their environment variables are read the
54//! way `--wide` already is: scanned raw, before `clap` builds the tree,
55//! because [`banner`] runs inside `first_screen`, which is built before
56//! parsing runs.
57
58use clap::{Arg, ArgAction, Command};
59
60/// The width and the fill, which live in `headwater_check::fill` and are named
61/// here so that a caller of this module keeps writing `paint::WIDTH`.
62///
63/// They moved down when the check report gained a layout: the report and the
64/// help are laid out by one implementation, and `headwater-check` is the crate
65/// every report composer can reach. Nothing is re-implemented here.
66pub use headwater_check::fill::{fold, fold_at, WIDEST, WIDTH};
67
68/// The column an argument's help starts at, at every node of the tree.
69///
70/// `clap`'s `TAB` is two spaces and its `NEXT_LINE_INDENT` is eight, and
71/// [`painted`] declares `next_line_help` everywhere so that the pair is the
72/// whole indent at every node.
73pub const INDENT: usize = 10;
74
75/// The width this process lays the help out at.
76///
77/// `COLUMNS` is read here and nowhere else in this binary, and only when the
78/// command line carries `--wide`. The scan is over the raw arguments because
79/// the answer is needed to build the tree that parses them: `clap` renders help
80/// inside the parse, out of strings that were folded before it started.
81pub fn width() -> usize {
82 // The variable is read inside the `true` arm rather than beside the scan,
83 // so that a run with no `--wide` on its command line makes no call at all.
84 // Two interface contracts say what reaches this binary out of the
85 // environment, and the honest sentence is shorter for it.
86 match std::env::args_os().any(|one| one == "--wide") {
87 false => WIDTH,
88 true => width_of(true, std::env::var("COLUMNS").ok().as_deref()),
89 }
90}
91
92/// The width a `--wide` and a `COLUMNS` reading come to.
93///
94/// Without `--wide` the answer is [`WIDTH`] and the reading is not consulted, so
95/// a run in a 40-column terminal and a run piped into a file write the same
96/// bytes. With it the reading is held to `[WIDTH, WIDEST]`: a narrower terminal
97/// gets 80 because the text was written to be read at 80, and a wider one gets
98/// 120 because a line of prose past that is harder to read rather than easier.
99/// A `COLUMNS` that is absent or is not a number is the same answer as no
100/// `--wide` at all.
101pub fn width_of(wide: bool, columns: Option<&str>) -> usize {
102 if !wide {
103 return WIDTH;
104 }
105 match columns.and_then(|text| text.trim().parse::<usize>().ok()) {
106 Some(number) => number.clamp(WIDTH, WIDEST),
107 None => WIDTH,
108 }
109}
110
111/// The tree, with every string folded and every node laid out the same way.
112///
113/// It walks the whole tree rather than the verbs, so a second word and an
114/// argument `clap` propagated are folded by the same code as a verb, and an
115/// argument added later is folded without anybody remembering to.
116pub fn painted(command: Command, width: usize) -> Command {
117 let mut one = command.next_line_help(true);
118 if let Some(about) = one.get_about().map(ToString::to_string) {
119 one = one.about(fold(&about, width));
120 }
121 one = one.mut_args(|arg| {
122 let Some(help) = arg.get_help().map(ToString::to_string) else {
123 return arg;
124 };
125 let room = width.saturating_sub(INDENT);
126 let tail = reserved(&arg);
127 let folded = fold_at(&help, room, tail);
128 arg.help(folded)
129 });
130 let names: Vec<String> = one
131 .get_subcommands()
132 .map(|inner| inner.get_name().to_string())
133 .collect();
134 for name in names {
135 one = one.mut_subcommand(name, |inner| painted(inner, width));
136 }
137 one
138}
139
140/// The same tree with every string put back on one line.
141///
142/// # What this undoes, and for whom
143///
144/// [`painted`] folds every `about` and every `help` in the tree, and a help
145/// screen is what that is for. A completion script is the other reader of the
146/// same tree and it wants the opposite: a shell shows a description in a
147/// listing it lays out itself, so a fold this engine chose arrives as a break
148/// at a width the shell did not pick. `main`'s `completions` runs this over the
149/// painted tree before handing it to `clap_complete`.
150///
151/// Folding and then flattening returns the source string, because [`fold_at`]
152/// breaks only at a space and rejoins words with one space. So the flag and
153/// subcommand descriptions do not move a byte, and only the positionals change
154/// — which is where the whole defect was.
155///
156/// # Why here rather than a tree built at no width
157///
158/// `command_at(usize::MAX)` folds nothing and produces the same bytes today,
159/// and it is the wrong answer. [`fold_at`]'s own contract is that "a newline in
160/// the source is a break the author asked for and survives", so that route
161/// leaves a completion script one authored newline in one help string away
162/// from the defect returning. This one removes the newline whatever put it
163/// there.
164///
165/// # Why the tree rather than the `zsh` writer
166///
167/// `clap_complete` flattens a flag and a subcommand description and does not
168/// flatten a positional one: its `zsh` generator escapes a positional by hand
169/// rather than through the `escape_help` its other two paths use, and that
170/// hand-written chain omits the newline. Its `bash`, `fish` and PowerShell
171/// generators write no positional description at all, so those three are clean
172/// for a reason this repository does not control. Flattening the tree is
173/// therefore the fix that survives a dependency bump, and
174/// `engine/crates/cli/tests/completions.rs` reads all four scripts for the same
175/// reason.
176pub fn flattened(command: Command) -> Command {
177 let mut one = command;
178 if let Some(about) = one.get_about().map(ToString::to_string) {
179 one = one.about(one_line(&about));
180 }
181 one = one.mut_args(|arg| {
182 let Some(help) = arg.get_help().map(ToString::to_string) else {
183 return arg;
184 };
185 arg.help(one_line(&help))
186 });
187 let names: Vec<String> = one
188 .get_subcommands()
189 .map(|inner| inner.get_name().to_string())
190 .collect();
191 for name in names {
192 one = one.mut_subcommand(name, flattened);
193 }
194 one
195}
196
197/// The words of a string, on one line, separated by one space each.
198///
199/// It splits on whitespace rather than replacing the newline, so a fold that
200/// left a space before its break cannot leave two spaces behind. Every help
201/// string of this binary is written as one logical line of single-spaced words,
202/// so over a folded string this returns the source exactly.
203fn one_line(text: &str) -> String {
204 text.split_whitespace().collect::<Vec<&str>>().join(" ")
205}
206
207/// The width of what `clap` appends after an argument's help, with its space.
208///
209/// It is the `spec_vals` of `HelpTemplate`, measured rather than rendered. The
210/// `env` feature is not compiled in, so the environment-variable clause of that
211/// function cannot occur here and is not measured. Every other clause is, and
212/// the whole surface is held to [`WIDTH`] by `tests/width.rs`, so a clause
213/// measured wrong is reported as a wide line rather than passing quietly.
214fn reserved(arg: &Arg) -> usize {
215 let mut parts: Vec<usize> = Vec::new();
216
217 // `clap` prints a default and a possible-value set for an argument that
218 // takes a value and for no other. A flag declared `SetTrue` carries
219 // `true`/`false` on its value parser and `false` as its default, and
220 // neither reaches a caller. `get_num_args` is `None` until
221 // `Command::build` and this runs before that, so the action is what
222 // answers here. An alias is printed for a flag as well and is not gated.
223 let takes_a_value = matches!(arg.get_action(), ArgAction::Set | ArgAction::Append);
224
225 let defaults = arg.get_default_values();
226 if takes_a_value && !defaults.is_empty() && !arg.is_hide_default_value_set() {
227 let written: Vec<String> = defaults
228 .iter()
229 .map(|value| value.to_string_lossy().into_owned())
230 .collect();
231 parts.push("[default: ]".chars().count() + written.join(" ").chars().count());
232 }
233
234 let mut aliases: Vec<usize> = Vec::new();
235 aliases.extend(
236 arg.get_visible_short_aliases()
237 .unwrap_or_default()
238 .iter()
239 .map(|_| 2),
240 );
241 aliases.extend(
242 arg.get_visible_aliases()
243 .unwrap_or_default()
244 .iter()
245 .map(|name| 2 + name.chars().count()),
246 );
247 if !aliases.is_empty() {
248 let plural = if aliases.len() == 1 { 0 } else { 2 };
249 let separators = 2 * (aliases.len() - 1);
250 parts.push(
251 "[alias: ]".chars().count() + plural + separators + aliases.iter().sum::<usize>(),
252 );
253 }
254
255 if takes_a_value && !arg.is_hide_possible_values_set() {
256 // `get_visible_quoted_name` is `clap`'s and is private, so its two
257 // rules are read off it here: a hidden value is not printed, and a name
258 // holding a space is printed in quotes.
259 let possible: Vec<usize> = arg
260 .get_possible_values()
261 .iter()
262 .filter(|value| !value.is_hide_set())
263 .map(|value| {
264 let name = value.get_name();
265 let quotes = usize::from(name.contains(char::is_whitespace)) * 2;
266 name.chars().count() + quotes
267 })
268 .collect();
269 if !possible.is_empty() {
270 let separators = 2 * (possible.len() - 1);
271 parts.push(
272 "[possible values: ]".chars().count() + separators + possible.iter().sum::<usize>(),
273 );
274 }
275 }
276
277 match parts.is_empty() {
278 true => 0,
279 // `spec_vals` joins its parts with one space, and one more space
280 // separates the whole of it from the help text before it.
281 false => parts.iter().sum::<usize>() + parts.len(),
282 }
283}
284
285/// A block of text folded to `width` and indented, with its closing newline.
286///
287/// The whole block is indented, first line included, which is what separates it
288/// from [`row`]: a row hangs under a name and this stands under a heading.
289pub fn fold_indented(text: &str, width: usize, at: usize) -> String {
290 let indent = " ".repeat(at);
291 let folded = fold(text, width.saturating_sub(at));
292 let body = folded.replace('\n', &format!("\n{indent}"));
293 format!("{indent}{body}\n")
294}
295
296/// The `clap` color choice a [`ColorMode`] means, stated rather than sensed.
297///
298/// # Why never `ColorChoice::Auto`
299///
300/// `Auto` hands the decision to `anstream`, which senses the stream itself and
301/// then reads `CLICOLOR_FORCE`. Both halves are wrong here.
302/// [`HW-DR-0045`](../../../../docs/decisions/0045-coloring-the-cli-and-where-the-banner-goes.md)
303/// rules that no run may force color into a pipe — there is no `--color=always`
304/// for the same reason — so an environment variable that turns escape bytes on
305/// in a redirected run is a promise this binary broke, and
306/// `tests/width.rs`'s `CLICOLOR_FORCE` case is the one that reports it.
307/// `anstream` also knows nothing of this binary's own `--no-color`, so `Auto`
308/// colors a help page the caller asked to be plain.
309///
310/// [`stdout_color`] has already read the flag, the environment and the stream,
311/// which is every input the decision has. This turns that one answer into
312/// `clap`'s vocabulary and adds no input of its own.
313#[must_use]
314pub fn color_choice(mode: ColorMode) -> clap::ColorChoice {
315 match mode {
316 ColorMode::Ansi => clap::ColorChoice::Always,
317 ColorMode::Plain => clap::ColorChoice::Never,
318 }
319}
320
321/// The palette `clap` paints a help page with, in the roles HW-DR-0045 names.
322///
323/// # Why the base is `Styles::plain()` rather than `clap`'s own default
324///
325/// `Styles::styled()` is bold and underline over nine roles, and HW-DR-0045
326/// rules on three: a section heading is default bold, a file path or a flag
327/// name is cyan, a verb name is green bold. Starting from plain and setting
328/// only what the decision names keeps the help screen on the same palette as
329/// `headwater check`, `headwater sweep report` and `headwater explain`, which
330/// is what the decision asks for in those words.
331///
332/// # The one judgment the palette does not settle
333///
334/// `clap` paints a flag name and a subcommand name with a single `literal`
335/// style, where HW-DR-0045 gives them cyan and green bold. Cyan wins here,
336/// because a verb page is mostly flags and a subcommand name appears on the
337/// root screen and the four `taxonomy`-shaped pages alone. Those verb names are
338/// painted green by hand in `first_screen`, where the layout is this crate's
339/// own, so the decision is kept on the surface that shows the most of them.
340///
341/// # Why `error`, `invalid` and `valid` stay plain
342///
343/// `clap` writes a parse refusal to standard error and renders it with these
344/// same styles, while the choice above is read off **standard output**. A run
345/// with a terminal on one stream and a pipe on the other would then color a
346/// refusal nobody asked to be colored. `err` in `main.rs` already colors every
347/// refusal this binary composes, off [`stderr_color`], which is the stream that
348/// answers for it.
349#[must_use]
350pub fn help_styles(mode: ColorMode) -> clap::builder::Styles {
351 use clap::builder::styling::{AnsiColor, Style};
352 let base = clap::builder::Styles::plain();
353 match mode {
354 ColorMode::Plain => base,
355 // `usage` is set alongside `header` because `{usage-heading}` renders
356 // the word `Usage:` through it, and that is a section heading beside
357 // `Options:` on the same page rather than a role of its own.
358 ColorMode::Ansi => base
359 .header(Style::new().bold())
360 .usage(Style::new().bold())
361 .literal(Style::new().fg_color(Some(AnsiColor::Cyan.into()))),
362 }
363}
364
365/// One row of a two-column list, folded so that no line passes `width`.
366///
367/// `at` is the column the second field starts at. A row whose text does not fit
368/// is continued under itself rather than under the name, which is what keeps a
369/// list of verbs readable when one summary is long.
370pub fn row(name: &str, text: &str, at: usize, width: usize) -> String {
371 let pad = at.saturating_sub(2 + name.chars().count()).max(1);
372 let folded = fold(text, width.saturating_sub(at));
373 let indent = " ".repeat(at);
374 let body = folded.replace('\n', &format!("\n{indent}"));
375 format!(" {name}{}{body}\n", " ".repeat(pad))
376}
377
378/// The same row with the name painted, and the layout decided before it is.
379///
380/// [`row`] runs first over the plain name, so the pad and the fold are computed
381/// in characters a reader sees, and the escape sequence is substituted into the
382/// finished line afterwards. That is the order `Route::render` takes and the
383/// order `a_folded_pointer_breaks_where_an_unpainted_one_does` holds it to:
384/// paint before fold and every break moves by the width of an escape sequence
385/// nothing prints.
386///
387/// The substitution is anchored rather than searched. [`row`] opens the line
388/// with two spaces and the name, so replacing that prefix once cannot reach a
389/// second occurrence of the name inside the summary that follows it.
390pub fn painted_row(
391 name: &str,
392 text: &str,
393 at: usize,
394 width: usize,
395 role: Role,
396 mode: ColorMode,
397) -> String {
398 let plain = row(name, text, at, width);
399 match mode {
400 ColorMode::Plain => plain,
401 ColorMode::Ansi => plain.replacen(
402 &format!(" {name}"),
403 &format!(" {}", paint(role, name, mode)),
404 1,
405 ),
406 }
407}
408
409/// [`ColorMode`], [`Role`], [`color_of`], [`paint`] and [`dim`] moved to
410/// `headwater_check::paint`, and are re-exported here unchanged.
411///
412/// `Finding::render`, `Run::render`, `explain`'s renderer and the two sweep
413/// renderers all need them and none of the three crates that declare those
414/// functions may depend on `headwater-cli`, so the types live where every
415/// caller can reach them — see the module comment of
416/// `engine/crates/check/src/paint.rs` for the full reasoning. What stays here
417/// is [`stdout_color`] and [`stderr_color`]: the one fact only this crate
418/// holds, which stream this process is attached to.
419pub use headwater_check::paint::{color_of, dim, glyph, paint, severity_role, severity_word};
420pub use headwater_check::paint::{ColorMode, Role};
421
422/// Whether `--no-color` is on the raw command line, scanned the way
423/// [`width`] scans for `--wide`.
424fn no_color_flag() -> bool {
425 std::env::args_os().any(|one| one == "--no-color")
426}
427
428/// `NO_COLOR`'s convention: any value at all, including an empty one, turns
429/// color off. `tests/width.rs` asserts this over `NO_COLOR=1`, `NO_COLOR=` and
430/// `NO_COLOR=0` alike.
431fn no_color_env() -> bool {
432 std::env::var_os("NO_COLOR").is_some()
433}
434
435/// The mode standard output renders in, for this run of the binary.
436#[must_use]
437pub fn stdout_color() -> ColorMode {
438 color_of(
439 no_color_flag(),
440 no_color_env(),
441 std::io::IsTerminal::is_terminal(&std::io::stdout()),
442 )
443}
444
445/// The mode standard error renders in, for this run of the binary.
446#[must_use]
447pub fn stderr_color() -> ColorMode {
448 color_of(
449 no_color_flag(),
450 no_color_env(),
451 std::io::IsTerminal::is_terminal(&std::io::stderr()),
452 )
453}
454
455/// Whether `--no-banner` or `HEADWATER_NO_BANNER` suppress the masthead,
456/// scanned the way [`no_color_flag`] and `NO_COLOR` are.
457#[must_use]
458pub fn banner_suppressed() -> bool {
459 std::env::args_os().any(|one| one == "--no-banner")
460 || std::env::var_os("HEADWATER_NO_BANNER").is_some()
461}
462
463/// Whether the raw command line asks for the root help screen: `-h` or
464/// `--help` present, and no token that names a verb.
465///
466/// Read the way [`no_color_flag`] is, before `clap` decides anything, because
467/// the masthead is printed by plain I/O ahead of `clap`'s own help writer
468/// rather than inside the template it renders — see `first_screen`'s doc
469/// comment for why a template cannot carry it. A `--root <path>` whose value
470/// happens to equal a verb's name is the one case this reads wrong, and it
471/// costs a missing masthead rather than a wrong screen: `clap` still resolves
472/// the command line the same way regardless of what this function returns.
473#[must_use]
474pub fn wants_root_help() -> bool {
475 let mut has_help = false;
476 let mut has_verb = false;
477 for one in std::env::args_os().skip(1) {
478 if one == "-h" || one == "--help" {
479 has_help = true;
480 }
481 if one
482 .to_str()
483 .is_some_and(|text| headwater_verbs::VERBS.iter().any(|verb| verb.name == text))
484 {
485 has_verb = true;
486 }
487 }
488 has_help && !has_verb
489}
490
491/// The masthead `HW-DR-0045` rules on, or today's plain name line where
492/// [`banner_suppressed`] holds.
493///
494/// `version` is `headwater_resolve::release::ENGINE`, the same value
495/// `--version` prints, so a caller never reads two numbers for one binary.
496/// The blank line closing the string is the one `first_screen` used to open
497/// with, folded in here so the root screen keeps the same shape either way.
498#[must_use]
499pub fn banner(version: &str, mode: ColorMode) -> String {
500 let tagline = "a documentation corpus, governed and checked like code";
501 if banner_suppressed() {
502 return format!("headwater — {tagline}\n\n");
503 }
504 let name = paint(Role::Verb, &format!("headwater {version}"), mode);
505 let rule = dim(&"─".repeat(WIDTH), mode);
506 format!("{name} — {}\n{rule}\n\n", dim(tagline, mode))
507}
508
509#[cfg(test)]
510mod tests {
511 use super::{
512 banner, color_of, fold, fold_at, fold_indented, row, width_of, ColorMode, INDENT, WIDEST,
513 WIDTH,
514 };
515
516 #[test]
517 fn color_is_plain_off_a_terminal_and_ansi_on_one_unless_overridden() {
518 assert_eq!(color_of(false, false, false), ColorMode::Plain);
519 assert_eq!(color_of(false, false, true), ColorMode::Ansi);
520 assert_eq!(
521 color_of(true, false, true),
522 ColorMode::Plain,
523 "--no-color wins"
524 );
525 assert_eq!(
526 color_of(false, true, true),
527 ColorMode::Plain,
528 "NO_COLOR wins"
529 );
530 assert_eq!(color_of(true, true, false), ColorMode::Plain);
531 }
532
533 #[test]
534 fn the_masthead_names_the_version_once_above_a_rule_of_the_help_width() {
535 let text = banner("9.9.9", ColorMode::Plain);
536 let mut lines = text.lines();
537 assert_eq!(
538 lines.next(),
539 Some("headwater 9.9.9 — a documentation corpus, governed and checked like code")
540 );
541 let rule = lines.next().expect("a rule line follows");
542 assert_eq!(rule.chars().count(), WIDTH);
543 assert!(rule.chars().all(|c| c == '─'));
544 }
545
546 #[test]
547 fn nothing_reads_columns_until_a_caller_asks_for_it() {
548 assert_eq!(width_of(false, Some("500")), WIDTH);
549 assert_eq!(width_of(false, Some("40")), WIDTH);
550 assert_eq!(width_of(false, None), WIDTH);
551 }
552
553 #[test]
554 fn a_width_a_caller_asks_for_is_held_to_the_band() {
555 assert_eq!(width_of(true, Some("40")), WIDTH);
556 assert_eq!(width_of(true, Some("100")), 100);
557 assert_eq!(width_of(true, Some("500")), WIDEST);
558 assert_eq!(width_of(true, Some("80")), WIDTH);
559 assert_eq!(width_of(true, Some("120")), WIDEST);
560 }
561
562 /// A reading that is not a number is the width every other run takes.
563 #[test]
564 fn a_columns_that_is_not_a_number_is_the_default_width() {
565 assert_eq!(width_of(true, None), WIDTH);
566 assert_eq!(width_of(true, Some("")), WIDTH);
567 assert_eq!(width_of(true, Some("wide")), WIDTH);
568 assert_eq!(width_of(true, Some("-1")), WIDTH);
569 }
570
571 /// The fill this module re-exports is the one in `headwater-check`.
572 ///
573 /// Its own cases live beside it, in `crates/check/src/fill.rs`. This one
574 /// holds the re-export: a second implementation appearing here would pass
575 /// every case there and lay the help out differently.
576 #[test]
577 fn the_fold_this_module_names_is_the_one_the_check_layer_owns() {
578 let text = "see docs/spec/06-engine-architecture.md#the-command-line for it";
579 assert_eq!(fold(text, 20), headwater_check::fill::fold(text, 20));
580 assert_eq!(
581 fold_at(text, 20, 6),
582 headwater_check::fill::fold_at(text, 20, 6)
583 );
584 assert_eq!(WIDTH, headwater_check::fill::WIDTH);
585 assert_eq!(WIDEST, headwater_check::fill::WIDEST);
586 }
587
588 #[test]
589 fn a_row_that_does_not_fit_is_continued_under_itself() {
590 let written = row(
591 "check",
592 "run the pipeline over the corpus, against the lock",
593 15,
594 40,
595 );
596 let lines: Vec<&str> = written.trim_end().lines().collect();
597 assert_eq!(lines[0], " check run the pipeline over the");
598 for line in &lines[1..] {
599 assert!(line.starts_with(&" ".repeat(15)), "{line:?}");
600 }
601 for line in &lines {
602 assert!(line.chars().count() <= 40, "{line:?}");
603 }
604 }
605
606 #[test]
607 fn an_indented_block_holds_every_line_inside_the_width() {
608 let written = fold_indented("run the checks, and fail on an error", 20, 6);
609 assert!(written.ends_with('\n'));
610 for line in written.lines() {
611 assert!(line.starts_with(" "), "{line:?}");
612 assert!(line.chars().count() <= 20, "{line:?}");
613 }
614 }
615
616 /// The indent is `clap`'s two-space `TAB` and its eight-space next-line
617 /// indent, and the whole point of declaring `next_line_help` is that it is
618 /// the same number at every node.
619 #[test]
620 fn the_indent_is_the_pair_clap_writes() {
621 assert_eq!(INDENT, " ".len() + " ".len());
622 }
623}