shep 0.25.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
//! The `--no-follow` path: tailing each matched sheep's log files on disk
//! and never touching the bus, plus the backlog a `--follow` prints before
//! it subscribes.
//!
//! Every run here goes through [`run_bleats`] except the two that call
//! [`tail_log_files`] directly, which hand `Streams` a cache they built
//! themselves and never dial a client.

use super::*;

// --- `--no-follow` reads the log files ---
// None of these subscribe, so there is nothing for `RUN_TIMEOUT` to
// guard but a bounded file read.

/// A `--no-follow` still wired to the bus fails the second assertion; one
/// wired to neither fails the first. This is the test that tells the two
/// apart from a `--no-follow` that reads the right files.
#[tokio::test]
async fn no_follow_reads_the_files_and_never_the_bus() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let out_path = write_log(dir.path(), "web-out.log", "from-the-file\n");

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    daemon.reply_to_list(vec![sheep]);
    daemon
        .push(BusEvent::LogOut {
            id: 1,
            line: "from-the-bus".into(),
        })
        .await;

    let (code, rendered, _err) =
        run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(code, ExitCode::Success);
    assert!(rendered.contains("from-the-file"));
    assert!(
        !rendered.contains("from-the-bus"),
        "the file path must never consult the bus: {rendered}"
    );
}

/// The shepherd's stamp, spelled out. [`shep_core::logstamp`] writes exactly
/// [`shep_core::logstamp::LOG_STAMP_BYTES`] bytes of it, and an app that
/// stamps its own lines in RFC 3339 can write the same bytes.
const SHEPHERD_STAMP: &str = "2026-09-02T14:22:31.412+02:00 ";

/// A sheep whose app turns the shepherd's stamp off has its own timestamp in
/// the file, and `strip` cuts anything in that shape. `--follow` already
/// printed the line whole, so the two paths disagreed by 30 bytes.
#[tokio::test]
async fn a_stamps_off_sheeps_own_timestamp_survives_the_file_tail() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let line = format!("{SHEPHERD_STAMP}the app stamped this itself");
    assert_eq!(
        SHEPHERD_STAMP.len(),
        shep_core::logstamp::LOG_STAMP_BYTES,
        "the fixture must be exactly the shape `strip` cuts, or this test \
         proves nothing"
    );
    let out_path = write_log(dir.path(), "web-out.log", &format!("{line}\n"));

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    sheep.log_timestamps = Some(false);
    daemon.reply_to_list(vec![sheep]);

    let (_code, rendered, _err) =
        run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(
        rendered,
        format!("web | {line}\n"),
        "a stamps-off sheep's file holds its lines as it wrote them"
    );
}

/// The claim the docs make for `log_timestamps = false`: the same lines
/// either way. Asserted as two rendered lines that are byte-identical, one
/// read off disk and one pushed on the bus.
#[tokio::test]
async fn a_stamps_off_sheep_prints_the_same_line_from_the_file_and_from_the_bus() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let line = format!("{SHEPHERD_STAMP}one line, two paths");
    let out_path = write_log(dir.path(), "web-out.log", &format!("{line}\n"));

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    sheep.log_timestamps = Some(false);
    daemon.reply_to_list(vec![sheep]);
    daemon
        .push(BusEvent::LogOut {
            id: 1,
            line: line.clone(),
        })
        .await;
    daemon.close_after_subscribe().await;

    let (_code, rendered, _err) = run_bleats(&client, &follow_args_out("all"), Format::Table).await;

    let want = format!("web | {line}");
    assert_eq!(
        rendered.lines().collect::<Vec<_>>(),
        vec![want.as_str(), want.as_str()],
        "the backlog line and the followed line must be the same line: {rendered}"
    );
}

/// The other direction, so the strip is switched per row rather than off:
/// a sheep whose app never asked for bare lines still has the shepherd's
/// stamp taken off its file lines, which is what a `--follow` of the same
/// sheep prints.
#[tokio::test]
async fn a_stamped_sheeps_shepherd_stamp_still_comes_off_the_file_tail() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let out_path = write_log(
        dir.path(),
        "web-out.log",
        &format!("{SHEPHERD_STAMP}the shepherd stamped this\n"),
    );

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    daemon.reply_to_list(vec![sheep]);

    let (_code, rendered, _err) =
        run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(
        rendered, "web | the shepherd stamped this\n",
        "a stamped row must keep losing the stamp the shepherd added"
    );
}

/// A sheep that already crashed before `shep bleats <name>` runs: the
/// backlog is what makes its last output reachable without starting it
/// again in a second window.
///
/// The follow is ended by `close_after_subscribe` once its backlog has
/// printed, so the run ends on the scripted connection rather than on
/// [`RUN_TIMEOUT`].
#[tokio::test]
async fn following_prints_the_existing_log_before_it_follows() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let out_path = write_log(
        dir.path(),
        "web-out.log",
        "boot: reading config\nFATAL: port 19999 already in use\n",
    );

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    daemon.reply_to_list(vec![sheep]);
    daemon.close_after_subscribe().await;

    let (_code, rendered, _err) = run_bleats(&client, &follow_args_out("all"), Format::Table).await;

    assert!(
        rendered.contains("FATAL: port 19999 already in use"),
        "a follow must carry the reason a dead sheep died: {rendered}"
    );
}

/// `--lines 0` is the escape hatch for someone who genuinely wants only
/// what arrives next, and it is what the foreground runner passes.
///
/// Ended by `close_after_subscribe` like the test above: what is asserted
/// is what the run printed before it subscribed.
#[tokio::test]
async fn lines_zero_follows_without_replaying_anything() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let out_path = write_log(dir.path(), "web-out.log", "OLD-HISTORY\n");

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    daemon.reply_to_list(vec![sheep]);
    daemon.close_after_subscribe().await;

    let args = BleatsArgs {
        lines: 0,
        ..follow_args_out("all")
    };
    let (_code, rendered, _err) = run_bleats(&client, &args, Format::Table).await;

    assert!(
        !rendered.contains("OLD-HISTORY"),
        "--lines 0 must replay nothing: {rendered}"
    );
}

/// A `read_to_string`-style implementation prints line 1 and fails this.
#[tokio::test]
async fn the_tail_is_bounded_by_lines() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    const CAP: usize = 50;
    let total = CAP + 20;
    let content: String = (1..=total).map(|n| format!("line-{n}\n")).collect();
    let out_path = write_log(dir.path(), "web-out.log", &content);

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    daemon.reply_to_list(vec![sheep]);

    let args = BleatsArgs {
        lines: CAP,
        ..no_follow_args_out("all")
    };
    let (_code, rendered, _err) = run_bleats(&client, &args, Format::Table).await;

    assert!(
        !rendered.lines().any(|line| line == "web | line-1"),
        "the first line must fall outside the tail: {rendered}"
    );
    assert!(
        rendered
            .lines()
            .any(|line| line == format!("web | line-{total}")),
        "the last line must be present: {rendered}"
    );
    assert_eq!(
        rendered.lines().count(),
        CAP,
        "exactly CAP lines must reach stdout: {rendered}"
    );
}

/// Guards the window and the discard-the-partial-first-line rule
/// together: an implementation that keeps the partial head emits a
/// quarter-megabyte fragment and fails this.
#[tokio::test]
async fn the_tail_is_bounded_by_bytes_and_never_shows_half_a_line() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let long_line = "x".repeat(usize::try_from(TAIL_WINDOW_BYTES).unwrap() + 1024);
    let content = format!("{long_line}\nshort\n");
    let out_path = write_log(dir.path(), "web-out.log", &content);

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    daemon.reply_to_list(vec![sheep]);

    let (_code, rendered, _err) =
        run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(
        rendered,
        "web | short\n",
        "no fragment of the long line may reach stdout ({} bytes rendered)",
        rendered.len()
    );
}

/// Two instances sharing one log file (a `merge_logs` app, or any app
/// with an explicit `out_file`) must be read once, not once per
/// instance: reading per row printed the whole file once per instance
/// pointing at it.
#[tokio::test]
async fn instances_sharing_one_log_file_are_read_once_not_once_each() {
    let dir = tempfile::tempdir().expect("tempdir");
    let shared = dir.path().join("talker-out.log");
    std::fs::write(&shared, "line one\nline two\n").expect("write");

    let shared_path = shared.to_string_lossy().to_string();
    let mut cache = HashMap::new();
    for id in 0..2u32 {
        cache.insert(
            id,
            ProcessInfo::builder(id, "talker", ProcStatus::Online)
                .out_file(Some(shared_path.clone()))
                .err_file(Some(shared_path.clone()))
                .build(),
        );
    }

    let mut out = Vec::new();
    let mut err = Vec::new();
    let mut streams = Streams::bare(&mut out, &mut err, Format::Table);
    let args = no_follow_args_out("talker");
    let selector = ProcessSelector::parse("talker").expect("selector");

    tail_log_files(
        &mut streams,
        false,
        &cache,
        &selector,
        &args,
        LogSource::Local,
    )
    .await
    .unwrap();

    let printed = String::from_utf8(out).expect("utf8");
    assert_eq!(
        printed.matches("line one").count(),
        1,
        "one file, one read, however many instances point at it:\n{printed}"
    );
}

/// Builds a cache of `count` rows for one app, and returns it with the
/// printed backlog. `shared` puts every instance on one file, the way
/// `merge_logs` does.
async fn backlog_of(dir: &Path, app: &str, count: u32, shared: bool) -> String {
    let mut cache = HashMap::new();
    for slot in 0..count {
        let stem = if shared {
            format!("{app}-out.log")
        } else {
            format!("{app}-{slot}-out.log")
        };
        let path = dir.join(&stem);
        std::fs::write(&path, format!("hello from {slot}\n")).expect("write");
        cache.insert(
            slot,
            ProcessInfo::builder(slot, app, ProcStatus::Online)
                .instance(Some(slot))
                .out_file(Some(path.to_string_lossy().to_string()))
                .build(),
        );
    }
    let mut out = Vec::new();
    let mut err = Vec::new();
    let mut streams = Streams::bare(&mut out, &mut err, Format::Table);
    let args = no_follow_args_out(app);
    let selector = ProcessSelector::parse(app).expect("selector");
    tail_log_files(
        &mut streams,
        false,
        &cache,
        &selector,
        &args,
        LogSource::Local,
    )
    .await
    .unwrap();
    String::from_utf8(out).expect("utf8")
}

#[tokio::test]
async fn a_multi_instance_app_labels_its_backlog_lines_with_the_slot() {
    let dir = tempfile::tempdir().expect("tempdir");
    let printed = backlog_of(dir.path(), "web", 2, false).await;
    assert!(printed.contains("web:0 |"), "{printed}");
    assert!(printed.contains("web:1 |"), "{printed}");
}

#[tokio::test]
async fn a_single_instance_app_keeps_the_bare_name() {
    let dir = tempfile::tempdir().expect("tempdir");
    let printed = backlog_of(dir.path(), "solo", 1, false).await;
    assert!(printed.contains("solo |"), "{printed}");
    assert!(
        !printed.contains("solo:0"),
        "no suffix for one instance: {printed}"
    );
}

#[tokio::test]
async fn a_shared_backlog_file_is_labelled_with_the_app_not_a_slot() {
    let dir = tempfile::tempdir().expect("tempdir");
    let printed = backlog_of(dir.path(), "talker", 2, true).await;
    assert!(printed.contains("talker |"), "{printed}");
    assert!(
        !printed.contains("talker:"),
        "one file holds both instances, and no line says which wrote it: {printed}"
    );
}

/// `truncated` must report `true` when the byte window alone cut the
/// tail short: a sheep logging a few long, structured lines can fill
/// `TAIL_WINDOW_BYTES` in far fewer than `limit` lines.
#[test]
fn read_tail_reports_truncated_on_a_byte_window_cut_alone() {
    let dir = tempfile::tempdir().unwrap();
    let long_line = "x".repeat(usize::try_from(TAIL_WINDOW_BYTES).unwrap() + 1024);
    let content = format!("{long_line}\nshort\n");
    let path = dir.path().join("web-out.log");
    std::fs::write(&path, &content).unwrap();

    let (lines, truncated) = read_tail(&path, 50, true).unwrap();

    assert_eq!(lines, vec!["short".to_string()]);
    assert!(
        truncated,
        "the file exceeds the byte window, so this tail is not the whole log"
    );
}

/// Three ways: `--out` (out lines only), `--err` (err lines only),
/// neither (out lines, then err lines, this module's own within-sheep
/// ordering).
#[tokio::test]
async fn out_and_err_select_which_file_is_read() {
    async fn run(args: BleatsArgs) -> String {
        let dir = tempfile::tempdir().unwrap();
        let sock = shep_client::testing::control_address(dir.path());
        let out_path = write_log(dir.path(), "web-out.log", "stdout-line\n");
        let err_path = write_log(dir.path(), "web-err.log", "stderr-line\n");

        let (client, daemon) = fake_client_with_push(&sock).await;
        let mut sheep = info(1, "web");
        sheep.out_file = Some(out_path);
        sheep.err_file = Some(err_path);
        daemon.reply_to_list(vec![sheep]);

        let (_code, out, _err) = run_bleats(&client, &args, Format::Table).await;
        out
    }

    let out_only = run(no_follow_args_out("all")).await;
    assert!(out_only.contains("stdout-line") && !out_only.contains("stderr-line"));

    let err_only = run(no_follow_args_err("all")).await;
    assert!(err_only.contains("stderr-line") && !err_only.contains("stdout-line"));

    let both = run(no_follow_args("all")).await;
    let out_pos = both
        .find("stdout-line")
        .expect("the stdout line is present");
    let err_pos = both
        .find("stderr-line")
        .expect("the stderr line is present");
    assert!(
        out_pos < err_pos,
        "out_file must render before err_file within one sheep: {both}"
    );
}

/// Fails if the sheep are tailed in id order rather than name order.
///
/// `b` holds the lower id but sorts after `a` by name, and the listing
/// is scripted in id order, so neither a `HashMap`'s arbitrary order
/// nor id order can make the assertion pass by accident.
#[tokio::test]
async fn files_are_printed_in_name_order() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let a_path = write_log(dir.path(), "a-out.log", "line-from-a\n");
    let b_path = write_log(dir.path(), "b-out.log", "line-from-b\n");

    let (client, daemon) = fake_client_with_push(&sock).await;
    // `b` takes the LOWER id, so id order and name order disagree.
    let mut sheep_b = info(1, "b");
    sheep_b.out_file = Some(b_path);
    let mut sheep_a = info(2, "a");
    sheep_a.out_file = Some(a_path);
    daemon.reply_to_list(vec![sheep_b, sheep_a]);

    let (_code, rendered, _err) =
        run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    let a_pos = rendered.find("line-from-a").expect("a's line is present");
    let b_pos = rendered.find("line-from-b").expect("b's line is present");
    assert!(
        a_pos < b_pos,
        "name order puts `a` (id 2) before `b` (id 1): {rendered}"
    );
}

/// Fails if one app's instances are tailed in id order rather than
/// slot order: a reload gives slot 0 a fresh high id, so id order
/// alone would read 1, 2, 0.
///
/// Slot 0 is given the highest id here and the listing is scripted in
/// slot order, so neither id order nor a `HashMap`'s arbitrary order
/// can make the assertion pass by accident.
#[tokio::test]
async fn one_apps_instances_are_printed_in_slot_order() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut listing = Vec::new();
    // Slot 0 reloaded, so it holds id 9 while slots 1 and 2 kept 1 and 2.
    for (id, slot) in [(9_u32, 0_u32), (1, 1), (2, 2)] {
        let mut sheep = info(id, "web");
        sheep.instance = Some(slot);
        sheep.out_file = Some(write_log(
            dir.path(),
            &format!("web-{slot}-out.log"),
            &format!("line-from-slot-{slot}\n"),
        ));
        listing.push(sheep);
    }
    daemon.reply_to_list(listing);

    let (_code, rendered, _err) =
        run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    let at = |slot: u32| {
        rendered
            .find(&format!("line-from-slot-{slot}"))
            .unwrap_or_else(|| panic!("slot {slot}'s line is present: {rendered}"))
    };
    assert!(
        at(0) < at(1) && at(1) < at(2),
        "slot order, not id order (which would read 1, 2, 0): {rendered}"
    );
}

/// The daemon creates both files at spawn, so a missing one means this
/// sheep has never run in this `$SHEP_HOME`. Said rather than exited on:
/// an empty tail and an empty log read the same on a terminal.
#[tokio::test]
async fn a_missing_file_is_noticed_and_the_rest_still_print() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let real_path = write_log(dir.path(), "web-out.log", "still-here\n");
    let missing_path = dir
        .path()
        .join("never-written.log")
        .to_str()
        .unwrap()
        .to_string();

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut ghost = info(1, "ghost");
    ghost.out_file = Some(missing_path.clone());
    let mut real = info(2, "web");
    real.out_file = Some(real_path);
    daemon.reply_to_list(vec![ghost, real]);

    let (code, out, err) = run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(
        code,
        ExitCode::Success,
        "a sheep that has never run is not a failed run"
    );
    assert!(out.contains("still-here"));
    assert!(
        err.contains(&format!(
            "notice[log_missing]: ghost: no out log at {missing_path} yet"
        )),
        "the notice must name the sheep, the stream and the file: {err}"
    );
}

/// A relative `out_file` is resolved against the shepherd's directory
/// by the shepherd and against this one here, so the two name different
/// files and the silent read was of the wrong one.
#[tokio::test]
async fn a_relative_path_names_the_file_this_process_actually_tried() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some("logs/out.log".to_string());
    daemon.reply_to_list(vec![sheep]);

    let (code, out, err) = run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(code, ExitCode::Success);
    assert!(out.is_empty());
    // Asserted against the un-resolved spelling rather than against a
    // second `absolute` call: a sibling test moves the process cwd, so
    // the expected string cannot be recomputed here.
    assert!(
        err.contains("out.log") && !err.contains("at logs"),
        "the notice must name the absolute path this process read: {err}"
    );
    assert!(
        err.contains("out_file is relative"),
        "the notice must say why the shepherd's file is a different one: {err}"
    );
}

/// Points `out_file` at a directory, not a `chmod 000` file: opening a
/// directory succeeds on unix and the read fails `EISDIR`
/// deterministically, including as root, where a `000` file would still
/// be readable.
#[tokio::test]
async fn an_unreadable_file_is_noticed_and_exits_failure_with_the_rest_still_printed() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let bad_dir = dir.path().join("a-directory");
    std::fs::create_dir(&bad_dir).unwrap();
    let bad_dir = bad_dir.to_str().unwrap().to_string();
    let real_path = write_log(dir.path(), "web-out.log", "still-here\n");

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut bad = info(1, "bad");
    bad.out_file = Some(bad_dir.clone());
    let mut real = info(2, "web");
    real.out_file = Some(real_path);
    daemon.reply_to_list(vec![bad, real]);

    let (code, out, err) = run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(code, ExitCode::Failure);
    assert!(
        out.contains("still-here"),
        "one sheep's unreadable file must not hide the rest of the flock's lines"
    );
    assert!(
        err.contains(&bad_dir),
        "the notice must name the unreadable path: {err}"
    );
}

/// An implementation that skips a `None` path in silence passes every
/// other test here and fails this one.
#[tokio::test]
async fn a_daemon_that_reported_no_path_is_noticed_not_silently_empty() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = None;
    sheep.err_file = None;
    daemon.reply_to_list(vec![sheep]);

    let (code, _out, err) = run_bleats(&client, &no_follow_args_out("all"), Format::Table).await;

    assert_eq!(
        code,
        ExitCode::Success,
        "version skew is not a fault in this run"
    );
    assert!(
        err.contains("log_path_unknown"),
        "a None path must be noticed, not silently empty: {err}"
    );
}

/// Sits beside `json_format_renders_the_pinned_six_key_line_shape`:
/// renaming a field of `BleatLine` must now fail both.
#[tokio::test]
async fn a_file_sourced_json_line_is_the_same_six_key_shape_as_a_bus_sourced_one() {
    let dir = tempfile::tempdir().unwrap();
    let sock = shep_client::testing::control_address(dir.path());
    let out_path = write_log(dir.path(), "web-out.log", "hello-from-disk\n");

    let (client, daemon) = fake_client_with_push(&sock).await;
    let mut sheep = info(1, "web");
    sheep.out_file = Some(out_path);
    daemon.reply_to_list(vec![sheep]);

    let (_code, out, _err) = run_bleats(&client, &no_follow_args_out("all"), Format::Json).await;

    let line = out.lines().next().expect("one JSON line was rendered");
    let json: serde_json::Value = serde_json::from_str(line).unwrap();
    let obj = json.as_object().expect("a bleats JSON line is an object");
    let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
    keys.sort_unstable();

    assert_eq!(
        keys,
        ["id", "instance", "line", "name", "schema_version", "stream"],
        "a file-sourced line must be the same shape as a bus-sourced one: {out}"
    );
    assert_eq!(json["id"], 1);
    assert_eq!(json["name"], "web");
    assert_eq!(json["stream"], "out");
    assert_eq!(
        json["instance"],
        serde_json::Value::Null,
        "one registered instance means no slot to report"
    );
    assert_eq!(json["line"], "hello-from-disk");
    assert_eq!(json["schema_version"], output::SCHEMA_VERSION);
}