shep 0.18.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
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
//! The part of a write both verbs share: print the change list, ask, check the
//! holder did not move, send, and report.

use std::io::IsTerminal as _;

use serde_json::Value as Json;
use shep_client::Client;
use shep_core::paths::ShepPaths;
use shep_core::protocol::{DogSectionToml, Request, Response};

use super::plan::schema_refusal;
use super::{Flags, Plan, Verb, Write, WriteRefusal};
use crate::cli::Format;
use crate::commands::copy;
use crate::commands::copy::address;
use crate::commands::copy::confirm::{self, Answer, Go};
use crate::commands::copy::report::shows_changes_first;
use crate::commands::rpc::{client_error, unexpected_response};
use crate::commands::settings_read::{self, Holder};
use crate::dog_settings::{AskFailure, ask_dog};
use crate::exit::ExitCode;
use crate::output::{CopyChange, SetReport, Streams, emit, write_outcome};

/// Prints the change list, asks, writes, and reports what the write left
/// waiting.
pub(super) async fn run(
    client: &Client,
    streams: &mut Streams<'_>,
    paths: &ShepPaths,
    name: &str,
    path: &str,
    plan: &Plan,
    flags: Flags,
) -> ExitCode {
    let verb = flags.verb;
    let holder = holder_of(&plan.write);
    if let Some(refusal) = schema_refusal(plan, name, path) {
        return refusal.report(streams);
    }
    let mut report = report_for(holder, name, path, plan, false, flags.dry_run);
    if plan.changes.is_empty() {
        let reason = match verb {
            Verb::Set => "already holds that value",
            Verb::Unset => "holds nothing to remove",
        };
        announce(
            streams,
            verb.as_str(),
            &format!("nothing to write: `{}.{}` {reason}", name, report.path),
        );
        return finish(streams, verb, &report);
    }
    let go = confirm::decide(
        flags.dry_run,
        flags.yes,
        std::io::stdin().is_terminal(),
        !plan.changes.is_empty(),
    );

    // The change list prints before anything is asked about it, in every
    // form. Where it prints depends on what that form's stdout carries: the
    // table form's stdout is the change list, and the JSON form's is one
    // envelope that can only be written after the write the question gates.
    if shows_changes_first(streams.fmt, go)
        && let Err(code) = print_changes(streams, verb, &report)
    {
        return code;
    }

    if go == Go::Refuse {
        return streams.fail(
            ExitCode::Usage,
            &format!(
                "nothing was written: `shep {verb}` asks before it writes, and this run has no \
                 terminal to ask on. Pass --yes to write without asking, or --dry-run to see the \
                 changes and write nothing",
                verb = verb.as_str()
            ),
        );
    }
    if go == Go::Ask {
        let mut reader = std::io::stdin().lock();
        match ask(
            streams,
            verb,
            name,
            &report.path,
            plan.changes.len(),
            &mut reader,
        ) {
            Answer::Yes => {}
            Answer::No => {
                announce(streams, verb.as_str(), "nothing was written");
                return finish(streams, verb, &report);
            }
            Answer::Failed(reason) => {
                return streams.fail(
                    ExitCode::Failure,
                    &format!("nothing was written: the question could not be read ({reason})"),
                );
            }
        }
    }
    if go == Go::DryRun {
        announce(streams, "dry_run", "nothing was written");
        return finish(streams, verb, &report);
    }

    // The answer was given, and the holder may have moved while it was being
    // given. A whole-section or whole-table write carries every key it read, so
    // a stale plan overwrites what someone else wrote in the meantime.
    if let Some(code) = stale_holder(client, streams, name, &plan.write, verb, &report.path).await {
        return code;
    }

    // A dog whose checks go further than its schema runs them on the table
    // this write would leave behind, before any of it is sent.
    if let Some((dog, sheep)) = dog_in(&plan.write, name) {
        match ask_dog(client, paths, dog, &plan.table, sheep).await {
            Ok(()) => {}
            Err(AskFailure::Refused { reason }) => {
                return WriteRefusal::ByDog(dog_refused(name, dog, &plan.write, &reason))
                    .report(streams);
            }
            // No dog said this one: shep could not read whether the dog wanted
            // to be asked, or could not read what it answered.
            Err(AskFailure::Unknown { reason }) => {
                return WriteRefusal::Refused(format!("nothing was written: {reason}"))
                    .report(streams);
            }
            Err(AskFailure::Transport(err)) => return client_error(streams, &err),
        }
        // The ask waits on the dog's own `action_timeout`, which is a second
        // window of the same kind the question already was: a dashboard filing
        // its own write on another key of this holder while the dog thinks is
        // the edit this plan would silently overwrite. The plan is as stale as
        // it was before the ask, and the dog's yes says nothing about it.
        if let Some(code) =
            stale_holder(client, streams, name, &plan.write, verb, &report.path).await
        {
            return code;
        }
    }

    let pending = match send(client, streams, name, &plan.write).await {
        Ok(pending) => pending,
        Err(code) => return code,
    };
    report = report_for(holder, name, path, plan, true, flags.dry_run);
    report.pending = pending;
    if !report.pending.is_empty() {
        announce(
            streams,
            "parked",
            &format!(
                "{} waiting on the next spawn (`shep reload {name}` promotes them)",
                report.pending.join(", ")
            ),
        );
    }
    announce(
        streams,
        "written",
        &format!(
            "wrote {} change{} onto `{}`",
            plan.changes.len(),
            if plan.changes.len() == 1 { "" } else { "s" },
            name
        ),
    );
    finish(streams, verb, &report)
}

/// Prints a notice, in the form that has room for one.
///
/// The table form's stdout is prose lines beside the change list. The JSON
/// form's stdout is the one envelope this invocation owes, and a notice printed
/// in it is a second object on the same stream, which is what a consumer
/// piping into `jq` chokes on. Everything a notice would have said is a field
/// of that envelope: `written`, `dry_run` and `pending`.
fn announce(streams: &mut Streams<'_>, code: &str, message: &str) {
    if streams.fmt == Format::Table {
        streams.note(code, message);
    }
}

/// Asks the operator, on stderr, beside the change list the question is about.
pub(super) fn ask(
    streams: &mut Streams<'_>,
    verb: Verb,
    name: &str,
    path: &str,
    changes: usize,
    reader: &mut dyn std::io::BufRead,
) -> Answer {
    let question = if changes == 1 {
        format!("{} `{}.{}`? [y/N] ", verb.as_str(), name, path)
    } else {
        format!(
            "{} {changes} keys under `{}.{}`? [y/N] ",
            verb.as_str(),
            name,
            path
        )
    };
    streams.aside("confirm", &question);
    confirm::read_answer(reader)
}

/// Sends the plan's write, and reports which keys wait for a respawn.
///
/// # Errors
/// The [`ExitCode`] already reported to `streams`.
async fn send(
    client: &Client,
    streams: &mut Streams<'_>,
    name: &str,
    write: &Write,
) -> Result<Vec<String>, ExitCode> {
    let request = match write {
        Write::SheepField { key, value } => Request::SetSheepField {
            name: name.to_string(),
            key: key.clone(),
            value: value.clone(),
        },
        Write::SheepDogTable { dog, table, .. } => Request::SetSheepDogSettings {
            name: name.to_string(),
            dog: dog.clone(),
            table: Some(table.clone()),
        },
        Write::DropSheepDogTable { dog, .. } => Request::SetSheepDogSettings {
            name: name.to_string(),
            dog: dog.clone(),
            table: None,
        },
        Write::ResetSheepField { key } => Request::ResetSheepField {
            name: name.to_string(),
            key: key.clone(),
        },
        Write::DogSection { toml, .. } => Request::SetDogConfig {
            name: name.to_string(),
            toml: DogSectionToml::from(toml.clone()),
        },
    };
    let pending_key = match write {
        Write::SheepField { key, .. } | Write::ResetSheepField { key } => key.clone(),
        _ => String::new(),
    };
    match client.request(request).await {
        Ok(Response::SheepFieldSet {
            pending, warning, ..
        }) => {
            if let Some(warning) = warning {
                streams.aside("warning", &warning);
            }
            Ok(pending.then_some(pending_key).into_iter().collect())
        }
        Ok(Response::SheepFieldReset {
            key,
            value,
            pending,
            warning,
            ..
        }) => {
            if let Some(warning) = warning {
                streams.aside("warning", &warning);
            }
            announce(
                streams,
                "reset",
                &format!("`{name}.{key}` holds {value} again"),
            );
            Ok(pending.then_some(pending_key).into_iter().collect())
        }
        Ok(Response::SheepDogSettingsSet { .. }) | Ok(Response::DogConfigSet { .. }) => {
            Ok(Vec::new())
        }
        Ok(_other) => Err(unexpected_response(streams)),
        Err(err) => Err(client_error(streams, &err)),
    }
}

/// The refusal for a holder that moved, or `None` while it still holds what the
/// plan was built from.
///
/// The reporting half, split from [`holder_unchanged`] because a write that
/// waits on a dog is checked twice: once before the ask and once after it.
///
/// # Errors
/// The [`ExitCode`] already reported to `streams`.
async fn stale_holder(
    client: &Client,
    streams: &mut Streams<'_>,
    name: &str,
    write: &Write,
    verb: Verb,
    path: &str,
) -> Option<ExitCode> {
    match holder_unchanged(client, streams, name, write).await {
        Ok(true) => None,
        Ok(false) => {
            Some(WriteRefusal::Stale(holder_moved(verb, name, path, write)).report(streams))
        }
        Err(code) => Some(code),
    }
}

/// The check that the holder still holds what the plan was built from.
///
/// [`Request::SetDogConfig`] replaces a whole section and
/// [`Request::SetSheepDogSettings`] replaces a whole table, so what this sends
/// carries every key it read, not only the one the operator named. Between the
/// read and the answer the lookout config pane can file a write of its own on a
/// different key of the same holder, and the text read before the question would
/// overwrite it with no warning. A sheep's own field is the one write of one key
/// rather than of a whole holder, so it has nothing to re-read, and neither does
/// dropping its override.
///
/// The comparison is on the whole holder rather than on the key the operator
/// named: a holder that moved anywhere is one this plan no longer describes, and
/// re-planning against it here would write a change the operator never saw the
/// old value of.
///
/// # Errors
/// The [`ExitCode`] already reported to `streams`.
pub(super) async fn holder_unchanged(
    client: &Client,
    streams: &mut Streams<'_>,
    name: &str,
    write: &Write,
) -> Result<bool, ExitCode> {
    Ok(match write {
        Write::SheepField { .. } | Write::ResetSheepField { .. } => true,
        Write::DogSection { before, .. } => {
            match settings_read::read_dog_section(client, streams, name).await {
                Ok(now) => !section_moved(before, &now),
                Err(code) => return Err(code),
            }
        }
        Write::SheepDogTable { dog, before, .. } | Write::DropSheepDogTable { dog, before } => {
            let config = match copy::read_config(client, streams, name).await {
                Ok(config) => config,
                Err(code) => return Err(code),
            };
            let now = address::dog_table(&config, dog)
                .and_then(Json::as_object)
                .cloned()
                .unwrap_or_default();
            !table_moved(before, &Json::Object(now))
        }
    })
}

/// `true` when a dog's section is no longer the text this plan was built from.
///
/// The compare ignores the blank line a section may or may not end with: the
/// text is what the shepherd answered both times, and a difference in nothing
/// but trailing whitespace is not a change an operator made.
pub(super) fn section_moved(before: &str, now: &str) -> bool {
    before.trim() != now.trim()
}

/// `true` when a sheep's table for a dog is no longer the table this plan was
/// built from.
pub(super) fn table_moved(before: &Json, now: &Json) -> bool {
    before != now
}

/// The sentence for a holder that moved between the read and the write.
///
/// The remedy is to read again, because the change list the operator answered
/// describes the holder this run read, and nothing here can show them the old
/// value of a key that moved in between.
pub(super) fn holder_moved(verb: Verb, name: &str, path: &str, write: &Write) -> String {
    let address = if path.is_empty() {
        name.to_string()
    } else {
        format!("{name}.{path}")
    };
    let verb = verb.as_str();
    format!(
        "nothing was written: {} changed while `shep {verb}` waited for the answer. `shep \
         get {address}` reads what it holds now, and `shep {verb}` again plans against that",
        holder_phrase(name, write),
    )
}

/// The holder this write lands on.
///
/// Read off the write rather than handed in by the caller: the request the
/// write stands in for is what decides whether `name` is a dog or a sheep.
fn holder_of(write: &Write) -> Holder {
    match write {
        Write::DogSection { .. } => Holder::Dog,
        _other => Holder::Sheep,
    }
}

/// The dog whose settings this write carries, and the sheep holding them when
/// it is a per-sheep table.
///
/// `None` is a sheep's own field, which is no dog's settings and so no dog's
/// to validate.
fn dog_in<'a>(write: &'a Write, name: &'a str) -> Option<(&'a str, Option<&'a str>)> {
    match write {
        Write::SheepDogTable { dog, .. } | Write::DropSheepDogTable { dog, .. } => {
            Some((dog.as_str(), Some(name)))
        }
        Write::DogSection { .. } => Some((name, None)),
        Write::SheepField { .. } | Write::ResetSheepField { .. } => None,
    }
}

/// The holder, named the way the operator named it.
fn holder_phrase(name: &str, write: &Write) -> String {
    match write {
        Write::DogSection { .. } => format!("`{name}`'s section"),
        Write::SheepDogTable { dog, .. } | Write::DropSheepDogTable { dog, .. } => {
            format!("`{name}`'s table for `{dog}`")
        }
        // A sheep's own field is one key and no whole holder, and no dog is
        // asked about one.
        Write::SheepField { .. } | Write::ResetSheepField { .. } => format!("`{name}`'s field"),
    }
}

/// The sentence for a write the dog itself turned down.
///
/// The dog's own words are the whole remedy: it is the only party that knows
/// what its settings have to hold.
fn dog_refused(name: &str, dog: &str, write: &Write, reason: &str) -> String {
    format!(
        "nothing was written: `{dog}` refused {}: {reason}",
        holder_phrase(name, write)
    )
}

/// The report a plan renders, before or after the write.
pub(super) fn report_for(
    holder: Holder,
    name: &str,
    path: &str,
    plan: &Plan,
    written: bool,
    dry_run: bool,
) -> SetReport {
    SetReport {
        kind: holder.as_str(),
        name: name.to_string(),
        path: path.to_string(),
        written,
        dry_run,
        pending: Vec::new(),
        changes: plan
            .changes
            .iter()
            .map(|change| CopyChange {
                key: change.key.clone(),
                change: change.kind.as_str(),
                old: change.old.clone(),
                new: change.new.clone(),
            })
            .collect(),
    }
}

/// Prints the change list on the stream the operator is reading.
///
/// The table form prints it on stdout. A form whose stdout is a JSON envelope
/// prints it on stderr, so the operator sees what they are being asked to
/// approve while stdout stays the one envelope the `--format json` rule keeps.
///
/// # Errors
/// [`ExitCode::Failure`] if the write itself failed.
fn print_changes(
    streams: &mut Streams<'_>,
    verb: Verb,
    report: &SetReport,
) -> Result<(), ExitCode> {
    let writer: &mut dyn std::io::Write = if streams.fmt == Format::Table {
        &mut *streams.out
    } else {
        &mut *streams.err
    };
    emit(
        writer,
        Format::Table,
        verb.as_str(),
        report.clone(),
        streams.style,
    )
    .map_err(|_| ExitCode::Failure)
}

/// Prints the run's verdict.
///
/// The table form already printed the change list, so this adds nothing to it.
/// The JSON form prints the one envelope this invocation owes, with `written`,
/// `pending` and `dry_run` filled in by what actually happened: a parked key is
/// a field of that envelope rather than a second object on stdout.
fn finish(streams: &mut Streams<'_>, verb: Verb, report: &SetReport) -> ExitCode {
    if streams.fmt != Format::Json {
        return ExitCode::Success;
    }
    write_outcome(emit(
        &mut *streams.out,
        Format::Json,
        verb.as_str(),
        report.clone(),
        streams.style,
    ))
}