shep 0.18.1

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
//! The one gate over a dog's settings: what a field accepts, in the
//! operator's words, and whether a whole table is made of values the dog's
//! schema takes.
//!
//! This module is outside `lookout` on purpose. The check used to live in
//! the pane that ran it, and `shep set` needs the same check: a value read
//! from a terminal has to be refused for the same reason lookout refuses
//! it, or the two disagree in front of the operator. Both now call here.
//!
//! Two doors, one set of rules. [`refusal`] answers one typed buffer against
//! one field, which is what a keystroke in a pane needs. [`table::table_refusal`]
//! answers a whole proposed table against the schema that describes it,
//! which is what a write that carries a table needs. [`touched_refusal`] is
//! the same walk over only the keys a caller chose, which is what a pane that
//! writes a table it only partly chose needs. Each arm of each door
//! reads the same bounds, the same grammars and the same sentences, so a
//! value cannot pass one and fail the other.
//!
//! Two sources for what a field accepts, and the per-field one wins outright
//! rather than merging: a field writing its own `accepts` gets those and
//! none from the table. One rule beats merge semantics nobody can predict.
//! The table itself is [`forms`], and the per-field lists are backed one
//! crate down, where shep-core hands `normalize` a value for every refusal
//! it makes, and names the enforcer for the few decided at spawn.
//!
//! [`refusal`] is built here rather than in the pane so that the sentence an
//! operator reads and the check that stops the write come from one table. A
//! dog's section is written by the shepherd without being read, so there is
//! no second gate behind this one. [`item_refusal`] is the same gate for one
//! element of a list field, against the bounds its item schema published.
//!
//! The arms of the gate live one level down, each with the tests for what it
//! checks: [`numbers`] for the whole-number and decimal parse and bounds,
//! [`items`] for one element of a list field, [`patterns`] for a dog's own
//! grammar, [`table`] for a value already shaped as JSON. What is here is the
//! door itself, the sentence, and the rule about what a sentence may print.

#[cfg(test)]
mod fixtures;
mod forms;
mod items;
mod numbers;
mod patterns;
pub(crate) mod table;
#[cfg(test)]
mod table_tests;
pub(crate) mod validate;
#[cfg(test)]
pub(crate) mod validate_tests;

pub use forms::bullets;
pub use items::item_refusal;
pub(crate) use table::touched_refusal;
pub(crate) use validate::{AskFailure, ask_dog, dog_write};

use shep_core::values::{MemSize, UpDuration};

use serde_json::Value;

use crate::lookout::field::{Bounds, Field, FieldKind, ValueKind};
use forms::{DURATION_FORMS, Form, MEMORY_FORMS};
use numbers::{integer_refusal, number_refusal};
use patterns::pattern_refusal;

/// What a refusal prints in place of a secret's value.
///
/// The same marker the pane already draws for a secret it holds, so an
/// operator reads one word for one idea rather than learning a second.
pub const REDACTED: &str = "<set>";

/// What may be printed for `typed`: [`REDACTED`] when `secret`, the value
/// itself otherwise.
///
/// Every sentence this module builds resolves its value here, so a refusal
/// added later cannot echo a secret by forgetting to ask. The rule is one
/// line and the reason it is a function is that it has three callers.
///
/// `pub(crate)`: `ConfigPane::apply_typing` builds one refusal of its own
/// through [`not_a_whole_number`], and [`table`] answers a value that is
/// already JSON, so both have to resolve what may be printed the same way.
#[must_use]
pub(crate) fn shown_value(secret: bool, typed: &str) -> &str {
    if secret { REDACTED } else { typed }
}

/// Why a typed buffer was not filed, in the operator's words.
///
/// One sentence, ready for the status bar: it names the key, quotes what
/// was typed, and says what the field does take. The house shape every
/// other refusal in the pane follows.
///
/// `Debug` is derived (IR-41), which is safe only because [`refusal`]
/// redacts before it builds one: a secret's value never reaches `text`,
/// so there is nothing here for a hand-written impl to hide. Both halves
/// are pinned by exact-string tests below.
///
/// The value quoted for a field that is not secret is one the operator
/// just typed, and the row beside it is already drawing the same thing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refusal {
    /// The sentence to print.
    pub text: String,
}

/// Whether `field` takes `typed`, and why not when it does not.
///
/// Five checks, and a field reaches at most one of them:
///
/// - A [`FieldKind::Integer`] buffer must parse as an `i64` and sit inside
///   whatever `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum`
///   the schema published, the two strict spellings folded into the pair at the
///   next integer in.
/// - A [`FieldKind::Number`] buffer must parse as a finite `f64` and sit inside
///   whatever `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum`
///   the schema published, each read as the schema wrote it.
/// - A string field naming one of shep-core's own grammars goes through
///   that type's `FromStr`, which is the enforcer itself rather than a
///   second opinion about it.
/// - Any other string field with a `pattern` is matched against it. This
///   is the only door a third-party dog's own grammar has, since shep has
///   no parser for a type it has never heard of.
/// - A numeric field whose `enum` this pane could not turn into a cycle
///   widget, which is what a set of numbers is, is held to that set by
///   [`enum_refusal`] once its value parsed. A set of strings is
///   [`FieldKind::Choice`] instead, chosen from rather than typed, so it
///   never reaches this door at all.
///
/// [`None`] for everything else, which is most fields: a plain string with
/// no pattern takes any string, and shep is not the authority on a dog's
/// config beyond what that dog published. A list field's element is checked
/// by [`item_refusal`] rather than here, against its item schema.
///
/// Fails open on a `pattern` that does not compile. A dog that ships a
/// broken regex has a bug in its schema, and refusing every value for that
/// field would lock an operator out of a setting on the strength of
/// somebody else's typo.
///
/// The empty buffer never reaches here: it means "unset", which is
/// `ConfigPane::apply_typing`'s own case and not a value to check.
#[must_use]
pub fn refusal(field: &Field, typed: &str) -> Option<Refusal> {
    if typed.is_empty() {
        return None;
    }
    // Every path below quotes `typed` back at the operator, and
    // `App::on_pane_text_key` paints the sentence in the status bar. For a
    // secret that is the one place in this pane the value would appear:
    // `ConfigPane::begin_typing` seeds a secret's editor empty and the row
    // renders `<set>`, both so the value is never drawn. A schema decides
    // this, not shep, so it is reachable rather than theoretical: a dog
    // publishing `pattern` or a `$ref` grammar on a field it also marks
    // `x-shep-secret` sends a credential straight through `pattern_refusal`.
    let shown = shown_value(field.secret, typed);
    if field.kind == FieldKind::Integer {
        return integer_refusal(&field.key, &field.bounds, typed, shown);
    }
    if field.kind == FieldKind::Number {
        return number_refusal(&field.key, &field.bounds, typed, shown);
    }
    match field.value_kind {
        Some(ValueKind::UpDuration) => typed
            .parse::<UpDuration>()
            .is_err()
            .then(|| refuse(&field.key, shown, "a duration", DURATION_FORMS)),
        Some(ValueKind::MemSize) => typed
            .parse::<MemSize>()
            .is_err()
            .then(|| refuse(&field.key, shown, "a size", MEMORY_FORMS)),
        None => pattern_refusal(
            &field.key,
            field.bounds.pattern.as_deref(),
            &field.accepts,
            typed,
            shown,
        ),
    }
}

/// The refusal for a [`FieldKind::Integer`] buffer that is not a number.
///
/// Not built from [`forms::INTEGER_FORMS`] through [`refuse`], which would append
/// "try a whole number" to a sentence that has just said "is not a whole
/// number". The table's own text still draws the panel, where "a whole
/// number" is the whole answer and reads correctly.
///
/// `pub(crate)`: `ConfigPane::apply_typing`'s own integer arm and [`table`]
/// answer with this, so the one sentence has one spelling.
///
/// `shown` is what may be printed: [`REDACTED`] rather than the value when
/// the field is a credential. Callers pass what [`refusal`] resolved.
pub(crate) fn not_a_whole_number(key: &str, shown: &str) -> Refusal {
    Refusal {
        text: format!("{key} is \"{shown}\", which is not a whole number shep accepts"),
    }
}

/// The refusal for a [`FieldKind::Number`] buffer that is not a finite number.
///
/// Not built from [`forms::NUMBER_FORMS`] through [`refuse`], for
/// [`not_a_whole_number`]'s reason: the table's text is "a number", so
/// `refuse` would append "try a number" to a sentence that has just said "is
/// not a number". The table's own text still draws the panel, where "a number"
/// is the whole answer and reads correctly.
///
/// `pub(crate)`: `ConfigPane::apply_typing`'s own number arm and [`table`]
/// answer with this, so the one sentence has one spelling.
///
/// `shown` is what may be printed: [`REDACTED`] rather than the value when the
/// field is a credential. Callers pass what [`refusal`] resolved.
pub(crate) fn not_a_number(key: &str, shown: &str) -> Refusal {
    Refusal {
        text: format!("{key} is \"{shown}\", which is not a number shep accepts"),
    }
}

/// The refusal for a field whose own numeric range admits nothing.
///
/// Said once, ahead of the parse, because it is true of every value: a field
/// whose schema asks for a range with nothing in it cannot be answered, and
/// refusing `1` for being under the floor and `3` for being over the ceiling
/// bounces an operator between two sentences that cannot both be met. Both
/// number arms answer with this one sentence, so an integer's empty range and a
/// decimal's read the same to the operator.
///
/// [`numbers`] builds it twice, for the pair
/// [`crate::lookout::field::Bounds`] normalises for an integer and the pair it
/// keeps as written for a decimal.
fn takes_no_value(key: &str) -> Refusal {
    Refusal {
        text: format!("{key} takes no value: its schema asks for a range with nothing in it"),
    }
}

/// The refusal for an arm of a tagged union that holds no value for a field
/// the arm's own `required` list names.
///
/// `field` and `tag` are those two keys as the caller's door names them: bare
/// for a pane's rows (`path`, `kind`), dotted to the union for the table walk
/// (`retro.path`, `retro.kind`). `arm` is the tag the table named.
///
/// `pub(crate)`: [`table`] answers a proposed table with it and
/// `ConfigPane::incomplete_union` answers the pane's filed edits with it, so
/// the two doors say one sentence for one rule.
pub(crate) fn required_field(field: &str, tag: &str, arm: &str) -> Refusal {
    Refusal {
        text: format!("{field} is required when {tag} is {arm}; give it a value"),
    }
}

/// The refusal for a value a closed set does not name, where the set is the
/// one the field's own kind holds rather than the one `bounds.choices` holds.
///
/// Not [`enum_refusal`]: a closed set can come from a `oneOf` of `const`s,
/// where `bounds.choices` holds nothing and [`enum_refusal`] fails open on it.
/// [`table`]'s scalar arm and [`items`]'s choice-item arm answer with this one
/// so a set is enforced whichever way the schema spelled it.
///
/// The refusal lists the set because it is the one rule an operator can
/// satisfy by copying from it.
///
/// `shown` is what may be printed, which [`shown_value`] has already resolved.
///
/// `pub(crate)`: [`table`] and [`items`] build their closed-set refusals with
/// it, and neither can reach [`enum_refusal`], which is private to this module.
pub(crate) fn takes_one_of(key: &str, shown: &str, names: &[String]) -> Refusal {
    Refusal {
        text: format!(
            "{key} is \"{shown}\"; {key} takes one of {}",
            names.join(", ")
        ),
    }
}

/// The refusal for a value a schema's `enum` does not list, [`None`] when the
/// schema named no `enum` or `value` is one of the values it named.
///
/// The rule is shared because two doors meet it: [`items`] checks one element
/// of a list against its item schema's `enum`, and [`numbers`] checks a scalar
/// integer or decimal against the `enum` its own schema published. A set of
/// strings on a scalar is the case that is not here, because [`FieldKind::Choice`]
/// draws it as a cycle widget and nothing is typed to check.
///
/// The refusal lists the allowed set because an `enum` is the one rule an
/// operator can satisfy by copying from it.
///
/// `value` is what the buffer parsed to, not the buffer: an `enum` of numbers is
/// the numbers, and comparing a typed `"1"` against a published `1` is a
/// comparison of strings.
///
/// [`items`] and [`numbers`] each build their `enum` arm with it, from the set
/// `bounds.choices` holds. A closed set spelled as a `oneOf` of `const`s lands
/// in the kind instead, and [`table`] and [`items`] answer for it through
/// [`takes_one_of`].
fn enum_refusal(key: &str, bounds: &Bounds, value: &Value, shown: &str) -> Option<Refusal> {
    if bounds.choices.is_empty()
        || bounds
            .choices
            .iter()
            .any(|choice| choice_taken(choice, value))
    {
        return None;
    }
    let allowed: Vec<String> = bounds.choices.iter().map(choice_text).collect();
    Some(Refusal {
        text: format!(
            "{key} is \"{shown}\"; {key} takes one of {}",
            allowed.join(", ")
        ),
    })
}

/// Whether `value` is the `enum` member `choice` names.
///
/// Numbers compare by value, not by the spelling the schema used:
/// `serde_json` holds `1` and `1.0` as two different numbers, and JSON Schema
/// means one of them. Comparing the two `Value`s refused a `1` a dog published
/// as `enum: [1.0]`. Two integers are compared as `i64`, so a set of them is not
/// collapsed through an `f64` that cannot name every `i64` near its ends.
fn choice_taken(choice: &Value, value: &Value) -> bool {
    match (choice.as_i64(), value.as_i64()) {
        (Some(written), Some(typed)) => written == typed,
        _ => match (choice.as_f64(), value.as_f64()) {
            (Some(written), Some(typed)) => written == typed,
            _ => choice == value,
        },
    }
}

/// One `enum` member as the operator reads it: bare for a string, compact JSON
/// for anything else.
fn choice_text(choice: &Value) -> String {
    match choice {
        Value::String(text) => text.clone(),
        other => other.to_string(),
    }
}

/// One refusal sentence, with what the field takes read off the same table
/// the explanation panel prints. Sharing the table is the point: a form
/// added to one is added to the other, and neither can name a spelling the
/// parser has stopped taking.
///
/// `shown` is what may be printed, which [`shown_value`] has already
/// resolved. Naming the parameter for the raw buffer invited a caller to
/// pass one.
fn refuse(key: &str, shown: &str, what: &str, forms: &[Form]) -> Refusal {
    let takes: Vec<&str> = forms.iter().map(|form| form.text).collect();
    Refusal {
        text: format!(
            "{key} is \"{shown}\", which is not {what} shep accepts; try {}",
            takes.join(", ")
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::fixtures::text_field;
    use super::forms::{DURATION_FORMS, DURATION_REFUSALS, MEMORY_FORMS};
    use super::*;
    use shep_core::values::UpDuration;
    /// The one rule both refusal doors share. `ConfigPane::apply_typing`
    /// builds its own sentence for an integer it cannot convert, so the
    /// redaction cannot live inside [`refusal`] alone.
    #[test]
    fn the_shown_value_of_a_secret_is_the_marker_and_never_the_buffer() {
        let mut field = text_field("token");
        assert_eq!(
            shown_value(field.secret, "sk_live_HUNTER2"),
            "sk_live_HUNTER2"
        );
        field.secret = true;
        assert_eq!(shown_value(field.secret, "sk_live_HUNTER2"), REDACTED);
        assert!(
            !not_a_whole_number(&field.key, shown_value(field.secret, "sk_live_HUNTER2"))
                .text
                .contains("HUNTER2")
        );
    }

    /// Driven through the same schema shape a real dog publishes:
    /// `shep-log-rotate` spells `max_age` as `Option<UpDuration>`, so the
    /// grammar sits two hops from the property, behind an `anyOf` and a
    /// `$ref`.
    ///
    /// `1d` is the natural spelling of a retention window and shep's
    /// grammar tops out at hours (`docs/specs/deferred.md`), so the panel
    /// must not offer it and the gate must refuse it.
    #[test]
    fn a_dogs_nullable_duration_refuses_a_day() {
        let field = duration_field();
        assert_eq!(
            field.value_kind,
            Some(ValueKind::UpDuration),
            "the $ref must resolve through the anyOf or this test proves nothing"
        );
        assert!(
            "1d".parse::<UpDuration>().is_err(),
            "1d must be outside the grammar or there is nothing to refuse"
        );
        let refused = refusal(&field, "1d").expect("1d is not a duration shep parses");
        assert_eq!(
            refused.text,
            "max_age is \"1d\", which is not a duration shep accepts; \
             try 500ms, 2s, 5m, 1h, a bare number is milliseconds"
        );
    }

    /// The passing case, which is what tells this gate apart from one that
    /// refuses everything.
    #[test]
    fn the_spellings_the_panel_offers_are_the_spellings_it_takes() {
        let field = duration_field();
        for accepted in DURATION_FORMS.iter().flat_map(|form| form.examples) {
            assert_eq!(
                refusal(&field, accepted),
                None,
                "{accepted} is printed as accepted and must be filed"
            );
        }
        for refused in DURATION_REFUSALS.iter().flat_map(|form| form.examples) {
            assert!(
                refusal(&field, refused).is_some(),
                "{refused} is printed as refused and must not be filed"
            );
        }
    }

    /// The size half, which had no test of its own here: it was reached
    /// only through `pane/edit.rs`'s `max_memory` case, so a change to
    /// that test's field would have taken this arm's coverage with it
    /// without failing anything.
    ///
    /// `10MB` is the spelling to refuse rather than a nonsense word.
    /// shep's grammar is `10M`, so `10MB` is what somebody writes who
    /// knows what they mean, and the sentence has to be the one that tells
    /// them the unit is a single letter.
    #[test]
    fn a_size_field_refuses_a_spelling_shep_does_not_take() {
        let mut field = text_field("max_size");
        field.value_kind = Some(ValueKind::MemSize);
        assert_eq!(
            refusal(&field, "10MB")
                .expect("10MB is not shep's spelling")
                .text,
            "max_size is \"10MB\", which is not a size shep accepts; \
             try 512M, 2G, a bare number is bytes"
        );
        for accepted in MEMORY_FORMS.iter().flat_map(|form| form.examples) {
            assert_eq!(
                refusal(&field, accepted),
                None,
                "{accepted} is printed as accepted and must be filed"
            );
        }
    }

    /// A secret's value must not reach the status bar, and both doors a
    /// schema opens onto this gate have to be shut: a `pattern`, and a
    /// `$ref` grammar. `ConfigPane::begin_typing` seeds a secret's editor
    /// empty and its row draws `<set>`, so a refusal echoing the buffer
    /// would be the one place in the pane a credential appears.
    ///
    /// Exact strings, both for the sentence and for the `Debug`, which is
    /// what IR-41 asks for on anything that can carry one.
    #[test]
    fn a_secrets_value_never_reaches_the_sentence_or_the_debug() {
        const CREDENTIAL: &str = "sk_live_HUNTER2";

        let mut pattern_door = text_field("token");
        pattern_door.secret = true;
        pattern_door.bounds.pattern = Some("^sk-".to_owned());
        let refused = refusal(&pattern_door, CREDENTIAL).expect("the pattern refuses it");
        assert_eq!(
            refused.text,
            "token is \"<set>\", which it does not take; try it matches ^sk-"
        );
        assert_eq!(
            format!("{refused:?}"),
            "Refusal { text: \"token is \\\"<set>\\\", which it does not take; try it matches ^sk-\" }"
        );

        let mut grammar_door = text_field("api_key");
        grammar_door.secret = true;
        grammar_door.value_kind = Some(ValueKind::UpDuration);
        let refused = refusal(&grammar_door, CREDENTIAL).expect("not a duration");
        assert!(!refused.text.contains(CREDENTIAL), "{}", refused.text);
        assert!(refused.text.contains("<set>"), "{}", refused.text);

        let mut integer_door = text_field("pin");
        integer_door.secret = true;
        integer_door.kind = FieldKind::Integer;
        let refused = refusal(&integer_door, CREDENTIAL).expect("not a number");
        assert_eq!(
            refused.text,
            "pin is \"<set>\", which is not a whole number shep accepts"
        );

        let mut bounded = text_field("pin");
        bounded.secret = true;
        bounded.kind = FieldKind::Integer;
        bounded.bounds.minimum = Some(1000);
        let refused = refusal(&bounded, "7").expect("below the floor");
        assert_eq!(refused.text, "pin is \"<set>\"; pin starts at 1000");

        // A decimal is the third door a schema opens onto this gate, and it
        // quotes the buffer the same way the other two do.
        let mut decimal = text_field("rate");
        decimal.secret = true;
        decimal.kind = FieldKind::Number;
        decimal.bounds.number_exclusive_minimum = Some(bound(0));
        let refused = refusal(&decimal, CREDENTIAL).expect("not a number");
        assert_eq!(
            refused.text,
            "rate is \"<set>\", which is not a number shep accepts"
        );
        let refused = refusal(&decimal, "0").expect("below the floor");
        assert_eq!(refused.text, "rate is \"<set>\"; rate must be above 0");
    }

    /// The control: a field that is not a credential still quotes what was
    /// typed, since redacting everything would pass the test above while
    /// making every refusal useless.
    #[test]
    fn an_ordinary_field_still_quotes_what_was_typed() {
        let mut field = text_field("region");
        field.bounds.pattern = Some("^eu-".to_owned());
        assert!(
            refusal(&field, "Europe")
                .expect("refused")
                .text
                .contains("Europe"),
        );
    }

    /// An empty buffer is `ConfigPane::apply_typing`'s unset case, not a
    /// value, and this gate must not take it over.
    #[test]
    fn an_empty_buffer_is_not_a_refusal() {
        assert_eq!(refusal(&duration_field(), ""), None);
    }

    /// Most fields are a plain string their schema says nothing about, and
    /// this gate is not shep's chance to invent a grammar for a dog's
    /// config. Nothing is refused there.
    #[test]
    fn a_field_with_no_grammar_and_no_pattern_refuses_nothing() {
        assert_eq!(refusal(&text_field("cwd"), "/srv/web"), None);
        assert_eq!(refusal(&text_field("cwd"), "!!! anything"), None);
    }

    /// A [`ValueKind::UpDuration`] field built the way a dog's schema
    /// spells an optional one: `anyOf: [{$ref}, {type: null}]`, with the
    /// grammar in `$defs`.
    fn duration_field() -> Field {
        let schema = serde_json::json!({
            "$defs": {
                "UpDuration": { "type": "string", "pattern": r"^\d+(ms|h|m|s)?$" },
            },
            "properties": {
                "max_age": {
                    "anyOf": [{ "$ref": "#/$defs/UpDuration" }, { "type": "null" }],
                    "description": "Also rotate this long after the last rotation.",
                },
            },
        });
        crate::lookout::pane::ConfigPane::dog("log-rotate".to_owned(), None, schema, String::new())
            .fields()
            .by_key("max_age")
            .expect("the schema declares max_age")
            .clone()
    }

    /// A bound written the way [`crate::lookout::field::Bounds`] keeps one.
    fn bound(value: i64) -> serde_json::Number {
        serde_json::json!(value)
            .as_number()
            .expect("a number")
            .clone()
    }
}