shep 0.18.2

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
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
//! The read-only config and env column: the left of the two columns under
//! the charts, and the rule that closes it.
//!
//! Reads [`SheepPane::config`](crate::lookout::pane_sheep::SheepPane), never
//! the dashboard's selection, so a pane pinned to a sheep the flock table
//! has reseated away from still draws the sheep it was opened on. Shows a
//! value the way `shep edit` already shows it, with no cursor and no lock
//! glyph: those belong to the editing pane this column never opens.

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use serde_json::{Map, Value};
use shep_core::protocol::SheepConfigView;

use crate::lookout::field::{Field, FieldSet};
use crate::lookout::pane;
use crate::lookout::pane_sheep::SheepPane;
use crate::lookout::theme::Palette;

use super::super::flock::fit;
use super::layout::{
    COLUMN_BODY_ROWS, COLUMN_HEADER_ROW, COLUMN_NAME_W, COLUMN_WIDTH, DIVIDER_COL,
};

/// Where the config/env column and the feed start, in rows relative to
/// `area`: [`COLUMN_HEADER_ROW`] whenever `height` still reaches it, or
/// right below the identity band once it drops under
/// [`MIN_HEIGHT_FOR_CHARTS`](super::layout::MIN_HEIGHT_FOR_CHARTS) and the
/// charts stop drawing at all. The config
/// and feed columns are what the pane is for, so they give ground last,
/// reclaiming the rows the charts would have used rather than staying
/// pinned to a row a short terminal can never reach.
pub(super) fn column_top_row(height: u16) -> u16 {
    if height > COLUMN_HEADER_ROW {
        COLUMN_HEADER_ROW
    } else {
        1
    }
}

/// The nine Flockfile groups' fields, plus the env keys, as the column
/// scrolls through them: one entry per rendered row, and every group label
/// this pass actually emitted, in the order it emitted them.
///
/// Both halves are read off the same walk, so a group header the field
/// loop below skips or misorders shows up wrong in both places at once
/// rather than only in the one a test happens to check.
fn column_body(view: &SheepConfigView, palette: Palette) -> (Vec<Line<'static>>, Vec<String>) {
    let (fields, values) = pane::sheep_fields(&view.config);
    let mut lines = Vec::new();
    let mut groups = Vec::new();
    let mut current: Option<&str> = None;
    for field in fields.fields() {
        // `env` is its own section below, with its own keys; a second row
        // here would repeat it under a `Map` field's own placeholder text.
        if field.key == "env" {
            continue;
        }
        if field.group.as_deref() != current {
            current = field.group.as_deref();
            let label = current.unwrap_or_default().to_owned();
            push_group_header(&mut lines, &label, palette);
            groups.push(label);
        }
        let pending = view.pending.iter().any(|key| key == &field.key);
        let overridden = view.overridden.iter().any(|key| key == &field.key);
        lines.push(field_row_line(
            &fields, field, &values, pending, overridden, palette,
        ));
    }
    push_group_header(&mut lines, "env", palette);
    for key in &view.env_keys {
        let sealed = view.env_secrets.iter().any(|secret| secret == key);
        lines.push(env_row_line(key, sealed, palette));
    }
    (lines, groups)
}

/// A blank separator, a rule the column's own width, then `label`: the
/// chrome [`column_body`] spends above every group and above the env
/// section it closes with.
fn push_group_header(lines: &mut Vec<Line<'static>>, label: &str, palette: Palette) {
    lines.push(Line::default());
    lines.push(Line::from(Span::styled(
        "\u{2500}".repeat(usize::from(COLUMN_WIDTH)),
        palette.muted(),
    )));
    lines.push(Line::from(Span::styled(label.to_owned(), palette.muted())));
}

/// [`column_body`]'s lines alone, for [`draw_column`] and [`column_len`],
/// neither of which needs the group labels back.
fn column_body_lines(view: &SheepConfigView, palette: Palette) -> Vec<Line<'static>> {
    column_body(view, palette).0
}

/// The one line the column draws while [`SheepPane::config`] is still
/// `None`: read once by [`draw_column`] and by
/// [`SheepPane::body_len`](crate::lookout::pane_sheep::SheepPane), so
/// scrolling can never claim a body one line longer than what is actually
/// drawn.
fn waiting_line(palette: Palette) -> Line<'static> {
    Line::from(Span::styled("reading config\u{2026}", palette.muted()))
}

/// How many lines [`draw_column`] would need to show every one of
/// `config`'s groups and env keys, or the single waiting line while there
/// is none yet. What [`SheepPane`]'s own `Viewport` scrolls through.
///
/// Colour never changes a line count, so [`Palette::detect`] is called
/// with nothing set rather than threading the real palette through from a
/// key handler that has no `Frame` to read one off.
#[must_use]
pub(crate) fn column_len(config: Option<&SheepConfigView>) -> usize {
    match config {
        Some(view) => column_body_lines(view, Palette::detect(None, None, None)).len(),
        None => 1,
    }
}

/// One field's row: the name, `!`-flagged and butter when
/// [`SheepConfigView::pending`] names it, `*`-flagged when
/// [`SheepConfigView::overridden`] names it instead (pending wins when both
/// apply, `view::pane::field_row::field_line`'s own rule, since the value on
/// screen
/// is not what the running child holds), the value, `(unset)`/`(default)`
/// muted like the name, anything else in the column's own body colour, and
/// `awaits respawn` right-aligned when pending.
///
/// Read-only: no lock glyph, no cost cell. Those belong to the editing
/// pane this column never opens; a value here is what `shep edit` already
/// shows, laid out for a screen with no cursor to carry.
fn field_row_line(
    fields: &FieldSet,
    field: &Field,
    values: &Map<String, Value>,
    pending: bool,
    overridden: bool,
    palette: Palette,
) -> Line<'static> {
    let raw = field_value_text(fields, field, values);
    let flag = if pending {
        "!"
    } else if overridden {
        "*"
    } else {
        ""
    };
    let note = if pending { "awaits respawn" } else { "" };
    let value_w = usize::from(COLUMN_WIDTH).saturating_sub(usize::from(COLUMN_NAME_W) + 2);
    let left_w = value_w.saturating_sub(note.chars().count());
    let key_style = if pending {
        palette.attention()
    } else {
        palette.muted()
    };
    let value_style = if pending {
        palette.attention()
    } else if matches!(raw.as_str(), pane::UNSET | pane::DEFAULT) {
        palette.muted()
    } else {
        Style::default()
    };
    let mut spans = vec![
        Span::styled(
            fit(&format!("{flag}{}", field.key), COLUMN_NAME_W),
            key_style,
        ),
        Span::raw("  "),
        Span::styled(fit(&raw, left_w as u16), value_style),
    ];
    if !note.is_empty() {
        spans.push(Span::styled(note.to_owned(), palette.attention()));
    }
    Line::from(spans)
}

/// `field`'s current value, the way this column shows it: `<set>` for a
/// [`Field::secret`] one (never the value, the same guard
/// [`ConfigPane::field_line`](crate::lookout::pane::ConfigPane) draws its own
/// row through), [`pane::UNSET`] for a JSON `null` or a key the config never
/// carried, [`pane::DEFAULT`] when what is there is exactly the schema's own
/// default, else [`pane::resolved_display`]'s answer: a bare
/// `MemSize`/`UpDuration` number annotated with its unit, the same as
/// [`ConfigPane::display_value`](crate::lookout::pane::ConfigPane::display_value)
/// draws for the same field.
fn field_value_text(fields: &FieldSet, field: &Field, values: &Map<String, Value>) -> String {
    // Names only: a table's secrets are marked by a schema this column
    // never holds. No tables is still `(default)`, below.
    let dogs = (field.key == "dogs").then(|| values.get("dogs")).flatten();
    if dogs
        .and_then(Value::as_object)
        .is_some_and(|tables| !tables.is_empty())
    {
        return pane::dog_names(dogs);
    }
    let raw = match values.get(&field.key) {
        None | Some(Value::Null) => return pane::UNSET.to_owned(),
        Some(Value::String(text)) => text.clone(),
        Some(Value::Bool(flag)) => flag.to_string(),
        Some(Value::Number(number)) => number.to_string(),
        Some(other) => other.to_string(),
    };
    if field.secret {
        "<set>".to_owned()
    } else if field.default.as_deref() == Some(raw.as_str()) {
        pane::DEFAULT.to_owned()
    } else {
        pane::resolved_display(fields, &field.key, &raw)
    }
}

/// One env row: the key alone, or, when [`SheepConfigView::env_secrets`]
/// names it, a butter block run standing in for the value it never
/// carries, the word `sealed`, and `edit in S` right-aligned. No value is
/// ever read: the block run is a fixed run of glyphs, not sized to
/// anything the store holds.
fn env_row_line(key: &str, sealed: bool, palette: Palette) -> Line<'static> {
    if !sealed {
        return Line::from(Span::raw(key.to_owned()));
    }
    let note = "edit in S";
    let value_w = usize::from(COLUMN_WIDTH).saturating_sub(usize::from(COLUMN_NAME_W) + 2);
    let left_w = value_w.saturating_sub(note.chars().count());
    let mid = format!("{}  sealed", "\u{2588}".repeat(8));
    Line::from(vec![
        Span::styled(fit(key, COLUMN_NAME_W), palette.muted()),
        Span::raw("  "),
        Span::styled(fit(&mid, left_w as u16), palette.attention()),
        Span::styled(note.to_owned(), palette.muted()),
    ])
}

/// The header row: `e edit`, and how many fields
/// [`SheepConfigView::pending`] is still carrying, once there is a config
/// to read one off. No `tab/S-tab next / prev group`: no key routes one, the
/// same defect the status bar carried until Task 7 fixed it for that frame.
fn column_header_line(pane: &SheepPane, palette: Palette) -> Line<'static> {
    let mut text = "\u{2588}\u{2588} CONFIG & ENV   e edit".to_owned();
    if let Some(pending) = pane
        .config()
        .map(|view| view.pending.len())
        .filter(|count| *count > 0)
    {
        text.push_str(&format!("   {pending} pending"));
    }
    Line::from(Span::styled(text, palette.muted()))
}

/// `top` to [`COLUMN_BODY_ROWS`] rows below it: the header, then as much of
/// [`column_body`] as [`SheepPane::view`]'s own offset scrolls into.
///
/// `top` is [`column_top_row`]'s answer, not always [`COLUMN_HEADER_ROW`]:
/// a short terminal moves the whole column up rather than truncating it
/// from a fixed row that terminal can never reach.
///
/// Reads [`SheepPane::config`], never
/// [`App::selected`](crate::lookout::app::App::selected): the same rule
/// [`draw`](super::draw)'s own identity band and
/// [`draw_charts`](super::charts::draw_charts) already follow, for
/// the reason both of their own doc comments give.
pub(super) fn draw_column(
    pane: &SheepPane,
    top: u16,
    area: Rect,
    buffer: &mut Buffer,
    palette: Palette,
) {
    write_column_row(buffer, area, top, &column_header_line(pane, palette));
    let body = match pane.config() {
        Some(view) => column_body_lines(view, palette),
        None => vec![waiting_line(palette)],
    };
    let offset = pane.view().offset();
    for (i, line) in body.iter().skip(offset).take(COLUMN_BODY_ROWS).enumerate() {
        write_column_row(buffer, area, top + 1 + i as u16, line);
    }
}

/// Writes one already-styled line into `buffer`, `row` cells below `area`'s
/// own top, clipped to [`COLUMN_WIDTH`] rather than the pane's own width:
/// this column never spends the cells the feed sits after.
fn write_column_row(buffer: &mut Buffer, area: Rect, row: u16, line: &Line<'static>) {
    if row >= area.height {
        return;
    }
    buffer.set_line(area.x, area.y + row, line, COLUMN_WIDTH);
}

/// The `\u{2502}` rule between the config/env column and the feed, one cell
/// wide, for every row the two sides draw into, from `top` (the same row
/// [`draw_column`] and [`draw_feed`](super::feed::draw_feed) were handed)
/// through
/// [`COLUMN_BODY_ROWS`] below it.
pub(super) fn draw_divider(top: u16, area: Rect, buffer: &mut Buffer, palette: Palette) {
    let rule = Line::from(Span::styled("\u{2502}", palette.muted()));
    for row in top..=(top + COLUMN_BODY_ROWS as u16) {
        if row >= area.height {
            break;
        }
        buffer.set_line(area.x + DIVIDER_COL, area.y + row, &rule, 1);
    }
}

#[cfg(test)]
mod tests {
    use shep_core::config::AppConfig;
    use shep_core::protocol::{ProcessInfo, Response};
    use shep_core::status::ProcStatus;

    use crate::lookout::app::{Body, KeyPress, Msg, RowKey, Sent};
    use crate::lookout::frames::render_text;

    use super::super::super::fixtures;
    use super::super::draw;
    use super::super::layout::{COLUMN_LAST_ROW, FEED_WIDTH, FEED_X, MIN_HEIGHT_FOR_COLUMN};
    use super::*;

    /// `web`, with `max_memory` parked until a respawn and two env keys.
    fn web_view() -> SheepConfigView {
        let mut config = AppConfig {
            name: "web".to_owned(),
            ..AppConfig::default()
        };
        config
            .env
            .insert("DB_HOST".to_owned(), "db.internal".to_owned());
        config
            .env
            .insert("API_KEY".to_owned(), "{{secret:API}}".to_owned());
        SheepConfigView::new(config, Vec::new(), vec!["max_memory".to_owned()])
    }

    /// `web`, with `max_memory` overridden and nothing pending: the
    /// editing pane's own `*` glyph (`view/pane.rs`'s `field_line`), pinned
    /// on this column's side of Decision 4's "same markers" rule.
    fn web_view_overridden() -> SheepConfigView {
        let config = AppConfig {
            name: "web".to_owned(),
            ..AppConfig::default()
        };
        SheepConfigView::new(config, vec!["max_memory".to_owned()], Vec::new())
    }

    /// A bare config carrying exactly `pairs` as its env, nothing pending or
    /// overridden: what [`a_sealed_key_is_marked_and_a_plain_one_is_not`]
    /// needs to tell a plain key from a sealed one without `web_view`'s own
    /// pending field in the way.
    fn view_with_env(pairs: &[(&str, &str)]) -> SheepConfigView {
        let mut config = AppConfig {
            name: "test".to_owned(),
            ..AppConfig::default()
        };
        for (key, value) in pairs {
            config.env.insert((*key).to_owned(), (*value).to_owned());
        }
        SheepConfigView::new(config, Vec::new(), Vec::new())
    }

    /// One line as a plain string, styles dropped: the same flattening
    /// `view::pane`'s own `text_of` does, one line at a time.
    fn line_text(line: &Line<'static>) -> String {
        line.spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect()
    }

    /// The groups [`column_body`] actually emitted, in the order it emitted
    /// them: read off the same walk [`draw_column`] draws from, not
    /// recomputed independently, so a bug in that walk shows up here too.
    fn group_labels_of(view: &SheepConfigView) -> Vec<String> {
        column_body(view, fixtures::plain()).1
    }

    /// The rendered text of the one row naming `key`.
    fn field_row_of(view: &SheepConfigView, key: &str) -> String {
        column_body_lines(view, fixtures::plain())
            .iter()
            .map(line_text)
            .find(|line| line.trim_start_matches(['!', '*']).starts_with(key))
            .unwrap_or_else(|| panic!("no row for {key}"))
    }

    /// The env section's own rows, everything after the `env` label line.
    fn env_rows_of(view: &SheepConfigView) -> Vec<String> {
        let lines: Vec<String> = column_body_lines(view, fixtures::plain())
            .iter()
            .map(line_text)
            .collect();
        let label = lines
            .iter()
            .position(|line| line.trim() == "env")
            .expect("column_body always closes with an env label");
        lines[label + 1..].to_vec()
    }

    /// The one row naming `key`, out of `rows`.
    fn row_for<'a>(key: &str, rows: &'a [String]) -> &'a str {
        rows.iter()
            .find(|row| row.contains(key))
            .unwrap_or_else(|| panic!("no row for {key} in {rows:?}"))
    }

    /// The whole column's text, `None` standing in for a pane whose config
    /// has not landed yet.
    fn column_of(config: Option<SheepConfigView>) -> String {
        let lines = match &config {
            Some(view) => column_body_lines(view, fixtures::plain()),
            None => vec![waiting_line(fixtures::plain())],
        };
        lines.iter().map(line_text).collect::<Vec<_>>().join("\n")
    }

    /// The group labels [`crate::lookout::pane::ConfigPane`]'s own field set
    /// carries for `web_view`'s config, in schema order, deduplicated
    /// consecutively the same way [`column_body`]'s own walk dedupes. The
    /// source of truth [`the_groups_are_the_schemas_nine_in_its_own_order`]
    /// checks the column against, instead of a copy of the order typed by
    /// hand: `column_body` layers its own `env`-skipping logic on top of the
    /// same [`pane::sheep_fields`] this reads, so a real cross-check is what
    /// would catch that column-specific divergence and a literal cannot.
    fn config_pane_group_labels(view: &SheepConfigView) -> Vec<String> {
        let pane = crate::lookout::pane::ConfigPane::sheep(view.clone());
        let mut labels = Vec::new();
        let mut current: Option<&str> = None;
        for field in pane.fields().fields() {
            if field.group.as_deref() != current {
                current = field.group.as_deref();
                labels.push(current.unwrap_or_default().to_owned());
            }
        }
        labels
    }

    /// Nine groups, in the schema's own order, the same order
    /// [`ConfigPane`](crate::lookout::pane::ConfigPane)'s own field set gives
    /// for the same config. The frame lists seven and puts `restart` second;
    /// `cron` is missing from it entirely.
    #[test]
    fn the_groups_are_the_schemas_nine_in_its_own_order() {
        let view = web_view();
        assert_eq!(group_labels_of(&view), config_pane_group_labels(&view));
    }

    /// A field parked until the next respawn is marked and says so.
    #[test]
    fn a_pending_field_is_marked_and_annotated() {
        let row = field_row_of(&web_view(), "max_memory");
        assert!(row.starts_with('!'), "{row:?}");
        assert!(row.contains("awaits respawn"), "{row:?}");
    }

    /// An overridden field carries the editing pane's own `*` glyph
    /// (`view/pane.rs`'s `field_line`), not `!`: pending and overridden are
    /// different facts, and `field_row_line` had no branch for this one at
    /// all before this task, so `SheepConfigView::overridden` was read by
    /// nothing in this column. `restart_delay` is neither pending nor
    /// overridden in this fixture, so its row must stay unmarked: a
    /// regression that marked every row once the list was non-empty would
    /// still pass the `max_memory` assertion alone.
    #[test]
    fn an_overridden_field_is_marked_with_the_editing_panes_own_glyph() {
        let row = field_row_of(&web_view_overridden(), "max_memory");
        assert!(row.starts_with('*'), "{row:?}");
        assert!(
            !row.contains("awaits respawn"),
            "overridden is not pending: {row:?}"
        );

        let other_row = field_row_of(&web_view_overridden(), "restart_delay");
        assert!(
            !other_row.starts_with('*') && !other_row.starts_with('!'),
            "an unrelated field must not pick up the marker: {other_row:?}"
        );
    }

    /// The wire clears env before the struct is built, so no pane can show a
    /// value. This test exists so a later change that starts carrying them
    /// fails here rather than shipping, so it reads the two values
    /// `web_view`'s fixture really holds: `db.internal` for `DB_HOST`,
    /// `{{secret:API}}` for `API_KEY`.
    ///
    /// The two key rows after them are the positive control: without them, a
    /// column that drew no env rows at all would pass the value assertions as
    /// easily as a correct one.
    #[test]
    fn no_env_value_reaches_the_column() {
        let rows = env_rows_of(&web_view());

        let rendered = rows.join("");
        assert!(!rendered.contains("db.internal"), "{rendered:?}");
        assert!(!rendered.contains("{{secret:API}}"), "{rendered:?}");

        assert!(row_for("DB_HOST", &rows).starts_with("DB_HOST"), "{rows:?}");
        assert!(row_for("API_KEY", &rows).starts_with("API_KEY"), "{rows:?}");
    }

    /// A key the store fills renders sealed; a Flockfile key does not.
    #[test]
    fn a_sealed_key_is_marked_and_a_plain_one_is_not() {
        let rows = env_rows_of(&view_with_env(&[
            ("PLAIN", "v"),
            ("SEALED", "{{secret:PW}}"),
        ]));
        assert!(row_for("SEALED", &rows).contains("sealed"));
        assert!(!row_for("PLAIN", &rows).contains("sealed"));
    }

    /// Until the reply lands there is no config, and an empty group list
    /// would read as a sheep that has none.
    #[test]
    fn a_pane_without_its_config_yet_says_so() {
        assert!(column_of(None).contains("reading config"));
    }

    /// `exp_backoff_restart_delay` is the Flockfile schema's longest field
    /// name: 25 characters, 26 with the pending `!` flag. `COLUMN_NAME_W`
    /// has to fit that exactly, not "wide enough" by a comment's own say-so.
    #[test]
    fn the_longest_pending_field_name_is_not_truncated() {
        let config = AppConfig {
            name: "web".to_owned(),
            ..AppConfig::default()
        };
        let view = SheepConfigView::new(
            config,
            Vec::new(),
            vec!["exp_backoff_restart_delay".to_owned()],
        );
        let row = field_row_of(&view, "exp_backoff_restart_delay");
        assert!(
            row.starts_with("!exp_backoff_restart_delay  "),
            "the flagged name and its separator must survive whole: {row:?}"
        );
    }

    /// [`field_value_text`] resolves a bare `MemSize` number the same way
    /// [`ConfigPane::display_value`](crate::lookout::pane::ConfigPane::display_value)
    /// does for the editing pane: both read [`pane::resolved_display`], so a
    /// `max_memory` that reads `52428800` on one screen cannot read
    /// `52428800 B` on the other.
    #[test]
    fn the_column_and_the_editing_pane_resolve_the_same_mem_size_units() {
        use shep_core::values::MemSize;

        let mut config = AppConfig {
            name: "web".to_owned(),
            ..AppConfig::default()
        };
        // Not a multiple of any binary unit, so `MemSize`'s own `Display`
        // falls through to the bare-digit branch `resolved_display` exists
        // to annotate; a round number like `50M` would serialize with its
        // unit already and prove nothing.
        config.max_memory = Some(MemSize::from_bytes(1_234_567));
        let column_view = SheepConfigView::new(config.clone(), Vec::new(), Vec::new());
        let editing_view = SheepConfigView::new(config, Vec::new(), Vec::new());

        let row = field_row_of(&column_view, "max_memory");
        let pane = crate::lookout::pane::ConfigPane::sheep(editing_view);
        let resolved = pane.display_value("max_memory");
        assert!(
            resolved.ends_with(" B"),
            "setup: the fixture must actually exercise the unit suffix: {resolved:?}"
        );
        assert!(
            row.contains(&resolved),
            "column row {row:?} does not carry the editing pane's own {resolved:?}"
        );
    }

    /// [`Field::secret`] guards the sheep column the same way
    /// [`ConfigPane`](crate::lookout::pane::ConfigPane)'s own row draws it: the
    /// value is never rendered, only that there is one. No Flockfile field
    /// carries the flag today, so this is unit-level, over a fabricated
    /// field rather than a real config.
    #[test]
    fn a_secret_field_renders_set_and_never_its_value() {
        use crate::lookout::field::FieldKind;

        let field = Field {
            key: "webhook".to_owned(),
            help: String::new(),
            group: None,
            kind: FieldKind::Text,
            value_kind: None,
            default: None,
            default_value: None,
            secret: true,
            editable: true,
            example: None,
            accepts: Vec::new(),
            refuses: Vec::new(),
            neighbours: Vec::new(),
            bounds: crate::lookout::field::Bounds::default(),
        };
        let fields = FieldSet::from_fields(vec![field.clone()], &[]);
        let mut values = Map::new();
        values.insert("webhook".to_owned(), Value::String("hunter2".to_owned()));
        assert_eq!(field_value_text(&fields, &field, &values), "<set>");
    }

    /// A dog's table can hold a credential, and only the dog's own schema
    /// says which field, so the column names the dogs and stops there.
    #[test]
    fn the_dogs_row_names_the_dogs_and_never_a_value() {
        let mut config = shep_core::config::AppConfig {
            name: "web".into(),
            ..Default::default()
        };
        let (fields, values) = pane::sheep_fields(&config);
        let dogs = fields.by_key("dogs").expect("dogs is a field");
        assert_eq!(field_value_text(&fields, dogs, &values), "(default)");
        let table = serde_json::json!({ "token": "sk-live-51Hx9Qa" });
        let table = table.as_object().cloned().expect("a table");
        config.dogs.insert("jobs".into(), table.into());
        let (fields, values) = pane::sheep_fields(&config);
        let dogs = fields.by_key("dogs").expect("dogs is a field");
        assert_eq!(field_value_text(&fields, dogs, &values), "jobs");
    }

    /// The regression Tasks 7 and 8 both shipped once each: a pane pinned to
    /// a sheep the flock table has since reseated its selection away from.
    /// The column reads `SheepPane::config` (set only by `adopt_config` and
    /// `set_sheep`, never by the reseat), so it has no equivalent bug
    /// surface to begin with; this pins that a later change cannot grow one
    /// by threading `App::selected` into the column instead.
    #[test]
    fn the_column_does_not_draw_the_sheep_that_replaced_the_pinned_one() {
        let mut app = fixtures::app_with(
            vec![
                ProcessInfo::builder(1, "alpha", ProcStatus::Online).build(),
                ProcessInfo::builder(2, "bravo", ProcStatus::Online).build(),
            ],
            fixtures::plain(),
        );
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        assert!(
            matches!(app.body(), Body::Sheep(_)),
            "setup: the pane opened on alpha"
        );
        let alpha_config = AppConfig {
            name: "alpha".to_owned(),
            ..AppConfig::default()
        };
        app.update(Msg::Replied {
            sent: Sent::SheepConfig {
                name: "alpha".to_string(),
            },
            result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
                alpha_config,
                Vec::new(),
                Vec::new(),
            )))),
        });
        let _ = app.update(Msg::Snapshot {
            rows: vec![ProcessInfo::builder(2, "bravo", ProcStatus::Online).build()],
            at: std::time::Instant::now(),
        });
        assert_eq!(
            app.selected(),
            Some(RowKey::Sheep(2)),
            "setup: the reseat moved the selection to bravo"
        );

        let Body::Sheep(pane) = app.body() else {
            panic!("the pane is still open");
        };
        let area = Rect::new(0, 0, 80, MIN_HEIGHT_FOR_COLUMN);
        let mut buffer = Buffer::empty(area);
        draw(&app, pane, area, &mut buffer);
        let text = render_text(&buffer);
        assert!(
            text.contains("name                        alpha"),
            "must still draw the pinned sheep's own `name` field: {text:?}"
        );
        assert!(!text.contains("bravo"), "{text:?}");
    }

    /// The divider sits one cell past the config column, at
    /// [`COLUMN_WIDTH`], for every row the two sides draw into.
    #[test]
    fn the_divider_runs_the_full_height_of_the_column_and_feed() {
        let mut app = fixtures::app_with(
            vec![ProcessInfo::builder(1, "alpha", ProcStatus::Online).build()],
            fixtures::plain(),
        );
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        let Body::Sheep(pane) = app.body() else {
            panic!("the pane is still open");
        };
        let area = Rect::new(0, 0, FEED_X + FEED_WIDTH, MIN_HEIGHT_FOR_COLUMN);
        let mut buffer = Buffer::empty(area);
        draw(&app, pane, area, &mut buffer);
        for row in COLUMN_HEADER_ROW..=COLUMN_LAST_ROW {
            assert_eq!(
                buffer[(COLUMN_WIDTH, row)].symbol(),
                "\u{2502}",
                "row {row} has no divider"
            );
        }
    }
}