shep 0.22.0

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
use super::super::super::app::{App, Control, Link};
use super::super::super::keymap::{Binding, ENTRY_ROWS, Group, KEY_CELL, TEXT_CELL, rows_over};
use super::super::super::theme::Palette;
use super::super::flock::fit;
use super::super::status;
use ratatui::text::{Line, Span};

/// Cells inside the border.
///
/// ```text
/// interior 126 = 4 columns x 30 + 3 gutters x 2
/// column    30 = 12 key (KEY_CELL) + 1 gap (GAP) + 17 text (TEXT_CELL)
/// box      128 = 126 interior + 1 border cell each side
/// floor    130 = 128 + 1 margin cell each side  (overlay::floor_for)
/// ```
///
/// Written out because a frame pinned one column short of its own column
/// set drops the thing it exists to show, which happened once already in
/// this bundle. `the_columns_sum_to_the_interior` asserts every line of
/// the block above.
pub(in crate::lookout::view) const INTERIOR: u16 = 126;

pub(super) const COLUMN_COUNT: u16 = 4;

const GAP: u16 = 1;

pub(super) const GUTTER: u16 = 2;

pub(super) const COLUMN: u16 = KEY_CELL + GAP + TEXT_CELL;

/// The entry row the sheep's first line draws at (zero-indexed): "entry
/// rows 5 through 8" in the design's own numbering.
const SHEEP_FIRST_ROW: usize = 4;

/// The one decoration in the whole TUI that carries no information, drawn
/// in the DOING column's own thirty cells at entry rows five through eight.
///
/// The design centres it in a 38-cell cell. DOING's column at 96..125 holds
/// it with room; CHANGING's (64..93) does not. Allowed exactly once, here.
pub(super) const SHEEP: [&str; 4] = [
    "  \u{259f}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599}",
    " \u{259f}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599}",
    " \u{259c}\u{2588}\u{2588}\u{2580} \u{2580}\u{2588}\u{2588}\u{259b}",
    "   \u{2580}\u{2598} \u{2580}\u{2598}",
];

/// [`GUTTER`] spaces, the separator between two adjacent cells whether the
/// cells are columns of the same bank or (nowhere yet) two banks side by
/// side.
///
/// `" ".repeat` rather than a `&'static str` of the right length: a literal
/// is right only while `GUTTER` is 2, and nothing would say so when it
/// changed. A lookout frame is drawn per keypress or two-second tick.
fn gutter_span() -> Span<'static> {
    Span::raw(" ".repeat(usize::from(GUTTER)))
}

/// `groups`' headings, each left-aligned in its own [`COLUMN`]-wide cell, in
/// reverse video over the group's own role, joined by [`GUTTER`]. Padded
/// through [`fit`] rather than concatenated raw, so the `REVERSED` modifier
/// paints the whole cell and not just the word.
///
/// Shared by the boxed heading row ([`heading_line`]) and each borderless
/// bank ([`draw_borderless`](crate::lookout::view::keymap::shed::draw_borderless)).
pub(super) fn heading_line_for(groups: &[Group], palette: Palette) -> Line<'static> {
    // `* 2` and not `* 2 - 1`: the exact count is one gutter short of this,
    // and subtracting it underflows to `usize::MAX` on an empty slice, which
    // panics in debug and aborts on the allocation in release. Every caller
    // passes a `chunks()` bank, which is never empty.
    let mut spans = Vec::with_capacity(groups.len() * 2);
    for (index, &group) in groups.iter().enumerate() {
        if index > 0 {
            spans.push(gutter_span());
        }
        spans.push(Span::styled(
            fit(group.heading(), COLUMN),
            palette.band(group.role()),
        ));
    }
    Line::from(spans)
}

/// The boxed form's heading row: [`heading_line_for`] over all four of
/// [`Group::DRAWN`].
fn heading_line(palette: Palette) -> Line<'static> {
    heading_line_for(&Group::DRAWN, palette)
}

/// One entry row across `groups`: one cell per group, joined by [`GUTTER`].
///
/// Shared the same way [`heading_line_for`] is: the boxed form's [`lines`]
/// calls it with all four of [`Group::DRAWN`] and `show_sheep: true`; every
/// borderless bank calls it with only its own groups and `show_sheep:
/// false`, since the sheep is boxed-only (see [`borderless_lines`](crate::lookout::view::keymap::shed::borderless_lines)'s own
/// comment on why).
pub(super) fn entry_line_for(
    groups: &[Group],
    all_rows: &[Binding],
    index: usize,
    palette: Palette,
    show_sheep: bool,
) -> Line<'static> {
    let mut spans = Vec::new();
    for (column, &group) in groups.iter().enumerate() {
        if column > 0 {
            spans.push(gutter_span());
        }
        let binding = all_rows
            .iter()
            .copied()
            .filter(|row| row.group == group)
            .nth(index);
        spans.extend(entry_cell(group, binding, index, palette, show_sheep));
    }
    Line::from(spans)
}

/// One [`COLUMN`]-wide cell: the group's own row at `index` if it has one,
/// styled `keys` in [`Palette::attention`] and `does` unstyled (the design's
/// own ink-2, "default fg"); the sheep at entry rows five through eight of
/// the DOING column once that group runs out and `show_sheep` says so; a
/// blank cell otherwise.
fn entry_cell(
    group: Group,
    binding: Option<Binding>,
    index: usize,
    palette: Palette,
    show_sheep: bool,
) -> Vec<Span<'static>> {
    if let Some(binding) = binding {
        return vec![
            Span::styled(fit(binding.keys, KEY_CELL), palette.attention()),
            // Unstyled: the design's colour table gives ordinary cell text as
            // ink-2, "default fg", and there is no `Palette` method for it
            // because unstyled is how it renders. The interior's paper-2
            // ground comes from `overlay::draw_boxed`, one per row.
            Span::raw(format!(
                "{}{}",
                " ".repeat(usize::from(GAP)),
                fit(binding.does, TEXT_CELL)
            )),
        ];
    }
    if show_sheep
        && group == Group::Doing
        && let Some(row) = index
            .checked_sub(SHEEP_FIRST_ROW)
            .and_then(|row| SHEEP.get(row))
    {
        return vec![Span::raw(fit(row, COLUMN))];
    }
    vec![Span::raw(" ".repeat(usize::from(COLUMN)))]
}

/// What the three destructive keys cost, and whether they can act at all.
///
/// One full-width line rather than beside the keys: the sentence plus the
/// control label is 57 characters against a 17-cell text field. `Link::Lost`
/// outranks the control gate, since `x`/`R`/`L` then refuse for a reason
/// `Control` does not carry.
pub(super) fn gate_line(app: &App, palette: Palette, width: u16) -> Line<'static> {
    Line::from(Span::styled(
        fit(&gate_text(app), width),
        palette.attention(),
    ))
}

/// [`gate_line`]'s own sentence, unfit: split out so
/// [`folded_gate_and_quit_line`] can combine it with the quit caption
/// before either is padded to a width.
fn gate_text(app: &App) -> String {
    if matches!(app.link(), Link::Lost { .. }) {
        " the three above are refused  \u{b7}  \u{2588} the link is down  \u{b7}  nothing acts"
            .to_string()
    } else {
        let label = match app.control() {
            Control::ReadOnly => status::READ_ONLY_LABEL,
            Control::Allowed => status::CONTROL_ENABLED_LABEL,
        };
        format!(
            " the three above each arm a confirm  \u{b7}  \u{21b5} confirms  \u{b7}  10s to \
             answer  \u{b7}  \u{2588} {label}"
        )
    }
}

/// The `NO_COLOR` disclosure. Its own function so [`Shed::Decoration`](crate::lookout::view::keymap::shed::Shed::Decoration) can
/// drop it without touching the quit line beside it.
const fn colour_sentence() -> &'static str {
    " colour is decoration only: every coloured cell says the same \
     thing in words. NO_COLOR loses nothing but the colour."
}

/// The quit line's unfit text: `h or ? closes this · <keys> quits lookout`,
/// with `<keys>` from [`Group::Closing`]'s own row rather than a literal.
///
/// # Panics
///
/// Never in practice: `keymap::binding` is exhaustive and only its
/// `KeyPress::Quit` arm returns a [`Group::Closing`] row.
#[track_caller]
fn quit_text(all_rows: &[Binding]) -> String {
    let quit = all_rows
        .iter()
        .find(|row| row.group == Group::Closing)
        .expect("binding() always gives Quit a Closing row");
    format!(" h or ?  closes this  \u{b7}  {}  quits lookout", quit.keys)
}

/// The keys that leave the overlay or lookout itself.
///
/// Its own function because [`Shed::Decoration`](crate::lookout::view::keymap::shed::Shed::Decoration) and [`Shed::Blank`](crate::lookout::view::keymap::shed::Shed::Blank) keep
/// it while dropping the `NO_COLOR` disclosure beside it.
/// [`Shed::Gate`](crate::lookout::view::keymap::shed::Shed::Gate) folds the same text into
/// [`folded_gate_and_quit_line`] when it has slack and drops it at
/// [`HEIGHT_FLOOR`](crate::lookout::view::keymap::shed::HEIGHT_FLOOR).
pub(super) fn quit_line(all_rows: &[Binding], palette: Palette, width: u16) -> Line<'static> {
    Line::from(Span::styled(
        fit(&quit_text(all_rows), width),
        palette.muted(),
    ))
}

/// The two lines that close the box: the `NO_COLOR` disclosure, and
/// [`quit_line`].
pub(super) fn closing_lines(
    all_rows: &[Binding],
    palette: Palette,
    width: u16,
) -> [Line<'static>; 2] {
    [
        Line::from(Span::styled(fit(colour_sentence(), width), palette.muted())),
        quit_line(all_rows, palette, width),
    ]
}

/// [`Shed::Gate`](crate::lookout::view::keymap::shed::Shed::Gate)'s own line, one row under [`Shed::Blank`](crate::lookout::view::keymap::shed::Shed::Blank)'s two:
/// [`gate_text`] and [`quit_text`] combined into a single sentence, styled
/// [`Palette::attention`] since the gate's own warning is still the reason
/// the line exists — the quit caption rides along rather than taking over.
pub(super) fn folded_gate_and_quit_line(
    app: &App,
    all_rows: &[Binding],
    palette: Palette,
    width: u16,
) -> Line<'static> {
    let text = format!(
        "{}  \u{b7}  {}",
        gate_text(app).trim(),
        quit_text(all_rows).trim()
    );
    Line::from(Span::styled(fit(&text, width), palette.attention()))
}

/// The overlay's rows, in order: the heading, [`ENTRY_ROWS`] entry rows, a
/// blank, the gate line, and the two closing lines.
#[must_use]
pub(in crate::lookout::view) fn lines(app: &App, interior: u16) -> Vec<Line<'static>> {
    let palette = app.palette();
    let all_rows = rows_over(app.over_a_tree());
    let mut out = Vec::with_capacity(ENTRY_ROWS + 5);
    out.push(heading_line(palette));
    out.extend(
        (0..ENTRY_ROWS).map(|index| entry_line_for(&Group::DRAWN, &all_rows, index, palette, true)),
    );
    out.push(Line::default());
    out.push(gate_line(app, palette, interior));
    out.extend(closing_lines(&all_rows, palette, interior));
    out
}

#[cfg(test)]
mod tests {

    use super::super::super::super::keymap::{Group, KEY_CELL, TEXT_CELL};

    use super::super::super::overlay;

    use super::*;

    use super::super::testing::*;
    use crate::lookout::app::{KeyPress, Msg};
    use crate::lookout::view::fixtures;
    use crate::output::width::visible_width;

    /// The arithmetic, asserted rather than commented.
    ///
    ///   interior 126 = 4 columns x 30 + 3 gutters x 2
    ///   column    30 = 12 key + 1 gap + 17 text
    ///   box      128 = 126 interior + 1 border each side
    ///   floor    130 = 128 + 1 margin each side
    #[test]
    fn the_columns_sum_to_the_interior() {
        // Literals. The form this avoids is
        // `assert_eq!(COLUMN, KEY_CELL + GAP + TEXT_CELL)`, which cannot
        // fail: `COLUMN` is defined as that sum, so all three constants can
        // be wrong together. Pinning each against a literal fails instead.
        assert_eq!((KEY_CELL, GAP, TEXT_CELL), (12, 1, 17));
        assert_eq!(
            COLUMN * COLUMN_COUNT + GUTTER * (COLUMN_COUNT - 1),
            INTERIOR
        );
        assert_eq!(COLUMN, 30);
        assert_eq!(INTERIOR, 126);
    }

    /// Every row of the box is exactly the interior wide, so no column
    /// drifts and the right border lands in one place on every row.
    #[test]
    fn every_row_is_the_interior_wide() {
        let app = app_with_overlay();
        for line in lines(&app, INTERIOR) {
            let text: String = line
                .spans
                .iter()
                .map(|span| span.content.as_ref())
                .collect();
            // Exactly the interior, not merely under it: the lone exception
            // is the blank separator row, an empty `Line` that relies on
            // `draw_boxed`'s per-row `blank_row` call to pad it. A width
            // merely `<=` the interior would pass with a gutter missing.
            assert!(
                text.is_empty() || visible_width(&text) == usize::from(INTERIOR),
                "a row is {} cells against {INTERIOR}: {text:?}",
                visible_width(&text)
            );
        }
    }

    /// Nineteen rows: a border pair, a heading, twelve entries, a blank,
    /// the gate line and two closing lines.
    #[test]
    fn the_box_is_nineteen_rows() {
        let app = app_with_overlay();
        assert_eq!(overlay::boxed_height(&lines(&app, INTERIOR)), 19);
    }

    /// All four headings draw, each at its own column start. Counting the
    /// starts rather than looking for the words is what catches a column
    /// that drew at the wrong offset.
    #[test]
    fn the_four_headings_sit_at_their_column_starts() {
        // `Group::DRAWN`'s literal order, pinned: the layout loop below
        // derives its expected offsets from `Group::DRAWN` itself, so it
        // stays green under a swap. The design specifies MOVING, LOOKING,
        // CHANGING, DOING left to right.
        assert_eq!(
            Group::DRAWN,
            [Group::Moving, Group::Looking, Group::Changing, Group::Doing]
        );

        let app = app_with_overlay();
        let rendered = render_overlay(&app, 160, 48);
        let heading_row = rendered
            .lines()
            .find(|row| row.contains(Group::Moving.heading()))
            .expect("no heading row");
        // Char-indexed, not byte-indexed: the border glyph at column 16
        // (`▐`, `overlay::BOX_LEFT`) is three bytes, so a byte slice at the
        // column's own numeric offset lands inside it.
        let chars: Vec<char> = heading_row.chars().collect();
        const MARGIN: usize = 16;
        assert_eq!(usize::from(160 - (INTERIOR + 2)) / 2, MARGIN);
        for (index, group) in Group::DRAWN.iter().enumerate() {
            let offset = usize::from(COLUMN + GUTTER) * index;
            // +1 for the box's own left border cell, and the box starts at
            // (160 - 128) / 2 = 16, which `MARGIN` above pins rather than
            // derives: a literal fails on a margin bug instead of quietly
            // measuring the wrong column.
            let start = MARGIN + 1 + offset;
            let from_start: String = chars[start..].iter().collect();
            assert!(
                from_start.starts_with(group.heading()),
                "{} is not at column {start}: {heading_row:?}",
                group.heading()
            );
        }
    }

    /// The sheep, and only here. The design allows it exactly once.
    #[test]
    fn the_sheep_draws_in_the_doing_column() {
        let app = app_with_overlay();
        let rendered = render_overlay(&app, 160, 48);
        assert!(rendered.contains(SHEEP[0]), "no sheep: {rendered}");
        // The rightmost column starts at 16 + 1 + 3 * (30 + 2) = 113.
        let row = rendered
            .lines()
            .find(|row| row.contains(SHEEP[0]))
            .expect("no sheep row");
        // `str::find` answers in BYTES and 113 is a character column, so the
        // byte index is inflated by every multi-byte glyph before it. That
        // made the old assertion a lower bound rather than a measurement.
        let column = row
            .find(SHEEP[0])
            .map(|at| row[..at].chars().count())
            .expect("no sheep on the row that contains it");
        // A pair, not a lower bound. `>= 113` alone cannot see a sheep drawn
        // too far right, and DOING is the last column, so nothing else would
        // catch it. Shifting `overlay::draw_boxed`'s own margin by 31 puts
        // the sheep at column 144 and fails here.
        let doing = 113..113 + usize::from(COLUMN);
        assert!(
            doing.contains(&column),
            "the sheep starts at column {column}, outside DOING's {doing:?}: {row:?}"
        );

        // Its ROW as well as its column. `SHEEP_FIRST_ROW` is a named layout
        // constant and nothing measured it, so a sheep shifted up or down
        // inside DOING passed everything. Counted from the heading row, so
        // the box's own vertical centring is not part of the claim.
        const UNDER_THE_HEADING: usize = 5;
        assert_eq!(1 + SHEEP_FIRST_ROW, UNDER_THE_HEADING);

        let rows: Vec<&str> = rendered.lines().collect();
        let heading_at = rows
            .iter()
            .position(|row| row.contains(Group::Moving.heading()))
            .expect("no heading row");
        let sheep_at = rows
            .iter()
            .position(|row| row.contains(SHEEP[0]))
            .expect("no sheep row");
        assert_eq!(
            sheep_at - heading_at,
            UNDER_THE_HEADING,
            "the sheep is {} rows under the heading, not {UNDER_THE_HEADING}",
            sheep_at - heading_at
        );
    }

    /// Three columns at 100, with DOING on a bank of its own below.
    #[test]
    fn three_columns_put_doing_on_its_own_bank() {
        let rendered = render_overlay(&app_with_overlay(), 100, 48);
        let heading_rows: Vec<&str> = rendered
            .lines()
            .filter(|row| Group::DRAWN.iter().any(|g| row.contains(g.heading())))
            .collect();
        assert_eq!(heading_rows.len(), 2, "{heading_rows:?}");
        // Every group, asserted by bank rather than a sample of two: at 100
        // columns the first bank carries three, so naming only MOVING and
        // CHANGING let a regression drop LOOKING and still pass.
        for group in [Group::Moving, Group::Looking, Group::Changing] {
            assert!(
                heading_rows[0].contains(group.heading()),
                "{} missing from the first bank: {:?}",
                group.heading(),
                heading_rows[0]
            );
        }
        assert!(heading_rows[1].contains(Group::Doing.heading()));
        assert!(
            !heading_rows[1].contains(Group::Moving.heading()),
            "the second bank should hold DOING alone: {:?}",
            heading_rows[1]
        );
    }

    /// Opened over a dog's tree of tables, `←/→` closes and opens a
    /// table; anywhere else it keeps the tab caption.
    #[test]
    fn the_arrows_close_and_open_a_table_over_a_dog_schema_pane() {
        let mut app = fixtures::app_in_dog_pane_with_a_nested_table();
        let _ = app.update(Msg::Key(KeyPress::Help));
        assert!(app.keymap_open(), "the overlay did not open");
        let over_a_dog = render_overlay(&app, 160, 48);
        assert!(over_a_dog.contains("close/open table"), "{over_a_dog}");
        assert!(!over_a_dog.contains("environment tab"), "{over_a_dog}");

        let plain = render_overlay(&app_with_overlay(), 160, 48);
        assert!(plain.contains("environment tab"), "{plain}");
        assert!(!plain.contains("close/open table"), "{plain}");
    }
}