shep 0.16.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
//! The two histories over rows `CPU_HEADER_ROW` to `AXIS_ROW`: which of
//! them a terminal this size has room for, and the rows they draw.
//!
//! [`chart_tier`] decides; [`draw_charts`], [`draw_cpu_only`] and
//! [`draw_sparkline_row`] are the three answers. [`rows`] holds the row
//! content each of them writes.

use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;

use crate::lookout::app::{App, HISTORY};
use crate::lookout::theme::Palette;

use super::super::{cell, flock};
use super::layout::{
    AXIS_ROW, CPU_CHART_ROW, CPU_HEADER_ROW, FULL_TIER_MIN_HEIGHT, GUTTER, HAIRLINE_ROW, MARGIN,
    MEM_CHART_ROW, MEM_HEADER_ROW, MIN_HEIGHT_FOR_CHARTS, TERMINAL_OVERHEAD,
};

mod rows;

use self::rows::{axis_row, cpu_chart_rows, cpu_header_text, mem_chart_rows, mem_header_text};

/// Which of the sheep pane's own charts fit `width` and `height`, decision
/// 8's own ladder: both charts at 140 columns and [`FULL_TIER_MIN_HEIGHT`]
/// rows, the CPU chart alone with a one-line memory summary from 100
/// columns, a single sparkline-and-gauge row below that (down to
/// [`flock::MIN_WIDTH`], the whole app's own floor, refused before this
/// pane ever opens), and nothing at all under [`MIN_HEIGHT_FOR_CHARTS`]
/// rows regardless of width.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ChartTier {
    /// Both charts, full height: [`draw_charts`].
    Full,
    /// The CPU chart and a one-line memory summary: [`draw_cpu_only`].
    CpuOnly,
    /// 1a's own CPU sparkline and memory gauge, one row: [`draw_sparkline_row`].
    Sparkline,
    /// No chart content at all; the row belongs to the config column instead.
    None,
}

pub(super) fn chart_tier(width: u16, height: u16) -> ChartTier {
    // `height` is `area.height`, the pane body's own row count.
    // [`MIN_HEIGHT_FOR_CHARTS`] and [`FULL_TIER_MIN_HEIGHT`] are both
    // stated in terminal rows, the count an operator actually reads off
    // their own terminal, so [`TERMINAL_OVERHEAD`] is added back before
    // either comparison.
    let terminal_height = height + TERMINAL_OVERHEAD;
    if terminal_height < MIN_HEIGHT_FOR_CHARTS {
        return ChartTier::None;
    }
    let by_width = if width >= 140 {
        ChartTier::Full
    } else if width >= 100 {
        ChartTier::CpuOnly
    } else if width >= flock::MIN_WIDTH {
        ChartTier::Sparkline
    } else {
        ChartTier::None
    };
    if by_width == ChartTier::Full && terminal_height < FULL_TIER_MIN_HEIGHT {
        ChartTier::CpuOnly
    } else {
        by_width
    }
}

/// Rows `CPU_HEADER_ROW` to `AXIS_ROW`: the CPU history, the memory
/// history, and the axis they share.
///
/// `body_cells` is computed once, here, and handed to both charts: the
/// other half of why a memory step and a CPU spike land on the same
/// column is [`GUTTER`] being one constant rather than two calculations
/// that could drift apart.
pub(super) fn draw_charts(
    app: &App,
    sheep_id: u32,
    max_memory: Option<u64>,
    area: Rect,
    buffer: &mut Buffer,
    palette: Palette,
) {
    let body_cells = chart_body_cells(area.width);
    let cpu_history = app.cpu_history(sheep_id);
    let rss_history = app.rss_history(sheep_id);

    let cpu_header = cpu_header_text(cpu_history.len(), body_cells);
    write_row(
        buffer,
        area,
        CPU_HEADER_ROW,
        &format!("\u{2588}\u{2588} CPU   {cpu_header}"),
        palette.muted(),
    );
    for (i, row_text) in cpu_chart_rows(cpu_history, body_cells)
        .into_iter()
        .enumerate()
    {
        write_row(
            buffer,
            area,
            CPU_CHART_ROW + i as u16,
            &row_text,
            Style::default(),
        );
    }

    let mem_window = &rss_history[rss_history.len().saturating_sub(body_cells)..];
    let mem_window_peak = mem_window.iter().copied().max().unwrap_or(0);
    let mem_header = mem_header_text(max_memory, mem_window_peak);
    write_row(
        buffer,
        area,
        MEM_HEADER_ROW,
        &format!("\u{2588}\u{2588} MEM   {mem_header}"),
        palette.muted(),
    );
    let (mem_rows, marked) = mem_chart_rows(rss_history, max_memory, body_cells);
    for (i, row_text) in mem_rows.into_iter().enumerate() {
        let style = if marked == Some(i) {
            palette.attention()
        } else {
            Style::default()
        };
        write_row(buffer, area, MEM_CHART_ROW + i as u16, &row_text, style);
    }

    write_row(
        buffer,
        area,
        AXIS_ROW,
        &axis_row(body_cells),
        palette.muted(),
    );
}

/// [`ChartTier::CpuOnly`]'s own rows: the CPU chart, unchanged, and in
/// [`MEM_HEADER_ROW`]'s own slot, [`mem_line_text`]'s single line in place
/// of the memory chart's header and five rows: memory still has a gauge
/// to fall back on; the CPU chart is the more diagnostic of the two, so it
/// is the one that stays.
pub(super) fn draw_cpu_only(
    app: &App,
    sheep_id: u32,
    max_memory: Option<u64>,
    area: Rect,
    buffer: &mut Buffer,
    palette: Palette,
) {
    let body_cells = chart_body_cells(area.width);
    let cpu_history = app.cpu_history(sheep_id);
    let rss_history = app.rss_history(sheep_id);

    let cpu_header = cpu_header_text(cpu_history.len(), body_cells);
    write_row(
        buffer,
        area,
        CPU_HEADER_ROW,
        &format!("\u{2588}\u{2588} CPU   {cpu_header}"),
        palette.muted(),
    );
    for (i, row_text) in cpu_chart_rows(cpu_history, body_cells)
        .into_iter()
        .enumerate()
    {
        write_row(
            buffer,
            area,
            CPU_CHART_ROW + i as u16,
            &row_text,
            Style::default(),
        );
    }

    let current_rss = rss_history.last().copied().unwrap_or(0);
    write_row(
        buffer,
        area,
        MEM_HEADER_ROW,
        &mem_line_text(current_rss, max_memory),
        Style::default(),
    );
    write_row(
        buffer,
        area,
        AXIS_ROW,
        &axis_row(body_cells),
        palette.muted(),
    );
}

/// [`ChartTier::CpuOnly`]'s own memory line: `rss` against its ceiling
/// when a ceiling exists, per the arithmetic in decision 8:
/// `rss 48.3M of 52M` plus a 10-cell gauge. With no ceiling set, the
/// gauge has nothing to fill against, the same no-limit case
/// [`cell::gauge`]'s own doc already covers.
fn mem_line_text(current_rss: u64, max_memory: Option<u64>) -> String {
    let gauge = cell::gauge(current_rss, max_memory, 10);
    match max_memory {
        Some(limit) => format!(
            "rss {} of {} {gauge}",
            crate::output::human_bytes(current_rss),
            crate::output::human_bytes(limit),
        ),
        None => format!("rss {} {gauge}", crate::output::human_bytes(current_rss)),
    }
}

/// The full-width hairline rule at [`HAIRLINE_ROW`], between the axis and
/// the column headers: the same `─` run
/// [`super::super::status::rule_line`] draws
/// under the pane's own header elsewhere in `lookout`.
pub(super) fn draw_hairline(area: Rect, buffer: &mut Buffer, palette: Palette) {
    write_row(
        buffer,
        area,
        HAIRLINE_ROW,
        &cell::rule(usize::from(area.width)),
        palette.muted(),
    );
}

/// [`ChartTier::Sparkline`]'s own row: 1a's own `CPU 20s` sparkline and
/// `MEM/CEIL` gauge, the pair [`super::super::flock`]'s own flat-view columns
/// already draw, on the one row this tier has left once both charts have
/// given up their own sixteen.
pub(super) fn draw_sparkline_row(
    app: &App,
    info: &shep_core::protocol::ProcessInfo,
    area: Rect,
    buffer: &mut Buffer,
    palette: Palette,
) {
    let spark = cell::sparkline(app.cpu_history(info.id), 10, app.cpu_ceiling());
    let gauge = cell::gauge(info.memory_bytes.unwrap_or(0), info.max_memory, 10);
    write_row(
        buffer,
        area,
        CPU_HEADER_ROW,
        &format!("CPU 20s   {spark}      MEM/CEIL   {gauge}"),
        palette.muted(),
    );
}

/// Writes one styled line into `buffer`, `row` cells below `area`'s own
/// top. Callers only reach here once [`draw`](super::draw) has already
/// checked `area`
/// is tall enough for `row`.
///
/// `set_stringn`, not a `Line` of one `Span`: the `Span` needs an owned
/// `String` and this runs ten to fifteen times a frame, where the borrowed
/// `&str` is all the buffer ever needed.
fn write_row(buffer: &mut Buffer, area: Rect, row: u16, text: &str, style: Style) {
    buffer.set_stringn(area.x, area.y + row, text, usize::from(area.width), style);
}

/// The chart body's width in cells: `area`'s own `width` less [`GUTTER`]
/// and [`MARGIN`], per decision 8's arithmetic:
///
/// ```text
/// 160 = 8 gutter + 140 body + 12 margin
/// 140 = 8 gutter + 120 body + 12 margin
/// ```
///
/// Capped at [`HISTORY`]: past 160 columns the arithmetic above would ask
/// for more samples than the buffer ever holds, and an uncapped body keeps
/// [`cpu_header_text`] reading `collecting` forever even once the buffer is
/// full. Which charts draw at which width past that point is task 11's own
/// tier; this is only the ceiling the header's own claim has to respect.
pub(super) fn chart_body_cells(width: u16) -> usize {
    usize::from(width)
        .saturating_sub(GUTTER + MARGIN)
        .min(HISTORY)
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use shep_core::protocol::ProcessInfo;
    use shep_core::status::ProcStatus;

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

    use super::super::super::fixtures;
    use super::super::draw;
    use super::super::layout::{CPU_ROWS, MEM_ROWS};
    use super::*;

    /// The chart-drawing twin of the test above: pane pinned to alpha,
    /// alpha leaves the flock, bravo (with its own, distinct `max_memory`)
    /// takes the reseated selection. `draw`'s own gate reads
    /// `App::sheep_pane_row`, which is `None` once alpha is gone, so
    /// nothing charts at all; reading `App::selected_row` instead would
    /// draw bravo's ceiling under a pane still naming alpha. The area here
    /// is tall and wide enough to reach `draw_charts`, unlike the test
    /// above's 80x3.
    #[test]
    fn the_charts_do_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)
                    .max_memory(Some(64 << 20))
                    .build(),
            ],
            fixtures::plain(),
        );
        let _ = app.update(Msg::Key(KeyPress::Confirm));
        assert!(
            matches!(app.body(), Body::Sheep(_)),
            "setup: the pane opened on alpha"
        );
        let _ = app.update(Msg::Snapshot {
            rows: vec![
                ProcessInfo::builder(2, "bravo", ProcStatus::Online)
                    .max_memory(Some(64 << 20))
                    .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, 40, MIN_HEIGHT_FOR_CHARTS);
        let mut buffer = Buffer::empty(area);
        draw(&app, pane, area, &mut buffer);
        let text = render_text(&buffer);
        assert!(
            !text.contains("ceiling"),
            "bravo's own max_memory must not draw once the pinned sheep is \
             gone: {text:?}"
        );
    }

    /// Past 160 columns [`chart_body_cells`] would ask for more samples than
    /// [`HISTORY`] ever holds; capped, or [`cpu_header_text`] would read
    /// `collecting` forever even once the buffer is full.
    #[test]
    fn chart_body_stays_within_the_history_buffer() {
        let body = chart_body_cells(300);
        assert_eq!(body, HISTORY);
        assert!(
            !cpu_header_text(HISTORY, body).contains("collecting"),
            "a full buffer past the cap must not still read collecting"
        );
    }

    /// One sheep, run through two real polls so both `cpu_history` and
    /// `rss_history` hold a differenced, nonzero last sample: the alignment
    /// test below needs the rightmost column of both charts' bottom row to
    /// be real content, not left-padding a too-short history would leave
    /// blank there too.
    fn app_with_two_polls(id: u32, name: &str, max_memory: Option<u64>) -> App {
        let t0 = std::time::Instant::now();
        let mut app = App::new(
            fixtures::plain(),
            Control::ReadOnly,
            "/home/ada/.shep".to_string(),
            t0,
        );
        app.update(Msg::Snapshot {
            rows: vec![
                ProcessInfo::builder(id, name, ProcStatus::Online)
                    .cpu_ms(Some(0))
                    .memory_bytes(Some(10 << 20))
                    .max_memory(max_memory)
                    .build(),
            ],
            at: t0,
        });
        let t1 = t0 + Duration::from_secs(2);
        app.update(Msg::Snapshot {
            rows: vec![
                ProcessInfo::builder(id, name, ProcStatus::Online)
                    .cpu_ms(Some(2000))
                    .memory_bytes(Some(10 << 20))
                    .max_memory(max_memory)
                    .build(),
            ],
            at: t1,
        });
        app
    }

    /// The whole reason this frame was picked over side-by-side charts: a
    /// memory step and a CPU spike land on the same column because both
    /// bodies are drawn to the same `body_cells`, not two calculations that
    /// could quietly drift apart. Rendered through [`draw_charts`] itself,
    /// not two calls typed with the same literal by hand: that would still
    /// pass if the two calls' own `body_cells` argument drifted apart at the
    /// call site, which is exactly the mutation this test exists to catch.
    #[test]
    fn the_two_charts_share_one_body_width() {
        let app = app_with_two_polls(1, "alpha", None);
        let area = Rect::new(0, 0, 40, MIN_HEIGHT_FOR_CHARTS);
        let mut buffer = Buffer::empty(area);
        draw_charts(&app, 1, None, area, &mut buffer, fixtures::plain());
        let text = render_text(&buffer);
        let lines: Vec<&str> = text.lines().collect();
        let cpu_last_row = lines[usize::from(CPU_CHART_ROW) + CPU_ROWS - 1];
        let mem_last_row = lines[usize::from(MEM_CHART_ROW) + MEM_ROWS - 1];
        let cpu_end = cpu_last_row.trim_end().chars().count();
        let mem_end = mem_last_row.trim_end().chars().count();
        assert_eq!(
            cpu_end, mem_end,
            "CPU chart's bottom row ends at column {cpu_end}, memory's at \
             {mem_end}: {cpu_last_row:?} vs {mem_last_row:?}"
        );
    }

    /// The arithmetic, asserted rather than trusted:
    ///     160 = 8 gutter + 140 body + 12 margin
    ///     body = min(width - 20, HISTORY)
    /// A scene one cell short of its own column set silently drops the
    /// thing it exists to show, which is why this is a test and not a
    /// comment.
    #[test]
    fn the_chart_body_is_the_width_less_its_gutter_and_margin() {
        assert_eq!(chart_body_cells(160), 140);
        assert_eq!(chart_body_cells(140), 120);
    }

    /// Past the design target the buffer runs out before the columns do, so
    /// the margin grows rather than leaving cells that can never fill.
    #[test]
    fn a_wider_terminal_grows_the_margin_rather_than_the_body() {
        assert_eq!(chart_body_cells(200), 140);
    }
}